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:
co-authored by
Claude Opus 5
parent
4304dd7c4b
commit
c82f2a965c
@@ -63,7 +63,7 @@ Three places must stay in sync when config keys change: the `WerkatorConfig` dat
|
||||
|
||||
## Repository Context
|
||||
|
||||
Everything repository-scoped goes through a `RepoContext` (`repo` package, ADR 0009): the primary checkout (`workingDir`), the repository's `BuildResultRepository` (`.git/werkator/build-results.json`), its `ArtifactStore` (keyed by the repository path), and a short `name` defaulting to the directory basename — the future route segment. `RepoContexts.open(dir)` builds one (running the pre-rename state-dir migration for that repository on the way); `RepoRegistry` opens one per entry of the instance configuration's `repositories` — or the current directory without a registry — lazily on first use and loudly: a non-repository entry or a duplicate name aborts the start naming the home file, a repository whose config must not be read (`ConfigException`) is skipped with an error. `RepoConfiguration` provides `registry.current()` (the cwd when served, else the first entry) as the `RepoContext` bean for the still-unscoped controllers, and the `BuildResultRepository`/`ArtifactStore` beans are that context's. The `--repo` mixin (`RepoOption`) selects by name in `build`, `retry`, and `status`. Git access and config loading stay path-based services taking `repo.workingDir`; the instance configuration (`~/.werkator.yml`, `ConfigLoader.homeDir`/`WERKATOR_HOME`, bound as `InstanceConfig`) is folded in by `ConfigLoader.loadRaw` itself — its `defaults` below every repository layer, its `server`/`executor`/`watcher.pollInterval` overlaid on top and stripped from the repository files with one warning — so every consumer of `load(dir)` sees the instance values without knowing the file. The context object is the identity (executor pools, watcher memory are keyed by it), so exactly one is opened per repository — `RunningBuild` carries it too, so `currentBuilds()` says which repository a running build belongs to: the current-builds view and API serve only the served repository's builds, and the watcher's worktree pruning is protected by its own repository's builds alone. Not yet repository-scoped: the routes and the UI, which still serve `registry.current()` only (session D).
|
||||
Everything repository-scoped goes through a `RepoContext` (`repo` package, ADR 0009): the primary checkout (`workingDir`), the repository's `BuildResultRepository` (`.git/werkator/build-results.json`), its `ArtifactStore` (keyed by the repository path), and a short `name` defaulting to the directory basename — the future route segment. `RepoContexts.open(dir)` builds one (running the pre-rename state-dir migration for that repository on the way); `RepoRegistry` opens one per entry of the instance configuration's `repositories` — or the current directory without a registry — lazily on first use and loudly: a non-repository entry or a duplicate name aborts the start naming the home file, a repository whose config must not be read (`ConfigException`) is skipped with an error. `RepoConfiguration` provides `registry.current()` (the cwd when served, else the first entry) as the `RepoContext` bean; the controllers no longer take it — they resolve per request from the `{repo}` path segment, and `registry.current()` is what the unscoped routes mean. The `--repo` mixin (`RepoOption`) selects by name in `build`, `retry`, and `status`. Git access and config loading stay path-based services taking `repo.workingDir`; the instance configuration (`~/.werkator.yml`, `ConfigLoader.homeDir`/`WERKATOR_HOME`, bound as `InstanceConfig`) is folded in by `ConfigLoader.loadRaw` itself — its `defaults` below every repository layer, its `server`/`executor`/`watcher.pollInterval` overlaid on top and stripped from the repository files with one warning — so every consumer of `load(dir)` sees the instance values without knowing the file. The context object is the identity (executor pools, watcher memory are keyed by it), so exactly one is opened per repository — `RunningBuild` carries it too, so `currentBuilds()` says which repository a running build belongs to: the current-builds view and API serve only the served repository's builds, and the watcher's worktree pruning is protected by its own repository's builds alone. Routes, pages, and artifact files are repository-scoped (session D): every mapping exists twice, `/api/repos/<name>/…` and `/repos/<name>/…` beside the unscoped form, an unknown name is a 404, and the link prefix follows the number of served repositories — one repository keeps its existing URLs, several make every link name its repository and show the switcher in the navigation.
|
||||
|
||||
## Build Execution
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ All production code lives under `de.hoennig.werkator`, with sub-packages `comman
|
||||
- `exitProcess` is called only from `main()` — never inside `CliRunner.run()`; this keeps the Spring context alive during tests.
|
||||
- Nothing is scheduled during CLI runs or tests: the watcher poll loop and metrics sampling start only via an explicit `start()` in the `server` profile.
|
||||
- Builds run detached in worktrees under `.git/werkator/worktrees/<branchKey>`; the primary checkout is never used for builds; never assume a single running build.
|
||||
- Everything repository-scoped (results, artifacts, worktrees, git and config access) goes through a `RepoContext`, never through an implicit current directory: the executor serializes per (context, branch) under one global `maxConcurrent`, the watcher polls every context in its own guard. `RepoRegistry` opens one context per entry of the instance configuration `~/.werkator.yml` (ADR 0009), or the current directory without one; the instance-level keys (`server`, `executor`, `watcher.pollInterval`) and the `defaults` block are folded into every repository's effective config by `ConfigLoader` itself, so no consumer reads the home file directly.
|
||||
- Everything repository-scoped (results, artifacts, worktrees, git and config access) goes through a `RepoContext`, never through an implicit current directory: the executor serializes per (context, branch) under one global `maxConcurrent`, the watcher polls every context in its own guard. `RepoRegistry` opens one context per entry of the instance configuration `~/.werkator.yml` (ADR 0009), or the current directory without one; the instance-level keys (`server`, `executor`, `watcher.pollInterval`) and the `defaults` block are folded into every repository's effective config by `ConfigLoader` itself, so no consumer reads the home file directly. Server routes carry the repository as `/repos/<name>/…` and `/api/repos/<name>/…`, with the unscoped form permanently meaning the served repository; the pages stay per repository and the navigation switches between them.
|
||||
- When config keys change, three places must stay in sync: the `WerkatorConfig` data classes, the `InitCommand` templates, and `docs/configuration.md`.
|
||||
- Every config file may declare `werkator.version.since`/`below` (the Werkator it is written for, never a format version — no API is involved). `since` is enforced in both directions, using `ConfigVersions.FORMAT_BROKE_IN` for "file predates a breaking change"; `below` only warns. A violation aborts the start for the machine and project config, but fails only that branch's builds for a branch config.
|
||||
- A branch describes its own CI: its committed `.werkator.yml` is the branch layer (`ConfigLoader.loadWithBranchLayer`, used by the watcher per origin branch and by `loadForWorktree` at build time) and takes precedence over `.git`/project — including the whole `builds` section, so a new configuration can be tried out on a branch without affecting other branches. Only the pinned set is stripped from that layer: secrets (`git`), host/repository sections (`server`, `gitea`, `executor`, `watcher`), the docker (`docker.enabled`, `docker.network`) and bubblewrap (`bwrap.enabled`, `bwrap.rootfs`, `bwrap.werkdock`) sandbox policies, and the trust gate (`requirePullRequest`). A branch must never reach credentials, disable its container or sandbox, change its network, substitute a foreign rootfs, raise global concurrency, or bypass its own pull-request gate; a branch's definitions apply to that branch alone.
|
||||
|
||||
@@ -64,9 +64,11 @@ The pinning model is untouched: pinned keys still come from each repo's machine
|
||||
|
||||
### D — Server, API, and UI scoping
|
||||
|
||||
- Routes gain the repo segment (`/api/repos/<name>/builds/…`, `/repos/<name>/builds/<key>`); with exactly one registered repo the today-routes keep working (redirect or alias) so bookmarks and posted Gitea links survive.
|
||||
- Latest/branches/history views group by repo or gain a repo column; one instance-wide metrics page; one control token.
|
||||
- Gitea status links use the repo-scoped URLs.
|
||||
- ~~Routes gain the repo segment (`/api/repos/<name>/builds/…`, `/repos/<name>/builds/<key>`); with exactly one registered repo the today-routes keep working (redirect or alias) so bookmarks and posted Gitea links survive.~~ — done 2026-09-03 (PR #13): every route of the builds API, the pages, and the artifact files is mapped twice; the unscoped form is not an alias with an expiry date but the permanent way to say "the served repository", and an unknown name is a 404 in each controller's own shape.
|
||||
- ~~Latest/branches/history views group by repo or gain a repo column; one instance-wide metrics page; one control token.~~ — done 2026-09-03, decided against the column: the pages stay per repository and the navigation gains a **repository switcher** (a row's actions need the repository anyway, branches come from one origin, artifacts from one store — and with one repository a column is noise). Metrics page and control token stay instance-wide as planned.
|
||||
- Gitea status links use the repo-scoped URLs — the permanent artifact links do (`BranchPermalinks.permanentUrl` takes the prefix, because the key is a hash of the build name alone and two repositories both having `main` would otherwise share one URL); the commit-status target URLs posted by `GiteaStatusPublisher` are carried over to session E, where the instance actually serves two repositories.
|
||||
- Also done: `RunningBuild` carries its `RepoContext` (the carry-over from session C), so the current-builds views and the watcher's worktree pruning tell repositories apart, and `cancel` refuses a key that is not recorded in the named repository.
|
||||
- Carried over to session E: the commit-status URLs; `docs/deployment.md` gets the registry setup.
|
||||
|
||||
### E — Rollout on mih34: Werkbaum joins
|
||||
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
> **WARNING:** This document describes only the change applied in this PR.
|
||||
> It may already be outdated once the next PR is merged.
|
||||
> Historic PR-documentation is not maintained along with new PRs — treat it as a snapshot, not as current documentation.
|
||||
|
||||
## The Problem
|
||||
|
||||
PR #12 gave the instance a registry of repositories, but the server still served exactly one of them.
|
||||
Every route — API, pages, artifact files — worked on `registry.current()`, so a second registered repository was built and polled, yet invisible and unreachable.
|
||||
Two consequences went beyond "not browsable": the current-builds views listed the running builds of *all* repositories while looking their status up in *one* repository's results, and `cancel` addressed a build by key across the whole instance.
|
||||
Step 22 session D is the repository dimension in the server: routes, links, and the UI.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- The rollout on the instance and the deployment documentation of the registry (session E).
|
||||
- Merging several repositories into one table: the pages stay per repository (see The Solution).
|
||||
- A per-repository control token or per-repository metrics: one instance, one token, one metrics page (ADR 0009).
|
||||
|
||||
## The Scenarios
|
||||
|
||||
### Feature: every route carries the repository
|
||||
|
||||
#### Background
|
||||
|
||||
- The instance serves a registry of repositories (ADR 0009); the *served* repository is `RepoRegistry.current()` — the current working directory when it is served, else the first entry.
|
||||
- The prefix is `/repos/<name>` for the pages and `/api/repos/<name>` for the API, where `<name>` is the registry entry's short name.
|
||||
|
||||
#### Scenario#13.01: The repository-scoped API answers for the named repository
|
||||
|
||||
So that a second registered repository is reachable at all.
|
||||
|
||||
- **Given** an instance serving a repository named `test`
|
||||
- **When** `GET /api/repos/test/builds/latest` is requested
|
||||
- **Then** the answer holds that repository's builds
|
||||
- **and** `GET /api/builds/latest` still answers the same, because the unscoped form means the served repository
|
||||
|
||||
##### Verified by
|
||||
|
||||
- [BuildsApiControllerTest — "the repository-scoped routes answer for the named repository and 404 for an unknown name"](../../src/test/kotlin/de/hoennig/werkator/server/BuildsApiControllerTest.kt)
|
||||
|
||||
#### Scenario#13.02: A name the instance does not serve is a miss, not an error
|
||||
|
||||
So that a typo in a URL reads like every other miss of this API.
|
||||
|
||||
- **Given** an instance that serves no repository named `no-such-repo`
|
||||
- **When** `GET /api/repos/no-such-repo/builds/latest` is requested
|
||||
- **Then** the answer is 404 with `{"error": "no repository named 'no-such-repo'"}`
|
||||
- **and** the page `/repos/no-such-repo` answers 404 as well
|
||||
|
||||
##### Verified by
|
||||
|
||||
- [BuildsApiControllerTest — "the repository-scoped routes answer for the named repository and 404 for an unknown name"](../../src/test/kotlin/de/hoennig/werkator/server/BuildsApiControllerTest.kt)
|
||||
- [UiControllerTest — "a page of a repository this instance does not serve answers 404"](../../src/test/kotlin/de/hoennig/werkator/server/UiControllerTest.kt)
|
||||
|
||||
#### Scenario#13.03: A single-repository installation keeps its existing URLs
|
||||
|
||||
So that no bookmark, no posted Gitea link, and no operator habit breaks on an installation that has exactly what it had before.
|
||||
|
||||
- **Given** an instance serving exactly one repository
|
||||
- **When** any page is rendered
|
||||
- **Then** every link it contains is unscoped (`/branches`, `/history`, `/api/builds/latest`)
|
||||
- **and** no repository switcher is shown, because there is nothing to switch
|
||||
|
||||
##### Verified by
|
||||
|
||||
- [UiControllerTest — "with one served repository the pages keep their existing URLs and show no switcher"](../../src/test/kotlin/de/hoennig/werkator/server/UiControllerTest.kt)
|
||||
|
||||
#### Scenario#13.04: With several repositories every link names its repository
|
||||
|
||||
So that a click inside a repository's page stays inside that repository.
|
||||
|
||||
- **Given** an instance serving the repositories `test` and `other`
|
||||
- **When** the page `/repos/test` is rendered
|
||||
- **Then** its navigation links, its `data-api`, and its `werkator-repo-base` meta carry `/repos/test`
|
||||
- **and** the switcher offers `/repos/other`
|
||||
|
||||
##### Verified by
|
||||
|
||||
- [UiControllerTest — "with several served repositories every link names its repository and the switcher appears"](../../src/test/kotlin/de/hoennig/werkator/server/UiControllerTest.kt)
|
||||
|
||||
#### Scenario#13.05: A repository-named route never reaches another repository
|
||||
|
||||
So that the repository in the path is a boundary, not a label.
|
||||
|
||||
- **Given** a build whose artifact key is not recorded in the repository named in the route
|
||||
- **When** that build is cancelled through `/api/repos/test/builds/<key>/cancel`
|
||||
- **Then** the answer is 404
|
||||
- **and** the executor is not asked to cancel anything
|
||||
|
||||
##### Verified by
|
||||
|
||||
- [BuildsApiControllerTest — "cancel does not reach a build of another repository"](../../src/test/kotlin/de/hoennig/werkator/server/BuildsApiControllerTest.kt)
|
||||
- [BuildsApiControllerTest — "current answers only the served repository's builds"](../../src/test/kotlin/de/hoennig/werkator/server/BuildsApiControllerTest.kt)
|
||||
- [WatcherTest — "a running build of another repository does not keep this repository's worktree"](../../src/test/kotlin/de/hoennig/werkator/watcher/WatcherTest.kt)
|
||||
|
||||
## The Solution
|
||||
|
||||
Every controller resolves its `RepoContext` per request instead of holding the served one as a bean: `repoOf(name)` is `registry.current()` without a name and `registry.byName(name)` with one, and an unknown name throws `UnknownRepositoryException`, which each controller turns into its own 404 shape.
|
||||
Each route is mapped twice — scoped and unscoped — so the unscoped form is not a transitional alias but the permanent way to say "the served repository".
|
||||
|
||||
`RunningBuild` carries its `RepoContext`, so the executor's instance-global `currentBuilds()` can be filtered: the current-builds view and API show their own repository's builds, and the watcher's worktree pruning is protected by its own repository's builds alone.
|
||||
`cancel` additionally verifies that the artifact key is recorded in the named repository — a queued or running build always has its PENDING/RUNNING result there.
|
||||
|
||||
The pages stay **per repository** instead of merging every repository's rows into one table with a repository column: a row's actions need the repository anyway, branches come from one origin and artifacts from one store, and with the single repository most installations have, such a column is pure noise.
|
||||
What makes the instance one UI is the switcher in the navigation.
|
||||
|
||||
The link prefix follows the *number of served repositories*, not the route a page was reached through — with one repository the installation keeps its existing URLs, with several every link names its repository.
|
||||
`werkator.js` reads that prefix once from a `werkator-repo-base` meta and builds its action and artifact URLs from it; the paths rendered into the DOM already carry it.
|
||||
`BranchPermalinks.permanentUrl` takes the prefix too: the permanent key is a hash of the build name alone, so two repositories both having `main` would otherwise share one permanent URL.
|
||||
|
||||
## Open Questions
|
||||
|
||||
- The metrics page and the control token stay instance-wide (ADR 0009); a per-repository token is not planned.
|
||||
- `/api/watcher` stays unscoped — its state already carries the per-repository reports, and the UI banner is instance-wide.
|
||||
|
||||
## Prerequisite PRs
|
||||
|
||||
- PR #12 — the repository registry (step 22 session C).
|
||||
|
||||
## Follow-up PRs
|
||||
|
||||
- Step 22 session E — the rollout: the registry with Werkator and Werkbaum under one service, and `docs/deployment.md`.
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user