ads PR-triggered builds
This commit is contained in:
@@ -80,6 +80,9 @@ branches:
|
|||||||
- build/doc
|
- build/doc
|
||||||
stdoutLog: build.stdout.log # filename for captured stdout
|
stdoutLog: build.stdout.log # filename for captured stdout
|
||||||
stderrLog: build.stderr.log # filename for captured stderr
|
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:
|
autoBuild:
|
||||||
enabled: false # whether to rebuild on schedule
|
enabled: false # whether to rebuild on schedule
|
||||||
times: ["01:00"] # UTC times HH:MM for scheduled builds
|
times: ["01:00"] # UTC times HH:MM for scheduled builds
|
||||||
@@ -115,6 +118,30 @@ branches:
|
|||||||
- "04:00"
|
- "04:00"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Notes on `branches.<name>.requirePullRequest`
|
||||||
|
|
||||||
|
The gate applies to all watcher-triggered builds (push-triggered and scheduled auto builds).
|
||||||
|
A manual `gittally build <branch>` 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.<name>.docker`
|
### Notes on `branches.<name>.docker`
|
||||||
|
|
||||||
With `docker.enabled`, GitTally shells out to the `docker` CLI; the `docker` command must be on the `PATH`.
|
With `docker.enabled`, GitTally shells out to the `docker` CLI; the `docker` command must be on the `PATH`.
|
||||||
|
|||||||
@@ -166,6 +166,9 @@ class InitCommand(
|
|||||||
- build/reports
|
- build/reports
|
||||||
stdoutLog: build.stdout.log # filename for captured stdout
|
stdoutLog: build.stdout.log # filename for captured stdout
|
||||||
stderrLog: build.stderr.log # filename for captured stderr
|
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:
|
autoBuild:
|
||||||
enabled: false # whether to rebuild on schedule
|
enabled: false # whether to rebuild on schedule
|
||||||
times: ["01:00"] # UTC times HH:MM for scheduled builds
|
times: ["01:00"] # UTC times HH:MM for scheduled builds
|
||||||
|
|||||||
@@ -57,6 +57,11 @@ data class BranchConfig(
|
|||||||
val artifactDirs: List<String> = listOf("build/reports"),
|
val artifactDirs: List<String> = listOf("build/reports"),
|
||||||
val stdoutLog: String = "build.stdout.log",
|
val stdoutLog: String = "build.stdout.log",
|
||||||
val stderrLog: String = "build.stderr.log",
|
val stderrLog: String = "build.stderr.log",
|
||||||
|
/**
|
||||||
|
* The watcher builds this branch only while its head commit matches a pull-request
|
||||||
|
* head (`refs/pull/<n>/head` on origin); manual `build` commands are not affected.
|
||||||
|
*/
|
||||||
|
val requirePullRequest: Boolean = false,
|
||||||
val autoBuild: AutoBuildConfig = AutoBuildConfig(),
|
val autoBuild: AutoBuildConfig = AutoBuildConfig(),
|
||||||
val docker: DockerConfig = DockerConfig(),
|
val docker: DockerConfig = DockerConfig(),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -63,6 +63,18 @@ class GitService(
|
|||||||
.filter { (branch, _) -> branch != "HEAD" }
|
.filter { (branch, _) -> branch != "HEAD" }
|
||||||
.toMap()
|
.toMap()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Head commits of the remote's pull-request refs (`refs/pull/<n>/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<String> =
|
||||||
|
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,
|
* A branch has new commits when its origin counterpart is ahead of the local branch,
|
||||||
* or when it exists only on origin.
|
* or when it exists only on origin.
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import de.hoennig.gittally.build.BuildExecutor
|
|||||||
import de.hoennig.gittally.build.BuildResultRepository
|
import de.hoennig.gittally.build.BuildResultRepository
|
||||||
import de.hoennig.gittally.build.BuildStatus
|
import de.hoennig.gittally.build.BuildStatus
|
||||||
import de.hoennig.gittally.build.GitWorktreeWorkspaces
|
import de.hoennig.gittally.build.GitWorktreeWorkspaces
|
||||||
|
import de.hoennig.gittally.config.BranchConfig
|
||||||
import de.hoennig.gittally.config.ConfigLoader
|
import de.hoennig.gittally.config.ConfigLoader
|
||||||
import de.hoennig.gittally.config.DurationParser
|
import de.hoennig.gittally.config.DurationParser
|
||||||
import de.hoennig.gittally.config.GitTallyConfig
|
import de.hoennig.gittally.config.GitTallyConfig
|
||||||
@@ -153,6 +154,8 @@ class Watcher(
|
|||||||
originBranches: Set<String>,
|
originBranches: Set<String>,
|
||||||
workingDir: Path,
|
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 =
|
val changedLocal =
|
||||||
gitService
|
gitService
|
||||||
.localBranches(workingDir)
|
.localBranches(workingDir)
|
||||||
@@ -160,9 +163,9 @@ class Watcher(
|
|||||||
val newOrigin =
|
val newOrigin =
|
||||||
gitService.newOriginBranches(DurationParser.parse(config.watcher.newBranchMaxAge), workingDir)
|
gitService.newOriginBranches(DurationParser.parse(config.watcher.newBranchMaxAge), workingDir)
|
||||||
for (branch in (changedLocal + newOrigin).distinct()) {
|
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
|
* 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
|
* 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).
|
* 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/<n>/head`); manual `build` commands bypass this gate.
|
||||||
*/
|
*/
|
||||||
private fun startBuildIfDue(
|
private fun startBuildIfDue(
|
||||||
branch: String,
|
branch: String,
|
||||||
allowSameCommit: Boolean,
|
allowSameCommit: Boolean,
|
||||||
|
config: GitTallyConfig,
|
||||||
|
pullRequestHeads: Lazy<Set<String>>,
|
||||||
workingDir: Path,
|
workingDir: Path,
|
||||||
): Boolean {
|
): Boolean {
|
||||||
val latest = repository.latestFor(branch)
|
val latest = repository.latestFor(branch)
|
||||||
@@ -185,14 +192,24 @@ class Watcher(
|
|||||||
if (!allowSameCommit && latest?.commit == commit) {
|
if (!allowSameCommit && latest?.commit == commit) {
|
||||||
return false
|
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)
|
log.info("enqueueing build of branch {} at commit {}", branch, commit)
|
||||||
buildExecutor.startBuild(branch, commit, workingDir)
|
buildExecutor.startBuild(branch, commit, workingDir)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun branchConfig(
|
||||||
|
config: GitTallyConfig,
|
||||||
|
branch: String,
|
||||||
|
): BranchConfig = config.branches[branch] ?: config.branches["default"] ?: BranchConfig()
|
||||||
|
|
||||||
private fun enqueueAutoBuilds(
|
private fun enqueueAutoBuilds(
|
||||||
config: GitTallyConfig,
|
config: GitTallyConfig,
|
||||||
originBranches: Set<String>,
|
originBranches: Set<String>,
|
||||||
|
pullRequestHeads: Lazy<Set<String>>,
|
||||||
workingDir: Path,
|
workingDir: Path,
|
||||||
) {
|
) {
|
||||||
val autoBuildBranches =
|
val autoBuildBranches =
|
||||||
@@ -216,7 +233,7 @@ class Watcher(
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// rebuilding the already-built commit is the point of an auto build
|
// 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)
|
autoBuildState.markTriggered(branch, today, slot)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -113,6 +113,19 @@ class GitServiceTest : FunSpec() {
|
|||||||
heads["feature/x"] shouldBe fixture.git(fixture.work, "rev-parse", "refs/remotes/origin/feature/x").stdout.trim()
|
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/<n>/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") {
|
test("fetchOrigin picks up new origin branches and prunes deleted ones") {
|
||||||
val fixture = Fixture()
|
val fixture = Fixture()
|
||||||
fixture.pushNewSeedBranch("feature/x")
|
fixture.pushNewSeedBranch("feature/x")
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ class WatcherTest : FunSpec() {
|
|||||||
every { gitService.newOriginBranches(any(), any()) } returns emptyList()
|
every { gitService.newOriginBranches(any(), any()) } returns emptyList()
|
||||||
every { gitService.hasNewCommits(any(), any()) } returns false
|
every { gitService.hasNewCommits(any(), any()) } returns false
|
||||||
every { gitService.originHeadCommit(any(), any()) } returns null
|
every { gitService.originHeadCommit(any(), any()) } returns null
|
||||||
|
every { gitService.pullRequestHeads(any()) } returns emptySet()
|
||||||
every { gitService.worktreePrune(any()) } returns Unit
|
every { gitService.worktreePrune(any()) } returns Unit
|
||||||
every { buildExecutor.currentBuilds() } returns emptyList()
|
every { buildExecutor.currentBuilds() } returns emptyList()
|
||||||
every { buildExecutor.startBuild(any(), any(), any()) } answers {
|
every { buildExecutor.startBuild(any(), any(), any()) } answers {
|
||||||
@@ -230,6 +231,77 @@ class WatcherTest : FunSpec() {
|
|||||||
verify { harness.gitService.newOriginBranches(Duration.ofHours(12), any()) }
|
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") {
|
test("auto builds rebuild the already built commit once per day and slot") {
|
||||||
val harness = Harness(autoBuildConfig("01:00", "11:00", "13:00"))
|
val harness = Harness(autoBuildConfig("01:00", "11:00", "13:00"))
|
||||||
harness.seed("main", BuildStatus.SUCCESS, commit = "commit-abc")
|
harness.seed("main", BuildStatus.SUCCESS, commit = "commit-abc")
|
||||||
|
|||||||
Reference in New Issue
Block a user