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
|
## 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
|
## 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.
|
- `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.
|
- 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.
|
- 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`.
|
- 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.
|
- 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.
|
- 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
|
### 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.
|
- ~~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.
|
- ~~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.
|
- 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
|
### 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(
|
fun from(
|
||||||
result: BuildResult,
|
result: BuildResult,
|
||||||
isLatestGreen: Boolean = false,
|
isLatestGreen: Boolean = false,
|
||||||
|
base: String = "",
|
||||||
) = BuildResultDto(
|
) = BuildResultDto(
|
||||||
branch = result.branch,
|
branch = result.branch,
|
||||||
name = result.name,
|
name = result.name,
|
||||||
@@ -36,7 +37,7 @@ data class BuildResultDto(
|
|||||||
runningSince = result.runningSince,
|
runningSince = result.runningSince,
|
||||||
durationSeconds = result.duration?.seconds,
|
durationSeconds = result.duration?.seconds,
|
||||||
artifactKey = result.artifactKey,
|
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
|
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 jakarta.servlet.http.HttpServletRequest
|
||||||
import org.springframework.core.io.FileSystemResource
|
import org.springframework.core.io.FileSystemResource
|
||||||
import org.springframework.core.io.Resource
|
import org.springframework.core.io.Resource
|
||||||
@@ -25,17 +26,22 @@ import kotlin.streams.asSequence
|
|||||||
*/
|
*/
|
||||||
@RestController
|
@RestController
|
||||||
class ArtifactFileController(
|
class ArtifactFileController(
|
||||||
private val artifactStore: ArtifactStore,
|
|
||||||
private val branchPermalinks: BranchPermalinks,
|
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(
|
fun serve(
|
||||||
|
@PathVariable(name = "repo", required = false) repoName: String?,
|
||||||
@PathVariable artifactKey: String,
|
@PathVariable artifactKey: String,
|
||||||
@PathVariable path: String,
|
@PathVariable path: String,
|
||||||
request: HttpServletRequest,
|
request: HttpServletRequest,
|
||||||
): ResponseEntity<Resource> {
|
): ResponseEntity<Resource> {
|
||||||
val artifactDir =
|
val artifactDir =
|
||||||
artifactStore.artifactDir(artifactKey)
|
repoOf(repoName).artifactStore.artifactDir(artifactKey)
|
||||||
?: return ResponseEntity.notFound().build()
|
?: return ResponseEntity.notFound().build()
|
||||||
val relativePath = path.removePrefix("/").removeSuffix("/")
|
val relativePath = path.removePrefix("/").removeSuffix("/")
|
||||||
directoryResponse(artifactDir, relativePath, request, noStore = true)?.let { return it }
|
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
|
* 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.
|
* `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(
|
fun serveLatestGreen(
|
||||||
|
@PathVariable(name = "repo", required = false) repoName: String?,
|
||||||
@PathVariable branchKey: String,
|
@PathVariable branchKey: String,
|
||||||
@PathVariable path: String,
|
@PathVariable path: String,
|
||||||
request: HttpServletRequest,
|
request: HttpServletRequest,
|
||||||
): ResponseEntity<Resource> {
|
): ResponseEntity<Resource> {
|
||||||
val build = branchPermalinks.latestGreenBuild(branchKey)
|
val repo = repoOf(repoName)
|
||||||
|
val build = branchPermalinks.latestGreenBuild(repo, branchKey)
|
||||||
val artifactDir =
|
val artifactDir =
|
||||||
artifactStore.artifactDir(build.artifactKey)
|
repo.artifactStore.artifactDir(build.artifactKey)
|
||||||
?: throw ResponseStatusException(
|
?: throw ResponseStatusException(
|
||||||
HttpStatus.NOT_FOUND,
|
HttpStatus.NOT_FOUND,
|
||||||
"the artifacts of build '${build.artifactKey}' are not stored anymore",
|
"the artifacts of build '${build.artifactKey}' are not stored anymore",
|
||||||
|
|||||||
@@ -17,7 +17,10 @@ import org.springframework.stereotype.Component
|
|||||||
class BranchListing(
|
class BranchListing(
|
||||||
private val gitService: GitService,
|
private val gitService: GitService,
|
||||||
) {
|
) {
|
||||||
fun branches(repo: RepoContext): List<BranchDto> {
|
fun branches(
|
||||||
|
repo: RepoContext,
|
||||||
|
base: String = "",
|
||||||
|
): List<BranchDto> {
|
||||||
val repository = repo.results
|
val repository = repo.results
|
||||||
val heads = gitService.originBranchHeads(repo.workingDir)
|
val heads = gitService.originBranchHeads(repo.workingDir)
|
||||||
val namedResults = repository.latestPerName().filter { it.name != it.branch && it.branch in heads }
|
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
|
// the permanent link belongs to the build it resolves to, not to every build of the name
|
||||||
val isLatestGreen =
|
val isLatestGreen =
|
||||||
row.artifactKey.isNotEmpty() && row.artifactKey == repository.latestGreenFor(row.name)?.artifactKey
|
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.ArtifactKeys
|
||||||
import de.hoennig.werkator.build.BuildResult
|
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.http.HttpStatus
|
||||||
import org.springframework.stereotype.Component
|
import org.springframework.stereotype.Component
|
||||||
import org.springframework.web.server.ResponseStatusException
|
import org.springframework.web.server.ResponseStatusException
|
||||||
@@ -18,10 +18,12 @@ import org.springframework.web.server.ResponseStatusException
|
|||||||
* artifacts.
|
* artifacts.
|
||||||
*/
|
*/
|
||||||
@Component
|
@Component
|
||||||
class BranchPermalinks(
|
class BranchPermalinks {
|
||||||
private val repository: BuildResultRepository,
|
fun latestGreenBuild(
|
||||||
) {
|
repo: RepoContext,
|
||||||
fun latestGreenBuild(branchKey: String): BuildResult {
|
branchKey: String,
|
||||||
|
): BuildResult {
|
||||||
|
val repository = repo.results
|
||||||
val names =
|
val names =
|
||||||
repository
|
repository
|
||||||
.latestPerName()
|
.latestPerName()
|
||||||
@@ -41,7 +43,16 @@ class BranchPermalinks(
|
|||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
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 =
|
private fun RepoContext.isLatestGreen(result: BuildResult): Boolean =
|
||||||
results.latestGreenFor(result.name)?.artifactKey == result.artifactKey
|
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`. */
|
/** An unknown repository name answers like every other miss of this API: 404 with `error`. */
|
||||||
@ExceptionHandler(UnknownRepositoryException::class)
|
@ExceptionHandler(UnknownRepositoryException::class)
|
||||||
fun unknownRepository(e: UnknownRepositoryException): ResponseEntity<Any> = notFound(e.message ?: "unknown repository")
|
fun unknownRepository(e: UnknownRepositoryException): ResponseEntity<Any> = notFound(e.message ?: "unknown repository")
|
||||||
@@ -60,21 +63,24 @@ class BuildsApiController(
|
|||||||
@PathVariable(name = "repo", required = false) repoName: String?,
|
@PathVariable(name = "repo", required = false) repoName: String?,
|
||||||
): List<BuildResultDto> {
|
): List<BuildResultDto> {
|
||||||
val repo = repoOf(repoName)
|
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`. */
|
/** The legacy branches view: every origin branch with its latest build or `unknown`. */
|
||||||
@GetMapping("/api/branches", "/api/repos/{repo}/branches")
|
@GetMapping("/api/branches", "/api/repos/{repo}/branches")
|
||||||
fun branches(
|
fun branches(
|
||||||
@PathVariable(name = "repo", required = false) repoName: String?,
|
@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")
|
@GetMapping("/api/builds/history", "/api/repos/{repo}/builds/history")
|
||||||
fun history(
|
fun history(
|
||||||
@PathVariable(name = "repo", required = false) repoName: String?,
|
@PathVariable(name = "repo", required = false) repoName: String?,
|
||||||
): List<BuildResultDto> {
|
): List<BuildResultDto> {
|
||||||
val repo = repoOf(repoName)
|
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
|
package de.hoennig.werkator.server
|
||||||
|
|
||||||
import de.hoennig.werkator.build.ArtifactStore
|
|
||||||
import de.hoennig.werkator.build.BuildExecutor
|
import de.hoennig.werkator.build.BuildExecutor
|
||||||
import de.hoennig.werkator.build.BuildResult
|
import de.hoennig.werkator.build.BuildResult
|
||||||
import de.hoennig.werkator.build.BuildResultRepository
|
|
||||||
import de.hoennig.werkator.build.BuildStatus
|
import de.hoennig.werkator.build.BuildStatus
|
||||||
import de.hoennig.werkator.config.ConfigFiles
|
import de.hoennig.werkator.config.ConfigFiles
|
||||||
import de.hoennig.werkator.config.ConfigLoader
|
import de.hoennig.werkator.config.ConfigLoader
|
||||||
import de.hoennig.werkator.git.GitService
|
import de.hoennig.werkator.git.GitService
|
||||||
import de.hoennig.werkator.metrics.SystemMetricsCollector
|
import de.hoennig.werkator.metrics.SystemMetricsCollector
|
||||||
import de.hoennig.werkator.repo.RepoContext
|
import de.hoennig.werkator.repo.RepoContext
|
||||||
|
import de.hoennig.werkator.repo.RepoRegistry
|
||||||
import jakarta.servlet.http.HttpServletRequest
|
import jakarta.servlet.http.HttpServletRequest
|
||||||
import org.springframework.beans.factory.ObjectProvider
|
import org.springframework.beans.factory.ObjectProvider
|
||||||
import org.springframework.boot.info.BuildProperties
|
import org.springframework.boot.info.BuildProperties
|
||||||
import org.springframework.http.HttpStatus
|
import org.springframework.http.HttpStatus
|
||||||
|
import org.springframework.http.ResponseEntity
|
||||||
import org.springframework.stereotype.Controller
|
import org.springframework.stereotype.Controller
|
||||||
import org.springframework.ui.Model
|
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.GetMapping
|
||||||
import org.springframework.web.bind.annotation.PathVariable
|
import org.springframework.web.bind.annotation.PathVariable
|
||||||
import org.springframework.web.server.ResponseStatusException
|
import org.springframework.web.server.ResponseStatusException
|
||||||
@@ -34,19 +35,31 @@ import kotlin.streams.asSequence
|
|||||||
*/
|
*/
|
||||||
@Controller
|
@Controller
|
||||||
class UiController(
|
class UiController(
|
||||||
private val repository: BuildResultRepository,
|
|
||||||
private val buildExecutor: BuildExecutor,
|
private val buildExecutor: BuildExecutor,
|
||||||
private val artifactStore: ArtifactStore,
|
|
||||||
private val configLoader: ConfigLoader,
|
private val configLoader: ConfigLoader,
|
||||||
private val gitService: GitService,
|
private val gitService: GitService,
|
||||||
private val metricsCollector: SystemMetricsCollector,
|
private val metricsCollector: SystemMetricsCollector,
|
||||||
private val branchListing: BranchListing,
|
private val branchListing: BranchListing,
|
||||||
private val branchPermalinks: BranchPermalinks,
|
private val branchPermalinks: BranchPermalinks,
|
||||||
private val buildProperties: ObjectProvider<BuildProperties>,
|
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
|
* Permanent redirects for the legacy script's static page names, so bookmarks
|
||||||
@@ -58,11 +71,15 @@ class UiController(
|
|||||||
setStatusCode(HttpStatus.MOVED_PERMANENTLY)
|
setStatusCode(HttpStatus.MOVED_PERMANENTLY)
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/")
|
@GetMapping("/", "/repos/{repo}")
|
||||||
fun latest(model: Model): String {
|
fun latest(
|
||||||
val links = baseModel(model, view = "latest", pageTitle = "Latest Builds")
|
@PathVariable(name = "repo", required = false) repoName: String?,
|
||||||
model.addAttribute("rows", repository.latestPerName().map { BuildRowView.from(it, links, permanentUrlOf(it)) })
|
model: Model,
|
||||||
model.addAttribute("apiPath", "/api/builds/latest")
|
): 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("allowRestart", true)
|
||||||
model.addAttribute("restartAtOriginHead", false)
|
model.addAttribute("restartAtOriginHead", false)
|
||||||
model.addAttribute("emptyMessage", "No builds recorded yet.")
|
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. */
|
/** The legacy branches view: every origin branch with its latest build or an `unknown` row. */
|
||||||
@GetMapping("/branches")
|
@GetMapping("/branches", "/repos/{repo}/branches")
|
||||||
fun branches(model: Model): String {
|
fun branches(
|
||||||
val links = baseModel(model, view = "branches", pageTitle = "Branches")
|
@PathVariable(name = "repo", required = false) repoName: String?,
|
||||||
model.addAttribute("rows", branchListing.branches(repo).map { BuildRowView.from(it, links) })
|
model: Model,
|
||||||
model.addAttribute("apiPath", "/api/branches")
|
): 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)
|
model.addAttribute("allowRestart", true)
|
||||||
// a row here stands for a branch, not for a past run
|
// a row here stands for a branch, not for a past run
|
||||||
model.addAttribute("restartAtOriginHead", true)
|
model.addAttribute("restartAtOriginHead", true)
|
||||||
@@ -82,11 +103,15 @@ class UiController(
|
|||||||
return "builds"
|
return "builds"
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/history")
|
@GetMapping("/history", "/repos/{repo}/history")
|
||||||
fun history(model: Model): String {
|
fun history(
|
||||||
val links = baseModel(model, view = "history", pageTitle = "Build History")
|
@PathVariable(name = "repo", required = false) repoName: String?,
|
||||||
model.addAttribute("rows", repository.history().map { BuildRowView.from(it, links, permanentUrlOf(it)) })
|
model: Model,
|
||||||
model.addAttribute("apiPath", "/api/builds/history")
|
): 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("allowRestart", false)
|
||||||
model.addAttribute("restartAtOriginHead", false)
|
model.addAttribute("restartAtOriginHead", false)
|
||||||
model.addAttribute("emptyMessage", "No builds archived yet.")
|
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. */
|
/** The permanent branch URL belongs to the build it resolves to — the name's latest green build. */
|
||||||
private fun permanentUrlOf(result: BuildResult): String? =
|
private fun permanentUrlOf(
|
||||||
if (repository.latestGreenFor(result.name)?.artifactKey == result.artifactKey) {
|
repo: RepoContext,
|
||||||
BranchPermalinks.permanentUrl(result.name)
|
result: BuildResult,
|
||||||
|
): String? =
|
||||||
|
if (repo.results.latestGreenFor(result.name)?.artifactKey == result.artifactKey) {
|
||||||
|
BranchPermalinks.permanentUrl(result.name, uiBase(repo))
|
||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/current")
|
@GetMapping("/current", "/repos/{repo}/current")
|
||||||
fun current(model: Model): String {
|
fun current(
|
||||||
val links = baseModel(model, view = "current", pageTitle = "Current Builds")
|
@PathVariable(name = "repo", required = false) repoName: String?,
|
||||||
val results = repository.history()
|
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 =
|
val currentBuilds =
|
||||||
buildExecutor.currentBuilds().filter { it.repo === repo }.map { build ->
|
buildExecutor.currentBuilds().filter { it.repo === repo }.map { build ->
|
||||||
CurrentBuildView(
|
CurrentBuildView(
|
||||||
@@ -130,35 +162,39 @@ class UiController(
|
|||||||
/** Hand-maintained release notes (templates/releases.html); linked from the version in the footer. */
|
/** Hand-maintained release notes (templates/releases.html); linked from the version in the footer. */
|
||||||
@GetMapping("/releases")
|
@GetMapping("/releases")
|
||||||
fun releases(model: Model): String {
|
fun releases(model: Model): String {
|
||||||
baseModel(model, view = "releases", pageTitle = "Release Notes")
|
baseModel(model, view = "releases", pageTitle = "Release Notes", repo = registry.current())
|
||||||
return "releases"
|
return "releases"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One metrics page for the whole instance — the resources are the instance's, not a repository's. */
|
||||||
@GetMapping("/system")
|
@GetMapping("/system")
|
||||||
fun system(model: Model): String {
|
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()))
|
model.addAttribute("metrics", SystemMetricsView.from(metricsCollector.snapshot()))
|
||||||
return "system"
|
return "system"
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Artifact index rendered from the artifact store — legacy pre-generated this page as static HTML. */
|
/** 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(
|
fun artifactIndex(
|
||||||
|
@PathVariable(name = "repo", required = false) repoName: String?,
|
||||||
@PathVariable artifactKey: String,
|
@PathVariable artifactKey: String,
|
||||||
model: Model,
|
model: Model,
|
||||||
): String {
|
): String {
|
||||||
val result = repository.history().firstOrNull { it.artifactKey == artifactKey }
|
val repo = repoOf(repoName)
|
||||||
val artifactDir = artifactStore.artifactDir(artifactKey)
|
val result = repo.results.history().firstOrNull { it.artifactKey == artifactKey }
|
||||||
|
val artifactDir = repo.artifactStore.artifactDir(artifactKey)
|
||||||
if (result == null && artifactDir == null) {
|
if (result == null && artifactDir == null) {
|
||||||
throw ResponseStatusException(HttpStatus.NOT_FOUND, "no build with artifact key '$artifactKey'")
|
throw ResponseStatusException(HttpStatus.NOT_FOUND, "no build with artifact key '$artifactKey'")
|
||||||
}
|
}
|
||||||
return artifactIndexView(
|
return artifactIndexView(
|
||||||
model,
|
model,
|
||||||
pageTitle = "Build Artifacts",
|
pageTitle = "Build Artifacts",
|
||||||
|
repo = repo,
|
||||||
result = result,
|
result = result,
|
||||||
artifactKey = artifactKey,
|
artifactKey = artifactKey,
|
||||||
artifactDir = artifactDir,
|
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
|
* stay on the permanent `/branches/…` paths, so every link copied from this page
|
||||||
* outlives artifact pruning.
|
* outlives artifact pruning.
|
||||||
*/
|
*/
|
||||||
@GetMapping("/branches/{branchKey}")
|
@GetMapping("/branches/{branchKey}", "/repos/{repo}/branches/{branchKey}")
|
||||||
fun latestGreenArtifactIndex(
|
fun latestGreenArtifactIndex(
|
||||||
|
@PathVariable(name = "repo", required = false) repoName: String?,
|
||||||
@PathVariable branchKey: String,
|
@PathVariable branchKey: String,
|
||||||
model: Model,
|
model: Model,
|
||||||
): String {
|
): String {
|
||||||
val build = branchPermalinks.latestGreenBuild(branchKey)
|
val repo = repoOf(repoName)
|
||||||
|
val build = branchPermalinks.latestGreenBuild(repo, branchKey)
|
||||||
model.addAttribute("permanentBranch", build.branch)
|
model.addAttribute("permanentBranch", build.branch)
|
||||||
model.addAttribute("concreteUrl", "/builds/${build.artifactKey}")
|
model.addAttribute("concreteUrl", uiBase(repo) + "/builds/${build.artifactKey}")
|
||||||
return artifactIndexView(
|
return artifactIndexView(
|
||||||
model,
|
model,
|
||||||
pageTitle = "Latest Green Build",
|
pageTitle = "Latest Green Build",
|
||||||
|
repo = repo,
|
||||||
result = build,
|
result = build,
|
||||||
artifactKey = build.artifactKey,
|
artifactKey = build.artifactKey,
|
||||||
artifactDir = artifactStore.artifactDir(build.artifactKey),
|
artifactDir = repo.artifactStore.artifactDir(build.artifactKey),
|
||||||
filesBase = "/branches/$branchKey",
|
filesBase = uiBase(repo) + "/branches/$branchKey",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun artifactIndexView(
|
private fun artifactIndexView(
|
||||||
model: Model,
|
model: Model,
|
||||||
pageTitle: String,
|
pageTitle: String,
|
||||||
|
repo: RepoContext,
|
||||||
result: BuildResult?,
|
result: BuildResult?,
|
||||||
artifactKey: String,
|
artifactKey: String,
|
||||||
artifactDir: Path?,
|
artifactDir: Path?,
|
||||||
filesBase: String,
|
filesBase: String,
|
||||||
): 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("artifactKey", artifactKey)
|
||||||
model.addAttribute("filesBase", filesBase)
|
model.addAttribute("filesBase", filesBase)
|
||||||
model.addAttribute("result", result?.let { BuildRowView.from(it, links) })
|
model.addAttribute("result", result?.let { BuildRowView.from(it, links) })
|
||||||
model.addAttribute("hasArtifacts", artifactDir != null)
|
model.addAttribute("hasArtifacts", artifactDir != null)
|
||||||
model.addAttribute("buildCommand", result?.let { buildCommandOf(it) })
|
model.addAttribute("buildCommand", result?.let { buildCommandOf(repo, it) })
|
||||||
model.addAttribute(
|
model.addAttribute(
|
||||||
"logs",
|
"logs",
|
||||||
artifactDir?.let { logFiles(it, scanForFailure = result != null && result.status != BuildStatus.SUCCESS) }
|
artifactDir?.let { logFiles(it, scanForFailure = result != null && result.status != BuildStatus.SUCCESS) }
|
||||||
@@ -226,21 +266,41 @@ class UiController(
|
|||||||
.toList()
|
.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. */
|
/** Adds the attributes every page needs and returns the Gitea link helper for row building. */
|
||||||
private fun baseModel(
|
private fun baseModel(
|
||||||
model: Model,
|
model: Model,
|
||||||
view: String,
|
view: String,
|
||||||
pageTitle: String,
|
pageTitle: String,
|
||||||
|
repo: RepoContext,
|
||||||
): GiteaWebLinks {
|
): GiteaWebLinks {
|
||||||
val config = configLoader.load(workingDir)
|
val config = configLoader.load(repo.workingDir)
|
||||||
val links = GiteaWebLinks(config.gitea)
|
val links = GiteaWebLinks(config.gitea)
|
||||||
val repoName =
|
val repoName =
|
||||||
listOf(config.gitea.owner.trim(), config.gitea.repo.trim())
|
listOf(config.gitea.owner.trim(), config.gitea.repo.trim())
|
||||||
.filter { it.isNotEmpty() }
|
.filter { it.isNotEmpty() }
|
||||||
.joinToString("/")
|
.joinToString("/")
|
||||||
|
val served = registry.all()
|
||||||
model.addAttribute("view", view)
|
model.addAttribute("view", view)
|
||||||
model.addAttribute("pageTitle", pageTitle)
|
model.addAttribute("pageTitle", pageTitle)
|
||||||
model.addAttribute("repoName", repoName)
|
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("version", buildProperties.getIfAvailable()?.version ?: "dev")
|
||||||
model.addAttribute("impressumUrl", config.server.impressumUrl.trim())
|
model.addAttribute("impressumUrl", config.server.impressumUrl.trim())
|
||||||
model.addAttribute("giteaRepoUrl", links.repoUrl ?: "")
|
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.
|
* 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.
|
* 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 =
|
val config =
|
||||||
try {
|
try {
|
||||||
configLoader.loadWithBranchLayer(
|
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");
|
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
|
// 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
|
// 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;
|
// 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";
|
const inProgress = build.status === "running" || build.status === "pending";
|
||||||
if (build.artifactKey) {
|
if (build.artifactKey) {
|
||||||
const artifactLink = elem("a", "artifact-link", inProgress ? "⏳" : "📄");
|
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";
|
artifactLink.title = inProgress ? "Open build log — no artifacts yet" : "Open artifacts";
|
||||||
artifactsCell.appendChild(artifactLink);
|
artifactsCell.appendChild(artifactLink);
|
||||||
}
|
}
|
||||||
@@ -542,7 +553,7 @@ function initCurrentBuilds() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const offset = logOffsets.get(build.artifactKey) || 0;
|
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);
|
const tail = await fetchJson(url);
|
||||||
logOffsets.set(build.artifactKey, tail.nextOffset);
|
logOffsets.set(build.artifactKey, tail.nextOffset);
|
||||||
if (tail.content) {
|
if (tail.content) {
|
||||||
@@ -690,11 +701,11 @@ document.addEventListener("click", async (event) => {
|
|||||||
try {
|
try {
|
||||||
if (action === "restart") {
|
if (action === "restart") {
|
||||||
const atOriginHead = button.dataset.atOriginHead === "true" ? "&atOriginHead=true" : "";
|
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") {
|
} 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") {
|
} else if (action === "delete") {
|
||||||
await sendAction("/api/builds/" + encodeURIComponent(button.dataset.artifactKey), "DELETE");
|
await sendAction(apiBase() + "/builds/" + encodeURIComponent(button.dataset.artifactKey), "DELETE");
|
||||||
}
|
}
|
||||||
if (refreshNow) {
|
if (refreshNow) {
|
||||||
refreshNow();
|
refreshNow();
|
||||||
|
|||||||
@@ -52,13 +52,13 @@
|
|||||||
<td class="duration-cell" data-label="Duration" th:text="${row.duration}">1:23</td>
|
<td class="duration-cell" data-label="Duration" th:text="${row.duration}">1:23</td>
|
||||||
<td data-label="Artifacts">
|
<td data-label="Artifacts">
|
||||||
<a th:if="${row.artifactKey != ''}" class="artifact-link"
|
<a th:if="${row.artifactKey != ''}" class="artifact-link"
|
||||||
th:href="'/builds/' + ${row.artifactKey}"
|
th:href="${repoBase} + '/builds/' + ${row.artifactKey}"
|
||||||
th:text="${row.inProgress} ? '⏳' : '📄'"
|
th:text="${row.inProgress} ? '⏳' : '📄'"
|
||||||
th:title="${row.inProgress} ? 'Open build log — no artifacts yet' : 'Open artifacts'">📄</a>
|
th:title="${row.inProgress} ? 'Open build log — no artifacts yet' : 'Open artifacts'">📄</a>
|
||||||
<a th:if="${row.latestGreenUrl != null}" class="artifact-link"
|
<a th:if="${row.latestGreenUrl != null}" class="artifact-link"
|
||||||
th:href="${row.latestGreenUrl}"
|
th:href="${row.latestGreenUrl}"
|
||||||
title="Permanent link: artifacts of the latest green build">🔗</a>
|
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>
|
title="Watch this build live">📡</a>
|
||||||
<span th:if="${row.artifactKey == ''}">n/a</span>
|
<span th:if="${row.artifactKey == ''}">n/a</span>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<main>
|
<main>
|
||||||
<h1 th:replace="~{fragments :: header(${pageTitle})}"></h1>
|
<h1 th:replace="~{fragments :: header(${pageTitle})}"></h1>
|
||||||
<div th:replace="~{fragments :: nav(${view})}"></div>
|
<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'">
|
<p id="no-current" class="empty-panel" th:style="${#lists.isEmpty(currentBuilds)} ? '' : 'display: none'">
|
||||||
No build is currently running.
|
No build is currently running.
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -7,11 +7,14 @@
|
|||||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
|
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
|
||||||
<link rel="stylesheet" href="/werkator.css">
|
<link rel="stylesheet" href="/werkator.css">
|
||||||
<meta name="werkator-gitea-repo-url" th:content="${giteaRepoUrl}">
|
<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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
<h1 th:fragment="header(title)">
|
<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="title-text" th:text="${title}">Latest Builds</span>
|
||||||
<span class="repo-name" th:unless="${#strings.isEmpty(repoName)}" th:text="${repoName}">owner/repo</span>
|
<span class="repo-name" th:unless="${#strings.isEmpty(repoName)}" th:text="${repoName}">owner/repo</span>
|
||||||
</h1>
|
</h1>
|
||||||
@@ -20,15 +23,23 @@
|
|||||||
<div class="view-row">
|
<div class="view-row">
|
||||||
<nav class="view-toggle">
|
<nav class="view-toggle">
|
||||||
<span th:if="${view == 'latest'}">Latest</span>
|
<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>
|
<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>
|
<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 == 'current'}">Current</span>
|
||||||
<span th:if="${view == 'system'}">System</span>
|
<span th:if="${view == 'system'}">System</span>
|
||||||
<a th:unless="${view == 'system'}" href="/system">System</a>
|
<a th:unless="${view == 'system'}" href="/system">System</a>
|
||||||
</nav>
|
</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 class="view-row-actions">
|
||||||
<span id="live-indicator" class="status status-unknown" title="live-update state">static</span>
|
<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>
|
<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.ArtifactStore
|
||||||
import de.hoennig.werkator.build.BuildResult
|
import de.hoennig.werkator.build.BuildResult
|
||||||
import de.hoennig.werkator.build.BuildStatus
|
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.kotest.core.spec.style.FunSpec
|
||||||
import io.mockk.clearMocks
|
import io.mockk.clearMocks
|
||||||
import io.mockk.every
|
import io.mockk.every
|
||||||
@@ -32,6 +34,12 @@ class ArtifactFileControllerTest : FunSpec() {
|
|||||||
@MockkBean
|
@MockkBean
|
||||||
lateinit var branchPermalinks: BranchPermalinks
|
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 artifactDir: Path = Files.createTempDirectory("werkator-artifact-serve-test")
|
||||||
|
|
||||||
private val greenBuild =
|
private val greenBuild =
|
||||||
@@ -46,12 +54,18 @@ class ArtifactFileControllerTest : FunSpec() {
|
|||||||
|
|
||||||
init {
|
init {
|
||||||
beforeEach {
|
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(any()) } returns null
|
||||||
every { artifactStore.artifactDir("known-key") } returns artifactDir
|
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")
|
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") {
|
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") {
|
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
|
mockMvc
|
||||||
.perform(get("/branches/main/build.log"))
|
.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.BuildResult
|
||||||
import de.hoennig.werkator.build.BuildResultRepository
|
import de.hoennig.werkator.build.BuildResultRepository
|
||||||
import de.hoennig.werkator.build.BuildStatus
|
import de.hoennig.werkator.build.BuildStatus
|
||||||
|
import de.hoennig.werkator.repo.RepoContext
|
||||||
import io.kotest.assertions.throwables.shouldThrow
|
import io.kotest.assertions.throwables.shouldThrow
|
||||||
import io.kotest.core.spec.style.FunSpec
|
import io.kotest.core.spec.style.FunSpec
|
||||||
import io.kotest.matchers.shouldBe
|
import io.kotest.matchers.shouldBe
|
||||||
@@ -17,7 +18,8 @@ import java.time.Instant
|
|||||||
|
|
||||||
class BranchPermalinksTest : FunSpec() {
|
class BranchPermalinksTest : FunSpec() {
|
||||||
private val repository = mockk<BuildResultRepository>()
|
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(
|
private fun result(
|
||||||
branch: String,
|
branch: String,
|
||||||
@@ -36,20 +38,20 @@ class BranchPermalinksTest : FunSpec() {
|
|||||||
every { repository.latestPerName() } returns listOf(result("feature/x"), result("main"))
|
every { repository.latestPerName() } returns listOf(result("feature/x"), result("main"))
|
||||||
every { repository.latestGreenFor("feature/x") } returns result("feature/x")
|
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") {
|
test("resolves the full branch key with hash suffix") {
|
||||||
every { repository.latestPerName() } returns listOf(result("feature/x"))
|
every { repository.latestPerName() } returns listOf(result("feature/x"))
|
||||||
every { repository.latestGreenFor("feature/x") } returns 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") {
|
test("an unknown branch key answers 404") {
|
||||||
every { repository.latestPerName() } returns listOf(result("main"))
|
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
|
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.latestPerName() } returns listOf(result("main", status = BuildStatus.FAILED))
|
||||||
every { repository.latestGreenFor("main") } returns null
|
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
|
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") {
|
test("a permanent key matching several branches answers 409 and names the candidates") {
|
||||||
every { repository.latestPerName() } returns listOf(result("feature/x"), result("feature_x"))
|
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.statusCode shouldBe HttpStatus.CONFLICT
|
||||||
exception.reason.orEmpty() shouldContain "feature/x"
|
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.latestPerName() } returns listOf(result("feature/x"), result("feature_x"))
|
||||||
every { repository.latestGreenFor("feature/x") } returns 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") {
|
test("permanentUrl uses the hash-free branch key") {
|
||||||
@@ -89,7 +91,7 @@ class BranchPermalinksTest : FunSpec() {
|
|||||||
every { repository.latestGreenFor("main@nightly") } returns nightly
|
every { repository.latestGreenFor("main@nightly") } returns nightly
|
||||||
|
|
||||||
// sanitized like any branch key: the '@' becomes '_' in the URL
|
// 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.results } returns repository
|
||||||
every { repo.artifactStore } returns artifactStore
|
every { repo.artifactStore } returns artifactStore
|
||||||
// the unscoped routes mean the served repository; `/api/repos/test/…` names it
|
// 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.current() } returns repo
|
||||||
every { registry.byName(any()) } returns null
|
every { registry.byName(any()) } returns null
|
||||||
every { registry.byName("test") } returns repo
|
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.git.GitService
|
||||||
import de.hoennig.werkator.metrics.SystemMetricsCollector
|
import de.hoennig.werkator.metrics.SystemMetricsCollector
|
||||||
import de.hoennig.werkator.repo.RepoContext
|
import de.hoennig.werkator.repo.RepoContext
|
||||||
|
import de.hoennig.werkator.repo.RepoRegistry
|
||||||
import io.kotest.core.spec.style.FunSpec
|
import io.kotest.core.spec.style.FunSpec
|
||||||
import io.mockk.clearMocks
|
import io.mockk.clearMocks
|
||||||
import io.mockk.every
|
import io.mockk.every
|
||||||
@@ -70,6 +71,9 @@ class PermanentBranchRoutesTest : FunSpec() {
|
|||||||
@MockkBean
|
@MockkBean
|
||||||
lateinit var repo: RepoContext
|
lateinit var repo: RepoContext
|
||||||
|
|
||||||
|
@MockkBean
|
||||||
|
lateinit var registry: RepoRegistry
|
||||||
|
|
||||||
private val artifactDir: Path = Files.createTempDirectory("werkator-permanent-routes-test")
|
private val artifactDir: Path = Files.createTempDirectory("werkator-permanent-routes-test")
|
||||||
|
|
||||||
private val greenBuild =
|
private val greenBuild =
|
||||||
@@ -95,14 +99,22 @@ class PermanentBranchRoutesTest : FunSpec() {
|
|||||||
branchListing,
|
branchListing,
|
||||||
branchPermalinks,
|
branchPermalinks,
|
||||||
repo,
|
repo,
|
||||||
|
registry,
|
||||||
)
|
)
|
||||||
|
every { repo.name } returns "test"
|
||||||
every { repo.workingDir } returns Paths.get(".")
|
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.load(any()) } returns WerkatorConfig()
|
||||||
every { configLoader.loadWithBranchLayer(any(), anyNullable()) } returns WerkatorConfig()
|
every { configLoader.loadWithBranchLayer(any(), anyNullable()) } returns WerkatorConfig()
|
||||||
every { gitService.showFileAtCommit(any(), any(), any()) } returns null
|
every { gitService.showFileAtCommit(any(), any(), any()) } returns null
|
||||||
every { controlTokens.token() } returns "test-token"
|
every { controlTokens.token() } returns "test-token"
|
||||||
every { branchListing.branches(any()) } returns emptyList()
|
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
|
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.SystemMetrics
|
||||||
import de.hoennig.werkator.metrics.SystemMetricsCollector
|
import de.hoennig.werkator.metrics.SystemMetricsCollector
|
||||||
import de.hoennig.werkator.repo.RepoContext
|
import de.hoennig.werkator.repo.RepoContext
|
||||||
|
import de.hoennig.werkator.repo.RepoRegistry
|
||||||
import io.kotest.core.spec.style.FunSpec
|
import io.kotest.core.spec.style.FunSpec
|
||||||
import io.kotest.matchers.shouldBe
|
import io.kotest.matchers.shouldBe
|
||||||
import io.kotest.matchers.string.shouldContain
|
import io.kotest.matchers.string.shouldContain
|
||||||
import io.kotest.matchers.string.shouldNotContain
|
import io.kotest.matchers.string.shouldNotContain
|
||||||
import io.mockk.clearMocks
|
import io.mockk.clearMocks
|
||||||
import io.mockk.every
|
import io.mockk.every
|
||||||
|
import io.mockk.mockk
|
||||||
import org.hamcrest.Matchers.containsString
|
import org.hamcrest.Matchers.containsString
|
||||||
import org.hamcrest.Matchers.not
|
import org.hamcrest.Matchers.not
|
||||||
import org.springframework.beans.factory.annotation.Autowired
|
import org.springframework.beans.factory.annotation.Autowired
|
||||||
@@ -78,6 +80,9 @@ class UiControllerTest : FunSpec() {
|
|||||||
@MockkBean
|
@MockkBean
|
||||||
lateinit var repo: RepoContext
|
lateinit var repo: RepoContext
|
||||||
|
|
||||||
|
@MockkBean
|
||||||
|
lateinit var registry: RepoRegistry
|
||||||
|
|
||||||
private val startedAt = Instant.parse("2026-07-07T10:00:00Z")
|
private val startedAt = Instant.parse("2026-07-07T10:00:00Z")
|
||||||
|
|
||||||
private val emptySystemMetrics =
|
private val emptySystemMetrics =
|
||||||
@@ -119,8 +124,16 @@ class UiControllerTest : FunSpec() {
|
|||||||
branchListing,
|
branchListing,
|
||||||
branchPermalinks,
|
branchPermalinks,
|
||||||
repo,
|
repo,
|
||||||
|
registry,
|
||||||
)
|
)
|
||||||
|
every { repo.name } returns "test"
|
||||||
every { repo.workingDir } returns Paths.get(".")
|
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
|
every { configLoader.load(any()) } returns
|
||||||
WerkatorConfig(
|
WerkatorConfig(
|
||||||
server = ServerConfig(impressumUrl = "https://example.org/imprint"),
|
server = ServerConfig(impressumUrl = "https://example.org/imprint"),
|
||||||
@@ -144,6 +157,40 @@ class UiControllerTest : FunSpec() {
|
|||||||
.andExpect(content().string(not(containsString("""href="/current""""))))
|
.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") {
|
test("latest view renders rows with badge, Gitea links, artifact link, actions, and token") {
|
||||||
every { repository.latestPerName() } returns listOf(successResult)
|
every { repository.latestPerName() } returns listOf(successResult)
|
||||||
|
|
||||||
@@ -503,7 +550,7 @@ class UiControllerTest : FunSpec() {
|
|||||||
Files.writeString(artifactDir.resolve("build.stdout.log"), "out")
|
Files.writeString(artifactDir.resolve("build.stdout.log"), "out")
|
||||||
Files.createDirectories(artifactDir.resolve("reports/tests/test"))
|
Files.createDirectories(artifactDir.resolve("reports/tests/test"))
|
||||||
Files.writeString(artifactDir.resolve("reports/tests/test/index.html"), "<html></html>")
|
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
|
every { artifactStore.artifactDir("main-abc123-key") } returns artifactDir
|
||||||
|
|
||||||
mockMvc
|
mockMvc
|
||||||
@@ -517,7 +564,7 @@ class UiControllerTest : FunSpec() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
test("permanent artifact index of a branch without a green build answers 404") {
|
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")
|
ResponseStatusException(HttpStatus.NOT_FOUND, "branch 'main' has no successful build")
|
||||||
|
|
||||||
mockMvc
|
mockMvc
|
||||||
|
|||||||
Reference in New Issue
Block a user