forked from heavy-duty/box
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:
parent
4fa7b3a2d8
commit
1b4594eafc
5 changed files with 276 additions and 6 deletions
22
.github/labeler.yml
vendored
Normal file
22
.github/labeler.yml
vendored
Normal 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
149
.github/scripts/labels-reconcile.sh
vendored
Normal 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
49
.github/workflows/labels.yml
vendored
Normal 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
47
CONTRIBUTING.md
Normal 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.
|
||||||
15
LABELS.md
15
LABELS.md
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue