Skip to content

fix(labels): strip status labels from closed issues and pull requests (#738) - #744

Merged
Birfy merged 1 commit into
mainfrom
fix/738-label-hygiene
Oct 22, 2026
Merged

Birfy merged 1 commit into
mainfrom
fix/738-label-hygiene

Conversation

@Birfy

@Birfy Birfy commented Oct 22, 2026 •

Copy link
Copy Markdown
Collaborator

Written-by: Dev2

Fixes #738

Tip SHA: b4c4ff2

Reproduce

A status label says what is happening to an item now, but nothing removed one when the item closed: on 2026-10-22 the board counted 11 closed issues (agent-working/agent-pr) and 43 closed pull requests (agent-pr) as in flight, and #718 closed while still carrying agent-working. Triage cleaned those 12 by hand; this replaces the hand cleanup with a run.

The reproduction is a query, not a scenario: GET /repos/ThinkFlowLab/FlyBot/issues?state=closed&labels=agent-pr returned 43 merged pull requests on that morning. The same live query is what the new script runs — see What I ran.

What changed

  • .github/scripts/label-hygiene.mjs (new): STALE_LABELS = ['agent-working', 'agent-pr', 'agent-ready', 'agent-task', 'awaiting-approval'], a closed set. planItem() keeps only items whose state is not open and removes only labels from that set; plan() dedupes by number, because the item with two status labels is returned by two queries.
    • One list call per status label. GitHub's labels= filter is an AND: labels=a,b returns items carrying both, so one query for five labels would find almost nothing. Five queries, dedupe, one removal call per (item, label).
    • Dry run is the default; --apply is what writes. Read back after writing: it re-runs the same queries and prints after: N item(s), and a non-zero after: returns ok: false / exit 1 — the run reports what it achieved instead of trusting its own DELETE calls.
    • main({ gh, env, repo, apply }) takes the gh runner and apply as parameters, the way merge-gate.mjs does, so the whole run is testable without network or credentials.
  • .github/workflows/label-hygiene.yml (new): schedule '30 19 * * *' (03:30 Asia/Shanghai, a quiet hour) plus workflow_dispatch; permissions: contents: read, issues: write, pull-requests: write; pinned actions/checkout and actions/setup-node; runs node .github/scripts/label-hygiene.mjs --apply.
  • .github/scripts/label-hygiene.test.mjs (new): 8 cases over a fake gh whose fixtures are real API pages — dry run writes nothing, a closed issue loses exactly its status labels and keeps bug/risk:T1/area:*, an open issue is untouched, a merged PR loses agent-pr, an item with only kept labels produces no removal call at all, a second run on a clean repository reports 0, plan() dedupes and only counts closed items, and the keep-list is asserted as a closed set so widening the blast radius needs a test change.
  • docs/autonomous-dev.md: one paragraph next to the audit-merges patrol, saying what this removes, what it never touches, and why hold stays out.

bug, area:*, risk:*, hold, needs-* and status:done are records and are never removed. No changelog fragment: nothing users can see changes (same as #697).

What I ran

At this tip b4c4ff2c06d2a06e319f2a55f3fd7e5f5bed092e (base 7af7c948, 4 files, +236/−0):

Red without the source change

  • Force closed: true in planItem (drop the state !== 'open' guard) → 7/8, the case asserting "an open issue is not touched" fails.
  • Drop the STALE_LABELS filter in planItem (remove every label) → 3/8: dry run, close-keeps-records, merged PR, second run and the kept-labels case all fail.
  • Delete the if (!apply) continue guard (a dry run writes) → 7/8, the dry-run case fails.
  • All three restored → 8/8.

Not run

  • The workflow itself has never run on Actions — it does not exist on main yet, so there is no run id, no cron tick and no gh workflow run to point at. The first scheduled tick is 03:30 Asia/Shanghai after merge; workflow_dispatch can fire it earlier.
  • actionlint is not installed on this box; the YAML parse and the comparison against the existing workflows stand in for it.
  • npm run verify as a whole and the JVM-free package job — untouched paths, no root workspace installed here.
  • The real gh binary is not on this box, so realGh was not executed; the API path was exercised through fetch with the same URLs.

Risk

T3: .github/** is T3 in .github/agent/risk-rules.json, and the diff matches its child_process pattern. Green tests-ok at the tip plus a PASS at this tip, then the queue — no approval click needed (user decision 2026-10-22).

What the write can do: nothing but DELETE /issues/<n>/labels/<name> for a label in STALE_LABELS, on an item whose state is not open. The set is closed and asserted by a test, so a new label cannot start being removed by accident; hold is not in it in either direction, and the job holds issues: write only — no contents, no actions, no secrets. The read-back is what I would trust: if a removal silently fails, the run goes red instead of reporting success.

Two judgement calls worth a reviewer's eye: agent-task and awaiting-approval are in the set. awaiting-approval is retired (#709 dropped its definition), so removing a leftover costs nothing; agent-task is controller-managed per AGENTS.md but .github/scripts/controller.mjs never adds or removes it (its sets are agent-pr, agent-ready, agent-working, risk:T*), so removing it on closed items creates no fight with the poll. If either should stay on a closed item, delete it from the one array and the closed-set test fails until it is described.

Scheduling risk: GitHub delays scheduled runs (2.5–6 h observed here, #629), so the cleanup happens hours after a close, not at it. That is the trade for not putting a write step on every merge; the enqueue guard and the PR closing path are untouched.

A status label says what is happening to an item now, so leaving it on a
closed issue or a merged pull request makes the board count finished work
as in flight: on 2026-10-22 that was 11 closed issues and 43 closed pull
requests.

`label-hygiene.mjs` lists closed items once per status label (the `labels=`
filter is an AND), removes only the five status labels in `STALE_LABELS`,
and reads the repository back afterwards so the run reports what it
actually achieved instead of trusting its own writes. `bug`, `area:*`,
`risk:*`, `hold`, `needs-*` and `status:done` are records and are never
touched; a dry run is the default and `--apply` is what writes.

The workflow runs it daily and on `workflow_dispatch` with issues: write.
@Birfy Birfy added the agent-pr Pull request opened by an agent label Oct 22, 2026
@Birfy Birfy self-assigned this Oct 22, 2026
@Birfy

Birfy commented Oct 22, 2026

Copy link
Copy Markdown
Collaborator Author

Verdict: PASS at b4c4ff2
Written-by: Reviewer

The script does what the issue asks and is safe to run unattended: it removes only labels from a closed set, it never touches hold, and it verifies its own work instead of assuming it.

What I checked in the code:

  • The removal set is closed (STALE_LABELS), matched case-insensitively, and everything else is left alone — bug, area:*, risk:*, needs-*, hold. A label nobody listed cannot be removed by accident, and case 8 pins that.
  • One list call per label is correct, not a workaround: GitHub's labels= on /issues is an AND, so a single call for five labels would find only items carrying all five. Dedupe by number is what keeps an item with two status labels from being planned twice (case 7).
  • --apply is opt-in, dry run is the default, and after writing it re-runs the same queries: after: N non-zero returns ok: false and exit 1, so a partial cleanup fails the run loudly.
  • The workflow uses github.token with issues: write only, checks out main by pinned action SHAs, and runs at 03:30 Asia/Shanghai; hold stays human-only, as the comment says.
  • list() parses --jq '.[]' line by line, so /issues returning both issues and merged pull requests is intended and covered (case 4).

Ran: (tip b4c4ff2c, TZ=UTC)

node --test .github/scripts/label-hygiene.test.mjs
  -> # tests 8 / # pass 8 / # fail 0

Red, two cuts (each restored):

planItem closed filter forced to `true`              -> 7/8 (case 7: an open item must not be planned)
dedupe removed (last entry wins per number)          -> 7/8 (case 7: two status labels on one item)

Not run: the live gh path against this repository (no gh in this sandbox); the whole .github/scripts suite is otherwise unchanged by this PR.

Non-blocking:

  1. If one DELETE throws mid-run, the loop aborts: the labels already removed are not summarised, and the remaining items are not attempted. A per-item try/catch with a final "removed N, failed M" would leave a better trace than a bare exception — the exit code is already honest either way.
  2. closed: item.state !== 'open' is defence in depth on top of state=closed in the query; harmless, and useful if the query ever changes.
  3. The dry run always reports ok: true, so a manual dry run can never fail a job. That reads as intended, but say so in the header comment if you want it to stay that way.

@Birfy
Birfy added this pull request to the merge queue Oct 22, 2026
Merged via the queue into main with commit c7eb098 Oct 22, 2026
9 checks passed
@Birfy
Birfy deleted the fix/738-label-hygiene branch October 22, 2026 10:02
@Birfy Birfy removed the agent-pr Pull request opened by an agent label Oct 22, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Strip stale agent-* labels mechanically instead of by hand

1 participant