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
@@ -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<String, String> =
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.
@@ -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,
@@ -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<BranchDto> =
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
}
}
@@ -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<BuildResultDto> = 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<BranchDto> = branchListing.branches(workingDir)
@GetMapping("/api/builds/history")
fun history(): List<BuildResultDto> = 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<Any> {
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,
@@ -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<BuildProperties>,
) {
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")
@@ -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),
)
}
}
+5 -1
View File
@@ -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; }
+20
View File
@@ -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();
@@ -21,6 +21,8 @@
<nav class="view-toggle">
<span th:if="${view == 'latest'}">Latest</span>
<a th:unless="${view == 'latest'}" href="/">Latest</a>
<span th:if="${view == 'branches'}">Branches</span>
<a th:unless="${view == 'branches'}" href="/branches">Branches</a>
<span th:if="${view == 'history'}">History</span>
<a th:unless="${view == 'history'}" href="/history">History</a>
<span th:if="${view == 'current'}">Current</span>
@@ -30,6 +32,7 @@
</nav>
<span class="view-row-actions">
<span id="live-indicator" class="status status-unknown" title="live-update state">static</span>
<button id="reload-button" class="reload-button" type="button" title="Reload view" aria-label="Reload view"></button>
</span>
</div>