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..36e3704
--- /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
+
+from datetime import datetime, UTC
+import html
+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).
+
+
+
+ | Week ending |
+ Merged PRs |
+ By PR type |
+ Median cycle |
+ Median first review |
+ PRs w/ review |
+
+
+
+ {"".join(rows_delivery)}
+
+
+
+
+
+ {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.
+
+
+
+ | Slice end |
+ Release |
+ Features |
+ Bug fixes |
+ Docs |
+ Escapes |
+ Rate |
+
+
+
+ {"".join(rows_escape)}
+
+
+
+
+
+ Bug backlog delta
+ Issues matching configured bug labels: opened vs closed in each window.
+
+
+
+ | Week ending |
+ Opened |
+ Closed |
+ Net |
+
+
+
+ {"".join(rows_bugs)}
+
+
+
+
+
+
+"""
+
+
+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)}
+
+ | PR type | Median |
+ {rows_t}
+
+
+ | PR size | Median |
+ {rows_s}
+
+
+ """
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..3ffc192
--- /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 datetime, UTC
+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..904c239
--- /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 datetime, timedelta, UTC
+
+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..2378e3e
--- /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
+
+from datetime import datetime, UTC
+import importlib.util
+from pathlib import Path
+from unittest.mock import patch
+
+from click.testing import CliRunner
+import pytest
+
+from github_pm.sdlc_models import (
+ BugBacklogResponse,
+ BugBacklogSeriesResponse,
+ CycleTimePayload,
+ DeliveryResponse,
+ DeliverySeriesResponse,
+ EscapedDefectResponse,
+ EscapedDefectSeriesResponse,
+ FirstReviewPayload,
+ ThroughputBreakdown,
+)
+
+
+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)
+ 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