From 884a95f5fa75f7e7fa5c25fe76862d1fe34a44df Mon Sep 17 00:00:00 2001 From: dan-claude-bot Date: Sat, 18 Jul 2026 18:31:54 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20label=20automation=20=E2=80=94=20the=20?= =?UTF-8?q?state=20reconciler,=20path-scoped=20labeler,=20and=20CONTRIBUTI?= =?UTF-8?q?NG?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .github/labeler.yml | 25 +++++ .github/scripts/labels-reconcile.sh | 149 ++++++++++++++++++++++++++++ .github/workflows/labels.yml | 49 +++++++++ CONTRIBUTING.md | 45 +++++++++ LABELS.md | 15 +-- 5 files changed, 277 insertions(+), 6 deletions(-) create mode 100644 .github/labeler.yml create mode 100644 .github/scripts/labels-reconcile.sh create mode 100644 .github/workflows/labels.yml create mode 100644 CONTRIBUTING.md diff --git a/.github/labeler.yml b/.github/labeler.yml new file mode 100644 index 0000000..0daec5f --- /dev/null +++ b/.github/labeler.yml @@ -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/**"] diff --git a/.github/scripts/labels-reconcile.sh b/.github/scripts/labels-reconcile.sh new file mode 100644 index 0000000..9d3e513 --- /dev/null +++ b/.github/scripts/labels-reconcile.sh @@ -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: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 +} + +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." diff --git a/.github/workflows/labels.yml b/.github/workflows/labels.yml new file mode 100644 index 0000000..235441f --- /dev/null +++ b/.github/workflows/labels.yml @@ -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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..9bc213f --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,45 @@ +# 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. **When all three approve**, the final review goes to the maintainer — the + labels workflow requests it automatically. +6. **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. diff --git a/LABELS.md b/LABELS.md index 8cc13de..2057bd5 100644 --- a/LABELS.md +++ b/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 derivable from GitHub's own facts — the draft flag, requested reviewers, -review states, push timestamps — so a scheduled workflow recomputes the state -and reconciles labels statelessly. A hand-moved label is a lie waiting to -happen; the workflow asserts the effective state instead. Until that workflow -lands, treat `state:` labels as advisory. +review states, push timestamps — so the labels workflow +([.github/workflows/labels.yml](.github/workflows/labels.yml)) recomputes the +state and reconciles labels statelessly, on a 15-minute cron plus PR events. +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 -idempotently. To create them by hand (needs push access): +The same workflow bootstraps the taxonomy: a manual dispatch creates any +missing label idempotently. To create them by hand (needs push access): ```sh gh label create "state:building" --color FBCA04 --description "PR is a draft — the coding agent is still building" --force