feat(server): Routen, Seiten und Artefakte tragen das Repository — /repos/<name>/… (Sitzung D)

Zweite Hälfte von Sitzung D (docs/plan/22-multi-repo.md, PR #13): Der Server
bediente bisher genau ein Repository der Registry. Jede Route — API, Seiten,
Artefakt-Dateien — arbeitete auf `registry.current()`; ein zweites
registriertes Repository wurde gebaut und gepollt, war aber unsichtbar und
unerreichbar.

Jeder Controller löst sein Repository jetzt je Anfrage auf, statt das
bediente als Bohne zu halten; jede Route ist zweimal gemappt. Die unscoped
Form ist kein Übergangs-Alias, sondern dauerhaft die Art zu sagen „das
bediente Repository" — Lesezeichen und die nach Gitea geposteten Links
kennen kein Segment.

Entschieden gegen die Repo-Spalte: Die Seiten bleiben je Repository, die
Navigation bekommt einen Umschalter. Die Aktionen einer Zeile brauchen das
Repository ohnehin, Branches kommen von einem origin und Artefakte aus einem
Store — und bei dem einen Repository, das die meisten Installationen haben,
wäre eine Spalte nur Rauschen.

Das Link-Präfix folgt der ZAHL der bedienten Repositories, nicht dem Weg, über
den eine Seite erreicht wurde: mit einem behält die Installation ihre
bisherigen URLs (Abnahmekriterium der Sitzung), mit mehreren benennt jeder
Link sein Repository. werkator.js liest das Präfix einmal aus einem
`werkator-repo-base`-Meta. `BranchPermalinks.permanentUrl` bekommt es
ebenfalls — der permanente Schlüssel ist ein Hash des Build-Namens allein,
zwei Repositories mit `main` teilten sich sonst eine permanente URL.

Fünf neue Tests, Gegenprobe per Mutation gezogen (Präfix fest auf leer →
genau der Mehr-Repo-Test fällt). 498 Tests grün, ktlint sauber. PR-Dokument
docs/prs/2026-09-03-PR#13-…, Plan, Architektur-Skill und AGENTS.md
nachgezogen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
mhoennig
2026-09-03 13:39:09 +02:00
co-authored by Claude Opus 5
parent 4304dd7c4b
commit c82f2a965c
20 changed files with 417 additions and 96 deletions
+16 -5
View File
@@ -99,6 +99,17 @@ function metaContent(name) {
const giteaRepoUrl = metaContent("werkator-gitea-repo-url");
// Empty with one served repository, `/repos/<name>` with several (ADR 0009). Every
// path this script builds itself is prefixed with it, so an action triggered on a
// repository's page acts on that repository — the paths rendered into the DOM
// (`data-api`, artifact links) already carry it.
const repoBase = metaContent("werkator-repo-base") || "";
/** The API of the repository this page belongs to; `/api` when only one is served. */
function apiBase() {
return repoBase ? "/api" + repoBase : "/api";
}
// The control token is deliberately NOT embedded in the pages: reading them is
// unauthenticated, so anyone could have read it out of the HTML. The operator
// pastes it once per browser from `.git/werkator/control-token` on the server;
@@ -389,7 +400,7 @@ function renderBuildRow(build, allowRestart, restartAtOriginHead) {
const inProgress = build.status === "running" || build.status === "pending";
if (build.artifactKey) {
const artifactLink = elem("a", "artifact-link", inProgress ? "⏳" : "📄");
artifactLink.href = "/builds/" + encodeURIComponent(build.artifactKey);
artifactLink.href = repoBase + "/builds/" + encodeURIComponent(build.artifactKey);
artifactLink.title = inProgress ? "Open build log — no artifacts yet" : "Open artifacts";
artifactsCell.appendChild(artifactLink);
}
@@ -542,7 +553,7 @@ function initCurrentBuilds() {
return;
}
const offset = logOffsets.get(build.artifactKey) || 0;
const url = `/api/builds/current/${encodeURIComponent(build.artifactKey)}/log?offset=${offset}`;
const url = `${apiBase()}/builds/current/${encodeURIComponent(build.artifactKey)}/log?offset=${offset}`;
const tail = await fetchJson(url);
logOffsets.set(build.artifactKey, tail.nextOffset);
if (tail.content) {
@@ -690,11 +701,11 @@ document.addEventListener("click", async (event) => {
try {
if (action === "restart") {
const atOriginHead = button.dataset.atOriginHead === "true" ? "&atOriginHead=true" : "";
await sendAction("/api/builds/restart?branch=" + encodeURIComponent(button.dataset.branch) + atOriginHead, "POST");
await sendAction(apiBase() + "/builds/restart?branch=" + encodeURIComponent(button.dataset.branch) + atOriginHead, "POST");
} else if (action === "cancel") {
await sendAction(`/api/builds/${encodeURIComponent(button.dataset.artifactKey)}/cancel`, "POST");
await sendAction(`${apiBase()}/builds/${encodeURIComponent(button.dataset.artifactKey)}/cancel`, "POST");
} else if (action === "delete") {
await sendAction("/api/builds/" + encodeURIComponent(button.dataset.artifactKey), "DELETE");
await sendAction(apiBase() + "/builds/" + encodeURIComponent(button.dataset.artifactKey), "DELETE");
}
if (refreshNow) {
refreshNow();