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:
mhoennig
2026-08-28 19:11:35 +02:00
co-authored by Claude Fable 5
parent f292badac1
commit 8aee3190ea
35 changed files with 702 additions and 158 deletions
+1 -1
View File
@@ -54,7 +54,7 @@ Three places must stay in sync when config keys change: the `GitTallyConfig` dat
## Build Execution ## Build Execution
`BuildExecutor` runs builds asynchronously: up to `builds.maxConcurrent` branches concurrently (default 1), but never more than one build per branch at a time. Each branch builds in its own reusable git worktree at `.git/gittally/worktrees/<branchKey>` (`BranchWorkspaces`), checked out detached at the requested commit — the primary checkout is never used for builds. Status transitions are persisted via `BuildResultRepository` (JSON file under `.git/gittally/`), published to Gitea non-fatally, and emitted as `BuildStatusChangedEvent`s. Cancellation addresses a build by artifact key and terminates the whole process tree. Future code (watcher, server, UI) must not assume a single running build. `BuildExecutor` runs builds asynchronously: up to `builds.maxConcurrent` branches concurrently (default 1), but never more than one build per branch at a time. Each branch builds in its own reusable git worktree at `.git/gittally/worktrees/<branchKey>` (`BranchWorkspaces`), checked out detached at the requested commit — the primary checkout is never used for builds. Status transitions are persisted via `BuildResultRepository` (JSON file under `.git/gittally/`), published to Gitea non-fatally, and emitted as `BuildStatusChangedEvent`s. An auto-build slot (`autoBuild.times` entry) may carry its own `buildCommand` and `name`; the watcher passes them to `startBuild` as `buildCommandOverride` and `name`, persisted in the build result — UI restart and startup recovery pass the recorded values on, so a build always repeats with the command and name it originally ran under. Both are resolved watcher-side from the repo install/project config; the worktree layer cannot change them. `BuildResult.name` (default: the branch) keys everything display- and retention-side — repository grouping (`latestPerName`), retention pools, branches-view rows, permanent latest-green links — while `BuildResult.branch` keys everything git-side: origin lookups, gone-from-origin pruning, worktrees (a named slot builds in its branch's worktree, serialized with the branch's other builds), and Gitea links/statuses. Cancellation addresses a build by artifact key and terminates the whole process tree. Future code (watcher, server, UI) must not assume a single running build.
On context close (e.g. systemd SIGTERM), a `ContextClosedEvent` listener in `BuildExecutor` terminates the process trees of all executing builds and waits (bounded) until their results are persisted as INTERRUPTED — a shutdown is never recorded as FAILED. Builds still queued stay PENDING and start no process. Both are re-enqueued by the watcher's startup recovery; INTERRUPTED therefore publishes as Gitea state `pending`, not `failure` (`GiteaStateMapping`). On context close (e.g. systemd SIGTERM), a `ContextClosedEvent` listener in `BuildExecutor` terminates the process trees of all executing builds and waits (bounded) until their results are persisted as INTERRUPTED — a shutdown is never recorded as FAILED. Builds still queued stay PENDING and start no process. Both are re-enqueued by the watcher's startup recovery; INTERRUPTED therefore publishes as Gitea state `pending`, not `failure` (`GiteaStateMapping`).
+37 -5
View File
@@ -17,7 +17,7 @@ The repo install config (`.git/gittally/.gittally.yml`) wins on any key present
When a branch builds, its build config is resolved with an extra layer: the `.gittally.yml` When a branch builds, its build config is resolved with an extra layer: the `.gittally.yml`
committed on the branch being built (read from its build worktree) overrides the two layers committed on the branch being built (read from its build worktree) overrides the two layers
above, giving the precedence **worktree > repo install > project**. So a branch can change its above, giving the precedence **worktree > repo install > project**. So a branch can change its
own `buildCommand`, `cleanCommand`, `artifactDirs`, log file names, `autoBuild`, and own `buildCommand`, `cleanCommand`, `artifactDirs`, log file names, and
`docker.image`/`dockerfile`/`context`/`env`. `docker.image`/`dockerfile`/`context`/`env`.
This layer applies **only** to the build itself. A pinned set is always taken from the repo This layer applies **only** to the build itself. A pinned set is always taken from the repo
@@ -27,9 +27,9 @@ install/project config and can never be set from the worktree:
- the container sandbox policy: `docker.enabled` and `docker.network`. - the container sandbox policy: `docker.enabled` and `docker.network`.
This keeps a branch from disabling its own build container, changing its network mode, or This keeps a branch from disabling its own build container, changing its network mode, or
reaching credentials. Watcher decisions that happen before a build exists — `autoBuild` reaching credentials. Watcher decisions that happen before a build exists — the whole
scheduling and the `requirePullRequest` gate — are still read from the repo install/project `autoBuild` section (schedule and slot commands) and the `requirePullRequest` gate — are
config, because there is no worktree at that point. read from the repo install/project config, because there is no worktree at that point.
## Inspect the Effective Config ## Inspect the Effective Config
@@ -147,7 +147,13 @@ branches:
requirePullRequest: false requirePullRequest: false
autoBuild: autoBuild:
enabled: false # whether to rebuild on schedule enabled: false # whether to rebuild on schedule
times: ["01:00"] # UTC times HH:MM for scheduled builds # UTC times HH:MM for scheduled builds. An entry may carry its own build
# command, so a nightly slot can run a fuller check than the on-commit
# builds, and a name recording its builds in a separate pool (see notes below):
# - time: "01:00"
# buildCommand: ./gradlew fullCheck
# name: main@nightly
times: ["01:00"]
# Optional Docker build runtime; when enabled, the clean and build commands # Optional Docker build runtime; when enabled, the clean and build commands
# run inside a container instead of natively (see notes below). # run inside a container instead of natively (see notes below).
docker: docker:
@@ -169,8 +175,15 @@ branches:
enabled: true enabled: true
master: master:
buildCommand: ./gradlew --console=plain --no-daemon quickCheck
autoBuild: autoBuild:
enabled: true enabled: true
times:
# the nightly rebuild runs the full check instead of the quick on-commit
# one, recorded separately as master@nightly
- time: "01:00"
buildCommand: ./gradlew --console=plain --no-daemon completeCheck
name: master@nightly
release: release:
buildCommand: ./gradlew --console=plain --no-daemon --no-build-cache test jacocoReport buildCommand: ./gradlew --console=plain --no-daemon --no-build-cache test jacocoReport
@@ -224,6 +237,25 @@ Without the `main` override, direct pushes and merges to `main` would never buil
A plain git origin (no Gitea/GitHub) serves no `refs/pull/*/head` at all, so gated branches would never build there. A plain git origin (no Gitea/GitHub) serves no `refs/pull/*/head` at all, so gated branches would never build there.
For such origins, disable all gates globally with `watcher.pullRequestGate: false` — typically in the machine-specific `.git/gittally/.gittally.yml`, so the committed configuration keeps the gates for forge-backed environments. For such origins, disable all gates globally with `watcher.pullRequestGate: false` — typically in the machine-specific `.git/gittally/.gittally.yml`, so the committed configuration keeps the gates for forge-backed environments.
### Notes on `branches.<name>.autoBuild.times`
Each entry is either a plain `HH:MM` string or an object with `time` and optional `buildCommand` and `name`; both forms mix freely in one list.
A slot without its own command runs the branch's regular `buildCommand`.
The typical use is a quick check on every commit and a fuller, slower check in the nightly slot of the same branch.
A slot's command is recorded in the build result.
Restarting such a build from the UI re-runs it with the slot's command, and the startup recovery re-enqueues an interrupted one likewise — a build is always repeated with the command it originally ran.
Manual `gittally build <branch>` runs and watcher builds for new commits always use the regular `buildCommand`.
Without a `name`, a slot's builds share the branch's history, retention pool, and permanent latest-green link — on a busy branch, the regular builds can displace the nightly build and its artifacts within a day.
A slot `name` (e.g. `master@nightly`) records the slot's builds in their own pool instead: an own row in the branches view (sorted after its branch), an own `retentionPerBranch` count, an own latest status, and an own permanent artifact link.
The URL key is the sanitized name — `master@nightly` is served as `/branches/master_nightly/…`.
The builds still run in the branch's worktree, one build per branch at a time, and the Gitea commit status is still reported per commit in the shared status context, so the last build of a commit wins there regardless of its name.
Do not name a slot like an existing branch — the pools would merge.
The name's results live as long as the underlying branch exists on origin.
The whole `autoBuild` section is a watcher decision made before a build worktree exists, so — unlike `buildCommand` itself — it is read from the repo install/project config and cannot be changed by the `.gittally.yml` committed on the branch being built.
### Notes on `watcher.fastForwardLocalRefs` ### Notes on `watcher.fastForwardLocalRefs`
Builds run in worktrees that share the primary checkout's `.git`, so a build tool can read `refs/heads/*` there. Builds run in worktrees that share the primary checkout's `.git`, so a build tool can read `refs/heads/*` there.
@@ -67,15 +67,27 @@ class BuildExecutor(
* not cancel-requested), that build is returned instead of stacking a duplicate — * not cancel-requested), that build is returned instead of stacking a duplicate —
* a double-triggered UI restart must not queue the same commit twice. Re-running * a double-triggered UI restart must not queue the same commit twice. Re-running
* a *finished* build stays possible; this only guards the active queue. * a *finished* build stays possible; this only guards the active queue.
* A [buildCommandOverride] (from an auto-build slot with its own command) replaces
* the branch's configured `buildCommand`; since the command differs, such a build
* never counts as a duplicate of a regular build of the same commit.
* A [name] (from a named auto-build slot) records the result under that name
* instead of the branch name, giving the slot its own history and retention pool;
* the build still runs in the branch's worktree, serialized with the branch's
* other builds.
*/ */
fun startBuild( fun startBuild(
branch: String, branch: String,
commit: String, commit: String,
workingDir: Path = Paths.get("."), workingDir: Path = Paths.get("."),
buildCommandOverride: String? = null,
name: String = branch,
): RunningBuild { ): RunningBuild {
val duplicate = val duplicate =
builds.values.firstOrNull { builds.values.firstOrNull {
!it.cancelled.get() && it.runningBuild.branch == branch && it.runningBuild.commit == commit !it.cancelled.get() &&
it.runningBuild.name == name &&
it.runningBuild.commit == commit &&
it.runningBuild.buildCommandOverride == buildCommandOverride
} }
if (duplicate != null) { if (duplicate != null) {
log.info("build of branch {} at commit {} is already queued or running; not queueing a duplicate", branch, commit) log.info("build of branch {} at commit {} is already queued or running; not queueing a duplicate", branch, commit)
@@ -86,19 +98,23 @@ class BuildExecutor(
val runningBuild = val runningBuild =
RunningBuild( RunningBuild(
branch = branch, branch = branch,
name = name,
commit = commit, commit = commit,
artifactKey = ArtifactKeys.buildKey(branch, startedAt), artifactKey = ArtifactKeys.buildKey(name, startedAt),
startedAt = startedAt, startedAt = startedAt,
stagingDir = stagingDir, stagingDir = stagingDir,
liveLogFile = stagingDir.resolve(LIVE_LOG_FILE), liveLogFile = stagingDir.resolve(LIVE_LOG_FILE),
buildCommandOverride = buildCommandOverride,
) )
val pending = val pending =
BuildResult( BuildResult(
branch = branch, branch = branch,
name = name,
commit = commit, commit = commit,
status = BuildStatus.PENDING, status = BuildStatus.PENDING,
startedAt = startedAt, startedAt = startedAt,
duration = null, duration = null,
buildCommandOverride = buildCommandOverride,
artifactKey = runningBuild.artifactKey, artifactKey = runningBuild.artifactKey,
) )
repository.append(pending) repository.append(pending)
@@ -244,11 +260,12 @@ class BuildExecutor(
workspace: Path, workspace: Path,
): Int { ): Int {
val branchConfig = branchConfig(build.runningBuild.branch, build.workingDir, workspace) val branchConfig = branchConfig(build.runningBuild.branch, build.workingDir, workspace)
val buildCommand = build.runningBuild.buildCommandOverride ?: branchConfig.buildCommand
val stagingDir = build.runningBuild.stagingDir val stagingDir = build.runningBuild.stagingDir
Files.newOutputStream(stagingDir.resolve(branchConfig.stdoutLog)).use { stdoutLog -> Files.newOutputStream(stagingDir.resolve(branchConfig.stdoutLog)).use { stdoutLog ->
Files.newOutputStream(stagingDir.resolve(branchConfig.stderrLog)).use { stderrLog -> Files.newOutputStream(stagingDir.resolve(branchConfig.stderrLog)).use { stderrLog ->
Files.newOutputStream(build.runningBuild.liveLogFile).use { liveLog -> Files.newOutputStream(build.runningBuild.liveLogFile).use { liveLog ->
writeLiveLogHeader(liveLog, build.runningBuild, branchConfig, workspace) writeLiveLogHeader(liveLog, build.runningBuild, branchConfig, buildCommand, workspace)
if (branchConfig.cleanCommand.isNotBlank()) { if (branchConfig.cleanCommand.isNotBlank()) {
val cleanExitCode = val cleanExitCode =
runCommand(build, branchConfig, branchConfig.cleanCommand, workspace, stdoutLog, stderrLog, liveLog) runCommand(build, branchConfig, branchConfig.cleanCommand, workspace, stdoutLog, stderrLog, liveLog)
@@ -259,7 +276,7 @@ class BuildExecutor(
if (build.cancelled.get() || shuttingDown.get()) { if (build.cancelled.get() || shuttingDown.get()) {
return CANCELLED_EXIT_CODE return CANCELLED_EXIT_CODE
} }
return runCommand(build, branchConfig, branchConfig.buildCommand, workspace, stdoutLog, stderrLog, liveLog) return runCommand(build, branchConfig, buildCommand, workspace, stdoutLog, stderrLog, liveLog)
} }
} }
} }
@@ -357,11 +374,13 @@ class BuildExecutor(
) )
} ?: BuildResult( } ?: BuildResult(
branch = runningBuild.branch, branch = runningBuild.branch,
name = runningBuild.name,
commit = runningBuild.commit, commit = runningBuild.commit,
status = status, status = status,
startedAt = runningBuild.startedAt, startedAt = runningBuild.startedAt,
runningSince = runningBuild.runningSince, runningSince = runningBuild.runningSince,
duration = duration, duration = duration,
buildCommandOverride = runningBuild.buildCommandOverride,
artifactKey = runningBuild.artifactKey, artifactKey = runningBuild.artifactKey,
).also { repository.append(it) } ).also { repository.append(it) }
eventPublisher.publishEvent(BuildStatusChangedEvent(updated)) eventPublisher.publishEvent(BuildStatusChangedEvent(updated))
@@ -408,15 +427,22 @@ class BuildExecutor(
liveLog: OutputStream, liveLog: OutputStream,
runningBuild: RunningBuild, runningBuild: RunningBuild,
branchConfig: BranchConfig, branchConfig: BranchConfig,
buildCommand: String,
workspace: Path, workspace: Path,
) { ) {
val header = val header =
buildString { buildString {
appendLine("building branch: ${runningBuild.branch}") appendLine("building branch: ${runningBuild.branch}")
if (runningBuild.name != runningBuild.branch) {
appendLine("build name: ${runningBuild.name}")
}
appendLine("commit: ${runningBuild.commit}") appendLine("commit: ${runningBuild.commit}")
appendLine("started: ${runningBuild.startedAt}") appendLine("started: ${runningBuild.startedAt}")
appendLine("workspace: $workspace") appendLine("workspace: $workspace")
appendLine("build command: ${branchConfig.buildCommand}") if (runningBuild.buildCommandOverride != null) {
appendLine("triggered by: auto-build slot with its own build command")
}
appendLine("build command: $buildCommand")
if (branchConfig.cleanCommand.isNotBlank()) { if (branchConfig.cleanCommand.isNotBlank()) {
appendLine("clean command: ${branchConfig.cleanCommand}") appendLine("clean command: ${branchConfig.cleanCommand}")
} }
@@ -4,7 +4,15 @@ import java.time.Duration
import java.time.Instant import java.time.Instant
data class BuildResult( data class BuildResult(
/** The git branch that was built — Gitea links and origin lookups always use this. */
val branch: String, val branch: String,
/**
* The build name this result is recorded under: the branch name, unless a named
* auto-build slot (`autoBuild.times[].name`, e.g. `master@nightly`) set its own.
* History rows, retention pools, latest status, and the permanent latest-green
* artifact links are all keyed by this name.
*/
val name: String = branch,
val commit: String, val commit: String,
val status: BuildStatus, val status: BuildStatus,
/** When the build was accepted (enqueued); the time until [runningSince] is queue wait. */ /** When the build was accepted (enqueued); the time until [runningSince] is queue wait. */
@@ -13,5 +21,11 @@ data class BuildResult(
val runningSince: Instant? = null, val runningSince: Instant? = null,
/** Pure build execution time (from [runningSince]), without the queue wait. */ /** Pure build execution time (from [runningSince]), without the queue wait. */
val duration: Duration? = null, val duration: Duration? = null,
/**
* Command dictated by the auto-build slot that triggered this build; null means the
* branch's configured `buildCommand` was used. A restart or startup-recovery re-run
* repeats the build with this command, so a build always reruns what it originally ran.
*/
val buildCommandOverride: String? = null,
val artifactKey: String, val artifactKey: String,
) )
@@ -2,12 +2,18 @@ package de.hoennig.gittally.build
import java.time.Instant import java.time.Instant
/**
* Entries are grouped by [BuildResult.name] — the branch name, unless a named
* auto-build slot records its builds under its own name. Every "latest", retention
* pool, and green lookup works on that name; only the gone-from-origin pruning
* looks at the underlying [BuildResult.branch].
*/
interface BuildResultRepository { interface BuildResultRepository {
fun append(result: BuildResult) fun append(result: BuildResult)
/** Applies [transform] to the newest entry of [branch]; returns null if the branch has no entries. */ /** Applies [transform] to the newest entry recorded under [name]; returns null if there is none. */
fun updateLatest( fun updateLatest(
branch: String, name: String,
transform: (BuildResult) -> BuildResult, transform: (BuildResult) -> BuildResult,
): BuildResult? ): BuildResult?
@@ -17,13 +23,13 @@ interface BuildResultRepository {
transform: (BuildResult) -> BuildResult, transform: (BuildResult) -> BuildResult,
): BuildResult? ): BuildResult?
fun latestFor(branch: String): BuildResult? fun latestFor(name: String): BuildResult?
/** The newest SUCCESS entry of [branch] — the build behind the permanent `/branches/…` links. */ /** The newest SUCCESS entry recorded under [name] — the build behind the permanent `/branches/…` links. */
fun latestGreenFor(branch: String): BuildResult? fun latestGreenFor(name: String): BuildResult?
/** The newest entry of each branch, newest first. */ /** The newest entry of each build name, newest first. */
fun latestPerBranch(): List<BuildResult> fun latestPerName(): List<BuildResult>
/** All entries, newest first. */ /** All entries, newest first. */
fun history(): List<BuildResult> fun history(): List<BuildResult>
@@ -33,17 +39,17 @@ interface BuildResultRepository {
/** /**
* Startup recovery: RUNNING entries and PENDING entries superseded by a newer entry * Startup recovery: RUNNING entries and PENDING entries superseded by a newer entry
* of the same branch become INTERRUPTED. Returns the changed entries. * of the same build name become INTERRUPTED. Returns the changed entries.
*/ */
fun markStaleRunningAsInterrupted(): List<BuildResult> fun markStaleRunningAsInterrupted(): List<BuildResult>
/** /**
* Keeps the newest [retentionPerBranch] entries per branch and drops entries of branches * Keeps the newest [retentionPerBranch] entries per build name and drops entries whose
* not contained in [originBranches]. With [retentionCutoff], entries started before the * branch is not contained in [originBranches]. With [retentionCutoff], entries started
* cutoff are dropped even within the retention count — except each branch's newest entry, * before the cutoff are dropped even within the retention count — except each name's
* so dormant branches keep their last status. With [keepLatestGreen], the newest SUCCESS * newest entry, so dormant branches keep their last status. With [keepLatestGreen], the
* entry of each surviving branch is kept even beyond both limits, so the permanent * newest SUCCESS entry of each surviving name is kept even beyond both limits, so the
* `/branches/…` artifact links stay valid while newer builds fail. * permanent `/branches/…` artifact links stay valid while newer builds fail.
* PENDING and RUNNING entries are never removed, regardless of all limits and even * PENDING and RUNNING entries are never removed, regardless of all limits and even
* when their branch is gone from [originBranches] — a queued or executing build * when their branch is gone from [originBranches] — a queued or executing build
* belongs to the executor, and pruning its result would make it invisible in UI * belongs to the executor, and pruning its result would make it invisible in UI
@@ -38,12 +38,12 @@ class FileBuildResultRepository(
} }
override fun updateLatest( override fun updateLatest(
branch: String, name: String,
transform: (BuildResult) -> BuildResult, transform: (BuildResult) -> BuildResult,
): BuildResult? { ): BuildResult? {
synchronized(lock) { synchronized(lock) {
val results = load() val results = load()
val index = indexOfLatest(results, branch) ?: return null val index = indexOfLatest(results, name) ?: return null
val updated = transform(results[index]) val updated = transform(results[index])
save(results.toMutableList().also { it[index] = updated }) save(results.toMutableList().also { it[index] = updated })
return updated return updated
@@ -66,19 +66,19 @@ class FileBuildResultRepository(
} }
} }
override fun latestFor(branch: String): BuildResult? { override fun latestFor(name: String): BuildResult? {
val results = load() val results = load()
return indexOfLatest(results, branch)?.let { results[it] } return indexOfLatest(results, name)?.let { results[it] }
} }
override fun latestGreenFor(branch: String): BuildResult? = override fun latestGreenFor(name: String): BuildResult? =
load() load()
.filter { it.branch == branch && it.status == BuildStatus.SUCCESS } .filter { it.name == name && it.status == BuildStatus.SUCCESS }
.maxByOrNull { it.startedAt } .maxByOrNull { it.startedAt }
override fun latestPerBranch(): List<BuildResult> = override fun latestPerName(): List<BuildResult> =
load() load()
.groupBy { it.branch } .groupBy { it.name }
.values .values
.map { entries -> entries.reduce(::laterOf) } .map { entries -> entries.reduce(::laterOf) }
.sortedByDescending { it.startedAt } .sortedByDescending { it.startedAt }
@@ -104,7 +104,7 @@ class FileBuildResultRepository(
val updated = val updated =
results.map { result -> results.map { result ->
val superseded = val superseded =
results.any { it.branch == result.branch && it.startedAt.isAfter(result.startedAt) } results.any { it.name == result.name && it.startedAt.isAfter(result.startedAt) }
if (result.status == BuildStatus.RUNNING || if (result.status == BuildStatus.RUNNING ||
(result.status == BuildStatus.PENDING && superseded) (result.status == BuildStatus.PENDING && superseded)
) { ) {
@@ -138,7 +138,7 @@ class FileBuildResultRepository(
active.toSet() + active.toSet() +
results results
.filter { it.branch in originBranchSet } .filter { it.branch in originBranchSet }
.groupBy { it.branch } .groupBy { it.name }
.values .values
.flatMap { entries -> .flatMap { entries ->
val newest = val newest =
@@ -146,7 +146,7 @@ class FileBuildResultRepository(
.sortedByDescending { it.startedAt } .sortedByDescending { it.startedAt }
.take(retentionPerBranch.coerceAtLeast(0)) .take(retentionPerBranch.coerceAtLeast(0))
.filterIndexed { index, entry -> .filterIndexed { index, entry ->
// the branch's newest entry is never age-pruned // the name's newest entry is never age-pruned
index == 0 || retentionCutoff == null || !entry.startedAt.isBefore(retentionCutoff) index == 0 || retentionCutoff == null || !entry.startedAt.isBefore(retentionCutoff)
} }
val latestGreen = val latestGreen =
@@ -163,14 +163,14 @@ class FileBuildResultRepository(
} }
} }
/** The index of the newest entry of [branch]; on equal timestamps the later appended entry wins. */ /** The index of the newest entry recorded under [name]; on equal timestamps the later appended entry wins. */
private fun indexOfLatest( private fun indexOfLatest(
results: List<BuildResult>, results: List<BuildResult>,
branch: String, name: String,
): Int? { ): Int? {
var latest: Int? = null var latest: Int? = null
results.forEachIndexed { index, result -> results.forEachIndexed { index, result ->
if (result.branch == branch && if (result.name == name &&
(latest == null || !result.startedAt.isBefore(results[latest].startedAt)) (latest == null || !result.startedAt.isBefore(results[latest].startedAt))
) { ) {
latest = index latest = index
@@ -5,7 +5,10 @@ import java.time.Instant
/** Handle to a build accepted by the [BuildExecutor]; log paths become valid once the build runs. */ /** Handle to a build accepted by the [BuildExecutor]; log paths become valid once the build runs. */
data class RunningBuild( data class RunningBuild(
/** The git branch being built. */
val branch: String, val branch: String,
/** The build name the result is recorded under; the branch name unless a named auto-build slot set its own. */
val name: String = branch,
val commit: String, val commit: String,
val artifactKey: String, val artifactKey: String,
val startedAt: Instant, val startedAt: Instant,
@@ -13,6 +16,8 @@ data class RunningBuild(
val stagingDir: Path, val stagingDir: Path,
/** Combined stdout+stderr log, written live while the build runs. */ /** Combined stdout+stderr log, written live while the build runs. */
val liveLogFile: Path, val liveLogFile: Path,
/** Command dictated by the triggering auto-build slot; null runs the branch's configured `buildCommand`. */
val buildCommandOverride: String? = null,
) { ) {
/** Set by the executor when the build leaves the queue and starts executing. */ /** Set by the executor when the build leaves the queue and starts executing. */
@Volatile @Volatile
@@ -37,8 +37,10 @@ class ConsoleBuildRunner(
branch: String, branch: String,
commit: String, commit: String,
workingDir: Path = Paths.get("."), workingDir: Path = Paths.get("."),
buildCommandOverride: String? = null,
name: String = branch,
): BuildStatus { ): BuildStatus {
val build = buildExecutor.startBuild(branch, commit, workingDir) val build = buildExecutor.startBuild(branch, commit, workingDir, buildCommandOverride, name)
var printed = 0L var printed = 0L
var result: BuildResult? = null var result: BuildResult? = null
while (result?.status?.isTerminal != true) { while (result?.status?.isTerminal != true) {
@@ -199,7 +199,12 @@ class InitCommand(
requirePullRequest: false requirePullRequest: false
autoBuild: autoBuild:
enabled: false # whether to rebuild on schedule enabled: false # whether to rebuild on schedule
times: ["01:00"] # UTC times HH:MM for scheduled builds # UTC times HH:MM for scheduled builds; an entry may carry its own
# command and a name for a separate history/artifact pool:
# - time: "01:00"
# buildCommand: ./gradlew fullCheck
# name: main@nightly
times: ["01:00"]
docker: docker:
enabled: false # run clean/build commands in a Docker container instead of natively enabled: false # run clean/build commands in a Docker container instead of natively
image: "" # image for the build container; required when enabled image: "" # image for the build container; required when enabled
@@ -34,7 +34,7 @@ class RetryCommand(
val failed: List<BuildResult> val failed: List<BuildResult>
try { try {
fetchBestEffort() fetchBestEffort()
failed = repository.latestPerBranch().filter { it.status == BuildStatus.FAILED } failed = repository.latestPerName().filter { it.status == BuildStatus.FAILED }
} catch (e: Exception) { } catch (e: Exception) {
System.err.println("error: ${e.message}") System.err.println("error: ${e.message}")
return ExitCode.USAGE return ExitCode.USAGE
@@ -51,7 +51,8 @@ class RetryCommand(
continue continue
} }
println("retrying branch ${result.branch} at commit ${commit.take(12)}") println("retrying branch ${result.branch} at commit ${commit.take(12)}")
val status = consoleBuildRunner.buildAndStream(result.branch, commit, workingDir) // a failed auto-slot build retries with its recorded command, under its name
val status = consoleBuildRunner.buildAndStream(result.branch, commit, workingDir, result.buildCommandOverride, result.name)
if (status != BuildStatus.SUCCESS) { if (status != BuildStatus.SUCCESS) {
anyFailed = true anyFailed = true
} }
@@ -26,7 +26,7 @@ class StatusCommand(
var history: Boolean = false var history: Boolean = false
override fun call(): Int { override fun call(): Int {
val results = if (history) repository.history() else repository.latestPerBranch() val results = if (history) repository.history() else repository.latestPerName()
if (results.isEmpty()) { if (results.isEmpty()) {
println("(no builds recorded)") println("(no builds recorded)")
} else { } else {
@@ -40,7 +40,7 @@ class StatusCommand(
val rows = val rows =
results.map { results.map {
listOf( listOf(
it.branch, it.name,
it.status.name.lowercase(), it.status.name.lowercase(),
it.commit.take(12), it.commit.take(12),
UiFormats.timestamp(it.startedAt), UiFormats.timestamp(it.startedAt),
@@ -0,0 +1,62 @@
package de.hoennig.gittally.config
import com.fasterxml.jackson.core.JsonGenerator
import com.fasterxml.jackson.core.JsonParser
import com.fasterxml.jackson.databind.DeserializationContext
import com.fasterxml.jackson.databind.JsonDeserializer
import com.fasterxml.jackson.databind.JsonNode
import com.fasterxml.jackson.databind.JsonSerializer
import com.fasterxml.jackson.databind.SerializerProvider
/**
* Accepts both YAML forms of an [AutoBuildSlot] entry: a plain `HH:MM` string, or an
* object with `time` and optional `buildCommand` and `name`.
*/
class AutoBuildSlotDeserializer : JsonDeserializer<AutoBuildSlot>() {
override fun deserialize(
parser: JsonParser,
context: DeserializationContext,
): AutoBuildSlot {
val node = parser.readValueAsTree<JsonNode>()
if (node.isTextual) {
return AutoBuildSlot(time = node.asText())
}
if (node.isObject) {
val time =
node.get("time")?.takeIf { it.isTextual }?.asText()
?: throw context.instantiationException(AutoBuildSlot::class.java, "auto-build slot object needs a 'time' (HH:MM)")
return AutoBuildSlot(
time = time,
buildCommand = node.get("buildCommand")?.asText() ?: "",
name = node.get("name")?.asText() ?: "",
)
}
throw context.instantiationException(
AutoBuildSlot::class.java,
"auto-build slot must be an HH:MM string or an object with 'time' and optional 'buildCommand' and 'name'",
)
}
}
/** Writes the compact form back: a plain string unless the slot carries its own command or name. */
class AutoBuildSlotSerializer : JsonSerializer<AutoBuildSlot>() {
override fun serialize(
slot: AutoBuildSlot,
generator: JsonGenerator,
provider: SerializerProvider,
) {
if (slot.buildCommand.isBlank() && slot.name.isBlank()) {
generator.writeString(slot.time)
return
}
generator.writeStartObject()
generator.writeStringField("time", slot.time)
if (slot.buildCommand.isNotBlank()) {
generator.writeStringField("buildCommand", slot.buildCommand)
}
if (slot.name.isNotBlank()) {
generator.writeStringField("name", slot.name)
}
generator.writeEndObject()
}
}
@@ -1,5 +1,8 @@
package de.hoennig.gittally.config package de.hoennig.gittally.config
import com.fasterxml.jackson.databind.annotation.JsonDeserialize
import com.fasterxml.jackson.databind.annotation.JsonSerialize
data class GitTallyConfig( data class GitTallyConfig(
val server: ServerConfig = ServerConfig(), val server: ServerConfig = ServerConfig(),
val git: GitConfig = GitConfig(), val git: GitConfig = GitConfig(),
@@ -148,5 +151,30 @@ data class DockerConfig(
data class AutoBuildConfig( data class AutoBuildConfig(
val enabled: Boolean = false, val enabled: Boolean = false,
val times: List<String> = listOf("01:00"), /**
* Daily UTC slots. Each entry is either a plain `HH:MM` time or an object with
* `time` and its own `buildCommand` — so a nightly slot can run a fuller check
* than the on-commit builds of the same branch.
*/
val times: List<AutoBuildSlot> = listOf(AutoBuildSlot("01:00")),
)
/**
* One scheduled auto-build slot; in YAML either a plain `HH:MM` string or an object
* (`time` plus optional `buildCommand` and `name`) — see [AutoBuildSlotDeserializer].
*/
@JsonDeserialize(using = AutoBuildSlotDeserializer::class)
@JsonSerialize(using = AutoBuildSlotSerializer::class)
data class AutoBuildSlot(
/** UTC time of day, `HH:MM`. */
val time: String,
/** Command this slot's build runs; empty runs the branch's [BranchConfig.buildCommand]. */
val buildCommand: String = "",
/**
* Build name this slot's builds are recorded under (e.g. `master@nightly`); empty
* records them under the branch name. A named slot gets its own history, retention
* pool, latest status, and permanent latest-green artifact link, so its builds are
* not displaced by the branch's regular builds.
*/
val name: String = "",
) )
@@ -9,7 +9,10 @@ val BuildStatus.jsonName: String
get() = name.lowercase() get() = name.lowercase()
data class BuildResultDto( data class BuildResultDto(
/** The git branch that was built; the UI links this to Gitea. */
val branch: String, val branch: String,
/** The build name the result is recorded under; the UI displays and restarts by this (= [branch] unless a named auto-build slot). */
val name: String = branch,
val commit: String, val commit: String,
val status: String, val status: String,
val startedAt: Instant, val startedAt: Instant,
@@ -17,7 +20,7 @@ data class BuildResultDto(
val runningSince: Instant? = null, val runningSince: Instant? = null,
val durationSeconds: Long?, val durationSeconds: Long?,
val artifactKey: String, val artifactKey: String,
/** The permanent branch URL, set only on the build it resolves to — the branch's latest green build. */ /** The permanent branch URL, set only on the build it resolves to — the name's latest green build. */
val latestGreenUrl: String? = null, val latestGreenUrl: String? = null,
) { ) {
companion object { companion object {
@@ -26,13 +29,14 @@ data class BuildResultDto(
isLatestGreen: Boolean = false, isLatestGreen: Boolean = false,
) = BuildResultDto( ) = BuildResultDto(
branch = result.branch, branch = result.branch,
name = result.name,
commit = result.commit, commit = result.commit,
status = result.status.jsonName, status = result.status.jsonName,
startedAt = result.startedAt, startedAt = result.startedAt,
runningSince = result.runningSince, runningSince = result.runningSince,
durationSeconds = result.duration?.seconds, durationSeconds = result.duration?.seconds,
artifactKey = result.artifactKey, artifactKey = result.artifactKey,
latestGreenUrl = if (isLatestGreen) BranchPermalinks.permanentUrl(result.branch) else null, latestGreenUrl = if (isLatestGreen) BranchPermalinks.permanentUrl(result.name) else null,
) )
} }
} }
@@ -45,7 +49,10 @@ data class BuildResultDto(
* where it resolves to. * where it resolves to.
*/ */
data class BranchDto( data class BranchDto(
/** The git branch; the UI links this to Gitea. */
val branch: String, val branch: String,
/** The row's build name; = [branch], except for the extra rows of named auto-build slots. */
val name: String = branch,
val commit: String, val commit: String,
val status: String, val status: String,
val startedAt: Instant?, val startedAt: Instant?,
@@ -60,9 +67,11 @@ data class BranchDto(
headCommit: String, headCommit: String,
latest: BuildResult?, latest: BuildResult?,
isLatestGreen: Boolean = false, isLatestGreen: Boolean = false,
name: String = branch,
) = if (latest == null) { ) = if (latest == null) {
BranchDto( BranchDto(
branch = branch, branch = branch,
name = name,
commit = headCommit, commit = headCommit,
status = CommitStatusDto.UNKNOWN_STATUS, status = CommitStatusDto.UNKNOWN_STATUS,
startedAt = null, startedAt = null,
@@ -72,13 +81,14 @@ data class BranchDto(
} else { } else {
BranchDto( BranchDto(
branch = branch, branch = branch,
name = name,
commit = latest.commit, commit = latest.commit,
status = latest.status.jsonName, status = latest.status.jsonName,
startedAt = latest.startedAt, startedAt = latest.startedAt,
runningSince = latest.runningSince, runningSince = latest.runningSince,
durationSeconds = latest.duration?.seconds, durationSeconds = latest.duration?.seconds,
artifactKey = latest.artifactKey, artifactKey = latest.artifactKey,
latestGreenUrl = if (isLatestGreen) BranchPermalinks.permanentUrl(branch) else null, latestGreenUrl = if (isLatestGreen) BranchPermalinks.permanentUrl(name) else null,
) )
} }
} }
@@ -87,6 +97,8 @@ data class BranchDto(
/** 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,
/** The build name; = [branch] unless a named auto-build slot triggered this build. */
val name: String = branch,
val commit: String, val commit: String,
val artifactKey: String, val artifactKey: String,
val status: String, val status: String,
@@ -9,8 +9,10 @@ import java.nio.file.Paths
/** /**
* The branches-view data, shared by the JSON API and the server-rendered page: * 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 * every origin branch joined with its latest build (or an `unknown` placeholder
* when never built), ordered like the legacy branches view — main/master first, * when never built), plus one extra row per named auto-build slot that has recorded
* then flat names, then hierarchical names, alphabetical within each group. * builds (e.g. `master@nightly`, sorted right after its branch). 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. * Legacy listed local branches; the new watcher's branch universe is origin.
*/ */
@Component @Component
@@ -18,21 +20,27 @@ class BranchListing(
private val gitService: GitService, private val gitService: GitService,
private val repository: BuildResultRepository, private val repository: BuildResultRepository,
) { ) {
fun branches(workingDir: Path = Paths.get(".")): List<BranchDto> = fun branches(workingDir: Path = Paths.get(".")): List<BranchDto> {
gitService val heads = gitService.originBranchHeads(workingDir)
.originBranchHeads(workingDir) val branchRows =
.entries heads.map { (branch, headCommit) ->
.sortedWith(compareBy({ sortGroup(it.key) }, { it.key })) // latestFor groups by build name, so a named slot's results never shadow the branch row
.map { (branch, headCommit) -> BranchDto.from(branch, headCommit, repository.latestFor(branch))
val latest = repository.latestFor(branch)
BranchDto.from(
branch,
headCommit,
latest,
// the permanent link belongs to the build it resolves to, not to every build of the branch
isLatestGreen = latest != null && latest.artifactKey == repository.latestGreenFor(branch)?.artifactKey,
)
} }
val namedRows =
repository
.latestPerName()
.filter { it.name != it.branch && it.branch in heads }
.map { latest -> BranchDto.from(latest.branch, latest.commit, latest, name = latest.name) }
return (branchRows + namedRows)
.sortedWith(compareBy({ sortGroup(it.branch) }, { it.branch }, { it.name }))
.map { row ->
// the permanent link belongs to the build it resolves to, not to every build of the name
val isLatestGreen =
row.artifactKey.isNotEmpty() && row.artifactKey == repository.latestGreenFor(row.name)?.artifactKey
if (isLatestGreen) row.copy(latestGreenUrl = BranchPermalinks.permanentUrl(row.name)) else row
}
}
private fun sortGroup(branch: String): Int = private fun sortGroup(branch: String): Int =
when { when {
@@ -10,36 +10,38 @@ import org.springframework.web.server.ResponseStatusException
/** /**
* Resolves the permanent `/branches/<branch-key>/…` artifact URLs: the key is the * Resolves the permanent `/branches/<branch-key>/…` artifact URLs: the key is the
* hash-free [ArtifactKeys.permanentBranchKey] (the full [ArtifactKeys.branchKey] * hash-free [ArtifactKeys.permanentBranchKey] (the full [ArtifactKeys.branchKey]
* works too), and the target is the branch's latest green build. Resolution happens * works too) of a build name — a branch, or a named auto-build slot like
* per request, so a permanent URL follows every new green build and stays valid as * `master@nightly` — and the target is that name's latest green build. Resolution
* long as the branch exists on origin and has ever built successfully — green only, * happens per request, so a permanent URL follows every new green build and stays
* a permanent link never points at a failed build's artifacts. * valid as long as the branch exists on origin and the name has ever built
* successfully — green only, a permanent link never points at a failed build's
* artifacts.
*/ */
@Component @Component
class BranchPermalinks( class BranchPermalinks(
private val repository: BuildResultRepository, private val repository: BuildResultRepository,
) { ) {
fun latestGreenBuild(branchKey: String): BuildResult { fun latestGreenBuild(branchKey: String): BuildResult {
val branches = val names =
repository repository
.latestPerBranch() .latestPerName()
.map { it.branch } .map { it.name }
.filter { branchKey == ArtifactKeys.permanentBranchKey(it) || branchKey == ArtifactKeys.branchKey(it) } .filter { branchKey == ArtifactKeys.permanentBranchKey(it) || branchKey == ArtifactKeys.branchKey(it) }
val branch = val name =
when (branches.size) { when (names.size) {
0 -> throw ResponseStatusException(HttpStatus.NOT_FOUND, "no recorded builds for branch key '$branchKey'") 0 -> throw ResponseStatusException(HttpStatus.NOT_FOUND, "no recorded builds for branch key '$branchKey'")
1 -> branches.single() 1 -> names.single()
else -> throw ResponseStatusException( else -> throw ResponseStatusException(
HttpStatus.CONFLICT, HttpStatus.CONFLICT,
"branch key '$branchKey' is ambiguous (${branches.joinToString()}); use the full branch key with hash suffix", "branch key '$branchKey' is ambiguous (${names.joinToString()}); use the full branch key with hash suffix",
) )
} }
return repository.latestGreenFor(branch) return repository.latestGreenFor(name)
?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "branch '$branch' has no successful build") ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "branch '$name' has no successful build")
} }
companion object { companion object {
/** The permanent artifact-index URL of [branch], shown in the branches view. */ /** The permanent artifact-index URL of the build name (branch or named slot), shown in the branches view. */
fun permanentUrl(branch: String): String = "/branches/${ArtifactKeys.permanentBranchKey(branch)}" fun permanentUrl(name: String): String = "/branches/${ArtifactKeys.permanentBranchKey(name)}"
} }
} }
@@ -41,7 +41,7 @@ class BuildsApiController(
var workingDir: Path = Paths.get(".") var workingDir: Path = Paths.get(".")
@GetMapping("/api/builds/latest") @GetMapping("/api/builds/latest")
fun latest(): List<BuildResultDto> = repository.latestPerBranch().map { BuildResultDto.from(it, it.isLatestGreen()) } fun latest(): List<BuildResultDto> = repository.latestPerName().map { BuildResultDto.from(it, it.isLatestGreen()) }
/** The legacy branches view: every origin branch with its latest build or `unknown`. */ /** The legacy branches view: every origin branch with its latest build or `unknown`. */
@GetMapping("/api/branches") @GetMapping("/api/branches")
@@ -50,7 +50,7 @@ class BuildsApiController(
@GetMapping("/api/builds/history") @GetMapping("/api/builds/history")
fun history(): List<BuildResultDto> = repository.history().map { BuildResultDto.from(it, it.isLatestGreen()) } fun history(): List<BuildResultDto> = repository.history().map { BuildResultDto.from(it, it.isLatestGreen()) }
private fun BuildResult.isLatestGreen(): Boolean = repository.latestGreenFor(branch)?.artifactKey == artifactKey private fun BuildResult.isLatestGreen(): Boolean = repository.latestGreenFor(name)?.artifactKey == artifactKey
/** The currently executing builds — several are possible, up to `builds.maxConcurrent`. */ /** The currently executing builds — several are possible, up to `builds.maxConcurrent`. */
@GetMapping("/api/builds/current") @GetMapping("/api/builds/current")
@@ -59,6 +59,7 @@ class BuildsApiController(
return buildExecutor.currentBuilds().map { build -> return buildExecutor.currentBuilds().map { build ->
CurrentBuildDto( CurrentBuildDto(
branch = build.branch, branch = build.branch,
name = build.name,
commit = build.commit, commit = build.commit,
artifactKey = build.artifactKey, artifactKey = build.artifactKey,
status = status =
@@ -84,9 +85,11 @@ class BuildsApiController(
} }
/** /**
* Re-enqueues the branch's last recorded commit — or its origin head for a branch * Re-enqueues the last recorded commit of the build name [branch] — or the origin
* never built, so the branches view can trigger first builds like legacy. * head for a branch never built, so the branches view can trigger first builds like
* The branch is a parameter, not a path variable, because branch names may contain * legacy. A restarted auto-slot build re-runs the command its slot dictated, under
* the slot's name.
* The name 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).
*/ */
@PostMapping("/api/builds/restart") @PostMapping("/api/builds/restart")
@@ -95,14 +98,23 @@ class BuildsApiController(
@RequestHeader(name = TOKEN_HEADER, required = false) headerToken: String?, @RequestHeader(name = TOKEN_HEADER, required = false) headerToken: String?,
): ResponseEntity<Any> { ): ResponseEntity<Any> {
rejectBadToken(headerToken)?.let { return it } rejectBadToken(headerToken)?.let { return it }
val latest = repository.latestFor(branch)
val commit = val commit =
repository.latestFor(branch)?.commit latest?.commit
?: gitService.originHeadCommit(branch, workingDir) ?: gitService.originHeadCommit(branch, workingDir)
?: return notFound("branch '$branch' has no recorded build and no origin counterpart") ?: return notFound("branch '$branch' has no recorded build and no origin counterpart")
val running = buildExecutor.startBuild(branch, commit) // a restarted build repeats what it originally ran: same branch, name, and command
val running =
buildExecutor.startBuild(
branch = latest?.branch ?: branch,
commit = commit,
buildCommandOverride = latest?.buildCommandOverride,
name = latest?.name ?: branch,
)
return ResponseEntity.accepted().body( return ResponseEntity.accepted().body(
BuildResultDto( BuildResultDto(
branch = running.branch, branch = running.branch,
name = running.name,
commit = running.commit, commit = running.commit,
status = BuildStatus.PENDING.jsonName, status = BuildStatus.PENDING.jsonName,
startedAt = running.startedAt, startedAt = running.startedAt,
@@ -56,7 +56,7 @@ class UiController(
@GetMapping("/") @GetMapping("/")
fun latest(model: Model): String { fun latest(model: Model): String {
val links = baseModel(model, view = "latest", pageTitle = "Latest Builds") val links = baseModel(model, view = "latest", pageTitle = "Latest Builds")
model.addAttribute("rows", repository.latestPerBranch().map { BuildRowView.from(it, links, permanentUrlOf(it)) }) model.addAttribute("rows", repository.latestPerName().map { BuildRowView.from(it, links, permanentUrlOf(it)) })
model.addAttribute("apiPath", "/api/builds/latest") model.addAttribute("apiPath", "/api/builds/latest")
model.addAttribute("allowRestart", true) model.addAttribute("allowRestart", true)
model.addAttribute("emptyMessage", "No builds recorded yet.") model.addAttribute("emptyMessage", "No builds recorded yet.")
@@ -84,10 +84,10 @@ class UiController(
return "builds" return "builds"
} }
/** The permanent branch URL belongs to the build it resolves to — the branch's latest green build. */ /** The permanent branch URL belongs to the build it resolves to — the name's latest green build. */
private fun permanentUrlOf(result: BuildResult): String? = private fun permanentUrlOf(result: BuildResult): String? =
if (repository.latestGreenFor(result.branch)?.artifactKey == result.artifactKey) { if (repository.latestGreenFor(result.name)?.artifactKey == result.artifactKey) {
BranchPermalinks.permanentUrl(result.branch) BranchPermalinks.permanentUrl(result.name)
} else { } else {
null null
} }
@@ -100,6 +100,7 @@ class UiController(
buildExecutor.currentBuilds().map { build -> buildExecutor.currentBuilds().map { build ->
CurrentBuildView( CurrentBuildView(
branch = build.branch, branch = build.branch,
name = build.name,
commit = build.commit, commit = build.commit,
commitAbbrev = build.commit.take(12), commitAbbrev = build.commit.take(12),
status = status =
@@ -92,7 +92,10 @@ object UiFormats {
/** One row of the build tables; [latestGreenUrl] only on the build that permanent link resolves to. */ /** One row of the build tables; [latestGreenUrl] only on the build that permanent link resolves to. */
data class BuildRowView( data class BuildRowView(
/** The git branch — target of the Gitea link and the copy button. */
val branch: String, val branch: String,
/** The displayed build name; = [branch] unless a named auto-build slot recorded the build. */
val name: String,
val commit: String, val commit: String,
val commitAbbrev: String, val commitAbbrev: String,
val status: String, val status: String,
@@ -115,6 +118,7 @@ data class BuildRowView(
latestGreenUrl: String? = null, latestGreenUrl: String? = null,
) = BuildRowView( ) = BuildRowView(
branch = result.branch, branch = result.branch,
name = result.name,
commit = result.commit, commit = result.commit,
commitAbbrev = result.commit.take(12), commitAbbrev = result.commit.take(12),
status = result.status.jsonName, status = result.status.jsonName,
@@ -135,6 +139,7 @@ data class BuildRowView(
links: GiteaWebLinks, links: GiteaWebLinks,
) = BuildRowView( ) = BuildRowView(
branch = entry.branch, branch = entry.branch,
name = entry.name,
commit = entry.commit, commit = entry.commit,
commitAbbrev = entry.commit.take(12), commitAbbrev = entry.commit.take(12),
status = entry.status, status = entry.status,
@@ -174,6 +179,8 @@ data class LogFileView(
/** One card of the current-builds view; the live log is fetched by `gittally.js`. */ /** One card of the current-builds view; the live log is fetched by `gittally.js`. */
data class CurrentBuildView( data class CurrentBuildView(
val branch: String, val branch: String,
/** The displayed build name; = [branch] unless a named auto-build slot triggered the build. */
val name: String,
val commit: String, val commit: String,
val commitAbbrev: String, val commitAbbrev: String,
val status: String, val status: String,
@@ -5,6 +5,7 @@ import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.databind.SerializationFeature import com.fasterxml.jackson.databind.SerializationFeature
import com.fasterxml.jackson.module.kotlin.readValue import com.fasterxml.jackson.module.kotlin.readValue
import com.fasterxml.jackson.module.kotlin.registerKotlinModule import com.fasterxml.jackson.module.kotlin.registerKotlinModule
import de.hoennig.gittally.config.AutoBuildSlot
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import java.nio.file.Files import java.nio.file.Files
import java.nio.file.Path import java.nio.file.Path
@@ -26,15 +27,15 @@ object AutoBuildSlots {
/** The latest valid slot at or before [now], or null when no slot is due yet today. */ /** The latest valid slot at or before [now], or null when no slot is due yet today. */
fun latestDueSlot( fun latestDueSlot(
times: List<String>, times: List<AutoBuildSlot>,
now: LocalTime, now: LocalTime,
): String? = ): AutoBuildSlot? =
times times
.mapNotNull { slot -> .mapNotNull { slot ->
try { try {
LocalTime.parse(slot.trim()) to slot LocalTime.parse(slot.time.trim()) to slot
} catch (_: DateTimeParseException) { } catch (_: DateTimeParseException) {
log.warn("skipping invalid auto-build time slot '{}': expected HH:MM", slot) log.warn("skipping invalid auto-build time slot '{}': expected HH:MM", slot.time)
null null
} }
}.filter { (parsed, _) -> !parsed.isAfter(now) } }.filter { (parsed, _) -> !parsed.isAfter(now) }
@@ -92,7 +92,7 @@ class Watcher(
} }
val restartable = val restartable =
repository repository
.latestPerBranch() .latestPerName()
.filter { it.status == BuildStatus.INTERRUPTED || it.status == BuildStatus.PENDING } .filter { it.status == BuildStatus.INTERRUPTED || it.status == BuildStatus.PENDING }
for (result in restartable) { for (result in restartable) {
val commit = gitService.originHeadCommit(result.branch, workingDir) val commit = gitService.originHeadCommit(result.branch, workingDir)
@@ -110,7 +110,8 @@ class Watcher(
repository.updateByArtifactKey(result.artifactKey) { it.copy(status = BuildStatus.INTERRUPTED) } repository.updateByArtifactKey(result.artifactKey) { it.copy(status = BuildStatus.INTERRUPTED) }
} }
log.info("restarting unfinished build of branch {}", result.branch) log.info("restarting unfinished build of branch {}", result.branch)
buildExecutor.startBuild(result.branch, commit, workingDir) // an interrupted auto-slot build re-runs the command its slot dictated, under its name
buildExecutor.startBuild(result.branch, commit, workingDir, result.buildCommandOverride, result.name)
} }
} }
@@ -144,9 +145,9 @@ class Watcher(
lastPollError = null, lastPollError = null,
queuedBranches = queuedBranches =
repository repository
.latestPerBranch() .latestPerName()
.filter { it.status == BuildStatus.PENDING || it.status == BuildStatus.RUNNING } .filter { it.status == BuildStatus.PENDING || it.status == BuildStatus.RUNNING }
.map { it.branch }, .map { it.name },
) )
} }
@@ -216,8 +217,10 @@ class Watcher(
config: GitTallyConfig, config: GitTallyConfig,
pullRequestHeads: Lazy<Set<String>>, pullRequestHeads: Lazy<Set<String>>,
workingDir: Path, workingDir: Path,
buildCommandOverride: String? = null,
name: String = branch,
): Boolean { ): Boolean {
val latest = repository.latestFor(branch) val latest = repository.latestFor(name)
if (latest?.status == BuildStatus.PENDING || latest?.status == BuildStatus.RUNNING) { if (latest?.status == BuildStatus.PENDING || latest?.status == BuildStatus.RUNNING) {
return false return false
} }
@@ -233,7 +236,7 @@ class Watcher(
return false return false
} }
log.info("enqueueing build of branch {} at commit {}", branch, commit) log.info("enqueueing build of branch {} at commit {}", branch, commit)
buildExecutor.startBuild(branch, commit, workingDir) buildExecutor.startBuild(branch, commit, workingDir, buildCommandOverride, name)
return true return true
} }
@@ -261,16 +264,27 @@ class Watcher(
val timeOfDay = LocalTime.ofInstant(now, ZoneOffset.UTC) val timeOfDay = LocalTime.ofInstant(now, ZoneOffset.UTC)
for ((branch, branchConfig) in autoBuildBranches) { for ((branch, branchConfig) in autoBuildBranches) {
val slot = AutoBuildSlots.latestDueSlot(branchConfig.autoBuild.times, timeOfDay) ?: continue val slot = AutoBuildSlots.latestDueSlot(branchConfig.autoBuild.times, timeOfDay) ?: continue
if (autoBuildState.isTriggered(branch, today, slot)) { if (autoBuildState.isTriggered(branch, today, slot.time)) {
continue continue
} }
if (branch !in originBranches) { if (branch !in originBranches) {
log.warn("skipping auto build of branch {}: branch is not on origin", branch) log.warn("skipping auto build of branch {}: branch is not on origin", branch)
continue continue
} }
// rebuilding the already-built commit is the point of an auto build // rebuilding the already-built commit is the point of an auto build; a slot
if (startBuildIfDue(branch, allowSameCommit = true, config, pullRequestHeads, workingDir)) { // with its own command dictates it, and a named slot records under its name
autoBuildState.markTriggered(branch, today, slot) val started =
startBuildIfDue(
branch,
allowSameCommit = true,
config,
pullRequestHeads,
workingDir,
slot.buildCommand.ifBlank { null },
slot.name.ifBlank { branch },
)
if (started) {
autoBuildState.markTriggered(branch, today, slot.time)
} }
} }
} }
@@ -307,7 +321,7 @@ class Watcher(
// never delete under a build that is still queued or executing // never delete under a build that is still queued or executing
buildExecutor.currentBuilds().forEach { keep += ArtifactKeys.branchKey(it.branch) } buildExecutor.currentBuilds().forEach { keep += ArtifactKeys.branchKey(it.branch) }
repository repository
.latestPerBranch() .latestPerName()
.filter { it.status == BuildStatus.PENDING || it.status == BuildStatus.RUNNING } .filter { it.status == BuildStatus.PENDING || it.status == BuildStatus.RUNNING }
.forEach { keep += ArtifactKeys.branchKey(it.branch) } .forEach { keep += ArtifactKeys.branchKey(it.branch) }
var removed = false var removed = false
+8 -6
View File
@@ -258,8 +258,9 @@ function actionButton(symbol, title, className, dataset) {
function renderBuildRow(build, allowRestart) { function renderBuildRow(build, allowRestart) {
const row = document.createElement("tr"); const row = document.createElement("tr");
const displayName = build.name || build.branch;
row.dataset.artifactKey = build.artifactKey || ""; row.dataset.artifactKey = build.artifactKey || "";
row.dataset.branch = build.branch; row.dataset.branch = displayName;
row.dataset.startedAt = build.startedAt || ""; row.dataset.startedAt = build.startedAt || "";
row.dataset.runningSince = build.runningSince || ""; row.dataset.runningSince = build.runningSince || "";
row.dataset.status = build.status || "unknown"; row.dataset.status = build.status || "unknown";
@@ -274,8 +275,8 @@ function renderBuildRow(build, allowRestart) {
const branchTools = elem("span", "link-tools"); const branchTools = elem("span", "link-tools");
branchTools.appendChild( branchTools.appendChild(
giteaRepoUrl giteaRepoUrl
? externalLink(giteaRepoUrl + "/src/branch/" + encodeBranchPath(build.branch), build.branch) ? externalLink(giteaRepoUrl + "/src/branch/" + encodeBranchPath(build.branch), displayName)
: elem("span", null, build.branch), : elem("span", null, displayName),
); );
branchTools.appendChild(copyButton(build.branch, "branch name")); branchTools.appendChild(copyButton(build.branch, "branch name"));
branchCell.appendChild(branchTools); branchCell.appendChild(branchTools);
@@ -332,7 +333,7 @@ function renderBuildRow(build, allowRestart) {
const actionsCell = elem("td", "actions-cell"); const actionsCell = elem("td", "actions-cell");
const actions = elem("div", "actions"); const actions = elem("div", "actions");
if (allowRestart) { if (allowRestart) {
actions.appendChild(actionButton("↻", "Restart build", null, { action: "restart", branch: build.branch })); actions.appendChild(actionButton("↻", "Restart build", null, { action: "restart", branch: displayName }));
} }
if (build.artifactKey) { if (build.artifactKey) {
actions.appendChild( actions.appendChild(
@@ -386,10 +387,11 @@ function renderBuildCard(build) {
const header = elem("header", "build-card-header"); const header = elem("header", "build-card-header");
header.appendChild(statusBadge(build.status || "running")); header.appendChild(statusBadge(build.status || "running"));
const branchTools = elem("span", "branch link-tools"); const branchTools = elem("span", "branch link-tools");
const displayName = build.name || build.branch;
branchTools.appendChild( branchTools.appendChild(
giteaRepoUrl giteaRepoUrl
? externalLink(giteaRepoUrl + "/src/branch/" + encodeBranchPath(build.branch), build.branch) ? externalLink(giteaRepoUrl + "/src/branch/" + encodeBranchPath(build.branch), displayName)
: elem("span", null, build.branch), : elem("span", null, displayName),
); );
header.appendChild(branchTools); header.appendChild(branchTools);
const commitCode = elem("code"); const commitCode = elem("code");
+4 -4
View File
@@ -24,15 +24,15 @@
<td class="empty" colspan="7" th:text="${emptyMessage}">No builds recorded yet.</td> <td class="empty" colspan="7" th:text="${emptyMessage}">No builds recorded yet.</td>
</tr> </tr>
<tr th:each="row : ${rows}" <tr th:each="row : ${rows}"
th:attr="data-artifact-key=${row.artifactKey},data-branch=${row.branch},data-started-at=${row.startedAtIso},data-running-since=${row.runningSinceIso},data-status=${row.status}"> th:attr="data-artifact-key=${row.artifactKey},data-branch=${row.name},data-started-at=${row.startedAtIso},data-running-since=${row.runningSinceIso},data-status=${row.status}">
<td data-label="Status"> <td data-label="Status">
<span th:class="'status status-' + ${row.status}" th:text="${row.status}">success</span> <span th:class="'status status-' + ${row.status}" th:text="${row.status}">success</span>
</td> </td>
<td class="branch" data-label="Branch"> <td class="branch" data-label="Branch">
<span class="link-tools"> <span class="link-tools">
<a th:if="${row.branchUrl != null}" th:href="${row.branchUrl}" target="_blank" <a th:if="${row.branchUrl != null}" th:href="${row.branchUrl}" target="_blank"
rel="noopener noreferrer" th:text="${row.branch}">main</a> rel="noopener noreferrer" th:text="${row.name}">main</a>
<span th:if="${row.branchUrl == null}" th:text="${row.branch}">main</span> <span th:if="${row.branchUrl == null}" th:text="${row.name}">main</span>
<button class="copy-button" type="button" th:attr="data-copy=${row.branch}" <button class="copy-button" type="button" th:attr="data-copy=${row.branch}"
title="Copy branch name" aria-label="Copy branch name"></button> title="Copy branch name" aria-label="Copy branch name"></button>
</span> </span>
@@ -65,7 +65,7 @@
<td class="actions-cell"> <td class="actions-cell">
<div class="actions"> <div class="actions">
<button th:if="${allowRestart}" class="action-button" type="button" data-action="restart" <button th:if="${allowRestart}" class="action-button" type="button" data-action="restart"
th:attr="data-branch=${row.branch}" title="Restart build" th:attr="data-branch=${row.name}" title="Restart build"
aria-label="Restart build"></button> aria-label="Restart build"></button>
<button th:if="${row.artifactKey != ''}" class="action-button delete-button" type="button" <button th:if="${row.artifactKey != ''}" class="action-button delete-button" type="button"
data-action="delete" th:attr="data-artifact-key=${row.artifactKey}" data-action="delete" th:attr="data-artifact-key=${row.artifactKey}"
+3 -3
View File
@@ -15,8 +15,8 @@
<span th:class="'status status-' + ${build.status}" th:text="${build.status}">running</span> <span th:class="'status status-' + ${build.status}" th:text="${build.status}">running</span>
<span class="branch link-tools"> <span class="branch link-tools">
<a th:if="${build.branchUrl != null}" th:href="${build.branchUrl}" target="_blank" <a th:if="${build.branchUrl != null}" th:href="${build.branchUrl}" target="_blank"
rel="noopener noreferrer" th:text="${build.branch}">main</a> rel="noopener noreferrer" th:text="${build.name}">main</a>
<span th:if="${build.branchUrl == null}" th:text="${build.branch}">main</span> <span th:if="${build.branchUrl == null}" th:text="${build.name}">main</span>
</span> </span>
<code> <code>
<a th:if="${build.commitUrl != null}" th:href="${build.commitUrl}" target="_blank" <a th:if="${build.commitUrl != null}" th:href="${build.commitUrl}" target="_blank"
@@ -30,7 +30,7 @@
th:attr="data-artifact-key=${build.artifactKey}" title="Cancel build">× Cancel</button> th:attr="data-artifact-key=${build.artifactKey}" title="Cancel build">× Cancel</button>
</span> </span>
</header> </header>
<pre class="live-log" th:attr="aria-label='live log of ' + ${build.branch}"></pre> <pre class="live-log" th:attr="aria-label='live log of ' + ${build.name}"></pre>
</section> </section>
</div> </div>
</main> </main>
@@ -219,6 +219,50 @@ 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")
val nightly = h.executor.startBuild("main", "sha-1", h.workingDir, "echo nightly-\$branch")
awaitStatus(h, "main", 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"
// the same branch without an override runs the regular command
val regular = h.executor.startBuild("main", "sha-2", h.workingDir)
awaitStatus(h, "main", BuildStatus.SUCCESS)
awaitIdle(h)
Files.readString(regular.stagingDir.resolve("build.stdout.log")) shouldContain "regular-main"
h.repository
.latestFor("main")
.shouldNotBeNull()
.buildCommandOverride shouldBe null
}
test("a named build is recorded under its name, keyed by the sanitized name") {
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)
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
}
test("startBuild returns the active build of the same branch and commit instead of stacking a duplicate") { test("startBuild returns the active build of the same branch and commit instead of stacking a duplicate") {
val h = harness("sleep 30") val h = harness("sleep 30")
@@ -228,6 +272,10 @@ class BuildExecutorTest : FunSpec() {
duplicate.artifactKey shouldBe first.artifactKey duplicate.artifactKey shouldBe first.artifactKey
h.repository.history().map { it.artifactKey } shouldContainExactly listOf(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")
nightly.artifactKey shouldNotBe first.artifactKey
// another commit of the branch is a distinct build, queued behind the first // another commit of the branch is a distinct build, queued behind the first
val newerCommit = h.executor.startBuild("main", "abc124", h.workingDir) val newerCommit = h.executor.startBuild("main", "abc124", h.workingDir)
newerCommit.artifactKey shouldNotBe first.artifactKey newerCommit.artifactKey shouldNotBe first.artifactKey
@@ -237,6 +285,7 @@ class BuildExecutorTest : FunSpec() {
val again = h.executor.startBuild("main", "abc123", h.workingDir) val again = h.executor.startBuild("main", "abc123", h.workingDir)
again.artifactKey shouldNotBe first.artifactKey again.artifactKey shouldNotBe first.artifactKey
h.executor.cancel(nightly.artifactKey).shouldBeTrue()
h.executor.cancel(newerCommit.artifactKey).shouldBeTrue() h.executor.cancel(newerCommit.artifactKey).shouldBeTrue()
h.executor.cancel(again.artifactKey).shouldBeTrue() h.executor.cancel(again.artifactKey).shouldBeTrue()
eventually(30.seconds) { eventually(30.seconds) {
@@ -39,7 +39,7 @@ class FileBuildResultRepositoryTest : FunSpec() {
val repository = FileBuildResultRepository(newFile()) val repository = FileBuildResultRepository(newFile())
repository.history().shouldBeEmpty() repository.history().shouldBeEmpty()
repository.latestPerBranch().shouldBeEmpty() repository.latestPerName().shouldBeEmpty()
repository.latestFor("main").shouldBeNull() repository.latestFor("main").shouldBeNull()
} }
@@ -99,19 +99,52 @@ class FileBuildResultRepositoryTest : FunSpec() {
repository.latestGreenFor("unknown").shouldBeNull() repository.latestGreenFor("unknown").shouldBeNull()
} }
test("latestPerBranch returns one entry per branch, newest first") { test("latestPerName returns one entry per build name, newest first") {
val repository = FileBuildResultRepository(newFile()) val repository = FileBuildResultRepository(newFile())
repository.append(result(branch = "main", startedOffsetSeconds = 0)) repository.append(result(branch = "main", startedOffsetSeconds = 0))
repository.append(result(branch = "main", startedOffsetSeconds = 60)) repository.append(result(branch = "main", startedOffsetSeconds = 60))
repository.append(result(branch = "feature/x", startedOffsetSeconds = 120)) repository.append(result(branch = "feature/x", startedOffsetSeconds = 120))
repository.latestPerBranch() shouldContainExactly repository.latestPerName() shouldContainExactly
listOf( listOf(
result(branch = "feature/x", startedOffsetSeconds = 120), result(branch = "feature/x", startedOffsetSeconds = 120),
result(branch = "main", startedOffsetSeconds = 60), result(branch = "main", startedOffsetSeconds = 60),
) )
} }
test("a named result forms its own pool for latest, green, supersession, and retention") {
val repository = FileBuildResultRepository(newFile())
repository.append(result(branch = "main", status = BuildStatus.SUCCESS, startedOffsetSeconds = 0))
repository.append(
result(branch = "main", status = BuildStatus.FAILED, startedOffsetSeconds = 10, artifactKey = "nightly-10")
.copy(name = "main@nightly"),
)
repository.append(result(branch = "main", status = BuildStatus.SUCCESS, startedOffsetSeconds = 20))
repository.latestFor("main")!!.artifactKey shouldBe "main-20"
repository.latestFor("main@nightly")!!.artifactKey shouldBe "nightly-10"
// the branch pool's green build does not leak into the nightly pool
repository.latestGreenFor("main@nightly").shouldBeNull()
repository.latestPerName().map { it.name } shouldContainExactlyInAnyOrder listOf("main", "main@nightly")
// retention counts per name: retention 1 keeps the nightly although the branch built more recently
val removed = repository.prune(listOf("main"), retentionPerBranch = 1)
removed.map { it.artifactKey } shouldContainExactly listOf("main-0")
repository.history().map { it.artifactKey } shouldContainExactlyInAnyOrder listOf("main-20", "nightly-10")
}
test("prune drops a named pool once its underlying branch is gone from origin") {
val repository = FileBuildResultRepository(newFile())
repository.append(
result(branch = "gone", status = BuildStatus.SUCCESS, startedOffsetSeconds = 0, artifactKey = "nightly-key")
.copy(name = "gone@nightly"),
)
val removed = repository.prune(listOf("main"), retentionPerBranch = 3)
removed.map { it.artifactKey } shouldContainExactly listOf("nightly-key")
}
test("updateLatest transforms only the newest entry of the branch") { test("updateLatest transforms only the newest entry of the branch") {
val repository = FileBuildResultRepository(newFile()) val repository = FileBuildResultRepository(newFile())
repository.append(result(branch = "main", status = BuildStatus.SUCCESS, startedOffsetSeconds = 0)) repository.append(result(branch = "main", status = BuildStatus.SUCCESS, startedOffsetSeconds = 0))
@@ -44,7 +44,7 @@ class RetryCommandTest : FunSpec() {
} }
test("retries every branch whose latest build failed, but no others") { test("retries every branch whose latest build failed, but no others") {
every { repository.latestPerBranch() } returns every { repository.latestPerName() } returns
listOf( listOf(
result("main", BuildStatus.FAILED), result("main", BuildStatus.FAILED),
result("feature/ok", BuildStatus.SUCCESS), result("feature/ok", BuildStatus.SUCCESS),
@@ -52,19 +52,19 @@ class RetryCommandTest : FunSpec() {
) )
every { gitService.originHeadCommit("main", dir) } returns "head-main" every { gitService.originHeadCommit("main", dir) } returns "head-main"
every { gitService.originHeadCommit("feature/y", dir) } returns "head-y" every { gitService.originHeadCommit("feature/y", dir) } returns "head-y"
every { consoleBuildRunner.buildAndStream(any(), any(), dir) } returns BuildStatus.SUCCESS every { consoleBuildRunner.buildAndStream(any(), any(), dir, anyNullable(), any()) } returns BuildStatus.SUCCESS
var exitCode = -1 var exitCode = -1
captureConsole { exitCode = command().call() } captureConsole { exitCode = command().call() }
exitCode shouldBe 0 exitCode shouldBe 0
verify { consoleBuildRunner.buildAndStream("main", "head-main", dir) } verify { consoleBuildRunner.buildAndStream("main", "head-main", dir, null, "main") }
verify { consoleBuildRunner.buildAndStream("feature/y", "head-y", dir) } verify { consoleBuildRunner.buildAndStream("feature/y", "head-y", dir, null, "feature/y") }
verify(exactly = 0) { consoleBuildRunner.buildAndStream("feature/ok", any(), dir) } verify(exactly = 0) { consoleBuildRunner.buildAndStream("feature/ok", any(), dir, anyNullable(), any()) }
} }
test("exits with code 1 when a retried build fails again") { test("exits with code 1 when a retried build fails again") {
every { repository.latestPerBranch() } returns listOf(result("main", BuildStatus.FAILED)) every { repository.latestPerName() } returns listOf(result("main", BuildStatus.FAILED))
every { gitService.originHeadCommit("main", dir) } returns "head-main" every { gitService.originHeadCommit("main", dir) } returns "head-main"
every { consoleBuildRunner.buildAndStream("main", "head-main", dir) } returns BuildStatus.FAILED every { consoleBuildRunner.buildAndStream("main", "head-main", dir) } returns BuildStatus.FAILED
@@ -75,7 +75,7 @@ class RetryCommandTest : FunSpec() {
} }
test("skips failed branches that are gone from origin") { test("skips failed branches that are gone from origin") {
every { repository.latestPerBranch() } returns listOf(result("gone", BuildStatus.FAILED)) every { repository.latestPerName() } returns listOf(result("gone", BuildStatus.FAILED))
every { gitService.originHeadCommit("gone", dir) } returns null every { gitService.originHeadCommit("gone", dir) } returns null
var exitCode = -1 var exitCode = -1
@@ -87,7 +87,7 @@ class RetryCommandTest : FunSpec() {
} }
test("prints a hint when there is nothing to retry") { test("prints a hint when there is nothing to retry") {
every { repository.latestPerBranch() } returns every { repository.latestPerName() } returns
listOf( listOf(
result("main", BuildStatus.SUCCESS), result("main", BuildStatus.SUCCESS),
result("feature/x", BuildStatus.INTERRUPTED), result("feature/x", BuildStatus.INTERRUPTED),
@@ -36,7 +36,7 @@ class StatusCommandTest : FunSpec() {
} }
test("prints the latest build per branch as a table with short commits and legacy duration format") { test("prints the latest build per branch as a table with short commits and legacy duration format") {
every { repository.latestPerBranch() } returns every { repository.latestPerName() } returns
listOf( listOf(
result("main", BuildStatus.SUCCESS), result("main", BuildStatus.SUCCESS),
result("feature/x", BuildStatus.FAILED, duration = null), result("feature/x", BuildStatus.FAILED, duration = null),
@@ -75,7 +75,7 @@ class StatusCommandTest : FunSpec() {
} }
test("prints a hint when no builds are recorded yet") { test("prints a hint when no builds are recorded yet") {
every { repository.latestPerBranch() } returns emptyList() every { repository.latestPerName() } returns emptyList()
var exitCode = -1 var exitCode = -1
val console = captureConsole { exitCode = StatusCommand(repository).call() } val console = captureConsole { exitCode = StatusCommand(repository).call() }
@@ -47,6 +47,39 @@ class ConfigLoaderTest : FunSpec() {
loader.load(dir).builds.maxConcurrent shouldBe 3 loader.load(dir).builds.maxConcurrent shouldBe 3
} }
test("autoBuild.times accepts plain HH:MM entries and slot objects with their own build command, mixed") {
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
""".trimIndent(),
)
val times =
loader
.load(dir)
.branches
.getValue("main")
.autoBuild.times
times shouldBe
listOf(
AutoBuildSlot("01:00"),
AutoBuildSlot("04:00", "./gradlew fullCheck", "main@nightly"),
)
// 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"
}
test("repo install config overrides project config for same keys") { test("repo install config overrides project config for same keys") {
val dir = Files.createTempDirectory("gittally-test") val dir = Files.createTempDirectory("gittally-test")
dir.resolve(".gittally.yml").toFile().writeText( dir.resolve(".gittally.yml").toFile().writeText(
@@ -27,6 +27,10 @@ class BranchListingTest : FunSpec() {
) )
init { init {
beforeEach {
every { repository.latestPerName() } returns emptyList()
}
test("orders main/master first, then flat names, then hierarchical names") { test("orders main/master first, then flat names, then hierarchical names") {
every { gitService.originBranchHeads(any()) } returns every { gitService.originBranchHeads(any()) } returns
mapOf( mapOf(
@@ -69,14 +73,36 @@ class BranchListingTest : FunSpec() {
test("a failed latest build carries no permanent URL — it belongs to the older green build") { 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 { gitService.originBranchHeads(any()) } returns mapOf("feature/x" to "aaa")
every { repository.latestFor("feature/x") } returns 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 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() val branches = listing.branches()
branches[0].status shouldBe "failed" branches[0].status shouldBe "failed"
branches[0].latestGreenUrl shouldBe null 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 { init {
test("resolves the hash-free permanent key to the branch's latest green build") { 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") every { repository.latestGreenFor("feature/x") } returns result("feature/x")
permalinks.latestGreenBuild("feature_x") shouldBe result("feature/x") permalinks.latestGreenBuild("feature_x") shouldBe result("feature/x")
} }
test("resolves the full branch key with hash suffix") { 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") every { repository.latestGreenFor("feature/x") } returns result("feature/x")
permalinks.latestGreenBuild(ArtifactKeys.branchKey("feature/x")) shouldBe result("feature/x") permalinks.latestGreenBuild(ArtifactKeys.branchKey("feature/x")) shouldBe result("feature/x")
} }
test("an unknown branch key answers 404") { 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") } val exception = shouldThrow<ResponseStatusException> { permalinks.latestGreenBuild("gone") }
@@ -55,7 +55,7 @@ class BranchPermalinksTest : FunSpec() {
} }
test("a branch without a green build answers 404") { 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 every { repository.latestGreenFor("main") } returns null
val exception = shouldThrow<ResponseStatusException> { permalinks.latestGreenBuild("main") } 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") { 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") } 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") { 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") every { repository.latestGreenFor("feature/x") } returns result("feature/x")
permalinks.latestGreenBuild(ArtifactKeys.branchKey("feature/x")) shouldBe 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") { test("permanentUrl uses the hash-free branch key") {
BranchPermalinks.permanentUrl("feature/x") shouldBe "/branches/feature_x" 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") { 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 mockMvc
.perform(get("/api/builds/latest")) .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") { test("restart enqueues the branch's last recorded commit, also for branch names with slashes") {
val liveLogFile = tempDir.resolve("restart.log") 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 every { buildExecutor.startBuild("feature/topic", successResult.commit) } returns
runningBuild(liveLogFile).copy(branch = "feature/topic") runningBuild(liveLogFile).copy(branch = "feature/topic", name = "feature/topic")
mockMvc mockMvc
.perform( .perform(
@@ -167,12 +167,58 @@ class BuildsApiControllerTest : FunSpec() {
verify { buildExecutor.startBuild("feature/topic", successResult.commit) } 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") { test("restart of a never-built branch enqueues its origin head commit") {
val liveLogFile = tempDir.resolve("first-build.log") val liveLogFile = tempDir.resolve("first-build.log")
every { repository.latestFor("fresh") } returns null every { repository.latestFor("fresh") } returns null
every { gitService.originHeadCommit("fresh", any()) } returns successResult.commit every { gitService.originHeadCommit("fresh", any()) } returns successResult.commit
every { buildExecutor.startBuild("fresh", successResult.commit) } returns every { buildExecutor.startBuild("fresh", successResult.commit) } returns
runningBuild(liveLogFile).copy(branch = "fresh") runningBuild(liveLogFile).copy(branch = "fresh", name = "fresh")
mockMvc mockMvc
.perform(post("/api/builds/restart").param("branch", "fresh").header(BuildsApiController.TOKEN_HEADER, "secret")) .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") { 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 mockMvc
.perform(get("/")) .perform(get("/"))
@@ -129,7 +129,7 @@ class UiControllerTest : FunSpec() {
} }
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") {
every { repository.latestPerBranch() } returns listOf(successResult) every { repository.latestPerName() } returns listOf(successResult)
mockMvc mockMvc
.perform(get("/")) .perform(get("/"))
@@ -188,9 +188,15 @@ class UiControllerTest : FunSpec() {
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(
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,
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 mockMvc
@@ -479,7 +485,7 @@ class UiControllerTest : FunSpec() {
test("branch names with HTML metacharacters render escaped") { test("branch names with HTML metacharacters render escaped") {
val nasty = "feat/<script>alert('x')</script>" 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 mockMvc
.perform(get("/")) .perform(get("/"))
@@ -1,5 +1,6 @@
package de.hoennig.gittally.watcher package de.hoennig.gittally.watcher
import de.hoennig.gittally.config.AutoBuildSlot
import io.kotest.core.spec.style.FunSpec import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.booleans.shouldBeFalse import io.kotest.matchers.booleans.shouldBeFalse
import io.kotest.matchers.booleans.shouldBeTrue import io.kotest.matchers.booleans.shouldBeTrue
@@ -13,19 +14,27 @@ import java.time.LocalTime
class AutoBuildStateTest : FunSpec() { class AutoBuildStateTest : FunSpec() {
private fun stateFile(): Path = Files.createTempDirectory("gittally-autobuild-test").resolve("auto-builds.json") private fun stateFile(): Path = Files.createTempDirectory("gittally-autobuild-test").resolve("auto-builds.json")
private fun slots(vararg times: String) = times.map { AutoBuildSlot(it) }
init { init {
test("latestDueSlot picks the latest slot at or before now") { test("latestDueSlot picks the latest slot at or before now") {
val times = listOf("01:00", "11:00", "13:00") val times = slots("01:00", "11:00", "13:00")
AutoBuildSlots.latestDueSlot(times, LocalTime.parse("00:59")).shouldBeNull() AutoBuildSlots.latestDueSlot(times, LocalTime.parse("00:59")).shouldBeNull()
AutoBuildSlots.latestDueSlot(times, LocalTime.parse("01:00")) shouldBe "01:00" AutoBuildSlots.latestDueSlot(times, LocalTime.parse("01:00"))?.time shouldBe "01:00"
AutoBuildSlots.latestDueSlot(times, LocalTime.parse("12:00")) shouldBe "11:00" AutoBuildSlots.latestDueSlot(times, LocalTime.parse("12:00"))?.time shouldBe "11:00"
AutoBuildSlots.latestDueSlot(times, LocalTime.parse("23:59")) shouldBe "13: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
} }
test("latestDueSlot skips invalid slots but keeps the valid ones") { test("latestDueSlot skips invalid slots but keeps the valid ones") {
AutoBuildSlots.latestDueSlot(listOf("25:99", "nope", "02:00"), LocalTime.parse("12:00")) shouldBe "02:00" AutoBuildSlots.latestDueSlot(slots("25:99", "nope", "02:00"), LocalTime.parse("12:00"))?.time shouldBe "02:00"
AutoBuildSlots.latestDueSlot(listOf("25:99"), LocalTime.parse("12:00")).shouldBeNull() AutoBuildSlots.latestDueSlot(slots("25:99"), LocalTime.parse("12:00")).shouldBeNull()
} }
test("latestDueSlot of an empty slot list is null") { test("latestDueSlot of an empty slot list is null") {
@@ -10,6 +10,7 @@ import de.hoennig.gittally.build.GitWorktreeWorkspaces
import de.hoennig.gittally.build.RunningBuild import de.hoennig.gittally.build.RunningBuild
import de.hoennig.gittally.config.ArtifactsConfig import de.hoennig.gittally.config.ArtifactsConfig
import de.hoennig.gittally.config.AutoBuildConfig import de.hoennig.gittally.config.AutoBuildConfig
import de.hoennig.gittally.config.AutoBuildSlot
import de.hoennig.gittally.config.BranchConfig import de.hoennig.gittally.config.BranchConfig
import de.hoennig.gittally.config.ConfigLoader import de.hoennig.gittally.config.ConfigLoader
import de.hoennig.gittally.config.GitTallyConfig import de.hoennig.gittally.config.GitTallyConfig
@@ -78,7 +79,7 @@ class WatcherTest : FunSpec() {
every { gitService.worktreePrune(any()) } returns Unit every { gitService.worktreePrune(any()) } returns Unit
every { gitService.fastForwardLocalBranches(any()) } returns emptyList() every { gitService.fastForwardLocalBranches(any()) } returns emptyList()
every { buildExecutor.currentBuilds() } returns emptyList() every { buildExecutor.currentBuilds() } returns emptyList()
every { buildExecutor.startBuild(any(), any(), any()) } answers { every { buildExecutor.startBuild(any(), any(), any(), anyNullable(), any()) } answers {
val branch = firstArg<String>() val branch = firstArg<String>()
val commit = secondArg<String>() val commit = secondArg<String>()
startedBuilds += branch to commit startedBuilds += branch to commit
@@ -92,14 +93,18 @@ class WatcherTest : FunSpec() {
branch: String, branch: String,
status: BuildStatus, status: BuildStatus,
commit: String = "commit-0", commit: String = "commit-0",
buildCommandOverride: String? = null,
name: String = branch,
): BuildResult { ): BuildResult {
val startedAt = noon.minusSeconds(3600).plusSeconds(seedCounter++) val startedAt = noon.minusSeconds(3600).plusSeconds(seedCounter++)
val result = val result =
BuildResult( BuildResult(
branch = branch, branch = branch,
name = name,
commit = commit, commit = commit,
status = status, status = status,
startedAt = startedAt, startedAt = startedAt,
buildCommandOverride = buildCommandOverride,
artifactKey = ArtifactKeys.buildKey(branch, startedAt), artifactKey = ArtifactKeys.buildKey(branch, startedAt),
) )
repository.append(result) repository.append(result)
@@ -136,7 +141,7 @@ class WatcherTest : FunSpec() {
branches = branches =
mapOf( mapOf(
"default" to BranchConfig(), "default" to BranchConfig(),
"main" to BranchConfig(autoBuild = AutoBuildConfig(enabled = true, times = times.toList())), "main" to BranchConfig(autoBuild = AutoBuildConfig(enabled = true, times = times.map { AutoBuildSlot(it) })),
), ),
) )
@@ -349,7 +354,7 @@ class WatcherTest : FunSpec() {
"main" to "main" to
BranchConfig( BranchConfig(
requirePullRequest = true, requirePullRequest = true,
autoBuild = AutoBuildConfig(enabled = true, times = listOf("11:00")), autoBuild = AutoBuildConfig(enabled = true, times = listOf(AutoBuildSlot("11:00"))),
), ),
), ),
), ),
@@ -374,9 +379,80 @@ class WatcherTest : FunSpec() {
harness.watcher.poll(harness.workingDir) harness.watcher.poll(harness.workingDir)
harness.startedBuilds shouldContainExactly listOf("main" to "commit-abc") 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) }
harness.autoBuildState().isTriggered("main", LocalDate.parse("2026-07-07"), "11:00").shouldBeTrue() 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") {
val harness =
Harness(
GitTallyConfig(
branches =
mapOf(
"default" to BranchConfig(),
"main" to
BranchConfig(
autoBuild =
AutoBuildConfig(
enabled = true,
times = listOf(AutoBuildSlot("11:00", "./gradlew fullCheck")),
),
),
),
),
)
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
harness.seed("main", BuildStatus.RUNNING, 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", "main@nightly") }
harness.autoBuildState().isTriggered("main", LocalDate.parse("2026-07-07"), "11:00").shouldBeTrue()
}
test("a commit-triggered build never carries a build command override") {
val harness = Harness()
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)
verify { harness.buildExecutor.startBuild("main", "commit-main", any(), null) }
}
test("an auto-build slot stays untriggered while the branch is still building") { test("an auto-build slot stays untriggered while the branch is still building") {
val harness = Harness(autoBuildConfig("11:00")) val harness = Harness(autoBuildConfig("11:00"))
harness.seed("main", BuildStatus.RUNNING, commit = "commit-abc") harness.seed("main", BuildStatus.RUNNING, commit = "commit-abc")
@@ -420,6 +496,23 @@ class WatcherTest : FunSpec() {
harness.startedBuilds shouldContainExactly listOf("main" to "commit-2") harness.startedBuilds shouldContainExactly listOf("main" to "commit-2")
} }
test("startup recovery re-enqueues an interrupted auto-slot build with its recorded command and name") {
val harness = Harness()
harness.seed(
"main",
BuildStatus.INTERRUPTED,
commit = "commit-1",
buildCommandOverride = "./gradlew fullCheck",
name = "main@nightly",
)
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") }
}
test("startup recovery closes out an orphaned PENDING build of a branch gone from origin") { test("startup recovery closes out an orphaned PENDING build of a branch gone from origin") {
val harness = Harness() val harness = Harness()
val orphan = harness.seed("gone", BuildStatus.PENDING, commit = "commit-1") val orphan = harness.seed("gone", BuildStatus.PENDING, commit = "commit-1")