a-review-skill/a-review/scripts/assemble_context.py
Travis Herbranson 86797abb99 Initial a-review skill: model-agnostic independent code review
Skill that sends git diffs to an independent AI model (Gemini CLI, Ollama)
for security, bug, performance, and convention review. Assembles a context
packet from CLAUDE.md + session notes + expanded diff so the reviewer
evaluates intent vs. implementation.

Includes context assembler script, structured review prompt template,
and review-ready CLAUDE.md with key patterns documented.
2026-04-27 20:32:56 -04:00

156 lines
4.4 KiB
Python

#!/usr/bin/env python3
"""Assemble the context packet for a-review.
Reads CLAUDE.md, latest session notes, expanded diff, and diff stat
from the current git repository. Outputs a structured context document
to stdout for piping to the reviewer model.
Usage:
python scripts/assemble_context.py # diff against HEAD~1
python scripts/assemble_context.py --staged # diff of staged changes
python scripts/assemble_context.py --context 15 # 15 lines of context
"""
from __future__ import annotations
import argparse
import subprocess
import sys
from pathlib import Path
def _run(cmd: list[str], cwd: Path | None = None) -> str:
"""Run a command and return stdout, or empty string on failure."""
try:
result = subprocess.run(
cmd,
capture_output=True,
text=True,
cwd=cwd,
check=False,
)
return result.stdout.strip()
except FileNotFoundError:
return ""
def _find_project_root() -> Path:
"""Find the git repository root."""
root = _run(["git", "rev-parse", "--show-toplevel"])
if root:
return Path(root)
return Path.cwd()
def _read_file_if_exists(path: Path) -> str:
"""Read a file if it exists, return empty string otherwise."""
if path.exists():
return path.read_text()
return ""
def _find_latest_session_entry(project_root: Path) -> str:
"""Find and return the latest session notes entry."""
sessions_dir = project_root / "sessions"
if not sessions_dir.exists():
return ""
# Find session note files
session_files = sorted(sessions_dir.glob("*.md"), key=lambda f: f.stat().st_mtime, reverse=True)
if not session_files:
return ""
content = session_files[0].read_text()
# Extract the last ## Session entry
sections = content.split("\n## Session")
if len(sections) < 2:
return content # Return full file if no session headers
last_section = "## Session" + sections[-1]
return last_section.strip()
def assemble(staged: bool = False, context_lines: int = 10) -> str:
"""Assemble the full context packet."""
root = _find_project_root()
parts: list[str] = []
# Header
project_name = root.name
parts.append(f"# Code Review Context: {project_name}")
parts.append("")
# CLAUDE.md
claude_md = _read_file_if_exists(root / "CLAUDE.md")
if claude_md:
parts.append("## Project Documentation (CLAUDE.md)")
parts.append("")
parts.append(claude_md)
parts.append("")
else:
parts.append("## Project Documentation")
parts.append("No CLAUDE.md found in project root.")
parts.append("")
# Session notes
session = _find_latest_session_entry(root)
if session:
parts.append("## Latest Session Notes")
parts.append("")
parts.append(session)
parts.append("")
else:
parts.append("## Session Notes")
parts.append("No session notes found.")
parts.append("")
# Diff stat
diff_ref = ["--cached"] if staged else ["HEAD~1"]
stat = _run(["git", "diff", "--stat", *diff_ref], cwd=root)
if stat:
parts.append("## Diff Summary")
parts.append("```")
parts.append(stat)
parts.append("```")
parts.append("")
# Expanded diff (with surrounding context)
expanded = _run(
["git", "diff", f"-U{context_lines}", *diff_ref],
cwd=root,
)
if expanded:
parts.append(f"## Expanded Diff ({context_lines} lines context)")
parts.append("```diff")
parts.append(expanded)
parts.append("```")
parts.append("")
# Standard diff
diff = _run(["git", "diff", *diff_ref], cwd=root)
if diff:
parts.append("## Diff")
parts.append("```diff")
parts.append(diff)
parts.append("```")
elif not expanded:
parts.append("## Diff")
parts.append("No changes found.")
return "\n".join(parts)
def main() -> None:
parser = argparse.ArgumentParser(description="Assemble a-review context packet")
parser.add_argument("--staged", action="store_true", help="Review staged changes instead of HEAD~1")
parser.add_argument("--context", type=int, default=10, help="Lines of surrounding context (default: 10)")
args = parser.parse_args()
output = assemble(staged=args.staged, context_lines=args.context)
sys.stdout.write(output)
if __name__ == "__main__":
main()