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
@@ -27,6 +27,7 @@ data class BuildResultDto(
|
||||
fun from(
|
||||
result: BuildResult,
|
||||
isLatestGreen: Boolean = false,
|
||||
base: String = "",
|
||||
) = BuildResultDto(
|
||||
branch = result.branch,
|
||||
name = result.name,
|
||||
@@ -36,7 +37,7 @@ data class BuildResultDto(
|
||||
runningSince = result.runningSince,
|
||||
durationSeconds = result.duration?.seconds,
|
||||
artifactKey = result.artifactKey,
|
||||
latestGreenUrl = if (isLatestGreen) BranchPermalinks.permanentUrl(result.name) else null,
|
||||
latestGreenUrl = if (isLatestGreen) BranchPermalinks.permanentUrl(result.name, base) else null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package de.hoennig.werkator.server
|
||||
|
||||
import de.hoennig.werkator.build.ArtifactStore
|
||||
import de.hoennig.werkator.repo.RepoContext
|
||||
import de.hoennig.werkator.repo.RepoRegistry
|
||||
import jakarta.servlet.http.HttpServletRequest
|
||||
import org.springframework.core.io.FileSystemResource
|
||||
import org.springframework.core.io.Resource
|
||||
@@ -25,17 +26,22 @@ import kotlin.streams.asSequence
|
||||
*/
|
||||
@RestController
|
||||
class ArtifactFileController(
|
||||
private val artifactStore: ArtifactStore,
|
||||
private val branchPermalinks: BranchPermalinks,
|
||||
private val registry: RepoRegistry,
|
||||
) {
|
||||
@GetMapping("/artifacts/{artifactKey}/{*path}")
|
||||
/** Scoped and unscoped, like every other route (ADR 0009); unscoped means the served repository. */
|
||||
private fun repoOf(name: String?): RepoContext =
|
||||
if (name == null) registry.current() else registry.byName(name) ?: throw UnknownRepositoryException(name)
|
||||
|
||||
@GetMapping("/artifacts/{artifactKey}/{*path}", "/repos/{repo}/artifacts/{artifactKey}/{*path}")
|
||||
fun serve(
|
||||
@PathVariable(name = "repo", required = false) repoName: String?,
|
||||
@PathVariable artifactKey: String,
|
||||
@PathVariable path: String,
|
||||
request: HttpServletRequest,
|
||||
): ResponseEntity<Resource> {
|
||||
val artifactDir =
|
||||
artifactStore.artifactDir(artifactKey)
|
||||
repoOf(repoName).artifactStore.artifactDir(artifactKey)
|
||||
?: return ResponseEntity.notFound().build()
|
||||
val relativePath = path.removePrefix("/").removeSuffix("/")
|
||||
directoryResponse(artifactDir, relativePath, request, noStore = true)?.let { return it }
|
||||
@@ -52,15 +58,17 @@ class ArtifactFileController(
|
||||
* slash, so relative links inside reports resolve correctly), and everything is
|
||||
* `no-store` because the content behind a URL changes with every new green build.
|
||||
*/
|
||||
@GetMapping("/branches/{branchKey}/{*path}")
|
||||
@GetMapping("/branches/{branchKey}/{*path}", "/repos/{repo}/branches/{branchKey}/{*path}")
|
||||
fun serveLatestGreen(
|
||||
@PathVariable(name = "repo", required = false) repoName: String?,
|
||||
@PathVariable branchKey: String,
|
||||
@PathVariable path: String,
|
||||
request: HttpServletRequest,
|
||||
): ResponseEntity<Resource> {
|
||||
val build = branchPermalinks.latestGreenBuild(branchKey)
|
||||
val repo = repoOf(repoName)
|
||||
val build = branchPermalinks.latestGreenBuild(repo, branchKey)
|
||||
val artifactDir =
|
||||
artifactStore.artifactDir(build.artifactKey)
|
||||
repo.artifactStore.artifactDir(build.artifactKey)
|
||||
?: throw ResponseStatusException(
|
||||
HttpStatus.NOT_FOUND,
|
||||
"the artifacts of build '${build.artifactKey}' are not stored anymore",
|
||||
|
||||
@@ -17,7 +17,10 @@ import org.springframework.stereotype.Component
|
||||
class BranchListing(
|
||||
private val gitService: GitService,
|
||||
) {
|
||||
fun branches(repo: RepoContext): List<BranchDto> {
|
||||
fun branches(
|
||||
repo: RepoContext,
|
||||
base: String = "",
|
||||
): List<BranchDto> {
|
||||
val repository = repo.results
|
||||
val heads = gitService.originBranchHeads(repo.workingDir)
|
||||
val namedResults = repository.latestPerName().filter { it.name != it.branch && it.branch in heads }
|
||||
@@ -45,7 +48,7 @@ class BranchListing(
|
||||
// the permanent link belongs to the build it resolves to, not to every build of the name
|
||||
val isLatestGreen =
|
||||
row.artifactKey.isNotEmpty() && row.artifactKey == repository.latestGreenFor(row.name)?.artifactKey
|
||||
if (isLatestGreen) row.copy(latestGreenUrl = BranchPermalinks.permanentUrl(row.name)) else row
|
||||
if (isLatestGreen) row.copy(latestGreenUrl = BranchPermalinks.permanentUrl(row.name, base)) else row
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ package de.hoennig.werkator.server
|
||||
|
||||
import de.hoennig.werkator.build.ArtifactKeys
|
||||
import de.hoennig.werkator.build.BuildResult
|
||||
import de.hoennig.werkator.build.BuildResultRepository
|
||||
import de.hoennig.werkator.repo.RepoContext
|
||||
import org.springframework.http.HttpStatus
|
||||
import org.springframework.stereotype.Component
|
||||
import org.springframework.web.server.ResponseStatusException
|
||||
@@ -18,10 +18,12 @@ import org.springframework.web.server.ResponseStatusException
|
||||
* artifacts.
|
||||
*/
|
||||
@Component
|
||||
class BranchPermalinks(
|
||||
private val repository: BuildResultRepository,
|
||||
) {
|
||||
fun latestGreenBuild(branchKey: String): BuildResult {
|
||||
class BranchPermalinks {
|
||||
fun latestGreenBuild(
|
||||
repo: RepoContext,
|
||||
branchKey: String,
|
||||
): BuildResult {
|
||||
val repository = repo.results
|
||||
val names =
|
||||
repository
|
||||
.latestPerName()
|
||||
@@ -41,7 +43,16 @@ class BranchPermalinks(
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** The permanent artifact-index URL of the build name (branch or named slot), shown in the branches view. */
|
||||
fun permanentUrl(name: String): String = "/branches/${ArtifactKeys.permanentBranchKey(name)}"
|
||||
/**
|
||||
* The permanent artifact-index URL of the build name (branch or named slot), shown
|
||||
* in the branches view. [base] is the repository prefix (`/repos/<name>`, empty with
|
||||
* one served repository): the key is a hash of the name alone, so two repositories
|
||||
* both having `main` would otherwise share one permanent URL — and it would resolve
|
||||
* against whichever repository the instance happens to serve unscoped.
|
||||
*/
|
||||
fun permanentUrl(
|
||||
name: String,
|
||||
base: String = "",
|
||||
): String = "$base/branches/${ArtifactKeys.permanentBranchKey(name)}"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,6 +51,9 @@ class BuildsApiController(
|
||||
private fun RepoContext.isLatestGreen(result: BuildResult): Boolean =
|
||||
results.latestGreenFor(result.name)?.artifactKey == result.artifactKey
|
||||
|
||||
/** The prefix the permanent links in the answers carry; empty with one served repository. */
|
||||
private fun uiBase(repo: RepoContext): String = if (registry.all().size > 1) "/repos/${repo.name}" else ""
|
||||
|
||||
/** An unknown repository name answers like every other miss of this API: 404 with `error`. */
|
||||
@ExceptionHandler(UnknownRepositoryException::class)
|
||||
fun unknownRepository(e: UnknownRepositoryException): ResponseEntity<Any> = notFound(e.message ?: "unknown repository")
|
||||
@@ -60,21 +63,24 @@ class BuildsApiController(
|
||||
@PathVariable(name = "repo", required = false) repoName: String?,
|
||||
): List<BuildResultDto> {
|
||||
val repo = repoOf(repoName)
|
||||
return repo.results.latestPerName().map { BuildResultDto.from(it, repo.isLatestGreen(it)) }
|
||||
return repo.results.latestPerName().map { BuildResultDto.from(it, repo.isLatestGreen(it), uiBase(repo)) }
|
||||
}
|
||||
|
||||
/** The legacy branches view: every origin branch with its latest build or `unknown`. */
|
||||
@GetMapping("/api/branches", "/api/repos/{repo}/branches")
|
||||
fun branches(
|
||||
@PathVariable(name = "repo", required = false) repoName: String?,
|
||||
): List<BranchDto> = branchListing.branches(repoOf(repoName))
|
||||
): List<BranchDto> {
|
||||
val repo = repoOf(repoName)
|
||||
return branchListing.branches(repo, uiBase(repo))
|
||||
}
|
||||
|
||||
@GetMapping("/api/builds/history", "/api/repos/{repo}/builds/history")
|
||||
fun history(
|
||||
@PathVariable(name = "repo", required = false) repoName: String?,
|
||||
): List<BuildResultDto> {
|
||||
val repo = repoOf(repoName)
|
||||
return repo.results.history().map { BuildResultDto.from(it, repo.isLatestGreen(it)) }
|
||||
return repo.results.history().map { BuildResultDto.from(it, repo.isLatestGreen(it), uiBase(repo)) }
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,21 +1,22 @@
|
||||
package de.hoennig.werkator.server
|
||||
|
||||
import de.hoennig.werkator.build.ArtifactStore
|
||||
import de.hoennig.werkator.build.BuildExecutor
|
||||
import de.hoennig.werkator.build.BuildResult
|
||||
import de.hoennig.werkator.build.BuildResultRepository
|
||||
import de.hoennig.werkator.build.BuildStatus
|
||||
import de.hoennig.werkator.config.ConfigFiles
|
||||
import de.hoennig.werkator.config.ConfigLoader
|
||||
import de.hoennig.werkator.git.GitService
|
||||
import de.hoennig.werkator.metrics.SystemMetricsCollector
|
||||
import de.hoennig.werkator.repo.RepoContext
|
||||
import de.hoennig.werkator.repo.RepoRegistry
|
||||
import jakarta.servlet.http.HttpServletRequest
|
||||
import org.springframework.beans.factory.ObjectProvider
|
||||
import org.springframework.boot.info.BuildProperties
|
||||
import org.springframework.http.HttpStatus
|
||||
import org.springframework.http.ResponseEntity
|
||||
import org.springframework.stereotype.Controller
|
||||
import org.springframework.ui.Model
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler
|
||||
import org.springframework.web.bind.annotation.GetMapping
|
||||
import org.springframework.web.bind.annotation.PathVariable
|
||||
import org.springframework.web.server.ResponseStatusException
|
||||
@@ -34,19 +35,31 @@ import kotlin.streams.asSequence
|
||||
*/
|
||||
@Controller
|
||||
class UiController(
|
||||
private val repository: BuildResultRepository,
|
||||
private val buildExecutor: BuildExecutor,
|
||||
private val artifactStore: ArtifactStore,
|
||||
private val configLoader: ConfigLoader,
|
||||
private val gitService: GitService,
|
||||
private val metricsCollector: SystemMetricsCollector,
|
||||
private val branchListing: BranchListing,
|
||||
private val branchPermalinks: BranchPermalinks,
|
||||
private val buildProperties: ObjectProvider<BuildProperties>,
|
||||
private val repo: RepoContext,
|
||||
private val registry: RepoRegistry,
|
||||
) {
|
||||
private val workingDir: Path
|
||||
get() = repo.workingDir
|
||||
/**
|
||||
* Every page exists twice, like the API (ADR 0009): repository-scoped under
|
||||
* `/repos/<name>/…` and unscoped, which means the served repository. Pages stay
|
||||
* per repository instead of merging every repository's rows into one table with a
|
||||
* repository column: a row's actions (restart, cancel, delete) need the repository
|
||||
* anyway, branches come from one origin and artifacts from one store — and with
|
||||
* the one repository that most installations have, such a column is pure noise.
|
||||
* What makes the instance one UI is the repository switcher in the navigation.
|
||||
*/
|
||||
private fun repoOf(name: String?): RepoContext =
|
||||
if (name == null) registry.current() else registry.byName(name) ?: throw UnknownRepositoryException(name)
|
||||
|
||||
/** An unknown repository name is a 404 page, not a server error. */
|
||||
@ExceptionHandler(UnknownRepositoryException::class)
|
||||
fun unknownRepository(e: UnknownRepositoryException): ResponseEntity<String> =
|
||||
ResponseEntity.status(HttpStatus.NOT_FOUND).body(e.message)
|
||||
|
||||
/**
|
||||
* Permanent redirects for the legacy script's static page names, so bookmarks
|
||||
@@ -58,11 +71,15 @@ class UiController(
|
||||
setStatusCode(HttpStatus.MOVED_PERMANENTLY)
|
||||
}
|
||||
|
||||
@GetMapping("/")
|
||||
fun latest(model: Model): String {
|
||||
val links = baseModel(model, view = "latest", pageTitle = "Latest Builds")
|
||||
model.addAttribute("rows", repository.latestPerName().map { BuildRowView.from(it, links, permanentUrlOf(it)) })
|
||||
model.addAttribute("apiPath", "/api/builds/latest")
|
||||
@GetMapping("/", "/repos/{repo}")
|
||||
fun latest(
|
||||
@PathVariable(name = "repo", required = false) repoName: String?,
|
||||
model: Model,
|
||||
): String {
|
||||
val repo = repoOf(repoName)
|
||||
val links = baseModel(model, view = "latest", pageTitle = "Latest Builds", repo = repo)
|
||||
model.addAttribute("rows", repo.results.latestPerName().map { BuildRowView.from(it, links, permanentUrlOf(repo, it)) })
|
||||
model.addAttribute("apiPath", apiBase(repo) + "/builds/latest")
|
||||
model.addAttribute("allowRestart", true)
|
||||
model.addAttribute("restartAtOriginHead", false)
|
||||
model.addAttribute("emptyMessage", "No builds recorded yet.")
|
||||
@@ -70,11 +87,15 @@ class UiController(
|
||||
}
|
||||
|
||||
/** The legacy branches view: every origin branch with its latest build or an `unknown` row. */
|
||||
@GetMapping("/branches")
|
||||
fun branches(model: Model): String {
|
||||
val links = baseModel(model, view = "branches", pageTitle = "Branches")
|
||||
model.addAttribute("rows", branchListing.branches(repo).map { BuildRowView.from(it, links) })
|
||||
model.addAttribute("apiPath", "/api/branches")
|
||||
@GetMapping("/branches", "/repos/{repo}/branches")
|
||||
fun branches(
|
||||
@PathVariable(name = "repo", required = false) repoName: String?,
|
||||
model: Model,
|
||||
): String {
|
||||
val repo = repoOf(repoName)
|
||||
val links = baseModel(model, view = "branches", pageTitle = "Branches", repo = repo)
|
||||
model.addAttribute("rows", branchListing.branches(repo, uiBase(repo)).map { BuildRowView.from(it, links) })
|
||||
model.addAttribute("apiPath", apiBase(repo) + "/branches")
|
||||
model.addAttribute("allowRestart", true)
|
||||
// a row here stands for a branch, not for a past run
|
||||
model.addAttribute("restartAtOriginHead", true)
|
||||
@@ -82,11 +103,15 @@ class UiController(
|
||||
return "builds"
|
||||
}
|
||||
|
||||
@GetMapping("/history")
|
||||
fun history(model: Model): String {
|
||||
val links = baseModel(model, view = "history", pageTitle = "Build History")
|
||||
model.addAttribute("rows", repository.history().map { BuildRowView.from(it, links, permanentUrlOf(it)) })
|
||||
model.addAttribute("apiPath", "/api/builds/history")
|
||||
@GetMapping("/history", "/repos/{repo}/history")
|
||||
fun history(
|
||||
@PathVariable(name = "repo", required = false) repoName: String?,
|
||||
model: Model,
|
||||
): String {
|
||||
val repo = repoOf(repoName)
|
||||
val links = baseModel(model, view = "history", pageTitle = "Build History", repo = repo)
|
||||
model.addAttribute("rows", repo.results.history().map { BuildRowView.from(it, links, permanentUrlOf(repo, it)) })
|
||||
model.addAttribute("apiPath", apiBase(repo) + "/builds/history")
|
||||
model.addAttribute("allowRestart", false)
|
||||
model.addAttribute("restartAtOriginHead", false)
|
||||
model.addAttribute("emptyMessage", "No builds archived yet.")
|
||||
@@ -94,17 +119,24 @@ class UiController(
|
||||
}
|
||||
|
||||
/** The permanent branch URL belongs to the build it resolves to — the name's latest green build. */
|
||||
private fun permanentUrlOf(result: BuildResult): String? =
|
||||
if (repository.latestGreenFor(result.name)?.artifactKey == result.artifactKey) {
|
||||
BranchPermalinks.permanentUrl(result.name)
|
||||
private fun permanentUrlOf(
|
||||
repo: RepoContext,
|
||||
result: BuildResult,
|
||||
): String? =
|
||||
if (repo.results.latestGreenFor(result.name)?.artifactKey == result.artifactKey) {
|
||||
BranchPermalinks.permanentUrl(result.name, uiBase(repo))
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
@GetMapping("/current")
|
||||
fun current(model: Model): String {
|
||||
val links = baseModel(model, view = "current", pageTitle = "Current Builds")
|
||||
val results = repository.history()
|
||||
@GetMapping("/current", "/repos/{repo}/current")
|
||||
fun current(
|
||||
@PathVariable(name = "repo", required = false) repoName: String?,
|
||||
model: Model,
|
||||
): String {
|
||||
val repo = repoOf(repoName)
|
||||
val links = baseModel(model, view = "current", pageTitle = "Current Builds", repo = repo)
|
||||
val results = repo.results.history()
|
||||
val currentBuilds =
|
||||
buildExecutor.currentBuilds().filter { it.repo === repo }.map { build ->
|
||||
CurrentBuildView(
|
||||
@@ -130,35 +162,39 @@ class UiController(
|
||||
/** Hand-maintained release notes (templates/releases.html); linked from the version in the footer. */
|
||||
@GetMapping("/releases")
|
||||
fun releases(model: Model): String {
|
||||
baseModel(model, view = "releases", pageTitle = "Release Notes")
|
||||
baseModel(model, view = "releases", pageTitle = "Release Notes", repo = registry.current())
|
||||
return "releases"
|
||||
}
|
||||
|
||||
/** One metrics page for the whole instance — the resources are the instance's, not a repository's. */
|
||||
@GetMapping("/system")
|
||||
fun system(model: Model): String {
|
||||
baseModel(model, view = "system", pageTitle = "System Metrics")
|
||||
baseModel(model, view = "system", pageTitle = "System Metrics", repo = registry.current())
|
||||
model.addAttribute("metrics", SystemMetricsView.from(metricsCollector.snapshot()))
|
||||
return "system"
|
||||
}
|
||||
|
||||
/** Artifact index rendered from the artifact store — legacy pre-generated this page as static HTML. */
|
||||
@GetMapping("/builds/{artifactKey}")
|
||||
@GetMapping("/builds/{artifactKey}", "/repos/{repo}/builds/{artifactKey}")
|
||||
fun artifactIndex(
|
||||
@PathVariable(name = "repo", required = false) repoName: String?,
|
||||
@PathVariable artifactKey: String,
|
||||
model: Model,
|
||||
): String {
|
||||
val result = repository.history().firstOrNull { it.artifactKey == artifactKey }
|
||||
val artifactDir = artifactStore.artifactDir(artifactKey)
|
||||
val repo = repoOf(repoName)
|
||||
val result = repo.results.history().firstOrNull { it.artifactKey == artifactKey }
|
||||
val artifactDir = repo.artifactStore.artifactDir(artifactKey)
|
||||
if (result == null && artifactDir == null) {
|
||||
throw ResponseStatusException(HttpStatus.NOT_FOUND, "no build with artifact key '$artifactKey'")
|
||||
}
|
||||
return artifactIndexView(
|
||||
model,
|
||||
pageTitle = "Build Artifacts",
|
||||
repo = repo,
|
||||
result = result,
|
||||
artifactKey = artifactKey,
|
||||
artifactDir = artifactDir,
|
||||
filesBase = "/artifacts/$artifactKey",
|
||||
filesBase = uiBase(repo) + "/artifacts/$artifactKey",
|
||||
)
|
||||
}
|
||||
|
||||
@@ -167,38 +203,42 @@ class UiController(
|
||||
* stay on the permanent `/branches/…` paths, so every link copied from this page
|
||||
* outlives artifact pruning.
|
||||
*/
|
||||
@GetMapping("/branches/{branchKey}")
|
||||
@GetMapping("/branches/{branchKey}", "/repos/{repo}/branches/{branchKey}")
|
||||
fun latestGreenArtifactIndex(
|
||||
@PathVariable(name = "repo", required = false) repoName: String?,
|
||||
@PathVariable branchKey: String,
|
||||
model: Model,
|
||||
): String {
|
||||
val build = branchPermalinks.latestGreenBuild(branchKey)
|
||||
val repo = repoOf(repoName)
|
||||
val build = branchPermalinks.latestGreenBuild(repo, branchKey)
|
||||
model.addAttribute("permanentBranch", build.branch)
|
||||
model.addAttribute("concreteUrl", "/builds/${build.artifactKey}")
|
||||
model.addAttribute("concreteUrl", uiBase(repo) + "/builds/${build.artifactKey}")
|
||||
return artifactIndexView(
|
||||
model,
|
||||
pageTitle = "Latest Green Build",
|
||||
repo = repo,
|
||||
result = build,
|
||||
artifactKey = build.artifactKey,
|
||||
artifactDir = artifactStore.artifactDir(build.artifactKey),
|
||||
filesBase = "/branches/$branchKey",
|
||||
artifactDir = repo.artifactStore.artifactDir(build.artifactKey),
|
||||
filesBase = uiBase(repo) + "/branches/$branchKey",
|
||||
)
|
||||
}
|
||||
|
||||
private fun artifactIndexView(
|
||||
model: Model,
|
||||
pageTitle: String,
|
||||
repo: RepoContext,
|
||||
result: BuildResult?,
|
||||
artifactKey: String,
|
||||
artifactDir: Path?,
|
||||
filesBase: String,
|
||||
): String {
|
||||
val links = baseModel(model, view = "artifact", pageTitle = pageTitle)
|
||||
val links = baseModel(model, view = "artifact", pageTitle = pageTitle, repo = repo)
|
||||
model.addAttribute("artifactKey", artifactKey)
|
||||
model.addAttribute("filesBase", filesBase)
|
||||
model.addAttribute("result", result?.let { BuildRowView.from(it, links) })
|
||||
model.addAttribute("hasArtifacts", artifactDir != null)
|
||||
model.addAttribute("buildCommand", result?.let { buildCommandOf(it) })
|
||||
model.addAttribute("buildCommand", result?.let { buildCommandOf(repo, it) })
|
||||
model.addAttribute(
|
||||
"logs",
|
||||
artifactDir?.let { logFiles(it, scanForFailure = result != null && result.status != BuildStatus.SUCCESS) }
|
||||
@@ -226,21 +266,41 @@ class UiController(
|
||||
.toList()
|
||||
}
|
||||
|
||||
/**
|
||||
* The prefix every in-page link and API path is built from. It follows the number
|
||||
* of served repositories, not the route the page was reached through: with one
|
||||
* repository the installation keeps its existing URLs (the session-D acceptance
|
||||
* criterion), with several every link names its repository.
|
||||
*/
|
||||
private fun uiBase(repo: RepoContext): String = if (registry.all().size > 1) "/repos/${repo.name}" else ""
|
||||
|
||||
private fun apiBase(repo: RepoContext): String = if (registry.all().size > 1) "/api/repos/${repo.name}" else "/api"
|
||||
|
||||
/** Adds the attributes every page needs and returns the Gitea link helper for row building. */
|
||||
private fun baseModel(
|
||||
model: Model,
|
||||
view: String,
|
||||
pageTitle: String,
|
||||
repo: RepoContext,
|
||||
): GiteaWebLinks {
|
||||
val config = configLoader.load(workingDir)
|
||||
val config = configLoader.load(repo.workingDir)
|
||||
val links = GiteaWebLinks(config.gitea)
|
||||
val repoName =
|
||||
listOf(config.gitea.owner.trim(), config.gitea.repo.trim())
|
||||
.filter { it.isNotEmpty() }
|
||||
.joinToString("/")
|
||||
val served = registry.all()
|
||||
model.addAttribute("view", view)
|
||||
model.addAttribute("pageTitle", pageTitle)
|
||||
model.addAttribute("repoName", repoName)
|
||||
// every in-page link is built from this prefix, so a scoped page stays scoped
|
||||
model.addAttribute("repoBase", uiBase(repo))
|
||||
model.addAttribute("homeUrl", uiBase(repo).ifEmpty { "/" })
|
||||
model.addAttribute("apiBase", apiBase(repo))
|
||||
model.addAttribute("repoKey", repo.name)
|
||||
// the switcher is what makes several repositories one UI; with one there is nothing to switch
|
||||
model.addAttribute("multiRepo", served.size > 1)
|
||||
model.addAttribute("repos", served.map { RepoLinkView(name = it.name, url = "/repos/${it.name}", current = it === repo) })
|
||||
model.addAttribute("version", buildProperties.getIfAvailable()?.version ?: "dev")
|
||||
model.addAttribute("impressumUrl", config.server.impressumUrl.trim())
|
||||
model.addAttribute("giteaRepoUrl", links.repoUrl ?: "")
|
||||
@@ -254,7 +314,11 @@ class UiController(
|
||||
* build of this pool ever ran — the branch and its job usually override it.
|
||||
* The command used by a past run is not persisted, so this is the current answer.
|
||||
*/
|
||||
private fun buildCommandOf(result: BuildResult): String {
|
||||
private fun buildCommandOf(
|
||||
repo: RepoContext,
|
||||
result: BuildResult,
|
||||
): String {
|
||||
val workingDir = repo.workingDir
|
||||
val config =
|
||||
try {
|
||||
configLoader.loadWithBranchLayer(
|
||||
|
||||
@@ -252,3 +252,10 @@ data class SystemMetricsView(
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** One entry of the repository switcher in the navigation (ADR 0009). */
|
||||
data class RepoLinkView(
|
||||
val name: String,
|
||||
val url: String,
|
||||
val current: Boolean,
|
||||
)
|
||||
|
||||
@@ -99,6 +99,17 @@ function metaContent(name) {
|
||||
|
||||
const giteaRepoUrl = metaContent("werkator-gitea-repo-url");
|
||||
|
||||
// Empty with one served repository, `/repos/<name>` with several (ADR 0009). Every
|
||||
// path this script builds itself is prefixed with it, so an action triggered on a
|
||||
// repository's page acts on that repository — the paths rendered into the DOM
|
||||
// (`data-api`, artifact links) already carry it.
|
||||
const repoBase = metaContent("werkator-repo-base") || "";
|
||||
|
||||
/** The API of the repository this page belongs to; `/api` when only one is served. */
|
||||
function apiBase() {
|
||||
return repoBase ? "/api" + repoBase : "/api";
|
||||
}
|
||||
|
||||
// The control token is deliberately NOT embedded in the pages: reading them is
|
||||
// unauthenticated, so anyone could have read it out of the HTML. The operator
|
||||
// pastes it once per browser from `.git/werkator/control-token` on the server;
|
||||
@@ -389,7 +400,7 @@ function renderBuildRow(build, allowRestart, restartAtOriginHead) {
|
||||
const inProgress = build.status === "running" || build.status === "pending";
|
||||
if (build.artifactKey) {
|
||||
const artifactLink = elem("a", "artifact-link", inProgress ? "⏳" : "📄");
|
||||
artifactLink.href = "/builds/" + encodeURIComponent(build.artifactKey);
|
||||
artifactLink.href = repoBase + "/builds/" + encodeURIComponent(build.artifactKey);
|
||||
artifactLink.title = inProgress ? "Open build log — no artifacts yet" : "Open artifacts";
|
||||
artifactsCell.appendChild(artifactLink);
|
||||
}
|
||||
@@ -542,7 +553,7 @@ function initCurrentBuilds() {
|
||||
return;
|
||||
}
|
||||
const offset = logOffsets.get(build.artifactKey) || 0;
|
||||
const url = `/api/builds/current/${encodeURIComponent(build.artifactKey)}/log?offset=${offset}`;
|
||||
const url = `${apiBase()}/builds/current/${encodeURIComponent(build.artifactKey)}/log?offset=${offset}`;
|
||||
const tail = await fetchJson(url);
|
||||
logOffsets.set(build.artifactKey, tail.nextOffset);
|
||||
if (tail.content) {
|
||||
@@ -690,11 +701,11 @@ document.addEventListener("click", async (event) => {
|
||||
try {
|
||||
if (action === "restart") {
|
||||
const atOriginHead = button.dataset.atOriginHead === "true" ? "&atOriginHead=true" : "";
|
||||
await sendAction("/api/builds/restart?branch=" + encodeURIComponent(button.dataset.branch) + atOriginHead, "POST");
|
||||
await sendAction(apiBase() + "/builds/restart?branch=" + encodeURIComponent(button.dataset.branch) + atOriginHead, "POST");
|
||||
} else if (action === "cancel") {
|
||||
await sendAction(`/api/builds/${encodeURIComponent(button.dataset.artifactKey)}/cancel`, "POST");
|
||||
await sendAction(`${apiBase()}/builds/${encodeURIComponent(button.dataset.artifactKey)}/cancel`, "POST");
|
||||
} else if (action === "delete") {
|
||||
await sendAction("/api/builds/" + encodeURIComponent(button.dataset.artifactKey), "DELETE");
|
||||
await sendAction(apiBase() + "/builds/" + encodeURIComponent(button.dataset.artifactKey), "DELETE");
|
||||
}
|
||||
if (refreshNow) {
|
||||
refreshNow();
|
||||
|
||||
@@ -52,13 +52,13 @@
|
||||
<td class="duration-cell" data-label="Duration" th:text="${row.duration}">1:23</td>
|
||||
<td data-label="Artifacts">
|
||||
<a th:if="${row.artifactKey != ''}" class="artifact-link"
|
||||
th:href="'/builds/' + ${row.artifactKey}"
|
||||
th:href="${repoBase} + '/builds/' + ${row.artifactKey}"
|
||||
th:text="${row.inProgress} ? '⏳' : '📄'"
|
||||
th:title="${row.inProgress} ? 'Open build log — no artifacts yet' : 'Open artifacts'">📄</a>
|
||||
<a th:if="${row.latestGreenUrl != null}" class="artifact-link"
|
||||
th:href="${row.latestGreenUrl}"
|
||||
title="Permanent link: artifacts of the latest green build">🔗</a>
|
||||
<a th:if="${row.inProgress}" class="artifact-link" href="/current"
|
||||
<a th:if="${row.inProgress}" class="artifact-link" th:href="${repoBase} + '/current'" href="/current"
|
||||
title="Watch this build live">📡</a>
|
||||
<span th:if="${row.artifactKey == ''}">n/a</span>
|
||||
</td>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<main>
|
||||
<h1 th:replace="~{fragments :: header(${pageTitle})}"></h1>
|
||||
<div th:replace="~{fragments :: nav(${view})}"></div>
|
||||
<div id="current-builds" data-api="/api/builds/current">
|
||||
<div id="current-builds" th:attr="data-api=${apiBase} + '/builds/current'" data-api="/api/builds/current">
|
||||
<p id="no-current" class="empty-panel" th:style="${#lists.isEmpty(currentBuilds)} ? '' : 'display: none'">
|
||||
No build is currently running.
|
||||
</p>
|
||||
|
||||
@@ -7,11 +7,14 @@
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
|
||||
<link rel="stylesheet" href="/werkator.css">
|
||||
<meta name="werkator-gitea-repo-url" th:content="${giteaRepoUrl}">
|
||||
<!-- Empty with one served repository, `/repos/<name>` with several: werkator.js
|
||||
builds its action and artifact URLs from it, so a page stays in its repository. -->
|
||||
<meta name="werkator-repo-base" th:content="${repoBase}">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<h1 th:fragment="header(title)">
|
||||
<a class="title-home" href="/" aria-label="Open latest builds"><img src="/favicon.svg" alt=""></a>
|
||||
<a class="title-home" th:href="${homeUrl}" href="/" aria-label="Open latest builds"><img src="/favicon.svg" alt=""></a>
|
||||
<span class="title-text" th:text="${title}">Latest Builds</span>
|
||||
<span class="repo-name" th:unless="${#strings.isEmpty(repoName)}" th:text="${repoName}">owner/repo</span>
|
||||
</h1>
|
||||
@@ -20,15 +23,23 @@
|
||||
<div class="view-row">
|
||||
<nav class="view-toggle">
|
||||
<span th:if="${view == 'latest'}">Latest</span>
|
||||
<a th:unless="${view == 'latest'}" href="/">Latest</a>
|
||||
<a th:unless="${view == 'latest'}" th:href="${homeUrl}" href="/">Latest</a>
|
||||
<span th:if="${view == 'branches'}">Branches</span>
|
||||
<a th:unless="${view == 'branches'}" href="/branches">Branches</a>
|
||||
<a th:unless="${view == 'branches'}" th:href="${repoBase} + '/branches'" href="/branches">Branches</a>
|
||||
<span th:if="${view == 'history'}">History</span>
|
||||
<a th:unless="${view == 'history'}" href="/history">History</a>
|
||||
<a th:unless="${view == 'history'}" th:href="${repoBase} + '/history'" href="/history">History</a>
|
||||
<span th:if="${view == 'current'}">Current</span>
|
||||
<span th:if="${view == 'system'}">System</span>
|
||||
<a th:unless="${view == 'system'}" href="/system">System</a>
|
||||
</nav>
|
||||
<!-- The repository switcher (ADR 0009): what makes several repositories one UI.
|
||||
With a single served repository there is nothing to switch, and the block is absent. -->
|
||||
<nav class="repo-switch" th:if="${multiRepo}">
|
||||
<th:block th:each="r : ${repos}">
|
||||
<span th:if="${r.current}" th:text="${r.name}" class="repo-current">werkator</span>
|
||||
<a th:unless="${r.current}" th:href="${r.url}" th:text="${r.name}">other</a>
|
||||
</th:block>
|
||||
</nav>
|
||||
<span class="view-row-actions">
|
||||
<span id="live-indicator" class="status status-unknown" title="live-update state">static</span>
|
||||
<button id="reload-button" class="reload-button" type="button" title="Reload view" aria-label="Reload view">⟳</button>
|
||||
|
||||
@@ -4,6 +4,8 @@ import com.ninjasquad.springmockk.MockkBean
|
||||
import de.hoennig.werkator.build.ArtifactStore
|
||||
import de.hoennig.werkator.build.BuildResult
|
||||
import de.hoennig.werkator.build.BuildStatus
|
||||
import de.hoennig.werkator.repo.RepoContext
|
||||
import de.hoennig.werkator.repo.RepoRegistry
|
||||
import io.kotest.core.spec.style.FunSpec
|
||||
import io.mockk.clearMocks
|
||||
import io.mockk.every
|
||||
@@ -32,6 +34,12 @@ class ArtifactFileControllerTest : FunSpec() {
|
||||
@MockkBean
|
||||
lateinit var branchPermalinks: BranchPermalinks
|
||||
|
||||
@MockkBean
|
||||
lateinit var repo: RepoContext
|
||||
|
||||
@MockkBean
|
||||
lateinit var registry: RepoRegistry
|
||||
|
||||
private val artifactDir: Path = Files.createTempDirectory("werkator-artifact-serve-test")
|
||||
|
||||
private val greenBuild =
|
||||
@@ -46,12 +54,18 @@ class ArtifactFileControllerTest : FunSpec() {
|
||||
|
||||
init {
|
||||
beforeEach {
|
||||
clearMocks(artifactStore, branchPermalinks)
|
||||
clearMocks(artifactStore, branchPermalinks, repo, registry)
|
||||
every { repo.name } returns "test"
|
||||
every { repo.artifactStore } returns artifactStore
|
||||
every { registry.current() } returns repo
|
||||
every { registry.all() } returns listOf(repo)
|
||||
every { registry.byName(any()) } returns null
|
||||
every { registry.byName("test") } returns repo
|
||||
every { artifactStore.artifactDir(any()) } returns null
|
||||
every { artifactStore.artifactDir("known-key") } returns artifactDir
|
||||
every { branchPermalinks.latestGreenBuild(any()) } throws
|
||||
every { branchPermalinks.latestGreenBuild(any(), any()) } throws
|
||||
ResponseStatusException(HttpStatus.NOT_FOUND, "no recorded builds")
|
||||
every { branchPermalinks.latestGreenBuild("main") } returns greenBuild
|
||||
every { branchPermalinks.latestGreenBuild(any(), "main") } returns greenBuild
|
||||
}
|
||||
|
||||
test("serves an html artifact with no-cache headers") {
|
||||
@@ -188,7 +202,7 @@ class ArtifactFileControllerTest : FunSpec() {
|
||||
}
|
||||
|
||||
test("permanent URL answers 404 when the green build's artifacts are gone") {
|
||||
every { branchPermalinks.latestGreenBuild("main") } returns greenBuild.copy(artifactKey = "pruned-key")
|
||||
every { branchPermalinks.latestGreenBuild(any(), "main") } returns greenBuild.copy(artifactKey = "pruned-key")
|
||||
|
||||
mockMvc
|
||||
.perform(get("/branches/main/build.log"))
|
||||
|
||||
@@ -4,6 +4,7 @@ import de.hoennig.werkator.build.ArtifactKeys
|
||||
import de.hoennig.werkator.build.BuildResult
|
||||
import de.hoennig.werkator.build.BuildResultRepository
|
||||
import de.hoennig.werkator.build.BuildStatus
|
||||
import de.hoennig.werkator.repo.RepoContext
|
||||
import io.kotest.assertions.throwables.shouldThrow
|
||||
import io.kotest.core.spec.style.FunSpec
|
||||
import io.kotest.matchers.shouldBe
|
||||
@@ -17,7 +18,8 @@ import java.time.Instant
|
||||
|
||||
class BranchPermalinksTest : FunSpec() {
|
||||
private val repository = mockk<BuildResultRepository>()
|
||||
private val permalinks = BranchPermalinks(repository)
|
||||
private val repo = mockk<RepoContext>().also { every { it.results } returns repository }
|
||||
private val permalinks = BranchPermalinks()
|
||||
|
||||
private fun result(
|
||||
branch: String,
|
||||
@@ -36,20 +38,20 @@ class BranchPermalinksTest : FunSpec() {
|
||||
every { repository.latestPerName() } returns listOf(result("feature/x"), result("main"))
|
||||
every { repository.latestGreenFor("feature/x") } returns result("feature/x")
|
||||
|
||||
permalinks.latestGreenBuild("feature_x") shouldBe result("feature/x")
|
||||
permalinks.latestGreenBuild(repo, "feature_x") shouldBe result("feature/x")
|
||||
}
|
||||
|
||||
test("resolves the full branch key with hash suffix") {
|
||||
every { repository.latestPerName() } returns listOf(result("feature/x"))
|
||||
every { repository.latestGreenFor("feature/x") } returns result("feature/x")
|
||||
|
||||
permalinks.latestGreenBuild(ArtifactKeys.branchKey("feature/x")) shouldBe result("feature/x")
|
||||
permalinks.latestGreenBuild(repo, ArtifactKeys.branchKey("feature/x")) shouldBe result("feature/x")
|
||||
}
|
||||
|
||||
test("an unknown branch key answers 404") {
|
||||
every { repository.latestPerName() } returns listOf(result("main"))
|
||||
|
||||
val exception = shouldThrow<ResponseStatusException> { permalinks.latestGreenBuild("gone") }
|
||||
val exception = shouldThrow<ResponseStatusException> { permalinks.latestGreenBuild(repo, "gone") }
|
||||
|
||||
exception.statusCode shouldBe HttpStatus.NOT_FOUND
|
||||
}
|
||||
@@ -58,7 +60,7 @@ class BranchPermalinksTest : FunSpec() {
|
||||
every { repository.latestPerName() } returns listOf(result("main", status = BuildStatus.FAILED))
|
||||
every { repository.latestGreenFor("main") } returns null
|
||||
|
||||
val exception = shouldThrow<ResponseStatusException> { permalinks.latestGreenBuild("main") }
|
||||
val exception = shouldThrow<ResponseStatusException> { permalinks.latestGreenBuild(repo, "main") }
|
||||
|
||||
exception.statusCode shouldBe HttpStatus.NOT_FOUND
|
||||
}
|
||||
@@ -66,7 +68,7 @@ class BranchPermalinksTest : FunSpec() {
|
||||
test("a permanent key matching several branches answers 409 and names the candidates") {
|
||||
every { repository.latestPerName() } returns listOf(result("feature/x"), result("feature_x"))
|
||||
|
||||
val exception = shouldThrow<ResponseStatusException> { permalinks.latestGreenBuild("feature_x") }
|
||||
val exception = shouldThrow<ResponseStatusException> { permalinks.latestGreenBuild(repo, "feature_x") }
|
||||
|
||||
exception.statusCode shouldBe HttpStatus.CONFLICT
|
||||
exception.reason.orEmpty() shouldContain "feature/x"
|
||||
@@ -76,7 +78,7 @@ class BranchPermalinksTest : FunSpec() {
|
||||
every { repository.latestPerName() } returns listOf(result("feature/x"), result("feature_x"))
|
||||
every { repository.latestGreenFor("feature/x") } returns result("feature/x")
|
||||
|
||||
permalinks.latestGreenBuild(ArtifactKeys.branchKey("feature/x")) shouldBe result("feature/x")
|
||||
permalinks.latestGreenBuild(repo, ArtifactKeys.branchKey("feature/x")) shouldBe result("feature/x")
|
||||
}
|
||||
|
||||
test("permanentUrl uses the hash-free branch key") {
|
||||
@@ -89,7 +91,7 @@ class BranchPermalinksTest : FunSpec() {
|
||||
every { repository.latestGreenFor("main@nightly") } returns nightly
|
||||
|
||||
// sanitized like any branch key: the '@' becomes '_' in the URL
|
||||
permalinks.latestGreenBuild("main_nightly") shouldBe nightly
|
||||
permalinks.latestGreenBuild(repo, "main_nightly") shouldBe nightly
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,6 +90,7 @@ class BuildsApiControllerTest : FunSpec() {
|
||||
every { repo.results } returns repository
|
||||
every { repo.artifactStore } returns artifactStore
|
||||
// the unscoped routes mean the served repository; `/api/repos/test/…` names it
|
||||
every { registry.all() } returns listOf(repo)
|
||||
every { registry.current() } returns repo
|
||||
every { registry.byName(any()) } returns null
|
||||
every { registry.byName("test") } returns repo
|
||||
|
||||
@@ -11,6 +11,7 @@ import de.hoennig.werkator.config.WerkatorConfig
|
||||
import de.hoennig.werkator.git.GitService
|
||||
import de.hoennig.werkator.metrics.SystemMetricsCollector
|
||||
import de.hoennig.werkator.repo.RepoContext
|
||||
import de.hoennig.werkator.repo.RepoRegistry
|
||||
import io.kotest.core.spec.style.FunSpec
|
||||
import io.mockk.clearMocks
|
||||
import io.mockk.every
|
||||
@@ -70,6 +71,9 @@ class PermanentBranchRoutesTest : FunSpec() {
|
||||
@MockkBean
|
||||
lateinit var repo: RepoContext
|
||||
|
||||
@MockkBean
|
||||
lateinit var registry: RepoRegistry
|
||||
|
||||
private val artifactDir: Path = Files.createTempDirectory("werkator-permanent-routes-test")
|
||||
|
||||
private val greenBuild =
|
||||
@@ -95,14 +99,22 @@ class PermanentBranchRoutesTest : FunSpec() {
|
||||
branchListing,
|
||||
branchPermalinks,
|
||||
repo,
|
||||
registry,
|
||||
)
|
||||
every { repo.name } returns "test"
|
||||
every { repo.workingDir } returns Paths.get(".")
|
||||
every { repo.results } returns repository
|
||||
every { repo.artifactStore } returns artifactStore
|
||||
every { registry.all() } returns listOf(repo)
|
||||
every { registry.current() } returns repo
|
||||
every { registry.byName(any()) } returns null
|
||||
every { registry.byName("test") } returns repo
|
||||
every { configLoader.load(any()) } returns WerkatorConfig()
|
||||
every { configLoader.loadWithBranchLayer(any(), anyNullable()) } returns WerkatorConfig()
|
||||
every { gitService.showFileAtCommit(any(), any(), any()) } returns null
|
||||
every { controlTokens.token() } returns "test-token"
|
||||
every { branchListing.branches(any()) } returns emptyList()
|
||||
every { branchPermalinks.latestGreenBuild("main") } returns greenBuild
|
||||
every { branchPermalinks.latestGreenBuild(any(), "main") } returns greenBuild
|
||||
every { artifactStore.artifactDir("main-key") } returns artifactDir
|
||||
}
|
||||
|
||||
|
||||
@@ -18,12 +18,14 @@ import de.hoennig.werkator.metrics.MetricAggregate
|
||||
import de.hoennig.werkator.metrics.SystemMetrics
|
||||
import de.hoennig.werkator.metrics.SystemMetricsCollector
|
||||
import de.hoennig.werkator.repo.RepoContext
|
||||
import de.hoennig.werkator.repo.RepoRegistry
|
||||
import io.kotest.core.spec.style.FunSpec
|
||||
import io.kotest.matchers.shouldBe
|
||||
import io.kotest.matchers.string.shouldContain
|
||||
import io.kotest.matchers.string.shouldNotContain
|
||||
import io.mockk.clearMocks
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import org.hamcrest.Matchers.containsString
|
||||
import org.hamcrest.Matchers.not
|
||||
import org.springframework.beans.factory.annotation.Autowired
|
||||
@@ -78,6 +80,9 @@ class UiControllerTest : FunSpec() {
|
||||
@MockkBean
|
||||
lateinit var repo: RepoContext
|
||||
|
||||
@MockkBean
|
||||
lateinit var registry: RepoRegistry
|
||||
|
||||
private val startedAt = Instant.parse("2026-07-07T10:00:00Z")
|
||||
|
||||
private val emptySystemMetrics =
|
||||
@@ -119,8 +124,16 @@ class UiControllerTest : FunSpec() {
|
||||
branchListing,
|
||||
branchPermalinks,
|
||||
repo,
|
||||
registry,
|
||||
)
|
||||
every { repo.name } returns "test"
|
||||
every { repo.workingDir } returns Paths.get(".")
|
||||
every { repo.results } returns repository
|
||||
every { repo.artifactStore } returns artifactStore
|
||||
every { registry.all() } returns listOf(repo)
|
||||
every { registry.current() } returns repo
|
||||
every { registry.byName(any()) } returns null
|
||||
every { registry.byName("test") } returns repo
|
||||
every { configLoader.load(any()) } returns
|
||||
WerkatorConfig(
|
||||
server = ServerConfig(impressumUrl = "https://example.org/imprint"),
|
||||
@@ -144,6 +157,40 @@ class UiControllerTest : FunSpec() {
|
||||
.andExpect(content().string(not(containsString("""href="/current""""))))
|
||||
}
|
||||
|
||||
test("with one served repository the pages keep their existing URLs and show no switcher") {
|
||||
every { repository.latestPerName() } returns listOf(successResult)
|
||||
|
||||
mockMvc
|
||||
.perform(get("/"))
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(content().string(containsString("""href="/branches"""")))
|
||||
// Thymeleaf drops an attribute whose value is empty, and werkator.js falls back to ""
|
||||
.andExpect(content().string(containsString("""<meta name="werkator-repo-base">""")))
|
||||
.andExpect(content().string(not(containsString("""class="repo-switch""""))))
|
||||
}
|
||||
|
||||
test("with several served repositories every link names its repository and the switcher appears") {
|
||||
val other = mockk<RepoContext>()
|
||||
every { other.name } returns "other"
|
||||
every { registry.all() } returns listOf(repo, other)
|
||||
every { repository.latestPerName() } returns listOf(successResult)
|
||||
|
||||
mockMvc
|
||||
.perform(get("/repos/test"))
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(content().string(containsString("""href="/repos/test/branches"""")))
|
||||
.andExpect(content().string(containsString("""data-api="/api/repos/test/builds/latest"""")))
|
||||
.andExpect(content().string(containsString("""<meta name="werkator-repo-base" content="/repos/test">""")))
|
||||
.andExpect(content().string(containsString("""class="repo-switch"""")))
|
||||
.andExpect(content().string(containsString("""href="/repos/other"""")))
|
||||
}
|
||||
|
||||
test("a page of a repository this instance does not serve answers 404") {
|
||||
mockMvc
|
||||
.perform(get("/repos/no-such-repo"))
|
||||
.andExpect(status().isNotFound)
|
||||
}
|
||||
|
||||
test("latest view renders rows with badge, Gitea links, artifact link, actions, and token") {
|
||||
every { repository.latestPerName() } returns listOf(successResult)
|
||||
|
||||
@@ -503,7 +550,7 @@ class UiControllerTest : FunSpec() {
|
||||
Files.writeString(artifactDir.resolve("build.stdout.log"), "out")
|
||||
Files.createDirectories(artifactDir.resolve("reports/tests/test"))
|
||||
Files.writeString(artifactDir.resolve("reports/tests/test/index.html"), "<html></html>")
|
||||
every { branchPermalinks.latestGreenBuild("main") } returns successResult
|
||||
every { branchPermalinks.latestGreenBuild(any(), "main") } returns successResult
|
||||
every { artifactStore.artifactDir("main-abc123-key") } returns artifactDir
|
||||
|
||||
mockMvc
|
||||
@@ -517,7 +564,7 @@ class UiControllerTest : FunSpec() {
|
||||
}
|
||||
|
||||
test("permanent artifact index of a branch without a green build answers 404") {
|
||||
every { branchPermalinks.latestGreenBuild("main") } throws
|
||||
every { branchPermalinks.latestGreenBuild(any(), "main") } throws
|
||||
ResponseStatusException(HttpStatus.NOT_FOUND, "branch 'main' has no successful build")
|
||||
|
||||
mockMvc
|
||||
|
||||
Reference in New Issue
Block a user