Skip to content

Commit 602d97c

Browse files
authored
Merge pull request #17 from akhundMurad/feature/github-action
feat(action): add composite GitHub Action for Pacta architecture review
2 parents 3c92948 + 52b1b01 commit 602d97c

12 files changed

Lines changed: 973 additions & 6 deletions

File tree

action.yml

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
name: 'Pacta Architecture Review'
2+
description: 'Run architecture checks and post a rich PR comment describing architectural changes'
3+
branding:
4+
icon: 'layers'
5+
color: 'blue'
6+
7+
inputs:
8+
target_dir:
9+
description: 'Repository root (default: .)'
10+
required: false
11+
default: '.'
12+
model:
13+
description: 'Path to architecture.yml'
14+
required: false
15+
default: 'architecture.yml'
16+
rules:
17+
description: 'Path to rules.pacta.yml'
18+
required: false
19+
default: 'rules.pacta.yml'
20+
baseline:
21+
description: 'Baseline ref name (omit to skip baseline comparison)'
22+
required: false
23+
default: ''
24+
python-version:
25+
description: 'Python version to use'
26+
required: false
27+
default: '3.11'
28+
fail-on-violations:
29+
description: 'Fail the check if new violations are found'
30+
required: false
31+
default: 'true'
32+
pacta-version:
33+
description: 'Pacta version to install (PyPI spec, default: pacta)'
34+
required: false
35+
default: 'pacta'
36+
37+
runs:
38+
using: 'composite'
39+
steps:
40+
- uses: actions/setup-python@v5
41+
with:
42+
python-version: ${{ inputs.python-version }}
43+
44+
- name: Install Pacta
45+
shell: bash
46+
run: pip install "${{ inputs.pacta-version }}"
47+
48+
- name: Run Architecture Check
49+
id: pacta
50+
shell: bash
51+
run: |
52+
ARGS="--model ${{ inputs.model }} --rules ${{ inputs.rules }}"
53+
if [ -n "${{ inputs.baseline }}" ]; then
54+
ARGS="$ARGS --baseline ${{ inputs.baseline }}"
55+
fi
56+
57+
# Generate GitHub Markdown comment
58+
pacta scan ${{ inputs.target_dir }} $ARGS --format github > "$RUNNER_TEMP/pacta-comment.md" || true
59+
60+
# Generate JSON for machine-readable results
61+
pacta scan ${{ inputs.target_dir }} $ARGS --format json > "$RUNNER_TEMP/pacta-results.json" || true
62+
63+
# Extract new violation count
64+
NEW=$(jq '.summary.by_status.new // 0' "$RUNNER_TEMP/pacta-results.json" 2>/dev/null || echo 0)
65+
echo "new_violations=$NEW" >> "$GITHUB_OUTPUT"
66+
67+
- name: Post or Update PR Comment
68+
if: github.event_name == 'pull_request'
69+
uses: actions/github-script@v7
70+
with:
71+
script: |
72+
const fs = require('fs');
73+
const commentPath = '${{ runner.temp }}/pacta-comment.md';
74+
const body = fs.readFileSync(commentPath, 'utf8');
75+
const marker = '<!-- pacta-architecture-report -->';
76+
const fullBody = marker + '\n' + body;
77+
78+
// Find existing comment to update (idempotent)
79+
const { data: comments } = await github.rest.issues.listComments({
80+
owner: context.repo.owner,
81+
repo: context.repo.repo,
82+
issue_number: context.issue.number,
83+
});
84+
const existing = comments.find(c => c.body.startsWith(marker));
85+
86+
if (existing) {
87+
await github.rest.issues.updateComment({
88+
owner: context.repo.owner,
89+
repo: context.repo.repo,
90+
comment_id: existing.id,
91+
body: fullBody,
92+
});
93+
} else {
94+
await github.rest.issues.createComment({
95+
owner: context.repo.owner,
96+
repo: context.repo.repo,
97+
issue_number: context.issue.number,
98+
body: fullBody,
99+
});
100+
}
101+
102+
- name: Fail on New Violations
103+
if: inputs.fail-on-violations == 'true' && steps.pacta.outputs.new_violations != '0'
104+
shell: bash
105+
run: |
106+
echo "::error::Pacta found ${{ steps.pacta.outputs.new_violations }} new architectural violation(s)"
107+
exit 1

assets/github-action-example.png

147 KB
Loading

docs/ci-integration.md

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,53 @@ jobs:
8888
!!! note "Persisting baselines"
8989
The baseline is stored in `.pacta/snapshots/`. Commit this directory to your repository, or use GitHub Actions cache/artifacts to persist it between runs.
9090

