forked from heavy-duty/ceremony
Merge pull request #32 from codex-bot-andresmgsl/build/18-issueflow-reconcile
feat: reconcile the issue work queue
This commit is contained in:
commit
66f1c08e1a
11 changed files with 483 additions and 19 deletions
1
.github/labels.conf
vendored
1
.github/labels.conf
vendored
|
|
@ -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
|
||||||
|
|
|
||||||
12
.github/workflows/labels.yml
vendored
12
.github/workflows/labels.yml
vendored
|
|
@ -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 }}
|
||||||
|
|
|
||||||
2
.github/workflows/self-labels.yml
vendored
2
.github/workflows/self-labels.yml
vendored
|
|
@ -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:
|
||||||
|
|
|
||||||
16
LABELS.md
16
LABELS.md
|
|
@ -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.
|
||||||
|
|
|
||||||
11
TRIAGE.md
11
TRIAGE.md
|
|
@ -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.
|
||||||
|
|
|
||||||
13
actions/issueflow-reconcile/action.yml
Normal file
13
actions/issueflow-reconcile/action.yml
Normal 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"
|
||||||
309
actions/issueflow-reconcile/issueflow-reconcile.sh
Normal file
309
actions/issueflow-reconcile/issueflow-reconcile.sh
Normal 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
|
||||||
|
|
@ -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"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
||||||
|
|
|
||||||
114
test/issueflow-reconcile.test.sh
Normal file
114
test/issueflow-reconcile.test.sh
Normal 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
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue