watchpr: detect merge/close in poll loop (was hanging after baseline)
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful

watchpr only reported meaningful changes as transitions from the poll
baseline. A PR already merged or closed when the watch started was
captured as the baseline and never produced a transition, so the loop
polled the dead PR forever (process alive, never exiting) -- the
single-PR --interval case observed in production.

Add a terminal-state check applied to the baseline snapshot: a PR that
is already merged or closed the moment watchpr starts is reported and
exits immediately, since it can never change again. Extract the
baseline+poll loop into agent.Watch behind a StateFetcher interface so
the loop, its open->merged/close detection, and its poll-error
resilience are unit-testable with a fake client.
This commit is contained in:
2026-08-12 23:32:28 +10:00
parent 7d9ae0fcc6
commit f915b5ba3b
3 changed files with 237 additions and 27 deletions
+150 -1
View File
@@ -1,6 +1,10 @@
package agent
import "testing"
import (
"errors"
"testing"
"time"
)
func base() PRState {
return PRState{
@@ -124,6 +128,151 @@ func TestMeaningfulChangeStaysFailed(t *testing.T) {
}
}
// fakeFetcher returns a scripted sequence of (state, error) results per call,
// so tests can drive Watch across baseline and successive polls.
type fakeFetcher struct {
states []PRState
errs []error
calls int
}
func (f *fakeFetcher) FetchState(ref PRRef, agentLogin string) (PRState, error) {
i := f.calls
if i >= len(f.states) {
i = len(f.states) - 1
}
f.calls++
var err error
if f.calls-1 < len(f.errs) {
err = f.errs[f.calls-1]
}
return f.states[i], err
}
func TestTerminalState(t *testing.T) {
open := base()
open.State = "open"
if term, _ := terminalState(open); term {
t.Errorf("open PR should not be terminal")
}
merged := base()
merged.State = "closed"
merged.Merged = true
if term, reason := terminalState(merged); !term || reason != "PR merged" {
t.Errorf("merged PR: got (%v, %q), want (true, %q)", term, reason, "PR merged")
}
closed := base()
closed.State = "closed"
if term, reason := terminalState(closed); !term || reason != "PR closed without merging" {
t.Errorf("closed PR: got (%v, %q), want (true, %q)", term, reason, "PR closed without merging")
}
}
// The production hang: a PR that is already merged when watchpr starts must be
// reported at baseline and exit, without ever consuming a tick. Before the fix,
// Watch only reported transitions, so a terminal baseline was polled forever.
func TestWatchExitsWhenAlreadyMergedAtBaseline(t *testing.T) {
merged := base()
merged.State = "closed"
merged.Merged = true
f := &fakeFetcher{states: []PRState{merged}}
ticks := make(chan time.Time) // never fires; a hang would block here
res, err := Watch(f, []PRRef{merged.Ref}, "unkin-agent", ticks, nil, nil)
if err != nil {
t.Fatalf("Watch: %v", err)
}
if res.Reason != "PR merged" {
t.Errorf("reason = %q, want %q", res.Reason, "PR merged")
}
if f.calls != 1 {
t.Errorf("fetch calls = %d, want 1 (baseline only)", f.calls)
}
}
// A PR already closed-without-merge at baseline must also exit immediately.
func TestWatchExitsWhenAlreadyClosedAtBaseline(t *testing.T) {
closed := base()
closed.State = "closed"
f := &fakeFetcher{states: []PRState{closed}}
ticks := make(chan time.Time)
res, err := Watch(f, []PRRef{closed.Ref}, "unkin-agent", ticks, nil, nil)
if err != nil {
t.Fatalf("Watch: %v", err)
}
if res.Reason != "PR closed without merging" {
t.Errorf("reason = %q, want %q", res.Reason, "PR closed without merging")
}
}
// An open→merged transition observed during polling must be detected and end
// the watch.
func TestWatchDetectsMergeAfterBaseline(t *testing.T) {
open := base()
merged := base()
merged.State = "closed"
merged.Merged = true
f := &fakeFetcher{states: []PRState{open, merged}} // baseline open, then merged
baselineFired := false
ticks := make(chan time.Time, 1)
ticks <- time.Now()
res, err := Watch(f, []PRRef{open.Ref}, "unkin-agent",
ticks, func() { baselineFired = true }, nil)
if err != nil {
t.Fatalf("Watch: %v", err)
}
if !baselineFired {
t.Errorf("onBaseline should fire for an open baseline")
}
if res.Reason != "PR merged" {
t.Errorf("reason = %q, want %q", res.Reason, "PR merged")
}
}
// A transient poll error must be reported and the loop must keep polling; a
// merge on the following tick still ends the watch.
func TestWatchContinuesPastPollError(t *testing.T) {
open := base()
merged := base()
merged.State = "closed"
merged.Merged = true
// baseline ok, first poll errors, second poll sees the merge.
f := &fakeFetcher{
states: []PRState{open, open, merged},
errs: []error{nil, errors.New("HTTP 502"), nil},
}
var gotErr error
ticks := make(chan time.Time, 2)
ticks <- time.Now()
ticks <- time.Now()
res, err := Watch(f, []PRRef{open.Ref}, "unkin-agent", ticks, nil,
func(_ PRRef, e error) { gotErr = e })
if err != nil {
t.Fatalf("Watch: %v", err)
}
if gotErr == nil {
t.Errorf("onError should have received the transient poll error")
}
if res.Reason != "PR merged" {
t.Errorf("reason = %q, want %q (loop must survive the error)", res.Reason, "PR merged")
}
}
// A baseline fetch error aborts the watch (nothing to establish a baseline
// from), unlike a mid-loop poll error.
func TestWatchBaselineErrorAborts(t *testing.T) {
f := &fakeFetcher{states: []PRState{base()}, errs: []error{errors.New("HTTP 500")}}
ticks := make(chan time.Time)
if _, err := Watch(f, []PRRef{base().Ref}, "unkin-agent", ticks, nil, nil); err == nil {
t.Fatal("Watch should return the baseline fetch error")
}
}
func TestCountNonAgentComments(t *testing.T) {
comments := []Comment{
{User: User{Login: "unkin-agent"}},