feat: label automation — the state reconciler, path-scoped labeler, and CONTRIBUTING

The machinery LABELS.md promised. labels.yml runs the reconciler on a
15-minute cron plus PR events (pull_request_target — every PR here is from a
fork, where pull_request gets a read-only token; no PR code is ever checked
out). The script derives each open PR's state:* from GitHub's own facts and
converges labels statelessly; stale is judged from real activity (commits,
comments, reviews), never label churn, so the sweep cannot un-stale its own
mark. actions/labeler applies scope:* from changed paths. CONTRIBUTING.md is
the guideline: the PR loop, and who sets which labels. Rehearsed with
DRY_RUN=1 against the live repo; shellcheck-clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
dan-claude-bot 2026-07-18 18:31:51 +00:00
parent 4fa7b3a2d8
commit 1b4594eafc
5 changed files with 276 additions and 6 deletions

22
.github/labeler.yml vendored Normal file
View file

@ -0,0 +1,22 @@
# path → scope:* map for actions/labeler — the PR half of LABELS.md's scope
# story (issues are hand-scoped at triage; paths only exist on PRs). Additive
# only: sync-labels stays off in labels.yml, so a hand-applied scope survives.
"scope:cli":
- changed-files:
- any-glob-to-any-file: ["bin/**", "test/cli.sh"]
"scope:installer":
- changed-files:
- any-glob-to-any-file: ["install.sh"]
"scope:host":
- changed-files:
- any-glob-to-any-file: ["host/**"]
"scope:tiers":
- changed-files:
- any-glob-to-any-file:
["host/grant-user.sh", "host/revoke-user.sh", "drill/multiuser.sh"]
"scope:templates":
- changed-files:
- any-glob-to-any-file: ["templates/**", "profiles/**"]
"scope:drill":
- changed-files:
- any-glob-to-any-file: ["drill/**"]

149
.github/scripts/labels-reconcile.sh vendored Normal file
View file

