feat: transition merged refs work to post-merge

This commit is contained in:
codex-bot-andresmgsl 2026-07-25 00:11:42 +00:00
parent 8e6423a07c
commit bb7dd51ba7
2 changed files with 172 additions and 42 deletions

View file

@ -21,7 +21,7 @@ ISSUEFLOW_STALE_HOURS="${ISSUEFLOW_STALE_HOURS:-48}"
} }
NOW="$ISSUEFLOW_NOW" NOW="$ISSUEFLOW_NOW"
STALE_AFTER=$((ISSUEFLOW_STALE_HOURS * 3600)) STALE_AFTER=$((ISSUEFLOW_STALE_HOURS * 3600))
QUEUE_LABELS=(ready claimed blocked) QUEUE_LABELS=(ready claimed blocked post-merge)
TRIAGE_ACTORS=() TRIAGE_ACTORS=()
# The needs-ruling invariants (#52) — one implementation for both surfaces. # The needs-ruling invariants (#52) — one implementation for both surfaces.
@ -122,6 +122,25 @@ claim_reclaim_marker() { # $1 = last activity epoch
printf 'claim-reclaimed-%s\n' "$1" printf 'claim-reclaimed-%s\n' "$1"
} }
refs_references() { # PR body on stdin -> local issue numbers named by Refs
awk '
tolower($0) ~ /(^|[^[:alnum:]_-])refs([[:space:]]|:)/ { print }
' | issue_references \
| awk -F '\t' '$1 == "LOCAL" { print $2 }' | sort -nu
}
unchecked_criteria() { # issue body on stdin -> unchecked task-list lines verbatim
awk '/^[[:space:]]*[-*][[:space:]]+\[[[:space:]]\]/ { print }'
}
post_merge_decision() { # $1 merged Refs-linked PR, unchecked criteria on stdin
local merged="$1" unchecked
unchecked="$(cat)"
if [ "$merged" = true ] && [ -n "$unchecked" ]; then echo TRANSITION
else echo KEEP
fi
}
issue_references() { # text on stdin -> LOCAL/CROSS<TAB>reference issue_references() { # text on stdin -> LOCAL/CROSS<TAB>reference
# A qualified reference belongs to another repository. Classify the whole # A qualified reference belongs to another repository. Classify the whole
# token before extracting numbers so rig#112 can never become local #112. # token before extracting numbers so rig#112 can never become local #112.
@ -262,6 +281,7 @@ last_issue_activity() {
reconcile_issue() { reconcile_issue() {
local n="$1" decision refs cross_refs states age assignees open_pr=false label owners local n="$1" decision refs cross_refs states age assignees open_pr=false label owners
local merged_ref=false unchecked=""
decision="$(queue_decision <<<"$ISSUE_LABELS")" decision="$(queue_decision <<<"$ISSUE_LABELS")"
case "$decision" in case "$decision" in
ADD_NEEDS_TRIAGE) ADD_NEEDS_TRIAGE)
@ -269,7 +289,7 @@ reconcile_issue() {
log "#$n: needs-triage (no queue state)" ;; log "#$n: needs-triage (no queue state)" ;;
FLAG_CONFLICT) FLAG_CONFLICT)
ensure_comment "$n" queue-conflict \ ensure_comment "$n" queue-conflict \
'The issue-flow sweep found conflicting queue labels. It cannot infer intent safely; triage must leave exactly one of `needs-triage`, `epic`, `ready`, `claimed`, or `blocked`.' 'The issue-flow sweep found conflicting queue labels. It cannot infer intent safely; triage must leave exactly one of `needs-triage`, `epic`, `ready`, `claimed`, `blocked`, or `post-merge`.'
log "#$n: conflicting queue labels; flagged" log "#$n: conflicting queue labels; flagged"
return ;; return ;;
esac esac
@ -277,46 +297,73 @@ reconcile_issue() {
if has_issue_label claimed; then if has_issue_label claimed; then
assignees="$(jq '.assignees | length' <<<"$ISSUE_JSON")" assignees="$(jq '.assignees | length' <<<"$ISSUE_JSON")"
grep -qxF "$n" <<<"${OPEN_PR_ISSUES:-}" && open_pr=true grep -qxF "$n" <<<"${OPEN_PR_ISSUES:-}" && open_pr=true
age="$(last_issue_activity "$n" "$(jq -r '.created_at' <<<"$ISSUE_JSON")")" grep -qxF "$n" <<<"${MERGED_REF_PR_ISSUES:-}" && merged_ref=true
if [ "$(claim_clock_exempt <<<"$ISSUE_LABELS")" = EXEMPT ]; then unchecked="$(unchecked_criteria <<<"$(jq -r '.body // ""' <<<"$ISSUE_JSON")")"
# Legitimately quiet work does not run the reclaim clock. Only the if [ "$(post_merge_decision "$merged_ref" <<<"$unchecked")" = TRANSITION ]; then
# clock stops: an unassigned claim is still a repair the decision must ensure_comment "$n" post-merge-transition \
# see, so it runs on a zero age rather than being skipped. "The Refs-linked PR merged with these acceptance criteria still unchecked:
decision="$(claim_decision "$assignees" "$open_pr" 0)"
$unchecked
The merge releases the claim; no builder owes a draft. Triage owes completion in a follow-up comment that names the owner and wake condition."
owners="$(jq -r '[.assignees[].login] | join(",")' <<<"$ISSUE_JSON")"
if [ -n "$owners" ]; then
run gh issue edit "$n" -R "$REPO" --remove-assignee "$owners" \
--remove-label claimed --add-label post-merge >/dev/null
else
run gh issue edit "$n" -R "$REPO" \
--remove-label claimed --add-label post-merge >/dev/null
fi
log "#$n: merged Refs PR -> post-merge; claim released"
else else
decision="$(claim_decision_at "$assignees" "$open_pr" "$age")" age="$(last_issue_activity "$n" "$(jq -r '.created_at' <<<"$ISSUE_JSON")")"
fi if [ "$(claim_clock_exempt <<<"$ISSUE_LABELS")" = EXEMPT ]; then
case "$decision" in # Legitimately quiet work does not run the reclaim clock. Only the
FLAG_UNASSIGNED) # clock stops: an unassigned claim is still a repair the decision must
ensure_comment "$n" claimed-unassigned \ # see, so it runs on a zero age rather than being skipped.
'This issue is `claimed` but has no assignee. The sweep cannot infer an owner; triage must repair the claim.' ;; decision="$(claim_decision "$assignees" "$open_pr" 0)"
RECLAIM) else
# The last-activity epoch identifies a claim episode. A fixed marker decision="$(claim_decision_at "$assignees" "$open_pr" "$age")"
# hid the required comment when the same issue was later claimed and fi
# reclaimed again. case "$decision" in
ensure_comment "$n" "$(claim_reclaim_marker "$age")" \ FLAG_UNASSIGNED)
'This claim has no linked open PR and no activity for 48 hours. The sweep is reclaiming it for the ready queue.' ensure_comment "$n" claimed-unassigned \
owners="$(jq -r '[.assignees[].login] | join(",")' <<<"$ISSUE_JSON")" 'This issue is `claimed` but has no assignee. The sweep cannot infer an owner; triage must repair the claim.' ;;
if [ -n "$owners" ]; then RECLAIM)
run gh issue edit "$n" -R "$REPO" --remove-assignee "$owners" \ # The last-activity epoch identifies a claim episode. A fixed marker
--remove-label claimed --add-label ready >/dev/null # hid the required comment when the same issue was later claimed and
else # reclaimed again.
run gh issue edit "$n" -R "$REPO" --remove-label claimed --add-label ready >/dev/null ensure_comment "$n" "$(claim_reclaim_marker "$age")" \
fi 'This claim has no linked open PR and no activity for 48 hours. The sweep is reclaiming it for the ready queue.'
log "#$n: stale claim reclaimed -> ready" ;; owners="$(jq -r '[.assignees[].login] | join(",")' <<<"$ISSUE_JSON")"
esac if [ -n "$owners" ]; then
if has_issue_label offsite; then run gh issue edit "$n" -R "$REPO" --remove-assignee "$owners" \
local timeline --remove-label claimed --add-label ready >/dev/null
if timeline="$(offsite_timeline "$n")"; then else
refs="$(offsite_cross_referenced_prs <<<"$timeline")" run gh issue edit "$n" -R "$REPO" --remove-label claimed --add-label ready >/dev/null
states="$(offsite_pr_states <<<"$refs")" fi
if [ "$(offsite_resolved_decision <<<"$states")" = NUDGE ]; then log "#$n: stale claim reclaimed -> ready" ;;
ensure_comment "$n" offsite-resolved \ esac
"$(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")" if has_issue_label offsite; then
log "#$n: resolved offsite PRs nudged" 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
fi fi
fi fi
elif has_issue_label post-merge; then
assignees="$(jq '.assignees | length' <<<"$ISSUE_JSON")"
if [ "$assignees" -gt 0 ]; then
ensure_comment "$n" post-merge-assigned \
'This `post-merge` issue has an assignee. The sweep will not undo a hand-assignment; triage must either clear it or move the issue back into buildable queue state.'
log "#$n: assigned post-merge issue flagged"
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")")"
@ -406,6 +453,16 @@ main() {
} }
}' --jq '.data.repository.pullRequests.nodes[].closingIssuesReferences.nodes[].number' \ }' --jq '.data.repository.pullRequests.nodes[].closingIssuesReferences.nodes[].number' \
| sort -nu)" | sort -nu)"
MERGED_REF_PR_ISSUES="$(gh api graphql --paginate -f owner="$owner" -f name="$name" -f query='
query($owner: String!, $name: String!, $endCursor: String) {
repository(owner: $owner, name: $name) {
pullRequests(first: 100, states: MERGED, after: $endCursor) {
nodes { body }
pageInfo { hasNextPage endCursor }
}
}
}' --jq '.data.repository.pullRequests.nodes[].body' \
| refs_references)"
local n local n
for n in $(gh api --paginate "repos/$REPO/issues?state=open&per_page=100" \ for n in $(gh api --paginate "repos/$REPO/issues?state=open&per_page=100" \

View file

@ -43,6 +43,10 @@ check "one ready queue label is valid" 0 "KEEP" queue_decision <<<"ready"
check "zero queue labels is derivably needs-triage" 0 "ADD_NEEDS_TRIAGE" queue_decision <<<"enhancement" check "zero queue labels is derivably needs-triage" 0 "ADD_NEEDS_TRIAGE" queue_decision <<<"enhancement"
check "multiple queue labels are ambiguous" 0 "FLAG_CONFLICT" queue_decision <<< $'ready\nblocked' check "multiple queue labels are ambiguous" 0 "FLAG_CONFLICT" queue_decision <<< $'ready\nblocked'
check "needs-triage plus queue is a conflict" 0 "FLAG_CONFLICT" queue_decision <<< $'needs-triage\nready' check "needs-triage plus queue is a conflict" 0 "FLAG_CONFLICT" queue_decision <<< $'needs-triage\nready'
check "claimed plus post-merge is a conflict" 0 "FLAG_CONFLICT" \
queue_decision <<< $'claimed\npost-merge'
check "post-merge plus needs-ruling is healthy" 0 "KEEP" \
queue_decision <<< $'post-merge\nneeds-ruling'
# Invariant 2: claims have an owner and either a PR or recent activity. # Invariant 2: claims have an owner and either a PR or recent activity.
check "claim with open PR stays claimed" 0 "KEEP" claim_decision 1 true 999999 check "claim with open PR stays claimed" 0 "KEEP" claim_decision 1 true 999999
@ -78,6 +82,18 @@ check "attention does not exempt a claimed issue" 0 "SWEEP" \
claim_clock_exempt <<<"attention" claim_clock_exempt <<<"attention"
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
refs_body=$'Refs #12\nAlso refs: #8 and heavy-duty/rig#4.\nCloses #99\nNot refs-ish #7'
check "Refs parser returns only local issues on Refs lines" 0 $'8\n12' \
refs_references <<<"$refs_body"
check "unchecked criteria preserve their source lines verbatim" 0 \
$'- [ ] first criterion\n * [ ] indented criterion' \
unchecked_criteria <<< $'- [x] done\n- [ ] first criterion\n * [ ] indented criterion'
check "merged Refs with unchecked criteria transitions" 0 "TRANSITION" \
post_merge_decision true <<<"- [ ] verify after merge"
check "open Refs does not transition" 0 "KEEP" \
post_merge_decision false <<<"- [ ] verify after merge"
check "merged Refs with all criteria checked does not transition" 0 "KEEP" \
post_merge_decision true </dev/null
check "one closed offsite PR nudges" 0 "NUDGE" offsite_resolved_decision <<<"CLOSED" 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 "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 "one open offsite PR keeps quiet" 0 "QUIET" offsite_resolved_decision <<< $'CLOSED\nOPEN'
@ -234,16 +250,18 @@ issue_stub_gh() {
fi fi
} }
issue_probe() { # $1 issue, $2 labels, $3 assignees (default 1), $4 open PR issue_probe() { # $1 issue, $2 labels, $3 assignees, $4 open PR, $5 merged Refs, $6 body
( (
local assignees="${3:-1}" open_pr="${4:-false}" assignee_json='[]' local assignees="${3:-1}" open_pr="${4:-false}" merged_ref="${5:-false}"
local body="${6:-}" assignee_json='[]'
[ "$assignees" -eq 0 ] || assignee_json='[{"login":"owner-bot"}]' [ "$assignees" -eq 0 ] || assignee_json='[{"login":"owner-bot"}]'
REPO=owner/repo NOW="$INOW" REPO=owner/repo NOW="$INOW"
ISSUE_LABELS="$2" ISSUE_LABELS="$2"
ISSUE_JSON="$(jq -n --arg at "$(iso_at $((INOW - 10 * 86400)))" \ ISSUE_JSON="$(jq -n --arg at "$(iso_at $((INOW - 10 * 86400)))" \
--argjson assignees "$assignee_json" \ --argjson assignees "$assignee_json" --arg body "$body" \
'{created_at: $at, assignees: $assignees, body: ""}')" '{created_at: $at, assignees: $assignees, body: $body}')"
if [ "$open_pr" = true ]; then OPEN_PR_ISSUES="$1"; else OPEN_PR_ISSUES=""; fi if [ "$open_pr" = true ]; then OPEN_PR_ISSUES="$1"; else OPEN_PR_ISSUES=""; fi
if [ "$merged_ref" = true ]; then MERGED_REF_PR_ISSUES="$1"; else MERGED_REF_PR_ISSUES=""; fi
run() { "$@"; } run() { "$@"; }
gh() { issue_stub_gh "$@"; } gh() { issue_stub_gh "$@"; }
reconcile_issue "$1" 2>&1 reconcile_issue "$1" 2>&1
@ -291,6 +309,34 @@ control="$(issue_probe 22 claimed)"
check "the flag-free control is reclaimed (the clock still runs elsewhere)" 0 "" \ check "the flag-free control is reclaimed (the clock still runs elsewhere)" 0 "" \
grep -q 'stale claim reclaimed -> ready' <<<"$control" grep -q 'stale claim reclaimed -> ready' <<<"$control"
# -- merged Refs work releases the claim before the reclaim clock ------------
printf '[]\n' >"$(cfix 35)"
transition="$(issue_probe 35 claimed 1 false true $'- [x] built\n- [ ] verify dispatch\n * [ ] confirm warning clears')"
check "merged Refs + unchecked criteria transitions in the sweep body" 0 "" \
grep -q 'merged Refs PR -> post-merge; claim released' <<<"$transition"
check "...names every remaining criterion verbatim in the comment" 0 "" \
bash -c 'grep -qF -- "- [ ] verify dispatch" "$1" &&
grep -qF -- " * [ ] confirm warning clears" "$1"' _ "$TMP/posted-35"
check "...states triage owes completion with owner and wake condition" 0 "" \
grep -qF 'Triage owes completion in a follow-up comment that names the owner and wake condition.' \
"$TMP/posted-35"
check "...unassigns and swaps claimed to post-merge" 0 "" \
grep -qF -- '--remove-assignee owner-bot --remove-label claimed --add-label post-merge' \
"$TMP/issue-edits"
printf '[]\n' >"$(cfix 36)"
post_merge_quiet="$(issue_probe 36 post-merge 0)"
check "quiet unassigned post-merge work is not reclaimed" 1 "" \
grep -q 'reclaimed' <<<"$post_merge_quiet"
check "...and causes no comment or edit" 1 "" test -f "$TMP/posted-36"
printf '[]\n' >"$(cfix 37)"
issue_probe 37 post-merge 1 >/dev/null
check "assigned post-merge is flagged" 0 "" \
grep -qF '<!-- issueflow:post-merge-assigned -->' "$TMP/posted-37"
check "...and the hand-assignment is not repaired" 1 "" \
grep -qF -- 'issue edit 37' "$TMP/issue-edits"
# -- offsite stops only the reclaim clock ------------------------------------ # -- offsite stops only the reclaim clock ------------------------------------
offsite="$(issue_probe 25 $'claimed\noffsite')" offsite="$(issue_probe 25 $'claimed\noffsite')"
check "a 10-day-quiet offsite claim is not reclaimed" 1 "" \ check "a 10-day-quiet offsite claim is not reclaimed" 1 "" \
@ -475,6 +521,33 @@ check "...stands down without minting" 1 "" test -s "$ARRIVAL/fixtures/edits"
check "...and the sweep still runs" 0 "" \ check "...and the sweep still runs" 0 "" \
grep -qF 'issueflow: reconciled.' <<<"$pr_out" grep -qF 'issueflow: reconciled.' <<<"$pr_out"
# The merged-Refs transition must survive the executable's set -e path too.
# Keep this at main() granularity: the GraphQL gather and loop are the code
# a sourced decision probe cannot exercise (#91's lesson).
printf '%s\n' \
'{"data":{"repository":{"pullRequests":{"nodes":[{"body":"Refs #40","closingIssuesReferences":{"nodes":[]}}],"pageInfo":{"hasNextPage":false,"endCursor":null}}}}}' \
>"$ARRIVAL/fixtures/graphql.json"
printf '[{"number":40}]\n' \
>"$ARRIVAL/fixtures/repos_owner_repo_issues_state_open_per_page_100.json"
jq -n --arg at "$(iso_at "$INOW")" \
'{number:40,user:{login:"triage-one"},created_at:$at,body:"- [x] built\n- [ ] verify live label",labels:[{name:"claimed"}],assignees:[{login:"builder"}]}' \
>"$ARRIVAL/fixtures/repos_owner_repo_issues_40.json"
printf '[]\n' >"$ARRIVAL/fixtures/repos_owner_repo_issues_40_comments.json"
: >"$ARRIVAL/fixtures/edits"
subprocess_out="$(
env PATH="$ARRIVAL/stub:$PATH" GH_FIXTURES="$ARRIVAL/fixtures" \
REPO=owner/repo LABELS_CONF="$ARRIVAL/labels.conf" \
bash "$ROOT/actions/issueflow-reconcile/issueflow-reconcile.sh" 2>&1
)"
subprocess_rc=$?
check "executable sweep transitions merged Refs work" 0 "" \
test "$subprocess_rc" -eq 0
check "...reaches the transition through GraphQL and the issue loop" 0 "" \
grep -qF '#40: merged Refs PR -> post-merge; claim released' <<<"$subprocess_out"
check "...and performs the release edit from the executable path" 0 "" \
grep -qF -- 'issue edit 40 -R owner/repo --remove-assignee builder --remove-label claimed --add-label post-merge' \
"$ARRIVAL/fixtures/edits"
# D2 preserved: only the deliberate stand-downs changed; a genuine failure on # D2 preserved: only the deliberate stand-downs changed; a genuine failure on
# the arrival path still kills the run loudly. # the arrival path still kills the run loudly.
: >"$ARRIVAL/fixtures/repos_owner_repo_issues_91.json.error" : >"$ARRIVAL/fixtures/repos_owner_repo_issues_91.json.error"