Merge pull request #32 from codex-bot-andresmgsl/build/18-issueflow-reconcile

feat: reconcile the issue work queue
This commit is contained in:
Daniel Marin 2026-07-23 12:15:29 +01:00 committed by GitHub
commit 66f1c08e1a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 483 additions and 19 deletions

1
.github/labels.conf vendored
View file

@ -1,4 +1,5 @@
panel=claude-bot-andresmgsl codex-bot-andresmgsl grok-bot-andresmgsl kimi-bot-andresmgsl panel=claude-bot-andresmgsl codex-bot-andresmgsl grok-bot-andresmgsl kimi-bot-andresmgsl
triage-actors=dan-claude-bot
scope:release-flow|C5DEF5|The reusable release workflow, decide, the doors scope:release-flow|C5DEF5|The reusable release workflow, decide, the doors
scope:guards|C5DEF5|changelog-armed / changelog-monotonic / drill-recorded scope:guards|C5DEF5|changelog-armed / changelog-monotonic / drill-recorded
scope:labels|C5DEF5|The labels workflow, reconciler, the taxonomy scope:labels|C5DEF5|The labels workflow, reconciler, the taxonomy

View file

@ -95,3 +95,15 @@ jobs:
env: env:
GH_TOKEN: ${{ github.token }} GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }} REPO: ${{ github.repository }}
- name: reconcile issue flow
if: github.repository != 'heavy-duty/ceremony'
uses: ./.ceremony-src/actions/issueflow-reconcile
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
- name: reconcile issue flow (dogfood — the workspace IS ceremony)
if: github.repository == 'heavy-duty/ceremony'
uses: ./actions/issueflow-reconcile
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}

View file

@ -7,6 +7,8 @@ name: labels
on: on:
schedule: [{cron: "*/15 * * * *"}] # advisory; the handoff label is the real wake schedule: [{cron: "*/15 * * * *"}] # advisory; the handoff label is the real wake
workflow_dispatch: # bootstraps missing labels on a fresh repo workflow_dispatch: # bootstraps missing labels on a fresh repo
issues:
types: [opened, edited, assigned, unassigned, labeled, unlabeled, closed, reopened]
pull_request_target: pull_request_target:
types: [opened, reopened, ready_for_review, converted_to_draft, synchronize, labeled, unlabeled] types: [opened, reopened, ready_for_review, converted_to_draft, synchronize, labeled, unlabeled]
permissions: permissions:

View file

@ -7,8 +7,8 @@ the labels workflow's bootstrap dispatch (issue #10).
Two state machines share the taxonomy: the **PR machine** (proven in Two state machines share the taxonomy: the **PR machine** (proven in
box/rig/cast, reconciled by machinery) and the **issue flow** (the box/rig/cast, reconciled by machinery) and the **issue flow** (the
triage → build queue, doctrine-enforced today, machinery to follow — triage → build queue, reconciled by the work-queue sweep). One rule joins
issue #18). One rule joins everything: **states are machine-owned, intent everything: **states are machine-owned, intent
labels are hand-set** — a hand-moved state label is a lie waiting to happen, labels are hand-set** — a hand-moved state label is a lie waiting to happen,
and the reconciler recomputes it from GitHub's own facts. and the reconciler recomputes it from GitHub's own facts.
@ -54,11 +54,11 @@ strips it on sight).
| `blocked` | `#6A737D` | waiting on another issue or PR (`Blocked by #N` in the body names it) | triage; anyone may correct it | | `blocked` | `#6A737D` | waiting on another issue or PR (`Blocked by #N` in the body names it) | triage; anyone may correct it |
| `epic` | `#5319E7` | organizes other issues via a dependency-ordered task list; **builders never pick an epic** | triage | | `epic` | `#5319E7` | organizes other issues via a dependency-ordered task list; **builders never pick an epic** | triage |
The invariant a board scan relies on: every open issue is either The work-queue sweep enforces the invariant a board scan relies on: every open issue is either
`needs-triage`, `epic`, or carries exactly one of `ready` / `claimed` / `needs-triage`, `epic`, or carries exactly one of `ready` / `claimed` /
`blocked`. A `claimed` issue with no open PR and no activity is what the `blocked`. It flags conflicts rather than guessing intent. A `claimed` issue
staleness sweep will reclaim (issue #18); until that machinery exists, with no open PR and no activity for 48 hours is reclaimed by the sweep: it
[TRIAGE.md](TRIAGE.md) owns the hygiene by hand. comments, unassigns the stale owner, and restores `ready`.
## Cross-cutting (PRs and issues) ## Cross-cutting (PRs and issues)
@ -114,7 +114,7 @@ on a PR would say the same thing twice and drift.
The labels workflow (issue #10) recomputes PR state statelessly on PR events The labels workflow (issue #10) recomputes PR state statelessly on PR events
plus a 15-minute advisory cron, and bootstraps this taxonomy idempotently on plus a 15-minute advisory cron, and bootstraps this taxonomy idempotently on
manual dispatch. Issue-flow labels are doctrine-owned until #18 lands manual dispatch. The same workflow reconciles issue-flow labels on issue
machinery for them. Default GitHub labels (`duplicate`, `invalid`, events and during the scheduled sweep. Default GitHub labels (`duplicate`, `invalid`,
`question`, `wontfix`, `help wanted`, `good first issue`) are deleted at `question`, `wontfix`, `help wanted`, `good first issue`) are deleted at
bootstrap — a `question` is a discussion, not an issue. bootstrap — a `question` is a discussion, not an issue.

View file

@ -82,13 +82,16 @@ dependency-ordered task list of child issues. Children reference the epic;
the epic's checklist is the progress view. Builders never pick the epic the epic's checklist is the progress view. Builders never pick the epic
itself. Keep the checklist current — a stale epic misleads every scan. itself. Keep the checklist current — a stale epic misleads every scan.
## Backlog hygiene (yours until #18 automates it) ## Backlog hygiene
- **Dedup before minting** — search issues *and* closed issues; extend or - **Dedup before minting** — search issues *and* closed issues; extend or
reopen before duplicating. reopen before duplicating.
- **Flip `blocked` → `ready`** when the named dependency lands. - The issue-flow sweep flips `blocked``ready` when every named dependency
- **Reclaim abandoned claims**: `claimed` + no open PR + no activity → lands, and flags a blocked issue whose dependency declaration is unreadable.
comment, unassign, restore `ready`. - The sweep reclaims abandoned claims after 48 hours: `claimed` + no open PR
+ no activity → comment, unassign, restore `ready`.
- Automation never guesses intent. Resolve the conflict comments it leaves on
malformed queue states, and close or extend completed epics when nudged.
- **Close obsolete issues** with the reason and a link to what obsoleted - **Close obsolete issues** with the reason and a link to what obsoleted
them. Every label on every open issue stays true; the board is only worth them. Every label on every open issue stays true; the board is only worth
scanning if it does not lie. scanning if it does not lie.

View file

@ -0,0 +1,13 @@
name: Reconcile issue flow
description: Converge the issue work queue and reclaim stale claims
runs:
using: composite
steps:
- name: reconcile issue flow
shell: bash
env:
LABELS_CONF: ${{ github.workspace }}/.github/labels.conf
EVENT_NAME: ${{ github.event_name }}
EVENT_ACTION: ${{ github.event.action }}
EVENT_ISSUE: ${{ github.event.issue.number }}
run: bash "$GITHUB_ACTION_PATH/issueflow-reconcile.sh"

View file

@ -0,0 +1,309 @@
#!/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)
TRIAGE_ACTORS=()
log() { printf 'issueflow: %s\n' "$*"; }
run() { if [ -n "${DRY_RUN:-}" ]; then log "DRY_RUN: $*"; else "$@"; fi; }
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_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"
}
blocked_references() { # body on stdin -> issue numbers, one per line
# Dependency declarations sometimes soft-wrap after a comma. Continue
# through the first sentence terminator; if prose omits one, conservatively
# retain later references so ambiguity can keep an issue blocked, never
# promote it prematurely.
awk '
{
line = $0
lower = tolower(line)
if (!active) {
marker = "blocked by"
start = index(lower, marker)
if (!start) next
line = substr(line, start + length(marker))
active = 1
}
if (line ~ /[.;]/) {
sub(/[.;].*/, "", line)
print line
exit
}
print line
}
' \
| { grep -Eo '#[0-9]+' || true; } | tr -d '#' | sort -nu
}
blocked_decision() { # $1 refs, $2 OPEN/CLOSED states
local refs="$1" states="$2"
if [ -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 }
' \
| { grep -Eo '#[0-9]+' || true; } | tr -d '#' | 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
}
# 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 gh api --paginate "repos/$REPO/issues/$n/comments" --jq '.[].body' \
| grep -qF "<!-- issueflow:$marker -->"; then return; fi
run gh issue comment "$n" -R "$REPO" --body "<!-- issueflow:$marker -->
$message" >/dev/null
}
reference_states() {
local ref state
while IFS= read -r ref; do
[ -n "$ref" ] || continue
state="$(gh 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
}
last_issue_activity() {
local n="$1" created="$2" latest
latest="$({
printf '%s\n' "$created"
gh api --paginate "repos/$REPO/issues/$n/comments" --jq '.[].created_at'
# Assignment is the claim itself. Ignoring it would let an old issue be
# reclaimed in the seconds between assignment and its required draft PR.
gh api --paginate "repos/$REPO/issues/$n/timeline" \
--jq '.[] | select(.event == "assigned") | .created_at'
} \
| sort | tail -n1)"
date -d "$latest" +%s
}
reconcile_issue() {
local n="$1" decision refs states age assignees open_pr=false label owners
decision="$(queue_decision <<<"$ISSUE_LABELS")"
case "$decision" in
ADD_NEEDS_TRIAGE)
run gh issue edit "$n" -R "$REPO" --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`, or `blocked`.'
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
age="$(last_issue_activity "$n" "$(jq -r '.created_at' <<<"$ISSUE_JSON")")"
decision="$(claim_decision_at "$assignees" "$open_pr" "$age")"
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 gh issue edit "$n" -R "$REPO" --remove-assignee "$owners" \
--remove-label claimed --add-label ready >/dev/null
else
run gh issue edit "$n" -R "$REPO" --remove-label claimed --add-label ready >/dev/null
fi
log "#$n: stale claim reclaimed -> ready" ;;
esac
elif has_issue_label blocked; then
refs="$(blocked_references <<<"$(jq -r '.body // ""' <<<"$ISSUE_JSON")")"
states="$(reference_states <<<"$refs")"
decision="$(blocked_decision "$refs" "$states")"
case "$decision" in
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 gh issue edit "$n" -R "$REPO" --remove-label blocked --add-label ready >/dev/null
log "#$n: blockers closed -> ready" ;;
esac
elif has_issue_label epic; then
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
}
reconcile_opened_issue() {
local n="$1" author triage=false labels remove="" label
ISSUE_JSON="$(gh api "repos/$REPO/issues/$n")"
jq -e 'has("pull_request") | not' <<<"$ISSUE_JSON" >/dev/null || return
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
for label in epic "${QUEUE_LABELS[@]}"; do
grep -qxF "$label" <<<"$labels" && remove="$remove,$label"
done
remove="${remove#,}"
if [ -n "$remove" ]; then
run gh issue edit "$n" -R "$REPO" --add-label needs-triage --remove-label "$remove" >/dev/null
else
run gh issue edit "$n" -R "$REPO" --add-label needs-triage >/dev/null
fi
log "#$n: needs-triage (opened by $author)"
}
main() {
local owner name
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
owner="${REPO%%/*}"
name="${REPO#*/}"
OPEN_PR_ISSUES="$(gh api graphql --paginate -f owner="$owner" -f name="$name" -f query='
query($owner: String!, $name: String!, $endCursor: String) {
repository(owner: $owner, name: $name) {
pullRequests(first: 100, states: OPEN, after: $endCursor) {
nodes { closingIssuesReferences(first: 100) { nodes { number } } }
pageInfo { hasNextPage endCursor }
}
}
}' --jq '.data.repository.pullRequests.nodes[].closingIssuesReferences.nodes[].number' \
| sort -nu)"
local n
for n in $(gh api --paginate "repos/$REPO/issues?state=open&per_page=100" \
--jq '.[] | select(has("pull_request") | not) | .number'); do
(
ISSUE_JSON="$(gh api "repos/$REPO/issues/$n")"
jq -e 'has("pull_request") | not' <<<"$ISSUE_JSON" >/dev/null || exit 0
ISSUE_LABELS="$(jq -r '.labels[].name' <<<"$ISSUE_JSON")"
reconcile_issue "$n"
) || log "#$n: reconcile failed — continuing with the remaining issues"
done
log "reconciled."
}
if [ "${BASH_SOURCE[0]}" = "$0" ]; then main "$@"; fi

View file

@ -78,6 +78,7 @@ load_config() { # $1 = consumer labels.conf; panel is mandatory, scopes optional
return 1 return 1
} }
;; ;;
triage-actors=*) ;;
*) parse_label_row "$line" >/dev/null || return ;; *) parse_label_row "$line" >/dev/null || return ;;
esac esac
done <"$conf" done <"$conf"
@ -102,7 +103,7 @@ configured_label_rows() { # validated scope rows, excluding the panel setting
[ -f "$conf" ] || return 0 [ -f "$conf" ] || return 0
while IFS= read -r line || [ -n "$line" ]; do while IFS= read -r line || [ -n "$line" ]; do
[ -n "$line" ] || continue [ -n "$line" ] || continue
case "$line" in panel=*) continue ;; esac case "$line" in panel=* | triage-actors=*) continue ;; esac
parse_label_row "$line" || return parse_label_row "$line" || return
done <"$conf" done <"$conf"
} }

View file

@ -229,6 +229,8 @@ on:
workflow_dispatch: # bootstraps missing labels on a fresh repo workflow_dispatch: # bootstraps missing labels on a fresh repo
pull_request_target: pull_request_target:
types: [opened, reopened, ready_for_review, converted_to_draft, synchronize, labeled, unlabeled] types: [opened, reopened, ready_for_review, converted_to_draft, synchronize, labeled, unlabeled]
issues:
types: [opened, labeled, unlabeled, assigned, unassigned, closed]
permissions: permissions:
contents: read contents: read
issues: write issues: write
@ -242,21 +244,23 @@ jobs:
token to write labels. The reusable workflow executes no PR code. It checks token to write labels. The reusable workflow executes no PR code. It checks
out only the consumer's base branch and the pinned ceremony implementation. out only the consumer's base branch and the pinned ceremony implementation.
`.github/labels.conf` has one mandatory panel setting followed by zero or `.github/labels.conf` has one mandatory panel setting, one mandatory
more scope rows: `triage-actors` setting, and then zero or more scope rows:
```text ```text
panel=claude-bot example-codex-bot example-grok-bot panel=claude-bot example-codex-bot example-grok-bot
triage-actors=example-triage-bot
scope:cli|C5DEF5|The command-line surface scope:cli|C5DEF5|The command-line surface
scope:docs|C5DEF5|Documentation scope:docs|C5DEF5|Documentation
``` ```
The panel is whitespace-separated. Label rows use exactly Both actor lists are whitespace-separated. `triage-actors` names the identities
allowed to mint issues without the sweep applying `needs-triage`. Label rows use exactly
`name|color|description`; blank lines are ignored and extra pipes are refused. `name|color|description`; blank lines are ignored and extra pipes are refused.
There are no comment lines: every non-blank line must be the `panel=` There are no comment lines: every non-blank line must be the `panel=`
setting or a label row, so `#`-prefixed prose is a parse failure, not a setting, the `triage-actors=` setting, or a label row, so `#`-prefixed prose
comment (rig #13's conversion found this the hard way — keep the file data is a parse failure, not a comment (rig #13's conversion found this the hard
only). way — keep the file data only).
Core state, blocker, work-queue, and release labels come from ceremony. Scope Core state, blocker, work-queue, and release labels come from ceremony. Scope
rows remain consumer-owned because paths and surfaces differ by repository. rows remain consumer-owned because paths and surfaces differ by repository.

View file

@ -0,0 +1,114 @@
#!/usr/bin/env bash
set -u
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# shellcheck source=test/harness.sh
source "$ROOT/test/harness.sh"
# shellcheck source=actions/issueflow-reconcile/issueflow-reconcile.sh
source "$ROOT/actions/issueflow-reconcile/issueflow-reconcile.sh"
TMP="$(mktemp -d)"
trap 'rm -rf "$TMP"' EXIT
printf '%s\n' \
'panel=one two' \
'triage-actors=triage-one triage-two' \
'scope:one|C5DEF5|First scope' >"$TMP/good.conf"
check "triage actors parse beside panel and labels" 0 "" load_issueflow_config "$TMP/good.conf"
load_issueflow_config "$TMP/good.conf"
check "triage actor is recognized" 0 "" is_triage_actor triage-two
check "non-triage actor is rejected" 1 "" is_triage_actor builder
printf '%s\n' 'panel=one' >"$TMP/missing.conf"
check "missing triage actors fails loudly" 1 "missing triage-actors=" load_issueflow_config "$TMP/missing.conf"
printf '%s\n' 'triage-actors=one' 'triage-actors=two' >"$TMP/duplicate.conf"
check "duplicate triage actors fails loudly" 1 "duplicate triage-actors" load_issueflow_config "$TMP/duplicate.conf"
# The dogfood caller and reusable workflow must expose the same runtime facts
# as the documented consumer stub. Static pins catch YAML blocks drifting to
# the adjacent composite step, which otherwise fails only after merge.
check "dogfood caller wakes on issue events" 0 " issues:" \
grep -F " issues:" "$ROOT/.github/workflows/self-labels.yml"
dogfood_pr_step="$(sed -n \
'/name: reconcile state + stale (dogfood/,/name: reconcile issue flow/p' \
"$ROOT/.github/workflows/labels.yml")"
# shellcheck disable=SC2016 # GitHub expressions are asserted as literals
check "dogfood PR reconcile receives repository" 0 ' REPO: ${{ github.repository }}' \
grep -F ' REPO: ${{ github.repository }}' <<<"$dogfood_pr_step"
# shellcheck disable=SC2016 # GitHub expressions are asserted as literals
check "dogfood PR reconcile receives token" 0 ' GH_TOKEN: ${{ github.token }}' \
grep -F ' GH_TOKEN: ${{ github.token }}' <<<"$dogfood_pr_step"
# Invariant 1: exactly one queue category.
check "one ready queue label is valid" 0 "KEEP" queue_decision <<<"ready"
check "zero queue labels is derivably needs-triage" 0 "ADD_NEEDS_TRIAGE" queue_decision <<<"enhancement"
check "multiple queue labels are ambiguous" 0 "FLAG_CONFLICT" queue_decision <<< $'ready\nblocked'
check "needs-triage plus queue is a conflict" 0 "FLAG_CONFLICT" queue_decision <<< $'needs-triage\nready'
# Invariant 2: claims have an owner and either a PR or recent activity.
check "claim with open PR stays claimed" 0 "KEEP" claim_decision 1 true 999999
check "unassigned claim is flagged" 0 "FLAG_UNASSIGNED" claim_decision 0 false 60
check "quiet unassigned claim is also reclaimed" 0 "RECLAIM" claim_decision 0 false $((STALE_AFTER + 1))
# shellcheck disable=SC2016 # expansions belong to the isolated bash -c process
check "injected clock: below stale boundary stays claimed" 0 "KEEP" \
bash -c 'ISSUEFLOW_NOW=100000 ISSUEFLOW_STALE_HOURS=1 source "$1"; claim_decision_at 1 false 96401' _ \
"$ROOT/actions/issueflow-reconcile/issueflow-reconcile.sh"
# shellcheck disable=SC2016 # expansions belong to the isolated bash -c process
check "injected clock: exact stale boundary stays claimed" 0 "KEEP" \
bash -c 'ISSUEFLOW_NOW=100000 ISSUEFLOW_STALE_HOURS=1 source "$1"; claim_decision_at 1 false 96400' _ \
"$ROOT/actions/issueflow-reconcile/issueflow-reconcile.sh"
# shellcheck disable=SC2016 # expansions belong to the isolated bash -c process
check "injected clock: past stale boundary is reclaimed" 0 "RECLAIM" \
bash -c 'ISSUEFLOW_NOW=100000 ISSUEFLOW_STALE_HOURS=1 source "$1"; claim_decision_at 1 false 96399' _ \
"$ROOT/actions/issueflow-reconcile/issueflow-reconcile.sh"
# shellcheck disable=SC2016 # expansion belongs to the isolated bash -c process
check "invalid injected clock fails loudly" 1 "ISSUEFLOW_NOW must be UTC epoch seconds" \
bash -c 'ISSUEFLOW_NOW=garbage source "$1"' _ \
"$ROOT/actions/issueflow-reconcile/issueflow-reconcile.sh"
check "reclaim marker is stable within a claim episode" 0 "claim-reclaimed-96399" \
claim_reclaim_marker 96399
check "a later claim episode receives a new reclaim marker" 0 "claim-reclaimed-99999" \
claim_reclaim_marker 99999
# Invariant 3: blocked declarations parse and release only when all close.
refs="$(blocked_references <<< $'Context #99. Blocked by #12 (first), #7 (second). Blocks #44.')"
check "blocked declaration extracts only declared refs" 0 $'7\n12' printf '%s\n' "$refs"
body=$'Blocked by #12 (first),\n#7 (soft-wrapped second). Blocks #44.'
check "soft-wrapped blocker declaration retains continuation refs" 0 "" test \
"$(blocked_references <<<"$body")" = $'7\n12'
body=$'Blocked by #12 (known)\nFollow-up context mentions #7 without a sentence boundary'
check "unterminated blocker prose errs toward retaining dependencies" 0 "" test \
"$(blocked_references <<<"$body")" = $'7\n12'
body="Part of #1. Blocked by #11 (needs a ceremony tag to pin), #12 (must be executed from the guide), #19 (the conversion vendors the doctrine). Blocks #14, #15 (they inherit the pilot's lessons)."
check "real issue 13 inline blockers parse" 0 "" test \
"$(blocked_references <<<"$body")" = $'11\n12\n19'
body="Part of #1. Blocked by #13 (inherits the pilot's lessons). Can run in parallel with #15."
check "real issue 14 inline blocker parses" 0 "" test \
"$(blocked_references <<<"$body")" = "13"
body="Part of #1. Blocked by #13 (pilot lessons). Can run in parallel with #14."
check "real issue 15 inline blocker parses" 0 "" test \
"$(blocked_references <<<"$body")" = "13"
body="Part of #1. Blocked by #11, #12 (needs a released ceremony + the bootstrap guide); benefits from #13's lessons but does not need #14/#15."
check "real issue 16 inline blockers parse" 0 "" test \
"$(blocked_references <<<"$body")" = $'11\n12'
check "open blocker keeps issue blocked" 0 "KEEP" blocked_decision "$refs" $'CLOSED\nOPEN'
check "all closed blockers release issue" 0 "READY" blocked_decision "$refs" $'CLOSED\nCLOSED'
check "missing blocked declaration is flagged" 0 "FLAG_UNPARSEABLE" blocked_decision "" ""
check "unreadable blocker is flagged" 0 "FLAG_UNPARSEABLE" blocked_decision "12" "UNKNOWN"
# Invariant 4: only configured triage actors mint directly into the queue.
check "triage-authored ready issue is accepted" 0 "KEEP" author_decision true <<<"ready"
check "outside author receives needs-triage" 0 "ADD_NEEDS_TRIAGE" author_decision false <<<"ready"
check "outside author already marked needs-triage is stable" 0 "KEEP" author_decision false <<<"needs-triage"
check "later sweep accepts a normalized outside-authored issue" 0 "KEEP" queue_decision <<<"ready"
# Invariant 5: completed epics get one nudge; incomplete/unparseable do not.
epic_refs="$(epic_references <<< $'## Definition of done\n- [ ] outside #8\n\n## Task list\n- [ ] #3 first\n- [x] #2 done\nplain #9')"
check "epic parser reads task-list refs only" 0 $'2\n3' printf '%s\n' "$epic_refs"
body=$'## Task list\n- [x] #2 done\n- [x] #3 done\n\n## Definition of done\n- [ ] open issue #99 must not suppress the nudge'
check "epic parser stops before later checkbox sections" 0 "" test \
"$(epic_references <<<"$body")" = $'2\n3'
check "completed epic is nudged" 0 "NUDGE" epic_decision "$epic_refs" $'CLOSED\nCLOSED'
check "open epic child suppresses nudge" 0 "KEEP" epic_decision "$epic_refs" $'CLOSED\nOPEN'
check "epic without parseable children is stable" 0 "KEEP" epic_decision "" ""
summary

