From bb06c8b731baa5a0aeb4fec49a16c6c5a27a8d3e Mon Sep 17 00:00:00 2001 From: Michael Hoennig Date: Wed, 8 Jul 2026 15:35:16 +0200 Subject: [PATCH] added permanent artifact links for latest green builds: /branches//... serves the latest green build's artifacts (green-only, resolved per request), keepLatestGreen retention protection, permanent artifact-index page, and latestGreenUrl links in the branches view Co-Authored-By: Claude Fable 5 --- docs/configuration.md | 5 + ...-artifact-links-for-latest-green-builds.md | 135 ++++++++++++++++++ .../de/hoennig/gittally/build/ArtifactKeys.kt | 7 + .../gittally/build/BuildResultRepository.kt | 8 +- .../build/FileBuildResultRepository.kt | 18 ++- .../hoennig/gittally/commands/InitCommand.kt | 3 + .../hoennig/gittally/config/GitTallyConfig.kt | 6 + .../de/hoennig/gittally/server/ApiDtos.kt | 5 + .../gittally/server/ArtifactFileController.kt | 82 ++++++++++- .../hoennig/gittally/server/BranchListing.kt | 9 +- .../gittally/server/BranchPermalinks.kt | 45 ++++++ .../hoennig/gittally/server/UiController.kt | 46 +++++- .../de/hoennig/gittally/server/UiViews.kt | 4 +- .../de/hoennig/gittally/watcher/Watcher.kt | 2 +- src/main/resources/static/gittally.js | 9 +- src/main/resources/templates/artifact.html | 9 +- src/main/resources/templates/builds.html | 3 + .../gittally/build/ArtifactKeysTest.kt | 5 + .../build/FileBuildResultRepositoryTest.kt | 48 +++++++ .../server/ArtifactFileControllerTest.kt | 98 ++++++++++++- .../gittally/server/BranchListingTest.kt | 17 +++ .../gittally/server/BranchPermalinksTest.kt | 86 +++++++++++ .../server/PermanentBranchRoutesTest.kt | 118 +++++++++++++++ .../gittally/server/UiControllerTest.kt | 47 +++++- .../hoennig/gittally/watcher/WatcherTest.kt | 23 +++ 25 files changed, 820 insertions(+), 18 deletions(-) create mode 100644 docs/prs/2026-07-08-PR#000-permanent-artifact-links-for-latest-green-builds.md create mode 100644 src/main/kotlin/de/hoennig/gittally/server/BranchPermalinks.kt create mode 100644 src/test/kotlin/de/hoennig/gittally/server/BranchPermalinksTest.kt create mode 100644 src/test/kotlin/de/hoennig/gittally/server/PermanentBranchRoutesTest.kt diff --git a/docs/configuration.md b/docs/configuration.md index 567a97e..f82ce02 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -57,6 +57,11 @@ artifacts: rootDir: "" # number of builds to keep per branch retentionPerBranch: 3 + # Keep each branch's latest green (successful) build even beyond retentionPerBranch. + # This backs the permanent artifact URLs /branches//... — they always serve + # the latest green build of a branch and stay valid while newer builds fail. + # The kept build is still dropped once its branch is deleted from origin. + keepLatestGreen: true # Controls the branch-polling loop. watcher: diff --git a/docs/prs/2026-07-08-PR#000-permanent-artifact-links-for-latest-green-builds.md b/docs/prs/2026-07-08-PR#000-permanent-artifact-links-for-latest-green-builds.md new file mode 100644 index 0000000..23998f9 --- /dev/null +++ b/docs/prs/2026-07-08-PR#000-permanent-artifact-links-for-latest-green-builds.md @@ -0,0 +1,135 @@ +> **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 + +Artifact URLs contain the full build key (branch hash, timestamp, build hash), e.g. `/artifacts/main-81d8471cd4ea-2026-07-08T14_27_21Z-2c6fbf59869f/reports/build/doc/`. +These URLs are cryptic, and they die when the build is pruned by retention. +There is no stable URL to share in READMEs, wikis, or bookmarks that always shows the current artifacts of a branch — especially painful for `main`, which is never deleted. + +## Non-Goals + +- No permanent links to failed builds — permanent links are green-only by design. +- No change to the concrete `/artifacts//…` and `/builds/` URLs; they keep working as before. +- No fallback from "latest green" to "latest build"; a branch that never built successfully has no permanent link (404). + +## The Scenarios + +### Feature: permanent artifact links for the latest green build of a branch + +#### Background + +- The *permanent branch key* is the sanitized branch name without the hash suffix, e.g. `feature_demo` for `feature/demo` (the full branch key with hash suffix is accepted too). +- *Green* means build status `success`. + +#### Scenario#000.01: A permanent URL serves the latest green build's artifacts + +So that links in READMEs, wikis, and bookmarks stay valid across new builds. + +- **Given** a branch `main` with a green build that stored artifacts +- **When** `GET /branches/main/build.stdout.log` is requested +- **Then** the file from the latest green build of `main` is served + - **and** it is served with `no-store` cache headers, because the content behind the URL changes with every new green build. + +##### Verified by + +- [ArtifactFileControllerTest](../../src/test/kotlin/de/hoennig/gittally/server/ArtifactFileControllerTest.kt) +- [BranchPermalinksTest](../../src/test/kotlin/de/hoennig/gittally/server/BranchPermalinksTest.kt) + +#### Scenario#000.02: Directory URLs serve their index page like a static web server + +So that legacy-style report links such as `/branches/main/reports/build/doc/` work. + +- **Given** a stored report directory containing an `index.html` +- **When** the directory URL with trailing slash is requested +- **Then** its `index.html` is served +- **When** the directory URL without trailing slash is requested +- **Then** a redirect to the trailing-slash form is answered, so relative links inside the report resolve correctly. + +##### Verified by + +- [ArtifactFileControllerTest](../../src/test/kotlin/de/hoennig/gittally/server/ArtifactFileControllerTest.kt) + +#### Scenario#000.03: The bare permanent URL renders a permanent artifact index + +So that users can browse the latest green artifacts from one stable bookmark. + +- **Given** a branch with a green build +- **When** `GET /branches/` is requested +- **Then** the artifact index page of the latest green build renders + - **and** all its log and report links stay on the permanent `/branches/…` paths + - **and** a note explains the permanent semantics and links to the concrete build page. + +##### Verified by + +- [UiControllerTest](../../src/test/kotlin/de/hoennig/gittally/server/UiControllerTest.kt) +- [PermanentBranchRoutesTest](../../src/test/kotlin/de/hoennig/gittally/server/PermanentBranchRoutesTest.kt) (the `/branches`, `/branches/`, and `/branches//` routes coexist) + +#### Scenario#000.04: Green-only and unambiguous resolution + +So that a permanent link never points at broken artifacts or the wrong branch. + +- **Given** a branch whose builds all failed +- **When** its permanent URL is requested +- **Then** 404 is answered. +- **Given** two branches whose names sanitize to the same permanent key (e.g. `feature/x` and `feature_x`) +- **When** the ambiguous permanent key is requested +- **Then** 409 is answered naming the candidates + - **and** the full branch key with hash suffix still resolves uniquely. + +##### Verified by + +- [BranchPermalinksTest](../../src/test/kotlin/de/hoennig/gittally/server/BranchPermalinksTest.kt) + +#### Scenario#000.05: The latest green build survives pruning + +So that a permanent link stays valid while newer builds fail, as long as the branch exists. + +- **Given** `artifacts.keepLatestGreen: true` (the default) + - **and** a branch whose latest green build is older than `artifacts.retentionPerBranch` newer failed builds +- **When** the watcher prunes results and artifacts +- **Then** the latest green build and its artifacts are kept + - **and** they are still dropped once the branch is deleted from origin. + +##### Verified by + +- [FileBuildResultRepositoryTest](../../src/test/kotlin/de/hoennig/gittally/build/FileBuildResultRepositoryTest.kt) +- [WatcherTest](../../src/test/kotlin/de/hoennig/gittally/watcher/WatcherTest.kt) + +#### Scenario#000.06: The branches view links the permanent URL + +So that users can discover the permanent link without constructing it by hand. + +- **Given** the branches view (`/branches` and `GET /api/branches`) +- **When** a branch has a green build +- **Then** the API entry carries `latestGreenUrl` + - **and** the Artifacts column shows a 🔗 link to it, next to the 📄 link of the concrete latest build. + +##### Verified by + +- [BranchListingTest](../../src/test/kotlin/de/hoennig/gittally/server/BranchListingTest.kt) +- [UiControllerTest](../../src/test/kotlin/de/hoennig/gittally/server/UiControllerTest.kt) + +## The Solution + +Permanent URLs are resolved dynamically per request instead of via filesystem symlinks, because `BuildResultRepository` already knows the latest green build authoritatively and symlinks would fight the `NOFOLLOW_LINKS` guards in serving and pruning. + +- `ArtifactKeys.permanentBranchKey` is the hash-free sanitized branch name; `BranchPermalinks` resolves it (or the full branch key) to the branch's latest green build, rejecting ambiguous keys with 409. +- `ArtifactFileController` gained `GET /branches/{branchKey}/{*path}`: it serves the resolved build's files with the existing traversal guards, adds directory→`index.html` handling with a trailing-slash redirect, and answers everything `no-store`. +- `UiController` gained `GET /branches/{branchKey}`, reusing the artifact-index view with a parameterized link base (`filesBase`), so the permanent index page only emits permanent links. +- `BuildResultRepository.latestGreenFor` and a `keepLatestGreen` prune flag protect the link target: the newest SUCCESS entry per branch survives retention pruning (config `artifacts.keepLatestGreen`, default `true`), and the artifact store keeps its directory because it prunes by the surviving results. +- `GET /api/branches` entries carry `latestGreenUrl`; the branches view renders it as a 🔗 icon in the Artifacts column (server-rendered Thymeleaf and `gittally.js` alike). + +## Open Questions + +- `artifacts.keepLatestGreen` defaults to `true`; set it to `false` to restore strict retention (the permanent URL then 404s once the green build is pruned). +- Redirecting the bare `/branches//` (trailing slash) to the index page keeps the file route and the UI route disjoint; a direct render was not worth duplicating the page mapping. + +## Additional Changes + +- The concrete artifact index page (`/builds/`) now builds its file links from the shared `filesBase` model attribute; rendered output is unchanged. + +## Follow-up PRs + +- Optionally mention the permanent URL in the Gitea commit status or README badge documentation. diff --git a/src/main/kotlin/de/hoennig/gittally/build/ArtifactKeys.kt b/src/main/kotlin/de/hoennig/gittally/build/ArtifactKeys.kt index fdbd13e..93c9e79 100644 --- a/src/main/kotlin/de/hoennig/gittally/build/ArtifactKeys.kt +++ b/src/main/kotlin/de/hoennig/gittally/build/ArtifactKeys.kt @@ -12,6 +12,13 @@ import java.time.Instant object ArtifactKeys { fun branchKey(branch: String): String = "${sanitize(branch)}-${sha256Prefix(branch)}" + /** + * The hash-free key of the permanent `/branches//…` artifact URLs, e.g. + * `feature/demo` → `feature_demo`. Unlike [branchKey] it is not necessarily + * unique; lookups must reject ambiguous matches. + */ + fun permanentBranchKey(branch: String): String = sanitize(branch) + fun buildKey( branch: String, startedAt: Instant, diff --git a/src/main/kotlin/de/hoennig/gittally/build/BuildResultRepository.kt b/src/main/kotlin/de/hoennig/gittally/build/BuildResultRepository.kt index 814deda..1ab4038 100644 --- a/src/main/kotlin/de/hoennig/gittally/build/BuildResultRepository.kt +++ b/src/main/kotlin/de/hoennig/gittally/build/BuildResultRepository.kt @@ -17,6 +17,9 @@ interface BuildResultRepository { fun latestFor(branch: String): BuildResult? + /** The newest SUCCESS entry of [branch] — the build behind the permanent `/branches/…` links. */ + fun latestGreenFor(branch: String): BuildResult? + /** The newest entry of each branch, newest first. */ fun latestPerBranch(): List @@ -34,10 +37,13 @@ interface BuildResultRepository { /** * Keeps the newest [retentionPerBranch] entries per branch and drops entries of branches - * not contained in [originBranches]. Returns the removed entries. + * not contained in [originBranches]. With [keepLatestGreen], the newest SUCCESS entry of + * each surviving branch is kept even beyond the retention count, so the permanent + * `/branches/…` artifact links stay valid while newer builds fail. Returns the removed entries. */ fun prune( originBranches: Collection, retentionPerBranch: Int, + keepLatestGreen: Boolean = false, ): List } diff --git a/src/main/kotlin/de/hoennig/gittally/build/FileBuildResultRepository.kt b/src/main/kotlin/de/hoennig/gittally/build/FileBuildResultRepository.kt index 55fa485..66657f7 100644 --- a/src/main/kotlin/de/hoennig/gittally/build/FileBuildResultRepository.kt +++ b/src/main/kotlin/de/hoennig/gittally/build/FileBuildResultRepository.kt @@ -70,6 +70,11 @@ class FileBuildResultRepository( return indexOfLatest(results, branch)?.let { results[it] } } + override fun latestGreenFor(branch: String): BuildResult? = + load() + .filter { it.branch == branch && it.status == BuildStatus.SUCCESS } + .maxByOrNull { it.startedAt } + override fun latestPerBranch(): List = load() .groupBy { it.branch } @@ -117,6 +122,7 @@ class FileBuildResultRepository( override fun prune( originBranches: Collection, retentionPerBranch: Int, + keepLatestGreen: Boolean, ): List { synchronized(lock) { val results = load() @@ -127,9 +133,15 @@ class FileBuildResultRepository( .groupBy { it.branch } .values .flatMap { entries -> - entries - .sortedByDescending { it.startedAt } - .take(retentionPerBranch.coerceAtLeast(0)) + val newest = + entries + .sortedByDescending { it.startedAt } + .take(retentionPerBranch.coerceAtLeast(0)) + val latestGreen = + entries + .filter { keepLatestGreen && it.status == BuildStatus.SUCCESS } + .maxByOrNull { it.startedAt } + newest + listOfNotNull(latestGreen) }.toSet() val removed = results.filterNot { it in kept } if (removed.isNotEmpty()) { diff --git a/src/main/kotlin/de/hoennig/gittally/commands/InitCommand.kt b/src/main/kotlin/de/hoennig/gittally/commands/InitCommand.kt index 46e9102..978dd2a 100644 --- a/src/main/kotlin/de/hoennig/gittally/commands/InitCommand.kt +++ b/src/main/kotlin/de/hoennig/gittally/commands/InitCommand.kt @@ -145,6 +145,9 @@ class InitCommand( rootDir: "" # number of builds to keep per branch retentionPerBranch: 3 + # keep each branch's latest green build beyond the retention count, + # so the permanent /branches//... artifact URLs stay valid while newer builds fail + keepLatestGreen: true # Controls the branch-polling loop. watcher: diff --git a/src/main/kotlin/de/hoennig/gittally/config/GitTallyConfig.kt b/src/main/kotlin/de/hoennig/gittally/config/GitTallyConfig.kt index b43cc1c..6297b2e 100644 --- a/src/main/kotlin/de/hoennig/gittally/config/GitTallyConfig.kt +++ b/src/main/kotlin/de/hoennig/gittally/config/GitTallyConfig.kt @@ -38,6 +38,12 @@ data class BuildsConfig( data class ArtifactsConfig( val retentionPerBranch: Int = 3, + /** + * Keep each branch's latest green (SUCCESS) build beyond [retentionPerBranch], + * so the permanent `/branches//…` artifact URLs stay valid while newer + * builds fail; the build is still dropped once its branch is gone from origin. + */ + val keepLatestGreen: Boolean = true, /** * Root directory for stored build artifacts; empty means the platform default * `XDG_STATE_HOME` (or `~/.local/state`) + `/gittally/artifacts/`. diff --git a/src/main/kotlin/de/hoennig/gittally/server/ApiDtos.kt b/src/main/kotlin/de/hoennig/gittally/server/ApiDtos.kt index 84adab9..209c3c3 100644 --- a/src/main/kotlin/de/hoennig/gittally/server/ApiDtos.kt +++ b/src/main/kotlin/de/hoennig/gittally/server/ApiDtos.kt @@ -32,6 +32,8 @@ data class BuildResultDto( /** * One entry of `GET /api/branches`, like a legacy branches-view row: an origin * branch with its latest build, or an `unknown` placeholder when never built. + * [latestGreenUrl] is the permanent artifact URL of the branch's latest green + * build; null while the branch has never built successfully. */ data class BranchDto( val branch: String, @@ -40,12 +42,14 @@ data class BranchDto( val startedAt: Instant?, val durationSeconds: Long?, val artifactKey: String, + val latestGreenUrl: String? = null, ) { companion object { fun from( branch: String, headCommit: String, latest: BuildResult?, + hasGreenBuild: Boolean = false, ) = if (latest == null) { BranchDto( branch = branch, @@ -63,6 +67,7 @@ data class BranchDto( startedAt = latest.startedAt, durationSeconds = latest.duration?.seconds, artifactKey = latest.artifactKey, + latestGreenUrl = if (hasGreenBuild) BranchPermalinks.permanentUrl(branch) else null, ) } } diff --git a/src/main/kotlin/de/hoennig/gittally/server/ArtifactFileController.kt b/src/main/kotlin/de/hoennig/gittally/server/ArtifactFileController.kt index ec4b6d3..b473d17 100644 --- a/src/main/kotlin/de/hoennig/gittally/server/ArtifactFileController.kt +++ b/src/main/kotlin/de/hoennig/gittally/server/ArtifactFileController.kt @@ -1,15 +1,19 @@ package de.hoennig.gittally.server import de.hoennig.gittally.build.ArtifactStore +import jakarta.servlet.http.HttpServletRequest import org.springframework.core.io.FileSystemResource import org.springframework.core.io.Resource import org.springframework.http.HttpHeaders +import org.springframework.http.HttpStatus import org.springframework.http.MediaType import org.springframework.http.MediaTypeFactory import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.RestController +import org.springframework.web.server.ResponseStatusException +import java.net.URI import java.nio.file.Files import java.nio.file.LinkOption import java.nio.file.Path @@ -21,6 +25,7 @@ import java.nio.file.Path @RestController class ArtifactFileController( private val artifactStore: ArtifactStore, + private val branchPermalinks: BranchPermalinks, ) { @GetMapping("/artifacts/{artifactKey}/{*path}") fun serve( @@ -30,17 +35,77 @@ class ArtifactFileController( val artifactDir = artifactStore.artifactDir(artifactKey) ?: return ResponseEntity.notFound().build() - val relativePath = path.removePrefix("/") + val file = + resolveFile(artifactDir, path.removePrefix("/")) + ?: return ResponseEntity.notFound().build() + return respond(file, noStore = file.extension() in NO_CACHE_EXTENSIONS) + } + + /** + * Permanent artifact URLs: serves the file from the branch's latest green build, + * so the URL outlives artifact pruning as long as the branch stays green-buildable. + * Directory paths serve their `index.html` (after a redirect adding the trailing + * 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}") + fun serveLatestGreen( + @PathVariable branchKey: String, + @PathVariable path: String, + request: HttpServletRequest, + ): ResponseEntity { + val build = branchPermalinks.latestGreenBuild(branchKey) + val artifactDir = + artifactStore.artifactDir(build.artifactKey) + ?: throw ResponseStatusException( + HttpStatus.NOT_FOUND, + "the artifacts of build '${build.artifactKey}' are not stored anymore", + ) + val relativePath = path.removePrefix("/").removeSuffix("/") if (relativePath.isBlank()) { - return ResponseEntity.notFound().build() + // the bare permanent URL is the artifact-index page rendered by the UI controller + return redirect(request.requestURI.trimEnd('/')) + } + val target = artifactDir.resolve(relativePath).normalize() + if (target.startsWith(artifactDir) && + Files.isDirectory(target, LinkOption.NOFOLLOW_LINKS) && + Files.isRegularFile(target.resolve(INDEX_FILE), LinkOption.NOFOLLOW_LINKS) + ) { + // relative links inside a report only resolve correctly under a trailing-slash URL + return if (request.requestURI.endsWith("/")) { + respond(target.resolve(INDEX_FILE), noStore = true) + } else { + redirect(request.requestURI + "/") + } + } + val file = + resolveFile(artifactDir, relativePath) + ?: return ResponseEntity.notFound().build() + return respond(file, noStore = true) + } + + /** Resolves [relativePath] inside [artifactDir]; null when it escapes the directory or is no regular file. */ + private fun resolveFile( + artifactDir: Path, + relativePath: String, + ): Path? { + if (relativePath.isBlank()) { + return null } val file = artifactDir.resolve(relativePath).normalize() if (!file.startsWith(artifactDir) || !Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS)) { - return ResponseEntity.notFound().build() + return null } + return file + } + + private fun respond( + file: Path, + noStore: Boolean, + ): ResponseEntity { val headers = HttpHeaders() headers.contentType = mediaType(file) - if (file.extension() in NO_CACHE_EXTENSIONS) { + if (noStore) { headers.cacheControl = "no-store, max-age=0" headers.pragma = "no-cache" headers.expires = 0 @@ -48,6 +113,14 @@ class ArtifactFileController( return ResponseEntity.ok().headers(headers).body(FileSystemResource(file)) } + /** A permanent-URL redirect must never be cached — its target changes with the next green build. */ + private fun redirect(encodedLocation: String): ResponseEntity { + val headers = HttpHeaders() + headers.location = URI.create(encodedLocation) + headers.cacheControl = "no-store, max-age=0" + return ResponseEntity.status(HttpStatus.FOUND).headers(headers).build() + } + private fun mediaType(file: Path): MediaType = when (file.extension()) { "log" -> MediaType(MediaType.TEXT_PLAIN, Charsets.UTF_8) @@ -61,5 +134,6 @@ class ArtifactFileController( companion object { private val NO_CACHE_EXTENSIONS = setOf("html", "json", "log") + private const val INDEX_FILE = "index.html" } } diff --git a/src/main/kotlin/de/hoennig/gittally/server/BranchListing.kt b/src/main/kotlin/de/hoennig/gittally/server/BranchListing.kt index 44cec46..4a50a59 100644 --- a/src/main/kotlin/de/hoennig/gittally/server/BranchListing.kt +++ b/src/main/kotlin/de/hoennig/gittally/server/BranchListing.kt @@ -23,7 +23,14 @@ class BranchListing( .originBranchHeads(workingDir) .entries .sortedWith(compareBy({ sortGroup(it.key) }, { it.key })) - .map { (branch, headCommit) -> BranchDto.from(branch, headCommit, repository.latestFor(branch)) } + .map { (branch, headCommit) -> + BranchDto.from( + branch, + headCommit, + repository.latestFor(branch), + hasGreenBuild = repository.latestGreenFor(branch) != null, + ) + } private fun sortGroup(branch: String): Int = when { diff --git a/src/main/kotlin/de/hoennig/gittally/server/BranchPermalinks.kt b/src/main/kotlin/de/hoennig/gittally/server/BranchPermalinks.kt new file mode 100644 index 0000000..83c3280 --- /dev/null +++ b/src/main/kotlin/de/hoennig/gittally/server/BranchPermalinks.kt @@ -0,0 +1,45 @@ +package de.hoennig.gittally.server + +import de.hoennig.gittally.build.ArtifactKeys +import de.hoennig.gittally.build.BuildResult +import de.hoennig.gittally.build.BuildResultRepository +import org.springframework.http.HttpStatus +import org.springframework.stereotype.Component +import org.springframework.web.server.ResponseStatusException + +/** + * Resolves the permanent `/branches//…` artifact URLs: the key is the + * hash-free [ArtifactKeys.permanentBranchKey] (the full [ArtifactKeys.branchKey] + * works too), and the target is the branch's latest green build. Resolution happens + * per request, so a permanent URL follows every new green build and stays valid as + * long as the branch exists on origin and has ever built successfully — green only, + * a permanent link never points at a failed build's artifacts. + */ +@Component +class BranchPermalinks( + private val repository: BuildResultRepository, +) { + fun latestGreenBuild(branchKey: String): BuildResult { + val branches = + repository + .latestPerBranch() + .map { it.branch } + .filter { branchKey == ArtifactKeys.permanentBranchKey(it) || branchKey == ArtifactKeys.branchKey(it) } + val branch = + when (branches.size) { + 0 -> throw ResponseStatusException(HttpStatus.NOT_FOUND, "no recorded builds for branch key '$branchKey'") + 1 -> branches.single() + else -> throw ResponseStatusException( + HttpStatus.CONFLICT, + "branch key '$branchKey' is ambiguous (${branches.joinToString()}); use the full branch key with hash suffix", + ) + } + return repository.latestGreenFor(branch) + ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "branch '$branch' has no successful build") + } + + companion object { + /** The permanent artifact-index URL of [branch], shown in the branches view. */ + fun permanentUrl(branch: String): String = "/branches/${ArtifactKeys.permanentBranchKey(branch)}" + } +} diff --git a/src/main/kotlin/de/hoennig/gittally/server/UiController.kt b/src/main/kotlin/de/hoennig/gittally/server/UiController.kt index feb467c..94e4a7f 100644 --- a/src/main/kotlin/de/hoennig/gittally/server/UiController.kt +++ b/src/main/kotlin/de/hoennig/gittally/server/UiController.kt @@ -2,6 +2,7 @@ package de.hoennig.gittally.server import de.hoennig.gittally.build.ArtifactStore import de.hoennig.gittally.build.BuildExecutor +import de.hoennig.gittally.build.BuildResult import de.hoennig.gittally.build.BuildResultRepository import de.hoennig.gittally.build.BuildStatus import de.hoennig.gittally.config.ConfigLoader @@ -35,6 +36,7 @@ class UiController( private val configLoader: ConfigLoader, private val metricsCollector: SystemMetricsCollector, private val branchListing: BranchListing, + private val branchPermalinks: BranchPermalinks, private val buildProperties: ObjectProvider, ) { var workingDir: Path = Paths.get(".") @@ -112,8 +114,50 @@ class UiController( if (result == null && artifactDir == null) { throw ResponseStatusException(HttpStatus.NOT_FOUND, "no build with artifact key '$artifactKey'") } - val links = baseModel(model, view = "artifact", pageTitle = "Build Artifacts") + return artifactIndexView( + model, + pageTitle = "Build Artifacts", + result = result, + artifactKey = artifactKey, + artifactDir = artifactDir, + filesBase = "/artifacts/$artifactKey", + ) + } + + /** + * The permanent artifact index of a branch's latest green build; the file links + * stay on the permanent `/branches/…` paths, so every link copied from this page + * outlives artifact pruning. + */ + @GetMapping("/branches/{branchKey}") + fun latestGreenArtifactIndex( + @PathVariable branchKey: String, + model: Model, + ): String { + val build = branchPermalinks.latestGreenBuild(branchKey) + model.addAttribute("permanentBranch", build.branch) + model.addAttribute("concreteUrl", "/builds/${build.artifactKey}") + return artifactIndexView( + model, + pageTitle = "Latest Green Build", + result = build, + artifactKey = build.artifactKey, + artifactDir = artifactStore.artifactDir(build.artifactKey), + filesBase = "/branches/$branchKey", + ) + } + + private fun artifactIndexView( + model: Model, + pageTitle: String, + result: BuildResult?, + artifactKey: String, + artifactDir: Path?, + filesBase: String, + ): String { + val links = baseModel(model, view = "artifact", pageTitle = pageTitle) 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 { branchBuildCommand(it.branch) }) diff --git a/src/main/kotlin/de/hoennig/gittally/server/UiViews.kt b/src/main/kotlin/de/hoennig/gittally/server/UiViews.kt index 7fc1d67..1c19cbf 100644 --- a/src/main/kotlin/de/hoennig/gittally/server/UiViews.kt +++ b/src/main/kotlin/de/hoennig/gittally/server/UiViews.kt @@ -67,7 +67,7 @@ object UiFormats { fun timeOfDay(instant: Instant): String = timeOfDayFormat.format(instant) } -/** One row of the latest/history build tables. */ +/** One row of the latest/history build tables; [latestGreenUrl] only on the branches view. */ data class BuildRowView( val branch: String, val commit: String, @@ -79,6 +79,7 @@ data class BuildRowView( val artifactKey: String, val branchUrl: String?, val commitUrl: String?, + val latestGreenUrl: String? = null, ) { companion object { fun from( @@ -112,6 +113,7 @@ data class BuildRowView( artifactKey = entry.artifactKey, branchUrl = links.branchUrl(entry.branch), commitUrl = links.commitUrl(entry.commit), + latestGreenUrl = entry.latestGreenUrl, ) } } diff --git a/src/main/kotlin/de/hoennig/gittally/watcher/Watcher.kt b/src/main/kotlin/de/hoennig/gittally/watcher/Watcher.kt index c4dcd3e..c83d535 100644 --- a/src/main/kotlin/de/hoennig/gittally/watcher/Watcher.kt +++ b/src/main/kotlin/de/hoennig/gittally/watcher/Watcher.kt @@ -245,7 +245,7 @@ class Watcher( originBranches: List, workingDir: Path, ) { - repository.prune(originBranches, config.artifacts.retentionPerBranch) + repository.prune(originBranches, config.artifacts.retentionPerBranch, config.artifacts.keepLatestGreen) artifactStore.prune(repository.history()) pruneWorktrees(originBranches, workingDir) } diff --git a/src/main/resources/static/gittally.js b/src/main/resources/static/gittally.js index 584c8ce..57edbed 100644 --- a/src/main/resources/static/gittally.js +++ b/src/main/resources/static/gittally.js @@ -227,7 +227,14 @@ function renderBuildRow(build, allowRestart) { artifactLink.href = "/builds/" + encodeURIComponent(build.artifactKey); artifactLink.title = "Open artifacts"; artifactsCell.appendChild(artifactLink); - } else { + } + if (build.latestGreenUrl) { + const permanentLink = elem("a", "artifact-link", "🔗"); + permanentLink.href = build.latestGreenUrl; + permanentLink.title = "Permanent link: artifacts of the latest green build"; + artifactsCell.appendChild(permanentLink); + } + if (!build.artifactKey && !build.latestGreenUrl) { artifactsCell.textContent = "n/a"; } row.appendChild(artifactsCell); diff --git a/src/main/resources/templates/artifact.html b/src/main/resources/templates/artifact.html index aaf1e9c..fc3226a 100644 --- a/src/main/resources/templates/artifact.html +++ b/src/main/resources/templates/artifact.html @@ -6,6 +6,11 @@

