diff --git a/.claude/skills/architecture/SKILL.md b/.claude/skills/architecture/SKILL.md index c0b05bf..998a365 100644 --- a/.claude/skills/architecture/SKILL.md +++ b/.claude/skills/architecture/SKILL.md @@ -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/` (`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/` (`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`). diff --git a/docs/configuration.md b/docs/configuration.md index 928fb53..36892bd 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -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..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 ` 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. diff --git a/src/main/kotlin/de/hoennig/gittally/build/BuildExecutor.kt b/src/main/kotlin/de/hoennig/gittally/build/BuildExecutor.kt index 9329fdd..e9d990b 100644 --- a/src/main/kotlin/de/hoennig/gittally/build/BuildExecutor.kt +++ b/src/main/kotlin/de/hoennig/gittally/build/BuildExecutor.kt @@ -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}") } diff --git a/src/main/kotlin/de/hoennig/gittally/build/BuildResult.kt b/src/main/kotlin/de/hoennig/gittally/build/BuildResult.kt index 02aa77e..4bd6170 100644 --- a/src/main/kotlin/de/hoennig/gittally/build/BuildResult.kt +++ b/src/main/kotlin/de/hoennig/gittally/build/BuildResult.kt @@ -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, ) diff --git a/src/main/kotlin/de/hoennig/gittally/build/BuildResultRepository.kt b/src/main/kotlin/de/hoennig/gittally/build/BuildResultRepository.kt index 5e52538..971e9fc 100644 --- a/src/main/kotlin/de/hoennig/gittally/build/BuildResultRepository.kt +++ b/src/main/kotlin/de/hoennig/gittally/build/BuildResultRepository.kt @@ -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 + /** The newest entry of each build name, newest first. */ + fun latestPerName(): List /** All entries, newest first. */ fun history(): List @@ -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 /** - * 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 diff --git a/src/main/kotlin/de/hoennig/gittally/build/FileBuildResultRepository.kt b/src/main/kotlin/de/hoennig/gittally/build/FileBuildResultRepository.kt index 9baa398..1fe31f2 100644 --- a/src/main/kotlin/de/hoennig/gittally/build/FileBuildResultRepository.kt +++ b/src/main/kotlin/de/hoennig/gittally/build/FileBuildResultRepository.kt @@ -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 = + override fun latestPerName(): List = 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, - 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 diff --git a/src/main/kotlin/de/hoennig/gittally/build/RunningBuild.kt b/src/main/kotlin/de/hoennig/gittally/build/RunningBuild.kt index 92df23a..c1cad79 100644 --- a/src/main/kotlin/de/hoennig/gittally/build/RunningBuild.kt +++ b/src/main/kotlin/de/hoennig/gittally/build/RunningBuild.kt @@ -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 diff --git a/src/main/kotlin/de/hoennig/gittally/commands/ConsoleBuildRunner.kt b/src/main/kotlin/de/hoennig/gittally/commands/ConsoleBuildRunner.kt index 04c03b5..9f3b47b 100644 --- a/src/main/kotlin/de/hoennig/gittally/commands/ConsoleBuildRunner.kt +++ b/src/main/kotlin/de/hoennig/gittally/commands/ConsoleBuildRunner.kt @@ -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) { diff --git a/src/main/kotlin/de/hoennig/gittally/commands/InitCommand.kt b/src/main/kotlin/de/hoennig/gittally/commands/InitCommand.kt index 326ea13..bae103a 100644 --- a/src/main/kotlin/de/hoennig/gittally/commands/InitCommand.kt +++ b/src/main/kotlin/de/hoennig/gittally/commands/InitCommand.kt @@ -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 diff --git a/src/main/kotlin/de/hoennig/gittally/commands/RetryCommand.kt b/src/main/kotlin/de/hoennig/gittally/commands/RetryCommand.kt index c201bd2..8d99fd0 100644 --- a/src/main/kotlin/de/hoennig/gittally/commands/RetryCommand.kt +++ b/src/main/kotlin/de/hoennig/gittally/commands/RetryCommand.kt @@ -34,7 +34,7 @@ class RetryCommand( val failed: List 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 } diff --git a/src/main/kotlin/de/hoennig/gittally/commands/StatusCommand.kt b/src/main/kotlin/de/hoennig/gittally/commands/StatusCommand.kt index ae1f6b4..f6e696b 100644 --- a/src/main/kotlin/de/hoennig/gittally/commands/StatusCommand.kt +++ b/src/main/kotlin/de/hoennig/gittally/commands/StatusCommand.kt @@ -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), diff --git a/src/main/kotlin/de/hoennig/gittally/config/AutoBuildSlotFormat.kt b/src/main/kotlin/de/hoennig/gittally/config/AutoBuildSlotFormat.kt new file mode 100644 index 0000000..4db2895 --- /dev/null +++ b/src/main/kotlin/de/hoennig/gittally/config/AutoBuildSlotFormat.kt @@ -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() { + override fun deserialize( + parser: JsonParser, + context: DeserializationContext, + ): AutoBuildSlot { + val node = parser.readValueAsTree() + 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() { + 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() + } +} diff --git a/src/main/kotlin/de/hoennig/gittally/config/GitTallyConfig.kt b/src/main/kotlin/de/hoennig/gittally/config/GitTallyConfig.kt index 80e7d11..a880ce3 100644 --- a/src/main/kotlin/de/hoennig/gittally/config/GitTallyConfig.kt +++ b/src/main/kotlin/de/hoennig/gittally/config/GitTallyConfig.kt @@ -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 = 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 = 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 = "", ) diff --git a/src/main/kotlin/de/hoennig/gittally/server/ApiDtos.kt b/src/main/kotlin/de/hoennig/gittally/server/ApiDtos.kt index eda1488..ef4ae33 100644 --- a/src/main/kotlin/de/hoennig/gittally/server/ApiDtos.kt +++ b/src/main/kotlin/de/hoennig/gittally/server/ApiDtos.kt @@ -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, diff --git a/src/main/kotlin/de/hoennig/gittally/server/BranchListing.kt b/src/main/kotlin/de/hoennig/gittally/server/BranchListing.kt index aa2ec4f..ac4d2e8 100644 --- a/src/main/kotlin/de/hoennig/gittally/server/BranchListing.kt +++ b/src/main/kotlin/de/hoennig/gittally/server/BranchListing.kt @@ -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 = - 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 { + 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 { diff --git a/src/main/kotlin/de/hoennig/gittally/server/BranchPermalinks.kt b/src/main/kotlin/de/hoennig/gittally/server/BranchPermalinks.kt index 83c3280..820d60f 100644 --- a/src/main/kotlin/de/hoennig/gittally/server/BranchPermalinks.kt +++ b/src/main/kotlin/de/hoennig/gittally/server/BranchPermalinks.kt @@ -10,36 +10,38 @@ import org.springframework.web.server.ResponseStatusException /** * Resolves the permanent `/branches//…` 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)}" } } diff --git a/src/main/kotlin/de/hoennig/gittally/server/BuildsApiController.kt b/src/main/kotlin/de/hoennig/gittally/server/BuildsApiController.kt index 400a85c..9d3bf77 100644 --- a/src/main/kotlin/de/hoennig/gittally/server/BuildsApiController.kt +++ b/src/main/kotlin/de/hoennig/gittally/server/BuildsApiController.kt @@ -41,7 +41,7 @@ class BuildsApiController( var workingDir: Path = Paths.get(".") @GetMapping("/api/builds/latest") - fun latest(): List = repository.latestPerBranch().map { BuildResultDto.from(it, it.isLatestGreen()) } + fun latest(): List = 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 = 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 { 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, diff --git a/src/main/kotlin/de/hoennig/gittally/server/UiController.kt b/src/main/kotlin/de/hoennig/gittally/server/UiController.kt index 82f7e5e..cd58082 100644 --- a/src/main/kotlin/de/hoennig/gittally/server/UiController.kt +++ b/src/main/kotlin/de/hoennig/gittally/server/UiController.kt @@ -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 = diff --git a/src/main/kotlin/de/hoennig/gittally/server/UiViews.kt b/src/main/kotlin/de/hoennig/gittally/server/UiViews.kt index f1de909..7acfb4d 100644 --- a/src/main/kotlin/de/hoennig/gittally/server/UiViews.kt +++ b/src/main/kotlin/de/hoennig/gittally/server/UiViews.kt @@ -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, diff --git a/src/main/kotlin/de/hoennig/gittally/watcher/AutoBuildState.kt b/src/main/kotlin/de/hoennig/gittally/watcher/AutoBuildState.kt index 7308667..d2073a7 100644 --- a/src/main/kotlin/de/hoennig/gittally/watcher/AutoBuildState.kt +++ b/src/main/kotlin/de/hoennig/gittally/watcher/AutoBuildState.kt @@ -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, + times: List, 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) } diff --git a/src/main/kotlin/de/hoennig/gittally/watcher/Watcher.kt b/src/main/kotlin/de/hoennig/gittally/watcher/Watcher.kt index b0a74ca..3cfa3e8 100644 --- a/src/main/kotlin/de/hoennig/gittally/watcher/Watcher.kt +++ b/src/main/kotlin/de/hoennig/gittally/watcher/Watcher.kt @@ -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>, 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 diff --git a/src/main/resources/static/gittally.js b/src/main/resources/static/gittally.js index 544fd14..f8ba3b6 100644 --- a/src/main/resources/static/gittally.js +++ b/src/main/resources/static/gittally.js @@ -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"); diff --git a/src/main/resources/templates/builds.html b/src/main/resources/templates/builds.html index a830e5c..23e3cbe 100644 --- a/src/main/resources/templates/builds.html +++ b/src/main/resources/templates/builds.html @@ -24,15 +24,15 @@ No builds recorded yet. + 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}"> success main - main + rel="noopener noreferrer" th:text="${row.name}">main + main @@ -65,7 +65,7 @@
-

