feat: reconcile the issue work queue

This commit is contained in:
codex-bot-andresmgsl 2026-07-22 19:21:01 +00:00
parent 2f0d3c65af
commit 8174080c9d
9 changed files with 333 additions and 16 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
triage-actors=dan-claude-bot
scope:release-flow|C5DEF5|The reusable release workflow, decide, the doors
scope:guards|C5DEF5|changelog-armed / changelog-monotonic / drill-recorded
scope:labels|C5DEF5|The labels workflow, reconciler, the taxonomy

View file

@ -92,6 +92,15 @@ jobs:
uses: ./actions/labels-reconcile
with:
bootstrap: ${{ github.event_name == 'workflow_dispatch' && 'yes' || 'no' }}
- 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,8 +7,8 @@ the labels workflow's bootstrap dispatch (issue #10).
Two state machines share the taxonomy: the **PR machine** (proven in
box/rig/cast, reconciled by machinery) and the **issue flow** (the
triage → build queue, doctrine-enforced today, machinery to follow —
issue #18). One rule joins everything: **states are machine-owned, intent
triage → build queue, reconciled by the work-queue sweep). One rule joins
everything: **states are machine-owned, intent
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.
@ -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 |
| `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` /
`blocked`. A `claimed` issue with no open PR and no activity is what the
staleness sweep will reclaim (issue #18); until that machinery exists,
[TRIAGE.md](TRIAGE.md) owns the hygiene by hand.
`blocked`. It flags conflicts rather than guessing intent. A `claimed` issue
with no open PR and no activity for 48 hours is reclaimed by the sweep: it
comments, unassigns the stale owner, and restores `ready`.
## 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
plus a 15-minute advisory cron, and bootstraps this taxonomy idempotently on
manual dispatch. Issue-flow labels are doctrine-owned until #18 lands
machinery for them. Default GitHub labels (`duplicate`, `invalid`,
manual dispatch. The same workflow reconciles issue-flow labels on issue
events and during the scheduled sweep. Default GitHub labels (`duplicate`, `invalid`,
`question`, `wontfix`, `help wanted`, `good first issue`) are deleted at
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
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
reopen before duplicating.
- **Flip `blocked` → `ready`** when the named dependency lands.
- **Reclaim abandoned claims**: `claimed` + no open PR + no activity →
comment, unassign, restore `ready`.
- The issue-flow sweep flips `blocked``ready` when every named dependency
lands, and flags a blocked issue whose dependency declaration is unreadable.
- 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
them. Every label on every open issue stays true; the board is only worth
scanning if it does not lie.

View file

@ -0,0 +1,10 @@
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
run: bash "$GITHUB_ACTION_PATH/issueflow-reconcile.sh"

View file

@ -0,0 +1,231 @@
#!/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.
STALE_AFTER=$((48 * 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"
if [ "$assignees" -eq 0 ]; then echo FLAG_UNASSIGNED
elif [ "$open_pr" = true ] || [ "$age" -le "$STALE_AFTER" ]; then echo KEEP
else echo RECLAIM
fi
}
blocked_references() { # body on stdin -> issue numbers, one per line
sed -nE 's/^[[:space:]]*Blocked by[[:space:]]+//Ip' \
| { 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
sed -nE '/^[[:space:]]*[-*][[:space:]]+\[[ xX]\]/ { s/.*#([0-9]+).*/\1/p; }' \
| 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'; } \
| sort | tail -n1)"
date -d "$latest" +%s
}
reconcile_issue() {
local n="$1" author triage=false decision refs states age assignees open_pr=false remove="" label owners
author="$(jq -r '.user.login' <<<"$ISSUE_JSON")"
is_triage_actor "$author" && triage=true
decision="$(author_decision "$triage" <<<"$ISSUE_LABELS")"
if [ "$decision" = ADD_NEEDS_TRIAGE ]; then
for label in epic "${QUEUE_LABELS[@]}"; do
has_issue_label "$label" && 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)"
ISSUE_LABELS=needs-triage
fi
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=$((NOW - $(last_issue_activity "$n" "$(jq -r '.created_at' <<<"$ISSUE_JSON")")))
decision="$(claim_decision "$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)
ensure_comment "$n" claim-reclaimed \
'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")"
run gh issue edit "$n" -R "$REPO" --remove-assignee "$owners" \
--remove-label claimed --add-label ready >/dev/null
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
}
main() {
REPO="${REPO:?set REPO to owner/name}"
LABELS_CONF="${LABELS_CONF:-.github/labels.conf}"
load_issueflow_config "$LABELS_CONF"
NOW="$(date +%s)"
OPEN_PR_ISSUES="$(gh pr list -R "$REPO" --state open --limit 100 \
--json closingIssuesReferences --jq '.[].closingIssuesReferences[].number' | sort -nu)"
local n
for n in $(gh issue list -R "$REPO" --state open --limit 100 --json number --jq '.[].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
}
;;
triage-actors=*) ;;
*) parse_label_row "$line" >/dev/null || return ;;
esac
done <"$conf"
@ -102,7 +103,7 @@ configured_label_rows() { # validated scope rows, excluding the panel setting
[ -f "$conf" ] || return 0
while IFS= read -r line || [ -n "$line" ]; do
[ -n "$line" ] || continue
case "$line" in panel=*) continue ;; esac
case "$line" in panel=* | triage-actors=*) continue ;; esac
parse_label_row "$line" || return
done <"$conf"
}

View file

@ -229,6 +229,8 @@ on:
workflow_dispatch: # bootstraps missing labels on a fresh repo
pull_request_target:
types: [opened, reopened, ready_for_review, converted_to_draft, synchronize, labeled, unlabeled]
issues:
types: [opened, labeled, unlabeled, assigned, unassigned, closed]
permissions:
contents: read
issues: write
@ -242,16 +244,18 @@ jobs:
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.
`.github/labels.conf` has one mandatory panel setting followed by zero or
more scope rows:
`.github/labels.conf` has one mandatory panel setting, one mandatory
`triage-actors` setting, and then zero or more scope rows:
```text
panel=claude-bot example-codex-bot example-grok-bot
triage-actors=example-triage-bot
scope:cli|C5DEF5|The command-line surface
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.
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

View file

@ -0,0 +1,58 @@
#!/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"
# 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 "recent claim without PR stays claimed" 0 "KEEP" claim_decision 1 false 60
check "unassigned claim is flagged" 0 "FLAG_UNASSIGNED" claim_decision 0 false 60
check "quiet claim without PR is reclaimed" 0 "RECLAIM" claim_decision 1 false $((STALE_AFTER + 1))
# Invariant 3: blocked declarations parse and release only when all close.
refs="$(blocked_references <<< $'Context #99\nBlocked by #12 and #7')"
check "blocked declaration extracts only declared refs" 0 $'7\n12' printf '%s\n' "$refs"
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"
# Invariant 5: completed epics get one nudge; incomplete/unparseable do not.
epic_refs="$(epic_references <<< $'- [ ] #3 first\n- [x] #2 done\nplain #8')"
check "epic parser reads task-list refs only" 0 $'2\n3' printf '%s\n' "$epic_refs"
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