From 0b17537b7cb09128e5014869e50c3c6c71429946 Mon Sep 17 00:00:00 2001 From: Michael Hoennig Date: Wed, 8 Jul 2026 06:32:40 +0200 Subject: [PATCH] ads PR-triggered builds --- docs/configuration.md | 27 +++++++ .../hoennig/gittally/commands/InitCommand.kt | 3 + .../hoennig/gittally/config/GitTallyConfig.kt | 5 ++ .../de/hoennig/gittally/git/GitService.kt | 12 ++++ .../de/hoennig/gittally/watcher/Watcher.kt | 23 +++++- .../de/hoennig/gittally/git/GitServiceTest.kt | 13 ++++ .../hoennig/gittally/watcher/WatcherTest.kt | 72 +++++++++++++++++++ 7 files changed, 152 insertions(+), 3 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index 9823976..567a97e 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -80,6 +80,9 @@ branches: - build/doc stdoutLog: build.stdout.log # filename for captured stdout stderrLog: build.stderr.log # filename for captured stderr + # Build this branch only while its head commit matches a pull-request head on origin + # (refs/pull/*/head — read via plain git, no API token needed; see notes below). + requirePullRequest: false autoBuild: enabled: false # whether to rebuild on schedule times: ["01:00"] # UTC times HH:MM for scheduled builds @@ -115,6 +118,30 @@ branches: - "04:00" ``` +### Notes on `branches..requirePullRequest` + +The gate applies to all watcher-triggered builds (push-triggered and scheduled auto builds). +A manual `gittally build ` always builds, regardless of this setting. + +Detection works without a Gitea API token: +the watcher lists `refs/pull/*/head` on origin via `git ls-remote` and builds a branch only when its head commit equals one of those pull-request head commits. +This ls-remote call is made at most once per poll cycle, and only when a branch requiring a pull request is otherwise due. + +Because matching is by commit id, a closed pull request whose head ref still equals the branch head also counts. +Distinguishing open from closed pull requests would require the Gitea API. + +To build pull-request branches only, set the key under `branches.default` and override it for permanent branches: + +```yaml +branches: + default: + requirePullRequest: true + main: + requirePullRequest: false +``` + +Without the `main` override, direct pushes and merges to `main` would never build — merge commits do not match any pull-request head. + ### Notes on `branches..docker` With `docker.enabled`, GitTally shells out to the `docker` CLI; the `docker` command must be on the `PATH`. diff --git a/src/main/kotlin/de/hoennig/gittally/commands/InitCommand.kt b/src/main/kotlin/de/hoennig/gittally/commands/InitCommand.kt index d838429..46e9102 100644 --- a/src/main/kotlin/de/hoennig/gittally/commands/InitCommand.kt +++ b/src/main/kotlin/de/hoennig/gittally/commands/InitCommand.kt @@ -166,6 +166,9 @@ class InitCommand( - build/reports stdoutLog: build.stdout.log # filename for captured stdout stderrLog: build.stderr.log # filename for captured stderr + # build only while the branch head matches a pull-request head on origin + # (refs/pull/*/head — read via plain git, no API token needed) + requirePullRequest: false autoBuild: enabled: false # whether to rebuild on schedule times: ["01:00"] # UTC times HH:MM for scheduled builds diff --git a/src/main/kotlin/de/hoennig/gittally/config/GitTallyConfig.kt b/src/main/kotlin/de/hoennig/gittally/config/GitTallyConfig.kt index aaf0924..b43cc1c 100644 --- a/src/main/kotlin/de/hoennig/gittally/config/GitTallyConfig.kt +++ b/src/main/kotlin/de/hoennig/gittally/config/GitTallyConfig.kt @@ -57,6 +57,11 @@ data class BranchConfig( val artifactDirs: List = listOf("build/reports"), val stdoutLog: String = "build.stdout.log", val stderrLog: String = "build.stderr.log", + /** + * The watcher builds this branch only while its head commit matches a pull-request + * head (`refs/pull//head` on origin); manual `build` commands are not affected. + */ + val requirePullRequest: Boolean = false, val autoBuild: AutoBuildConfig = AutoBuildConfig(), val docker: DockerConfig = DockerConfig(), ) diff --git a/src/main/kotlin/de/hoennig/gittally/git/GitService.kt b/src/main/kotlin/de/hoennig/gittally/git/GitService.kt index 899a6a4..5e0e358 100644 --- a/src/main/kotlin/de/hoennig/gittally/git/GitService.kt +++ b/src/main/kotlin/de/hoennig/gittally/git/GitService.kt @@ -63,6 +63,18 @@ class GitService( .filter { (branch, _) -> branch != "HEAD" } .toMap() + /** + * Head commits of the remote's pull-request refs (`refs/pull//head`), which Gitea + * exposes to plain git clients — no API token needed. A branch whose head appears + * here has a pull request open for exactly this commit. + */ + fun pullRequestHeads(workingDir: Path = Paths.get(".")): Set = + authenticated(workingDir) { environment -> + runner.runOrThrow(listOf("git", "ls-remote", "origin", "refs/pull/*/head"), workingDir, environment) + }.lines() + .map { it.substringBefore('\t') } + .toSet() + /** * A branch has new commits when its origin counterpart is ahead of the local branch, * or when it exists only on origin. diff --git a/src/main/kotlin/de/hoennig/gittally/watcher/Watcher.kt b/src/main/kotlin/de/hoennig/gittally/watcher/Watcher.kt index a5cb358..c4dcd3e 100644 --- a/src/main/kotlin/de/hoennig/gittally/watcher/Watcher.kt +++ b/src/main/kotlin/de/hoennig/gittally/watcher/Watcher.kt @@ -6,6 +6,7 @@ import de.hoennig.gittally.build.BuildExecutor import de.hoennig.gittally.build.BuildResultRepository import de.hoennig.gittally.build.BuildStatus import de.hoennig.gittally.build.GitWorktreeWorkspaces +import de.hoennig.gittally.config.BranchConfig import de.hoennig.gittally.config.ConfigLoader import de.hoennig.gittally.config.DurationParser import de.hoennig.gittally.config.GitTallyConfig @@ -153,6 +154,8 @@ class Watcher( originBranches: Set, workingDir: Path, ) { + // one ls-remote per poll cycle at most, and only when a due branch requires a pull request + val pullRequestHeads = lazy { gitService.pullRequestHeads(workingDir) } val changedLocal = gitService .localBranches(workingDir) @@ -160,9 +163,9 @@ class Watcher( val newOrigin = gitService.newOriginBranches(DurationParser.parse(config.watcher.newBranchMaxAge), workingDir) for (branch in (changedLocal + newOrigin).distinct()) { - startBuildIfDue(branch, allowSameCommit = false, workingDir = workingDir) + startBuildIfDue(branch, allowSameCommit = false, config, pullRequestHeads, workingDir) } - enqueueAutoBuilds(config, originBranches, workingDir) + enqueueAutoBuilds(config, originBranches, pullRequestHeads, workingDir) } /** @@ -171,10 +174,14 @@ class Watcher( * never move local branch refs, so "already built" is tracked via the result * repository, not by resetting the local ref like legacy. A new commit for a * branch that is still pending/running waits for a later cycle (queue-behind). + * With `requirePullRequest`, the branch head must match a pull-request head on + * origin (`refs/pull//head`); manual `build` commands bypass this gate. */ private fun startBuildIfDue( branch: String, allowSameCommit: Boolean, + config: GitTallyConfig, + pullRequestHeads: Lazy>, workingDir: Path, ): Boolean { val latest = repository.latestFor(branch) @@ -185,14 +192,24 @@ class Watcher( if (!allowSameCommit && latest?.commit == commit) { return false } + if (branchConfig(config, branch).requirePullRequest && commit !in pullRequestHeads.value) { + log.info("not enqueueing branch {}: no pull request has head commit {}", branch, commit) + return false + } log.info("enqueueing build of branch {} at commit {}", branch, commit) buildExecutor.startBuild(branch, commit, workingDir) return true } + private fun branchConfig( + config: GitTallyConfig, + branch: String, + ): BranchConfig = config.branches[branch] ?: config.branches["default"] ?: BranchConfig() + private fun enqueueAutoBuilds( config: GitTallyConfig, originBranches: Set, + pullRequestHeads: Lazy>, workingDir: Path, ) { val autoBuildBranches = @@ -216,7 +233,7 @@ class Watcher( continue } // rebuilding the already-built commit is the point of an auto build - if (startBuildIfDue(branch, allowSameCommit = true, workingDir = workingDir)) { + if (startBuildIfDue(branch, allowSameCommit = true, config, pullRequestHeads, workingDir)) { autoBuildState.markTriggered(branch, today, slot) } } diff --git a/src/test/kotlin/de/hoennig/gittally/git/GitServiceTest.kt b/src/test/kotlin/de/hoennig/gittally/git/GitServiceTest.kt index ab09ba2..c54401d 100644 --- a/src/test/kotlin/de/hoennig/gittally/git/GitServiceTest.kt +++ b/src/test/kotlin/de/hoennig/gittally/git/GitServiceTest.kt @@ -113,6 +113,19 @@ class GitServiceTest : FunSpec() { heads["feature/x"] shouldBe fixture.git(fixture.work, "rev-parse", "refs/remotes/origin/feature/x").stdout.trim() } + test("pullRequestHeads returns the head commits of the remote's pull-request refs") { + val fixture = Fixture() + fixture.pushNewSeedBranch("feature/x") + + service.pullRequestHeads(fixture.work) shouldBe emptySet() + + // Gitea materializes a pull request as refs/pull//head on the served repository + fixture.git(fixture.seed, "push", "origin", "refs/heads/feature/x:refs/pull/1/head") + val featureHead = fixture.git(fixture.seed, "rev-parse", "refs/heads/feature/x").stdout.trim() + + service.pullRequestHeads(fixture.work) shouldBe setOf(featureHead) + } + test("fetchOrigin picks up new origin branches and prunes deleted ones") { val fixture = Fixture() fixture.pushNewSeedBranch("feature/x") diff --git a/src/test/kotlin/de/hoennig/gittally/watcher/WatcherTest.kt b/src/test/kotlin/de/hoennig/gittally/watcher/WatcherTest.kt index 76cd48f..2cce0be 100644 --- a/src/test/kotlin/de/hoennig/gittally/watcher/WatcherTest.kt +++ b/src/test/kotlin/de/hoennig/gittally/watcher/WatcherTest.kt @@ -72,6 +72,7 @@ class WatcherTest : FunSpec() { every { gitService.newOriginBranches(any(), any()) } returns emptyList() every { gitService.hasNewCommits(any(), any()) } returns false every { gitService.originHeadCommit(any(), any()) } returns null + every { gitService.pullRequestHeads(any()) } returns emptySet() every { gitService.worktreePrune(any()) } returns Unit every { buildExecutor.currentBuilds() } returns emptyList() every { buildExecutor.startBuild(any(), any(), any()) } answers { @@ -230,6 +231,77 @@ class WatcherTest : FunSpec() { verify { harness.gitService.newOriginBranches(Duration.ofHours(12), any()) } } + test("a branch requiring a pull request is only built when its head matches a pull-request head") { + val harness = Harness(GitTallyConfig(branches = mapOf("default" to BranchConfig(requirePullRequest = true)))) + every { harness.gitService.originBranches(any()) } returns listOf("feature/pr", "feature/no-pr") + every { harness.gitService.newOriginBranches(any(), any()) } returns listOf("feature/pr", "feature/no-pr") + every { harness.gitService.originHeadCommit("feature/pr", any()) } returns "commit-pr" + every { harness.gitService.originHeadCommit("feature/no-pr", any()) } returns "commit-solo" + every { harness.gitService.pullRequestHeads(any()) } returns setOf("commit-pr") + + harness.watcher.poll(harness.workingDir) + + harness.startedBuilds shouldContainExactly listOf("feature/pr" to "commit-pr") + } + + test("pull-request refs are not queried when no due branch requires a pull request") { + val harness = Harness() + every { harness.gitService.originBranches(any()) } returns listOf("feature/x") + every { harness.gitService.newOriginBranches(any(), any()) } returns listOf("feature/x") + every { harness.gitService.originHeadCommit("feature/x", any()) } returns "commit-x" + + harness.watcher.poll(harness.workingDir) + + harness.startedBuilds shouldContainExactly listOf("feature/x" to "commit-x") + verify(exactly = 0) { harness.gitService.pullRequestHeads(any()) } + } + + test("a branch entry overrides requirePullRequest from the default entry") { + val harness = + Harness( + GitTallyConfig( + branches = + mapOf( + "default" to BranchConfig(requirePullRequest = true), + "main" to BranchConfig(requirePullRequest = 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 + every { harness.gitService.originHeadCommit("main", any()) } returns "commit-main" + + harness.watcher.poll(harness.workingDir) + + harness.startedBuilds shouldContainExactly listOf("main" to "commit-main") + } + + test("an auto build requiring a pull request is skipped and its slot stays untriggered") { + val harness = + Harness( + GitTallyConfig( + branches = + mapOf( + "default" to BranchConfig(), + "main" to + BranchConfig( + requirePullRequest = true, + autoBuild = AutoBuildConfig(enabled = true, times = listOf("11:00")), + ), + ), + ), + ) + harness.seed("main", BuildStatus.SUCCESS, commit = "commit-abc") + every { harness.gitService.originBranches(any()) } returns listOf("main") + every { harness.gitService.originHeadCommit("main", any()) } returns "commit-abc" + + harness.watcher.poll(harness.workingDir) + + harness.startedBuilds.shouldBeEmpty() + harness.autoBuildState().isTriggered("main", LocalDate.parse("2026-07-07"), "11:00").shouldBeFalse() + } + test("auto builds rebuild the already built commit once per day and slot") { val harness = Harness(autoBuildConfig("01:00", "11:00", "13:00")) harness.seed("main", BuildStatus.SUCCESS, commit = "commit-abc")