package agent import ( "errors" "testing" "time" ) func base() PRState { return PRState{ Ref: PRRef{Owner: "unkin", Repo: "repo", Number: 1}, State: "open", Merged: false, HeadSHA: "abc123", Mergeable: true, CIStatus: "pending", NonAgentComments: 0, } } func TestMeaningfulChange(t *testing.T) { tests := []struct { name string mutatePrev func(s *PRState) mutate func(s *PRState) wantChange bool }{ { name: "no change", mutate: func(s *PRState) {}, wantChange: false, }, { name: "CI pending to success is benign", mutate: func(s *PRState) { s.CIStatus = "success" }, wantChange: false, }, { name: "open to merged alerts", mutate: func(s *PRState) { s.Merged = true; s.State = "closed" }, wantChange: true, }, { name: "open to closed without merge alerts", mutate: func(s *PRState) { s.State = "closed" }, wantChange: true, }, { name: "new non-agent comment alerts", mutate: func(s *PRState) { s.NonAgentComments = 1 }, wantChange: true, }, { name: "CI to failure alerts", mutate: func(s *PRState) { s.CIStatus = "failure" }, wantChange: true, }, { name: "CI to error alerts", mutate: func(s *PRState) { s.CIStatus = "error" }, wantChange: true, }, { // A single mergeable=false poll is debounced: Gitea often reports // this transiently right after a push. name: "mergeable true to false for one poll is benign", mutate: func(s *PRState) { s.Mergeable = false }, wantChange: false, }, { // mergeable=false persisting into a second consecutive poll is a // real conflict and alerts. name: "mergeable false persisting a second poll alerts", mutatePrev: func(s *PRState) { s.Mergeable = false }, mutate: func(s *PRState) { s.Mergeable = false }, wantChange: true, }, { // mergeable recovered (false then true) must not alert. name: "mergeable recovered false to true is benign", mutatePrev: func(s *PRState) { s.Mergeable = false }, mutate: func(s *PRState) {}, wantChange: false, }, { name: "new head sha alone is benign", mutate: func(s *PRState) { s.HeadSHA = "def456" }, wantChange: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { prev := base() if tt.mutatePrev != nil { tt.mutatePrev(&prev) } cur := base() tt.mutate(&cur) got, reason := MeaningfulChange(prev, cur) if got != tt.wantChange { t.Errorf("MeaningfulChange() = %v (%q), want %v", got, reason, tt.wantChange) } if got && reason == "" { t.Errorf("change reported without a reason") } }) } } // A comment that only the agent posts must not alert: the non-agent count is // unchanged, so MeaningfulChange sees nothing. func TestMeaningfulChangeAgentCommentIgnored(t *testing.T) { prev := base() cur := base() // agent commented, but NonAgentComments stayed 0 if got, _ := MeaningfulChange(prev, cur); got { t.Errorf("agent-only comment should not alert") } } // Once CI is already failing, staying failed must not re-alert. func TestMeaningfulChangeStaysFailed(t *testing.T) { prev := base() prev.CIStatus = "failure" cur := base() cur.CIStatus = "failure" if got, _ := MeaningfulChange(prev, cur); got { t.Errorf("CI staying failed should not re-alert") } } // 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"}}, {User: User{Login: "ben"}}, {User: User{Login: "unkin-agent"}}, {User: User{Login: "reviewer"}}, } if n := countNonAgentComments(comments, "unkin-agent"); n != 2 { t.Errorf("countNonAgentComments = %d, want 2", n) } }