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:
+182
-11
@@ -791,11 +791,21 @@ func TestWatchAnonymousKeepsPolling(t *testing.T) {
|
||||
}
|
||||
|
||||
// drainableTicks returns a channel holding n ticks and already closed, so Watch
|
||||
// polls exactly n times and then returns instead of blocking.
|
||||
// polls exactly n times and then returns instead of blocking. The ticks are
|
||||
// spaced far past conflictWindow, so a run of non-mergeable polls confirms on
|
||||
// its second observation; spacedTicks drives the intervals where it must not.
|
||||
func drainableTicks(n int) <-chan time.Time {
|
||||
return spacedTicks(n, 10*time.Minute)
|
||||
}
|
||||
|
||||
// spacedTicks is drainableTicks with the poll interval named. The tick carries
|
||||
// the time Watch measures the conflict window on, which makes every wall-clock
|
||||
// assertion in these tests exact and instant.
|
||||
func spacedTicks(n int, interval time.Duration) <-chan time.Time {
|
||||
start := time.Date(2026, 9, 24, 12, 0, 0, 0, time.UTC)
|
||||
ticks := make(chan time.Time, n)
|
||||
for i := 0; i < n; i++ {
|
||||
ticks <- time.Now()
|
||||
for i := 1; i <= n; i++ {
|
||||
ticks <- start.Add(time.Duration(i) * interval)
|
||||
}
|
||||
close(ticks)
|
||||
return ticks
|
||||
@@ -911,9 +921,10 @@ func TestPRWatchMergeability(t *testing.T) {
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
w := newPRWatch(snap(tt.baseline))
|
||||
start := time.Date(2026, 9, 24, 12, 0, 0, 0, time.UTC)
|
||||
got := 0
|
||||
for i, m := range tt.polls {
|
||||
changed, reason := w.observe(snap(m))
|
||||
changed, reason := w.observe(snap(m), start.Add(time.Duration(i+1)*10*time.Minute))
|
||||
if !changed {
|
||||
continue
|
||||
}
|
||||
@@ -958,13 +969,12 @@ func TestMergeabilityDecoding(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The inverse of the bug this PR fixed: Gitea reports mergeable=false while it
|
||||
// recomputes the merge base after a push, and watchpr is normally started right
|
||||
// after the agent pushes, so the baseline can land inside that window. Demanding
|
||||
// a mergeable observation to arm the rule left a genuinely conflicted PR silent
|
||||
// forever. The new head is a new merge computation, so the conflict it confirms
|
||||
// belongs to this watch.
|
||||
func TestWatchAlertsWhenBaselineLandedInTheRecomputeWindow(t *testing.T) {
|
||||
// The inverse of the bug this PR fixed: demanding a mergeable observation to arm
|
||||
// the rule left a PR that was non-mergeable at baseline silent forever, even
|
||||
// after a push gave Gitea a fresh merge computation to answer for. A head that
|
||||
// moves during the watch arms the rule, so the conflict the new head keeps
|
||||
// reporting is attributable to this watch and is reported.
|
||||
func TestWatchAlertsWhenAPushArmsABaselineConflict(t *testing.T) {
|
||||
baseline := base()
|
||||
baseline.Mergeable = MergeNo // still recomputing; the push is not visible yet
|
||||
|
||||
@@ -1160,3 +1170,164 @@ func TestWatchReportsBaselineStates(t *testing.T) {
|
||||
t.Errorf("baseline = %+v, want the conflicted, CI-red snapshot the watch started from", got[0])
|
||||
}
|
||||
}
|
||||
|
||||
// The residual gap, pinned so it is a decision rather than an accident: the
|
||||
// agent pushes, the push conflicts, watchpr starts inside Gitea's recompute and
|
||||
// nothing moves again. Every poll answers false and none of them is
|
||||
// attributable to this watch, so no alert is ever sent -- the baseline line is
|
||||
// the only notice. Gitea's payload carries no field that separates this from a
|
||||
// merge check still running.
|
||||
func TestWatchNeverAlertsOnAConflictLandedByThePushBeforeTheWatch(t *testing.T) {
|
||||
pushed := base()
|
||||
pushed.Mergeable = MergeNo
|
||||
pushed.HeadSHA = "def456"
|
||||
f := &fakeFetcher{states: []PRState{pushed}}
|
||||
|
||||
res, err := Watch(f, []PRRef{pushed.Ref}, "unkin-agent", drainableTicks(50), nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Watch: %v", err)
|
||||
}
|
||||
if res.Reason != "" {
|
||||
t.Fatalf("Watch ended with %q; nothing distinguishes this conflict from a merge check in flight, so it must stay silent", res.Reason)
|
||||
}
|
||||
}
|
||||
|
||||
// The whole mergeability rule as a sequence table, driven through Watch at the
|
||||
// intervals that decide it. The debounce is wall-clock, so the same sequence of
|
||||
// snapshots must alert or stay silent according to how far apart the polls are.
|
||||
func TestWatchConflictSequences(t *testing.T) {
|
||||
snap := func(m Mergeability, head, bse string) PRState {
|
||||
st := base()
|
||||
st.Mergeable = m
|
||||
st.HeadSHA = head
|
||||
st.BaseSHA = bse
|
||||
return st
|
||||
}
|
||||
repeat := func(st PRState, n int) []PRState {
|
||||
out := make([]PRState, n)
|
||||
for i := range out {
|
||||
out[i] = st
|
||||
}
|
||||
return out
|
||||
}
|
||||
movingBase := func(m Mergeability, n int) []PRState {
|
||||
out := make([]PRState, n)
|
||||
for i := range out {
|
||||
out[i] = snap(m, "h1", fmt.Sprintf("b%d", i))
|
||||
}
|
||||
return out
|
||||
}
|
||||
const (
|
||||
quick = 5 * time.Second // well inside conflictWindow
|
||||
normal = 60 * time.Second // watchpr's default
|
||||
relaxed = 10 * time.Minute // past conflictWindow in a single gap
|
||||
)
|
||||
const conflict = "PR lost mergeability (conflict)"
|
||||
|
||||
yes := snap(MergeYes, "h1", "b1")
|
||||
no := snap(MergeNo, "h1", "b1")
|
||||
pushedNo := snap(MergeNo, "h2", "b1")
|
||||
movedNo := snap(MergeNo, "h1", "b2")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
interval time.Duration
|
||||
baseline PRState
|
||||
polls []PRState
|
||||
errs []error
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "a push then the recompute's falses is not a conflict",
|
||||
interval: normal, baseline: yes, polls: repeat(pushedNo, 2), want: "",
|
||||
},
|
||||
{
|
||||
name: "a fast poller rides out the whole recompute after a push",
|
||||
interval: quick, baseline: yes, polls: repeat(pushedNo, 20), want: "",
|
||||
},
|
||||
{
|
||||
name: "a base move then the recompute's falses is not a conflict",
|
||||
interval: normal, baseline: no, polls: repeat(movedNo, 2), want: "",
|
||||
},
|
||||
{
|
||||
name: "a base moving under every poll still confirms a conflict",
|
||||
interval: normal, baseline: yes, polls: movingBase(MergeNo, 20), want: conflict,
|
||||
},
|
||||
{
|
||||
name: "a conflict that predates the watch stays silent forever",
|
||||
interval: relaxed, baseline: no, polls: repeat(no, 50), want: "",
|
||||
},
|
||||
{
|
||||
name: "a sustained loss after a mergeable baseline alerts",
|
||||
interval: normal, baseline: yes, polls: repeat(no, 3), want: conflict,
|
||||
},
|
||||
{
|
||||
name: "a mergeable poll breaks the run however long it ran",
|
||||
interval: relaxed, baseline: yes, polls: []PRState{no, yes, no}, want: "",
|
||||
},
|
||||
{
|
||||
name: "an unknown poll breaks the run however long it ran",
|
||||
interval: relaxed, baseline: yes, polls: []PRState{no, snap(MergeUnknown, "h1", "b1"), no}, want: "",
|
||||
},
|
||||
{
|
||||
name: "a baseline conflict that clears and returns alerts",
|
||||
interval: relaxed, baseline: no, polls: []PRState{yes, no, no}, want: conflict,
|
||||
},
|
||||
{
|
||||
name: "a failed poll breaks the run",
|
||||
interval: relaxed, baseline: yes, polls: []PRState{no, no, no},
|
||||
errs: []error{nil, nil, errors.New("HTTP 502"), nil}, want: "",
|
||||
},
|
||||
{
|
||||
name: "the run restarts after a failed poll and still confirms",
|
||||
interval: relaxed, baseline: yes, polls: []PRState{no, no, no, no},
|
||||
errs: []error{nil, nil, errors.New("HTTP 502"), nil, nil}, want: conflict,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
f := &fakeFetcher{states: append([]PRState{tt.baseline}, tt.polls...), errs: tt.errs}
|
||||
res, err := Watch(f, []PRRef{tt.baseline.Ref}, "unkin-agent",
|
||||
spacedTicks(len(tt.polls), tt.interval), nil, func(PRRef, error) {})
|
||||
if err != nil {
|
||||
t.Fatalf("Watch: %v", err)
|
||||
}
|
||||
if res.Reason != tt.want {
|
||||
t.Fatalf("reason = %q, want %q", res.Reason, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Two refs watched together keep separate runs and separate clocks: a PR being
|
||||
// pushed to must neither delay nor trigger the conflict its neighbour is really
|
||||
// in, and the alert must name the conflicted one.
|
||||
func TestWatchConflictIsolatedFromANeighboursPushes(t *testing.T) {
|
||||
conflicted := PRRef{Owner: "unkin", Repo: "repo", Number: 1}
|
||||
pushing := PRRef{Owner: "unkin", Repo: "repo", Number: 2}
|
||||
snap := func(ref PRRef, m Mergeability, head string) PRState {
|
||||
st := base()
|
||||
st.Ref = ref
|
||||
st.Mergeable = m
|
||||
st.HeadSHA = head
|
||||
return st
|
||||
}
|
||||
f := &perRefFetcher{
|
||||
states: map[string][]PRState{
|
||||
conflicted.String(): {snap(conflicted, MergeYes, "a1"), snap(conflicted, MergeNo, "a1")},
|
||||
pushing.String(): {snap(pushing, MergeYes, "b1"), snap(pushing, MergeYes, "b2"), snap(pushing, MergeYes, "b3")},
|
||||
},
|
||||
calls: map[string]int{},
|
||||
}
|
||||
|
||||
res, err := Watch(f, []PRRef{conflicted, pushing}, "unkin-agent", drainableTicks(4), nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Watch: %v", err)
|
||||
}
|
||||
if res.Reason != "PR lost mergeability (conflict)" {
|
||||
t.Fatalf("reason = %q, want the conflict on %s", res.Reason, conflicted)
|
||||
}
|
||||
if res.Ref != conflicted {
|
||||
t.Errorf("alert names %s, want %s", res.Ref, conflicted)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user