forked from heavy-duty/ceremony
Merge pull request #312 from cndgrr/build/293-issueflow-flags
issueflow-reconcile — the sweep flags what the window and collision rules forbid: unblocked twins, and an unblocked non-member during a standing window
This commit is contained in:
commit
fd22bd2fe7
3 changed files with 785 additions and 9 deletions
|
|
@ -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
|
# 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
|
# 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.
|
# 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
|
local slug digest
|
||||||
slug="$(printf '%s' "$1" | tr -c '[:alnum:]' '-' | sed 's/--*/-/g; s/^-//; s/-$//')"
|
slug="$(printf '%s' "$2" | tr -c '[:alnum:]' '-' | sed 's/--*/-/g; s/^-//; s/-$//')"
|
||||||
digest="$(printf '%s' "$1" | sha256sum | cut -c1-12)"
|
digest="$(printf '%s' "$2" | sha256sum | cut -c1-12)"
|
||||||
printf 'blockers-parsed-%s-%s\n' "${slug:-none}" "$digest"
|
printf '%s-%s-%s\n' "$1" "${slug:-none}" "$digest"
|
||||||
}
|
}
|
||||||
|
|
||||||
blocked_parse_echo_needed() { # $1 issue, $2 this parse's marker → 0 echo, 1 quiet
|
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
|
# 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
|
# the only identity: two sets are the same here iff blocked_parse_marker says
|
||||||
# so, the same rule the marker itself is built on.
|
# 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
|
local bodies last
|
||||||
guarded_read bodies gh api --paginate "repos/$REPO/issues/$1/comments" --jq '.[].body' \
|
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")"
|
|| 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
|
# The read fails closed above (#247 D1): an unreadable history skips the
|
||||||
# issue rather than answering "nothing echoed yet" and re-posting.
|
# issue rather than answering "nothing echoed yet" and re-posting.
|
||||||
last="$(grep -o '<!-- issueflow:blockers-parsed-[[:alnum:]-]* -->' <<<"$bodies" | tail -n 1)"
|
last="$(grep -o "<!-- issueflow:$2-[[:alnum:]-]* -->" <<<"$bodies" | tail -n 1)"
|
||||||
[ "$last" != "<!-- issueflow:$2 -->" ]
|
[ "$last" != "<!-- issueflow:$3 -->" ]
|
||||||
}
|
}
|
||||||
|
|
||||||
blocked_decision() { # $1 local refs, $2 OPEN/CLOSED states, $3 cross-repo refs
|
blocked_decision() { # $1 local refs, $2 OPEN/CLOSED states, $3 cross-repo refs
|
||||||
|
|
@ -402,6 +417,166 @@ epic_decision() { # $1 refs, $2 states
|
||||||
fi
|
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, `number<TAB>labels<TAB>title`.
|
||||||
|
#
|
||||||
|
# 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 -> "key<TAB>number" 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 -> "number<TAB>key=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 "number<TAB>state"
|
||||||
|
awk -F '\t' -v n="$1" '$1 == n { print $2 }' <<<"$2"
|
||||||
|
}
|
||||||
|
|
||||||
offsite_cross_referenced_prs() { # timeline JSON on stdin -> owner/repo#N
|
offsite_cross_referenced_prs() { # timeline JSON on stdin -> owner/repo#N
|
||||||
jq -r '
|
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
|
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 "<!-- issueflow:$marker -->
|
||||||
|
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 "<!-- issueflow:$marker -->
|
||||||
|
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() {
|
reconcile_issue() {
|
||||||
local n="$1" decision refs cross_refs states age evidence_age ruling_age created assignees open_pr=false label owners
|
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=""
|
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"
|
reconcile_attention "$n" issue "$assignees" "$attention_suppression"
|
||||||
fi
|
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 ruling invariants (#52), on any queue state ----
|
||||||
# The flag composes with the queue labels (#50 D8), so this runs after the
|
# 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
|
# queue branches rather than inside one of them. The FLAG_CONFLICT return
|
||||||
|
|
@ -935,19 +1195,56 @@ main() {
|
||||||
done < <(refs_references <<<"$body")
|
done < <(refs_references <<<"$body")
|
||||||
done)"
|
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_COUNT=0
|
||||||
SKIPPED_ISSUES=""
|
SKIPPED_ISSUES=""
|
||||||
# A command substitution in a for list suppresses errexit. Capture and
|
# A command substitution in a for list suppresses errexit. Capture and
|
||||||
# check the board read before entering the loop, or a 504 (including one
|
# check the board read before entering the loop, or a 504 (including one
|
||||||
# after partial pagination) reports a full pass over a truncated board
|
# after partial pagination) reports a full pass over a truncated board
|
||||||
# (#257).
|
# (#257).
|
||||||
if ! guarded_read issue_numbers gh api --paginate \
|
#
|
||||||
"repos/$REPO/issues?state=open&per_page=100" \
|
# The read answers the whole payload rather than a projection of it because
|
||||||
--jq '.[] | select(has("pull_request") | not) | .number'; then
|
# 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")"
|
log "could not read the issue board: $(read_failure_reason "$READ_FAILURE_STDERR")"
|
||||||
return 1
|
return 1
|
||||||
fi
|
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
|
if [ -z "$issue_numbers" ]; then
|
||||||
log "no open issues."
|
log "no open issues."
|
||||||
else
|
else
|
||||||
|
|
|
||||||
13
changelog.d/293.md
Normal file
13
changelog.d/293.md
Normal file
|
|
@ -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).
|
||||||
|
|
@ -2044,6 +2044,472 @@ order_board '[{"number":83}]'
|
||||||
order_run >/dev/null
|
order_run >/dev/null
|
||||||
check "...and still leaves the job green (D7)" 0 "" test $? -eq 0
|
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 '<!-- issueflow:collision-' "$BOARD/edits"
|
||||||
|
check "...the window one too" 0 "" grep -qF '<!-- issueflow:window-nonmember-' "$BOARD/edits"
|
||||||
|
|
||||||
|
# D4: a state that still stands is silent on the next sweep. The thread is
|
||||||
|
# seeded with the marker the first sweep wrote, which is exactly what the
|
||||||
|
# real API answers an hour later.
|
||||||
|
jq -n --arg b "<!-- issueflow:$(state_marker collision 'issueflow-reconcile=257') -->
|
||||||
|
said already" '[{"user": {"login": "sweep-bot"}, "body": $b}]' \
|
||||||
|
>"$BOARD/repos_owner_repo_issues_284_comments.json"
|
||||||
|
jq -n --arg b "<!-- issueflow:$(state_marker window-nonmember '#249') -->
|
||||||
|
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 "<!-- issueflow:$(state_marker collision 'issueflow-reconcile=253') -->
|
||||||
|
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 "<!-- issueflow:blockers-parsed-none-abc123def456 -->
|
||||||
|
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 "<!-- issueflow:$(state_marker collision 'issueflow-reconcile=257') -->
|
||||||
|
this flag's own last word" \
|
||||||
|
--arg c "<!-- issueflow:blockers-parsed-none-abc123def456 -->
|
||||||
|
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 "<!-- issueflow:$(state_marker collision 'issueflow-reconcile=253') -->
|
||||||
|
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 ----------------
|
# -- the invariant is enforced at the source, not remembered ----------------
|
||||||
# Staging only holds while every mutation goes through run(). A future call
|
# 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
|
# site reaching gh directly would reopen this hole silently, so it is pinned
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue