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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user