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,
)
+16 -5
View File
@@ -99,6 +99,17 @@ function metaContent(name) {
const giteaRepoUrl = metaContent("werkator-gitea-repo-url");
// Empty with one served repository, `/repos/<name>` with several (ADR 0009). Every
// path this script builds itself is prefixed with it, so an action triggered on a
// repository's page acts on that repository — the paths rendered into the DOM
// (`data-api`, artifact links) already carry it.
const repoBase = metaContent("werkator-repo-base") || "";
/** The API of the repository this page belongs to; `/api` when only one is served. */
function apiBase() {
return repoBase ? "/api" + repoBase : "/api";
}
// The control token is deliberately NOT embedded in the pages: reading them is
// unauthenticated, so anyone could have read it out of the HTML. The operator
// pastes it once per browser from `.git/werkator/control-token` on the server;
@@ -389,7 +400,7 @@ function renderBuildRow(build, allowRestart, restartAtOriginHead) {
const inProgress = build.status === "running" || build.status === "pending";
if (build.artifactKey) {
const artifactLink = elem("a", "artifact-link", inProgress ? "⏳" : "📄");
artifactLink.href = "/builds/" + encodeURIComponent(build.artifactKey);
artifactLink.href = repoBase + "/builds/" + encodeURIComponent(build.artifactKey);
artifactLink.title = inProgress ? "Open build log — no artifacts yet" : "Open artifacts";
artifactsCell.appendChild(artifactLink);
}
@@ -542,7 +553,7 @@ function initCurrentBuilds() {
return;
}
const offset = logOffsets.get(build.artifactKey) || 0;
const url = `/api/builds/current/${encodeURIComponent(build.artifactKey)}/log?offset=${offset}`;
const url = `${apiBase()}/builds/current/${encodeURIComponent(build.artifactKey)}/log?offset=${offset}`;
const tail = await fetchJson(url);
logOffsets.set(build.artifactKey, tail.nextOffset);
if (tail.content) {
@@ -690,11 +701,11 @@ document.addEventListener("click", async (event) => {
try {
if (action === "restart") {
const atOriginHead = button.dataset.atOriginHead === "true" ? "&atOriginHead=true" : "";
await sendAction("/api/builds/restart?branch=" + encodeURIComponent(button.dataset.branch) + atOriginHead, "POST");
await sendAction(apiBase() + "/builds/restart?branch=" + encodeURIComponent(button.dataset.branch) + atOriginHead, "POST");
} else if (action === "cancel") {
await sendAction(`/api/builds/${encodeURIComponent(button.dataset.artifactKey)}/cancel`, "POST");
await sendAction(`${apiBase()}/builds/${encodeURIComponent(button.dataset.artifactKey)}/cancel`, "POST");
} else if (action === "delete") {
await sendAction("/api/builds/" + encodeURIComponent(button.dataset.artifactKey), "DELETE");
await sendAction(apiBase() + "/builds/" + encodeURIComponent(button.dataset.artifactKey), "DELETE");
}
if (refreshNow) {
refreshNow();
+2 -2
View File
@@ -52,13 +52,13 @@
<td class="duration-cell" data-label="Duration" th:text="${row.duration}">1:23</td>
<td data-label="Artifacts">
<a th:if="${row.artifactKey != ''}" class="artifact-link"
th:href="'/builds/' + ${row.artifactKey}"
th:href="${repoBase} + '/builds/' + ${row.artifactKey}"
th:text="${row.inProgress} ? '⏳' : '📄'"
th:title="${row.inProgress} ? 'Open build log — no artifacts yet' : 'Open artifacts'">📄</a>
<a th:if="${row.latestGreenUrl != null}" class="artifact-link"
th:href="${row.latestGreenUrl}"
title="Permanent link: artifacts of the latest green build">🔗</a>
<a th:if="${row.inProgress}" class="artifact-link" href="/current"
<a th:if="${row.inProgress}" class="artifact-link" th:href="${repoBase} + '/current'" href="/current"
title="Watch this build live">📡</a>
<span th:if="${row.artifactKey == ''}">n/a</span>
</td>
+1 -1
View File
@@ -5,7 +5,7 @@
<main>
<h1 th:replace="~{fragments :: header(${pageTitle})}"></h1>
<div th:replace="~{fragments :: nav(${view})}"></div>
<div id="current-builds" data-api="/api/builds/current">
<div id="current-builds" th:attr="data-api=${apiBase} + '/builds/current'" data-api="/api/builds/current">
<p id="no-current" class="empty-panel" th:style="${#lists.isEmpty(currentBuilds)} ? '' : 'display: none'">
No build is currently running.
</p>
+15 -4
View File
@@ -7,11 +7,14 @@
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
<link rel="stylesheet" href="/werkator.css">
<meta name="werkator-gitea-repo-url" th:content="${giteaRepoUrl}">
<!-- Empty with one served repository, `/repos/<name>` with several: werkator.js
builds its action and artifact URLs from it, so a page stays in its repository. -->
<meta name="werkator-repo-base" th:content="${repoBase}">
</head>
<body>
<h1 th:fragment="header(title)">
<a class="title-home" href="/" aria-label="Open latest builds"><img src="/favicon.svg" alt=""></a>
<a class="title-home" th:href="${homeUrl}" href="/" aria-label="Open latest builds"><img src="/favicon.svg" alt=""></a>
<span class="title-text" th:text="${title}">Latest Builds</span>
<span class="repo-name" th:unless="${#strings.isEmpty(repoName)}" th:text="${repoName}">owner/repo</span>
</h1>
@@ -20,15 +23,23 @@
<div class="view-row">
<nav class="view-toggle">
<span th:if="${view == 'latest'}">Latest</span>
<a th:unless="${view == 'latest'}" href="/">Latest</a>
<a th:unless="${view == 'latest'}" th:href="${homeUrl}" href="/">Latest</a>
<span th:if="${view == 'branches'}">Branches</span>
<a th:unless="${view == 'branches'}" href="/branches">Branches</a>
<a th:unless="${view == 'branches'}" th:href="${repoBase} + '/branches'" href="/branches">Branches</a>
<span th:if="${view == 'history'}">History</span>
<a th:unless="${view == 'history'}" href="/history">History</a>
<a th:unless="${view == 'history'}" th:href="${repoBase} + '/history'" href="/history">History</a>
<span th:if="${view == 'current'}">Current</span>
<span th:if="${view == 'system'}">System</span>
<a th:unless="${view == 'system'}" href="/system">System</a>
</nav>
<!-- The repository switcher (ADR 0009): what makes several repositories one UI.
With a single served repository there is nothing to switch, and the block is absent. -->
<nav class="repo-switch" th:if="${multiRepo}">
<th:block th:each="r : ${repos}">
<span th:if="${r.current}" th:text="${r.name}" class="repo-current">werkator</span>
<a th:unless="${r.current}" th:href="${r.url}" th:text="${r.name}">other</a>
</th:block>
</nav>
<span class="view-row-actions">
<span id="live-indicator" class="status status-unknown" title="live-update state">static</span>
<button id="reload-button" class="reload-button" type="button" title="Reload view" aria-label="Reload view"></button>
@@ -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