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:
@@ -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).
|
`/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.
|
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.
|
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.
|
||||||
|
|||||||
@@ -52,6 +52,17 @@ class GitService(
|
|||||||
.lines()
|
.lines()
|
||||||
.filter { it != "HEAD" }
|
.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,
|
* A branch has new commits when its origin counterpart is ahead of the local branch,
|
||||||
* or when it exists only on origin.
|
* 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. */
|
/** One entry of `GET /api/builds/current`; the log grows while the build runs. */
|
||||||
data class CurrentBuildDto(
|
data class CurrentBuildDto(
|
||||||
val branch: String,
|
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.BuildExecutor
|
||||||
import de.hoennig.gittally.build.BuildResultRepository
|
import de.hoennig.gittally.build.BuildResultRepository
|
||||||
import de.hoennig.gittally.build.BuildStatus
|
import de.hoennig.gittally.build.BuildStatus
|
||||||
|
import de.hoennig.gittally.git.GitService
|
||||||
import org.springframework.http.HttpStatus
|
import org.springframework.http.HttpStatus
|
||||||
import org.springframework.http.ResponseEntity
|
import org.springframework.http.ResponseEntity
|
||||||
import org.springframework.web.bind.annotation.DeleteMapping
|
import org.springframework.web.bind.annotation.DeleteMapping
|
||||||
@@ -17,6 +18,7 @@ import java.nio.ByteBuffer
|
|||||||
import java.nio.channels.FileChannel
|
import java.nio.channels.FileChannel
|
||||||
import java.nio.file.Files
|
import java.nio.file.Files
|
||||||
import java.nio.file.Path
|
import java.nio.file.Path
|
||||||
|
import java.nio.file.Paths
|
||||||
import java.nio.file.StandardOpenOption
|
import java.nio.file.StandardOpenOption
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -30,10 +32,18 @@ class BuildsApiController(
|
|||||||
private val buildExecutor: BuildExecutor,
|
private val buildExecutor: BuildExecutor,
|
||||||
private val artifactStore: ArtifactStore,
|
private val artifactStore: ArtifactStore,
|
||||||
private val controlTokens: ControlTokenService,
|
private val controlTokens: ControlTokenService,
|
||||||
|
private val gitService: GitService,
|
||||||
|
private val branchListing: BranchListing,
|
||||||
) {
|
) {
|
||||||
|
var workingDir: Path = Paths.get(".")
|
||||||
|
|
||||||
@GetMapping("/api/builds/latest")
|
@GetMapping("/api/builds/latest")
|
||||||
fun latest(): List<BuildResultDto> = repository.latestPerBranch().map { BuildResultDto.from(it) }
|
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")
|
@GetMapping("/api/builds/history")
|
||||||
fun history(): List<BuildResultDto> = repository.history().map { BuildResultDto.from(it) }
|
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
|
* The branch is a parameter, not a path variable, because branch names may contain
|
||||||
* slashes (Tomcat rejects encoded slashes in the path by default).
|
* slashes (Tomcat rejects encoded slashes in the path by default).
|
||||||
*/
|
*/
|
||||||
@@ -79,10 +90,11 @@ class BuildsApiController(
|
|||||||
@RequestParam(name = "token", required = false) paramToken: String?,
|
@RequestParam(name = "token", required = false) paramToken: String?,
|
||||||
): ResponseEntity<Any> {
|
): ResponseEntity<Any> {
|
||||||
rejectBadToken(headerToken ?: paramToken)?.let { return it }
|
rejectBadToken(headerToken ?: paramToken)?.let { return it }
|
||||||
val latest =
|
val commit =
|
||||||
repository.latestFor(branch)
|
repository.latestFor(branch)?.commit
|
||||||
?: return notFound("branch '$branch' has no recorded build")
|
?: gitService.originHeadCommit(branch, workingDir)
|
||||||
val running = buildExecutor.startBuild(branch, latest.commit)
|
?: return notFound("branch '$branch' has no recorded build and no origin counterpart")
|
||||||
|
val running = buildExecutor.startBuild(branch, commit)
|
||||||
return ResponseEntity.accepted().body(
|
return ResponseEntity.accepted().body(
|
||||||
BuildResultDto(
|
BuildResultDto(
|
||||||
branch = running.branch,
|
branch = running.branch,
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ class UiController(
|
|||||||
private val controlTokens: ControlTokenService,
|
private val controlTokens: ControlTokenService,
|
||||||
private val configLoader: ConfigLoader,
|
private val configLoader: ConfigLoader,
|
||||||
private val metricsCollector: SystemMetricsCollector,
|
private val metricsCollector: SystemMetricsCollector,
|
||||||
|
private val branchListing: BranchListing,
|
||||||
private val buildProperties: ObjectProvider<BuildProperties>,
|
private val buildProperties: ObjectProvider<BuildProperties>,
|
||||||
) {
|
) {
|
||||||
var workingDir: Path = Paths.get(".")
|
var workingDir: Path = Paths.get(".")
|
||||||
@@ -48,6 +49,17 @@ class UiController(
|
|||||||
return "builds"
|
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")
|
@GetMapping("/history")
|
||||||
fun history(model: Model): String {
|
fun history(model: Model): String {
|
||||||
val links = baseModel(model, view = "history", pageTitle = "Build History")
|
val links = baseModel(model, view = "history", pageTitle = "Build History")
|
||||||
|
|||||||
@@ -96,6 +96,23 @@ data class BuildRowView(
|
|||||||
branchUrl = links.branchUrl(result.branch),
|
branchUrl = links.branchUrl(result.branch),
|
||||||
commitUrl = links.commitUrl(result.commit),
|
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),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -66,7 +66,11 @@ a:hover { text-decoration: underline; }
|
|||||||
|
|
||||||
/* view toggle nav + live indicator */
|
/* view toggle nav + live indicator */
|
||||||
.view-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin: 0 0 18px; }
|
.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 { 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 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; }
|
.view-toggle span { background: var(--link); color: white; }
|
||||||
|
|||||||
@@ -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 ---------------------------------------------------------------
|
// ---- page wiring ---------------------------------------------------------------
|
||||||
|
|
||||||
initBuildsTable();
|
initBuildsTable();
|
||||||
initCurrentBuilds();
|
initCurrentBuilds();
|
||||||
initSystemTable();
|
initSystemTable();
|
||||||
|
initReloadButton();
|
||||||
setInterval(tickRunningDurations, 1000);
|
setInterval(tickRunningDurations, 1000);
|
||||||
tickRunningDurations();
|
tickRunningDurations();
|
||||||
|
|||||||
@@ -21,6 +21,8 @@
|
|||||||
<nav class="view-toggle">
|
<nav class="view-toggle">
|
||||||
<span th:if="${view == 'latest'}">Latest</span>
|
<span th:if="${view == 'latest'}">Latest</span>
|
||||||
<a th:unless="${view == 'latest'}" href="/">Latest</a>
|
<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>
|
<span th:if="${view == 'history'}">History</span>
|
||||||
<a th:unless="${view == 'history'}" href="/history">History</a>
|
<a th:unless="${view == 'history'}" href="/history">History</a>
|
||||||
<span th:if="${view == 'current'}">Current</span>
|
<span th:if="${view == 'current'}">Current</span>
|
||||||
@@ -30,6 +32,7 @@
|
|||||||
</nav>
|
</nav>
|
||||||
<span class="view-row-actions">
|
<span class="view-row-actions">
|
||||||
<span id="live-indicator" class="status status-unknown" title="live-update state">static</span>
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -101,6 +101,18 @@ class GitServiceTest : FunSpec() {
|
|||||||
service.originBranches(fixture.work) shouldContainExactly listOf("main")
|
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") {
|
test("fetchOrigin picks up new origin branches and prunes deleted ones") {
|
||||||
val fixture = Fixture()
|
val fixture = Fixture()
|
||||||
fixture.pushNewSeedBranch("feature/x")
|
fixture.pushNewSeedBranch("feature/x")
|
||||||
|
|||||||
@@ -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.BuildResultRepository
|
||||||
import de.hoennig.gittally.build.BuildStatus
|
import de.hoennig.gittally.build.BuildStatus
|
||||||
import de.hoennig.gittally.build.RunningBuild
|
import de.hoennig.gittally.build.RunningBuild
|
||||||
|
import de.hoennig.gittally.git.GitService
|
||||||
import io.kotest.core.spec.style.FunSpec
|
import io.kotest.core.spec.style.FunSpec
|
||||||
import io.mockk.clearMocks
|
import io.mockk.clearMocks
|
||||||
import io.mockk.every
|
import io.mockk.every
|
||||||
@@ -43,6 +44,12 @@ class BuildsApiControllerTest : FunSpec() {
|
|||||||
@MockkBean
|
@MockkBean
|
||||||
lateinit var controlTokens: ControlTokenService
|
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 startedAt = Instant.parse("2026-07-07T10:00:00Z")
|
||||||
|
|
||||||
private val successResult =
|
private val successResult =
|
||||||
@@ -67,7 +74,7 @@ class BuildsApiControllerTest : FunSpec() {
|
|||||||
|
|
||||||
init {
|
init {
|
||||||
beforeEach {
|
beforeEach {
|
||||||
clearMocks(repository, buildExecutor, artifactStore, controlTokens)
|
clearMocks(repository, buildExecutor, artifactStore, controlTokens, gitService, branchListing)
|
||||||
every { controlTokens.matches(any()) } answers { firstArg<String?>() == "secret" }
|
every { controlTokens.matches(any()) } answers { firstArg<String?>() == "secret" }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,14 +154,50 @@ class BuildsApiControllerTest : FunSpec() {
|
|||||||
verify { buildExecutor.startBuild("feature/topic", successResult.commit) }
|
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 { repository.latestFor("gone") } returns null
|
||||||
|
every { gitService.originHeadCommit("gone", any()) } returns null
|
||||||
|
|
||||||
mockMvc
|
mockMvc
|
||||||
.perform(post("/api/builds/restart").param("branch", "gone").param("token", "secret"))
|
.perform(post("/api/builds/restart").param("branch", "gone").param("token", "secret"))
|
||||||
.andExpect(status().isNotFound)
|
.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") {
|
test("restart with a wrong token answers 403 and does not build") {
|
||||||
mockMvc
|
mockMvc
|
||||||
.perform(
|
.perform(
|
||||||
|
|||||||
@@ -55,6 +55,9 @@ class UiControllerTest : FunSpec() {
|
|||||||
@MockkBean
|
@MockkBean
|
||||||
lateinit var metricsCollector: SystemMetricsCollector
|
lateinit var metricsCollector: SystemMetricsCollector
|
||||||
|
|
||||||
|
@MockkBean
|
||||||
|
lateinit var branchListing: BranchListing
|
||||||
|
|
||||||
private val startedAt = Instant.parse("2026-07-07T10:00:00Z")
|
private val startedAt = Instant.parse("2026-07-07T10:00:00Z")
|
||||||
|
|
||||||
private val emptySystemMetrics =
|
private val emptySystemMetrics =
|
||||||
@@ -85,7 +88,7 @@ class UiControllerTest : FunSpec() {
|
|||||||
|
|
||||||
init {
|
init {
|
||||||
beforeEach {
|
beforeEach {
|
||||||
clearMocks(repository, buildExecutor, artifactStore, controlTokens, configLoader, metricsCollector)
|
clearMocks(repository, buildExecutor, artifactStore, controlTokens, configLoader, metricsCollector, branchListing)
|
||||||
every { configLoader.load(any()) } returns
|
every { configLoader.load(any()) } returns
|
||||||
GitTallyConfig(
|
GitTallyConfig(
|
||||||
server = ServerConfig(impressumUrl = "https://example.org/imprint"),
|
server = ServerConfig(impressumUrl = "https://example.org/imprint"),
|
||||||
@@ -102,6 +105,7 @@ class UiControllerTest : FunSpec() {
|
|||||||
.andExpect(status().isOk)
|
.andExpect(status().isOk)
|
||||||
.andExpect(content().string(containsString("No builds recorded yet.")))
|
.andExpect(content().string(containsString("No builds recorded yet.")))
|
||||||
.andExpect(content().string(containsString("""data-api="/api/builds/latest"""")))
|
.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") {
|
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")))
|
.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") {
|
test("history view renders mixed history without restart actions") {
|
||||||
every { repository.history() } returns
|
every { repository.history() } returns
|
||||||
listOf(
|
listOf(
|
||||||
|
|||||||
Reference in New Issue
Block a user