+            

         
     
diff --git a/src/test/kotlin/de/hoennig/gittally/build/BuildExecutorTest.kt b/src/test/kotlin/de/hoennig/gittally/build/BuildExecutorTest.kt index ad0beb5..4ee332e 100644 --- a/src/test/kotlin/de/hoennig/gittally/build/BuildExecutorTest.kt +++ b/src/test/kotlin/de/hoennig/gittally/build/BuildExecutorTest.kt @@ -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) { diff --git a/src/test/kotlin/de/hoennig/gittally/build/FileBuildResultRepositoryTest.kt b/src/test/kotlin/de/hoennig/gittally/build/FileBuildResultRepositoryTest.kt index 2c7c596..6901c96 100644 --- a/src/test/kotlin/de/hoennig/gittally/build/FileBuildResultRepositoryTest.kt +++ b/src/test/kotlin/de/hoennig/gittally/build/FileBuildResultRepositoryTest.kt @@ -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)) diff --git a/src/test/kotlin/de/hoennig/gittally/commands/RetryCommandTest.kt b/src/test/kotlin/de/hoennig/gittally/commands/RetryCommandTest.kt index 83f7e26..7cfb252 100644 --- a/src/test/kotlin/de/hoennig/gittally/commands/RetryCommandTest.kt +++ b/src/test/kotlin/de/hoennig/gittally/commands/RetryCommandTest.kt @@ -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), diff --git a/src/test/kotlin/de/hoennig/gittally/commands/StatusCommandTest.kt b/src/test/kotlin/de/hoennig/gittally/commands/StatusCommandTest.kt index 83667be..67ed6a5 100644 --- a/src/test/kotlin/de/hoennig/gittally/commands/StatusCommandTest.kt +++ b/src/test/kotlin/de/hoennig/gittally/commands/StatusCommandTest.kt @@ -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() } diff --git a/src/test/kotlin/de/hoennig/gittally/config/ConfigLoaderTest.kt b/src/test/kotlin/de/hoennig/gittally/config/ConfigLoaderTest.kt index daee18d..9d96ebf 100644 --- a/src/test/kotlin/de/hoennig/gittally/config/ConfigLoaderTest.kt +++ b/src/test/kotlin/de/hoennig/gittally/config/ConfigLoaderTest.kt @@ -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( diff --git a/src/test/kotlin/de/hoennig/gittally/server/BranchListingTest.kt b/src/test/kotlin/de/hoennig/gittally/server/BranchListingTest.kt index 621a282..112cdba 100644 --- a/src/test/kotlin/de/hoennig/gittally/server/BranchListingTest.kt +++ b/src/test/kotlin/de/hoennig/gittally/server/BranchListingTest.kt @@ -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" + } } } diff --git a/src/test/kotlin/de/hoennig/gittally/server/BranchPermalinksTest.kt b/src/test/kotlin/de/hoennig/gittally/server/BranchPermalinksTest.kt index cd76ef1..c3f9972 100644 --- a/src/test/kotlin/de/hoennig/gittally/server/BranchPermalinksTest.kt +++ b/src/test/kotlin/de/hoennig/gittally/server/BranchPermalinksTest.kt @@ -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 { 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 { 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 { 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 + } } } diff --git a/src/test/kotlin/de/hoennig/gittally/server/BuildsApiControllerTest.kt b/src/test/kotlin/de/hoennig/gittally/server/BuildsApiControllerTest.kt index 1c3b1b7..40d657a 100644 --- a/src/test/kotlin/de/hoennig/gittally/server/BuildsApiControllerTest.kt +++ b/src/test/kotlin/de/hoennig/gittally/server/BuildsApiControllerTest.kt @@ -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")) diff --git a/src/test/kotlin/de/hoennig/gittally/server/UiControllerTest.kt b/src/test/kotlin/de/hoennig/gittally/server/UiControllerTest.kt index e71301b..725f8f2 100644 --- a/src/test/kotlin/de/hoennig/gittally/server/UiControllerTest.kt +++ b/src/test/kotlin/de/hoennig/gittally/server/UiControllerTest.kt @@ -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/" - every { repository.latestPerBranch() } returns listOf(successResult.copy(branch = nasty)) + every { repository.latestPerName() } returns listOf(successResult.copy(branch = nasty, name = nasty)) mockMvc .perform(get("/")) diff --git a/src/test/kotlin/de/hoennig/gittally/watcher/AutoBuildStateTest.kt b/src/test/kotlin/de/hoennig/gittally/watcher/AutoBuildStateTest.kt index 59263a4..2e30a75 100644 --- a/src/test/kotlin/de/hoennig/gittally/watcher/AutoBuildStateTest.kt +++ b/src/test/kotlin/de/hoennig/gittally/watcher/AutoBuildStateTest.kt @@ -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") { diff --git a/src/test/kotlin/de/hoennig/gittally/watcher/WatcherTest.kt b/src/test/kotlin/de/hoennig/gittally/watcher/WatcherTest.kt index 4613c84..cac242f 100644 --- a/src/test/kotlin/de/hoennig/gittally/watcher/WatcherTest.kt +++ b/src/test/kotlin/de/hoennig/gittally/watcher/WatcherTest.kt @@ -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() val commit = secondArg() 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")