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
@@ -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,