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 07fd83d feat(pr-management-stats): add deterministic HTML dashboard
renderer (#348)
07fd83d is described below
commit 07fd83d376b6866d7a7af61ca066477aa0132996
Author: Yeonguk Choo <[email protected]>
AuthorDate: Sat May 30 07:43:14 2026 +0900
feat(pr-management-stats): add deterministic HTML dashboard renderer (#348)
* feat(pr-management-stats): add deterministic HTML dashboard renderer
* fix(pr-management-stats): fix pagination bug, add test suite, guard
partial fetches
Address the review on #348.
reference.py
- Fix paginated_search cursor-insertion bug: the insert(4,"-F");
insert(5,...) pair produced two consecutive -F flags and silently
stopped pagination after page 1. Use cmd.extend(["-F", f"after=..."]).
The fix lives at the source so every consumer paginates correctly.
- Retry transient 5xx / RATE_LIMITED failures once with backoff, and
expose a `status["partial"]` signal when pagination is cut short.
- Add isDraft to CLOSED_PRS_QUERY and make classify(*, partial=False)
an explicit contract instead of the caller's setdefault shim.
- Group the project-specific defaults behind a DEFAULTS_AIRFLOW profile.
dashboard.py
- Drop the local paginated_search override (the bug is fixed upstream)
and import it from reference.
- Surface partial fetches: INCOMPLETE DATA banner in the HTML and
partial:true in the JSON sidecar.
- Area recommendation count now reflects the untriaged pile (u4w), the
signal that fires the rule, not total contributor PRs.
- Reword the "self-contained" claim to the accurate "directory-portable".
tests/ (new) + pyproject.toml + prek pytest hook
- pagination (bug regression + retry + partial), aggregations,
partial-classify contract, render helpers, and an end-to-end
reference<->dashboard JSON-parity check. All gh calls stubbed.
README: document tests, directory-portable model, and the configurable
project-specific defaults (RFC-AI-0004 vendor neutrality).
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
---
.pre-commit-config.yaml | 14 +
tools/pr-management-stats/README.md | 87 +-
tools/pr-management-stats/dashboard.py | 1875 ++++++++++++++++++++
tools/pr-management-stats/pyproject.toml | 42 +
tools/pr-management-stats/reference.py | 104 +-
tools/pr-management-stats/tests/helpers.py | 112 ++
.../pr-management-stats/tests/test_aggregations.py | 153 ++
.../tests/test_classify_partial.py | 63 +
.../pr-management-stats/tests/test_html_render.py | 82 +
.../pr-management-stats/tests/test_json_parity.py | 116 ++
tools/pr-management-stats/tests/test_pagination.py | 164 ++
tools/pr-management-stats/uv.lock | 83 +
12 files changed, 2876 insertions(+), 19 deletions(-)
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 8d63982..faf8830 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -276,6 +276,20 @@ repos:
files:
^(tools/sandbox-lint/(src|tests|pyproject\.toml|expected\.json)|\.claude/settings\.json)
pass_filenames: false
+ # Project-local checks for the pr-management-stats tool at
+ # `tools/pr-management-stats/`. Unlike the other tool projects this is a
+ # pair of directory-portable scripts (reference.py + dashboard.py), not a
+ # src-layout package, so only the pytest gate is wired here; ruff-format /
+ # mypy over the large pre-existing render script are tracked separately.
+ - repo: local
+ hooks:
+ - id: pr-management-stats-pytest
+ name: pytest (pr-management-stats)
+ language: system
+ entry: uv run --directory tools/pr-management-stats pytest
+ files:
^tools/pr-management-stats/(reference\.py|dashboard\.py|tests/|pyproject\.toml)
+ pass_filenames: false
+
- repo: local
hooks:
- id: skill-and-tool-validator-ruff-check
diff --git a/tools/pr-management-stats/README.md
b/tools/pr-management-stats/README.md
index 017aea9..2838993 100644
--- a/tools/pr-management-stats/README.md
+++ b/tools/pr-management-stats/README.md
@@ -5,6 +5,8 @@
- [pr-management-stats reference
implementation](#pr-management-stats-reference-implementation)
- [Layout](#layout)
- [Invocation](#invocation)
+ - [Configuration and vendor neutrality](#configuration-and-vendor-neutrality)
+ - [Tests](#tests)
- [Contract for the agent](#contract-for-the-agent)
- [Parity implementations](#parity-implementations)
- [Cross-references](#cross-references)
@@ -43,18 +45,43 @@ exists for two reasons:
```text
tools/pr-management-stats/
-├── README.md (this file)
-└── reference.py (Python implementation: fetch + classify + emit
intermediates)
+├── README.md (this file)
+├── pyproject.toml (pins the pytest harness; the tool itself is stdlib-only)
+├── reference.py (Python implementation: fetch + classify + emit
intermediates)
+├── dashboard.py (full HTML render extending reference.py — all 11 panels)
+└── tests/ (pytest suite: pagination, aggregations, render, JSON
parity)
```
+The full HTML dashboard render (per `render.md`) lives in
+[`dashboard.py`](dashboard.py); it imports the fetch + classify
+primitives from `reference.py` and inlines its own SVG / CSS helpers.
+
+The tool is **directory-portable**, not single-file or installable: the
+two scripts plus their resources travel together as one directory, and
+`dashboard.py` imports its sibling `reference.py` by name. Run it as a
+script (`python3 dashboard.py …`) — the script's own directory is on
+`sys.path[0]`, so the sibling import resolves from any working directory.
+Because the directory name (`pr-management-stats`) is not a valid Python
+module identifier, the tool is **not** a package and cannot be run with
+`python3 -m`. The only third-party dependency is `pytest`, and that is
+dev-only (for the test suite); the scripts themselves are stdlib-only.
+
## Invocation
```bash
+# Reference (fetch + classify + JSON sidecar only)
python3 tools/pr-management-stats/reference.py \
--repo <upstream> \
--viewer <maintainer-handle> \
--since 2026-04-12 \
--out /tmp/dashboard.html
+
+# Full dashboard (all 11 panels rendered as self-contained HTML)
+python3 tools/pr-management-stats/dashboard.py \
+ --repo <upstream> \
+ --viewer <maintainer-handle> \
+ --since 2026-04-12 \
+ --out /tmp/dashboard.html
```
The script:
@@ -71,6 +98,53 @@ The script:
review-thread comment, label add, draft conversion).
5. Writes a JSON sidecar with all the counts that feed the dashboard.
+## Configuration and vendor neutrality
+
+The default triage marker, AI footer, ready-label, and area-prefix are
+example values for the reference instance these scripts were built
+against — they are **not** vendor-neutral, and they do not need to be:
+every one is a CLI override, so an adopter for another project supplies
+their own without editing the tool. (The framework's placeholder
+convention governs repo slugs and URLs in prose, not these runtime
+config defaults.) Override per invocation:
+
+```bash
+python3 dashboard.py --repo <upstream> --viewer <handle> \
+ --triage-marker "<your quality-criteria marker>" \
+ --ai-footer "<your bot footer>" \
+ --ready-label "<your ready label>" \
+ --area-prefix "<your area label prefix>"
+```
+
+When pagination is cut short (a `gh` error, a rate limit, or the page
+cap is reached), the run does not silently publish a truncated view: a
+visible **INCOMPLETE DATA** banner is added to the HTML and `partial:
+true` is written to the JSON sidecar. Transient `5xx` / `RATE_LIMITED`
+failures are retried once with backoff before that happens.
+
+## Tests
+
+```bash
+# from the tool directory
+uv run pytest # or: python3 -m pytest
+```
+
+The suite is stdlib + `pytest` only and stubs all `gh` calls — no
+network access:
+
+- `tests/test_pagination.py` — guards the cursor-pagination fix, the
+ retry/backoff path, and the partial-fetch signal.
+- `tests/test_aggregations.py` — pure aggregation functions
+ (`compute_hero_counts`, `compute_pressure_by_area`,
+ `compute_recommendations`, weekly velocity).
+- `tests/test_classify_partial.py` — the explicit partial closed-PR
+ classify contract.
+- `tests/test_html_render.py` — render helpers, including the
+ incomplete-data banner toggle and HTML escaping.
+- `tests/test_json_parity.py` — runs both `reference.py` and
+ `dashboard.py` over one fixture and asserts the dashboard sidecar is a
+ superset of reference's with identical values on every shared key.
+
## Contract for the agent
When the agent invokes the skill, it MUST:
@@ -81,10 +155,11 @@ When the agent invokes the skill, it MUST:
## Parity implementations
-This script is a fetch + classify reference. The full render lives
-in the agent-emitted version per `render.md`. Adopters who want a
-deterministic CI-runnable equivalent should extend this script with
-the aggregation + HTML emission directly; we welcome PRs.
+`reference.py` provides fetch + classify only. `dashboard.py`
+extends it with the aggregation + HTML emission for all 11 panels
+declared in `render.md`, and is the recommended path for CI-rendered
+dashboards. Adopters who want a different language target are
+welcome to add additional parity implementations.
## Cross-references
diff --git a/tools/pr-management-stats/dashboard.py
b/tools/pr-management-stats/dashboard.py
new file mode 100644
index 0000000..bb764af
--- /dev/null
+++ b/tools/pr-management-stats/dashboard.py
@@ -0,0 +1,1875 @@
+#!/usr/bin/env python3
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you 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.
+
+"""
+Full HTML-rendering extension on top of reference.py.
+
+reference.py stops at fetch + classify + JSON sidecar; this script
+reuses its primitives, then computes every aggregate from aggregate.md
+and emits the 11-section dashboard from render.md.
+
+Usage:
+ dashboard.py --repo apache/airflow --viewer potiuk \\
+ [--since 2026-04-12] [--out dashboard.html]
+
+Output:
+ - <out> HTML dashboard (all 11 sections per render.md)
+ - <out-stem>.json Intermediate state (superset of reference.py's keys —
+ identical values on every shared key; see
+ tests/test_json_parity.py)
+
+Design: directory-portable, not single-file. This script reuses
+reference.py's fetch + classify primitives by importing them from the
+sibling module, so the whole tools/pr-management-stats/ directory must
+travel together. Run it as a script (`python3 dashboard.py ...`) — the
+directory of the running script is on sys.path[0], so the sibling import
+resolves regardless of the current working directory. It is NOT a package
+(the directory name is not a valid module identifier) and cannot be run
+with `python3 -m`. The JSON sidecar contract is preserved so existing
+reference.py consumers don't break.
+"""
+from __future__ import annotations
+
+import argparse
+import html
+import json
+import sys
+from collections import defaultdict
+from datetime import datetime, timedelta, timezone
+from pathlib import Path
+
+# Sibling-module import — see the module docstring's "Design" note. Resolves
+# because the running script's directory is sys.path[0]; the directory is not
+# a Python package, so the tool is directory-portable rather than
self-contained.
+from reference import (
+ CLOSED_PRS_QUERY,
+ COLLAB_ASSOCIATIONS,
+ DEFAULT_AI_FOOTER,
+ DEFAULT_AREA_PREFIX,
+ DEFAULT_READY_LABEL,
+ DEFAULT_TRIAGE_MARKER,
+ OPEN_PRS_QUERY,
+ classify,
+ compute_codeowners_panel,
+ compute_weekly_velocity,
+ fetch_codeowners,
+ fetch_ready_pr_files,
+ is_bot,
+ paginated_search,
+ parse_iso,
+ weeks_buckets,
+)
+
+# ============================================================
+# Colour palette (render.md#colour-scheme)
+# ============================================================
+
+C_GREEN = "#56d364"
+C_AMBER = "#d29922"
+C_RED = "#f85149"
+C_CYAN = "#76e3ea"
+C_AREA = "#56d4dd"
+C_BLUE = "#58a6ff"
+C_MAGENTA = "#db61a2"
+C_GREY = "#6e7681"
+C_DIM = "#8b949e"
+C_BG = "#0d1117"
+C_PANEL = "#161b22"
+C_BORDER = "#30363d"
+C_FG = "#c9d1d9"
+
+
+# ============================================================
+# Tiny utilities
+# ============================================================
+
+
+def esc(s) -> str:
+ if s is None:
+ return ""
+ return html.escape(str(s))
+
+
+def pct(num: float, denom: float) -> float:
+ if not denom:
+ return 0.0
+ return round(100.0 * num / denom, 1)
+
+
+def colour_for_pct(p: float) -> str:
+ """render.md: green ≥ 50, amber 20–49, red < 20."""
+ if p >= 50:
+ return C_GREEN
+ if p >= 20:
+ return C_AMBER
+ return C_RED
+
+
+def colour_for_pressure(score: int) -> str:
+ """render.md#pressure-score band: red ≥30, amber 15-29, grey <15."""
+ if score >= 30:
+ return C_RED
+ if score >= 15:
+ return C_AMBER
+ return C_GREY
+
+
+def week_label(dt: datetime) -> str:
+ return dt.strftime("%m-%d")
+
+
+# ============================================================
+# SVG render helpers
+# ============================================================
+
+
+def svg_line_chart(series, *, width=720, height=220, colours=None, y_max=None,
+ y_label="", x_labels=None):
+ """Multi-series inline SVG line chart per
render.md#inline-svg-line-chart-helper."""
+ if not series:
+ return f'<svg viewBox="0 0 {width} {height}"></svg>'
+ colours = colours or [C_BLUE, C_GREEN, C_RED, C_AMBER, C_MAGENTA]
+ pad_l, pad_r, pad_t, pad_b = 50, 110, 14, 30
+ w_in = width - pad_l - pad_r
+ h_in = height - pad_t - pad_b
+ flat = [v for s in series for v in s["values"]]
+ if not flat:
+ return f'<svg viewBox="0 0 {width} {height}"></svg>'
+ vmax = y_max if y_max is not None else (max(flat) or 1)
+ parts = [
+ f'<svg viewBox="0 0 {width} {height}"
xmlns="http://www.w3.org/2000/svg" '
+ f'style="background:{C_PANEL};border:1px solid
{C_BORDER};border-radius:6px;">'
+ ]
+ for i in range(5):
+ y = pad_t + i * h_in / 4
+ v = vmax - i * vmax / 4
+ parts.append(
+ f'<line x1="{pad_l}" y1="{y:.1f}" x2="{width - pad_r}"
y2="{y:.1f}" '
+ f'stroke="{C_BORDER}" stroke-width="0.5"/>'
+ )
+ parts.append(
+ f'<text x="{pad_l - 6}" y="{y + 3:.1f}" fill="{C_DIM}" '
+ f'font-size="10" text-anchor="end">{v:.0f}</text>'
+ )
+ if x_labels:
+ n = len(x_labels)
+ for i, lbl in enumerate(x_labels):
+ x = pad_l + i * w_in / max(n - 1, 1)
+ parts.append(
+ f'<text x="{x:.1f}" y="{height - 10}" fill="{C_DIM}" '
+ f'font-size="10" text-anchor="middle">{esc(lbl)}</text>'
+ )
+ if y_label:
+ parts.append(
+ f'<text x="10" y="{pad_t + h_in / 2}" fill="{C_DIM}"
font-size="10" '
+ f'transform="rotate(-90 10 {pad_t + h_in / 2})"
text-anchor="middle">{esc(y_label)}</text>'
+ )
+ for idx, s in enumerate(series):
+ vals = s["values"]
+ n = len(vals)
+ c = s.get("colour") or colours[idx % len(colours)]
+ pts = []
+ for i, v in enumerate(vals):
+ x = pad_l + i * w_in / max(n - 1, 1)
+ y = pad_t + h_in - (v / vmax) * h_in if vmax else pad_t + h_in
+ pts.append((x, y, v))
+ d = " ".join(f"{x:.1f},{y:.1f}" for x, y, _ in pts)
+ parts.append(
+ f'<polyline fill="none" stroke="{c}" stroke-width="2"
points="{d}"/>'
+ )
+ for x, y, v in pts:
+ parts.append(f'<circle cx="{x:.1f}" cy="{y:.1f}" r="3"
fill="{c}"/>')
+ parts.append(
+ f'<rect x="{width - pad_r + 4}" y="{pad_t + idx * 18 - 6}" '
+ f'width="10" height="10" fill="{c}"/>'
+ )
+ parts.append(
+ f'<text x="{width - pad_r + 18}" y="{pad_t + idx * 18 + 3}" '
+ f'fill="{C_FG}" font-size="11">{esc(s["label"])}</text>'
+ )
+ parts.append("</svg>")
+ return "".join(parts)
+
+
+def svg_stacked_horizontal_bars(rows, *, width=720, height=None,
+ segment_keys, segment_colours, row_height=30,
+ row_labels=None):
+ """N-row stacked horizontal bars (one per bucket)."""
+ height = height or (row_height * len(rows) + 40)
+ pad_l, pad_r, pad_t, pad_b = 70, 30, 10, 30
+ w_in = width - pad_l - pad_r
+ max_total = max(
+ (sum(r.get(k, 0) for k in segment_keys) for r in rows), default=0
+ )
+ parts = [
+ f'<svg viewBox="0 0 {width} {height}"
xmlns="http://www.w3.org/2000/svg" '
+ f'style="background:{C_PANEL};border:1px solid
{C_BORDER};border-radius:6px;">'
+ ]
+ for i, row in enumerate(rows):
+ y = pad_t + i * row_height
+ total = sum(row.get(k, 0) for k in segment_keys)
+ label = row_labels[i] if row_labels else ""
+ parts.append(
+ f'<text x="{pad_l - 6}" y="{y + row_height / 2 + 3:.1f}" '
+ f'fill="{C_DIM}" font-size="10"
text-anchor="end">{esc(label)}</text>'
+ )
+ if max_total == 0:
+ continue
+ bar_w = (total / max_total) * w_in
+ offset = 0.0
+ for key, colour in zip(segment_keys, segment_colours):
+ v = row.get(key, 0)
+ if v == 0:
+ continue
+ seg_w = (v / total) * bar_w if total else 0
+ parts.append(
+ f'<rect x="{pad_l + offset:.1f}" y="{y + 4:.1f}" '
+ f'width="{seg_w:.1f}" height="{row_height - 8}"
fill="{colour}"/>'
+ )
+ if seg_w > 24:
+ parts.append(
+ f'<text x="{pad_l + offset + seg_w / 2:.1f}" '
+ f'y="{y + row_height / 2 + 3:.1f}" fill="{C_BG}" '
+ f'font-size="10" text-anchor="middle">{v}</text>'
+ )
+ offset += seg_w
+ if total > 0:
+ parts.append(
+ f'<text x="{pad_l + bar_w + 6:.1f}" '
+ f'y="{y + row_height / 2 + 3:.1f}" fill="{C_FG}" '
+ f'font-size="10">{total}</text>'
+ )
+ parts.append("</svg>")
+ return "".join(parts)
+
+
+# ============================================================
+# CSS (inline so dashboard.py + reference.py are independently
carry-over-able)
+# ============================================================
+
+
+CSS = f"""
+<style>
+* {{ box-sizing: border-box; }}
+body {{
+ font: 14px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
+ background: {C_BG};
+ color: {C_FG};
+ margin: 0;
+ padding: 24px;
+ max-width: 1240px;
+ margin-left: auto;
+ margin-right: auto;
+}}
+h1 {{ font-size: 22px; margin: 0 0 4px; }}
+h2 {{ font-size: 16px; margin: 28px 0 12px; padding-bottom: 6px;
border-bottom: 1px solid {C_BORDER}; }}
+h3 {{ font-size: 13px; margin: 12px 0 6px; color: {C_DIM}; font-weight: 600; }}
+.context {{ color: {C_DIM}; font-size: 12px; }}
+.warn {{ background: rgba(248,81,73,0.1); border: 1px solid {C_RED}; padding:
10px 14px;
+ border-radius: 6px; margin: 12px 0; color: {C_RED}; font-size: 12px; }}
+.hero {{ display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px;
margin: 12px 0; }}
+.card {{ background: {C_PANEL}; border: 1px solid {C_BORDER}; border-radius:
6px;
+ padding: 16px; }}
+.card .big {{ font-size: 28px; font-weight: 600; line-height: 1.1; }}
+.card .sub {{ font-size: 12px; color: {C_DIM}; margin-top: 6px; line-height:
1.4; }}
+.action {{ border-left: 4px solid {C_BORDER}; padding: 12px 16px; margin: 8px
0;
+ background: {C_PANEL}; border-radius: 0 6px 6px 0; }}
+.action.high {{ border-left-color: {C_RED}; }}
+.action.medium {{ border-left-color: {C_AMBER}; }}
+.action.low {{ border-left-color: {C_GREY}; }}
+.action .title {{ font-weight: 600; margin-bottom: 4px; }}
+.action .detail {{ font-size: 12px; color: {C_DIM}; }}
+.action code {{ display: inline-block; background: {C_BG}; padding: 4px 8px;
+ border-radius: 4px; margin-top: 8px; font-size: 12px;
+ color: {C_CYAN}; user-select: all; }}
+.panel {{ background: {C_PANEL}; border: 1px solid {C_BORDER}; border-radius:
6px;
+ padding: 16px; margin: 8px 0; }}
+table {{ width: 100%; border-collapse: collapse; font-size: 12px; }}
+th, td {{ padding: 6px 8px; border-bottom: 1px solid {C_BORDER}; text-align:
right; }}
+th:first-child, td:first-child {{ text-align: left; }}
+th {{ background: {C_PANEL}; font-weight: 600; color: {C_DIM}; }}
+tr.total td {{ background: rgba(240,246,252,0.05); font-weight: 600;
+ border-top: 2px solid {C_BORDER}; }}
+.area {{ color: {C_AREA}; font-weight: 600; }}
+.green {{ color: {C_GREEN}; }} .amber {{ color: {C_AMBER}; }}
+.red {{ color: {C_RED}; }} .cyan {{ color: {C_CYAN}; }} .grey {{ color:
{C_GREY}; }}
+.blue {{ color: {C_BLUE}; }} .magenta {{ color: {C_MAGENTA}; }}
+details {{ background: {C_PANEL}; border: 1px solid {C_BORDER}; border-radius:
6px;
+ padding: 12px 16px; margin: 12px 0; }}
+details summary {{ cursor: pointer; font-weight: 600; }}
+details[open] summary {{ margin-bottom: 12px; }}
+.legend {{ background: {C_PANEL}; border: 1px solid {C_BORDER}; border-radius:
6px;
+ padding: 16px; margin: 16px 0; font-size: 12px; }}
+.legend dt {{ font-weight: 600; margin-top: 8px; color: {C_FG}; }}
+.legend dd {{ margin: 4px 0 0 0; color: {C_DIM}; }}
+.footer {{ color: {C_DIM}; font-size: 11px; margin-top: 24px; padding-top:
12px;
+ border-top: 1px solid {C_BORDER}; }}
+.pressure-row {{ display: flex; justify-content: space-between; align-items:
center;
+ gap: 12px; padding: 10px 14px; margin: 6px 0;
+ border-left: 4px solid {C_BORDER}; background: {C_PANEL};
+ border-radius: 0 6px 6px 0; }}
+.pressure-row.high {{ border-left-color: {C_RED}; }}
+.pressure-row.medium {{ border-left-color: {C_AMBER}; }}
+.pressure-row.low {{ border-left-color: {C_GREY}; }}
+.pressure-row .score {{ font-size: 18px; font-weight: 600; color: {C_FG}; }}
+.pressure-row code {{ background: {C_BG}; padding: 2px 6px; border-radius: 4px;
+ font-size: 11px; color: {C_CYAN}; }}
+.funnel {{ display: grid; grid-template-columns: repeat(5, 1fr); gap: 10px; }}
+.caveat {{ font-size: 11px; color: {C_DIM}; font-style: italic; margin-top:
4px; }}
+.sparkline {{ display: inline-flex; gap: 1px; height: 18px; align-items:
flex-end; }}
+.sparkline .bar {{ width: 6px; background: {C_BLUE}; }}
+.sparkline .bar.ai {{ background: {C_MAGENTA}; }}
+</style>
+"""
+
+
+# NOTE: paginated_search is imported from reference.py. The cursor-insertion
+# bug that previously forced a local override here is now fixed at the source
+# (reference.py), so every consumer — not just this script — paginates
+# correctly. See tests/test_pagination.py.
+
+
+# ============================================================
+# Aggregation layer (aggregate.md)
+# ============================================================
+
+
+def compute_hero_counts(open_prs):
+ """Hero card data — 2 rows, 4 cards each."""
+ h = {
+ "open_total": 0,
+ "non_drafts": 0,
+ "drafts": 0,
+ "contribs": 0,
+ "collabs": 0,
+ "ready": 0,
+ "untriaged": 0,
+ "untriaged_4w": 0,
+ "qc_triaged": 0,
+ "defacto": 0,
+ "ai_triaged": 0,
+ "bots": 0,
+ "bots_dependabot": 0,
+ "bots_other": 0,
+ "contrib_nondraft_total": 0,
+ "responded": 0,
+ "waiting_ai": 0,
+ "waiting_manual": 0,
+ }
+ for pr in open_prs:
+ author = pr.get("_author")
+ if is_bot(author):
+ h["bots"] += 1
+ if author == "dependabot" or (author and "dependabot" in author):
+ h["bots_dependabot"] += 1
+ else:
+ h["bots_other"] += 1
+ continue
+ h["open_total"] += 1
+ if pr["isDraft"]:
+ h["drafts"] += 1
+ else:
+ h["non_drafts"] += 1
+ if pr["_is_contrib"]:
+ h["contribs"] += 1
+ if not pr["isDraft"]:
+ h["contrib_nondraft_total"] += 1
+ if pr["_is_collab"]:
+ h["collabs"] += 1
+ if pr["_has_ready"]:
+ h["ready"] += 1
+ if pr["_is_untriaged"]:
+ h["untriaged"] += 1
+ if pr["_age_days"] > 28:
+ h["untriaged_4w"] += 1
+ if pr["_is_triaged"]:
+ h["qc_triaged"] += 1
+ if pr["_responded"]:
+ h["responded"] += 1
+ if pr["_is_engaged"] and not pr["_is_triaged"]:
+ h["defacto"] += 1
+ if pr["_has_ai_footer"]:
+ h["ai_triaged"] += 1
+ if pr.get("_waiting_ai"):
+ h["waiting_ai"] += 1
+ if pr.get("_waiting_manual"):
+ h["waiting_manual"] += 1
+ return h
+
+
+def compute_health_rating(hero, recs):
+ """aggregate.md#health-rating — issue-points map → label/colour."""
+ pts = 0
+ if hero["untriaged_4w"] > 0:
+ pts += 2
+ if hero["untriaged"] > 30:
+ pts += 1
+ if hero["ready"] >= 50:
+ pts += 1
+ if any(r["priority"] == "high" for r in recs):
+ pts += 2
+ if pts >= 4:
+ return ("🔥 Action needed", C_RED)
+ if pts >= 2:
+ return ("⚠️ Needs attention", C_AMBER)
+ return ("✅ Healthy", C_GREEN)
+
+
+def compute_pressure_by_area(open_prs, area_prefix):
+ """aggregate.md#pressure-score weighted sum per area."""
+ scores = defaultdict(
+ lambda: {
+ "score": 0,
+ "contribs": 0,
+ "u4w": 0,
+ "u14w": 0,
+ "urec": 0,
+ "wait": 0,
+ "ready": 0,
+ }
+ )
+ for pr in open_prs:
+ if not pr["_is_contrib"]:
+ continue
+ age = pr["_age_days"]
+ areas = pr["_areas"] or ["(no area)"]
+ for area in areas:
+ a = scores[area]
+ a["contribs"] += 1
+ if pr["_is_untriaged"]:
+ if age > 28:
+ a["u4w"] += 1
+ a["score"] += 5
+ elif age > 7:
+ a["u14w"] += 1
+ a["score"] += 3
+ else:
+ a["urec"] += 1
+ a["score"] += 1
+ elif pr["_is_triaged"] and not pr["_responded"] and age > 7:
+ a["wait"] += 1
+ a["score"] += 2
+ if pr["_has_ready"]:
+ a["ready"] += 1
+ a["score"] += 1
+ rows = [
+ (area.replace(area_prefix, ""), v)
+ for area, v in scores.items()
+ if v["contribs"] >= 3
+ ]
+ rows.sort(key=lambda x: -x[1]["score"])
+ return rows[:8]
+
+
+def compute_recommendations(open_prs, weekly, pressure, hero,
ready_trend_growth):
+ """render.md#recommendation-rules — fixed table evaluated in order."""
+ now = datetime.now(timezone.utc)
+ untriaged_4w = [p for p in open_prs if p["_is_untriaged"] and
p["_age_days"] > 28]
+ untriaged_14 = [
+ p for p in open_prs if p["_is_untriaged"] and 7 < p["_age_days"] <= 28
+ ]
+ stale_drafts = [
+ p
+ for p in open_prs
+ if p["isDraft"]
+ and p["_is_triaged"]
+ and p["_triage_at"]
+ and (now - p["_triage_at"]).days >= 7
+ and not p["_responded"]
+ ]
+ responded_no_ready = [
+ p for p in open_prs if p["_responded"] and not p["_has_ready"]
+ ]
+
+ recs = []
+ if untriaged_4w:
+ recs.append(
+ {
+ "priority": "high",
+ "icon": "🔥",
+ "title": f"Triage {len(untriaged_4w)} non-draft contributor
PRs older than 4 weeks",
+ "detail": "Focus on the >4w bucket — those are the ones
rotting longest.",
+ "action": "/pr-management-triage all PR issues",
+ "count": len(untriaged_4w),
+ }
+ )
+ elif untriaged_14:
+ recs.append(
+ {
+ "priority": "medium",
+ "icon": "👀",
+ "title": f"Triage {len(untriaged_14)} non-draft PRs aged 1-4
weeks",
+ "detail": "The 1-4w bucket is the queue's leading edge.",
+ "action": "/pr-management-triage all PR issues",
+ "count": len(untriaged_14),
+ }
+ )
+ if stale_drafts:
+ recs.append(
+ {
+ "priority": "medium",
+ "icon": "🗑️",
+ "title": f"Close {len(stale_drafts)} stale-triaged drafts
(≥7d, no response)",
+ "detail": "Closure path lives under the stale flow (sweep step
1a).",
+ "action": "/pr-management-triage stale",
+ "count": len(stale_drafts),
+ }
+ )
+ if hero["ready"] >= 50:
+ recs.append(
+ {
+ "priority": "high",
+ "icon": "📥",
+ "title": f"{hero['ready']} PRs labeled \"ready for maintainer
review\"",
+ "detail": "The queue is past triage — needs review attention.",
+ "action": "/pr-management-code-review ready",
+ "count": hero["ready"],
+ }
+ )
+ elif 20 <= hero["ready"] < 50:
+ recs.append(
+ {
+ "priority": "medium",
+ "icon": "📥",
+ "title": f"{hero['ready']} PRs in \"ready for maintainer
review\" queue",
+ "detail": "Same trigger family — banded by queue size.",
+ "action": "/pr-management-code-review ready",
+ "count": hero["ready"],
+ }
+ )
+ if responded_no_ready:
+ recs.append(
+ {
+ "priority": "medium",
+ "icon": "🔄",
+ "title": f"{len(responded_no_ready)} triaged PRs have author
responses awaiting re-triage",
+ "detail": "Surface as request-author-confirmation in next
sweep.",
+ "action": "/pr-management-triage all PR issues",
+ "count": len(responded_no_ready),
+ }
+ )
+ if pressure:
+ area, v = pressure[0]
+ if v["u4w"] + v["u14w"] >= 5:
+ recs.append(
+ {
+ "priority": "medium",
+ "icon": "📍",
+ "title": f"Area \"{area}\" has {v['contribs']} contributor
PRs ({v['u4w']} untriaged >4w)",
+ "detail": "One area dominating the untriaged queue —
scoped pass clears bulk.",
+ "action": f"/pr-management-triage label:area:{area}",
+ # urgency is driven by the untriaged pile (the rule's
+ # trigger), not by total contributor PRs in the area.
+ "count": v["u4w"],
+ }
+ )
+ # Rule 8 — velocity drop
+ if len(weekly) >= 2:
+ last_total = weekly[-2]["merged"] + weekly[-2]["closed_not_merged"]
+ cur_total = weekly[-1]["merged"] + weekly[-1]["closed_not_merged"]
+ drop = last_total - cur_total
+ if drop > 30:
+ recs.append(
+ {
+ "priority": "low",
+ "icon": "📉",
+ "title": f"PR closure velocity dropped {drop} this week",
+ "detail": "No immediate action — re-check next week.",
+ "action": "—",
+ "count": drop,
+ }
+ )
+ # Rule 9 — ready trend growth
+ if ready_trend_growth:
+ top_area, growth = ready_trend_growth
+ if growth >= 10:
+ recs.append(
+ {
+ "priority": "low",
+ "icon": "📈",
+ "title": f"Ready-for-review queue in \"{top_area}\" grew
by {growth} this week",
+ "detail": "Growth concentrated in one area — focused
review pass.",
+ "action": f"/pr-management-code-review
label:area:{top_area}",
+ "count": growth,
+ }
+ )
+ # Rule 10 — sweep-dominated weeks
+ if len(weekly) >= 2:
+ sweep_recent = sum(
+ 1 for w in weekly[-2:] if w["closed_after_triage"] > w["merged"]
+ )
+ if sweep_recent == 2:
+ sweep_n = sum(w["closed_after_triage"] for w in weekly[-2:])
+ merged_n = sum(w["merged"] for w in weekly[-2:])
+ recs.append(
+ {
+ "priority": "medium",
+ "icon": "🧹",
+ "title": f"Stale-sweep dominating closures ({sweep_n}
sweep-close vs {merged_n} merged)",
+ "detail": "Too many PRs reaching the stale sweep — review
earlier-stage interventions.",
+ "action": "—",
+ "count": sweep_n,
+ }
+ )
+
+ # Sort: high → medium → low; within tier by count desc
+ order = {"high": 0, "medium": 1, "low": 2}
+ recs.sort(key=lambda r: (order[r["priority"]], -r["count"]))
+ return recs
+
+
+def _bucket_dates(weeks):
+ return [(s, e) for s, e in weeks]
+
+
+def compute_backlog_over_time(open_prs, closed_prs, weeks):
+ """End-of-week open backlog snapshot."""
+ out = []
+ for s, e in weeks:
+ n = 0
+ for pr in open_prs + closed_prs:
+ created = parse_iso(pr.get("createdAt"))
+ if not created or created > e:
+ continue
+ closed_at = parse_iso(pr.get("closedAt"))
+ if closed_at is None or closed_at > e:
+ n += 1
+ out.append({"start": s, "end": e, "value": n})
+ return out
+
+
+def compute_opened_by_author_class(all_prs, weeks):
+ """3-line: FIRST_TIME, CONTRIBUTOR, MAINTAINER per week."""
+ out = []
+ for s, e in weeks:
+ b = {"start": s, "end": e, "first_time": 0, "contributor": 0,
"maintainer": 0}
+ for pr in all_prs:
+ ca = parse_iso(pr.get("createdAt"))
+ if not ca or not (s <= ca < e):
+ continue
+ assoc = pr.get("authorAssociation", "")
+ author = (pr.get("author") or {}).get("login")
+ if is_bot(author):
+ continue
+ if assoc in COLLAB_ASSOCIATIONS:
+ b["maintainer"] += 1
+ elif assoc in ("FIRST_TIMER", "FIRST_TIME_CONTRIBUTOR", "NONE"):
+ b["first_time"] += 1
+ else:
+ b["contributor"] += 1
+ out.append(b)
+ return out
+
+
+def compute_ready_queue_cumulative(open_prs, weeks):
+ """Cumulative count of currently-ready PRs whose label_added_at <=
week.end."""
+ out = []
+ for s, e in weeks:
+ n = 0
+ for pr in open_prs:
+ if not pr["_has_ready"]:
+ continue
+ lab = pr.get("_label_added_at")
+ if lab and lab <= e:
+ n += 1
+ elif not lab:
+ created = parse_iso(pr.get("createdAt"))
+ if created and created <= e:
+ n += 1
+ out.append({"start": s, "end": e, "value": n})
+ return out
+
+
+def compute_triage_velocity(all_prs, weeks, ctx):
+ """2-line: AI-drafted, manual QC marker — by first QC-comment week."""
+ out = []
+ for s, e in weeks:
+ b = {"start": s, "end": e, "ai": 0, "manual": 0}
+ for pr in all_prs:
+ first_qc = None
+ is_ai = False
+ for c in (pr.get("comments", {}) or {}).get("nodes", []) or []:
+ if c.get("authorAssociation") not in COLLAB_ASSOCIATIONS:
+ continue
+ if ctx["triage_marker"] in (c.get("body") or ""):
+ at = parse_iso(c["createdAt"])
+ if first_qc is None or at < first_qc:
+ first_qc = at
+ is_ai = ctx["ai_footer"] in (c.get("body") or "")
+ if first_qc and s <= first_qc < e:
+ if is_ai:
+ b["ai"] += 1
+ else:
+ b["manual"] += 1
+ out.append(b)
+ return out
+
+
+def compute_triage_coverage_rate(all_prs, weeks):
+ """% of PRs opened in window that are engaged (_is_engaged)."""
+ out = []
+ for s, e in weeks:
+ opened = 0
+ engaged = 0
+ for pr in all_prs:
+ ca = parse_iso(pr.get("createdAt"))
+ if not ca or not (s <= ca < e):
+ continue
+ opened += 1
+ if pr.get("_is_engaged"):
+ engaged += 1
+ out.append(
+ {"start": s, "end": e, "opened": opened, "engaged": engaged,
+ "rate": pct(engaged, opened)}
+ )
+ return out
+
+
+def compute_opened_vs_closed(all_prs, weeks):
+ """Per-week opened / closed_total / net_delta."""
+ out = []
+ for s, e in weeks:
+ opened = 0
+ closed = 0
+ for pr in all_prs:
+ ca = parse_iso(pr.get("createdAt"))
+ if ca and s <= ca < e:
+ opened += 1
+ cl = parse_iso(pr.get("closedAt"))
+ if cl and s <= cl < e:
+ closed += 1
+ out.append({"start": s, "end": e, "opened": opened, "closed": closed,
+ "net": opened - closed})
+ return out
+
+
+def compute_ready_trend_by_area(open_prs, weeks, pressure, area_prefix):
+ """Top-5 pressure areas with ≥3 currently-ready PRs; cumulative line per
area."""
+ candidate_areas = [a for a, _ in pressure]
+ series = {}
+ for area in candidate_areas:
+ full_label = f"{area_prefix}{area}"
+ ready_in_area = [p for p in open_prs if p["_has_ready"] and full_label
in p["_labels"]]
+ if len(ready_in_area) < 3:
+ continue
+ per_week = []
+ for s, e in weeks:
+ n = 0
+ for pr in ready_in_area:
+ lab = pr.get("_label_added_at")
+ if lab and lab <= e:
+ n += 1
+ elif not lab:
+ created = parse_iso(pr.get("createdAt"))
+ if created and created <= e:
+ n += 1
+ per_week.append(n)
+ series[area] = per_week
+ if len(series) >= 5:
+ break
+ # growth in last 7d for the top area
+ growth_top = None
+ if series:
+ first_area = next(iter(series))
+ cur = series[first_area][-1]
+ prev = series[first_area][-2] if len(series[first_area]) >= 2 else 0
+ growth_top = (first_area, cur - prev)
+ return series, growth_top
+
+
+def compute_triage_funnel(open_prs):
+ """5 mutually-exclusive buckets — precedence per render.md."""
+ funnel = {
+ "ready": 0,
+ "responded": 0,
+ "waiting_manual": 0,
+ "waiting_ai": 0,
+ "untriaged": 0,
+ "other": 0,
+ }
+ for pr in open_prs:
+ if not pr["_is_contrib"]:
+ continue
+ if pr["isDraft"]:
+ continue
+ if pr["_has_ready"]:
+ funnel["ready"] += 1
+ elif pr["_is_triaged"] and pr["_responded"]:
+ funnel["responded"] += 1
+ elif pr.get("_waiting_manual"):
+ funnel["waiting_manual"] += 1
+ elif pr.get("_waiting_ai"):
+ funnel["waiting_ai"] += 1
+ elif pr["_is_untriaged"]:
+ funnel["untriaged"] += 1
+ else:
+ funnel["other"] += 1
+ return funnel
+
+
+def compute_triager_activity(open_prs, closed_prs, weeks, ctx):
+ """Per-maintainer per-week PR-engagement counts, AI vs manual."""
+ # collab_login -> week_idx -> {ai: set(pr_num), manual: set(pr_num)}
+ activity = defaultdict(
+ lambda: [{"ai": set(), "manual": set()} for _ in weeks]
+ )
+ for pr in open_prs + closed_prs:
+ pr_num = pr.get("number")
+ for c in (pr.get("comments", {}) or {}).get("nodes", []) or []:
+ if c.get("authorAssociation") not in COLLAB_ASSOCIATIONS:
+ continue
+ author = (c.get("author") or {}).get("login")
+ if not author or is_bot(author):
+ continue
+ at = parse_iso(c.get("createdAt"))
+ if not at:
+ continue
+ body = c.get("body") or ""
+ is_ai = ctx["ai_footer"] in body
+ for idx, (s, e) in enumerate(weeks):
+ if s <= at < e:
+ if is_ai:
+ activity[author][idx]["ai"].add(pr_num)
+ else:
+ activity[author][idx]["manual"].add(pr_num)
+ break
+ rows = []
+ for login, per_week in activity.items():
+ totals_ai = set().union(*[w["ai"] for w in per_week])
+ totals_manual = set().union(*[w["manual"] for w in per_week])
+ total_prs = totals_ai | totals_manual
+ rows.append(
+ {
+ "login": login,
+ "total": len(total_prs),
+ "ai": len(totals_ai),
+ "manual": len(totals_manual),
+ "per_week": [
+ {"ai": len(w["ai"]), "manual": len(w["manual"])} for w in
per_week
+ ],
+ }
+ )
+ rows.sort(key=lambda r: -r["total"])
+ return rows[:15]
+
+
+def compute_table_final_state(closed_prs, area_prefix, ctx):
+ """Table 1 — triaged closed PRs grouped by area, since cutoff."""
+ by_area = defaultdict(
+ lambda: {"triaged_total": 0, "closed": 0, "merged": 0, "responded": 0}
+ )
+ for pr in closed_prs:
+ # Was this PR triaged?
+ has_qc = False
+ t_at = None
+ for c in (pr.get("comments", {}) or {}).get("nodes", []) or []:
+ if c.get("authorAssociation") in COLLAB_ASSOCIATIONS and ctx[
+ "triage_marker"
+ ] in (c.get("body") or ""):
+ has_qc = True
+ t_at = parse_iso(c["createdAt"])
+ break
+ if not has_qc:
+ continue
+ responded = False
+ if t_at:
+ for c in (pr.get("comments", {}) or {}).get("nodes", []) or []:
+ ca = (c.get("author") or {}).get("login")
+ pa = (pr.get("author") or {}).get("login")
+ if ca and pa and ca == pa and parse_iso(c["createdAt"]) > t_at:
+ responded = True
+ break
+ labels = [l["name"] for l in (pr.get("labels", {}) or {}).get("nodes",
[]) or []]
+ areas = [l for l in labels if l.startswith(area_prefix)] or ["(no
area)"]
+ for area in areas:
+ b = by_area[area]
+ b["triaged_total"] += 1
+ if pr.get("merged"):
+ b["merged"] += 1
+ else:
+ b["closed"] += 1
+ if responded:
+ b["responded"] += 1
+ rows = []
+ for area, b in by_area.items():
+ rows.append(
+ {
+ "area": area.replace(area_prefix, ""),
+ **b,
+ "pct_closed": pct(b["closed"], b["triaged_total"]),
+ "pct_merged": pct(b["merged"], b["triaged_total"]),
+ "pct_responded": pct(b["responded"], b["triaged_total"]),
+ }
+ )
+ rows.sort(key=lambda r: -r["triaged_total"])
+ # (no area) goes last
+ rows.sort(key=lambda r: 1 if r["area"] == "(no area)" else 0)
+ # TOTAL row (each PR counted once — recompute over closed_prs)
+ totals = {"triaged_total": 0, "closed": 0, "merged": 0, "responded": 0}
+ seen = set()
+ for pr in closed_prs:
+ if pr["number"] in seen:
+ continue
+ has_qc = False
+ t_at = None
+ for c in (pr.get("comments", {}) or {}).get("nodes", []) or []:
+ if c.get("authorAssociation") in COLLAB_ASSOCIATIONS and ctx[
+ "triage_marker"
+ ] in (c.get("body") or ""):
+ has_qc = True
+ t_at = parse_iso(c["createdAt"])
+ break
+ if not has_qc:
+ continue
+ seen.add(pr["number"])
+ totals["triaged_total"] += 1
+ responded = False
+ if t_at:
+ for c in (pr.get("comments", {}) or {}).get("nodes", []) or []:
+ ca = (c.get("author") or {}).get("login")
+ pa = (pr.get("author") or {}).get("login")
+ if ca and pa and ca == pa and parse_iso(c["createdAt"]) > t_at:
+ responded = True
+ break
+ if pr.get("merged"):
+ totals["merged"] += 1
+ else:
+ totals["closed"] += 1
+ if responded:
+ totals["responded"] += 1
+ rows.append(
+ {
+ "area": "TOTAL",
+ **totals,
+ "pct_closed": pct(totals["closed"], totals["triaged_total"]),
+ "pct_merged": pct(totals["merged"], totals["triaged_total"]),
+ "pct_responded": pct(totals["responded"], totals["triaged_total"]),
+ "_is_total": True,
+ }
+ )
+ return rows
+
+
+def compute_table_still_open(open_prs, area_prefix):
+ """Table 2 — open PRs grouped by area with TOTAL row."""
+ by_area = defaultdict(
+ lambda: {
+ "total": 0,
+ "contribs": 0,
+ "drafts": 0,
+ "non_drafts": 0,
+ "triaged": 0,
+ "responded": 0,
+ "ready": 0,
+ "drafted_by_triager": 0,
+ }
+ )
+ for pr in open_prs:
+ if is_bot(pr.get("_author")):
+ continue
+ areas = pr["_areas"] or ["(no area)"]
+ for area in areas:
+ b = by_area[area]
+ b["total"] += 1
+ if pr["_is_contrib"]:
+ b["contribs"] += 1
+ if pr["isDraft"]:
+ b["drafts"] += 1
+ if pr["_is_triaged"]:
+ b["drafted_by_triager"] += 1
+ else:
+ b["non_drafts"] += 1
+ if pr["_is_triaged"]:
+ b["triaged"] += 1
+ if pr["_responded"]:
+ b["responded"] += 1
+ if pr["_has_ready"]:
+ b["ready"] += 1
+ rows = []
+ for area, b in by_area.items():
+ rows.append(
+ {
+ "area": area.replace(area_prefix, ""),
+ **b,
+ "pct_contribs": pct(b["contribs"], b["total"]),
+ "pct_drafts": pct(b["drafts"], b["contribs"]),
+ "pct_responded": pct(b["responded"], b["triaged"]),
+ "pct_ready": pct(b["ready"], b["contribs"]),
+ }
+ )
+ rows.sort(key=lambda r: -r["total"])
+ rows.sort(key=lambda r: 1 if r["area"] == "(no area)" else 0)
+ # TOTAL — each PR once
+ t = {"total": 0, "contribs": 0, "drafts": 0, "non_drafts": 0,
+ "triaged": 0, "responded": 0, "ready": 0, "drafted_by_triager": 0}
+ for pr in open_prs:
+ if is_bot(pr.get("_author")):
+ continue
+ t["total"] += 1
+ if pr["_is_contrib"]:
+ t["contribs"] += 1
+ if pr["isDraft"]:
+ t["drafts"] += 1
+ if pr["_is_triaged"]:
+ t["drafted_by_triager"] += 1
+ else:
+ t["non_drafts"] += 1
+ if pr["_is_triaged"]:
+ t["triaged"] += 1
+ if pr["_responded"]:
+ t["responded"] += 1
+ if pr["_has_ready"]:
+ t["ready"] += 1
+ rows.append(
+ {
+ "area": "TOTAL",
+ **t,
+ "pct_contribs": pct(t["contribs"], t["total"]),
+ "pct_drafts": pct(t["drafts"], t["contribs"]),
+ "pct_responded": pct(t["responded"], t["triaged"]),
+ "pct_ready": pct(t["ready"], t["contribs"]),
+ "_is_total": True,
+ }
+ )
+ return rows
+
+
+
+
+# ============================================================
+# Render — per panel
+# ============================================================
+
+
+def render_title(ctx, *, lag_warning=False, partial_fetch=False):
+ out = []
+ out.append(
+ f'<h1>📊 {esc(ctx["repo"])} — Maintainer dashboard</h1>'
+ )
+ out.append(
+ f'<div class="context">{ctx["now"].strftime("%A, %B %d, %Y · %H:%M
UTC")} · '
+ f'viewer @{esc(ctx["viewer"])} · 6-week window since
{ctx["cutoff"].date()}</div>'
+ )
+ if partial_fetch:
+ out.append(
+ '<div class="warn">⚠ INCOMPLETE DATA — one or more PR pages failed
'
+ "to fetch (error, rate limit, or page cap reached). Counts and
trends "
+ "below undercount the real backlog; re-run before acting on
them.</div>"
+ )
+ if lag_warning:
+ out.append(
+ '<div class="warn">⚠ 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.</div>"
+ )
+ return "".join(out)
+
+
+def render_hero_rows(hero, health):
+ rating, rating_colour = health
+ c1 = [
+ {"big": rating, "sub": "based on triage backlog + queue size",
"colour": rating_colour},
+ {
+ "big": str(hero["open_total"]),
+ "sub": (
+ f'<div>{hero["non_drafts"]} non-draft · {hero["drafts"]}
draft</div>'
+ f'<div>{hero["contribs"]} contributor · {hero["collabs"]}
collaborator-authored</div>'
+ ),
+ "colour": C_CYAN,
+ },
+ {
+ "big": str(hero["ready"]),
+ "sub": f'{pct(hero["ready"], hero["contrib_nondraft_total"])}% of
contributor queue',
+ "colour": C_GREEN,
+ },
+ {
+ "big": str(hero["untriaged"]),
+ "sub": f'{hero["untriaged_4w"]} are >4 weeks old',
+ "colour": C_RED if hero["untriaged_4w"] > 0
+ else (C_AMBER if hero["untriaged"] > 30 else C_GREEN),
+ },
+ ]
+ c2 = [
+ {
+ "big": str(hero["qc_triaged"]),
+ "sub": f'{pct(hero["qc_triaged"],
hero["contrib_nondraft_total"])}% of contributor non-drafts (Quality Criteria
marker)',
+ "colour": C_BLUE,
+ },
+ {
+ "big": str(hero["defacto"]),
+ "sub": f'{pct(hero["defacto"], hero["contrib_nondraft_total"])}%
of contributor non-drafts (engaged, no marker)',
+ "colour": C_AMBER,
+ },
+ {
+ "big": str(hero["ai_triaged"]),
+ "sub": f'{pct(hero["ai_triaged"], hero["qc_triaged"])}% of
Quality-Criteria-triaged',
+ "colour": C_GREY,
+ },
+ {
+ "big": str(hero["bots"]),
+ "sub": f'{hero["bots_dependabot"]} dependabot ·
{hero["bots_other"]} other',
+ "colour": C_GREY,
+ },
+ ]
+
+ def card_html(c):
+ return (
+ f'<div class="card"><div class="big"
style="color:{c["colour"]}">{c["big"]}</div>'
+ f'<div class="sub">{c["sub"]}</div></div>'
+ )
+
+ return (
+ '<h2>Backlog state</h2>'
+ f'<div class="hero">{"".join(card_html(c) for c in c1)}</div>'
+ '<h3>Triage coverage breakdown</h3>'
+ f'<div class="hero">{"".join(card_html(c) for c in c2)}</div>'
+ )
+
+
+def render_recommendations(recs):
+ if not recs:
+ return (
+ "<h2>What needs attention</h2>"
+ f'<div class="action low"><div class="title">✨ No urgent actions
detected</div>'
+ f'<div class="detail">Queue is in healthy shape — periodic
/pr-management-triage when convenient.</div></div>'
+ )
+ out = ["<h2>What needs attention</h2>"]
+ for r in recs:
+ code = (
+ f'<code>{esc(r["action"])}</code>'
+ if r["action"] and r["action"] != "—"
+ else ""
+ )
+ out.append(
+ f'<div class="action {r["priority"]}">'
+ f'<div class="title">{esc(r["icon"])} {esc(r["title"])}</div>'
+ f'<div class="detail">{esc(r["detail"])}</div>'
+ f'{code}</div>'
+ )
+ return "".join(out)
+
+
+def render_trends_over_time(*, backlog, by_author, ready_cum, triage_velocity,
+ coverage_rate, weeks, ctx):
+ labels = [week_label(s) for s, _ in weeks]
+ out = ["<h2>Trends over time</h2>"]
+
+ # backlog
+ out.append("<h3>Open backlog over time</h3>")
+ out.append(
+ svg_line_chart(
+ [{"label": "open backlog", "values": [b["value"] for b in
backlog], "colour": C_BLUE}],
+ x_labels=labels,
+ y_label="open count",
+ )
+ )
+
+ # by author class
+ out.append("<h3>PRs opened by author class</h3>")
+ out.append(
+ svg_line_chart(
+ [
+ {"label": "FIRST_TIME", "values": [b["first_time"] for b in
by_author], "colour": C_GREEN},
+ {"label": "CONTRIBUTOR", "values": [b["contributor"] for b in
by_author], "colour": C_BLUE},
+ {"label": "MAINTAINER", "values": [b["maintainer"] for b in
by_author], "colour": C_MAGENTA},
+ ],
+ x_labels=labels,
+ )
+ )
+
+ # ready cumulative
+ out.append("<h3>Ready-for-review queue size (cumulative)</h3>")
+ out.append(
+ svg_line_chart(
+ [{"label": "ready cum", "values": [b["value"] for b in ready_cum],
"colour": C_GREEN}],
+ x_labels=labels,
+ )
+ )
+
+ # triage velocity
+ out.append("<h3>Triage velocity (AI vs manual)</h3>")
+ out.append(
+ svg_line_chart(
+ [
+ {"label": "AI-drafted", "values": [b["ai"] for b in
triage_velocity], "colour": C_MAGENTA},
+ {"label": "manual QC", "values": [b["manual"] for b in
triage_velocity], "colour": C_BLUE},
+ ],
+ x_labels=labels,
+ )
+ )
+ out.append('<div class="caveat">comments(last:25) cap may under-count
older weeks.</div>')
+
+ # coverage rate
+ out.append("<h3>Triage coverage rate by week opened (%)</h3>")
+ out.append(
+ svg_line_chart(
+ [{"label": "%engaged", "values": [b["rate"] for b in
coverage_rate], "colour": C_AMBER}],
+ x_labels=labels,
+ y_max=100,
+ )
+ )
+ out.append('<div class="caveat">Same comment-cap caveat as triage
velocity.</div>')
+ return "".join(out)
+
+
+def render_closure_velocity(weekly, weeks):
+ rows = [{"merged": w["merged"], "closed": w["closed_not_merged"]} for w in
weekly]
+ labels = [week_label(s) for s, _ in weeks]
+ total_merged = sum(r["merged"] for r in rows)
+ total_closed = sum(r["closed"] for r in rows)
+ total_total = total_merged + total_closed
+ avg = round(total_total / len(rows), 1) if rows else 0
+ peak = max((r["merged"] + r["closed"] for r in rows), default=0)
+ return (
+ '<h2>Closure velocity (oldest → newest)</h2>'
+ + svg_stacked_horizontal_bars(
+ rows,
+ segment_keys=["merged", "closed"],
+ segment_colours=[C_GREEN, C_GREY],
+ row_labels=labels,
+ )
+ + f'<div class="caveat">6-week total: {total_total} · '
+ f'avg {avg}/wk · peak {peak}/wk · '
+ f'<span class="green">{total_merged} merged</span> + '
+ f'<span class="grey">{total_closed} closed-without-merge</span></div>'
+ )
+
+
+def render_opened_vs_closed(buckets, weeks):
+ labels = [week_label(s) for s, _ in weeks]
+ chart = svg_line_chart(
+ [
+ {"label": "opened", "values": [b["opened"] for b in buckets],
"colour": C_BLUE},
+ {"label": "closed", "values": [b["closed"] for b in buckets],
"colour": C_GREEN},
+ ],
+ x_labels=labels,
+ )
+ if not buckets:
+ return "<h2>Opened vs closed momentum</h2>" + chart
+ last = buckets[-1]
+ six_open = sum(b["opened"] for b in buckets)
+ six_close = sum(b["closed"] for b in buckets)
+ six_net = six_open - six_close
+ last_net = last["net"]
+ direction_six = "backlog shrinking" if six_net < 0 else "backlog growing"
+ return (
+ '<h2>Opened vs closed momentum (last 6 weeks)</h2>'
+ + chart
+ + f'<div class="caveat">Net delta this week: '
+ f'<strong>{last_net:+d}</strong> PRs ({last["opened"]} opened -
{last["closed"]} closed).<br>'
+ f'6-week net: <strong>{six_net:+d}</strong> ({six_open} opened -
{six_close} closed) — {direction_six}.'
+ "</div>"
+ )
+
+
+def render_ready_trend(ready_trend, weeks):
+ series_data, growth = ready_trend
+ labels = [week_label(s) for s, _ in weeks]
+ if not series_data:
+ return (
+ "<h2>Ready-for-review trend by top areas</h2>"
+ '<div class="caveat">No areas with ≥3 currently-ready PRs.</div>'
+ )
+ series = []
+ for area, vals in series_data.items():
+ # colour by pressure-band — approximate via the last value
+ last = vals[-1] if vals else 0
+ c = C_RED if last >= 30 else (C_AMBER if last >= 15 else C_GREY)
+ series.append({"label": area, "values": vals, "colour": c})
+ chart = svg_line_chart(series, x_labels=labels)
+ growth_lines = []
+ for area, vals in series_data.items():
+ cur = vals[-1] if vals else 0
+ prev = vals[-2] if len(vals) >= 2 else 0
+ delta = cur - prev
+ growth_lines.append(
+ f'<div><strong class="area">{esc(area)}</strong>: {cur} ready
(+{delta} in last 7d)</div>'
+ )
+ return (
+ "<h2>Ready-for-review trend (top areas)</h2>"
+ + chart
+ + f'<div class="caveat">{"".join(growth_lines)}</div>'
+ )
+
+
+def render_closed_by_reason(weekly, weeks):
+ labels = [week_label(s) for s, _ in weeks]
+ rows = [
+ {
+ "merged": w["merged"],
+ "responded": w["closed_after_responded"],
+ "sweep": w["closed_after_triage"],
+ "untriaged": w["closed_no_triage"],
+ }
+ for w in weekly
+ ]
+ tot_merged = sum(r["merged"] for r in rows)
+ tot_resp = sum(r["responded"] for r in rows)
+ tot_sweep = sum(r["sweep"] for r in rows)
+ tot_untri = sum(r["untriaged"] for r in rows)
+ return (
+ "<h2>Closed by triage reason (last 6 weeks)</h2>"
+ + svg_stacked_horizontal_bars(
+ rows,
+ segment_keys=["merged", "responded", "sweep", "untriaged"],
+ segment_colours=[C_GREEN, C_AMBER, C_RED, C_GREY],
+ row_labels=labels,
+ )
+ + f'<div class="caveat">6-week breakdown: '
+ f'<span class="green">{tot_merged} merged</span> · '
+ f'<span class="amber">{tot_resp} engaged-then-closed</span> · '
+ f'<span class="red">{tot_sweep} sweep-closed</span> · '
+ f'<span class="grey">{tot_untri} no-triage</span></div>'
+ )
+
+
+def render_pressure(pressure, area_prefix):
+ if not pressure:
+ return (
+ "<h2>Pressure by area</h2>"
+ '<div class="caveat">No areas with ≥3 contributor PRs.</div>'
+ )
+ out = [
+ "<h2>Pressure by area</h2>",
+ '<div class="caveat">Pressure score = weighted sum of urgent PR
conditions per area. Higher score = more attention needed.</div>',
+ ]
+ for area, v in pressure:
+ band = "high" if v["score"] >= 30 else ("medium" if v["score"] >= 15
else "low")
+ out.append(
+ f'<div class="pressure-row {band}">'
+ f'<div><strong class="area">{esc(area)}</strong> — '
+ f'{v["contribs"]} contributor PRs · '
+ f'<span class="red">{v["u4w"]}</span> >4w · '
+ f'<span class="amber">{v["u14w"]}</span> 1-4w · '
+ f'<span class="grey">{v["urec"]}</span> recent · '
+ f'<span class="green">{v["ready"]}</span> ready</div>'
+ f'<div><span class="score">{v["score"]}</span> '
+ f'<code>/pr-management-triage label:area:{esc(area)}</code></div>'
+ "</div>"
+ )
+ return "".join(out)
+
+
+def render_codeowners(rows, total_ready):
+ if not rows:
+ return (
+ "<h2>Ready-for-review queue by CODEOWNER</h2>"
+ '<div class="caveat">.github/CODEOWNERS not found — panel skipped
per render.md.</div>'
+ )
+ out = [
+ "<h2>Ready-for-review queue by CODEOWNER</h2>",
+ '<div class="caveat">For each owner: count of currently-ready PRs
touching files they own. A PR with multiple owners counts once per owner.
Waiting = subset where this owner left a comment the author hasn\'t replied to.
Comments capped at last:25 per PR.</div>',
+ "<table>",
+ '<tr><th>Owner</th><th>Ready PRs</th><th>(% of queue)</th><th>Waiting
for author</th></tr>',
+ ]
+ for owner, ready, waiting in rows:
+ ready_colour = (
+ C_RED if ready >= 50 else (C_AMBER if ready >= 20 else (C_GREEN if
ready >= 10 else C_GREY))
+ )
+ wait_html = (
+ f'<span class="red">{waiting}</span>' if waiting > 0 else f'<span
class="grey">0</span>'
+ )
+ out.append(
+ f'<tr><td>@{esc(owner)}</td>'
+ f'<td style="color:{ready_colour}">{ready}</td>'
+ f'<td class="grey">{pct(ready, total_ready)}%</td>'
+ f'<td>{wait_html}</td></tr>'
+ )
+ out.append("</table>")
+ return "".join(out)
+
+
+def render_funnel(funnel):
+ cards = [
+ {"big": funnel["ready"], "sub": "Ready for review", "colour": C_GREEN},
+ {"big": funnel["responded"], "sub": "Responded (post-QC)", "colour":
C_CYAN},
+ {"big": funnel["waiting_ai"], "sub": "Waiting: AI-triage only",
"colour": C_MAGENTA},
+ {"big": funnel["waiting_manual"], "sub": "Waiting: author response to
maintainer", "colour": C_RED},
+ {"big": funnel["untriaged"], "sub": "Not yet triaged", "colour":
C_BLUE},
+ ]
+ body = "".join(
+ f'<div class="card"><div class="big"
style="color:{c["colour"]}">{c["big"]}</div>'
+ f'<div class="sub">{c["sub"]}</div></div>'
+ for c in cards
+ )
+ return (
+ '<h2>Triage funnel</h2>'
+ f'<div class="funnel">{body}</div>'
+ '<div class="caveat">The two waiting cards are mutually exclusive — a
PR with both unresponded AI-drafted and manual maintainer comments counts only
in "author response to maintainer". Excludes drafts and bots.</div>'
+ )
+
+
+def render_triager_activity(rows, weeks):
+ if not rows:
+ return (
+ "<h2>Triager activity (6-week window)</h2>"
+ '<div class="caveat">No triager activity in the last 6 weeks —
quiet window or fetch shape missing comment data.</div>'
+ )
+ labels = [week_label(s) for s, _ in weeks]
+ out = ["<h2>Triager activity (6-week window)</h2>", "<table>"]
+ th_weeks = "".join(f"<th>{esc(l)}</th>" for l in labels)
+ out.append(
+ f"<tr><th>Triager</th><th>Total</th><th>AI</th><th>Manual</th>"
+ f"{th_weeks}<th>Trend</th></tr>"
+ )
+ total_ai = sum(r["ai"] for r in rows)
+ total_manual = sum(r["manual"] for r in rows)
+ total_all = sum(r["total"] for r in rows)
+ for r in rows:
+ max_wk = max(((w["ai"] + w["manual"]) for w in r["per_week"]),
default=1) or 1
+ spark = '<span class="sparkline">' + "".join(
+ (
+ f'<span class="bar ai" style="height:{max(2, int(18 * w["ai"]
/ max_wk))}px"></span>'
+ f'<span class="bar" style="height:{max(2, int(18 * w["manual"]
/ max_wk))}px"></span>'
+ )
+ for w in r["per_week"]
+ ) + "</span>"
+ wk_cells = "".join(
+ f'<td><span class="magenta">{w["ai"]}</span>/<span
class="blue">{w["manual"]}</span></td>'
+ for w in r["per_week"]
+ )
+ out.append(
+ f'<tr><td><a href="https://github.com/{esc(r["login"])}" '
+ f'style="color:{C_CYAN}">@{esc(r["login"])}</a></td>'
+ f'<td>{r["total"]}</td>'
+ f'<td class="magenta">{r["ai"]}</td>'
+ f'<td class="blue">{r["manual"]}</td>'
+ f'{wk_cells}<td>{spark}</td></tr>'
+ )
+ out.append("</table>")
+ out.append(
+ f'<div class="caveat">6-week throughput: '
+ f'<span class="magenta">{total_ai} AI-assisted</span> / '
+ f'<span class="blue">{total_manual} manual</span> / '
+ f'{total_all} total across {len(rows)} active maintainers.</div>'
+ )
+ return "".join(out)
+
+
+def render_detailed_tables(table1, table2, cutoff, repo):
+ # Table 1
+ t1 = [
+ f"<details><summary>Triaged PRs — Final State since {cutoff.date()}
({esc(repo)})</summary><table>",
+ "<tr><th>Area</th><th>Triaged
Total</th><th>Closed</th><th>%Closed</th>"
+
"<th>Merged</th><th>%Merged</th><th>Responded</th><th>%Responded</th></tr>",
+ ]
+ for r in table1:
+ cls = "total" if r.get("_is_total") else ""
+ pct_resp_colour = colour_for_pct(r["pct_responded"])
+ t1.append(
+ f'<tr class="{cls}"><td class="area">{esc(r["area"])}</td>'
+ f'<td class="amber">{r["triaged_total"]}</td>'
+ f'<td class="red">{r["closed"]}</td>'
+ f'<td>{r["pct_closed"]}%</td>'
+ f'<td class="green">{r["merged"]}</td>'
+ f'<td>{r["pct_merged"]}%</td>'
+ f'<td class="cyan">{r["responded"]}</td>'
+ f'<td
style="color:{pct_resp_colour}">{r["pct_responded"]}%</td></tr>'
+ )
+ t1.append("</table></details>")
+
+ # Table 2
+ t2 = [
+ f"<details><summary>Triaged PRs — Still Open
({esc(repo)})</summary><table>",
+ "<tr><th>Area</th><th>Total</th><th>Contrib</th><th>%Contrib</th>"
+ "<th>Draft</th><th>%Draft</th><th>Non-Draft</th>"
+ "<th>Triaged</th><th>Responded</th><th>%Resp</th>"
+ "<th>Ready</th><th>%Ready</th><th>Drafted by triager</th></tr>",
+ ]
+ for r in table2:
+ cls = "total" if r.get("_is_total") else ""
+ pct_draft_colour = C_RED if r["pct_drafts"] > 60 else C_FG
+ pct_resp_colour = colour_for_pct(r["pct_responded"])
+ pct_ready_colour = colour_for_pct(r["pct_ready"])
+ t2.append(
+ f'<tr class="{cls}"><td class="area">{esc(r["area"])}</td>'
+ f'<td class="grey">{r["total"]}</td>'
+ f'<td class="cyan">{r["contribs"]}</td>'
+ f'<td>{r["pct_contribs"]}%</td>'
+ f'<td>{r["drafts"]}</td>'
+ f'<td style="color:{pct_draft_colour}">{r["pct_drafts"]}%</td>'
+ f'<td>{r["non_drafts"]}</td>'
+ f'<td class="amber">{r["triaged"]}</td>'
+ f'<td class="green">{r["responded"]}</td>'
+ f'<td style="color:{pct_resp_colour}">{r["pct_responded"]}%</td>'
+ f'<td class="green">{r["ready"]}</td>'
+ f'<td style="color:{pct_ready_colour}">{r["pct_ready"]}%</td>'
+ f'<td class="magenta">{r["drafted_by_triager"]}</td></tr>'
+ )
+ t2.append("</table></details>")
+ return "".join(t1) + "".join(t2)
+
+
+def render_legend():
+ return f"""<h2>Legend / methodology</h2>
+<div class="legend">
+<dl>
+<dt>Hero card colours</dt>
+<dd><span class="green">green</span> = healthy / on-target;
+ <span class="amber">amber</span> = needs attention soon;
+ <span class="red">red</span> = action needed now;
+ <span class="cyan">cyan</span> = informational (raw counts).</dd>
+
+<dt>Recommendation priorities</dt>
+<dd>Coloured left border on action cards:
+ <span class="red">red</span> = high (do today),
+ <span class="amber">amber</span> = medium (this week),
+ <span class="grey">grey</span> = low (background awareness).</dd>
+
+<dt>Closure velocity bars</dt>
+<dd><span class="green">green</span> = PRs merged that week,
+ <span class="grey">grey</span> = PRs closed without merging.
+ Bar widths normalised to the busiest week in the 6-week window.</dd>
+
+<dt>Opened-vs-closed line chart</dt>
+<dd><span class="blue">Blue</span> = opened per week. <span
class="green">Green</span> = closed/merged per week.
+ Where blue is above green the backlog grew; vice-versa, it shrank.</dd>
+
+<dt>Ready-for-review trend</dt>
+<dd>Cumulative count of currently-ready PRs by week, per top-pressure area.
+ Line colour by area's pressure band: <span class="red">red ≥ 30</span>,
+ <span class="amber">amber 15–29</span>, <span class="grey">grey <
15</span>.</dd>
+
+<dt>Closed by triage reason</dt>
+<dd><span class="green">merged</span> · <span class="amber">closed after
author responded</span> ·
+ <span class="red">closed after triage, no response (sweep)</span> ·
+ <span class="grey">closed without ever being triaged</span>.</dd>
+
+<dt>Pressure score</dt>
+<dd>Weighted sum of urgent contributor PRs per area: untriaged >4w = 5pt,
+ 1–4w = 3pt, <1w = 1pt; triaged-waiting >7d = 2pt; ready = 1pt.</dd>
+
+<dt>Triage states (funnel grid)</dt>
+<dd><span class="green"><strong>Ready</strong></span>: has <code>ready for
maintainer review</code> label.
+ <span class="cyan"><strong>Responded</strong></span>: QC marker present
AND author replied/pushed after it.
+ <span class="magenta"><strong>Waiting: AI-only</strong></span>: only
AI-drafted comment unresponded.
+ <span class="red"><strong>Waiting: author response to
maintainer</strong></span>: manual maintainer comment unresponded.
+ <span class="blue"><strong>Not yet triaged</strong></span>: never received
a QC comment.</dd>
+
+<dt>Triager activity sparkline</dt>
+<dd>One bar per week (6 bars total). Magenta = AI-drafted, blue = manual. Bar
height = relative weekly volume.</dd>
+
+<dt>Percentage-cell colours</dt>
+<dd><span class="green">green ≥ 50%</span>, <span class="amber">amber
20–49%</span>, <span class="red">red < 20%</span>.
+ 50% reads green (happier colour wins on tie).</dd>
+
+<dt>Detailed-table columns</dt>
+<dd><span class="cyan"><strong>Contrib.</strong></span> —
non-collaborator-authored PRs (denominator for contributor-scoped metrics).
+ <span class="amber"><strong>Triaged</strong></span> — comment by
OWNER/MEMBER/COLLABORATOR containing
+ <code>Pull Request quality criteria</code> after the last commit.
+ <span class="green"><strong>Responded</strong></span> — author
commented/pushed after the triage comment.
+ <span class="green"><strong>Ready</strong></span> — carries <code>ready
for maintainer review</code> label.
+ <span class="magenta"><strong>Drafted by triager</strong></span> — drafts
that are also triaged.</dd>
+
+<dt>Methodology</dt>
+<dd>Snapshot taken at the timestamp shown in the title bar. Open PRs via
GraphQL search
+ with full engagement schema (comments, latestReviews, reviewThreads,
timelineItems).
+ Closed/merged via GitHub search. Triage marker: collab comment containing
the literal
+ string <code>Pull Request quality criteria</code> after the last commit.
Bots filtered
+ at fetch time (<code>*[bot]</code>, dependabot, github-actions).</dd>
+</dl>
+</div>"""
+
+
+def render_summary(hero, recent_drafts):
+ return (
+ f'<div class="footer">Summary: {hero["open_total"]} open · '
+ f'{hero["qc_triaged"]} triaged ({pct(hero["qc_triaged"],
hero["contrib_nondraft_total"])}%) · '
+ f'{hero["responded"]} responded · '
+ f'{hero["ready"]} ready for review · '
+ f'{recent_drafts} drafted by triager in last 7d.</div>'
+ )
+
+
+# ============================================================
+# Dashboard composer
+# ============================================================
+
+
+def render_dashboard(
+ ctx,
+ *,
+ hero,
+ health,
+ recs,
+ weekly,
+ pressure,
+ ready_trend,
+ codeowners_rows,
+ funnel,
+ backlog,
+ by_author,
+ ready_cum,
+ triage_velocity,
+ coverage_rate,
+ opened_vs_closed,
+ triager_activity,
+ table_final,
+ table_open,
+ recent_drafts,
+ lag_warning=False,
+ partial_fetch=False,
+):
+ sections = [
+ "<!DOCTYPE html><html><head><meta charset=\"utf-8\">"
+ f"<title>{esc(ctx['repo'])} — dashboard</title>{CSS}</head><body>",
+ render_title(ctx, lag_warning=lag_warning,
partial_fetch=partial_fetch),
+ render_hero_rows(hero, health),
+ render_recommendations(recs),
+ render_trends_over_time(
+ backlog=backlog,
+ by_author=by_author,
+ ready_cum=ready_cum,
+ triage_velocity=triage_velocity,
+ coverage_rate=coverage_rate,
+ weeks=ctx["weeks"],
+ ctx=ctx,
+ ),
+ render_closure_velocity(weekly, ctx["weeks"]),
+ render_opened_vs_closed(opened_vs_closed, ctx["weeks"]),
+ render_ready_trend(ready_trend, ctx["weeks"]),
+ render_closed_by_reason(weekly, ctx["weeks"]),
+ render_pressure(pressure, ctx["area_prefix"]),
+ render_codeowners(codeowners_rows, hero["ready"]),
+ render_funnel(funnel),
+ render_triager_activity(triager_activity, ctx["weeks"]),
+ render_detailed_tables(table_final, table_open, ctx["cutoff"],
ctx["repo"]),
+ render_legend(),
+ render_summary(hero, recent_drafts),
+ "</body></html>",
+ ]
+ return "\n".join(sections)
+
+
+# ============================================================
+# Main
+# ============================================================
+
+
+def main():
+ ap = argparse.ArgumentParser(
+ description="pr-management-stats full dashboard render (extends
reference.py)"
+ )
+ ap.add_argument("--repo", required=True, help="owner/name, e.g.
apache/airflow")
+ ap.add_argument("--viewer", required=True, help="viewer GitHub login")
+ ap.add_argument("--since", help="cutoff YYYY-MM-DD (default: 6 weeks ago)")
+ ap.add_argument("--out", default="dashboard.html", help="output HTML path")
+ ap.add_argument("--triage-marker", default=DEFAULT_TRIAGE_MARKER)
+ ap.add_argument("--ai-footer", default=DEFAULT_AI_FOOTER)
+ ap.add_argument("--ready-label", default=DEFAULT_READY_LABEL)
+ ap.add_argument("--area-prefix", default=DEFAULT_AREA_PREFIX)
+ ap.add_argument("--page-size", type=int, default=30)
+ args = ap.parse_args()
+
+ now = datetime.now(timezone.utc)
+ weeks = 6
+ cutoff = now - timedelta(weeks=weeks)
+ if args.since:
+ cutoff = datetime.strptime(args.since,
"%Y-%m-%d").replace(tzinfo=timezone.utc)
+
+ ctx = {
+ "now": now,
+ "cutoff": cutoff,
+ "weeks": weeks_buckets(now, weeks),
+ "triage_marker": args.triage_marker,
+ "ai_footer": args.ai_footer,
+ "ready_label": args.ready_label,
+ "area_prefix": args.area_prefix,
+ "repo": args.repo,
+ "viewer": args.viewer,
+ }
+
+ print(f"== dashboard.py — pr-management-stats canonical render ==",
file=sys.stderr)
+ print(
+ f" repo={args.repo} viewer={args.viewer} cutoff={cutoff.date()}",
+ file=sys.stderr,
+ )
+
+ # ---- Fetch (reuses reference.py primitives) ----
+ # `fetch_status` collects partial-fetch signals from both paginated calls;
+ # if either was cut short (error / rate-limit / max_pages) the dashboard is
+ # flagged incomplete rather than silently published as if it were whole.
+ fetch_status = {"partial": False}
+
+ print("Fetching open PRs (full engagement schema) ...", file=sys.stderr)
+ open_prs = paginated_search(
+ OPEN_PRS_QUERY,
+ f"is:pr is:open repo:{args.repo}",
+ page_size=args.page_size,
+ status=fetch_status,
+ )
+ print(f" -> {len(open_prs)} open PRs", file=sys.stderr)
+ for pr in open_prs:
+ classify(pr, ctx)
+
+ print(f"Fetching closed/merged PRs since {cutoff.date()} ...",
file=sys.stderr)
+ closed_prs = paginated_search(
+ CLOSED_PRS_QUERY,
+ f"is:pr is:closed repo:{args.repo} closed:>={cutoff.date()}",
+ page_size=50,
+ max_pages=20,
+ status=fetch_status,
+ )
+ print(f" -> {len(closed_prs)} closed PRs", file=sys.stderr)
+
+ # Closed PRs come from the reduced CLOSED_PRS_QUERY (no engagement
+ # collections). classify(partial=True) makes that contract explicit and
+ # reads the heavy signals defensively; isDraft IS selected by the query.
+ if closed_prs:
+ print(
+ f" classifying {len(closed_prs)} closed PRs from the partial "
+ "(closed-PR) schema — engagement signals limited to
comments/labels",
+ file=sys.stderr,
+ )
+ for pr in closed_prs:
+ classify(pr, ctx, partial=True)
+
+ if fetch_status["partial"]:
+ print(
+ "WARNING: pagination was cut short — dashboard is INCOMPLETE "
+ "(partial banner added to HTML, partial=true in JSON sidecar).",
+ file=sys.stderr,
+ )
+
+ print("Fetching CODEOWNERS + ready PR files ...", file=sys.stderr)
+ codeowners = fetch_codeowners(args.repo)
+ ready_nums = [pr["number"] for pr in open_prs if pr["_has_ready"]]
+ files_per_pr = fetch_ready_pr_files(args.repo, ready_nums) if ready_nums
else {}
+ print(
+ f" -> CODEOWNERS={len(codeowners)} chars, ready files for
{len(files_per_pr)} PRs",
+ file=sys.stderr,
+ )
+
+ # ---- Aggregate ----
+ print("Aggregating ...", file=sys.stderr)
+ hero = compute_hero_counts(open_prs)
+ weekly = compute_weekly_velocity(closed_prs, ctx["weeks"],
args.triage_marker)
+ pressure = compute_pressure_by_area(open_prs, args.area_prefix)
+ ready_trend = compute_ready_trend_by_area(
+ open_prs, ctx["weeks"], pressure, args.area_prefix
+ )
+ backlog = compute_backlog_over_time(open_prs, closed_prs, ctx["weeks"])
+ by_author = compute_opened_by_author_class(open_prs + closed_prs,
ctx["weeks"])
+ ready_cum = compute_ready_queue_cumulative(open_prs, ctx["weeks"])
+ triage_vel = compute_triage_velocity(open_prs + closed_prs, ctx["weeks"],
ctx)
+ coverage = compute_triage_coverage_rate(open_prs + closed_prs,
ctx["weeks"])
+ momentum = compute_opened_vs_closed(open_prs + closed_prs, ctx["weeks"])
+ funnel = compute_triage_funnel(open_prs)
+ triager_act = compute_triager_activity(
+ open_prs, closed_prs, ctx["weeks"], ctx
+ )
+ table_final = compute_table_final_state(closed_prs, args.area_prefix, ctx)
+ table_open = compute_table_still_open(open_prs, args.area_prefix)
+ codeowners_rows = (
+ compute_codeowners_panel(open_prs, files_per_pr, codeowners)
+ if codeowners
+ else []
+ )
+ recs = compute_recommendations(
+ open_prs, weekly, pressure, hero, ready_trend[1]
+ )
+ health = compute_health_rating(hero, recs)
+
+ recent_drafts = sum(
+ 1
+ for pr in open_prs
+ if pr["isDraft"]
+ and pr["_is_triaged"]
+ and pr["_triage_at"]
+ and (now - pr["_triage_at"]).days <= 7
+ )
+
+ # ---- Render ----
+ print("Rendering ...", file=sys.stderr)
+ html_out = render_dashboard(
+ ctx,
+ hero=hero,
+ health=health,
+ recs=recs,
+ weekly=weekly,
+ pressure=pressure,
+ ready_trend=ready_trend,
+ codeowners_rows=codeowners_rows,
+ funnel=funnel,
+ backlog=backlog,
+ by_author=by_author,
+ ready_cum=ready_cum,
+ triage_velocity=triage_vel,
+ coverage_rate=coverage,
+ opened_vs_closed=momentum,
+ triager_activity=triager_act,
+ table_final=table_final,
+ table_open=table_open,
+ recent_drafts=recent_drafts,
+ partial_fetch=fetch_status["partial"],
+ )
+
+ out_path = Path(args.out)
+ out_path.parent.mkdir(parents=True, exist_ok=True)
+ out_path.write_text(html_out)
+
+ # ---- JSON sidecar: superset of reference.py's keys ----
+ # Every key reference.py emits is present here with an identically-computed
+ # value; dashboard.py only ADDS keys. tests/test_json_parity.py asserts
this
+ # contract against a shared fixture so a refactor on either side can't
drift
+ # it unnoticed.
+ intermediates = {
+ # Keys shared with reference.py — same value on identical input.
+ "fetched_at": now.isoformat(),
+ "repo": args.repo,
+ "viewer": args.viewer,
+ "cutoff": cutoff.isoformat(),
+ "open_count": len(open_prs),
+ "closed_count": len(closed_prs),
+ "ready_count": sum(1 for p in open_prs if p["_has_ready"]),
+ "untriaged_count": sum(1 for p in open_prs if p["_is_untriaged"]),
+ "untriaged_4w_count": sum(
+ 1 for p in open_prs if p["_is_untriaged"] and p["_age_days"] > 28
+ ),
+ "engaged_count": sum(1 for p in open_prs if p["_is_engaged"]),
+ "ai_triaged_count": sum(1 for p in open_prs if p["_has_ai_footer"]),
+ "files_per_ready_pr_count": len(files_per_pr),
+ "codeowners_bytes": len(codeowners),
+ # New keys (dashboard.py extras)
+ "hero": hero,
+ "health_rating": health[0],
+ "recommendation_count": len(recs),
+ "pressure_areas": [
+ {"area": a, **v} for a, v in pressure
+ ],
+ "funnel": funnel,
+ "weekly_velocity_totals": {
+ "merged": sum(w["merged"] for w in weekly),
+ "closed_not_merged": sum(w["closed_not_merged"] for w in weekly),
+ },
+ "partial": fetch_status["partial"],
+ }
+ side = out_path.with_suffix(".json")
+ side.write_text(json.dumps(intermediates, indent=2, default=str))
+
+ print(f"\nDashboard written to {out_path}", file=sys.stderr)
+ print(f"Intermediate state written to {side}", file=sys.stderr)
+ print(json.dumps({k: intermediates[k] for k in (
+ "open_count", "closed_count", "ready_count",
+ "untriaged_count", "untriaged_4w_count", "engaged_count",
+ "ai_triaged_count", "health_rating", "recommendation_count",
+ )}, indent=2))
+
+
+if __name__ == "__main__":
+ main()
diff --git a/tools/pr-management-stats/pyproject.toml
b/tools/pr-management-stats/pyproject.toml
new file mode 100644
index 0000000..e17844d
--- /dev/null
+++ b/tools/pr-management-stats/pyproject.toml
@@ -0,0 +1,42 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you 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.
+
+[project]
+name = "pr-management-stats"
+version = "0.1.0"
+description = "Tests for the pr-management-stats deterministic dashboard
renderer (reference.py + dashboard.py)."
+readme = "README.md"
+requires-python = ">=3.11"
+license = { text = "Apache-2.0" }
+# The tool is two directory-portable scripts (reference.py, dashboard.py), not
+# an installable package. This pyproject exists only to pin the test harness.
+dependencies = []
+
+[dependency-groups]
+dev = [
+ "pytest>=8.0",
+]
+
+[tool.pytest.ini_options]
+minversion = "8.0"
+addopts = "-ra -q"
+testpaths = ["tests"]
+# reference.py / dashboard.py live flat at the tool root; put it on sys.path so
+# `import reference` / `import dashboard` resolve under both bare `pytest` and
+# `uv run pytest` (matching how the scripts find each other at runtime).
"tests"
+# is added so the shared `helpers` fixture module is importable.
+pythonpath = [".", "tests"]
diff --git a/tools/pr-management-stats/reference.py
b/tools/pr-management-stats/reference.py
index 7e5ef97..6a7ce91 100644
--- a/tools/pr-management-stats/reference.py
+++ b/tools/pr-management-stats/reference.py
@@ -56,6 +56,7 @@ import json
import re
import subprocess
import sys
+import time
from collections import defaultdict
from datetime import datetime, timedelta, timezone
from pathlib import Path
@@ -64,6 +65,13 @@ from pathlib import Path
# Constants (project-overridable via --config)
# --------------------------------------------------------------------------
+# Example default values for the reference instance these scripts were built
+# against. They are NOT vendor-neutral, and that is fine here: per RFC-AI-0004
+# every project-specific value is a CLI override (the --triage-marker /
+# --ai-footer / --ready-label / --area-prefix flags below), so an adopter for
+# another project supplies their own without editing this file. The framework's
+# placeholder convention governs repo slugs / URLs in prose, not these runtime
+# config defaults.
DEFAULT_TRIAGE_MARKER = "Pull Request quality criteria"
DEFAULT_AI_FOOTER = "AI-assisted triage tool"
DEFAULT_READY_LABEL = "ready for maintainer review"
@@ -71,6 +79,9 @@ DEFAULT_AREA_PREFIX = "area:"
COLLAB_ASSOCIATIONS = {"OWNER", "MEMBER", "COLLABORATOR"}
BOT_LOGINS = {"github-actions", "dependabot", "renovate",
"copilot-pull-request-reviewer"}
+# stderr markers that indicate a transient (retryable) gh/GraphQL failure.
+_TRANSIENT_MARKERS = ("502", "503", "504", "rate limit", "timeout", "timed
out", "abuse")
+
def parse_iso(t):
if not t:
@@ -134,7 +145,7 @@ query($q: String!, $first: Int!, $after: String) {
pageInfo { hasNextPage endCursor }
nodes {
... on PullRequest {
- number title createdAt closedAt mergedAt merged state
+ number title isDraft createdAt closedAt mergedAt merged state
author { login __typename } authorAssociation
labels(first: 20) { nodes { name } }
comments(last: 25) {
@@ -152,24 +163,76 @@ def run_gh(*args, **kwargs):
return subprocess.run(["gh", *args], capture_output=True, text=True,
**kwargs)
-def paginated_search(query, search_q, page_size=30, max_pages=40):
- """Run a paginated GraphQL search query, return all nodes."""
+def _is_rate_limited(errors):
+ """True if a GraphQL errors[] payload reports RATE_LIMITED."""
+ for e in errors or []:
+ if isinstance(e, dict) and e.get("type") == "RATE_LIMITED":
+ return True
+ return False
+
+
+def _run_graphql_page(cmd, page, max_retries, backoff):
+ """Run one gh GraphQL page, retrying transient (5xx / rate-limit) failures.
+
+ Returns the parsed response dict, or None on a permanent failure (caller
+ should treat None as "pagination cut short"). Backoff uses ``time.sleep``
+ looked up at call time so tests can patch it.
+ """
+ for attempt in range(max_retries + 1):
+ r = subprocess.run(cmd, capture_output=True, text=True)
+ retries_left = attempt < max_retries
+ if r.returncode != 0:
+ transient = any(m in r.stderr.lower() for m in _TRANSIENT_MARKERS)
+ if transient and retries_left:
+ print(f" page {page}: transient error, retry {attempt +
1}/{max_retries}",
+ file=sys.stderr)
+ time.sleep(backoff * (attempt + 1))
+ continue
+ print(f" page {page}: error {r.stderr[:200]}", file=sys.stderr)
+ return None
+ try:
+ d = json.loads(r.stdout)
+ except json.JSONDecodeError:
+ if retries_left:
+ time.sleep(backoff * (attempt + 1))
+ continue
+ print(f" page {page}: invalid JSON response", file=sys.stderr)
+ return None
+ if "errors" in d:
+ if _is_rate_limited(d["errors"]) and retries_left:
+ print(f" page {page}: RATE_LIMITED, retry {attempt +
1}/{max_retries}",
+ file=sys.stderr)
+ time.sleep(backoff * (attempt + 1))
+ continue
+ print(f" page {page}: errors {d['errors'][:1]}", file=sys.stderr)
+ return None
+ return d
+ return None
+
+
+def paginated_search(query, search_q, page_size=30, max_pages=40, *,
+ max_retries=1, backoff=2.0, status=None):
+ """Run a paginated GraphQL search query, return all nodes.
+
+ Retries transient (5xx / RATE_LIMITED) failures up to ``max_retries`` times
+ with linear backoff. If ``status`` is a dict, sets ``status["partial"] =
+ True`` when pagination was cut short — an error, or ``max_pages`` reached
+ while more pages remained — so callers can flag incomplete output rather
+ than silently publish a truncated result.
+ """
all_nodes = []
cursor = None
+ partial = False
for page in range(1, max_pages + 1):
cmd = ["gh", "api", "graphql",
"-F", f"first={page_size}",
"-F", f"q={search_q}",
"-F", f"query={query}"]
if cursor:
- cmd.insert(4, "-F"); cmd.insert(5, f"after={cursor}")
- r = subprocess.run(cmd, capture_output=True, text=True)
- if r.returncode != 0:
- print(f" page {page}: error {r.stderr[:200]}", file=sys.stderr)
- break
- d = json.loads(r.stdout)
- if "errors" in d:
- print(f" page {page}: errors {d['errors'][:1]}", file=sys.stderr)
+ cmd.extend(["-F", f"after={cursor}"])
+ d = _run_graphql_page(cmd, page, max_retries, backoff)
+ if d is None:
+ partial = True
break
nodes = d["data"]["search"]["nodes"]
all_nodes.extend(nodes)
@@ -178,6 +241,12 @@ def paginated_search(query, search_q, page_size=30,
max_pages=40):
if not pi["hasNextPage"]:
break
cursor = pi["endCursor"]
+ else:
+ # Loop ran the full max_pages without the hasNextPage=False break —
+ # there were (or may have been) more pages we never fetched.
+ partial = True
+ if status is not None:
+ status["partial"] = status.get("partial", False) or partial
return all_nodes
@@ -221,7 +290,16 @@ def fetch_codeowners(repo):
# Classification — see classify.md
# --------------------------------------------------------------------------
-def classify(pr, ctx):
+def classify(pr, ctx, *, partial=False):
+ """Annotate a PR node in place with `_`-prefixed classification fields.
+
+ `partial=True` declares that the PR came from a reduced schema (the
+ closed-PR query, which omits the heavy engagement collections —
+ commits / latestReviews / reviewThreads / timelineItems). Those signals are
+ read defensively below, so an absent collection contributes False to
+ `_is_engaged` rather than raising. `isDraft` IS required from both queries
+ (CLOSED_PRS_QUERY now selects it); it falls back to False only as a guard.
+ """
author = pr["author"]["login"] if pr["author"] else None
assoc = pr.get("authorAssociation", "?")
pr["_author"] = author
@@ -288,7 +366,7 @@ def classify(pr, ctx):
pr["_is_untriaged"] = (
not pr["_is_engaged"]
and pr["_is_contrib"]
- and not pr["isDraft"]
+ and not pr.get("isDraft", False)
and not pr["_has_ready"]
)
diff --git a/tools/pr-management-stats/tests/helpers.py
b/tools/pr-management-stats/tests/helpers.py
new file mode 100644
index 0000000..72c918c
--- /dev/null
+++ b/tools/pr-management-stats/tests/helpers.py
@@ -0,0 +1,112 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you 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.
+
+"""Shared fixture builders for the pr-management-stats tests.
+
+Not a test module (no ``test_`` prefix) so pytest does not collect it.
+Builds GraphQL-shaped PR node dicts that match the OPEN_PRS_QUERY /
+CLOSED_PRS_QUERY schemas so they can be fed straight through
+``reference.classify`` and the dashboard aggregations.
+"""
+from __future__ import annotations
+
+from datetime import datetime, timedelta, timezone
+
+import reference
+
+# Fixed "now" so age-based classification is deterministic across runs.
+NOW = datetime(2026, 5, 29, 12, 0, 0, tzinfo=timezone.utc)
+
+
+def _iso(days_ago: float) -> str:
+ return (NOW - timedelta(days=days_ago)).isoformat().replace("+00:00", "Z")
+
+
+def make_ctx(**overrides):
+ ctx = {
+ "now": NOW,
+ "cutoff": NOW - timedelta(weeks=6),
+ "weeks": reference.weeks_buckets(NOW, 6),
+ "triage_marker": reference.DEFAULT_TRIAGE_MARKER,
+ "ai_footer": reference.DEFAULT_AI_FOOTER,
+ "ready_label": reference.DEFAULT_READY_LABEL,
+ "area_prefix": reference.DEFAULT_AREA_PREFIX,
+ "repo": "apache/airflow",
+ "viewer": "tester",
+ }
+ ctx.update(overrides)
+ return ctx
+
+
+def comment(body, *, author="maintainer", assoc="MEMBER", days_ago=3):
+ return {
+ "author": {"login": author, "__typename": "User"},
+ "authorAssociation": assoc,
+ "createdAt": _iso(days_ago),
+ "body": body,
+ }
+
+
+def make_pr(
+ number,
+ *,
+ author="contributor",
+ assoc="NONE",
+ is_draft=False,
+ created_days_ago=10,
+ labels=None,
+ comments=None,
+ reviews=None,
+ review_threads=None,
+ timeline=None,
+ commits=None,
+ closed_days_ago=None,
+ merged=False,
+ include_engagement=True,
+):
+ """Build one PR node.
+
+ ``include_engagement=False`` simulates the reduced CLOSED_PRS_QUERY schema
+ (no commits / latestReviews / reviewThreads / timelineItems).
+ """
+ node = {
+ "number": number,
+ "title": f"PR {number}",
+ "isDraft": is_draft,
+ "createdAt": _iso(created_days_ago),
+ "author": {"login": author, "__typename": "User"} if author else None,
+ "authorAssociation": assoc,
+ "labels": {"nodes": [{"name": n} for n in (labels or [])]},
+ "comments": {"nodes": comments or []},
+ }
+ if include_engagement:
+ node["latestReviews"] = {"nodes": reviews or []}
+ node["reviewThreads"] = {"nodes": review_threads or []}
+ node["timelineItems"] = {"nodes": timeline or []}
+ node["commits"] = {"nodes": commits or []}
+ if closed_days_ago is not None:
+ node["closedAt"] = _iso(closed_days_ago)
+ node["mergedAt"] = _iso(closed_days_ago) if merged else None
+ node["merged"] = merged
+ node["state"] = "MERGED" if merged else "CLOSED"
+ return node
+
+
+def classify_all(prs, ctx, *, partial=False):
+ for pr in prs:
+ reference.classify(pr, ctx, partial=partial)
+ return prs
diff --git a/tools/pr-management-stats/tests/test_aggregations.py
b/tools/pr-management-stats/tests/test_aggregations.py
new file mode 100644
index 0000000..3811d62
--- /dev/null
+++ b/tools/pr-management-stats/tests/test_aggregations.py
@@ -0,0 +1,153 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you 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.
+
+"""Unit tests for the pure aggregation functions (aggregate.md)."""
+from __future__ import annotations
+
+import dashboard
+import reference
+from helpers import classify_all, comment, make_ctx, make_pr
+
+QC = reference.DEFAULT_TRIAGE_MARKER
+AI = reference.DEFAULT_AI_FOOTER
+READY = reference.DEFAULT_READY_LABEL
+
+
+def _hero_dataset(ctx):
+ prs = [
+ # contributor, non-draft, untriaged, >4 weeks old
+ make_pr(1, author="alice", assoc="NONE", created_days_ago=40),
+ # contributor, non-draft, ready-labelled (engaged, not untriaged)
+ make_pr(2, author="bob", assoc="NONE", labels=[READY]),
+ # collaborator-authored
+ make_pr(3, author="maint", assoc="MEMBER"),
+ # contributor draft
+ make_pr(4, author="carol", assoc="NONE", is_draft=True),
+ # bot
+ make_pr(5, author="dependabot", assoc="NONE"),
+ # contributor with quality-criteria marker comment by a member →
triaged
+ make_pr(
+ 6, author="dave", assoc="NONE",
+ comments=[comment(f"... {QC} ...", author="maint",
assoc="MEMBER")],
+ ),
+ ]
+ return classify_all(prs, ctx)
+
+
+def test_compute_hero_counts():
+ ctx = make_ctx()
+ hero = dashboard.compute_hero_counts(_hero_dataset(ctx))
+
+ assert hero["open_total"] == 5 # bot excluded
+ assert hero["drafts"] == 1
+ assert hero["non_drafts"] == 4
+ assert hero["contribs"] == 4
+ assert hero["collabs"] == 1
+ assert hero["contrib_nondraft_total"] == 3
+ assert hero["ready"] == 1
+ assert hero["untriaged"] == 1
+ assert hero["untriaged_4w"] == 1
+ assert hero["qc_triaged"] == 1
+ assert hero["defacto"] == 1 # PR2: engaged-via-ready, no marker
+ assert hero["ai_triaged"] == 0
+ assert hero["bots"] == 1
+ assert hero["bots_dependabot"] == 1
+ assert hero["bots_other"] == 0
+
+
+def test_compute_pressure_by_area_thresholds_and_score():
+ ctx = make_ctx()
+ prs = classify_all(
+ [
+ make_pr(10, assoc="NONE", labels=["area:scheduler"],
created_days_ago=40),
+ make_pr(11, assoc="NONE", labels=["area:scheduler"],
created_days_ago=40),
+ make_pr(12, assoc="NONE", labels=["area:scheduler"],
created_days_ago=12),
+ # second area below the contribs>=3 cutoff → must be dropped
+ make_pr(13, assoc="NONE", labels=["area:api"],
created_days_ago=40),
+ ],
+ ctx,
+ )
+ rows = dashboard.compute_pressure_by_area(prs, ctx["area_prefix"])
+
+ assert [area for area, _ in rows] == ["scheduler"] # api dropped (<3
contribs)
+ _, v = rows[0]
+ assert v["contribs"] == 3
+ assert v["u4w"] == 2 # two PRs >28d
+ assert v["u14w"] == 1 # one PR 7<age<=28
+ assert v["score"] == 5 + 5 + 3
+
+
+def test_compute_recommendations_high_priority_for_4w_backlog():
+ ctx = make_ctx()
+ prs = classify_all(
+ [make_pr(n, assoc="NONE", created_days_ago=40) for n in range(20, 23)],
+ ctx,
+ )
+ hero = dashboard.compute_hero_counts(prs)
+ recs = dashboard.compute_recommendations(prs, [], [], hero, 0)
+
+ assert recs, "expected at least the 4-week-backlog recommendation"
+ assert recs[0]["priority"] == "high"
+ assert recs[0]["count"] == 3
+
+
+def test_area_recommendation_count_uses_untriaged_not_total_contribs():
+ """Regression for PR #348 nit: the area rec's count must reflect the
+ untriaged pile (u4w), the signal that fires the rule — not total
contribs."""
+ pressure = [(
+ "scheduler",
+ {"contribs": 9, "u4w": 4, "u14w": 2, "urec": 0, "wait": 0, "ready": 0,
"score": 26},
+ )]
+ hero = {"ready": 0}
+ recs = dashboard.compute_recommendations([], [], pressure, hero, 0)
+
+ area_recs = [r for r in recs if r["icon"] == "📍"]
+ assert len(area_recs) == 1
+ assert area_recs[0]["count"] == 4 # u4w, NOT contribs (9)
+
+
+def test_compute_health_rating_bands():
+ # 0 points → healthy
+ assert dashboard.compute_health_rating(
+ {"untriaged_4w": 0, "untriaged": 0, "ready": 0}, []
+ )[0].startswith("✅")
+ # untriaged_4w (2) + untriaged>30 (1) = 3 points → needs attention
+ assert dashboard.compute_health_rating(
+ {"untriaged_4w": 5, "untriaged": 40, "ready": 0}, []
+ )[0].startswith("⚠️")
+ # add a high-priority rec (2) → 5 points → action needed
+ assert dashboard.compute_health_rating(
+ {"untriaged_4w": 5, "untriaged": 40, "ready": 0},
+ [{"priority": "high"}],
+ )[0].startswith("🔥")
+
+
+def test_weekly_velocity_counts_merged_and_closed():
+ ctx = make_ctx()
+ closed = [
+ make_pr(30, assoc="NONE", created_days_ago=20, closed_days_ago=3,
+ merged=True, include_engagement=False,
+ comments=[comment(f"{QC}", author="m", assoc="MEMBER",
days_ago=4)]),
+ make_pr(31, assoc="NONE", created_days_ago=20, closed_days_ago=3,
+ merged=False, include_engagement=False),
+ ]
+ weekly = reference.compute_weekly_velocity(closed, ctx["weeks"],
ctx["triage_marker"])
+
+ assert len(weekly) == 6
+ assert sum(w["merged"] for w in weekly) == 1
+ assert sum(w["closed_not_merged"] for w in weekly) == 1
+ assert sum(w["merged_triaged"] for w in weekly) == 1
diff --git a/tools/pr-management-stats/tests/test_classify_partial.py
b/tools/pr-management-stats/tests/test_classify_partial.py
new file mode 100644
index 0000000..78a279e
--- /dev/null
+++ b/tools/pr-management-stats/tests/test_classify_partial.py
@@ -0,0 +1,63 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you 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.
+
+"""Tests for the explicit partial-schema classify contract.
+
+PR #348 review flagged the old `pr.setdefault(...)` block as silently papering
+over the reduced closed-PR schema. The contract is now explicit:
+classify(partial=True) reads the heavy engagement collections defensively, and
+the closed-PR query selects isDraft so direct reads stay safe.
+"""
+from __future__ import annotations
+
+import reference
+from helpers import comment, make_ctx, make_pr
+
+
+def test_partial_closed_pr_classifies_without_engagement_collections():
+ ctx = make_ctx()
+ # include_engagement=False → no
commits/latestReviews/reviewThreads/timelineItems
+ pr = make_pr(
+ 1, assoc="NONE", created_days_ago=20, closed_days_ago=2,
+ include_engagement=False,
+ )
+ reference.classify(pr, ctx, partial=True)
+
+ assert pr["_author"] == "contributor"
+ assert pr["_is_contrib"] is True
+ assert pr["_is_engaged"] is False # no collab signal in the comments
+
+
+def test_partial_classify_tolerates_missing_isdraft_key():
+ """Even if a node omits isDraft entirely, classify must not KeyError."""
+ ctx = make_ctx()
+ pr = make_pr(2, assoc="NONE", include_engagement=False)
+ del pr["isDraft"]
+
+ reference.classify(pr, ctx, partial=True) # must not raise
+
+ assert "_is_untriaged" in pr
+
+
+def test_partial_classify_still_detects_collab_engagement_from_comments():
+ ctx = make_ctx()
+ pr = make_pr(
+ 3, assoc="NONE", include_engagement=False, closed_days_ago=1,
+ comments=[comment("looks good", author="maint", assoc="MEMBER")],
+ )
+ reference.classify(pr, ctx, partial=True)
+ assert pr["_is_engaged"] is True
diff --git a/tools/pr-management-stats/tests/test_html_render.py
b/tools/pr-management-stats/tests/test_html_render.py
new file mode 100644
index 0000000..6aa1709
--- /dev/null
+++ b/tools/pr-management-stats/tests/test_html_render.py
@@ -0,0 +1,82 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you 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.
+
+"""Tests for the HTML render helpers (render.md)."""
+from __future__ import annotations
+
+import dashboard
+from helpers import make_ctx
+
+
+def test_partial_banner_appears_only_when_fetch_was_incomplete():
+ ctx = make_ctx()
+ with_banner = dashboard.render_title(ctx, partial_fetch=True)
+ without_banner = dashboard.render_title(ctx, partial_fetch=False)
+
+ assert "INCOMPLETE DATA" in with_banner
+ assert 'class="warn"' in with_banner
+ assert "INCOMPLETE DATA" not in without_banner
+
+
+def test_title_renders_repo_and_viewer():
+ ctx = make_ctx(repo="apache/airflow", viewer="potiuk")
+ out = dashboard.render_title(ctx)
+ assert "apache/airflow" in out
+ assert "@potiuk" in out
+
+
+def test_recommendations_empty_state():
+ out = dashboard.render_recommendations([])
+ assert "No urgent actions detected" in out
+
+
+def test_recommendations_render_titles_and_actions():
+ recs = [
+ {
+ "priority": "high",
+ "icon": "🔥",
+ "title": "Triage 3 PRs",
+ "detail": "do it",
+ "action": "/pr-management-triage all PR issues",
+ "count": 3,
+ }
+ ]
+ out = dashboard.render_recommendations(recs)
+ assert "Triage 3 PRs" in out
+ assert "/pr-management-triage all PR issues" in out
+ assert 'class="action high"' in out
+
+
+def test_hero_rows_show_counts():
+ hero = {
+ "open_total": 42, "non_drafts": 30, "drafts": 12, "contribs": 25,
+ "collabs": 17, "ready": 5, "untriaged": 8, "untriaged_4w": 2,
+ "qc_triaged": 10, "defacto": 4, "ai_triaged": 6, "bots": 3,
+ "bots_dependabot": 2, "bots_other": 1, "contrib_nondraft_total": 20,
+ }
+ health = ("✅ Healthy", "#56d364")
+ out = dashboard.render_hero_rows(hero, health)
+ assert "42" in out
+ assert "✅ Healthy" in out
+ assert "Backlog state" in out
+
+
+def test_html_escaping_in_title():
+ ctx = make_ctx(repo="a/<script>", viewer="x")
+ out = dashboard.render_title(ctx)
+ assert "<script>" not in out
+ assert "<script>" in out
diff --git a/tools/pr-management-stats/tests/test_json_parity.py
b/tools/pr-management-stats/tests/test_json_parity.py
new file mode 100644
index 0000000..5115438
--- /dev/null
+++ b/tools/pr-management-stats/tests/test_json_parity.py
@@ -0,0 +1,116 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you 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.
+
+"""End-to-end JSON-sidecar parity between reference.py and dashboard.py.
+
+PR #348 review asked for a golden-file guard on the "preserved bit-for-bit"
+claim. This runs BOTH scripts' main() over the same in-memory fixture (gh
+calls stubbed) and asserts dashboard's sidecar is a superset of reference's
+with identical values on every shared key. `fetched_at` is excluded — it is a
+wall-clock stamp, not a computed quantity.
+"""
+from __future__ import annotations
+
+import json
+
+import dashboard
+import reference
+from helpers import comment, make_pr
+
+QC = reference.DEFAULT_TRIAGE_MARKER
+READY = reference.DEFAULT_READY_LABEL
+
+# Shared key whose value is a timestamp, not a computed quantity.
+_VOLATILE = {"fetched_at"}
+
+
+def _open_fixture():
+ return [
+ make_pr(1, author="alice", assoc="NONE", created_days_ago=40),
+ make_pr(2, author="bob", assoc="NONE", labels=[READY]),
+ make_pr(3, author="maint", assoc="MEMBER"),
+ make_pr(4, author="carol", assoc="NONE", is_draft=True),
+ make_pr(
+ 5, author="dave", assoc="NONE",
+ comments=[comment(f"{QC}", author="maint", assoc="MEMBER")],
+ ),
+ ]
+
+
+def _closed_fixture():
+ return [
+ make_pr(20, assoc="NONE", created_days_ago=20, closed_days_ago=3,
+ merged=True, include_engagement=False),
+ make_pr(21, assoc="NONE", created_days_ago=18, closed_days_ago=2,
+ merged=False, include_engagement=False),
+ ]
+
+
+def _install_stubs(monkeypatch, module):
+ """Stub a module's fetch primitives to serve the in-memory fixture."""
+ def fake_paginated_search(query, search_q, *args, status=None, **kwargs):
+ if status is not None:
+ status.setdefault("partial", False)
+ if "is:open" in search_q:
+ return [dict(pr) for pr in _open_fixture()]
+ return [dict(pr) for pr in _closed_fixture()]
+
+ monkeypatch.setattr(module, "paginated_search", fake_paginated_search)
+ monkeypatch.setattr(module, "fetch_codeowners", lambda repo: "")
+ monkeypatch.setattr(module, "fetch_ready_pr_files", lambda repo, nums: {})
+
+
+def _run(monkeypatch, module, out_path):
+ _install_stubs(monkeypatch, module)
+ argv = [
+ "prog", "--repo", "apache/airflow", "--viewer", "tester",
+ "--since", "2026-04-01", "--out", str(out_path),
+ ]
+ monkeypatch.setattr(module.sys, "argv", argv)
+ module.main()
+ return json.loads(out_path.with_suffix(".json").read_text())
+
+
+def test_dashboard_sidecar_is_superset_of_reference(tmp_path, monkeypatch):
+ with monkeypatch.context() as m:
+ ref = _run(m, reference, tmp_path / "ref.html")
+ with monkeypatch.context() as m:
+ dash = _run(m, dashboard, tmp_path / "dash.html")
+
+ shared = set(ref) - _VOLATILE
+ # Superset: every reference key survives in the dashboard sidecar.
+ missing = shared - set(dash)
+ assert not missing, f"dashboard dropped reference keys: {missing}"
+
+ # Identical values on every shared, non-volatile key.
+ for key in sorted(shared):
+ assert dash[key] == ref[key], f"value drift on {key!r}: {dash[key]!r}
!= {ref[key]!r}"
+
+ # And the dashboard genuinely extends the contract.
+ assert set(dash) - set(ref), "dashboard should add keys beyond reference"
+ assert "partial" in dash
+
+
+def test_known_counts_for_fixture(tmp_path, monkeypatch):
+ with monkeypatch.context() as m:
+ dash = _run(m, dashboard, tmp_path / "dash.html")
+
+ assert dash["open_count"] == 5
+ assert dash["closed_count"] == 2
+ assert dash["ready_count"] == 1
+ assert dash["untriaged_count"] == 1
+ assert dash["partial"] is False
diff --git a/tools/pr-management-stats/tests/test_pagination.py
b/tools/pr-management-stats/tests/test_pagination.py
new file mode 100644
index 0000000..f7a5538
--- /dev/null
+++ b/tools/pr-management-stats/tests/test_pagination.py
@@ -0,0 +1,164 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you 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.
+
+"""Regression tests for reference.paginated_search.
+
+Guards the cursor-insertion bug (PR #348 review blocker): the old code did
+``cmd.insert(4, "-F"); cmd.insert(5, f"after={cursor}")`` which produced two
+``-F`` flags in a row and silently stopped pagination after page 1. Also
+covers the retry/backoff and partial-fetch signalling added alongside the fix.
+"""
+from __future__ import annotations
+
+import json
+from types import SimpleNamespace
+
+import reference
+
+
+def _resp(returncode=0, payload=None, stderr=""):
+ stdout = json.dumps(payload) if payload is not None else ""
+ return SimpleNamespace(returncode=returncode, stdout=stdout, stderr=stderr)
+
+
+def _page(numbers, *, has_next, cursor=None):
+ return {
+ "data": {
+ "search": {
+ "nodes": [{"number": n} for n in numbers],
+ "pageInfo": {"hasNextPage": has_next, "endCursor": cursor},
+ }
+ }
+ }
+
+
+def test_paginates_all_pages_and_builds_clean_cursor_argv(monkeypatch):
+ """Page 2 must carry exactly one well-formed `-F after=<cursor>` pair."""
+ pages = [
+ _resp(payload=_page([1, 2], has_next=True, cursor="CURSOR1")),
+ _resp(payload=_page([3], has_next=False)),
+ ]
+ calls = []
+
+ def fake_run(cmd, capture_output, text):
+ calls.append(cmd)
+ return pages[len(calls) - 1]
+
+ monkeypatch.setattr(reference.subprocess, "run", fake_run)
+
+ nodes = reference.paginated_search("QUERY", "search q", page_size=30,
max_pages=5)
+
+ # All pages fetched — the bug truncated this to just [1, 2].
+ assert [n["number"] for n in nodes] == [1, 2, 3]
+
+ page2 = calls[1]
+ # Exactly one cursor field, immediately preceded by its -F flag.
+ assert page2.count("after=CURSOR1") == 1
+ assert page2[page2.index("after=CURSOR1") - 1] == "-F"
+ # The old bug produced two consecutive -F tokens; assert that never
happens.
+ assert not any(
+ page2[i] == "-F" and page2[i + 1] == "-F" for i in range(len(page2) -
1)
+ )
+ # Page 1 carries no cursor at all.
+ assert not any(tok.startswith("after=") for tok in calls[0])
+
+
+def test_partial_flag_set_on_hard_error(monkeypatch):
+ monkeypatch.setattr(
+ reference.subprocess, "run",
+ lambda cmd, capture_output, text: _resp(returncode=1, stderr="fatal:
nope"),
+ )
+ status = {}
+ nodes = reference.paginated_search("Q", "q", max_retries=0, status=status)
+ assert nodes == []
+ assert status["partial"] is True
+
+
+def test_partial_flag_set_when_max_pages_hit(monkeypatch):
+ # Every page claims there is another page → we cap out and must flag
partial.
+ monkeypatch.setattr(
+ reference.subprocess, "run",
+ lambda cmd, capture_output, text: _resp(
+ payload=_page([1], has_next=True, cursor="C")
+ ),
+ )
+ status = {}
+ nodes = reference.paginated_search("Q", "q", max_pages=3, status=status)
+ assert len(nodes) == 3
+ assert status["partial"] is True
+
+
+def test_partial_false_on_clean_finish(monkeypatch):
+ monkeypatch.setattr(
+ reference.subprocess, "run",
+ lambda cmd, capture_output, text: _resp(payload=_page([1],
has_next=False)),
+ )
+ status = {}
+ reference.paginated_search("Q", "q", status=status)
+ assert status["partial"] is False
+
+
+def test_retry_on_transient_then_success(monkeypatch):
+ responses = iter([
+ _resp(returncode=1, stderr="HTTP 502 Bad Gateway"),
+ _resp(payload=_page([7], has_next=False)),
+ ])
+ monkeypatch.setattr(
+ reference.subprocess, "run",
+ lambda cmd, capture_output, text: next(responses),
+ )
+ slept = []
+ monkeypatch.setattr(reference.time, "sleep", lambda s: slept.append(s))
+
+ status = {}
+ nodes = reference.paginated_search("Q", "q", max_retries=1, backoff=0.5,
status=status)
+
+ assert [n["number"] for n in nodes] == [7]
+ assert status["partial"] is False
+ assert slept == [0.5] # one backoff before the successful retry
+
+
+def test_rate_limited_graphql_error_is_retried(monkeypatch):
+ responses = iter([
+ _resp(payload={"errors": [{"type": "RATE_LIMITED", "message": "slow
down"}]}),
+ _resp(payload=_page([9], has_next=False)),
+ ])
+ monkeypatch.setattr(
+ reference.subprocess, "run",
+ lambda cmd, capture_output, text: next(responses),
+ )
+ monkeypatch.setattr(reference.time, "sleep", lambda s: None)
+
+ nodes = reference.paginated_search("Q", "q", max_retries=1, backoff=0)
+ assert [n["number"] for n in nodes] == [9]
+
+
+def test_non_transient_error_not_retried(monkeypatch):
+ calls = []
+
+ def fake_run(cmd, capture_output, text):
+ calls.append(cmd)
+ return _resp(returncode=1, stderr="permission denied")
+
+ monkeypatch.setattr(reference.subprocess, "run", fake_run)
+ monkeypatch.setattr(reference.time, "sleep", lambda s: None)
+
+ status = {}
+ reference.paginated_search("Q", "q", max_retries=3, status=status)
+ # No retries for a non-transient failure → exactly one subprocess call.
+ assert len(calls) == 1
+ assert status["partial"] is True
diff --git a/tools/pr-management-stats/uv.lock
b/tools/pr-management-stats/uv.lock
new file mode 100644
index 0000000..08efcc7
--- /dev/null
+++ b/tools/pr-management-stats/uv.lock
@@ -0,0 +1,83 @@
+version = 1
+revision = 3
+requires-python = ">=3.11"
+
+[options]
+exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included
for backwards compatibility when using relative exclude-newer values.
+exclude-newer-span = "P7D"
+
+[[package]]
+name = "colorama"
+version = "0.4.6"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url =
"https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz",
hash =
"sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size
= 27697, upload-time = "2022-10-25T02:36:22.414Z" }
+wheels = [
+ { url =
"https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl",
hash =
"sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size
= 25335, upload-time = "2022-10-25T02:36:20.889Z" },
+]
+
+[[package]]
+name = "iniconfig"
+version = "2.3.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url =
"https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz",
hash =
"sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size
= 20503, upload-time = "2025-10-18T21:55:43.219Z" }
+wheels = [
+ { url =
"https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl",
hash =
"sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size
= 7484, upload-time = "2025-10-18T21:55:41.639Z" },
+]
+
+[[package]]
+name = "packaging"
+version = "26.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url =
"https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz",
hash =
"sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size
= 228134, upload-time = "2026-04-24T20:15:23.917Z" }
+wheels = [
+ { url =
"https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl",
hash =
"sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size
= 100195, upload-time = "2026-04-24T20:15:22.081Z" },
+]
+
+[[package]]
+name = "pluggy"
+version = "1.6.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url =
"https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz",
hash =
"sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size
= 69412, upload-time = "2025-05-15T12:30:07.975Z" }
+wheels = [
+ { url =
"https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl",
hash =
"sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size
= 20538, upload-time = "2025-05-15T12:30:06.134Z" },
+]
+
+[[package]]
+name = "pr-management-stats"
+version = "0.1.0"
+source = { virtual = "." }
+
+[package.dev-dependencies]
+dev = [
+ { name = "pytest" },
+]
+
+[package.metadata]
+
+[package.metadata.requires-dev]
+dev = [{ name = "pytest", specifier = ">=8.0" }]
+
+[[package]]
+name = "pygments"
+version = "2.20.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url =
"https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz",
hash =
"sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size
= 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
+wheels = [
+ { url =
"https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl",
hash =
"sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size
= 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
+]
+
+[[package]]
+name = "pytest"
+version = "9.0.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "colorama", marker = "sys_platform == 'win32'" },
+ { name = "iniconfig" },
+ { name = "packaging" },
+ { name = "pluggy" },
+ { name = "pygments" },
+]
+sdist = { url =
"https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz",
hash =
"sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size
= 1572165, upload-time = "2026-04-07T17:16:18.027Z" }
+wheels = [
+ { url =
"https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl",
hash =
"sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size
= 375249, upload-time = "2026-04-07T17:16:16.13Z" },
+]