#!/usr/bin/env bash set -u ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" # shellcheck source=test/harness.sh source "$ROOT/test/harness.sh" # shellcheck source=actions/issueflow-reconcile/issueflow-reconcile.sh source "$ROOT/actions/issueflow-reconcile/issueflow-reconcile.sh" TMP="$(mktemp -d)" trap 'rm -rf "$TMP"' EXIT printf '%s\n' \ 'panel=one two' \ 'triage-actors=triage-one triage-two' \ 'scope:one|C5DEF5|First scope' >"$TMP/good.conf" check "triage actors parse beside panel and labels" 0 "" load_issueflow_config "$TMP/good.conf" load_issueflow_config "$TMP/good.conf" check "triage actor is recognized" 0 "" is_triage_actor triage-two check "non-triage actor is rejected" 1 "" is_triage_actor builder printf '%s\n' 'panel=one' >"$TMP/missing.conf" check "missing triage actors fails loudly" 1 "missing triage-actors=" load_issueflow_config "$TMP/missing.conf" printf '%s\n' 'triage-actors=one' 'triage-actors=two' >"$TMP/duplicate.conf" check "duplicate triage actors fails loudly" 1 "duplicate triage-actors" load_issueflow_config "$TMP/duplicate.conf" # A panel[]= row (#224 D8) must not take the issue board down: this # loader ignores every line that is not triage-actors=. That tolerance was # incidental; this row makes it deliberate, so a future tightening cannot # break the sweep as a side effect. printf '%s\n' \ 'panel=one two' \ 'panel[builder-z]=two' \ 'triage-actors=triage-one' >"$TMP/bracketed.conf" check "a per-author panel row is tolerated by the issue-flow loader" 0 "" \ load_issueflow_config "$TMP/bracketed.conf" # The dogfood caller and reusable workflow must expose the same runtime facts # as the documented consumer stub. Static pins catch YAML blocks drifting to # the adjacent composite step, which otherwise fails only after merge. check "dogfood caller wakes on issue events" 0 " issues:" \ grep -F " issues:" "$ROOT/.github/workflows/self-labels.yml" dogfood_pr_step="$(sed -n \ '/name: reconcile state + stale (dogfood/,/name: reconcile issue flow/p' \ "$ROOT/.github/workflows/labels-sweep.yml")" # shellcheck disable=SC2016 # GitHub expressions are asserted as literals check "dogfood PR reconcile receives repository" 0 ' REPO: ${{ github.repository }}' \ grep -F ' REPO: ${{ github.repository }}' <<<"$dogfood_pr_step" # shellcheck disable=SC2016 # GitHub expressions are asserted as literals check "dogfood PR reconcile receives token" 0 ' GH_TOKEN: ${{ github.token }}' \ grep -F ' GH_TOKEN: ${{ github.token }}' <<<"$dogfood_pr_step" # Invariant 1: exactly one queue category. 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 check "unassigned claim is flagged" 0 "FLAG_UNASSIGNED" claim_decision 0 false 60 check "quiet unassigned claim is also reclaimed" 0 "RECLAIM" claim_decision 0 false $((STALE_AFTER + 1)) # shellcheck disable=SC2016 # expansions belong to the isolated bash -c process check "injected clock: below stale boundary stays claimed" 0 "KEEP" \ bash -c 'ISSUEFLOW_NOW=100000 ISSUEFLOW_STALE_HOURS=1 source "$1"; claim_decision_at 1 false 96401' _ \ "$ROOT/actions/issueflow-reconcile/issueflow-reconcile.sh" # shellcheck disable=SC2016 # expansions belong to the isolated bash -c process check "injected clock: exact stale boundary stays claimed" 0 "KEEP" \ bash -c 'ISSUEFLOW_NOW=100000 ISSUEFLOW_STALE_HOURS=1 source "$1"; claim_decision_at 1 false 96400' _ \ "$ROOT/actions/issueflow-reconcile/issueflow-reconcile.sh" # shellcheck disable=SC2016 # expansions belong to the isolated bash -c process check "injected clock: past stale boundary is reclaimed" 0 "RECLAIM" \ bash -c 'ISSUEFLOW_NOW=100000 ISSUEFLOW_STALE_HOURS=1 source "$1"; claim_decision_at 1 false 96399' _ \ "$ROOT/actions/issueflow-reconcile/issueflow-reconcile.sh" # shellcheck disable=SC2016 # expansion belongs to the isolated bash -c process check "invalid injected clock fails loudly" 1 "ISSUEFLOW_NOW must be UTC epoch seconds" \ bash -c 'ISSUEFLOW_NOW=garbage source "$1"' _ \ "$ROOT/actions/issueflow-reconcile/issueflow-reconcile.sh" check "reclaim marker is stable within a claim episode" 0 "claim-reclaimed-96399" \ claim_reclaim_marker 96399 check "a later claim episode receives a new reclaim marker" 0 "claim-reclaimed-99999" \ claim_reclaim_marker 99999 check "offsite exempts the claim clock" 0 "EXEMPT" claim_clock_exempt <<<"offsite" check "needs-ruling still exempts through the shared gate" 0 "EXEMPT" \ claim_clock_exempt <<<"needs-ruling" check "both quiet flags still produce one exemption verdict" 0 "EXEMPT" \ claim_clock_exempt <<< $'offsite\nneeds-ruling' check "blocked does not exempt a claimed issue" 0 "SWEEP" claim_clock_exempt <<<"blocked" 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 >"$GH_COMMENTS"; exit; fi' \ ' shift' \ ' done' \ 'fi' >"$TMP/gh" chmod +x "$TMP/gh" : >"$TMP/comments" # shellcheck disable=SC2016 # expansions belong to the isolated bash -c process check "cross-repo warning is idempotent across two sweeps" 0 "" \ env PATH="$TMP:$PATH" GH_COMMENTS="$TMP/comments" bash -c \ 'source "$1"; REPO=heavy-duty/ceremony ensure_comment 99 blocked-cross-repo "cross-repo warning" ensure_comment 99 blocked-cross-repo "cross-repo warning" test "$(grep -cF "" "$GH_COMMENTS")" -eq 1' \ _ "$ROOT/actions/issueflow-reconcile/issueflow-reconcile.sh" # Invariant 4: only configured triage actors mint directly into the queue. check "triage-authored ready issue is accepted" 0 "KEEP" author_decision true <<<"ready" check "outside author receives needs-triage" 0 "ADD_NEEDS_TRIAGE" author_decision false <<<"ready" check "outside author already marked needs-triage is stable" 0 "KEEP" author_decision false <<<"needs-triage" check "later sweep accepts a normalized outside-authored issue" 0 "KEEP" queue_decision <<<"ready" # Invariant 5: completed epics get one nudge; incomplete/unparseable do not. epic_refs="$(epic_references <<< $'## Definition of done\n- [ ] outside #8\n\n## Task list\n- [ ] #3 first\n- [x] #2 done\nplain #9')" check "epic parser reads task-list refs only" 0 $'2\n3' printf '%s\n' "$epic_refs" body=$'## Task list\n- [x] #2 done\n- [x] #3 done\n\n## Definition of done\n- [ ] open issue #99 must not suppress the nudge' check "epic parser stops before later checkbox sections" 0 "" test \ "$(epic_references <<<"$body")" = $'2\n3' # shellcheck disable=SC2016 # backticks and ${{ }}-shaped prose are fixture literals body=$'## Task list\n\n- [x] #2 Scaffold: layout, test harness, shellcheck + actionlint CI\n- [x] #3 `lib/version.sh` — one version abstraction, two backends\n- [x] #4 `lib/changelog.sh` — the one canonical section extractor\n- [x] #5 `actions/changelog-armed` — the version-keyed arming guard\n- [x] #6 `actions/changelog-monotonic` — shipped release headings are append-only\n- [x] #7 `actions/drill-recorded` — a release carries its evidence\n- [x] #8 `lib/decide.sh` — the merge door'\''s decision, pure\n- [x] #9 The reusable release workflow — both doors, one implementation\n- [x] #10 Labels machinery: reusable workflow + core/scope split\n- [x] #11 Dogfood: ceremony releases itself (0.1.0) — **shipped: tag `0.1.0`, release, `drills/0.1.0.md`; main re-armed at `0.1.1-dev`**\n- [x] #12 `docs/CONSUMERS.md` + README doctrine\n- [ ] #13 Convert rig (pilot) — **PR [rig#112](https://github.com/heavy-duty/rig/pull/112) is approved by the whole panel on head `3c72c1b` and sits at `state:needs-human` since 2026-07-23 10:47Z; the merge is the human'\''s. #14/#15 unblock when it lands**\n- [ ] #14 Convert box\n- [ ] #15 Convert cast (artifact hook debut)\n- [ ] #16 Adopt in incubator (greenfield consumer)\n\nAdjacent, same repo, separable from the release chain: the **agent team flow** (discussion → triage → issue → build → review → human merge) landed as doctrine in PR #17 (CONTRIBUTING.md, LABELS.md, TRIAGE.md, BUILDER.md, REVIEWER.md); #10'\''s bootstrap carries its labels, #12'\''s guide carries its adoption checklist. Consumption is split by what has a runtime: **machinery by reference** (GitHub materializes pinned workflows/actions at run time), **doctrine as a machine-verified mirror** (`.ceremony/` in each consumer, byte-identical to the pin, CI-guarded — agents read rules from the checkout, never cross-repo):\n\n- [x] #18 Issue-flow reconciliation — the work-queue sweep — **shipped 2026-07-23** in #32 (`66f1c08`)\n- [ ] #61 issueflow-reconcile — cross-repo references must not be read as local issue numbers (found in triage hygiene against the live corpus after #18 shipped; it is why this epic cannot currently be nudged complete)\n- [x] #19 actions/docs-sync — the vendored-doctrine mirror + guard\n- [x] #24 Entry templates — the pipeline'\''s doors made mechanical (from discussion #23)\n- [ ] #50 `needs-ruling` — the pending-human-decision flag (its own epic; from discussion #30)\n- [ ] #56 Fleet scope and cross-repo discovery — the two guards, the runner hole, the roster question (its own epic; from discussion #55, filed at @danmt'\''s request). Children: #57 (BUILDER/REVIEWER/FLEET discovery guards), #58 (`actions/runner-isolated`). Added to this list by triage 2026-07-23 — it is agent-team-flow work like #50, so a scan of this epic must see it.' check "real epic 1 task list drops rig PR and retains local references" 0 \ $'2\n3\n4\n5\n6\n7\n8\n9\n10\n11\n12\n13\n14\n15\n16\n18\n19\n23\n24\n30\n32\n50\n55\n56\n57\n58\n61' \ epic_references <<<"$body" check "completed epic is nudged" 0 "NUDGE" epic_decision "$epic_refs" $'CLOSED\nCLOSED' check "open epic child suppresses nudge" 0 "KEEP" epic_decision "$epic_refs" $'CLOSED\nOPEN' check "epic without parseable children is stable" 0 "KEEP" epic_decision "" "" # Invariant 1 keeps ignoring the ruling flag (#50 D8): it composes with the # queue labels and is not one of them. check "claimed plus a pending ruling is a healthy issue" 0 "KEEP" \ queue_decision <<< $'claimed\nneeds-ruling' check "a ruling flag alone is still invariant 1's violation" 0 "ADD_NEEDS_TRIAGE" \ queue_decision <<< $'needs-ruling' check "claimed plus offsite is a healthy issue" 0 "KEEP" \ queue_decision <<< $'claimed\noffsite' check "offsite alone still needs triage" 0 "ADD_NEEDS_TRIAGE" \ queue_decision <<<"offsite" check "claimed plus attention is a healthy issue" 0 "KEEP" \ queue_decision <<< $'claimed\nattention' # --------------------------------------------------------------------------- # The ruling pass on the issue surface (#52), against a recording stub: the # reclaim clock stops under a pending ruling, an applied stale heals off, # label churn is not activity, the nudge resets on its own comment, and no # edit anywhere names the flag (#50 D9). The stub serves fixture JSON per # endpoint with the caller's --jq applied by real jq, appends posted comments # back into the fixture (a second sweep sees the first one's writes), and # records every label edit. # --------------------------------------------------------------------------- INOW=2000000000 iso_at() { date -u -d "@$1" +%Y-%m-%dT%H:%M:%SZ; } # gh's own rendering of a 5xx whose body carries a `message` key — the line # crew#329's job log carried, verbatim (#247), and the payload beside it. GH_STUB_STDERR="gh: We couldn't respond to your request in time. (HTTP 504)" GH_STUB_ERROR_BODY='{"message":"We could not respond to your request in time.","documentation_url":"https://docs.github.com/rest"}' export GH_STUB_STDERR # the PATH-stubbed gh of the executable runs reads it too issue_stub_gh() { if [ "$1" = api ]; then shift local jqexpr="" endpoint="" file while [ $# -gt 0 ]; do case "$1" in --jq) jqexpr="$2"; shift ;; -*) ;; *) [ -n "$endpoint" ] || endpoint="$1" ;; esac shift done file="$TMP/$(printf '%s' "$endpoint" | tr '/' '_').json" printf '%s\n' "$endpoint" >>"$TMP/api-calls" # A `.http-error` sentinel is the real 5xx (#247): `gh api` prints the # response body — GitHub's JSON error object — to STDOUT, says why on # stderr, and exits non-zero. The `.error` sentinel models a failure with # no payload, which is the *safe* path (an empty label set is empty either # way), and is why this class was never caught. Both now speak on stderr, # because the real gh always does and the reason line renders it. if [ -f "$file.http-error" ]; then # A --jq call gets the filter applied to the error body, as gh does. # That is what "yields no timestamps" looks like — the shape that let # last_issue_activity fall back to created_at and reclaim a live claim. if [ -n "$jqexpr" ]; then jq -r "$jqexpr" "$file.http-error" 2>/dev/null || true else cat "$file.http-error" fi printf '%s\n' "$GH_STUB_STDERR" >&2 return 1 fi [ ! -f "$file.error" ] || { printf '%s\n' "$GH_STUB_STDERR" >&2; return 1; } [ -f "$file" ] || { printf '[]\n'; return 0; } if [ -n "$jqexpr" ]; then jq -r "$jqexpr" "$file"; else cat "$file"; fi elif [ "$1" = issue ] && [ "$2" = comment ]; then local n="$3" body="" file shift 3 while [ $# -gt 0 ]; do case "$1" in --body) body="$2"; shift ;; esac shift done printf '%s\n----\n' "$body" >>"$TMP/posted-$n" file="$TMP/repos_owner_repo_issues_${n}_comments.json" [ -f "$file" ] || printf '[]\n' >"$file" jq --arg b "$body" --arg at "$(iso_at "$INOW")" \ '. + [{"user":{"login":"sweep-bot"},"created_at":$at,"html_url":"https://x/posted","body":$b}]' \ "$file" >"$file.tmp" && mv "$file.tmp" "$file" elif [ "$1" = issue ] && [ "$2" = edit ]; then printf '%s\n' "$*" >>"$TMP/issue-edits" fi } issue_probe() { # $1 issue, $2 labels, $3 assignees, $4 false|closing|refs, $5 merged PR specs, $6 body ( local assignees="${3:-1}" open_pr="${4:-false}" merged_ref_prs="${5:-}" local body="${6:-}" assignee_json='[]' open_pr_records="" spec pr merged_at [ "$assignees" -eq 0 ] || assignee_json='[{"login":"owner-bot"}]' REPO=owner/repo NOW="$INOW" ISSUE_LABELS="$2" ISSUE_JSON="$(jq -n --arg at "$(iso_at $((INOW - 10 * 86400)))" \ --argjson assignees "$assignee_json" --arg body "$body" \ '{created_at: $at, assignees: $assignees, body: $body}')" case "$open_pr" in true|closing) open_pr_records="$(printf 'CLOSING\t%s\n' "$1")" ;; refs|draft-refs) open_pr_records="$(printf 'BODY\tRefs #%s\n' "$1")" ;; esac OPEN_PR_ISSUES="$(open_pr_issues <<<"$open_pr_records")" # Records are ISSUEPRMERGED_AT (#242). A spec is `PR` or # `PR@`; the bare form takes a fixed hour-old merge, which is every # probe that does not care about merge order. An empty list is no record # at all, so the no-merged-PR probes read exactly as they did. MERGED_REF_PR_RECORDS="$( # shellcheck disable=SC2086 # the spec list is deliberately word-split for spec in $merged_ref_prs; do pr="${spec%%@*}" merged_at="${spec#*@}" [ "$merged_at" != "$spec" ] || merged_at="$(iso_at $((INOW - 3600)))" printf '%s\t%s\t%s\n' "$1" "$pr" "$merged_at" done)" run() { "$@"; } gh() { issue_stub_gh "$@"; } reconcile_issue "$1" 2>&1 ) } tfix() { printf '%s/repos_owner_repo_issues_%s_timeline.json' "$TMP" "$1"; } cfix() { printf '%s/repos_owner_repo_issues_%s_comments.json' "$TMP" "$1"; } # -- the reclaim clock stops under a pending ruling (48h quiet, no PR) ------- jq -n --arg l "$(iso_at $((INOW - 10 * 86400)))" \ '[{"event":"labeled","label":{"name":"needs-ruling"},"actor":{"login":"setter"},"created_at":$l}, {"event":"assigned","created_at":$l}]' >"$(tfix 21)" # The escalation is conforming and the rung markers are pre-seeded — by 10 # days in both rungs fired long ago (#73), so this probe observes the nudge # wiring alone; shape and rung behavior have their own probes in # test/ruling.test.sh. jq -n --arg at "$(iso_at $((INOW - 10 * 86400 - 60)))" \ --arg b $'Options: A — x B — y\nRecommend: A, because x.\nBlocked: z\nDefault: none — hard block' \ --arg r12 "$(iso_at $((INOW - 10 * 86400 + 13 * 3600)))" \ --arg r24 "$(iso_at $((INOW - 10 * 86400 + 25 * 3600)))" \ '[{"user":{"login":"setter"},"created_at":$at,"html_url":"https://x/esc21","body":$b}, {"user":{"login":"sweep-bot"},"created_at":$r12,"html_url":"https://x/r12","body":"\nrung"}, {"user":{"login":"sweep-bot"},"created_at":$r24,"html_url":"https://x/r24","body":"\nrung"}]' \ >"$(cfix 21)" exempt="$(issue_probe 21 $'claimed\nneeds-ruling')" check "a 10-day-quiet claim under a ruling is not reclaimed" 1 "" \ grep -q 'reclaimed' <<<"$exempt" check "...the same silence still nudges the pending ruling" 0 "" \ grep -q 'ruling nudge' <<<"$exempt" # shellcheck disable=SC2016 # expansions belong to the isolated bash -c process check "...and the nudge went to the decider with the escalation linked" 0 "" \ bash -c 'grep -qF "@danmt" "$1" && grep -qF "https://x/esc21" "$1"' _ "$TMP/posted-21" again="$(issue_probe 21 $'claimed\nneeds-ruling')" check "the sweep right after the nudge holds its silence" 1 "" \ grep -q 'ruling nudge' <<<"$again" check "exactly one nudge across both sweeps" 0 "1" \ grep -c -- '^----$' "$TMP/posted-21" # -- control: the same silence without the flag is reclaimed ----------------- jq -n --arg l "$(iso_at $((INOW - 10 * 86400)))" \ '[{"event":"assigned","created_at":$l}]' >"$(tfix 22)" printf '[]\n' >"$(cfix 22)" 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 refs 380 '- [ ] verify after merge')" check "issue_probe: 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 46 open_closing="$(issue_probe 46 claimed 1 closing 460 '- [ ] verify after merge')" check "issue_probe: closing-linked open PR remains the unchanged control" 0 "" \ test -z "$open_closing" 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" # End to end on the crew#321 shape: the later merge is the *lower*-numbered # PR, and its marker is already on the issue. Selecting by number would find # no marker for #461, fire the transition a second time, and release a claim # the board already released (#242). recent_timeline 46 jq -n --arg b '' \ --arg at "$(iso_at $((INOW - 60)))" \ '[{"body":$b,"created_at":$at}]' >"$(cfix 46)" spent_edit_count="$(wc -l <"$TMP/issue-edits")" spent="$(issue_probe 46 claimed 1 false \ "461@$(iso_at $((INOW - 7200))) 460@$(iso_at $((INOW - 3600)))" \ '- [ ] verify after merge')" check "the marker of the later-merged lower-numbered PR is the one read" 0 "" \ test -z "$spent" # shellcheck disable=SC2016 # positional parameters belong to bash -c check "...so the spent transition is not fired a second time" 0 "" \ bash -c 'test "$1" -eq "$(wc -l <"$2")" && test ! -f "$3"' _ \ "$spent_edit_count" "$TMP/issue-edits" "$TMP/posted-46" 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 "" \ grep -q 'reclaimed' <<<"$offsite" issue_probe 26 $'claimed\noffsite' 0 >/dev/null check "an unassigned offsite claim is still flagged" 0 "" \ grep -q 'issueflow:claimed-unassigned' "$TMP/posted-26" offsite_open="$(issue_probe 27 $'claimed\noffsite' 1 true)" check "an offsite claim with an open PR stays claimed" 1 "" \ grep -q 'reclaimed' <<<"$offsite_open" offsite_both="$(issue_probe 28 $'claimed\noffsite\nneeds-ruling')" check "offsite plus needs-ruling stays claimed" 1 "" \ 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 '' "$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" # Both checks above still hold, and #247 D1 changed what reaches them: # last_issue_activity reads the same timeline endpoint, so the issue is now # skipped before the offsite verification runs. The skip is why nothing is # posted, and its reason line is a deliberate report rather than an alarm # (D4). D8 leaves offsite_timeline's own silence alone, so it is pinned here # directly rather than through a probe that can no longer reach it. offsite_timeline_probe() { ( REPO=owner/repo; gh() { issue_stub_gh "$@"; }; offsite_timeline "$1" ); } check "an unreadable offsite timeline yields nothing and still fails closed" 1 "" \ offsite_timeline_probe 32 check "...while a readable one answers its payload" 0 "[]" offsite_timeline_probe 31 : >"$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" \ claim_decision 1 false 3600 check "no reconciler mutation names offsite (#68 D4)" 1 "" \ grep -E 'gh (issue|pr) edit.*offsite' \ "$ROOT/actions/issueflow-reconcile/issueflow-reconcile.sh" \ "$ROOT/actions/labels-reconcile/labels-reconcile.sh" # -- an already-applied stale heals off, and no edit names the flag ---------- jq -n --arg l "$(iso_at $((INOW - 3600)))" \ '[{"event":"labeled","label":{"name":"needs-ruling"},"actor":{"login":"setter"},"created_at":$l}]' >"$(tfix 23)" jq -n --arg at "$(iso_at $((INOW - 3660)))" \ '[{"user":{"login":"setter"},"created_at":$at,"html_url":"https://x/esc23","body":"question, options, recommendation"}]' \ >"$(cfix 23)" healed="$(issue_probe 23 $'claimed\nneeds-ruling\nstale')" check "an applied stale comes off under a pending ruling" 0 "" \ grep -q 'unstale (a ruling is pending)' <<<"$healed" check "...via an edit that removes exactly stale" 0 "" \ grep -q -- '--remove-label stale' "$TMP/issue-edits" check "no issue edit across every probe names the ruling flag (#50 D9)" 1 "" \ grep -q 'needs-ruling' "$TMP/issue-edits" # -- label churn is not activity: the nudge clock reads comments, not labels -- jq -n --arg flag "$(iso_at $((INOW - 8 * 86400)))" \ --arg churn "$(iso_at $((INOW - 2 * 86400)))" \ --arg assigned "$(iso_at $((INOW - 9 * 86400)))" \ '[{"event":"labeled","label":{"name":"needs-ruling"},"actor":{"login":"setter"},"created_at":$flag}, {"event":"labeled","label":{"name":"priority"},"actor":{"login":"anyone"},"created_at":$churn}, {"event":"assigned","created_at":$assigned}]' >"$(tfix 24)" jq -n --arg at "$(iso_at $((INOW - 8 * 86400 - 60)))" \ '[{"user":{"login":"setter"},"created_at":$at,"html_url":"https://x/esc24","body":"question, options, recommendation"}]' \ >"$(cfix 24)" churn_last="$( (REPO=owner/repo; gh() { issue_stub_gh "$@"; } last_issue_activity 24 "$(iso_at $((INOW - 10 * 86400)))") )" check "last activity ignores the 2-day-old label churn" 0 "" \ test "$churn_last" = "$((INOW - 8 * 86400 - 60))" churned="$(issue_probe 24 $'claimed\nneeds-ruling')" check "8 real-quiet days nudge through a 2-day-old label churn" 0 "" \ grep -q 'ruling nudge' <<<"$churned" # --------------------------------------------------------------------------- # An unreadable fact invents no verdict on the issue surface either (#247). # `gh api` prints a 5xx body to stdout AND exits non-zero, and GitHub's 5xx # body is a JSON object — so the payload that reached the guards was valid # JSON, `.labels[]` came back empty, and queue_decision was handed the wrong # input. The pure guards first, then the two decisions the fall-through # reached. # --------------------------------------------------------------------------- payload_refused() { ! issue_payload_valid "$@"; } # 0 when the payload is refused check "a healthy issue payload is accepted" 0 "" \ issue_payload_valid 40 <<<'{"number":40,"labels":[{"name":"ready"}]}' check "an issue carrying no labels at all is still a valid payload" 0 "" \ issue_payload_valid 40 <<<'{"number":40,"labels":[]}' # The reported shape: gh renders `gh: (HTTP 504)` from a body with a # `message` key, which proves the body was valid JSON. The status check is what # catches this one; the shape check refuses it independently. check "a JSON error object is not an issue payload" 0 "" \ payload_refused 40 <<<"$GH_STUB_ERROR_BODY" # The live path a status check alone would leave open (D3): 200, exit 0, and # `.labels[]` empties exactly as it does on the 504. check "an HTTP 200 whose body is null is refused" 0 "" payload_refused 40 <<<'null' check "a payload missing .labels is refused" 0 "" \ payload_refused 40 <<<'{"number":40}' check "a payload whose .labels is not an array is refused" 0 "" \ payload_refused 40 <<<'{"number":40,"labels":"ready"}' check "a payload about a different issue is refused" 0 "" \ payload_refused 40 <<<'{"number":41,"labels":[]}' check "a payload that is not JSON at all is refused" 0 "" \ payload_refused 40 <<<'not json' check "an empty payload is refused" 0 "" payload_refused 40 "$(cfix 50)" printf '%s\n' "$GH_STUB_ERROR_BODY" >"$(cfix 50).http-error" jq -n --arg at "$(iso_at $((INOW - 10 * 86400)))" \ '[{"event":"assigned","created_at":$at}]' >"$(tfix 50)" claim_edits_before="$(wc -l <"$TMP/issue-edits")" check "a 504 on the comments read skips the issue instead of grading its age" \ 3 "#50: skipped this pass — could not read its activity history: $GH_STUB_STDERR" \ issue_probe 50 claimed 1 check "...so the live claim is not reclaimed" 1 "" \ grep -q 'stale claim reclaimed -> ready' <<<"$(issue_probe 50 claimed 1)" # shellcheck disable=SC2016 # positional parameters belong to bash -c check "...no unassign, no label swap, and no reclaim comment" 0 "" \ bash -c 'test "$1" -eq "$(wc -l <"$2")" && test ! -f "$3"' _ \ "$claim_edits_before" "$TMP/issue-edits" "$TMP/posted-50" # -- the suppressed comment: a 504 on the marker read ----------------------- # The marker is on the issue. Read as "no marker", a failed read re-posts the # comment the marker exists to suppress — every sweep, forever. jq -n --arg b '' \ --arg at "$(iso_at $((INOW - 3600)))" \ '[{"user":{"login":"sweep-bot"},"created_at":$at,"html_url":"https://x/m","body":$b}]' \ >"$(cfix 51)" printf '%s\n' "$GH_STUB_ERROR_BODY" >"$(cfix 51).http-error" check "a 504 on the marker read skips rather than reading it as no marker" \ 3 "#51: skipped this pass — could not read its comments: $GH_STUB_STDERR" \ issue_probe 51 blocked 1 false "" "no parseable declaration here" check "...so no duplicate comment is posted" 1 "" test -f "$TMP/posted-51" # -- a deliberate skip is counted; a genuine crash is still named (D4) ------- printf '%s\n' '{"number":60,"labels":[{"name":"ready"}],"assignees":[]}' \ >"$TMP/repos_owner_repo_issues_60.json" printf '%s\n' '{"number":61,"labels":[{"name":"ready"}],"assignees":[]}' \ >"$TMP/repos_owner_repo_issues_61.json" printf '%s\n' "$GH_STUB_ERROR_BODY" >"$TMP/repos_owner_repo_issues_61.json.http-error" pass_probe() { # $1 issue; $2 non-empty makes reconcile_issue crash ( REPO=owner/repo gh() { issue_stub_gh "$@"; } [ -z "${2:-}" ] || reconcile_issue() { return 9; } SKIPPED_COUNT=0 SKIPPED_ISSUES="" reconcile_issue_pass "$1" printf 'rc=%s count=%s issues=%s\n' "$?" "$SKIPPED_COUNT" "$SKIPPED_ISSUES" ) } check "a genuine non-read crash still names the failure byte-identically" 0 \ "issueflow: #60: reconcile failed — continuing with the remaining issues" \ pass_probe 60 crash check "...and the pass still returns 0, so the loop reaches the next issue" 0 \ "rc=0" pass_probe 60 crash check "...and a crash is not counted as a skip" 0 "count=0" pass_probe 60 crash check "a skipped issue is counted and named" 0 "count=1 issues=#61" pass_probe 61 check "...and is not also reported as a crash" 1 "" \ grep -q 'reconcile failed' <<<"$(pass_probe 61)" check "...leaving the loop free to continue" 0 "rc=0" pass_probe 61 # --------------------------------------------------------------------------- # The arrival path, executed the way the action executes it (#91): four # triage-authored mints died silently because the stand-down `return`s in # reconcile_opened_issue carried the failed test's status into `set -e`. A # sourced test takes the `set -u`-only branch and is blind to that class of # bug by construction, so these run the script as a subprocess behind a # PATH-stubbed gh — the house pattern from test/release-chain.test.sh. # --------------------------------------------------------------------------- ARRIVAL="$TMP/arrival" mkdir -p "$ARRIVAL/stub" "$ARRIVAL/fixtures" printf 'triage-actors=triage-one triage-two\n' >"$ARRIVAL/labels.conf" cat >"$ARRIVAL/stub/gh" <<'EOF' #!/usr/bin/env bash # Endpoints map to files under $GH_FIXTURES ('/?&=' -> '_'); an absent file # answers an empty list, a .error sentinel fails the call like a dead API. if [ "$1" = api ]; then shift endpoint="" jqexpr="" query="" while [ $# -gt 0 ]; do case "$1" in --jq) jqexpr="$2"; shift ;; -f|-F) case "$2" in query=*) query="${2#query=}" ;; esac shift ;; -*) ;; *) [ -n "$endpoint" ] || endpoint="$1" ;; esac shift done file="$GH_FIXTURES/$(printf '%s' "$endpoint" | tr '/?&=' '____').json" if [ "$endpoint" = graphql ]; then case "$query" in *'states: OPEN'*) file="$GH_FIXTURES/graphql-open.json" ;; *'states: MERGED'*) file="$GH_FIXTURES/graphql-merged.json" ;; esac fi # `.http-error` is the real 5xx (#247): the response body — GitHub's JSON # error object — goes to STDOUT, the reason to stderr, and the status is # non-zero. `.error` is the payload-free failure, which is the safe path. if [ -f "$file.http-error" ]; then if [ -n "$jqexpr" ]; then jq -r "$jqexpr" "$file.http-error" 2>/dev/null || true else cat "$file.http-error" fi printf '%s\n' "${GH_STUB_STDERR:-}" >&2 exit 1 fi [ ! -f "$file.error" ] || { printf '%s\n' "${GH_STUB_STDERR:-}" >&2; exit 1; } if [ -f "$file" ]; then payload="$(cat "$file")"; else payload='[]'; fi if [ -n "$jqexpr" ]; then jq -r "$jqexpr" <<<"$payload"; else printf '%s\n' "$payload"; fi exit 0 fi if [ "$1" = issue ]; then printf '%s\n' "$*" >>"$GH_FIXTURES/edits"; exit 0; fi echo "gh stub: unexpected call: gh $*" >&2 exit 97 EOF chmod +x "$ARRIVAL/stub/gh" printf '%s\n' \ '{"data":{"repository":{"pullRequests":{"nodes":[],"pageInfo":{"hasNextPage":false,"endCursor":null}}}}}' \ >"$ARRIVAL/fixtures/graphql-open.json" cp "$ARRIVAL/fixtures/graphql-open.json" "$ARRIVAL/fixtures/graphql-merged.json" arrival_fixture() { printf '%s\n' "$1" >"$ARRIVAL/fixtures/repos_owner_repo_issues_91.json"; } arrival_run() { : >"$ARRIVAL/fixtures/edits" env PATH="$ARRIVAL/stub:$PATH" GH_FIXTURES="$ARRIVAL/fixtures" \ REPO=owner/repo LABELS_CONF="$ARRIVAL/labels.conf" \ EVENT_NAME=issues EVENT_ACTION=opened EVENT_ISSUE=91 \ bash "$ROOT/actions/issueflow-reconcile/issueflow-reconcile.sh" } arrival_fixture '{"user":{"login":"triage-one"},"labels":[{"name":"ready"}]}' triage_out="$(arrival_run 2>&1)" triage_rc=$? check "a triage-authored arrival exits 0 (#91's four dead mints)" 0 "" \ test "$triage_rc" -eq 0 check "...and its output reaches the sweep" 0 "" \ grep -qF 'issueflow: reconciled.' <<<"$triage_out" check "...and mints nothing" 1 "" test -s "$ARRIVAL/fixtures/edits" arrival_fixture '{"user":{"login":"outsider"},"labels":[{"name":"ready"}]}' outside_out="$(arrival_run 2>&1)" outside_rc=$? check "an outside-authored arrival exits 0" 0 "" test "$outside_rc" -eq 0 check "...still mints needs-triage" 0 "" \ grep -qF 'needs-triage (opened by outsider)' <<<"$outside_out" check "...still strips the smuggled queue label" 0 "" \ grep -qxF 'issue edit 91 -R owner/repo --add-label needs-triage --remove-label ready' \ "$ARRIVAL/fixtures/edits" check "...and the sweep still runs after the mint" 0 "" \ grep -qF 'issueflow: reconciled.' <<<"$outside_out" arrival_fixture '{"user":{"login":"outsider"},"labels":[],"pull_request":{"url":"x"}}' pr_out="$(arrival_run 2>&1)" pr_rc=$? check "a PR arrival exits 0" 0 "" test "$pr_rc" -eq 0 check "...stands down without minting" 1 "" test -s "$ARRIVAL/fixtures/edits" check "...and the sweep still runs" 0 "" \ grep -qF 'issueflow: reconciled.' <<<"$pr_out" # Exercise both directions through main(): a merged-Refs transition still # fires without a linked open PR, then the open-body gather suppresses it. # A sourced decision probe cannot exercise the GraphQL gather and loop # (#91's lesson). printf '%s\n' \ '{"data":{"repository":{"pullRequests":{"nodes":[],"pageInfo":{"hasNextPage":false,"endCursor":null}}}}}' \ >"$ARRIVAL/fixtures/graphql-open.json" printf '%s\n' \ '{"data":{"repository":{"pullRequests":{"nodes":[{"number":400,"mergedAt":"2026-07-30T19:05:16Z","body":"Refs #40","closingIssuesReferences":{"nodes":[]}}],"pageInfo":{"hasNextPage":false,"endCursor":null}}}}}' \ >"$ARRIVAL/fixtures/graphql-merged.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" transition_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 )" transition_rc=$? check "an executable sweep with no linked open PR exits 0" 0 "" \ test "$transition_rc" -eq 0 check "...reaches the transition through GraphQL and the issue loop" 0 "" \ grep -qF '#40: merged Refs PR -> post-merge; claim released' <<<"$transition_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" printf '%s\n' \ '{"data":{"repository":{"pullRequests":{"nodes":[{"number":401,"body":"Refs #40","isDraft":false,"closingIssuesReferences":{"nodes":[]}}],"pageInfo":{"hasNextPage":false,"endCursor":null}}}}}' \ >"$ARRIVAL/fixtures/graphql-open.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 "an open Refs-bodied PR suppresses the post-merge transition" 0 "" \ test "$subprocess_rc" -eq 0 check "...leaves the live claim assigned" 1 "" \ grep -qF '#40: merged Refs PR -> post-merge; claim released' <<<"$subprocess_out" check "...performs no release edit" 1 "" \ grep -qF -- 'issue edit 40 -R owner/repo --remove-assignee builder --remove-label claimed --add-label post-merge' \ "$ARRIVAL/fixtures/edits" # The query selects every OPEN PR and deliberately does not select isDraft; # this fixture-only flip documents that draft identity cannot narrow the set. sed 's/"isDraft":false/"isDraft":true/' "$ARRIVAL/fixtures/graphql-open.json" \ >"$ARRIVAL/fixtures/graphql-open.json.tmp" mv "$ARRIVAL/fixtures/graphql-open.json.tmp" "$ARRIVAL/fixtures/graphql-open.json" : >"$ARRIVAL/fixtures/edits" draft_transition_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 )" check "a draft Refs-bodied PR suppresses post-merge transition identically" 1 "" \ grep -qF '#40: merged Refs PR -> post-merge; claim released' <<<"$draft_transition_out" # The same body linkage protects the reclaim clock even when no Refs-linked # PR has merged. This is the derived half of crew#321's destructive shape. printf '%s\n' \ '{"data":{"repository":{"pullRequests":{"nodes":[{"number":411,"body":"Refs #41","isDraft":false,"closingIssuesReferences":{"nodes":[]}}],"pageInfo":{"hasNextPage":false,"endCursor":null}}}}}' \ >"$ARRIVAL/fixtures/graphql-open.json" printf '%s\n' \ '{"data":{"repository":{"pullRequests":{"nodes":[],"pageInfo":{"hasNextPage":false,"endCursor":null}}}}}' \ >"$ARRIVAL/fixtures/graphql-merged.json" printf '[{"number":41}]\n' \ >"$ARRIVAL/fixtures/repos_owner_repo_issues_state_open_per_page_100.json" jq -n --arg at "$(iso_at $((INOW - 10 * 86400)))" \ '{number:41,user:{login:"triage-one"},created_at:$at,body:"- [ ] build",labels:[{name:"claimed"}],assignees:[{login:"builder"}]}' \ >"$ARRIVAL/fixtures/repos_owner_repo_issues_41.json" printf '[]\n' >"$ARRIVAL/fixtures/repos_owner_repo_issues_41_comments.json" jq -n --arg at "$(iso_at $((INOW - 10 * 86400)))" \ '[{"event":"assigned","created_at":$at}]' \ >"$ARRIVAL/fixtures/repos_owner_repo_issues_41_timeline.json" : >"$ARRIVAL/fixtures/edits" reclaim_out="$( env PATH="$ARRIVAL/stub:$PATH" GH_FIXTURES="$ARRIVAL/fixtures" \ ISSUEFLOW_NOW="$INOW" REPO=owner/repo LABELS_CONF="$ARRIVAL/labels.conf" \ bash "$ROOT/actions/issueflow-reconcile/issueflow-reconcile.sh" 2>&1 )" reclaim_rc=$? check "an open Refs-bodied PR suppresses stale reclaim" 0 "" test "$reclaim_rc" -eq 0 check "...keeps the quiet live claim" 1 "" \ grep -qF '#41: stale claim reclaimed -> ready' <<<"$reclaim_out" # Drafts are live claim evidence by the same OPEN query (D4). The query does # not select isDraft, so this fixture-only flip deliberately leaves production # input byte-identical and guards the absence of a draft/readiness predicate. sed 's/"isDraft":false/"isDraft":true/' "$ARRIVAL/fixtures/graphql-open.json" \ >"$ARRIVAL/fixtures/graphql-open.json.tmp" mv "$ARRIVAL/fixtures/graphql-open.json.tmp" "$ARRIVAL/fixtures/graphql-open.json" : >"$ARRIVAL/fixtures/edits" draft_out="$( env PATH="$ARRIVAL/stub:$PATH" GH_FIXTURES="$ARRIVAL/fixtures" \ ISSUEFLOW_NOW="$INOW" REPO=owner/repo LABELS_CONF="$ARRIVAL/labels.conf" \ bash "$ROOT/actions/issueflow-reconcile/issueflow-reconcile.sh" 2>&1 )" check "a draft Refs-bodied PR suppresses stale reclaim identically" 1 "" \ grep -qF '#41: stale claim reclaimed -> ready' <<<"$draft_out" # 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" err_out="$(arrival_run 2>&1)" err_rc=$? check "a dead API on the arrival path still fails the run (D2)" 0 "" \ test "$err_rc" -eq 1 check "...and the sweep does not run over a lying arrival" 1 "" \ grep -qF 'issueflow: reconciled.' <<<"$err_out" # --------------------------------------------------------------------------- # The whole sweep over an unreadable board (#247), executed. The sourced # probes above drive one issue's pass; only this path exercises the loop, the # counting and the tail — and only this path reproduces crew#329's log, which # ended `issueflow: reconciled.` with rc=0 over a label it should never have # written. Its own fixture directory: the arrival fixtures above are stateful # across their cases. # --------------------------------------------------------------------------- SWEEP="$TMP/sweep" mkdir -p "$SWEEP" printf '%s\n' \ '{"data":{"repository":{"pullRequests":{"nodes":[],"pageInfo":{"hasNextPage":false,"endCursor":null}}}}}' \ >"$SWEEP/graphql-open.json" cp "$SWEEP/graphql-open.json" "$SWEEP/graphql-merged.json" # 70: the 504 with a JSON error body on the per-issue read. printf '%s\n' "$GH_STUB_ERROR_BODY" >"$SWEEP/repos_owner_repo_issues_70.json.http-error" # 71: healthy, and carrying no queue label — so if the sweep reaches it, it # writes needs-triage. That write is the evidence the loop continued. printf '%s\n' \ '{"number":71,"user":{"login":"triage-one"},"labels":[{"name":"enhancement"}],"assignees":[]}' \ >"$SWEEP/repos_owner_repo_issues_71.json" # 72: HTTP 200 whose body is `null` — exit 0, and the label set empties just # as it does on the 504. The shape check is the only thing that catches it. printf 'null\n' >"$SWEEP/repos_owner_repo_issues_72.json" sweep_board() { printf '%s\n' "$1" >"$SWEEP/repos_owner_repo_issues_state_open_per_page_100.json"; } sweep_run() { : >"$SWEEP/edits" env PATH="$ARRIVAL/stub:$PATH" GH_FIXTURES="$SWEEP" ISSUEFLOW_NOW="$INOW" \ REPO=owner/repo LABELS_CONF="$ARRIVAL/labels.conf" \ bash "$ROOT/actions/issueflow-reconcile/issueflow-reconcile.sh" 2>&1 } sweep_board '[{"number":70},{"number":71}]' sweep_out="$(sweep_run)" sweep_rc=$? check "an unreadable issue does not red the sweep (D7)" 0 "" test "$sweep_rc" -eq 0 check "the 504's JSON error body is skipped, with the reason named" 0 \ "issueflow: #70: skipped this pass — could not read the issue: $GH_STUB_STDERR" \ printf '%s\n' "$sweep_out" check "...and crew#329's label is never written" 1 "" \ grep -qF '#70: needs-triage (no queue state)' <<<"$sweep_out" check "...nor any edit at all on the unreadable issue" 1 "" \ grep -qF 'issue edit 70' "$SWEEP/edits" check "...while the readable issue beside it is reconciled as before" 0 "" \ grep -qxF 'issue edit 71 -R owner/repo --add-label needs-triage' "$SWEEP/edits" check "...and the partial pass names its count and its issue" 0 \ 'issueflow: 1 issue skipped this pass on an unreadable fact: #70' \ printf '%s\n' "$sweep_out" check "...after a byte-identical reconciled. line" 0 "" \ grep -qxF 'issueflow: reconciled.' <<<"$sweep_out" sweep_board '[{"number":72}]' null_out="$(sweep_run)" null_rc=$? check "an HTTP 200 whose body is null exits 0 and writes nothing" 0 "" \ test "$null_rc" -eq 0 check "...because the shape check refuses it, on its own line" 0 \ 'issueflow: #72: skipped this pass — the issue read answered a payload that is not issue #72 carrying a label array' \ printf '%s\n' "$null_out" check "...so no label is derived from an empty label set" 1 "" \ grep -qF 'issue edit 72' "$SWEEP/edits" check "...and the tail names it too" 0 \ 'issueflow: 1 issue skipped this pass on an unreadable fact: #72' \ printf '%s\n' "$null_out" sweep_board '[{"number":70},{"number":72}]' both_out="$(sweep_run)" check "two skipped issues are both named, in the plural" 0 \ 'issueflow: 2 issues skipped this pass on unreadable facts: #70 #72' \ printf '%s\n' "$both_out" sweep_board '[{"number":71}]' whole_out="$(sweep_run)" whole_rc=$? check "a whole pass still exits 0" 0 "" test "$whole_rc" -eq 0 check "...ends on the byte-identical reconciled. line, with no tail after it" 0 \ "issueflow: reconciled." printf '%s\n' "$(tail -n1 <<<"$whole_out")" check "...and says nothing about skipping" 1 "" \ grep -q 'skipped this pass' <<<"$whole_out" summary