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
+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)