watchpr: only alert on mergeability lost after the baseline
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful

Track mergeability across the whole run and decode it as a tri-state, so a PR already conflicted when watching starts is polled on instead of reported.
This commit is contained in:
2026-09-26 18:45:48 +10:00
parent 0cf41409f1
commit 6988be9ff3
6 changed files with 298 additions and 51 deletions
+108 -23
View File
@@ -3,6 +3,7 @@ package agent
import (
"errors"
"fmt"
"strings"
"time"
)
@@ -16,17 +17,62 @@ func IsPRGone(err error) bool {
return errors.Is(err, errPRGone)
}
// Mergeability is Gitea's mergeable flag as a tri-state. The flag is null or
// absent while Gitea recomputes the merge base, and an unknown value must not be
// read as either answer.
type Mergeability int
const (
MergeUnknown Mergeability = iota
MergeYes
MergeNo
)
func (m Mergeability) String() string {
switch m {
case MergeYes:
return "true"
case MergeNo:
return "false"
}
return "unknown"
}
func (m Mergeability) MarshalJSON() ([]byte, error) {
switch m {
case MergeYes:
return []byte("true"), nil
case MergeNo:
return []byte("false"), nil
}
return []byte("null"), nil
}
func (m *Mergeability) UnmarshalJSON(b []byte) error {
switch strings.TrimSpace(string(b)) {
case "true":
*m = MergeYes
case "false":
*m = MergeNo
case "null":
*m = MergeUnknown
default:
return fmt.Errorf("mergeable: unexpected value %s", b)
}
return nil
}
// PRState is a point-in-time snapshot of the PR attributes watchpr tracks.
type PRState struct {
Ref PRRef `json:"ref"`
State string `json:"state"` // open / closed
Merged bool `json:"merged"`
HeadSHA string `json:"head_sha"`
Mergeable bool `json:"mergeable"`
CIStatus string `json:"ci_status"` // success / pending / failure / error / ""
NonAgentComments int `json:"non_agent_comments"`
Title string `json:"title"`
URL string `json:"url"`
Ref PRRef `json:"ref"`
State string `json:"state"` // open / closed
Merged bool `json:"merged"`
HeadSHA string `json:"head_sha"`
Mergeable Mergeability `json:"mergeable"`
CIStatus string `json:"ci_status"` // success / pending / failure / error / ""
NonAgentComments int `json:"non_agent_comments"`
Title string `json:"title"`
URL string `json:"url"`
}
// FetchState builds a PRState for the given ref. agentLogin's comments are
@@ -95,6 +141,53 @@ func terminalState(st PRState) (bool, string) {
return false, ""
}
// conflictPolls is how many consecutive non-mergeable polls confirm a real
// conflict. Gitea reports mergeable=false while it recomputes the merge base
// after a push, so a single poll is debounced.
const conflictPolls = 2
// prWatch tracks one PR across polls. Mergeability needs more memory than the
// previous snapshot: losing it only counts as a change if the PR was mergeable
// at some point after watching began, since a conflict that predates the
// baseline is the state the operator is already waiting on.
type prWatch struct {
prev PRState
everMergeable bool
conflicts int
}
func newPRWatch(baseline PRState) *prWatch {
w := &prWatch{prev: baseline}
w.track(baseline)
return w
}
// track folds one snapshot's mergeability into the run of observations. Unknown
// neither confirms a conflict nor clears one.
func (w *prWatch) track(st PRState) {
switch st.Mergeable {
case MergeYes:
w.everMergeable = true
w.conflicts = 0
case MergeNo:
w.conflicts++
}
}
// observe folds in the newest snapshot and reports whether the watch should end.
func (w *prWatch) observe(cur PRState) (bool, string) {
changed, reason := MeaningfulChange(w.prev, cur)
w.prev = cur
w.track(cur)
if changed {
return true, reason
}
if w.everMergeable && w.conflicts >= conflictPolls && cur.State == "open" {
return true, "PR lost mergeability (conflict)"
}
return false, ""
}
// MaxPollFailures is how many consecutive failed polls of the same PR are
// tolerated before Watch gives up. The abort fires on the 20th failed tick, so
// at watchpr's default 60s interval a watch rides out ~19 minutes of failure.
@@ -112,7 +205,7 @@ const MaxPollFailures = 20
// onBaseline, if set, fires once after all baselines are captured and before the
// first tick.
func Watch(f StateFetcher, refs []PRRef, agentLogin string, ticks <-chan time.Time, onBaseline func(), onError func(PRRef, error)) (WatchResult, error) {
prev := make(map[string]PRState, len(refs))
watches := make(map[string]*prWatch, len(refs))
for _, ref := range refs {
st, err := f.FetchState(ref, agentLogin)
if err != nil {
@@ -121,7 +214,7 @@ func Watch(f StateFetcher, refs []PRRef, agentLogin string, ticks <-chan time.Ti
if terminal, reason := terminalState(st); terminal {
return WatchResult{Ref: ref, Reason: reason, State: st}, nil
}
prev[ref.String()] = st
watches[ref.String()] = newPRWatch(st)
}
if onBaseline != nil {
onBaseline()
@@ -145,10 +238,9 @@ func Watch(f StateFetcher, refs []PRRef, agentLogin string, ticks <-chan time.Ti
continue
}
fails[key] = 0
if changed, reason := MeaningfulChange(prev[key], cur); changed {
if changed, reason := watches[key].observe(cur); changed {
return WatchResult{Ref: ref, Reason: reason, State: cur}, nil
}
prev[key] = cur
}
}
return WatchResult{}, nil
@@ -172,15 +264,15 @@ func isFailedCI(state string) bool {
// MeaningfulChange compares a previous state to the current one and reports
// whether a change warrants alerting the operator, with a human-readable
// reason. Benign transitions (CI pending→success, the agent's own comments, an
// unchanged snapshot) return false.
// reason. Benign transitions (CI pending→success, the agent's own comments, a
// new head commit, an unchanged snapshot) return false. Mergeability is not
// decided here: it takes a whole run of observations, which prWatch keeps.
//
// Alerting conditions:
// - the PR merged
// - the PR closed without merging
// - a new comment from someone other than the agent
// - CI transitioned into failure/error
// - the PR lost mergeability (a conflict appeared) for two consecutive polls
func MeaningfulChange(prev, cur PRState) (bool, string) {
if !prev.Merged && cur.Merged {
return true, "PR merged"
@@ -195,12 +287,5 @@ func MeaningfulChange(prev, cur PRState) (bool, string) {
if isFailedCI(cur.CIStatus) && !isFailedCI(prev.CIStatus) {
return true, "CI failed (" + cur.CIStatus + ")"
}
// Gitea computes mergeability asynchronously, so a PR can briefly report
// mergeable=false right after a push. Require the loss to persist across two
// consecutive polls (both prev and cur false, still open) before treating it
// as a real conflict; a single false poll is debounced.
if !prev.Mergeable && !cur.Mergeable && cur.State == "open" {
return true, "PR lost mergeability (conflict)"
}
return false, ""
}