From c82f2a965cf8dcff2dca6f20b108250ddff37b57 Mon Sep 17 00:00:00 2001 From: mhoennig Date: Thu, 3 Sep 2026 13:39:09 +0200 Subject: [PATCH] =?UTF-8?q?feat(server):=20Routen,=20Seiten=20und=20Artefa?= =?UTF-8?q?kte=20tragen=20das=20Repository=20=E2=80=94=20/repos//?= =?UTF-8?q?=E2=80=A6=20(Sitzung=20D)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .claude/skills/architecture/SKILL.md | 2 +- AGENTS.md | 2 +- docs/plan/22-multi-repo.md | 8 +- ...26-09-03-PR#13-repository-scoped-routes.md | 121 ++++++++++++++ .../de/hoennig/werkator/server/ApiDtos.kt | 3 +- .../werkator/server/ArtifactFileController.kt | 22 ++- .../hoennig/werkator/server/BranchListing.kt | 7 +- .../werkator/server/BranchPermalinks.kt | 25 ++- .../werkator/server/BuildsApiController.kt | 12 +- .../hoennig/werkator/server/UiController.kt | 152 +++++++++++++----- .../de/hoennig/werkator/server/UiViews.kt | 7 + src/main/resources/static/werkator.js | 21 ++- src/main/resources/templates/builds.html | 4 +- src/main/resources/templates/current.html | 2 +- src/main/resources/templates/fragments.html | 19 ++- .../server/ArtifactFileControllerTest.kt | 22 ++- .../werkator/server/BranchPermalinksTest.kt | 18 ++- .../server/BuildsApiControllerTest.kt | 1 + .../server/PermanentBranchRoutesTest.kt | 14 +- .../werkator/server/UiControllerTest.kt | 51 +++++- 20 files changed, 417 insertions(+), 96 deletions(-) create mode 100644 docs/prs/2026-09-03-PR#13-repository-scoped-routes.md diff --git a/.claude/skills/architecture/SKILL.md b/.claude/skills/architecture/SKILL.md index 5d67090..9cb94ab 100644 --- a/.claude/skills/architecture/SKILL.md +++ b/.claude/skills/architecture/SKILL.md @@ -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//…` and `/repos//…` 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 diff --git a/AGENTS.md b/AGENTS.md index 2be29fe..7754a94 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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/`; 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//…` and `/api/repos//…`, 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. diff --git a/docs/plan/22-multi-repo.md b/docs/plan/22-multi-repo.md index 65c8efd..9bf7449 100644 --- a/docs/plan/22-multi-repo.md +++ b/docs/plan/22-multi-repo.md @@ -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//builds/…`, `/repos//builds/`); 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//builds/…`, `/repos//builds/`); 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 diff --git a/docs/prs/2026-09-03-PR#13-repository-scoped-routes.md b/docs/prs/2026-09-03-PR#13-repository-scoped-routes.md new file mode 100644 index 0000000..71188c4 --- /dev/null +++ b/docs/prs/2026-09-03-PR#13-repository-scoped-routes.md @@ -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/` for the pages and `/api/repos/` for the API, where `` 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//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`. diff --git a/src/main/kotlin/de/hoennig/werkator/server/ApiDtos.kt b/src/main/kotlin/de/hoennig/werkator/server/ApiDtos.kt index eb88f45..b79d1d0 100644 --- a/src/main/kotlin/de/hoennig/werkator/server/ApiDtos.kt +++ b/src/main/kotlin/de/hoennig/werkator/server/ApiDtos.kt @@ -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, ) } } diff --git a/src/main/kotlin/de/hoennig/werkator/server/ArtifactFileController.kt b/src/main/kotlin/de/hoennig/werkator/server/ArtifactFileController.kt index b03d6c1..011a834 100644 --- a/src/main/kotlin/de/hoennig/werkator/server/ArtifactFileController.kt +++ b/src/main/kotlin/de/hoennig/werkator/server/ArtifactFileController.kt @@ -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 { 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 { - 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", diff --git a/src/main/kotlin/de/hoennig/werkator/server/BranchListing.kt b/src/main/kotlin/de/hoennig/werkator/server/BranchListing.kt index 2bd3d65..f80d20f 100644 --- a/src/main/kotlin/de/hoennig/werkator/server/BranchListing.kt +++ b/src/main/kotlin/de/hoennig/werkator/server/BranchListing.kt @@ -17,7 +17,10 @@ import org.springframework.stereotype.Component class BranchListing( private val gitService: GitService, ) { - fun branches(repo: RepoContext): List { + fun branches( + repo: RepoContext, + base: String = "", + ): List { 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 } } diff --git a/src/main/kotlin/de/hoennig/werkator/server/BranchPermalinks.kt b/src/main/kotlin/de/hoennig/werkator/server/BranchPermalinks.kt index 38ccd1d..0e25bdb 100644 --- a/src/main/kotlin/de/hoennig/werkator/server/BranchPermalinks.kt +++ b/src/main/kotlin/de/hoennig/werkator/server/BranchPermalinks.kt @@ -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/`, 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)}" } } diff --git a/src/main/kotlin/de/hoennig/werkator/server/BuildsApiController.kt b/src/main/kotlin/de/hoennig/werkator/server/BuildsApiController.kt index ac2bed1..26030b7 100644 --- a/src/main/kotlin/de/hoennig/werkator/server/BuildsApiController.kt +++ b/src/main/kotlin/de/hoennig/werkator/server/BuildsApiController.kt @@ -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 = notFound(e.message ?: "unknown repository") @@ -60,21 +63,24 @@ class BuildsApiController( @PathVariable(name = "repo", required = false) repoName: String?, ): List { 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 = branchListing.branches(repoOf(repoName)) + ): List { + 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 { 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)) } } /** diff --git a/src/main/kotlin/de/hoennig/werkator/server/UiController.kt b/src/main/kotlin/de/hoennig/werkator/server/UiController.kt index 196527e..0701b6c 100644 --- a/src/main/kotlin/de/hoennig/werkator/server/UiController.kt +++ b/src/main/kotlin/de/hoennig/werkator/server/UiController.kt @@ -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, - 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//…` 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 = + 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( diff --git a/src/main/kotlin/de/hoennig/werkator/server/UiViews.kt b/src/main/kotlin/de/hoennig/werkator/server/UiViews.kt index cd1dc0b..479a747 100644 --- a/src/main/kotlin/de/hoennig/werkator/server/UiViews.kt +++ b/src/main/kotlin/de/hoennig/werkator/server/UiViews.kt @@ -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, +) diff --git a/src/main/resources/static/werkator.js b/src/main/resources/static/werkator.js index f3b2be9..662f600 100644 --- a/src/main/resources/static/werkator.js +++ b/src/main/resources/static/werkator.js @@ -99,6 +99,17 @@ function metaContent(name) { const giteaRepoUrl = metaContent("werkator-gitea-repo-url"); +// Empty with one served repository, `/repos/` 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(); diff --git a/src/main/resources/templates/builds.html b/src/main/resources/templates/builds.html index 777e42d..6d8e0dd 100644 --- a/src/main/resources/templates/builds.html +++ b/src/main/resources/templates/builds.html @@ -52,13 +52,13 @@ 1:23 📄 🔗 - 📡 n/a diff --git a/src/main/resources/templates/current.html b/src/main/resources/templates/current.html index ab1d5f6..2c353f7 100644 --- a/src/main/resources/templates/current.html +++ b/src/main/resources/templates/current.html @@ -5,7 +5,7 @@

