This is an automated email from the ASF dual-hosted git repository.
potiuk pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow-steward.git
The following commit(s) were added to refs/heads/main by this push:
new 6b9d75e feat(pr-management-stats): add maintainer dashboard, trends,
and recommendations (#68)
6b9d75e is described below
commit 6b9d75eab9f1b7454f2c70ed3213c4fc1f25418b
Author: Jarek Potiuk <[email protected]>
AuthorDate: Wed May 6 12:36:43 2026 +0200
feat(pr-management-stats): add maintainer dashboard, trends, and
recommendations (#68)
Restructure pr-management-stats from "two dense tables" to a multi-section
maintainer dashboard. Hero cards show repo-health rating; a deterministic
recommendation panel surfaces the next slash-command to run; weekly charts
cover closure velocity, opened-vs-closed momentum, ready-for-review trend
by top areas, and closed-by-triage-reason breakdown; a pressure ranking
points at where to focus a triage/review session. Original tables move into
collapsible details for maintainers who want raw per-area numbers.
Adds five new sections to aggregate.md (pressure score, weekly velocity,
opened-vs-closed buckets, ready-for-review trend by top areas,
closed-by-triage-reason buckets, health rating), a pressure_weight helper
to classify.md, a ready-label timeline fetch to fetch.md, and a full
dashboard layout + recommendation rules + verbose legend to render.md.
Mirrors the equivalent change to apache/airflow's pr-stats skill.
---
.claude/skills/pr-management-stats/SKILL.md | 151 +++++--
.claude/skills/pr-management-stats/aggregate.md | 186 ++++++++-
.claude/skills/pr-management-stats/classify.md | 35 +-
.claude/skills/pr-management-stats/fetch.md | 61 ++-
.claude/skills/pr-management-stats/render.md | 520 +++++++++++++++++-------
5 files changed, 764 insertions(+), 189 deletions(-)
diff --git a/.claude/skills/pr-management-stats/SKILL.md
b/.claude/skills/pr-management-stats/SKILL.md
index 847e1ef..e347de9 100644
--- a/.claude/skills/pr-management-stats/SKILL.md
+++ b/.claude/skills/pr-management-stats/SKILL.md
@@ -1,49 +1,52 @@
---
name: pr-management-stats
-mode: A
description: |
- Produce maintainer-facing statistics about open pull requests on
- the configured `<upstream>` repo (default: read from
`<project-config>/project.md → upstream_repo`). Successor to
- `breeze pr stats`: read-only, no mutations — just two summary
- tables grouped by `area:*` label (Triaged final-state, and
- Triaged still-open) plus per-area age-bucket breakdowns so the
- maintainer can see where queue pressure is sitting.
-
- Invoke when the user says "how is the PR queue doing", "run PR
- stats", "show the area breakdown", "how many PRs are still
- waiting on authors after triage", or any variation on the "give
- me numbers about the open PR backlog" theme. Also appropriate
- as a quick health check before or after a triage sweep.
+ Read-only maintainer dashboard for the open-PR backlog of <upstream>.
+ Surfaces a health rating, prioritised action recommendations, weekly closure
+ velocity trends, area pressure ranking, and a triage-funnel breakdown — with
+ the underlying area-grouped tables as a collapsible details section.
+when_to_use: |
+ When the user asks "how is the PR queue doing", "run PR stats", "what should
+ I do today", "show me the trends", "where is queue pressure sitting", or any
+ variation on "give me the maintainer view of the backlog". Good as a daily
+ health check, before or after a triage sweep, or as an input to a planning
+ session.
---
<!-- SPDX-License-Identifier: Apache-2.0
https://www.apache.org/licenses/LICENSE-2.0 -->
<!-- Placeholder convention:
- <repo> → target GitHub repository in `owner/name` form (default: read
from `<project-config>/project.md → upstream_repo`)
+ <repo> → target GitHub repository in `owner/name` form (default:
<upstream>)
<viewer> → the authenticated GitHub login of the maintainer running the
skill
Substitute these before running any `gh` command below. -->
# pr-management-stats
-Read-only skill that answers "what does the open-PR backlog
-*look* like" as two tables:
+Read-only skill that answers "what should the maintainer **do** about the
+open-PR backlog right now". Primary output is a **dashboard** with five
+sections:
-| Table | Row set | Purpose |
+| Section | What it shows | Maintainer use |
|---|---|---|
-| **Triaged final-state** | closed / merged PRs since a cutoff date, broken
down by `area:*` label | Shows triage outcomes — what fraction of triaged PRs
merged, closed, or got an author response before closing. |
-| **Triaged still-open** | all currently-open PRs, broken down by `area:*`
label | Shows current queue pressure — triage coverage, author-response rate,
ready-for-review count, age buckets. |
+| **Hero cards** | Health rating, total open, ready-for-review count,
untriaged-non-drafts (with >4w callout) | At-a-glance status |
+| **What needs attention** | Prioritised action recommendations
(high/medium/low) with the exact slash command to run | Decide what to spend
the next hour on |
+| **Closure velocity** | Per-week merged/closed bars over the last 6 weeks,
plus avg/peak | Spot slowdowns or burst weeks |
+| **Pressure by area** | `area:*` ranking by weighted untriaged-old PR count |
Pick a focused triage / review session |
+| **Triage funnel** | Triage coverage %, author response rate %, stalest
bucket, this-week velocity | See whether the funnel is healthy end-to-end |
+
+The two original tables (**Triaged final-state since cutoff** and **Triaged
still-open by area**) are kept as a *collapsible details section* at the bottom
of the dashboard for maintainers who want the raw per-area numbers.
-The skill is the statistical complement of
[`pr-management-triage`](../pr-management-triage/SKILL.md) — same repo, same
classification logic, no mutations. Running the two in sequence (stats → triage
→ stats) lets a maintainer measure a sweep's effect.
+The skill is the statistical complement of
[`pr-management-triage`](../pr-management-triage/SKILL.md) — same repo, same
classification logic, no mutations. Running the two in sequence (stats → triage
→ stats) lets a maintainer measure a sweep's effect; the dashboard's
recommendations link directly back to specific `pr-management-triage`
invocations.
Detail files:
| File | Purpose |
|---|---|
| [`fetch.md`](fetch.md) | GraphQL templates for open-PR list and
closed/merged-since-cutoff list. |
-| [`classify.md`](classify.md) | Triage-status detection (waiting vs.
responded vs. never-triaged) — reuses the `Pull Request quality criteria`
marker from `pr-management-triage`. |
-| [`aggregate.md`](aggregate.md) | Area grouping, age buckets, totals,
percentage rules. |
-| [`render.md`](render.md) | The two tables — column order, footers, header
wording. |
+| [`classify.md`](classify.md) | Triage-status detection (waiting vs.
responded vs. never-triaged) — reuses the `Pull Request quality criteria`
marker from `pr-management-triage`. Also defines the per-PR `pressure_weight`. |
+| [`aggregate.md`](aggregate.md) | Area grouping, age buckets, totals,
percentage rules. Also defines weekly velocity buckets, area pressure scores,
and the health-rating thresholds. |
+| [`render.md`](render.md) | The dashboard layout (hero / actions / trends /
hotspots / details) plus the underlying tables, colour scheme, and
recommendation rules. |
---
@@ -110,10 +113,14 @@ read-only and inherits everything from
`pr-management-triage`'s contract.
**Golden rule 3 — one GraphQL call per batch, not per PR.** Same rule as
`pr-management-triage/fetch-and-batch.md`. One aliased query covers the open-PR
list for a whole page; the closed/merged fetch is paginated by GitHub's search
cursor. Never call `gh pr view` per PR.
-**Golden rule 4 — include a legend with every render.** The tables are dense
(15+ columns on Table 2). Always print a short legend after the tables
explaining the columns — `Contrib.` = non-collaborator, `Responded` = author
replied after the triage comment, `Drafted by triager` = PR converted to draft
by the viewer, etc. Nobody remembers column abbreviations in isolation.
+**Golden rule 4 — include a legend with every render.** The tables are dense
(15+ columns on the still-open table). Always print a short legend after the
tables explaining the columns — `Contrib.` = non-collaborator, `Responded` =
author replied after the triage comment, `Drafted by triager` = PR converted to
draft by the viewer, etc. Nobody remembers column abbreviations in isolation.
The dashboard's hero cards and recommendation panel are themselves
self-explanatory and don't need the [...]
**Golden rule 5 — state the input scope up front.** Before rendering, print
one line summarising what the stats cover: repo name, total open PR count,
closed-since cutoff date, and viewer login. The numbers only make sense in
context.
+**Golden rule 6 — recommendations are deterministic, not opinions.** Every
action surfaced in the "What needs attention" panel comes from a fixed rule in
[`render.md#recommendation-rules`](render.md). The skill never editorialises
("queue is doing well", "you should focus on X") — it surfaces the rule's
trigger and the suggested next-step command. The maintainer reads the trigger
and decides; the skill never decides for them. New rules are added by editing
the rules table, not by adding [...]
+
+**Golden rule 7 — actions link to other skills, never mutate.** Every
recommendation's `action` field is the *exact* slash-command the maintainer can
paste to do the work — almost always `/pr-management-triage`,
`/maintainer-review`, or a focused variant with a label/PR-number filter. The
stats skill itself remains pure-read (Golden rule 1); the dashboard makes
downstream skills *one paste away* from running.
+
---
## Inputs
@@ -180,15 +187,96 @@ Also compute a `TOTAL` row where each PR is counted
exactly once (NOT the sum of
---
-## Step 5 — Render
+## Step 5a — Compute health rating + action recommendations
+
+Pure function of the classified open-PR set. No network.
+
+1. Apply the **health rating** thresholds from
[`aggregate.md#health-rating`](aggregate.md): each fired threshold is a "issue
point". Map total points → `✅ Healthy` / `⚠️ Needs attention` / `🔥 Action
needed`.
+2. Walk the **recommendation rules** from
[`render.md#recommendation-rules`](render.md) in declared order. Each rule that
fires produces one entry with `priority`, `icon`, `title`, `detail`, `action`
(the exact slash command), and a count.
+3. The recommendation list is the input to the dashboard's "What needs
attention" panel. If zero rules fire, surface the explicit "no urgent actions
detected" panel — never leave the section empty.
+
+---
+
+## Step 5b — Compute weekly velocity buckets
+
+Pure function of the closed/merged-since-cutoff PR set.
+
+For each of the last 6 weeks (rolling, anchored on the fetch-start `<now>`),
bucket PRs by `closedAt` and count `merged` and `closed` separately. Also count
the triaged-then-merged / triaged-then-closed / triaged-then-responded subsets
— those are what feed the trend mini-stats below the velocity bars.
+
+See [`aggregate.md#weekly-velocity`](aggregate.md) for the exact bucket
boundaries and the avg/peak summary computation.
+
+---
+
+## Step 5c — Compute opened-vs-closed weekly buckets
+
+Pure function of *both* the open-PR set (Step 1) and the
closed/merged-since-cutoff PR set (Step 3) — every PR's `createdAt` is checked
against each weekly window regardless of current state.
+
+For each of the same six rolling weekly windows, compute:
+
+- `opened` — PR's `createdAt` falls in the window
+- `closed_total` — PR was closed/merged in the window (reuses the velocity
buckets from Step 5b)
+- `net_delta = opened - closed_total`
+
+These per-week numbers feed the dashboard's "Opened vs closed momentum" line
chart and the two-line "Net delta" summary below it. See
[`aggregate.md#opened-vs-closed-weekly-buckets`](aggregate.md) for the exact
spec.
+
+---
+
+## Step 5d — Compute ready-for-review trend by top areas
+
+Needs one extra fetch (per [`fetch.md#ready-label-timeline`](fetch.md)): for
each currently-`ready for maintainer review` PR, the timestamp of its most
recent `LabeledEvent` adding that label. Aliased GraphQL, ~30 PRs per call.
+
+Then for each top-pressure area (top 5 by Step 5f's score, filtered to areas
with ≥ 3 currently-ready PRs), compute a 6-bucket cumulative count:
`ready_count[a][w] = count of currently-ready PRs in area a where labeled_at <=
w.end`.
+
+Feeds the dashboard's "Ready-for-review trend" multi-line chart. See
[`aggregate.md#ready-for-review-trend-by-top-areas`](aggregate.md) for the
exact spec and rendering rules.
+
+---
+
+## Step 5e — Compute closed-by-triage-reason buckets
+
+Pure function of the closed/merged-since-cutoff PR set (Step 3) — reuses the
existing per-PR `is_triaged` / `responded_before_close` / `merged` flags.
+
+For each weekly bucket, classify each closed PR into exactly one of four
categories: `merged`, `closed-after-responded`,
`closed-after-triage-no-response`, `closed-no-triage`. Sum per category per
week.
+
+Feeds the dashboard's "Closed-by-triage-reason per week" stacked bar chart.
See [`aggregate.md#closed-by-triage-reason-per-week`](aggregate.md) for the
category definitions, colour map, and summary line.
+
+---
+
+## Step 5f — Compute area pressure scores
+
+Pure function of the classified open-PR set.
+
+Per area, compute a **pressure score** = weighted sum of urgent PR conditions.
The weights are defined in [`aggregate.md#pressure-score`](aggregate.md):
+
+- untriaged non-draft, > 4 weeks old → 5 pts
+- untriaged non-draft, 1–4 weeks old → 3 pts
+- untriaged non-draft, < 1 week old → 1 pt
+- triaged-waiting, > 7 days old → 2 pts (author abandoned, sweep candidate)
+- ready-for-review (label present) → 1 pt (queue waiting on maintainer review)
+- everything else → 0 pts (drafts the maintainer can ignore until author
engages)
+
+Sort areas by score descending; render the top 8 (filtering areas with < 3
contributor PRs as noise) in the "Pressure by area" panel.
+
+---
+
+## Step 6 — Render dashboard
-Emit the two tables in the order defined by [`render.md`](render.md):
+Render the maintainer dashboard per the layout in
[`render.md#dashboard-layout`](render.md):
-1. **Triaged PRs — Final State since `<cutoff>`** — one row per area where
`Triaged Total > 0`.
-2. **Triaged PRs — Still Open** — one row per area where `Total > 0`, plus the
`TOTAL` row.
-3. **Legend** — one short paragraph explaining the non-obvious columns.
+1. **Context line** — repo, open count, cutoff, viewer, timestamp.
+2. **Hero cards (4)** — health rating, total open, ready count,
untriaged-non-draft count.
+3. **What needs attention** — recommendation list from Step 5a.
+4. **Closure velocity** — weekly bar chart from Step 5b.
+5. **Opened vs closed momentum** — line chart from Step 5c.
+6. **Ready-for-review trend** — multi-line chart from Step 5d (top areas).
+7. **Closed by triage reason** — stacked-bar chart from Step 5e.
+8. **Pressure by area** — top areas from Step 5f.
+9. **Triage funnel** — coverage %, response rate %, stalest bucket, this-week
velocity.
+10. **Detailed tables** (collapsed by default):
+ 1. **Triaged PRs — Final State since `<cutoff>`** — one row per area where
`Triaged Total > 0`.
+ 2. **Triaged PRs — Still Open** — one row per area where `Total > 0`, plus
the `TOTAL` row.
+11. **Legend** — verbose explanation of every colour, column abbreviation, and
computed metric on the dashboard.
-The tables are Markdown (GitHub-flavoured) so the same output renders cleanly
in the CLI, in a Slack paste, or pasted into a GitHub comment.
+The dashboard is **HTML by default** so the colour-coded hero cards, action
priority bars, and velocity bars render correctly. A Markdown fallback (and a
Rich terminal-tables variant for the detailed-tables section only) is produced
when the maintainer passes `markdown` or `tables-only`. See
[`render.md`](render.md) for the full layout, the colour scheme, and the
recommendation rule definitions.
---
@@ -196,9 +284,10 @@ The tables are Markdown (GitHub-flavoured) so the same
output renders cleanly in
- **No mutations.** See Golden rule 1.
- **No per-PR drill-in.** The output is aggregate — if the maintainer wants to
inspect a specific PR, they run `pr-management-triage pr:<N>` or open it in the
browser.
-- **No timeline / trend charts.** A single snapshot per invocation. Tracking
week-over-week is the maintainer's job — re-run the skill at a different
`since:` date if needed.
- **No author-level stats.** Grouping is by area label, not by author login. A
stats-by-author skill is a separate scope.
- **No PR *quality* scoring.** CI pass/fail, diff size, and review-thread
counts are all omitted from the aggregate — they belong in the per-PR
`pr-management-triage` view.
+- **No long-term historical trends.** The closure-velocity panel covers the
last 6 weeks computed from the closed-since-cutoff fetch (one snapshot at fetch
time). There is no persistent time-series store; tracking month-over-month is
the maintainer's job — re-run the skill at a different `since:` date if needed.
+- **No automatic actions from recommendations.** Every "What needs attention"
entry is a *suggestion* with a slash-command the maintainer can paste. The
stats skill itself never invokes another skill, never adds labels, never closes
PRs.
---
diff --git a/.claude/skills/pr-management-stats/aggregate.md
b/.claude/skills/pr-management-stats/aggregate.md
index 627e557..3d4aafc 100644
--- a/.claude/skills/pr-management-stats/aggregate.md
+++ b/.claude/skills/pr-management-stats/aggregate.md
@@ -99,8 +99,192 @@ Percentages can legitimately sum to > 100% when a PR was
both merged and respond
---
+## Pressure score
+
+Per area, the dashboard's "Pressure by area" ranking uses a weighted urgency
score so areas with stale-and-untriaged contributor PRs surface above areas
with healthy queues regardless of raw size.
+
+For each contributor PR in an area, add the matching weight (first-match-wins,
top to bottom):
+
+| Condition | Weight | Rationale |
+|---|---|---|
+| ready-for-review label present | **1** | queue waiting on maintainer review
— soft pressure |
+| triaged-waiting AND triage comment age ≥ 7 days | **2** | author abandoned;
stale-sweep candidate |
+| draft (any age, any triage state) | **0** | author's court — not maintainer
pressure |
+| untriaged non-draft AND `last_author_at` ≥ 28 days | **5** | most urgent —
slipped through triage |
+| untriaged non-draft AND 7–28 days | **3** | author still likely active;
needs triage soon |
+| untriaged non-draft AND < 7 days | **1** | recent — give the next sweep a
chance |
+
+Collaborator-authored PRs (`OWNER`/`MEMBER`/`COLLABORATOR`) score **0**
regardless of state — they have a different lifecycle (see
[`#counters-per-area`](#counters-per-area)).
+
+Sort areas by score descending. The dashboard renders the top 8 areas; areas
with fewer than 3 contributor PRs are filtered as noise (a tiny area with one
stale PR shouldn't dominate the ranking).
+
+The score is also used to bucket the area into a severity colour for the
dashboard:
+
+| Score | Severity | Border colour |
+|---|---|---|
+| ≥ 30 | high | red |
+| 15–29 | medium | amber |
+| < 15 | low | grey |
+
+Tune the weights here in lockstep with the recommendation rules in
[`render.md#recommendation-rules`](render.md) — they share the same notion of
"what's urgent" and drift between the two would produce contradictory dashboard
sections.
+
+---
+
+## Weekly velocity
+
+The dashboard's "Closure velocity" panel buckets the closed-since-cutoff PR
set into the last 6 calendar-weeks (rolling, anchored on the fetch-start
`<now>`).
+
+For each `w` in `0..5`:
+
+```text
+window_end = now - w * 7 days
+window_start = window_end - 7 days
+```text
+
+`w == 0` is the current week (oldest = `<now> - 7d`, newest = `<now>`). `w ==
5` is the oldest week in the chart.
+
+Per window, count three metrics:
+
+| Field | Count rule |
+|---|---|
+| `merged` | PR has `merged == true` AND `closedAt` falls in the window |
+| `closed` | PR has `merged == false` AND `closedAt` falls in the window |
+| `triaged_then_responded` | PR is triaged (per
[`classify.md#triage-marker`](classify.md)) AND `closedAt` falls in the window
AND the author commented after the triage comment |
+
+Render the bars oldest → newest (so the eye sweeps left-to-right matching
natural time order). Each bar is a stacked `merged` (green) + `closed` (grey)
segment, normalised to the maximum total in the 6-week window.
+
+Below the bars, print three summary numbers:
+
+- **6-week total** — sum of `merged + closed` across all six windows.
+- **avg/wk** — `total / 6`, rounded.
+- **peak** — `max(merged + closed)` across the six windows.
+
+The avg and peak give the maintainer a quick sense of whether this week is
normal, slow, or unusually busy.
+
+---
+
+## Opened-vs-closed weekly buckets
+
+The dashboard's "Opened vs closed momentum" line chart needs a parallel bucket
counting **PRs opened** per week (in addition to the closures already computed
above).
+
+For each of the same six rolling windows (`w` in `0..5`), count:
+
+| Field | Count rule |
+|---|---|
+| `opened` | any PR (open or closed at fetch time) where `createdAt` falls in
the window |
+| `closed_total` | `merged + closed` from the velocity bucket above |
+| `net_delta` | `opened - closed_total` (positive = backlog growing that week,
negative = shrinking) |
+
+`opened` requires combining the open-PR set (Step 1 fetch) and the
closed-since-cutoff set (Step 3 fetch) into a single iteration — every PR's
`createdAt` is checked against the window regardless of current state. PRs
opened *before* the cutoff but closed *within* the window count for the closed
bucket but not the opened bucket (their createdAt is out of window).
+
+Below the chart, print two summary lines computed from these buckets:
+
+```text
+Net delta this week: ±<N> PRs (<opened> opened - <closed_total> closed)
+6-week net: ±<N> PRs (<sum_opened> opened - <sum_closed> closed) — backlog
<growing|shrinking|stable>
+```text
+
+`stable` is used when `|6-week net| < 10`. Anything bigger reads as a real
direction.
+
+The line chart itself is rendered via inline SVG in [`render.md`](render.md).
The aggregate layer just produces the per-week numbers — chart geometry (line
interpolation, axes, gridlines) is purely a render concern.
+
+### Why opened *and* closed (not just net)
+
+Net alone hides activity. A week with 100 opened and 100 closed has the same
net as a week with 0 opened and 0 closed, but the maintainer experience is
wildly different — the first is a busy week, the second is dormant. Rendering
both lines lets the maintainer see "we're keeping up" vs "nothing's happening"
at a glance.
+
+---
+
+## Ready-for-review trend by top areas
+
+The dashboard's "Ready-for-review trend" panel shows the cumulative count of
currently-`ready for maintainer review` PRs over the last 6 weeks, broken down
by the top-N highest-pressure areas (default N = 5; areas with fewer than 3
currently-ready PRs are excluded as noise).
+
+For each PR currently carrying the label, the **labeled-at timestamp** is the
`createdAt` of the most recent `LabeledEvent` where `label.name == "ready for
maintainer review"` from the PR's timeline (see
[`fetch.md#ready-label-timeline`](fetch.md)).
+
+For each top area `a` and each weekly bucket `w` in `0..5`:
+
+```text
+ready_count[a][w] = count of currently-ready PRs in area a where labeled_at <=
w.end
+```text
+
+This is a **cumulative** count, not a per-week delta — by construction it's
monotonically non-decreasing because PRs that lose the label drop out of the
*currently-ready* set entirely (they're not in this dataset).
+
+Render the result as a multi-line chart, one line per area, with the area's
pressure-band colour from [`#pressure-score`](#pressure-score) (red / amber /
grey lines). Each line ends at the current count visible in the dashboard's
hero card.
+
+Below the chart, print a one-line per-area summary:
+
+```text
+providers: 46 ready (+8 in last 7d)
+task-sdk: 40 ready (+5 in last 7d)
+…
+```text
+
+The "+N in last 7d" is the count of PRs labeled within the last week —
surfaces whether the queue is growing faster than it can be reviewed.
+
+### Why cumulative, not weekly-delta
+
+A maintainer looking at the trend wants to see "how is the review backlog
evolving" — a steadily-growing line means review velocity isn't keeping up with
triage promotion. Per-week deltas would show only the additions and obscure
that the queue keeps *being* big. The cumulative view answers the actual
question.
+
+---
+
+## Closed by triage reason per week
+
+The dashboard's "Closed-by-triage-reason" panel shows the per-week stacked
breakdown of closed/merged PRs by triage outcome. Each closed PR falls into
exactly one of four categories:
+
+| Category | Definition | Colour |
+|---|---|---|
+| `merged` | `merged == true` (regardless of triage state) | green |
+| `closed-after-responded` | `merged == false` AND `is_triaged` AND
`responded_before_close == true` | amber |
+| `closed-after-triage-no-response` | `merged == false` AND `is_triaged` AND
`responded_before_close == false` | red |
+| `closed-no-triage` | `merged == false` AND NOT `is_triaged` | grey |
+
+For each weekly bucket, count PRs in each category. Render as a 6-row stacked
horizontal bar (same layout as the velocity chart — newest at the bottom).
+
+The four colours map directly to maintainer outcomes:
+
+- **green** = success path (PR shipped)
+- **amber** = engagement-but-no-merge (author responded but PR didn't make it
— could be design rejection, scope change, etc.)
+- **red** = stale-sweep / abandonment (triaged then ghosted; usually closed
via sweep 1a)
+- **grey** = no-triage closure (author closed it themselves, or maintainer
closed without going through triage)
+
+A healthy week has a tall green segment with thin amber/red/grey segments. A
week dominated by red is a triage-followup pile-up; a week dominated by grey is
contributors self-cleaning their own PRs (also fine).
+
+Below the bars, print three summary numbers:
+
+```text
+6-week breakdown: <merged_total> merged · <closed_after_responded>
engaged-then-closed · <closed_no_response> sweep-closed · <closed_no_triage>
no-triage
+```text
+
+This panel makes the *quality* of closures visible — the velocity panel says
"how many", this panel says "of what type".
+
+---
+
+## Health rating
+
+Top-of-dashboard hero card. Computed as a count of fired threshold conditions:
+
+| Condition | Issue points |
+|---|---|
+| Any contributor non-draft PR untriaged AND > 4 weeks old | **2** |
+| > 30 contributor non-draft PRs untriaged AND in 1–4 weeks bucket | **1** |
+| > 100 PRs labelled `ready for maintainer review` | **1** |
+| > 20 stale-triaged drafts (drafts where triage comment ≥ 7 days old AND no
author response) | **1** |
+
+Sum the points and map:
+
+| Total points | Label | Colour |
+|---|---|---|
+| 0 | `✅ Healthy` | green |
+| 1–2 | `⚠️ Needs attention` | amber |
+| ≥ 3 | `🔥 Action needed` | red |
+
+The `>4w untriaged` condition is weighted 2x because PRs that have slipped
past the 4-week mark without triage are the highest-cost failure mode — they
make the project look unresponsive even though everything else may be fine. A
single `>4w` PR alone reaches "needs attention".
+
+The thresholds are intentionally conservative — most well-tended repos sit at
0 or 1 issue point. If a maintainer sees the rating regularly hitting "Action
needed", that's the signal to schedule a focused triage day.
+
+---
+
## Cache
-Persist `area_stats` and `totals` into the scratch cache as JSON. The cache
entry is keyed by `(fetch_timestamp, cutoff)` — if the maintainer re-invokes
with the same cutoff inside the 15-minute freshness window, render from cache
without re-fetching.
+Persist `area_stats`, `totals`, the per-area pressure scores, the weekly
velocity buckets, and the recommendation list into the scratch cache as JSON.
The cache entry is keyed by `(fetch_timestamp, cutoff)` — if the maintainer
re-invokes with the same cutoff inside the 15-minute freshness window, render
from cache without re-fetching.
The cache is advisory for stats. If a consumer (e.g. a wrapping `loop` that
re-runs every 30 minutes) wants live numbers, invalidate the cache explicitly
with `clear-cache`.
diff --git a/.claude/skills/pr-management-stats/classify.md
b/.claude/skills/pr-management-stats/classify.md
index a3a0785..556b7b9 100644
--- a/.claude/skills/pr-management-stats/classify.md
+++ b/.claude/skills/pr-management-stats/classify.md
@@ -73,7 +73,7 @@ pullRequest(number: $n) {
}
}
}
-```
+```text
If `actor.login` is the viewer (or any maintainer login tracked in the session
cache) and `createdAt >= triage_comment_createdAt`, mark the PR as
`drafted_by_triager` with `drafted_at = createdAt`.
@@ -97,7 +97,7 @@ last_author_interaction = max(
last_commit.committedDate,
pr.createdAt,
)
-```
+```text
Why `max`: a PR freshly opened without activity still needs *some* age signal
— `createdAt` is the floor. A PR where the author commented after pushing a
commit should be counted by the comment timestamp, not the commit.
@@ -122,7 +122,7 @@ A PR is by a *contributor* (for the `Contrib.` column) when:
```text
authorAssociation NOT IN (OWNER, MEMBER, COLLABORATOR)
-```
+```text
Everything else (including `FIRST_TIME_CONTRIBUTOR`, `FIRST_TIMER`,
`CONTRIBUTOR`, `NONE`) counts as contributor. Bots (`[bot]`-suffixed logins or
`dependabot` / `github-actions`) are NOT contributors — they're a separate
class and should be excluded from the open-PR stats entirely. Filter bots at
fetch time, not at classification time, so the denominator in every percentage
excludes them.
@@ -142,12 +142,39 @@ Table 1's `Responded` column measures, per area, how many
triaged PRs got an aut
responded_before_close =
is_triaged AND
exists(comment by pr.author where comment.createdAt >
triage_comment.createdAt AND comment.createdAt <= pr.closedAt)
-```
+```text
Count the PR as responded if it has the marker AND an author comment between
triage and close. `%Responded` = responded / triaged_total for that area.
---
+## Pressure weight
+
+Per-PR helper used by [`aggregate.md#pressure-score`](aggregate.md). Returns
the integer weight a single contributor PR contributes to its area's pressure
score. Pure function of fields already populated above; no extra fetches.
+
+```text
+def pressure_weight(pr) -> int:
+ if pr.author_association in ("OWNER", "MEMBER", "COLLABORATOR"):
+ return 0 # collaborator PRs don't add maintainer
pressure
+ if pr.is_ready_for_review:
+ return 1 # waiting on maintainer review — soft
pressure
+ if pr.is_triaged_waiting and (now - pr.triage_ts) >= 7 days:
+ return 2 # stale triaged — sweep candidate
+ if pr.is_draft:
+ return 0 # author's court
+ # untriaged non-draft
+ age = now - pr.last_author_at
+ if age >= 28 days: return 5
+ if age >= 7 days: return 3
+ return 1
+```text
+
+The first-match-wins ordering matters: a ready-for-review PR that's also a
stale triaged draft scores 1 (ready takes precedence — once it has the label,
the maintainer is the gate, not the author). Keep this function in lockstep
with the table in [`aggregate.md#pressure-score`](aggregate.md).
+
+---
+
## Re-classification stability
The stats run must produce the same numbers when invoked twice on the same
cached state. Keep the classification pure (no time-dependent randomness) and
anchor age-bucket cutoffs to `<now>` captured at fetch start, not at render
time. Otherwise a slow run drifts PRs across buckets between fetch and render.
+
+This applies to `pressure_weight` too — the `7d` / `28d` thresholds are
computed from the same `<now>` as the age buckets, so a PR that's exactly on a
bucket boundary scores deterministically across re-runs of the same fetch.
diff --git a/.claude/skills/pr-management-stats/fetch.md
b/.claude/skills/pr-management-stats/fetch.md
index 412fabb..bbe49d9 100644
--- a/.claude/skills/pr-management-stats/fetch.md
+++ b/.claude/skills/pr-management-stats/fetch.md
@@ -51,13 +51,13 @@ query(
}
}
}
-```
+```text
### `searchQuery`
```text
is:pr is:open repo:<repo> sort:created-asc
-```
+```text
Sort is `created-asc` (oldest PR first) so the age-bucket counts accumulate
deterministically — same PR always lands in the same row in a re-run. `is:pr`
filters out issues in the same search.
@@ -69,7 +69,7 @@ gh api graphql \
-F batchSize=50 \
-F cursor="$CURSOR" \
--field query=@/tmp/pr-management-stats-open.graphql
-```
+```text
### Batch size
@@ -116,7 +116,7 @@ query(
}
}
}
-```
+```text
Notice `comments(last: 25)` — higher than the 10 used for open PRs because a
triaged PR that was then closed will often have extra follow-up comments; we
still need to find the original triage marker. If the marker isn't in the last
25 comments for a given PR, drop that PR from Table 1 (it wasn't triaged by the
bot/viewer convention).
@@ -134,7 +134,7 @@ This pull request has had no activity from the author for
over 4 weeks.
@<author>, you are welcome to reopen this PR when you are ready to continue
working on it. Thank you for your contribution!
<!-- Pull Request quality criteria -->
-```
+```text
In this case the visible body contains no "Pull Request quality criteria" text
at all — the only marker is the HTML comment at the bottom. Running the same
marker match against `bodyText` misses these entirely. A spot-check on a 40-PR
sample from `<upstream>` found ~10% of triaged-marker comments were
HTML-comment-only: invisible to a `bodyText`-based search.
@@ -174,7 +174,7 @@ Two stages:
```graphql
query {
- repository(owner:$owner,name:$repo) {
+ repository(owner:"apache",name:"airflow") {
pr63407: pullRequest(number:63407) {
number author{login} authorAssociation closedAt mergedAt state merged
labels(first:30){nodes{name}}
@@ -184,7 +184,7 @@ query {
# … 30 aliases per query
}
}
-```
+```text
For each returned PR, apply the same marker check as
[`classify.md`](classify.md) (`Pull Request quality criteria` substring in raw
`body`, author in `OWNER/MEMBER/COLLABORATOR`). Record `responded_before_close`
when the author has a comment after the triage marker and on or before
`closedAt`.
@@ -201,7 +201,7 @@ When the maintainer explicitly asks for a quick
approximation (`fast-closed` fla
```text
is:pr -is:open repo:<repo> closed:>=<cutoff> sort:updated-desc
-```
+```text
The fast path must print a clear caveat above Table 1: *"fast-closed mode:
Table 1 uses the free-text search index which currently undercounts older
triaged+merged PRs on `<upstream>`. Re-run without `fast-closed` for accurate
numbers."*
@@ -209,7 +209,7 @@ The fast path must print a clear caveat above Table 1:
*"fast-closed mode: Table
```text
is:pr -is:open repo:<repo> closed:>=<cutoff> sort:updated-desc
-```
+```text
`-is:open` matches both `closed` and `merged` states. `closed:>=` is GitHub's
search qualifier for closed/merged date. `sort:updated-desc` keeps the most
recent final actions at the top (so Ctrl-C'ing a long pagination returns the
freshest portion).
@@ -221,7 +221,7 @@ gh api graphql \
-F batchSize=50 \
-F cursor="$CURSOR" \
--field query=@/tmp/pr-management-stats-closed.graphql
-```
+```text
### Cutoff default
@@ -229,7 +229,7 @@ If the maintainer doesn't pass `since:<date>`, default to
six weeks ago:
```bash
cutoff=$(date -u -d "-42 days" +%Y-%m-%d)
-```
+```text
Six weeks covers ~a sprint-and-a-half, which is long enough to smooth out
day-to-day variation in closures without being so far back that the numbers
lose meaning.
@@ -248,7 +248,7 @@ while : ; do
cursor=$(echo "$out" | jq -r '.data.search.pageInfo.endCursor')
[ "$hasNext" = "true" ] || break
done
-```
+```text
Two safety valves:
@@ -284,6 +284,41 @@ sequence (e.g. `\z`, `\e`), even `strict=False` fails with
---
+## Ready-label timeline
+
+Needed for the dashboard's "Ready-for-review trend" chart (see
[`aggregate.md#ready-for-review-trend-by-top-areas`](aggregate.md)). The
open-PRs query above tells us *which* PRs currently carry the `ready for
maintainer review` label, but not *when* the label was added. Without that
timestamp the trend chart can't show growth.
+
+Run an aliased GraphQL query, **30 PRs per call**, that fetches each PR's
`LabeledEvent` timeline filtered to the relevant label:
+
+```graphql
+query {
+ repository(owner:"<owner>",name:"<name>") {
+ pr12345: pullRequest(number:12345) {
+ number
+ timelineItems(last:50, itemTypes:[LABELED_EVENT]) {
+ nodes {
+ ... on LabeledEvent {
+ createdAt
+ label { name }
+ }
+ }
+ }
+ }
+ # … repeated for the other 29 PRs in the batch
+ }
+}
+```text
+
+Per-PR processing: pick the most recent `LabeledEvent` whose `label.name ==
"ready for maintainer review"`. That `createdAt` is the PR's `ready_at`
timestamp. PRs that never had the label (shouldn't happen — input set is
filtered to currently-labelled PRs) are dropped silently.
+
+`itemTypes:[LABELED_EVENT]` is critical — without it the timeline returns
every event (commits, comments, reviews) and blows the complexity budget. With
the filter, the per-PR cost is tiny.
+
+Budget: ~7 GraphQL calls for ~200 currently-ready PRs on `<upstream>`. Well
under the per-session ceiling.
+
+Cache the per-PR `ready_at` in
`/tmp/pr-management-stats-cache-<repo-slug>.json` keyed by `(pr_number,
head_sha)` — same convention as the rest of the cache. The label-add timestamp
is stable across head SHAs (it's a label event, not a commit event), so the
cache hit rate is high.
+
+---
+
## Why no `statusCheckRollup` / `mergeable` / `reviewThreads`
`pr-management-triage` needs all three for classification;
`pr-management-stats` does not. Dropping them keeps the query complexity well
below GitHub's per-page ceiling, which is how we can safely run `batchSize=50`
here versus `20` in `pr-management-triage`. If a future stats column ever needs
one of those fields, raise only that query's complexity — don't pull them into
the default shape "just in case".
@@ -305,7 +340,7 @@ sequence (e.g. `\z`, `\e`), even `strict=False` fails with
"prs": { "12300": {"state": "MERGED", "responded_before_close": true,
"areas": ["scheduler"]} }
}
}
-```
+```text
### Invalidation
diff --git a/.claude/skills/pr-management-stats/render.md
b/.claude/skills/pr-management-stats/render.md
index 9f57ae7..e9981fb 100644
--- a/.claude/skills/pr-management-stats/render.md
+++ b/.claude/skills/pr-management-stats/render.md
@@ -3,162 +3,297 @@
# Render
-Print the two tables, the legend, and a summary line. **Primary output is
Rich-rendered colored tables to the terminal** — the same library the
now-removed `breeze pr auto-triage` and `breeze pr stats` commands used, so the
colour scheme and state-marker vocabulary stay consistent with what the
maintainer already knows. Rich handles wide tables correctly (horizontal
overflow, per-column wrapping) so Table 2's 20+ columns don't fall apart in a
normal terminal.
+The skill produces a **maintainer dashboard** as the primary output: an HTML
page with five colour-coded sections at the top, a time-trend line chart, and
the full per-area tables collapsed underneath. The dashboard is designed to
answer "what should I do today" at a glance, with the underlying numbers one
click away.
-A Markdown fallback is produced when the maintainer explicitly asks for
shareable output (`markdown` flag, or the output is being piped to a non-tty).
+A Rich-rendered terminal-tables variant and a Markdown fallback are produced
when the maintainer asks for them (`tables-only` or `markdown` flag, or output
is being piped to a non-tty). Those modes render only the per-area tables —
they skip the hero cards, recommendation panel, charts, and pressure ranking,
since those rely on visual layout that doesn't translate to a terminal.
---
-## Why Rich, not Markdown by default
+## Why HTML is the default
-The first cut of this skill emitted GitHub-flavoured Markdown tables. In
practice this produced two problems:
+The previous skill iteration emitted Rich tables as the primary output. Two
empirical problems pushed the dashboard to HTML:
-1. Table 2's width (20+ columns) exceeded the rendering width of common
Markdown viewers. The table collapsed into a wrapped mess of `|`-separated
values that read as prose, not a table.
-2. Markdown carries no colour, so state-relevant cells (CI failing, many
commits behind, etc.) had no visual cue — the maintainer had to compare numbers
by eye.
+1. **The signal is in the recommendations, not the tables.** Maintainers asked
variations of "what should I do" — a 17-column table requires the maintainer to
visually scan, compare, and infer. The dashboard surfaces the same conclusions
explicitly with priority colours.
+2. **Trends need pictures.** Closure velocity over time and opened-vs-closed
momentum are intuitive as bar / line charts and dense as numeric columns. SVG
inline charts solve both.
-Rich solves both: its table renderer computes column widths from the actual
terminal size, overflows horizontally instead of wrapping (or truncates cells
with an ellipsis if set), and supports inline colour markup. Rich is also what
the now-removed `breeze pr stats` and `breeze pr auto-triage` commands used, so
anyone migrating from the old breeze CLI sees the same colours and state names.
+Rich's per-cell colour markup is still useful for the *details* tables (which
the dashboard embeds in collapsed `<details>` blocks), so the colour vocabulary
below is shared between HTML and Rich.
---
-## Rich rendering
+## Dashboard layout
-Use `rich.console.Console` + `rich.table.Table`. The shape mirrors what the
now-removed `breeze pr stats` command used, kept verbatim so the output stays
familiar to maintainers used to that command.
+The HTML page renders sections in this exact order. Section headings and their
meanings are stable across runs so a maintainer can scroll-jump consistently.
-Minimum table setup:
+### 1. Title bar + context line
-```python
-from rich.console import Console
-from rich.table import Table
-from rich.panel import Panel
+```text
+📊 <upstream> — Maintainer dashboard
+Tuesday, May 6, 2026 · 14:33 UTC · viewer @potiuk · 6-week window since
2026-03-24
+```text
-console = Console()
+The title is plain text. Context line includes `<weekday>, <month> <day>,
<year> · <HH:MM> UTC · viewer @<login> · 6-week window since <cutoff>`. Use the
fetch-start `<now>`, not render-end, so a slow run remains a single-moment
snapshot.
-t = Table(
- title=f"Triaged PRs — Final State since {cutoff} ({repo})",
- title_style="bold",
- show_lines=True,
- show_footer=True,
-)
-t.add_column("Area", style="bold cyan", min_width=12, footer="Area")
-t.add_column("Triaged Total", justify="right", style="yellow", footer="Triaged
Total")
-# … (see colour scheme below)
+### 2. Hero cards (4-column grid)
-for area in sorted_areas:
- t.add_row(area, str(triaged_total), …)
+Four equally-sized cards, each one big number with a sub-label:
-# TOTAL row with bold-white style per cell
-t.add_row("[bold white]TOTAL[/]", f"[bold white]{n}[/]", …, style="on grey7",
end_section=True)
+| Card | Big number | Sub-label | Colour rule |
+|---|---|---|---|
+| **Repo Health** | the rating label (`✅ Healthy` / `⚠️ Needs attention` / `🔥
Action needed`) | "based on triage backlog + queue size" | green / amber / red,
per [`aggregate.md#health-rating`](aggregate.md) |
+| **Open PRs (non-bot)** | total open count | `<contrib_count> from
contributors · <collab_count> collaborator-authored` | blue (informational) |
+| **Ready for review** | `len(ready_open)` | `<pct>% of contributor queue` |
green |
+| **Untriaged non-drafts** | `len(untriaged_nondraft)` | `<X> are >4 weeks
old` | red if >0 are >4w, amber if total > 30, green otherwise |
-console.print(t)
-```
+Card layout is responsive: 4-column on wide screens, 2-column on narrow,
1-column on mobile-width. The big number uses 32px font; sub-labels are 12px
dim grey.
-Key settings:
+### 3. What needs attention (action panel)
-- `show_lines=True` — horizontal separators between rows make the 20-column
Table 2 readable.
-- `show_footer=True` with a `footer=` on each column — column headers repeat
at the bottom for long tables.
-- `end_section=True` on the TOTAL row plus `style="on grey7"` — the totals row
is visually separated and tinted, matching the look the predecessor `breeze pr
stats` command used.
+A vertical list of action cards built from the recommendation rules in
[`#recommendation-rules`](#recommendation-rules) below. Each card has a
coloured left border (red = high, amber = medium, grey = low), an icon, a
one-line title, a 1–2-line detail explanation, and a monospace `code` block
holding the exact slash-command the maintainer can paste.
----
+If zero rules fire, render a single low-priority card with a `✨` icon and the
body "No urgent actions detected. Queue is in healthy shape — periodic
/pr-management-triage when convenient." Never leave the section visually empty.
+
+The order inside the panel is: high-priority first (sorted by count
descending), then medium (same), then low. Within a tier the rule firing order
from [`#recommendation-rules`](#recommendation-rules) breaks ties.
+
+### 4. Closure velocity (per-week bar chart)
+
+Title: **Closures per week (oldest → newest)**
+
+A 6-row stacked horizontal bar chart, one row per week (W-5, W-4, W-3, W-2,
W-1, this wk).
+
+Each bar is two stacked segments:
+
+- **green** — `merged` count
+- **grey** — `closed` count (closed without merging)
+
+Bar width = `(merged + closed) / max_weekly_total * 100%`. Numbers inside each
segment when wide enough; otherwise to the right of the bar.
+
+Below the bars: a one-line summary of `6-week total: N · avg N/wk · peak N/wk`.
+
+This panel reads as "how much did we ship per week, and is that trending up or
down". Use the labelled date (`05-06`) on the left axis so the maintainer sees
the actual calendar weeks, not abstract `W-N` labels.
+
+### 5. Opened vs closed momentum (line chart)
+
+Title: **Opened vs closed PRs (last 6 weeks)**
+
+An inline SVG line chart with two lines:
+
+- **blue line** — count of PRs *opened* per week (createdAt in the window)
+- **green line** — count of PRs *closed/merged* per week (closedAt in the
window, includes both merge and close-without-merge)
-## Colour scheme (inherited from the predecessor breeze commands)
+A horizontal grid line at 0 and at the max value. Y-axis labels (3 ticks: 0,
mid, max). X-axis labels: week-start dates.
-Use the same palette the now-removed `breeze pr auto-triage` and `breeze pr
stats` commands used so state meanings transfer without relearning. Markup uses
Rich's inline `[color]…[/]` syntax.
+Below the chart, a small "**Net delta**" line:
-| Concept | Colour | Used in |
+```text
+Net delta this week: +12 PRs (102 opened - 90 closed)
+6-week net: -45 PRs (1450 opened - 1495 closed) — backlog shrinking
+```text
+
+The two-line mini-summary translates the chart into the maintainer-relevant
question: "is the open-PR backlog growing or shrinking?". Negative net =
backlog shrinking (good); positive net = growing (need more close-out velocity).
+
+The line chart is kept inline-SVG (no JavaScript) so it renders in any
browser, in a Slack image preview, or in any embedded HTML viewer. Keep the
chart 720×220px so it's readable but doesn't dominate the page.
+
+### 6. Ready-for-review trend (multi-line chart)
+
+Title: **Ready-for-review trend (last 6 weeks, top areas)**
+
+An inline SVG line chart with one line per top-pressure area (default: top 5,
filtered to areas with ≥ 3 currently-ready PRs). Each line is **cumulative**:
at week W it shows the count of PRs that *currently* carry the `ready for
maintainer review` label and were labelled on or before W.
+
+Each area's line uses its pressure-band colour (red, amber, or grey per
[`aggregate.md#pressure-score`](aggregate.md)). Same SVG dimensions as the
opened-vs-closed chart (720×220px). The chart legend in the top-left lists each
area name with its line colour swatch.
+
+Below the chart, a per-area summary list:
+
+```text
+providers: 46 ready (+8 in last 7d)
+task-sdk: 40 ready (+5 in last 7d)
+…
+```text
+
+The "+N in last 7d" surfaces whether the queue is growing faster than
maintainers are reviewing — a steadily climbing line means review velocity
isn't keeping up.
+
+### 7. Closed by triage reason (stacked bar chart)
+
+Title: **Closed by triage reason (last 6 weeks)**
+
+Six rows, oldest → newest, one row per week. Each row is a
horizontally-stacked bar with four coloured segments:
+
+| Segment | Definition | Colour |
|---|---|---|
-| Area name | `bold cyan` | Area column of both tables |
-| Triage / Waiting for Author | `yellow` | `Triaged`, `%Responded` when < 100%
|
-| Responded | `green` | `Responded` column, `%Responded` when ≥ 50% |
-| Ready for review | `bold green` | `Ready`, `%Ready` columns, Table 1
`Merged` column |
-| Flagged / failing | `red` | `Closed` (when no merge), `%Closed`, CI-failing
indicators |
-| Drafted by triager | `magenta` | `Drafted by triager` |
-| Drafted age buckets (secondary) | `magenta dim` | `Drafted <1d` … `Drafted
>4w` |
-| Author-resp age buckets (secondary) | `dim` | `Author resp <1d` … `Author
resp >4w` |
-| Contributor (non-collab) | `bright_cyan` | `Contrib.`, `%Contrib.` |
-| Unknown / ambiguous | `dim` | fallback for `?` / `-` |
-| TOTAL row | `bold white` on `grey7` | the footer-before-footer TOTAL row |
-
-When a percentage cell is a close call (e.g. `%Responded` of exactly 50%),
keep the happier colour — `green` above 50, `yellow` below — matching the "when
in doubt, show it as still-ok" convention the predecessor breeze commands used.
+| **merged** | `merged == true` | green |
+| **closed-after-responded** | not merged, was triaged, author responded
before close | amber |
+| **closed-after-triage-no-response** | not merged, was triaged, author never
responded (sweep close) | red |
+| **closed-no-triage** | not merged, never triaged (author self-close, etc.) |
grey |
+
+Bar width per row = `(sum of all four) / max_weekly_total * 100%`. Numbers
inside each segment when wide enough; otherwise to the right of the bar.
+
+Below the bars, a one-line breakdown of the 6-week totals:
+
+```text
+6-week breakdown: <merged> merged · <closed-after-responded>
engaged-then-closed · <closed-no-response> sweep-closed · <closed-no-triage>
no-triage
+```text
+
+A healthy week is mostly green with thin amber/red segments. A red-dominated
week means a stale-sweep landed; a grey-dominated week means contributors are
self-cleaning (also healthy, but worth noticing).
+
+### 8. Pressure by area
+
+Title: **Pressure by area** (with a sub-line *"Pressure score = weighted sum
of untriaged-old PRs per area. Higher score = more maintainer attention
needed."*)
+
+Up to 8 rows, sorted by pressure score descending (filtering areas with < 3
contributor PRs). Each row is a horizontally-laid-out card with a coloured left
border (red / amber / grey, severity bands per
[`aggregate.md#pressure-score`](aggregate.md)) containing:
+
+- area name (cyan, bold, e.g. `providers`)
+- one-line stat: `<contrib_total> contributor PRs · <red>untriaged_4w</red>
>4w · <amber>untriaged_1_4w</amber> 1-4w · <grey>untriaged_recent</grey> recent
· <green>ready_pending</green> ready for review`
+- pressure score (right-aligned)
+- the slash-command to focus on this area: `/pr-management-triage
label:area:<X>` (dimmed)
+
+This panel answers "if I have 30 minutes, which area moves the most needles?".
Top row is always the highest-leverage focus.
+
+### 9. Triage funnel (4-column hero grid)
+
+A second hero grid, same layout as the top one, showing the funnel-health
summary numbers:
+
+| Card | Big number | Sub-label | Colour rule |
+|---|---|---|---|
+| **Triage Coverage** | `<pct>%` of contributor PRs that have been seen by a
maintainer (triaged + ready + draft / contributors) | `<seen> of <total>
contributor PRs have been seen by a maintainer` | green ≥ 50, amber 20–49, red
< 20 |
+| **Author Response Rate** | `<pct>%` of triaged PRs where the author replied
| `<responded> of <triaged> triaged PRs got an author reply` | same colour rule
|
+| **Stalest Bucket** | count of contributor PRs in the `>4w` age bucket |
"contributor PRs untouched >4 weeks" | red if > 50, amber if > 20, green
otherwise |
+| **This Week's Velocity** | this week's `merged + closed` total | `<merged>
merged · <closed> closed (avg <N>/wk)` | default (informational) |
+
+This grid completes the dashboard: hero cards at the top (queue size +
immediate red flags), recommendations next (what to do), velocity +
opened-vs-closed (momentum), pressure by area (where), and triage funnel
(process health).
+
+### 10. Detailed tables (collapsed `<details>` blocks)
+
+Two `<details>` elements, each opening into a compact area-grouped table:
+
+- **Triaged PRs — Final State since `<cutoff>`** — same structure as the
[Table 1](#table-1--triaged-prs-final-state) section below.
+- **Triaged PRs — Still Open** — same structure as [Table
2](#table-2--triaged-prs-still-open) below, possibly compact (drop the 8
age-bucket columns) for screen width.
+
+Both tables are HTML-rendered with the same colour scheme as the dashboard.
Maintainers who want the raw per-area numbers click to expand; the default view
is the dashboard sections above.
+
+### 11. Legend / methodology
+
+A bordered panel at the bottom explaining all the colours, columns, and
computed values. Critical content because the dashboard packs a lot of distinct
numbers into small footprints. See [`#legend`](#legend) below for the verbatim
block.
---
-## Triage state markers
+## Recommendation rules
-The triage workflow categorises every non-collab PR into one of four states
(the same four the now-removed `breeze pr auto-triage` overview table surfaced,
and that the [`pr-management-triage`](../pr-management-triage/SKILL.md) skill
continues to use):
+The "What needs attention" panel is built from this fixed rule set, evaluated
in order. Each rule that fires produces one entry in the panel.
-| Marker | Meaning | Colour |
-|---|---|---|
-| `Ready for review` | PR has the `ready for maintainer review` label |
`green` |
-| `Waiting for Author` | PR is triaged (quality-criteria comment posted), no
author response, OR the PR is draft | `yellow` |
-| `Responded` | author commented / pushed after the triage comment |
`bright_cyan` |
-| `-` | not yet triaged | `blue` |
+| # | Trigger | Priority | Icon | Title template | Action template |
+|---|---|---|---|---|---|
+| 1 | `len(untriaged_old) > 0` (any contributor non-draft >4w) | high | 🔥 |
`Triage <N> non-draft contributor PRs older than 4 weeks` |
`/pr-management-triage all PR issues (focus on >4w bucket)` |
+| 2 | `len(untriaged_old) == 0 AND len(untriaged_med) > 0` (1-4w bucket
non-empty) | medium | 👀 | `Triage <N> non-draft PRs aged 1-4 weeks` |
`/pr-management-triage all PR issues` |
+| 3 | `len(stale_triaged_drafts) > 0` (drafts triaged ≥ 7d ago, no reply) |
medium | 🗑️ | `Close <N> stale-triaged drafts (≥7d, no response)` |
`/pr-management-triage stale → sweep 1a` |
+| 4 | `len(ready_open) >= 50` | high | 📥 | `<N> PRs labeled "ready for
maintainer review"` | `/maintainer-review (filter: ready for maintainer
review)` |
+| 5 | `20 <= len(ready_open) < 50` | medium | 📥 | `<N> PRs in "ready for
maintainer review" queue` | same as rule 4 |
+| 6 | `len(responded_no_ready) > 0` (triaged + responded but not
ready-for-review) | medium | 🔄 | `<N> triaged PRs have author responses
awaiting re-triage` | `/pr-management-triage all PR issues (will surface as
mark-ready-with-ping)` |
+| 7 | top area's `untriaged_4w + untriaged_1_4w >= 5` | medium | 📍 | `Area
"<area>" has <total> contributor PRs (<X> untriaged >4w)` |
`/pr-management-triage label:area:<area>` |
+| 8 | `velocity_drop > 30` (last_wk total - this_wk total) | low | 📉 | `PR
closure velocity dropped <N> this week` | `No immediate action — check next
week.` |
+| 9 | top ready-trend area's growth in last 7d ≥ 10 PRs | low | 📈 |
`Ready-for-review queue in "<area>" grew by <N> this week` |
`/maintainer-review label:area:<area>` |
+| 10 | weekly closed-by-reason `closed_no_response > merged` for 2+ recent
weeks | medium | 🧹 | `Stale-sweep is dominating closures (last 2 weeks: <N>
sweep-close vs <M> merged)` | review `/pr-management-triage stale` cadence —
too many PRs reaching the sweep |
-`pr-management-stats` surfaces the same buckets at the area level. After Table
2, print a small **state-breakdown panel** that slices the full open-PR set
into the four triage-state markers:
+Rules 1 and 2 are **mutually exclusive** (only one fires depending on whether
any >4w PRs exist). Rules 4 and 5 are **mutually exclusive** (banding on
`ready_open` count). All other rules can fire independently.
-```python
-state_panel_lines = [
- "Triage-state breakdown:",
- f" [green]Ready for review[/] : {ready} PRs",
- f" [bright_cyan]Responded[/] : {responded} PRs (triaged, author
has replied)",
- f" [yellow]Waiting for Author[/] : {waiting} PRs (triaged, no response)
+ {drafts_not_ready} drafts",
- f" [blue]- (not yet triaged)[/] : {untriaged} PRs",
-]
-console.print(Panel("\n".join(state_panel_lines), title="By triage state",
border_style="cyan", expand=False))
-```
+When adding a new rule:
+
+- Prefer count-based triggers over percentage-based ones (counts are easier to
reason about for the maintainer).
+- The action template must be a literal slash-command the maintainer can paste
— never instructions like "consider running …".
+- Detail string should explain *why* the rule fired in one sentence, plus what
command will help.
+
+---
+
+## Colour scheme
+
+Shared between the HTML dashboard and the Rich terminal-tables fallback. HTML
uses CSS hex values; Rich uses its semantic colour names. Both palettes are
derived from the now-removed `breeze pr stats` / `breeze pr auto-triage`
commands so state-meaning carries over from the old CLI.
+
+| Concept | HTML (hex) | Rich | Used in |
+|---|---|---|---|
+| Area name | `#56d4dd` (cyan) | `bold cyan` | Area column of all tables, area
cards in pressure section |
+| Triage / Waiting for Author | `#d29922` (amber) | `yellow` | `Triaged`
count, `%Responded < 50%`, "Needs attention" health label |
+| Responded | `#56d364` (green) | `green` | `Responded` column, `%Responded ≥
50%`, "Healthy" health label |
+| Ready for review | `#56d364` (green, bold) | `bold green` | `Ready` count,
`%Ready` columns, "merged" Table 1 column |
+| Flagged / failing | `#f85149` (red) | `red` | `Closed` (without merge)
column, "Action needed" health label, > 4w untriaged callouts |
+| Drafted by triager | `#db61a2` (magenta) | `magenta` | `Drafted by triager`
column |
+| Drafted age buckets (secondary) | `#6f3a55` (dim magenta) | `magenta dim` |
`Drafted <1d` … `Drafted >4w` |
+| Author-resp age buckets (secondary) | `#6e7681` (dim) | `dim` | `Author resp
<1d` … `Author resp >4w` |
+| Contributor (non-collab) | `#76e3ea` (bright cyan) | `bright_cyan` |
`Contrib.`, `%Contrib.`, "Open PRs" hero card |
+| Informational (counts) | `#76e3ea` (bright cyan) | `bright_cyan` | "Open
PRs" hero, neutral big numbers |
+| Unknown / ambiguous | `#6e7681` (dim) | `dim` | `?` / `—` cells,
low-priority recommendations |
+| TOTAL row | `#f0f6fc` on `#21262d` (white on dark grey) | `bold white on
grey7` | Footer-before-footer TOTAL row |
+| **Velocity bars: merged segment** | `#56d364` (green) | n/a | Per-week bar
chart, merged portion |
+| **Velocity bars: closed segment** | `#6e7681` (grey) | n/a | Per-week bar
chart, closed-without-merge portion |
+| **Line chart: opened line** | `#58a6ff` (blue) | n/a | Opened-vs-closed
momentum chart |
+| **Line chart: closed line** | `#56d364` (green) | n/a | Opened-vs-closed
momentum chart |
+| **Ready-trend: high-pressure area line** | `#f85149` (red) | n/a |
Ready-for-review trend chart, areas with pressure ≥ 30 |
+| **Ready-trend: medium-pressure area line** | `#d29922` (amber) | n/a |
Ready-for-review trend chart, areas with pressure 15-29 |
+| **Ready-trend: low-pressure area line** | `#6e7681` (grey) | n/a |
Ready-for-review trend chart, areas with pressure < 15 |
+| **Closed-by-reason: merged segment** | `#56d364` (green) | n/a |
Closed-by-triage-reason stacked bar chart |
+| **Closed-by-reason: closed-after-responded** | `#d29922` (amber) | n/a |
Closed-by-triage-reason stacked bar chart |
+| **Closed-by-reason: closed-no-response** | `#f85149` (red) | n/a |
Closed-by-triage-reason stacked bar chart |
+| **Closed-by-reason: closed-no-triage** | `#6e7681` (grey) | n/a |
Closed-by-triage-reason stacked bar chart |
+| **Recommendation priority — high** | `#f85149` (red border) | n/a | Action
card left border |
+| **Recommendation priority — medium** | `#d29922` (amber border) | n/a |
Action card left border |
+| **Recommendation priority — low** | `#6e7681` (grey border) | n/a | Action
card left border |
+
+When a percentage is on a colour boundary (e.g. `%Responded` exactly 50%),
keep the happier colour — green ≥ 50, amber 20-49, red < 20. The "when in doubt
show it as still-ok" convention matches the predecessor breeze commands.
+
+---
+
+## Triage state markers
-Exact counting rules:
+Distinct from the colour scheme: these are the four conceptual *states* a
contributor PR can be in at any moment. The dashboard uses them in the "Triage
funnel" hero grid and in the recommendation triggers.
-- `ready` — `ready_for_review` label present (takes precedence over the other
markers).
-- `responded` — triaged with author reply after the triage comment (and NOT
marked ready).
-- `waiting` — triaged with no author reply (and NOT marked ready). Drafts
without a triage marker fall into `waiting` too, matching the "drafts are
author's court" logic the predecessor breeze commands used.
-- `untriaged` — none of the above. Non-draft PR that hasn't been triaged yet.
+| Marker | Definition | Colour | Maintainer action |
+|---|---|---|---|
+| `Ready for review` | `ready for maintainer review` label is present | green
| run `/maintainer-review` to actually code-review the PR |
+| `Responded` | PR is triaged AND author has commented or pushed after the
triage comment, AND not yet `Ready` | bright cyan | re-triage; may now qualify
for `mark-ready-with-ping` |
+| `Waiting for Author` | PR is triaged, no author response — OR — PR is a
draft (whether triaged or not) | amber | nothing; author owns the next move
(may become a sweep candidate after 7d) |
+| `Not yet triaged` | None of the above. Non-draft PR that has never received
a quality-criteria comment | blue (or grey) | run `/pr-management-triage` to
give it a first look |
-Counts must sum to the open-PR total (non-bot). If they don't, print a warning
and the discrepancy.
+Counting rules are precedence-based — `Ready` takes precedence over the
others, then `Responded`, then `Waiting`, with `Not yet triaged` as the
fallback. So a single PR is in exactly one bucket; the four counts must sum to
the total open non-bot PR count.
---
## Context line
-Before the first table, print one line summarising the scope. This line is
plain text (no Rich markup needed — it's header-style, not a table) so it
renders identically in the terminal and in the Markdown fallback.
+Plain text, before the first hero card:
```text
-<upstream> — 413 open PRs (non-bot) · closed/merged since 2026-03-11 · viewer
@potiuk · 2026-04-22 22:33 UTC
-```
+<upstream> — 459 open PRs (non-bot) · closed/merged since 2026-03-24 · viewer
@potiuk · 2026-05-06 14:33 UTC
+```text
Structure: `<repo> — <open_count> open PRs (non-bot) · closed/merged since
<cutoff> · viewer @<login> · <now>`.
-The `<now>` is the fetch-start timestamp, not the render-end timestamp — a
slow run should still be interpretable as a snapshot at a single moment.
-
-If the closed-since counts came from the lagging search index (see
[`fetch.md#known-limitation`](fetch.md)), print a one-line caveat between the
context line and Table 1:
+If the closed-since counts came from the lagging search index (see
[`fetch.md#known-limitation`](fetch.md)), prepend a one-line caveat before the
hero cards:
```text
-⚠ Table 1 built from GitHub's free-text search of the quality-criteria marker.
The index lags — older triaged+merged PRs are likely undercounted. Pass
accurate-closed for the hybrid REST + GraphQL path.
-```
+⚠ Closed-PR table built from GitHub's free-text search of the quality-criteria
marker. The index lags — older triaged+merged PRs are likely undercounted. Pass
accurate-closed for the hybrid REST + GraphQL path.
+```text
---
## Table 1 — Triaged PRs, final state
-Title: `Triaged PRs — Final State since <cutoff> (<repo>)`
+Lives inside the collapsed `<details>` block at the bottom of the dashboard.
Title: `Triaged PRs — Final State since <cutoff> (<repo>)`.
One row per area where `triaged_total > 0`, sorted by `triaged_total`
descending. `(no area)` goes last. Append a bold **TOTAL** row.
| Column | Source | Colour |
|---|---|---|
-| Area | area name without the `area:` prefix | `bold cyan` |
-| Triaged Total | `triaged_total` | `yellow` |
-| Closed | `closed` | `red` |
+| Area | area name without the `area:` prefix | cyan |
+| Triaged Total | `triaged_total` | amber |
+| Closed | `closed` | red |
| %Closed | `pct_closed` | default |
-| Merged | `merged` | `green` |
+| Merged | `merged` | green |
| %Merged | `pct_merged` | default |
-| Responded | `responded_before_close` | `bright_cyan` |
-| %Responded | `pct_responded` | default |
+| Responded | `responded_before_close` | bright cyan |
+| %Responded | `pct_responded` | green if ≥ 50, amber if 20–49, red < 20 |
---
## Table 2 — Triaged PRs, still open
-Title: `Triaged PRs — Still Open (<repo>)`
+Lives inside the second collapsed `<details>` block. Title: `Triaged PRs —
Still Open (<repo>)`.
One row per area where `total > 0`, sorted by `total` descending. `(no area)`
last. Append a bold **TOTAL** row.
@@ -166,81 +301,186 @@ One row per area where `total > 0`, sorted by `total`
descending. `(no area)` la
| Column | Source | Denominator | Colour |
|---|---|---|---|
-| Area | area name | — | `bold cyan` |
-| Total | `total` (all PRs) | — | `dim` |
-| Contrib. | `contributors` | — | `bright_cyan` |
+| Area | area name | — | cyan |
+| Total | `total` (all PRs) | — | dim |
+| Contrib. | `contributors` | — | bright cyan |
| %Contrib. | `contributors / total` | `total` | default |
| Draft | `drafts` (contributor) | — | default |
-| %Draft | `drafts / contributors` | `contributors` | default |
+| %Draft | `drafts / contributors` | `contributors` | red if > 60%, otherwise
default |
| Non-Draft | `non_drafts` (contributor) | — | default |
-| Triaged | `triaged_waiting + triaged_responded` (contributor) | — | `yellow`
|
-| Responded | `triaged_responded` (contributor) | — | `green` |
-| %Responded | `triaged_responded / (triaged_waiting + triaged_responded)` |
the triaged set | default |
-| Ready | `ready_for_review` (contributor) | — | `bold green` |
-| %Ready | `ready_for_review / contributors` | `contributors` | default |
-| Drafted by triager | `triager_drafted` (contributor) | — | `magenta` |
-| Drafted `<1d` / `1-7d` / `1-4w` / `>4w` (4 cols) |
`draft_age_buckets[bucket]` (contributor) | — | `magenta dim` |
-| Author resp `<1d` / `1-7d` / `1-4w` / `>4w` (4 cols) | `age_buckets[bucket]`
(contributor) | — | `dim` |
+| Triaged | `triaged_waiting + triaged_responded` | — | amber |
+| Responded | `triaged_responded` | — | green |
+| %Responded | `triaged_responded / (triaged_waiting + triaged_responded)` |
the triaged set | green ≥ 50, amber 20–49, red < 20 |
+| Ready | `ready_for_review` | — | green (bold) |
+| %Ready | `ready_for_review / contributors` | `contributors` | green ≥ 50,
amber 20–49, red < 20 |
+| Drafted by triager | `triager_drafted` | — | magenta |
+| Drafted `<1d` / `1-7d` / `1-4w` / `>4w` (4 cols, optional) |
`draft_age_buckets[bucket]` | — | dim magenta |
+| Author resp `<1d` / `1-7d` / `1-4w` / `>4w` (4 cols, optional) |
`age_buckets[bucket]` | — | dim |
-Column order: Area → Total → Contrib./%Contrib. (area composition) →
Draft/%Draft/Non-Draft (where the contributor work sits) → Triaged/Resp./%Resp.
(how the triage funnel is going) → Ready/%Ready (what's at the review bar) →
Drafted by triager + age buckets (time-since slices of the active contributor
work).
+Column order: Area → Total → Contrib./%Contrib. (area composition) →
Draft/%Draft/Non-Draft (where the contributor work sits) → Triaged/Resp./%Resp.
(how the triage funnel is going) → Ready/%Ready (what's at the review bar) →
Drafted by triager + age buckets.
All numeric columns right-aligned. Keep area name left-aligned.
-### Wide-table note
+### Compact mode
+
+If the rendered HTML is being embedded into a narrower context, drop the 8
age-bucket columns (keep through `Drafted by triager`). The dashboard's
"Stalest Bucket" hero card still surfaces the >4w count globally.
+
+---
+
+## Legend
+
+Render at the bottom of the dashboard inside a bordered panel. The legend
explains every column, colour, and concept the dashboard introduces. Verbose by
design — no maintainer should have to memorise the column abbreviations.
+
+```html
+<div class="legend">
+<strong>Reading the dashboard</strong>
+
+<dl>
+<dt>Hero card colours</dt>
+<dd>
+ <span style="color:#56d364">green</span> = healthy / on-target;
+ <span style="color:#d29922">amber</span> = needs attention soon;
+ <span style="color:#f85149">red</span> = action needed now;
+ <span style="color:#76e3ea">cyan</span> = informational (raw counts).
+</dd>
+
+<dt>Recommendation priorities</dt>
+<dd>
+ Each "What needs attention" card has a coloured left border:
+ <span style="color:#f85149">red</span> = high priority (do today),
+ <span style="color:#d29922">amber</span> = medium (this week),
+ <span style="color:#6e7681">grey</span> = low (background awareness only).
+</dd>
+
+<dt>Closure velocity bars</dt>
+<dd>
+ Per-week stacked bars: <span style="color:#56d364">green</span> = PRs merged
that week, <span style="color:#6e7681">grey</span> = PRs closed without
merging. Total bar width is normalised to the busiest week in the 6-week window.
+</dd>
+
+<dt>Opened-vs-closed line chart</dt>
+<dd>
+ <span style="color:#58a6ff">Blue line</span> = PRs opened per week
(createdAt). <span style="color:#56d364">Green line</span> = PRs closed/merged
per week (closedAt). Where blue is above green the backlog grew that week;
where green is above blue the backlog shrank. The "Net delta" lines under the
chart translate this to actual ± numbers.
+</dd>
+
+<dt>Ready-for-review trend (multi-line chart)</dt>
+<dd>
+ Cumulative count of currently-`ready for maintainer review` PRs by week, one
line per top-pressure area. Each line uses its area's pressure-band colour
(<span style="color:#f85149">red</span> ≥ 30, <span
style="color:#d29922">amber</span> 15–29, <span
style="color:#6e7681">grey</span> < 15). A steeply climbing line means
review velocity isn't keeping up with triage promotion in that area. The "+N in
last 7d" lines below the chart show recent growth pace per area.
+</dd>
+
+<dt>Closed by triage reason (stacked bars)</dt>
+<dd>
+ Per-week stacked bars showing how each week's closures break down by triage
outcome:
+ <span style="color:#56d364">green</span> = merged (success),
+ <span style="color:#d29922">amber</span> = closed after author responded
(engaged but didn't ship — design rejection, scope change),
+ <span style="color:#f85149">red</span> = closed without author response
(sweep-close on abandoned PRs),
+ <span style="color:#6e7681">grey</span> = closed without ever being triaged
(author self-close or maintainer fast-close).
+ Healthy weeks are mostly green; a red-dominated week means a stale-sweep
landed; a grey-dominated week means contributors are self-cleaning.
+</dd>
+
+<dt>Pressure score</dt>
+<dd>
+ Per area: weighted sum of urgent contributor PRs. Each PR contributes 0–5
points based on triage state and age (see <a
href="aggregate.md#pressure-score">aggregate.md#pressure-score</a>). Higher
score → area needs more maintainer attention. Border colour: <span
style="color:#f85149">red ≥ 30</span>, <span style="color:#d29922">amber
15–29</span>, <span style="color:#6e7681">grey < 15</span>.
+</dd>
+
+<dt>Triage states (used in the funnel cards and recommendation rules)</dt>
+<dd>
+ <span style="color:#56d364"><strong>Ready for review</strong></span>: PR has
the <code>ready for maintainer review</code> label.
+ <span style="color:#76e3ea"><strong>Responded</strong></span>: maintainer
left a triage comment AND the author replied/pushed after it.
+ <span style="color:#d29922"><strong>Waiting for Author</strong></span>:
triaged but no author reply, OR draft (any state).
+ <span style="color:#58a6ff"><strong>Not yet triaged</strong></span>:
non-draft PR that has never received a quality-criteria comment.
+</dd>
+
+<dt>Detailed-table columns</dt>
+<dd>
+ <span style="color:#76e3ea"><strong>Contrib.</strong></span> —
non-collaborator-authored PRs (denominator for nearly every contributor-scoped
metric).
+ <span style="color:#d29922"><strong>Triaged</strong></span> — PRs where a
maintainer comment contains <code>Pull Request quality criteria</code> after
the last commit.
+ <span style="color:#56d364"><strong>Responded</strong></span> — author
commented or pushed after the triage comment.
+ <span style="color:#56d364"><strong>Ready</strong></span> — PRs carrying the
<code>ready for maintainer review</code> label.
+ <span style="color:#db61a2"><strong>Drafted by triager</strong></span> —
drafts that also have a triage marker (heuristic: <code>isDraft AND
is_triaged</code>).
+ <span style="color:#6e7681"><strong><1d / 1-7d / 1-4w /
>4w</strong></span> — time since the PR author's last interaction (comment,
commit, or PR creation).
+</dd>
+
+<dt>Percentage-cell colours</dt>
+<dd>
+ <span style="color:#56d364">green</span> if ≥ 50%, <span
style="color:#d29922">amber</span> if 20–49%, <span
style="color:#f85149">red</span> if < 20%. The convention is "happier colour
wins on a tie" so 50% reads as green, not amber.
+</dd>
+
+<dt>Methodology</dt>
+<dd>
+ Snapshot taken at the timestamp shown in the context line. Open-PR
enumeration via GraphQL search. Closed/merged enumeration via REST
<code>/pulls?state=closed</code> paginated until 3 consecutive pages
out-of-window. Triage detection: comment by OWNER/MEMBER/COLLABORATOR
containing the literal string <code>Pull Request quality criteria</code> after
the last commit. Bots filtered at fetch time (<code>*[bot]</code>,
<code>dependabot</code>, <code>github-actions</code>).
+</dd>
+</dl>
+</div>
+```text
+
+The legend is the *only* place the dashboard repeats the meaning of its colour
conventions. Everything else relies on visual parsing — which is why the legend
is verbose.
-21 columns is wide but Rich handles it. Two behaviours to be aware of:
+---
-- **Rich computes column widths from the real terminal size.** A narrow
terminal will trim to fit. `console.size.width` is available if the skill wants
to override — but don't; let Rich decide.
-- **Optional compact mode.** If the maintainer passes `compact`, drop the 8
age-bucket columns, keeping only through `Drafted by triager`. The
state-breakdown panel still prints. Mention compact mode in the context line
when active.
+## End-of-output summary
-### Markdown fallback
+Close with a single-line summary the maintainer can paste into Slack or a
status email:
-When rendering to Markdown (piped, or `markdown` flag), keep the same column
order and colour-meaning mapping but express colour with emoji markers in the
percentage cells:
+```text
+Summary: 459 open · 37 triaged (8%) · 8 responded (22% of triaged) · 187 ready
for review · 3 drafted by triager in last 7d.
+```text
-- 🟢 `%Ready` ≥ 50% (green)
-- 🟡 `%Responded` < 50% *of triaged* (yellow / waiting)
-- 🔴 `%Draft` > 60% in an area (flag)
+Format: `Summary: <total> open · <triaged> triaged (<pct>%) · <responded>
responded (<pct>% of triaged) · <ready> ready for review · <recent-drafts>
drafted by triager in last 7d.`
-These emoji markers are the **only** place emoji is allowed (the tone-rules
section below still bans emoji in comment bodies / prose). Colour isn't
available in Markdown, so a small semantic marker substitutes.
+Plain text (no HTML, no Rich markup). Keep the structure stable across runs —
scripts that scrape this line shouldn't break between skill revisions.
---
-## Legend
+## Markdown fallback
-After both tables and the state-breakdown panel, print a short legend. Keep it
under 10 lines — the goal is to let someone cold-read the tables without
opening this doc.
+When rendering to Markdown (output piped to a file, or `markdown` flag), drop
the HTML hero cards / SVG charts / coloured borders, and emit the same logical
sections in flat Markdown:
-```python
-legend_lines = [
- "[bold]Column legend:[/]",
- " [bright_cyan]Contrib.[/] — PRs by non-collaborator
contributors.",
- " [yellow]Triaged[/] — PRs where a maintainer posted a
quality-criteria triage comment after the last commit.",
- " [green]Responded[/] — author commented or pushed a commit
after the triage comment.",
- " [bold green]Ready[/] — PRs carrying the `ready for
maintainer review` label.",
- " [magenta]Drafted by triager[/] — PRs converted to draft by a
maintainer (heuristic: draft + triaged).",
- " [dim]Author resp[/] columns — time since the PR author's last
interaction (comment, commit, or PR creation).",
- " [magenta dim]Drafted[/] columns — time since the triage comment
landed on a draft PR.",
-]
-console.print(Panel("\n".join(legend_lines), border_style="dim", expand=False))
-```
+- Hero cards → a 4-column GFM table
+- Recommendations → an unordered list with `🔥` / `👀` / `📥` / `🔄` / `📍` / `📉` /
`✨` icons in front of each item
+- Velocity → a bullet list `<date>: <merged>✓ / <closed>✗ (total <N>)`
+- Opened-vs-closed → a bullet list `<date>: opened <X> / closed <Y> (net <±N>)`
+- Pressure by area → a Markdown table with the same columns
+- Triage funnel → another 4-column GFM table
+- Detailed tables → unchanged GFM tables (no `<details>` collapse — render
expanded)
+- Legend → a bullet list
+
+Emoji markers are allowed in Markdown to substitute for colour. They are the
*only* place emoji is allowed (the tone-rules section below still bans emoji in
comment bodies).
---
-## End-of-output summary
+## Rich terminal-tables variant
-Close with a single-line summary the maintainer can use for at-a-glance
reporting:
+When the maintainer passes `tables-only`, render only the two detailed tables
using `rich.console.Console` + `rich.table.Table` (the same layout the
now-removed `breeze pr stats` produced). Skip the dashboard sections entirely —
Rich can't render the hero cards or charts cleanly in a typical terminal.
-```text
-Summary: 413 open · 66 triaged (16%) · 3 responded (5% of triaged) · 126 ready
for review · 43 drafted by triager in last 7d.
-```
+```python
+from rich.console import Console
+from rich.table import Table
-Format: `Summary: <total> open · <triaged> triaged (<pct>%) · <responded>
responded (<pct>% of triaged) · <ready> ready for review · <recent-drafts>
drafted by triager in last 7d.`
+console = Console()
+
+t = Table(
+ title=f"Triaged PRs — Final State since {cutoff} ({repo})",
+ title_style="bold",
+ show_lines=True,
+ show_footer=True,
+)
+t.add_column("Area", style="bold cyan", min_width=12, footer="Area")
+t.add_column("Triaged Total", justify="right", style="yellow", footer="Triaged
Total")
+# … (see colour scheme above)
+
+for area in sorted_areas:
+ t.add_row(area, str(triaged_total), …)
+
+t.add_row("[bold white]TOTAL[/]", f"[bold white]{n}[/]", …, style="on grey7",
end_section=True)
+console.print(t)
+```text
-This line is plain text (no Rich markup) so it copies cleanly into Slack or a
status email. Keep the structure stable across runs — scripts that scrape this
line shouldn't break between skill revisions.
+Use `show_lines=True` and `show_footer=True` so the footer mirrors the header
on long tables. The `tables-only` mode is the legacy fallback for maintainers
who script around the output and don't render HTML.
---
## Tone rules
-- **No emoji in Rich output.** Colour is the visual cue. The only place emoji
is allowed is the Markdown-fallback percentage cells described under *Markdown
fallback*.
-- **No opinions.** The stats describe state; interpretation belongs to the
maintainer reading them. Don't add "queue is in good shape" or "need to close
stale drafts" sentences.
-- **No PR-level drill-in** in the stats output. If the maintainer wants to
zoom in on a specific area, the follow-up is `pr-management-triage
label:area:<X>`, not a stats continuation.
+- **No emoji in HTML body text** outside of recommendation icons and the
health-rating label. The icons are functional (priority signals); free-text
emoji is noise.
+- **No opinions.** The dashboard surfaces deterministic numbers;
interpretation belongs to the maintainer reading them. Don't let the renderer
add "queue is in good shape" or "need to close stale drafts" sentences. The
recommendation panel's `detail` strings explain the *trigger* and the *action*
— not editorial.
+- **No PR-level drill-in** in any rendered output. If the maintainer wants to
zoom in on a specific area, the follow-up is `pr-management-triage
label:area:<X>`, not a stats continuation. Recommendations encode this
discipline by always pointing at another skill, never embedding PR numbers.