diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a58430..c3ff203 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ so entries say what changed, cite the issue, and stop. - `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). +- `issueflow-reconcile` — nudge once when an `offsite` flag outlives every visible cross-referenced PR (#69). ## 0.1.0 — 2026-07-22 diff --git a/LABELS.md b/LABELS.md index e5539ae..a4f38a1 100644 --- a/LABELS.md +++ b/LABELS.md @@ -107,7 +107,9 @@ 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. +repaired, and epic-completion and PR-side stale behavior are unchanged. The +sweep tells the assignee once when every visible cross-referenced PR has +closed; it only tells, and never clears the flag or changes the claim. ## Scope — which surface? (PRs and issues, any number) diff --git a/actions/issueflow-reconcile/issueflow-reconcile.sh b/actions/issueflow-reconcile/issueflow-reconcile.sh index 920e9b1..31b12c0 100644 --- a/actions/issueflow-reconcile/issueflow-reconcile.sh +++ b/actions/issueflow-reconcile/issueflow-reconcile.sh @@ -192,6 +192,27 @@ epic_decision() { # $1 refs, $2 states fi } +offsite_cross_referenced_prs() { # timeline JSON on stdin -> owner/repo#N + jq -r ' + .[] + | select(.event == "cross-referenced") + | .source.issue + | select(.pull_request != null) + | select(.repository.full_name != null and .number != null) + | "\(.repository.full_name)#\(.number)" + ' | sort -u +} + +offsite_resolved_decision() { # PR states on stdin -> NUDGE | QUIET + local states + states="$(cat)" + if [ -n "$states" ] && ! grep -Eq '^(OPEN|UNKNOWN)$' <<<"$states"; then + echo NUDGE + else + echo QUIET + fi +} + # API edge. Marker comments make warnings and nudges idempotent across sweeps. ensure_comment() { # $1 issue, $2 marker, $3 message local n="$1" marker="$2" message="$3" @@ -210,6 +231,21 @@ reference_states() { done } +offsite_pr_states() { + local ref repo number state + while IFS= read -r ref; do + [ -n "$ref" ] || continue + repo="${ref%#*}" + number="${ref##*#}" + state="$(gh api "repos/$repo/pulls/$number" --jq '.state' 2>/dev/null || echo UNKNOWN)" + case "$state" in open) echo OPEN ;; closed) echo CLOSED ;; *) echo UNKNOWN ;; esac + done +} + +offsite_timeline() { # unreadable timelines are deliberately silent + gh api --paginate "repos/$REPO/issues/$1/timeline" 2>/dev/null || return 1 +} + last_issue_activity() { local n="$1" created="$2" latest latest="$({ @@ -269,6 +305,18 @@ reconcile_issue() { fi log "#$n: stale claim reclaimed -> ready" ;; esac + if has_issue_label offsite; then + local timeline + if timeline="$(offsite_timeline "$n")"; then + refs="$(offsite_cross_referenced_prs <<<"$timeline")" + states="$(offsite_pr_states <<<"$refs")" + if [ "$(offsite_resolved_decision <<<"$states")" = NUDGE ]; then + ensure_comment "$n" offsite-resolved \ + "$(tr '\n' ' ' <<<"$refs" | sed 's/[[:space:]]*$//') is closed; this issue's \`offsite\` flag is still up. Clear it and close the issue, or say what is still outstanding. @$(jq -r '.assignees[0].login' <<<"$ISSUE_JSON")" + log "#$n: resolved offsite PRs nudged" + fi + fi + fi elif has_issue_label blocked; then refs="$(blocked_references <<<"$(jq -r '.body // ""' <<<"$ISSUE_JSON")")" cross_refs="$(blocked_cross_references <<<"$(jq -r '.body // ""' <<<"$ISSUE_JSON")")" diff --git a/test/issueflow-reconcile.test.sh b/test/issueflow-reconcile.test.sh index d7e2f06..52e65a5 100644 --- a/test/issueflow-reconcile.test.sh +++ b/test/issueflow-reconcile.test.sh @@ -76,6 +76,21 @@ check "both quiet flags still produce one exemption verdict" 0 "EXEMPT" \ 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 >"$TMP/api-calls" + [ ! -f "$file.error" ] || return 1 [ -f "$file" ] || { printf '[]\n'; return 0; } if [ -n "$jqexpr" ]; then jq -r "$jqexpr" "$file"; else cat "$file"; fi elif [ "$1" = issue ] && [ "$2" = comment ]; then @@ -274,10 +291,61 @@ check "an offsite claim with an open PR stays claimed" 1 "" \ offsite_both="$(issue_probe 28 $'claimed\noffsite\nneeds-ruling')" check "offsite plus needs-ruling stays claimed" 1 "" \ grep -q 'reclaimed' <<<"$offsite_both" + +# -- resolved offsite work nudges once and only from complete evidence ------- +jq -n --arg at "$(iso_at $((INOW - 3600)))" \ + '[{"event":"assigned","created_at":$at}, + {"event":"cross-referenced","source":{"issue":{"number":112,"repository":{"full_name":"heavy-duty/rig"},"pull_request":{"url":"x"}}}}]' \ + >"$(tfix 29)" +printf '{"state":"closed"}\n' >"$TMP/repos_heavy-duty_rig_pulls_112.json" +printf '[]\n' >"$(cfix 29)" +resolved="$(issue_probe 29 $'claimed\noffsite')" +check "a closed cross-referenced PR nudges and names the PR" 0 "" \ + grep -q 'heavy-duty/rig#112 is closed' "$TMP/posted-29" +check "the resolved nudge leaves the claim untouched" 1 "" \ + grep -q 'reclaimed' <<<"$resolved" +issue_probe 29 $'claimed\noffsite' >/dev/null +check "the resolved nudge is idempotent across sweeps" 0 "1" \ + grep -cF '' "$TMP/posted-29" + +jq -n --arg at "$(iso_at $((INOW - 3600)))" \ + '[{"event":"assigned","created_at":$at}, + {"event":"cross-referenced","source":{"issue":{"number":112,"repository":{"full_name":"heavy-duty/rig"},"pull_request":{"url":"x"}}}}, + {"event":"cross-referenced","source":{"issue":{"number":9,"repository":{"full_name":"heavy-duty/box"},"pull_request":{"url":"x"}}}}]' \ + >"$(tfix 30)" +printf '{"state":"open"}\n' >"$TMP/repos_heavy-duty_box_pulls_9.json" +printf '[]\n' >"$(cfix 30)" +issue_probe 30 $'claimed\noffsite' >/dev/null +check "one open cross-referenced PR suppresses the nudge" 1 "" \ + test -f "$TMP/posted-30" + +printf '[]\n' >"$(tfix 31)" +printf '[]\n' >"$(cfix 31)" +issue_probe 31 $'claimed\noffsite' >/dev/null +check "no visible cross-referenced PR stays silent" 1 "" test -f "$TMP/posted-31" + +: >"$(tfix 32).error" +printf '[]\n' >"$(cfix 32)" +unreadable="$(issue_probe 32 $'claimed\noffsite')" +check "an unreadable timeline stays silent" 1 "" test -f "$TMP/posted-32" +check "...and leaves the sweep running without an alarming log" 1 "" \ + grep -qiE 'error|failed' <<<"$unreadable" + +: >"$TMP/api-calls" +printf '[]\n' >"$(tfix 33)" +printf '[]\n' >"$(cfix 33)" +issue_probe 33 claimed >/dev/null +check "a non-offsite claim performs only the ordinary timeline read" 0 "1" \ + grep -cF 'repos/owner/repo/issues/33/timeline' "$TMP/api-calls" +: >"$TMP/api-calls" +printf '[]\n' >"$(tfix 34)" +printf '[]\n' >"$(cfix 34)" +issue_probe 34 $'claimed\noffsite' >/dev/null +check "an offsite claim performs the one guarded verification read" 0 "2" \ + grep -cF 'repos/owner/repo/issues/34/timeline' "$TMP/api-calls" + 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" \