diff --git a/actions/issueflow-reconcile/issueflow-reconcile.sh b/actions/issueflow-reconcile/issueflow-reconcile.sh index 9081297..41532c2 100644 --- a/actions/issueflow-reconcile/issueflow-reconcile.sh +++ b/actions/issueflow-reconcile/issueflow-reconcile.sh @@ -346,10 +346,18 @@ blocked_parse_marker() { # $1 rendered set -> the echo's idempotency marker # pair and leave the class: `-`, `_` and `.` are legal in a qualifier token # and all collapse the same way. The slug stays in front so a human reading # the raw comment can still see which set it belongs to; it decides nothing. + state_marker blockers-parsed "$1" +} + +state_marker() { # $1 = marker family, $2 = the state's rendered value + # The one spelling of a value-keyed marker. Three flags now key on a state + # that changes rather than on "have I ever said this" — the blocked-parse + # echo (#252) and the two board flags (#293) — and a second implementation + # of the slug-plus-digest rule is the drift a shared helper prevents. local slug digest - slug="$(printf '%s' "$1" | tr -c '[:alnum:]' '-' | sed 's/--*/-/g; s/^-//; s/-$//')" - digest="$(printf '%s' "$1" | sha256sum | cut -c1-12)" - printf 'blockers-parsed-%s-%s\n' "${slug:-none}" "$digest" + slug="$(printf '%s' "$2" | tr -c '[:alnum:]' '-' | sed 's/--*/-/g; s/^-//; s/-$//')" + digest="$(printf '%s' "$2" | sha256sum | cut -c1-12)" + printf '%s-%s-%s\n' "$1" "${slug:-none}" "$digest" } blocked_parse_echo_needed() { # $1 issue, $2 this parse's marker → 0 echo, 1 quiet @@ -367,13 +375,20 @@ blocked_parse_echo_needed() { # $1 issue, $2 this parse's marker → 0 echo, 1 q # Comparing markers rather than re-rendering the last set keeps the digest as # the only identity: two sets are the same here iff blocked_parse_marker says # so, the same rule the marker itself is built on. + state_echo_needed "$1" blockers-parsed "$2" +} + +state_echo_needed() { # $1 issue, $2 family, $3 this state's marker → 0 echo, 1 quiet + # The value-keyed dedup itself, family-scoped so each flag compares against + # its OWN last word and never against another flag's (#293 D4 asks for the + # declaration echo's mechanism exactly, and three families now share it). local bodies last guarded_read bodies gh api --paginate "repos/$REPO/issues/$1/comments" --jq '.[].body' \ || skip_issue "$1" "could not read its comments: $(read_failure_reason "$READ_FAILURE_STDERR")" # The read fails closed above (#247 D1): an unreadable history skips the # issue rather than answering "nothing echoed yet" and re-posting. - last="$(grep -o '' <<<"$bodies" | tail -n 1)" - [ "$last" != "" ] + last="$(grep -o "" <<<"$bodies" | tail -n 1)" + [ "$last" != "" ] } blocked_decision() { # $1 local refs, $2 OPEN/CLOSED states, $3 cross-repo refs @@ -402,6 +417,166 @@ epic_decision() { # $1 refs, $2 states fi } +# ---- the two board flags (#293): collision (#288) and window (#292) -------- +# +# Both are ADVISORY and comment-only (#293 D1). The sweep never guesses +# intent, so neither writes a label, changes a state, or invents one: it +# states the board fact and triage resolves it. Both are also the first +# checks here whose input is the WHOLE board rather than one issue, so the +# facts are gathered once in main() and every decision below is pure over +# those records — one record per open issue, `numberlabelstitle`. +# +# Why they exist as guards at all: #288 and #292 are triage prose, and both +# failed silently on the same morning (2026-08-04) — #284 minted `ready` +# into a claimed file's function, and six `ready` non-members raced an +# emptying gate. #262 measured the pattern: the same class of rule, once in +# a guard, produced zero misses. + +DELIVERABLE_PATH_PREFIXES=(actions/ lib/ bin/ .github/) + +deliverable_key() { # $1 = one title segment -> its normalized key, or nothing + # Normalized, because the 2026-08-04 miss spelled one deliverable two ways + # — `actions/issueflow-reconcile` against plain `issueflow-reconcile` — so + # exact-prefix matching would have missed the pair it was written for + # (#293 D2). One leading path segment comes off, then every extension: + # `issueflow-reconcile.test.sh` and `issueflow-reconcile.sh` are the same + # spelling habit one more time. + local key="$1" prefix + key="${key#"${key%%[![:space:]]*}"}" + key="${key%"${key##*[![:space:]]}"}" + for prefix in "${DELIVERABLE_PATH_PREFIXES[@]}"; do + [ "${key#"$prefix"}" = "$key" ] || { key="${key#"$prefix"}"; break; } + done + while [[ "$key" =~ \.[[:alnum:]]+$ ]]; do key="${key%.*}"; done + # Case folds because the key is a spelling, not an identifier, and folding + # only ever widens the match — the same direction of error the blocked + # parse takes, and the cheap one: a false pair costs a comment a human + # dismisses, a missed pair costs two builders one deliverable. + printf '%s\n' "$key" | tr '[:upper:]' '[:lower:]' +} + +deliverable_keys() { # title on stdin -> its deliverable keys, one per line + local title prefix segment key + IFS= read -r title + # The issue contract forces every title to name its deliverable before the + # em dash, so the key exists on every well-formed title by construction + # (#288 D5). A title without one names no deliverable, and inventing a key + # out of prose is the guessing this sweep never does — the malformed title + # is triage's own contract to enforce, not this flag's to infer around. + prefix="${title%%—*}" + [ "$prefix" != "$title" ] || return 0 + # A multi-file deliverable joins its files with `+` and collides on any + # segment: `TRIAGE.md + RELEASES.md` carries both keys. + local segments=() + IFS='+' read -r -a segments <<<"$prefix" + # An issue answers a SET of keys, never a multiset. Normalization is + # many-to-one by design — `issueflow-reconcile.sh + issueflow-reconcile.test.sh` + # is one deliverable spelled twice, which is exactly the `+` shape D2 wrote + # the segment rule for — and a repeated key makes `collision_flags`' scan + # find the issue adjacent to itself, chaining it to its own number: the + # comment would ask #402 to declare `Blocked by #402`. It corrupts the chain + # between two such issues too, since each contributes two rows to one key. + # Deduping here rather than in the index keeps the set property with the + # function whose contract it is. + { for segment in "${segments[@]}"; do + key="$(deliverable_key "$segment")" + [ -z "$key" ] || printf '%s\n' "$key" + done + } | awk '!seen[$0]++' +} + +unblocked_claimable() { # $1 = comma-joined labels -> 0 when the issue is unblocked + # THE one definition of `unblocked`, because #293 gives both flags one word + # and one gloss on it: D2 as corrected reads "`unblocked` means open and not + # `blocked` — carrying `ready` or `claimed`, with or without an open PR", + # and D3b's first line says D3 uses D2's corrected `unblocked` and names the + # domain as the claimable set. Two spellings of one spec word is how the + # flags came to disagree about `needs-triage`, so there is one predicate and + # both flags call it. + # + # `blocked` is out: a chained issue is the GOAL state of #288's rule, and + # flagging it would report the fix as the defect. Anything else without + # `ready` or `claimed` is out because it is not claimable — `needs-triage` + # and a label-less issue are not states a builder can pick up, and an + # unlabeled one is getting `needs-triage` from this very pass. `epic` and + # `post-merge` are out by #288 D6 and #292 D1 alike — neither is picked by a + # builder — and they carry no queue label to admit them here anyway. + case ",$1," in *,blocked,*) return 1 ;; esac + case ",$1," in *,ready,*|*,claimed,*) return 0 ;; esac + return 1 +} + +collision_in_scope() { # $1 = comma-joined labels -> 0 in the collision set + unblocked_claimable "$1" +} + +window_in_scope() { # $1 = comma-joined labels -> 0 subject to the window rule + # The same `unblocked`, not a second reading of it. Excluding only + # `blocked`/`epic`/`post-merge` here admitted `needs-triage` and a + # label-less issue, which left the sweep adding `needs-triage` to an + # unlabeled issue and then, in the same pass, telling it about a membership + # call made at mint time. Neither is claimable; #292's invariant is stated + # over the claimable set (D3b), and its exemptions say why — `epic` and + # `post-merge` are exempt *because neither is claimable*. + unblocked_claimable "$1" +} + +collision_key_index() { # board records on stdin -> "keynumber" in scope + local n labels title key + while IFS=$'\t' read -r n labels title; do + [ -n "$n" ] || continue + collision_in_scope "$labels" || continue + while IFS= read -r key; do + [ -z "$key" ] || printf '%s\t%s\n' "$key" "$n" + done < <(deliverable_keys <<<"$title") + done +} + +collision_flags() { # key index on stdin -> "numberkey=carrier[,key=carrier]" + # A CHAIN, not a fan (#288 D3): within one key, each issue names the newest + # open carrier below it, so the declaration the flag asks for releases + # exactly one successor per close. Three issues on one deliverable draw two + # comments — #257 naming #253, #284 naming #257 — never three pairs, which + # is the fan the rule exists to forbid. + # + # One line per issue, its keys folded into one state: an issue carrying two + # colliding deliverables has ONE offending state and owes one comment (D4), + # the same shape the blocked-parse echo takes with its set. + sort -t $'\t' -k1,1 -k2,2n \ + | awk -F '\t' ' + $1 == key { print $2 "\t" $1 "=" carrier } + { key = $1; carrier = $2 } + ' \ + | sort -t $'\t' -k1,1n -k2,2 \ + | awk -F '\t' ' + $1 != n { if (n != "") print n "\t" state; n = $1; state = $2; next } + { state = state "," $2 } + END { if (n != "") print n "\t" state } + ' +} + +window_flags() { # $1 gate members, $2 window carriers; records on stdin -> numbers + local n labels title gate="$1" carriers="$2" + [ -n "$carriers" ] || return 0 + while IFS=$'\t' read -r n labels title; do + [ -n "$n" ] || continue + window_in_scope "$labels" || continue + grep -qxF "$n" <<<"$gate" && continue + # The release issue is the graph's SINK, never one of its own members + # (#292 D2), so it can never be its own non-member. + grep -qxF "$n" <<<"$carriers" && continue + printf '%s\n' "$n" + done +} + +window_state() { # $1 = window carriers -> the rendered state, "#249" | "#249, #250" + awk 'NF { printf "%s#%s", (shown++ ? ", " : ""), $1 } END { printf "\n" }' <<<"$1" +} + +flag_for_issue() { # $1 = issue, $2 = flag records "numberstate" + awk -F '\t' -v n="$1" '$1 == n { print $2 }' <<<"$2" +} + offsite_cross_referenced_prs() { # timeline JSON on stdin -> owner/repo#N jq -r ' .[] @@ -544,6 +719,84 @@ last_issue_comment_activity() { # $1 issue, $2 created_at → epoch; non-zero on issue_activity_at "$1" "$2" comments-only } +reconcile_board_flags() { # $1 = issue — the collision and window flags (#293) + # Dedup is the declaration echo's, per family (#293 D4): the marker is + # keyed to the offending state's VALUE and compared against this family's + # last word on the thread, so a state that changes speaks and a state that + # stands is silent. What that buys over ensure_comment's any-occurrence + # grep is the A -> B -> A case — an issue that collides with #257, is + # re-declared against #284, and collides with #257 again is saying + # something new each time, and an any-occurrence marker would go quiet on + # the third. What it does not buy is the state that resolves and returns + # unchanged: nothing is posted at the resolution, so the thread's last word + # is still the state itself and the return is silent. That is the echo's + # own boundary, and it is the right one here — the flag speaks about a + # board fact that is true right now, and a board where the fact never + # changed has nothing new to say. + local n="$1" state marker rendered + state="$(flag_for_issue "$n" "${COLLISION_FLAGS:-}")" + if [ -n "$state" ]; then + marker="$(state_marker collision "$state")" + if state_echo_needed "$n" collision "$marker"; then + rendered="$(tr ',' '\n' <<<"$state" \ + | awk -F= '{ print "- `" $1 "` — also carried by #" $2 }')" + run gh issue comment "$n" -R "$REPO" --body " +This issue and the issue named beside each key below are both open and +unblocked, and their titles name the same deliverable: + +$rendered + +That owes a **collision edge**, and #288 makes it unconditional: a deliverable +already carried by an open \`ready\`, \`claimed\` or \`blocked\` issue owes +\`Blocked by #N\` on the newer issue, naming the newest open carrier, so each +close releases exactly one successor. Disjoint regions do not waive it — +\`ready\` must mean claimable concurrently with every other \`ready\` issue, +and an undeclared collision sends two builders at one deliverable. + +The key is the title's em-dash prefix, normalized: one leading \`actions/\`, +\`lib/\`, \`bin/\` or \`.github/\` segment comes off, then every extension, and +a \`+\`-joined title matches on any segment. That is what the machine read, +never a judgment about what the deliverable is — if two spellings normalized +to one deliverable that is really two, say so and no edge is owed. + +*Comment only: nothing on this path writes a label or changes a state. The +marker carries the collision itself, so an unchanged one never re-posts.*" >/dev/null + log "#$n: collision flag — $state" + fi + fi + + state="$(flag_for_issue "$n" "${WINDOW_FLAGS:-}")" + if [ -n "$state" ]; then + marker="$(state_marker window-nonmember "$state")" + if state_echo_needed "$n" window-nonmember "$marker"; then + run gh issue comment "$n" -R "$REPO" --body " +A release window is standing ($state) and this issue is neither one of its +gate members nor an \`epic\` or \`post-merge\` issue. + +#292's invariant: during a standing window — an open \`release\`-labeled issue +with a non-empty gate — the \`ready\` set is a subset of the gate, \`epic\` and +\`post-merge\` exempt. Every mint during a window is a membership call, binary, +made at mint time: **behind the gate**, this issue's own Dependencies declare +the release issue as a blocker and the sweep releases it when the release +closes; or **into the graph**, three writes in one tick — this issue declares +its immediate predecessors, every member whose immediate predecessor it +becomes re-points to it, and the release issue gains \`Blocked by #N\`, which +records membership and nothing else. Silence is not a state. + +The gate is read from the release issue's own \`Blocked by\` declarations — the +same parse every \`blocked\` issue is gated on, echoed on that issue. + +*Comment only: nothing on this path writes a label or changes a state. The +marker carries the window itself, so an unchanged one never re-posts.*" >/dev/null + # "unblocked", not "ready": the flag fires on `claimed` too, PR in + # flight or not, which is the one wording #293 D3b went out of its way + # to correct. The log line is read by a human deciding whether the + # sweep understood the board, so it says what the predicate says. + log "#$n: window flag — an unblocked non-member under $state" + fi + fi +} + reconcile_issue() { local n="$1" decision refs cross_refs states age evidence_age ruling_age created assignees open_pr=false label owners local merged_ref_pr="" transition_marker="" transition_handled=false parsed_set="" parse_marker="" @@ -794,6 +1047,13 @@ See \`$release_doctrine_path\`. The operator blessing the order is the one step reconcile_attention "$n" issue "$assignees" "$attention_suppression" fi + # ---- the two board flags (#293), on any queue state ---- + # After the queue branches for the same reason the ruling block is: both + # compose with every queue state, and FLAG_CONFLICT's early return still + # short-circuits them, because a board lying about its queue state is + # repaired before anything is derived from it. + reconcile_board_flags "$n" + # ---- the ruling invariants (#52), on any queue state ---- # The flag composes with the queue labels (#50 D8), so this runs after the # queue branches rather than inside one of them. The FLAG_CONFLICT return @@ -935,19 +1195,56 @@ main() { done < <(refs_references <<<"$body") done)" - local n tail_line issue_numbers + local n tail_line issue_numbers board_json release_bodies rn rbody gate + local window_rendered="" SKIPPED_COUNT=0 SKIPPED_ISSUES="" # A command substitution in a for list suppresses errexit. Capture and # check the board read before entering the loop, or a 504 (including one # after partial pagination) reports a full pass over a truncated board # (#257). - if ! guarded_read issue_numbers gh api --paginate \ - "repos/$REPO/issues?state=open&per_page=100" \ - --jq '.[] | select(has("pull_request") | not) | .number'; then + # + # The read answers the whole payload rather than a projection of it because + # the two board flags (#293) are decided over the WHOLE board — every open + # issue's labels and title, and every open `release` issue's body. One read + # supplies all of it; a second pagination for the same rows would be a + # second board, free to disagree with this one mid-sweep. + if ! guarded_read board_json gh api --paginate \ + "repos/$REPO/issues?state=open&per_page=100"; then log "could not read the issue board: $(read_failure_reason "$READ_FAILURE_STDERR")" return 1 fi + BOARD_RECORDS="$(jq -r '.[] | select(has("pull_request") | not) + | [(.number | tostring), ((.labels // []) | map(.name) | join(",")), (.title // "")] + | @tsv' \ + <<<"$board_json")" + issue_numbers="$(cut -f1 <<<"$BOARD_RECORDS")" + # A standing window is an open `release`-labeled issue whose gate still + # holds an OPEN member (#292 D1). The board read IS the open set, so + # membership decides openness with no extra call — and an all-closed gate + # is exactly the emptied gate the release's own `blocked` -> `ready` + # promotion answers, which is why a `ready` release leaves the flag + # dormant rather than flagging the whole board. + release_bodies="$(jq -r '.[] | select(has("pull_request") | not) + | select((.labels // []) | map(.name) | index("release")) + | [(.number | tostring), ((.body // "") | gsub("[\t\r\n]"; " "))] | @tsv' \ + <<<"$board_json")" + WINDOW_CARRIERS="" + WINDOW_GATE="" + if [ -n "$issue_numbers" ]; then + while IFS=$'\t' read -r rn rbody; do + [ -n "$rn" ] || continue + gate="$(blocked_references <<<"$rbody")" + [ -n "$gate" ] || continue + grep -qxF -f <(printf '%s\n' "$issue_numbers") <<<"$gate" || continue + WINDOW_CARRIERS="${WINDOW_CARRIERS}${rn}"$'\n' + WINDOW_GATE="${WINDOW_GATE}${gate}"$'\n' + done <<<"$release_bodies" + fi + [ -z "$WINDOW_CARRIERS" ] || window_rendered="$(window_state "$WINDOW_CARRIERS")" + COLLISION_FLAGS="$(collision_key_index <<<"$BOARD_RECORDS" | collision_flags)" + WINDOW_FLAGS="$(window_flags "$WINDOW_GATE" "$WINDOW_CARRIERS" <<<"$BOARD_RECORDS" \ + | awk -v state="$window_rendered" 'NF { print $1 "\t" state }')" if [ -z "$issue_numbers" ]; then log "no open issues." else diff --git a/changelog.d/293.md b/changelog.d/293.md new file mode 100644 index 0000000..40a6d63 --- /dev/null +++ b/changelog.d/293.md @@ -0,0 +1,13 @@ +### Added + +- The issue-flow sweep now flags a collision the board never declared: two + open, unblocked issues whose titles name one deliverable draw a comment + naming the newer's owed `Blocked by` edge. Keys normalize, so + `actions/x` and `x` are one deliverable (#288). +- The sweep now flags an unblocked non-member during a standing release + window, naming the window's invariant. `claimed` counts, PR in flight or + not. The gate is read from the release issue's own `Blocked by` + declarations, and an emptied gate leaves it dormant (#292). +- Both flags are advisory: comments only, no label write and no state + change, deduped against each family's last word on the thread so a + standing state re-sweeps silently (#293). diff --git a/test/issueflow-reconcile.test.sh b/test/issueflow-reconcile.test.sh index 37f3682..1f39de6 100644 --- a/test/issueflow-reconcile.test.sh +++ b/test/issueflow-reconcile.test.sh @@ -2044,6 +2044,472 @@ 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/graphql-open.json" "$BOARD/graphql-open.json" +cp "$ARRIVAL/fixtures/graphql-merged.json" "$BOARD/graphql-merged.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_per_page_100.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" 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/graphql-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/graphql-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_per_page_100.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/graphql-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