Merge pull request #71 from codex-bot-andresmgsl/build/69-offsite-stale-flag-nudge

feat: nudge resolved offsite claims
This commit is contained in:
Daniel Marin 2026-07-23 14:43:06 +01:00 committed by GitHub
commit 553409cad4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 122 additions and 3 deletions

View file

@ -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). - `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). - 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). - `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 ## 0.1.0 — 2026-07-22

View file

@ -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 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 never writes it. It stops only the claim-reclaim clock: missing assignees are
still flagged, queue-label conflicts and missing queue state are still 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) ## Scope — which surface? (PRs and issues, any number)

View file

@ -192,6 +192,27 @@ epic_decision() { # $1 refs, $2 states
fi 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. # API edge. Marker comments make warnings and nudges idempotent across sweeps.
ensure_comment() { # $1 issue, $2 marker, $3 message ensure_comment() { # $1 issue, $2 marker, $3 message
local n="$1" marker="$2" message="$3" local n="$1" marker="$2" message="$3"
@ -210,6 +231,21 @@ reference_states() {
done 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() { last_issue_activity() {
local n="$1" created="$2" latest local n="$1" created="$2" latest
latest="$({ latest="$({
@ -269,6 +305,18 @@ reconcile_issue() {
fi fi
log "#$n: stale claim reclaimed -> ready" ;; log "#$n: stale claim reclaimed -> ready" ;;
esac 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 elif has_issue_label blocked; then
refs="$(blocked_references <<<"$(jq -r '.body // ""' <<<"$ISSUE_JSON")")" refs="$(blocked_references <<<"$(jq -r '.body // ""' <<<"$ISSUE_JSON")")"
cross_refs="$(blocked_cross_references <<<"$(jq -r '.body // ""' <<<"$ISSUE_JSON")")" cross_refs="$(blocked_cross_references <<<"$(jq -r '.body // ""' <<<"$ISSUE_JSON")")"

View file

@ -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 "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 "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 </dev/null check "empty labels do not exempt a claimed issue" 0 "SWEEP" claim_clock_exempt </dev/null
check "one closed offsite PR nudges" 0 "NUDGE" offsite_resolved_decision <<<"CLOSED"
check "two closed offsite PRs nudge" 0 "NUDGE" offsite_resolved_decision <<< $'CLOSED\nCLOSED'
check "one open offsite PR keeps quiet" 0 "QUIET" offsite_resolved_decision <<< $'CLOSED\nOPEN'
check "no visible offsite PR keeps quiet" 0 "QUIET" offsite_resolved_decision </dev/null
check "an unreadable offsite PR keeps quiet" 0 "QUIET" offsite_resolved_decision <<< $'CLOSED\nUNKNOWN'
timeline='[
{"event":"cross-referenced","source":{"issue":{"number":112,"repository":{"full_name":"heavy-duty/rig"},"pull_request":{"url":"x"}}}},
{"event":"cross-referenced","source":{"issue":{"number":7,"repository":{"full_name":"heavy-duty/rig"}}}},
{"event":"mentioned","source":{"issue":{"number":9,"repository":{"full_name":"heavy-duty/box"},"pull_request":{"url":"x"}}}},
{"event":"assigned"}
]'
check "offsite timeline extracts only cross-referenced PRs" 0 "heavy-duty/rig#112" \
offsite_cross_referenced_prs <<<"$timeline"
check "empty offsite timeline extracts nothing" 0 "" offsite_cross_referenced_prs <<<"[]"
# Invariant 3: blocked declarations parse and release only when all close. # Invariant 3: blocked declarations parse and release only when all close.
refs="$(blocked_references <<< $'Context #99. Blocked by #12 (first), #7 (second). Blocks #44.')" refs="$(blocked_references <<< $'Context #99. Blocked by #12 (first), #7 (second). Blocks #44.')"
@ -193,6 +208,8 @@ issue_stub_gh() {
shift shift
done done
file="$TMP/$(printf '%s' "$endpoint" | tr '/' '_').json" file="$TMP/$(printf '%s' "$endpoint" | tr '/' '_').json"
printf '%s\n' "$endpoint" >>"$TMP/api-calls"
[ ! -f "$file.error" ] || return 1
[ -f "$file" ] || { printf '[]\n'; return 0; } [ -f "$file" ] || { printf '[]\n'; return 0; }
if [ -n "$jqexpr" ]; then jq -r "$jqexpr" "$file"; else cat "$file"; fi if [ -n "$jqexpr" ]; then jq -r "$jqexpr" "$file"; else cat "$file"; fi
elif [ "$1" = issue ] && [ "$2" = comment ]; then 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')" offsite_both="$(issue_probe 28 $'claimed\noffsite\nneeds-ruling')"
check "offsite plus needs-ruling stays claimed" 1 "" \ check "offsite plus needs-ruling stays claimed" 1 "" \
grep -q 'reclaimed' <<<"$offsite_both" 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 '<!-- issueflow:offsite-resolved -->' "$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" \ check "a one-hour claim stays claimed for the ordinary age reason" 0 "KEEP" \
claim_decision 1 false 3600 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 "" \ check "no reconciler mutation names offsite (#68 D4)" 1 "" \
grep -E 'gh (issue|pr) edit.*offsite' \ grep -E 'gh (issue|pr) edit.*offsite' \
"$ROOT/actions/issueflow-reconcile/issueflow-reconcile.sh" \ "$ROOT/actions/issueflow-reconcile/issueflow-reconcile.sh" \