watchpr: arm the conflict rule on a new merge computation
A mergeable=false baseline meant a conflict could never be reported, since Gitea sends false while it recomputes after a push. Arm on a head or base SHA change as well as on a mergeable poll, break the conflict run on unknown and failed polls, and print the baseline with the conditions it suppresses.
This commit is contained in:
+54
-28
@@ -17,9 +17,11 @@ 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.
|
||||
// Mergeability is Gitea's mergeable flag. Gitea 1.26 always sends a plain bool,
|
||||
// and sends false both for a real conflict and while it recomputes the merge
|
||||
// base after a push, so false on its own decides nothing (prWatch resolves it).
|
||||
// Unknown covers what the bool cannot carry: an absent or null flag from another
|
||||
// Gitea build, and a snapshot no successful poll ever filled in.
|
||||
type Mergeability int
|
||||
|
||||
const (
|
||||
@@ -68,6 +70,7 @@ type PRState struct {
|
||||
State string `json:"state"` // open / closed
|
||||
Merged bool `json:"merged"`
|
||||
HeadSHA string `json:"head_sha"`
|
||||
BaseSHA string `json:"base_sha"`
|
||||
Mergeable Mergeability `json:"mergeable"`
|
||||
CIStatus string `json:"ci_status"` // success / pending / failure / error / ""
|
||||
NonAgentComments int `json:"non_agent_comments"`
|
||||
@@ -101,6 +104,7 @@ func FetchState(c *GiteaClient, ref PRRef, agentLogin string) (PRState, error) {
|
||||
State: pr.State,
|
||||
Merged: pr.Merged,
|
||||
HeadSHA: pr.Head.Sha,
|
||||
BaseSHA: pr.Base.Sha,
|
||||
Mergeable: pr.Mergeable,
|
||||
CIStatus: ci,
|
||||
NonAgentComments: countNonAgentComments(comments, agentLogin),
|
||||
@@ -141,48 +145,67 @@ 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.
|
||||
// conflictPolls is how many consecutive non-mergeable polls of an unchanged
|
||||
// merge computation confirm a real conflict.
|
||||
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.
|
||||
// prWatch tracks one PR across polls, because mergeability needs more memory
|
||||
// than the previous snapshot. Gitea reports mergeable=false while it recomputes
|
||||
// the merge base after a push, so a false is only trusted once this watch has
|
||||
// seen a merge computation start: the PR was mergeable at some point, or its
|
||||
// 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.
|
||||
type prWatch struct {
|
||||
prev PRState
|
||||
everMergeable bool
|
||||
conflicts int
|
||||
prev PRState
|
||||
armed bool
|
||||
conflicts int
|
||||
}
|
||||
|
||||
func newPRWatch(baseline PRState) *prWatch {
|
||||
w := &prWatch{prev: baseline}
|
||||
w.track(baseline)
|
||||
return w
|
||||
return &prWatch{prev: baseline, armed: baseline.Mergeable == MergeYes}
|
||||
}
|
||||
|
||||
// track folds one snapshot's mergeability into the run of observations. Unknown
|
||||
// neither confirms a conflict nor clears one.
|
||||
// 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 {
|
||||
return cur.HeadSHA != prev.HeadSHA || cur.BaseSHA != prev.BaseSHA
|
||||
}
|
||||
|
||||
// track folds one snapshot's mergeability into the run of observations. The
|
||||
// polls that confirm a conflict must be adjacent, so anything but another
|
||||
// non-mergeable observation breaks the run.
|
||||
func (w *prWatch) track(st PRState) {
|
||||
switch st.Mergeable {
|
||||
case MergeYes:
|
||||
w.everMergeable = true
|
||||
w.conflicts = 0
|
||||
case MergeNo:
|
||||
w.conflicts++
|
||||
case MergeYes:
|
||||
w.armed = true
|
||||
w.conflicts = 0
|
||||
default:
|
||||
w.conflicts = 0
|
||||
}
|
||||
}
|
||||
|
||||
// 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.conflicts = 0
|
||||
}
|
||||
|
||||
// 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)
|
||||
if mergeInputsChanged(w.prev, cur) {
|
||||
w.armed = true
|
||||
w.conflicts = 0
|
||||
}
|
||||
w.prev = cur
|
||||
w.track(cur)
|
||||
if changed {
|
||||
return true, reason
|
||||
}
|
||||
if w.everMergeable && w.conflicts >= conflictPolls && cur.State == "open" {
|
||||
if w.armed && w.conflicts >= conflictPolls && cur.State == "open" {
|
||||
return true, "PR lost mergeability (conflict)"
|
||||
}
|
||||
return false, ""
|
||||
@@ -202,10 +225,11 @@ const MaxPollFailures = 20
|
||||
// is gone, renamed, or no longer visible), and MaxPollFailures consecutive
|
||||
// failures of one PR all abort, because a watcher that sees nothing must not
|
||||
// look healthy.
|
||||
// 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) {
|
||||
// onBaseline, if set, receives every captured baseline once, before the first
|
||||
// tick, so a caller can show what state the watch started from.
|
||||
func Watch(f StateFetcher, refs []PRRef, agentLogin string, ticks <-chan time.Time, onBaseline func([]PRState), onError func(PRRef, error)) (WatchResult, error) {
|
||||
watches := make(map[string]*prWatch, len(refs))
|
||||
baselines := make([]PRState, 0, len(refs))
|
||||
for _, ref := range refs {
|
||||
st, err := f.FetchState(ref, agentLogin)
|
||||
if err != nil {
|
||||
@@ -215,9 +239,10 @@ func Watch(f StateFetcher, refs []PRRef, agentLogin string, ticks <-chan time.Ti
|
||||
return WatchResult{Ref: ref, Reason: reason, State: st}, nil
|
||||
}
|
||||
watches[ref.String()] = newPRWatch(st)
|
||||
baselines = append(baselines, st)
|
||||
}
|
||||
if onBaseline != nil {
|
||||
onBaseline()
|
||||
onBaseline(baselines)
|
||||
}
|
||||
fails := make(map[string]int, len(refs))
|
||||
for range ticks {
|
||||
@@ -229,6 +254,7 @@ func Watch(f StateFetcher, refs []PRRef, agentLogin string, ticks <-chan time.Ti
|
||||
return WatchResult{}, fmt.Errorf("polling %s: %w", key, err)
|
||||
}
|
||||
fails[key]++
|
||||
watches[key].missed()
|
||||
if onError != nil {
|
||||
onError(ref, err)
|
||||
}
|
||||
@@ -265,8 +291,8 @@ 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, a
|
||||
// new head commit, an unchanged snapshot) return false. Mergeability is not
|
||||
// decided here: it takes a whole run of observations, which prWatch keeps.
|
||||
// new head or base 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
|
||||
|
||||
Reference in New Issue
Block a user