reintroduced legacy Branches view: added /branches endpoint for listing origin branches and their latest builds (or unknown for never-built branches), updated UI with reload button and navigation, and enhanced API and tests

This commit is contained in:
Michael Hoennig
2026-07-07 15:16:01 +02:00
parent f1f5946ac4
commit a659cd6764
14 changed files with 310 additions and 9 deletions
@@ -0,0 +1,64 @@
package de.hoennig.gittally.server
import de.hoennig.gittally.build.BuildResult
import de.hoennig.gittally.build.BuildResultRepository
import de.hoennig.gittally.build.BuildStatus
import de.hoennig.gittally.git.GitService
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe
import io.mockk.every
import io.mockk.mockk
import java.time.Duration
import java.time.Instant
class BranchListingTest : FunSpec() {
private val gitService = mockk<GitService>()
private val repository = mockk<BuildResultRepository>()
private val listing = BranchListing(gitService, repository)
private val mainResult =
BuildResult(
branch = "main",
commit = "0123456789abcdef0123456789abcdef01234567",
status = BuildStatus.SUCCESS,
startedAt = Instant.parse("2026-07-07T10:00:00Z"),
duration = Duration.ofSeconds(83),
artifactKey = "main-abc123-key",
)
init {
test("orders main/master first, then flat names, then hierarchical names") {
every { gitService.originBranchHeads(any()) } returns
mapOf(
"feature/x" to "aaa",
"zz-flat" to "bbb",
"main" to "ccc",
"aa/nested" to "ddd",
"develop" to "eee",
)
every { repository.latestFor(any()) } returns null
listing.branches().map { it.branch } shouldBe
listOf("main", "develop", "zz-flat", "aa/nested", "feature/x")
}
test("joins the latest build and marks never-built branches as unknown with the origin head") {
every { gitService.originBranchHeads(any()) } returns
mapOf("main" to "newer-head", "feature/x" to "fedcba98")
every { repository.latestFor("main") } returns mainResult
every { repository.latestFor("feature/x") } returns null
val branches = listing.branches()
branches[0].branch shouldBe "main"
branches[0].status shouldBe "success"
branches[0].commit shouldBe mainResult.commit
branches[0].artifactKey shouldBe "main-abc123-key"
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 ""
}
}
}
@@ -7,6 +7,7 @@ import de.hoennig.gittally.build.BuildResult
import de.hoennig.gittally.build.BuildResultRepository
import de.hoennig.gittally.build.BuildStatus
import de.hoennig.gittally.build.RunningBuild
import de.hoennig.gittally.git.GitService
import io.kotest.core.spec.style.FunSpec
import io.mockk.clearMocks
import io.mockk.every
@@ -43,6 +44,12 @@ class BuildsApiControllerTest : FunSpec() {
@MockkBean
lateinit var controlTokens: ControlTokenService
@MockkBean
lateinit var gitService: GitService
@MockkBean
lateinit var branchListing: BranchListing
private val startedAt = Instant.parse("2026-07-07T10:00:00Z")
private val successResult =
@@ -67,7 +74,7 @@ class BuildsApiControllerTest : FunSpec() {
init {
beforeEach {
clearMocks(repository, buildExecutor, artifactStore, controlTokens)
clearMocks(repository, buildExecutor, artifactStore, controlTokens, gitService, branchListing)
every { controlTokens.matches(any()) } answers { firstArg<String?>() == "secret" }
}
@@ -147,14 +154,50 @@ class BuildsApiControllerTest : FunSpec() {
verify { buildExecutor.startBuild("feature/topic", successResult.commit) }
}
test("restart of a branch without recorded builds answers 404") {
test("restart of a never-built branch enqueues its origin head commit") {
val liveLogFile = tempDir.resolve("first-build.log")
every { repository.latestFor("fresh") } returns null
every { gitService.originHeadCommit("fresh", any()) } returns successResult.commit
every { buildExecutor.startBuild("fresh", successResult.commit) } returns
runningBuild(liveLogFile).copy(branch = "fresh")
mockMvc
.perform(post("/api/builds/restart").param("branch", "fresh").param("token", "secret"))
.andExpect(status().isAccepted)
.andExpect(jsonPath("$.status").value("pending"))
verify { buildExecutor.startBuild("fresh", successResult.commit) }
}
test("restart of a branch without recorded builds and without origin counterpart answers 404") {
every { repository.latestFor("gone") } returns null
every { gitService.originHeadCommit("gone", any()) } returns null
mockMvc
.perform(post("/api/builds/restart").param("branch", "gone").param("token", "secret"))
.andExpect(status().isNotFound)
}
test("branches answers the branch listing with unknown placeholders for never-built branches") {
every { branchListing.branches(any()) } returns
listOf(
BranchDto.from("main", "ignored-head", successResult),
BranchDto.from("feature/x", "fedcba9876543210fedcba9876543210fedcba98", null),
)
mockMvc
.perform(get("/api/branches"))
.andExpect(status().isOk)
.andExpect(jsonPath("$[0].branch").value("main"))
.andExpect(jsonPath("$[0].status").value("success"))
.andExpect(jsonPath("$[0].commit").value(successResult.commit))
.andExpect(jsonPath("$[1].branch").value("feature/x"))
.andExpect(jsonPath("$[1].status").value("unknown"))
.andExpect(jsonPath("$[1].commit").value("fedcba9876543210fedcba9876543210fedcba98"))
.andExpect(jsonPath("$[1].startedAt").doesNotExist())
.andExpect(jsonPath("$[1].artifactKey").value(""))
}
test("restart with a wrong token answers 403 and does not build") {
mockMvc
.perform(
@@ -55,6 +55,9 @@ class UiControllerTest : FunSpec() {
@MockkBean
lateinit var metricsCollector: SystemMetricsCollector
@MockkBean
lateinit var branchListing: BranchListing
private val startedAt = Instant.parse("2026-07-07T10:00:00Z")
private val emptySystemMetrics =
@@ -85,7 +88,7 @@ class UiControllerTest : FunSpec() {
init {
beforeEach {
clearMocks(repository, buildExecutor, artifactStore, controlTokens, configLoader, metricsCollector)
clearMocks(repository, buildExecutor, artifactStore, controlTokens, configLoader, metricsCollector, branchListing)
every { configLoader.load(any()) } returns
GitTallyConfig(
server = ServerConfig(impressumUrl = "https://example.org/imprint"),
@@ -102,6 +105,7 @@ class UiControllerTest : FunSpec() {
.andExpect(status().isOk)
.andExpect(content().string(containsString("No builds recorded yet.")))
.andExpect(content().string(containsString("""data-api="/api/builds/latest"""")))
.andExpect(content().string(containsString("""id="reload-button"""")))
}
test("latest view renders rows with badge, Gitea links, artifact link, actions, and token") {
@@ -122,6 +126,24 @@ class UiControllerTest : FunSpec() {
.andExpect(content().string(containsString("1:23")))
}
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("feature/x", "fedcba9876543210fedcba9876543210fedcba98", null),
)
mockMvc
.perform(get("/branches"))
.andExpect(status().isOk)
.andExpect(content().string(containsString("status status-success")))
.andExpect(content().string(containsString("status status-unknown")))
.andExpect(content().string(containsString("feature/x")))
.andExpect(content().string(containsString("fedcba987654")))
.andExpect(content().string(containsString("""data-api="/api/branches"""")))
.andExpect(content().string(containsString("""data-action="restart"""")))
}
test("history view renders mixed history without restart actions") {
every { repository.history() } returns
listOf(