@ -0,0 +1,149 @@
#!/usr/bin/env bash
set -euo pipefail
# labels-reconcile.sh — the automation LABELS.md promises: state labels are
# written by machinery, never by hand. Every run derives each open PR's
# state:* from GitHub's own facts (draft flag, requested reviewers, submitted
# reviews) and converges the labels to it, so a killed run or a hand-moved
# label heals on the next pass. Stale is judged from real activity — commits,
# comments, reviews — never from label churn, or the sweep would un-stale its
# own mark every tick.
#
# DRY_RUN=1 narrates every mutation instead of performing it (how this script
# is rehearsed against the live repo). A workflow_dispatch run also bootstraps
# the taxonomy (label create --force), which is how a fresh repo — or a label
# someone deleted — self-heals.
REPO="${REPO:?set REPO to owner/name}"
HUMAN="${HUMAN_REVIEWER:-danmt}"
BOTS=(claude-bot-andresmgsl codex-bot-andresmgsl grok-bot-andresmgsl)
STATES=(state:building state:bots-reviewing state:addressing state:needs-human)
STALE_AFTER=$((48 * 3600))
log() { printf 'labels: %s\n' "$*"; }
run() { # every mutation goes through here — DRY_RUN=1 logs instead of doing
if [ -n "${DRY_RUN:-}" ]; then log "DRY_RUN: $*"; else "$@"; fi
}
bootstrap_labels() { # dispatch-only: ~20 upserts is too chatty for every cron tick
while IFS='|' read -r name color desc; do
[ -n "$name" ] || continue
run gh label create "$name" -R "$REPO" --color "$color" --description "$desc" --force
done <<'EOF'
state:building|FBCA04|PR is a draft — the coding agent is still building
state:bots-reviewing|1D76DB|Waiting on the bot reviewers to finish the round
state:addressing|D93F0B|All bots reviewed — coding agent owes the single reply + fixes
state:needs-human|8250DF|All bots approve — waiting on the human reviewer
stale|B60205|No activity for 48h — needs a poke (sweep-managed)
blocked|6A737D|Waiting on another PR or issue to land first
release|0E8A16|Release flow and version/packaging work
scope:cli|C5DEF5|bin/box — the command surface
scope:installer|C5DEF5|install.sh, versioned installs, upgrade/uninstall
scope:host|C5DEF5|host/ — setup, teardown, firewall, isolation stack
scope:tiers|C5DEF5|restricted tier — grant/revoke, multi-user
scope:templates|C5DEF5|templates/ — the box seeds
scope:drill|C5DEF5|drill/ — rehearsals, doctor, RUNS.md
EOF
}
if [ "${GITHUB_EVENT_NAME:-}" = workflow_dispatch ]; then
log "workflow_dispatch: bootstrapping the taxonomy"
bootstrap_labels
fi
now="$(date +%s)"
for n in $(gh pr list -R "$REPO" --state open --limit 100 --json number --jq '.[].number'); do
pr="$(gh api "repos/$REPO/pulls/$n")"
draft="$(jq -r '.draft' <<<"$pr")"
labels="$(jq -r '.labels[].name' <<<"$pr")"
requested_logins="$(jq -r '.requested_reviewers[].login' <<<"$pr")"
# PENDING reviews are unsubmitted drafts sitting in someone's browser — not a verdict
reviews="$(gh api --paginate "repos/$REPO/pulls/$n/reviews" --jq '.[]' \
| jq -s '[.[] | select(.state != "PENDING")]')"
latest() { # $1 = login → their latest submitted review state, or empty
jq -r --arg u "$1" \
'[.[] | select(.user.login == $u)] | sort_by(.submitted_at) | last | .state // empty' \
<<<"$reviews"
}
requested() { grep -qxF "$1" <<<"$requested_logins"; }
has_label() { grep -qxF "$1" <<<"$labels"; }
# ---- who is the ball with? (the LABELS.md state machine) ----
desired=""
if [ "$draft" = true ]; then
desired=state:building
elif requested "$HUMAN"; then
# an explicit human request outranks the bot rounds — it is the final
# gate, and a maintainer pulling a PR to themselves early counts too
desired=state:needs-human
else
for b in "${BOTS[@]}"; do
# in requested_reviewers = round (re-)requested and unanswered; never
# reviewed at all = the round hasn't even started for this bot
if requested "$b" || [ -z "$(latest "$b")" ]; then desired=state:bots-reviewing; fi
done
if [ -z "$desired" ]; then
all_approved=1
for b in "${BOTS[@]}"; do
[ "$(latest "$b")" = APPROVED ] || all_approved=0
done
if [ "$all_approved" = 1 ]; then
# the ball is the human's — unless their last word was CHANGES_REQUESTED
# and nobody has re-requested them since (then the agent owes fixes)
if ! requested "$HUMAN" && [ "$(latest "$HUMAN")" = CHANGES_REQUESTED ]; then
desired=state:addressing
else
desired=state:needs-human
fi
else
desired=state:addressing
fi
fi
fi
# encode the runbook's last step: all bots approve → the human is asked, once.
# The guard (never requested, never reviewed) is what makes this idempotent.
if [ "$desired" = state:needs-human ] && ! requested "$HUMAN" && [ -z "$(latest "$HUMAN")" ]; then
run gh api "repos/$REPO/pulls/$n/requested_reviewers" -f "reviewers[]=$HUMAN" --silent
log "#$n: requested $HUMAN (all bots approve)"
fi
# ---- converge the state:* labels ----
remove=""
for s in "${STATES[@]}"; do
if [ "$s" != "$desired" ] && has_label "$s"; then remove="$remove,$s"; fi
done
remove="${remove#,}"
if ! has_label "$desired" || [ -n "$remove" ]; then
args=(--add-label "$desired")
[ -n "$remove" ] && args+=(--remove-label "$remove")
run gh issue edit "$n" -R "$REPO" "${args[@]}" >/dev/null
log "#$n: state -> $desired${remove:+ (cleared $remove)}"
fi
# ---- stale: real activity only, and blocked is legitimately quiet ----
last_activity="$(
{
jq -r '.created_at' <<<"$pr"
jq -r '.[].submitted_at' <<<"$reviews"
gh api --paginate "repos/$REPO/issues/$n/comments" --jq '.[].created_at'
gh api --paginate "repos/$REPO/pulls/$n/comments" --jq '.[].created_at'
gh api --paginate "repos/$REPO/pulls/$n/commits" --jq '.[].commit.committer.date'
} | sort | tail -n1
)"
age=$((now - $(date -d "$last_activity" +%s)))
if has_label blocked || [ "$age" -le "$STALE_AFTER" ]; then
if has_label stale; then
run gh issue edit "$n" -R "$REPO" --remove-label stale >/dev/null
log "#$n: unstale"
fi
elif ! has_label stale; then
run gh issue edit "$n" -R "$REPO" --add-label stale >/dev/null
log "#$n: stale ($((age / 3600))h quiet)"
fi
done
log "reconciled."

49
.github/workflows/labels.yml vendored Normal file
View file