+

+ Permanent link: this page always shows the latest green build of branch + main — every link on it stays valid across new builds. + Open this specific build instead. +

Build

  • @@ -45,7 +50,7 @@

    Logs

    @@ -57,7 +62,7 @@

    Build Artifacts

    diff --git a/src/main/resources/templates/builds.html b/src/main/resources/templates/builds.html index faf6b45..4947450 100644 --- a/src/main/resources/templates/builds.html +++ b/src/main/resources/templates/builds.html @@ -53,6 +53,9 @@ 📄 + 🔗 n/a diff --git a/src/test/kotlin/de/hoennig/gittally/build/ArtifactKeysTest.kt b/src/test/kotlin/de/hoennig/gittally/build/ArtifactKeysTest.kt index 3163120..d4c2f93 100644 --- a/src/test/kotlin/de/hoennig/gittally/build/ArtifactKeysTest.kt +++ b/src/test/kotlin/de/hoennig/gittally/build/ArtifactKeysTest.kt @@ -20,6 +20,11 @@ class ArtifactKeysTest : FunSpec() { ArtifactKeys.branchKey("feature/x") shouldNotBe ArtifactKeys.branchKey("feature_x") } + test("permanentBranchKey is the hash-free sanitized branch name") { + ArtifactKeys.permanentBranchKey("feature/x") shouldBe "feature_x" + ArtifactKeys.branchKey("feature/x") shouldContain ArtifactKeys.permanentBranchKey("feature/x") + } + test("buildKey is stable for the same input") { ArtifactKeys.buildKey("main", startedAt) shouldBe ArtifactKeys.buildKey("main", startedAt) } diff --git a/src/test/kotlin/de/hoennig/gittally/build/FileBuildResultRepositoryTest.kt b/src/test/kotlin/de/hoennig/gittally/build/FileBuildResultRepositoryTest.kt index 3c96bd6..9c55bbf 100644 --- a/src/test/kotlin/de/hoennig/gittally/build/FileBuildResultRepositoryTest.kt +++ b/src/test/kotlin/de/hoennig/gittally/build/FileBuildResultRepositoryTest.kt @@ -80,6 +80,25 @@ class FileBuildResultRepositoryTest : FunSpec() { repository.latestFor("main") shouldBe result(branch = "main", startedOffsetSeconds = 60) } + test("latestGreenFor returns the newest SUCCESS entry even when newer builds failed") { + val repository = FileBuildResultRepository(newFile()) + repository.append(result(branch = "main", status = BuildStatus.SUCCESS, startedOffsetSeconds = 0)) + repository.append(result(branch = "main", status = BuildStatus.SUCCESS, startedOffsetSeconds = 60)) + repository.append(result(branch = "main", status = BuildStatus.FAILED, startedOffsetSeconds = 120)) + repository.append(result(branch = "other", status = BuildStatus.SUCCESS, startedOffsetSeconds = 180)) + + repository.latestGreenFor("main") shouldBe + result(branch = "main", status = BuildStatus.SUCCESS, startedOffsetSeconds = 60) + } + + test("latestGreenFor returns null for a branch without a successful build") { + val repository = FileBuildResultRepository(newFile()) + repository.append(result(branch = "main", status = BuildStatus.FAILED)) + + repository.latestGreenFor("main").shouldBeNull() + repository.latestGreenFor("unknown").shouldBeNull() + } + test("latestPerBranch returns one entry per branch, newest first") { val repository = FileBuildResultRepository(newFile()) repository.append(result(branch = "main", startedOffsetSeconds = 0)) @@ -201,6 +220,35 @@ class FileBuildResultRepositoryTest : FunSpec() { ) } + test("prune with keepLatestGreen keeps the newest green build beyond the retention count") { + val repository = FileBuildResultRepository(newFile()) + repository.append(result(branch = "main", status = BuildStatus.SUCCESS, startedOffsetSeconds = 0)) + repository.append(result(branch = "main", status = BuildStatus.FAILED, startedOffsetSeconds = 60)) + repository.append(result(branch = "main", status = BuildStatus.FAILED, startedOffsetSeconds = 120)) + + val removed = + repository.prune(originBranches = listOf("main"), retentionPerBranch = 2, keepLatestGreen = true) + + removed.shouldBeEmpty() + repository.history() shouldContainExactly + listOf( + result(branch = "main", status = BuildStatus.FAILED, startedOffsetSeconds = 120), + result(branch = "main", status = BuildStatus.FAILED, startedOffsetSeconds = 60), + result(branch = "main", status = BuildStatus.SUCCESS, startedOffsetSeconds = 0), + ) + } + + test("prune with keepLatestGreen still drops green builds of branches missing from origin") { + val repository = FileBuildResultRepository(newFile()) + repository.append(result(branch = "gone", status = BuildStatus.SUCCESS)) + + val removed = + repository.prune(originBranches = listOf("main"), retentionPerBranch = 3, keepLatestGreen = true) + + removed shouldContainExactly listOf(result(branch = "gone", status = BuildStatus.SUCCESS)) + repository.history().shouldBeEmpty() + } + test("prune drops entries of branches missing from origin") { val repository = FileBuildResultRepository(newFile()) repository.append(result(branch = "main", startedOffsetSeconds = 0)) diff --git a/src/test/kotlin/de/hoennig/gittally/server/ArtifactFileControllerTest.kt b/src/test/kotlin/de/hoennig/gittally/server/ArtifactFileControllerTest.kt index 9f46148..5bd2686 100644 --- a/src/test/kotlin/de/hoennig/gittally/server/ArtifactFileControllerTest.kt +++ b/src/test/kotlin/de/hoennig/gittally/server/ArtifactFileControllerTest.kt @@ -2,18 +2,24 @@ package de.hoennig.gittally.server import com.ninjasquad.springmockk.MockkBean import de.hoennig.gittally.build.ArtifactStore +import de.hoennig.gittally.build.BuildResult +import de.hoennig.gittally.build.BuildStatus import io.kotest.core.spec.style.FunSpec import io.mockk.clearMocks import io.mockk.every import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest +import org.springframework.http.HttpStatus import org.springframework.test.web.servlet.MockMvc import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get import org.springframework.test.web.servlet.result.MockMvcResultMatchers.content import org.springframework.test.web.servlet.result.MockMvcResultMatchers.header import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import org.springframework.web.server.ResponseStatusException import java.nio.file.Files import java.nio.file.Path +import java.time.Duration +import java.time.Instant @WebMvcTest(ArtifactFileController::class, properties = ["spring.main.web-application-type=servlet"]) class ArtifactFileControllerTest : FunSpec() { @@ -23,13 +29,29 @@ class ArtifactFileControllerTest : FunSpec() { @MockkBean lateinit var artifactStore: ArtifactStore + @MockkBean + lateinit var branchPermalinks: BranchPermalinks + private val artifactDir: Path = Files.createTempDirectory("gittally-artifact-serve-test") + private val greenBuild = + BuildResult( + branch = "main", + commit = "0123456789abcdef", + status = BuildStatus.SUCCESS, + startedAt = Instant.parse("2026-07-07T10:00:00Z"), + duration = Duration.ofSeconds(83), + artifactKey = "known-key", + ) + init { beforeEach { - clearMocks(artifactStore) + clearMocks(artifactStore, branchPermalinks) every { artifactStore.artifactDir(any()) } returns null every { artifactStore.artifactDir("known-key") } returns artifactDir + every { branchPermalinks.latestGreenBuild(any()) } throws + ResponseStatusException(HttpStatus.NOT_FOUND, "no recorded builds") + every { branchPermalinks.latestGreenBuild("main") } returns greenBuild } test("serves an html artifact with no-cache headers") { @@ -85,5 +107,79 @@ class ArtifactFileControllerTest : FunSpec() { Files.deleteIfExists(outside) } } + + test("permanent URL serves the file from the branch's latest green build") { + Files.writeString(artifactDir.resolve("build.log"), "line one") + + mockMvc + .perform(get("/branches/main/build.log")) + .andExpect(status().isOk) + .andExpect(header().string("Cache-Control", "no-store, max-age=0")) + .andExpect(content().string("line one")) + } + + test("permanent URL serves even normally cacheable files with no-store") { + val nested = Files.createDirectories(artifactDir.resolve("reports/tests")) + Files.writeString(nested.resolve("summary.css"), "body {}") + + mockMvc + .perform(get("/branches/main/reports/tests/summary.css")) + .andExpect(status().isOk) + .andExpect(header().string("Cache-Control", "no-store, max-age=0")) + } + + test("permanent directory URL with trailing slash serves the directory's index.html") { + val reportDir = Files.createDirectories(artifactDir.resolve("reports/build/doc")) + Files.writeString(reportDir.resolve("index.html"), "doc") + + mockMvc + .perform(get("/branches/main/reports/build/doc/")) + .andExpect(status().isOk) + .andExpect(header().string("Content-Type", "text/html")) + .andExpect(content().string("doc")) + } + + test("permanent directory URL without trailing slash redirects to the trailing-slash form") { + val reportDir = Files.createDirectories(artifactDir.resolve("reports/build/doc")) + Files.writeString(reportDir.resolve("index.html"), "doc") + + mockMvc + .perform(get("/branches/main/reports/build/doc")) + .andExpect(status().isFound) + .andExpect(header().string("Location", "/branches/main/reports/build/doc/")) + .andExpect(header().string("Cache-Control", "no-store, max-age=0")) + } + + test("permanent URL with a bare trailing slash redirects to the artifact index page") { + mockMvc + .perform(get("/branches/main/")) + .andExpect(status().isFound) + .andExpect(header().string("Location", "/branches/main")) + } + + test("permanent URL of an unknown branch key answers 404") { + mockMvc + .perform(get("/branches/no-such-branch/build.log")) + .andExpect(status().isNotFound) + } + + test("permanent URL answers 404 when the green build's artifacts are gone") { + every { branchPermalinks.latestGreenBuild("main") } returns greenBuild.copy(artifactKey = "pruned-key") + + mockMvc + .perform(get("/branches/main/build.log")) + .andExpect(status().isNotFound) + } + + test("permanent URL rejects path traversal out of the artifact directory") { + val outside = Files.writeString(artifactDir.parent.resolve("outside.txt"), "secret") + try { + mockMvc + .perform(get("/branches/main/../outside.txt")) + .andExpect(status().is4xxClientError) + } finally { + Files.deleteIfExists(outside) + } + } } } diff --git a/src/test/kotlin/de/hoennig/gittally/server/BranchListingTest.kt b/src/test/kotlin/de/hoennig/gittally/server/BranchListingTest.kt index aa9ac5f..a1763bc 100644 --- a/src/test/kotlin/de/hoennig/gittally/server/BranchListingTest.kt +++ b/src/test/kotlin/de/hoennig/gittally/server/BranchListingTest.kt @@ -37,6 +37,7 @@ class BranchListingTest : FunSpec() { "develop" to "eee", ) every { repository.latestFor(any()) } returns null + every { repository.latestGreenFor(any()) } returns null listing.branches().map { it.branch } shouldBe listOf("main", "develop", "zz-flat", "aa/nested", "feature/x") @@ -46,7 +47,9 @@ class BranchListingTest : FunSpec() { every { gitService.originBranchHeads(any()) } returns mapOf("main" to "newer-head", "feature/x" to "fedcba98") every { repository.latestFor("main") } returns mainResult + every { repository.latestGreenFor("main") } returns mainResult every { repository.latestFor("feature/x") } returns null + every { repository.latestGreenFor("feature/x") } returns null val branches = listing.branches() @@ -54,11 +57,25 @@ class BranchListingTest : FunSpec() { branches[0].status shouldBe "success" branches[0].commit shouldBe mainResult.commit branches[0].artifactKey shouldBe "main-abc123-key" + branches[0].latestGreenUrl shouldBe "/branches/main" branches[1].branch shouldBe "feature/x" branches[1].status shouldBe "unknown" branches[1].commit shouldBe "fedcba98" branches[1].startedAt shouldBe null branches[1].artifactKey shouldBe "" + branches[1].latestGreenUrl shouldBe null + } + + test("a failed latest build still links the older green build's permanent URL") { + every { gitService.originBranchHeads(any()) } returns mapOf("feature/x" to "aaa") + every { repository.latestFor("feature/x") } returns + mainResult.copy(branch = "feature/x", status = BuildStatus.FAILED) + every { repository.latestGreenFor("feature/x") } returns mainResult.copy(branch = "feature/x") + + val branches = listing.branches() + + branches[0].status shouldBe "failed" + branches[0].latestGreenUrl shouldBe "/branches/feature_x" } } } diff --git a/src/test/kotlin/de/hoennig/gittally/server/BranchPermalinksTest.kt b/src/test/kotlin/de/hoennig/gittally/server/BranchPermalinksTest.kt new file mode 100644 index 0000000..cd76ef1 --- /dev/null +++ b/src/test/kotlin/de/hoennig/gittally/server/BranchPermalinksTest.kt @@ -0,0 +1,86 @@ +package de.hoennig.gittally.server + +import de.hoennig.gittally.build.ArtifactKeys +import de.hoennig.gittally.build.BuildResult +import de.hoennig.gittally.build.BuildResultRepository +import de.hoennig.gittally.build.BuildStatus +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe +import io.kotest.matchers.string.shouldContain +import io.mockk.every +import io.mockk.mockk +import org.springframework.http.HttpStatus +import org.springframework.web.server.ResponseStatusException +import java.time.Duration +import java.time.Instant + +class BranchPermalinksTest : FunSpec() { + private val repository = mockk() + private val permalinks = BranchPermalinks(repository) + + private fun result( + branch: String, + status: BuildStatus = BuildStatus.SUCCESS, + ) = BuildResult( + branch = branch, + commit = "0123456789abcdef", + status = status, + startedAt = Instant.parse("2026-07-07T10:00:00Z"), + duration = Duration.ofSeconds(83), + artifactKey = "$branch-key", + ) + + init { + test("resolves the hash-free permanent key to the branch's latest green build") { + every { repository.latestPerBranch() } returns listOf(result("feature/x"), result("main")) + every { repository.latestGreenFor("feature/x") } returns result("feature/x") + + permalinks.latestGreenBuild("feature_x") shouldBe result("feature/x") + } + + test("resolves the full branch key with hash suffix") { + every { repository.latestPerBranch() } returns listOf(result("feature/x")) + every { repository.latestGreenFor("feature/x") } returns result("feature/x") + + permalinks.latestGreenBuild(ArtifactKeys.branchKey("feature/x")) shouldBe result("feature/x") + } + + test("an unknown branch key answers 404") { + every { repository.latestPerBranch() } returns listOf(result("main")) + + val exception = shouldThrow { permalinks.latestGreenBuild("gone") } + + exception.statusCode shouldBe HttpStatus.NOT_FOUND + } + + test("a branch without a green build answers 404") { + every { repository.latestPerBranch() } returns listOf(result("main", status = BuildStatus.FAILED)) + every { repository.latestGreenFor("main") } returns null + + val exception = shouldThrow { permalinks.latestGreenBuild("main") } + + exception.statusCode shouldBe HttpStatus.NOT_FOUND + } + + test("a permanent key matching several branches answers 409 and names the candidates") { + every { repository.latestPerBranch() } returns listOf(result("feature/x"), result("feature_x")) + + val exception = shouldThrow { permalinks.latestGreenBuild("feature_x") } + + exception.statusCode shouldBe HttpStatus.CONFLICT + exception.reason.orEmpty() shouldContain "feature/x" + } + + test("with ambiguous permanent keys the full branch key still resolves") { + every { repository.latestPerBranch() } 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") + } + + test("permanentUrl uses the hash-free branch key") { + BranchPermalinks.permanentUrl("feature/x") shouldBe "/branches/feature_x" + } + } +} diff --git a/src/test/kotlin/de/hoennig/gittally/server/PermanentBranchRoutesTest.kt b/src/test/kotlin/de/hoennig/gittally/server/PermanentBranchRoutesTest.kt new file mode 100644 index 0000000..07225cc --- /dev/null +++ b/src/test/kotlin/de/hoennig/gittally/server/PermanentBranchRoutesTest.kt @@ -0,0 +1,118 @@ +package de.hoennig.gittally.server + +import com.ninjasquad.springmockk.MockkBean +import de.hoennig.gittally.build.ArtifactStore +import de.hoennig.gittally.build.BuildExecutor +import de.hoennig.gittally.build.BuildResult +import de.hoennig.gittally.build.BuildResultRepository +import de.hoennig.gittally.build.BuildStatus +import de.hoennig.gittally.config.ConfigLoader +import de.hoennig.gittally.config.GitTallyConfig +import de.hoennig.gittally.metrics.SystemMetricsCollector +import io.kotest.core.spec.style.FunSpec +import io.mockk.clearMocks +import io.mockk.every +import org.hamcrest.Matchers.containsString +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.content +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import java.nio.file.Files +import java.nio.file.Path +import java.time.Duration +import java.time.Instant + +/** + * The three `/branches…` routes live in two controllers; this slice registers both + * and proves the mappings coexist: the exact list page, the permanent index page, + * and the catch-all permanent file route. + */ +@WebMvcTest( + controllers = [UiController::class, ArtifactFileController::class], + properties = ["spring.main.web-application-type=servlet"], +) +class PermanentBranchRoutesTest : FunSpec() { + @Autowired + lateinit var mockMvc: MockMvc + + @MockkBean + lateinit var repository: BuildResultRepository + + @MockkBean + lateinit var buildExecutor: BuildExecutor + + @MockkBean + lateinit var artifactStore: ArtifactStore + + @MockkBean + lateinit var controlTokens: ControlTokenService + + @MockkBean + lateinit var configLoader: ConfigLoader + + @MockkBean + lateinit var metricsCollector: SystemMetricsCollector + + @MockkBean + lateinit var branchListing: BranchListing + + @MockkBean + lateinit var branchPermalinks: BranchPermalinks + + private val artifactDir: Path = Files.createTempDirectory("gittally-permanent-routes-test") + + private val greenBuild = + BuildResult( + branch = "main", + commit = "0123456789abcdef", + status = BuildStatus.SUCCESS, + startedAt = Instant.parse("2026-07-07T10:00:00Z"), + duration = Duration.ofSeconds(83), + artifactKey = "main-key", + ) + + init { + beforeEach { + clearMocks( + repository, + buildExecutor, + artifactStore, + controlTokens, + configLoader, + metricsCollector, + branchListing, + branchPermalinks, + ) + every { configLoader.load(any()) } returns GitTallyConfig() + every { controlTokens.token() } returns "test-token" + every { branchListing.branches(any()) } returns emptyList() + every { branchPermalinks.latestGreenBuild("main") } returns greenBuild + every { artifactStore.artifactDir("main-key") } returns artifactDir + } + + test("/branches still renders the branch list page") { + mockMvc + .perform(get("/branches")) + .andExpect(status().isOk) + .andExpect(content().string(containsString("""data-api="/api/branches""""))) + } + + test("/branches/ renders the permanent artifact index page") { + mockMvc + .perform(get("/branches/main")) + .andExpect(status().isOk) + .andExpect(content().string(containsString("latest green build of branch"))) + } + + test("/branches// serves the artifact file") { + Files.writeString(artifactDir.resolve("build.log"), "line one") + + mockMvc + .perform(get("/branches/main/build.log")) + .andExpect(status().isOk) + .andExpect(content().string("line one")) + } + } +} diff --git a/src/test/kotlin/de/hoennig/gittally/server/UiControllerTest.kt b/src/test/kotlin/de/hoennig/gittally/server/UiControllerTest.kt index db8bf78..6c4fac4 100644 --- a/src/test/kotlin/de/hoennig/gittally/server/UiControllerTest.kt +++ b/src/test/kotlin/de/hoennig/gittally/server/UiControllerTest.kt @@ -21,10 +21,12 @@ import org.hamcrest.Matchers.containsString import org.hamcrest.Matchers.not import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest +import org.springframework.http.HttpStatus import org.springframework.test.web.servlet.MockMvc import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get import org.springframework.test.web.servlet.result.MockMvcResultMatchers.content import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import org.springframework.web.server.ResponseStatusException import java.nio.file.Files import java.nio.file.Path import java.time.Duration @@ -58,6 +60,9 @@ class UiControllerTest : FunSpec() { @MockkBean lateinit var branchListing: BranchListing + @MockkBean + lateinit var branchPermalinks: BranchPermalinks + private val startedAt = Instant.parse("2026-07-07T10:00:00Z") private val emptySystemMetrics = @@ -88,7 +93,16 @@ class UiControllerTest : FunSpec() { init { beforeEach { - clearMocks(repository, buildExecutor, artifactStore, controlTokens, configLoader, metricsCollector, branchListing) + clearMocks( + repository, + buildExecutor, + artifactStore, + controlTokens, + configLoader, + metricsCollector, + branchListing, + branchPermalinks, + ) every { configLoader.load(any()) } returns GitTallyConfig( server = ServerConfig(impressumUrl = "https://example.org/imprint"), @@ -129,7 +143,7 @@ class UiControllerTest : FunSpec() { test("branches view renders built and never-built branches with restart actions") { every { branchListing.branches(any()) } returns listOf( - BranchDto.from("main", "ignored-head", successResult), + BranchDto.from("main", "ignored-head", successResult, hasGreenBuild = true), BranchDto.from("feature/x", "fedcba9876543210fedcba9876543210fedcba98", null), ) @@ -142,6 +156,8 @@ class UiControllerTest : FunSpec() { .andExpect(content().string(containsString("fedcba987654"))) .andExpect(content().string(containsString("""data-api="/api/branches""""))) .andExpect(content().string(containsString("""data-action="restart""""))) + .andExpect(content().string(containsString("""href="/branches/main""""))) + .andExpect(content().string(containsString("Permanent link"))) } test("history view renders mixed history without restart actions") { @@ -227,6 +243,33 @@ class UiControllerTest : FunSpec() { .andExpect(content().string(containsString("No log files are stored for this build"))) } + test("permanent artifact index renders the latest green build with permanent file links") { + val artifactDir = Files.createDirectories(tempDir.resolve("permanent-main-key")) + Files.writeString(artifactDir.resolve("build.stdout.log"), "out") + Files.createDirectories(artifactDir.resolve("reports/tests/test")) + Files.writeString(artifactDir.resolve("reports/tests/test/index.html"), "") + every { branchPermalinks.latestGreenBuild("main") } returns successResult + every { artifactStore.artifactDir("main-abc123-key") } returns artifactDir + + mockMvc + .perform(get("/branches/main")) + .andExpect(status().isOk) + .andExpect(content().string(containsString("latest green build of branch"))) + .andExpect(content().string(containsString("""/branches/main/build.stdout.log" target="_blank""""))) + .andExpect(content().string(containsString("""/branches/main/reports/tests/test/index.html""""))) + .andExpect(content().string(containsString("/builds/main-abc123-key"))) + .andExpect(content().string(not(containsString("/artifacts/main-abc123-key")))) + } + + test("permanent artifact index of a branch without a green build answers 404") { + every { branchPermalinks.latestGreenBuild("main") } throws + ResponseStatusException(HttpStatus.NOT_FOUND, "branch 'main' has no successful build") + + mockMvc + .perform(get("/branches/main")) + .andExpect(status().isNotFound) + } + test("artifact index of an unknown key answers 404") { every { repository.history() } returns emptyList() every { artifactStore.artifactDir("no-such-key") } returns null diff --git a/src/test/kotlin/de/hoennig/gittally/watcher/WatcherTest.kt b/src/test/kotlin/de/hoennig/gittally/watcher/WatcherTest.kt index 2cce0be..43f386f 100644 --- a/src/test/kotlin/de/hoennig/gittally/watcher/WatcherTest.kt +++ b/src/test/kotlin/de/hoennig/gittally/watcher/WatcherTest.kt @@ -8,6 +8,7 @@ import de.hoennig.gittally.build.BuildStatus import de.hoennig.gittally.build.FileBuildResultRepository import de.hoennig.gittally.build.GitWorktreeWorkspaces import de.hoennig.gittally.build.RunningBuild +import de.hoennig.gittally.config.ArtifactsConfig import de.hoennig.gittally.config.AutoBuildConfig import de.hoennig.gittally.config.BranchConfig import de.hoennig.gittally.config.ConfigLoader @@ -379,6 +380,28 @@ class WatcherTest : FunSpec() { verify { harness.gitService.worktreePrune(any()) } } + test("poll keeps the latest green build beyond retention unless keepLatestGreen is disabled") { + val keeping = Harness(GitTallyConfig(artifacts = ArtifactsConfig(retentionPerBranch = 1))) + keeping.seed("main", BuildStatus.SUCCESS, commit = "commit-1") + keeping.seed("main", BuildStatus.FAILED, commit = "commit-2") + every { keeping.gitService.originBranches(any()) } returns listOf("main") + + keeping.watcher.poll(keeping.workingDir) + + keeping.repository.history().map { it.status } shouldContainExactly + listOf(BuildStatus.FAILED, BuildStatus.SUCCESS) + + val dropping = + Harness(GitTallyConfig(artifacts = ArtifactsConfig(retentionPerBranch = 1, keepLatestGreen = false))) + dropping.seed("main", BuildStatus.SUCCESS, commit = "commit-1") + dropping.seed("main", BuildStatus.FAILED, commit = "commit-2") + every { dropping.gitService.originBranches(any()) } returns listOf("main") + + dropping.watcher.poll(dropping.workingDir) + + dropping.repository.history().map { it.status } shouldContainExactly listOf(BuildStatus.FAILED) + } + test("worktrees of queued or running builds are never pruned") { val harness = Harness() harness.seed("busy", BuildStatus.RUNNING, commit = "commit-1")