From 5a3d72f09c00ffd0be43b91283c96060d9292364 Mon Sep 17 00:00:00 2001 From: codex-bot-andresmgsl <304681515+codex-bot-andresmgsl@users.noreply.github.com> Date: Sat, 25 Jul 2026 04:29:37 +0000 Subject: [PATCH] fix: make post-merge transitions episode-aware --- .../issueflow-reconcile.sh | 72 +++++++++++++++---- test/issueflow-reconcile.test.sh | 67 ++++++++++++----- 2 files changed, 107 insertions(+), 32 deletions(-) diff --git a/actions/issueflow-reconcile/issueflow-reconcile.sh b/actions/issueflow-reconcile/issueflow-reconcile.sh index 3ddb687..c5ab519 100644 --- a/actions/issueflow-reconcile/issueflow-reconcile.sh +++ b/actions/issueflow-reconcile/issueflow-reconcile.sh @@ -124,23 +124,48 @@ claim_reclaim_marker() { # $1 = last activity epoch refs_references() { # PR body on stdin -> local issue numbers named by Refs awk ' - tolower($0) ~ /(^|[^[:alnum:]_-])refs([[:space:]]|:)/ { print } + { + line = $0 + lower = tolower(line) + if (match(lower, /(^|[^[:alnum:]_-])refs[[:space:]:]+/)) { + line = substr(line, RSTART + RLENGTH) + if (line ~ /^(#|([[:alnum:]_.-]+\/)?[[:alnum:]_.-]+#)[0-9]+/) { + sub(/[.(;].*/, "", line) + print line + } + } + } ' | 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 }' + awk ' + /^[[:space:]]*([-*]|[0-9]+\.)[[:space:]]+\[[[:space:]]\]/ { + sub(/\r$/, "") + print + } + ' } -post_merge_decision() { # $1 merged Refs-linked PR, unchecked criteria on stdin - local merged="$1" unchecked +post_merge_decision() { # $1 merged Refs PR, $2 linked open PR, $3 already handled + local merged="$1" open_pr="$2" handled="$3" unchecked unchecked="$(cat)" - if [ "$merged" = true ] && [ -n "$unchecked" ]; then echo TRANSITION + if [ -n "$merged" ] && [ "$open_pr" = false ] && [ "$handled" = false ] \ + && [ -n "$unchecked" ]; then echo TRANSITION else echo KEEP fi } +post_merge_pr_for_issue() { # $1 issue; records are ISSUEPR + awk -F '\t' -v issue="$1" '$1 == issue { print $2 }' \ + <<<"${MERGED_REF_PR_RECORDS:-}" | sort -n | tail -n1 +} + +post_merge_transition_marker() { # $1 merged PR number + printf 'post-merge-transition-pr-%s\n' "$1" +} + issue_references() { # text on stdin -> LOCAL/CROSSreference # A qualified reference belongs to another repository. Classify the whole # token before extracting numbers so rig#112 can never become local #112. @@ -235,12 +260,16 @@ offsite_resolved_decision() { # PR states on stdin -> NUDGE | QUIET # 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" - if gh api --paginate "repos/$REPO/issues/$n/comments" --jq '.[].body' \ - | grep -qF ""; then return; fi + if issue_comment_has_marker "$n" "$marker"; then return; fi run gh issue comment "$n" -R "$REPO" --body " $message" >/dev/null } +issue_comment_has_marker() { # $1 issue, $2 marker + gh api --paginate "repos/$REPO/issues/$1/comments" --jq '.[].body' \ + | grep -qF "" +} + reference_states() { local ref state while IFS= read -r ref; do @@ -281,7 +310,8 @@ last_issue_activity() { reconcile_issue() { local n="$1" decision refs cross_refs states age assignees open_pr=false label owners - local merged_ref=false unchecked="" remove_claimed=claimed + local merged_ref_pr="" transition_marker="" transition_handled=false + local unchecked="" remove_claimed=claimed decision="$(queue_decision <<<"$ISSUE_LABELS")" case "$decision" in ADD_NEEDS_TRIAGE) @@ -297,10 +327,16 @@ reconcile_issue() { if has_issue_label claimed; then assignees="$(jq '.assignees | length' <<<"$ISSUE_JSON")" grep -qxF "$n" <<<"${OPEN_PR_ISSUES:-}" && open_pr=true - grep -qxF "$n" <<<"${MERGED_REF_PR_ISSUES:-}" && merged_ref=true + merged_ref_pr="$(post_merge_pr_for_issue "$n")" + if [ -n "$merged_ref_pr" ]; then + transition_marker="$(post_merge_transition_marker "$merged_ref_pr")" + issue_comment_has_marker "$n" "$transition_marker" \ + && transition_handled=true + fi unchecked="$(unchecked_criteria <<<"$(jq -r '.body // ""' <<<"$ISSUE_JSON")")" - if [ "$(post_merge_decision "$merged_ref" <<<"$unchecked")" = TRANSITION ]; then - ensure_comment "$n" post-merge-transition \ + if [ "$(post_merge_decision "$merged_ref_pr" "$open_pr" "$transition_handled" \ + <<<"$unchecked")" = TRANSITION ]; then + ensure_comment "$n" "$transition_marker" \ "The Refs-linked PR merged with these acceptance criteria still unchecked: $unchecked @@ -457,16 +493,22 @@ main() { } }' --jq '.data.repository.pullRequests.nodes[].closingIssuesReferences.nodes[].number' \ | sort -nu)" - MERGED_REF_PR_ISSUES="$(gh api graphql --paginate -f owner="$owner" -f name="$name" -f query=' + MERGED_REF_PR_RECORDS="$(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 } + nodes { number body } pageInfo { hasNextPage endCursor } } } - }' --jq '.data.repository.pullRequests.nodes[].body' \ - | refs_references)" + }' --jq '.data.repository.pullRequests.nodes[] + | .number as $pr | .body | split("\n")[] + | [$pr, .] | @tsv' \ + | while IFS=$'\t' read -r pr body; do + while IFS= read -r issue; do + [ -n "$issue" ] && printf '%s\t%s\n' "$issue" "$pr" + done < <(refs_references <<<"$body") + done)" local n for n in $(gh api --paginate "repos/$REPO/issues?state=open&per_page=100" \ diff --git a/test/issueflow-reconcile.test.sh b/test/issueflow-reconcile.test.sh index 7679134..d1a8f15 100644 --- a/test/issueflow-reconcile.test.sh +++ b/test/issueflow-reconcile.test.sh @@ -82,18 +82,20 @@ check "attention does not exempt a claimed issue" 0 "SWEEP" \ claim_clock_exempt <<<"attention" 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 @@ -311,7 +317,7 @@ check "the flag-free control is reclaimed (the clock still runs elsewhere)" 0 "" # -- 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')" +transition="$(issue_probe 35 claimed 1 false 350 $'- [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" # shellcheck disable=SC2016 # positional parameters belong to bash -c @@ -346,7 +352,7 @@ recent_timeline() { } edit_count_before="$(wc -l <"$TMP/issue-edits")" recent_timeline 38 -open_refs="$(issue_probe 38 claimed 1 true false '- [ ] verify after merge')" +open_refs="$(issue_probe 38 claimed 1 true 380 '- [ ] verify after merge')" check "open Refs PR leaves the issue exactly as found" 0 "" \ test -z "$open_refs" # shellcheck disable=SC2016 # positional parameters belong to bash -c @@ -355,7 +361,7 @@ check "...with no edit or comment" 0 "" \ "$edit_count_before" "$TMP/issue-edits" "$TMP/posted-38" recent_timeline 39 -merged_closes="$(issue_probe 39 claimed 1 false false '- [ ] verify after merge')" +merged_closes="$(issue_probe 39 claimed 1 false "" '- [ ] verify after merge')" check "merged Closes PR leaves a recent claim exactly as found" 0 "" \ test -z "$merged_closes" # shellcheck disable=SC2016 # positional parameters belong to bash -c @@ -364,7 +370,7 @@ check "...with no edit or comment" 0 "" \ "$edit_count_before" "$TMP/issue-edits" "$TMP/posted-39" recent_timeline 40 -all_checked="$(issue_probe 40 claimed 1 false true '- [x] verified after merge')" +all_checked="$(issue_probe 40 claimed 1 false 400 '- [x] verified after merge')" check "merged Refs with zero unchecked boxes leaves the issue exactly as found" 0 "" \ test -z "$all_checked" # shellcheck disable=SC2016 # positional parameters belong to bash -c @@ -373,12 +379,39 @@ check "...with no edit or comment" 0 "" \ "$edit_count_before" "$TMP/issue-edits" "$TMP/posted-40" printf '[]\n' >"$(cfix 41)" -attention_transition="$(issue_probe 41 $'claimed\nattention' 1 false true '- [ ] verify')" +attention_transition="$(issue_probe 41 $'claimed\nattention' 1 false 410 '- [ ] verify')" check "derived post-merge transition clears attention with the released claim" 0 "" \ grep -qF -- '--remove-label claimed,attention --add-label post-merge' "$TMP/issue-edits" check "...still completes the transition" 0 "" \ grep -qF 'merged Refs PR -> post-merge; claim released' <<<"$attention_transition" +recent_timeline 43 +jq -n --arg b '' \ + --arg at "$(iso_at $((INOW - 60)))" \ + '[{"body":$b,"created_at":$at}]' >"$(cfix 43)" +reentry_edit_count="$(wc -l <"$TMP/issue-edits")" +historical="$(issue_probe 43 claimed 1 false 430 '- [ ] corrective verification')" +check "a handled historical Refs merge cannot steal a re-entered claim" 0 "" \ + test -z "$historical" +# shellcheck disable=SC2016 # positional parameters belong to bash -c +check "...and re-entry produces no edit or duplicate transition comment" 0 "" \ + bash -c 'test "$1" -eq "$(wc -l <"$2")" && test ! -f "$3"' _ \ + "$reentry_edit_count" "$TMP/issue-edits" "$TMP/posted-43" + +printf '[]\n' >"$(cfix 44)" +second_transition="$(issue_probe 44 claimed 1 false 441 '- [ ] second verification')" +check "a later merged Refs PR gets an episode-specific transition comment" 0 "" \ + grep -qF '' "$TMP/posted-44" +check "...and the later episode still transitions" 0 "" \ + grep -qF 'merged Refs PR -> post-merge; claim released' <<<"$second_transition" + +printf '[]\n' >"$(cfix 45)" +issue_probe 45 $'claimed\npost-merge' >/dev/null +# shellcheck disable=SC2016 # Markdown backticks are literal evidence +check "queue-conflict evidence lists every category including post-merge" 0 "" \ + grep -qF 'needs-triage`, `epic`, `ready`, `claimed`, `blocked`, or `post-merge`' \ + "$TMP/posted-45" + printf '[]\n' >"$(cfix 42)" issue_probe 42 $'post-merge\nattention' 0 >/dev/null check "hand-created post-merge plus attention is flagged, not rewritten" 0 "" \ @@ -572,7 +605,7 @@ check "...and the sweep still runs" 0 "" \ # 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}}}}}' \ + '{"data":{"repository":{"pullRequests":{"nodes":[{"number":400,"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"