diff --git a/.github/scripts/label-hygiene.mjs b/.github/scripts/label-hygiene.mjs new file mode 100644 index 000000000..114f37ac2 --- /dev/null +++ b/.github/scripts/label-hygiene.mjs @@ -0,0 +1,78 @@ +// Strip status labels from items that are no longer open (#738). +// +// node .github/scripts/label-hygiene.mjs --repo ThinkFlowLab/FlyBot # dry run: prints the work +// node .github/scripts/label-hygiene.mjs --repo ThinkFlowLab/FlyBot --apply # removes the labels +// +// A status label answers "what is happening to this item right now", so it must not outlive the item: +// on 2026-10-22 the board counted 11 closed issues and 43 closed pull requests as in flight because +// nothing removed `agent-working`/`agent-pr` when they closed. Only the status labels in +// `STALE_LABELS` are ever removed; `bug`, `area:*`, `risk:*`, `hold`, `needs-*` and `status:done` are +// records and this script never touches them. `hold` stays human-only in both directions. +import { execFileSync } from 'node:child_process' +import { fileURLToPath } from 'node:url' + +export const STALE_LABELS = ['agent-working', 'agent-pr', 'agent-ready', 'agent-task', 'awaiting-approval'] + +// Labels may arrive as `["bug"]` or as objects; GitHub's REST API returns objects. +export const labelNames = item => (item.labels ?? []).map(l => (typeof l === 'string' ? l : l?.name)).filter(Boolean) + +// What this item needs: it must be closed, and only the stale labels among its labels are removed. +export function planItem(item) { + const labels = labelNames(item) + const stale = labels.filter(name => STALE_LABELS.includes(String(name).toLowerCase())) + return { number: item.number, title: item.title ?? '', state: item.state, stale, closed: item.state !== 'open' } +} + +// [{ number, title, stale }] for the items that are closed and still carry a status label, deduped by +// number (one list call per status label, so an item with two stale labels is seen twice). +export function plan(pages) { + const byNumber = new Map() + for (const item of pages) { + const entry = planItem(item) + if (!entry.closed || entry.stale.length === 0) continue + const seen = byNumber.get(entry.number) + if (!seen) byNumber.set(entry.number, { number: entry.number, title: entry.title, stale: [...entry.stale] }) + else for (const name of entry.stale) if (!seen.stale.includes(name)) seen.stale.push(name) + } + return [...byNumber.values()].sort((a, b) => a.number - b.number) +} + +const realGh = (...a) => execFileSync('gh', a, { encoding: 'utf8', maxBuffer: 16 << 20 }) + +// `gh` and `env` are injectable so the whole run can be driven without the network, the way +// merge-gate.mjs is tested. +export function main({ gh = realGh, env = process.env, repo: repoOption, apply: applyOption } = {}) { + const arg = (name) => { const i = process.argv.indexOf(name); return i > 0 ? process.argv[i + 1] : undefined } + const repo = repoOption || env.GH_REPO || arg('--repo') + if (!repo) throw new Error('set GH_REPO or pass --repo owner/name') + const apply = applyOption ?? process.argv.includes('--apply') + const list = (path) => gh('api', '--paginate', '--jq', '.[]', path).split('\n').filter(Boolean).map(line => JSON.parse(line)) + + // One list call per status label: GitHub's `labels=` filter is an AND, so asking for all five at once + // would only find items that carry all five. + const pages = STALE_LABELS.flatMap(name => list(`repos/${repo}/issues?state=closed&labels=${encodeURIComponent(name)}&per_page=100`)) + const items = plan(pages) + const labels = items.reduce((n, item) => n + item.stale.length, 0) + console.log(`label-hygiene: before: ${items.length} item(s) / ${labels} label(s) not open`) + for (const item of items) { + for (const name of item.stale) console.log(`#${item.number} ${item.title} -${name}`) + if (!apply) continue + for (const name of item.stale) gh('api', '-X', 'DELETE', `repos/${repo}/issues/${item.number}/labels/${encodeURIComponent(name)}`) + } + if (!apply) { + console.log('label-hygiene: dry run; add --apply to remove these labels') + return { ok: items.length === 0 || !apply, items, applied: false } + } + // Read back rather than assume: the run reports its own result so the cleanup is verified, not trusted. + const after = plan(STALE_LABELS.flatMap(name => list(`repos/${repo}/issues?state=closed&labels=${encodeURIComponent(name)}&per_page=100`))) + console.log(`label-hygiene: after: ${after.length} item(s)`) + if (after.length) { + console.log(`label-hygiene: STILL STALE: ${after.map(item => `#${item.number} (${item.stale.join(', ')})`).join(', ')}`) + return { ok: false, items, after, applied: true } + } + return { ok: true, items, after, applied: true } +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + try { const r = main(); process.exit(r.ok ? 0 : 1) } catch (e) { console.error(`label-hygiene: ${e.message}`); process.exit(1) } +} diff --git a/.github/scripts/label-hygiene.test.mjs b/.github/scripts/label-hygiene.test.mjs new file mode 100644 index 000000000..66a207306 --- /dev/null +++ b/.github/scripts/label-hygiene.test.mjs @@ -0,0 +1,108 @@ +// label-hygiene (#738): a status label answers "what is happening right now", so a closed issue or a +// merged pull request must not keep one. These tests drive main() with a fake `gh`, the way +// merge-gate.mjs is tested: the fixtures are real API pages, and every removal is a recorded call. +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { main, plan, planItem, STALE_LABELS } from './label-hygiene.mjs' + +const issue = (number, state, labels, title = `Item ${number}`) => + ({ number, state, title, labels: labels.map(name => ({ name })) }) + +// A fake API over a page store: `GET repos/x/y/issues?state=closed&labels=` filters like GitHub +// (state and one label), and a DELETE removes that label from the item. Every call is recorded. +const fakeGh = (items) => { + const store = items.map(item => ({ ...item, labels: item.labels })) + const calls = [] + const gh = (...args) => { + const path = args[args.length - 1] + calls.push(args.join(' ')) + if (args.includes('DELETE')) { + const m = /\/issues\/(\d+)\/labels\/(.+)$/.exec(path) + const item = store.find(i => String(i.number) === m[1]) + const name = decodeURIComponent(m[2]) + item.labels = item.labels.filter(l => l.name !== name) + return '' + } + const url = new URL('https://api.github.com/' + path) + const label = url.searchParams.get('labels') + const state = url.searchParams.get('state') + return store + .filter(i => (state ? i.state === state : true)) + .filter(i => (label ? i.labels.some(l => l.name === label) : true)) + .map(i => JSON.stringify(i)) + .join('\n') + } + gh.calls = calls + gh.store = store + gh.deletes = () => calls.filter(c => c.includes('DELETE')) + return gh +} + +const run = (gh, apply) => main({ gh, env: { GH_REPO: 'ThinkFlowLab/FlyBot' }, repo: 'ThinkFlowLab/FlyBot', apply }) + +test('#738: a dry run prints what it would do and removes nothing', () => { + const gh = fakeGh([issue(602, 'closed', ['agent-working', 'bug', 'risk:T1'])]) + const r = run(gh, false) + assert.equal(gh.deletes().length, 0, 'a dry run never writes') + assert.equal(r.applied, false) + assert.deepEqual(r.items.map(i => [i.number, i.stale]), [[602, ['agent-working']]]) +}) + +test('#738: a closed issue loses exactly its status labels, and keeps bug and risk:T1', () => { + const gh = fakeGh([issue(602, 'closed', ['agent-working', 'agent-pr', 'bug', 'risk:T1', 'area:automation'])]) + const r = run(gh, true) + assert.equal(r.ok, true) + assert.deepEqual(gh.store[0].labels.map(l => l.name), ['bug', 'risk:T1', 'area:automation']) + assert.equal(gh.deletes().length, 2) + for (const call of gh.deletes()) assert.match(call, /agent-(working|pr)/) +}) + +test('#738: an open issue with a status label is not touched', () => { + const gh = fakeGh([issue(700, 'open', ['agent-working', 'bug'])]) + const r = run(gh, true) + assert.deepEqual(r.items, []) + assert.equal(gh.deletes().length, 0) + assert.deepEqual(gh.store[0].labels.map(l => l.name), ['agent-working', 'bug']) +}) + +test('#738: a merged pull request loses agent-pr', () => { + const gh = fakeGh([{ ...issue(704, 'closed', ['agent-pr', 'bug']), pull_request: { merged_at: '2026-10-22T08:00:00Z' } }]) + const r = run(gh, true) + assert.deepEqual(r.items.map(i => [i.number, i.stale]), [[704, ['agent-pr']]]) + assert.deepEqual(gh.store[0].labels.map(l => l.name), ['bug']) +}) + +test('#738: an issue with only kept labels produces no removal call', () => { + const gh = fakeGh([issue(710, 'closed', ['bug', 'area:ui', 'risk:T2', 'status:done'])]) + const r = run(gh, true) + assert.deepEqual(r.items, []) + assert.equal(gh.deletes().length, 0, 'a kept label is never removed') + // Two read passes and nothing else: one list call per status label to find the work, and the same + // again to read the repository back after applying. A DELETE here would be the bug this pins. + assert.equal(gh.calls.length, 2 * STALE_LABELS.length, 'list calls only, one per status label per pass') + for (const call of gh.calls) assert.match(call, /issues\?state=closed&labels=/, 'no write before or after') +}) + +test('#738: a second run on a clean repository reports 0 items and writes nothing', () => { + const gh = fakeGh([issue(602, 'closed', ['agent-working', 'bug'])]) + run(gh, true) + const before = gh.calls.length + const r = run(gh, true) + assert.equal(r.ok, true) + assert.deepEqual(r.items, []) + assert.equal(gh.deletes().length, 1, 'the first run removed one label, the second none') + assert.ok(gh.calls.length > before) +}) + +test('#738: the plan dedupes an item that carries two status labels, and only counts closed ones', () => { + const pages = [issue(1, 'closed', ['agent-working', 'agent-pr']), issue(1, 'closed', ['agent-working']), issue(2, 'open', ['agent-pr'])] + assert.deepEqual(plan(pages).map(i => [i.number, i.stale]), [[1, ['agent-working', 'agent-pr']]]) + assert.equal(planItem(issue(3, 'closed', ['bug'])).stale.length, 0) +}) + +test('#738: the keep-list is a closed set, so a new label is not removed by accident', () => { + // Only these five are ever removed; everything else is a record (bug, area:*, risk:*, hold, needs-*, + // status:done). A status label added to the list without a test would widen the blast radius. + assert.deepEqual(STALE_LABELS, ['agent-working', 'agent-pr', 'agent-ready', 'agent-task', 'awaiting-approval']) + for (const keep of ['bug', 'area:ui', 'risk:T3', 'hold', 'needs-human', 'status:done']) assert.equal(STALE_LABELS.includes(keep), false) +}) diff --git a/.github/workflows/label-hygiene.yml b/.github/workflows/label-hygiene.yml new file mode 100644 index 000000000..25856fbc0 --- /dev/null +++ b/.github/workflows/label-hygiene.yml @@ -0,0 +1,40 @@ +name: Label hygiene + +# Status labels must not outlive the item (#738): a closed issue or a merged pull request that still +# carries `agent-working`/`agent-pr`/`agent-ready`/`agent-task`/`awaiting-approval` makes the board +# count finished work as in flight. This job removes exactly those labels and nothing else, and it +# prints `before:`/`after:` counts so the cleanup is verified rather than trusted. +# +# `hold` is untouched: only a human removes it (AGENTS.md → Merge gates). +on: + schedule: + - cron: '30 19 * * *' # 03:30 Asia/Shanghai, a quiet hour + workflow_dispatch: + +concurrency: + group: label-hygiene + cancel-in-progress: false + +permissions: + contents: read + issues: write + pull-requests: write + +jobs: + label-hygiene: + name: label-hygiene + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: main + persist-credentials: false + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: 24 + - name: Strip stale status labels + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + run: node .github/scripts/label-hygiene.mjs --apply diff --git a/docs/autonomous-dev.md b/docs/autonomous-dev.md index 44154960b..44fc6f638 100644 --- a/docs/autonomous-dev.md +++ b/docs/autonomous-dev.md @@ -207,6 +207,16 @@ skipped the queue prints as `bypassed queue:` and fails the run while an older o not — the watermark lives in the Actions API and nothing is committed. Its first run passes `--baseline`, so the first green run is the baseline and only a newer bypass turns it red ([#697](https://github.com/ThinkFlowLab/FlyBot/issues/697)). +**The patrol that keeps the labels honest.** A status label says what is happening to an item *now*, so it must not +outlive the item: on 2026-10-22 the board counted 11 closed issues and 43 closed pull requests as in flight, because +nothing removed `agent-working`/`agent-pr` when they closed. `.github/workflows/label-hygiene.yml` runs +`label-hygiene.mjs` daily and on `workflow_dispatch`, and it removes exactly the status labels in its `STALE_LABELS` +list — `bug`, `area:*`, `risk:*`, `hold`, `needs-*` and `status:done` are records and it never touches them, because +`hold` stays human-only in both directions. It asks once per status label (GitHub's `labels=` filter is an AND, so one +query for five labels would only find an item carrying all five), prints the `before:` count, applies, then reads the +repository back and prints `after:` — a non-zero `after:` fails the run, so the cleanup is verified rather than +trusted ([#738](https://github.com/ThinkFlowLab/FlyBot/issues/738)). + **What the trigger does.** A merge queue builds each entry on a `gh-readonly-queue/main/pr-N-` branch (a merge of the PR onto the current `main`, plus the entries ahead of it) and merges only when the required checks pass on that commit. Required checks are matched by name on that commit, so `Test` must run on `merge_group`; otherwise `tests-ok`