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:
co-authored by
Claude Fable 5
parent
f292badac1
commit
8aee3190ea
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user