View file

@ -12,6 +12,7 @@ trap 'rm -rf "$TMP"' EXIT
printf '%s\n' \ printf '%s\n' \
'panel=one two three' \ 'panel=one two three' \
'triage-actors=triage-one' \
'' \ '' \
'scope:one|C5DEF5|First scope' \ 'scope:one|C5DEF5|First scope' \
'scope:two|C5DEF5|Second scope' >"$TMP/good.conf" 'scope:two|C5DEF5|Second scope' >"$TMP/good.conf"
@ -25,6 +26,10 @@ check "panel is parsed" 0 "one two three" bash -c \
check "core and config rows merge" 0 "scope:two|C5DEF5|Second scope" bash -c \ check "core and config rows merge" 0 "scope:two|C5DEF5|Second scope" bash -c \
'source "$1"; core_label_rows; configured_label_rows "$2"' _ \ 'source "$1"; core_label_rows; configured_label_rows "$2"' _ \
"$ROOT/actions/labels-reconcile/labels-reconcile.sh" "$TMP/good.conf" "$ROOT/actions/labels-reconcile/labels-reconcile.sh" "$TMP/good.conf"
# shellcheck disable=SC2016 # expansion belongs to the nested bash
check "triage config is not parsed as a label row" 1 "" bash -c \
'source "$1"; configured_label_rows "$2" | grep -F triage-actors' _ \
"$ROOT/actions/labels-reconcile/labels-reconcile.sh" "$TMP/good.conf"
check "missing scope config is an empty table" 0 "" configured_label_rows "$TMP/missing.conf" check "missing scope config is an empty table" 0 "" configured_label_rows "$TMP/missing.conf"
printf '%s\n' 'scope:bad|C5DEF5' >"$TMP/bad.conf" printf '%s\n' 'scope:bad|C5DEF5' >"$TMP/bad.conf"