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:
2026-09-26 20:48:31 +10:00
parent 6988be9ff3
commit 54c1d868d3
5 changed files with 346 additions and 43 deletions
+44 -8
View File
@@ -1,8 +1,10 @@
// Command watchpr polls one or more Gitea pull requests and exits when a
// tracked PR changes in a way worth alerting on: it merges or closes, gets a
// new comment from someone other than the agent, its CI fails, or it loses
// mergeability. Benign transitions (CI pending→success, the agent's own
// comments) are ignored.
// mergeability after the baseline. Benign transitions (CI pending→success, the
// agent's own pushes and comments) are ignored, and so is any condition that was
// already true at the baseline -- which watchpr prints, so a run started against
// a conflicted or CI-red PR says so.
//
// watchpr owner/repo#12 owner/repo:15
// watchpr --once --json owner/repo#12
@@ -14,6 +16,7 @@ import (
"encoding/json"
"fmt"
"os"
"strings"
"time"
"git.unkin.net/unkin/agent-tools/internal/agent"
@@ -43,7 +46,8 @@ func newRootCmd() *cobra.Command {
Short: "Poll Gitea PRs and exit when one changes meaningfully.",
Long: "watchpr polls each PR every --interval and exits (reporting what changed)\n" +
"when a PR merges/closes, gets a new non-agent comment, its CI fails, or it\n" +
"loses mergeability. Accepts refs as owner/repo#N or owner/repo:N.",
"loses mergeability after the baseline. Conditions already true at the\n" +
"baseline are printed, not alerted on. Refs take owner/repo#N or owner/repo:N.",
Version: version,
Args: cobra.ArbitraryArgs,
SilenceUsage: true,
@@ -126,9 +130,13 @@ func runWatch(c *agent.GiteaClient, refs []agent.PRRef, interval time.Duration,
ticker := time.NewTicker(interval)
defer ticker.Stop()
onBaseline := func() {
if !jsonMode {
fmt.Fprintf(os.Stderr, "watching %d PR(s) every %s; baseline established\n", len(refs), interval)
onBaseline := func(states []agent.PRState) {
if jsonMode {
return
}
fmt.Fprintf(os.Stderr, "watching %d PR(s) every %s; baseline established\n", len(refs), interval)
for _, st := range states {
fmt.Fprintln(os.Stderr, baselineLine(st))
}
}
onError := func(ref agent.PRRef, err error) {
@@ -176,8 +184,36 @@ func report(key, reason string, st agent.PRState, jsonMode bool) {
}
func printState(st agent.PRState) {
fmt.Printf("%s state=%s merged=%t mergeable=%s ci=%s head=%s comments(non-agent)=%d\n",
st.Ref.String(), st.State, st.Merged, st.Mergeable, ciOrNone(st.CIStatus), shortSHA(st.HeadSHA), st.NonAgentComments)
fmt.Println(stateLine(st))
}
func stateLine(st agent.PRState) string {
return fmt.Sprintf("%s state=%s merged=%t mergeable=%s ci=%s head=%s base=%s comments(non-agent)=%d",
st.Ref.String(), st.State, st.Merged, st.Mergeable, ciOrNone(st.CIStatus),
shortSHA(st.HeadSHA), shortSHA(st.BaseSHA), st.NonAgentComments)
}
// baselineLine describes the state a watch started from, naming the conditions
// it will deliberately stay silent about: watchpr alerts on changes, so a PR
// that is already conflicted or already CI-red produces no alert for either, and
// that suppression has to be visible to whoever started the watch.
func baselineLine(st agent.PRState) string {
line := " baseline " + stateLine(st)
if s := suppressedAtBaseline(st); s != "" {
return line + " -- " + s + "; not alerting on a pre-existing condition"
}
return line
}
func suppressedAtBaseline(st agent.PRState) string {
var conds []string
if st.Mergeable == agent.MergeNo {
conds = append(conds, "already non-mergeable (a real conflict, or Gitea still recomputing)")
}
if st.CIStatus == "failure" || st.CIStatus == "error" {
conds = append(conds, "CI already "+st.CIStatus)
}
return strings.Join(conds, ", ")
}
func ciOrNone(s string) string {
+30
View File
@@ -182,3 +182,33 @@ func TestDescribeFailureNamesTheCause(t *testing.T) {
})
}
}
// A watch started against an already conflicted or CI-red PR sends no alert for
// either, so the baseline line has to say that is what it is doing: before this,
// such a run printed nothing in either direction.
func TestBaselineLineNamesSuppressedConditions(t *testing.T) {
st := agent.PRState{
Ref: agent.PRRef{Owner: "unkin", Repo: "repo", Number: 7},
State: "open",
Mergeable: agent.MergeNo,
CIStatus: "failure",
HeadSHA: "cafebabecafebabe",
BaseSHA: "deadbeefdeadbeef",
}
got := baselineLine(st)
for _, want := range []string{
"unkin/repo#7", "mergeable=false", "ci=failure", "head=cafebabe", "base=deadbeef",
"already non-mergeable", "CI already failure", "not alerting",
} {
if !strings.Contains(got, want) {
t.Errorf("baselineLine = %q, want it to mention %q", got, want)
}
}
clean := st
clean.Mergeable = agent.MergeYes
clean.CIStatus = "success"
if got := baselineLine(clean); strings.Contains(got, "not alerting") {
t.Errorf("baselineLine = %q, want no suppression note for a clean baseline", got)
}
}