From a659cd6764ed73e7948309ec0cb662f65058da0f Mon Sep 17 00:00:00 2001 From: Michael Hoennig Date: Tue, 7 Jul 2026 15:16:01 +0200 Subject: [PATCH] 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 --- docs/plan/08-web-ui.md | 8 +++ .../de/hoennig/gittally/git/GitService.kt | 11 ++++ .../de/hoennig/gittally/server/ApiDtos.kt | 39 +++++++++++ .../hoennig/gittally/server/BranchListing.kt | 34 ++++++++++ .../gittally/server/BuildsApiController.kt | 22 +++++-- .../hoennig/gittally/server/UiController.kt | 12 ++++ .../de/hoennig/gittally/server/UiViews.kt | 17 +++++ src/main/resources/static/gittally.css | 6 +- src/main/resources/static/gittally.js | 20 ++++++ src/main/resources/templates/fragments.html | 3 + .../de/hoennig/gittally/git/GitServiceTest.kt | 12 ++++ .../gittally/server/BranchListingTest.kt | 64 +++++++++++++++++++ .../server/BuildsApiControllerTest.kt | 47 +++++++++++++- .../gittally/server/UiControllerTest.kt | 24 ++++++- 14 files changed, 310 insertions(+), 9 deletions(-) create mode 100644 src/main/kotlin/de/hoennig/gittally/server/BranchListing.kt create mode 100644 src/test/kotlin/de/hoennig/gittally/server/BranchListingTest.kt diff --git a/docs/plan/08-web-ui.md b/docs/plan/08-web-ui.md index a7abd99..33ff3c7 100644 --- a/docs/plan/08-web-ui.md +++ b/docs/plan/08-web-ui.md @@ -72,3 +72,11 @@ After `git push`, the open Latest tab showed the new build without reload and it `/current` showed the build card with streaming live log, ticking duration, and cancel button; on completion the card flipped to `finished` with a working link to the artifact page (status badge, build command, three log links, `reports/demo/index.html` served with no-cache headers). Killing the server flipped the indicator to a red `error` badge (fetch failure in the tooltip) and dimmed the stale table — zero spinners (verified through a TCP proxy so the tab outlived the process); restarting the server brought `live` back. The 375px viewport stacked rows as labeled cards, and the History view correctly offers delete but no restart. + +Addendum (2026-07-07, after step 12): the legacy Branches view had not been ported; it was added later on request. +`/branches` (nav: Latest | Branches | History | Current | System) lists every branch with its latest build, or an `unknown` row when never built — main/master first, then flat names, then hierarchical names, like legacy. +Deviation: the listing enumerates origin branches instead of legacy's local branches, because the new watcher's branch universe is origin (local refs never move and origin-only branches do get built). +`POST /api/builds/restart` falls back to the branch's origin head when it has no recorded build, so the Branches view can trigger first builds like legacy. + +Addendum (2026-07-07): the legacy per-page reload button (`⟳`, top right) was also re-added on request, next to the live indicator. +On polling pages it triggers an immediate data refresh via the page's poller; pages without a poller (artifact index) reload fully. diff --git a/src/main/kotlin/de/hoennig/gittally/git/GitService.kt b/src/main/kotlin/de/hoennig/gittally/git/GitService.kt index 0a0ff2c..899a6a4 100644 --- a/src/main/kotlin/de/hoennig/gittally/git/GitService.kt +++ b/src/main/kotlin/de/hoennig/gittally/git/GitService.kt @@ -52,6 +52,17 @@ class GitService( .lines() .filter { it != "HEAD" } + /** All origin branches with their head commit, in one git call; refnames cannot contain spaces. */ + fun originBranchHeads(workingDir: Path = Paths.get(".")): Map = + runner + .runOrThrow( + listOf("git", "for-each-ref", "--format=%(refname:strip=3) %(objectname)", "refs/remotes/origin"), + workingDir, + ).lines() + .map { it.substringBeforeLast(' ') to it.substringAfterLast(' ') } + .filter { (branch, _) -> branch != "HEAD" } + .toMap() + /** * A branch has new commits when its origin counterpart is ahead of the local branch, * or when it exists only on origin. diff --git a/src/main/kotlin/de/hoennig/gittally/server/ApiDtos.kt b/src/main/kotlin/de/hoennig/gittally/server/ApiDtos.kt index 95b875a..84adab9 100644 --- a/src/main/kotlin/de/hoennig/gittally/server/ApiDtos.kt +++ b/src/main/kotlin/de/hoennig/gittally/server/ApiDtos.kt @@ -29,6 +29,45 @@ 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. + */ +data class BranchDto( + val branch: String, + val commit: String, + val status: String, + val startedAt: Instant?, + val durationSeconds: Long?, + val artifactKey: String, +) { + companion object { + fun from( + branch: String, + headCommit: String, + latest: BuildResult?, + ) = if (latest == null) { + BranchDto( + branch = branch, + commit = headCommit, + status = CommitStatusDto.UNKNOWN_STATUS, + startedAt = null, + durationSeconds = null, + artifactKey = "", + ) + } else { + BranchDto( + branch = branch, + commit = latest.commit, + status = latest.status.jsonName, + startedAt = latest.startedAt, + durationSeconds = latest.duration?.seconds, + artifactKey = latest.artifactKey, + ) + } + } +} + /** One entry of `GET /api/builds/current`; the log grows while the build runs. */ data class CurrentBuildDto( val branch: String, diff --git a/src/main/kotlin/de/hoennig/gittally/server/BranchListing.kt b/src/main/kotlin/de/hoennig/gittally/server/BranchListing.kt new file mode 100644 index 0000000..44cec46 --- /dev/null +++ b/src/main/kotlin/de/hoennig/gittally/server/BranchListing.kt @@ -0,0 +1,34 @@ +package de.hoennig.gittally.server + +import de.hoennig.gittally.build.BuildResultRepository +import de.hoennig.gittally.git.GitService +import org.springframework.stereotype.Component +import java.nio.file.Path +import java.nio.file.Paths + +/** + * The branches-view data, shared by the JSON API and the server-rendered page: + * every origin branch joined with its latest build (or an `unknown` placeholder + * when never built), ordered like the legacy branches view — main/master first, + * then flat names, then hierarchical names, alphabetical within each group. + * Legacy listed local branches; the new watcher's branch universe is origin. + */ +@Component +class BranchListing( + private val gitService: GitService, + private val repository: BuildResultRepository, +) { + fun branches(workingDir: Path = Paths.get(".")): List = + gitService + .originBranchHeads(workingDir) + .entries + .sortedWith(compareBy({ sortGroup(it.key) }, { it.key })) + .map { (branch, headCommit) -> BranchDto.from(branch, headCommit, repository.latestFor(branch)) } + + private fun sortGroup(branch: String): Int = + when { + branch == "main" || branch == "master" -> 0 + '/' !in branch -> 1 + else -> 2 + } +} diff --git a/src/main/kotlin/de/hoennig/gittally/server/BuildsApiController.kt b/src/main/kotlin/de/hoennig/gittally/server/BuildsApiController.kt index 150e053..491983d 100644 --- a/src/main/kotlin/de/hoennig/gittally/server/BuildsApiController.kt +++ b/src/main/kotlin/de/hoennig/gittally/server/BuildsApiController.kt @@ -4,6 +4,7 @@ import de.hoennig.gittally.build.ArtifactStore import de.hoennig.gittally.build.BuildExecutor import de.hoennig.gittally.build.BuildResultRepository import de.hoennig.gittally.build.BuildStatus +import de.hoennig.gittally.git.GitService import org.springframework.http.HttpStatus import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.DeleteMapping @@ -17,6 +18,7 @@ import java.nio.ByteBuffer import java.nio.channels.FileChannel import java.nio.file.Files import java.nio.file.Path +import java.nio.file.Paths import java.nio.file.StandardOpenOption /** @@ -30,10 +32,18 @@ class BuildsApiController( private val buildExecutor: BuildExecutor, private val artifactStore: ArtifactStore, private val controlTokens: ControlTokenService, + private val gitService: GitService, + private val branchListing: BranchListing, ) { + var workingDir: Path = Paths.get(".") + @GetMapping("/api/builds/latest") fun latest(): List = repository.latestPerBranch().map { BuildResultDto.from(it) } + /** The legacy branches view: every origin branch with its latest build or `unknown`. */ + @GetMapping("/api/branches") + fun branches(): List = branchListing.branches(workingDir) + @GetMapping("/api/builds/history") fun history(): List = repository.history().map { BuildResultDto.from(it) } @@ -68,7 +78,8 @@ class BuildsApiController( } /** - * Re-enqueues the branch's last recorded commit, like the legacy `/control/restart`. + * Re-enqueues the branch's last recorded commit — or its origin head for a branch + * never built, so the branches view can trigger first builds like legacy. * The branch is a parameter, not a path variable, because branch names may contain * slashes (Tomcat rejects encoded slashes in the path by default). */ @@ -79,10 +90,11 @@ class BuildsApiController( @RequestParam(name = "token", required = false) paramToken: String?, ): ResponseEntity { rejectBadToken(headerToken ?: paramToken)?.let { return it } - val latest = - repository.latestFor(branch) - ?: return notFound("branch '$branch' has no recorded build") - val running = buildExecutor.startBuild(branch, latest.commit) + val commit = + repository.latestFor(branch)?.commit + ?: gitService.originHeadCommit(branch, workingDir) + ?: return notFound("branch '$branch' has no recorded build and no origin counterpart") + val running = buildExecutor.startBuild(branch, commit) return ResponseEntity.accepted().body( BuildResultDto( branch = running.branch, diff --git a/src/main/kotlin/de/hoennig/gittally/server/UiController.kt b/src/main/kotlin/de/hoennig/gittally/server/UiController.kt index 499d578..feb467c 100644 --- a/src/main/kotlin/de/hoennig/gittally/server/UiController.kt +++ b/src/main/kotlin/de/hoennig/gittally/server/UiController.kt @@ -34,6 +34,7 @@ class UiController( private val controlTokens: ControlTokenService, private val configLoader: ConfigLoader, private val metricsCollector: SystemMetricsCollector, + private val branchListing: BranchListing, private val buildProperties: ObjectProvider, ) { var workingDir: Path = Paths.get(".") @@ -48,6 +49,17 @@ class UiController( return "builds" } + /** The legacy branches view: every origin branch with its latest build or an `unknown` row. */ + @GetMapping("/branches") + fun branches(model: Model): String { + val links = baseModel(model, view = "branches", pageTitle = "Branches") + model.addAttribute("rows", branchListing.branches(workingDir).map { BuildRowView.from(it, links) }) + model.addAttribute("apiPath", "/api/branches") + model.addAttribute("allowRestart", true) + model.addAttribute("emptyMessage", "No branches found on origin.") + return "builds" + } + @GetMapping("/history") fun history(model: Model): String { val links = baseModel(model, view = "history", pageTitle = "Build History") diff --git a/src/main/kotlin/de/hoennig/gittally/server/UiViews.kt b/src/main/kotlin/de/hoennig/gittally/server/UiViews.kt index 4ddbd33..7fc1d67 100644 --- a/src/main/kotlin/de/hoennig/gittally/server/UiViews.kt +++ b/src/main/kotlin/de/hoennig/gittally/server/UiViews.kt @@ -96,6 +96,23 @@ data class BuildRowView( branchUrl = links.branchUrl(result.branch), commitUrl = links.commitUrl(result.commit), ) + + /** A branches-view row; never-built branches have no timestamps and no artifact. */ + fun from( + entry: BranchDto, + links: GiteaWebLinks, + ) = BuildRowView( + branch = entry.branch, + commit = entry.commit, + commitAbbrev = entry.commit.take(12), + status = entry.status, + startedAtIso = entry.startedAt?.toString() ?: "", + startedAt = entry.startedAt?.let { UiFormats.timestamp(it) } ?: "", + duration = UiFormats.duration(entry.durationSeconds?.let { Duration.ofSeconds(it) }), + artifactKey = entry.artifactKey, + branchUrl = links.branchUrl(entry.branch), + commitUrl = links.commitUrl(entry.commit), + ) } } diff --git a/src/main/resources/static/gittally.css b/src/main/resources/static/gittally.css index 3c1164b..e2348cf 100644 --- a/src/main/resources/static/gittally.css +++ b/src/main/resources/static/gittally.css @@ -66,7 +66,11 @@ a:hover { text-decoration: underline; } /* view toggle nav + live indicator */ .view-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin: 0 0 18px; } -.view-row-actions { margin-left: auto; } +.view-row-actions { margin-left: auto; display: inline-flex; align-items: center; gap: 8px; } +.reload-button { appearance: none; display: inline-flex; align-items: center; justify-content: center; width: 30px; height: 30px; border: 1px solid var(--border); border-radius: 6px; background: var(--panel); color: var(--link); font: 18px/1 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; cursor: pointer; } +.reload-button:hover { background: color-mix(in srgb, var(--link) 8%, transparent); } +.reload-button.is-reloading { animation: reload-spin 0.6s ease-out; } +@keyframes reload-spin { to { transform: rotate(360deg); } } .view-toggle { display: inline-flex; gap: 0; border: 1px solid var(--border); border-radius: 8px; overflow: hidden; background: var(--panel); } .view-toggle a, .view-toggle span { display: inline-flex; min-width: 88px; justify-content: center; padding: 7px 12px; font-weight: 700; } .view-toggle span { background: var(--link); color: white; } diff --git a/src/main/resources/static/gittally.js b/src/main/resources/static/gittally.js index b6fde1b..584c8ce 100644 --- a/src/main/resources/static/gittally.js +++ b/src/main/resources/static/gittally.js @@ -482,10 +482,30 @@ document.addEventListener("click", async (event) => { } }); +// ---- reload button ------------------------------------------------------------- + +/** Refreshes via the page's poller; pages without one (e.g. artifact index) reload fully. */ +function initReloadButton() { + const button = document.getElementById("reload-button"); + if (!button) { + return; + } + button.addEventListener("animationend", () => button.classList.remove("is-reloading")); + button.addEventListener("click", () => { + button.classList.add("is-reloading"); + if (refreshNow) { + refreshNow(); + } else { + window.location.reload(); + } + }); +} + // ---- page wiring --------------------------------------------------------------- initBuildsTable(); initCurrentBuilds(); initSystemTable(); +initReloadButton(); setInterval(tickRunningDurations, 1000); tickRunningDurations(); diff --git a/src/main/resources/templates/fragments.html b/src/main/resources/templates/fragments.html index e442c71..38849d5 100644 --- a/src/main/resources/templates/fragments.html +++ b/src/main/resources/templates/fragments.html @@ -21,6 +21,8 @@ static + diff --git a/src/test/kotlin/de/hoennig/gittally/git/GitServiceTest.kt b/src/test/kotlin/de/hoennig/gittally/git/GitServiceTest.kt index 53338f1..ab09ba2 100644 --- a/src/test/kotlin/de/hoennig/gittally/git/GitServiceTest.kt +++ b/src/test/kotlin/de/hoennig/gittally/git/GitServiceTest.kt @@ -101,6 +101,18 @@ class GitServiceTest : FunSpec() { service.originBranches(fixture.work) shouldContainExactly listOf("main") } + test("originBranchHeads maps every origin branch to its head commit, without HEAD") { + val fixture = Fixture() + fixture.pushNewSeedBranch("feature/x") + service.fetchOrigin(fixture.work) + + val heads = service.originBranchHeads(fixture.work) + + heads.keys shouldBe setOf("feature/x", "main") + heads["main"] shouldBe fixture.git(fixture.work, "rev-parse", "refs/remotes/origin/main").stdout.trim() + heads["feature/x"] shouldBe fixture.git(fixture.work, "rev-parse", "refs/remotes/origin/feature/x").stdout.trim() + } + test("fetchOrigin picks up new origin branches and prunes deleted ones") { val fixture = Fixture() fixture.pushNewSeedBranch("feature/x") diff --git a/src/test/kotlin/de/hoennig/gittally/server/BranchListingTest.kt b/src/test/kotlin/de/hoennig/gittally/server/BranchListingTest.kt new file mode 100644 index 0000000..aa9ac5f --- /dev/null +++ b/src/test/kotlin/de/hoennig/gittally/server/BranchListingTest.kt @@ -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() + private val repository = mockk() + 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 "" + } + } +} diff --git a/src/test/kotlin/de/hoennig/gittally/server/BuildsApiControllerTest.kt b/src/test/kotlin/de/hoennig/gittally/server/BuildsApiControllerTest.kt index a03e666..461964e 100644 --- a/src/test/kotlin/de/hoennig/gittally/server/BuildsApiControllerTest.kt +++ b/src/test/kotlin/de/hoennig/gittally/server/BuildsApiControllerTest.kt @@ -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() == "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( diff --git a/src/test/kotlin/de/hoennig/gittally/server/UiControllerTest.kt b/src/test/kotlin/de/hoennig/gittally/server/UiControllerTest.kt index 68cb42b..fefce70 100644 --- a/src/test/kotlin/de/hoennig/gittally/server/UiControllerTest.kt +++ b/src/test/kotlin/de/hoennig/gittally/server/UiControllerTest.kt @@ -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(