Let auto-build slots run their own build command under their own name
A branches.<name>.autoBuild.times entry is now either a plain HH:MM string or an object with time, its own buildCommand, and a name, so a nightly slot can run a fuller check than the on-commit builds. The watcher passes the slot's command and name to the executor, persisted in the build result — UI restarts, gittally retry, and the startup recovery repeat a build with the command and name it originally ran under. A named slot (e.g. master@nightly) gets its own pool: repository grouping, retention count, branches-view row (sorted after its branch), latest status, and permanent latest-green artifact link are keyed by the build name, while origin lookups, gone-branch pruning, worktrees, and Gitea links/statuses stay keyed by the real branch. Without a name, slot builds share the branch's pool as before. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
f292badac1
commit
8aee3190ea
@@ -27,6 +27,10 @@ class BranchListingTest : FunSpec() {
|
||||
)
|
||||
|
||||
init {
|
||||
beforeEach {
|
||||
every { repository.latestPerName() } returns emptyList()
|
||||
}
|
||||
|
||||
test("orders main/master first, then flat names, then hierarchical names") {
|
||||
every { gitService.originBranchHeads(any()) } returns
|
||||
mapOf(
|
||||
@@ -69,14 +73,36 @@ class BranchListingTest : FunSpec() {
|
||||
test("a failed latest build carries no permanent URL — it belongs to the older green build") {
|
||||
every { gitService.originBranchHeads(any()) } returns mapOf("feature/x" to "aaa")
|
||||
every { repository.latestFor("feature/x") } returns
|
||||
mainResult.copy(branch = "feature/x", status = BuildStatus.FAILED, artifactKey = "failed-key")
|
||||
mainResult.copy(branch = "feature/x", name = "feature/x", status = BuildStatus.FAILED, artifactKey = "failed-key")
|
||||
every { repository.latestGreenFor("feature/x") } returns
|
||||
mainResult.copy(branch = "feature/x", artifactKey = "green-key")
|
||||
mainResult.copy(branch = "feature/x", name = "feature/x", artifactKey = "green-key")
|
||||
|
||||
val branches = listing.branches()
|
||||
|
||||
branches[0].status shouldBe "failed"
|
||||
branches[0].latestGreenUrl shouldBe null
|
||||
}
|
||||
|
||||
test("a named slot pool gets its own row right after its branch") {
|
||||
val nightly =
|
||||
mainResult.copy(name = "main@nightly", status = BuildStatus.FAILED, artifactKey = "nightly-key")
|
||||
every { gitService.originBranchHeads(any()) } returns mapOf("main" to "head", "develop" to "d")
|
||||
every { repository.latestFor("main") } returns mainResult
|
||||
every { repository.latestFor("develop") } returns null
|
||||
every { repository.latestGreenFor("main") } returns mainResult
|
||||
every { repository.latestGreenFor("main@nightly") } returns null
|
||||
every { repository.latestGreenFor("develop") } returns null
|
||||
every { repository.latestPerName() } returns listOf(mainResult, nightly)
|
||||
|
||||
val rows = listing.branches()
|
||||
|
||||
rows.map { it.name } shouldBe listOf("main", "main@nightly", "develop")
|
||||
rows[1].branch shouldBe "main"
|
||||
rows[1].status shouldBe "failed"
|
||||
rows[1].artifactKey shouldBe "nightly-key"
|
||||
// the branch row keeps its own status and permanent link, untouched by the nightly
|
||||
rows[0].status shouldBe "success"
|
||||
rows[0].latestGreenUrl shouldBe "/branches/main"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,21 +33,21 @@ class BranchPermalinksTest : FunSpec() {
|
||||
|
||||
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.latestPerName() } 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.latestPerName() } 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"))
|
||||
every { repository.latestPerName() } returns listOf(result("main"))
|
||||
|
||||
val exception = shouldThrow<ResponseStatusException> { permalinks.latestGreenBuild("gone") }
|
||||
|
||||
@@ -55,7 +55,7 @@ class BranchPermalinksTest : FunSpec() {
|
||||
}
|
||||
|
||||
test("a branch without a green build answers 404") {
|
||||
every { repository.latestPerBranch() } returns listOf(result("main", status = BuildStatus.FAILED))
|
||||
every { repository.latestPerName() } returns listOf(result("main", status = BuildStatus.FAILED))
|
||||
every { repository.latestGreenFor("main") } returns null
|
||||
|
||||
val exception = shouldThrow<ResponseStatusException> { permalinks.latestGreenBuild("main") }
|
||||
@@ -64,7 +64,7 @@ class BranchPermalinksTest : FunSpec() {
|
||||
}
|
||||
|
||||
test("a permanent key matching several branches answers 409 and names the candidates") {
|
||||
every { repository.latestPerBranch() } returns listOf(result("feature/x"), result("feature_x"))
|
||||
every { repository.latestPerName() } returns listOf(result("feature/x"), result("feature_x"))
|
||||
|
||||
val exception = shouldThrow<ResponseStatusException> { permalinks.latestGreenBuild("feature_x") }
|
||||
|
||||
@@ -73,7 +73,7 @@ class BranchPermalinksTest : FunSpec() {
|
||||
}
|
||||
|
||||
test("with ambiguous permanent keys the full branch key still resolves") {
|
||||
every { repository.latestPerBranch() } returns listOf(result("feature/x"), result("feature_x"))
|
||||
every { repository.latestPerName() } 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")
|
||||
@@ -82,5 +82,14 @@ class BranchPermalinksTest : FunSpec() {
|
||||
test("permanentUrl uses the hash-free branch key") {
|
||||
BranchPermalinks.permanentUrl("feature/x") shouldBe "/branches/feature_x"
|
||||
}
|
||||
|
||||
test("resolves a named slot pool to its own latest green build") {
|
||||
val nightly = result("main").copy(name = "main@nightly", artifactKey = "nightly-key")
|
||||
every { repository.latestPerName() } returns listOf(result("main"), nightly)
|
||||
every { repository.latestGreenFor("main@nightly") } returns nightly
|
||||
|
||||
// sanitized like any branch key: the '@' becomes '_' in the URL
|
||||
permalinks.latestGreenBuild("main_nightly") shouldBe nightly
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,7 +80,7 @@ class BuildsApiControllerTest : FunSpec() {
|
||||
}
|
||||
|
||||
test("latest answers one entry per branch with lowercase status and duration in seconds") {
|
||||
every { repository.latestPerBranch() } returns listOf(successResult)
|
||||
every { repository.latestPerName() } returns listOf(successResult)
|
||||
|
||||
mockMvc
|
||||
.perform(get("/api/builds/latest"))
|
||||
@@ -151,9 +151,9 @@ class BuildsApiControllerTest : FunSpec() {
|
||||
|
||||
test("restart enqueues the branch's last recorded commit, also for branch names with slashes") {
|
||||
val liveLogFile = tempDir.resolve("restart.log")
|
||||
every { repository.latestFor("feature/topic") } returns successResult.copy(branch = "feature/topic")
|
||||
every { repository.latestFor("feature/topic") } returns successResult.copy(branch = "feature/topic", name = "feature/topic")
|
||||
every { buildExecutor.startBuild("feature/topic", successResult.commit) } returns
|
||||
runningBuild(liveLogFile).copy(branch = "feature/topic")
|
||||
runningBuild(liveLogFile).copy(branch = "feature/topic", name = "feature/topic")
|
||||
|
||||
mockMvc
|
||||
.perform(
|
||||
@@ -167,12 +167,58 @@ class BuildsApiControllerTest : FunSpec() {
|
||||
verify { buildExecutor.startBuild("feature/topic", successResult.commit) }
|
||||
}
|
||||
|
||||
test("restart of an auto-slot build repeats its recorded build command") {
|
||||
val liveLogFile = tempDir.resolve("auto-restart.log")
|
||||
every { repository.latestFor("main") } returns successResult.copy(buildCommandOverride = "./gradlew fullCheck")
|
||||
every { buildExecutor.startBuild("main", successResult.commit, buildCommandOverride = "./gradlew fullCheck") } returns
|
||||
runningBuild(liveLogFile).copy(buildCommandOverride = "./gradlew fullCheck")
|
||||
|
||||
mockMvc
|
||||
.perform(post("/api/builds/restart").param("branch", "main").header(BuildsApiController.TOKEN_HEADER, "secret"))
|
||||
.andExpect(status().isAccepted)
|
||||
.andExpect(jsonPath("$.status").value("pending"))
|
||||
|
||||
// so a restarted nightly build repeats its slot's command, not the regular one
|
||||
verify { buildExecutor.startBuild("main", successResult.commit, buildCommandOverride = "./gradlew fullCheck") }
|
||||
}
|
||||
|
||||
test("restart of a named slot build re-runs under its name on its real branch") {
|
||||
val liveLogFile = tempDir.resolve("named-restart.log")
|
||||
every { repository.latestFor("main@nightly") } returns
|
||||
successResult.copy(name = "main@nightly", buildCommandOverride = "./gradlew fullCheck")
|
||||
every {
|
||||
buildExecutor.startBuild(
|
||||
"main",
|
||||
successResult.commit,
|
||||
buildCommandOverride = "./gradlew fullCheck",
|
||||
name = "main@nightly",
|
||||
)
|
||||
} returns runningBuild(liveLogFile).copy(name = "main@nightly")
|
||||
|
||||
mockMvc
|
||||
.perform(
|
||||
post("/api/builds/restart")
|
||||
.param("branch", "main@nightly")
|
||||
.header(BuildsApiController.TOKEN_HEADER, "secret"),
|
||||
).andExpect(status().isAccepted)
|
||||
.andExpect(jsonPath("$.name").value("main@nightly"))
|
||||
|
||||
verify {
|
||||
buildExecutor.startBuild(
|
||||
"main",
|
||||
successResult.commit,
|
||||
buildCommandOverride = "./gradlew fullCheck",
|
||||
name = "main@nightly",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
runningBuild(liveLogFile).copy(branch = "fresh", name = "fresh")
|
||||
|
||||
mockMvc
|
||||
.perform(post("/api/builds/restart").param("branch", "fresh").header(BuildsApiController.TOKEN_HEADER, "secret"))
|
||||
|
||||
@@ -117,7 +117,7 @@ class UiControllerTest : FunSpec() {
|
||||
}
|
||||
|
||||
test("latest view renders the empty state, and the nav no longer offers the current view") {
|
||||
every { repository.latestPerBranch() } returns emptyList()
|
||||
every { repository.latestPerName() } returns emptyList()
|
||||
|
||||
mockMvc
|
||||
.perform(get("/"))
|
||||
@@ -129,7 +129,7 @@ class UiControllerTest : FunSpec() {
|
||||
}
|
||||
|
||||
test("latest view renders rows with badge, Gitea links, artifact link, actions, and token") {
|
||||
every { repository.latestPerBranch() } returns listOf(successResult)
|
||||
every { repository.latestPerName() } returns listOf(successResult)
|
||||
|
||||
mockMvc
|
||||
.perform(get("/"))
|
||||
@@ -188,9 +188,15 @@ class UiControllerTest : FunSpec() {
|
||||
test("history view renders mixed history without restart actions") {
|
||||
every { repository.history() } returns
|
||||
listOf(
|
||||
successResult.copy(branch = "main", status = BuildStatus.RUNNING, duration = null, artifactKey = "run-key"),
|
||||
successResult.copy(
|
||||
branch = "main",
|
||||
name = "main",
|
||||
status = BuildStatus.RUNNING,
|
||||
duration = null,
|
||||
artifactKey = "run-key",
|
||||
),
|
||||
successResult,
|
||||
successResult.copy(branch = "feature/x", status = BuildStatus.FAILED, artifactKey = "failed-key"),
|
||||
successResult.copy(branch = "feature/x", name = "feature/x", status = BuildStatus.FAILED, artifactKey = "failed-key"),
|
||||
)
|
||||
|
||||
mockMvc
|
||||
@@ -479,7 +485,7 @@ class UiControllerTest : FunSpec() {
|
||||
|
||||
test("branch names with HTML metacharacters render escaped") {
|
||||
val nasty = "feat/<script>alert('x')</script>"
|
||||
every { repository.latestPerBranch() } returns listOf(successResult.copy(branch = nasty))
|
||||
every { repository.latestPerName() } returns listOf(successResult.copy(branch = nasty, name = nasty))
|
||||
|
||||
mockMvc
|
||||
.perform(get("/"))
|
||||
|
||||
Reference in New Issue
Block a user