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