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
@@ -27,6 +27,7 @@ data class BuildResultDto(
fun from(
result: BuildResult,
isLatestGreen: Boolean = false,
base: String = "",
) = BuildResultDto(
branch = result.branch,
name = result.name,
@@ -36,7 +37,7 @@ data class BuildResultDto(
runningSince = result.runningSince,
durationSeconds = result.duration?.seconds,
artifactKey = result.artifactKey,
latestGreenUrl = if (isLatestGreen) BranchPermalinks.permanentUrl(result.name) else null,
latestGreenUrl = if (isLatestGreen) BranchPermalinks.permanentUrl(result.name, base) else null,
)
}
}
@@ -1,6 +1,7 @@
package de.hoennig.werkator.server
import de.hoennig.werkator.build.ArtifactStore
import de.hoennig.werkator.repo.RepoContext
import de.hoennig.werkator.repo.RepoRegistry
import jakarta.servlet.http.HttpServletRequest
import org.springframework.core.io.FileSystemResource
import org.springframework.core.io.Resource
@@ -25,17 +26,22 @@ import kotlin.streams.asSequence
*/
@RestController
class ArtifactFileController(
private val artifactStore: ArtifactStore,
private val branchPermalinks: BranchPermalinks,
private val registry: RepoRegistry,
) {
@GetMapping("/artifacts/{artifactKey}/{*path}")
/** Scoped and unscoped, like every other route (ADR 0009); unscoped means the served repository. */
private fun repoOf(name: String?): RepoContext =
if (name == null) registry.current() else registry.byName(name) ?: throw UnknownRepositoryException(name)
@GetMapping("/artifacts/{artifactKey}/{*path}", "/repos/{repo}/artifacts/{artifactKey}/{*path}")
fun serve(
@PathVariable(name = "repo", required = false) repoName: String?,
@PathVariable artifactKey: String,
@PathVariable path: String,
request: HttpServletRequest,
): ResponseEntity<Resource> {
val artifactDir =
artifactStore.artifactDir(artifactKey)
repoOf(repoName).artifactStore.artifactDir(artifactKey)
?: return ResponseEntity.notFound().build()
val relativePath = path.removePrefix("/").removeSuffix("/")
directoryResponse(artifactDir, relativePath, request, noStore = true)?.let { return it }
@@ -52,15 +58,17 @@ class ArtifactFileController(
* slash, so relative links inside reports resolve correctly), and everything is
* `no-store` because the content behind a URL changes with every new green build.
*/
@GetMapping("/branches/{branchKey}/{*path}")
@GetMapping("/branches/{branchKey}/{*path}", "/repos/{repo}/branches/{branchKey}/{*path}")
fun serveLatestGreen(
@PathVariable(name = "repo", required = false) repoName: String?,
@PathVariable branchKey: String,
@PathVariable path: String,
request: HttpServletRequest,
): ResponseEntity<Resource> {
val build = branchPermalinks.latestGreenBuild(branchKey)
val repo = repoOf(repoName)
val build = branchPermalinks.latestGreenBuild(repo, branchKey)
val artifactDir =
artifactStore.artifactDir(build.artifactKey)
repo.artifactStore.artifactDir(build.artifactKey)
?: throw ResponseStatusException(
HttpStatus.NOT_FOUND,
"the artifacts of build '${build.artifactKey}' are not stored anymore",
@@ -17,7 +17,10 @@ import org.springframework.stereotype.Component
class BranchListing(
private val gitService: GitService,
) {
fun branches(repo: RepoContext): List<BranchDto> {
fun branches(
repo: RepoContext,
base: String = "",
): List<BranchDto> {
val repository = repo.results
val heads = gitService.originBranchHeads(repo.workingDir)
val namedResults = repository.latestPerName().filter { it.name != it.branch && it.branch in heads }
@@ -45,7 +48,7 @@ class BranchListing(
// the permanent link belongs to the build it resolves to, not to every build of the name
val isLatestGreen =
row.artifactKey.isNotEmpty() && row.artifactKey == repository.latestGreenFor(row.name)?.artifactKey
if (isLatestGreen) row.copy(latestGreenUrl = BranchPermalinks.permanentUrl(row.name)) else row
if (isLatestGreen) row.copy(latestGreenUrl = BranchPermalinks.permanentUrl(row.name, base)) else row
}
}
@@ -2,7 +2,7 @@ package de.hoennig.werkator.server
import de.hoennig.werkator.build.ArtifactKeys
import de.hoennig.werkator.build.BuildResult
import de.hoennig.werkator.build.BuildResultRepository
import de.hoennig.werkator.repo.RepoContext
import org.springframework.http.HttpStatus
import org.springframework.stereotype.Component
import org.springframework.web.server.ResponseStatusException
@@ -18,10 +18,12 @@ import org.springframework.web.server.ResponseStatusException
* artifacts.
*/
@Component
class BranchPermalinks(
private val repository: BuildResultRepository,
) {
fun latestGreenBuild(branchKey: String): BuildResult {
class BranchPermalinks {
fun latestGreenBuild(
repo: RepoContext,
branchKey: String,
): BuildResult {
val repository = repo.results
val names =
repository
.latestPerName()
@@ -41,7 +43,16 @@ class BranchPermalinks(
}
companion object {
/** The permanent artifact-index URL of the build name (branch or named slot), shown in the branches view. */
fun permanentUrl(name: String): String = "/branches/${ArtifactKeys.permanentBranchKey(name)}"
/**
* The permanent artifact-index URL of the build name (branch or named slot), shown
* in the branches view. [base] is the repository prefix (`/repos/<name>`, empty with
* one served repository): the key is a hash of the name alone, so two repositories
* both having `main` would otherwise share one permanent URL — and it would resolve
* against whichever repository the instance happens to serve unscoped.
*/
fun permanentUrl(
name: String,
base: String = "",
): String = "$base/branches/${ArtifactKeys.permanentBranchKey(name)}"
}
}
@@ -51,6 +51,9 @@ class BuildsApiController(
private fun RepoContext.isLatestGreen(result: BuildResult): Boolean =
results.latestGreenFor(result.name)?.artifactKey == result.artifactKey
/** The prefix the permanent links in the answers carry; empty with one served repository. */
private fun uiBase(repo: RepoContext): String = if (registry.all().size > 1) "/repos/${repo.name}" else ""
/** An unknown repository name answers like every other miss of this API: 404 with `error`. */
@ExceptionHandler(UnknownRepositoryException::class)
fun unknownRepository(e: UnknownRepositoryException): ResponseEntity<Any> = notFound(e.message ?: "unknown repository")
@@ -60,21 +63,24 @@ class BuildsApiController(
@PathVariable(name = "repo", required = false) repoName: String?,
): List<BuildResultDto> {
val repo = repoOf(repoName)
return repo.results.latestPerName().map { BuildResultDto.from(it, repo.isLatestGreen(it)) }
return repo.results.latestPerName().map { BuildResultDto.from(it, repo.isLatestGreen(it), uiBase(repo)) }
}
/** The legacy branches view: every origin branch with its latest build or `unknown`. */
@GetMapping("/api/branches", "/api/repos/{repo}/branches")
fun branches(
@PathVariable(name = "repo", required = false) repoName: String?,
): List<BranchDto> = branchListing.branches(repoOf(repoName))
): List<BranchDto> {
val repo = repoOf(repoName)
return branchListing.branches(repo, uiBase(repo))
}
@GetMapping("/api/builds/history", "/api/repos/{repo}/builds/history")
fun history(
@PathVariable(name = "repo", required = false) repoName: String?,
): List<BuildResultDto> {
val repo = repoOf(repoName)
return repo.results.history().map { BuildResultDto.from(it, repo.isLatestGreen(it)) }
return repo.results.history().map { BuildResultDto.from(it, repo.isLatestGreen(it), uiBase(repo)) }
}
/**
@@ -1,21 +1,22 @@
package de.hoennig.werkator.server
import de.hoennig.werkator.build.ArtifactStore
import de.hoennig.werkator.build.BuildExecutor
import de.hoennig.werkator.build.BuildResult
import de.hoennig.werkator.build.BuildResultRepository
import de.hoennig.werkator.build.BuildStatus
import de.hoennig.werkator.config.ConfigFiles
import de.hoennig.werkator.config.ConfigLoader
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 jakarta.servlet.http.HttpServletRequest
import org.springframework.beans.factory.ObjectProvider
import org.springframework.boot.info.BuildProperties
import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
import org.springframework.stereotype.Controller
import org.springframework.ui.Model
import org.springframework.web.bind.annotation.ExceptionHandler
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.server.ResponseStatusException
@@ -34,19 +35,31 @@ import kotlin.streams.asSequence
*/
@Controller
class UiController(
private val repository: BuildResultRepository,
private val buildExecutor: BuildExecutor,
private val artifactStore: ArtifactStore,
private val configLoader: ConfigLoader,
private val gitService: GitService,
private val metricsCollector: SystemMetricsCollector,
private val branchListing: BranchListing,
private val branchPermalinks: BranchPermalinks,
private val buildProperties: ObjectProvider<BuildProperties>,
private val repo: RepoContext,
private val registry: RepoRegistry,
) {
private val workingDir: Path
get() = repo.workingDir
/**
* Every page exists twice, like the API (ADR 0009): repository-scoped under
* `/repos/<name>/…` and unscoped, which means the served repository. Pages stay
* per repository instead of merging every repository's rows into one table with a
* repository column: a row's actions (restart, cancel, delete) need the repository
* anyway, branches come from one origin and artifacts from one store — and with
* the one repository that most installations have, such a column is pure noise.
* What makes the instance one UI is the repository switcher in the navigation.
*/
private fun repoOf(name: String?): RepoContext =
if (name == null) registry.current() else registry.byName(name) ?: throw UnknownRepositoryException(name)
/** An unknown repository name is a 404 page, not a server error. */
@ExceptionHandler(UnknownRepositoryException::class)
fun unknownRepository(e: UnknownRepositoryException): ResponseEntity<String> =
ResponseEntity.status(HttpStatus.NOT_FOUND).body(e.message)
/**
* Permanent redirects for the legacy script's static page names, so bookmarks
@@ -58,11 +71,15 @@ class UiController(
setStatusCode(HttpStatus.MOVED_PERMANENTLY)
}
@GetMapping("/")
fun latest(model: Model): String {
val links = baseModel(model, view = "latest", pageTitle = "Latest Builds")
model.addAttribute("rows", repository.latestPerName().map { BuildRowView.from(it, links, permanentUrlOf(it)) })
model.addAttribute("apiPath", "/api/builds/latest")
@GetMapping("/", "/repos/{repo}")
fun latest(
@PathVariable(name = "repo", required = false) repoName: String?,
model: Model,
): String {
val repo = repoOf(repoName)
val links = baseModel(model, view = "latest", pageTitle = "Latest Builds", repo = repo)
model.addAttribute("rows", repo.results.latestPerName().map { BuildRowView.from(it, links, permanentUrlOf(repo, it)) })
model.addAttribute("apiPath", apiBase(repo) + "/builds/latest")
model.addAttribute("allowRestart", true)
model.addAttribute("restartAtOriginHead", false)
model.addAttribute("emptyMessage", "No builds recorded yet.")
@@ -70,11 +87,15 @@ class UiController(
}
/** The legacy branches view: every origin branch with its latest build or an `unknown` row. */
@GetMapping("/branches")
fun branches(model: Model): String {
val links = baseModel(model, view = "branches", pageTitle = "Branches")
model.addAttribute("rows", branchListing.branches(repo).map { BuildRowView.from(it, links) })
model.addAttribute("apiPath", "/api/branches")
@GetMapping("/branches", "/repos/{repo}/branches")
fun branches(
@PathVariable(name = "repo", required = false) repoName: String?,
model: Model,
): String {
val repo = repoOf(repoName)
val links = baseModel(model, view = "branches", pageTitle = "Branches", repo = repo)
model.addAttribute("rows", branchListing.branches(repo, uiBase(repo)).map { BuildRowView.from(it, links) })
model.addAttribute("apiPath", apiBase(repo) + "/branches")
model.addAttribute("allowRestart", true)
// a row here stands for a branch, not for a past run
model.addAttribute("restartAtOriginHead", true)
@@ -82,11 +103,15 @@ class UiController(
return "builds"
}
@GetMapping("/history")
fun history(model: Model): String {
val links = baseModel(model, view = "history", pageTitle = "Build History")
model.addAttribute("rows", repository.history().map { BuildRowView.from(it, links, permanentUrlOf(it)) })
model.addAttribute("apiPath", "/api/builds/history")
@GetMapping("/history", "/repos/{repo}/history")
fun history(
@PathVariable(name = "repo", required = false) repoName: String?,
model: Model,
): String {
val repo = repoOf(repoName)
val links = baseModel(model, view = "history", pageTitle = "Build History", repo = repo)
model.addAttribute("rows", repo.results.history().map { BuildRowView.from(it, links, permanentUrlOf(repo, it)) })
model.addAttribute("apiPath", apiBase(repo) + "/builds/history")
model.addAttribute("allowRestart", false)
model.addAttribute("restartAtOriginHead", false)
model.addAttribute("emptyMessage", "No builds archived yet.")
@@ -94,17 +119,24 @@ class UiController(
}
/** The permanent branch URL belongs to the build it resolves to — the name's latest green build. */
private fun permanentUrlOf(result: BuildResult): String? =
if (repository.latestGreenFor(result.name)?.artifactKey == result.artifactKey) {
BranchPermalinks.permanentUrl(result.name)
private fun permanentUrlOf(
repo: RepoContext,
result: BuildResult,
): String? =
if (repo.results.latestGreenFor(result.name)?.artifactKey == result.artifactKey) {
BranchPermalinks.permanentUrl(result.name, uiBase(repo))
} else {
null
}
@GetMapping("/current")
fun current(model: Model): String {
val links = baseModel(model, view = "current", pageTitle = "Current Builds")
val results = repository.history()
@GetMapping("/current", "/repos/{repo}/current")
fun current(
@PathVariable(name = "repo", required = false) repoName: String?,
model: Model,
): String {
val repo = repoOf(repoName)
val links = baseModel(model, view = "current", pageTitle = "Current Builds", repo = repo)
val results = repo.results.history()
val currentBuilds =
buildExecutor.currentBuilds().filter { it.repo === repo }.map { build ->
CurrentBuildView(
@@ -130,35 +162,39 @@ class UiController(
/** Hand-maintained release notes (templates/releases.html); linked from the version in the footer. */
@GetMapping("/releases")
fun releases(model: Model): String {
baseModel(model, view = "releases", pageTitle = "Release Notes")
baseModel(model, view = "releases", pageTitle = "Release Notes", repo = registry.current())
return "releases"
}
/** One metrics page for the whole instance — the resources are the instance's, not a repository's. */
@GetMapping("/system")
fun system(model: Model): String {
baseModel(model, view = "system", pageTitle = "System Metrics")
baseModel(model, view = "system", pageTitle = "System Metrics", repo = registry.current())
model.addAttribute("metrics", SystemMetricsView.from(metricsCollector.snapshot()))
return "system"
}
/** Artifact index rendered from the artifact store — legacy pre-generated this page as static HTML. */
@GetMapping("/builds/{artifactKey}")
@GetMapping("/builds/{artifactKey}", "/repos/{repo}/builds/{artifactKey}")
fun artifactIndex(
@PathVariable(name = "repo", required = false) repoName: String?,
@PathVariable artifactKey: String,
model: Model,
): String {
val result = repository.history().firstOrNull { it.artifactKey == artifactKey }
val artifactDir = artifactStore.artifactDir(artifactKey)
val repo = repoOf(repoName)
val result = repo.results.history().firstOrNull { it.artifactKey == artifactKey }
val artifactDir = repo.artifactStore.artifactDir(artifactKey)
if (result == null && artifactDir == null) {
throw ResponseStatusException(HttpStatus.NOT_FOUND, "no build with artifact key '$artifactKey'")
}
return artifactIndexView(
model,
pageTitle = "Build Artifacts",
repo = repo,
result = result,
artifactKey = artifactKey,
artifactDir = artifactDir,
filesBase = "/artifacts/$artifactKey",
filesBase = uiBase(repo) + "/artifacts/$artifactKey",
)
}
@@ -167,38 +203,42 @@ class UiController(
* stay on the permanent `/branches/…` paths, so every link copied from this page
* outlives artifact pruning.
*/
@GetMapping("/branches/{branchKey}")
@GetMapping("/branches/{branchKey}", "/repos/{repo}/branches/{branchKey}")
fun latestGreenArtifactIndex(
@PathVariable(name = "repo", required = false) repoName: String?,
@PathVariable branchKey: String,
model: Model,
): String {
val build = branchPermalinks.latestGreenBuild(branchKey)
val repo = repoOf(repoName)
val build = branchPermalinks.latestGreenBuild(repo, branchKey)
model.addAttribute("permanentBranch", build.branch)
model.addAttribute("concreteUrl", "/builds/${build.artifactKey}")
model.addAttribute("concreteUrl", uiBase(repo) + "/builds/${build.artifactKey}")
return artifactIndexView(
model,
pageTitle = "Latest Green Build",
repo = repo,
result = build,
artifactKey = build.artifactKey,
artifactDir = artifactStore.artifactDir(build.artifactKey),
filesBase = "/branches/$branchKey",
artifactDir = repo.artifactStore.artifactDir(build.artifactKey),
filesBase = uiBase(repo) + "/branches/$branchKey",
)
}
private fun artifactIndexView(
model: Model,
pageTitle: String,
repo: RepoContext,
result: BuildResult?,
artifactKey: String,
artifactDir: Path?,
filesBase: String,
): String {
val links = baseModel(model, view = "artifact", pageTitle = pageTitle)
val links = baseModel(model, view = "artifact", pageTitle = pageTitle, repo = repo)
model.addAttribute("artifactKey", artifactKey)
model.addAttribute("filesBase", filesBase)
model.addAttribute("result", result?.let { BuildRowView.from(it, links) })
model.addAttribute("hasArtifacts", artifactDir != null)
model.addAttribute("buildCommand", result?.let { buildCommandOf(it) })
model.addAttribute("buildCommand", result?.let { buildCommandOf(repo, it) })
model.addAttribute(
"logs",
artifactDir?.let { logFiles(it, scanForFailure = result != null && result.status != BuildStatus.SUCCESS) }
@@ -226,21 +266,41 @@ class UiController(
.toList()
}
/**
* The prefix every in-page link and API path is built from. It follows the number
* of served repositories, not the route the page was reached through: with one
* repository the installation keeps its existing URLs (the session-D acceptance
* criterion), with several every link names its repository.
*/
private fun uiBase(repo: RepoContext): String = if (registry.all().size > 1) "/repos/${repo.name}" else ""
private fun apiBase(repo: RepoContext): String = if (registry.all().size > 1) "/api/repos/${repo.name}" else "/api"
/** Adds the attributes every page needs and returns the Gitea link helper for row building. */
private fun baseModel(
model: Model,
view: String,
pageTitle: String,
repo: RepoContext,
): GiteaWebLinks {
val config = configLoader.load(workingDir)
val config = configLoader.load(repo.workingDir)
val links = GiteaWebLinks(config.gitea)
val repoName =
listOf(config.gitea.owner.trim(), config.gitea.repo.trim())
.filter { it.isNotEmpty() }
.joinToString("/")
val served = registry.all()
model.addAttribute("view", view)
model.addAttribute("pageTitle", pageTitle)
model.addAttribute("repoName", repoName)
// every in-page link is built from this prefix, so a scoped page stays scoped
model.addAttribute("repoBase", uiBase(repo))
model.addAttribute("homeUrl", uiBase(repo).ifEmpty { "/" })
model.addAttribute("apiBase", apiBase(repo))
model.addAttribute("repoKey", repo.name)
// the switcher is what makes several repositories one UI; with one there is nothing to switch
model.addAttribute("multiRepo", served.size > 1)
model.addAttribute("repos", served.map { RepoLinkView(name = it.name, url = "/repos/${it.name}", current = it === repo) })
model.addAttribute("version", buildProperties.getIfAvailable()?.version ?: "dev")
model.addAttribute("impressumUrl", config.server.impressumUrl.trim())
model.addAttribute("giteaRepoUrl", links.repoUrl ?: "")
@@ -254,7 +314,11 @@ class UiController(
* build of this pool ever ran — the branch and its job usually override it.
* The command used by a past run is not persisted, so this is the current answer.
*/
private fun buildCommandOf(result: BuildResult): String {
private fun buildCommandOf(
repo: RepoContext,
result: BuildResult,
): String {
val workingDir = repo.workingDir
val config =
try {
configLoader.loadWithBranchLayer(
@@ -252,3 +252,10 @@ data class SystemMetricsView(
)
}
}
/** One entry of the repository switcher in the navigation (ADR 0009). */
data class RepoLinkView(
val name: String,
val url: String,
val current: Boolean,
)