Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 78 additions & 0 deletions .github/scripts/label-hygiene.mjs
Original file line number Diff line number Diff line change
@@ -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) }
}
108 changes: 108 additions & 0 deletions .github/scripts/label-hygiene.test.mjs
Original file line number Diff line number Diff line change
@@ -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=<name>` 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)
})
40 changes: 40 additions & 0 deletions .github/workflows/label-hygiene.yml
Original file line number Diff line number Diff line change
@@ -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
10 changes: 10 additions & 0 deletions docs/autonomous-dev.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-<sha>` 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`
Expand Down
Loading