#!/usr/bin/env bash set -u ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" # shellcheck source=test/harness.sh source "$ROOT/test/harness.sh" # The suite drives the GITHUB backend: its gh() stubs ARE the forge boundary # now, and forge_api/forge_issue_edit/... resolve to the gh invocations those # stubs already intercept (#188). Without this the verbs are undefined. # shellcheck source=lib/forge.sh . "$ROOT/lib/forge.sh" forge_select github # 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"; forge_select github; 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 # shellcheck disable=SC2317 # reached through the forge backend, not called directly (#188) 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 endpoint="$(forge_stub_path "$endpoint")" 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; } # An absent fixture answers an empty list, and a --jq call gets the filter # applied to it — the arrival stub's shape, and gh's. Returning the raw # `[]` to a --jq caller made every missing fixture answer a literal `[]` # where the real API answers nothing, and `[]` outsorts an ISO-8601 # timestamp in the C locale but not in a UTF-8 one, so last_issue_activity # dated an issue by a stub artifact on the runner and by its created_at # here. The old code swallowed the resulting date failure; #247's guards # turn it into a skip, which is what made the lie visible. local payload='[]' [ ! -f "$file" ] || payload="$(cat "$file")" if [ -n "$jqexpr" ]; then jq -r "$jqexpr" <<<"$payload"; else printf '%s\n' "$payload"; 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/$(printf 'repos/%s/issues/%s/comments' "$REPO" "$n" | tr / _).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"}]' # `PROBE_NOW` moves the sweep's clock without moving the fixtures — the # only way to prove a rule that self-rate-limits on its own comment's # timestamp (#254): sweep, then sweep again a day later and watch the # nudge stay silent because the comment it posted is now the activity. REPO="${PROBE_REPO:-owner/repo}" NOW="${PROBE_NOW:-$INOW}" # The nudge links the issue on the forge the job is running on, so the # probe stands on one. PROBE_SERVER_URL lets a case assert the link moves # with the forge instead of being written into the source (#198). GITHUB_SERVER_URL="${PROBE_SERVER_URL:-https://github.com}" 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() { "$@"; } # shellcheck disable=SC2317 # reached through the forge backend, not called directly (#188) 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"; } # -- release epics announce an opened declared gate, comment-only (#253) ----- printf '{"state":"closed"}\n' >"$TMP/repos_owner_repo_issues_201.json" printf '{"state":"closed"}\n' >"$TMP/repos_owner_repo_issues_202.json" printf '{"state":"open"}\n' >"$TMP/repos_owner_repo_issues_203.json" printf '[]\n' >"$(cfix 53)" : >"$TMP/issue-edits" release_init_edits_before="$(wc -l <"$TMP/issue-edits")" release_init_body=$'Blocked by #201, #202.\n\n## Task list\n- [ ] #203 later work' release_init="$(issue_probe 53 $'epic\nrelease' 0 false "" "$release_init_body")" check "a release epic with every declared blocker closed announces init" 0 "" \ grep -qF '' "$TMP/posted-53" check "the init announce names all five steps" 0 "5" \ grep -cE '^[1-5]\. ' "$TMP/posted-53" # shellcheck disable=SC2016 # backticks are the literal portable doctrine citation check "the init announce cites the portable vendored doctrine path" 0 "" \ grep -qF 'See `.ceremony/RELEASES.md`.' "$TMP/posted-53" check "the init announce names the never-automated operator blessing" 0 "" \ grep -qF 'operator blessing the order is the one step this chain never automates' \ "$TMP/posted-53" check "the opened release gate is logged" 0 "" \ grep -qF '#53: release-init due' <<<"$release_init" release_init_gate_reads_before_repeat="$( grep -cE 'repos/owner/repo/issues/(201|202)$' "$TMP/api-calls" )" issue_probe 53 $'epic\nrelease' 0 false "" "$release_init_body" >/dev/null check "an unchanged opened gate announces only once" 0 "1" \ grep -cF '' "$TMP/posted-53" check "an announced gate does not re-read its durable blockers" 0 \ "$release_init_gate_reads_before_repeat" \ grep -cE 'repos/owner/repo/issues/(201|202)$' "$TMP/api-calls" printf '{"state":"closed"}\n' >"$TMP/repos_heavy-duty_ceremony_issues_201.json" printf '{"state":"closed"}\n' >"$TMP/repos_heavy-duty_ceremony_issues_202.json" printf '[]\n' >"$TMP/repos_heavy-duty_ceremony_issues_53_comments.json" PROBE_REPO=heavy-duty/ceremony \ issue_probe 53 $'epic\nrelease' 0 false "" "$release_init_body" >/dev/null # shellcheck disable=SC2016 # backticks are the literal dogfood doctrine citation check "the ceremony dogfood announce cites its root doctrine path" 0 "" \ grep -qF 'See `RELEASES.md`.' "$TMP/posted-53" printf '[]\n' >"$(cfix 54)" issue_probe 54 $'epic\nrelease' 0 false "" \ $'Blocked by #203.\n\n## Task list\n- [x] #201 complete' >/dev/null check "an open declared blocker suppresses init despite a complete task list" 1 "" \ grep -qF '' "$TMP/posted-54" check "the independent epic-complete nudge still fires" 0 "" \ grep -qF '' "$TMP/posted-54" printf '[]\n' >"$(cfix 55)" issue_probe 55 $'epic\nrelease' 0 false "" \ $'Blocked by #201.\n\n## Task list\n- [x] #202 complete' >/dev/null check "release-init and epic-complete can coexist" 0 "2" \ grep -c -- '^----$' "$TMP/posted-55" # shellcheck disable=SC2016 # positional parameter belongs to the isolated shell check "the coexisting comments keep distinct markers" 0 "" \ bash -c 'grep -qF "" "$1" && grep -qF "" "$1"' \ _ "$TMP/posted-55" for n in 56 57 58 59; do printf '[]\n' >"$(cfix "$n")"; done issue_probe 56 $'epic\nrelease' 0 false "" 'No dependency declaration.' >/dev/null check "a release epic without a Blocked by declaration stays silent" 1 "" \ test -f "$TMP/posted-56" issue_probe 57 $'epic\nrelease' 0 false "" 'Blocked by heavy-duty/rig#9.' >/dev/null check "a cross-repo release gate stays silent" 1 "" test -f "$TMP/posted-57" issue_probe 58 $'epic\nrelease' 0 false "" 'Blocked by #204.' >/dev/null check "an unreadable release gate stays silent" 1 "" test -f "$TMP/posted-58" : >"$TMP/api-calls" issue_probe 59 epic 0 false "" 'Blocked by #201.' >/dev/null check "a plain epic does not parse or announce a release gate" 1 "" \ test -f "$TMP/posted-59" check "a plain epic pays no Blocked by reference read" 1 "" \ grep -qF 'repos/owner/repo/issues/201' "$TMP/api-calls" printf '[]\n' >"$(cfix 60)" issue_probe 60 $'ready\nrelease' 0 false "" 'Blocked by #201.' >/dev/null check "a non-epic release issue does not announce init" 1 "" \ test -f "$TMP/posted-60" # shellcheck disable=SC2016 # positional parameter belongs to the isolated shell check "every release-init probe is comment-only" 0 "$release_init_edits_before" \ bash -c 'wc -l <"$1"' _ "$TMP/issue-edits" # -- malformed attention targets are diagnosed, never repaired (#232) ------- attention_episode() { # $1 issue, $2 labeled timestamp jq -n --arg at "$2" \ '[{"event":"labeled","label":{"name":"attention"},"actor":{"login":"setter"},"created_at":$at}]' \ >"$(tfix "$1")" printf '[]\n' >"$(cfix "$1")" } : >"$TMP/issue-edits" attention_edits_before="$(wc -l <"$TMP/issue-edits")" for n in 61 62 63; do attention_episode "$n" "$(iso_at $((INOW - 60)))" done # The assignment event is the claim clock's own activity fact. The live issue # has since lost its assignee, which is exactly the claimed-unassigned shape. jq --arg at "$(iso_at $((INOW - 60)))" \ '. + [{"event":"assigned","created_at":$at}]' \ "$(tfix 63)" >"$(tfix 63).tmp" && mv "$(tfix 63).tmp" "$(tfix 63)" printf '{"state":"open"}\n' >"$TMP/repos_owner_repo_issues_999.json" issue_probe 61 $'ready\nattention' 0 >/dev/null check "unassigned attention under ready is diagnosed once" 0 "1" \ grep -cF '' "$TMP/posted-63" check "the suppressed attention detection remains in the log" 0 "1" \ grep -cF 'comment suppressed by claimed-unassigned precedence' <<<"$claimed_attention" attention_episode 64 "$(iso_at $((INOW - 60)))" issue_probe 64 $'ready\nattention' 1 >/dev/null check "assigned attention under ready is healthy" 1 "" test -f "$TMP/posted-64" attention_episode 65 "$(iso_at $((INOW - 60)))" issue_probe 65 $'claimed\nattention' 1 true >/dev/null check "assigned attention under claimed is healthy" 1 "" test -f "$TMP/posted-65" attention_episode 66 "$(iso_at $((INOW - 60)))" issue_probe 66 $'blocked\nattention' 1 false "" 'Blocked by #999' >/dev/null # A `blocked` issue now always carries one comment — the parse echo (#252) — # so "healthy" can no longer be spelled "no comment file at all". It is spelled # the way this section's other cases already spell it: no attention diagnostic. # Both halves are pinned, so the case still fails if an attention comment # appears beside the echo, or if a second comment of any kind does. check "assigned attention under blocked is healthy" 1 "" \ grep -qF '' "$TMP/posted-67" check "the post-merge suppression remains in the log" 0 "1" \ grep -cF 'comment suppressed by post-merge-assigned precedence' <<<"$post_merge_attention" attention_episode 68 "$(iso_at $((INOW - 120)))" issue_probe 68 $'ready\nattention' 0 >/dev/null issue_probe 68 $'ready\nattention' 0 >/dev/null check "two sweeps in one malformed episode post once" 0 "1" \ grep -cF '\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" # The quiet itself is now visible (#254) — this probe is 10 days old with no # activity, so it draws the evidence nudge and nothing else. It used to # assert no comment at all; that assertion described the starvation this # issue exists to end, and the edit half of it is what still matters. check "...and causes no edit" 1 "" grep -qF -- 'issue edit 36' "$TMP/issue-edits" check "...only the evidence nudge speaks" 0 "1" \ grep -c -- '^----$' "$TMP/posted-36" printf '[]\n' >"$(cfix 37)" # The live shape of an assigned `post-merge` issue: the assignee in the issue # payload AND the `assigned` event that put it there in the timeline. With an # empty timeline this fixture could not see the defect it exists to guard — # the evidence clock counting that hour-old assignment as activity and # silencing the nudge for another 7 days, on the one board state where an # assignee is itself the bug being reported. jq -n --arg at "$(iso_at $((INOW - 3600)))" \ '[{"event":"assigned","created_at":$at}]' >"$(tfix 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" # The flag and the nudge answer different questions — a board bug and a # starved wake condition — so neither suppresses the other (#254). check "...and the evidence nudge rides beside it, neither suppressed" 0 "2" \ grep -c -- '^----$' "$TMP/posted-37" # -- the post-merge evidence nudge (#254), the ruling nudge's twin ---------- # The ruling nudge solved "a wait goes quiet and nobody is told" for # `needs-ruling`; `post-merge` had no equivalent, and crew#181's real-host # criterion starved four times across two releases for want of one. Same # 7-day constant (`ruling_nudge_decision`, reused not mirrored), same # deliberate absence of an idempotency marker, and — unlike the ruling # nudge — addressed to the triage actor, because `post-merge` is triage's # completion queue and the operator owes nothing here (#254 D1). nudge_edits_before="$(wc -l <"$TMP/issue-edits")" quiet_comment() { # $1 issue, $2 seconds of quiet — one ordinary comment, then silence jq -n --arg at "$(iso_at $((INOW - $2)))" \ '[{"user":{"login":"triage-one"},"created_at":$at,"html_url":"https://x/c","body":"evidence pending"}]' \ >"$(cfix "$1")" printf '[]\n' >"$(tfix "$1")" } quiet_comment 80 $((8 * 86400)) nudged="$(issue_probe 80 post-merge 0)" check "8 quiet days on a post-merge item draws the evidence nudge" 0 "" \ grep -q 'post-merge evidence nudge' <<<"$nudged" # shellcheck disable=SC2016 # expansions belong to the isolated bash -c process check "...addressed to the triage actor, never the human reviewer" 0 "" \ bash -c 'grep -qF "@triage-one" "$1" && ! grep -qF "@danmt" "$1"' _ "$TMP/posted-80" check "...with the issue link as the payload" 0 "" \ grep -qF 'https://github.com/owner/repo/issues/80' "$TMP/posted-80" check "...carrying the do-not-add-a-marker warning in the comment" 0 "" \ grep -qF 'Do not add a marker.' "$TMP/posted-80" # Asserted directly, not merely omitted: a marker would turn "once per 7 # quiet days" into "once per issue, forever" — the exact "fix" lib/ruling.sh's # header records as the thing that breaks this rule. check "...and no idempotency marker on the path" 1 "" \ grep -qF '\nrung"}, {"user":{"login":"sweep-bot"},"created_at":$r24,"html_url":"https://x/r24","body":"\nrung"}]' \ >"$(cfix 84)" both="$(issue_probe 84 $'post-merge\nneeds-ruling' 0)" check "a quiet post-merge item under a pending ruling nudges both waits" 0 "" \ grep -q 'post-merge evidence nudge' <<<"$both" check "...and the ruling nudge is not suppressed by it" 0 "" \ grep -q 'ruling nudge' <<<"$both" # shellcheck disable=SC2016 # expansions belong to the isolated bash -c process check "...each addressing its own party" 0 "" \ bash -c 'grep -qF "@triage-one" "$1" && grep -qF "@danmt" "$1"' _ "$TMP/posted-84" # Every other queue state: the nudge is `post-merge`'s alone. Each is equally # quiet, and `claimed` carries an open PR so its own reclaim clock — the one # other 10-day rule on this path — stays out of the way. non_post_merge=(85:ready:0:false 86:claimed:1:true 87:blocked:0:false 88:epic:0:false 89:needs-triage:0:false) for spec in "${non_post_merge[@]}"; do IFS=: read -r n state probe_assignees probe_pr <<<"$spec" printf '[]\n' >"$(cfix "$n")" printf '[]\n' >"$(tfix "$n")" check "a 10-day-quiet $state issue draws no evidence nudge" 1 "" \ grep -q 'post-merge evidence nudge' \ <<<"$(issue_probe "$n" "$state" "$probe_assignees" "$probe_pr")" done # The machine never judges prose: which criterion starved is not a fact the # sweep reads, so a body it could not parse if it tried still nudges. quiet_comment 90 $((8 * 86400)) unparseable_body="$(issue_probe 90 post-merge 0 false "" '¯\_(ツ)_/¯ wake: ask danmt sometime')" check "an unparseable body still nudges — the link is the payload" 0 "" \ grep -q 'post-merge evidence nudge' <<<"$unparseable_body" check "...and the nudge quotes none of it" 1 "" \ grep -qF 'ask danmt sometime' "$TMP/posted-90" # One spelling of the 7-day rule. `lib/ruling.sh` exists because this family # already paid for two copies of a constant; a second one here is the drift, # and it is cheap to pin at the grep level. # Code lines only: the branch's comment names the constant it must not # respell, which is the sentence a future reader needs and not a second copy. check "the 7-day rule is not respelled in the sweep" 1 "" \ grep -nE '^[^#]*(7 \* 24 \* 3600|604800)' \ "$ROOT/actions/issueflow-reconcile/issueflow-reconcile.sh" # shellcheck disable=SC2016 # the call site is asserted as a literal check "...it is reused from lib/ruling.sh" 0 "" \ grep -qF 'ruling_nudge_decision "$NOW" "$evidence_age"' \ "$ROOT/actions/issueflow-reconcile/issueflow-reconcile.sh" # `post-merge` is the one queue state whose whole meaning is that the machine # owes nothing (LABELS.md: the sweep never reclaims it). This issue makes the # quiet visible; it must never make it actionable. # shellcheck disable=SC2016 # positional parameter belongs to the isolated shell check "the evidence-nudge probes perform no issue edits" 0 "$nudge_edits_before" \ bash -c 'wc -l <"$1"' _ "$TMP/issue-edits" # -- the issue-side ruling clock is comments-only (#284) --------------------- # #52 D10's "reuse the activity computation" made the issue-side ruling nudge # ride the claim-reclamation clock, `assigned` events included — so claiming # a flagged issue dated it, and the escalation the flag exists to keep # visible went quiet for another 7 days at exactly the moment somebody # started working through it. The ruling clock is now the comments-only one # (D1); the reclaim clock keeps the assignment (D2), because there the # assignment IS the claim. Every must-nudge probe below was run against the # pre-#284 sweep and went red — the #274 round-1 discipline: the fixture # proves the defect, not merely the fix. ruling_clock_edits_before="$(wc -l <"$TMP/issue-edits")" ruling_quiet() { # $1 issue — conforming escalation, both rungs fired, ~9d of comment quiet jq -n --arg l "$(iso_at $((INOW - 10 * 86400)))" \ '[{"event":"labeled","label":{"name":"needs-ruling"},"actor":{"login":"setter"},"created_at":$l}]' \ >"$(tfix "$1")" 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/esc","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 "$1")" } timeline_add() { # $1 issue, $2 event, $3 seconds ago jq --arg e "$2" --arg at "$(iso_at $((INOW - $3)))" \ '. + [{"event":$e,"created_at":$at}]' \ "$(tfix "$1")" >"$(tfix "$1").tmp" && mv "$(tfix "$1").tmp" "$(tfix "$1")" } comment_add() { # $1 issue, $2 seconds ago jq --arg at "$(iso_at $((INOW - $2)))" \ '. + [{"user":{"login":"decider"},"created_at":$at,"html_url":"https://x/d","body":"still thinking"}]' \ "$(cfix "$1")" >"$(cfix "$1").tmp" && mv "$(cfix "$1").tmp" "$(cfix "$1")" } # The live shape, and the defect: a builder claims the flagged issue, the # assignment is an hour old, the decider has been silent ~9 days. ruling_quiet 101 timeline_add 101 assigned 3600 claimed_fresh="$(issue_probe 101 $'claimed\nneeds-ruling' 1 true)" check "an hour-old claim does not silence a ruling 9 days quiet" 0 "" \ grep -q 'ruling nudge' <<<"$claimed_fresh" check "...and the fresh assignment is not reclaim bait either" 1 "" \ grep -q 'reclaimed' <<<"$claimed_fresh" # The claimed branch's top read, pinned. `claimed` + `needs-ruling` with no # assignee is the one composition where this branch posts BEFORE the ruling # block: `claim_decision` returns FLAG_UNASSIGNED and the # `claimed-unassigned` comment goes out, so a ruling clock read down there # would date the issue by the sweep's own writing and buy the escalation # another 7 quiet days — the #274 hazard, on the very branch #284 D6 exists # to hold. Probe 101 does not reach it: it carries an assignee and an open # PR. One sweep, both outputs — the board diagnostic and the nudge it must # not silence — because asserting only the nudge would pass with the # diagnostic silently gone. Reds the moment the top read drifts below the # post, and the deletion of that read is what proved it (#284 D6, #307). ruling_quiet 110 timeline_add 110 assigned 3600 unassigned_flag="$(issue_probe 110 $'claimed\nneeds-ruling' 0 false)" check "an unassigned claim under a ruling still draws its board flag" 0 "1" \ grep -cF '' "$TMP/posted-110" check "...and the ruling nudge fires beside it, undated by it" 0 "" \ grep -q 'ruling nudge' <<<"$unassigned_flag" # The clock rule alone, no assignee in the way: an assigned/unassigned pair # in the timeline is the claim's history, not activity toward the ruling. ruling_quiet 102 timeline_add 102 assigned 3600 timeline_add 102 unassigned 3500 ready_pair="$(issue_probe 102 $'ready\nneeds-ruling' 0)" check "a ready issue nudges through an hour-old assignment pair" 0 "" \ grep -q 'ruling nudge' <<<"$ready_pair" # post-merge + needs-ruling fires BOTH nudges in one sweep, from one read # taken before either write. This probe is also the read-order pin: the # evidence nudge posts first and the stub stamps it as fresh activity, so # restoring a ruling-clock read below `ensure_comment` turns the second # check red — the hazard #274 met and killed inside one round. ruling_quiet 103 timeline_add 103 assigned 3600 timeline_add 103 unassigned 3500 both_fresh="$(issue_probe 103 $'post-merge\nneeds-ruling' 0)" check "a fresh assignment starves neither post-merge wait" 0 "" \ grep -q 'post-merge evidence nudge' <<<"$both_fresh" check "...the ruling nudge fires beside it, not behind it" 0 "" \ grep -q 'ruling nudge' <<<"$both_fresh" # blocked composes the same way. The #252 parse echo is pre-seeded old so # the probe isolates the clock rule — steady state, where the echo for this # parse set already exists and the branch posts nothing before the tail. ruling_quiet 104 timeline_add 104 assigned 3600 refs_104="$(blocked_references <<<'Blocked by #999.')" cross_104="$(blocked_cross_references <<<'Blocked by #999.')" marker_104="$(blocked_parse_marker "$(blocked_parse_set "$refs_104" "$cross_104")")" jq --arg m "$marker_104" --arg at "$(iso_at $((INOW - 9 * 86400)))" \ '. + [{"user":{"login":"sweep-bot"},"created_at":$at,"html_url":"https://x/echo","body":("\necho")}]' \ "$(cfix 104)" >"$(cfix 104).tmp" && mv "$(cfix 104).tmp" "$(cfix 104)" blocked_fresh="$(issue_probe 104 $'blocked\nneeds-ruling' 0 false "" 'Blocked by #999.')" check "a blocked issue nudges through an hour-old assignment" 0 "" \ grep -q 'ruling nudge' <<<"$blocked_fresh" # 6 days of comment quiet is 6, with or without an assignment inside it. ruling_quiet 105 timeline_add 105 assigned 3600 comment_add 105 $((6 * 86400)) six_days="$(issue_probe 105 $'claimed\nneeds-ruling' 1 true)" check "6 days of comment quiet draws no nudge" 1 "" \ grep -q 'ruling nudge' <<<"$six_days" # Label churn is not activity on this clock either — it reads no timeline # at all, which closes the class rather than the spelling. ruling_quiet 106 timeline_add 106 labeled 1800 timeline_add 106 unlabeled 1700 churn="$(issue_probe 106 $'ready\nneeds-ruling' 0)" check "hour-old label churn does not hold the nudge back" 0 "" \ grep -q 'ruling nudge' <<<"$churn" # Self-rate-limiting, asserted as the property (#254's discipline): sweep # again a day after probe 101's nudge and the nudge it posted is the # activity that keeps it silent — no marker involved. day_after="$(PROBE_NOW=$((INOW + 86400)) issue_probe 101 $'claimed\nneeds-ruling' 1 true)" check "the sweep a day after its nudge holds its silence" 1 "" \ grep -q 'ruling nudge' <<<"$day_after" # No flag, no nudge, whatever the clock says. quiet_comment 107 $((60 * 86400)) noflag="$(issue_probe 107 ready 0)" check "an unflagged issue draws no ruling nudge at any age" 1 "" \ grep -q 'ruling nudge' <<<"$noflag" # D2's input doing its job — the one thing a "the clocks are the same now, # merge them" refactor would break. Red the instant `last_issue_activity` # loses `assigned`. printf '[]\n' >"$(cfix 108)" jq -n --arg at "$(iso_at $((INOW - 600)))" \ '[{"event":"assigned","created_at":$at}]' >"$(tfix 108)" not_reclaimed="$(issue_probe 108 claimed 1 false)" check "a 10-minute-old claim on a silent issue is not reclaimed" 1 "" \ grep -q 'reclaimed' <<<"$not_reclaimed" # One fixture, two clocks, asserted directly and not by inspection: the # newest event is the assignment; the reclaim clock returns it and the # ruling clock returns the older comment. jq -n --arg at "$(iso_at $((INOW - 8 * 86400)))" \ '[{"user":{"login":"decider"},"created_at":$at,"html_url":"https://x/c9","body":"x"}]' >"$(cfix 109)" jq -n --arg at "$(iso_at $((INOW - 3600)))" \ '[{"event":"assigned","created_at":$at}]' >"$(tfix 109)" clock_read() { # $1 clock fn — both against fixture 109 ( REPO=owner/repo # shellcheck disable=SC2317 # reached indirectly, through the clock under test gh() { issue_stub_gh "$@"; } "$1" 109 "$(iso_at $((INOW - 10 * 86400)))" ) } check "one fixture, two clocks: the reclaim clock returns the assignment" \ 0 "$((INOW - 3600))" clock_read last_issue_activity check "...and the ruling clock returns the older comment" \ 0 "$((INOW - 8 * 86400))" clock_read last_issue_comment_activity # The mechanical call-site pins: the reclaim clock can never reach # reconcile_ruling, on any path, asserted against the source and not the # diff. Beside them, the one-spelling pins this issue inherits stay green. # shellcheck disable=SC2016 # the call sites are asserted as literals check "reconcile_ruling is never handed the reclaim clock" 1 "" \ grep -E '^[^#]*reconcile_ruling.*\$age' \ "$ROOT/actions/issueflow-reconcile/issueflow-reconcile.sh" # shellcheck disable=SC2016 # the call site is asserted as a literal check "...its one call site is fed the comments-only clock" 0 "1" \ grep -cF 'reconcile_ruling "$n" "$ruling_age" "$NOW"' \ "$ROOT/actions/issueflow-reconcile/issueflow-reconcile.sh" # shellcheck disable=SC2016 # the guarded_read is asserted as a literal check "...and ruling_age is never fed by the reclaim clock" 1 "" \ grep -E 'ruling_age.*last_issue_activity ' \ "$ROOT/actions/issueflow-reconcile/issueflow-reconcile.sh" # shellcheck disable=SC2016 # positional parameter belongs to the isolated shell check "the #284 probes perform no issue edits" 0 "$ruling_clock_edits_before" \ bash -c 'wc -l <"$1"' _ "$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. # shellcheck disable=SC2317 # reached through the forge backend, not called directly (#188) 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" # -- the parse echo: one comment per changed set, none per sweep (#252) ------ # The whole point is a sweep-visible statement of what was read, so it is # probed through the sweep and not only as a rendering: the marker has to # survive the comment body, the second pass has to find it, and the third has # to miss it because the declaration changed. printf '{"state":"open"}\n' >"$TMP/repos_owner_repo_issues_90.json" printf '{"state":"open"}\n' >"$TMP/repos_owner_repo_issues_91.json" printf '{"state":"open"}\n' >"$TMP/repos_owner_repo_issues_92.json" printf '[]\n' >"$(cfix 35)" echo_edits_before="$(wc -l <"$TMP/issue-edits")" first_echo="$(issue_probe 35 blocked 1 false "" "Part of #1. Blocked by #90, #91.")" check "a first parse is echoed, naming the set" 0 "" \ grep -qF 'parse to: {#90, #91}' "$TMP/posted-35" check "...and the sweep log carries the same set" 0 \ "issueflow: #35: blocked declarations parse to {#90, #91}" \ printf '%s\n' "$first_echo" issue_probe 35 blocked 1 false "" "Part of #1. Blocked by #90, #91." >/dev/null check "an unchanged parse draws nothing on the next sweep" 0 "1" \ grep -cF "" "$TMP/posted-35" # AC-1's other input, and it is not the sweep above. A re-sweep of a # BYTE-IDENTICAL body is quiet under both spellings of the decision — the one # that keys on the parse and the one that keys on the body — so it cannot tell # them apart. Only an edit that changes the prose and preserves the parse can: # the refs are reordered and sentences are added on either side, and the set is # still {#90, #91}. What this pins is that the marker is a function of the # PARSE and not of the prose around it, which is the property the echo's whole # idempotency rests on and which nothing else in the suite states. preserved_edits_before="$(wc -l <"$TMP/issue-edits")" issue_probe 35 blocked 1 false "" \ "Some new prose here. Blocked by #91, #90. And more text." >/dev/null check "a body edit that preserves the parse draws nothing" 0 "1" \ grep -cF "" "$TMP/posted-35" check "...and adds no echo under any other marker either" 0 "1" \ grep -cF '" "$TMP/posted-35" check "...naming the new set" 0 "" \ grep -qF 'parse to: {#90, #91, #92}' "$TMP/posted-35" check "...and saying so in the sweep log" 0 \ "issueflow: #35: blocked declarations parse to {#90, #91, #92}" \ printf '%s\n' "$changed_echo" check "...and leaving the first echo alone" 0 "1" \ grep -cF "" "$TMP/posted-35" # shellcheck disable=SC2016 # positional parameters belong to bash -c check "no label write comes from the echo path" 0 "" \ bash -c 'test "$1" -eq "$(wc -l <"$2")"' _ "$echo_edits_before" "$TMP/issue-edits" # crew#308, replayed through the sweep: the declaration denies #221 and the # parse unions it anyway. Nobody saw that set for as long as it stayed inside # the machine; the echo puts it in the thread that contains the declaration. printf '{"state":"open"}\n' >"$TMP/repos_owner_repo_issues_162.json" printf '{"state":"open"}\n' >"$TMP/repos_owner_repo_issues_221.json" printf '{"state":"open"}\n' >"$TMP/repos_owner_repo_issues_265.json" printf '[]\n' >"$(cfix 36)" issue_probe 36 blocked 1 false "" \ 'Blocked by #162, #265. It is no longer blocked by #221.' >/dev/null check "the #308 misparse is echoed verbatim, denial and all" 0 "" \ grep -qF 'parse to: {#162, #221, #265}' "$TMP/posted-36" # The empty parse says so, and the flag that catches the UNREADABLE # declaration is untouched beside it: one comment states what was read, the # other states that nothing was. printf '[]\n' >"$(cfix 37)" issue_probe 37 blocked 1 false "" 'No declaration anywhere in this body.' >/dev/null check "an empty parse is echoed as the empty set" 0 "" \ grep -qF 'parse to: {}' "$TMP/posted-37" check "...and blocked-unparseable still fires beside it" 0 "" \ grep -qF '' "$TMP/posted-37" # The collision the round found, replayed through the sweep: two declarations # whose parsed sets differ but whose slugs do not. Keyed on the slug, the # second edit found the first echo's marker and posted nothing — the machine # silently gating on `acme-widgets#9` while the thread said `acme/widgets#9`, # which is the readable-but-wrong shape this whole change exists to surface. # Asserted end-to-end, so it is the second echo landing that is observed. printf '[]\n' >"$(cfix 38)" issue_probe 38 blocked 1 false "" 'Blocked by acme/widgets#9.' >/dev/null check "a qualified cross-repo declaration is echoed" 0 "1" \ grep -cF 'parse to: {acme/widgets#9}' "$TMP/posted-38" collision_edits_before="$(wc -l <"$TMP/issue-edits")" issue_probe 38 blocked 1 false "" 'Blocked by acme-widgets#9.' >/dev/null check "a slug-colliding edit still draws its own echo" 0 "1" \ grep -cF 'parse to: {acme-widgets#9}' "$TMP/posted-38" check "...under a marker of its own" 0 "1" \ grep -cF "" "$TMP/posted-38" check "...leaving the colliding first echo alone" 0 "1" \ grep -cF "" "$TMP/posted-38" # The cross-repo flag is marker-constant across both parses, so it stays at one # while the echo moves: what spoke on the second sweep was the changed set. check "...and not re-flagging cross-repo, which did not change" 0 "1" \ grep -cF '' "$TMP/posted-38" # shellcheck disable=SC2016 # positional parameters belong to bash -c check "no label write comes from the colliding-edit path either" 0 "" \ bash -c 'test "$1" -eq "$(wc -l <"$2")"' _ "$collision_edits_before" "$TMP/issue-edits" # A -> B -> A. The marker names the SET, so the return to A is a marker this # thread has carried before; the question the echo has to answer is not "have I # ever said this" but "is this still what I am saying". Searching the whole # history answers the first, and the return went silent while the thread's # newest echo asserted B and the sweep gated on A — a stale parse presented as # the current one, which is the readable-but-wrong shape #252 exists to kill. # All four sweeps are driven, because the bug is only visible as a sequence. printf '[]\n' >"$(cfix 52)" issue_probe 52 blocked 1 false "" 'Blocked by #90, #91.' >/dev/null issue_probe 52 blocked 1 false "" 'Blocked by #90, #91, #92.' >/dev/null return_edits_before="$(wc -l <"$TMP/issue-edits")" issue_probe 52 blocked 1 false "" 'Blocked by #90, #91.' >/dev/null check "a set edited back to a previously echoed one speaks again" 0 "3" \ grep -cF '" "$TMP/posted-52" # The assertion the silence used to fail: it is the NEWEST echo that has to # name what the sweep gates on, not merely some echo somewhere in the thread. # shellcheck disable=SC2016 # positional parameters belong to bash -c check "...leaving the newest echo naming the set the sweep now gates on" 0 \ "parse to: {#90, #91}" \ bash -c 'grep -o "parse to: {[^}]*}" "$1" | tail -n 1' _ "$TMP/posted-52" issue_probe 52 blocked 1 false "" 'Blocked by #90, #91.' >/dev/null check "an unchanged sweep after the return still draws nothing" 0 "3" \ grep -cF '' \ --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 # shellcheck disable=SC2317 # reached through the forge backend, not called directly (#188) 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 # Inlined, not the suite's helper: this stub is a standalone executable on # PATH and cannot see a shell function from the test process. Strips the # paging the forge shim injects so fixtures stay keyed on the logical # endpoint (#188). endpoint="$(printf '%s' "$endpoint" | sed -E 's/([?&])(per_page|limit|page)=[0-9]+/\1/g; s/[?&]+$//; s/([?&])&+/\1/g')" file="$GH_FIXTURES/$(printf '%s' "$endpoint" | tr '/?&=' '____').json" if [ "$endpoint" = graphql ]; then case "$query" in 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" # The two PR gathers were GraphQL until #188; they are REST now, so the # fixtures are the /pulls list both forges return. Empty by default — the # merged-Refs case below fills the closed one. printf '[]\n' >"$ARRIVAL/fixtures/repos_owner_repo_pulls_state_open.json" printf '[]\n' >"$ARRIVAL/fixtures/repos_owner_repo_pulls_state_closed.json" arrival_fixture() { printf '%s\n' "$1" >"$ARRIVAL/fixtures/repos_owner_repo_issues_91.json"; } # CEREMONY_FORGE=github below, and at the executable-sweep driver further # down: these fixtures ARE a GitHub board (a gh stub on PATH answering # /api/v3 shapes), so the suite says so at the forge boundary rather than # letting main()'s preflight infer a forge from whatever env the CI job # leaked (#188). Stubbing `gh` and staying silent about the forge is the # boundary this issue moved. arrival_run() { : >"$ARRIVAL/fixtures/edits" env PATH="$ARRIVAL/stub:$PATH" CEREMONY_FORGE=github GH_FIXTURES="$ARRIVAL/fixtures" \ CEREMONY_FORGE=github \ 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" # The merged-Refs transition must survive the executable's set -e path too. # Keep this at main() granularity: the PR gather and loop are the code a # sourced decision probe cannot exercise (#91's lesson). # # merged_at is what makes this PR merged rather than merely closed — the # REST replacement for GraphQL's states: MERGED filter (#188). Both forges # return the field, and both return null on a closed-unmerged PR. printf '%s\n' \ '[{"number":400,"body":"Refs #40","merged_at":"2026-07-30T00:00:00Z"},{"number":401,"body":"Refs #40","merged_at":null}]' \ >"$ARRIVAL/fixtures/repos_owner_repo_pulls_state_closed.json" printf '[{"number":40}]\n' \ >"$ARRIVAL/fixtures/repos_owner_repo_issues_state_open.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" CEREMONY_FORGE=github 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 the REST gather 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' \ '[{"number":401,"body":"Refs #40","draft":false,"merged_at":null}]' \ >"$ARRIVAL/fixtures/repos_owner_repo_pulls_state_open.json" : >"$ARRIVAL/fixtures/edits" subprocess_out="$( env PATH="$ARRIVAL/stub:$PATH" CEREMONY_FORGE=github GH_FIXTURES="$ARRIVAL/fixtures" \ CEREMONY_FORGE=github \ 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 issue/PR discriminator is not `has("pull_request")` --------------- # Measured on the two list endpoints, 2026-08-02 (#188): # # GitHub plain issues OMIT the key -> 0 of 9 carried it # Forgejo every entry HAS the key -> 10 of 10, valued null on issues # # So `select(has("pull_request") | not)` selected ZERO issues on Forgejo and # the sweep printed "reconciled." over an untouched board — the blind sweep # again, one layer in, and invisible because the log looks identical to a # legitimately empty queue. Caught by rehearsing DRY_RUN against rig's live # board, not by any unit test. `.pull_request == null` is true for an issue on # both forges (an absent key reads as null) and false for a PR on both. disc() { jq -e "$1" >/dev/null 2>&1 && echo issue || echo pr; } check "github-shaped issue (key absent) reads as an issue" 0 "issue" \ bash -c 'echo "{\"number\":1}" | jq -e ".pull_request == null" >/dev/null && echo issue || echo pr' check "forgejo-shaped issue (key present, null) reads as an issue" 0 "issue" \ bash -c 'echo "{\"number\":1,\"pull_request\":null}" | jq -e ".pull_request == null" >/dev/null && echo issue || echo pr' check "a PR reads as a PR on either shape" 0 "pr" \ bash -c 'echo "{\"number\":1,\"pull_request\":{\"url\":\"x\"}}" | jq -e ".pull_request == null" >/dev/null && echo issue || echo pr' # The old test, kept as the must-fail: it disagrees with the new one on the # forgejo shape, which is exactly the bug. check "the old has() test misreads a forgejo issue as a PR" 0 "pr" \ bash -c 'echo "{\"number\":1,\"pull_request\":null}" | jq -e "has(\"pull_request\") | not" >/dev/null && echo issue || echo pr' # -- the OPEN-pull gather, at main() granularity ---------------------------- # The closed/merged half above proves one REST path; this proves the other, # which is a DIFFERENT pipeline: `.body | @base64` -> base64 -d -> # closes_references -> OPEN_PR_ISSUES. The 27 parser cases in # test/closes_references.test.sh cannot reach it — they test the parser, not # the encoding and wiring around it (#188). # # Both directions in ONE sweep, so neither assertion can pass vacuously: # #50 IS closed by an open PR -> the claim is KEPT, no reclaim edit # #51 is closed by nothing -> the claim is RECLAIMED # A break anywhere in the pipeline reclaims #50 too, and the first check # fails. A break that reclaims nothing fails the second. # # `Closes #50` sits on the THIRD line of the body on purpose. jq's @tsv # escapes a newline to a literal backslash-n, so a line-oriented parser # reading an @tsv-encoded body sees one line and drops everything after the # first — with the declaration on line 3, that defect reclaims #50 and this # case goes red. On line 1 it would pass either way, which is the definition # of a vacuous test. printf '%s\n' \ '[{"number":500,"body":"## Summary\nSome prose about the work.\nCloses #50\n","merged_at":null}]' \ >"$ARRIVAL/fixtures/repos_owner_repo_pulls_state_open.json" printf '[]\n' >"$ARRIVAL/fixtures/repos_owner_repo_pulls_state_closed.json" printf '[{"number":50},{"number":51}]\n' \ >"$ARRIVAL/fixtures/repos_owner_repo_issues_state_open.json" # Both claims are two hours quiet against a ONE-hour stale bound, so the # reclaim clock has genuinely expired for whichever of them no open PR # rescues. The clock is injected rather than real: INOW is a fixed epoch in # 2033, so without ISSUEFLOW_NOW the subprocess reads its own wall clock, # dates these claims in the future, and both survive on a negative age — # which is a green test proving nothing. for n in 50 51; do jq -n --arg at "$(iso_at $((INOW - 7200)))" --argjson n "$n" \ '{number:$n,user:{login:"triage-one"},created_at:$at,body:"- [x] built",labels:[{name:"claimed"}],assignees:[{login:"builder"}]}' \ >"$ARRIVAL/fixtures/repos_owner_repo_issues_$n.json" printf '[]\n' >"$ARRIVAL/fixtures/repos_owner_repo_issues_${n}_comments.json" done : >"$ARRIVAL/fixtures/edits" open_pr_out="$( env PATH="$ARRIVAL/stub:$PATH" CEREMONY_FORGE=github GH_FIXTURES="$ARRIVAL/fixtures" \ CEREMONY_FORGE=github ISSUEFLOW_NOW="$INOW" ISSUEFLOW_STALE_HOURS=1 \ REPO=owner/repo LABELS_CONF="$ARRIVAL/labels.conf" \ bash "$ROOT/actions/issueflow-reconcile/issueflow-reconcile.sh" 2>&1 )" check "the open-pull gather completes" 0 "" \ grep -qF 'issueflow: reconciled.' <<<"$open_pr_out" check "a claim closed by an open PR survives the base64 round trip" 1 "" \ grep -qE 'issue edit 50 .*--remove-label claimed' "$ARRIVAL/fixtures/edits" check "...while the claim no open PR closes is reclaimed in the same sweep" 0 "" \ grep -qE 'issue edit 51 .*--remove-label claimed --add-label ready' \ "$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" 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/repos_owner_repo_pulls_state_open.json" cp "$SWEEP/repos_owner_repo_pulls_state_open.json" "$SWEEP/repos_owner_repo_pulls_state_closed.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.json"; } sweep_run() { : >"$SWEEP/edits" env PATH="$ARRIVAL/stub:$PATH" CEREMONY_FORGE=github GH_FIXTURES="$SWEEP" ISSUEFLOW_NOW="$INOW" \ REPO=owner/repo LABELS_CONF="$ARRIVAL/labels.conf" \ bash "$ROOT/actions/issueflow-reconcile/issueflow-reconcile.sh" 2>&1 } # The board read is a precondition for the whole pass (#257). A failed read # cannot be inferred from an empty result: its status is the only fact that # separates an unreadable board from a clean one. Drive both through main(), # including gh's partial-pagination shape where stdout is non-empty on error. board_fixture="$SWEEP/repos_owner_repo_issues_state_open.json" printf '%s\n' "$GH_STUB_ERROR_BODY" >"$board_fixture.http-error" board_504_out="$(sweep_run)" board_504_rc=$? check "an issue-list 504 aborts the whole pass" 0 "" test "$board_504_rc" -eq 1 check "...names the board read's stderr" 0 \ "issueflow: could not read the issue board: $GH_STUB_STDERR" \ printf '%s\n' "$board_504_out" check "...writes no issue edit or comment" 1 "" test -s "$SWEEP/edits" check "...never reports the pass reconciled" 1 "" \ grep -qF 'issueflow: reconciled.' <<<"$board_504_out" board_silent_out="$(GH_STUB_STDERR="" sweep_run)" board_silent_rc=$? check "a silent issue-list failure still aborts" 0 "" test "$board_silent_rc" -eq 1 check "...renders the empty stderr as a fact" 0 \ 'issueflow: could not read the issue board: no error output' \ printf '%s\n' "$board_silent_out" check "...also writes nothing" 1 "" test -s "$SWEEP/edits" printf '[{"number":71}]\n' >"$board_fixture.http-error" partial_board_out="$(sweep_run)" partial_board_rc=$? check "partial pagination aborts the whole pass" 0 "" \ test "$partial_board_rc" -eq 1 check "...does not reconcile the returned first page" 1 "" \ grep -qF 'issue edit 71' "$SWEEP/edits" check "...does not report the truncated pass reconciled" 1 "" \ grep -qF 'issueflow: reconciled.' <<<"$partial_board_out" rm -f "$board_fixture.http-error" sweep_board '[]' empty_board_out="$(sweep_run)" empty_board_rc=$? check "a successful empty board stays green" 0 "" test "$empty_board_rc" -eq 0 check "...writes nothing" 1 "" test -s "$SWEEP/edits" check "...names the empty-board outcome" 0 'issueflow: no open issues.' \ printf '%s\n' "$empty_board_out" check "...still ends with byte-identical reconciled." 0 \ 'issueflow: reconciled.' printf '%s\n' "$(tail -n1 <<<"$empty_board_out")" 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" # --------------------------------------------------------------------------- # The ordering invariant (#247 D1): a skip implies ZERO writes, wherever in # the pass the failed read lives. Round 1 measured what the per-read guards # alone left standing — a pass could remove `stale`, or mint `needs-triage`, # and only then reach a guarded read, fail it, and report the issue as # skipped. The sweep said it had touched nothing while a write had landed: # the same false report #247 exists to close, one layer along. # # Every composition is driven TWICE against identical fixtures, differing # only in whether the late read answers. The healthy run is the control — it # proves the mutation is genuinely on this path, so the failing run's "no # edit" is a fact about the guard and not about a branch that never fired. # Executed through the sweep, because staging is a property of the pass. # --------------------------------------------------------------------------- ORDER="$TMP/order" mkdir -p "$ORDER" cp "$SWEEP/repos_owner_repo_pulls_state_open.json" "$SWEEP/repos_owner_repo_pulls_state_closed.json" "$ORDER/" order_board() { printf '%s\n' "$1" >"$ORDER/repos_owner_repo_issues_state_open.json"; } order_fixture() { # $1 issue, $2 labels JSON, $3 body jq -n --argjson n "$1" --argjson labels "$2" --arg body "${3:-}" \ --arg at "$(iso_at $((INOW - 10 * 86400)))" \ '{number: $n, created_at: $at, user: {login: "triage-one"}, labels: $labels, assignees: [], body: $body}' \ >"$ORDER/repos_owner_repo_issues_$1.json" } order_run() { : >"$ORDER/edits" env PATH="$ARRIVAL/stub:$PATH" CEREMONY_FORGE=github GH_FIXTURES="$ORDER" ISSUEFLOW_NOW="$INOW" \ REPO=owner/repo LABELS_CONF="$ARRIVAL/labels.conf" \ bash "$ROOT/actions/issueflow-reconcile/issueflow-reconcile.sh" 2>&1 } # The late read fails, or answers. `guarded_read` is what turns either into a # skip, so which endpoint carries the sentinel is what picks the composition. order_breaks() { printf '%s\n' "$GH_STUB_ERROR_BODY" >"$ORDER/repos_owner_repo_issues_$1_$2.json.http-error"; } order_heals() { rm -f "$ORDER/repos_owner_repo_issues_$1_$2.json.http-error"; } # A skip must leave no trace of the staged effect: not the write, and not the # log line that would have announced it. Both halves, because a landed write # under a "skipped" line and a "reconciled" line over no write are the same # lie told from opposite ends. order_wrote() { grep -qF "issue $2 $1" "$ORDER/edits"; } # -- 1. unstale, then a failed activity read (the round's first composition) - # `needs-ruling` heals an applied `stale` off before the tail reads the # issue's activity. The read is two statements later; the write is already # gone. order_fixture 80 '[{"name":"ready"},{"name":"needs-ruling"},{"name":"stale"}]' order_board '[{"number":80}]' order_heals 80 comments healthy_unstale="$(order_run)" check "the control: a healthy pass really does unstale a pending ruling" 0 "" \ order_wrote 80 edit check "...and says so" 0 "issueflow: #80: unstale (a ruling is pending)" \ printf '%s\n' "$healthy_unstale" order_breaks 80 comments broken_unstale="$(order_run)" check "a failed activity read skips the unstale composition" 0 \ "issueflow: #80: skipped this pass — could not read its activity history: $GH_STUB_STDERR" \ printf '%s\n' "$broken_unstale" check "...and the stale label is still on the issue" 1 "" order_wrote 80 edit check "...and nothing claims it came off" 1 "" \ grep -qF 'unstale (a ruling is pending)' <<<"$broken_unstale" # -- 2. ADD_NEEDS_TRIAGE, then a failed activity read (the second) ----------- # The mint falls through — unlike FLAG_CONFLICT, which returns — into the # same tail. crew#329's own label, written and then disowned by the log. order_fixture 81 '[{"name":"enhancement"},{"name":"needs-ruling"}]' order_board '[{"number":81}]' order_heals 81 comments healthy_mint="$(order_run)" check "the control: a healthy pass really does mint needs-triage here" 0 "" \ order_wrote 81 edit check "...and says so" 0 "issueflow: #81: needs-triage (no queue state)" \ printf '%s\n' "$healthy_mint" order_breaks 81 comments broken_mint="$(order_run)" check "a failed activity read skips the needs-triage composition" 0 \ "issueflow: #81: skipped this pass — could not read its activity history: $GH_STUB_STDERR" \ printf '%s\n' "$broken_mint" check "...and crew#329's label is not written on the way out" 1 "" \ order_wrote 81 edit check "...and nothing claims it was" 1 "" \ grep -qF '#81: needs-triage (no queue state)' <<<"$broken_mint" # -- 3. the blockers->ready flip, then a failed COMMENTS read ---------------- # The read that guards this branch is the marker check ahead of the parse # echo: a broken comments endpoint skips there, before the echo or the flip # is staged. The timeline is no longer an input on this path at all (#284 # D1 — the ruling clock reads comments alone), so the unreadable-timeline # case moved from "skips everything" to its own pin below. printf '%s\n' '{"number":82,"state":"closed"}' \ >"$ORDER/repos_owner_repo_issues_82.json" order_fixture 83 '[{"name":"blocked"},{"name":"needs-ruling"}]' 'Blocked by #82.' order_board '[{"number":83}]' order_heals 83 comments order_heals 83 timeline healthy_flip="$(order_run)" check "the control: a healthy pass really does flip cleared blockers to ready" 0 \ "issueflow: #83: blockers closed -> ready" printf '%s\n' "$healthy_flip" check "...writing the label edit" 0 "" order_wrote 83 edit check "...and posting the blockers-cleared comment" 0 "" order_wrote 83 comment order_breaks 83 comments broken_flip="$(order_run)" check "a failed comments read skips the blockers->ready composition" 0 \ "issueflow: #83: skipped this pass — could not read its comments: $GH_STUB_STDERR" \ printf '%s\n' "$broken_flip" check "...leaving the issue blocked" 1 "" order_wrote 83 edit check "...with no comment posted about it" 1 "" order_wrote 83 comment check "...and nothing claiming the flip happened" 1 "" \ grep -qF 'blockers closed -> ready' <<<"$broken_flip" # The read this path no longer takes cannot skip it (#284): with comments # healthy and the timeline broken, the flip commits, and only the ruling # ladder's own soft-failing read goes without — no verdict is invented, and # no unrelated write is held hostage by an input the clocks stopped reading. order_heals 83 comments order_breaks 83 timeline narrowed_flip="$(order_run)" check "a failed timeline read no longer skips the flip" 1 "" \ grep -qF 'skipped this pass' <<<"$narrowed_flip" check "...the flip commits" 0 "" order_wrote 83 edit check "...and the ruling ladder says what it could not read" 0 \ "issueflow: #83: ruling timeline unreadable — no verdict invented this pass" \ printf '%s\n' "$narrowed_flip" # -- 4. a posted nudge, then a failed COMMENTS read ------------------------- # The comment-only half of the class: the epic nudge's own marker check is # the read that fails, so the nudge is never staged and the skip reports the # truth. A comment is as much a mutation as a label — it is the thing # markers exist to make idempotent. order_fixture 84 '[{"name":"epic"},{"name":"needs-ruling"}]' \ '## Task list - [x] #82' order_board '[{"number":84}]' order_heals 84 comments order_heals 84 timeline healthy_nudge="$(order_run)" check "the control: a healthy pass really does nudge a completed epic" 0 \ "issueflow: #84: completed epic nudged" printf '%s\n' "$healthy_nudge" check "...by posting a comment" 0 "" order_wrote 84 comment order_breaks 84 comments broken_nudge="$(order_run)" check "a failed comments read skips the epic-nudge composition" 0 \ "issueflow: #84: skipped this pass — could not read its comments: $GH_STUB_STDERR" \ printf '%s\n' "$broken_nudge" check "...and the nudge comment is never posted" 1 "" order_wrote 84 comment check "...and nothing claims it was" 1 "" \ grep -qF 'completed epic nudged' <<<"$broken_nudge" # The narrowed surface again (#284): a broken timeline neither skips nor # suppresses the nudge; the ruling ladder alone goes without a verdict. order_heals 84 comments order_breaks 84 timeline narrowed_nudge="$(order_run)" check "a failed timeline read no longer skips the epic nudge" 1 "" \ grep -qF 'skipped this pass' <<<"$narrowed_nudge" check "...the nudge commits" 0 "" order_wrote 84 comment # -- the skip is still just a skip: counted, tailed, and green (D4, D6, D7) -- check "a mutation-bearing composition that skips is still not a crash" 1 "" \ grep -qF 'reconcile failed' <<<"$broken_flip" check "...is still counted in the D6 tail" 0 \ 'issueflow: 1 issue skipped this pass on an unreadable fact: #83' \ printf '%s\n' "$broken_flip" order_board '[{"number":83}]' order_run >/dev/null check "...and still leaves the job green (D7)" 0 "" test $? -eq 0 # -- the two board flags (#293): the deliverable key, normalized ------------ # The 2026-08-04 miss spelled one deliverable two ways, so exact-prefix # matching is specified away (D2). These pin the normalization itself. count_lines() { deliverable_keys | grep -c .; } keys_of() { # title on stdin -> its keys, each bracketed so `check` matches exactly # ANCHORED, because `check` compares its expectation as a substring: a bare # `issueflow-reconcile` expectation is satisfied by `issueflow-reconcile.test` # too, so the multi-extension row below stayed green under a normalization # stripping only the last extension — it asserted nothing it was named for. # Bracketing each key makes every row here fail for its own reason. deliverable_keys | sed 's/.*/[&]/' } check "the em-dash prefix is the key" 0 "[issueflow-reconcile]" \ keys_of <<<"issueflow-reconcile — the ruling clock counts assigned" check "a leading actions/ segment comes off" 0 "[issueflow-reconcile]" \ keys_of <<<"actions/issueflow-reconcile — a failed board read" check "...and so does .github/" 0 "[labeler]" \ keys_of <<<".github/labeler.yml — one wrong answer left by D4" check "...and lib/" 0 "[attention]" keys_of <<<"lib/attention.sh — the target" check "...and bin/" 0 "[decide]" keys_of <<<"bin/decide.sh — the door" check "every extension comes off, not just the last" 0 "[issueflow-reconcile]" \ keys_of <<<"issueflow-reconcile.test.sh — the pre-read is unpinned" check "the key folds case" 0 "[triage]" keys_of <<<"TRIAGE.md — the bullet" check "a + title carries both segments" 0 $'[triage]\n[releases]' \ keys_of <<<"TRIAGE.md + RELEASES.md — a standing window is a graph" # A path segment the rule does not name stays part of the key: the strip list # is closed on purpose (D2), so `test/issueflow-reconcile.test.sh` is its own # deliverable and not the action it exercises. check "an unlisted path segment stays in the key" 0 "[test/issueflow-reconcile]" \ keys_of <<<"test/issueflow-reconcile.test.sh — the pre-read" # One issue answers a SET. Normalization is many-to-one by design, so a `+` # title can spell one deliverable twice — a deliverable and its test named # together is the ordinary shape here, not an exotic one — and a repeated key # makes the chain scan find the issue adjacent to ITSELF. check "a + title whose segments normalize to one key answers that key once" 0 \ "[issueflow-reconcile]" \ keys_of <<<"issueflow-reconcile.sh + issueflow-reconcile.test.sh — one deliverable" check "...and answers it exactly once, not twice" 0 "1" \ count_lines <<<"issueflow-reconcile.sh + issueflow-reconcile.test.sh — one deliverable" check "...and the path prefix folds onto the bare spelling the same way" 0 "1" \ count_lines <<<"actions/issueflow-reconcile + issueflow-reconcile.sh — still one" # No em dash, no key. Inventing one out of prose is the guessing this sweep # never does; the malformed title is triage's own contract to enforce. The # emptiness is asserted through grep's exit, since `check` cannot assert an # empty expectation. check "a title with no em dash names no deliverable" 1 "" \ grep -q . < <(deliverable_keys <<<"a title that names nothing") # -- the collision decision: a chain, never a fan (#288 D3) ------------------ # Sourced helpers, not `bash -c`: a subshell started with -c has none of these # functions, and a pipeline ending in grep would then answer "no match" from a # command-not-found and pass a negative case for the wrong reason. collision_chain() { collision_key_index | collision_flags; } collision_flags_issue() { collision_chain | grep -q "^$1"; } window_flags_issue() { # $1 issue, $2 gate, $3 carriers; records on stdin window_flags "$2" "$3" | grep -qx "$1" } collision_board=$'253\tclaimed\tissueflow-reconcile — release-init\n257\tclaimed\tactions/issueflow-reconcile — a failed board read\n284\tready\tissueflow-reconcile — the ruling clock' check "three issues on one deliverable chain, each naming the newest below it" 0 \ $'257\tissueflow-reconcile=253\n284\tissueflow-reconcile=257' \ collision_chain <<<"$collision_board" check "...so the oldest carrier is never itself flagged" 1 "" \ collision_flags_issue 253 <<<"$collision_board" check "a lone carrier draws nothing" 0 "" \ collision_chain <<<$'284\tready\tissueflow-reconcile — alone' # `blocked` is the GOAL state of #288's rule; flagging it reports the fix as # the defect. Both legs of the test plan, on one board. check "two blocked twins are the declared chain, not a collision" 0 "" \ collision_chain \ <<<$'264\tblocked\tTRIAGE.md — one\n266\tblocked\tTRIAGE.md — two' check "a blocked twin does not carry a ready one's edge either" 0 "" \ collision_chain \ <<<$'264\tblocked\tTRIAGE.md — one\n266\tready\tTRIAGE.md — two' check "an epic carrying the key is outside the claimable set (#288 D6)" 0 "" \ collision_chain \ <<<$'264\tepic\tTRIAGE.md — one\n266\tready\tTRIAGE.md — two' check "a post-merge carrier is outside it too" 0 "" \ collision_chain \ <<<$'264\tpost-merge\tTRIAGE.md — one\n266\tready\tTRIAGE.md — two' # The #284 shape, stated as its own case (test plan): a `claimed` issue whose # PR is already in flight is the STRONGEST collision on the board, not a # weaker one, and the flag reads the queue label rather than the PR link. check "a claimed carrier with a PR in flight still carries the collision" 0 \ $'284\tissueflow-reconcile=253' \ collision_chain \ <<<$'253\tclaimed,scope:labels\tissueflow-reconcile — release-init\n284\tready\tissueflow-reconcile — the ruling clock' # One issue, two colliding deliverables: ONE offending state, one comment (D4). check "a multi-file title folds its collisions into one state" 0 \ $'295\treleases=292,triage=264' \ collision_chain \ <<<$'264\tready\tTRIAGE.md — one\n292\tready\tRELEASES.md — two\n295\tready\tTRIAGE.md + RELEASES.md — three' # ...and an issue can never be its own carrier. A `+` title whose segments # normalize to one key contributed that key twice, and the chain scan, which # reads adjacent rows within a key, then found the issue beside itself: the # comment asked #402 to declare `Blocked by #402`. check "a self-folding + title never chains an issue to its own number" 0 "" \ collision_chain \ <<<$'402\tready\tissueflow-reconcile.sh + issueflow-reconcile.test.sh — one deliverable' check "...and two such carriers chain once, to each other" 0 \ $'284\tissueflow-reconcile=257' \ collision_chain \ <<<$'257\tready\tissueflow-reconcile.sh + issueflow-reconcile.test.sh — one\n284\tready\tactions/issueflow-reconcile — two' check "...with the older carrier still asked for nothing" 1 "" \ collision_flags_issue 257 \ <<<$'257\tready\tissueflow-reconcile.sh + issueflow-reconcile.test.sh — one\n284\tready\tactions/issueflow-reconcile — two' # -- the window decision (#292 D1) ------------------------------------------ window_board=$'249\tblocked,release\tRelease 0.6.0 — the board empties\n253\tclaimed\tissueflow-reconcile — a member\n264\tready\tTRIAGE.md — a non-member\n270\tepic\tsome epic — exempt\n271\tpost-merge\tsome item — exempt\n272\tblocked\tsome issue — already placed' check "a ready non-member is flagged during a standing window" 0 "264" \ window_flags "253" "249" <<<"$window_board" check "...and a gate member is not" 1 "" \ window_flags_issue 264 $'253\n264' 249 <<<"$window_board" check "...nor an epic (#292 D1 exempts it by name)" 1 "" \ window_flags_issue 270 253 249 <<<"$window_board" check "...nor a post-merge issue" 1 "" \ window_flags_issue 271 253 249 <<<"$window_board" check "...nor a blocked issue, which is already placed behind something" 1 "" \ window_flags_issue 272 253 249 <<<"$window_board" # The release issue is the graph's SINK (#292 D2), so it can never be its own # non-member — even when its own labels would otherwise admit it. check "the window carrier is never flagged as its own non-member" 1 "" \ window_flags_issue 249 253 249 \ <<<$'249\tready,release\tRelease 0.6.0 — the board empties' check "no standing window means no flag at all" 0 "" \ window_flags "" "" <<<"$window_board" check "two standing windows render as one state" 0 "#249, #250" window_state $'249\n250\n' # ONE reading of `unblocked` across both flags. D2 as corrected glosses the # word as "carrying `ready` or `claimed`" and D3b says D3 uses that gloss and # names the domain as the claimable set, so an issue that is `needs-triage` or # carries no queue label at all is outside BOTH flags. Excluding only # `blocked`/`epic`/`post-merge` admitted them, and the second case is the one # that showed: the same pass adds `needs-triage` to an unlabeled issue and # then tells it about a membership call made at mint time. scope_board=$'249\tblocked,release\tRelease 0.6.0 — the board empties\n253\tclaimed\tissueflow-reconcile — a member\n400\tneeds-triage\tTRIAGE.md — not through the door yet\n401\t\tTRIAGE.md — no queue label at all\n402\tclaimed\tREVIEWER.md — claimable, and a non-member' check "a needs-triage issue is not in the window flag's domain" 1 "" \ window_flags_issue 400 253 249 <<<"$scope_board" check "...nor is an issue carrying no queue label at all" 1 "" \ window_flags_issue 401 253 249 <<<"$scope_board" check "...while the claimable non-member beside them still flags" 0 "402" \ window_flags "253" "249" <<<"$scope_board" # The same word, asserted through the other flag, so the two can never drift # apart again without a red. check "the collision flag reads that word identically" 0 "" \ collision_chain <<<$'400\tneeds-triage\tTRIAGE.md — one\n401\t\tTRIAGE.md — two' check "...and both flags answer one shared predicate" 1 "" \ unblocked_claimable "needs-triage" check "...which admits ready and claimed, and nothing else" 0 "" \ unblocked_claimable "claimed,scope:labels" # -- the 2026-08-04 board, replayed whole (D5) ------------------------------ # The corpus the operator ruled on. Both flags are decided over the WHOLE # board, so a sourced decision probe cannot exercise the gather — these run # the script as a subprocess behind the PATH-stubbed gh, #91's lesson applied # to a board-wide check. BOARD="$TMP/board" mkdir -p "$BOARD" cp "$ARRIVAL/fixtures/repos_owner_repo_pulls_state_open.json" "$BOARD/repos_owner_repo_pulls_state_open.json" cp "$ARRIVAL/fixtures/repos_owner_repo_pulls_state_closed.json" "$BOARD/repos_owner_repo_pulls_state_closed.json" board_issue() { # $1 number, $2 labels(csv), $3 title, $4 body, $5 assignee count local labels_json labels_json="$(printf '%s' "$2" | tr ',' '\n' \ | jq -R . | jq -sc 'map(select(. != "") | {name: .})')" jq -n --argjson n "$1" --argjson labels "$labels_json" --arg t "$3" \ --arg b "${4:-}" --argjson a "${5:-0}" --arg at "$(iso_at "$INOW")" \ '{number: $n, state: "open", title: $t, body: $b, labels: $labels, created_at: $at, user: {login: "triage-one"}, assignees: (if $a > 0 then [{login: "builder-bot"}] else [] end)}' \ >"$BOARD/repos_owner_repo_issues_$1.json" } board_assemble() { # numbers… -> the open-issue list, with fresh comment threads local n for n in "$@"; do printf '[]\n' >"$BOARD/repos_owner_repo_issues_${n}_comments.json"; done # shellcheck disable=SC2016 # the filename expansion belongs to the loop below for n in "$@"; do cat "$BOARD/repos_owner_repo_issues_$n.json"; done \ | jq -sc . >"$BOARD/repos_owner_repo_issues_state_open.json" } flag_count() { # $1 = collision|window, $2 = a sweep's output grep -c ": $1 flag — " <<<"$2" } board_run() { : >"$BOARD/edits" env PATH="$ARRIVAL/stub:$PATH" CEREMONY_FORGE=github GH_FIXTURES="$BOARD" ISSUEFLOW_NOW="$INOW" \ REPO=owner/repo LABELS_CONF="$ARRIVAL/labels.conf" \ bash "$ROOT/actions/issueflow-reconcile/issueflow-reconcile.sh" 2>&1 } # The morning shape, as the board actually stood at the 10:28:54Z mint: # #253 `claimed` with no open PR (#285 was not created until 10:49:16Z), # #257 `ready` since the evening before, #284 minted `ready` into both of # them — six `ready` non-members against a standing gate, and one deliverable # carried three times in two spellings. board_issue 249 blocked,release 'Release 0.6.0 — the board empties into the tag' \ 'Blocked by #253.' board_issue 253 claimed 'issueflow-reconcile — a release epic announces its own release-init' '' 1 board_issue 257 ready 'actions/issueflow-reconcile — a failed board read sweeps an empty board' board_issue 264 ready 'TRIAGE.md — the no-assignee clause scopes to the flag' # shellcheck disable=SC2016 # the backticks are the real issue title's Markdown board_issue 266 ready 'TRIAGE.md — the epic task-list heading is literally `## Task list`' board_issue 276 ready 'REVIEWER.md — the green-check precondition' board_issue 281 ready 'LABELS.md — the attention row' # The blocked twin on the same key, on the board rather than in a decision # probe: it is neither a collision flag nor one of the six. board_issue 282 blocked 'TRIAGE.md — the two comment links come out' 'Blocked by #266.' # shellcheck disable=SC2016 # the backticks are the real issue title's Markdown board_issue 284 ready 'issueflow-reconcile — the issue-side ruling clock counts `assigned`' board_assemble 249 253 257 264 266 276 281 282 284 morning_out="$(board_run)" morning_rc=$? check "the morning board replays green" 0 "" test "$morning_rc" -eq 0 # D5's named pair, and the whole reason the key is normalized: #257 spells the # deliverable `actions/issueflow-reconcile`, #284 spells it bare. check "#284 draws the collision flag, naming #257 across the spelling variance" 0 \ 'issueflow: #284: collision flag — issueflow-reconcile=257' \ printf '%s\n' "$morning_out" check "...and #257 names #253, so the flag asks for a chain and not a fan" 0 \ 'issueflow: #257: collision flag — issueflow-reconcile=253' \ printf '%s\n' "$morning_out" check "...while #253, the oldest carrier, is asked for nothing" 1 "" \ grep -qF 'issueflow: #253: collision flag' <<<"$morning_out" check "the TRIAGE.md pair chains the same way" 0 \ 'issueflow: #266: collision flag — triage=264' printf '%s\n' "$morning_out" check "...while the blocked twin beside them is the goal state, not a flag" 1 "" \ grep -qF 'issueflow: #282: collision flag' <<<"$morning_out" check "the morning board draws exactly three collision flags" 0 "3" \ flag_count collision "$morning_out" # D3's corpus: the six `ready` non-members that raced the emptying gate. for nonmember in 257 264 266 276 281 284; do check "#$nonmember is flagged as an unblocked non-member under #249" 0 \ "issueflow: #$nonmember: window flag — an unblocked non-member under #249" \ printf '%s\n' "$morning_out" done check "the morning board draws exactly six window flags" 0 "6" \ flag_count window "$morning_out" check "...and never flags the gate member holding the window open" 1 "" \ grep -qF 'issueflow: #253: window flag' <<<"$morning_out" check "...nor the blocked issue already placed behind something" 1 "" \ grep -qF 'issueflow: #282: window flag' <<<"$morning_out" check "...nor the release issue that carries the window" 1 "" \ grep -qF 'issueflow: #249: window flag' <<<"$morning_out" # D1: comments only. Not "no unexpected edit" — no edit at all. check "the whole replay writes no label and no state (D1)" 1 "" \ grep -qF 'issue edit' "$BOARD/edits" check "...and no new label is ever proposed" 1 "" \ grep -qE 'add-label (collision|window)' "$BOARD/edits" check "the collision comment cites the rule it is asking for" 0 "" \ grep -qF 'collision edge' "$BOARD/edits" check "...and names #288 as its authority" 0 "" grep -qF '#288 makes it unconditional' "$BOARD/edits" check "the window comment names #292's invariant" 0 "" \ grep -qF "#292's invariant" "$BOARD/edits" # shellcheck disable=SC2016 # backticks are the comment body's own Markdown check "...and states the subset rule with its exemptions" 0 "" \ grep -qF 'the `ready` set is a subset of the gate' "$BOARD/edits" check "both comments carry idempotency markers (D4)" 0 "" \ grep -qF ' said already" '[{"user": {"login": "sweep-bot"}, "body": $b}]' \ >"$BOARD/repos_owner_repo_issues_284_comments.json" jq -n --arg b " said already" '[{"user": {"login": "sweep-bot"}, "body": $b}]' \ >"$BOARD/repos_owner_repo_issues_276_comments.json" resweep_out="$(board_run)" check "a standing collision is silent on the next sweep (D4)" 1 "" \ grep -qF 'issueflow: #284: collision flag' <<<"$resweep_out" check "a standing window non-membership is silent too" 1 "" \ grep -qF 'issueflow: #276: window flag' <<<"$resweep_out" check "...while every other flag on the board still speaks" 0 "2" \ flag_count collision "$resweep_out" check "...and the window flags with it" 0 "5" \ flag_count window "$resweep_out" # The value-keyed marker's whole point: a state that CHANGED speaks, even # though this family has already had its say on the thread (#252's A -> B -> A). jq -n --arg b " an older, different state" '[{"user": {"login": "sweep-bot"}, "body": $b}]' \ >"$BOARD/repos_owner_repo_issues_284_comments.json" changed_out="$(board_run)" check "a changed collision state speaks over this family's last word" 0 \ 'issueflow: #284: collision flag — issueflow-reconcile=257' \ printf '%s\n' "$changed_out" # And a family only ever silences itself: the blocked-parse echo's marker # lives on many of these threads and must not read as either flag's. jq -n --arg b " a different family entirely" '[{"user": {"login": "sweep-bot"}, "body": $b}]' \ >"$BOARD/repos_owner_repo_issues_284_comments.json" foreign_out="$(board_run)" check "another family's marker never silences the collision flag" 0 \ 'issueflow: #284: collision flag — issueflow-reconcile=257' \ printf '%s\n' "$foreign_out" # The direction that is actually load-bearing, and that the case above cannot # reach: a foreign family's marker landing AFTER this flag's own must not make # the flag speak again. Family-blind, "the last marker on the thread" is the # blocked-parse echo's, which is not this state's marker, and the flag # re-posts a comment that already stands — the noise D4's dedup exists to # stop, on the one thread where three families all have something to say. jq -n --arg b " this flag's own last word" \ --arg c " a different family, later on the thread" \ '[{"user": {"login": "sweep-bot"}, "body": $b}, {"user": {"login": "sweep-bot"}, "body": $c}]' \ >"$BOARD/repos_owner_repo_issues_284_comments.json" later_foreign_out="$(board_run)" check "a foreign family's LATER marker never makes the flag re-post" 1 "" \ grep -qF 'issueflow: #284: collision flag' <<<"$later_foreign_out" check "...while every other collision on the board still speaks" 0 "2" \ flag_count collision "$later_foreign_out" # -- the post-ruling board draws nothing (D5's must-not-flag leg) ----------- # The same issues after triage placed them: the TRIAGE.md triple chained # oldest-first, the reconciler chain chained, and every one of them a gate # member. Every flag above must go quiet, or the flag is reporting the fix. board_issue 249 blocked,release 'Release 0.6.0 — the board empties into the tag' \ 'Blocked by #253, #257, #264, #266, #276, #281, #282, #284.' board_issue 253 claimed 'issueflow-reconcile — a release epic announces its own release-init' '' 1 board_issue 257 blocked 'actions/issueflow-reconcile — a failed board read sweeps an empty board' \ 'Blocked by #253.' board_issue 264 ready 'TRIAGE.md — the no-assignee clause scopes to the flag' # shellcheck disable=SC2016 # the backticks are the real issue title's Markdown board_issue 266 blocked 'TRIAGE.md — the epic task-list heading is literally `## Task list`' \ 'Blocked by #264.' board_issue 276 ready 'REVIEWER.md — the green-check precondition' board_issue 281 ready 'LABELS.md — the attention row' board_issue 282 blocked 'TRIAGE.md — the two comment links come out' 'Blocked by #266.' # shellcheck disable=SC2016 # the backticks are the real issue title's Markdown board_issue 284 blocked 'issueflow-reconcile — the issue-side ruling clock counts `assigned`' \ 'Blocked by #257.' board_assemble 249 253 257 264 266 276 281 282 284 ruled_out="$(board_run)" check "the post-ruling board replays green" 0 "" test $? -eq 0 check "...and draws no collision flag at all" 1 "" \ grep -qF ': collision flag' <<<"$ruled_out" check "...and no window flag either" 1 "" grep -qF ': window flag' <<<"$ruled_out" check "...and still reports a whole pass" 0 'issueflow: reconciled.' \ printf '%s\n' "$ruled_out" # -- an emptied gate leaves the window flag dormant (test plan) ------------- # A gate DECLARATION never empties: #249 names fifteen members and still names # fifteen after all fifteen close. So the precondition is the gate's OPEN # members, not its parse — read straight off the board, which already is the # open set. Under the declaration reading the release issue, now `ready`, is # itself an open unblocked non-`epic` non-member, and D3 would flag the sink # at the exact moment the window ends. board_issue 249 ready,release 'Release 0.6.0 — the board empties into the tag' \ 'Blocked by #218, #230, #232, #236, #237, #238, #241, #242, #247, #248, #251, #252, #253, #254, #257.' board_issue 264 ready 'TRIAGE.md — the no-assignee clause scopes to the flag' # shellcheck disable=SC2016 # the backticks are the real issue title's Markdown board_issue 266 ready 'TRIAGE.md — the epic task-list heading is literally `## Task list`' board_assemble 249 264 266 empty_gate_out="$(board_run)" check "a fifteen-member declaration with every member closed leaves D3 dormant" 1 "" \ grep -qF ': window flag' <<<"$empty_gate_out" check "...and the release issue is never flagged as its own non-member" 1 "" \ grep -qF 'issueflow: #249' <<<"$empty_gate_out" check "...while the collision flag beside it is unaffected" 0 \ 'issueflow: #266: collision flag — triage=264' printf '%s\n' "$empty_gate_out" # -- both carriers claimed, both with their own PRs open (test plan) -------- # The ninety-three minutes from #285's creation to its merge: under D2's # struck parenthetical the live collision went silent for all of them, so # whether the flag ever fired depended on where the sweep tick fell relative # to a builder opening a PR. It fires on the board, and only on the board. printf '%s\n' \ '{"data":{"repository":{"pullRequests":{"nodes":[{"number":285,"body":"","closingIssuesReferences":{"nodes":[{"number":253}]}},{"number":286,"body":"","closingIssuesReferences":{"nodes":[{"number":284}]}}],"pageInfo":{"hasNextPage":false,"endCursor":null}}}}}' \ >"$BOARD/repos_owner_repo_pulls_state_open.json" board_issue 253 claimed 'issueflow-reconcile — a release epic announces its own release-init' '' 1 # shellcheck disable=SC2016 # the backticks are the real issue title's Markdown board_issue 284 claimed 'issueflow-reconcile — the issue-side ruling clock counts `assigned`' '' 1 board_assemble 253 284 both_claimed_out="$(board_run)" check "two claimed carriers, both with PRs in flight, still draw the newer's flag" 0 \ 'issueflow: #284: collision flag — issueflow-reconcile=253' \ printf '%s\n' "$both_claimed_out" check "...and both live claims are left exactly as they were" 1 "" \ grep -qF 'issue edit' "$BOARD/edits" # The same board with the newer side `ready`, so the queue label is visibly # the only input the flag has. # shellcheck disable=SC2016 # the backticks are the real issue title's Markdown board_issue 284 ready 'issueflow-reconcile — the issue-side ruling clock counts `assigned`' board_assemble 253 284 in_flight_out="$(board_run)" check "a claimed carrier with an open PR draws the ready issue's flag too" 0 \ 'issueflow: #284: collision flag — issueflow-reconcile=253' \ printf '%s\n' "$in_flight_out" # -- D3b's headline case, on the WINDOW side (acceptance criterion) ---------- # The criterion says a `claimed` non-member is flagged whether or not it has # an open PR, and it is the line the 18:11Z ruling turned on — triage had # excluded the open-PR case at 18:06Z and corrected it five minutes later. # The collision fixtures above cover the PR-liveness question for their flag; # this covers it for the other one. #292's charge against the third state is # that a non-member competes with gate members for builders, and a non-member # holding a builder AND a review round is that competition realized. printf '%s\n' \ '{"data":{"repository":{"pullRequests":{"nodes":[{"number":403,"body":"","closingIssuesReferences":{"nodes":[{"number":402}]}}],"pageInfo":{"hasNextPage":false,"endCursor":null}}}}}' \ >"$BOARD/repos_owner_repo_pulls_state_open.json" board_issue 249 blocked,release 'Release 0.6.0 — the board empties into the tag' \ 'Blocked by #253.' board_issue 253 claimed 'issueflow-reconcile — a member holding the window open' '' 1 board_issue 402 claimed 'REVIEWER.md — a non-member with a builder and a round' '' 1 board_assemble 249 253 402 nonmember_pr_out="$(board_run)" check "a claimed non-member with an open PR still draws the window flag" 0 \ 'issueflow: #402: window flag — an unblocked non-member under #249' \ printf '%s\n' "$nonmember_pr_out" check "...and the gate member beside it, also claimed with a PR, is not" 1 "" \ grep -qF 'issueflow: #253: window flag' <<<"$nonmember_pr_out" check "...and the live claim is left exactly as it was" 1 "" \ grep -qF 'issue edit' "$BOARD/edits" check "...one window flag on the board, and only one" 0 "1" \ flag_count window "$nonmember_pr_out" # -- flagged, resolved, recreated unchanged: silent, and specified ---------- # D4's boundary, asserted rather than left accidental. Nothing is posted at # the resolution — D1 admits no comment there — so the thread's last word is # still the state itself and an identical return says nothing new. #292 D2b # owns the recurrence: a board state violating the window invariants is # triage's to repair in the tick it is seen, and triage has already been told # about this one. jq -n --arg b " flagged once" '[{"user": {"login": "sweep-bot"}, "body": $b}]' \ >"$BOARD/repos_owner_repo_issues_284_comments.json" board_assemble_keep() { # board_assemble without wiping the seeded threads local n for n in "$@"; do cat "$BOARD/repos_owner_repo_issues_$n.json"; done \ | jq -sc . >"$BOARD/repos_owner_repo_issues_state_open.json" } board_assemble_keep 284 resolved_out="$(board_run)" check "the collision resolves when its carrier leaves the board" 1 "" \ grep -qF ': collision flag' <<<"$resolved_out" check "...and the resolution itself writes nothing at all" 1 "" test -s "$BOARD/edits" board_assemble_keep 253 284 recreated_out="$(board_run)" check "an unchanged state recreated is silent — D4's stated boundary" 1 "" \ grep -qF 'issueflow: #284: collision flag' <<<"$recreated_out" # -- today's board draws nothing (the post-ruling shape, live) -------------- # #249 the `blocked` sink, this issue `claimed` with no open PR and a gate # member, #307 and #311 `blocked`. The `claimed` member is the case D3b would # flag if membership were read wrong, which is why it is here. printf '%s\n' \ '{"data":{"repository":{"pullRequests":{"nodes":[],"pageInfo":{"hasNextPage":false,"endCursor":null}}}}}' \ >"$BOARD/repos_owner_repo_pulls_state_open.json" board_issue 249 blocked,release 'Release 0.6.0 — the board empties into the tag' \ 'Blocked by #293, #307.' board_issue 293 claimed 'issueflow-reconcile — the sweep flags what the window and collision rules forbid' '' 1 board_issue 307 blocked 'test/issueflow-reconcile.test.sh — the ruling pre-read is unpinned' \ 'Blocked by #293.' board_issue 311 blocked 'docs/CONSUMERS.md — a deliberate non-member' 'Blocked by #249.' board_assemble 249 293 307 311 today_out="$(board_run)" check "today's board draws no collision flag" 1 "" grep -qF ': collision flag' <<<"$today_out" check "...and no window flag: the claimed member is a member" 1 "" \ grep -qF ': window flag' <<<"$today_out" check "...and still reports a whole pass" 0 'issueflow: reconciled.' \ printf '%s\n' "$today_out" # -- the invariant is enforced at the source, not remembered ---------------- # Staging only holds while every mutation goes through run(). A future call # site reaching gh directly would reopen this hole silently, so it is pinned # here rather than left to review — the shape lib/ruling.sh already uses for # #50 D9. reconcile_opened_issue is deliberately exempt: it runs outside the # per-issue subshell, under live errexit, and stages nothing (#247 D8). # The verbs are the shim's now, not gh's (#188, and every remaining call site # ported by #198) — pinning `gh issue` here would pin a string this surface no # longer contains and pass vacuously forever. mutation_calls() { grep -nE '(^|[^_[:alnum:]])forge_issue_(edit|comment)' \ "$ROOT/actions/issueflow-reconcile/issueflow-reconcile.sh" "$ROOT/lib/ruling.sh" \ | grep -vE '^\S+:[0-9]+: *#' || true } # shellcheck disable=SC2016 # positional parameters belong to bash -c check "every issue mutation on this surface goes through run()" 0 "" \ bash -c 'while IFS= read -r line; do [ -n "$line" ] || continue case "$line" in *"run forge_issue_"*) ;; *) printf "unstaged mutation: %s\n" "$line"; exit 1 ;; esac done <<<"$1"' _ "$(mutation_calls)" check "...and the pin sees the call sites it is guarding" 0 "" \ test "$(mutation_calls | wc -l)" -ge 8 # -- the gather->consumer seam, at main() granularity (#198) ----------------- # Every MERGED_REF_PR_RECORDS fixture above assigns the variable DIRECTLY, so # they prove post_merge_pr_for_issue given three columns and pass unchanged if # the gather emits two. The seam the 0.6.0 resolution actually decides is the # one none of them span, so it is driven here through the real REST gather. # 1. The out-of-order pair. crew#176's shape: the HIGHER PR number merged # EARLIER, so number order and merge order disagree. A two-column emit # leaves $3 empty, every sort key ties, and the tie-break silently answers # the highest number — the #242 fix undone with no error and no red. printf '%s\n' \ '[{"number":184,"body":"Refs #42","merged_at":"2026-07-30T19:05:16Z"},{"number":182,"body":"Refs #42","merged_at":"2026-07-30T19:05:18Z"}]' \ >"$ARRIVAL/fixtures/repos_owner_repo_pulls_state_closed.json" printf '[]\n' >"$ARRIVAL/fixtures/repos_owner_repo_pulls_state_open.json" printf '[{"number":42}]\n' \ >"$ARRIVAL/fixtures/repos_owner_repo_issues_state_open.json" jq -n --arg at "$(iso_at "$INOW")" \ '{number:42,user:{login:"triage-one"},created_at:$at,body:"- [ ] verify after merge",labels:[{name:"claimed"}],assignees:[{login:"builder"}]}' \ >"$ARRIVAL/fixtures/repos_owner_repo_issues_42.json" printf '[]\n' >"$ARRIVAL/fixtures/repos_owner_repo_issues_42_comments.json" : >"$ARRIVAL/fixtures/edits" order_out="$( env PATH="$ARRIVAL/stub:$PATH" CEREMONY_FORGE=github 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 "the out-of-order gather still transitions the issue" 0 "" \ grep -qF '#42: merged Refs PR -> post-merge; claim released' <<<"$order_out" # The marker carries the deliverable's number, so it is where the answer is # observable from outside. 182 merged LAST; 184 is the higher number. check "...naming the PR that merged last, not the highest-numbered one" 0 "" \ grep -qF 'post-merge-transition-pr-182' "$ARRIVAL/fixtures/edits" check "...and never the higher number that merged earlier" 1 "" \ grep -qF 'post-merge-transition-pr-184' "$ARRIVAL/fixtures/edits" # 2. The multi-line open body. open_pr_issues is line-oriented, so the decoded # body must arrive one BODY row per PHYSICAL line. Handed over as a single # record it loses every declaration including the first, and the claim is # reclaimed under a live PR. `Refs #43` sits on line 3 for that reason: on # line 1 the case would pass either way, which is a vacuous test. printf '%s\n' \ '[{"number":430,"body":"## Summary\nSome prose about the work.\nRefs #43\nMore prose after it.","merged_at":null}]' \ >"$ARRIVAL/fixtures/repos_owner_repo_pulls_state_open.json" printf '[]\n' >"$ARRIVAL/fixtures/repos_owner_repo_pulls_state_closed.json" printf '[{"number":43}]\n' \ >"$ARRIVAL/fixtures/repos_owner_repo_issues_state_open.json" jq -n --arg at "$(iso_at $((INOW - 7200)))" \ '{number:43,user:{login:"triage-one"},created_at:$at,body:"- [ ] build",labels:[{name:"claimed"}],assignees:[{login:"builder"}]}' \ >"$ARRIVAL/fixtures/repos_owner_repo_issues_43.json" printf '[]\n' >"$ARRIVAL/fixtures/repos_owner_repo_issues_43_comments.json" printf '[]\n' >"$ARRIVAL/fixtures/repos_owner_repo_issues_43_timeline.json" : >"$ARRIVAL/fixtures/edits" multiline_out="$( env PATH="$ARRIVAL/stub:$PATH" CEREMONY_FORGE=github GH_FIXTURES="$ARRIVAL/fixtures" \ ISSUEFLOW_NOW="$INOW" ISSUEFLOW_STALE_HOURS=1 \ REPO=owner/repo LABELS_CONF="$ARRIVAL/labels.conf" \ bash "$ROOT/actions/issueflow-reconcile/issueflow-reconcile.sh" 2>&1 )" check "the multi-line gather completes" 0 "" \ grep -qF 'issueflow: reconciled.' <<<"$multiline_out" check "a Refs off the first line of an open body still rescues the claim" 1 "" \ grep -qE 'issue edit 43 .*--remove-label claimed' "$ARRIVAL/fixtures/edits" summary