watchpr: debounce conflicts on elapsed time, not poll count

Merge-input movement arms the conflict rule and no longer clears the run of
non-mergeable observations, so an arming poll's own false cannot be half the
evidence and a base branch moving under every poll cannot starve a real
conflict. The run must now span conflictWindow, measured on the tick that
fired each poll.
This commit is contained in:
2026-09-26 21:14:20 +10:00
parent 7baa194c52
commit 27e48ac45e
2 changed files with 223 additions and 32 deletions
+41 -21
View File
@@ -145,9 +145,14 @@ func terminalState(st PRState) (bool, string) {
return false, ""
}
// conflictPolls is how many consecutive non-mergeable polls of an unchanged
// merge computation confirm a real conflict.
const conflictPolls = 2
// conflictWindow is how long an unbroken run of non-mergeable polls must span
// before it is reported as a conflict. What the run has to outlast is Gitea's
// merge recompute, which is a duration, so counting polls cannot express it:
// --interval varies from seconds to hours between callers. Recomputes have been
// observed finishing in ~5-20s with no bound on the tail, so this is set well
// clear of that; the cost of it being too long is only a later conflict alert,
// while too short is a false one.
const conflictWindow = 2 * time.Minute
// prWatch tracks one PR across polls, because mergeability needs more memory
// than the previous snapshot. Gitea reports mergeable=false while it recomputes
@@ -155,11 +160,15 @@ const conflictPolls = 2
// seen a merge computation start: the PR was mergeable at some point, or its
// 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.
// 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.
type prWatch struct {
prev PRState
armed bool
conflicts int
prev PRState
armed bool
// conflictSince is when the current unbroken run of non-mergeable polls
// began; zero when no run is in progress.
conflictSince time.Time
}
func newPRWatch(baseline PRState) *prWatch {
@@ -173,39 +182,48 @@ func mergeInputsChanged(prev, cur PRState) bool {
}
// track folds one snapshot's mergeability into the run of observations. The
// polls that confirm a conflict must be adjacent, so anything but another
// non-mergeable observation breaks the run.
func (w *prWatch) track(st PRState) {
// observations that confirm a conflict must be adjacent, so anything but
// another non-mergeable one breaks the run.
func (w *prWatch) track(st PRState, now time.Time) {
switch st.Mergeable {
case MergeNo:
w.conflicts++
if w.conflictSince.IsZero() {
w.conflictSince = now
}
case MergeYes:
w.armed = true
w.conflicts = 0
w.conflictSince = time.Time{}
default:
w.conflicts = 0
w.conflictSince = time.Time{}
}
}
// 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.conflicts = 0
w.conflictSince = time.Time{}
}
// observe folds in the newest snapshot and reports whether the watch should end.
func (w *prWatch) observe(cur PRState) (bool, string) {
// 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.
func (w *prWatch) confirmed(now time.Time) bool {
return !w.conflictSince.IsZero() && now.Sub(w.conflictSince) >= conflictWindow
}
// observe folds in the newest snapshot, taken at now, and reports whether the
// watch should end.
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.conflicts = 0
}
w.prev = cur
w.track(cur)
w.track(cur, now)
if changed {
return true, reason
}
if w.armed && w.conflicts >= conflictPolls && cur.State == "open" {
if w.armed && w.confirmed(now) && cur.State == "open" {
return true, "PR lost mergeability (conflict)"
}
return false, ""
@@ -245,7 +263,9 @@ func Watch(f StateFetcher, refs []PRRef, agentLogin string, ticks <-chan time.Ti
onBaseline(baselines)
}
fails := make(map[string]int, len(refs))
for range ticks {
// The tick carries the time it fired, which is the clock the conflict
// window is measured on.
for now := range ticks {
for _, ref := range refs {
key := ref.String()
cur, err := f.FetchState(ref, agentLogin)
@@ -264,7 +284,7 @@ func Watch(f StateFetcher, refs []PRRef, agentLogin string, ticks <-chan time.Ti
continue
}
fails[key] = 0
if changed, reason := watches[key].observe(cur); changed {
if changed, reason := watches[key].observe(cur, now); changed {
return WatchResult{Ref: ref, Reason: reason, State: cur}, nil
}
}