watchpr: start the conflict window at the arm
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful

The window ran from the first non-mergeable poll even while the rule was
disarmed, so the poll that armed it confirmed a run it played no part in.
Measure from the later of the run's start and the arm; re-arming stays a
no-op so a base moving under every poll still confirms.
This commit is contained in:
2026-09-26 22:14:25 +10:00
parent 7ffa123e3d
commit 5928233a97
6 changed files with 218 additions and 41 deletions
+17 -9
View File
@@ -235,16 +235,24 @@ wrapped per stage (login / read denied / write denied) via `ErrVaultDenied`.
`false`s has spanned `conflictWindow` (2m); a mergeable poll, an unknown one or
a failed poll all break the run. The debounce is wall-clock, measured from the
tick that fired each poll, because what it outlasts is the recompute and
`--interval` spans seconds to hours. Arming and the run are independent:
movement arms and nothing else, since `base.sha` is the base branch tip as of
the response, so it moves for every open PR whenever main does and a run reset
there could never complete on a busy repo. `Mergeability` stays tri-state for
what the bool cannot carry: an absent/null flag from some other Gitea, and a
state no successful poll ever filled in. Head and base SHAs are read only to
`--interval` spans seconds to hours. The window starts at the later of the
run's first observation and the arm, which is what keeps arming and the run
independent: movement arms without resetting the run — `base.sha` is the base
branch tip as of the response, so it moves for every open PR whenever main does
and a reset there could never complete on a busy repo — while an arming poll
still cannot confirm a run it played no part in. Re-arming an already-armed
watch is a no-op, so a base moving under every poll advances the window once
and never again. Both times are pointers because the zero `time.Time` is a
legal clock value and cannot also mean "no run". `Mergeability` stays tri-state
for what the bool cannot carry: an absent/null flag from some other Gitea, and
a state no successful poll ever filled in. Head and base SHAs are read only to
arm — a push never alerts. The residue: a conflict landed by the push just
before the watch began, on a head and base that never move again, is never
reported; Gitea's payload has no field separating it from a check in flight
(`merge_base` is the true merge base and does not move on recheck).
before the watch began is unreported while the commits stay put, since Gitea's
payload has no field separating it from a check in flight (`merge_base` is the
true merge base and does not move on recheck). The commits rarely stay put —
a merge to the base branch moves `base.sha` under every open PR — but that
move only arms; the two minutes it then has to outlast are the recompute the
move started, not the falses before it.
- CI "combined status" comes from `/commits/{sha}/status`; an empty head SHA
yields an empty state without an API call.
- Gitea backs every PR with an issue of the same number and serves comments from
+14 -8
View File
@@ -94,15 +94,21 @@ watched, and the baseline line — written to stderr, as a JSON record under
Gitea reports `mergeable: false` both for a real conflict and while it
recomputes the merge base after a push, so a conflict is only reported once the
watch has seen a merge computation start (the PR was mergeable, or its head or
base commit moved) **and** the non-mergeable polls have then run unbroken for
two minutes. The debounce is a duration, not a poll count, because what it has
to outlast is Gitea's recompute and `--interval` ranges from seconds to hours.
base commit moved) **and** the non-mergeable polls have **then** run unbroken for
two minutes. The window runs from whichever came later, so the poll that starts
the merge computation never confirms a run of falses that predates it. The
debounce is a duration, not a poll count, because what it has to outlast is
Gitea's recompute and `--interval` ranges from seconds to hours.
One case is therefore never reported: a conflict introduced by the push
immediately before the watch started, on a PR whose head and base never move
again. Nothing in Gitea's payload separates that from a merge check still in
flight, so watchpr stays silent about it for as long as it runs — the baseline
line is how you see it.
So a conflict introduced by the push immediately before the watch started is not
reported while the commits stay put: nothing in Gitea's payload separates it from
a merge check still in flight, and the baseline line is the only notice of it.
That is a narrower gap than it looks, because `base.sha` is the base branch's
tip — it moves for every open PR whenever anything merges to the base branch, so
the commits rarely stay put for long. A move like that arms the rule, and the
move itself is silent: the window restarts from it, so what is eventually
reported is a conflict still standing two minutes after that fresh merge
computation began, never the run of falses that preceded it.
```bash
# Watch until something meaningful happens (default interval 60s)
+31 -10
View File
@@ -5,11 +5,11 @@
// agent's own pushes and comments) are ignored, and so is any condition that was
// already true at the baseline -- which watchpr prints on stderr (as a JSON
// record under --json), so a run started against a conflicted or CI-red PR says
// so. One case is never reported at all: a conflict introduced by the push
// immediately before the watch started, on a PR whose head and base then never
// move, stays silent for the whole run, because nothing in Gitea's payload
// separates it from a merge check still in flight. The baseline line is the
// only notice of it.
// so. A conflict introduced by the push immediately before the watch started is
// not reported while the commits stay put, because nothing in Gitea's payload
// separates it from a merge check still in flight; the baseline line is the only
// notice of it. A later move of the head or base arms the rule without alerting,
// and restarts the two-minute window from itself.
//
// watchpr owner/repo#12 owner/repo:15
// watchpr --once --json owner/repo#12
@@ -73,7 +73,7 @@ func newRootCmd() *cobra.Command {
}
refs = append(refs, ref)
}
c := clientFor()
c := clientFor(jsonMode)
if once {
return runOnce(c, refs, jsonMode)
}
@@ -99,15 +99,33 @@ func newRootCmd() *cobra.Command {
// clientFor builds the Gitea client. Watching public repos works anonymously,
// so an unavailable token is a warning, not a failure; a poll that is actually
// rejected re-mints then.
func clientFor() *agent.GiteaClient {
func clientFor(jsonMode bool) *agent.GiteaClient {
token, err := agent.GiteaToken()
if err != nil {
fmt.Fprintf(os.Stderr, "warning: no Gitea token (%v); polling anonymously\n", err)
warn(os.Stderr, jsonMode, "no Gitea token (%v); polling anonymously", err)
token = ""
}
return agent.NewGiteaClient(token)
}
// warnRecord is the --json form of a warning. Under --json stderr carries
// nothing but NDJSON records, so a caller parsing it line by line never has to
// guess which shape a line is.
type warnRecord struct {
Warning string `json:"warning"`
}
// warn writes a non-fatal notice to stderr in whichever form the caller asked
// for.
func warn(w io.Writer, jsonMode bool, format string, args ...any) {
msg := fmt.Sprintf(format, args...)
if jsonMode {
_ = json.NewEncoder(w).Encode(warnRecord{Warning: msg})
return
}
_, _ = fmt.Fprintf(w, "warning: %s\n", msg)
}
// runOnce fetches and prints the current state of each PR, then exits 0.
func runOnce(c *agent.GiteaClient, refs []agent.PRRef, jsonMode bool) error {
login := agent.AgentLogin()
@@ -140,7 +158,7 @@ func runWatch(c *agent.GiteaClient, refs []agent.PRRef, interval time.Duration,
emitBaselines(os.Stderr, states, interval, jsonMode)
}
onError := func(ref agent.PRRef, err error) {
fmt.Fprintf(os.Stderr, "warning: polling %s: %v\n", ref.String(), err)
warn(os.Stderr, jsonMode, "polling %s: %v", ref.String(), err)
}
res, err := agent.Watch(c, refs, login, ticker.C, onBaseline, onError)
@@ -233,8 +251,11 @@ func baselineLine(st agent.PRState) string {
func suppressedAtBaseline(st agent.PRState) string {
var conds []string
if st.Mergeable == agent.MergeNo {
switch st.Mergeable {
case agent.MergeNo:
conds = append(conds, "already non-mergeable (a real conflict, or Gitea still recomputing)")
case agent.MergeUnknown:
conds = append(conds, "mergeability unknown (the conflict rule is disarmed until Gitea answers or the commits move)")
}
if st.CIStatus == "failure" || st.CIStatus == "error" {
conds = append(conds, "CI already "+st.CIStatus)
+53
View File
@@ -256,3 +256,56 @@ func TestBaselineIsEmittedInJSONMode(t *testing.T) {
t.Errorf("clean baseline = %q, want no suppression field", buf.String())
}
}
// Under --json stderr is the baseline and warning channel, so it has to be one
// shape: a caller parsing it line by line must never meet a bare `warning:`
// line between two NDJSON records.
func TestJSONModeStderrIsAllRecords(t *testing.T) {
st := agent.PRState{
Ref: agent.PRRef{Owner: "unkin", Repo: "repo", Number: 7},
State: "open",
Mergeable: agent.MergeNo,
}
var buf bytes.Buffer
warn(&buf, true, "no Gitea token (%v); polling anonymously", errors.New("vault refused"))
emitBaselines(&buf, []agent.PRState{st}, 30*time.Second, true)
warn(&buf, true, "polling %s: %v", st.Ref.String(), errors.New("HTTP 502"))
lines := strings.Split(strings.TrimSpace(buf.String()), "\n")
if len(lines) != 3 {
t.Fatalf("stderr = %q, want 3 records", buf.String())
}
for _, line := range lines {
var rec map[string]any
if err := json.Unmarshal([]byte(line), &rec); err != nil {
t.Errorf("stderr line %q is not a JSON record: %v", line, err)
}
}
var first warnRecord
if err := json.Unmarshal([]byte(lines[0]), &first); err != nil || !strings.Contains(first.Warning, "vault refused") {
t.Errorf("first record = %q, want the token warning", lines[0])
}
buf.Reset()
warn(&buf, false, "polling %s: %v", st.Ref.String(), errors.New("HTTP 502"))
if got := buf.String(); !strings.HasPrefix(got, "warning: ") {
t.Errorf("plain-mode warning = %q, want the warning: prefix", got)
}
}
// An unknown mergeability disarms the conflict rule exactly as a non-mergeable
// baseline does, so the baseline has to name it too.
func TestBaselineNamesAnUnknownMergeability(t *testing.T) {
st := agent.PRState{
Ref: agent.PRRef{Owner: "unkin", Repo: "repo", Number: 7},
State: "open",
Mergeable: agent.MergeUnknown,
CIStatus: "success",
}
if got := suppressedAtBaseline(st); !strings.Contains(got, "mergeability unknown") {
t.Errorf("suppressed = %q, want it to name the unknown mergeability", got)
}
if got := baselineLine(st); !strings.Contains(got, "not alerting") {
t.Errorf("baselineLine = %q, want the suppression note", got)
}
}
+45 -14
View File
@@ -161,20 +161,36 @@ const conflictWindow = 2 * time.Minute
// head or base SHA moved. A bare false inherited from the baseline says nothing
// -- it is equally a conflict the operator is already waiting on and a recompute
// in flight -- so it arms nothing. Arming and the run of falses are independent:
// movement only arms, because a base branch that moves under the PR on every
// push to main would otherwise restart the run forever.
// movement only arms and never resets the run, because a base branch that moves
// under the PR on every push to main would otherwise restart it forever. The
// window is instead measured from the later of the run's start and the arm, so
// an arming poll confirms nothing it has not itself outlasted.
type prWatch struct {
prev PRState
armed bool
// armedAt is when the rule armed mid-watch; nil while disarmed, and nil when
// the baseline armed it, since then there is no transition to measure from.
armedAt *time.Time
// conflictSince is when the current unbroken run of non-mergeable polls
// began; zero when no run is in progress.
conflictSince time.Time
// began; nil when no run is in progress.
conflictSince *time.Time
}
func newPRWatch(baseline PRState) *prWatch {
return &prWatch{prev: baseline, armed: baseline.Mergeable == MergeYes}
}
// arm records the disarmed->armed transition and when it happened. Re-arming is
// a no-op, so a base branch moving under every poll advances nothing.
func (w *prWatch) arm(now time.Time) {
if w.armed {
return
}
w.armed = true
at := now
w.armedAt = &at
}
// mergeInputsChanged reports whether the commits Gitea merges have moved, which
// starts a fresh merge computation whose result is attributable to this watch.
func mergeInputsChanged(prev, cur PRState) bool {
@@ -187,28 +203,43 @@ func mergeInputsChanged(prev, cur PRState) bool {
func (w *prWatch) track(st PRState, now time.Time) {
switch st.Mergeable {
case MergeNo:
if w.conflictSince.IsZero() {
w.conflictSince = now
if w.conflictSince == nil {
at := now
w.conflictSince = &at
}
case MergeYes:
w.armed = true
w.conflictSince = time.Time{}
w.arm(now)
w.conflictSince = nil
default:
w.conflictSince = time.Time{}
w.conflictSince = nil
}
}
// missed records a poll that never produced a snapshot; the run of adjacent
// non-mergeable observations does not survive the gap.
func (w *prWatch) missed() {
w.conflictSince = time.Time{}
w.conflictSince = nil
}
// windowStart is the later of the run's first observation and the arm, so the
// window always covers observations this watch can attribute a merge
// computation to. A baseline arm records no time and leaves the run governing.
func (w *prWatch) windowStart() *time.Time {
if w.conflictSince == nil {
return nil
}
if w.armedAt != nil && w.armedAt.After(*w.conflictSince) {
return w.armedAt
}
return w.conflictSince
}
// confirmed reports whether the run of non-mergeable observations has spanned
// the recompute window. The run starts at its first observation, so a lone
// non-mergeable poll never confirms anything whatever the interval.
// the recompute window since it started counting, so neither a lone
// non-mergeable poll nor the poll that armed the rule confirms anything.
func (w *prWatch) confirmed(now time.Time) bool {
return !w.conflictSince.IsZero() && now.Sub(w.conflictSince) >= conflictWindow
start := w.windowStart()
return start != nil && now.Sub(*start) >= conflictWindow
}
// observe folds in the newest snapshot, taken at now, and reports whether the
@@ -216,7 +247,7 @@ func (w *prWatch) confirmed(now time.Time) bool {
func (w *prWatch) observe(cur PRState, now time.Time) (bool, string) {
changed, reason := MeaningfulChange(w.prev, cur)
if mergeInputsChanged(w.prev, cur) {
w.armed = true
w.arm(now)
}
w.prev = cur
w.track(cur, now)
+58
View File
@@ -1253,6 +1253,19 @@ func TestWatchConflictSequences(t *testing.T) {
name: "a base moving under every poll still confirms a conflict",
interval: normal, baseline: yes, polls: movingBase(MergeNo, 20), want: conflict,
},
{
name: "a base move does not confirm a run it took no part in",
interval: normal, baseline: no, polls: []PRState{no, no, no, movedNo}, want: "",
},
{
name: "a push does not confirm a run it took no part in",
interval: normal, baseline: no, polls: []PRState{no, no, no, pushedNo}, want: "",
},
{
name: "an armed run confirms a window after the arm, not before",
interval: normal, baseline: no,
polls: []PRState{no, no, no, movedNo, movedNo, movedNo}, want: conflict,
},
{
name: "a conflict that predates the watch stays silent forever",
interval: relaxed, baseline: no, polls: repeat(no, 50), want: "",
@@ -1331,3 +1344,48 @@ func TestWatchConflictIsolatedFromANeighboursPushes(t *testing.T) {
t.Errorf("alert names %s, want %s", res.Ref, conflicted)
}
}
// The window is measured from the arm, not from the run the arming poll walked
// in on: a base branch that moves under a long-conflicted PR arms the rule and
// then has to outlast the recompute itself before anything is reported.
func TestWatchConflictWindowStartsAtTheArm(t *testing.T) {
stuck := base()
stuck.Mergeable = MergeNo
moved := stuck
moved.BaseSHA = "base111"
f := &fakeFetcher{states: []PRState{stuck, stuck, stuck, stuck, moved, moved, moved}}
res, err := Watch(f, []PRRef{stuck.Ref}, "unkin-agent", spacedTicks(6, time.Minute), nil, nil)
if err != nil {
t.Fatalf("Watch: %v", err)
}
if res.Reason != "PR lost mergeability (conflict)" {
t.Fatalf("reason = %q, want the mergeability loss two minutes after the base moved", res.Reason)
}
if f.calls != 7 {
t.Errorf("fetch calls = %d, want 7: the window runs from the arming poll, not from the run it inherited", f.calls)
}
}
// The zero time is a legal clock value, so it must not double as the "no run in
// progress" sentinel: a caller whose ticks start at the zero time gets the same
// debounce as any other.
func TestWatchConflictWindowCountsFromTheZeroTime(t *testing.T) {
ok := base()
conflicted := base()
conflicted.Mergeable = MergeNo
ticks := make(chan time.Time, 2)
ticks <- time.Time{}
ticks <- time.Time{}.Add(10 * time.Minute)
close(ticks)
f := &fakeFetcher{states: []PRState{ok, conflicted, conflicted}}
res, err := Watch(f, []PRRef{ok.Ref}, "unkin-agent", ticks, nil, nil)
if err != nil {
t.Fatalf("Watch: %v", err)
}
if res.Reason != "PR lost mergeability (conflict)" {
t.Fatalf("reason = %q, want the mergeability loss; a run starting at the zero time still counts", res.Reason)
}
}