feat(server): Routen, Seiten und Artefakte tragen das Repository — /repos/<name>/… (Sitzung D)

Zweite Hälfte von Sitzung D (docs/plan/22-multi-repo.md, PR #13): Der Server
bediente bisher genau ein Repository der Registry. Jede Route — API, Seiten,
Artefakt-Dateien — arbeitete auf `registry.current()`; ein zweites
registriertes Repository wurde gebaut und gepollt, war aber unsichtbar und
unerreichbar.

Jeder Controller löst sein Repository jetzt je Anfrage auf, statt das
bediente als Bohne zu halten; jede Route ist zweimal gemappt. Die unscoped
Form ist kein Übergangs-Alias, sondern dauerhaft die Art zu sagen „das
bediente Repository" — Lesezeichen und die nach Gitea geposteten Links
kennen kein Segment.

Entschieden gegen die Repo-Spalte: Die Seiten bleiben je Repository, die
Navigation bekommt einen Umschalter. Die Aktionen einer Zeile brauchen das
Repository ohnehin, Branches kommen von einem origin und Artefakte aus einem
Store — und bei dem einen Repository, das die meisten Installationen haben,
wäre eine Spalte nur Rauschen.

Das Link-Präfix folgt der ZAHL der bedienten Repositories, nicht dem Weg, über
den eine Seite erreicht wurde: mit einem behält die Installation ihre
bisherigen URLs (Abnahmekriterium der Sitzung), mit mehreren benennt jeder
Link sein Repository. werkator.js liest das Präfix einmal aus einem
`werkator-repo-base`-Meta. `BranchPermalinks.permanentUrl` bekommt es
ebenfalls — der permanente Schlüssel ist ein Hash des Build-Namens allein,
zwei Repositories mit `main` teilten sich sonst eine permanente URL.

Fünf neue Tests, Gegenprobe per Mutation gezogen (Präfix fest auf leer →
genau der Mehr-Repo-Test fällt). 498 Tests grün, ktlint sauber. PR-Dokument
docs/prs/2026-09-03-PR#13-…, Plan, Architektur-Skill und AGENTS.md
nachgezogen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
mhoennig
2026-09-03 13:39:09 +02:00
co-authored by Claude Opus 5
parent 4304dd7c4b
commit c82f2a965c
20 changed files with 417 additions and 96 deletions
@@ -4,6 +4,8 @@ import com.ninjasquad.springmockk.MockkBean
import de.hoennig.werkator.build.ArtifactStore
import de.hoennig.werkator.build.BuildResult
import de.hoennig.werkator.build.BuildStatus
import de.hoennig.werkator.repo.RepoContext
import de.hoennig.werkator.repo.RepoRegistry
import io.kotest.core.spec.style.FunSpec
import io.mockk.clearMocks
import io.mockk.every
@@ -32,6 +34,12 @@ class ArtifactFileControllerTest : FunSpec() {
@MockkBean
lateinit var branchPermalinks: BranchPermalinks
@MockkBean
lateinit var repo: RepoContext
@MockkBean
lateinit var registry: RepoRegistry
private val artifactDir: Path = Files.createTempDirectory("werkator-artifact-serve-test")
private val greenBuild =
@@ -46,12 +54,18 @@ class ArtifactFileControllerTest : FunSpec() {
init {
beforeEach {
clearMocks(artifactStore, branchPermalinks)
clearMocks(artifactStore, branchPermalinks, repo, registry)
every { repo.name } returns "test"
every { repo.artifactStore } returns artifactStore
every { registry.current() } returns repo
every { registry.all() } returns listOf(repo)
every { registry.byName(any()) } returns null
every { registry.byName("test") } returns repo
every { artifactStore.artifactDir(any()) } returns null
every { artifactStore.artifactDir("known-key") } returns artifactDir
every { branchPermalinks.latestGreenBuild(any()) } throws
every { branchPermalinks.latestGreenBuild(any(), any()) } throws
ResponseStatusException(HttpStatus.NOT_FOUND, "no recorded builds")
every { branchPermalinks.latestGreenBuild("main") } returns greenBuild
every { branchPermalinks.latestGreenBuild(any(), "main") } returns greenBuild
}
test("serves an html artifact with no-cache headers") {
@@ -188,7 +202,7 @@ class ArtifactFileControllerTest : FunSpec() {
}
test("permanent URL answers 404 when the green build's artifacts are gone") {
every { branchPermalinks.latestGreenBuild("main") } returns greenBuild.copy(artifactKey = "pruned-key")
every { branchPermalinks.latestGreenBuild(any(), "main") } returns greenBuild.copy(artifactKey = "pruned-key")
mockMvc
.perform(get("/branches/main/build.log"))
@@ -4,6 +4,7 @@ import de.hoennig.werkator.build.ArtifactKeys
import de.hoennig.werkator.build.BuildResult
import de.hoennig.werkator.build.BuildResultRepository
import de.hoennig.werkator.build.BuildStatus
import de.hoennig.werkator.repo.RepoContext
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe
@@ -17,7 +18,8 @@ import java.time.Instant
class BranchPermalinksTest : FunSpec() {
private val repository = mockk<BuildResultRepository>()
private val permalinks = BranchPermalinks(repository)
private val repo = mockk<RepoContext>().also { every { it.results } returns repository }
private val permalinks = BranchPermalinks()
private fun result(
branch: String,
@@ -36,20 +38,20 @@ class BranchPermalinksTest : FunSpec() {
every { repository.latestPerName() } returns listOf(result("feature/x"), result("main"))
every { repository.latestGreenFor("feature/x") } returns result("feature/x")
permalinks.latestGreenBuild("feature_x") shouldBe result("feature/x")
permalinks.latestGreenBuild(repo, "feature_x") shouldBe result("feature/x")
}
test("resolves the full branch key with hash suffix") {
every { repository.latestPerName() } returns listOf(result("feature/x"))
every { repository.latestGreenFor("feature/x") } returns result("feature/x")
permalinks.latestGreenBuild(ArtifactKeys.branchKey("feature/x")) shouldBe result("feature/x")
permalinks.latestGreenBuild(repo, ArtifactKeys.branchKey("feature/x")) shouldBe result("feature/x")
}
test("an unknown branch key answers 404") {
every { repository.latestPerName() } returns listOf(result("main"))
val exception = shouldThrow<ResponseStatusException> { permalinks.latestGreenBuild("gone") }
val exception = shouldThrow<ResponseStatusException> { permalinks.latestGreenBuild(repo, "gone") }
exception.statusCode shouldBe HttpStatus.NOT_FOUND
}
@@ -58,7 +60,7 @@ class BranchPermalinksTest : FunSpec() {
every { repository.latestPerName() } returns listOf(result("main", status = BuildStatus.FAILED))
every { repository.latestGreenFor("main") } returns null
val exception = shouldThrow<ResponseStatusException> { permalinks.latestGreenBuild("main") }
val exception = shouldThrow<ResponseStatusException> { permalinks.latestGreenBuild(repo, "main") }
exception.statusCode shouldBe HttpStatus.NOT_FOUND
}
@@ -66,7 +68,7 @@ class BranchPermalinksTest : FunSpec() {
test("a permanent key matching several branches answers 409 and names the candidates") {
every { repository.latestPerName() } returns listOf(result("feature/x"), result("feature_x"))
val exception = shouldThrow<ResponseStatusException> { permalinks.latestGreenBuild("feature_x") }
val exception = shouldThrow<ResponseStatusException> { permalinks.latestGreenBuild(repo, "feature_x") }
exception.statusCode shouldBe HttpStatus.CONFLICT
exception.reason.orEmpty() shouldContain "feature/x"
@@ -76,7 +78,7 @@ class BranchPermalinksTest : FunSpec() {
every { repository.latestPerName() } returns listOf(result("feature/x"), result("feature_x"))
every { repository.latestGreenFor("feature/x") } returns result("feature/x")
permalinks.latestGreenBuild(ArtifactKeys.branchKey("feature/x")) shouldBe result("feature/x")
permalinks.latestGreenBuild(repo, ArtifactKeys.branchKey("feature/x")) shouldBe result("feature/x")
}
test("permanentUrl uses the hash-free branch key") {
@@ -89,7 +91,7 @@ class BranchPermalinksTest : FunSpec() {
every { repository.latestGreenFor("main@nightly") } returns nightly
// sanitized like any branch key: the '@' becomes '_' in the URL
permalinks.latestGreenBuild("main_nightly") shouldBe nightly
permalinks.latestGreenBuild(repo, "main_nightly") shouldBe nightly
}
}
}
@@ -90,6 +90,7 @@ class BuildsApiControllerTest : FunSpec() {
every { repo.results } returns repository
every { repo.artifactStore } returns artifactStore
// the unscoped routes mean the served repository; `/api/repos/test/…` names it
every { registry.all() } returns listOf(repo)
every { registry.current() } returns repo
every { registry.byName(any()) } returns null
every { registry.byName("test") } returns repo
@@ -11,6 +11,7 @@ import de.hoennig.werkator.config.WerkatorConfig
import de.hoennig.werkator.git.GitService
import de.hoennig.werkator.metrics.SystemMetricsCollector
import de.hoennig.werkator.repo.RepoContext
import de.hoennig.werkator.repo.RepoRegistry
import io.kotest.core.spec.style.FunSpec
import io.mockk.clearMocks
import io.mockk.every
@@ -70,6 +71,9 @@ class PermanentBranchRoutesTest : FunSpec() {
@MockkBean
lateinit var repo: RepoContext
@MockkBean
lateinit var registry: RepoRegistry
private val artifactDir: Path = Files.createTempDirectory("werkator-permanent-routes-test")
private val greenBuild =
@@ -95,14 +99,22 @@ class PermanentBranchRoutesTest : FunSpec() {
branchListing,
branchPermalinks,
repo,
registry,
)
every { repo.name } returns "test"
every { repo.workingDir } returns Paths.get(".")
every { repo.results } returns repository
every { repo.artifactStore } returns artifactStore
every { registry.all() } returns listOf(repo)
every { registry.current() } returns repo
every { registry.byName(any()) } returns null
every { registry.byName("test") } returns repo
every { configLoader.load(any()) } returns WerkatorConfig()
every { configLoader.loadWithBranchLayer(any(), anyNullable()) } returns WerkatorConfig()
every { gitService.showFileAtCommit(any(), any(), any()) } returns null
every { controlTokens.token() } returns "test-token"
every { branchListing.branches(any()) } returns emptyList()
every { branchPermalinks.latestGreenBuild("main") } returns greenBuild
every { branchPermalinks.latestGreenBuild(any(), "main") } returns greenBuild
every { artifactStore.artifactDir("main-key") } returns artifactDir
}
@@ -18,12 +18,14 @@ import de.hoennig.werkator.metrics.MetricAggregate
import de.hoennig.werkator.metrics.SystemMetrics
import de.hoennig.werkator.metrics.SystemMetricsCollector
import de.hoennig.werkator.repo.RepoContext
import de.hoennig.werkator.repo.RepoRegistry
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe
import io.kotest.matchers.string.shouldContain
import io.kotest.matchers.string.shouldNotContain
import io.mockk.clearMocks
import io.mockk.every
import io.mockk.mockk
import org.hamcrest.Matchers.containsString
import org.hamcrest.Matchers.not
import org.springframework.beans.factory.annotation.Autowired
@@ -78,6 +80,9 @@ class UiControllerTest : FunSpec() {
@MockkBean
lateinit var repo: RepoContext
@MockkBean
lateinit var registry: RepoRegistry
private val startedAt = Instant.parse("2026-07-07T10:00:00Z")
private val emptySystemMetrics =
@@ -119,8 +124,16 @@ class UiControllerTest : FunSpec() {
branchListing,
branchPermalinks,
repo,
registry,
)
every { repo.name } returns "test"
every { repo.workingDir } returns Paths.get(".")
every { repo.results } returns repository
every { repo.artifactStore } returns artifactStore
every { registry.all() } returns listOf(repo)
every { registry.current() } returns repo
every { registry.byName(any()) } returns null
every { registry.byName("test") } returns repo
every { configLoader.load(any()) } returns
WerkatorConfig(
server = ServerConfig(impressumUrl = "https://example.org/imprint"),
@@ -144,6 +157,40 @@ class UiControllerTest : FunSpec() {
.andExpect(content().string(not(containsString("""href="/current""""))))
}
test("with one served repository the pages keep their existing URLs and show no switcher") {
every { repository.latestPerName() } returns listOf(successResult)
mockMvc
.perform(get("/"))
.andExpect(status().isOk)
.andExpect(content().string(containsString("""href="/branches"""")))
// Thymeleaf drops an attribute whose value is empty, and werkator.js falls back to ""
.andExpect(content().string(containsString("""<meta name="werkator-repo-base">""")))
.andExpect(content().string(not(containsString("""class="repo-switch""""))))
}
test("with several served repositories every link names its repository and the switcher appears") {
val other = mockk<RepoContext>()
every { other.name } returns "other"
every { registry.all() } returns listOf(repo, other)
every { repository.latestPerName() } returns listOf(successResult)
mockMvc
.perform(get("/repos/test"))
.andExpect(status().isOk)
.andExpect(content().string(containsString("""href="/repos/test/branches"""")))
.andExpect(content().string(containsString("""data-api="/api/repos/test/builds/latest"""")))
.andExpect(content().string(containsString("""<meta name="werkator-repo-base" content="/repos/test">""")))
.andExpect(content().string(containsString("""class="repo-switch"""")))
.andExpect(content().string(containsString("""href="/repos/other"""")))
}
test("a page of a repository this instance does not serve answers 404") {
mockMvc
.perform(get("/repos/no-such-repo"))
.andExpect(status().isNotFound)
}
test("latest view renders rows with badge, Gitea links, artifact link, actions, and token") {
every { repository.latestPerName() } returns listOf(successResult)
@@ -503,7 +550,7 @@ class UiControllerTest : FunSpec() {
Files.writeString(artifactDir.resolve("build.stdout.log"), "out")
Files.createDirectories(artifactDir.resolve("reports/tests/test"))
Files.writeString(artifactDir.resolve("reports/tests/test/index.html"), "<html></html>")
every { branchPermalinks.latestGreenBuild("main") } returns successResult
every { branchPermalinks.latestGreenBuild(any(), "main") } returns successResult
every { artifactStore.artifactDir("main-abc123-key") } returns artifactDir
mockMvc
@@ -517,7 +564,7 @@ class UiControllerTest : FunSpec() {
}
test("permanent artifact index of a branch without a green build answers 404") {
every { branchPermalinks.latestGreenBuild("main") } throws
every { branchPermalinks.latestGreenBuild(any(), "main") } throws
ResponseStatusException(HttpStatus.NOT_FOUND, "branch 'main' has no successful build")
mockMvc