From 939d8eeb9c4876d8a15538901c688f40b68a1acd Mon Sep 17 00:00:00 2001 From: mhoennig Date: Sat, 29 Aug 2026 07:52:22 +0200 Subject: [PATCH] Hourly scheduled builds via a ??:MM slot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `atTimes: ["??:05"]` runs a build five past every hour. The pattern expands to its 24 concrete slots before the due-slot match, so each hour is its own slot in the trigger state and fires once — the existing per-slot semantics carry over unchanged, including that only the latest due slot of a day triggers and that a slot whose pool is still building is retried until it starts. Only the hour may be a wildcard; anything else is skipped with a warning. Co-Authored-By: Claude Opus 5 --- docs/configuration.md | 4 ++- .../hoennig/gittally/commands/InitCommand.kt | 2 +- .../gittally/config/BuildDefinition.kt | 6 ++++- .../gittally/watcher/AutoBuildState.kt | 25 ++++++++++++++++--- .../gittally/watcher/AutoBuildStateTest.kt | 24 ++++++++++++++++++ 5 files changed, 55 insertions(+), 6 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index ccfbf17..e4e53f0 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -118,7 +118,7 @@ builds: # Example of a named build definition; all keys except its name are optional: # pitest: # onPush: false # trigger: build every new commit (default: false) - # atTimes: ["01:00"] # trigger: daily UTC times HH:MM (default: none) + # atTimes: ["01:00"] # trigger: daily UTC times HH:MM, "??:05" = hourly at :05 # branches: ["master", "release/*"] # selector: names or glob patterns (default: all) # activeWithin: 24h # selector: only branches with commits in the last 24h # buildCommand: ./gradlew piTestFull # overrides; unset keys fall back to the @@ -265,6 +265,8 @@ Every key of the `builds` section names a build definition (a job) over the bran A build definition has triggers, a branch selector, and build-setting overrides. Triggers: `onPush: true` builds every new commit of the selected branches; `atTimes: ["HH:MM", …]` rebuilds their heads once per day and slot (UTC). +A slot may also be written as `??:MM` — that minute of every hour, expanded to its 24 slots, so the build runs hourly. +Only the latest due slot of a day triggers, so slots missed while the server was down are skipped instead of piling up, and a slot whose pool is still building is retried on the next poll cycle until it succeeds. A definition may have both; one with neither never triggers automatically. Selector: `branches` lists branch names or glob patterns (`*` matches any characters, also across `/`); empty selects all origin branches. diff --git a/src/main/kotlin/de/hoennig/gittally/commands/InitCommand.kt b/src/main/kotlin/de/hoennig/gittally/commands/InitCommand.kt index 5dadccb..d1d4383 100644 --- a/src/main/kotlin/de/hoennig/gittally/commands/InitCommand.kt +++ b/src/main/kotlin/de/hoennig/gittally/commands/InitCommand.kt @@ -162,7 +162,7 @@ class InitCommand( # Example definition — triggers (onPush/atTimes), branch selector # (branches/activeWithin), and overrides of the branch settings: # pitest: - # atTimes: ["01:00"] # daily UTC times HH:MM + # 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 # buildCommand: ./gradlew piTestFull diff --git a/src/main/kotlin/de/hoennig/gittally/config/BuildDefinition.kt b/src/main/kotlin/de/hoennig/gittally/config/BuildDefinition.kt index fd98a60..78c243a 100644 --- a/src/main/kotlin/de/hoennig/gittally/config/BuildDefinition.kt +++ b/src/main/kotlin/de/hoennig/gittally/config/BuildDefinition.kt @@ -16,7 +16,11 @@ import java.time.Instant data class BuildDefinition( /** Build every new commit of the selected branches. */ val onPush: Boolean = false, - /** Daily UTC times `HH:MM`; each slot rebuilds the selected branches' heads once per day. */ + /** + * Daily UTC times `HH:MM`; each slot rebuilds the selected branches' heads once per day. + * `??:MM` is the hourly form — it stands for that minute of every hour, so each of its + * 24 slots triggers separately. + */ val atTimes: List = emptyList(), /** * Branch names or glob patterns (`*` matches any characters, also across `/`); diff --git a/src/main/kotlin/de/hoennig/gittally/watcher/AutoBuildState.kt b/src/main/kotlin/de/hoennig/gittally/watcher/AutoBuildState.kt index 36b32a1..5d5f734 100644 --- a/src/main/kotlin/de/hoennig/gittally/watcher/AutoBuildState.kt +++ b/src/main/kotlin/de/hoennig/gittally/watcher/AutoBuildState.kt @@ -29,22 +29,41 @@ data class AutoBuildTrigger( object AutoBuildSlots { private val log = LoggerFactory.getLogger(AutoBuildSlots::class.java) - /** The latest valid slot at or before [now], or null when no slot is due yet today. */ + /** + * The latest valid slot at or before [now], or null when no slot is due yet today. + * The returned slot is always a concrete `HH:MM` — an hourly pattern is expanded + * first, so each of its hours triggers separately. + */ fun latestDueSlot( times: List, now: LocalTime, ): String? = times + .flatMap { expand(it) } .mapNotNull { slot -> try { - LocalTime.parse(slot.trim()) to slot + LocalTime.parse(slot) to slot } catch (_: DateTimeParseException) { - log.warn("skipping invalid scheduled-build time slot '{}': expected HH:MM", slot) + log.warn("skipping invalid scheduled-build time slot '{}': expected HH:MM or ??:MM", slot) null } }.filter { (parsed, _) -> !parsed.isAfter(now) } .maxByOrNull { (parsed, _) -> parsed } ?.second + + /** `??:MM` means every hour at that minute and expands to its 24 slots; `HH:MM` is itself. */ + private fun expand(time: String): List { + val slot = time.trim() + if (!slot.startsWith("??:")) { + return listOf(slot) + } + val minute = slot.substringAfter(':').toIntOrNull() + if (minute == null || minute !in 0..59) { + log.warn("skipping invalid scheduled-build time slot '{}': expected ??:MM with MM from 00 to 59", slot) + return emptyList() + } + return (0..23).map { hour -> "%02d:%02d".format(hour, minute) } + } } /** diff --git a/src/test/kotlin/de/hoennig/gittally/watcher/AutoBuildStateTest.kt b/src/test/kotlin/de/hoennig/gittally/watcher/AutoBuildStateTest.kt index 59263a4..ade2cc8 100644 --- a/src/test/kotlin/de/hoennig/gittally/watcher/AutoBuildStateTest.kt +++ b/src/test/kotlin/de/hoennig/gittally/watcher/AutoBuildStateTest.kt @@ -28,6 +28,30 @@ class AutoBuildStateTest : FunSpec() { AutoBuildSlots.latestDueSlot(listOf("25:99"), LocalTime.parse("12:00")).shouldBeNull() } + test("an hourly ??:MM slot is due every hour and resolves to that hour's concrete slot") { + val times = listOf("??:05") + + AutoBuildSlots.latestDueSlot(times, LocalTime.parse("00:04")).shouldBeNull() + AutoBuildSlots.latestDueSlot(times, LocalTime.parse("00:05")) shouldBe "00:05" + AutoBuildSlots.latestDueSlot(times, LocalTime.parse("07:30")) shouldBe "07:05" + // the next hour is a different slot, so it triggers again + AutoBuildSlots.latestDueSlot(times, LocalTime.parse("08:05")) shouldBe "08:05" + AutoBuildSlots.latestDueSlot(times, LocalTime.parse("23:59")) shouldBe "23:05" + } + + test("hourly and fixed slots combine, the latest due one wins") { + val times = listOf("??:05", "12:30") + + AutoBuildSlots.latestDueSlot(times, LocalTime.parse("12:20")) shouldBe "12:05" + AutoBuildSlots.latestDueSlot(times, LocalTime.parse("12:45")) shouldBe "12:30" + AutoBuildSlots.latestDueSlot(times, LocalTime.parse("13:10")) shouldBe "13:05" + } + + test("latestDueSlot skips an hourly slot with an impossible minute") { + AutoBuildSlots.latestDueSlot(listOf("??:70"), LocalTime.parse("12:00")).shouldBeNull() + AutoBuildSlots.latestDueSlot(listOf("??:xx", "02:00"), LocalTime.parse("12:00")) shouldBe "02:00" + } + test("latestDueSlot of an empty slot list is null") { AutoBuildSlots.latestDueSlot(emptyList(), LocalTime.parse("12:00")).shouldBeNull() }