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:
Michael Hoennig
2026-07-08 15:35:16 +02:00
co-authored by Claude Fable 5
parent 83e70e73c3
commit bb06c8b731
25 changed files with 820 additions and 18 deletions
@@ -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