91+
### Pacta GitHub Action (Recommended)
92+
93+
The simplest way to get rich architectural PR comments. Uses `--format github` to generate a descriptive Markdown comment with structural changes, violation details, and architecture trends:
94+
95+
```yaml
96+
name: Architecture Check
97+
98+
on:
99+
pull_request:
100+
branches: [main]
101+
102+
jobs:
103+
architecture:
104+
runs-on: ubuntu-latest
105+
permissions:
106+
pull-requests: write
107+
steps:
108+
- uses: actions/checkout@v4
109+
- uses: akhundMurad/pacta@main
110+
with:
111+
model: architecture.yml
112+
rules: rules.pacta.yml
113+
baseline: baseline
114+
```
115+
116+
The action will:
117+
118+
- Run `pacta scan` with `--format github` to produce a rich Markdown report
119+
- Post (or update) a PR comment with structural changes, new/fixed violations, and architecture trends
120+
- Fail the check if new violations are introduced (configurable via `fail-on-violations: false`)
121+
122+
**Example comment:**
123+
124+
![Github Action output example](https://raw.githubusercontent.com/akhundMurad/pacta/main/assets/github-action-example.png)
125+
126+
**Action Inputs:**
127+
128+
| Input | Default | Description |
129+
|-------|---------|-------------|
130+
| `target_dir` | `.` | Repository root |
131+
| `model` | `architecture.yml` | Path to architecture model |
132+
| `rules` | `rules.pacta.yml` | Path to rules file |
133+
| `baseline` | *(none)* | Baseline ref for incremental checks |
134+
| `python-version` | `3.11` | Python version |
135+
| `fail-on-violations` | `true` | Fail if new violations found |
136+
| `pacta-version` | `pacta` | Pacta package specifier |
137+
91138
### JSON Output for PR Comments
92139

93140
Generate JSON output and post results as a PR comment:

docs/cli.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ pacta scan [PATH] [OPTIONS]
2020
|--------|---------|-------------|
2121
| `--model FILE` | `architecture.yml` | Architecture model file |
2222
| `--rules FILE` | `rules.pacta.yml` | Rules file (repeatable) |
23-
| `--format {text,json}` | `text` | Output format |
23+
| `--format {text,json,github}` | `text` | Output format (`github` produces Markdown for PR comments) |
2424
| `--baseline REF` | - | Compare against baseline snapshot |
2525
| `--save-ref REF` | - | Save snapshot under this ref |
2626
| `--mode {full,changed_only}` | `full` | Evaluation mode |
@@ -84,7 +84,7 @@ pacta check [PATH] [OPTIONS]
8484
| `--ref REF` | `latest` | Snapshot ref to check |
8585
| `--model FILE` | `architecture.yml` | Architecture model file |
8686
| `--rules FILE` | `rules.pacta.yml` | Rules file (repeatable) |
87-
| `--format {text,json}` | `text` | Output format |
87+
| `--format {text,json,github}` | `text` | Output format (`github` produces Markdown for PR comments) |
8888
| `--baseline REF` | - | Compare against baseline snapshot |
8989
| `--save-ref REF` | - | Also save result under this ref |
9090
| `-q, --quiet` | - | Summary only |

pacta/cli/_trends.py

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

pacta/reporting/builder.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import json
2+
import re
13
from collections import Counter
24
from collections.abc import Mapping, Sequence
35
from dataclasses import replace
@@ -189,11 +191,20 @@ def _normalize_diff(self, diff: Any | None) -> DiffSummary | None:
189191
if isinstance(diff, DiffSummary):
190192
return diff
191193

194+
# Extract detail names from SnapshotDiff.details if available
195+
details = get_field(diff, "details", default={}) or {}
196+
nodes_detail = details.get("nodes", {}) if isinstance(details, dict) else {}
197+
edges_detail = details.get("edges", {}) if isinstance(details, dict) else {}
198+
192199
return DiffSummary(
193200
nodes_added=int(get_field(diff, "nodes_added", default=get_field(diff, "nodesAdded", default=0))),
194201
nodes_removed=int(get_field(diff, "nodes_removed", default=get_field(diff, "nodesRemoved", default=0))),
195202
edges_added=int(get_field(diff, "edges_added", default=get_field(diff, "edgesAdded", default=0))),
196203
edges_removed=int(get_field(diff, "edges_removed", default=get_field(diff, "edgesRemoved", default=0))),
204+
added_node_names=tuple(_humanize_node(n) for n in nodes_detail.get("added", ())),
205+
removed_node_names=tuple(_humanize_node(n) for n in nodes_detail.get("removed", ())),
206+
added_edge_names=tuple(_humanize_edge(e) for e in edges_detail.get("added", ())),
207+
removed_edge_names=tuple(_humanize_edge(e) for e in edges_detail.get("removed", ())),
197208
)
198209

199210
def _build_summary(self, violations: Sequence[Violation], engine_errors: Sequence[EngineError]) -> Summary:
@@ -213,3 +224,43 @@ def _build_summary(self, violations: Sequence[Violation], engine_errors: Sequenc
213224
by_rule=dict(sorted(by_rule.items(), key=lambda kv: kv[0])),
214225
engine_errors=len(engine_errors),
215226
)
227+
228+
229+
# --- Diff key humanization ---
230+
231+
_NODE_ID_RE = re.compile(r"^[a-z]+://[^:]+::(.+)$")
232+
233+
234+
def _humanize_node(key: str) -> str:
235+
"""Turn 'python://code-root::src.domain.user' into 'src.domain.user'."""
236+
m = _NODE_ID_RE.match(key)
237+
return m.group(1) if m else key
238+
239+
240+
def _humanize_edge(key: str) -> str:
241+
"""Turn an edge key into 'src.fqname → dst.fqname'.
242+
243+
Edge keys come in two forms:
244+
1) 'from_id->to_id:kind' (structured key)
245+
2) JSON blob from dumps_deterministic(e.to_dict())
246+
"""
247+
# Form 1: structured key
248+
if "->" in key and not key.startswith("{"):
249+
arrow_idx = key.index("->")
250+
from_part = key[:arrow_idx]
251+
rest = key[arrow_idx + 2 :]
252+
to_part = rest.split(":")[0] if ":" in rest else rest
253+
return f"{_humanize_node(from_part)}{_humanize_node(to_part)}"
254+
255+
# Form 2: JSON blob — extract src/dst fqnames
256+
try:
257+
data = json.loads(key)
258+
src = data.get("src", {})
259+
dst = data.get("dst", {})
260+
src_name = src.get("fqname", str(src))
261+
dst_name = dst.get("fqname", str(dst))
262+
return f"{src_name}{dst_name}"
263+
except (json.JSONDecodeError, TypeError, AttributeError):
264+
pass
265+
266+
return key

0 commit comments

Comments
 (0)