diff --git a/BUILDER.md b/BUILDER.md index 185afe7..d966f86 100644 --- a/BUILDER.md +++ b/BUILDER.md @@ -18,7 +18,8 @@ triage bug, and the move is to say so on the issue, not to guess. - Assign yourself, swap `ready` → `claimed`, and comment that you are starting. The claim is a promise of a draft PR soon — a claim with no PR - and no activity is what the staleness sweep reclaims. + and no activity is what the staleness sweep reclaims unless `offsite` + records that its PR lives in another repository. - **Abandoning is fine; ghosting is not.** If you stop, say where you got to, push the branch if it holds anything useful, unassign, and restore `ready`. @@ -28,9 +29,12 @@ triage bug, and the move is to say so on the issue, not to guess. - Branch per issue; open the PR **as a draft early**, `Closes #N` in the body. `Closes #N` does not cross repos: when the PR is in a different repo from its authorizing issue, use `Part of /#N` instead, and - comment on that issue with the draft PR link as soon as the draft opens. + in the same step set `offsite` and comment on that issue with the draft PR + link as soon as the draft opens. Triage closes the authorizing issue by hand when its acceptance criteria - are met; the cross-repo merge never closes it. This codifies the linkage + are met; at that handoff the builder reports whether the cross-repo PR + merged or closed and clears `offsite` in the same comment. The cross-repo + merge never closes the authorizing issue. This codifies the linkage builders already used on rig#112 and ceremony #13/#16 rather than adding a new review obligation. Drafts are invisible to the reviewer panel on purpose — the draft phase is yours. diff --git a/CHANGELOG.md b/CHANGELOG.md index 16523f4..1a58430 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ so entries say what changed, cite the issue, and stop. - Cross-repo doctrine: the panel is the PR's repo's roster, a review request is authorization but not panel membership, and `Part of #N` replaces the `Closes #N` that cannot cross repos (#57). - `actions/runner-isolated` — a `pull_request`-triggered job may never run on a self-hosted runner (#58). - The sweep's `needs-ruling` invariants, one implementation for both surfaces: the issue-side staleness exemption, the bare-flag check (comment-only, the label is never removed), and the 7-day nudge to the decider (#52). +- `offsite` — protect claimed issues whose PR lives in another repository from the claim-reclaim clock (#68). ## 0.1.0 — 2026-07-22 diff --git a/LABELS.md b/LABELS.md index 5d3def5..e5539ae 100644 --- a/LABELS.md +++ b/LABELS.md @@ -66,6 +66,7 @@ comments, unassigns the stale owner, and restores `ready`. |---|---|---| | `stale` | `#B60205` | no activity for 48h — sweep-managed, never hand-applied | | `blocked` | `#6A737D` | (see above — same label serves PRs waiting on another PR/issue; legitimately quiet, the staleness sweep skips it) | +| `offsite` | `#CFD3D7` | issue deliverable is a PR in another repository; set by the builder with the draft link and cleared by the builder at handoff | | `needs-ruling` | `#D4C5F9` | a human decision is required; the question, options and a recommendation are in the flagging comment. Set by triage or the builder; a state, not a signal — it clears on agreement, not on a reply | | `release` | `#0E8A16` | release flow, versioning, packaging work — and the ceremony PR itself | | `merge-next` | `#0E8A16` | head of the merge queue — merge this one next. Queue order is *intent*: never set by the reconciler, only cleared by it | @@ -99,6 +100,15 @@ activity, so it resets its own window and never repeats within a quiet week. Label churn is not activity — the clock reads comments, reviews and commits, or the sweep would reset itself. +`offsite` is issue-only and records that a claimed issue's deliverable lives +in another repository, where a closing reference cannot make a local open PR +visible to the sweep (#68). The builder sets it in the same step that posts +the cross-repo draft link, then clears it at handoff in the same comment that +reports whether that PR merged or closed. The machine reads the flag and +never writes it. It stops only the claim-reclaim clock: missing assignees are +still flagged, queue-label conflicts and missing queue state are still +repaired, and epic-completion and PR-side stale behavior are unchanged. + ## Scope — which surface? (PRs and issues, any number) All scopes share one calm color, `#C5DEF5` — scopes locate, states alert. The diff --git a/actions/issueflow-reconcile/issueflow-reconcile.sh b/actions/issueflow-reconcile/issueflow-reconcile.sh index d51d898..920e9b1 100644 --- a/actions/issueflow-reconcile/issueflow-reconcile.sh +++ b/actions/issueflow-reconcile/issueflow-reconcile.sh @@ -101,6 +101,19 @@ claim_decision() { # $1 assignee count, $2 linked open PR, $3 age seconds fi } +claim_clock_exempt() { # labels on stdin -> EXEMPT | SWEEP + local labels + labels="$(cat)" + # Cross-repo work has no local closing PR by construction (#68), so its + # deliberate silence must share the one claim-clock gate with rulings. + if grep -qxF offsite <<<"$labels" \ + || [ "$(ruling_stale_exempt <<<"$labels")" = EXEMPT ]; then + echo EXEMPT + else + echo SWEEP + fi +} + claim_decision_at() { # $1 assignee count, $2 linked open PR, $3 last activity epoch claim_decision "$1" "$2" "$((NOW - $3))" } @@ -229,12 +242,10 @@ reconcile_issue() { assignees="$(jq '.assignees | length' <<<"$ISSUE_JSON")" grep -qxF "$n" <<<"${OPEN_PR_ISSUES:-}" && open_pr=true age="$(last_issue_activity "$n" "$(jq -r '.created_at' <<<"$ISSUE_JSON")")" - if [ "$(ruling_stale_exempt <<<"$ISSUE_LABELS")" = EXEMPT ]; then - # Waiting on a human is legitimately quiet (#50 D10): the reclaim - # clock does not run under a pending ruling — the same treatment - # `blocked` gets by never reaching this branch at all. Only the clock - # stops: an unassigned claim is still a repair the decision must see, - # so it runs on a zero age rather than being skipped. + if [ "$(claim_clock_exempt <<<"$ISSUE_LABELS")" = EXEMPT ]; then + # Legitimately quiet work does not run the reclaim clock. Only the + # clock stops: an unassigned claim is still a repair the decision must + # see, so it runs on a zero age rather than being skipped. decision="$(claim_decision "$assignees" "$open_pr" 0)" else decision="$(claim_decision_at "$assignees" "$open_pr" "$age")" diff --git a/actions/labels-reconcile/labels-reconcile.sh b/actions/labels-reconcile/labels-reconcile.sh index 67e8a0e..25231ea 100755 --- a/actions/labels-reconcile/labels-reconcile.sh +++ b/actions/labels-reconcile/labels-reconcile.sh @@ -391,6 +391,7 @@ blocker:unrequested|E99695|Somebody still owes a verdict and nobody was asked fo merge-next|0E8A16|Head of the merge queue — merge this one next (set by hand/agent, cleared here) stale|B60205|No activity for 48h — needs a poke (sweep-managed) blocked|6A737D|Waiting on another PR or issue to land first +offsite|CFD3D7|Issue deliverable is a PR in another repository — claim clock paused needs-ruling|D4C5F9|A human decision is pending — question, options and a recommendation are in the comment release|0E8A16|Release flow and version/packaging work needs-triage|FBCA04|Did not come through triage — owes normalization or conversion to a discussion diff --git a/test/issueflow-reconcile.test.sh b/test/issueflow-reconcile.test.sh index 4f913a2..d7e2f06 100644 --- a/test/issueflow-reconcile.test.sh +++ b/test/issueflow-reconcile.test.sh @@ -68,6 +68,14 @@ check "reclaim marker is stable within a claim episode" 0 "claim-reclaimed-96399 claim_reclaim_marker 96399 check "a later claim episode receives a new reclaim marker" 0 "claim-reclaimed-99999" \ claim_reclaim_marker 99999 +check "offsite exempts the claim clock" 0 "EXEMPT" claim_clock_exempt <<<"offsite" +check "needs-ruling still exempts through the shared gate" 0 "EXEMPT" \ + claim_clock_exempt <<<"needs-ruling" +check "both quiet flags still produce one exemption verdict" 0 "EXEMPT" \ + claim_clock_exempt <<< $'offsite\nneeds-ruling' +check "blocked does not exempt a claimed issue" 0 "SWEEP" claim_clock_exempt <<<"blocked" +check "ready does not exempt a claimed issue" 0 "SWEEP" claim_clock_exempt <<<"ready" +check "empty labels do not exempt a claimed issue" 0 "SWEEP" claim_clock_exempt &1 @@ -246,6 +261,28 @@ control="$(issue_probe 22 claimed)" check "the flag-free control is reclaimed (the clock still runs elsewhere)" 0 "" \ grep -q 'stale claim reclaimed -> ready' <<<"$control" +# -- offsite stops only the reclaim clock ------------------------------------ +offsite="$(issue_probe 25 $'claimed\noffsite')" +check "a 10-day-quiet offsite claim is not reclaimed" 1 "" \ + grep -q 'reclaimed' <<<"$offsite" +issue_probe 26 $'claimed\noffsite' 0 >/dev/null +check "an unassigned offsite claim is still flagged" 0 "" \ + grep -q 'issueflow:claimed-unassigned' "$TMP/posted-26" +offsite_open="$(issue_probe 27 $'claimed\noffsite' 1 true)" +check "an offsite claim with an open PR stays claimed" 1 "" \ + grep -q 'reclaimed' <<<"$offsite_open" +offsite_both="$(issue_probe 28 $'claimed\noffsite\nneeds-ruling')" +check "offsite plus needs-ruling stays claimed" 1 "" \ + grep -q 'reclaimed' <<<"$offsite_both" +check "a one-hour claim stays claimed for the ordinary age reason" 0 "KEEP" \ + claim_decision 1 false 3600 +check "the offsite exemption flag is named once at its issueflow decision point" 0 "1" \ + grep -c 'offsite' "$ROOT/actions/issueflow-reconcile/issueflow-reconcile.sh" +check "no reconciler mutation names offsite (#68 D4)" 1 "" \ + grep -E 'gh (issue|pr) edit.*offsite' \ + "$ROOT/actions/issueflow-reconcile/issueflow-reconcile.sh" \ + "$ROOT/actions/labels-reconcile/labels-reconcile.sh" + # -- an already-applied stale heals off, and no edit names the flag ---------- jq -n --arg l "$(iso_at $((INOW - 3600)))" \ '[{"event":"labeled","label":{"name":"needs-ruling"},"actor":{"login":"setter"},"created_at":$l}]' >"$(tfix 23)"