From 28021f01fc0d90b76a058b864a3b8517987365c4 Mon Sep 17 00:00:00 2001 From: daimon Date: Mon, 27 Jul 2026 20:53:33 +0000 Subject: [PATCH 1/8] Add PR triage skills: status labeller + Mon/Wed/Fri digest 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) --- .agents/skills/_shared/pr_triage/README.md | 24 ++ .agents/skills/_shared/pr_triage/labels.py | 318 ++++++++++++++++++ .agents/skills/pr-digest/SKILL.md | 64 ++++ .agents/skills/pr-digest/resources/format.md | 53 +++ .../skills/pr-digest/scripts/build_digest.py | 208 ++++++++++++ .agents/skills/pr-status-labeller/SKILL.md | 51 +++ .../scripts/reconcile_labels.py | 146 ++++++++ 7 files changed, 864 insertions(+) create mode 100644 .agents/skills/_shared/pr_triage/README.md create mode 100644 .agents/skills/_shared/pr_triage/labels.py create mode 100644 .agents/skills/pr-digest/SKILL.md create mode 100644 .agents/skills/pr-digest/resources/format.md create mode 100644 .agents/skills/pr-digest/scripts/build_digest.py create mode 100644 .agents/skills/pr-status-labeller/SKILL.md create mode 100644 .agents/skills/pr-status-labeller/scripts/reconcile_labels.py diff --git a/.agents/skills/_shared/pr_triage/README.md b/.agents/skills/_shared/pr_triage/README.md new file mode 100644 index 000000000..b2e28ea35 --- /dev/null +++ b/.agents/skills/_shared/pr_triage/README.md @@ -0,0 +1,24 @@ +# Shared PR triage core + +`labels.py` is the single deterministic source of truth for CausalPy PR triage. +It fetches open PRs via `gh`, computes a set of orthogonal facts per PR +(lifecycle, conflict, CI, review, decision-needed, risk, idle-band, +author-class), and derives one `next_action` per PR via an explicit precedence +order. + +Two skills consume it, and consume the *same* function so they can never +disagree: + +- `pr-status-labeller` — writes `status:*` labels to GitHub. +- `pr-digest` — produces the read-only Mon/Wed/Fri Discord digest. + +No LLM judgment lives here. All configuration (maintainer/labs handles, idle +thresholds, precedence order, managed label set) is at the top of `labels.py`; +edit it there and nowhere else. + +Run standalone to inspect classification: + +```bash +python labels.py # human-readable table +python labels.py --json # structured output +``` diff --git a/.agents/skills/_shared/pr_triage/labels.py b/.agents/skills/_shared/pr_triage/labels.py new file mode 100644 index 000000000..99477b9d6 --- /dev/null +++ b/.agents/skills/_shared/pr_triage/labels.py @@ -0,0 +1,318 @@ +# Copyright 2022 - 2026 The PyMC Labs Developers +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Deterministic PR triage core for CausalPy. + +Single source of truth for "what are the facts about each open PR". Both the +`pr-status-labeller` skill (writes `status:*` labels to GitHub) and the +`pr-digest` skill (Mon/Wed/Fri Discord digest) call this module so the two +never disagree. No LLM judgment lives here: everything is a pure function of +the GitHub API. The only judgment layer is the prose/framing the digest agent +adds on top of this structured output. + +Usage: + python labels.py [--repo OWNER/NAME] [--json] + +Requires the `gh` CLI authenticated with read access to the repo. +""" + +from __future__ import annotations + +import argparse +import contextlib +import datetime as _dt +import json +import subprocess +import sys +from dataclasses import asdict, dataclass, field + +# --- Configuration (edit here, nowhere else) -------------------------------- + +DEFAULT_REPO = "pymc-labs/CausalPy" + +# Author classification. Everyone not listed and not a bot is "external". +MAINTAINERS = {"drbenvincent"} +LABS = { + "NathanielF", + "juanitorduz", + "cetagostini", + "twiecki", + "ricardoV94", + "OriolAbril", + "daimon-pymclabs", # labs automation account (still bot-like, see BOT rule) +} + +# Idle-band thresholds, in days. Confirmed with maintainer: +# nudge (aging) at 30d, stale warning at 90d. +AGING_DAYS = 30 +STALE_DAYS = 90 + +# Precedence order for the single derived `next_action`. First match wins. +# This is the ONE place priority judgment is encoded; tune it here. +NEXT_ACTION_ORDER = [ + "decision", # needs a maintainer decision (the hard queue) + "stale-draft", # your own draft, idle >= STALE_DAYS + "aging-draft", # your own draft, idle >= AGING_DAYS + "in-flight-draft", # fresh draft, WIP, silent + "waiting-on-author", # changes requested, ball in author's court + "ready-to-merge", # approved + clean + not red + "mechanical", # conflicting or CI red (agent-fixable in Tier 2) + "ready-for-review", # clean, green/pending, awaiting a reviewer +] + +# The `status:*` labels the labeller is allowed to manage. It touches ONLY this +# namespace, never `review:*`, `major`, `needs:maintainer-decision`, etc. +STATUS_LABELS = { + "status:conflicting", + "status:ci-failing", + "status:waiting-on-author", + "status:ready-for-review", + "status:aging", + "status:stale", +} + +# ----------------------------------------------------------------------------- + + +def _now() -> _dt.datetime: + return _dt.datetime.now(_dt.UTC) + + +def _parse_ts(ts: str) -> _dt.datetime: + return _dt.datetime.fromisoformat(ts) + + +def _days_since(ts: str) -> int: + return (_now() - _parse_ts(ts)).days + + +def _gh_json(args: list[str]) -> object: + out = subprocess.run( + ["gh", *args], capture_output=True, text=True, check=True + ).stdout + return json.loads(out) if out.strip() else None + + +def fetch_prs(repo: str) -> list[dict]: + """Fetch open PRs. Forces GitHub to compute the lazily-evaluated + `mergeable` field, which comes back UNKNOWN in bulk listings.""" + fields = ( + "number,title,author,isDraft,mergeable,reviewDecision," + "updatedAt,createdAt,labels,statusCheckRollup" + ) + prs = ( + _gh_json( + [ + "pr", + "list", + "--repo", + repo, + "--state", + "open", + "--limit", + "200", + "--json", + fields, + ] + ) + or [] + ) + # Poke each UNKNOWN mergeable once; `gh pr view` triggers the computation. + for pr in prs: + if pr.get("mergeable") == "UNKNOWN": + with contextlib.suppress(subprocess.CalledProcessError): + v = _gh_json( + [ + "pr", + "view", + str(pr["number"]), + "--repo", + repo, + "--json", + "mergeable", + ] + ) + if v: + pr["mergeable"] = v.get("mergeable", "UNKNOWN") + return prs + + +def _ci_state(pr: dict) -> str: + rollup = pr.get("statusCheckRollup") or [] + states = [c.get("conclusion") or c.get("state") for c in rollup] + if not states: + return "none" + bad = {"FAILURE", "ERROR", "TIMED_OUT", "CANCELLED", "ACTION_REQUIRED"} + if any(s in bad for s in states): + return "red" + pending = {"PENDING", "IN_PROGRESS", "QUEUED", "EXPECTED", None} + if any(s in pending for s in states): + return "pending" + return "green" + + +def _author_class(login: str) -> str: + if login in MAINTAINERS: + return "maintainer" + if "[bot]" in login or login.startswith("app/") or login.endswith("-pymclabs"): + return "bot" + if login in LABS: + return "labs" + return "external" + + +def _idle_band(idle: int) -> str: + if idle >= STALE_DAYS: + return "stale" + if idle >= AGING_DAYS: + return "aging" + return "fresh" + + +@dataclass +class PRFacts: + number: int + title: str + author: str + url: str + author_class: str + lifecycle: str # draft | ready + conflict: str # clean | conflicting | unknown + ci: str # green | red | pending | none + review: str # none | required | changes-requested | approved + decision_needed: bool + major: bool + risk: str | None # high | medium | low | None + idle_days: int + age_days: int + idle_band: str # fresh | aging | stale + next_action: str + status_labels: list[str] = field(default_factory=list) + + +def _review_state(reviewDecision: str | None) -> str: + return { + None: "none", + "REVIEW_REQUIRED": "required", + "CHANGES_REQUESTED": "changes-requested", + "APPROVED": "approved", + }.get(reviewDecision, "none") + + +def _next_action(f: dict) -> str: + draft = f["lifecycle"] == "draft" + if f["decision_needed"]: + return "decision" + if draft: + if f["idle_band"] == "stale": + return "stale-draft" + if f["idle_band"] == "aging": + return "aging-draft" + return "in-flight-draft" + if f["review"] == "changes-requested": + return "waiting-on-author" + if f["review"] == "approved" and f["conflict"] == "clean" and f["ci"] != "red": + return "ready-to-merge" + if f["conflict"] == "conflicting" or f["ci"] == "red": + return "mechanical" + return "ready-for-review" + + +def _status_labels_for(f: dict) -> list[str]: + """Derive the `status:*` label set. Drafts stay out of the reviewer-facing + statuses; they only carry aging/stale flags.""" + labels: list[str] = [] + draft = f["lifecycle"] == "draft" + if f["idle_band"] == "stale": + labels.append("status:stale") + elif f["idle_band"] == "aging": + labels.append("status:aging") + if not draft: + if f["conflict"] == "conflicting": + labels.append("status:conflicting") + if f["ci"] == "red": + labels.append("status:ci-failing") + if f["review"] == "changes-requested": + labels.append("status:waiting-on-author") + if f["next_action"] == "ready-for-review": + labels.append("status:ready-for-review") + return labels + + +def classify(prs: list[dict]) -> list[PRFacts]: + results: list[PRFacts] = [] + for pr in prs: + login = pr["author"]["login"] + names = [l["name"] for l in pr.get("labels", [])] + risk = next( + (n.split(":", 1)[1] for n in names if n.startswith("review:")), None + ) + merge = pr.get("mergeable", "UNKNOWN") + conflict = {"CONFLICTING": "conflicting", "MERGEABLE": "clean"}.get( + merge, "unknown" + ) + f = { + "number": pr["number"], + "title": pr["title"], + "author": login, + "url": f"https://github.com/{REPO}/pull/{pr['number']}", + "author_class": _author_class(login), + "lifecycle": "draft" if pr["isDraft"] else "ready", + "conflict": conflict, + "ci": _ci_state(pr), + "review": _review_state(pr.get("reviewDecision")), + "decision_needed": "needs:maintainer-decision" in names, + "major": "major" in names, + "risk": risk, + "idle_days": _days_since(pr["updatedAt"]), + "age_days": _days_since(pr["createdAt"]), + } + f["idle_band"] = _idle_band(f["idle_days"]) + f["next_action"] = _next_action(f) + f["status_labels"] = _status_labels_for(f) + results.append(PRFacts(**f)) + return results + + +REPO = DEFAULT_REPO + + +def main() -> int: + global REPO + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--repo", default=DEFAULT_REPO) + ap.add_argument("--json", action="store_true", help="emit JSON to stdout") + args = ap.parse_args() + REPO = args.repo + facts = classify(fetch_prs(REPO)) + payload = { + "repo": REPO, + "generated_at": _now().isoformat(), + "open_count": len(facts), + "prs": [asdict(f) for f in facts], + } + if args.json: + json.dump(payload, sys.stdout, indent=2) + print() + else: + for f in sorted(facts, key=lambda x: (x.next_action, -x.idle_days)): + print( + f"#{f.number:<5} {f.next_action:<18} idle{f.idle_days:>4}d " + f"{f.author_class:<10} {f.title[:55]}" + ) + print(f"\n{len(facts)} open PRs") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.agents/skills/pr-digest/SKILL.md b/.agents/skills/pr-digest/SKILL.md new file mode 100644 index 000000000..7a576a985 --- /dev/null +++ b/.agents/skills/pr-digest/SKILL.md @@ -0,0 +1,64 @@ +--- +name: pr-digest +description: Produce the Mon/Wed/Fri CausalPy PR triage digest for Discord — a read-only, recommendation-only summary that surfaces exactly one hard decision, a short clean-review queue, and everything else collapsed as "no action needed". Use when asked to generate the PR digest, summarize the PR backlog for a maintainer, or run the triage recap. +--- + +# PR Digest + +Generate the maintainer-facing PR triage digest. This is **Tier 1**: read-only. +The digest recommends; it never approves, merges, comments, labels, or mutates +GitHub in any way. Every action it names is the maintainer's to take. + +## Boundary + +- This skill only reads and reports. It never writes to GitHub. Writing + `status:*` labels is a separate concern owned by the `pr-status-labeller` + skill; mechanical GitHub actions (nudges, branch syncs) are Tier 2 and not + part of this skill. +- Classification is deterministic and lives in + `../_shared/pr_triage/labels.py`. Do not re-implement it or let the model + re-derive states by eye — always run the script so the digest matches the + labeller byte-for-byte. +- The only judgment this skill adds is prose: the framing of the single hard + item and any cross-PR clustering insight. Everything else is templated. + +## Workflow + +1. Run the builder: + `python scripts/build_digest.py --day --mention ` + It calls the shared core live (so it is never stale), selects the one hard + item, groups the rest, and emits JSON with a ready-to-post `markdown` field + plus a `hard_item` payload. +2. Fill the `{{HARD_ITEM_FRAMING}}` placeholder in `markdown` following + `resources/format.md`. This is the one place light judgment is required. +3. Post the completed markdown to the CausalPy Discord channel, tagging the + maintainer. Do nothing else — no GitHub writes. + +## The one-hard-item rule + +Confirmed ceiling: surface exactly **one** deep-focus decision per digest. +Maintainers realistically do no more than one per day, so a longer list just +gets deferred wholesale (choice overload). The builder picks it deterministically +(oldest decision-needed or major-ready PR); remaining hard items are listed as +"queued behind this one" so they are visible but not demanded today. + +## Framing the hard item (the anti-paralysis layer) + +The research this is built on: choice overload, the default effect, omission +bias / loss aversion, and implementation intentions. The digest counteracts +decision paralysis by three moves, all applied in `resources/format.md`: + +- **Recommend a default, don't ask an open question.** The maintainer vetoes or + adjusts a proposed direction rather than deciding from scratch. +- **Frame every hard item as a reversible, small next step**, never a terminal + architectural commitment. "State a direction so contributors can move" beats + "decide the API forever". +- **Name the cost of *not* deciding** (contributor idle days, downstream + blockage), so loss aversion pushes toward action, not just away from a wrong + call. + +## Cadence + +Monday, Wednesday, Friday. Triggered either by a Daimon automation that runs +this skill and posts, or by a maintainer running it locally. The logic is +identical across triggers; only the posting step differs. diff --git a/.agents/skills/pr-digest/resources/format.md b/.agents/skills/pr-digest/resources/format.md new file mode 100644 index 000000000..743c30232 --- /dev/null +++ b/.agents/skills/pr-digest/resources/format.md @@ -0,0 +1,53 @@ +# Digest format and the hard-item framing + +The builder emits nearly-complete markdown. Your only writing task is the +`{{HARD_ITEM_FRAMING}}` block for the single hard item. Keep everything else +as the builder produced it. + +## Structure (builder-owned, do not restructure) + +1. Header + one-line "today you have N things" summary + the standing note that + the digest never acts on its own. +2. 🎯 **One decision today** — the single hard item (you frame this). +3. 🟢 Approved, needs a merge click (only if any). +4. ✅ Quick reviews — clean reads, external contributors first. +5. ━━━ divider ━━━ then the FYI zone: waiting-on-author, mechanical, + stale/aging drafts, in-flight drafts. All counts + links, no action. + +## Writing the hard item + +Aim for three short paragraphs. Apply all three anti-paralysis moves. + +1. **Context + why it's stuck.** Explain what the PR(s) actually decide and why + it has stalled. If several PRs are one underlying decision (e.g. packaging: + pixi + conda-lock + flit), cluster them explicitly — the builder only names + one lead PR, so you add the "these three are really one decision" insight + and link the others. +2. **Recommended direction (veto or adjust).** State a concrete default the + maintainer can accept in one word or override. Not an open question. +3. **Reversible + cost of waiting.** Say plainly that the step is small and + reversible (stating a direction, not merging), then quantify the cost of + inaction (contributor idle days, downstream PRs blocked). + +### Example (packaging cluster) + +> **Packaging & env tooling: pick a coherent direction.** #239 (setuptools → +> flit), #412 (add pixi) and #281 (conda-lock) all poke the same packaging +> surface, and that coupling is why each has sat for years — no reviewer can +> safely decide their piece without the whole picture. +> +> **Recommended direction (veto or adjust):** adopt pixi as the dev-env + lock +> solution, decline conda-lock as redundant once pixi lands, and evaluate the +> flit backend swap separately since it's independent. Comment that direction +> on a new `packaging strategy` decision issue and link all three. +> +> **Reversible and small.** You're not merging anything today — you're stating +> a direction so three contributors can move, and it can still be revised +> before any merge. Cost of not doing it: contributors idle up to ~3 years, and +> every future packaging PR inherits the same paralysis. + +## Tone + +Direct, concrete, no filler. The maintainer reads this three times a week; it +must stay skimmable. The FYI zone is meant to be skipped — do not editorialize +it. diff --git a/.agents/skills/pr-digest/scripts/build_digest.py b/.agents/skills/pr-digest/scripts/build_digest.py new file mode 100644 index 000000000..9c5172c0e --- /dev/null +++ b/.agents/skills/pr-digest/scripts/build_digest.py @@ -0,0 +1,208 @@ +# Copyright 2022 - 2026 The PyMC Labs Developers +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Build the Mon/Wed/Fri CausalPy PR digest from the deterministic triage core. + +This produces the *structure and data* of the digest deterministically. It +selects exactly ONE hard item (confirmed ceiling: maintainers realistically do +no more than one deep-focus decision per day) and groups everything else. The +`pr-digest` skill then adds the light judgment layer: the recommended-default +and reversible-fork framing for the single hard item, and any cross-PR +clustering insight (e.g. "these three are one packaging decision"). + +Output is a JSON object with a ready-to-post `markdown` field plus the +`hard_item` payload the agent should flesh out before posting. + +Usage: + python build_digest.py [--repo OWNER/NAME] [--day Mon] [--mention ] +""" + +from __future__ import annotations + +import argparse +import importlib.util +import json +import os +import sys + +_CORE = os.path.join( + os.path.dirname(__file__), "..", "..", "_shared", "pr_triage", "labels.py" +) +_spec = importlib.util.spec_from_file_location("pr_triage_labels", _CORE) +core = importlib.util.module_from_spec(_spec) +sys.modules["pr_triage_labels"] = core # needed so dataclass annotations resolve +_spec.loader.exec_module(core) + + +def _fmt(f: dict) -> str: + return f"[#{f['number']} {f['title'][:60]}]({f['url']})" + + +def _link(f: dict) -> str: + return f"[#{f['number']}]({f['url']})" + + +def pick_hard_item(prs: list[dict]) -> dict | None: + """The single deep-focus item. Candidates are decision-needed PRs and + major PRs that are ready for review. Rank by cost-of-waiting (age), so the + longest-frozen decision leads. Returns None if nothing qualifies.""" + candidates = [ + f + for f in prs + if f["decision_needed"] + or (f["major"] and f["next_action"] == "ready-for-review") + ] + if not candidates: + return None + return max(candidates, key=lambda f: f["age_days"]) + + +def build(payload: dict, day: str, mention: str | None) -> dict: + prs = payload["prs"] + by = lambda a: [f for f in prs if f["next_action"] == a] + + hard = pick_hard_item(prs) + other_hard = [ + f + for f in prs + if ( + f["decision_needed"] + or (f["major"] and f["next_action"] == "ready-for-review") + ) + and (hard is None or f["number"] != hard["number"]) + ] + + reviews = sorted( + by("ready-for-review"), + key=lambda f: (f["author_class"] != "external", f["idle_days"]), + ) + waiting = by("waiting-on-author") + mechanical = by("mechanical") + stale_drafts = by("stale-draft") + by("aging-draft") + in_flight = by("in-flight-draft") + to_merge = by("ready-to-merge") + + m: list[str] = [] + tag = f" <@{mention}>" if mention else "" + m.append(f"**CausalPy PR digest — {day}**{tag}") + m.append( + f"{payload['open_count']} open. **Today: " + f"{'1 decision + ' if hard else ''}{len(reviews)} quick reviews" + f"{f' + {len(to_merge)} ready to merge' if to_merge else ''}.** " + "Everything below the line is tracked and needs nothing from you. " + "This digest never acts on its own — it only recommends." + ) + + if hard: + m.append("\n**🎯 Your one decision today** (~20 min, needs real focus)") + m.append("\n{{HARD_ITEM_FRAMING}}") # agent fills: context, default, cost + m.append( + f"\nLead PR: {_fmt(hard)} ({hard['author']}, " + f"open {hard['age_days']}d, idle {hard['idle_days']}d)." + ) + if other_hard: + queued = ", ".join(_link(f) for f in other_hard[:4]) + m.append( + f"\n*{len(other_hard)} more decision(s) queued behind this one " + f"({queued}). Deliberately not asking today — one hard thing " + "per digest.*" + ) + + if to_merge: + m.append("\n**🟢 Approved, just needs a merge click**") + for f in to_merge: + m.append(f"- {_fmt(f)} ({f['author']})") + + if reviews: + m.append( + "\n**✅ Quick reviews — clean reads, no yak-shaving** " + "(do as many as you have time for, external contributors first)" + ) + for f in reviews: + risk = f" `review:{f['risk']}`" if f["risk"] else "" + ext = " — external" if f["author_class"] == "external" else "" + m.append(f"- {_fmt(f)} ({f['author']}{ext}){risk}") + + m.append("\n━━━━━━━━━━━━━━━━━━━━") + m.append("**Below: tracked, no action needed. Skim or skip.**") + + if waiting: + m.append( + f"\n**⏳ Waiting on authors ({len(waiting)})** — changes " + "requested, ball in their court: " + + ", ".join(_link(f) for f in waiting) + + "." + ) + if mechanical: + m.append( + f"\n**🔧 Mechanical, auto-nudged once Tier 2 is on " + f"({len(mechanical)})** — conflicts or CI red, no judgment " + "needed: " + + ", ".join(_link(f) for f in mechanical[:8]) + + ("…" if len(mechanical) > 8 else "") + + "." + ) + if stale_drafts: + oldest = max(stale_drafts, key=lambda f: f["age_days"]) + m.append( + f"\n**🗂️ Stale/aging drafts — a nudge, not a task " + f"({len(stale_drafts)})** — oldest is {_link(oldest)} " + f"(opened {oldest['age_days']}d ago, idle " + f"{oldest['idle_days']}d). Worth a bulk abandon-or-resume " + "pass some quiet afternoon." + ) + if in_flight: + m.append( + f"\n**🌱 {len(in_flight)} active drafts in flight** — WIP, " + "correctly silent. A draft only surfaces above once it ages " + f"past {core.AGING_DAYS}d idle." + ) + + return { + "day": day, + "markdown": "\n".join(m), + "hard_item": hard, + "other_hard": other_hard, + "counts": { + "open": payload["open_count"], + "reviews": len(reviews), + "waiting": len(waiting), + "mechanical": len(mechanical), + "stale_drafts": len(stale_drafts), + "in_flight": len(in_flight), + "to_merge": len(to_merge), + }, + } + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--repo", default=core.DEFAULT_REPO) + ap.add_argument("--day", default="Today") + ap.add_argument("--mention", default=None, help="Discord user id to tag") + args = ap.parse_args() + core.REPO = args.repo + facts = core.classify(core.fetch_prs(args.repo)) + payload = { + "repo": args.repo, + "open_count": len(facts), + "prs": [core.asdict(f) for f in facts], + } + result = build(payload, args.day, args.mention) + json.dump(result, sys.stdout, indent=2) + print() + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.agents/skills/pr-status-labeller/SKILL.md b/.agents/skills/pr-status-labeller/SKILL.md new file mode 100644 index 000000000..7b285ab08 --- /dev/null +++ b/.agents/skills/pr-status-labeller/SKILL.md @@ -0,0 +1,51 @@ +--- +name: pr-status-labeller +description: Reconcile machine-computed status labels on open CausalPy PRs so the backlog state is visible on GitHub itself. Adds and removes only the `status:*` label namespace to match deterministic reality. Use when asked to refresh PR status labels, sync the triage labels, or run the labeller. +--- + +# PR Status Labeller + +Keep a `status:*` label set on every open PR that mirrors its machine-computed +state, so anyone browsing GitHub sees the same triage picture the Discord +digest shows. + +## Boundary + +- Writes **only** the `status:*` namespace. Never touches `review:*`, `major`, + `needs:maintainer-decision`, or any other human-owned semantic label. +- Never approves, merges, comments, or requests changes. Labels only. +- Never touches `-client` or any repo outside the configured one. +- Classification is the shared deterministic core in + `../_shared/pr_triage/labels.py`. This skill does not decide states; it only + reconciles labels against what the core computes. + +## Managed labels + +`status:conflicting`, `status:ci-failing`, `status:waiting-on-author`, +`status:ready-for-review`, `status:aging` (idle ≥30d), `status:stale` +(idle ≥90d). Drafts stay out of the reviewer-facing statuses; they only carry +`status:aging` / `status:stale` so WIP is never nagged. + +## Workflow + +1. Dry-run first, always: + `python scripts/reconcile_labels.py --repo pymc-labs/CausalPy` + This prints the exact add/remove diff per PR and writes nothing. +2. Review the diff. If it looks right, apply: + `python scripts/reconcile_labels.py --repo pymc-labs/CausalPy --apply` + `--apply` creates any missing managed labels, then reconciles each PR. + +## Relationship to the digest + +This skill and `pr-digest` share one core so they can never disagree. The +digest recomputes state live rather than reading these labels, so a missed +labeller run degrades GitHub visibility but never corrupts the digest. Run the +labeller on whatever cadence keeps GitHub fresh (e.g. daily); it is independent +of the Mon/Wed/Fri digest cadence. + +## Cadence and safety + +Because it mutates GitHub, treat this as a Tier 2 capability: start by running +the dry-run and eyeballing output for a few days before enabling `--apply` on a +schedule. The write surface is deliberately tiny (six labels, one namespace) so +a bad run is trivially reversible. diff --git a/.agents/skills/pr-status-labeller/scripts/reconcile_labels.py b/.agents/skills/pr-status-labeller/scripts/reconcile_labels.py new file mode 100644 index 000000000..61f573952 --- /dev/null +++ b/.agents/skills/pr-status-labeller/scripts/reconcile_labels.py @@ -0,0 +1,146 @@ +# Copyright 2022 - 2026 The PyMC Labs Developers +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Reconcile `status:*` labels on open CausalPy PRs. + +Deterministic. Calls the shared triage core, then for each PR adds/removes +labels so the live `status:*` set matches computed reality. Touches ONLY the +`status:*` namespace — never `review:*`, `major`, `needs:maintainer-decision`, +or any other semantic label a human owns. + +Dry-run by default. Pass --apply to actually mutate GitHub. + +Usage: + python reconcile_labels.py [--repo OWNER/NAME] [--apply] +""" + +from __future__ import annotations + +import argparse +import contextlib +import importlib.util +import os +import subprocess +import sys + +_CORE = os.path.join( + os.path.dirname(__file__), "..", "..", "_shared", "pr_triage", "labels.py" +) +_spec = importlib.util.spec_from_file_location("pr_triage_labels", _CORE) +core = importlib.util.module_from_spec(_spec) +sys.modules["pr_triage_labels"] = core # needed so dataclass annotations resolve +_spec.loader.exec_module(core) + +# Colour + description for each managed label, created on first apply if absent. +LABEL_DEFS = { + "status:conflicting": ("B60205", "Has merge conflicts with the base branch"), + "status:ci-failing": ("B60205", "Remote CI is red"), + "status:waiting-on-author": ("FBCA04", "Changes requested; awaiting author"), + "status:ready-for-review": ("0E8A16", "Clean and green; awaiting a reviewer"), + "status:aging": ("D4C5F9", f"No activity in >={core.AGING_DAYS}d"), + "status:stale": ("5319E7", f"No activity in >={core.STALE_DAYS}d"), +} + + +def _gh(args: list[str]) -> None: + subprocess.run(["gh", *args], check=True, capture_output=True, text=True) + + +def _label_args(flag: str, labels: set[str]) -> list[str]: + """Flatten a set of labels into repeated `--flag name` CLI args.""" + out: list[str] = [] + for name in labels: + out += [flag, name] + return out + + +def ensure_labels(repo: str) -> None: + for name, (color, desc) in LABEL_DEFS.items(): + with contextlib.suppress(subprocess.CalledProcessError): + _gh( + [ + "label", + "create", + name, + "--repo", + repo, + "--color", + color, + "--description", + desc, + "--force", + ] + ) + + +def current_status_labels(pr: dict) -> set[str]: + return {l["name"] for l in pr.get("labels", []) if l["name"].startswith("status:")} + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--repo", default=core.DEFAULT_REPO) + ap.add_argument( + "--apply", action="store_true", help="mutate GitHub (default: dry-run)" + ) + args = ap.parse_args() + core.REPO = args.repo + + raw = core.fetch_prs(args.repo) + facts = {f.number: f for f in core.classify(raw)} + + if args.apply: + ensure_labels(args.repo) + + changes = 0 + for pr in raw: + n = pr["number"] + have = current_status_labels(pr) + want = set(facts[n].status_labels) + add, remove = want - have, have - want + if not add and not remove: + continue + changes += 1 + verb = "APPLY" if args.apply else "DRY " + print(f"[{verb}] #{n}: +{sorted(add) or '-'} -{sorted(remove) or '-'}") + if args.apply: + if add: + _gh( + [ + "pr", + "edit", + str(n), + "--repo", + args.repo, + *_label_args("--add-label", add), + ] + ) + if remove: + _gh( + [ + "pr", + "edit", + str(n), + "--repo", + args.repo, + *_label_args("--remove-label", remove), + ] + ) + + mode = "applied" if args.apply else "dry-run (pass --apply to write)" + print(f"\n{changes} PR(s) with label changes — {mode}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) From 1058868dd6f231b5985d10b48a62d3d6ba6b776c Mon Sep 17 00:00:00 2001 From: daimon-pymclabs Date: Tue, 28 Jul 2026 00:03:59 +0000 Subject: [PATCH 2/8] Fix ruff lint + BugBot findings in PR triage skills - 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) --- .agents/skills/_shared/pr_triage/labels.py | 18 ++++++++++++++++-- .../skills/pr-digest/scripts/build_digest.py | 9 +++++++-- .../scripts/reconcile_labels.py | 9 ++++++++- 3 files changed, 31 insertions(+), 5 deletions(-) diff --git a/.agents/skills/_shared/pr_triage/labels.py b/.agents/skills/_shared/pr_triage/labels.py index 99477b9d6..74536cb57 100644 --- a/.agents/skills/_shared/pr_triage/labels.py +++ b/.agents/skills/_shared/pr_triage/labels.py @@ -70,6 +70,10 @@ "ready-for-review", # clean, green/pending, awaiting a reviewer ] +# Rank lookup so the CLI table and any consumer order by this list, not +# alphabetically. Editing NEXT_ACTION_ORDER above now actually reorders output. +NEXT_ACTION_RANK = {a: i for i, a in enumerate(NEXT_ACTION_ORDER)} + # The `status:*` labels the labeller is allowed to manage. It touches ONLY this # namespace, never `review:*`, `major`, `needs:maintainer-decision`, etc. STATUS_LABELS = { @@ -246,6 +250,10 @@ def _status_labels_for(f: dict) -> list[str]: labels.append("status:waiting-on-author") if f["next_action"] == "ready-for-review": labels.append("status:ready-for-review") + # Guard against drift: the labeller only manages the STATUS_LABELS set. + assert set(labels) <= STATUS_LABELS, ( + f"emitted labels {set(labels) - STATUS_LABELS} are outside STATUS_LABELS" + ) return labels @@ -253,7 +261,7 @@ def classify(prs: list[dict]) -> list[PRFacts]: results: list[PRFacts] = [] for pr in prs: login = pr["author"]["login"] - names = [l["name"] for l in pr.get("labels", [])] + names = [lab["name"] for lab in pr.get("labels", [])] risk = next( (n.split(":", 1)[1] for n in names if n.startswith("review:")), None ) @@ -305,7 +313,13 @@ def main() -> int: json.dump(payload, sys.stdout, indent=2) print() else: - for f in sorted(facts, key=lambda x: (x.next_action, -x.idle_days)): + for f in sorted( + facts, + key=lambda x: ( + NEXT_ACTION_RANK.get(x.next_action, len(NEXT_ACTION_ORDER)), + -x.idle_days, + ), + ): print( f"#{f.number:<5} {f.next_action:<18} idle{f.idle_days:>4}d " f"{f.author_class:<10} {f.title[:55]}" diff --git a/.agents/skills/pr-digest/scripts/build_digest.py b/.agents/skills/pr-digest/scripts/build_digest.py index 9c5172c0e..aa3f7aa84 100644 --- a/.agents/skills/pr-digest/scripts/build_digest.py +++ b/.agents/skills/pr-digest/scripts/build_digest.py @@ -69,7 +69,9 @@ def pick_hard_item(prs: list[dict]) -> dict | None: def build(payload: dict, day: str, mention: str | None) -> dict: prs = payload["prs"] - by = lambda a: [f for f in prs if f["next_action"] == a] + + def by(action): + return [f for f in prs if f["next_action"] == action] hard = pick_hard_item(prs) other_hard = [ @@ -82,8 +84,11 @@ def build(payload: dict, day: str, mention: str | None) -> dict: and (hard is None or f["number"] != hard["number"]) ] + hard_numbers = {f["number"] for f in other_hard} + if hard is not None: + hard_numbers.add(hard["number"]) reviews = sorted( - by("ready-for-review"), + (f for f in by("ready-for-review") if f["number"] not in hard_numbers), key=lambda f: (f["author_class"] != "external", f["idle_days"]), ) waiting = by("waiting-on-author") diff --git a/.agents/skills/pr-status-labeller/scripts/reconcile_labels.py b/.agents/skills/pr-status-labeller/scripts/reconcile_labels.py index 61f573952..7a8864079 100644 --- a/.agents/skills/pr-status-labeller/scripts/reconcile_labels.py +++ b/.agents/skills/pr-status-labeller/scripts/reconcile_labels.py @@ -51,6 +51,11 @@ "status:stale": ("5319E7", f"No activity in >={core.STALE_DAYS}d"), } +# LABEL_DEFS must cover exactly the managed namespace declared in core. +assert set(LABEL_DEFS) == core.STATUS_LABELS, ( + f"LABEL_DEFS out of sync with STATUS_LABELS: {set(LABEL_DEFS) ^ core.STATUS_LABELS}" +) + def _gh(args: list[str]) -> None: subprocess.run(["gh", *args], check=True, capture_output=True, text=True) @@ -84,7 +89,9 @@ def ensure_labels(repo: str) -> None: def current_status_labels(pr: dict) -> set[str]: - return {l["name"] for l in pr.get("labels", []) if l["name"].startswith("status:")} + return { + lab["name"] for lab in pr.get("labels", []) if lab["name"].startswith("status:") + } def main() -> int: From e26d69f78b00c86da29694fc53cc48317158df4e Mon Sep 17 00:00:00 2001 From: daimon-pymclabs Date: Tue, 28 Jul 2026 04:05:07 +0000 Subject: [PATCH 3/8] Make NEXT_ACTION_ORDER drive _next_action precedence (BugBot finding 2) _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) --- .agents/skills/_shared/pr_triage/labels.py | 48 ++++++++++++++-------- 1 file changed, 32 insertions(+), 16 deletions(-) diff --git a/.agents/skills/_shared/pr_triage/labels.py b/.agents/skills/_shared/pr_triage/labels.py index 74536cb57..d92b5944e 100644 --- a/.agents/skills/_shared/pr_triage/labels.py +++ b/.agents/skills/_shared/pr_triage/labels.py @@ -57,8 +57,11 @@ AGING_DAYS = 30 STALE_DAYS = 90 -# Precedence order for the single derived `next_action`. First match wins. -# This is the ONE place priority judgment is encoded; tune it here. +# Precedence order for the single derived `next_action`. First match wins: +# `_next_action` walks this list and returns the first action whose predicate +# holds, and the CLI table / consumers sort by it. This is the ONE place +# priority judgment is encoded; tune it here. `ready-for-review` is the +# always-true fallback and must stay last. NEXT_ACTION_ORDER = [ "decision", # needs a maintainer decision (the hard queue) "stale-draft", # your own draft, idle >= STALE_DAYS @@ -214,21 +217,34 @@ def _review_state(reviewDecision: str | None) -> str: def _next_action(f: dict) -> str: + """Derive the single `next_action`. Precedence is `NEXT_ACTION_ORDER`, + read verbatim: we walk that list and return the first action whose + predicate matches, so reordering the list up top genuinely reorders the + classification (true first-match-wins). `ready-for-review` is the + always-true fallback and must stay last in the list.""" draft = f["lifecycle"] == "draft" - if f["decision_needed"]: - return "decision" - if draft: - if f["idle_band"] == "stale": - return "stale-draft" - if f["idle_band"] == "aging": - return "aging-draft" - return "in-flight-draft" - if f["review"] == "changes-requested": - return "waiting-on-author" - if f["review"] == "approved" and f["conflict"] == "clean" and f["ci"] != "red": - return "ready-to-merge" - if f["conflict"] == "conflicting" or f["ci"] == "red": - return "mechanical" + predicates = { + "decision": lambda: f["decision_needed"], + "stale-draft": lambda: draft and f["idle_band"] == "stale", + "aging-draft": lambda: draft and f["idle_band"] == "aging", + "in-flight-draft": lambda: draft, + "waiting-on-author": lambda: f["review"] == "changes-requested", + "ready-to-merge": lambda: ( + f["review"] == "approved" + and f["conflict"] == "clean" + and f["ci"] != "red" + ), + "mechanical": lambda: f["conflict"] == "conflicting" or f["ci"] == "red", + "ready-for-review": lambda: True, + } + # Guard against drift between the precedence list and the predicate map. + assert set(predicates) == set(NEXT_ACTION_ORDER), ( + "predicate keys out of sync with NEXT_ACTION_ORDER: " + f"{set(predicates) ^ set(NEXT_ACTION_ORDER)}" + ) + for action in NEXT_ACTION_ORDER: + if predicates[action](): + return action return "ready-for-review" From c62eb0db4a855f39539b9b58ae2d3044afd2bff1 Mon Sep 17 00:00:00 2001 From: daimon-pymclabs Date: Tue, 28 Jul 2026 08:11:29 +0000 Subject: [PATCH 4/8] Fix idle-timer reset by label writes; ruff-format the triage core 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) --- .agents/skills/_shared/pr_triage/labels.py | 82 ++++++++++++++++------ 1 file changed, 62 insertions(+), 20 deletions(-) diff --git a/.agents/skills/_shared/pr_triage/labels.py b/.agents/skills/_shared/pr_triage/labels.py index d92b5944e..ccda15f90 100644 --- a/.agents/skills/_shared/pr_triage/labels.py +++ b/.agents/skills/_shared/pr_triage/labels.py @@ -103,6 +103,31 @@ def _days_since(ts: str) -> int: return (_now() - _parse_ts(ts)).days +def _last_activity(pr: dict) -> str: + """Timestamp of the PR's last real activity, used to measure idleness. + + Intentionally NOT `updatedAt`: that field is bumped by every mutation to + the PR, including the label-only `gh pr edit` writes the labeller itself + performs. If idleness were read from `updatedAt`, applying `status:aging` + would reset the clock, the next reconcile run would see a "fresh" PR and + remove the label it just added, and aging/stale state could never stick. + + Instead we take the most recent of: PR creation, last commit, and last + human/CI comment. None of those are moved by a label edit, so the idle + figure is stable across labeller runs, and the digest (which shares this + core) reads the same number.""" + stamps = [pr["createdAt"]] + for commit in pr.get("commits") or []: + # committedDate is the push time; take them all so a rebase that + # reorders commits can't hide the newest one. + if commit.get("committedDate"): + stamps.append(commit["committedDate"]) + for c in pr.get("comments") or []: + if c.get("createdAt"): + stamps.append(c["createdAt"]) + return max(stamps, key=_parse_ts) + + def _gh_json(args: list[str]) -> object: out = subprocess.run( ["gh", *args], capture_output=True, text=True, check=True @@ -112,10 +137,23 @@ def _gh_json(args: list[str]) -> object: def fetch_prs(repo: str) -> list[dict]: """Fetch open PRs. Forces GitHub to compute the lazily-evaluated - `mergeable` field, which comes back UNKNOWN in bulk listings.""" + `mergeable` field, which comes back UNKNOWN in bulk listings. + + We deliberately fetch `comments` (and `commits`, per-PR below) rather than + trusting `updatedAt` to measure idleness. `updatedAt` is bumped by ANY edit + to the PR, including the label-only `gh pr edit` writes this very system + makes when it applies `status:aging`/`status:stale`. Reading idleness from + it would mean each labeller apply resets the idle clock, so the next run + sees a fresh PR and strips the aging/stale label it just added: the state + could never persist. See `_last_activity`. + + `commits` is fetched per-PR, NOT in the bulk list: a bulk `pr list` that + includes `commits` (each carrying an authors connection) blows past + GitHub's 500k GraphQL node budget. We piggy-back the commit fetch onto the + same per-PR `gh pr view` used to resolve lazy `mergeable`.""" fields = ( "number,title,author,isDraft,mergeable,reviewDecision," - "updatedAt,createdAt,labels,statusCheckRollup" + "updatedAt,createdAt,labels,statusCheckRollup,comments" ) prs = ( _gh_json( @@ -134,22 +172,28 @@ def fetch_prs(repo: str) -> list[dict]: ) or [] ) - # Poke each UNKNOWN mergeable once; `gh pr view` triggers the computation. + # Per-PR pass: fetch `commits` (too expensive in bulk, see docstring) and, + # in the same call, resolve any UNKNOWN `mergeable` (`gh pr view` triggers + # the lazy computation). One `gh pr view` per PR covers both. for pr in prs: + want = ["commits"] if pr.get("mergeable") == "UNKNOWN": - with contextlib.suppress(subprocess.CalledProcessError): - v = _gh_json( - [ - "pr", - "view", - str(pr["number"]), - "--repo", - repo, - "--json", - "mergeable", - ] - ) - if v: + want.append("mergeable") + with contextlib.suppress(subprocess.CalledProcessError): + v = _gh_json( + [ + "pr", + "view", + str(pr["number"]), + "--repo", + repo, + "--json", + ",".join(want), + ] + ) + if v: + pr["commits"] = v.get("commits", []) + if "mergeable" in want: pr["mergeable"] = v.get("mergeable", "UNKNOWN") return prs @@ -230,9 +274,7 @@ def _next_action(f: dict) -> str: "in-flight-draft": lambda: draft, "waiting-on-author": lambda: f["review"] == "changes-requested", "ready-to-merge": lambda: ( - f["review"] == "approved" - and f["conflict"] == "clean" - and f["ci"] != "red" + f["review"] == "approved" and f["conflict"] == "clean" and f["ci"] != "red" ), "mechanical": lambda: f["conflict"] == "conflicting" or f["ci"] == "red", "ready-for-review": lambda: True, @@ -298,7 +340,7 @@ def classify(prs: list[dict]) -> list[PRFacts]: "decision_needed": "needs:maintainer-decision" in names, "major": "major" in names, "risk": risk, - "idle_days": _days_since(pr["updatedAt"]), + "idle_days": _days_since(_last_activity(pr)), "age_days": _days_since(pr["createdAt"]), } f["idle_band"] = _idle_band(f["idle_days"]) From 157e87a78f3da6a25eabf914d0795bdf645099c1 Mon Sep 17 00:00:00 2001 From: root Date: Sat, 1 Aug 2026 00:04:33 +0000 Subject: [PATCH 5/8] Fix remaining BugBot findings in PR triage core - 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 --- .agents/skills/_shared/pr_triage/labels.py | 28 +++++++++++++++++-- .../skills/pr-digest/scripts/build_digest.py | 11 ++++++++ 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/.agents/skills/_shared/pr_triage/labels.py b/.agents/skills/_shared/pr_triage/labels.py index ccda15f90..e344bc051 100644 --- a/.agents/skills/_shared/pr_triage/labels.py +++ b/.agents/skills/_shared/pr_triage/labels.py @@ -70,6 +70,7 @@ "waiting-on-author", # changes requested, ball in author's court "ready-to-merge", # approved + clean + not red "mechanical", # conflicting or CI red (agent-fixable in Tier 2) + "mergeability-unknown", # GitHub never resolved `mergeable`; don't claim clean "ready-for-review", # clean, green/pending, awaiting a reviewer ] @@ -125,6 +126,14 @@ def _last_activity(pr: dict) -> str: for c in pr.get("comments") or []: if c.get("createdAt"): stamps.append(c["createdAt"]) + # Reviews count as real activity too: a PR reviewed yesterday is not idle, + # even if the last commit and comment are months old. Without this, a + # just-reviewed PR could pick up `status:stale` immediately after a human + # engaged with it. + for r in pr.get("reviews") or []: + ts = r.get("submittedAt") or r.get("createdAt") + if ts: + stamps.append(ts) return max(stamps, key=_parse_ts) @@ -176,7 +185,7 @@ def fetch_prs(repo: str) -> list[dict]: # in the same call, resolve any UNKNOWN `mergeable` (`gh pr view` triggers # the lazy computation). One `gh pr view` per PR covers both. for pr in prs: - want = ["commits"] + want = ["commits", "reviews"] if pr.get("mergeable") == "UNKNOWN": want.append("mergeable") with contextlib.suppress(subprocess.CalledProcessError): @@ -193,6 +202,7 @@ def fetch_prs(repo: str) -> list[dict]: ) if v: pr["commits"] = v.get("commits", []) + pr["reviews"] = v.get("reviews", []) if "mergeable" in want: pr["mergeable"] = v.get("mergeable", "UNKNOWN") return prs @@ -201,9 +211,18 @@ def fetch_prs(repo: str) -> list[dict]: def _ci_state(pr: dict) -> str: rollup = pr.get("statusCheckRollup") or [] states = [c.get("conclusion") or c.get("state") for c in rollup] + # CANCELLED is not a failure signal here. This repo uses + # `cancel-in-progress` concurrency, so every superseded run stays in the + # rollup as CANCELLED next to the run that replaced it. Counting those as + # red would mark green PRs `status:ci-failing` and route them to + # `mechanical`. SKIPPED/NEUTRAL are likewise non-signals. + ignored = {"CANCELLED", "SKIPPED", "NEUTRAL"} + states = [s for s in states if s not in ignored] if not states: return "none" - bad = {"FAILURE", "ERROR", "TIMED_OUT", "CANCELLED", "ACTION_REQUIRED"} + # STARTUP_FAILURE is GitHub's conclusion when a workflow never starts (bad + # YAML, missing secret). It is a failure, not a green check. + bad = {"FAILURE", "ERROR", "TIMED_OUT", "STARTUP_FAILURE", "ACTION_REQUIRED"} if any(s in bad for s in states): return "red" pending = {"PENDING", "IN_PROGRESS", "QUEUED", "EXPECTED", None} @@ -277,6 +296,11 @@ def _next_action(f: dict) -> str: f["review"] == "approved" and f["conflict"] == "clean" and f["ci"] != "red" ), "mechanical": lambda: f["conflict"] == "conflicting" or f["ci"] == "red", + # `conflict == "unknown"` means GitHub never finished computing + # mergeability even after the per-PR `gh pr view` retry. Such a PR may + # still be conflicting, so it must not be advertised as a clean review + # target; it gets its own bucket and no `status:ready-for-review`. + "mergeability-unknown": lambda: f["conflict"] == "unknown", "ready-for-review": lambda: True, } # Guard against drift between the precedence list and the predicate map. diff --git a/.agents/skills/pr-digest/scripts/build_digest.py b/.agents/skills/pr-digest/scripts/build_digest.py index aa3f7aa84..95ff5fef4 100644 --- a/.agents/skills/pr-digest/scripts/build_digest.py +++ b/.agents/skills/pr-digest/scripts/build_digest.py @@ -93,6 +93,7 @@ def by(action): ) waiting = by("waiting-on-author") mechanical = by("mechanical") + unknown_merge = by("mergeability-unknown") stale_drafts = by("stale-draft") + by("aging-draft") in_flight = by("in-flight-draft") to_merge = by("ready-to-merge") @@ -157,6 +158,15 @@ def by(action): + ("…" if len(mechanical) > 8 else "") + "." ) + if unknown_merge: + m.append( + f"\n**❔ Mergeability unresolved ({len(unknown_merge)})** — GitHub " + "never finished computing `mergeable`, so these are not claimed as " + "clean review targets. They usually resolve on the next run: " + + ", ".join(_link(f) for f in unknown_merge[:8]) + + ("…" if len(unknown_merge) > 8 else "") + + "." + ) if stale_drafts: oldest = max(stale_drafts, key=lambda f: f["age_days"]) m.append( @@ -183,6 +193,7 @@ def by(action): "reviews": len(reviews), "waiting": len(waiting), "mechanical": len(mechanical), + "mergeability_unknown": len(unknown_merge), "stale_drafts": len(stale_drafts), "in_flight": len(in_flight), "to_merge": len(to_merge), From e676d4404bc94737da76a453ec7088dd11a09df0 Mon Sep 17 00:00:00 2001 From: daimon-pymclabs Date: Mon, 3 Aug 2026 04:03:32 +0000 Subject: [PATCH 6/8] Fail loudly on per-PR detail fetch failure (BugBot) 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. --- .agents/skills/_shared/pr_triage/labels.py | 75 +++++++++++++++------- 1 file changed, 53 insertions(+), 22 deletions(-) diff --git a/.agents/skills/_shared/pr_triage/labels.py b/.agents/skills/_shared/pr_triage/labels.py index e344bc051..ce6821f1c 100644 --- a/.agents/skills/_shared/pr_triage/labels.py +++ b/.agents/skills/_shared/pr_triage/labels.py @@ -29,7 +29,6 @@ from __future__ import annotations import argparse -import contextlib import datetime as _dt import json import subprocess @@ -184,27 +183,48 @@ def fetch_prs(repo: str) -> list[dict]: # Per-PR pass: fetch `commits` (too expensive in bulk, see docstring) and, # in the same call, resolve any UNKNOWN `mergeable` (`gh pr view` triggers # the lazy computation). One `gh pr view` per PR covers both. + # + # A per-PR view that fails is NOT harmless: without `commits`/`reviews`, + # `_last_activity` silently falls back to creation time plus issue + # comments, so a PR pushed to this morning can read as months idle and + # `--apply` would write a wrong `status:aging`/`status:stale`. We retry + # once (these failures are usually transient rate-limit/network blips) and, + # if it still fails, flag the PR `detail_incomplete` and warn on stderr. + # Classification then withholds the idle-derived labels for that PR rather + # than guessing. Never swallow the failure and carry on. for pr in prs: want = ["commits", "reviews"] if pr.get("mergeable") == "UNKNOWN": want.append("mergeable") - with contextlib.suppress(subprocess.CalledProcessError): - v = _gh_json( - [ - "pr", - "view", - str(pr["number"]), - "--repo", - repo, - "--json", - ",".join(want), - ] - ) - if v: - pr["commits"] = v.get("commits", []) - pr["reviews"] = v.get("reviews", []) - if "mergeable" in want: - pr["mergeable"] = v.get("mergeable", "UNKNOWN") + args = [ + "pr", + "view", + str(pr["number"]), + "--repo", + repo, + "--json", + ",".join(want), + ] + v = None + for attempt in range(2): + try: + v = _gh_json(args) + break + except subprocess.CalledProcessError as exc: + if attempt == 0: + continue + print( + f"warning: `gh pr view {pr['number']}` failed after a retry " + f"({exc}); idle-based labels withheld for this PR", + file=sys.stderr, + ) + if v: + pr["commits"] = v.get("commits", []) + pr["reviews"] = v.get("reviews", []) + if "mergeable" in want: + pr["mergeable"] = v.get("mergeable", "UNKNOWN") + else: + pr["detail_incomplete"] = True return prs @@ -267,6 +287,10 @@ class PRFacts: age_days: int idle_band: str # fresh | aging | stale next_action: str + # True when the per-PR detail fetch failed even after a retry, so + # `commits`/`reviews` are missing and `idle_days` is a floor, not a fact. + # Consumers (labeller, digest) must not treat this PR's idleness as real. + detail_incomplete: bool = False status_labels: list[str] = field(default_factory=list) @@ -319,10 +343,16 @@ def _status_labels_for(f: dict) -> list[str]: statuses; they only carry aging/stale flags.""" labels: list[str] = [] draft = f["lifecycle"] == "draft" - if f["idle_band"] == "stale": - labels.append("status:stale") - elif f["idle_band"] == "aging": - labels.append("status:aging") + # Idle-derived labels need the commit/review history. If the detail fetch + # failed, `idle_days` was computed from creation time and issue comments + # only, which overstates idleness for a PR whose recent activity was + # pushes or reviews. Withhold `status:aging`/`status:stale` rather than + # write a label the next healthy run would have to undo. + if not f.get("detail_incomplete"): + if f["idle_band"] == "stale": + labels.append("status:stale") + elif f["idle_band"] == "aging": + labels.append("status:aging") if not draft: if f["conflict"] == "conflicting": labels.append("status:conflicting") @@ -366,6 +396,7 @@ def classify(prs: list[dict]) -> list[PRFacts]: "risk": risk, "idle_days": _days_since(_last_activity(pr)), "age_days": _days_since(pr["createdAt"]), + "detail_incomplete": bool(pr.get("detail_incomplete")), } f["idle_band"] = _idle_band(f["idle_days"]) f["next_action"] = _next_action(f) From 04834ea80959b60bd62ef7637f647c8df91feb3d Mon Sep 17 00:00:00 2001 From: daimon-pymclabs Date: Mon, 3 Aug 2026 04:11:30 +0000 Subject: [PATCH 7/8] Make an incomplete fetch neutral, not destructive (BugBot) 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. --- .agents/skills/_shared/pr_triage/labels.py | 57 ++++++++++++++++------ 1 file changed, 41 insertions(+), 16 deletions(-) diff --git a/.agents/skills/_shared/pr_triage/labels.py b/.agents/skills/_shared/pr_triage/labels.py index ce6821f1c..cd52fd062 100644 --- a/.agents/skills/_shared/pr_triage/labels.py +++ b/.agents/skills/_shared/pr_triage/labels.py @@ -88,6 +88,11 @@ "status:stale", } +# The subset of STATUS_LABELS derived purely from the idle clock. These are the +# only labels whose input (`commits`/`reviews`) can go missing on a failed +# detail fetch, so they get the carry-over treatment in `_status_labels_for`. +IDLE_LABELS = {"status:aging", "status:stale"} + # ----------------------------------------------------------------------------- @@ -261,7 +266,16 @@ def _author_class(login: str) -> str: return "external" -def _idle_band(idle: int) -> str: +def _idle_band(idle: int, detail_incomplete: bool = False) -> str: + """Bucket the idle clock. Returns `unknown` when the per-PR detail fetch + failed: without `commits`/`reviews`, `idle_days` was computed from creation + time and issue comments only, so it is a ceiling on real idleness, not a + measurement. `unknown` is a first-class band, not a synonym for `fresh` -- + it means "do not act on this number", and both `_next_action` (which stops + routing drafts to stale-draft/aging-draft) and `_status_labels_for` (which + stops deriving idle labels and carries the existing ones over) read it.""" + if detail_incomplete: + return "unknown" if idle >= STALE_DAYS: return "stale" if idle >= AGING_DAYS: @@ -285,7 +299,7 @@ class PRFacts: risk: str | None # high | medium | low | None idle_days: int age_days: int - idle_band: str # fresh | aging | stale + idle_band: str # fresh | aging | stale | unknown next_action: str # True when the per-PR detail fetch failed even after a retry, so # `commits`/`reviews` are missing and `idle_days` is a floor, not a fact. @@ -310,6 +324,10 @@ def _next_action(f: dict) -> str: classification (true first-match-wins). `ready-for-review` is the always-true fallback and must stay last in the list.""" draft = f["lifecycle"] == "draft" + # The two draft idle buckets test the band by equality, so an `unknown` + # band (failed detail fetch) matches neither and the draft falls through to + # `in-flight-draft`. That is deliberate: the digest must not call a draft + # stale on an idle figure the labeller already refuses to trust. predicates = { "decision": lambda: f["decision_needed"], "stale-draft": lambda: draft and f["idle_band"] == "stale", @@ -338,21 +356,26 @@ def _next_action(f: dict) -> str: return "ready-for-review" -def _status_labels_for(f: dict) -> list[str]: +def _status_labels_for(f: dict, existing: set[str] | None = None) -> list[str]: """Derive the `status:*` label set. Drafts stay out of the reviewer-facing - statuses; they only carry aging/stale flags.""" + statuses; they only carry aging/stale flags. + + `existing` is the PR's current `status:*` labels. It matters only for the + `unknown` idle band: the labeller reconciles by set difference, so a label + left OUT of this list is a label it will REMOVE. Simply withholding the + idle labels when the detail fetch failed would therefore strip correct, + durable aging/stale state on every unlucky run. Instead we carry the + existing idle labels through unchanged, which makes the reconcile a no-op + for them: a failed fetch neither writes a new idle verdict nor erases the + last good one.""" labels: list[str] = [] draft = f["lifecycle"] == "draft" - # Idle-derived labels need the commit/review history. If the detail fetch - # failed, `idle_days` was computed from creation time and issue comments - # only, which overstates idleness for a PR whose recent activity was - # pushes or reviews. Withhold `status:aging`/`status:stale` rather than - # write a label the next healthy run would have to undo. - if not f.get("detail_incomplete"): - if f["idle_band"] == "stale": - labels.append("status:stale") - elif f["idle_band"] == "aging": - labels.append("status:aging") + if f["idle_band"] == "unknown": + labels.extend(sorted((existing or set()) & IDLE_LABELS)) + elif f["idle_band"] == "stale": + labels.append("status:stale") + elif f["idle_band"] == "aging": + labels.append("status:aging") if not draft: if f["conflict"] == "conflicting": labels.append("status:conflicting") @@ -398,9 +421,11 @@ def classify(prs: list[dict]) -> list[PRFacts]: "age_days": _days_since(pr["createdAt"]), "detail_incomplete": bool(pr.get("detail_incomplete")), } - f["idle_band"] = _idle_band(f["idle_days"]) + f["idle_band"] = _idle_band(f["idle_days"], f["detail_incomplete"]) f["next_action"] = _next_action(f) - f["status_labels"] = _status_labels_for(f) + f["status_labels"] = _status_labels_for( + f, {n for n in names if n in STATUS_LABELS} + ) results.append(PRFacts(**f)) return results From b3645e9dd958621d54af8930921054e3b6a56dcd Mon Sep 17 00:00:00 2001 From: daimon-pymclabs Date: Mon, 3 Aug 2026 12:07:26 +0000 Subject: [PATCH 8/8] Treat STALE checks as red; stop trusting/printing raw data (BugBot) 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 --- .agents/skills/_shared/pr_triage/labels.py | 16 +++++++-- .../skills/pr-digest/scripts/build_digest.py | 36 ++++++++++++++++--- 2 files changed, 45 insertions(+), 7 deletions(-) diff --git a/.agents/skills/_shared/pr_triage/labels.py b/.agents/skills/_shared/pr_triage/labels.py index cd52fd062..783e05b91 100644 --- a/.agents/skills/_shared/pr_triage/labels.py +++ b/.agents/skills/_shared/pr_triage/labels.py @@ -246,8 +246,20 @@ def _ci_state(pr: dict) -> str: if not states: return "none" # STARTUP_FAILURE is GitHub's conclusion when a workflow never starts (bad - # YAML, missing secret). It is a failure, not a green check. - bad = {"FAILURE", "ERROR", "TIMED_OUT", "STARTUP_FAILURE", "ACTION_REQUIRED"} + # YAML, missing secret). It is a failure, not a green check. STALE is the + # conclusion for a check GitHub has given up on (the run outlived its + # commit); GitHub does not count a stale required check as successful, so + # neither do we -- otherwise the PR could reach `ready-to-merge`. Unlike + # CANCELLED, a STALE check is not superseded by a sibling run in the + # rollup, so it needs a re-run: `red` routes it to `mechanical`. + bad = { + "FAILURE", + "ERROR", + "TIMED_OUT", + "STARTUP_FAILURE", + "ACTION_REQUIRED", + "STALE", + } if any(s in bad for s in states): return "red" pending = {"PENDING", "IN_PROGRESS", "QUEUED", "EXPECTED", None} diff --git a/.agents/skills/pr-digest/scripts/build_digest.py b/.agents/skills/pr-digest/scripts/build_digest.py index 95ff5fef4..f0cdf9110 100644 --- a/.agents/skills/pr-digest/scripts/build_digest.py +++ b/.agents/skills/pr-digest/scripts/build_digest.py @@ -44,8 +44,27 @@ _spec.loader.exec_module(core) +def _md(text: str) -> str: + """Escape characters that would terminate a markdown link label early. + + PR titles routinely carry prefixes like `[WIP]` or `[RFC]`, and a raw `]` + interpolated between `[` and `]` closes the link on the wrong character, + corrupting the rest of the line.""" + return text.replace("\\", "\\\\").replace("[", "\\[").replace("]", "\\]") + + def _fmt(f: dict) -> str: - return f"[#{f['number']} {f['title'][:60]}]({f['url']})" + return f"[#{f['number']} {_md(f['title'][:60])}]({f['url']})" + + +def _idle(f: dict) -> str: + """Render the idle clock, or admit we don't know it. + + `idle_days` is only a measurement when the per-PR detail fetch succeeded. + With `detail_incomplete` the core computed it without commits/reviews and + already refuses to derive idle labels from it (`idle_band == "unknown"`), so + the digest must not print it as a fact either.""" + return "idle unknown" if f["detail_incomplete"] else f"idle {f['idle_days']}d" def _link(f: dict) -> str: @@ -87,9 +106,16 @@ def by(action): hard_numbers = {f["number"] for f in other_hard} if hard is not None: hard_numbers.add(hard["number"]) + # Externals first, then by idle. PRs with an untrusted idle figure sort + # after the trusted ones in their group rather than being interleaved on a + # number the core told us not to act on. reviews = sorted( (f for f in by("ready-for-review") if f["number"] not in hard_numbers), - key=lambda f: (f["author_class"] != "external", f["idle_days"]), + key=lambda f: ( + f["author_class"] != "external", + f["detail_incomplete"], + f["idle_days"], + ), ) waiting = by("waiting-on-author") mechanical = by("mechanical") @@ -114,7 +140,7 @@ def by(action): m.append("\n{{HARD_ITEM_FRAMING}}") # agent fills: context, default, cost m.append( f"\nLead PR: {_fmt(hard)} ({hard['author']}, " - f"open {hard['age_days']}d, idle {hard['idle_days']}d)." + f"open {hard['age_days']}d, {_idle(hard)})." ) if other_hard: queued = ", ".join(_link(f) for f in other_hard[:4]) @@ -172,8 +198,8 @@ def by(action): m.append( f"\n**🗂️ Stale/aging drafts — a nudge, not a task " f"({len(stale_drafts)})** — oldest is {_link(oldest)} " - f"(opened {oldest['age_days']}d ago, idle " - f"{oldest['idle_days']}d). Worth a bulk abandon-or-resume " + f"(opened {oldest['age_days']}d ago, {_idle(oldest)}). " + "Worth a bulk abandon-or-resume " "pass some quiet afternoon." ) if in_flight: