A build definition says when it runs in a trigger block of its own
`onPush`, `atTimes`, `branches`, and `activeWithin` move into a nested `trigger`. The split is structural on purpose: the inheritance from `builds.default` now subtracts one key instead of a list of four, so a selector added to `TriggerConfig` later is non-inheritable by construction rather than because someone remembered to extend the list. A definition still writing those keys flat is refused by name, per file and scoped like the version check — the machine and project config abort the start, a branch's committed config fails only that branch. Ignoring them would leave the build with no trigger at all, which is a job that quietly stops running: the failure this refusal exists to prevent. Two more things a definition can now say: - A `!` prefix in `trigger.branches` excludes, and an exclusion wins whatever the order. `["*", "!master"]` gives one branch a build of its own without the default build running over it as well — until now the only way out of that double build was to drop the second definition's push trigger. - `statusContext` overrides the Gitea check this build reports as, empty keeping the repository-wide one. Two builds of a commit shared a context and overwrote each other's result, so a quick check beside a long build was not readable in Gitea. Pinned like `requirePullRequest`: a branch that could pick its context could take over the check a branch protection rule depends on. Fixed on the way: a branch whose builds all belong to named definitions rendered an empty row in the branches view, reading as "never built" directly beside its real builds. That row was unreachable before the exclusion patterns made such a branch possible.
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
package de.hoennig.gittally
|
||||
|
||||
import de.hoennig.gittally.config.ConfigVersionException
|
||||
import de.hoennig.gittally.config.ConfigException
|
||||
import org.springframework.boot.CommandLineRunner
|
||||
import org.springframework.boot.ExitCodeGenerator
|
||||
import org.springframework.boot.SpringApplication
|
||||
@@ -31,7 +31,7 @@ class CliRunner(
|
||||
.setExecutionExceptionHandler { exception, commandLine, _ ->
|
||||
// a config GitTally must not read is a stated fact, not a crash: the message
|
||||
// names the file, the versions, and the way out — a stack trace would bury it
|
||||
if (exception is ConfigVersionException) {
|
||||
if (exception is ConfigException) {
|
||||
commandLine.err.println("Error: ${exception.message}")
|
||||
CONFIG_ERROR_EXIT_CODE
|
||||
} else {
|
||||
|
||||
@@ -396,12 +396,27 @@ class BuildExecutor(
|
||||
description = description(status, duration),
|
||||
targetUrl = null,
|
||||
workingDir = build.workingDir,
|
||||
// from the primary config, not the worktree: statusContext is pinned, so a
|
||||
// branch cannot report under a check name it was not given
|
||||
context = statusContextOf(build),
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
log.warn("could not publish Gitea status {} for {}: {}", status, build.runningBuild.commit, e.message)
|
||||
}
|
||||
}
|
||||
|
||||
/** The build's own Gitea status context, empty when it uses the repository-wide one. */
|
||||
private fun statusContextOf(build: ActiveBuild): String =
|
||||
try {
|
||||
configLoader
|
||||
.load(build.workingDir)
|
||||
.buildSettings(build.runningBuild.branch, build.runningBuild.build)
|
||||
.statusContext
|
||||
} catch (e: Exception) {
|
||||
log.warn("could not resolve the status context of {}: {}", build.runningBuild.branch, e.message)
|
||||
""
|
||||
}
|
||||
|
||||
private fun description(
|
||||
status: BuildStatus,
|
||||
duration: Duration?,
|
||||
|
||||
@@ -182,7 +182,13 @@ class InitCommand(
|
||||
# they apply to that branch alone, so a new job can be tried out on one branch.
|
||||
builds:
|
||||
default:
|
||||
onPush: true # build every new commit of the selected branches
|
||||
# When this build runs and for which branches. The only part a build does
|
||||
# NOT inherit from the default — everything below it does.
|
||||
trigger:
|
||||
onPush: true # build every new commit of the selected branches
|
||||
# branches: ["*", "!master"] # names or globs; "!" excludes; default: all
|
||||
# atTimes: ["01:00"] # daily UTC times HH:MM ("??:05" = every hour at :05)
|
||||
# activeWithin: 24h # only branches with recent commits
|
||||
# run before each build
|
||||
cleanCommand: rm -rf build
|
||||
# shell command for each build
|
||||
@@ -203,12 +209,16 @@ class InitCommand(
|
||||
context: "." # Docker build context used with dockerfile
|
||||
network: "" # Docker network mode for the build container; empty = Docker default (pinned)
|
||||
env: {} # additional environment variables set inside the build container
|
||||
# Further jobs inherit those settings and add their own trigger and selector:
|
||||
# Gitea check this build reports as; empty uses gitea.statusContext.
|
||||
# Two builds of one commit under the same context overwrite each other.
|
||||
statusContext: ""
|
||||
# Further jobs inherit those settings and only bring their own trigger:
|
||||
# pitest:
|
||||
# atTimes: ["01:00"] # daily UTC times HH:MM ("??:05" = every hour at :05)
|
||||
# branches: ["master"] # names or glob patterns; default: all branches
|
||||
# activeWithin: 24h # only branches with recent commits
|
||||
# trigger:
|
||||
# atTimes: ["01:00"]
|
||||
# branches: ["master"]
|
||||
# buildCommand: ./gradlew pitestFull
|
||||
# statusContext: GitTally/pitest
|
||||
|
||||
# Build artifact storage and retention.
|
||||
artifacts:
|
||||
|
||||
@@ -8,16 +8,82 @@ import java.time.Instant
|
||||
* top-level `builds` section next to the reserved execution key `maxConcurrent`
|
||||
* (split apart by [ConfigLoader]).
|
||||
*
|
||||
* A definition carries the complete description of one build. The `default` entry is
|
||||
* additionally the base every other definition inherits its settings from — but never
|
||||
* its trigger, see [SELECTOR_KEYS][ConfigLoader]. Unset values fall through to the
|
||||
* [BranchConfig] defaults.
|
||||
*
|
||||
* The `default` build records its results under the plain branch name; every other
|
||||
* build records under `<branch>@<name>` with its own history, retention pool, and
|
||||
* permanent latest-green link.
|
||||
* A definition describes one build completely, in two halves: [trigger] says when it
|
||||
* runs and for which branches, everything else says what it does. The `default` entry
|
||||
* is additionally the base every other definition inherits from — its settings, never
|
||||
* its [trigger]. That is why the halves are separated structurally instead of by a list
|
||||
* of key names: a selector added to [TriggerConfig] later is non-inheritable by
|
||||
* construction, not because someone remembered to extend a list.
|
||||
*/
|
||||
data class BuildDefinition(
|
||||
/** When and for which branches this build runs; never inherited from `builds.default`. */
|
||||
val trigger: TriggerConfig = TriggerConfig(),
|
||||
/** Overrides the build command; null inherits it. */
|
||||
val buildCommand: String? = null,
|
||||
/** Overrides the clean command; null inherits it. */
|
||||
val cleanCommand: String? = null,
|
||||
/** Overrides the artifact directories; null inherits them. */
|
||||
val artifactDirs: List<String>? = null,
|
||||
/** Overrides the stdout log file name; null inherits it. */
|
||||
val stdoutLog: String? = null,
|
||||
/** Overrides the stderr log file name; null inherits it. */
|
||||
val stderrLog: String? = null,
|
||||
/**
|
||||
* The watcher builds a selected branch only while its head commit matches a
|
||||
* pull-request head; null inherits. Pinned — a branch's own committed config can
|
||||
* never set it, or it would bypass its own gate.
|
||||
*/
|
||||
val requirePullRequest: Boolean? = null,
|
||||
/**
|
||||
* Gitea commit status context of this build; null uses `gitea.statusContext`.
|
||||
* Two builds of the same commit under the same context overwrite each other's
|
||||
* result, so a second build over a branch needs its own context to be readable.
|
||||
*/
|
||||
val statusContext: String? = null,
|
||||
/** Overrides of the docker settings; null inherits them. */
|
||||
val docker: DockerOverrides? = null,
|
||||
) {
|
||||
/** The settings this build runs with: [branchConfig] with this definition applied; unset values fall through. */
|
||||
fun applyTo(branchConfig: BranchConfig): BranchConfig =
|
||||
branchConfig.copy(
|
||||
buildCommand = buildCommand ?: branchConfig.buildCommand,
|
||||
cleanCommand = cleanCommand ?: branchConfig.cleanCommand,
|
||||
artifactDirs = artifactDirs ?: branchConfig.artifactDirs,
|
||||
stdoutLog = stdoutLog ?: branchConfig.stdoutLog,
|
||||
stderrLog = stderrLog ?: branchConfig.stderrLog,
|
||||
requirePullRequest = requirePullRequest ?: branchConfig.requirePullRequest,
|
||||
statusContext = statusContext ?: branchConfig.statusContext,
|
||||
docker =
|
||||
branchConfig.docker.copy(
|
||||
enabled = docker?.enabled ?: branchConfig.docker.enabled,
|
||||
image = docker?.image ?: branchConfig.docker.image,
|
||||
dockerfile = docker?.dockerfile ?: branchConfig.docker.dockerfile,
|
||||
context = docker?.context ?: branchConfig.docker.context,
|
||||
network = docker?.network ?: branchConfig.docker.network,
|
||||
env = docker?.env ?: branchConfig.docker.env,
|
||||
),
|
||||
)
|
||||
|
||||
companion object {
|
||||
/** Name of the implicit build that preserves the pre-ADR-0007 behavior: `onPush` over all branches. */
|
||||
const val DEFAULT = "default"
|
||||
|
||||
/** The result-pool name of [build] on [branch]: the plain branch for the default build. */
|
||||
fun poolName(
|
||||
branch: String,
|
||||
build: String,
|
||||
): String = if (build == DEFAULT) branch else "$branch@$build"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* When a build runs and for which branches — the `trigger` block of a build definition,
|
||||
* and the one part of it that is never inherited from `builds.default`.
|
||||
*
|
||||
* A definition with neither [onPush] nor [atTimes] never triggers automatically; that is
|
||||
* how `builds.default` is written when it is meant as a settings base only.
|
||||
*/
|
||||
data class TriggerConfig(
|
||||
/** Build every new commit of the selected branches. */
|
||||
val onPush: Boolean = false,
|
||||
/**
|
||||
@@ -28,7 +94,8 @@ data class BuildDefinition(
|
||||
val atTimes: List<String> = emptyList(),
|
||||
/**
|
||||
* Branch names or glob patterns (`*` matches any characters, also across `/`);
|
||||
* empty selects all origin branches.
|
||||
* empty selects all origin branches. A pattern prefixed with `!` excludes instead,
|
||||
* and an exclusion always wins — `["*", "!master"]` is every branch but master.
|
||||
*/
|
||||
val branches: List<String> = emptyList(),
|
||||
/**
|
||||
@@ -36,27 +103,15 @@ data class BuildDefinition(
|
||||
* empty applies no age filter. Combines with [branches] as an intersection.
|
||||
*/
|
||||
val activeWithin: String = "",
|
||||
/** Overrides the branch's build command; null inherits it. */
|
||||
val buildCommand: String? = null,
|
||||
/** Overrides the branch's clean command; null inherits it. */
|
||||
val cleanCommand: String? = null,
|
||||
/** Overrides the branch's artifact directories; null inherits them. */
|
||||
val artifactDirs: List<String>? = null,
|
||||
/** Overrides the branch's stdout log file name; null inherits it. */
|
||||
val stdoutLog: String? = null,
|
||||
/** Overrides the branch's stderr log file name; null inherits it. */
|
||||
val stderrLog: String? = null,
|
||||
/**
|
||||
* The watcher builds a selected branch only while its head commit matches a
|
||||
* pull-request head; null inherits. Pinned — a branch's own committed config can
|
||||
* never set it, or it would bypass its own gate.
|
||||
*/
|
||||
val requirePullRequest: Boolean? = null,
|
||||
/** Overrides of the branch's docker settings; null inherits them. */
|
||||
val docker: DockerOverrides? = null,
|
||||
) {
|
||||
/** True when [branch] matches the [branches] patterns (or none are configured). */
|
||||
fun selectsByName(branch: String): Boolean = branches.isEmpty() || branches.any { globToRegex(it).matches(branch) }
|
||||
/** True when [branch] matches the [branches] patterns (or none are configured) and none excludes it. */
|
||||
fun selectsByName(branch: String): Boolean {
|
||||
val (excluding, including) = branches.partition { it.startsWith(EXCLUDE_PREFIX) }
|
||||
if (excluding.any { globToRegex(it.removePrefix(EXCLUDE_PREFIX)).matches(branch) }) {
|
||||
return false
|
||||
}
|
||||
return including.isEmpty() || including.any { globToRegex(it).matches(branch) }
|
||||
}
|
||||
|
||||
/**
|
||||
* True when [branch] passes both selector parts; [headCommittedAt] is the branch
|
||||
@@ -80,35 +135,9 @@ data class BuildDefinition(
|
||||
|
||||
fun maxAge(): Duration = DurationParser.parse(activeWithin)
|
||||
|
||||
/** The branch settings with this build's overrides applied; unset values fall through. */
|
||||
fun applyTo(branchConfig: BranchConfig): BranchConfig =
|
||||
branchConfig.copy(
|
||||
buildCommand = buildCommand ?: branchConfig.buildCommand,
|
||||
cleanCommand = cleanCommand ?: branchConfig.cleanCommand,
|
||||
artifactDirs = artifactDirs ?: branchConfig.artifactDirs,
|
||||
stdoutLog = stdoutLog ?: branchConfig.stdoutLog,
|
||||
stderrLog = stderrLog ?: branchConfig.stderrLog,
|
||||
requirePullRequest = requirePullRequest ?: branchConfig.requirePullRequest,
|
||||
docker =
|
||||
branchConfig.docker.copy(
|
||||
enabled = docker?.enabled ?: branchConfig.docker.enabled,
|
||||
image = docker?.image ?: branchConfig.docker.image,
|
||||
dockerfile = docker?.dockerfile ?: branchConfig.docker.dockerfile,
|
||||
context = docker?.context ?: branchConfig.docker.context,
|
||||
network = docker?.network ?: branchConfig.docker.network,
|
||||
env = docker?.env ?: branchConfig.docker.env,
|
||||
),
|
||||
)
|
||||
|
||||
companion object {
|
||||
/** Name of the implicit build that preserves the pre-ADR-0007 behavior: `onPush` over all branches. */
|
||||
const val DEFAULT = "default"
|
||||
|
||||
/** The result-pool name of [build] on [branch]: the plain branch for the default build. */
|
||||
fun poolName(
|
||||
branch: String,
|
||||
build: String,
|
||||
): String = if (build == DEFAULT) branch else "$branch@$build"
|
||||
/** Marks a [branches] pattern as excluding. */
|
||||
const val EXCLUDE_PREFIX = "!"
|
||||
|
||||
private fun globToRegex(pattern: String): Regex =
|
||||
Regex(
|
||||
|
||||
@@ -79,6 +79,7 @@ class ConfigLoader(
|
||||
// scoped to this branch: an incompatible branch config fails its own builds and
|
||||
// must never stop the server or hold up the branches that are fine
|
||||
checkVersion(branchLayer, "the committed .gittally.yml of this branch", BRANCH_HINT)
|
||||
checkTriggerBlocks(branchLayer, "the committed .gittally.yml of this branch", BRANCH_HINT)
|
||||
return toConfig(deepMerge(loadRaw(workingDir), stripPinned(branchLayer)))
|
||||
}
|
||||
|
||||
@@ -200,8 +201,34 @@ class ConfigLoader(
|
||||
}
|
||||
|
||||
private fun isTriggered(definition: Any?): Boolean {
|
||||
val entry = definition as? Map<*, *> ?: return false
|
||||
return entry["onPush"] == true || (entry["atTimes"] as? List<*>)?.isNotEmpty() == true
|
||||
val trigger = (definition as? Map<*, *>)?.get("trigger") as? Map<*, *> ?: return false
|
||||
return trigger["onPush"] == true || (trigger["atTimes"] as? List<*>)?.isNotEmpty() == true
|
||||
}
|
||||
|
||||
/**
|
||||
* Refuses a definition that still writes its trigger and selector keys flat instead of
|
||||
* inside `trigger`. Silently ignoring them would leave a build with no trigger at all —
|
||||
* a branch that stops building without saying so, which is worse than not starting.
|
||||
* Scoped like [checkVersion]: per file, so the message names the one to fix.
|
||||
*/
|
||||
private fun checkTriggerBlocks(
|
||||
raw: Map<String, Any?>,
|
||||
source: String,
|
||||
hint: String,
|
||||
) {
|
||||
val builds = raw["builds"] as? Map<*, *> ?: return
|
||||
val offenders =
|
||||
builds.entries.mapNotNull { (name, value) ->
|
||||
val flat = (value as? Map<*, *>)?.keys?.filter { it in FLAT_TRIGGER_KEYS } ?: return@mapNotNull null
|
||||
flat.takeIf { it.isNotEmpty() }?.let { "builds.$name: ${it.joinToString(", ")}" }
|
||||
}
|
||||
if (offenders.isEmpty()) {
|
||||
return
|
||||
}
|
||||
throw ConfigFormatException(
|
||||
"$source declares a build trigger outside its trigger block (${offenders.joinToString("; ")}). " +
|
||||
"Move these keys into a `trigger:` block inside the definition. $hint",
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -213,7 +240,7 @@ class ConfigLoader(
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
private fun mergeBuildDefaults(raw: Map<String, Any?>): Map<String, Any?> {
|
||||
val builds = raw["builds"] as? Map<String, Any?> ?: return raw
|
||||
val base = (builds[BuildDefinition.DEFAULT] as? Map<String, Any?>)?.minus(SELECTOR_KEYS) ?: return raw
|
||||
val base = (builds[BuildDefinition.DEFAULT] as? Map<String, Any?>)?.minus(TRIGGER_KEYS) ?: return raw
|
||||
if (base.isEmpty()) {
|
||||
return raw
|
||||
}
|
||||
@@ -245,6 +272,8 @@ class ConfigLoader(
|
||||
// per file, so the message names the file to fix — the merged map has no provenance
|
||||
checkVersion(project, ".gittally.yml", ROLLBACK_HINT)
|
||||
checkVersion(repoInstall, ".git/gittally/.gittally.yml", ROLLBACK_HINT)
|
||||
checkTriggerBlocks(project, ".gittally.yml", ROLLBACK_HINT)
|
||||
checkTriggerBlocks(repoInstall, ".git/gittally/.gittally.yml", ROLLBACK_HINT)
|
||||
return deepMerge(project, repoInstall)
|
||||
}
|
||||
|
||||
@@ -348,15 +377,25 @@ class ConfigLoader(
|
||||
/**
|
||||
* Settings keys a branch must never override, in a build definition as well as in
|
||||
* a legacy branch entry: the trust gate that decides whether the watcher builds
|
||||
* this branch at all.
|
||||
* this branch at all, and the Gitea check this build reports as — a branch that
|
||||
* could choose its own context could take over the check a branch protection
|
||||
* rule depends on.
|
||||
*/
|
||||
private val PINNED_SETTING_KEYS = setOf("requirePullRequest")
|
||||
private val PINNED_SETTING_KEYS = setOf("requirePullRequest", "statusContext")
|
||||
|
||||
/** `docker` keys a branch must never override: the sandbox policy. */
|
||||
private val PINNED_DOCKER_KEYS = setOf("enabled", "network")
|
||||
|
||||
/** Keys of a build definition that say *when* it runs; never inherited from `builds.default`. */
|
||||
private val SELECTOR_KEYS = setOf("onPush", "atTimes", "branches", "activeWithin")
|
||||
/**
|
||||
* The one key of a build definition that says *when* and *for which branches* it
|
||||
* runs; never inherited from `builds.default`. A single key on purpose: a selector
|
||||
* added inside it is non-inheritable by construction, where a list of key names
|
||||
* would have to be remembered.
|
||||
*/
|
||||
private val TRIGGER_KEYS = setOf("trigger")
|
||||
|
||||
/** The keys that moved into [TRIGGER_KEYS]; still writing them flat is refused, not ignored. */
|
||||
private val FLAT_TRIGGER_KEYS = setOf("onPush", "atTimes", "branches", "activeWithin")
|
||||
|
||||
private const val LEGACY_BRANCHES_WARNING = "legacy-branches-ignored"
|
||||
|
||||
|
||||
@@ -44,10 +44,23 @@ sealed interface VersionVerdict {
|
||||
}
|
||||
|
||||
/** A configuration file this GitTally must not read; carries the file's name in its message. */
|
||||
class ConfigVersionException(
|
||||
open class ConfigException(
|
||||
message: String,
|
||||
) : RuntimeException(message)
|
||||
|
||||
/** The file declares a GitTally that cannot read it, see [ConfigVersions]. */
|
||||
class ConfigVersionException(
|
||||
message: String,
|
||||
) : ConfigException(message)
|
||||
|
||||
/**
|
||||
* The file is written in a shape this GitTally no longer reads. Refusing it is the point:
|
||||
* a key that moved and is silently ignored means a build that quietly stops happening.
|
||||
*/
|
||||
class ConfigFormatException(
|
||||
message: String,
|
||||
) : ConfigException(message)
|
||||
|
||||
object ConfigVersions {
|
||||
/**
|
||||
* The version in which the configuration format last changed incompatibly — a file
|
||||
|
||||
@@ -22,7 +22,7 @@ data class GitTallyConfig(
|
||||
) {
|
||||
/** The configured [buildDefinitions] plus the implicit `default` build unless overridden. */
|
||||
fun effectiveBuildDefinitions(): Map<String, BuildDefinition> =
|
||||
mapOf(BuildDefinition.DEFAULT to BuildDefinition(onPush = true)) + buildDefinitions
|
||||
mapOf(BuildDefinition.DEFAULT to BuildDefinition(trigger = TriggerConfig(onPush = true))) + buildDefinitions
|
||||
|
||||
/**
|
||||
* The settings one build of [build] on [branch] runs with: the branch entry (falling
|
||||
@@ -153,6 +153,8 @@ data class BranchConfig(
|
||||
* head (`refs/pull/<n>/head` on origin); manual `build` commands are not affected.
|
||||
*/
|
||||
val requirePullRequest: Boolean = false,
|
||||
/** Gitea commit status context of this build; empty uses `gitea.statusContext`. */
|
||||
val statusContext: String = "",
|
||||
val autoBuild: AutoBuildConfig = AutoBuildConfig(),
|
||||
val docker: DockerConfig = DockerConfig(),
|
||||
)
|
||||
|
||||
@@ -56,13 +56,19 @@ class GiteaClient(
|
||||
|
||||
fun isEnabled(workingDir: Path = Paths.get(".")): Boolean = isEnabled(configLoader.load(workingDir))
|
||||
|
||||
/** Publishes a commit status for [sha]; returns false when disabled or the request failed. */
|
||||
/**
|
||||
* Publishes a commit status for [sha]; returns false when disabled or the request failed.
|
||||
* [context] is the build's own status context (`builds.<name>.statusContext`); blank or
|
||||
* null uses the repository-wide `gitea.statusContext`. Two builds of one commit sharing
|
||||
* a context overwrite each other's result, which is what a per-build context avoids.
|
||||
*/
|
||||
fun publishStatus(
|
||||
sha: String,
|
||||
status: BuildStatus,
|
||||
description: String,
|
||||
targetUrl: String? = null,
|
||||
workingDir: Path = Paths.get("."),
|
||||
context: String? = null,
|
||||
): Boolean {
|
||||
val config = configLoader.load(workingDir)
|
||||
if (!isEnabled(config)) {
|
||||
@@ -71,7 +77,7 @@ class GiteaClient(
|
||||
}
|
||||
val body = mutableMapOf<String, String>()
|
||||
body["state"] = status.toGiteaState()
|
||||
body["context"] = config.gitea.statusContext
|
||||
body["context"] = context?.takeIf { it.isNotBlank() } ?: config.gitea.statusContext
|
||||
body["description"] = description
|
||||
if (!targetUrl.isNullOrBlank()) {
|
||||
body["target_url"] = targetUrl
|
||||
|
||||
@@ -22,16 +22,25 @@ class BranchListing(
|
||||
) {
|
||||
fun branches(workingDir: Path = Paths.get(".")): List<BranchDto> {
|
||||
val heads = gitService.originBranchHeads(workingDir)
|
||||
val namedResults = repository.latestPerName().filter { it.name != it.branch && it.branch in heads }
|
||||
val branchesWithNamedPool = namedResults.map { it.branch }.toSet()
|
||||
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))
|
||||
}
|
||||
heads
|
||||
.mapNotNull { (branch, headCommit) ->
|
||||
// latestFor groups by build name, so a named slot's results never shadow the branch row
|
||||
val latest = repository.latestFor(branch)
|
||||
// A branch whose builds all belong to named definitions has no default pool,
|
||||
// and an empty row for it would read as "never built" next to its real ones.
|
||||
// Without any build at all the row stays: that a branch is known and idle is
|
||||
// exactly what it says then.
|
||||
if (latest == null && branch in branchesWithNamedPool) {
|
||||
null
|
||||
} else {
|
||||
BranchDto.from(branch, headCommit, latest)
|
||||
}
|
||||
}
|
||||
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) }
|
||||
namedResults.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 ->
|
||||
|
||||
@@ -208,7 +208,7 @@ class Watcher(
|
||||
gitService.newOriginBranches(DurationParser.parse(config.watcher.newBranchMaxAge), workingDir)
|
||||
val changed = (changedLocal + newOrigin).distinct()
|
||||
for (branch in changed) {
|
||||
val onPush = definitionsFor(branch, heads[branch], workingDir, config).filterValues { it.onPush }
|
||||
val onPush = definitionsFor(branch, heads[branch], workingDir, config).filterValues { it.trigger.onPush }
|
||||
for ((buildName, definition) in onPush) {
|
||||
if (selects(definition, branch, headCommitTimes)) {
|
||||
startBuildIfDue(branch, allowSameCommit = false, config, pullRequestHeads, workingDir, buildName)
|
||||
@@ -270,7 +270,7 @@ class Watcher(
|
||||
definition: BuildDefinition,
|
||||
branch: String,
|
||||
headCommitTimes: Lazy<Map<String, Instant>>,
|
||||
): Boolean = definition.selects(branch, { headCommitTimes.value[branch] }, clock.instant())
|
||||
): Boolean = definition.trigger.selects(branch, { headCommitTimes.value[branch] }, clock.instant())
|
||||
|
||||
/**
|
||||
* Enqueues a build of the branch's origin head unless one is already pending or
|
||||
@@ -331,12 +331,14 @@ class Watcher(
|
||||
val timeOfDay = LocalTime.ofInstant(now, ZoneOffset.UTC)
|
||||
for (branch in originBranches) {
|
||||
val scheduled =
|
||||
definitionsFor(branch, heads[branch], workingDir, config).filterValues { it.atTimes.isNotEmpty() }
|
||||
definitionsFor(branch, heads[branch], workingDir, config).filterValues {
|
||||
it.trigger.atTimes.isNotEmpty()
|
||||
}
|
||||
for ((buildName, definition) in scheduled) {
|
||||
if (!selects(definition, branch, headCommitTimes)) {
|
||||
continue
|
||||
}
|
||||
val slot = AutoBuildSlots.latestDueSlot(definition.atTimes, timeOfDay) ?: continue
|
||||
val slot = AutoBuildSlots.latestDueSlot(definition.trigger.atTimes, timeOfDay) ?: continue
|
||||
val pool = BuildDefinition.poolName(branch, buildName)
|
||||
if (autoBuildState.value.isTriggered(pool, today, slot)) {
|
||||
continue
|
||||
|
||||
@@ -7,6 +7,29 @@
|
||||
<div th:replace="~{fragments :: nav(${view})}"></div>
|
||||
<div class="panel release-notes">
|
||||
|
||||
<h2>v0.9.20 <span class="muted">— 2026-08-29</span></h2>
|
||||
<ul>
|
||||
<li>A build definition is now split in two: a <code>trigger</code> block says when the
|
||||
build runs and for which branches (<code>onPush</code>, <code>atTimes</code>,
|
||||
<code>branches</code>, <code>activeWithin</code>), everything beside it says what
|
||||
the build does. Only the second half is inherited from <code>builds.default</code>,
|
||||
and making that structural means a selector added later cannot become inheritable
|
||||
by accident. A definition still writing those keys flat is refused by name — a
|
||||
trigger nobody reads any more is a build that silently stops running.</li>
|
||||
<li>A branch pattern prefixed with <code>!</code> excludes instead of selecting, and an
|
||||
exclusion wins whatever the order: <code>branches: ["*", "!master"]</code> is every
|
||||
branch but master. That lets one branch have a build of its own without being built
|
||||
by the default one as well — previously the only way to avoid the double build was
|
||||
to give up the second definition's push trigger.</li>
|
||||
<li>A build may report under its own Gitea check with <code>statusContext</code>; empty
|
||||
keeps the repository-wide <code>gitea.statusContext</code>. Two builds of one commit
|
||||
used to overwrite each other's result there, so a second build over the same branch
|
||||
— a quick check next to a long one — was not readable in Gitea.</li>
|
||||
<li><strong>Fixed:</strong> a branch whose builds all belong to named definitions showed
|
||||
an empty row in the branches view, reading as "never built" right next to its actual
|
||||
builds.</li>
|
||||
</ul>
|
||||
|
||||
<h2>v0.9.19 <span class="muted">— 2026-08-29</span></h2>
|
||||
<ul>
|
||||
<li>A build definition now describes its build completely: <code>requirePullRequest</code>
|
||||
|
||||
@@ -132,9 +132,9 @@ class BuildExecutorTest : FunSpec() {
|
||||
liveLog shouldContain "out-main"
|
||||
liveLog shouldContain "err-main"
|
||||
|
||||
verify { h.giteaClient.publishStatus("abc123", BuildStatus.PENDING, any(), null, h.workingDir) }
|
||||
verify { h.giteaClient.publishStatus("abc123", BuildStatus.RUNNING, any(), null, h.workingDir) }
|
||||
verify { h.giteaClient.publishStatus("abc123", BuildStatus.SUCCESS, any(), null, h.workingDir) }
|
||||
verify { h.giteaClient.publishStatus("abc123", BuildStatus.PENDING, any(), null, h.workingDir, any()) }
|
||||
verify { h.giteaClient.publishStatus("abc123", BuildStatus.RUNNING, any(), null, h.workingDir, any()) }
|
||||
verify { h.giteaClient.publishStatus("abc123", BuildStatus.SUCCESS, any(), null, h.workingDir, any()) }
|
||||
verify { h.artifactStore.persist(match { it.status == BuildStatus.SUCCESS }, build.stagingDir, h.workingDir) }
|
||||
}
|
||||
|
||||
@@ -395,7 +395,7 @@ class BuildExecutorTest : FunSpec() {
|
||||
h.events.map { it.result.status } shouldContainExactly
|
||||
listOf(BuildStatus.PENDING, BuildStatus.RUNNING, BuildStatus.INTERRUPTED)
|
||||
Files.readString(build.liveLogFile) shouldContain "build interrupted by shutdown"
|
||||
verify { h.giteaClient.publishStatus("abc123", BuildStatus.INTERRUPTED, any(), null, h.workingDir) }
|
||||
verify { h.giteaClient.publishStatus("abc123", BuildStatus.INTERRUPTED, any(), null, h.workingDir, any()) }
|
||||
verify { h.artifactStore.persist(match { it.status == BuildStatus.INTERRUPTED }, build.stagingDir, any()) }
|
||||
}
|
||||
|
||||
@@ -426,7 +426,7 @@ class BuildExecutorTest : FunSpec() {
|
||||
h.events
|
||||
.filter { it.result.artifactKey == second.artifactKey }
|
||||
.map { it.result.status } shouldContainExactly listOf(BuildStatus.PENDING)
|
||||
verify(exactly = 0) { h.giteaClient.publishStatus("sha-2", BuildStatus.RUNNING, any(), any(), any()) }
|
||||
verify(exactly = 0) { h.giteaClient.publishStatus("sha-2", BuildStatus.RUNNING, any(), any(), any(), any()) }
|
||||
}
|
||||
|
||||
test("shutdown without any build in flight is a no-op") {
|
||||
@@ -464,7 +464,7 @@ class BuildExecutorTest : FunSpec() {
|
||||
test("a Gitea failure does not fail the build") {
|
||||
val h = harness("echo ok")
|
||||
every {
|
||||
h.giteaClient.publishStatus(any(), any(), any(), any(), any())
|
||||
h.giteaClient.publishStatus(any(), any(), any(), any(), any(), any())
|
||||
} throws RuntimeException("gitea down")
|
||||
|
||||
h.executor.startBuild("main", "abc123", h.workingDir)
|
||||
|
||||
@@ -2,6 +2,8 @@ package de.hoennig.gittally.config
|
||||
|
||||
import io.kotest.assertions.throwables.shouldThrow
|
||||
import io.kotest.core.spec.style.FunSpec
|
||||
import io.kotest.matchers.booleans.shouldBeFalse
|
||||
import io.kotest.matchers.booleans.shouldBeTrue
|
||||
import io.kotest.matchers.maps.shouldBeEmpty
|
||||
import io.kotest.matchers.nulls.shouldNotBeNull
|
||||
import io.kotest.matchers.shouldBe
|
||||
@@ -69,9 +71,10 @@ class ConfigLoaderTest : FunSpec() {
|
||||
maxConcurrent: 2
|
||||
builds:
|
||||
pitest:
|
||||
atTimes: ["01:00"]
|
||||
branches: ["master", "release/*"]
|
||||
activeWithin: 24h
|
||||
trigger:
|
||||
atTimes: ["01:00"]
|
||||
branches: ["master", "release/*"]
|
||||
activeWithin: 24h
|
||||
buildCommand: ./gradlew piTestFull
|
||||
""".trimIndent(),
|
||||
)
|
||||
@@ -83,14 +86,17 @@ class ConfigLoaderTest : FunSpec() {
|
||||
mapOf(
|
||||
"pitest" to
|
||||
BuildDefinition(
|
||||
atTimes = listOf("01:00"),
|
||||
branches = listOf("master", "release/*"),
|
||||
activeWithin = "24h",
|
||||
trigger =
|
||||
TriggerConfig(
|
||||
atTimes = listOf("01:00"),
|
||||
branches = listOf("master", "release/*"),
|
||||
activeWithin = "24h",
|
||||
),
|
||||
buildCommand = "./gradlew piTestFull",
|
||||
),
|
||||
)
|
||||
// the implicit default build (onPush over all branches) stays in place
|
||||
config.effectiveBuildDefinitions()["default"] shouldBe BuildDefinition(onPush = true)
|
||||
config.effectiveBuildDefinitions()["default"] shouldBe BuildDefinition(trigger = TriggerConfig(onPush = true))
|
||||
}
|
||||
|
||||
test("an explicit builds.default entry overrides the implicit default build") {
|
||||
@@ -99,11 +105,12 @@ class ConfigLoaderTest : FunSpec() {
|
||||
"""
|
||||
builds:
|
||||
default:
|
||||
onPush: false
|
||||
trigger:
|
||||
onPush: false
|
||||
""".trimIndent(),
|
||||
)
|
||||
|
||||
loader.load(dir).effectiveBuildDefinitions()["default"] shouldBe BuildDefinition(onPush = false)
|
||||
loader.load(dir).effectiveBuildDefinitions()["default"] shouldBe BuildDefinition(trigger = TriggerConfig(onPush = false))
|
||||
}
|
||||
|
||||
test("a config that needs a newer GitTally is refused, naming the file and both versions") {
|
||||
@@ -209,7 +216,8 @@ class ConfigLoaderTest : FunSpec() {
|
||||
"""
|
||||
builds:
|
||||
pitest:
|
||||
atTimes: ["01:00"]
|
||||
trigger:
|
||||
atTimes: ["01:00"]
|
||||
buildCommand: ./gradlew piTestPartial
|
||||
""".trimIndent(),
|
||||
)
|
||||
@@ -220,7 +228,8 @@ class ConfigLoaderTest : FunSpec() {
|
||||
pitest:
|
||||
buildCommand: ./gradlew piTestFull
|
||||
experiment:
|
||||
onPush: true
|
||||
trigger:
|
||||
onPush: true
|
||||
""".trimIndent(),
|
||||
)
|
||||
|
||||
@@ -228,8 +237,12 @@ class ConfigLoaderTest : FunSpec() {
|
||||
|
||||
// the branch layer merges into the definition instead of replacing it
|
||||
config.buildDefinitions.getValue("pitest").buildCommand shouldBe "./gradlew piTestFull"
|
||||
config.buildDefinitions.getValue("pitest").atTimes shouldBe listOf("01:00")
|
||||
config.buildDefinitions.getValue("experiment").onPush shouldBe true
|
||||
config.buildDefinitions
|
||||
.getValue("pitest")
|
||||
.trigger.atTimes shouldBe listOf("01:00")
|
||||
config.buildDefinitions
|
||||
.getValue("experiment")
|
||||
.trigger.onPush shouldBe true
|
||||
}
|
||||
|
||||
test("a branch cannot raise the concurrency limit or reach the sandbox policy through a build definition") {
|
||||
@@ -239,6 +252,7 @@ class ConfigLoaderTest : FunSpec() {
|
||||
builds:
|
||||
default:
|
||||
requirePullRequest: true
|
||||
statusContext: GitTally
|
||||
docker:
|
||||
enabled: true
|
||||
network: none
|
||||
@@ -255,6 +269,7 @@ class ConfigLoaderTest : FunSpec() {
|
||||
builds:
|
||||
default:
|
||||
requirePullRequest: false
|
||||
statusContext: GitTally/impersonated
|
||||
docker:
|
||||
enabled: false
|
||||
network: host
|
||||
@@ -270,6 +285,7 @@ class ConfigLoaderTest : FunSpec() {
|
||||
settings.requirePullRequest shouldBe true
|
||||
settings.docker.enabled shouldBe true
|
||||
settings.docker.network shouldBe "none"
|
||||
settings.statusContext shouldBe "GitTally"
|
||||
// everything that describes the build itself stays the branch's own business
|
||||
settings.docker.image shouldBe "attacker-image"
|
||||
}
|
||||
@@ -291,7 +307,8 @@ class ConfigLoaderTest : FunSpec() {
|
||||
"""
|
||||
builds:
|
||||
invented:
|
||||
atTimes: ["03:00"]
|
||||
trigger:
|
||||
atTimes: ["03:00"]
|
||||
buildCommand: ./gradlew whatever
|
||||
docker:
|
||||
enabled: false
|
||||
@@ -309,20 +326,119 @@ class ConfigLoaderTest : FunSpec() {
|
||||
settings.requirePullRequest shouldBe true
|
||||
}
|
||||
|
||||
test("an exclusion pattern takes a branch out of a build that would otherwise select it") {
|
||||
val dir = Files.createTempDirectory("gittally-test")
|
||||
dir.resolve(".gittally.yml").toFile().writeText(
|
||||
"""
|
||||
builds:
|
||||
default:
|
||||
trigger:
|
||||
onPush: true
|
||||
branches: ["*", "!master"]
|
||||
release:
|
||||
trigger:
|
||||
onPush: true
|
||||
branches: ["master"]
|
||||
""".trimIndent(),
|
||||
)
|
||||
|
||||
val definitions = loader.load(dir).buildDefinitions
|
||||
|
||||
definitions
|
||||
.getValue("default")
|
||||
.trigger
|
||||
.selectsByName("mihoe/feature")
|
||||
.shouldBeTrue()
|
||||
definitions
|
||||
.getValue("default")
|
||||
.trigger
|
||||
.selectsByName("master")
|
||||
.shouldBeFalse()
|
||||
definitions
|
||||
.getValue("release")
|
||||
.trigger
|
||||
.selectsByName("master")
|
||||
.shouldBeTrue()
|
||||
definitions
|
||||
.getValue("release")
|
||||
.trigger
|
||||
.selectsByName("mihoe/feature")
|
||||
.shouldBeFalse()
|
||||
}
|
||||
|
||||
test("an exclusion wins over a matching pattern, whatever their order") {
|
||||
val trigger = TriggerConfig(branches = listOf("!release/hotfix", "release/*"))
|
||||
|
||||
trigger.selectsByName("release/1.0").shouldBeTrue()
|
||||
trigger.selectsByName("release/hotfix").shouldBeFalse()
|
||||
}
|
||||
|
||||
test("a trigger key written outside the trigger block is refused, naming the definition") {
|
||||
val dir = Files.createTempDirectory("gittally-test")
|
||||
dir.resolve(".gittally.yml").toFile().writeText(
|
||||
"""
|
||||
builds:
|
||||
nightly:
|
||||
atTimes: ["01:00"]
|
||||
buildCommand: ./gradlew check
|
||||
""".trimIndent(),
|
||||
)
|
||||
|
||||
// ignoring it would leave the build without a trigger — a job that silently
|
||||
// stops running is worse than a configuration that refuses to load
|
||||
val thrown = shouldThrow<ConfigFormatException> { loader.load(dir) }
|
||||
|
||||
thrown.message.shouldContain(".gittally.yml")
|
||||
thrown.message.shouldContain("builds.nightly: atTimes")
|
||||
thrown.message.shouldContain("trigger:")
|
||||
}
|
||||
|
||||
test("a branch writing its trigger flat fails only its own builds") {
|
||||
val dir = Files.createTempDirectory("gittally-test")
|
||||
dir.resolve(".gittally.yml").toFile().writeText(
|
||||
"""
|
||||
builds:
|
||||
default:
|
||||
trigger:
|
||||
onPush: true
|
||||
""".trimIndent(),
|
||||
)
|
||||
|
||||
shouldThrow<ConfigFormatException> {
|
||||
loader.loadWithBranchLayer(
|
||||
dir,
|
||||
"""
|
||||
builds:
|
||||
experiment:
|
||||
onPush: true
|
||||
""".trimIndent(),
|
||||
)
|
||||
}.message.shouldContain("this branch")
|
||||
// the primary config is untouched, so every other branch keeps building
|
||||
loader
|
||||
.load(dir)
|
||||
.buildDefinitions
|
||||
.getValue("default")
|
||||
.trigger.onPush
|
||||
.shouldBeTrue()
|
||||
}
|
||||
|
||||
test("builds.default is the base of every other build, but never its trigger") {
|
||||
val dir = Files.createTempDirectory("gittally-test")
|
||||
dir.resolve(".gittally.yml").toFile().writeText(
|
||||
"""
|
||||
builds:
|
||||
default:
|
||||
onPush: true
|
||||
branches: ["master"]
|
||||
trigger:
|
||||
onPush: true
|
||||
branches: ["master"]
|
||||
buildCommand: ./gradlew check
|
||||
artifactDirs: [build/reports]
|
||||
docker:
|
||||
image: shared-image
|
||||
nightly:
|
||||
atTimes: ["01:00"]
|
||||
trigger:
|
||||
atTimes: ["01:00"]
|
||||
artifactDirs: [build/reports, build/libs]
|
||||
""".trimIndent(),
|
||||
)
|
||||
@@ -333,9 +449,9 @@ class ConfigLoaderTest : FunSpec() {
|
||||
nightly.docker?.image shouldBe "shared-image"
|
||||
nightly.artifactDirs shouldBe listOf("build/reports", "build/libs")
|
||||
// a trigger says when *this* build runs; inheriting it would fire every job at once
|
||||
nightly.onPush shouldBe false
|
||||
nightly.branches shouldBe emptyList()
|
||||
nightly.atTimes shouldBe listOf("01:00")
|
||||
nightly.trigger.onPush shouldBe false
|
||||
nightly.trigger.branches shouldBe emptyList<String>()
|
||||
nightly.trigger.atTimes shouldBe listOf("01:00")
|
||||
}
|
||||
|
||||
test("branches is honored while no build is defined and ignored as soon as one is") {
|
||||
@@ -587,14 +703,17 @@ class ConfigLoaderTest : FunSpec() {
|
||||
default:
|
||||
buildCommand: from-branch
|
||||
pitest:
|
||||
atTimes: ["03:00"]
|
||||
trigger:
|
||||
atTimes: ["03:00"]
|
||||
buildCommand: ./gradlew piTestFull
|
||||
""".trimIndent(),
|
||||
)
|
||||
|
||||
config.git.token shouldBe "real-secret"
|
||||
config.buildSettings("main", "default").buildCommand shouldBe "from-branch"
|
||||
config.buildDefinitions.getValue("pitest").atTimes shouldBe listOf("03:00")
|
||||
config.buildDefinitions
|
||||
.getValue("pitest")
|
||||
.trigger.atTimes shouldBe listOf("03:00")
|
||||
}
|
||||
|
||||
test("loadWithBranchLayer without a branch config equals load") {
|
||||
|
||||
@@ -6,6 +6,7 @@ import com.github.tomakehurst.wiremock.client.WireMock.equalTo
|
||||
import com.github.tomakehurst.wiremock.client.WireMock.equalToJson
|
||||
import com.github.tomakehurst.wiremock.client.WireMock.get
|
||||
import com.github.tomakehurst.wiremock.client.WireMock.getRequestedFor
|
||||
import com.github.tomakehurst.wiremock.client.WireMock.matchingJsonPath
|
||||
import com.github.tomakehurst.wiremock.client.WireMock.okJson
|
||||
import com.github.tomakehurst.wiremock.client.WireMock.post
|
||||
import com.github.tomakehurst.wiremock.client.WireMock.postRequestedFor
|
||||
@@ -102,6 +103,34 @@ class GiteaClientTest :
|
||||
)
|
||||
}
|
||||
|
||||
test("a build's own status context replaces the repository-wide one") {
|
||||
server.stubFor(post(publishUrl).willReturn(aResponse().withStatus(201)))
|
||||
|
||||
// without this, two builds of one commit overwrite each other's check
|
||||
client.publishStatus(
|
||||
sha = "abc123",
|
||||
status = BuildStatus.SUCCESS,
|
||||
description = "d",
|
||||
context = "GitTally/quick",
|
||||
) shouldBe true
|
||||
|
||||
server.verify(
|
||||
postRequestedFor(urlEqualTo(publishUrl))
|
||||
.withRequestBody(matchingJsonPath("${'$'}.context", equalTo("GitTally/quick"))),
|
||||
)
|
||||
}
|
||||
|
||||
test("a blank build status context falls back to the repository-wide one") {
|
||||
server.stubFor(post(publishUrl).willReturn(aResponse().withStatus(201)))
|
||||
|
||||
client.publishStatus("abc123", BuildStatus.SUCCESS, "d", context = "") shouldBe true
|
||||
|
||||
server.verify(
|
||||
postRequestedFor(urlEqualTo(publishUrl))
|
||||
.withRequestBody(matchingJsonPath("${'$'}.context", equalTo("GitTally"))),
|
||||
)
|
||||
}
|
||||
|
||||
test("omits target_url when none is given") {
|
||||
server.stubFor(post(publishUrl).willReturn(aResponse().withStatus(201)))
|
||||
|
||||
|
||||
@@ -31,6 +31,19 @@ class BranchListingTest : FunSpec() {
|
||||
every { repository.latestPerName() } returns emptyList()
|
||||
}
|
||||
|
||||
test("a branch built only by named definitions loses its empty default row") {
|
||||
val releaseResult = mainResult.copy(build = "release", name = "master@release")
|
||||
every { gitService.originBranchHeads(any()) } returns mapOf("master" to "aaa", "idle" to "bbb")
|
||||
every { repository.latestPerName() } returns listOf(releaseResult.copy(branch = "master"))
|
||||
every { repository.latestFor(any()) } returns null
|
||||
every { repository.latestGreenFor(any()) } returns null
|
||||
|
||||
val rows = listing.branches()
|
||||
|
||||
// no bare "master" row next to master@release — it would read as "never built"
|
||||
rows.map { it.name } shouldBe listOf("master@release", "idle")
|
||||
}
|
||||
|
||||
test("orders main/master first, then flat names, then hierarchical names") {
|
||||
every { gitService.originBranchHeads(any()) } returns
|
||||
mapOf(
|
||||
|
||||
@@ -14,6 +14,7 @@ import de.hoennig.gittally.config.BranchConfig
|
||||
import de.hoennig.gittally.config.BuildDefinition
|
||||
import de.hoennig.gittally.config.ConfigLoader
|
||||
import de.hoennig.gittally.config.GitTallyConfig
|
||||
import de.hoennig.gittally.config.TriggerConfig
|
||||
import de.hoennig.gittally.config.WatcherConfig
|
||||
import de.hoennig.gittally.git.GitService
|
||||
import io.kotest.assertions.throwables.shouldThrow
|
||||
@@ -394,8 +395,7 @@ class WatcherTest : FunSpec() {
|
||||
mapOf(
|
||||
"pitest" to
|
||||
BuildDefinition(
|
||||
atTimes = listOf("11:00"),
|
||||
branches = listOf("main", "release/*"),
|
||||
trigger = TriggerConfig(atTimes = listOf("11:00"), branches = listOf("main", "release/*")),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -421,7 +421,7 @@ class WatcherTest : FunSpec() {
|
||||
Harness(
|
||||
GitTallyConfig(
|
||||
buildDefinitions =
|
||||
mapOf("pitest" to BuildDefinition(atTimes = listOf("11:00"), activeWithin = "24h")),
|
||||
mapOf("pitest" to BuildDefinition(trigger = TriggerConfig(atTimes = listOf("11:00"), activeWithin = "24h"))),
|
||||
),
|
||||
)
|
||||
every { harness.gitService.originBranches(any()) } returns listOf("active", "dormant")
|
||||
@@ -443,7 +443,7 @@ class WatcherTest : FunSpec() {
|
||||
Harness(
|
||||
GitTallyConfig(
|
||||
buildDefinitions =
|
||||
mapOf("lint" to BuildDefinition(onPush = true, branches = listOf("main"))),
|
||||
mapOf("lint" to BuildDefinition(trigger = TriggerConfig(onPush = true, branches = listOf("main")))),
|
||||
),
|
||||
)
|
||||
every { harness.gitService.originBranches(any()) } returns listOf("main", "feature/x")
|
||||
@@ -463,7 +463,7 @@ class WatcherTest : FunSpec() {
|
||||
|
||||
test("builds.default with onPush false disables the implicit on-push build") {
|
||||
val harness =
|
||||
Harness(GitTallyConfig(buildDefinitions = mapOf("default" to BuildDefinition(onPush = false))))
|
||||
Harness(GitTallyConfig(buildDefinitions = mapOf("default" to BuildDefinition(trigger = TriggerConfig(onPush = false)))))
|
||||
every { harness.gitService.originBranches(any()) } returns listOf("main")
|
||||
every { harness.gitService.localBranches(any()) } returns listOf("main")
|
||||
every { harness.gitService.hasNewCommits("main", any()) } returns true
|
||||
@@ -490,7 +490,7 @@ class WatcherTest : FunSpec() {
|
||||
val harness = Harness()
|
||||
val branchLayer =
|
||||
GitTallyConfig(
|
||||
buildDefinitions = mapOf("pitest" to BuildDefinition(atTimes = listOf("11:00"))),
|
||||
buildDefinitions = mapOf("pitest" to BuildDefinition(trigger = TriggerConfig(atTimes = listOf("11:00")))),
|
||||
)
|
||||
every { harness.gitService.originBranches(any()) } returns listOf("main", "experiment")
|
||||
every { harness.gitService.originBranchHeads(any()) } returns
|
||||
@@ -515,7 +515,7 @@ class WatcherTest : FunSpec() {
|
||||
val branchLayer =
|
||||
GitTallyConfig(
|
||||
buildDefinitions =
|
||||
mapOf("pitest" to BuildDefinition(atTimes = listOf("11:00"), branches = listOf("main"))),
|
||||
mapOf("pitest" to BuildDefinition(trigger = TriggerConfig(atTimes = listOf("11:00"), branches = listOf("main")))),
|
||||
)
|
||||
every { harness.gitService.originBranches(any()) } returns listOf("main", "experiment")
|
||||
every { harness.gitService.originBranchHeads(any()) } returns
|
||||
@@ -558,7 +558,7 @@ class WatcherTest : FunSpec() {
|
||||
// caching the definitions by head commit alone would never notice
|
||||
val edited =
|
||||
GitTallyConfig(
|
||||
buildDefinitions = mapOf("nightly" to BuildDefinition(atTimes = listOf("11:00"))),
|
||||
buildDefinitions = mapOf("nightly" to BuildDefinition(trigger = TriggerConfig(atTimes = listOf("11:00")))),
|
||||
)
|
||||
every { harness.configLoader.load(any()) } returns edited
|
||||
every { harness.configLoader.loadWithBranchLayer(any(), anyNullable()) } returns edited
|
||||
|
||||
Reference in New Issue
Block a user