watchpr: start the conflict window at the arm
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:
+45
-14
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user