Merge pull request #98 from dan-claude-bot/feat/label-automation
feat: label automation — state reconciler, path-scoped labeler, and CONTRIBUTING
This commit is contained in:
commit
ddd948c1a7
7 changed files with 495 additions and 7 deletions
25
.github/labeler.yml
vendored
Normal file
25
.github/labeler.yml
vendored
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
# 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:capture":
|
||||||
|
- changed-files:
|
||||||
|
- any-glob-to-any-file:
|
||||||
|
["src/draft.ts", "src/capture.ts", "test/draft*.test.ts", "test/capture*.test.ts"]
|
||||||
|
"scope:apply":
|
||||||
|
- changed-files:
|
||||||
|
- any-glob-to-any-file:
|
||||||
|
["src/apply.ts", "src/diff.ts", "src/destroy.ts", "src/smoke.ts", "test/apply.test.ts", "test/diff.test.ts", "test/destroy.test.ts"]
|
||||||
|
"scope:secrets":
|
||||||
|
- changed-files:
|
||||||
|
- any-glob-to-any-file: ["src/secrets.ts", "test/secrets.test.ts"]
|
||||||
|
"scope:fleet":
|
||||||
|
- changed-files:
|
||||||
|
- any-glob-to-any-file:
|
||||||
|
["src/fleet.ts", "src/inventory.ts", "src/server.ts", "src/team.ts", "test/fleet*.test.ts", "test/inventory.test.ts"]
|
||||||
|
"scope:manifest":
|
||||||
|
- changed-files:
|
||||||
|
- any-glob-to-any-file:
|
||||||
|
["src/manifest.ts", "src/resolve.ts", "src/envtemplate.ts", "src/bindings.ts", "test/manifest.test.ts", "test/resolve.test.ts"]
|
||||||
|
"scope:coolify-api":
|
||||||
|
- changed-files:
|
||||||
|
- any-glob-to-any-file: ["src/coolify.ts", "reference/**"]
|
||||||
217
.github/scripts/labels-reconcile.sh
vendored
Normal file
217
.github/scripts/labels-reconcile.sh
vendored
Normal file
|
|
@ -0,0 +1,217 @@
|
||||||
|
#!/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.
|
||||||
|
#
|
||||||
|
# The verdict contract (CONTRIBUTING.md): reviews end in approve or
|
||||||
|
# request-changes. Some live bots are comment-only and post agreement as a
|
||||||
|
# COMMENTED review — a non-verdict this machine refuses to guess about (body
|
||||||
|
# parsing is a heuristic, and a wrong guess promotes an unapproved PR). The
|
||||||
|
# judgment call belongs to the PR AUTHOR, who reads the round and escalates
|
||||||
|
# by requesting the human's review — an explicit request is a fact, and it is
|
||||||
|
# the one this machine trusts (see decide_state's top precedence). The
|
||||||
|
# machine auto-requests the human only in the no-judgment-needed case: three
|
||||||
|
# formal head-current approvals. Any approval that counts must be bound to
|
||||||
|
# the CURRENT head SHA: GitHub keeps approvals alive across pushes, and a
|
||||||
|
# stale approval must never promote unreviewed code to the human.
|
||||||
|
#
|
||||||
|
# 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) — that heal is dispatch-only; the cron
|
||||||
|
# sweep tolerates a missing label rather than recreating it.
|
||||||
|
#
|
||||||
|
# The state machine below is pure (globals in, state out) and covered by
|
||||||
|
# fixture tests in test/labels-reconcile.sh.
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# The state machine. Pure functions over four globals, set per PR:
|
||||||
|
# DRAFT true|false
|
||||||
|
# HEAD_SHA the PR's current head commit
|
||||||
|
# REQUESTED newline-separated logins with a review currently requested
|
||||||
|
# REVIEWS_JSON JSON array of submitted (non-PENDING) reviews
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
requested() { grep -qxF "$1" <<<"$REQUESTED"; }
|
||||||
|
|
||||||
|
bot_verdict() { # $1 = login → MISSING | BLOCK | APPROVE | STALE | FEEDBACK
|
||||||
|
local review state commit
|
||||||
|
review="$(jq -c --arg u "$1" \
|
||||||
|
'[.[] | select(.user.login == $u)] | sort_by(.submitted_at) | last // empty' \
|
||||||
|
<<<"$REVIEWS_JSON")"
|
||||||
|
if [ -z "$review" ]; then echo MISSING; return; fi
|
||||||
|
state="$(jq -r '.state' <<<"$review")"
|
||||||
|
commit="$(jq -r '.commit_id' <<<"$review")"
|
||||||
|
case "$state" in
|
||||||
|
CHANGES_REQUESTED)
|
||||||
|
# blocks at ANY head — GitHub's own semantic: only a newer review
|
||||||
|
# from the same reviewer clears it
|
||||||
|
echo BLOCK ;;
|
||||||
|
APPROVED)
|
||||||
|
if [ "$commit" = "$HEAD_SHA" ]; then echo APPROVE; else echo STALE; fi ;;
|
||||||
|
*)
|
||||||
|
# COMMENTED and anything else: a non-verdict. The machine does not
|
||||||
|
# read bodies — if the comment is really an agreement, the AUTHOR
|
||||||
|
# says so by requesting the human's review.
|
||||||
|
echo FEEDBACK ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
decide_state() { # → the one state:* label this PR should carry
|
||||||
|
if [ "$DRAFT" = true ]; then echo state:building; return; fi
|
||||||
|
# an explicit human request outranks the bot rounds — it is the final
|
||||||
|
# gate, and a maintainer pulling a PR to themselves early counts too
|
||||||
|
if requested "$HUMAN"; then echo state:needs-human; return; fi
|
||||||
|
local b v verdicts=""
|
||||||
|
for b in "${BOTS[@]}"; do
|
||||||
|
if requested "$b"; then echo state:bots-reviewing; return; fi
|
||||||
|
done
|
||||||
|
for b in "${BOTS[@]}"; do
|
||||||
|
v="$(bot_verdict "$b")"
|
||||||
|
if [ "$v" = MISSING ]; then echo state:bots-reviewing; return; fi
|
||||||
|
verdicts="$verdicts $v"
|
||||||
|
done
|
||||||
|
case "$verdicts" in
|
||||||
|
# FEEDBACK = a comment with no verdict → the agent owes the round-reply.
|
||||||
|
# STALE = a verdict for an older head → the agent owes a re-request.
|
||||||
|
*BLOCK* | *FEEDBACK* | *STALE*) echo state:addressing; return ;;
|
||||||
|
esac
|
||||||
|
# the bots all approve — but if the human's standing word is
|
||||||
|
# changes-requested (and nobody re-requested them yet), the agent owes
|
||||||
|
# fixes, not the human a nag
|
||||||
|
if [ "$(bot_verdict "$HUMAN")" = BLOCK ]; then
|
||||||
|
echo state:addressing
|
||||||
|
else
|
||||||
|
echo state:needs-human
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# The sweep: fetch facts, decide, converge. One PR's failure never aborts the
|
||||||
|
# others — each PR reconciles in a subshell and a failure just logs.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
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:capture|C5DEF5|draft/capture — reading the live world into a manifest
|
||||||
|
scope:apply|C5DEF5|apply/diff/destroy — reconciling onto Coolify
|
||||||
|
scope:secrets|C5DEF5|secrets, age, the encrypted state repo
|
||||||
|
scope:fleet|C5DEF5|fleet/inventory/server — placement
|
||||||
|
scope:manifest|C5DEF5|manifest/resolve/envtemplate — the manifest language
|
||||||
|
scope:coolify-api|C5DEF5|coolify.ts + OpenAPI reference — the client
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
has_label() { grep -qxF "$1" <<<"$LABELS"; }
|
||||||
|
|
||||||
|
reconcile_pr() { # $1 = PR number; relies on the globals set from its fetch
|
||||||
|
local n="$1" desired remove s args last_activity age
|
||||||
|
|
||||||
|
desired="$(decide_state)"
|
||||||
|
|
||||||
|
# encode the runbook's last step for the no-judgment case: three formal
|
||||||
|
# head-current approvals → the human is asked, once. The guard (never
|
||||||
|
# requested, never reviewed) makes it idempotent — and the shared
|
||||||
|
# concurrency group in labels.yml makes it race-free. With a comment-only
|
||||||
|
# bot on the panel this path stays cold and the AUTHOR requests the human.
|
||||||
|
if [ "$desired" = state:needs-human ] && ! requested "$HUMAN" \
|
||||||
|
&& [ -z "$(jq -r --arg u "$HUMAN" '[.[] | select(.user.login == $u)] | last | .state // empty' <<<"$REVIEWS_JSON")" ]; then
|
||||||
|
run gh api "repos/$REPO/pulls/$n/requested_reviewers" -f "reviewers[]=$HUMAN" --silent
|
||||||
|
log "#$n: requested $HUMAN (round passed)"
|
||||||
|
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")
|
||||||
|
if run gh issue edit "$n" -R "$REPO" "${args[@]}" >/dev/null; then
|
||||||
|
log "#$n: state -> $desired${remove:+ (cleared $remove)}"
|
||||||
|
else
|
||||||
|
# a deleted label must not wedge the sweep — dispatch heals the taxonomy
|
||||||
|
log "#$n: WARNING: label edit failed (missing label? run the workflow manually to bootstrap)"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---- stale: real activity only, and blocked is legitimately quiet ----
|
||||||
|
last_activity="$(
|
||||||
|
{
|
||||||
|
jq -r '.created_at' <<<"$PR_JSON"
|
||||||
|
jq -r '.[].submitted_at' <<<"$REVIEWS_JSON"
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
main() {
|
||||||
|
REPO="${REPO:?set REPO to owner/name}"
|
||||||
|
NOW="$(date +%s)"
|
||||||
|
|
||||||
|
if [ "${GITHUB_EVENT_NAME:-}" = workflow_dispatch ]; then
|
||||||
|
log "workflow_dispatch: bootstrapping the taxonomy"
|
||||||
|
bootstrap_labels
|
||||||
|
fi
|
||||||
|
|
||||||
|
local n
|
||||||
|
for n in $(gh pr list -R "$REPO" --state open --limit 100 --json number --jq '.[].number'); do
|
||||||
|
(
|
||||||
|
PR_JSON="$(gh api "repos/$REPO/pulls/$n")"
|
||||||
|
DRAFT="$(jq -r '.draft' <<<"$PR_JSON")"
|
||||||
|
HEAD_SHA="$(jq -r '.head.sha' <<<"$PR_JSON")"
|
||||||
|
LABELS="$(jq -r '.labels[].name' <<<"$PR_JSON")"
|
||||||
|
REQUESTED="$(jq -r '.requested_reviewers[].login' <<<"$PR_JSON")"
|
||||||
|
# PENDING reviews are unsubmitted drafts in someone's browser — not a verdict
|
||||||
|
REVIEWS_JSON="$(gh api --paginate "repos/$REPO/pulls/$n/reviews" --jq '.[]' \
|
||||||
|
| jq -s '[.[] | select(.state != "PENDING")]')"
|
||||||
|
reconcile_pr "$n"
|
||||||
|
) || log "#$n: reconcile failed — continuing with the remaining PRs"
|
||||||
|
done
|
||||||
|
log "reconciled."
|
||||||
|
}
|
||||||
|
|
||||||
|
# sourced by test/labels-reconcile.sh for the fixture tests; executed in CI
|
||||||
|
if [ "${BASH_SOURCE[0]}" = "$0" ]; then
|
||||||
|
main "$@"
|
||||||
|
fi
|
||||||
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
|
|
@ -22,3 +22,5 @@ jobs:
|
||||||
- run: npm test
|
- run: npm test
|
||||||
- name: installer is valid bash
|
- name: installer is valid bash
|
||||||
run: bash -n install.sh bin/cast scripts/*.sh
|
run: bash -n install.sh bin/cast scripts/*.sh
|
||||||
|
- name: labels state-machine tests
|
||||||
|
run: bash test/labels-reconcile.sh
|
||||||
|
|
|
||||||
56
.github/workflows/labels.yml
vendored
Normal file
56
.github/workflows/labels.yml
vendored
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
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
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
scope:
|
||||||
|
if: github.event_name == 'pull_request_target'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
concurrency:
|
||||||
|
group: labels-scope-${{ github.event.pull_request.number }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
steps:
|
||||||
|
- uses: actions/labeler@v5
|
||||||
|
with:
|
||||||
|
# additive only — a hand-applied scope must survive the machine
|
||||||
|
sync-labels: false
|
||||||
|
|
||||||
|
reconcile:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
# ONE shared group: every reconcile sweeps every open PR, so cron and
|
||||||
|
# PR-event runs must serialize or two sweeps race the same PR's labels
|
||||||
|
# and both pass the request-the-human-once guard. GitHub keeps at most
|
||||||
|
# one queued run per group (older queued runs are superseded), which
|
||||||
|
# coalesces bursts instead of piling them up.
|
||||||
|
concurrency:
|
||||||
|
group: labels-reconcile
|
||||||
|
cancel-in-progress: false
|
||||||
|
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
|
||||||
62
CONTRIBUTING.md
Normal file
62
CONTRIBUTING.md
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
# 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:`).
|
||||||
|
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. **Reviews end in a verdict.** A reviewer — bot or human — either
|
||||||
|
**approves** or **requests changes**, never a bare comment. A
|
||||||
|
comment-only review is a non-verdict: it doesn't say whether the round
|
||||||
|
passed, and the state machine (and anyone scanning the board) has to
|
||||||
|
guess. The verdict carries *blockingness only*, the body carries the
|
||||||
|
feedback: non-blocking nits ride an **approval** and the author addresses
|
||||||
|
them at their discretion; anything blocking — including a question that
|
||||||
|
gates the verdict — is **request changes**, saying what unblocks it. The
|
||||||
|
reconciler treats a comment-only review as not-approved, so commenting
|
||||||
|
without a verdict only stalls the PR. The machine never reads review
|
||||||
|
bodies: when a comment-only reviewer's line is really an agreement, that
|
||||||
|
judgment belongs to the **author** — escalate by requesting the
|
||||||
|
maintainer's review (step 6), and the reconciler flips the label on that
|
||||||
|
request, because an explicit request is a fact it can trust.
|
||||||
|
6. **When the round passes, the author hands the PR to the maintainer** by
|
||||||
|
requesting their review — that request is what flips `state:needs-human`.
|
||||||
|
With three formal head-current approvals the labels workflow requests it
|
||||||
|
automatically; when part of the panel is comment-only, reading their
|
||||||
|
agreement is the author's judgment, so the author makes the request.
|
||||||
|
7. **Checks must be green**: `npm run check`, `npm run build`, and
|
||||||
|
`npm test` locally mirror what CI runs.
|
||||||
|
|
||||||
|
## 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.
|
||||||
17
LABELS.md
17
LABELS.md
|
|
@ -17,7 +17,7 @@ single reply, and a human takes the final review.
|
||||||
| `state:building` | `#FBCA04` | the coding agent, still building | PR opened as draft | marked ready + bot reviews requested |
|
| `state:building` | `#FBCA04` | the coding agent, still building | PR opened as draft | marked ready + bot reviews requested |
|
||||||
| `state:bots-reviewing` | `#1D76DB` | the reviewer bots to finish the round | ready with reviews requested, or fixes pushed and reviews re-requested | all three bots have reviewed the round |
|
| `state:bots-reviewing` | `#1D76DB` | the reviewer bots to finish the round | ready with reviews requested, or fixes pushed and reviews re-requested | all three bots have reviewed the round |
|
||||||
| `state:addressing` | `#D93F0B` | the coding agent to reply and push fixes | all bots reviewed the round, not all approved | the single round-reply is posted and fixes pushed |
|
| `state:addressing` | `#D93F0B` | the coding agent to reply and push fixes | all bots reviewed the round, not all approved | the single round-reply is posted and fixes pushed |
|
||||||
| `state:needs-human` | `#8250DF` | the human reviewer | all three bots approve | merged — or changes requested, which cycles back to `state:addressing` |
|
| `state:needs-human` | `#8250DF` | the human reviewer | the human review is requested — by the author when the round passes, or automatically on three formal head-current approvals | merged — or changes requested, which cycles back to `state:addressing` |
|
||||||
|
|
||||||
`bots-reviewing` and `addressing` are deliberately distinct: staleness in the
|
`bots-reviewing` and `addressing` are deliberately distinct: staleness in the
|
||||||
first means *poke the bots*, staleness in the second means *the agent dropped
|
first means *poke the bots*, staleness in the second means *the agent dropped
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
123
test/labels-reconcile.sh
Normal file
123
test/labels-reconcile.sh
Normal file
|
|
@ -0,0 +1,123 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Fixture tests for the labels-reconcile state machine: a comment is a
|
||||||
|
# non-verdict whatever its body says (the AUTHOR escalates by requesting the
|
||||||
|
# human), a stale approval does not promote unreviewed code, and an explicit
|
||||||
|
# human request outranks everything.
|
||||||
|
# Dependency-free beyond jq; no network, no daemon — pure decide_state.
|
||||||
|
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
# shellcheck source=.github/scripts/labels-reconcile.sh
|
||||||
|
. .github/scripts/labels-reconcile.sh
|
||||||
|
|
||||||
|
# The DRAFT/HEAD_SHA/REQUESTED/REVIEWS_JSON assignments below are the state
|
||||||
|
# machine's inputs, consumed inside the sourced decide_state — not unused.
|
||||||
|
# shellcheck disable=SC2034
|
||||||
|
BOT1="${BOTS[0]}" BOT2="${BOTS[1]}" BOT3="${BOTS[2]}"
|
||||||
|
pass=0 fail=0
|
||||||
|
|
||||||
|
expect() { # $1 = description, $2 = want, $3 = got
|
||||||
|
if [ "$2" = "$3" ]; then
|
||||||
|
pass=$((pass + 1))
|
||||||
|
else
|
||||||
|
fail=$((fail + 1))
|
||||||
|
printf 'FAIL: %s — want %s, got %s\n' "$1" "$2" "$3"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
rev() { # $1=login $2=state $3=commit $4=body $5=submitted_at → one review object
|
||||||
|
jq -n --arg u "$1" --arg s "$2" --arg c "$3" --arg b "$4" --arg t "$5" \
|
||||||
|
'{user: {login: $u}, state: $s, commit_id: $c, body: $b, submitted_at: $t}'
|
||||||
|
}
|
||||||
|
|
||||||
|
reviews() { jq -s '.' <<<"$*"; } # collect review objects into an array
|
||||||
|
|
||||||
|
# -- drafts are building, whoever is requested --------------------------------
|
||||||
|
DRAFT=true HEAD_SHA=head1 REQUESTED="" REVIEWS_JSON='[]'
|
||||||
|
expect "draft PR is building" state:building "$(decide_state)"
|
||||||
|
|
||||||
|
# -- fresh ready PR with bots requested ---------------------------------------
|
||||||
|
DRAFT=false REQUESTED="$BOT1
|
||||||
|
$BOT2
|
||||||
|
$BOT3" REVIEWS_JSON='[]'
|
||||||
|
expect "requested bots mean bots-reviewing" state:bots-reviewing "$(decide_state)"
|
||||||
|
|
||||||
|
# -- a bot that never reviewed keeps the round open ---------------------------
|
||||||
|
REQUESTED="" REVIEWS_JSON="$(reviews \
|
||||||
|
"$(rev "$BOT1" APPROVED head1 "" t1)" \
|
||||||
|
"$(rev "$BOT2" APPROVED head1 "" t2)")"
|
||||||
|
expect "missing bot review means bots-reviewing" state:bots-reviewing "$(decide_state)"
|
||||||
|
|
||||||
|
# -- a comment is a non-verdict, agreement body or not: the author escalates --
|
||||||
|
REVIEWS_JSON="$(reviews \
|
||||||
|
"$(rev "$BOT1" COMMENTED head1 "✅ **Reviewed — I agree with everything.**" t1)" \
|
||||||
|
"$(rev "$BOT2" APPROVED head1 "" t2)" \
|
||||||
|
"$(rev "$BOT3" APPROVED head1 "" t3)")"
|
||||||
|
expect "comment-only agreement still parks on the author" state:addressing "$(decide_state)"
|
||||||
|
# ...and the author's escalation — requesting the human — flips it
|
||||||
|
REQUESTED="$HUMAN"
|
||||||
|
expect "author escalation flips to needs-human" state:needs-human "$(decide_state)"
|
||||||
|
REQUESTED=""
|
||||||
|
|
||||||
|
# -- three formal approvals need no author judgment ---------------------------
|
||||||
|
REVIEWS_JSON="$(reviews \
|
||||||
|
"$(rev "$BOT1" APPROVED head1 "" t1)" \
|
||||||
|
"$(rev "$BOT2" APPROVED head1 "" t2)" \
|
||||||
|
"$(rev "$BOT3" APPROVED head1 "" t3)")"
|
||||||
|
expect "three formal approvals reach needs-human" state:needs-human "$(decide_state)"
|
||||||
|
|
||||||
|
# -- a comment WITHOUT a verdict parks the PR on the agent --------------------
|
||||||
|
REVIEWS_JSON="$(reviews \
|
||||||
|
"$(rev "$BOT1" COMMENTED head1 "🔧 Reviewed — I agree with most; feedback below." t1)" \
|
||||||
|
"$(rev "$BOT2" APPROVED head1 "" t2)" \
|
||||||
|
"$(rev "$BOT3" APPROVED head1 "" t3)")"
|
||||||
|
expect "comment without verdict is addressing" state:addressing "$(decide_state)"
|
||||||
|
|
||||||
|
# -- changes requested blocks, at any head ------------------------------------
|
||||||
|
REVIEWS_JSON="$(reviews \
|
||||||
|
"$(rev "$BOT1" CHANGES_REQUESTED old1 "blockers below" t1)" \
|
||||||
|
"$(rev "$BOT2" APPROVED head1 "" t2)" \
|
||||||
|
"$(rev "$BOT3" APPROVED head1 "" t3)")"
|
||||||
|
expect "changes-requested blocks even from an old head" state:addressing "$(decide_state)"
|
||||||
|
|
||||||
|
# -- a stale approval must not promote unreviewed code ------------------------
|
||||||
|
REVIEWS_JSON="$(reviews \
|
||||||
|
"$(rev "$BOT1" APPROVED old1 "" t1)" \
|
||||||
|
"$(rev "$BOT2" APPROVED head1 "" t2)" \
|
||||||
|
"$(rev "$BOT3" APPROVED head1 "" t3)")"
|
||||||
|
expect "stale approval is addressing (agent owes re-request)" state:addressing "$(decide_state)"
|
||||||
|
|
||||||
|
# -- a re-requested bot reopens the round even with an old approval on file ---
|
||||||
|
REQUESTED="$BOT1"
|
||||||
|
expect "re-requested bot means bots-reviewing" state:bots-reviewing "$(decide_state)"
|
||||||
|
REQUESTED=""
|
||||||
|
|
||||||
|
# -- only the LATEST review per bot counts ------------------------------------
|
||||||
|
REVIEWS_JSON="$(reviews \
|
||||||
|
"$(rev "$BOT1" CHANGES_REQUESTED head1 "blockers" t1)" \
|
||||||
|
"$(rev "$BOT1" APPROVED head1 "" t2)" \
|
||||||
|
"$(rev "$BOT2" APPROVED head1 "" t3)" \
|
||||||
|
"$(rev "$BOT3" APPROVED head1 "" t4)")"
|
||||||
|
expect "later approval supersedes earlier block" state:needs-human "$(decide_state)"
|
||||||
|
|
||||||
|
# -- an explicit human request outranks the bot rounds ------------------------
|
||||||
|
REQUESTED="$HUMAN" REVIEWS_JSON="$(reviews \
|
||||||
|
"$(rev "$BOT1" COMMENTED head1 "feedback, no verdict" t1)")"
|
||||||
|
expect "human requested outranks bots" state:needs-human "$(decide_state)"
|
||||||
|
REQUESTED=""
|
||||||
|
|
||||||
|
# -- human CHANGES_REQUESTED puts the ball back on the agent ------------------
|
||||||
|
REVIEWS_JSON="$(reviews \
|
||||||
|
"$(rev "$BOT1" APPROVED head1 "" t1)" \
|
||||||
|
"$(rev "$BOT2" APPROVED head1 "" t2)" \
|
||||||
|
"$(rev "$BOT3" APPROVED head1 "" t3)" \
|
||||||
|
"$(rev "$HUMAN" CHANGES_REQUESTED head1 "not yet" t4)")"
|
||||||
|
expect "human block with bots approving is addressing" state:addressing "$(decide_state)"
|
||||||
|
# ...and re-requesting the human hands it back to them
|
||||||
|
REQUESTED="$HUMAN"
|
||||||
|
expect "re-requested human is needs-human again" state:needs-human "$(decide_state)"
|
||||||
|
REQUESTED=""
|
||||||
|
|
||||||
|
printf 'labels-reconcile tests: %d passed, %d failed\n' "$pass" "$fail"
|
||||||
|
[ "$fail" -eq 0 ]
|
||||||
Loading…
Reference in a new issue