forked from heavy-duty/ceremony
`CI / test` was red at 97e63ac on a case that passes on this box: upstream's
own "an empty payload is refused". The cause is not the test.
`jq -e` disagrees with itself across versions on EMPTY input. jq 1.7 exits 4 —
no valid result was ever produced. jq 1.6 exits 0. Measured both ways today
against the same filter. This instance's runner image
(ghcr.io/catthehacker/ubuntu:act-22.04) carries jq 1.6.
So on this forge the guard #247 D3 added specifically to refuse an unreadable
read was ACCEPTING one: an empty body read as a valid issue payload, and the
sweep would have reconciled an issue from a payload it never received. The
test is upstream's, it is correct, and it passes on a GitHub runner — which is
why upstream never saw this.
The fix does not depend on jq's exit code for an input it never receives: the
payload is read, emptiness is decided in the shell, and jq judges only a
non-empty body.
Verified under BOTH jq versions, not just the one on this box: empty refused
and healthy accepted on 1.6 and 1.7, and the whole suite green under jq 1.6 —
28 test files, 0 failed — as well as under 1.7.
Refs #198
1324 lines
64 KiB
Bash
1324 lines
64 KiB
Bash
#!/usr/bin/env bash
|
|
# shellcheck disable=SC2016 # backticks in comment bodies are Markdown literals
|
|
if [ "${BASH_SOURCE[0]}" = "$0" ]; then
|
|
set -euo pipefail
|
|
else
|
|
set -u
|
|
fi
|
|
|
|
# The issue-flow half of the labels state machine. Decisions are pure strings;
|
|
# API calls live below the divider so fixture tests can exercise every branch.
|
|
|
|
ISSUEFLOW_NOW="${ISSUEFLOW_NOW:-$(date -u +%s)}"
|
|
ISSUEFLOW_STALE_HOURS="${ISSUEFLOW_STALE_HOURS:-48}"
|
|
[[ "$ISSUEFLOW_NOW" =~ ^[0-9]+$ ]] || {
|
|
echo "issueflow: ISSUEFLOW_NOW must be UTC epoch seconds" >&2
|
|
if [ "${BASH_SOURCE[0]}" = "$0" ]; then exit 1; else return 1; fi
|
|
}
|
|
[[ "$ISSUEFLOW_STALE_HOURS" =~ ^[0-9]+$ ]] || {
|
|
echo "issueflow: ISSUEFLOW_STALE_HOURS must be a non-negative integer" >&2
|
|
if [ "${BASH_SOURCE[0]}" = "$0" ]; then exit 1; else return 1; fi
|
|
}
|
|
NOW="$ISSUEFLOW_NOW"
|
|
STALE_AFTER=$((ISSUEFLOW_STALE_HOURS * 3600))
|
|
QUEUE_LABELS=(ready claimed blocked post-merge)
|
|
TRIAGE_ACTORS=()
|
|
|
|
# The needs-ruling invariants (#52) — one implementation for both surfaces.
|
|
# shellcheck source=lib/ruling.sh
|
|
. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../../lib/ruling.sh"
|
|
# shellcheck source=lib/forge.sh
|
|
. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../../lib/forge.sh"
|
|
# shellcheck source=lib/closes_references.sh
|
|
. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../../lib/closes_references.sh"
|
|
# The attention target invariants (#232) — diagnosis only, both surfaces.
|
|
# shellcheck source=lib/attention.sh
|
|
. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../../lib/attention.sh"
|
|
# The guarded read and its reason line (#101, #247) — one implementation for
|
|
# both surfaces.
|
|
# shellcheck source=lib/read.sh
|
|
. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../../lib/read.sh"
|
|
|
|
# The status a per-issue subshell exits with when it walked away from an
|
|
# unreadable fact (#247 D4). Distinguished from every other non-zero status so
|
|
# a deliberate skip is counted rather than reported as a crash — and so the
|
|
# existing crash handler still names a genuine one.
|
|
ISSUEFLOW_SKIP=3
|
|
# Set by reconcile_issue_pass, read once by main for the D6 tail.
|
|
SKIPPED_COUNT=0
|
|
SKIPPED_ISSUES=""
|
|
|
|
# A per-issue pass is ATOMIC: it commits its whole effect or none of it
|
|
# (#247 D1). Inside reconcile_issue_pass's subshell, `run` and `log` do not
|
|
# act — they append here, and commit_staged_effects replays them in order
|
|
# once the pass has completed. Everywhere else (the arrival path, the sweep's
|
|
# own lines) they act immediately, as they always did.
|
|
#
|
|
# This is the ordering invariant itself, not a fix for the two sites that
|
|
# happened to violate it: a mutation reached before a later guarded read is
|
|
# what let a pass remove `stale`, or mint `needs-triage`, and THEN report the
|
|
# issue as skipped — the sweep saying it touched nothing while a write had
|
|
# landed, which is the same false-report class #247 exists to close. Stated
|
|
# per site it would hold until the next composition; stated here it holds for
|
|
# compositions nobody has written yet, because reconcile_issue has no way to
|
|
# mutate directly.
|
|
#
|
|
# Reads are deliberately NOT staged. They may happen anywhere in the pass,
|
|
# because nothing lands until the end.
|
|
STAGING=false
|
|
STAGED_EFFECTS=()
|
|
|
|
emit() { printf 'issueflow: %s\n' "$*"; }
|
|
apply() { if [ -n "${DRY_RUN:-}" ]; then emit "DRY_RUN: $*"; else "$@"; fi; }
|
|
|
|
stage() { # $1 = LOG|WRITE, rest = the effect's argv, kept exact by the count
|
|
STAGED_EFFECTS+=("$#" "$@")
|
|
}
|
|
|
|
log() { if [ "$STAGING" = true ]; then stage LOG "$@"; else emit "$@"; fi; }
|
|
run() { if [ "$STAGING" = true ]; then stage WRITE "$@"; else apply "$@"; fi; }
|
|
|
|
commit_staged_effects() {
|
|
# In staging order, so a completed pass's log and writes read exactly as
|
|
# they did when each acted at its own call site. The `>/dev/null` is the one
|
|
# every `run` call site already applies: a redirection cannot travel with
|
|
# the argv, so it is applied here instead — uniformly, because on this
|
|
# surface every staged write has it.
|
|
local i=0 argc
|
|
STAGING=false
|
|
while [ "$i" -lt "${#STAGED_EFFECTS[@]}" ]; do
|
|
argc="${STAGED_EFFECTS[i]}"
|
|
if [ "${STAGED_EFFECTS[i + 1]}" = LOG ]; then
|
|
emit "${STAGED_EFFECTS[@]:i + 2:argc - 1}"
|
|
else
|
|
apply "${STAGED_EFFECTS[@]:i + 2:argc - 1}" >/dev/null
|
|
fi
|
|
i=$((i + 1 + argc))
|
|
done
|
|
STAGED_EFFECTS=()
|
|
}
|
|
|
|
skip_issue() { # $1 = issue, $2 = the whole reason clause — ends this issue's pass
|
|
# Leaves the issue exactly as it is: nothing is derived from a read that
|
|
# did not answer, and nothing this pass staged is ever committed — `exit`
|
|
# discards the subshell that holds the buffer. So a skip implies zero
|
|
# `gh issue edit`, zero `gh issue comment`, and no log line claiming an
|
|
# effect that never landed, wherever in the pass the failed read lives.
|
|
# Called from the read itself, so no call site can forget to check — which
|
|
# is why it exits rather than returns. The reason rides its own
|
|
# `#$n:`-prefixed line (#247 D5), emitted directly: the skip is a fact
|
|
# about the pass, not one of the effects the pass staged.
|
|
emit "#$1: skipped this pass — $2"
|
|
exit "$ISSUEFLOW_SKIP"
|
|
}
|
|
|
|
load_issueflow_config() { # $1 = labels.conf
|
|
local conf="$1" line seen=false
|
|
[ -f "$conf" ] || { echo "issueflow: missing config: $conf" >&2; return 1; }
|
|
TRIAGE_ACTORS=()
|
|
while IFS= read -r line || [ -n "$line" ]; do
|
|
case "$line" in
|
|
triage-actors=*)
|
|
[ "$seen" = false ] || {
|
|
echo "issueflow: duplicate triage-actors line in $conf" >&2
|
|
return 1
|
|
}
|
|
seen=true
|
|
read -r -a TRIAGE_ACTORS <<<"${line#triage-actors=}"
|
|
[ "${#TRIAGE_ACTORS[@]}" -gt 0 ] || {
|
|
echo "issueflow: triage-actors must name at least one actor in $conf" >&2
|
|
return 1
|
|
}
|
|
;;
|
|
esac
|
|
done <"$conf"
|
|
[ "$seen" = true ] || {
|
|
echo "issueflow: missing triage-actors= line in $conf" >&2
|
|
return 1
|
|
}
|
|
}
|
|
|
|
is_triage_actor() {
|
|
local actor
|
|
for actor in "${TRIAGE_ACTORS[@]}"; do
|
|
[ "$actor" = "$1" ] && return 0
|
|
done
|
|
return 1
|
|
}
|
|
|
|
has_issue_label() { grep -qxF "$1" <<<"$ISSUE_LABELS"; }
|
|
|
|
queue_decision() { # labels on stdin -> KEEP | ADD_NEEDS_TRIAGE | FLAG_CONFLICT
|
|
local labels count=0 label categories=0
|
|
labels="$(cat)"
|
|
grep -qxF needs-triage <<<"$labels" && categories=$((categories + 1))
|
|
grep -qxF epic <<<"$labels" && categories=$((categories + 1))
|
|
for label in "${QUEUE_LABELS[@]}"; do
|
|
if grep -qxF "$label" <<<"$labels"; then count=$((count + 1)); fi
|
|
done
|
|
[ "$count" -gt 0 ] && categories=$((categories + 1))
|
|
if [ "$categories" -eq 0 ]; then echo ADD_NEEDS_TRIAGE
|
|
elif [ "$categories" -gt 1 ] || [ "$count" -gt 1 ]; then echo FLAG_CONFLICT
|
|
else echo KEEP
|
|
fi
|
|
}
|
|
|
|
author_decision() { # $1 = true when author is triage; labels on stdin
|
|
local triage="$1" labels
|
|
labels="$(cat)"
|
|
if [ "$triage" = false ] && ! grep -qxF needs-triage <<<"$labels"; then
|
|
echo ADD_NEEDS_TRIAGE
|
|
else echo KEEP
|
|
fi
|
|
}
|
|
|
|
claim_decision() { # $1 assignee count, $2 linked open PR, $3 age seconds
|
|
local assignees="$1" open_pr="$2" age="$3"
|
|
# Staleness wins over missing ownership: a stale unassigned claim is
|
|
# derivably reclaimable, while a recent unassigned claim needs triage.
|
|
if [ "$open_pr" = false ] && [ "$age" -gt "$STALE_AFTER" ]; then echo RECLAIM
|
|
elif [ "$assignees" -eq 0 ]; then echo FLAG_UNASSIGNED
|
|
else echo KEEP
|
|
fi
|
|
}
|
|
|
|
claim_clock_exempt() { # labels on stdin -> EXEMPT | SWEEP
|
|
local labels
|
|
labels="$(cat)"
|
|
# Cross-repo work has no local closing PR by construction (#68), so its
|
|
# deliberate silence must share the one claim-clock gate with rulings.
|
|
if grep -qxF offsite <<<"$labels" \
|
|
|| [ "$(ruling_stale_exempt <<<"$labels")" = EXEMPT ]; then
|
|
echo EXEMPT
|
|
else
|
|
echo SWEEP
|
|
fi
|
|
}
|
|
|
|
claim_decision_at() { # $1 assignee count, $2 linked open PR, $3 last activity epoch
|
|
claim_decision "$1" "$2" "$((NOW - $3))"
|
|
}
|
|
|
|
claim_reclaim_marker() { # $1 = last activity epoch
|
|
printf 'claim-reclaimed-%s\n' "$1"
|
|
}
|
|
|
|
refs_references() { # PR body on stdin -> local issue numbers named by Refs
|
|
awk '
|
|
{
|
|
line = $0
|
|
lower = tolower(line)
|
|
if (match(lower, /(^|[^[:alnum:]_-])refs[[:space:]:]+/)) {
|
|
line = substr(line, RSTART + RLENGTH)
|
|
if (line ~ /^(#|([[:alnum:]_.-]+\/)?[[:alnum:]_.-]+#)[0-9]+/) {
|
|
sub(/[.(;].*/, "", line)
|
|
print line
|
|
}
|
|
}
|
|
}
|
|
' | issue_references \
|
|
| awk -F '\t' '$1 == "LOCAL" { print $2 }' | sort -nu
|
|
}
|
|
|
|
open_pr_issues() { # records on stdin: CLOSING|BODY<TAB>value -> issue numbers
|
|
local kind value
|
|
while IFS=$'\t' read -r kind value; do
|
|
case "$kind" in
|
|
CLOSING) [ -n "$value" ] && printf '%s\n' "$value" ;;
|
|
BODY) refs_references <<<"$value" ;;
|
|
esac
|
|
done | sort -nu
|
|
}
|
|
|
|
unchecked_criteria() { # issue body on stdin -> unchecked task-list lines verbatim
|
|
awk '
|
|
/^[[:space:]]*([-*]|[0-9]+\.)[[:space:]]+\[[[:space:]]\]/ {
|
|
sub(/\r$/, "")
|
|
print
|
|
}
|
|
'
|
|
}
|
|
|
|
post_merge_decision() { # $1 merged Refs PR, $2 linked open PR, $3 already handled
|
|
local merged="$1" open_pr="$2" handled="$3" unchecked
|
|
unchecked="$(cat)"
|
|
if [ -n "$merged" ] && [ "$open_pr" = false ] && [ "$handled" = false ] \
|
|
&& [ -n "$unchecked" ]; then echo TRANSITION
|
|
else echo KEEP
|
|
fi
|
|
}
|
|
|
|
post_merge_pr_for_issue() { # $1 issue; records are ISSUE<TAB>PR<TAB>MERGED_AT
|
|
# The deliverable is the PR that merged last, not the one numbered highest.
|
|
# Merge order is not number order in this family: crew#176's two Refs PRs
|
|
# merged #184 at 19:05:16Z and #182 at 19:05:18Z — the higher number two
|
|
# seconds earlier. Number order is also what spends a marker on the wrong
|
|
# PR: crew#321 carries `post-merge-transition-pr-326` while its real
|
|
# deliverable crew#322 — a lower number, merging later — is still open, so
|
|
# under the old rule the transition it owes could never fire (#242).
|
|
# mergedAt is ISO-8601 UTC, so it sorts as a string; ties break by highest
|
|
# PR number so the answer never depends on input order.
|
|
awk -F '\t' -v issue="$1" '$1 == issue { print $3 "\t" $2 }' \
|
|
<<<"${MERGED_REF_PR_RECORDS:-}" \
|
|
| sort -t $'\t' -k1,1 -k2,2n | tail -n1 | cut -f2
|
|
}
|
|
|
|
post_merge_transition_marker() { # $1 merged PR number
|
|
printf 'post-merge-transition-pr-%s\n' "$1"
|
|
}
|
|
|
|
issue_references() { # text on stdin -> LOCAL/CROSS<TAB>reference
|
|
# A qualified reference belongs to another repository. Classify the whole
|
|
# token before extracting numbers so rig#112 can never become local #112.
|
|
{ grep -Eo '([[:alnum:]_.-]+/)?[[:alnum:]_.-]+#[0-9]+|#[0-9]+' || true; } \
|
|
| awk '
|
|
index($0, "#") == 1 { print "LOCAL\t" substr($0, 2); next }
|
|
{ print "CROSS\t" $0 }
|
|
'
|
|
}
|
|
|
|
blocked_reference_records() { # body on stdin -> classified reference records
|
|
# Every occurrence of the marker contributes a clause. Binding to the first
|
|
# occurrence alone dropped the later sentences of a repeated declaration
|
|
# ("Blocked by #152. Blocked by #153. Blocked by #148 — …") and let earlier
|
|
# prose that merely mentioned being blocked hijack the parse — the false
|
|
# `ready` promotion on rig#154 (#184). Each clause runs to its own first
|
|
# sentence terminator; declarations sometimes soft-wrap after a comma, so
|
|
# an open clause continues across lines, and if prose omits the terminator
|
|
# it retains to end of input. Unioning can over-retain — prose like "this
|
|
# was blocked by #9 before the split" now contributes #9 — and that is the
|
|
# correct direction of error: a stale `blocked` is a triage comment away,
|
|
# a false `ready` sends a builder into work that cannot merge (#184).
|
|
awk '
|
|
BEGIN { marker = "blocked by" }
|
|
{
|
|
line = $0
|
|
while (1) {
|
|
if (!active) {
|
|
start = index(tolower(line), marker)
|
|
if (!start) next
|
|
line = substr(line, start + length(marker))
|
|
active = 1
|
|
}
|
|
if (match(line, /[.;]/)) {
|
|
print substr(line, 1, RSTART - 1)
|
|
line = substr(line, RSTART + 1)
|
|
active = 0
|
|
} else {
|
|
print line
|
|
next
|
|
}
|
|
}
|
|
}
|
|
' | issue_references
|
|
}
|
|
|
|
blocked_references() { # body on stdin -> local issue numbers, one per line
|
|
blocked_reference_records | awk -F '\t' '$1 == "LOCAL" { print $2 }' | sort -nu
|
|
}
|
|
|
|
blocked_cross_references() { # body on stdin -> qualified refs, one per line
|
|
blocked_reference_records | awk -F '\t' '$1 == "CROSS" { print $2 }' | sort -u
|
|
}
|
|
|
|
blocked_parse_set() { # $1 local refs, $2 cross refs -> "{#7, #12}" | "{}"
|
|
# The parse, rendered once. The comment, the marker and the log line all
|
|
# read this one string, so the three can never disagree about what the
|
|
# machine read. Both classes are shown because both are parsed: the locals
|
|
# in the numeric order blocked_references answers, then the qualified
|
|
# references blocked_cross_references answers — a cross-repo clause is as
|
|
# capable of being readable-but-wrong as a local one.
|
|
local rendered
|
|
rendered="$(
|
|
{ [ -z "$1" ] || awk '{ print "#" $0 }' <<<"$1"
|
|
[ -z "${2:-}" ] || printf '%s\n' "$2"
|
|
} | awk '{ printf "%s%s", (NR > 1 ? ", " : ""), $0 } END { printf "\n" }'
|
|
)"
|
|
printf '{%s}\n' "$rendered"
|
|
}
|
|
|
|
blocked_parse_marker() { # $1 rendered set -> the echo's idempotency marker
|
|
# Scoped to the SET's value, not to the issue and not to the sweep: the
|
|
# marker names WHAT was echoed, and blocked_parse_echo_needed decides whether
|
|
# it is still what the thread is saying.
|
|
#
|
|
# The identity is the DIGEST, not the slug beside it. Slugging is many-to-one
|
|
# — `{acme/widgets#9}` and `{acme-widgets#9}` are both parses this reconciler
|
|
# accepts, and both slug to `acme-widgets-9` — so a slug-keyed marker lets a
|
|
# changed set find the old marker and say nothing, silence in precisely the
|
|
# case the echo exists to speak about. Distinguishing `/` would close that
|
|
# pair and leave the class: `-`, `_` and `.` are legal in a qualifier token
|
|
# and all collapse the same way. The slug stays in front so a human reading
|
|
# the raw comment can still see which set it belongs to; it decides nothing.
|
|
state_marker blockers-parsed "$1"
|
|
}
|
|
|
|
state_marker() { # $1 = marker family, $2 = the state's rendered value
|
|
# The one spelling of a value-keyed marker. Three flags now key on a state
|
|
# that changes rather than on "have I ever said this" — the blocked-parse
|
|
# echo (#252) and the two board flags (#293) — and a second implementation
|
|
# of the slug-plus-digest rule is the drift a shared helper prevents.
|
|
local slug digest
|
|
slug="$(printf '%s' "$2" | tr -c '[:alnum:]' '-' | sed 's/--*/-/g; s/^-//; s/-$//')"
|
|
digest="$(printf '%s' "$2" | sha256sum | cut -c1-12)"
|
|
printf '%s-%s-%s\n' "$1" "${slug:-none}" "$digest"
|
|
}
|
|
|
|
blocked_parse_echo_needed() { # $1 issue, $2 this parse's marker → 0 echo, 1 quiet
|
|
# Idempotency for the parse echo is against the LAST parse echo on the
|
|
# thread, not against any historical one. ensure_comment's any-occurrence
|
|
# grep is right for a flag like `blocked-unparseable`, whose question is
|
|
# "have I ever said this"; it is wrong for a value that changes, whose
|
|
# question is "is this still what I am saying". The difference is A -> B -> A:
|
|
# under an any-occurrence search the return to A finds A's own first echo and
|
|
# stays silent, leaving the thread's most recent echo asserting B while the
|
|
# sweep gates on A. A stale parse presented as the current one is the exact
|
|
# failure #252 exists to kill, and the third edit changed the parsed set, so
|
|
# the criterion says it speaks.
|
|
#
|
|
# Comparing markers rather than re-rendering the last set keeps the digest as
|
|
# the only identity: two sets are the same here iff blocked_parse_marker says
|
|
# so, the same rule the marker itself is built on.
|
|
state_echo_needed "$1" blockers-parsed "$2"
|
|
}
|
|
|
|
state_echo_needed() { # $1 issue, $2 family, $3 this state's marker → 0 echo, 1 quiet
|
|
# The value-keyed dedup itself, family-scoped so each flag compares against
|
|
# its OWN last word and never against another flag's (#293 D4 asks for the
|
|
# declaration echo's mechanism exactly, and three families now share it).
|
|
local bodies last
|
|
guarded_read bodies forge_api --paginate "repos/$REPO/issues/$1/comments" --jq '.[].body' \
|
|
|| skip_issue "$1" "could not read its comments: $(read_failure_reason "$READ_FAILURE_STDERR")"
|
|
# The read fails closed above (#247 D1): an unreadable history skips the
|
|
# issue rather than answering "nothing echoed yet" and re-posting.
|
|
last="$(grep -o "<!-- issueflow:$2-[[:alnum:]-]* -->" <<<"$bodies" | tail -n 1)"
|
|
[ "$last" != "<!-- issueflow:$3 -->" ]
|
|
}
|
|
|
|
blocked_decision() { # $1 local refs, $2 OPEN/CLOSED states, $3 cross-repo refs
|
|
local refs="$1" states="$2" cross_refs="${3:-}"
|
|
if [ -n "$cross_refs" ]; then echo FLAG_CROSS_REPO
|
|
elif [ -z "$refs" ]; then echo FLAG_UNPARSEABLE
|
|
elif grep -qxF OPEN <<<"$states"; then echo KEEP
|
|
elif grep -qxF UNKNOWN <<<"$states"; then echo FLAG_UNPARSEABLE
|
|
else echo READY
|
|
fi
|
|
}
|
|
|
|
epic_references() { # markdown task-list issue references from body on stdin
|
|
awk '
|
|
tolower($0) ~ /^##[[:space:]]+task list[[:space:]]*$/ { in_list = 1; next }
|
|
in_list && /^#/ { exit }
|
|
in_list && /^[[:space:]]*[-*][[:space:]]+\[[ xX]\]/ { print }
|
|
' | issue_references \
|
|
| awk -F '\t' '$1 == "LOCAL" { print $2 }' | sort -nu
|
|
}
|
|
|
|
epic_decision() { # $1 refs, $2 states
|
|
local refs="$1" states="$2"
|
|
if [ -n "$refs" ] && ! grep -Eq '^(OPEN|UNKNOWN)$' <<<"$states"; then echo NUDGE
|
|
else echo KEEP
|
|
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
|
|
jq -r '
|
|
.[]
|
|
| select(.event == "cross-referenced")
|
|
| .source.issue
|
|
| select(.pull_request != null)
|
|
| select(.repository.full_name != null and .number != null)
|
|
| "\(.repository.full_name)#\(.number)"
|
|
' | sort -u
|
|
}
|
|
|
|
offsite_resolved_decision() { # PR states on stdin -> NUDGE | QUIET
|
|
local states
|
|
states="$(cat)"
|
|
if [ -n "$states" ] && ! grep -Eq '^(OPEN|UNKNOWN)$' <<<"$states"; then
|
|
echo NUDGE
|
|
else
|
|
echo QUIET
|
|
fi
|
|
}
|
|
|
|
issue_payload_valid() { # $1 = the requested issue; payload on stdin
|
|
# The second of D3's two required guards, and neither subsumes the other.
|
|
# The status check catches the 504 whose body is GitHub's JSON error object
|
|
# — valid JSON that passes every jq guard and empties the label set. THIS
|
|
# one catches an HTTP 200 whose body is `null`, which exits 0 and empties it
|
|
# just the same. `.number` is checked against the issue asked for, so a
|
|
# payload about some other issue can never be reconciled as this one.
|
|
# THE EMPTINESS CHECK IS NOT REDUNDANT, and it is not stylistic. `jq -e`
|
|
# disagrees with itself across versions on empty input: jq 1.7 exits 4 (no
|
|
# valid result was ever produced), jq 1.6 exits **0**. This instance's
|
|
# runner image (ghcr.io/catthehacker/ubuntu:act-22.04) carries jq 1.6, so
|
|
# without this line an EMPTY payload reads as a valid issue payload here —
|
|
# the precise thing D3 added this guard to refuse — and the sweep would
|
|
# reconcile an issue from a body it never received. Measured both ways,
|
|
# 2026-08-05: `jq -e '<this filter>' </dev/null` → rc 0 on 1.6, rc 4 on 1.7.
|
|
# The test that caught it is upstream's own and passes on a GitHub runner
|
|
# (#198).
|
|
local payload
|
|
payload="$(cat)"
|
|
case "$payload" in *[![:space:]]*) ;; *) return 1 ;; esac
|
|
jq -e --arg n "$1" '
|
|
type == "object" and (.number | tostring) == $n and (.labels | type) == "array"
|
|
' <<<"$payload" >/dev/null 2>&1
|
|
}
|
|
|
|
skipped_tail() { # $1 = skip count, $2 = the issue numbers → the D6 line, or nothing
|
|
# `reconciled.` stays byte-identical when the pass was whole — tests pin that
|
|
# exact string, and #101 D1 is the precedent for not folding new text into a
|
|
# matched line. A partial pass says so on a line of its own, after it, so a
|
|
# consumer reading only the tail of a job log can see it.
|
|
[ "$1" -gt 0 ] || return 0
|
|
if [ "$1" -eq 1 ]; then
|
|
printf '%s issue skipped this pass on an unreadable fact: %s\n' "$1" "$2"
|
|
else
|
|
printf '%s issues skipped this pass on unreadable facts: %s\n' "$1" "$2"
|
|
fi
|
|
}
|
|
|
|
# API edge. Marker comments make warnings and nudges idempotent across sweeps.
|
|
ensure_comment() { # $1 issue, $2 marker, $3 message
|
|
local n="$1" marker="$2" message="$3"
|
|
if issue_comment_has_marker "$n" "$marker"; then return; fi
|
|
run forge_issue_comment "$n" "<!-- issueflow:$marker -->
|
|
$message" >/dev/null
|
|
}
|
|
|
|
issue_comment_has_marker() { # $1 issue, $2 marker → 0 found, 1 genuinely absent
|
|
# A failed read used to answer "no marker", which re-posts the comment the
|
|
# marker exists to suppress — absence of evidence read as evidence of
|
|
# absence (#247 D1). It cannot be a return value: every caller treats
|
|
# non-zero as "absent", so the skip is taken here, at the read.
|
|
local bodies
|
|
guarded_read bodies forge_api --paginate "repos/$REPO/issues/$1/comments" --jq '.[].body' \
|
|
|| skip_issue "$1" "could not read its comments: $(read_failure_reason "$READ_FAILURE_STDERR")"
|
|
grep -qF "<!-- issueflow:$2 -->" <<<"$bodies"
|
|
}
|
|
|
|
reference_states() {
|
|
local ref state
|
|
while IFS= read -r ref; do
|
|
[ -n "$ref" ] || continue
|
|
state="$(forge_api "repos/$REPO/issues/$ref" --jq '.state' 2>/dev/null || echo UNKNOWN)"
|
|
case "$state" in open) echo OPEN ;; closed) echo CLOSED ;; *) echo UNKNOWN ;; esac
|
|
done
|
|
}
|
|
|
|
offsite_pr_states() {
|
|
local ref repo number state
|
|
while IFS= read -r ref; do
|
|
[ -n "$ref" ] || continue
|
|
repo="${ref%#*}"
|
|
number="${ref##*#}"
|
|
state="$(forge_api "repos/$repo/pulls/$number" --jq '.state' 2>/dev/null || echo UNKNOWN)"
|
|
case "$state" in open) echo OPEN ;; closed) echo CLOSED ;; *) echo UNKNOWN ;; esac
|
|
done
|
|
}
|
|
|
|
offsite_timeline() { # unreadable timelines are deliberately silent
|
|
forge_api --paginate "repos/$REPO/issues/$1/timeline" 2>/dev/null || return 1
|
|
}
|
|
|
|
issue_activity_at() { # $1 issue, $2 created_at, $3 with-assignment|comments-only
|
|
# One body, two clocks over it — a second activity computation is the drift
|
|
# the reuse exists to prevent, and the two callers below are the whole
|
|
# difference between them.
|
|
#
|
|
# Both reads are checked, and a failure reports rather than answering an age
|
|
# (#247 D1). Swallowed, the comments read falls back to `created_at`, and a
|
|
# `claimed` issue created months ago but commented on seconds earlier is
|
|
# reclaimed — the live builder unassigned, under a comment asserting 48
|
|
# hours of silence. `needs-triage` is cheap to remove; that is not.
|
|
# The backend's stderr is left to flow to this function's own, where the
|
|
# caller's guarded_read captures it for the reason line.
|
|
local n="$1" created="$2" mode="$3" comments timeline="" latest
|
|
comments="$(forge_api --paginate "repos/$REPO/issues/$n/comments" --jq '.[].created_at')" \
|
|
|| return 1
|
|
if [ "$mode" = with-assignment ]; then
|
|
timeline="$(forge_api --paginate "repos/$REPO/issues/$n/timeline" \
|
|
--jq '.[] | select(.event == "assigned") | .created_at')" || return 1
|
|
fi
|
|
latest="$(printf '%s\n%s\n%s\n' "$created" "$comments" "$timeline" | sort | tail -n1)"
|
|
date -d "$latest" +%s
|
|
}
|
|
|
|
last_issue_activity() { # $1 issue, $2 created_at → epoch; non-zero if a read failed
|
|
# The claim clock, and only the claim clock (#284). Assignment is the claim
|
|
# itself. Ignoring it would let an old issue be reclaimed in the seconds
|
|
# between assignment and its required draft PR. The ruling clock no longer
|
|
# rides here: an assignment says nothing about whether the decider
|
|
# answered, and counting it let claiming a flagged issue buy its
|
|
# escalation another 7 quiet days.
|
|
issue_activity_at "$1" "$2" with-assignment
|
|
}
|
|
|
|
last_issue_comment_activity() { # $1 issue, $2 created_at → epoch; non-zero on a failed read
|
|
# The evidence nudge's clock (#254), and the issue-side ruling clock with
|
|
# it (#284): on an issue, a comment is the only substantive activity
|
|
# toward a ruling. Same computation as the claim clock, one input fewer,
|
|
# and the input it drops is the one that would starve each criterion: on
|
|
# `post-merge` there is no claim for an assignment to protect, and an
|
|
# assignee there is the invalid composition the `post-merge-assigned` flag
|
|
# reports; under `needs-ruling` the assignment is the *claim's* fact, and
|
|
# counting it silenced the escalation at exactly the moment somebody
|
|
# started working through it. Either way, a wider clock would let board
|
|
# state buy the wait another 7 days of silence.
|
|
#
|
|
# A comment the sweep itself wrote is still activity here, deliberately:
|
|
# the nudge carries no marker, so its own comment is what rate-limits it,
|
|
# and no machine comment can be exempted without exempting that one too.
|
|
# Reading authorship back into the clock would mean a body read this issue
|
|
# forbids.
|
|
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 forge_issue_comment "$n" "<!-- 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 forge_issue_comment "$n" "<!-- 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() {
|
|
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 unchecked="" remove_claimed=claimed
|
|
local attention_active=true attention_suppression=""
|
|
decision="$(queue_decision <<<"$ISSUE_LABELS")"
|
|
case "$decision" in
|
|
ADD_NEEDS_TRIAGE)
|
|
run forge_issue_edit "$n" --add-label needs-triage >/dev/null
|
|
log "#$n: needs-triage (no queue state)" ;;
|
|
FLAG_CONFLICT)
|
|
ensure_comment "$n" queue-conflict \
|
|
'The issue-flow sweep found conflicting queue labels. It cannot infer intent safely; triage must leave exactly one of `needs-triage`, `epic`, `ready`, `claimed`, `blocked`, or `post-merge`.'
|
|
log "#$n: conflicting queue labels; flagged"
|
|
return ;;
|
|
esac
|
|
|
|
if has_issue_label claimed; then
|
|
assignees="$(jq '.assignees | length' <<<"$ISSUE_JSON")"
|
|
grep -qxF "$n" <<<"${OPEN_PR_ISSUES:-}" && open_pr=true
|
|
# Under a pending ruling, the ruling clock is read at the top of the
|
|
# branch, before anything either arm below can post — the derived
|
|
# transition comment, the reclaim notice and the claimed-unassigned flag
|
|
# are all comments, and a read taken after one would date the issue by
|
|
# this sweep's own writing (#284; the hazard #274 met from the other
|
|
# side). Two clocks on purpose: `age` below counts the assignment
|
|
# because the assignment IS the claim; the ruling waits on a human, and
|
|
# an assignment says nothing about whether the decider answered.
|
|
if has_issue_label needs-ruling; then
|
|
created="$(jq -r '.created_at' <<<"$ISSUE_JSON")"
|
|
guarded_read ruling_age last_issue_comment_activity "$n" "$created" \
|
|
|| skip_issue "$n" "could not read its activity history: $(read_failure_reason "$READ_FAILURE_STDERR")"
|
|
fi
|
|
merged_ref_pr="$(post_merge_pr_for_issue "$n")"
|
|
if [ -n "$merged_ref_pr" ]; then
|
|
transition_marker="$(post_merge_transition_marker "$merged_ref_pr")"
|
|
issue_comment_has_marker "$n" "$transition_marker" \
|
|
&& transition_handled=true
|
|
fi
|
|
unchecked="$(unchecked_criteria <<<"$(jq -r '.body // ""' <<<"$ISSUE_JSON")")"
|
|
if [ "$(post_merge_decision "$merged_ref_pr" "$open_pr" "$transition_handled" \
|
|
<<<"$unchecked")" = TRANSITION ]; then
|
|
ensure_comment "$n" "$transition_marker" \
|
|
"The Refs-linked PR merged with these acceptance criteria still unchecked:
|
|
|
|
$unchecked
|
|
|
|
The merge releases the claim; no builder owes a draft. Triage owes completion in a follow-up comment that names the owner and wake condition."
|
|
owners="$(jq -r '[.assignees[].login] | join(",")' <<<"$ISSUE_JSON")"
|
|
# `attention` is a demand for the assigned builder. The derived
|
|
# transition releases that builder, so carrying the demand forward
|
|
# would create an impossible parked-for state (#175 D4).
|
|
has_issue_label attention && remove_claimed=claimed,attention
|
|
if [ -n "$owners" ]; then
|
|
run forge_issue_edit "$n" --remove-assignee "$owners" \
|
|
--remove-label "$remove_claimed" --add-label post-merge >/dev/null
|
|
else
|
|
run forge_issue_edit "$n" \
|
|
--remove-label "$remove_claimed" --add-label post-merge >/dev/null
|
|
fi
|
|
log "#$n: merged Refs PR -> post-merge; claim released"
|
|
attention_active=false
|
|
else
|
|
created="$(jq -r '.created_at' <<<"$ISSUE_JSON")"
|
|
guarded_read age last_issue_activity "$n" "$created" \
|
|
|| skip_issue "$n" "could not read its activity history: $(read_failure_reason "$READ_FAILURE_STDERR")"
|
|
if [ "$(claim_clock_exempt <<<"$ISSUE_LABELS")" = EXEMPT ]; then
|
|
# Legitimately quiet work does not run the reclaim clock. Only the
|
|
# clock stops: an unassigned claim is still a repair the decision must
|
|
# see, so it runs on a zero age rather than being skipped.
|
|
decision="$(claim_decision "$assignees" "$open_pr" 0)"
|
|
else
|
|
decision="$(claim_decision_at "$assignees" "$open_pr" "$age")"
|
|
fi
|
|
case "$decision" in
|
|
FLAG_UNASSIGNED)
|
|
ensure_comment "$n" claimed-unassigned \
|
|
'This issue is `claimed` but has no assignee. The sweep cannot infer an owner; triage must repair the claim.' ;;
|
|
RECLAIM)
|
|
# The last-activity epoch identifies a claim episode. A fixed marker
|
|
# hid the required comment when the same issue was later claimed and
|
|
# reclaimed again.
|
|
ensure_comment "$n" "$(claim_reclaim_marker "$age")" \
|
|
'This claim has no linked open PR and no activity for 48 hours. The sweep is reclaiming it for the ready queue.'
|
|
owners="$(jq -r '[.assignees[].login] | join(",")' <<<"$ISSUE_JSON")"
|
|
if [ -n "$owners" ]; then
|
|
run forge_issue_edit "$n" --remove-assignee "$owners" \
|
|
--remove-label claimed --add-label ready >/dev/null
|
|
else
|
|
run forge_issue_edit "$n" --remove-label claimed --add-label ready >/dev/null
|
|
fi
|
|
log "#$n: stale claim reclaimed -> ready" ;;
|
|
esac
|
|
[ "$decision" != FLAG_UNASSIGNED ] || attention_suppression=claimed-unassigned
|
|
if has_issue_label offsite; then
|
|
local timeline
|
|
if timeline="$(offsite_timeline "$n")"; then
|
|
refs="$(offsite_cross_referenced_prs <<<"$timeline")"
|
|
states="$(offsite_pr_states <<<"$refs")"
|
|
if [ "$(offsite_resolved_decision <<<"$states")" = NUDGE ]; then
|
|
ensure_comment "$n" offsite-resolved \
|
|
"$(tr '\n' ' ' <<<"$refs" | sed 's/[[:space:]]*$//') is closed; this issue's \`offsite\` flag is still up. Clear it and close the issue, or say what is still outstanding. @$(jq -r '.assignees[0].login' <<<"$ISSUE_JSON")"
|
|
log "#$n: resolved offsite PRs nudged"
|
|
fi
|
|
fi
|
|
fi
|
|
fi
|
|
elif has_issue_label post-merge; then
|
|
assignees="$(jq '.assignees | length' <<<"$ISSUE_JSON")"
|
|
# The evidence nudge's clock is read BEFORE any comment this branch
|
|
# posts. `ensure_comment` below is itself activity, so reading after it
|
|
# would let the assigned-flag comment silence the nudge for another 7
|
|
# days — the same self-silencing the ruling nudge avoids by taking its
|
|
# clock from this same read, below.
|
|
created="$(jq -r '.created_at' <<<"$ISSUE_JSON")"
|
|
guarded_read evidence_age last_issue_comment_activity "$n" "$created" \
|
|
|| skip_issue "$n" "could not read its activity history: $(read_failure_reason "$READ_FAILURE_STDERR")"
|
|
# On this surface the ruling clock IS this read (#284 D6): both nudges
|
|
# wait on comments and nothing else, so the evidence clock is handed to
|
|
# the ruling block rather than read again — and handed HERE, before the
|
|
# assigned-flag comment and the evidence nudge below, so neither wait is
|
|
# ever answered by anything this pass writes. `post-merge` +
|
|
# `needs-ruling` now costs one comments read where it cost three.
|
|
ruling_age="$evidence_age"
|
|
if [ "$assignees" -gt 0 ] || has_issue_label attention; then
|
|
ensure_comment "$n" post-merge-assigned \
|
|
'This `post-merge` issue has an assignee or `attention`. The sweep will not undo hand-set intent; triage must clear the invalid composition or move the issue back into buildable queue state.'
|
|
log "#$n: assigned or attention-bearing post-merge issue flagged"
|
|
fi
|
|
attention_suppression=post-merge-assigned
|
|
# ---- the post-merge evidence nudge (#254), the ruling nudge's twin ----
|
|
# A `post-merge` item waits on named evidence with a named owner, and
|
|
# nothing nudged when the wait went quiet: crew#181's real-host criterion
|
|
# starved four separate times across two releases, crew#240/#264 sat
|
|
# until an operator happened to run the right read. Same 7-day rule, same
|
|
# constant, same no-marker property — `ruling_nudge_decision` is the one
|
|
# spelling of all three (lib/ruling.sh), and a second `7 * 24 * 3600`
|
|
# here is the drift that file exists to prevent.
|
|
#
|
|
# The addressee is the triage actor, not `HUMAN_REVIEWER`: `post-merge`
|
|
# is triage's completion queue by contract (TRIAGE.md), so a starving
|
|
# wake condition is triage's to answer, and routing it to the operator
|
|
# asks the wrong party for a move it does not owe. `triage-actors=` is
|
|
# mandatory config — `load_issueflow_config` refuses to run without it —
|
|
# so there is nothing to fall back to, and a silent fallback is exactly
|
|
# how the wrong addressee comes back.
|
|
if [ "$(ruling_nudge_decision "$NOW" "$evidence_age")" = NUDGE ]; then
|
|
local quiet_days=$(((NOW - evidence_age) / 86400))
|
|
# Trailing slash stripped so a server URL that carries one does not
|
|
# render `//owner/repo` — `:-` first so an absent value is empty rather
|
|
# than fatal, `%/` second so a present one is normalized (#198).
|
|
local server="${GITHUB_SERVER_URL:-}"
|
|
server="${server%/}"
|
|
run forge_issue_comment "$n" "@${TRIAGE_ACTORS[0]} — this \`post-merge\` item has had no comment for ${quiet_days} days: ${server}/$REPO/issues/$n
|
|
|
|
Its wake evidence is still owed. \`post-merge\` means the merge landed and
|
|
triage owns completion — judge the remaining criteria against the evidence
|
|
and close the issue, or say what is still outstanding and who owes it. The
|
|
sweep names no criterion: which one starved is prose, and the machine never
|
|
judges prose (the link is the payload).
|
|
|
|
*This nudge is comment-only and carries no idempotency marker on purpose: the comment itself is activity, so posting it resets the 7-day window and the rule self-rate-limits to one nudge per 7 quiet days. Do not add a marker.*" >/dev/null
|
|
log "#$n: post-merge evidence nudge (${quiet_days}d quiet — triage owes the wake evidence)"
|
|
fi
|
|
elif has_issue_label blocked; then
|
|
refs="$(blocked_references <<<"$(jq -r '.body // ""' <<<"$ISSUE_JSON")")"
|
|
cross_refs="$(blocked_cross_references <<<"$(jq -r '.body // ""' <<<"$ISSUE_JSON")")"
|
|
# The parse is echoed before any verdict is derived from it (#252). The
|
|
# clause parse is exact and unforgiving, and its output was invisible:
|
|
# crew#308 silently parsed a negated "no longer blocked by #221" as a
|
|
# blocker, crew#71 spent five days as an unresolvable queue conflict, and
|
|
# crew#284's declaration had to be re-derived by hand-running the parser.
|
|
# Every one of those was found by a human running the parser, hours or
|
|
# days late. `blocked-unparseable` already catches the UNREADABLE
|
|
# declaration; this catches the readable-but-wrong one, which no flag can
|
|
# detect because the machine cannot judge what a human meant — only state
|
|
# what it read, and let the human see the divergence in one sweep.
|
|
#
|
|
# The illustrative `#9` in the body below is code-spanned for the same
|
|
# reason `blocked-unparseable` code-spans its `Blocked by #N`: an
|
|
# unbackticked `#N` in a comment this sweep posts on a cron linkifies, and
|
|
# writes a "mentioned in" event onto an unrelated issue once per echo.
|
|
parsed_set="$(blocked_parse_set "$refs" "$cross_refs")"
|
|
parse_marker="$(blocked_parse_marker "$parsed_set")"
|
|
if blocked_parse_echo_needed "$n" "$parse_marker"; then
|
|
run forge_issue_comment "$n" "<!-- issueflow:$parse_marker -->
|
|
This issue's \`Blocked by\` declarations parse to: $parsed_set
|
|
|
|
That is the exact set this sweep gates on — what the machine read, never a
|
|
judgment about whether it is what you meant. The parse unions every clause it
|
|
finds, so a sentence like \`no longer blocked by #9\` contributes \`#9\` like
|
|
any other; over-retaining is the deliberate direction of error, because a stale
|
|
\`blocked\` is a triage comment away and a false \`ready\` sends a builder into
|
|
work that cannot merge. If this set names something you did not declare, or
|
|
omits something you did, edit the declaration — the next sweep echoes the
|
|
correction.
|
|
|
|
*Comment only: nothing on this path writes a label. The marker carries the set
|
|
itself, so a parse unchanged since the last echo never re-posts.*" >/dev/null
|
|
fi
|
|
log "#$n: blocked declarations parse to $parsed_set"
|
|
states="$(reference_states <<<"$refs")"
|
|
decision="$(blocked_decision "$refs" "$states" "$cross_refs")"
|
|
case "$decision" in
|
|
FLAG_CROSS_REPO)
|
|
ensure_comment "$n" blocked-cross-repo \
|
|
"This issue's \`Blocked by\` declaration names cross-repo dependencies that the sweep cannot resolve: $(tr '\n' ' ' <<<"$cross_refs" | sed 's/[[:space:]]*$//'). Triage must verify those dependencies and flip this issue to \`ready\` by hand." ;;
|
|
FLAG_UNPARSEABLE)
|
|
ensure_comment "$n" blocked-unparseable \
|
|
'This issue is `blocked`, but its body has no parseable `Blocked by #N` declaration. The sweep will not guess the dependency.' ;;
|
|
READY)
|
|
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
|
|
log "#$n: blockers closed -> ready" ;;
|
|
esac
|
|
elif has_issue_label epic; then
|
|
if has_issue_label release && ! issue_comment_has_marker "$n" release-init-due; then
|
|
release_doctrine_path=.ceremony/RELEASES.md
|
|
# Ceremony dogfoods the action but owns doctrine at the repository root (#253).
|
|
[ "$REPO" != heavy-duty/ceremony ] || release_doctrine_path=RELEASES.md
|
|
refs="$(blocked_references <<<"$(jq -r '.body // ""' <<<"$ISSUE_JSON")")"
|
|
cross_refs="$(blocked_cross_references <<<"$(jq -r '.body // ""' <<<"$ISSUE_JSON")")"
|
|
states="$(reference_states <<<"$refs")"
|
|
if [ "$(blocked_decision "$refs" "$states" "$cross_refs")" = READY ]; then
|
|
ensure_comment "$n" release-init-due \
|
|
"This release epic's declared gate is open. Release initialization is due:
|
|
|
|
1. Mint the window's members.
|
|
2. Graph hard dependencies and same-file clusters.
|
|
3. Write ordered waves 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.
|
|
|
|
See \`$release_doctrine_path\`. The operator blessing the order is the one step this chain never automates."
|
|
log "#$n: release-init due"
|
|
fi
|
|
fi
|
|
refs="$(epic_references <<<"$(jq -r '.body // ""' <<<"$ISSUE_JSON")")"
|
|
states="$(reference_states <<<"$refs")"
|
|
if [ "$(epic_decision "$refs" "$states")" = NUDGE ]; then
|
|
ensure_comment "$n" epic-complete \
|
|
"Every issue referenced by this epic's task list is closed. Please close the epic or extend its task list."
|
|
log "#$n: completed epic nudged"
|
|
fi
|
|
fi
|
|
|
|
# The flag composes with every build queue state, but requires an assignee.
|
|
# Existing post-merge/claimed diagnostics take precedence so one board bug
|
|
# draws one comment (#232 D5); the shared helper still logs the suppression.
|
|
if [ "$attention_active" = true ] && has_issue_label attention; then
|
|
[ -n "${assignees:-}" ] || assignees="$(jq '.assignees | length' <<<"$ISSUE_JSON")"
|
|
reconcile_attention "$n" issue "$assignees" "$attention_suppression"
|
|
fi
|
|
|
|
# ---- the two board flags (#293), on any queue state ----
|
|
# After the queue branches for the same reason the ruling block is: both
|
|
# compose with every queue state, and FLAG_CONFLICT's early return still
|
|
# short-circuits them, because a board lying about its queue state is
|
|
# repaired before anything is derived from it.
|
|
reconcile_board_flags "$n"
|
|
|
|
# ---- the ruling invariants (#52), on any queue state ----
|
|
# The flag composes with the queue labels (#50 D8), so this runs after the
|
|
# queue branches rather than inside one of them. The FLAG_CONFLICT return
|
|
# above still short-circuits it on purpose: a board lying about its queue
|
|
# state is repaired by triage before anything else is derived from it.
|
|
if has_issue_label needs-ruling; then
|
|
# An already-applied stale comes off: waiting on a human is legitimately
|
|
# quiet (#50 D10), and nothing on the issue side ever puts stale back.
|
|
if has_issue_label stale; then
|
|
run forge_issue_edit "$n" --remove-label stale >/dev/null
|
|
log "#$n: unstale (a ruling is pending)"
|
|
fi
|
|
# The ruling clock reads comments only (#284 D1): an `assigned` event is
|
|
# the claim clock's fact, and counting it here let claiming a flagged
|
|
# issue buy its escalation another 7 quiet days. The reclaim clock
|
|
# (`age`) must never reach this call — the branches that write comments
|
|
# before this block (`claimed`, `post-merge`) arrive holding
|
|
# `ruling_age` already, read before anything they post; the fresh read
|
|
# serves the paths that arrive empty-handed.
|
|
if [ -z "${ruling_age:-}" ]; then
|
|
created="$(jq -r '.created_at' <<<"$ISSUE_JSON")"
|
|
guarded_read ruling_age last_issue_comment_activity "$n" "$created" \
|
|
|| skip_issue "$n" "could not read its activity history: $(read_failure_reason "$READ_FAILURE_STDERR")"
|
|
fi
|
|
reconcile_ruling "$n" "$ruling_age" "$NOW"
|
|
fi
|
|
}
|
|
|
|
reconcile_opened_issue() {
|
|
local n="$1" author triage=false labels remove="" label
|
|
ISSUE_JSON="$(forge_api "repos/$REPO/issues/$n")"
|
|
# The stand-downs return 0 explicitly: a bare return carries the failed
|
|
# test's status, which under execution is live `set -e` — and it killed the
|
|
# run on every triage-authored mint, before one issue was reconciled (#91).
|
|
# `.pull_request == null`, NOT `has("pull_request") | not` (#188). The key's
|
|
# PRESENCE is a GitHub-only discriminator: GitHub omits it on a plain issue,
|
|
# Forgejo emits it on every entry and sets it to null. Measured on the list
|
|
# endpoints, 2026-08-02 — GitHub 0 of 9 issues carried the key, Forgejo 10 of
|
|
# 10 did. So the old test selected ZERO issues here and the sweep printed
|
|
# "reconciled." over an untouched board: the blind sweep again, one layer in.
|
|
# The null test is true for an issue on both forges (absent key reads as
|
|
# null) and false for a PR on both.
|
|
jq -e '.pull_request == null' <<<"$ISSUE_JSON" >/dev/null || return 0
|
|
author="$(jq -r '.user.login' <<<"$ISSUE_JSON")"
|
|
is_triage_actor "$author" && triage=true
|
|
labels="$(jq -r '.labels[].name' <<<"$ISSUE_JSON")"
|
|
[ "$(author_decision "$triage" <<<"$labels")" = ADD_NEEDS_TRIAGE ] || return 0
|
|
for label in epic "${QUEUE_LABELS[@]}"; do
|
|
grep -qxF "$label" <<<"$labels" && remove="$remove,$label"
|
|
done
|
|
remove="${remove#,}"
|
|
if [ -n "$remove" ]; then
|
|
run forge_issue_edit "$n" --add-label needs-triage --remove-label "$remove" >/dev/null
|
|
else
|
|
run forge_issue_edit "$n" --add-label needs-triage >/dev/null
|
|
fi
|
|
log "#$n: needs-triage (opened by $author)"
|
|
}
|
|
|
|
reconcile_issue_pass() { # $1 = issue — one issue's whole pass, in its own subshell
|
|
# The subshell is #91's resilience: one unreadable or broken issue must not
|
|
# take the sweep down. What it is NOT is an errexit boundary — a command
|
|
# whose status is tested by `||` runs with errexit suppressed, and the
|
|
# suppression extends through the whole subshell body, so the handler below
|
|
# is what disables the errexit that would have caught a failed read (#247
|
|
# D2). Removing it would revive errexit and lose #91. Explicit per-read
|
|
# checks are the mechanism instead, and each one exits with ISSUEFLOW_SKIP.
|
|
#
|
|
# What the subshell IS, since #247's first round, is the atomicity
|
|
# boundary: the staged effects live in it, so ending it — by a skip, or by
|
|
# a crash — discards them, and no partial pass can ever reach the board.
|
|
local n="$1" status=0
|
|
(
|
|
# Everything below stages rather than acts, and commits at the bottom —
|
|
# so a skip taken at any read, and a crash at any statement, leaves the
|
|
# issue exactly as it was (D1). `|| exit $?` keeps a crash's status the
|
|
# subshell's own, as it was when reconcile_issue was the last command
|
|
# here: the commit must not overwrite it, and must not run under it.
|
|
STAGING=true
|
|
guarded_read ISSUE_JSON forge_api "repos/$REPO/issues/$n" \
|
|
|| skip_issue "$n" "could not read the issue: $(read_failure_reason "$READ_FAILURE_STDERR")"
|
|
issue_payload_valid "$n" <<<"$ISSUE_JSON" \
|
|
|| skip_issue "$n" "the issue read answered a payload that is not issue #$n carrying a label array"
|
|
jq -e 'has("pull_request") | not' <<<"$ISSUE_JSON" >/dev/null || exit 0
|
|
ISSUE_LABELS="$(jq -r '.labels[].name' <<<"$ISSUE_JSON")"
|
|
reconcile_issue "$n" || exit $?
|
|
commit_staged_effects
|
|
) || status=$?
|
|
if [ "$status" -eq "$ISSUEFLOW_SKIP" ]; then
|
|
SKIPPED_COUNT=$((SKIPPED_COUNT + 1))
|
|
SKIPPED_ISSUES="${SKIPPED_ISSUES:+$SKIPPED_ISSUES }#$n"
|
|
elif [ "$status" -ne 0 ]; then
|
|
# Byte-identical, and still owed: a skip is deliberate, a crash is not,
|
|
# and folding the two together would hide one behind the other (D4).
|
|
log "#$n: reconcile failed — continuing with the remaining issues"
|
|
fi
|
|
}
|
|
|
|
main() {
|
|
# See labels-reconcile's twin (#188). This one already failed loudly on
|
|
# Forgejo — but with `line 408: gh: command not found`, which names the
|
|
# symptom and not the cause, and only after the sibling step had already
|
|
# reported a green blind sweep.
|
|
# The forge is decided once, here, before anything reads the board, and
|
|
# the backend that can speak it is loaded (#188). The CEREMONY_FORGE_CLIENT
|
|
# wrapper that stood here died with the call-site port: it declared "this
|
|
# code uses gh", which stopped being true the moment every site went
|
|
# through the shim, and leaving it would have defaulted the forgejo path
|
|
# into the very client its own preflight refuses.
|
|
forge_preflight || return 1
|
|
# "" means decide from the environment; forge_select takes an explicit
|
|
# forge only in tests.
|
|
forge_select "" || return 1
|
|
|
|
REPO="${REPO:?set REPO to owner/name}"
|
|
LABELS_CONF="${LABELS_CONF:-.github/labels.conf}"
|
|
load_issueflow_config "$LABELS_CONF"
|
|
if [ "${EVENT_NAME:-}" = issues ] && [ "${EVENT_ACTION:-}" = opened ]; then
|
|
reconcile_opened_issue "${EVENT_ISSUE:?set EVENT_ISSUE for issues:opened}"
|
|
fi
|
|
# Both gathers were `gh api graphql` until #188. Forgejo has NO GraphQL
|
|
# API — a real forgejo-runner job even arrives with GITHUB_GRAPHQL_URL set
|
|
# to the empty string (probe task 278) — so these could not be translated
|
|
# to a Forgejo endpoint; there is none. They are REST + a parser this repo
|
|
# owns, over `number`, `body` and `merged_at`, which /api/v3 and /api/v1
|
|
# both return in the same shape (measured on both; `.merged_at` is
|
|
# Z-suffixed UTC on each, so it still sorts as a string).
|
|
#
|
|
# owner/name were separate GraphQL variables until #188. REST takes the
|
|
# owner/name path whole, so they are gone.
|
|
#
|
|
# crew#321 released a live claim because the open side read only closing
|
|
# links while the merged side parsed Refs bodies. One parser now supplies
|
|
# the local body references on both sides, so transition and reclaim agree
|
|
# — which is why this is `open_pr_issues` over Refs and no longer
|
|
# `closes_references`.
|
|
#
|
|
# Bodies travel base64 because they contain newlines: jq's @tsv escapes a
|
|
# newline to a literal backslash-n, which a line-oriented parser reads as
|
|
# one line and silently loses every declaration after the first.
|
|
# `open_pr_issues` IS line-oriented — upstream fed it `split("\n")[]` — so
|
|
# the decoded body is re-split into one BODY row per physical line here.
|
|
# Handing it the whole decoded body as ONE record loses every declaration
|
|
# including the first, silently, because the trailing lines arrive as
|
|
# records whose kind matches neither case arm (#198).
|
|
#
|
|
# BOTH record kinds are fed, not just BODY. Upstream's CLOSING rows came
|
|
# from GitHub's `closingIssuesReferences` — GitHub's own parse of the
|
|
# CLOSING KEYWORDS in the body — and `lib/closes_references.sh` is exactly
|
|
# the replacement #188 wrote for that field. Feeding BODY rows alone would
|
|
# drop every `Closes #N` link on the open side: `refs_references` matches
|
|
# `Refs` and deliberately not `Closes` (#151), so an open PR that says it
|
|
# CLOSES an issue would stop counting as that issue's open PR and the claim
|
|
# would be reclaimed under it. test/issueflow-reconcile.test.sh's
|
|
# base64-round-trip case is that regression, and it is red without this.
|
|
OPEN_PR_ISSUES="$(forge_api --paginate "repos/$REPO/pulls?state=open" \
|
|
--jq '.[] | .body // "" | @base64' \
|
|
| while IFS= read -r b64; do
|
|
[ -n "$b64" ] || continue
|
|
body="$(printf '%s' "$b64" | base64 -d)"
|
|
printf '%s' "$body" | closes_references | awk '{ print "CLOSING\t" $0 }'
|
|
printf '%s' "$body" | awk '{ print "BODY\t" $0 }'
|
|
done | open_pr_issues)"
|
|
# Three columns: ISSUE<TAB>PR<TAB>MERGED_AT (#242). The third is not
|
|
# decoration — `post_merge_pr_for_issue` sorts on it to answer the PR that
|
|
# merged LAST rather than the one numbered highest, and with the column
|
|
# absent every sort key ties and the old number order comes back silently.
|
|
# base64 cannot contain a tab, so the three fields split cleanly.
|
|
MERGED_REF_PR_RECORDS="$(forge_api --paginate "repos/$REPO/pulls?state=closed" \
|
|
--jq '.[] | select(.merged_at != null)
|
|
| "\(.number)\t\(.merged_at)\t\(.body // "" | @base64)"' \
|
|
| while IFS=$'\t' read -r pr merged b64; do
|
|
[ -n "$b64" ] || continue
|
|
while IFS= read -r issue; do
|
|
[ -n "$issue" ] && printf '%s\t%s\t%s\n' "$issue" "$pr" "$merged"
|
|
done < <(printf '%s' "$b64" | base64 -d | refs_references)
|
|
done)"
|
|
|
|
local n tail_line issue_numbers board_json release_bodies rn rbody gate body
|
|
local window_rendered=""
|
|
SKIPPED_COUNT=0
|
|
SKIPPED_ISSUES=""
|
|
# A command substitution in a for list suppresses errexit. Capture and
|
|
# check the board read before entering the loop, or a 504 (including one
|
|
# after partial pagination) reports a full pass over a truncated board
|
|
# (#257).
|
|
#
|
|
# The read answers the whole payload rather than a projection of it because
|
|
# the two board flags (#293) are decided over the WHOLE board — every open
|
|
# issue's labels and title, and every open `release` issue's body. One read
|
|
# supplies all of it; a second pagination for the same rows would be a
|
|
# second board, free to disagree with this one mid-sweep.
|
|
if ! guarded_read board_json forge_api --paginate \
|
|
"repos/$REPO/issues?state=open&per_page=100"; then
|
|
log "could not read the issue board: $(read_failure_reason "$READ_FAILURE_STDERR")"
|
|
return 1
|
|
fi
|
|
BOARD_RECORDS="$(jq -r '.[] | select(has("pull_request") | not)
|
|
| [(.number | tostring), ((.labels // []) | map(.name) | join(",")), (.title // "")]
|
|
| @tsv' \
|
|
<<<"$board_json")"
|
|
issue_numbers="$(cut -f1 <<<"$BOARD_RECORDS")"
|
|
# A standing window is an open `release`-labeled issue whose gate still
|
|
# holds an OPEN member (#292 D1). The board read IS the open set, so
|
|
# membership decides openness with no extra call — and an all-closed gate
|
|
# is exactly the emptied gate the release's own `blocked` -> `ready`
|
|
# promotion answers, which is why a `ready` release leaves the flag
|
|
# dormant rather than flagging the whole board.
|
|
release_bodies="$(jq -r '.[] | select(has("pull_request") | not)
|
|
| select((.labels // []) | map(.name) | index("release"))
|
|
| [(.number | tostring), ((.body // "") | gsub("[\t\r\n]"; " "))] | @tsv' \
|
|
<<<"$board_json")"
|
|
WINDOW_CARRIERS=""
|
|
WINDOW_GATE=""
|
|
if [ -n "$issue_numbers" ]; then
|
|
while IFS=$'\t' read -r rn rbody; do
|
|
[ -n "$rn" ] || continue
|
|
gate="$(blocked_references <<<"$rbody")"
|
|
[ -n "$gate" ] || continue
|
|
grep -qxF -f <(printf '%s\n' "$issue_numbers") <<<"$gate" || continue
|
|
WINDOW_CARRIERS="${WINDOW_CARRIERS}${rn}"$'\n'
|
|
WINDOW_GATE="${WINDOW_GATE}${gate}"$'\n'
|
|
done <<<"$release_bodies"
|
|
fi
|
|
[ -z "$WINDOW_CARRIERS" ] || window_rendered="$(window_state "$WINDOW_CARRIERS")"
|
|
COLLISION_FLAGS="$(collision_key_index <<<"$BOARD_RECORDS" | collision_flags)"
|
|
WINDOW_FLAGS="$(window_flags "$WINDOW_GATE" "$WINDOW_CARRIERS" <<<"$BOARD_RECORDS" \
|
|
| awk -v state="$window_rendered" 'NF { print $1 "\t" state }')"
|
|
if [ -z "$issue_numbers" ]; then
|
|
log "no open issues."
|
|
else
|
|
while IFS= read -r n; do
|
|
[ -n "$n" ] && reconcile_issue_pass "$n"
|
|
done <<<"$issue_numbers"
|
|
fi
|
|
log "reconciled."
|
|
# The job stays green (D7): an hourly sweep over a hundred-issue board meets
|
|
# transient 504s as a matter of course, and reddening the whole run for one
|
|
# skipped issue trains consumers to ignore red — the outcome #95 and #101
|
|
# both steered away from on the PR surface. This line is what buys back the
|
|
# auditability that costs.
|
|
tail_line="$(skipped_tail "$SKIPPED_COUNT" "$SKIPPED_ISSUES")"
|
|
[ -z "$tail_line" ] || log "$tail_line"
|
|
}
|
|
|
|
if [ "${BASH_SOURCE[0]}" = "$0" ]; then main "$@"; fi
|