diff --git a/BUILDER.md b/BUILDER.md index 23534e6..ac9e0c3 100644 --- a/BUILDER.md +++ b/BUILDER.md @@ -141,7 +141,12 @@ triage bug, and the move is to say so on the issue, not to guess. merge — a live proof of a workflow trigger, a released-artifact check, anything whose subject does not exist until the change is on the base branch — the same-repo PR uses `Refs #N` instead, and triage closes the - issue by hand on the evidence, exactly as it does for cross-repo work. + issue by hand on the evidence, exactly as it does for cross-repo work. The + merge releases the claim: the issue moves to `post-merge`, the builder + walks away, and triage owns verification and closure. If evidence later + requires corrective build work, triage returns it to `ready` or mints a + fresh `ready` issue; any builder claims from current `main`, and the + original builder has no special standing. The issue body is what says so; you never judge which issues qualify, and absent that instruction `Closes #N` remains the default. The exception was bought the hard way: #143 carried `Closes #137` as doctrine then required, diff --git a/LABELS.md b/LABELS.md index 2db8ba5..6a5af1e 100644 --- a/LABELS.md +++ b/LABELS.md @@ -52,14 +52,32 @@ strips it on sight). | `ready` | `#0E8A16` | triaged, spec complete, unblocked — a builder can start now and succeed | triage | | `claimed` | `#1D76DB` | a builder owns it: assignee set, a draft PR expected shortly | the claiming builder | | `blocked` | `#6A737D` | waiting on another issue or PR (`Blocked by #N` in the body names it) | triage; anyone may correct it | +| `post-merge` | `#006B75` | the Refs-linked PR merged; post-merge acceptance criteria remain; the claim is released — nothing here is buildable and nobody owes a draft | the sweep or triage | | `epic` | `#5319E7` | organizes other issues via a dependency-ordered task list; **builders never pick an epic** | triage | The work-queue sweep enforces the invariant a board scan relies on: every open issue is either `needs-triage`, `epic`, or carries exactly one of `ready` / `claimed` / -`blocked`. It flags conflicts rather than guessing intent. A `claimed` issue +`blocked` / `post-merge`. It flags conflicts rather than guessing intent. A `claimed` issue with no open PR and no activity for 48 hours is reclaimed by the sweep: it comments, unassigns the stale owner, and restores `ready`. +When a merged PR references a `claimed` issue with `Refs #N` and unchecked +criteria remain, the sweep moves the issue to `post-merge`, clears the +assignee, and comments with the remaining criteria verbatim. The comment says +that the claim is released and that triage owes a follow-up naming the owner +and wake condition for completion. Triage writes that full transition comment +in the same tick when it or the operator makes the move by hand. The sweep +never reclaims `post-merge`: weeks of quiet can be the state working. + +`post-merge` never composes with `blocked`; the transition comment carries the +wait. It never composes with `attention`, because releasing the claim clears +the assignee and leaves nobody parked-for. An assigned `post-merge` issue is +flagged rather than repaired: a hand-assignment is intent. `needs-ruling` +still composes. When the remainder becomes buildable, triage moves +`post-merge` to `ready` or mints a fresh `ready` issue. Any builder may claim +that work from current `main`; the original builder has no special standing, +and re-entry does not set `attention`. + ## Cross-cutting (PRs and issues) | Label | Color | Meaning | @@ -155,7 +173,12 @@ unanswered `attention` is exactly the silence the 48-hour reclaim should take. It is hand-set doctrine only: nothing in `actions/` sets, clears, reads, or validates it, and no reconciler enforces the assignee requirement. An `attention` issue without an assignee is therefore a board bug, not a -demand; anyone may assign it or remove the flag. +demand; anyone may assign it or remove the flag. It never composes with +`post-merge`, whose released claim has no assignee to answer the demand. The +one machine-clear exception is the derived `claimed` → `post-merge` +transition: releasing the assignee clears a carried `attention` in the same +edit. A hand-created `post-merge` + `attention` composition is flagged, not +rewritten. The three signals are mutually distinct: `attention` means an assignee owes a move; `needs-ruling` means a human owes a decision under diff --git a/TRIAGE.md b/TRIAGE.md index 971641b..840c09a 100644 --- a/TRIAGE.md +++ b/TRIAGE.md @@ -96,7 +96,12 @@ Every issue you mint carries, in this order: rather than `Closes #N`. A criterion that survives the merge only if someone remembers to reopen the issue is an incomplete criterion — #137's amended body is the worked example, reopened by hand after `Closes #137` - closed it with the criterion unmet (#151). + closed it with the criterion unmet (#151). The merge moves the issue to + `post-merge` and releases the claim. The sweep writes the transition + comment when it derives the move; when triage or the operator moves it by + hand, triage writes the comment in the same tick. In either case triage + follows up with the remaining criteria, their owner, and the wake condition + for completion. - **Test plan**: what proves it, including the cases that must fail. - **Dependencies**: `Blocked by #N` / `Blocks #N`, and `Part of #E` when an epic organizes it. Name a cross-repo dependency the same way with its @@ -126,6 +131,11 @@ itself. Keep the checklist current — a stale epic misleads every scan. lands, and flags a blocked issue whose dependency declaration is unreadable. - The sweep reclaims abandoned claims after 48 hours: `claimed` + no open PR + no activity → comment, unassign, restore `ready`. +- `post-merge` is triage's completion queue, not a parked claim. Tick verified + criteria and close under the criterion's existing contract. If corrective + build work becomes necessary, move it to `ready` or mint a fresh `ready` + issue: any builder claims from current `main`, the original builder has no + special standing, and re-entry does not set `attention`. - Automation never guesses intent. Resolve the conflict comments it leaves on malformed queue states, and close or extend completed epics when nudged. - **Close obsolete issues** with the reason and a link to what obsoleted diff --git a/actions/issueflow-reconcile/issueflow-reconcile.sh b/actions/issueflow-reconcile/issueflow-reconcile.sh index fa30c6b..c5ab519 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,50 @@ 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 ' + { + 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:]]*([-*]|[0-9]+\.)[[:space:]]+\[[[:space:]]\]/ { + sub(/\r$/, "") + print + } + ' +} + +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 [ -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. @@ -216,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 @@ -262,6 +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_pr="" transition_marker="" transition_handled=false + local unchecked="" remove_claimed=claimed decision="$(queue_decision <<<"$ISSUE_LABELS")" case "$decision" in ADD_NEEDS_TRIAGE) @@ -269,7 +319,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 +327,83 @@ 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)" - else - decision="$(claim_decision_at "$assignees" "$open_pr" "$age")" + 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 - 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" + unchecked="$(unchecked_criteria <<<"$(jq -r '.body // ""' <<<"$ISSUE_JSON")")" + 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 + +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")" + # `attention` is a demand for the assigned builder. The derived + # transition releases that builder, so carrying the demand forward + # would create an impossible parked-for state (#175 D4). + has_issue_label attention && remove_claimed=claimed,attention + if [ -n "$owners" ]; then + run gh issue edit "$n" -R "$REPO" --remove-assignee "$owners" \ + --remove-label "$remove_claimed" --add-label post-merge >/dev/null + else + run gh issue edit "$n" -R "$REPO" \ + --remove-label "$remove_claimed" --add-label post-merge >/dev/null + fi + log "#$n: merged Refs PR -> post-merge; claim released" + else + 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 ] || has_issue_label attention; then + ensure_comment "$n" post-merge-assigned \ + 'This `post-merge` issue has an assignee or `attention`. The sweep will not undo hand-set intent; triage must clear the invalid composition or move the issue back into buildable queue state.' + log "#$n: assigned or attention-bearing 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 +493,22 @@ main() { } }' --jq '.data.repository.pullRequests.nodes[].closingIssuesReferences.nodes[].number' \ | sort -nu)" + 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 { number body } + pageInfo { hasNextPage endCursor } + } + } + }' --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/actions/labels-reconcile/labels-reconcile.sh b/actions/labels-reconcile/labels-reconcile.sh index 4d1ddc0..25b1827 100755 --- a/actions/labels-reconcile/labels-reconcile.sh +++ b/actions/labels-reconcile/labels-reconcile.sh @@ -472,6 +472,7 @@ release|0E8A16|Release flow and version/packaging work needs-triage|FBCA04|Did not come through triage — owes normalization or conversion to a discussion ready|0E8A16|Triaged, spec complete, unblocked — a builder can start now and succeed claimed|1D76DB|A builder owns it: assignee set, draft PR expected shortly +post-merge|006B75|Refs-linked PR merged; post-merge criteria remain and triage owns completion epic|5319E7|Organizes other issues via a dependency-ordered task list — builders never pick it EOF } diff --git a/changelog.d/175.md b/changelog.d/175.md new file mode 100644 index 0000000..2448c7b --- /dev/null +++ b/changelog.d/175.md @@ -0,0 +1 @@ +- Add `post-merge` issue state for merged `Refs` work awaiting triage-owned verification. diff --git a/test/issueflow-reconcile.test.sh b/test/issueflow-reconcile.test.sh index 8d867c6..d1a8f15 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,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 @@ -291,6 +315,108 @@ 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 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 +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" + +# -- non-triggers stay byte-for-byte outside the transition ------------------ +recent_timeline() { + jq -n --arg at "$(iso_at $((INOW - 60)))" \ + '[{"event":"assigned","created_at":$at}]' >"$(tfix "$1")" + printf '[]\n' >"$(cfix "$1")" +} +edit_count_before="$(wc -l <"$TMP/issue-edits")" +recent_timeline 38 +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 +check "...with no edit or comment" 0 "" \ + bash -c 'test "$1" -eq "$(wc -l <"$2")" && test ! -f "$3"' _ \ + "$edit_count_before" "$TMP/issue-edits" "$TMP/posted-38" + +recent_timeline 39 +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 +check "...with no edit or comment" 0 "" \ + bash -c 'test "$1" -eq "$(wc -l <"$2")" && test ! -f "$3"' _ \ + "$edit_count_before" "$TMP/issue-edits" "$TMP/posted-39" + +recent_timeline 40 +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 +check "...with no edit or comment" 0 "" \ + bash -c 'test "$1" -eq "$(wc -l <"$2")" && test ! -f "$3"' _ \ + "$edit_count_before" "$TMP/issue-edits" "$TMP/posted-40" + +printf '[]\n' >"$(cfix 41)" +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 "" \ + grep -qF '' "$TMP/posted-42" + # -- 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 +601,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":[{"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" +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" diff --git a/test/labels-reconcile.test.sh b/test/labels-reconcile.test.sh index 2c41628..ed3439c 100755 --- a/test/labels-reconcile.test.sh +++ b/test/labels-reconcile.test.sh @@ -77,6 +77,9 @@ expect "a 300-char reason passes through whole" 300 "${#exact_reason}" # -- a missing core taxonomy row is visible without mutating labels ---------- core_rows="$(core_label_rows)" core_names="$(cut -d'|' -f1 <<<"$core_rows")" +expect "post-merge core row is byte-exact" \ + "post-merge|006B75|Refs-linked PR merged; post-merge criteria remain and triage owns completion" \ + "$(grep '^post-merge|' <<<"$core_rows")" expect "a complete core taxonomy does not warn" "" \ "$(missing_core_labels_warning "$core_rows" "$core_names")" expect "one missing core label is named exactly" \