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>
7.4 KiB
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/latestis requested - Then the answer holds that repository's builds
- and
GET /api/builds/lateststill answers the same, because the unscoped form means the served repository
- and
Verified by
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/latestis requested - Then the answer is 404 with
{"error": "no repository named 'no-such-repo'"}- and the page
/repos/no-such-repoanswers 404 as well
- and the page
Verified by
- BuildsApiControllerTest — "the repository-scoped routes answer for the named repository and 404 for an unknown name"
- UiControllerTest — "a page of a repository this instance does not serve answers 404"
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
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
testandother - When the page
/repos/testis rendered - Then its navigation links, its
data-api, and itswerkator-repo-basemeta carry/repos/test- and the switcher offers
/repos/other
- and the switcher offers
Verified by
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"
- BuildsApiControllerTest — "current answers only the served repository's builds"
- WatcherTest — "a running build of another repository does not keep this repository's worktree"
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/watcherstays 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.