Skip to content

Commit 6ba6299

Browse files
authored
feat: add PR body generator from agent bundle
Adds a deterministic Markdown PR body generator from agent artifact bundles with bundle validation, evidence-only rendering, and focused tests.
1 parent 4487814 commit 6ba6299

2 files changed

Lines changed: 243 additions & 0 deletions

File tree

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
#!/usr/bin/env python3
2+
"""Render a deterministic pull-request body from an agent artifact bundle."""
3+
4+
from __future__ import annotations
5+
6+
import argparse
7+
import json
8+
import sys
9+
from pathlib import Path
10+
from typing import Any
11+
12+
REPO_ROOT = Path(__file__).resolve().parents[1]
13+
if str(REPO_ROOT) not in sys.path:
14+
sys.path.insert(0, str(REPO_ROOT))
15+
16+
from scripts.validate_agent_artifact_bundle import DEFAULT_BUNDLE_PATH, _bundle_from_payload, _load_json_object, validate_bundle_payload
17+
18+
19+
def _bullet_list(values: list[str], empty: str) -> list[str]:
20+
if not values:
21+
return [f"- {empty}"]
22+
return [f"- `{value}`" for value in values]
23+
24+
25+
def _validation_lines(validation_evidence: object) -> list[str]:
26+
if not isinstance(validation_evidence, list) or not validation_evidence:
27+
return ["- No validation evidence provided in bundle."]
28+
29+
lines: list[str] = []
30+
for entry in validation_evidence:
31+
if not isinstance(entry, dict):
32+
continue
33+
command = entry.get("command")
34+
result = entry.get("result")
35+
if isinstance(command, str) and isinstance(result, str):
36+
lines.append(f"- `{command}`: `{result}`")
37+
return lines or ["- No validation evidence provided in bundle."]
38+
39+
40+
def _safe_gate_lines(safe_pr_gate: object) -> list[str]:
41+
if not isinstance(safe_pr_gate, dict):
42+
return ["- safe_pr_gate: `unavailable`"]
43+
44+
lines = [
45+
f"- result: `{safe_pr_gate.get('result')}`",
46+
f"- ok: `{str(safe_pr_gate.get('ok')).lower()}`",
47+
f"- allow_dirty: `{str(safe_pr_gate.get('allow_dirty')).lower()}`",
48+
]
49+
problems = safe_pr_gate.get("problems")
50+
if isinstance(problems, list) and problems:
51+
lines.append("- problems:")
52+
lines.extend(f" - `{problem}`" for problem in problems if isinstance(problem, str))
53+
else:
54+
lines.append("- problems: `none`")
55+
return lines
56+
57+
58+
def render_pr_body_from_bundle(bundle: dict[str, Any]) -> str:
59+
changed_files = bundle.get("changed_files")
60+
changed_file_lines = _bullet_list(changed_files if isinstance(changed_files, list) else [], "No changed files provided in bundle.")
61+
validation_lines = _validation_lines(bundle.get("validation_evidence"))
62+
safe_gate_lines = _safe_gate_lines(bundle.get("safe_pr_gate"))
63+
64+
evidence_lines = [
65+
f"- branch: `{bundle.get('branch')}`",
66+
f"- bundle_result: `{bundle.get('result')}`",
67+
]
68+
mcp_ref = bundle.get("mcp_context_output_ref")
69+
if isinstance(mcp_ref, str):
70+
evidence_lines.append(f"- mcp_context_output_ref: `{mcp_ref}`")
71+
72+
lines = [
73+
"## Summary",
74+
"",
75+
"Deterministic agent artifact bundle evidence for this change.",
76+
"",
77+
"## Scope",
78+
"",
79+
*changed_file_lines,
80+
"",
81+
"## Validation",
82+
"",
83+
*validation_lines,
84+
"",
85+
"## Safety Gate",
86+
"",
87+
*safe_gate_lines,
88+
"",
89+
"## Evidence",
90+
"",
91+
*evidence_lines,
92+
"",
93+
]
94+
return "\n".join(lines)
95+
96+
97+
def render_pr_body_from_payload(payload: dict[str, Any]) -> str:
98+
validation = validate_bundle_payload(payload)
99+
if not validation["ok"]:
100+
issues = "\n".join(f"- {issue}" for issue in validation["issues"])
101+
raise RuntimeError(f"agent artifact bundle failed validation:\n{issues}")
102+
103+
bundle, bundle_issues = _bundle_from_payload(payload)
104+
if bundle is None:
105+
raise RuntimeError("; ".join(bundle_issues))
106+
return render_pr_body_from_bundle(bundle)
107+
108+
109+
def render_pr_body_from_file(path: Path) -> str:
110+
return render_pr_body_from_payload(_load_json_object(path))
111+
112+
113+
def _parse_args(argv: list[str]) -> argparse.Namespace:
114+
parser = argparse.ArgumentParser(description="Render deterministic PR body Markdown from an agent artifact bundle.")
115+
parser.add_argument("--bundle", type=Path, default=DEFAULT_BUNDLE_PATH, help="Bundle JSON path.")
116+
return parser.parse_args(argv)
117+
118+
119+
def main(argv: list[str] | None = None) -> int:
120+
args = _parse_args(sys.argv[1:] if argv is None else argv)
121+
try:
122+
sys.stdout.write(render_pr_body_from_file(args.bundle))
123+
return 0
124+
except RuntimeError as exc:
125+
sys.stderr.write(f"{exc}\n")
126+
return 1
127+
128+
129+
if __name__ == "__main__":
130+
raise SystemExit(main())
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
from __future__ import annotations
2+
3+
import json
4+
from pathlib import Path
5+
6+
import scripts.pr_body_from_agent_bundle as pr_body
7+
8+
ARTIFACT_PATH = Path("artifacts/agent_artifact_bundle_example.json")
9+
10+
11+
def test_render_pr_body_from_committed_bundle_is_deterministic() -> None:
12+
first = pr_body.render_pr_body_from_file(ARTIFACT_PATH)
13+
second = pr_body.render_pr_body_from_file(ARTIFACT_PATH)
14+
15+
assert first == second
16+
assert first == "\n".join(
17+
[
18+
"## Summary",
19+
"",
20+
"Deterministic agent artifact bundle evidence for this change.",
21+
"",
22+
"## Scope",
23+
"",
24+
"- `artifacts/agent_artifact_bundle_example.json`",
25+
"- `scripts/generate_agent_artifact_bundle_example.py`",
26+
"- `tests/test_agent_artifact_bundle.py`",
27+
"",
28+
"## Validation",
29+
"",
30+
"- `python -m compileall -q scripts/agent_artifact_bundle.py scripts/generate_agent_artifact_bundle_example.py`: `pass`",
31+
"- `pytest tests/test_agent_artifact_bundle.py -q`: `pass`",
32+
"",
33+
"## Safety Gate",
34+
"",
35+
"- result: `PASS`",
36+
"- ok: `true`",
37+
"- allow_dirty: `false`",
38+
"- problems: `none`",
39+
"",
40+
"## Evidence",
41+
"",
42+
"- branch: `feat/agent-artifact-bundle-example`",
43+
"- bundle_result: `PASS`",
44+
"- mcp_context_output_ref: `artifacts/mcp_context_layer_example.json`",
45+
"",
46+
]
47+
)
48+
49+
50+
def test_render_pr_body_uses_only_bundle_validation_evidence() -> None:
51+
bundle = {
52+
"branch": "feat/no-validation",
53+
"changed_files": [],
54+
"ok": True,
55+
"result": "PASS",
56+
"safe_pr_gate": {
57+
"allow_dirty": False,
58+
"allowed_prefixes": [],
59+
"branch": "feat/no-validation",
60+
"changed_paths": [],
61+
"ok": True,
62+
"problems": [],
63+
"result": "PASS",
64+
"status_short": [],
65+
},
66+
"validation_evidence": [],
67+
}
68+
69+
rendered = pr_body.render_pr_body_from_payload(bundle)
70+
71+
assert "- No validation evidence provided in bundle." in rendered
72+
assert "pytest" not in rendered
73+
74+
75+
def test_render_pr_body_rejects_invalid_bundle_without_markdown() -> None:
76+
invalid = {
77+
"branch": "feat/bad",
78+
"changed_files": [],
79+
"ok": True,
80+
"result": "FAIL",
81+
"safe_pr_gate": {
82+
"allow_dirty": False,
83+
"allowed_prefixes": [],
84+
"branch": "feat/bad",
85+
"changed_paths": [],
86+
"ok": True,
87+
"problems": [],
88+
"result": "PASS",
89+
"status_short": [],
90+
},
91+
"validation_evidence": [],
92+
}
93+
94+
try:
95+
pr_body.render_pr_body_from_payload(invalid)
96+
except RuntimeError as exc:
97+
assert "bundle.result must match bundle.ok" in str(exc)
98+
else:
99+
raise AssertionError("expected invalid bundle to raise RuntimeError")
100+
101+
102+
def test_cli_outputs_markdown_only_for_valid_bundle(tmp_path: Path, capsys) -> None:
103+
payload = json.loads(ARTIFACT_PATH.read_text(encoding="utf-8"))
104+
bundle_path = tmp_path / "bundle.json"
105+
bundle_path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
106+
107+
exit_code = pr_body.main(["--bundle", str(bundle_path)])
108+
captured = capsys.readouterr()
109+
110+
assert exit_code == 0
111+
assert captured.err == ""
112+
assert captured.out.startswith("## Summary\n")
113+
assert "## Safety Gate\n" in captured.out

0 commit comments

Comments
 (0)