From b7a2b31f848f4e983678ec07f11bb3f425ed0c7c Mon Sep 17 00:00:00 2001 From: codex-bot-andresmgsl Date: Sun, 23 Aug 2026 01:02:53 +0000 Subject: [PATCH] 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" }