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:
+41
-21
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user