Add PR triage skills: status labeller + Mon/Wed/Fri digest - #1090
Add PR triage skills: status labeller + Mon/Wed/Fri digest#1090daimon-pymclabs wants to merge 8 commits into
Conversation
Introduces a two-layer, agent-assisted PR triage system to help work down the review backlog without removing human judgment from reviews. Layer 1 - shared deterministic core (.agents/skills/_shared/pr_triage/labels.py): single source of truth that fetches open PRs via `gh`, computes orthogonal per-PR facts (lifecycle, conflict, CI, review, decision-needed, risk, idle-band, author-class), and derives one next-action per PR via an explicit, tunable precedence order. No LLM judgment; pure function of the GitHub API. Layer 2 - two thin skills that consume the same core so they can never disagree: - pr-status-labeller: reconciles a `status:*` label namespace on GitHub (conflicting, ci-failing, waiting-on-author, ready-for-review, aging, stale). Dry-run by default; touches only status:* labels, never human-owned ones. - pr-digest: read-only Mon/Wed/Fri digest for Discord. Surfaces exactly one hard decision per run (anti-choice-overload), a short clean-review queue, and collapses everything else as "no action needed". Recommends only; never writes to GitHub. Idle thresholds: aging at 30d, stale at 90d. The digest recomputes state live rather than trusting the labeller's last run, so a missed labeller run degrades GitHub visibility but never corrupts the digest. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
PR SummaryMedium Risk Overview
The core deliberately avoids Reviewed by Cursor Bugbot for commit b3645e9. Bugbot is set up for automated code reviews on this repo. Configure here. |
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #1090 +/- ##
=======================================
Coverage 95.98% 95.98%
=======================================
Files 104 104
Lines 16277 16277
Branches 912 912
=======================================
Hits 15623 15623
Misses 488 488
Partials 166 166 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
- ruff E741/E731: rename ambiguous `l` loop vars, replace lambda-assign with def - Make NEXT_ACTION_ORDER load-bearing: CLI table now sorts by its rank, not alphabetically - Guard emitted status labels against STATUS_LABELS; tie LABEL_DEFS to it so they can't drift - Exclude hard-item PRs (lead + queued) from quick reviews to stop double-counting Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Pushed
|
_next_action now walks NEXT_ACTION_ORDER and returns the first action whose predicate holds (true first-match-wins), so the precedence list is genuinely the single source of truth for both classification and the CLI/consumer sort. Verified behavior-identical to the old if/elif chain across all 576 input combinations. Adds a drift guard asserting the predicate map matches the order list, and corrects the NEXT_ACTION_ORDER docstring. Findings 1 (quick-review double-count) and 3 (STATUS_LABELS dead constant) were already resolved in the prior BugBot pass (1058868); left unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Addressed the BugBot findings (commit e26d69f): Finding 2 (MEDIUM — Finding 1 (MEDIUM — quick-review double-count) — already resolved (no change needed). Finding 3 (LOW — No unit tests exist for these scripts; validated via the exhaustive equivalence check above plus |
BugBot (High): idle_days/idle_band were derived from GitHub's `updatedAt`, which a label-only `gh pr edit` bumps. So each `--apply` run of the labeller reset the idle clock, and the next run saw a "fresh" PR and stripped the `status:aging`/`status:stale` label it had just added; the aging/stale state could never persist across scheduled applies, and the live idle figures the digest reads were corrupted. Fix, in the shared core (labels.py) so digest and labeller stay consistent: - Add `_last_activity(pr)`: the max of PR creation, commit push times, and comment timestamps. None of these are moved by a label edit, so idleness is now stable across labeller runs. - `idle_days` is computed from `_last_activity(pr)` instead of `updatedAt`. - Fetch `comments` in the bulk `pr list`, and fetch `commits` per-PR in the existing `gh pr view` poke loop. `commits` cannot go in the bulk list: each commit carries an authors connection, which pushes a 200-PR listing past GitHub's 500k GraphQL node limit. The per-PR fetch piggy-backs on the same view call already used to resolve lazy `mergeable`, so it adds no extra call. CI (prek): the `ruff-format` hook was red on this branch. Applied ruff-format to labels.py; `prek run` on all PR-touched files is now green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Addressed the BugBot idle-timer finding and the failing prek check (commit c62eb0d). BugBot (High): label writes reset the idle timer. FIXED. Fix lives in the shared core (
Verified end to end against the live repo: prek CI. FIXED. |
Automated triageRecommendation: Why:
Review focus:
Confidence: medium — the PR adds non-trivial tooling that needs a maintainer to decide on direction before a code review can settle it. |
- CI: ignore CANCELLED/SKIPPED/NEUTRAL (cancel-in-progress concurrency leaves superseded runs as CANCELLED, which was marking green PRs red) - CI: treat STARTUP_FAILURE as a failure, not a green check - Add a mergeability-unknown next_action so PRs whose mergeable never resolves are not advertised as clean review targets, plus a digest bucket so they don't silently vanish - Count reviews in the idle calculation so a just-reviewed PR can't be labelled aging/stale Co-Authored-By: Claude <noreply@anthropic.com>
|
Addressed the 4 remaining BugBot findings in c62eb0d..HEAD:
|
The per-PR `gh pr view` was wrapped in contextlib.suppress, so a transient failure left `commits`/`reviews` unset and classification carried on. `_last_activity` then fell back to creation time plus issue comments, which overstates idleness for any PR whose recent activity was pushes or reviews, and `--apply` could write a wrong `status:aging`/`status:stale`. Retry the view once, warn on stderr if it still fails, flag the PR `detail_incomplete`, and withhold the idle-derived labels for it.
|
Addressed the BugBot finding Silent fetch failure corrupts idle (high severity) in e676d44. The per-PR Now: retry the view once (these are usually rate-limit/network blips), warn on stderr if it still fails, flag the PR |
Documentation build overview
29 files changed ·
|
Two follow-ups to e676d44, both correct. Withholding the idle labels was worse than the bug it fixed: the labeller reconciles by set difference, so omitting `status:aging`/`status:stale` reads as a remove, and one unlucky fetch would strip durable idle state. Carry the existing idle labels through instead, so the reconcile is a no-op for them: a failed fetch neither writes a new verdict nor erases the last good one. `_next_action` still routed drafts by `idle_band`, so the digest could call a draft stale on the same number the labeller refused to trust. Introduce `idle_band == "unknown"` as the single carrier of "do not act on this": the draft buckets test by equality and now fall through to `in-flight-draft`, and the label derivation branches on it too.
|
Both follow-up findings were right, and the first one is a regression I introduced in e676d44. Fixed in 04834ea. Incomplete fetch strips idle labels (high). Correct, and worse than the bug it was meant to fix. Draft action ignores incomplete idle (medium). Also correct: the labeller refused to trust the number while the digest happily bucketed a draft as stale on it. Rather than add a second Verified across the matrix: healthy stale still labels stale; incomplete with an existing |
Three findings from the latest BugBot pass, all correct. `_ci_state` let a `STALE` conclusion fall through to `green`: it was in neither the ignored set nor the failure set. GitHub does not count a stale required check as successful, so a PR could reach `ready-to-merge` on a check GitHub gave up on. Unlike `CANCELLED`, a `STALE` check is not superseded by a sibling run in the rollup, so it genuinely needs a re-run -- `red` is the right verdict and routes it to `mechanical`. The digest printed `idle_days` as fact in the hard-item line even when `detail_incomplete` was set, i.e. the exact figure the core already refuses to derive labels from. It now renders "idle unknown" there, and the review queue sorts untrusted-idle PRs after the trusted ones in their author group instead of interleaving them on a number nobody should act on. `_fmt` interpolated raw PR titles between `[` and `]`, so a `[WIP]`/`[RFC]` prefix closed the markdown link on the wrong character and corrupted the rest of the line. Titles are now escaped. Co-Authored-By: Claude <noreply@anthropic.com>
BugBot pass addressed — b3645e9Fixed (3)
Rejected as already fixed (2) — both were filed against
All 9 earlier cursor[bot] threads on this PR are resolved. |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 2 potential issues.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit b3645e9. Configure here.
| f["author_class"] != "external", | ||
| f["detail_incomplete"], | ||
| f["idle_days"], | ||
| ), |
There was a problem hiding this comment.
Review queue starves old PRs
Medium Severity
The quick-review list sorts by ascending idle_days, so freshest PRs appear first. Longer-waiting external PRs sink as new ones arrive. The shared CLI table already ranks by descending idle, so the digest backlog order fights the triage goal of working down neglected reviews.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit b3645e9. Configure here.
| or (f["major"] and f["next_action"] == "ready-for-review") | ||
| ) | ||
| and (hard is None or f["number"] != hard["number"]) | ||
| ] |
There was a problem hiding this comment.
Queued decisions out of order
Medium Severity
other_hard keeps gh pr list order (newest created first) instead of sorting by age_days like pick_hard_item. The “queued behind this one” line then shows the newest remaining hard items, not the next-oldest by cost-of-waiting.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit b3645e9. Configure here.


