diff --git a/actions/issueflow-reconcile/issueflow-reconcile.sh b/actions/issueflow-reconcile/issueflow-reconcile.sh index fa30c6b..f5cca51 100644 --- a/actions/issueflow-reconcile/issueflow-reconcile.sh +++ b/actions/issueflow-reconcile/issueflow-reconcile.sh @@ -21,7 +21,7 @@ ISSUEFLOW_STALE_HOURS="${ISSUEFLOW_STALE_HOURS:-48}" } NOW="$ISSUEFLOW_NOW" STALE_AFTER=$((ISSUEFLOW_STALE_HOURS * 3600)) -QUEUE_LABELS=(ready claimed blocked) +QUEUE_LABELS=(ready claimed blocked post-merge) TRIAGE_ACTORS=() # 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" } +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/CROSSreference # A qualified reference belongs to another repository. Classify the whole # token before extracting numbers so rig#112 can never become local #112. @@ -262,6 +281,7 @@ 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="" decision="$(queue_decision <<<"$ISSUE_LABELS")" case "$decision" in ADD_NEEDS_TRIAGE) @@ -269,7 +289,7 @@ reconcile_issue() { log "#$n: needs-triage (no queue state)" ;; FLAG_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" return ;; esac @@ -277,46 +297,73 @@ reconcile_issue() { if has_issue_label claimed; then assignees="$(jq '.assignees | length' <<<"$ISSUE_JSON")" grep -qxF "$n" <<<"${OPEN_PR_ISSUES:-}" && open_pr=true - age="$(last_issue_activity "$n" "$(jq -r '.created_at' <<<"$ISSUE_JSON")")" - if [ "$(claim_clock_exempt <<<"$ISSUE_LABELS")" = EXEMPT ]; then - # Legitimately quiet work does not run the reclaim clock. Only the - # clock stops: an unassigned claim is still a repair the decision must - # see, so it runs on a zero age rather than being skipped. - decision="$(claim_decision "$assignees" "$open_pr" 0)" + grep -qxF "$n" <<<"${MERGED_REF_PR_ISSUES:-}" && merged_ref=true + unchecked="$(unchecked_criteria <<<"$(jq -r '.body // ""' <<<"$ISSUE_JSON")")" + if [ "$(post_merge_decision "$merged_ref" <<<"$unchecked")" = TRANSITION ]; then + ensure_comment "$n" post-merge-transition \ + "The Refs-linked PR merged with these acceptance criteria still unchecked: + +$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 - decision="$(claim_decision_at "$assignees" "$open_pr" "$age")" - fi - case "$decision" in - FLAG_UNASSIGNED) - ensure_comment "$n" claimed-unassigned \ - 'This issue is `claimed` but has no assignee. The sweep cannot infer an owner; triage must repair the claim.' ;; - RECLAIM) - # The last-activity epoch identifies a claim episode. A fixed marker - # hid the required comment when the same issue was later claimed and - # reclaimed again. - ensure_comment "$n" "$(claim_reclaim_marker "$age")" \ - 'This claim has no linked open PR and no activity for 48 hours. The sweep is reclaiming it for the ready queue.' - 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 ready >/dev/null - else - run gh issue edit "$n" -R "$REPO" --remove-label claimed --add-label ready >/dev/null - 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" + age="$(last_issue_activity "$n" "$(jq -r '.created_at' <<<"$ISSUE_JSON")")" + if [ "$(claim_clock_exempt <<<"$ISSUE_LABELS")" = EXEMPT ]; then + # Legitimately quiet work does not run the reclaim clock. Only the + # clock stops: an unassigned claim is still a repair the decision must + # see, so it runs on a zero age rather than being skipped. + decision="$(claim_decision "$assignees" "$open_pr" 0)" + else + decision="$(claim_decision_at "$assignees" "$open_pr" "$age")" + fi + case "$decision" in + FLAG_UNASSIGNED) + ensure_comment "$n" claimed-unassigned \ + 'This issue is `claimed` but has no assignee. The sweep cannot infer an owner; triage must repair the claim.' ;; + RECLAIM) + # The last-activity epoch identifies a claim episode. A fixed marker + # hid the required comment when the same issue was later claimed and + # reclaimed again. + ensure_comment "$n" "$(claim_reclaim_marker "$age")" \ + 'This claim has no linked open PR and no activity for 48 hours. The sweep is reclaiming it for the ready queue.' + 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 ready >/dev/null + else + run gh issue edit "$n" -R "$REPO" --remove-label claimed --add-label ready >/dev/null + 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 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 refs="$(blocked_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' \ | 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 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 8d867c6..9a89551 100644 --- a/test/issueflow-reconcile.test.sh +++ b/test/issueflow-reconcile.test.sh @@ -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 "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 "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. 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" 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 @@ -291,6 +309,34 @@ control="$(issue_probe 22 claimed)" check "the flag-free control is reclaimed (the clock still runs elsewhere)" 0 "" \ 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 '' "$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="$(issue_probe 25 $'claimed\noffsite')" 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 "" \ 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 # the arrival path still kills the run loudly. : >"$ARRIVAL/fixtures/repos_owner_repo_issues_91.json.error"