Skip to content

Commit 37eb368

Browse files
committed
feat(cli): add GitHub report format with snapshot-based trends summary
1 parent ce3a6c0 commit 37eb368

4 files changed

Lines changed: 89 additions & 4 deletions

File tree

pacta/cli/_trends.py

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
from __future__ import annotations
2+
3+
from dataclasses import replace
4+
from datetime import datetime
5+
6+
from pacta.reporting.types import Report, TrendPoint, TrendSummary
7+
from pacta.snapshot.store import FsSnapshotStore
8+
9+
10+
def attach_trends(
11+
report: Report,
12+
*,
13+
repo_root: str,
14+
last: int = 5,
15+
) -> Report:
16+
"""
17+
Load recent snapshots and attach a TrendSummary to the report.
18+
19+
Returns the report unchanged if no history is available.
20+
"""
21+
try:
22+
store = FsSnapshotStore(repo_root=repo_root)
23+
objects = store.list_objects()
24+
except Exception:
25+
return report
26+
27+
if len(objects) < 2:
28+
return report
29+
30+
# Take the most recent N entries (list_objects returns newest-first)
31+
entries = objects[:last]
32+
# Reverse to chronological order (oldest first)
33+
entries = list(reversed(entries))
34+
35+
points: list[TrendPoint] = []
36+
for _, snapshot in entries:
37+
meta = snapshot.meta
38+
node_count = float(len(snapshot.nodes))
39+
edge_count = float(len(snapshot.edges))
40+
violation_count = float(len(snapshot.violations))
41+
density = round(edge_count / node_count, 2) if node_count > 0 else 0.0
42+
43+
label = _format_label(meta.created_at)
44+
points.append(
45+
TrendPoint(
46+
label=label,
47+
violations=violation_count,
48+
nodes=node_count,
49+
edges=edge_count,
50+
density=density,
51+
)
52+
)
53+
54+
first = points[0]
55+
last_pt = points[-1]
56+
57+
trends = TrendSummary(
58+
points=tuple(points),
59+
violation_change=last_pt.violations - first.violations,
60+
node_change=last_pt.nodes - first.nodes,
61+
edge_change=last_pt.edges - first.edges,
62+
density_change=round(last_pt.density - first.density, 2),
63+
)
64+
65+
return replace(report, trends=trends)
66+
67+
68+
def _format_label(created_at: str | None) -> str:
69+
if not created_at or "T" not in created_at:
70+
return created_at or "unknown"
71+
try:
72+
dt = datetime.fromisoformat(created_at.replace("Z", "+00:00"))
73+
return dt.strftime("%b %d")
74+
except ValueError:
75+
return created_at.split("T")[0]

pacta/cli/check.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
from pathlib import Path
22

33
from pacta.cli._io import default_model_file, default_rules_files, ensure_repo_root
4+
from pacta.cli._trends import attach_trends
45
from pacta.cli.exitcodes import exit_code_from_report_dict
56
from pacta.core.config import EngineConfig
67
from pacta.core.engine import DefaultPactaEngine
8+
from pacta.reporting.renderers.github import GitHubReportRenderer
79
from pacta.reporting.renderers.json import JsonReportRenderer
810
from pacta.reporting.renderers.text import TextReportRenderer
911
from pacta.snapshot.store import FsSnapshotStore
@@ -70,7 +72,10 @@ def run(
7072
store.save(result.snapshot, refs=[save_ref])
7173

7274
# Render report
73-
if fmt == "json":
75+
if fmt == "github":
76+
report = attach_trends(result.report, repo_root=repo_root)
77+
out = GitHubReportRenderer().render(report)
78+
elif fmt == "json":
7479
out = JsonReportRenderer().render(result.report)
7580
else:
7681
out = TextReportRenderer(verbosity=verbosity).render(result.report) # type: ignore[arg-type]

pacta/cli/main.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ def build_parser() -> argparse.ArgumentParser:
1414
# scan
1515
scan_p = sub.add_parser("scan", help="Scan repository and evaluate rules.")
1616
scan_p.add_argument("path", nargs="?", default=".", help="Repository root (default: .)")
17-
scan_p.add_argument("--format", choices=["text", "json"], default="text", help="Output format.")
17+
scan_p.add_argument("--format", choices=["text", "json", "github"], default="text", help="Output format.")
1818
scan_p.add_argument("--rules", action="append", default=None, help="Rules file path (repeatable).")
1919
scan_p.add_argument("--model", default=None, help="Architecture model file (architecture.yaml).")
2020
scan_p.add_argument("--baseline", default=None, help="Baseline snapshot ref (e.g. baseline).")
@@ -30,7 +30,7 @@ def build_parser() -> argparse.ArgumentParser:
3030
check_p = sub.add_parser("check", help="Evaluate rules against a snapshot.")
3131
check_p.add_argument("path", nargs="?", default=".", help="Repository root (default: .)")
3232
check_p.add_argument("--ref", default="latest", help="Snapshot ref to check (default: latest).")
33-
check_p.add_argument("--format", choices=["text", "json"], default="text", help="Output format.")
33+
check_p.add_argument("--format", choices=["text", "json", "github"], default="text", help="Output format.")
3434
check_p.add_argument("--rules", action="append", default=None, help="Rules file path (repeatable).")
3535
check_p.add_argument("--model", default=None, help="Architecture model file (architecture.yaml).")
3636
check_p.add_argument("--baseline", default=None, help="Baseline snapshot ref.")

pacta/cli/scan.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
from pacta.cli._engine_adapter import run_engine_scan
22
from pacta.cli._io import default_model_file, default_rules_files, ensure_repo_root
3+
from pacta.cli._trends import attach_trends
34
from pacta.cli.exitcodes import exit_code_from_report_dict
5+
from pacta.reporting.renderers.github import GitHubReportRenderer
46
from pacta.reporting.renderers.json import JsonReportRenderer
57
from pacta.reporting.renderers.text import TextReportRenderer
68

@@ -32,7 +34,10 @@ def run(
3234
tool_version=tool_version,
3335
)
3436

35-
if fmt == "json":
37+
if fmt == "github":
38+
report = attach_trends(report, repo_root=repo_root)
39+
out = GitHubReportRenderer().render(report)
40+
elif fmt == "json":
3641
out = JsonReportRenderer().render(report)
3742
else:
3843
out = TextReportRenderer(verbosity=verbosity).render(report) # type: ignore[arg-type]

0 commit comments

Comments
 (0)