From 379c5102c5db99efc3cccd9457df66f0ebbd827b Mon Sep 17 00:00:00 2001 From: David Butenhof Date: Mon, 11 May 2026 11:38:00 -0400 Subject: [PATCH 1/2] Add sdlc-report CLI script This commit introduces a new CLI script for generating SDLC reports and updates the documentation to reflect its integration with the existing SDLC KPI computation logic. Assisted-by: Cursor Signed-off-by: David Butenhof --- backend/pyproject.toml | 1 + backend/src/github_pm/sdlc_html_render.py | 339 ++++++++++++++++++++++ backend/src/github_pm/sdlc_report_cli.py | 105 +++++++ backend/src/github_pm/sdlc_service.py | 6 +- backend/tests/test_sdlc_html_render.py | 114 ++++++++ backend/tests/test_sdlc_report_cli.py | 145 +++++++++ 6 files changed, 705 insertions(+), 5 deletions(-) create mode 100644 backend/src/github_pm/sdlc_html_render.py create mode 100644 backend/src/github_pm/sdlc_report_cli.py create mode 100644 backend/tests/test_sdlc_html_render.py create mode 100644 backend/tests/test_sdlc_report_cli.py diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 5610203..a1e9466 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -40,6 +40,7 @@ include = ["*"] [project.scripts] github_pm = "github_pm.cli:main" +sdlc-report = "github_pm.sdlc_report_cli:main" [project.optional-dependencies] dev = [ diff --git a/backend/src/github_pm/sdlc_html_render.py b/backend/src/github_pm/sdlc_html_render.py new file mode 100644 index 0000000..a92a247 --- /dev/null +++ b/backend/src/github_pm/sdlc_html_render.py @@ -0,0 +1,339 @@ +"""Static HTML report for SDLC KPI payloads (offline-friendly). + +Generated-by: Cursor +""" + +from __future__ import annotations + +import html +from datetime import UTC, datetime +from typing import Any + +from github_pm.sdlc_models import ( + BugBacklogSeriesResponse, + DeliverySeriesResponse, + EscapedDefectSeriesResponse, +) + + +def _esc(text: object) -> str: + return html.escape(str(text), quote=True) + + +def format_duration_seconds(secs: float | None) -> str: + """Human-readable duration for median seconds (or em dash if unknown).""" + if secs is None: + return "—" + if secs < 0: + return "—" + total = int(round(secs)) + if total < 60: + return f"{total}s" + m, s = divmod(total, 60) + if m < 60: + return f"{m}m {s}s" if s else f"{m}m" + h, m = divmod(m, 60) + if h < 48: + parts = [f"{h}h"] + if m: + parts.append(f"{m}m") + return " ".join(parts) + d, h = divmod(h, 24) + parts = [f"{d}d"] + if h: + parts.append(f"{h}h") + return " ".join(parts) + + +def _dt_utc_iso(d: datetime) -> str: + if d.tzinfo is None: + d = d.replace(tzinfo=UTC) + else: + d = d.astimezone(UTC) + return d.strftime("%Y-%m-%d %H:%M UTC") + + +def _pr_type_label(key: str) -> str: + return { + "feature": "Feature", + "bug_fix": "Bug fix", + "docs": "Docs", + "unclassified": "Unclassified", + }.get(key, key.replace("_", " ").title()) + + +def _size_label(key: str) -> str: + return key.replace("_", " ").title() + + +def _fmt_pct(rate: float | None) -> str: + if rate is None: + return "—" + return f"{100.0 * rate:.1f}%" + + +def render_sdlc_report_html( + *, + repo: str, + generated_at: datetime, + delivery: DeliverySeriesResponse, + escaped: EscapedDefectSeriesResponse, + bugs: BugBacklogSeriesResponse, +) -> str: + """Build a single self-contained HTML document (no external assets).""" + title = f"SDLC — {_esc(repo)}" + rows_delivery: list[str] = [] + for sl in delivery.slices: + tp = sl.merged_pr_throughput + type_bits = ", ".join( + f"{_pr_type_label(k)}: {v}" for k, v in sorted(tp.by_pr_type.items()) + ) + rows_delivery.append( + "" + f"{_esc(_dt_utc_iso(sl.window_end))}" + f"{tp.total}" + f"{_esc(type_bits)}" + f"{_esc(format_duration_seconds(sl.median_pr_cycle_time.median_seconds))}" + f"{_esc(format_duration_seconds(sl.median_time_to_first_review.median_seconds))}" + f"{sl.median_time_to_first_review.included_pr_count}/" + f"{sl.median_time_to_first_review.eligible_pr_count}" + "" + ) + + rows_escape: list[str] = [] + for sl in escaped.slices: + slice_end = sl.window_end if sl.window_end is not None else sl.as_of + wk = _esc(_dt_utc_iso(slice_end)) + if not sl.releases: + rows_escape.append( + f"{wk} — no milestone rows" + ) + continue + for rel in sl.releases: + tag = " next" if rel.is_next_open else "" + rows_escape.append( + "" + f"{wk}" + f"{_esc(rel.release)}{tag}" + f"{rel.feature_prs}" + f"{rel.bug_fix_prs}" + f"{rel.docs_prs}" + f"{rel.escape_issues}" + f"{_esc(_fmt_pct(rel.rate))}" + "" + ) + + rows_bugs: list[str] = [] + for sl in bugs.slices: + rows_bugs.append( + "" + f"{_esc(_dt_utc_iso(sl.window_end))}" + f"{sl.bugs_opened}" + f"{sl.bugs_closed}" + f"{sl.net:+d}" + "" + ) + + if delivery.slices: + last = delivery.slices[-1] + cycle_breakdown = _breakdown_section( + "Median PR cycle time by type / size (latest week)", + last.median_pr_cycle_time, + ) + review_breakdown = _breakdown_section( + "Median time to first human review by type / size (latest week)", + last.median_time_to_first_review, + ) + else: + cycle_breakdown = _empty_breakdown_section( + "Median PR cycle time by type / size (latest week)" + ) + review_breakdown = _empty_breakdown_section( + "Median time to first human review by type / size (latest week)" + ) + + return f""" + + + + + {title} + + + +
+
+

SDLC metrics

+
Repository {_esc(repo)} · Generated {_esc(_dt_utc_iso(generated_at))}
+

Rolling windows: {delivery.weeks} slices × {delivery.week_days} days (UTC, oldest → newest in tables).

+
+ +
+

Delivery

+

Merged PR throughput, median cycle time, median time to first human review (bots excluded).

+ + + + + + + + + + + + + {"".join(rows_delivery)} + +
Week endingMerged PRsBy PR typeMedian cycleMedian first reviewPRs w/ review
+
+ +
+ {cycle_breakdown} + {review_breakdown} +
+ +
+

Escaped defect rate (weekly incremental)

+

Per milestone row: merged PR counts in the window and escape-labeled issues created in the window (attributed by milestone), same semantics as the API.

+ + + + + + + + + + + + + + {"".join(rows_escape)} + +
Slice endReleaseFeaturesBug fixesDocsEscapesRate
+
+ +
+

Bug backlog delta

+

Issues matching configured bug labels: opened vs closed in each window.

+ + + + + + + + + + + {"".join(rows_bugs)} + +
Week endingOpenedClosedNet
+
+
+ + +""" + + +def _empty_breakdown_section(title: str) -> str: + return f""" +
+

{_esc(title)}

+

No delivery data for this report.

+
+ """ + + +def _breakdown_section(title: str, payload: Any) -> str: + """Render by_pr_type and by_pr_size tables for cycle or first-review payload.""" + rows_t = "".join( + f"{_esc(_pr_type_label(k))}{_esc(format_duration_seconds(v))}" + for k, v in sorted(payload.by_pr_type.items()) + ) + rows_s = "".join( + f"{_esc(_size_label(k))}{_esc(format_duration_seconds(v))}" + for k, v in sorted(payload.by_pr_size.items()) + ) + median = format_duration_seconds(payload.median_seconds) + return f""" +
+

{_esc(title)}

+

Overall median: {_esc(median)}

+ + + {rows_t} +
PR typeMedian
+ + + {rows_s} +
PR sizeMedian
+
+ """ diff --git a/backend/src/github_pm/sdlc_report_cli.py b/backend/src/github_pm/sdlc_report_cli.py new file mode 100644 index 0000000..29a72ee --- /dev/null +++ b/backend/src/github_pm/sdlc_report_cli.py @@ -0,0 +1,105 @@ +"""Offline CLI: fetch SDLC KPIs from GitHub and write a static HTML report. + +Configuration uses the same environment variables as ``github_pm.context.Settings``: + +* ``GITHUB_TOKEN`` (required) — personal access token with repo scope +* ``GITHUB_REPO`` — ``owner/name`` (default: ``vllm-project/guidellm``) +* ``SDLC_FEATURE_LABELS``, ``SDLC_BUG_LABELS``, ``SDLC_DOCS_LABELS``, ``SDLC_ESCAPE_LABEL`` — optional CSV label lists + +CLI-specific optional env vars (overridden by flags when provided): + +* ``SDLC_REPORT_WEEKS`` — number of weekly slices (default ``4``) +* ``SDLC_REPORT_WEEK_DAYS`` — days per slice (default ``7``) +* ``SDLC_REPORT_OUTPUT`` — default output path when ``--output`` is omitted + +Generated-by: Cursor +""" + +from __future__ import annotations + +from datetime import UTC, datetime +from pathlib import Path + +import click + + +@click.command() +@click.option( + "-o", + "--output", + type=click.Path(path_type=Path, dir_okay=False), + default=None, + envvar="SDLC_REPORT_OUTPUT", + help="HTML file to write (default: sdlc-report.html in the current directory).", +) +@click.option( + "--weeks", + type=click.IntRange(1, 52), + default=None, + envvar="SDLC_REPORT_WEEKS", + help="Number of rolling windows (default: 4, or SDLC_REPORT_WEEKS).", +) +@click.option( + "--week-days", + type=click.IntRange(1, 90), + default=None, + envvar="SDLC_REPORT_WEEK_DAYS", + help="Length of each window in days (default: 7, or SDLC_REPORT_WEEK_DAYS).", +) +@click.option( + "--repo", + default=None, + envvar="GITHUB_REPO", + help="Override repository owner/name (otherwise from settings / GITHUB_REPO).", +) +def main( + output: Path | None, + weeks: int | None, + week_days: int | None, + repo: str | None, +) -> None: + """Generate a static SDLC HTML report (same metrics as the /api/v1/sdlc REST API).""" + from github_pm import sdlc_service + from github_pm.api import Connector + from github_pm.context import Settings + from github_pm.sdlc_html_render import render_sdlc_report_html + + settings = Settings() + if repo: + settings = settings.model_copy(update={"github_repo": repo}) + + token = (settings.github_token or "").strip() + if not token: + raise click.UsageError( + "GitHub token is required. Set GITHUB_TOKEN in the environment " + "(see github_pm.context.Settings)." + ) + + weeks_v = 4 if weeks is None else weeks + days_v = 7 if week_days is None else week_days + out = output if output is not None else Path.cwd() / "sdlc-report.html" + + gitctx = Connector(token, github_repo=settings.github_repo) + delivery = sdlc_service.compute_sdlc_delivery_series( + gitctx, settings, weeks=weeks_v, week_days=days_v + ) + escaped = sdlc_service.compute_escaped_defect_rate_series( + gitctx, settings, weeks=weeks_v, week_days=days_v + ) + bugs = sdlc_service.compute_bug_backlog_delta_series( + gitctx, settings, weeks=weeks_v, week_days=days_v + ) + + html_doc = render_sdlc_report_html( + repo=settings.github_repo, + generated_at=datetime.now(tz=UTC), + delivery=delivery, + escaped=escaped, + bugs=bugs, + ) + out.write_text(html_doc, encoding="utf-8") + click.echo(f"Wrote {out.resolve()}") + + +if __name__ == "__main__": + main() diff --git a/backend/src/github_pm/sdlc_service.py b/backend/src/github_pm/sdlc_service.py index 8b43899..d49e76c 100644 --- a/backend/src/github_pm/sdlc_service.py +++ b/backend/src/github_pm/sdlc_service.py @@ -1,8 +1,4 @@ -"""Shared SDLC KPI computation (used by FastAPI routes). - -The standalone ``sdlc-report`` script (``scripts/sdlc_report.py``) mirrors this -logic without importing ``github_pm``; keep behavior aligned when changing -metrics. +"""Shared SDLC KPI computation (used by FastAPI routes and the ``sdlc-report`` CLI). Generated-by: Cursor """ diff --git a/backend/tests/test_sdlc_html_render.py b/backend/tests/test_sdlc_html_render.py new file mode 100644 index 0000000..33f6741 --- /dev/null +++ b/backend/tests/test_sdlc_html_render.py @@ -0,0 +1,114 @@ +"""Tests for SDLC static HTML rendering. + +Generated-by: Cursor +""" + +from datetime import UTC, datetime, timedelta + +from github_pm.sdlc_html_render import format_duration_seconds, render_sdlc_report_html +from github_pm.sdlc_models import ( + BugBacklogResponse, + BugBacklogSeriesResponse, + CycleTimePayload, + DeliveryResponse, + DeliverySeriesResponse, + EscapedDefectResponse, + EscapedDefectRow, + EscapedDefectSeriesResponse, + FirstReviewPayload, + ThroughputBreakdown, +) + + +def test_format_duration_seconds(): + assert format_duration_seconds(None) == "—" + assert format_duration_seconds(45.0) == "45s" + assert format_duration_seconds(120.0) == "2m" + assert format_duration_seconds(3720.0) == "1h 2m" + + +def test_render_sdlc_report_html_contains_sections(): + t0 = datetime(2025, 4, 3, 12, 0, 0, tzinfo=UTC) + t1 = datetime(2025, 4, 10, 12, 0, 0, tzinfo=UTC) + delivery = DeliverySeriesResponse( + weeks=1, + week_days=7, + slices=[ + DeliveryResponse( + window_days=7, + window_start=t0, + window_end=t1, + as_of=t1, + merged_pr_throughput=ThroughputBreakdown( + total=1, + by_pr_type={"feature": 1}, + by_pr_size={"small": 1}, + ), + median_pr_cycle_time=CycleTimePayload( + median_seconds=86400.0, + by_pr_type={"feature": 86400.0}, + by_pr_size={"small": 86400.0}, + pr_count=1, + ), + median_time_to_first_review=FirstReviewPayload( + median_seconds=3600.0, + by_pr_type={"feature": 3600.0}, + by_pr_size={"small": 3600.0}, + included_pr_count=1, + eligible_pr_count=2, + ), + ) + ], + ) + escaped = EscapedDefectSeriesResponse( + weeks=1, + week_days=7, + slices=[ + EscapedDefectResponse( + window_start=t0, + window_end=t1, + as_of=t1, + releases=[ + EscapedDefectRow( + release="v0.1.0", + feature_prs=1, + bug_fix_prs=0, + docs_prs=0, + escape_issues=0, + rate=0.0, + is_next_open=True, + ) + ], + ) + ], + ) + bugs = BugBacklogSeriesResponse( + weeks=1, + week_days=7, + slices=[ + BugBacklogResponse( + window_days=7, + window_start=t0, + window_end=t1, + as_of=t1, + bugs_opened=2, + bugs_closed=1, + net=1, + ) + ], + ) + html = render_sdlc_report_html( + repo="o/r", + generated_at=t1 + timedelta(hours=1), + delivery=delivery, + escaped=escaped, + bugs=bugs, + ) + assert "" in html + assert "SDLC metrics" in html + assert "o/r" in html + assert "Delivery" in html + assert "Escaped defect" in html + assert "Bug backlog" in html + assert "v0.1.0" in html + assert "1d" in html or "24h" in html # median cycle in breakdown diff --git a/backend/tests/test_sdlc_report_cli.py b/backend/tests/test_sdlc_report_cli.py new file mode 100644 index 0000000..cb3893d --- /dev/null +++ b/backend/tests/test_sdlc_report_cli.py @@ -0,0 +1,145 @@ +"""Tests for the offline SDLC HTML report CLI. + +Generated-by: Cursor +""" + +from __future__ import annotations + +import importlib.util +from datetime import UTC, datetime, timedelta +from pathlib import Path +from unittest.mock import patch + +import pytest +from click.testing import CliRunner + + +def _have_fastapi() -> bool: + return importlib.util.find_spec("fastapi") is not None + + +pytestmark = pytest.mark.skipif( + not _have_fastapi(), + reason="SDLC report CLI tests require project dependencies (fastapi).", +) + +from github_pm.sdlc_models import ( + BugBacklogResponse, + BugBacklogSeriesResponse, + CycleTimePayload, + DeliveryResponse, + DeliverySeriesResponse, + EscapedDefectResponse, + EscapedDefectSeriesResponse, + FirstReviewPayload, + ThroughputBreakdown, +) + + +def _minimal_series(): + t0 = datetime(2025, 4, 3, 12, 0, 0, tzinfo=UTC) + t1 = datetime(2025, 4, 10, 12, 0, 0, tzinfo=UTC) + delivery = DeliverySeriesResponse( + weeks=1, + week_days=7, + slices=[ + DeliveryResponse( + window_days=7, + window_start=t0, + window_end=t1, + as_of=t1, + merged_pr_throughput=ThroughputBreakdown( + total=0, by_pr_type={}, by_pr_size={} + ), + median_pr_cycle_time=CycleTimePayload( + median_seconds=None, + by_pr_type={}, + by_pr_size={}, + pr_count=0, + ), + median_time_to_first_review=FirstReviewPayload( + median_seconds=None, + by_pr_type={}, + by_pr_size={}, + included_pr_count=0, + eligible_pr_count=0, + ), + ) + ], + ) + escaped = EscapedDefectSeriesResponse( + weeks=1, + week_days=7, + slices=[EscapedDefectResponse(as_of=t1, releases=[])], + ) + bugs = BugBacklogSeriesResponse( + weeks=1, + week_days=7, + slices=[ + BugBacklogResponse( + window_days=7, + window_start=t0, + window_end=t1, + as_of=t1, + bugs_opened=0, + bugs_closed=0, + net=0, + ) + ], + ) + return delivery, escaped, bugs + + +class TestSdlcReportCli: + def test_requires_token(self, tmp_path: Path): + from github_pm.sdlc_report_cli import main + + runner = CliRunner() + result = runner.invoke( + main, + ["--output", str(tmp_path / "out.html")], + env={ + "GITHUB_TOKEN": "", + "GITHUB_REPO": "a/b", + }, + ) + assert result.exit_code != 0 + out = (getattr(result, "stdout", "") or "") + ( + getattr(result, "stderr", "") or "" + ) + assert "token" in out.lower() + + def test_writes_html(self, tmp_path: Path): + from github_pm.sdlc_report_cli import main + + delivery, escaped, bugs = _minimal_series() + out = tmp_path / "report.html" + + with ( + patch("github_pm.api.Connector") as mc, + patch( + "github_pm.sdlc_service.compute_sdlc_delivery_series", + return_value=delivery, + ), + patch( + "github_pm.sdlc_service.compute_escaped_defect_rate_series", + return_value=escaped, + ), + patch( + "github_pm.sdlc_service.compute_bug_backlog_delta_series", + return_value=bugs, + ), + ): + mc.return_value = object() + runner = CliRunner() + result = runner.invoke( + main, + ["--output", str(out), "--weeks", "1"], + env={"GITHUB_TOKEN": "tok", "GITHUB_REPO": "x/y"}, + ) + + assert result.exit_code == 0, result.output + assert out.is_file() + body = out.read_text(encoding="utf-8") + assert "SDLC metrics" in body + assert "x/y" in body From 0200e50ba70079b44a3e356449cbdf72754ef84f Mon Sep 17 00:00:00 2001 From: David Butenhof Date: Mon, 11 May 2026 11:54:56 -0400 Subject: [PATCH 2/2] formatting Signed-off-by: David Butenhof --- backend/src/github_pm/sdlc_html_render.py | 2 +- backend/src/github_pm/sdlc_report_cli.py | 2 +- backend/tests/test_sdlc_html_render.py | 2 +- backend/tests/test_sdlc_report_cli.py | 24 +++++++++++------------ 4 files changed, 15 insertions(+), 15 deletions(-) diff --git a/backend/src/github_pm/sdlc_html_render.py b/backend/src/github_pm/sdlc_html_render.py index a92a247..36e3704 100644 --- a/backend/src/github_pm/sdlc_html_render.py +++ b/backend/src/github_pm/sdlc_html_render.py @@ -5,8 +5,8 @@ from __future__ import annotations +from datetime import datetime, UTC import html -from datetime import UTC, datetime from typing import Any from github_pm.sdlc_models import ( diff --git a/backend/src/github_pm/sdlc_report_cli.py b/backend/src/github_pm/sdlc_report_cli.py index 29a72ee..3ffc192 100644 --- a/backend/src/github_pm/sdlc_report_cli.py +++ b/backend/src/github_pm/sdlc_report_cli.py @@ -17,7 +17,7 @@ from __future__ import annotations -from datetime import UTC, datetime +from datetime import datetime, UTC from pathlib import Path import click diff --git a/backend/tests/test_sdlc_html_render.py b/backend/tests/test_sdlc_html_render.py index 33f6741..904c239 100644 --- a/backend/tests/test_sdlc_html_render.py +++ b/backend/tests/test_sdlc_html_render.py @@ -3,7 +3,7 @@ Generated-by: Cursor """ -from datetime import UTC, datetime, timedelta +from datetime import datetime, timedelta, UTC from github_pm.sdlc_html_render import format_duration_seconds, render_sdlc_report_html from github_pm.sdlc_models import ( diff --git a/backend/tests/test_sdlc_report_cli.py b/backend/tests/test_sdlc_report_cli.py index cb3893d..2378e3e 100644 --- a/backend/tests/test_sdlc_report_cli.py +++ b/backend/tests/test_sdlc_report_cli.py @@ -5,23 +5,13 @@ from __future__ import annotations +from datetime import datetime, UTC import importlib.util -from datetime import UTC, datetime, timedelta from pathlib import Path from unittest.mock import patch -import pytest from click.testing import CliRunner - - -def _have_fastapi() -> bool: - return importlib.util.find_spec("fastapi") is not None - - -pytestmark = pytest.mark.skipif( - not _have_fastapi(), - reason="SDLC report CLI tests require project dependencies (fastapi).", -) +import pytest from github_pm.sdlc_models import ( BugBacklogResponse, @@ -36,6 +26,16 @@ def _have_fastapi() -> bool: ) +def _have_fastapi() -> bool: + return importlib.util.find_spec("fastapi") is not None + + +pytestmark = pytest.mark.skipif( + not _have_fastapi(), + reason="SDLC report CLI tests require project dependencies (fastapi).", +) + + def _minimal_series(): t0 = datetime(2025, 4, 3, 12, 0, 0, tzinfo=UTC) t1 = datetime(2025, 4, 10, 12, 0, 0, tzinfo=UTC)