From d493c993b752731408eab275a5270f8c8559f7ac Mon Sep 17 00:00:00 2001 From: codex-bot-andresmgsl Date: Sun, 23 Aug 2026 01:01:17 +0000 Subject: [PATCH 1/4] test: drive release membership records --- test/issueflow-reconcile.test.sh | 209 +++++++++++++++++++++++++++++++ 1 file changed, 209 insertions(+) diff --git a/test/issueflow-reconcile.test.sh b/test/issueflow-reconcile.test.sh index 918b635..4e50bb0 100644 --- a/test/issueflow-reconcile.test.sh +++ b/test/issueflow-reconcile.test.sh @@ -2384,6 +2384,215 @@ 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' +# -- a release carrier is not a member of its own gate (#327 D1) ----------- +self_gate_body='A member narrates Blocked by #163.' +check "a self-only parsed gate does not make its release issue a carrier" 1 "" \ + grep -q . < <(release_window_gate 163 $'163\n164' <<<"$self_gate_body") +mixed_gate_body='Blocked by #163, #164, #165.' +check "an open non-self member still makes the release issue a carrier" 0 \ + $'163\t164\n163\t165' \ + release_window_gate 163 $'163\n164' <<<"$mixed_gate_body" +# shellcheck disable=SC2016 # awk fields belong to awk, not the shell +check "the carrier number never contributes to its own WINDOW_GATE" 1 "" \ + awk -F '\t' '$2 == 163 { found = 1 } END { exit !found }' \ + < <(release_window_gate 163 $'163\n164' <<<"$mixed_gate_body") + +# -- the membership record, read by heading (#343 D2) ----------------------- +# The record is a machine record with one shape, and every case below is a +# shape a real release body already carries. The corpus is heavy-duty/crew#346 +# — 21 members, a literal "## The members, in claim order" narration heading, +# rows citing merged PRs and another repository, one row annotating an issue as +# explicitly NOT a member, a verification lane that is not in the build queue, +# and a "## Task list" progress view beside all of it. +# Bracketed, so the assertion is the WHOLE set and not a prefix of it: a +# substring match on a bare list would let an extra member in silently, which +# is the one direction every mutation below travels. +membership_set() { # release body on stdin -> its members as one bracketed line + printf '[%s]\n' "$(membership_references | tr '\n' ' ' | sed 's/[[:space:]]*$//')" +} +membership_body="$(printf '%s\n' \ + 'The 0.6.0 window. Blocked by #249.' \ + 'A quoted declaration in narration: "Blocked by #906" is what the epic says.' \ + '' \ + '## The members, in claim order' \ + '- #900 — a narration heading, not the record' \ + '' \ + '## Members' \ + '- [ ] #253 — landed as #901, ports heavy-duty/crew#346, and #902 is not a member' \ + '- #257' \ + '- [x] #264 — landed' \ + '- crew#348 — a parallel track in another repository' \ + '- #266, #276 — two references on one row' \ + '- the verification lane, not in the build queue' \ + '- #249 — the sink itself' \ + '' \ + '## Task list' \ + '- [ ] #281 — the progress view')" +check "the record enrols exactly its rows' bare first tokens" 0 "[249 253 257 264]" \ + membership_set <<<"$membership_body" +# The heading is ANCHORED. crew#346 carries this exact narration heading, so a +# substring or prefix match reads it as the record and enrols its rows. +check "a narrated members heading is not the record" 1 "" \ + grep -qx 900 < <(membership_references <<<"$membership_body") +# The row's FIRST token, not every reference in it. epic_references prints the +# whole row and takes them all, which is right for a progress view and wrong +# here: these three are a merged PR, a sibling repository, and an issue the row +# itself names as a non-member. +check "a row's prose PR reference is not a member" 1 "" \ + grep -qx 901 < <(membership_references <<<"$membership_body") +check "...nor an issue the row names as explicitly not a member" 1 "" \ + grep -qx 902 < <(membership_references <<<"$membership_body") +check "...nor a qualified reference in the prose" 1 "" \ + grep -qx 346 < <(membership_references <<<"$membership_body") +# Silence, not a guess: a first token that is not a bare local reference +# contributes nothing, whether it is qualified, punctuated, or prose. +check "a qualified first token contributes no member" 1 "" \ + grep -qx 348 < <(membership_references <<<"$membership_body") +check "a punctuated first token contributes no member" 1 "" \ + grep -qx 266 < <(membership_references <<<"$membership_body") +check "...and the second reference on that row contributes none either" 1 "" \ + grep -qx 276 < <(membership_references <<<"$membership_body") +# The record ends at the next heading, so the progress view beside it is not +# membership — that separation is the whole reason the two lists are distinct. +check "a task-list reference is not a member" 1 "" \ + grep -qx 281 < <(membership_references <<<"$membership_body") +check "...nor a reference in narration outside the record" 1 "" \ + grep -qx 906 < <(membership_references <<<"$membership_body") +check "an unchecked row and a checked row enrol alike" 0 $'253\n264' \ + membership_references <<<"$membership_body" +check "a bare row with no checkbox enrols too" 0 "257" \ + membership_references <<<"$membership_body" +# Case-insensitive, trailing whitespace tolerated — the shape `## Task list` +# already has, stated once in RELEASES.md and implemented once here. +check "the heading matches case-insensitively with trailing whitespace" 0 "412" \ + membership_references <<<"$(printf '%s\n' '## MEMBERS ' '- #412 — admitted')" +# Every CommonMark list marker opens a row, and only those. A row is whatever a +# reader sees as one, so a marker class narrower than the set Markdown renders +# would drop a member a human wrote — and take the standing window down with +# it, which is silence in the one place D2 does not want silence. A class WIDER +# than it is the same error mirrored, and the more dangerous direction: a line +# no renderer reads as a row becomes a member, and one phantom open member +# keeps a window standing and suppresses its non-member flag. Only the first +# token rule decides what a row MEANS; the marker class decides what a row IS. +marker_body="$(printf '%s\n' \ + '## Members' \ + '- #412 — a hyphen row' \ + '* #413 — an asterisk row' \ + '+ #414 — a plus row' \ + '1. #415 — an ordered row' \ + '2) #416 — an ordered row, the paren form' \ + '123456789. #419 — nine digits, the widest ordered marker there is' \ + '1234567890. #420 — ten digits, which CommonMark does not render as a row' \ + ' #417 — no marker at all, so not a row')" +check "every Markdown list marker opens a member row" 0 \ + "[412 413 414 415 416 419]" \ + membership_set <<<"$marker_body" +check "a plus row enrols its member" 0 "" \ + grep -qx 414 < <(membership_references <<<"$marker_body") +check "an ordered row enrols its member" 0 "" \ + grep -qx 415 < <(membership_references <<<"$marker_body") +check "...and so does its paren form" 0 "" \ + grep -qx 416 < <(membership_references <<<"$marker_body") +# The bound is CommonMark 5.2's: an ordered marker is 1 to 9 digits then `.` or +# `)`. Both sides of it are asserted, because one alone is met by a class that +# is merely different rather than right — the 9-digit row is the widest marker +# a renderer accepts and must enrol, the 10-digit line is not a list row at all +# and must contribute nothing. The bound is written twice in the parser, in the +# row match and in the marker strip; a widening of either reds the pair. +check "the widest ordered marker CommonMark allows enrols its member" 0 "" \ + grep -qx 419 < <(membership_references <<<"$marker_body") +check "a tenth digit is not an ordered marker, so the line is not a row" 1 "" \ + grep -qx 420 < <(membership_references <<<"$marker_body") +# The marker is what makes the line a row, so a bare reference on its own line +# is narration inside the record, not a member. Indented deliberately: at +# column 0 a `#` would end the record as a heading, and this assertion is about +# the marker, not the terminator. +check "a line with no list marker is not a row" 1 "" \ + grep -qx 417 < <(membership_references <<<"$marker_body") +# Indentation bounds the row the way the digit count bounds the marker, and both +# sides are asserted for the same reason: a bound met on one side alone is a +# class merely different rather than right. Three spaces still open a row — +# CommonMark 4.4 allows up to three, and refusing them would drop a member a +# human wrote and reads. A fourth does not, and what it means depends on context +# the line itself does not carry: GitHub renders ` - #N` after `## Members` +# as `
`, and the same bytes under a `- #N` row as a nested `
  • `. +# The record is FLAT, so both are silence: an indented code block is not a row at +# all, and a sub-bullet annotating a member row is not a second member. Enrolling +# either is the phantom-member direction — an open reference taken from non-row +# content keeps a window standing and suppresses its non-member flag. A leading +# tab is four columns wherever indentation decides block structure, so it falls +# under the same bound. +indent_body="$(printf '%s\n' \ + '## Members' \ + '- #421 — column zero' \ + ' - #422 — three spaces, the deepest indentation that still opens a row' \ + ' - #423 — four spaces: a sub-row here, an indented code block alone' \ + $'\t- #424 — a tab, the same four columns, so neither is it')" +check "the record admits exactly its unindented and shallowly indented rows" 0 \ + "[421 422]" \ + membership_set <<<"$indent_body" +check "three spaces still open a row" 0 "" \ + grep -qx 422 < <(membership_references <<<"$indent_body") +check "a row indented past the bound is a sub-row, and not a second member" 1 "" \ + grep -qx 423 < <(membership_references <<<"$indent_body") +check "...and neither is the tab-indented one" 1 "" \ + grep -qx 424 < <(membership_references <<<"$indent_body") +# The same bytes with no row above them, which is the shape the panel found: no +# list is open, so the renderer reads an indented code block and there is nothing +# for the record to enrol. A body whose record is entirely non-rows enumerates +# no membership, and D4 then applies to it like any other empty record. +code_block_body="$(printf '%s\n' \ + '## Members' \ + ' - #425 — four spaces with no list open: an indented code block' \ + $'\t- #426 — and a tab, the same block')" +check "an indented code block inside the record enrols nobody" 0 "[]" \ + membership_set <<<"$code_block_body" +# The terminator itself, pinned where it can be seen: the record ends at the +# next line starting with `#`, the shape `## Task list` already has. A bare +# unindented reference is therefore the end of the record, not a member of it, +# and the rows after it are outside. +check "an unindented bare reference ends the record" 0 "[412]" \ + membership_set <<<"$(printf '%s\n' '## Members' '- #412' '#417' '- #418')" + +# -- the carrier decision reads the record (#343 D3, D4, D5) ---------------- +check "an open member in the record makes the release issue a carrier" 0 \ + $'249\t253\n249\t257' \ + release_window_members 249 $'249\n253' \ + <<<"$(printf '%s\n' '## Members' '- #253' '- #257')" +# D4: no fallback. This is THE defect's own state — #317 from its mint until +# #249 closed at 2026-08-05T11:12Z, and crew's fifteen version epics at 0.6.0 +# adoption: a version epic declaring its predecessor exactly as *Gates* +# instructs and enumerating nothing. Falling back to the gate here restores +# the reading that made a shut window stand. +check "a declared open predecessor with no record is not a carrier (D4)" 1 "" \ + grep -q . < <(release_window_members 317 $'249\n317\n343' <<<'Blocked by #249.') +check "...and an empty record is not a carrier either" 1 "" \ + grep -q . < <(release_window_members 249 $'249\n253' \ + <<<"$(printf '%s\n' '## Members' '' '## Task list' '- [ ] #253')") +check "...nor is a record whose every member has closed" 1 "" \ + grep -q . < <(release_window_members 249 $'249\n264' \ + <<<"$(printf '%s\n' '## Members' '- #218' '- #230')") +# D5: #327's self-exclusion, inherited rather than re-decided. Both readings +# share release_window_records, so the guard cannot hold on one side only. +check "a membership row naming the carrier contributes no member (D5)" 1 "" \ + grep -q . < <(release_window_members 249 $'249\n253' \ + <<<"$(printf '%s\n' '## Members' '- #249 — the sink itself')") +# shellcheck disable=SC2016 # awk fields belong to awk, not the shell +check "...and never contributes to WINDOW_MEMBERS beside a real member" 1 "" \ + awk -F '\t' '$2 == 249 { found = 1 } END { exit !found }' \ + < <(release_window_members 249 $'249\n253' \ + <<<"$(printf '%s\n' '## Members' '- #249' '- #253')") + +# The snapshot may nominate a flag before this issue's own queue branch runs; +# the pure second gate reads the state that branch actually concluded. +check "a pass concluding ready still permits both board flags" 0 "" \ + board_flags_in_scope ready +check "a pass concluding claimed still permits both board flags" 0 "" \ + board_flags_in_scope claimed +check "a pass concluding post-merge silences both board flags" 1 "" \ + board_flags_in_scope post-merge + + # -- 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" \ From b7a2b31f848f4e983678ec07f11bb3f425ed0c7c Mon Sep 17 00:00:00 2001 From: codex-bot-andresmgsl Date: Sun, 23 Aug 2026 01:02:53 +0000 Subject: [PATCH 2/4] feat: parse release membership records --- .../issueflow-reconcile.sh | 112 +++++++++++++++++- 1 file changed, 107 insertions(+), 5 deletions(-) diff --git a/actions/issueflow-reconcile/issueflow-reconcile.sh b/actions/issueflow-reconcile/issueflow-reconcile.sh index 4197e4e..df82c4f 100644 --- a/actions/issueflow-reconcile/issueflow-reconcile.sh +++ b/actions/issueflow-reconcile/issueflow-reconcile.sh @@ -517,6 +517,14 @@ window_in_scope() { # $1 = comma-joined labels -> 0 subject to the window rule unblocked_claimable "$1" } +board_flags_in_scope() { # $1 = queue state concluded by this issue's pass + # The board snapshot decides which issues might owe a flag, but this pass + # speaks only about the queue state it leaves behind (#327 D2). A derived + # claimed -> post-merge transition therefore cannot post the snapshot's + # now-false claim that the issue is still unblocked and 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 @@ -551,20 +559,114 @@ collision_flags() { # key index on stdin -> "numberkey=carrier[,key=carrier ' } -window_flags() { # $1 gate members, $2 window carriers; records on stdin -> numbers - local n labels title gate="$1" carriers="$2" +window_flags() { # $1 window members, $2 window carriers; records on stdin -> numbers + local n labels title members="$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" <<<"$members" && continue + # The carrier is the graph's SINK, so the flag excludes it explicitly; + # membership parsing is a separate decision and cannot prove this guard. grep -qxF "$n" <<<"$carriers" && continue printf '%s\n' "$n" done } +membership_references() { # release body on stdin -> its enumerated members + # The membership record (#343 D2), read by HEADING and never by a marker + # phrase. `blocked_reference_records` unions every occurrence of its marker + # and runs each clause to its own sentence terminator — deliberate, and the + # right error direction for a `blocked` issue, but the wrong one here: a + # release body is mostly narration ABOUT its members, so a phrase parser + # takes references out of the prose. That is the mechanism that put + # heavy-duty/crew's `0.2.0` epic inside its own gate. The heading match is + # anchored for the same reason: crew#346 carries a literal + # `## The members, in claim order` heading, which a substring match reads as + # the record and an anchored one does not. + # + # One member per row, and it is the row's FIRST token after the list marker + # and an optional checkbox. `epic_references` prints the whole row and takes + # every local reference in it, which is right for a progress view and wrong + # here — measured on crew#346, whose member rows carry merged PR numbers, + # another repository's issues, and one issue annotated in its own row as + # explicitly NOT a member of the window. A first token that is not a bare + # local `#` contributes nothing: silence, not a guess. A qualified + # reference is never a member either, because a window is one repository's + # DAG decided against one board read. + # + # A row is any Markdown list row, so the marker class is the whole CommonMark + # set and exactly it — `-`, `*`, `+`, and 1 to 9 digits then `.` or `)` + # (CommonMark 5.2). Recognising only some of them would drop a row a human + # wrote, and reads, as a member: silence is the correct answer to a row whose + # first token is not a bare local reference, and the wrong one to a member + # enumerated under a marker this parse did not know. Recognising MORE than + # them is the same error mirrored: `1234567890. #412` is not a list row to + # any renderer, so reading it as one takes a member out of narration, and one + # phantom open member keeps a window standing and suppresses its non-member + # flag. The bound is written twice, in the row match and in the strip, and + # both are pinned. `epic_references` matches a narrower class; it is a + # progress view with its own fixtures and is byte-unchanged here (#343 D7). + # + # Indentation is bounded the same way and for the same reason: at most three + # spaces open a row (CommonMark 4.4), and a leading tab is four columns of it + # wherever indentation decides block structure. Past that bound the line is + # not a top-level row, and which non-row it is depends on context this parse + # does not carry — GitHub renders ` - #412` after `## Members` as + # `
    ` and the same bytes under a `- #N` row as a nested `
  • `. The + # record is FLAT, so both are silence: an indented code block is not a row at + # all, a sub-bullet annotating a member row is not a second member, and + # enrolling either is the tenth digit's phantom-member direction one axis + # over. Below the bound the answer goes the other way for the same reason: one + # to three spaces is byte-identical to a top-level row a human indented, so it + # enrols, and the sub-row that shape can also be is the price (#348). + awk ' + tolower($0) ~ /^##[[:space:]]+members[[:space:]]*$/ { in_record = 1; next } + in_record && /^#/ { exit } + in_record && /^ {0,3}([-*+]|[0-9]{1,9}[.)])[[:space:]]+/ { + row = $0 + sub(/^ {0,3}([-*+]|[0-9]{1,9}[.)])[[:space:]]+/, "", row) + sub(/^\[[ xX]\][[:space:]]+/, "", row) + split(row, token, "[[:space:]]+") + if (token[1] ~ /^#[0-9]+$/) print substr(token[1], 2) + } + ' | sort -nu +} + +release_window_records() { # $1 carrier, $2 open numbers; refs on stdin -> carriermember + # A carrier is never a member of its own window (#327 D1, which #343 D5 + # inherits rather than re-decides). Remove it before deciding whether any + # open member makes the window stand, and before returning every non-self + # reference that contributes to WINDOW_MEMBERS. One function, so the two + # readings below can never drift apart on that guard. + local carrier="$1" open_numbers="$2" members member + members="$(awk -v carrier="$carrier" '$0 != carrier')" + [ -n "$members" ] || return 0 + grep -qxF -f <(printf '%s\n' "$open_numbers") <<<"$members" || return 0 + while IFS= read -r member; do + [ -n "$member" ] && printf '%s\t%s\n' "$carrier" "$member" + done <<<"$members" +} + +release_window_gate() { # $1 carrier, $2 open issue numbers; body on stdin -> carriermember + # #327 D1's reading of a release issue's `Blocked by` set, kept whole and + # kept driven. The window stopped consuming it at #343 D3 — a release + # epic's declaration answers its predecessor gate and nothing else — so + # what this keeps standing is the self-exclusion guard's other half: the + # gate side and the membership side share release_window_records, and this + # is where a change to it that only the gate could see reds. + blocked_references | release_window_records "$1" "$2" +} + +release_window_members() { # $1 carrier, $2 open issue numbers; body on stdin -> carriermember + # What the carrier decision reads (#343 D3). No fallback to the gate when + # the record is absent (#343 D4): a release issue enumerating no membership + # is not a carrier, and the board draws no window flag. A fallback would + # reinstate the misreading for precisely the bodies that have not been + # migrated, which is where it does its damage. + membership_references | release_window_records "$1" "$2" +} + window_state() { # $1 = window carriers -> the rendered state, "#249" | "#249, #250" awk 'NF { printf "%s#%s", (shown++ ? ", " : ""), $1 } END { printf "\n" }' <<<"$1" } From 9a37db4b18ef456b806212007ca8087c6bafad44 Mon Sep 17 00:00:00 2001 From: codex-bot-andresmgsl Date: Sun, 23 Aug 2026 01:06:54 +0000 Subject: [PATCH 3/4] feat: derive release windows from membership records --- .../issueflow-reconcile.sh | 85 +++++++++++------- test/issueflow-reconcile.test.sh | 87 +++++++++++++++---- 2 files changed, 124 insertions(+), 48 deletions(-) diff --git a/actions/issueflow-reconcile/issueflow-reconcile.sh b/actions/issueflow-reconcile/issueflow-reconcile.sh index df82c4f..faf6c8d 100644 --- a/actions/issueflow-reconcile/issueflow-reconcile.sh +++ b/actions/issueflow-reconcile/issueflow-reconcile.sh @@ -830,7 +830,7 @@ 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) +reconcile_board_flags() { # $1 issue, $2 concluded queue state — board 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 @@ -845,6 +845,7 @@ reconcile_board_flags() { # $1 = issue — the collision and window flags (#293) # 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 + board_flags_in_scope "$2" || return 0 state="$(flag_for_issue "$n" "${COLLISION_FLAGS:-}")" if [ -n "$state" ]; then marker="$(state_marker collision "$state")" @@ -882,20 +883,22 @@ marker carries the collision itself, so an unchanged one never re-posts.*" >/dev if state_echo_needed "$n" window-nonmember "$marker"; then run forge_issue_comment "$n" " A release window is standing ($state) and this issue is neither one of its -gate members nor an \`epic\` or \`post-merge\` issue. +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. +with a non-empty membership record — the \`ready\` set is a subset of that +record, \`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 a row for +this issue in its membership record. 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. +Membership is read from the release issue's own \`## Members\` record: the rows +under that heading, one member each, the row's first token a bare \`#N\` and +everything after it prose. A \`Blocked by\` declaration on a release issue +answers its predecessor gate and never its membership (#343). *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 @@ -913,9 +916,14 @@ reconcile_issue() { local merged_ref_pr="" transition_marker="" transition_handled=false parsed_set="" parse_marker="" local unchecked="" remove_claimed=claimed local attention_active=true attention_suppression="" + local concluded_queue_state="" + for label in needs-triage epic "${QUEUE_LABELS[@]}"; do + has_issue_label "$label" && concluded_queue_state="$label" + done decision="$(queue_decision <<<"$ISSUE_LABELS")" case "$decision" in ADD_NEEDS_TRIAGE) + concluded_queue_state=needs-triage run forge_issue_edit "$n" --add-label needs-triage >/dev/null log "#$n: needs-triage (no queue state)" ;; FLAG_CONFLICT) @@ -969,6 +977,7 @@ The merge releases the claim; no builder owes a draft. Triage owes completion in --remove-label "$remove_claimed" --add-label post-merge >/dev/null fi log "#$n: merged Refs PR -> post-merge; claim released" + concluded_queue_state=post-merge attention_active=false else created="$(jq -r '.created_at' <<<"$ISSUE_JSON")" @@ -999,6 +1008,7 @@ The merge releases the claim; no builder owes a draft. Triage owes completion in else run forge_issue_edit "$n" --remove-label claimed --add-label ready >/dev/null fi + concluded_queue_state=ready log "#$n: stale claim reclaimed -> ready" ;; esac [ "$decision" != FLAG_UNASSIGNED ] || attention_suppression=claimed-unassigned @@ -1122,6 +1132,7 @@ itself, so a parse unchanged since the last echo never re-posts.*" >/dev/null ensure_comment "$n" blockers-cleared \ 'Every issue named by `Blocked by` is closed. The sweep is moving this issue to `ready`.' run forge_issue_edit "$n" --remove-label blocked --add-label ready >/dev/null + concluded_queue_state=ready log "#$n: blockers closed -> ready" ;; esac elif has_issue_label epic; then @@ -1138,7 +1149,7 @@ itself, so a parse unchanged since the last echo never re-posts.*" >/dev/null 1. Mint the window's members. 2. Graph hard dependencies and same-file clusters. -3. Write ordered waves and the progress task list. +3. Write ordered waves, the \`## Members\` record, and the progress task list. 4. Ask the operator to bless the order, then open the first wave. 5. Ship the release, close this epic, and trigger the next window. @@ -1168,7 +1179,7 @@ See \`$release_doctrine_path\`. The operator blessing the order is the one step # 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" + reconcile_board_flags "$n" "$concluded_queue_state" # ---- the ruling invariants (#52), on any queue state ---- # The flag composes with the queue labels (#50 D8), so this runs after the @@ -1351,7 +1362,7 @@ main() { done < <(printf '%s' "$b64" | base64 -d | refs_references) done)" - local n tail_line issue_numbers board_json release_bodies rn rbody gate body + local n tail_line issue_numbers board_json release_numbers rn window_records body local window_rendered="" SKIPPED_COUNT=0 SKIPPED_ISSUES="" @@ -1376,31 +1387,39 @@ main() { | @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(.pull_request == null) + # A standing window is an open `release`-labeled issue whose MEMBERSHIP + # RECORD still holds an OPEN member (#292 D1 as #343 D3 re-reads it). The + # board read IS the open set, so membership decides openness with no extra + # call — and an all-closed record is exactly the emptied window the + # release's own `blocked` -> `ready` promotion answers, which is why a + # `ready` release leaves the flag dormant rather than flagging the board. + # + # The record is read by heading, so each body must reach the parse with its + # LINE STRUCTURE INTACT. Taking it from the board payload by issue number + # preserves that structure without fetching a second, disagreeing board. + release_numbers="$(jq -r '.[] | select(.pull_request == null) | select((.labels // []) | map(.name) | index("release")) - | [(.number | tostring), ((.body // "") | gsub("[\t\r\n]"; " "))] | @tsv' \ + | .number' \ <<<"$board_json")" WINDOW_CARRIERS="" - WINDOW_GATE="" + WINDOW_MEMBERS="" 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" + window_records="$( + while IFS= read -r rn; do + [ -n "$rn" ] || continue + jq -r --argjson n "$rn" '.[] | select(.pull_request == null) + | select(.number == $n) | .body // ""' <<<"$board_json" \ + | release_window_members "$rn" "$issue_numbers" + done <<<"$release_numbers" + )" + # One record per parsed non-self member keeps the carrier decision and + # its WINDOW_MEMBERS contribution coupled to the extracted function. + WINDOW_CARRIERS="$(cut -f1 <<<"$window_records" | awk 'NF' | sort -nu)" + WINDOW_MEMBERS="$(cut -f2 <<<"$window_records" | awk 'NF' | sort -nu)" 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" \ + WINDOW_FLAGS="$(window_flags "$WINDOW_MEMBERS" "$WINDOW_CARRIERS" <<<"$BOARD_RECORDS" \ | awk -v state="$window_rendered" 'NF { print $1 "\t" state }')" if [ -z "$issue_numbers" ]; then log "no open issues." diff --git a/test/issueflow-reconcile.test.sh b/test/issueflow-reconcile.test.sh index 4e50bb0..0d0d8ed 100644 --- a/test/issueflow-reconcile.test.sh +++ b/test/issueflow-reconcile.test.sh @@ -502,6 +502,11 @@ 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" +# Release-init is where the membership record is first written, so step 3 +# names it beside the waves and the progress task list. +# shellcheck disable=SC2016 # backticks are the comment body's own Markdown +check "step 3 names the membership record it first writes" 0 "" \ + grep -qF '3. Write ordered waves, the `## Members` record' "$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" @@ -714,9 +719,16 @@ check "the flag-free control is reclaimed (the clock still runs elsewhere)" 0 "" # -- merged Refs work releases the claim before the reclaim clock ------------ printf '[]\n' >"$(cfix 35)" +COLLISION_FLAGS=$'35\tissueflow-reconcile=34' +WINDOW_FLAGS=$'35\t#50' transition="$(issue_probe 35 claimed 1 false 350 $'- [x] built\n- [ ] verify dispatch\n * [ ] confirm warning clears')" +unset COLLISION_FLAGS WINDOW_FLAGS check "merged Refs + unchecked criteria transitions in the sweep body" 0 "" \ grep -q 'merged Refs PR -> post-merge; claim released' <<<"$transition" +check "a pass concluding post-merge draws no precomputed collision flag" 1 "" \ + grep -q 'collision flag' <<<"$transition" +check "...and no precomputed window flag" 1 "" \ + grep -q 'window flag' <<<"$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" && @@ -1821,12 +1833,12 @@ check "...the null-valued row is TRAVERSED, with an observable outcome" 0 "" \ check "...and the object-valued PR row is not reconciled as an issue" 1 "" \ grep -qE '^issueflow: #61' <<<"$fjb_out" -# release_bodies is the THIRD producer and has its own has() site. A `release` +# release_numbers is the THIRD producer and has its own issue-shape filter. A `release` # issue on a forgejo-shaped board must reach the window gather, or the #292 # flags are decided over an empty set (@codex-reviewer-andresmgsl, #210). printf '%s\n' \ '[{"number":60,"pull_request":null,"labels":[{"name":"ready"}],"title":"an issue"}, - {"number":62,"pull_request":null,"labels":[{"name":"release"}],"title":"Release 9.9.9","body":"Blocked by #60."}, + {"number":62,"pull_request":null,"labels":[{"name":"release"}],"title":"Release 9.9.9","body":"Blocked by #59.\n\n## Members\n- #60"}, {"number":63,"pull_request":null,"labels":[{"name":"ready"}],"title":"a claimable non-member"}, {"number":61,"pull_request":{"merged":false},"labels":[],"title":"a pull request"}]' \ >"$FORGEJO_BOARD/repos_owner_repo_issues_state_open.json" @@ -1836,13 +1848,13 @@ jq -n --arg at "$(iso_at "$INOW")" \ >"$FORGEJO_BOARD/repos_owner_repo_issues_63.json" printf '[]\n' >"$FORGEJO_BOARD/repos_owner_repo_issues_63_comments.json" jq -n --arg at "$(iso_at "$INOW")" \ - '{number:62,user:{login:"triage-one"},created_at:$at,body:"Blocked by #60.",pull_request:null, + '{number:62,user:{login:"triage-one"},created_at:$at,body:"Blocked by #59.\n\n## Members\n- #60",pull_request:null, labels:[{name:"release"}],assignees:[]}' \ >"$FORGEJO_BOARD/repos_owner_repo_issues_62.json" printf '[]\n' >"$FORGEJO_BOARD/repos_owner_repo_issues_62_comments.json" fjb2_out="$(forgejo_board_run)" -# The observable effect of release_bodies being NON-empty: an open `release` -# issue whose gate still holds an open member makes every claimable non-member +# The observable effect of release_numbers being NON-empty: an open `release` +# issue whose membership record still holds an open member makes every claimable non-member # draw a window flag. With that gather empty there are no carriers and no flag, # so this row discriminates the site rather than merely reaching it. check "a release issue on a forgejo-shaped board reaches the window gather" 0 "" \ @@ -2683,7 +2695,7 @@ board_run() { # 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.' + "$(printf '%s\n' 'Blocked by #253.' '' '## Members' '- #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' @@ -2743,7 +2755,18 @@ 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" + grep -qF 'the `ready` set is a subset of that' "$BOARD/edits" +# shellcheck disable=SC2016 # backticks are the comment body's own Markdown +check "...and tells triage where membership is actually read from" 0 "" \ + grep -qF 'Membership is read from the release issue'"'"'s own `## Members` record' \ + "$BOARD/edits" +# shellcheck disable=SC2016 # backticks are the comment body's own Markdown +check "...and says what a release issue's Blocked by line does answer" 0 "" \ + grep -qF 'answers its predecessor gate and never its membership' "$BOARD/edits" +check "...and asks the third write for a row, not a declaration" 0 "" \ + grep -qF 'the release issue gains a row for' "$BOARD/edits" +check "no window comment sends triage to a Blocked by declaration" 1 "" \ + grep -qF 'The gate is read from the release issue' "$BOARD/edits" check "both comments carry idempotency markers (D4)" 0 "" \ grep -qF '