added permanent artifact links for latest green builds: /branches/<branch-key>/... 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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
83e70e73c3
commit
bb06c8b731
@@ -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/<key>/…` 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,
|
||||
|
||||
@@ -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<BuildResult>
|
||||
|
||||
@@ -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<String>,
|
||||
retentionPerBranch: Int,
|
||||
keepLatestGreen: Boolean = false,
|
||||
): List<BuildResult>
|
||||
}
|
||||
|
||||
@@ -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<BuildResult> =
|
||||
load()
|
||||
.groupBy { it.branch }
|
||||
@@ -117,6 +122,7 @@ class FileBuildResultRepository(
|
||||
override fun prune(
|
||||
originBranches: Collection<String>,
|
||||
retentionPerBranch: Int,
|
||||
keepLatestGreen: Boolean,
|
||||
): List<BuildResult> {
|
||||
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()) {
|
||||
|
||||
@@ -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/<branch-key>/... artifact URLs stay valid while newer builds fail
|
||||
keepLatestGreen: true
|
||||
|
||||
# Controls the branch-polling loop.
|
||||
watcher:
|
||||
|
||||
@@ -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/<branch-key>/…` 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/<repo-key>`.
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Resource> {
|
||||
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<Resource> {
|
||||
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<Resource> {
|
||||
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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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/<branch-key>/…` 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)}"
|
||||
}
|
||||
}
|
||||
@@ -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<BuildProperties>,
|
||||
) {
|
||||
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) })
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -245,7 +245,7 @@ class Watcher(
|
||||
originBranches: List<String>,
|
||||
workingDir: Path,
|
||||
) {
|
||||
repository.prune(originBranches, config.artifacts.retentionPerBranch)
|
||||
repository.prune(originBranches, config.artifacts.retentionPerBranch, config.artifacts.keepLatestGreen)
|
||||
artifactStore.prune(repository.history())
|
||||
pruneWorktrees(originBranches, workingDir)
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -6,6 +6,11 @@
|
||||
<h1 th:replace="~{fragments :: header(${pageTitle})}"></h1>
|
||||
<div th:replace="~{fragments :: nav(${view})}"></div>
|
||||
<article class="panel">
|
||||
<p th:if="${permanentBranch != null}" class="muted">
|
||||
Permanent link: this page always shows the latest green build of branch
|
||||
<b th:text="${permanentBranch}">main</b> — every link on it stays valid across new builds.
|
||||
<a th:href="${concreteUrl}">Open this specific build</a> instead.
|
||||
</p>
|
||||
<h2>Build</h2>
|
||||
<ul th:if="${result != null}" class="build-facts">
|
||||
<li>
|
||||
@@ -45,7 +50,7 @@
|
||||
<h2>Logs</h2>
|
||||
<ul th:if="${!#lists.isEmpty(logs)}">
|
||||
<li th:each="log : ${logs}">
|
||||
<a th:href="'/artifacts/' + ${artifactKey} + '/' + ${log}" target="_blank"
|
||||
<a th:href="${filesBase} + '/' + ${log}" target="_blank"
|
||||
rel="noopener noreferrer" th:text="${log}">build.log</a>
|
||||
</li>
|
||||
</ul>
|
||||
@@ -57,7 +62,7 @@
|
||||
<h2>Build Artifacts</h2>
|
||||
<ul th:if="${!#lists.isEmpty(reportIndexes)}">
|
||||
<li th:each="report : ${reportIndexes}">
|
||||
<a th:href="'/artifacts/' + ${artifactKey} + '/reports/' + ${report}" target="_blank"
|
||||
<a th:href="${filesBase} + '/reports/' + ${report}" target="_blank"
|
||||
rel="noopener noreferrer" th:text="'reports/' + ${report}">reports/tests/index.html</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
@@ -53,6 +53,9 @@
|
||||
<td data-label="Artifacts">
|
||||
<a th:if="${row.artifactKey != ''}" class="artifact-link"
|
||||
th:href="'/builds/' + ${row.artifactKey}" title="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>
|
||||
<span th:if="${row.artifactKey == ''}">n/a</span>
|
||||
</td>
|
||||
<td class="actions-cell">
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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"), "<html>doc</html>")
|
||||
|
||||
mockMvc
|
||||
.perform(get("/branches/main/reports/build/doc/"))
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(header().string("Content-Type", "text/html"))
|
||||
.andExpect(content().string("<html>doc</html>"))
|
||||
}
|
||||
|
||||
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"), "<html>doc</html>")
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<BuildResultRepository>()
|
||||
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<ResponseStatusException> { 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<ResponseStatusException> { 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<ResponseStatusException> { 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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/<key> 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/<key>/<path> 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"))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"), "<html></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
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user