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..a45cfe5 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