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
@@ -67,15 +67,27 @@ class BuildExecutor(
* 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 *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(
branch: String,
commit: String,
workingDir: Path = Paths.get("."),
buildCommandOverride: String? = null,
name: String = branch,
): RunningBuild {
val duplicate =
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) {
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 =
RunningBuild(
branch = branch,
name = name,
commit = commit,
artifactKey = ArtifactKeys.buildKey(branch, startedAt),
artifactKey = ArtifactKeys.buildKey(name, startedAt),
startedAt = startedAt,
stagingDir = stagingDir,
liveLogFile = stagingDir.resolve(LIVE_LOG_FILE),
buildCommandOverride = buildCommandOverride,
)
val pending =
BuildResult(
branch = branch,
name = name,
commit = commit,
status = BuildStatus.PENDING,
startedAt = startedAt,
duration = null,
buildCommandOverride = buildCommandOverride,
artifactKey = runningBuild.artifactKey,
)
repository.append(pending)
@@ -244,11 +260,12 @@ class BuildExecutor(
workspace: Path,
): Int {
val branchConfig = branchConfig(build.runningBuild.branch, build.workingDir, workspace)
val buildCommand = build.runningBuild.buildCommandOverride ?: branchConfig.buildCommand
val stagingDir = build.runningBuild.stagingDir
Files.newOutputStream(stagingDir.resolve(branchConfig.stdoutLog)).use { stdoutLog ->
Files.newOutputStream(stagingDir.resolve(branchConfig.stderrLog)).use { stderrLog ->
Files.newOutputStream(build.runningBuild.liveLogFile).use { liveLog ->
writeLiveLogHeader(liveLog, build.runningBuild, branchConfig, workspace)
writeLiveLogHeader(liveLog, build.runningBuild, branchConfig, buildCommand, workspace)
if (branchConfig.cleanCommand.isNotBlank()) {
val cleanExitCode =
runCommand(build, branchConfig, branchConfig.cleanCommand, workspace, stdoutLog, stderrLog, liveLog)
@@ -259,7 +276,7 @@ class BuildExecutor(
if (build.cancelled.get() || shuttingDown.get()) {
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(
branch = runningBuild.branch,
name = runningBuild.name,
commit = runningBuild.commit,
status = status,
startedAt = runningBuild.startedAt,
runningSince = runningBuild.runningSince,
duration = duration,
buildCommandOverride = runningBuild.buildCommandOverride,
artifactKey = runningBuild.artifactKey,
).also { repository.append(it) }
eventPublisher.publishEvent(BuildStatusChangedEvent(updated))
@@ -408,15 +427,22 @@ class BuildExecutor(
liveLog: OutputStream,
runningBuild: RunningBuild,
branchConfig: BranchConfig,
buildCommand: String,
workspace: Path,
) {
val header =
buildString {
appendLine("building branch: ${runningBuild.branch}")
if (runningBuild.name != runningBuild.branch) {
appendLine("build name: ${runningBuild.name}")
}
appendLine("commit: ${runningBuild.commit}")
appendLine("started: ${runningBuild.startedAt}")
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()) {
appendLine("clean command: ${branchConfig.cleanCommand}")
}
@@ -4,7 +4,15 @@ import java.time.Duration
import java.time.Instant
data class BuildResult(
/** The git branch that was built — Gitea links and origin lookups always use this. */
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 status: BuildStatus,
/** When the build was accepted (enqueued); the time until [runningSince] is queue wait. */
@@ -13,5 +21,11 @@ data class BuildResult(
val runningSince: Instant? = null,
/** Pure build execution time (from [runningSince]), without the queue wait. */
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,
)
@@ -2,12 +2,18 @@ package de.hoennig.gittally.build
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 {
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(
branch: String,
name: String,
transform: (BuildResult) -> BuildResult,
): BuildResult?
@@ -17,13 +23,13 @@ interface BuildResultRepository {
transform: (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. */
fun latestGreenFor(branch: String): BuildResult?
/** The newest SUCCESS entry recorded under [name] — the build behind the permanent `/branches/…` links. */
fun latestGreenFor(name: String): BuildResult?
/** The newest entry of each branch, newest first. */
fun latestPerBranch(): List<BuildResult>
/** The newest entry of each build name, newest first. */
fun latestPerName(): List<BuildResult>
/** All entries, newest first. */
fun history(): List<BuildResult>
@@ -33,17 +39,17 @@ interface BuildResultRepository {
/**
* 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>
/**
* Keeps the newest [retentionPerBranch] entries per branch and drops entries of branches
* not contained in [originBranches]. With [retentionCutoff], entries started before the
* cutoff are dropped even within the retention count — except each branch's newest entry,
* so dormant branches keep their last status. With [keepLatestGreen], the newest SUCCESS
* entry of each surviving branch is kept even beyond both limits, so the permanent
* `/branches/…` artifact links stay valid while newer builds fail.
* Keeps the newest [retentionPerBranch] entries per build name and drops entries whose
* branch is not contained in [originBranches]. With [retentionCutoff], entries started
* before the cutoff are dropped even within the retention count — except each name's
* newest entry, so dormant branches keep their last status. With [keepLatestGreen], the
* newest SUCCESS entry of each surviving name is kept even beyond both limits, so the
* permanent `/branches/…` artifact links stay valid while newer builds fail.
* 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
* belongs to the executor, and pruning its result would make it invisible in UI
@@ -38,12 +38,12 @@ class FileBuildResultRepository(
}
override fun updateLatest(
branch: String,
name: String,
transform: (BuildResult) -> BuildResult,
): BuildResult? {
synchronized(lock) {
val results = load()
val index = indexOfLatest(results, branch) ?: return null
val index = indexOfLatest(results, name) ?: return null
val updated = transform(results[index])
save(results.toMutableList().also { it[index] = updated })
return updated
@@ -66,19 +66,19 @@ class FileBuildResultRepository(
}
}
override fun latestFor(branch: String): BuildResult? {
override fun latestFor(name: String): BuildResult? {
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()
.filter { it.branch == branch && it.status == BuildStatus.SUCCESS }
.filter { it.name == name && it.status == BuildStatus.SUCCESS }
.maxByOrNull { it.startedAt }
override fun latestPerBranch(): List<BuildResult> =
override fun latestPerName(): List<BuildResult> =
load()
.groupBy { it.branch }
.groupBy { it.name }
.values
.map { entries -> entries.reduce(::laterOf) }
.sortedByDescending { it.startedAt }
@@ -104,7 +104,7 @@ class FileBuildResultRepository(
val updated =
results.map { result ->
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 ||
(result.status == BuildStatus.PENDING && superseded)
) {
@@ -138,7 +138,7 @@ class FileBuildResultRepository(
active.toSet() +
results
.filter { it.branch in originBranchSet }
.groupBy { it.branch }
.groupBy { it.name }
.values
.flatMap { entries ->
val newest =
@@ -146,7 +146,7 @@ class FileBuildResultRepository(
.sortedByDescending { it.startedAt }
.take(retentionPerBranch.coerceAtLeast(0))
.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)
}
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(
results: List<BuildResult>,
branch: String,
name: String,
): Int? {
var latest: Int? = null
results.forEachIndexed { index, result ->
if (result.branch == branch &&
if (result.name == name &&
(latest == null || !result.startedAt.isBefore(results[latest].startedAt))
) {
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. */
data class RunningBuild(
/** The git branch being built. */
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 artifactKey: String,
val startedAt: Instant,
@@ -13,6 +16,8 @@ data class RunningBuild(
val stagingDir: Path,
/** Combined stdout+stderr log, written live while the build runs. */
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. */
@Volatile
@@ -37,8 +37,10 @@ class ConsoleBuildRunner(
branch: String,
commit: String,
workingDir: Path = Paths.get("."),
buildCommandOverride: String? = null,
name: String = branch,
): BuildStatus {
val build = buildExecutor.startBuild(branch, commit, workingDir)
val build = buildExecutor.startBuild(branch, commit, workingDir, buildCommandOverride, name)
var printed = 0L
var result: BuildResult? = null
while (result?.status?.isTerminal != true) {
@@ -199,7 +199,12 @@ class InitCommand(
requirePullRequest: false
autoBuild:
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:
enabled: false # run clean/build commands in a Docker container instead of natively
image: "" # image for the build container; required when enabled
@@ -34,7 +34,7 @@ class RetryCommand(
val failed: List<BuildResult>
try {
fetchBestEffort()
failed = repository.latestPerBranch().filter { it.status == BuildStatus.FAILED }
failed = repository.latestPerName().filter { it.status == BuildStatus.FAILED }
} catch (e: Exception) {
System.err.println("error: ${e.message}")
return ExitCode.USAGE
@@ -51,7 +51,8 @@ class RetryCommand(
continue
}
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) {
anyFailed = true
}
@@ -26,7 +26,7 @@ class StatusCommand(
var history: Boolean = false
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()) {
println("(no builds recorded)")
} else {
@@ -40,7 +40,7 @@ class StatusCommand(
val rows =
results.map {
listOf(
it.branch,
it.name,
it.status.name.lowercase(),
it.commit.take(12),
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
import com.fasterxml.jackson.databind.annotation.JsonDeserialize
import com.fasterxml.jackson.databind.annotation.JsonSerialize
data class GitTallyConfig(
val server: ServerConfig = ServerConfig(),
val git: GitConfig = GitConfig(),
@@ -148,5 +151,30 @@ data class DockerConfig(
data class AutoBuildConfig(
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()
data class BuildResultDto(
/** The git branch that was built; the UI links this to Gitea. */
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 status: String,
val startedAt: Instant,
@@ -17,7 +20,7 @@ data class BuildResultDto(
val runningSince: Instant? = null,
val durationSeconds: Long?,
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,
) {
companion object {
@@ -26,13 +29,14 @@ data class BuildResultDto(
isLatestGreen: Boolean = false,
) = BuildResultDto(
branch = result.branch,
name = result.name,
commit = result.commit,
status = result.status.jsonName,
startedAt = result.startedAt,
runningSince = result.runningSince,
durationSeconds = result.duration?.seconds,
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.
*/
data class BranchDto(
/** The git branch; the UI links this to Gitea. */
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 status: String,
val startedAt: Instant?,
@@ -60,9 +67,11 @@ data class BranchDto(
headCommit: String,
latest: BuildResult?,
isLatestGreen: Boolean = false,
name: String = branch,
) = if (latest == null) {
BranchDto(
branch = branch,
name = name,
commit = headCommit,
status = CommitStatusDto.UNKNOWN_STATUS,
startedAt = null,
@@ -72,13 +81,14 @@ data class BranchDto(
} else {
BranchDto(
branch = branch,
name = name,
commit = latest.commit,
status = latest.status.jsonName,
startedAt = latest.startedAt,
runningSince = latest.runningSince,
durationSeconds = latest.duration?.seconds,
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. */
data class CurrentBuildDto(
val branch: String,
/** The build name; = [branch] unless a named auto-build slot triggered this build. */
val name: String = branch,
val commit: String,
val artifactKey: 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:
* every origin branch joined with its latest build (or an `unknown` placeholder
* when never built), ordered like the legacy branches view — main/master first,
* then flat names, then hierarchical names, alphabetical within each group.
* when never built), plus one extra row per named auto-build slot that has recorded
* 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.
*/
@Component
@@ -18,21 +20,27 @@ class BranchListing(
private val gitService: GitService,
private val repository: BuildResultRepository,
) {
fun branches(workingDir: Path = Paths.get(".")): List<BranchDto> =
gitService
.originBranchHeads(workingDir)
.entries
.sortedWith(compareBy({ sortGroup(it.key) }, { it.key }))
.map { (branch, headCommit) ->
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,
)
fun branches(workingDir: Path = Paths.get(".")): List<BranchDto> {
val heads = gitService.originBranchHeads(workingDir)
val branchRows =
heads.map { (branch, headCommit) ->
// latestFor groups by build name, so a named slot's results never shadow the branch row
BranchDto.from(branch, headCommit, repository.latestFor(branch))
}
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 =
when {
@@ -10,36 +10,38 @@ import org.springframework.web.server.ResponseStatusException
/**
* Resolves the permanent `/branches/<branch-key>/…` artifact URLs: the key is the
* hash-free [ArtifactKeys.permanentBranchKey] (the full [ArtifactKeys.branchKey]
* works too), and the target is the branch's latest green build. Resolution happens
* per request, so a permanent URL follows every new green build and stays valid as
* long as the branch exists on origin and has ever built successfully — green only,
* a permanent link never points at a failed build's artifacts.
* works too) of a build name — a branch, or a named auto-build slot like
* `master@nightly` — and the target is that name's latest green build. Resolution
* happens per request, so a permanent URL follows every new green build and stays
* 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
class BranchPermalinks(
private val repository: BuildResultRepository,
) {
fun latestGreenBuild(branchKey: String): BuildResult {
val branches =
val names =
repository
.latestPerBranch()
.map { it.branch }
.latestPerName()
.map { it.name }
.filter { branchKey == ArtifactKeys.permanentBranchKey(it) || branchKey == ArtifactKeys.branchKey(it) }
val branch =
when (branches.size) {
val name =
when (names.size) {
0 -> throw ResponseStatusException(HttpStatus.NOT_FOUND, "no recorded builds for branch key '$branchKey'")
1 -> branches.single()
1 -> names.single()
else -> throw ResponseStatusException(
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)
?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "branch '$branch' has no successful build")
return repository.latestGreenFor(name)
?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "branch '$name' has no successful build")
}
companion object {
/** The permanent artifact-index URL of [branch], shown in the branches view. */
fun permanentUrl(branch: String): String = "/branches/${ArtifactKeys.permanentBranchKey(branch)}"
/** The permanent artifact-index URL of the build name (branch or named slot), shown in the branches view. */
fun permanentUrl(name: String): String = "/branches/${ArtifactKeys.permanentBranchKey(name)}"
}
}
@@ -41,7 +41,7 @@ class BuildsApiController(
var workingDir: Path = Paths.get(".")
@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`. */
@GetMapping("/api/branches")
@@ -50,7 +50,7 @@ class BuildsApiController(
@GetMapping("/api/builds/history")
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`. */
@GetMapping("/api/builds/current")
@@ -59,6 +59,7 @@ class BuildsApiController(
return buildExecutor.currentBuilds().map { build ->
CurrentBuildDto(
branch = build.branch,
name = build.name,
commit = build.commit,
artifactKey = build.artifactKey,
status =
@@ -84,9 +85,11 @@ class BuildsApiController(
}
/**
* Re-enqueues the branch's last recorded commit — or its origin head for a branch
* never built, so the branches view can trigger first builds like legacy.
* The branch is a parameter, not a path variable, because branch names may contain
* Re-enqueues the last recorded commit of the build name [branch] — or the origin
* head for a branch never built, so the branches view can trigger first builds like
* 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).
*/
@PostMapping("/api/builds/restart")
@@ -95,14 +98,23 @@ class BuildsApiController(
@RequestHeader(name = TOKEN_HEADER, required = false) headerToken: String?,
): ResponseEntity<Any> {
rejectBadToken(headerToken)?.let { return it }
val latest = repository.latestFor(branch)
val commit =
repository.latestFor(branch)?.commit
latest?.commit
?: gitService.originHeadCommit(branch, workingDir)
?: 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(
BuildResultDto(
branch = running.branch,
name = running.name,
commit = running.commit,
status = BuildStatus.PENDING.jsonName,
startedAt = running.startedAt,
@@ -56,7 +56,7 @@ class UiController(
@GetMapping("/")
fun latest(model: Model): String {
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("allowRestart", true)
model.addAttribute("emptyMessage", "No builds recorded yet.")
@@ -84,10 +84,10 @@ class UiController(
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? =
if (repository.latestGreenFor(result.branch)?.artifactKey == result.artifactKey) {
BranchPermalinks.permanentUrl(result.branch)
if (repository.latestGreenFor(result.name)?.artifactKey == result.artifactKey) {
BranchPermalinks.permanentUrl(result.name)
} else {
null
}
@@ -100,6 +100,7 @@ class UiController(
buildExecutor.currentBuilds().map { build ->
CurrentBuildView(
branch = build.branch,
name = build.name,
commit = build.commit,
commitAbbrev = build.commit.take(12),
status =
@@ -92,7 +92,10 @@ object UiFormats {
/** One row of the build tables; [latestGreenUrl] only on the build that permanent link resolves to. */
data class BuildRowView(
/** The git branch — target of the Gitea link and the copy button. */
val branch: String,
/** The displayed build name; = [branch] unless a named auto-build slot recorded the build. */
val name: String,
val commit: String,
val commitAbbrev: String,
val status: String,
@@ -115,6 +118,7 @@ data class BuildRowView(
latestGreenUrl: String? = null,
) = BuildRowView(
branch = result.branch,
name = result.name,
commit = result.commit,
commitAbbrev = result.commit.take(12),
status = result.status.jsonName,
@@ -135,6 +139,7 @@ data class BuildRowView(
links: GiteaWebLinks,
) = BuildRowView(
branch = entry.branch,
name = entry.name,
commit = entry.commit,
commitAbbrev = entry.commit.take(12),
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`. */
data class CurrentBuildView(
val branch: String,
/** The displayed build name; = [branch] unless a named auto-build slot triggered the build. */
val name: String,
val commit: String,
val commitAbbrev: String,
val status: String,
@@ -5,6 +5,7 @@ import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.databind.SerializationFeature
import com.fasterxml.jackson.module.kotlin.readValue
import com.fasterxml.jackson.module.kotlin.registerKotlinModule
import de.hoennig.gittally.config.AutoBuildSlot
import org.slf4j.LoggerFactory
import java.nio.file.Files
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. */
fun latestDueSlot(
times: List<String>,
times: List<AutoBuildSlot>,
now: LocalTime,
): String? =
): AutoBuildSlot? =
times
.mapNotNull { slot ->
try {
LocalTime.parse(slot.trim()) to slot
LocalTime.parse(slot.time.trim()) to slot
} 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
}
}.filter { (parsed, _) -> !parsed.isAfter(now) }
@@ -92,7 +92,7 @@ class Watcher(
}
val restartable =
repository
.latestPerBranch()
.latestPerName()
.filter { it.status == BuildStatus.INTERRUPTED || it.status == BuildStatus.PENDING }
for (result in restartable) {
val commit = gitService.originHeadCommit(result.branch, workingDir)
@@ -110,7 +110,8 @@ class Watcher(
repository.updateByArtifactKey(result.artifactKey) { it.copy(status = BuildStatus.INTERRUPTED) }
}
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,
queuedBranches =
repository
.latestPerBranch()
.latestPerName()
.filter { it.status == BuildStatus.PENDING || it.status == BuildStatus.RUNNING }
.map { it.branch },
.map { it.name },
)
}
@@ -216,8 +217,10 @@ class Watcher(
config: GitTallyConfig,
pullRequestHeads: Lazy<Set<String>>,
workingDir: Path,
buildCommandOverride: String? = null,
name: String = branch,
): Boolean {
val latest = repository.latestFor(branch)
val latest = repository.latestFor(name)
if (latest?.status == BuildStatus.PENDING || latest?.status == BuildStatus.RUNNING) {
return false
}
@@ -233,7 +236,7 @@ class Watcher(
return false
}
log.info("enqueueing build of branch {} at commit {}", branch, commit)
buildExecutor.startBuild(branch, commit, workingDir)
buildExecutor.startBuild(branch, commit, workingDir, buildCommandOverride, name)
return true
}
@@ -261,16 +264,27 @@ class Watcher(
val timeOfDay = LocalTime.ofInstant(now, ZoneOffset.UTC)
for ((branch, branchConfig) in autoBuildBranches) {
val slot = AutoBuildSlots.latestDueSlot(branchConfig.autoBuild.times, timeOfDay) ?: continue
if (autoBuildState.isTriggered(branch, today, slot)) {
if (autoBuildState.isTriggered(branch, today, slot.time)) {
continue
}
if (branch !in originBranches) {
log.warn("skipping auto build of branch {}: branch is not on origin", branch)
continue
}
// rebuilding the already-built commit is the point of an auto build
if (startBuildIfDue(branch, allowSameCommit = true, config, pullRequestHeads, workingDir)) {
autoBuildState.markTriggered(branch, today, slot)
// rebuilding the already-built commit is the point of an auto build; a slot
// with its own command dictates it, and a named slot records under its name
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
buildExecutor.currentBuilds().forEach { keep += ArtifactKeys.branchKey(it.branch) }
repository
.latestPerBranch()
.latestPerName()
.filter { it.status == BuildStatus.PENDING || it.status == BuildStatus.RUNNING }
.forEach { keep += ArtifactKeys.branchKey(it.branch) }
var removed = false
+8 -6
View File
@@ -258,8 +258,9 @@ function actionButton(symbol, title, className, dataset) {
function renderBuildRow(build, allowRestart) {
const row = document.createElement("tr");
const displayName = build.name || build.branch;
row.dataset.artifactKey = build.artifactKey || "";
row.dataset.branch = build.branch;
row.dataset.branch = displayName;
row.dataset.startedAt = build.startedAt || "";
row.dataset.runningSince = build.runningSince || "";
row.dataset.status = build.status || "unknown";
@@ -274,8 +275,8 @@ function renderBuildRow(build, allowRestart) {
const branchTools = elem("span", "link-tools");
branchTools.appendChild(
giteaRepoUrl
? externalLink(giteaRepoUrl + "/src/branch/" + encodeBranchPath(build.branch), build.branch)
: elem("span", null, build.branch),
? externalLink(giteaRepoUrl + "/src/branch/" + encodeBranchPath(build.branch), displayName)
: elem("span", null, displayName),
);
branchTools.appendChild(copyButton(build.branch, "branch name"));
branchCell.appendChild(branchTools);
@@ -332,7 +333,7 @@ function renderBuildRow(build, allowRestart) {
const actionsCell = elem("td", "actions-cell");
const actions = elem("div", "actions");
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) {
actions.appendChild(
@@ -386,10 +387,11 @@ function renderBuildCard(build) {
const header = elem("header", "build-card-header");
header.appendChild(statusBadge(build.status || "running"));
const branchTools = elem("span", "branch link-tools");
const displayName = build.name || build.branch;
branchTools.appendChild(
giteaRepoUrl
? externalLink(giteaRepoUrl + "/src/branch/" + encodeBranchPath(build.branch), build.branch)
: elem("span", null, build.branch),
? externalLink(giteaRepoUrl + "/src/branch/" + encodeBranchPath(build.branch), displayName)
: elem("span", null, displayName),
);
header.appendChild(branchTools);
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>
</tr>
<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">
<span th:class="'status status-' + ${row.status}" th:text="${row.status}">success</span>
</td>
<td class="branch" data-label="Branch">
<span class="link-tools">
<a th:if="${row.branchUrl != null}" th:href="${row.branchUrl}" target="_blank"
rel="noopener noreferrer" th:text="${row.branch}">main</a>
<span th:if="${row.branchUrl == null}" th:text="${row.branch}">main</span>
rel="noopener noreferrer" th:text="${row.name}">main</a>
<span th:if="${row.branchUrl == null}" th:text="${row.name}">main</span>
<button class="copy-button" type="button" th:attr="data-copy=${row.branch}"
title="Copy branch name" aria-label="Copy branch name"></button>
</span>
@@ -65,7 +65,7 @@
<td class="actions-cell">
<div class="actions">
<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>
<button th:if="${row.artifactKey != ''}" class="action-button delete-button" type="button"
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 class="branch link-tools">
<a th:if="${build.branchUrl != null}" th:href="${build.branchUrl}" target="_blank"
rel="noopener noreferrer" th:text="${build.branch}">main</a>
<span th:if="${build.branchUrl == null}" th:text="${build.branch}">main</span>
rel="noopener noreferrer" th:text="${build.name}">main</a>
<span th:if="${build.branchUrl == null}" th:text="${build.name}">main</span>
</span>
<code>
<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>
</span>
</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>
</div>
</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") {
val h = harness("sleep 30")
@@ -228,6 +272,10 @@ class BuildExecutorTest : FunSpec() {
duplicate.artifactKey shouldBe first.artifactKey
h.repository.history().map { it.artifactKey } shouldContainExactly listOf(first.artifactKey)
// a build of the same commit with a command override runs a different command — not a duplicate
val nightly = h.executor.startBuild("main", "abc123", h.workingDir, "echo full-check")
nightly.artifactKey shouldNotBe first.artifactKey
// another commit of the branch is a distinct build, queued behind the first
val newerCommit = h.executor.startBuild("main", "abc124", h.workingDir)
newerCommit.artifactKey shouldNotBe first.artifactKey
@@ -237,6 +285,7 @@ class BuildExecutorTest : FunSpec() {
val again = h.executor.startBuild("main", "abc123", h.workingDir)
again.artifactKey shouldNotBe first.artifactKey
h.executor.cancel(nightly.artifactKey).shouldBeTrue()
h.executor.cancel(newerCommit.artifactKey).shouldBeTrue()
h.executor.cancel(again.artifactKey).shouldBeTrue()
eventually(30.seconds) {
@@ -39,7 +39,7 @@ class FileBuildResultRepositoryTest : FunSpec() {
val repository = FileBuildResultRepository(newFile())
repository.history().shouldBeEmpty()
repository.latestPerBranch().shouldBeEmpty()
repository.latestPerName().shouldBeEmpty()
repository.latestFor("main").shouldBeNull()
}
@@ -99,19 +99,52 @@ class FileBuildResultRepositoryTest : FunSpec() {
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())
repository.append(result(branch = "main", startedOffsetSeconds = 0))
repository.append(result(branch = "main", startedOffsetSeconds = 60))
repository.append(result(branch = "feature/x", startedOffsetSeconds = 120))
repository.latestPerBranch() shouldContainExactly
repository.latestPerName() shouldContainExactly
listOf(
result(branch = "feature/x", startedOffsetSeconds = 120),
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") {
val repository = FileBuildResultRepository(newFile())
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") {
every { repository.latestPerBranch() } returns
every { repository.latestPerName() } returns
listOf(
result("main", BuildStatus.FAILED),
result("feature/ok", BuildStatus.SUCCESS),
@@ -52,19 +52,19 @@ class RetryCommandTest : FunSpec() {
)
every { gitService.originHeadCommit("main", dir) } returns "head-main"
every { gitService.originHeadCommit("feature/y", dir) } returns "head-y"
every { consoleBuildRunner.buildAndStream(any(), any(), dir) } returns BuildStatus.SUCCESS
every { consoleBuildRunner.buildAndStream(any(), any(), dir, anyNullable(), any()) } returns BuildStatus.SUCCESS
var exitCode = -1
captureConsole { exitCode = command().call() }
exitCode shouldBe 0
verify { consoleBuildRunner.buildAndStream("main", "head-main", dir) }
verify { consoleBuildRunner.buildAndStream("feature/y", "head-y", dir) }
verify(exactly = 0) { consoleBuildRunner.buildAndStream("feature/ok", any(), dir) }
verify { consoleBuildRunner.buildAndStream("main", "head-main", dir, null, "main") }
verify { consoleBuildRunner.buildAndStream("feature/y", "head-y", dir, null, "feature/y") }
verify(exactly = 0) { consoleBuildRunner.buildAndStream("feature/ok", any(), dir, anyNullable(), any()) }
}
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 { 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") {
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
var exitCode = -1
@@ -87,7 +87,7 @@ class RetryCommandTest : FunSpec() {
}
test("prints a hint when there is nothing to retry") {
every { repository.latestPerBranch() } returns
every { repository.latestPerName() } returns
listOf(
result("main", BuildStatus.SUCCESS),
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") {
every { repository.latestPerBranch() } returns
every { repository.latestPerName() } returns
listOf(
result("main", BuildStatus.SUCCESS),
result("feature/x", BuildStatus.FAILED, duration = null),
@@ -75,7 +75,7 @@ class StatusCommandTest : FunSpec() {
}
test("prints a hint when no builds are recorded yet") {
every { repository.latestPerBranch() } returns emptyList()
every { repository.latestPerName() } returns emptyList()
var exitCode = -1
val console = captureConsole { exitCode = StatusCommand(repository).call() }
@@ -47,6 +47,39 @@ class ConfigLoaderTest : FunSpec() {
loader.load(dir).builds.maxConcurrent shouldBe 3
}
test("autoBuild.times accepts plain HH:MM entries and slot objects with their own build command, mixed") {
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") {
val dir = Files.createTempDirectory("gittally-test")
dir.resolve(".gittally.yml").toFile().writeText(
@@ -27,6 +27,10 @@ class BranchListingTest : FunSpec() {
)
init {
beforeEach {
every { repository.latestPerName() } returns emptyList()
}
test("orders main/master first, then flat names, then hierarchical names") {
every { gitService.originBranchHeads(any()) } returns
mapOf(
@@ -69,14 +73,36 @@ class BranchListingTest : FunSpec() {
test("a failed latest build carries no permanent URL — it belongs to the older green build") {
every { gitService.originBranchHeads(any()) } returns mapOf("feature/x" to "aaa")
every { repository.latestFor("feature/x") } returns
mainResult.copy(branch = "feature/x", status = BuildStatus.FAILED, artifactKey = "failed-key")
mainResult.copy(branch = "feature/x", name = "feature/x", status = BuildStatus.FAILED, artifactKey = "failed-key")
every { repository.latestGreenFor("feature/x") } returns
mainResult.copy(branch = "feature/x", artifactKey = "green-key")
mainResult.copy(branch = "feature/x", name = "feature/x", artifactKey = "green-key")
val branches = listing.branches()
branches[0].status shouldBe "failed"
branches[0].latestGreenUrl shouldBe null
}
test("a named slot pool gets its own row right after its branch") {
val nightly =
mainResult.copy(name = "main@nightly", status = BuildStatus.FAILED, artifactKey = "nightly-key")
every { gitService.originBranchHeads(any()) } returns mapOf("main" to "head", "develop" to "d")
every { repository.latestFor("main") } returns mainResult
every { repository.latestFor("develop") } returns null
every { repository.latestGreenFor("main") } returns mainResult
every { repository.latestGreenFor("main@nightly") } returns null
every { repository.latestGreenFor("develop") } returns null
every { repository.latestPerName() } returns listOf(mainResult, nightly)
val rows = listing.branches()
rows.map { it.name } shouldBe listOf("main", "main@nightly", "develop")
rows[1].branch shouldBe "main"
rows[1].status shouldBe "failed"
rows[1].artifactKey shouldBe "nightly-key"
// the branch row keeps its own status and permanent link, untouched by the nightly
rows[0].status shouldBe "success"
rows[0].latestGreenUrl shouldBe "/branches/main"
}
}
}
@@ -33,21 +33,21 @@ class BranchPermalinksTest : FunSpec() {
init {
test("resolves the hash-free permanent key to the branch's latest green build") {
every { repository.latestPerBranch() } returns listOf(result("feature/x"), result("main"))
every { repository.latestPerName() } returns listOf(result("feature/x"), result("main"))
every { repository.latestGreenFor("feature/x") } returns result("feature/x")
permalinks.latestGreenBuild("feature_x") shouldBe result("feature/x")
}
test("resolves the full branch key with hash suffix") {
every { repository.latestPerBranch() } returns listOf(result("feature/x"))
every { repository.latestPerName() } returns listOf(result("feature/x"))
every { repository.latestGreenFor("feature/x") } returns result("feature/x")
permalinks.latestGreenBuild(ArtifactKeys.branchKey("feature/x")) shouldBe result("feature/x")
}
test("an unknown branch key answers 404") {
every { repository.latestPerBranch() } returns listOf(result("main"))
every { repository.latestPerName() } returns listOf(result("main"))
val exception = shouldThrow<ResponseStatusException> { permalinks.latestGreenBuild("gone") }
@@ -55,7 +55,7 @@ class BranchPermalinksTest : FunSpec() {
}
test("a branch without a green build answers 404") {
every { repository.latestPerBranch() } returns listOf(result("main", status = BuildStatus.FAILED))
every { repository.latestPerName() } returns listOf(result("main", status = BuildStatus.FAILED))
every { repository.latestGreenFor("main") } returns null
val exception = shouldThrow<ResponseStatusException> { permalinks.latestGreenBuild("main") }
@@ -64,7 +64,7 @@ class BranchPermalinksTest : FunSpec() {
}
test("a permanent key matching several branches answers 409 and names the candidates") {
every { repository.latestPerBranch() } returns listOf(result("feature/x"), result("feature_x"))
every { repository.latestPerName() } returns listOf(result("feature/x"), result("feature_x"))
val exception = shouldThrow<ResponseStatusException> { permalinks.latestGreenBuild("feature_x") }
@@ -73,7 +73,7 @@ class BranchPermalinksTest : FunSpec() {
}
test("with ambiguous permanent keys the full branch key still resolves") {
every { repository.latestPerBranch() } returns listOf(result("feature/x"), result("feature_x"))
every { repository.latestPerName() } returns listOf(result("feature/x"), result("feature_x"))
every { repository.latestGreenFor("feature/x") } returns result("feature/x")
permalinks.latestGreenBuild(ArtifactKeys.branchKey("feature/x")) shouldBe result("feature/x")
@@ -82,5 +82,14 @@ class BranchPermalinksTest : FunSpec() {
test("permanentUrl uses the hash-free branch key") {
BranchPermalinks.permanentUrl("feature/x") shouldBe "/branches/feature_x"
}
test("resolves a named slot pool to its own latest green build") {
val nightly = result("main").copy(name = "main@nightly", artifactKey = "nightly-key")
every { repository.latestPerName() } returns listOf(result("main"), nightly)
every { repository.latestGreenFor("main@nightly") } returns nightly
// sanitized like any branch key: the '@' becomes '_' in the URL
permalinks.latestGreenBuild("main_nightly") shouldBe nightly
}
}
}
@@ -80,7 +80,7 @@ class BuildsApiControllerTest : FunSpec() {
}
test("latest answers one entry per branch with lowercase status and duration in seconds") {
every { repository.latestPerBranch() } returns listOf(successResult)
every { repository.latestPerName() } returns listOf(successResult)
mockMvc
.perform(get("/api/builds/latest"))
@@ -151,9 +151,9 @@ class BuildsApiControllerTest : FunSpec() {
test("restart enqueues the branch's last recorded commit, also for branch names with slashes") {
val liveLogFile = tempDir.resolve("restart.log")
every { repository.latestFor("feature/topic") } returns successResult.copy(branch = "feature/topic")
every { repository.latestFor("feature/topic") } returns successResult.copy(branch = "feature/topic", name = "feature/topic")
every { buildExecutor.startBuild("feature/topic", successResult.commit) } returns
runningBuild(liveLogFile).copy(branch = "feature/topic")
runningBuild(liveLogFile).copy(branch = "feature/topic", name = "feature/topic")
mockMvc
.perform(
@@ -167,12 +167,58 @@ class BuildsApiControllerTest : FunSpec() {
verify { buildExecutor.startBuild("feature/topic", successResult.commit) }
}
test("restart of an auto-slot build repeats its recorded build command") {
val liveLogFile = tempDir.resolve("auto-restart.log")
every { repository.latestFor("main") } returns successResult.copy(buildCommandOverride = "./gradlew fullCheck")
every { buildExecutor.startBuild("main", successResult.commit, buildCommandOverride = "./gradlew fullCheck") } returns
runningBuild(liveLogFile).copy(buildCommandOverride = "./gradlew fullCheck")
mockMvc
.perform(post("/api/builds/restart").param("branch", "main").header(BuildsApiController.TOKEN_HEADER, "secret"))
.andExpect(status().isAccepted)
.andExpect(jsonPath("$.status").value("pending"))
// so a restarted nightly build repeats its slot's command, not the regular one
verify { buildExecutor.startBuild("main", successResult.commit, buildCommandOverride = "./gradlew fullCheck") }
}
test("restart of a named slot build re-runs under its name on its real branch") {
val liveLogFile = tempDir.resolve("named-restart.log")
every { repository.latestFor("main@nightly") } returns
successResult.copy(name = "main@nightly", buildCommandOverride = "./gradlew fullCheck")
every {
buildExecutor.startBuild(
"main",
successResult.commit,
buildCommandOverride = "./gradlew fullCheck",
name = "main@nightly",
)
} returns runningBuild(liveLogFile).copy(name = "main@nightly")
mockMvc
.perform(
post("/api/builds/restart")
.param("branch", "main@nightly")
.header(BuildsApiController.TOKEN_HEADER, "secret"),
).andExpect(status().isAccepted)
.andExpect(jsonPath("$.name").value("main@nightly"))
verify {
buildExecutor.startBuild(
"main",
successResult.commit,
buildCommandOverride = "./gradlew fullCheck",
name = "main@nightly",
)
}
}
test("restart of a never-built branch enqueues its origin head commit") {
val liveLogFile = tempDir.resolve("first-build.log")
every { repository.latestFor("fresh") } returns null
every { gitService.originHeadCommit("fresh", any()) } returns successResult.commit
every { buildExecutor.startBuild("fresh", successResult.commit) } returns
runningBuild(liveLogFile).copy(branch = "fresh")
runningBuild(liveLogFile).copy(branch = "fresh", name = "fresh")
mockMvc
.perform(post("/api/builds/restart").param("branch", "fresh").header(BuildsApiController.TOKEN_HEADER, "secret"))
@@ -117,7 +117,7 @@ class UiControllerTest : FunSpec() {
}
test("latest view renders the empty state, and the nav no longer offers the current view") {
every { repository.latestPerBranch() } returns emptyList()
every { repository.latestPerName() } returns emptyList()
mockMvc
.perform(get("/"))
@@ -129,7 +129,7 @@ class UiControllerTest : FunSpec() {
}
test("latest view renders rows with badge, Gitea links, artifact link, actions, and token") {
every { repository.latestPerBranch() } returns listOf(successResult)
every { repository.latestPerName() } returns listOf(successResult)
mockMvc
.perform(get("/"))
@@ -188,9 +188,15 @@ class UiControllerTest : FunSpec() {
test("history view renders mixed history without restart actions") {
every { repository.history() } returns
listOf(
successResult.copy(branch = "main", status = BuildStatus.RUNNING, duration = null, artifactKey = "run-key"),
successResult.copy(
branch = "main",
name = "main",
status = BuildStatus.RUNNING,
duration = null,
artifactKey = "run-key",
),
successResult,
successResult.copy(branch = "feature/x", status = BuildStatus.FAILED, artifactKey = "failed-key"),
successResult.copy(branch = "feature/x", name = "feature/x", status = BuildStatus.FAILED, artifactKey = "failed-key"),
)
mockMvc
@@ -479,7 +485,7 @@ class UiControllerTest : FunSpec() {
test("branch names with HTML metacharacters render escaped") {
val nasty = "feat/<script>alert('x')</script>"
every { repository.latestPerBranch() } returns listOf(successResult.copy(branch = nasty))
every { repository.latestPerName() } returns listOf(successResult.copy(branch = nasty, name = nasty))
mockMvc
.perform(get("/"))
@@ -1,5 +1,6 @@
package de.hoennig.gittally.watcher
import de.hoennig.gittally.config.AutoBuildSlot
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.booleans.shouldBeFalse
import io.kotest.matchers.booleans.shouldBeTrue
@@ -13,19 +14,27 @@ import java.time.LocalTime
class AutoBuildStateTest : FunSpec() {
private fun stateFile(): Path = Files.createTempDirectory("gittally-autobuild-test").resolve("auto-builds.json")
private fun slots(vararg times: String) = times.map { AutoBuildSlot(it) }
init {
test("latestDueSlot picks the latest slot at or before now") {
val times = 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("01:00")) shouldBe "01:00"
AutoBuildSlots.latestDueSlot(times, LocalTime.parse("12:00")) shouldBe "11:00"
AutoBuildSlots.latestDueSlot(times, LocalTime.parse("23:59")) shouldBe "13:00"
AutoBuildSlots.latestDueSlot(times, LocalTime.parse("01:00"))?.time shouldBe "01:00"
AutoBuildSlots.latestDueSlot(times, LocalTime.parse("12:00"))?.time shouldBe "11:00"
AutoBuildSlots.latestDueSlot(times, LocalTime.parse("23:59"))?.time shouldBe "13:00"
}
test("latestDueSlot answers the whole slot including its build command") {
val slot = AutoBuildSlot(time = "01:00", buildCommand = "./gradlew fullCheck")
AutoBuildSlots.latestDueSlot(listOf(slot), LocalTime.parse("02:00")) shouldBe slot
}
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(listOf("25:99"), LocalTime.parse("12:00")).shouldBeNull()
AutoBuildSlots.latestDueSlot(slots("25:99", "nope", "02:00"), LocalTime.parse("12:00"))?.time shouldBe "02:00"
AutoBuildSlots.latestDueSlot(slots("25:99"), LocalTime.parse("12:00")).shouldBeNull()
}
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.config.ArtifactsConfig
import de.hoennig.gittally.config.AutoBuildConfig
import de.hoennig.gittally.config.AutoBuildSlot
import de.hoennig.gittally.config.BranchConfig
import de.hoennig.gittally.config.ConfigLoader
import de.hoennig.gittally.config.GitTallyConfig
@@ -78,7 +79,7 @@ class WatcherTest : FunSpec() {
every { gitService.worktreePrune(any()) } returns Unit
every { gitService.fastForwardLocalBranches(any()) } 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 commit = secondArg<String>()
startedBuilds += branch to commit
@@ -92,14 +93,18 @@ class WatcherTest : FunSpec() {
branch: String,
status: BuildStatus,
commit: String = "commit-0",
buildCommandOverride: String? = null,
name: String = branch,
): BuildResult {
val startedAt = noon.minusSeconds(3600).plusSeconds(seedCounter++)
val result =
BuildResult(
branch = branch,
name = name,
commit = commit,
status = status,
startedAt = startedAt,
buildCommandOverride = buildCommandOverride,
artifactKey = ArtifactKeys.buildKey(branch, startedAt),
)
repository.append(result)
@@ -136,7 +141,7 @@ class WatcherTest : FunSpec() {
branches =
mapOf(
"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
BranchConfig(
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.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()
}
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") {
val harness = Harness(autoBuildConfig("11:00"))
harness.seed("main", BuildStatus.RUNNING, commit = "commit-abc")
@@ -420,6 +496,23 @@ class WatcherTest : FunSpec() {
harness.startedBuilds shouldContainExactly listOf("main" to "commit-2")
}
test("startup recovery re-enqueues an interrupted auto-slot build with its recorded command and name") {
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") {
val harness = Harness()
val orphan = harness.seed("gone", BuildStatus.PENDING, commit = "commit-1")