choo121600 commented on code in PR #348:
URL: https://github.com/apache/airflow-steward/pull/348#discussion_r3322554332


##########
tools/pr-management-stats/dashboard.py:
##########
@@ -0,0 +1,1859 @@
+#!/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 (shape-compatible with reference.py)
+
+Design: reference.py remains untouched. This script reuses its
+fetch + classify by importing top-level functions; the JSON sidecar
+contract is preserved so existing consumers don't break.
+"""
+from __future__ import annotations
+
+import argparse
+import html
+import json
+import subprocess
+import sys
+from collections import defaultdict
+from datetime import datetime, timedelta, timezone
+from pathlib import Path
+
+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,
+    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>
+"""
+
+
+# reference.paginated_search has a cursor-insertion bug that silently
+# stops pagination after page 1 (mangles the -F flag ordering). We
+# override it here without touching reference.py so the JSON sidecar
+# parity contract is preserved.
+def paginated_search(query, search_q, page_size=30, max_pages=40):
+    all_nodes = []
+    cursor = None
+    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.extend(["-F", f"after={cursor}"])
+        r = subprocess.run(cmd, capture_output=True, text=True)
+        if r.returncode != 0:
+            sys.stderr.write(f"  page {page}: error {r.stderr[:300]}\n")
+            break
+        d = json.loads(r.stdout)
+        if "errors" in d:
+            sys.stderr.write(f"  page {page}: errors {d['errors'][:1]}\n")
+            break
+        nodes = d["data"]["search"]["nodes"]
+        all_nodes.extend(nodes)
+        pi = d["data"]["search"]["pageInfo"]
+        sys.stderr.write(f"  page {page}: +{len(nodes)} (total 
{len(all_nodes)})\n")
+        if not pi["hasNextPage"]:
+            break
+        cursor = pi["endCursor"]
+    return all_nodes

Review Comment:
   Fixed at the source:
   `cmd.extend(["-F", f"after={cursor}"])`,
   override block removed, now importing `paginated_search` from `reference`.
   
   Regression test in `test_pagination.py` asserts clean argv generation and no 
consecutive `-F`.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to