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
@@ -54,7 +54,7 @@ Three places must stay in sync when config keys change: the `GitTallyConfig` dat
|
||||
|
||||
## Build Execution
|
||||
|
||||
`BuildExecutor` runs builds asynchronously: up to `builds.maxConcurrent` branches concurrently (default 1), but never more than one build per branch at a time. Each branch builds in its own reusable git worktree at `.git/gittally/worktrees/<branchKey>` (`BranchWorkspaces`), checked out detached at the requested commit — the primary checkout is never used for builds. Status transitions are persisted via `BuildResultRepository` (JSON file under `.git/gittally/`), published to Gitea non-fatally, and emitted as `BuildStatusChangedEvent`s. Cancellation addresses a build by artifact key and terminates the whole process tree. Future code (watcher, server, UI) must not assume a single running build.
|
||||
`BuildExecutor` runs builds asynchronously: up to `builds.maxConcurrent` branches concurrently (default 1), but never more than one build per branch at a time. Each branch builds in its own reusable git worktree at `.git/gittally/worktrees/<branchKey>` (`BranchWorkspaces`), checked out detached at the requested commit — the primary checkout is never used for builds. Status transitions are persisted via `BuildResultRepository` (JSON file under `.git/gittally/`), published to Gitea non-fatally, and emitted as `BuildStatusChangedEvent`s. An auto-build slot (`autoBuild.times` entry) may carry its own `buildCommand` and `name`; the watcher passes them to `startBuild` as `buildCommandOverride` and `name`, persisted in the build result — UI restart and startup recovery pass the recorded values on, so a build always repeats with the command and name it originally ran under. Both are resolved watcher-side from the repo install/project config; the worktree layer cannot change them. `BuildResult.name` (default: the branch) keys everything display- and retention-side — repository grouping (`latestPerName`), retention pools, branches-view rows, permanent latest-green links — while `BuildResult.branch` keys everything git-side: origin lookups, gone-from-origin pruning, worktrees (a named slot builds in its branch's worktree, serialized with the branch's other builds), and Gitea links/statuses. Cancellation addresses a build by artifact key and terminates the whole process tree. Future code (watcher, server, UI) must not assume a single running build.
|
||||
|
||||
On context close (e.g. systemd SIGTERM), a `ContextClosedEvent` listener in `BuildExecutor` terminates the process trees of all executing builds and waits (bounded) until their results are persisted as INTERRUPTED — a shutdown is never recorded as FAILED. Builds still queued stay PENDING and start no process. Both are re-enqueued by the watcher's startup recovery; INTERRUPTED therefore publishes as Gitea state `pending`, not `failure` (`GiteaStateMapping`).
|
||||
|
||||
|
||||
+37
-5
@@ -17,7 +17,7 @@ The repo install config (`.git/gittally/.gittally.yml`) wins on any key present
|
||||
When a branch builds, its build config is resolved with an extra layer: the `.gittally.yml`
|
||||
committed on the branch being built (read from its build worktree) overrides the two layers
|
||||
above, giving the precedence **worktree > repo install > project**. So a branch can change its
|
||||
own `buildCommand`, `cleanCommand`, `artifactDirs`, log file names, `autoBuild`, and
|
||||
own `buildCommand`, `cleanCommand`, `artifactDirs`, log file names, and
|
||||
`docker.image`/`dockerfile`/`context`/`env`.
|
||||
|
||||
This layer applies **only** to the build itself. A pinned set is always taken from the repo
|
||||
@@ -27,9 +27,9 @@ install/project config and can never be set from the worktree:
|
||||
- the container sandbox policy: `docker.enabled` and `docker.network`.
|
||||
|
||||
This keeps a branch from disabling its own build container, changing its network mode, or
|
||||
reaching credentials. Watcher decisions that happen before a build exists — `autoBuild`
|
||||
scheduling and the `requirePullRequest` gate — are still read from the repo install/project
|
||||
config, because there is no worktree at that point.
|
||||
reaching credentials. Watcher decisions that happen before a build exists — the whole
|
||||
`autoBuild` section (schedule and slot commands) and the `requirePullRequest` gate — are
|
||||
read from the repo install/project config, because there is no worktree at that point.
|
||||
|
||||
## Inspect the Effective Config
|
||||
|
||||
@@ -147,7 +147,13 @@ branches:
|
||||
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 build
|
||||
# command, so a nightly slot can run a fuller check than the on-commit
|
||||
# builds, and a name recording its builds in a separate pool (see notes below):
|
||||
# - time: "01:00"
|
||||
# buildCommand: ./gradlew fullCheck
|
||||
# name: main@nightly
|
||||
times: ["01:00"]
|
||||
# Optional Docker build runtime; when enabled, the clean and build commands
|
||||
# run inside a container instead of natively (see notes below).
|
||||
docker:
|
||||
@@ -169,8 +175,15 @@ branches:
|
||||
enabled: true
|
||||
|
||||
master:
|
||||
buildCommand: ./gradlew --console=plain --no-daemon quickCheck
|
||||
autoBuild:
|
||||
enabled: true
|
||||
times:
|
||||
# the nightly rebuild runs the full check instead of the quick on-commit
|
||||
# one, recorded separately as master@nightly
|
||||
- time: "01:00"
|
||||
buildCommand: ./gradlew --console=plain --no-daemon completeCheck
|
||||
name: master@nightly
|
||||
|
||||
release:
|
||||
buildCommand: ./gradlew --console=plain --no-daemon --no-build-cache test jacocoReport
|
||||
@@ -224,6 +237,25 @@ Without the `main` override, direct pushes and merges to `main` would never buil
|
||||
A plain git origin (no Gitea/GitHub) serves no `refs/pull/*/head` at all, so gated branches would never build there.
|
||||
For such origins, disable all gates globally with `watcher.pullRequestGate: false` — typically in the machine-specific `.git/gittally/.gittally.yml`, so the committed configuration keeps the gates for forge-backed environments.
|
||||
|
||||
### Notes on `branches.<name>.autoBuild.times`
|
||||
|
||||
Each entry is either a plain `HH:MM` string or an object with `time` and optional `buildCommand` and `name`; both forms mix freely in one list.
|
||||
A slot without its own command runs the branch's regular `buildCommand`.
|
||||
The typical use is a quick check on every commit and a fuller, slower check in the nightly slot of the same branch.
|
||||
|
||||
A slot's command is recorded in the build result.
|
||||
Restarting such a build from the UI re-runs it with the slot's command, and the startup recovery re-enqueues an interrupted one likewise — a build is always repeated with the command it originally ran.
|
||||
Manual `gittally build <branch>` runs and watcher builds for new commits always use the regular `buildCommand`.
|
||||
|
||||
Without a `name`, a slot's builds share the branch's history, retention pool, and permanent latest-green link — on a busy branch, the regular builds can displace the nightly build and its artifacts within a day.
|
||||
A slot `name` (e.g. `master@nightly`) records the slot's builds in their own pool instead: an own row in the branches view (sorted after its branch), an own `retentionPerBranch` count, an own latest status, and an own permanent artifact link.
|
||||
The URL key is the sanitized name — `master@nightly` is served as `/branches/master_nightly/…`.
|
||||
The builds still run in the branch's worktree, one build per branch at a time, and the Gitea commit status is still reported per commit in the shared status context, so the last build of a commit wins there regardless of its name.
|
||||
Do not name a slot like an existing branch — the pools would merge.
|
||||
The name's results live as long as the underlying branch exists on origin.
|
||||
|
||||
The whole `autoBuild` section is a watcher decision made before a build worktree exists, so — unlike `buildCommand` itself — it is read from the repo install/project config and cannot be changed by the `.gittally.yml` committed on the branch being built.
|
||||
|
||||
### Notes on `watcher.fastForwardLocalRefs`
|
||||
|
||||
Builds run in worktrees that share the primary checkout's `.git`, so a build tool can read `refs/heads/*` there.
|
||||
|
||||
@@ -67,15 +67,27 @@ class BuildExecutor(
|
||||
* not cancel-requested), that build is returned instead of stacking a duplicate —
|
||||
* 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
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user