@ -0,0 +1,49 @@
name: labels
# The automation LABELS.md promises. Two halves:
# scope — path-derived scope:* labels on PRs (actions/labeler)
# reconcile — the state:* machine + the stale sweep (.github/scripts/labels-reconcile.sh)
#
# pull_request_target, not pull_request: every PR here arrives from a fork,
# where pull_request (and pull_request_review) run with a READ-ONLY token and
# cannot label anything. _target is safe in this workflow because no PR code
# is ever checked out or executed — labeler reads changed paths via the API,
# and reconcile checks out the BASE branch only. Keep it that way.
#
# Review-submitted transitions (bots finishing a round) ride the cron: there
# is no pull_request_review_target, so the 15-minute tick is the wake signal —
# the same cadence the reviewer bots poll at.
on:
schedule:
- cron: "*/15 * * * *"
workflow_dispatch: # also bootstraps missing labels — run once on a fresh repo
pull_request_target:
types: [opened, reopened, ready_for_review, converted_to_draft, synchronize]
permissions:
contents: read
issues: write
pull-requests: write
concurrency:
group: labels-${{ github.event.pull_request.number || 'cron' }}
cancel-in-progress: false
jobs:
scope:
if: github.event_name == 'pull_request_target'
runs-on: ubuntu-latest
steps:
- uses: actions/labeler@v5
with:
# additive only — a hand-applied scope must survive the machine
sync-labels: false
reconcile:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4 # base branch only — never the PR's code
- name: reconcile state + stale
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
run: bash .github/scripts/labels-reconcile.sh

47
CONTRIBUTING.md Normal file
View file

@ -0,0 +1,47 @@
# Contributing
How change lands in this repo. The short version: PRs are born as drafts,
three reviewer bots take the first rounds, a human takes the last word — and
labels tell you where everything is without opening anything.
## The PR loop
1. **Fork and branch.** Contributors work from forks; upstream branches are
for maintainers. Title the PR conventionally (`feat:`, `fix:`, `docs:`),
and include a `CHANGELOG.md` entry under `## Unreleased` when the change
deserves one.
2. **Open as a draft** while you build. Drafts are invisible to the reviewer
bots on purpose.
3. **When it's ready**: mark ready-for-review and request all three bots —
`claude-bot-andresmgsl`, `codex-bot-andresmgsl`, `grok-bot-andresmgsl`.
They poll roughly every 15 minutes.
4. **Rounds are answered whole.** Wait until all three have reviewed, then
answer the entire round in a **single reply**, push the fixes, and
re-request the bots that didn't approve. Prefer verification over
argument: a test settles what a comment thread can't.
5. **When all three approve**, the final review goes to the maintainer — the
labels workflow requests it automatically.
6. **Checks must be green**: `shellcheck` and `bash test/cli.sh` locally
mirror what CI runs; the multi-user rehearsal runs in CI on a real Incus.
## Labels — who sets what
The full taxonomy lives in [LABELS.md](LABELS.md). What matters day to day is
who sets each kind — most of it is machinery, and hand-moving a
machine-owned label just gets corrected on the next pass:
| Labels | Set by |
|---|---|
| `state:*` | the labels workflow ([.github/workflows/labels.yml](.github/workflows/labels.yml)) — recomputed from GitHub's own facts every 15 minutes and on PR events. Never by hand. |
| `stale` | the same workflow — 48h without commits, comments, or reviews. `blocked` PRs are exempt: they are quiet legitimately. |
| `scope:*` on PRs | actions/labeler, from the changed paths ([.github/labeler.yml](.github/labeler.yml)). Additive — you may add more, the machine won't remove them. |
| `scope:*` on issues | you, when opening or triaging — issues have no paths to derive from. |
| `blocked`, `release` | you — automation never guesses intent. |
| `bug` / `enhancement` / `documentation` | you, on issues only — a PR's type already lives in its title. |
## Issues
Give issues the same care as PR titles: say the surface in the title, apply a
`scope:` label and a type label (`bug` / `enhancement` / `documentation`) when
you open one, and `blocked` when it waits on something — that is what keeps
the board navigable as the issue count grows.

View file

@ -54,13 +54,16 @@ would just say the same thing twice, drifting apart eventually.
State labels are written by automation, never by hand. Every state above is State labels are written by automation, never by hand. Every state above is
derivable from GitHub's own facts — the draft flag, requested reviewers, derivable from GitHub's own facts — the draft flag, requested reviewers,
review states, push timestamps — so a scheduled workflow recomputes the state review states, push timestamps — so the labels workflow
and reconciles labels statelessly. A hand-moved label is a lie waiting to ([.github/workflows/labels.yml](.github/workflows/labels.yml)) recomputes the
happen; the workflow asserts the effective state instead. Until that workflow state and reconciles labels statelessly, on a 15-minute cron plus PR events.
lands, treat `state:` labels as advisory. A hand-moved label is a lie waiting to happen; the workflow asserts the
effective state instead. `scope:` labels on PRs are applied from the changed
paths by actions/labeler ([.github/labeler.yml](.github/labeler.yml));
[CONTRIBUTING.md](CONTRIBUTING.md) says who sets what.
The same workflow bootstraps the taxonomy: it creates any missing label The same workflow bootstraps the taxonomy: a manual dispatch creates any
idempotently. To create them by hand (needs push access): missing label idempotently. To create them by hand (needs push access):
```sh ```sh
gh label create "state:building" --color FBCA04 --description "PR is a draft — the coding agent is still building" --force gh label create "state:building" --color FBCA04 --description "PR is a draft — the coding agent is still building" --force