watchpr: describe arming as a one-way latch, not a window restart
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful

The docs claimed every later head/base move restarts the conflict window.
arm() returns early once armed, so only the first move sets armedAt and
every move after it is a no-op. State the real trade-off instead: repeated
moves do not extend the debounce, so a conflict can be confirmed while the
newest recompute is younger than the window.

Also narrow the --json stderr claim to the notices watchpr writes itself
(cobra's terminal Error: line is plain text), rename the test to what it
covers, and stop the unknown-mergeability baseline implying a false answer
arms the rule.
This commit is contained in:
2026-09-26 22:30:14 +10:00
parent 5928233a97
commit ebdd25f725
3 changed files with 30 additions and 14 deletions
+12 -6
View File
@@ -8,8 +8,12 @@
// so. A conflict introduced by the push immediately before the watch started is
// not reported while the commits stay put, because nothing in Gitea's payload
// separates it from a merge check still in flight; the baseline line is the only
// notice of it. A later move of the head or base arms the rule without alerting,
// and restarts the two-minute window from itself.
// notice of it. The first later move of the head or base arms the rule without
// alerting, and the window then runs from that arm rather than from the falses
// that predate it. Moves after that neither re-arm nor restart the window -- a
// restart on every base move could never complete on a busy base branch -- so a
// conflict can be confirmed while the newest merge recompute is less than two
// minutes old.
//
// watchpr owner/repo#12 owner/repo:15
// watchpr --once --json owner/repo#12
@@ -108,9 +112,11 @@ func clientFor(jsonMode bool) *agent.GiteaClient {
return agent.NewGiteaClient(token)
}
// warnRecord is the --json form of a warning. Under --json stderr carries
// nothing but NDJSON records, so a caller parsing it line by line never has to
// guess which shape a line is.
// warnRecord is the --json form of a warning. Under --json every notice watchpr
// itself writes to stderr -- warnings and the baseline -- is an NDJSON record,
// so a caller parsing them line by line never has to guess which shape a line
// is. A terminal failure is the one exception: SilenceErrors stays off, so
// cobra prints it as a plain "Error: ..." line and the exit status is non-zero.
type warnRecord struct {
Warning string `json:"warning"`
}
@@ -255,7 +261,7 @@ func suppressedAtBaseline(st agent.PRState) string {
case agent.MergeNo:
conds = append(conds, "already non-mergeable (a real conflict, or Gitea still recomputing)")
case agent.MergeUnknown:
conds = append(conds, "mergeability unknown (the conflict rule is disarmed until Gitea answers or the commits move)")
conds = append(conds, "mergeability unknown (the conflict rule is disarmed until Gitea reports mergeable or the commits move)")
}
if st.CIStatus == "failure" || st.CIStatus == "error" {
conds = append(conds, "CI already "+st.CIStatus)
+6 -4
View File
@@ -257,10 +257,12 @@ func TestBaselineIsEmittedInJSONMode(t *testing.T) {
}
}
// Under --json stderr is the baseline and warning channel, so it has to be one
// shape: a caller parsing it line by line must never meet a bare `warning:`
// line between two NDJSON records.
func TestJSONModeStderrIsAllRecords(t *testing.T) {
// Under --json stderr is the baseline and warning channel, so every notice
// watchpr writes there has to be one shape: a caller parsing it line by line
// must never meet a bare `warning:` line between two NDJSON records. Cobra's
// terminal `Error: ...` line is not covered here -- SilenceErrors stays off, so
// it is plain text on stderr alongside a non-zero exit.
func TestJSONModeWarningsAndBaselineAreRecords(t *testing.T) {
st := agent.PRState{
Ref: agent.PRRef{Owner: "unkin", Repo: "repo", Number: 7},
State: "open",