-
+

No build is currently running.

diff --git a/src/main/resources/templates/fragments.html b/src/main/resources/templates/fragments.html index d1b3e93..1fecc9a 100644 --- a/src/main/resources/templates/fragments.html +++ b/src/main/resources/templates/fragments.html @@ -7,11 +7,14 @@ + +

- + Latest Builds owner/repo

@@ -20,15 +23,23 @@
+ + static diff --git a/src/test/kotlin/de/hoennig/werkator/server/ArtifactFileControllerTest.kt b/src/test/kotlin/de/hoennig/werkator/server/ArtifactFileControllerTest.kt index 55c6ada..de1b54d 100644 --- a/src/test/kotlin/de/hoennig/werkator/server/ArtifactFileControllerTest.kt +++ b/src/test/kotlin/de/hoennig/werkator/server/ArtifactFileControllerTest.kt @@ -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")) diff --git a/src/test/kotlin/de/hoennig/werkator/server/BranchPermalinksTest.kt b/src/test/kotlin/de/hoennig/werkator/server/BranchPermalinksTest.kt index 91613db..9058ca0 100644 --- a/src/test/kotlin/de/hoennig/werkator/server/BranchPermalinksTest.kt +++ b/src/test/kotlin/de/hoennig/werkator/server/BranchPermalinksTest.kt @@ -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() - private val permalinks = BranchPermalinks(repository) + private val repo = mockk().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 { permalinks.latestGreenBuild("gone") } + val exception = shouldThrow { 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 { permalinks.latestGreenBuild("main") } + val exception = shouldThrow { 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 { permalinks.latestGreenBuild("feature_x") } + val exception = shouldThrow { 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 } } } diff --git a/src/test/kotlin/de/hoennig/werkator/server/BuildsApiControllerTest.kt b/src/test/kotlin/de/hoennig/werkator/server/BuildsApiControllerTest.kt index 728c730..f034de4 100644 --- a/src/test/kotlin/de/hoennig/werkator/server/BuildsApiControllerTest.kt +++ b/src/test/kotlin/de/hoennig/werkator/server/BuildsApiControllerTest.kt @@ -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 diff --git a/src/test/kotlin/de/hoennig/werkator/server/PermanentBranchRoutesTest.kt b/src/test/kotlin/de/hoennig/werkator/server/PermanentBranchRoutesTest.kt index 6f87757..d10b965 100644 --- a/src/test/kotlin/de/hoennig/werkator/server/PermanentBranchRoutesTest.kt +++ b/src/test/kotlin/de/hoennig/werkator/server/PermanentBranchRoutesTest.kt @@ -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 } diff --git a/src/test/kotlin/de/hoennig/werkator/server/UiControllerTest.kt b/src/test/kotlin/de/hoennig/werkator/server/UiControllerTest.kt index c2bbbc7..7374aea 100644 --- a/src/test/kotlin/de/hoennig/werkator/server/UiControllerTest.kt +++ b/src/test/kotlin/de/hoennig/werkator/server/UiControllerTest.kt @@ -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(""""""))) + .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() + 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(""""""))) + .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"), "") - 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