What
A two-layer, agent-assisted PR triage system to help work down the review backlog without removing human judgment from reviews. This is the Tier 1 ("recommend, don't act") slice: the digest is read-only, and the only GitHub writes are a tightly-scoped label namespace behind an explicit
--applyflag.Design: labels, not states
Every open PR carries several orthogonal, non-exclusive facts (it can be conflicting and have changes requested and be stale at once). So the model is a set of independent labels, plus a single derived
next-actioncomputed via an explicit precedence order used only for grouping.Layer 1 — shared deterministic core
.agents/skills/_shared/pr_triage/labels.pyis the single source of truth. It fetches open PRs viagh(forcing the lazily-computedmergeablefield), computes per-PR facts —lifecycle,conflict,ci,review,decision-needed,risk,idle-band,author-class— and derives onenext_action. No LLM judgment; it is a pure function of the GitHub API. All tunables (maintainer/labs handles, idle thresholds, precedence order, managed labels) live at the top of the file.Layer 2 — two thin skills sharing that core
They call the same function, so they can never disagree.
pr-status-labellerreconciles astatus:*label namespace on GitHub (conflicting,ci-failing,waiting-on-author,ready-for-review,aging,stale). Dry-run by default;--applyto write. Touches onlystatus:*— neverreview:*,major,needs:maintainer-decision, or any human-owned label. Drafts stay out of reviewer-facing statuses.pr-digestproduces a read-only Mon/Wed/Fri digest. It surfaces exactly one hard decision per run, a short clean-review queue (external contributors first), and collapses everything else under a "no action needed" divider. It recommends only; it never writes to GitHub.Why one hard item per digest
The digest is shaped to counter decision paralysis, not just capacity: it recommends a default to veto rather than asking an open question, frames each hard item as a small reversible step, and names the cost of not deciding. Surfacing one deep-focus decision per run (rather than the full high-risk list) keeps the queue from being deferred wholesale.
Idle thresholds
Aging nudge at 30d, stale warning at 90d. The digest recomputes state live rather than trusting the labeller's last run, so a missed labeller run degrades GitHub visibility but never corrupts the digest.
Triggering
The logic is trigger-agnostic: an automation can run either skill on a schedule, or a maintainer can run them locally. Only the posting step differs.
Checks run locally
ruff check(clean),ruff format(clean),codespell(clean),pymarkdownagainst the repo config (clean),py_compile, and both scripts smoke-tested against the live repo (42 open PRs classify without error; labeller dry-run produces a sane add/remove diff).Not in this PR
Mechanical execution (auto-nudges, branch syncs) and any agent-authored reviews. Those are later tiers, deliberately gated behind this read-only slice earning trust first.
🤖 Generated with Claude Code