diff --git a/.github/workflows/pr-review.yml b/.github/workflows/pr-review.yml
new file mode 100644
index 00000000..180590f6
--- /dev/null
+++ b/.github/workflows/pr-review.yml
@@ -0,0 +1,43 @@
+name: Code Review Autopilot
+
+on:
+ pull_request:
+ types: [opened, synchronize, reopened]
+
+permissions:
+ contents: read
+ pull-requests: write
+
+jobs:
+ review:
+ name: AI Code Review
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 0 # full history so context builder can read files
+
+ - name: Set up Python 3.11
+ uses: actions/setup-python@v5
+ with:
+ python-version: "3.11"
+
+ - name: Install dependencies
+ run: pip install -r requirements.txt
+
+ - name: Run Code Review Autopilot
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ GITHUB_REPOSITORY: ${{ github.repository }}
+ ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
+ CLAUDE_MODEL: ${{ vars.CLAUDE_MODEL || 'claude-sonnet-4-20250514' }}
+ STORAGE_BACKEND: sqlite
+ SQLITE_DB_PATH: reviews.db
+ PR_NUMBER: ${{ github.event.pull_request.number }}
+ LOG_LEVEL: INFO
+ MAX_CONTEXT_FILES: "15"
+ MAX_CONTEXT_TOKENS: "80000"
+ run: python -m src.review_bot
diff --git a/app.py b/app.py
new file mode 100644
index 00000000..13ebcfb6
--- /dev/null
+++ b/app.py
@@ -0,0 +1,268 @@
+"""
+Code Review Autopilot — Streamlit Dashboard
+
+A read-only dashboard that displays PR reviews stored by the review bot.
+No manual PR analysis triggers — data comes from the shared SQLite / Postgres store.
+
+Run:
+ streamlit run app.py
+"""
+
+from __future__ import annotations
+
+import os
+from datetime import datetime, timedelta
+
+import streamlit as st
+from dotenv import load_dotenv
+
+load_dotenv()
+
+from src.services.storage_service import get_storage
+
+# ── Page config ──────────────────────────────────────────────────────────────
+
+st.set_page_config(
+ page_title="Code Review Autopilot",
+ page_icon="🤖",
+ layout="wide",
+ initial_sidebar_state="expanded",
+)
+
+# ── Custom CSS ───────────────────────────────────────────────────────────────
+
+st.markdown(
+ """
+
+ """,
+ unsafe_allow_html=True,
+)
+
+
+# ── Helpers ──────────────────────────────────────────────────────────────────
+
+def _decision_badge(decision: str) -> str:
+ cls = {"Approve": "badge-approve", "Needs Changes": "badge-needs", "Reject": "badge-reject"}.get(
+ decision, "badge-needs"
+ )
+ return f'{decision}'
+
+
+def _risk_span(level: str) -> str:
+ cls = {"Low": "risk-low", "Medium": "risk-medium", "High": "risk-high", "Critical": "risk-critical"}.get(
+ level, ""
+ )
+ return f'{level}'
+
+
+def _severity_icon(sev: str) -> str:
+ return {"High": "🔴", "Medium": "🟡", "Low": "🟢"}.get(sev, "⚪")
+
+
+# ── Sidebar filters ─────────────────────────────────────────────────────────
+
+def _sidebar_filters() -> dict:
+ st.sidebar.title("🤖 Code Review Autopilot")
+ st.sidebar.markdown("---")
+ st.sidebar.subheader("Filters")
+
+ # We load all reviews first so we can derive filter options
+ storage = get_storage()
+ all_reviews = storage.load_review_results()
+
+ repos = sorted({r.get("repo", "") for r in all_reviews if r.get("repo")})
+ selected_repo = st.sidebar.selectbox("Repository", ["All"] + repos)
+
+ risk_levels = ["All", "Low", "Medium", "High", "Critical"]
+ selected_risk = st.sidebar.selectbox("Risk Level", risk_levels)
+
+ decisions = ["All", "Approve", "Needs Changes", "Reject"]
+ selected_decision = st.sidebar.selectbox("Decision", decisions)
+
+ date_range = st.sidebar.date_input(
+ "Date range",
+ value=(datetime.now() - timedelta(days=30), datetime.now()),
+ )
+
+ filters: dict = {}
+ if selected_repo != "All":
+ filters["repo"] = selected_repo
+ if selected_risk != "All":
+ filters["risk_level"] = selected_risk
+ if selected_decision != "All":
+ filters["decision"] = selected_decision
+ if isinstance(date_range, (list, tuple)) and len(date_range) == 2:
+ filters["date_from"] = str(date_range[0])
+ filters["date_to"] = str(date_range[1]) + "T23:59:59"
+
+ st.sidebar.markdown("---")
+ st.sidebar.caption("Data refreshes on page load.")
+
+ return filters
+
+
+# ── Review list ──────────────────────────────────────────────────────────────
+
+def _render_review_list(reviews: list[dict]) -> int | None:
+ """Render a compact list of reviews and return the selected index."""
+ if not reviews:
+ st.info("No reviews found. Reviews appear here automatically after the GitHub Actions bot runs.")
+ return None
+
+ st.markdown(f"### Showing **{len(reviews)}** review(s)")
+
+ selected_idx: int | None = None
+ for idx, r in enumerate(reviews):
+ with st.container():
+ cols = st.columns([0.5, 3, 1.5, 1, 1, 1.5])
+ cols[0].markdown(f"**#{r.get('pr_number', '?')}**")
+ cols[1].markdown(f"**{r.get('pr_title', 'Untitled')}** \n`{r.get('repo', '')}`")
+ cols[2].markdown(f"🧑💻 {r.get('pr_author', 'unknown')} \n🌿 `{r.get('branch', '')}`")
+ cols[3].markdown(
+ f"Score: **{r.get('risk_score', '?')}** \n{_risk_span(r.get('risk_level', ''))}",
+ unsafe_allow_html=True,
+ )
+ cols[4].markdown(_decision_badge(r.get("decision", "")), unsafe_allow_html=True)
+ if cols[5].button("View", key=f"view_{idx}"):
+ selected_idx = idx
+ st.divider()
+
+ return selected_idx
+
+
+# ── Detailed review view ────────────────────────────────────────────────────
+
+def _render_detail(r: dict) -> None:
+ """Render the full review report for a single PR."""
+ st.markdown("---")
+ st.markdown(f"## 🤖 Review: PR #{r.get('pr_number')} — {r.get('pr_title', '')}")
+
+ # Meta
+ meta_cols = st.columns(4)
+ meta_cols[0].metric("Repository", r.get("repo", ""))
+ meta_cols[1].metric("Author", r.get("pr_author", ""))
+ meta_cols[2].metric("Branch", r.get("branch", ""))
+ meta_cols[3].metric("Commit", (r.get("commit_sha") or "")[:8])
+
+ st.markdown("")
+
+ # ─ 1. Summary
+ st.subheader("1. Pull Request Review Summary")
+ st.markdown(r.get("summary", ""))
+
+ # ─ 2. Risk Assessment
+ st.subheader("2. Risk Assessment")
+ risk_cols = st.columns(4)
+ risk_cols[0].metric("Risk Score", f"{r.get('risk_score', '?')} / 100")
+ risk_cols[1].markdown(f"**Risk Level:** {_risk_span(r.get('risk_level', ''))}", unsafe_allow_html=True)
+ risk_cols[2].markdown(f"**Decision:** {_decision_badge(r.get('decision', ''))}", unsafe_allow_html=True)
+ risk_cols[3].metric("Assessment", r.get("overall_assessment", ""))
+ reasoning = r.get("reasoning", "")
+ if reasoning:
+ st.info(reasoning)
+
+ # ─ 3. File-wise Impact
+ files = r.get("files", [])
+ if files:
+ st.subheader("3. File-wise Impact")
+ file_data = [{"File": f.get("file", ""), "Summary": f.get("summary", "")} for f in files]
+ st.table(file_data)
+
+ # ─ 4. Cross-file Impact
+ cross = r.get("cross_file_impact", [])
+ if cross:
+ st.subheader("4. Cross-file Impact")
+ for c in cross:
+ st.markdown(f"- **{c.get('component', '')}** — {c.get('impact', '')}")
+
+ # ─ 5. Key Issues
+ issues = r.get("issues", [])
+ if issues:
+ st.subheader("5. Key Issues Found")
+ for i, iss in enumerate(issues, 1):
+ sev = iss.get("severity", "Medium")
+ icon = _severity_icon(sev)
+ with st.expander(f"{icon} Issue {i}: [{sev}] {iss.get('file', '')} (line {iss.get('line', '?')})"):
+ st.markdown(f"**Issue:** {iss.get('issue', '')}")
+ st.markdown(f"**Risk:** {iss.get('risk', '')}")
+ affected = iss.get("affected_related_code", [])
+ if affected:
+ st.markdown("**Affected related code:** " + ", ".join(f"`{a}`" for a in affected))
+ st.markdown(f"**Suggestion:** {iss.get('suggestion', '')}")
+ code = iss.get("suggested_code", "")
+ if code:
+ st.code(code, language="python")
+
+ # ─ 6. Good Improvements
+ goods = r.get("good_improvements", [])
+ if goods:
+ st.subheader("6. Good Improvements")
+ for g in goods:
+ st.markdown(f"- ✅ {g}")
+
+ # ─ 7. Bad Regressions
+ bads = r.get("bad_regressions", [])
+ if bads:
+ st.subheader("7. Bad Regressions")
+ for b in bads:
+ st.markdown(f"- ❌ {b}")
+
+ # ─ 8. Recommended Actions
+ actions = r.get("recommended_actions", [])
+ if actions:
+ st.subheader("8. Recommended Actions Before Merge")
+ for a in actions:
+ st.markdown(f"- {a}")
+
+ # ─ Timestamp
+ st.markdown("---")
+ st.caption(f"Review generated at {r.get('created_at', 'N/A')}")
+
+
+# ── Main ─────────────────────────────────────────────────────────────────────
+
+def render_streamlit_dashboard() -> None:
+ filters = _sidebar_filters()
+
+ storage = get_storage()
+ reviews = storage.load_review_results(filters if filters else None)
+
+ # Session-state for selected review
+ if "selected_review_idx" not in st.session_state:
+ st.session_state.selected_review_idx = None
+
+ selected = _render_review_list(reviews)
+ if selected is not None:
+ st.session_state.selected_review_idx = selected
+
+ idx = st.session_state.selected_review_idx
+ if idx is not None and 0 <= idx < len(reviews):
+ _render_detail(reviews[idx])
+
+
+# ── Entrypoint ───────────────────────────────────────────────────────────────
+
+render_streamlit_dashboard()
diff --git a/requirements.txt b/requirements.txt
index 10e5fbf9..c6830209 100644
Binary files a/requirements.txt and b/requirements.txt differ
diff --git a/src/__init__.py b/src/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/src/__pycache__/__init__.cpython-311.pyc b/src/__pycache__/__init__.cpython-311.pyc
new file mode 100644
index 00000000..b6be437a
Binary files /dev/null and b/src/__pycache__/__init__.cpython-311.pyc differ
diff --git a/src/__pycache__/review_bot.cpython-311.pyc b/src/__pycache__/review_bot.cpython-311.pyc
new file mode 100644
index 00000000..e3726fd7
Binary files /dev/null and b/src/__pycache__/review_bot.cpython-311.pyc differ
diff --git a/src/review_bot.py b/src/review_bot.py
new file mode 100644
index 00000000..b0215471
--- /dev/null
+++ b/src/review_bot.py
@@ -0,0 +1,84 @@
+"""
+review_bot.py – CLI entry point invoked by GitHub Actions.
+
+Usage (in Actions):
+ python -m src.review_bot
+
+Required environment variables:
+ GITHUB_TOKEN, GITHUB_REPOSITORY, ANTHROPIC_API_KEY, PR_NUMBER
+"""
+
+from __future__ import annotations
+
+import logging
+import os
+import sys
+
+from dotenv import load_dotenv
+
+load_dotenv() # allow local .env during development
+
+from src.services.claude_client import ClaudeClient
+from src.services.github_client import GitHubClient
+from src.services.review_service import run_review
+from src.services.storage_service import get_storage
+
+# ── Logging ──────────────────────────────────────────────────────────────────
+
+logging.basicConfig(
+ level=os.getenv("LOG_LEVEL", "INFO"),
+ format="%(asctime)s %(levelname)-8s %(name)s %(message)s",
+ datefmt="%H:%M:%S",
+)
+logger = logging.getLogger("review_bot")
+
+
+def main() -> None:
+ # ── Validate env ─────────────────────────────────────────────────────────
+ pr_number_raw = os.getenv("PR_NUMBER")
+ if not pr_number_raw:
+ logger.error("PR_NUMBER environment variable is not set.")
+ sys.exit(1)
+ try:
+ pr_number = int(pr_number_raw)
+ except ValueError:
+ logger.error("PR_NUMBER must be an integer, got: %s", pr_number_raw)
+ sys.exit(1)
+
+ github_token = os.getenv("GITHUB_TOKEN", "")
+ if not github_token:
+ logger.error("GITHUB_TOKEN is not set.")
+ sys.exit(1)
+
+ repo = os.getenv("GITHUB_REPOSITORY", "")
+ if not repo:
+ logger.error("GITHUB_REPOSITORY is not set.")
+ sys.exit(1)
+
+ anthropic_key = os.getenv("ANTHROPIC_API_KEY", "")
+ if not anthropic_key:
+ logger.error("ANTHROPIC_API_KEY is not set.")
+ sys.exit(1)
+
+ # ── Instantiate services ────────────────────────────────────────────────
+ gh = GitHubClient(token=github_token, repo=repo)
+ claude = ClaudeClient(api_key=anthropic_key)
+ storage = get_storage()
+
+ # ── Run ──────────────────────────────────────────────────────────────────
+ logger.info("Starting Code Review Autopilot for %s PR #%s", repo, pr_number)
+ try:
+ result = run_review(pr_number, gh, claude, storage)
+ logger.info(
+ "Review complete — decision: %s | risk: %s (%s)",
+ result.get("decision"),
+ result.get("risk_score"),
+ result.get("risk_level"),
+ )
+ except Exception:
+ logger.exception("Review pipeline failed")
+ sys.exit(1)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/src/services/__init__.py b/src/services/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/src/services/__pycache__/__init__.cpython-311.pyc b/src/services/__pycache__/__init__.cpython-311.pyc
new file mode 100644
index 00000000..1cf4e945
Binary files /dev/null and b/src/services/__pycache__/__init__.cpython-311.pyc differ
diff --git a/src/services/__pycache__/claude_client.cpython-311.pyc b/src/services/__pycache__/claude_client.cpython-311.pyc
new file mode 100644
index 00000000..e089cd22
Binary files /dev/null and b/src/services/__pycache__/claude_client.cpython-311.pyc differ
diff --git a/src/services/__pycache__/context_builder.cpython-311.pyc b/src/services/__pycache__/context_builder.cpython-311.pyc
new file mode 100644
index 00000000..c1fb7c3e
Binary files /dev/null and b/src/services/__pycache__/context_builder.cpython-311.pyc differ
diff --git a/src/services/__pycache__/github_client.cpython-311.pyc b/src/services/__pycache__/github_client.cpython-311.pyc
new file mode 100644
index 00000000..14b93244
Binary files /dev/null and b/src/services/__pycache__/github_client.cpython-311.pyc differ
diff --git a/src/services/__pycache__/review_service.cpython-311.pyc b/src/services/__pycache__/review_service.cpython-311.pyc
new file mode 100644
index 00000000..8020696b
Binary files /dev/null and b/src/services/__pycache__/review_service.cpython-311.pyc differ
diff --git a/src/services/__pycache__/storage_service.cpython-311.pyc b/src/services/__pycache__/storage_service.cpython-311.pyc
new file mode 100644
index 00000000..db075901
Binary files /dev/null and b/src/services/__pycache__/storage_service.cpython-311.pyc differ
diff --git a/src/services/claude_client.py b/src/services/claude_client.py
new file mode 100644
index 00000000..7b08c5f5
--- /dev/null
+++ b/src/services/claude_client.py
@@ -0,0 +1,216 @@
+"""
+Claude AI client – sends the review payload and parses the strict JSON response.
+"""
+
+from __future__ import annotations
+
+import json
+import logging
+import os
+import re
+from typing import Any
+
+import requests
+
+logger = logging.getLogger(__name__)
+
+# ── System prompt (reusable constant) ────────────────────────────────────────
+
+REVIEW_SYSTEM_PROMPT = """\
+You are **Code Review Autopilot**, an expert senior software engineer performing a \
+thorough, production-grade code review of a GitHub Pull Request.
+
+## Your responsibilities
+1. Analyse **every changed file** and its **diff/patch**.
+2. Analyse the **related code context** provided (imports, callers, callees, sibling \
+ modules, tests, schemas, configs).
+3. Identify **good improvements** the PR introduces.
+4. Identify **bad regressions** or degradations.
+5. Evaluate **cross-file and downstream impact** — business logic side effects, \
+ interface breakage, backward-compatibility, determinism, security, performance, \
+ reliability, maintainability.
+6. Score the risk and recommend a decision (Approve / Needs Changes / Reject).
+7. For every issue found, provide a **practical suggestion** and, when helpful, a \
+ short **suggested code snippet**.
+
+## Output contract
+Return **ONLY** a single valid JSON object (no markdown fences, no commentary) \
+conforming to the schema below. Do not wrap it in ```json … ```.
+
+{
+ "summary": "",
+ "overall_assessment": "",
+ "risk_score": <0-100>,
+ "risk_level": "",
+ "decision": "",
+ "reasoning": "",
+ "cross_file_impact": [
+ {"component": "", "impact": ""}
+ ],
+ "files": [
+ {"file": "", "summary": ""}
+ ],
+ "issues": [
+ {
+ "file": "",
+ "line": ,
+ "severity": "",
+ "issue": "",
+ "risk": "",
+ "affected_related_code": [""],
+ "suggestion": "",
+ "suggested_code": ""
+ }
+ ],
+ "good_improvements": [""],
+ "bad_regressions": [""],
+ "recommended_actions": [""]
+}
+
+## Rules
+- Do NOT output anything outside the JSON object.
+- Use real line numbers from the provided diff hunks.
+- Be honest: if the PR is good, say so. If it is risky, explain why.
+- Keep suggestions concrete and actionable.
+"""
+
+# ── User prompt template ────────────────────────────────────────────────────
+
+REVIEW_USER_PROMPT_TEMPLATE = """\
+## Pull Request #{pr_number}: {pr_title}
+
+**Author:** {pr_author}
+**Branch:** {branch} → {base_branch}
+**Description:**
+{pr_body}
+
+---
+
+## Changed Files & Diffs
+
+{files_and_diffs}
+
+---
+
+## Related Code Context
+
+{related_context}
+
+---
+
+Perform a thorough review. Return ONLY the JSON described in the system prompt.
+"""
+
+
+class ClaudeClient:
+ """Anthropic Messages API client for code review."""
+
+ API_URL = "https://api.anthropic.com/v1/messages"
+
+ def __init__(
+ self,
+ api_key: str | None = None,
+ model: str | None = None,
+ ):
+ self.api_key = api_key or os.getenv("ANTHROPIC_API_KEY", "")
+ self.model = model or os.getenv("CLAUDE_MODEL", "claude-sonnet-4-20250514")
+ self.session = requests.Session()
+ self.session.headers.update(
+ {
+ "x-api-key": self.api_key,
+ "anthropic-version": "2023-06-01",
+ "Content-Type": "application/json",
+ }
+ )
+
+ # ── Build the user message ───────────────────────────────────────────────
+
+ @staticmethod
+ def build_user_message(
+ pr_number: int,
+ pr_title: str,
+ pr_author: str,
+ pr_body: str,
+ branch: str,
+ base_branch: str,
+ files_and_diffs: str,
+ related_context: str,
+ ) -> str:
+ return REVIEW_USER_PROMPT_TEMPLATE.format(
+ pr_number=pr_number,
+ pr_title=pr_title,
+ pr_author=pr_author,
+ branch=branch,
+ base_branch=base_branch,
+ pr_body=pr_body or "(no description)",
+ files_and_diffs=files_and_diffs,
+ related_context=related_context or "(none available)",
+ )
+
+ # ── Call Claude ──────────────────────────────────────────────────────────
+
+ def analyze_with_claude(self, user_message: str, max_tokens: int = 8192) -> dict[str, Any]:
+ """
+ Send the review payload to Claude and return the parsed JSON review.
+ Raises on HTTP or parse errors after retry.
+ """
+ payload = {
+ "model": self.model,
+ "max_tokens": max_tokens,
+ "system": REVIEW_SYSTEM_PROMPT,
+ "messages": [{"role": "user", "content": user_message}],
+ }
+
+ resp = self.session.post(self.API_URL, json=payload, timeout=120)
+ resp.raise_for_status()
+ data = resp.json()
+
+ # Extract text block
+ text = ""
+ for block in data.get("content", []):
+ if block.get("type") == "text":
+ text += block["text"]
+
+ return self._parse_json(text)
+
+ # ── Robust JSON parsing ──────────────────────────────────────────────────
+
+ @staticmethod
+ def _parse_json(text: str) -> dict[str, Any]:
+ """
+ Attempt to parse a JSON object from Claude's response.
+ Falls back to stripping markdown fences or extracting the first { … }.
+ """
+ text = text.strip()
+
+ # Direct parse
+ try:
+ return json.loads(text)
+ except json.JSONDecodeError:
+ pass
+
+ # Strip markdown fences
+ cleaned = re.sub(r"^```(?:json)?\s*", "", text, flags=re.MULTILINE)
+ cleaned = re.sub(r"\s*```\s*$", "", cleaned, flags=re.MULTILINE).strip()
+ try:
+ return json.loads(cleaned)
+ except json.JSONDecodeError:
+ pass
+
+ # Extract first JSON object
+ match = re.search(r"\{", cleaned)
+ if match:
+ depth, start = 0, match.start()
+ for i, ch in enumerate(cleaned[start:], start):
+ if ch == "{":
+ depth += 1
+ elif ch == "}":
+ depth -= 1
+ if depth == 0:
+ try:
+ return json.loads(cleaned[start : i + 1])
+ except json.JSONDecodeError:
+ break
+
+ logger.error("Failed to parse JSON from Claude response:\n%s", text[:500])
+ raise ValueError("Claude did not return valid JSON. Raw output saved for debugging.")
diff --git a/src/services/context_builder.py b/src/services/context_builder.py
new file mode 100644
index 00000000..3d929169
--- /dev/null
+++ b/src/services/context_builder.py
@@ -0,0 +1,341 @@
+"""
+Context builder – gathers related code files so Claude can review
+not just the diff but also the surrounding code that might be affected.
+
+Heuristics used:
+ 1. Imports / usings found in changed files
+ 2. Sibling files in the same directory / module
+ 3. Test files matching naming conventions
+ 4. Schema / config / model files matching symbol names
+ 5. Direct callers / callees discovered via code search
+"""
+
+from __future__ import annotations
+
+import logging
+import os
+import re
+from dataclasses import dataclass, field
+from pathlib import PurePosixPath
+from typing import TYPE_CHECKING
+
+if TYPE_CHECKING:
+ from src.services.github_client import GitHubClient, PRFile
+
+logger = logging.getLogger(__name__)
+
+MAX_CONTEXT_FILES = int(os.getenv("MAX_CONTEXT_FILES", "15"))
+MAX_CONTEXT_TOKENS = int(os.getenv("MAX_CONTEXT_TOKENS", "80000"))
+# rough chars-per-token estimate for code
+CHARS_PER_TOKEN = 4
+
+
+@dataclass
+class ContextFile:
+ path: str
+ snippet: str
+ reason: str
+
+
+@dataclass
+class RelatedContext:
+ files: list[ContextFile] = field(default_factory=list)
+
+ def as_text(self) -> str:
+ if not self.files:
+ return "(no related context gathered)"
+ parts: list[str] = []
+ for cf in self.files:
+ parts.append(f"### {cf.path} (reason: {cf.reason})\n```\n{cf.snippet}\n```")
+ return "\n\n".join(parts)
+
+
+# ── Public API ────────────────────────────────────────────────────────────────
+
+def get_related_code_context(
+ gh: GitHubClient,
+ changed_files: list[PRFile],
+ commit_sha: str,
+) -> RelatedContext:
+ """
+ Main entry – collects dependency context, sibling context, test context,
+ and caller/callee context up to token budget.
+ """
+ ctx = RelatedContext()
+ budget = MAX_CONTEXT_TOKENS * CHARS_PER_TOKEN
+ seen_paths: set[str] = {f.filename for f in changed_files}
+
+ # 1. Dependency context (imports)
+ _add_dependency_context(gh, changed_files, commit_sha, ctx, seen_paths, budget)
+ # 2. Sibling / module context
+ _add_sibling_context(gh, changed_files, commit_sha, ctx, seen_paths, budget)
+ # 3. Test context
+ _add_test_context(gh, changed_files, commit_sha, ctx, seen_paths, budget)
+ # 4. Caller / callee context via code search
+ _add_caller_callee_context(gh, changed_files, ctx, seen_paths, budget)
+
+ # Trim to budget
+ _trim_to_budget(ctx, budget)
+ return ctx
+
+
+def get_dependency_context(
+ gh: GitHubClient,
+ changed_files: list[PRFile],
+ commit_sha: str,
+) -> list[ContextFile]:
+ """Return files imported/used by changed files."""
+ ctx = RelatedContext()
+ seen: set[str] = {f.filename for f in changed_files}
+ _add_dependency_context(gh, changed_files, commit_sha, ctx, seen, MAX_CONTEXT_TOKENS * CHARS_PER_TOKEN)
+ return ctx.files
+
+
+def get_relevant_tests(
+ gh: GitHubClient,
+ changed_files: list[PRFile],
+ commit_sha: str,
+) -> list[ContextFile]:
+ """Return test files related to the changed files."""
+ ctx = RelatedContext()
+ seen: set[str] = {f.filename for f in changed_files}
+ _add_test_context(gh, changed_files, commit_sha, ctx, seen, MAX_CONTEXT_TOKENS * CHARS_PER_TOKEN)
+ return ctx.files
+
+
+# ── Internal collectors ──────────────────────────────────────────────────────
+
+def _used_chars(ctx: RelatedContext) -> int:
+ return sum(len(cf.snippet) for cf in ctx.files)
+
+
+def _room(ctx: RelatedContext, budget: int) -> bool:
+ return _used_chars(ctx) < budget and len(ctx.files) < MAX_CONTEXT_FILES
+
+
+def _fetch_and_add(
+ gh: GitHubClient,
+ path: str,
+ ref: str,
+ reason: str,
+ ctx: RelatedContext,
+ seen: set[str],
+ budget: int,
+ max_chars: int = 12000,
+) -> None:
+ if path in seen or not _room(ctx, budget):
+ return
+ content = gh.get_file_content(path, ref=ref)
+ if content is None:
+ return
+ snippet = content[:max_chars]
+ ctx.files.append(ContextFile(path=path, snippet=snippet, reason=reason))
+ seen.add(path)
+
+
+# ---- 1. Imports / dependency context ----------------------------------------
+
+_IMPORT_PATTERNS: list[re.Pattern[str]] = [
+ # Python: from x.y import z | import x.y
+ re.compile(r"^\s*(?:from\s+([\w.]+)\s+import|import\s+([\w.]+))", re.MULTILINE),
+ # JS/TS: import … from "path" | require("path")
+ re.compile(r"""(?:import\s+.*?\s+from\s+['"]([^'"]+)['"]|require\s*\(\s*['"]([^'"]+)['"]\s*\))""", re.MULTILINE),
+ # Go: import "path"
+ re.compile(r'import\s+"([^"]+)"', re.MULTILINE),
+ # Java/Kotlin: import x.y.z;
+ re.compile(r"^\s*import\s+([\w.]+);", re.MULTILINE),
+]
+
+
+def _extract_imports(source: str) -> list[str]:
+ """Extract import paths from source code (best-effort, multi-language)."""
+ imports: list[str] = []
+ for pat in _IMPORT_PATTERNS:
+ for m in pat.finditer(source):
+ groups = [g for g in m.groups() if g]
+ imports.extend(groups)
+ return imports
+
+
+def _resolve_import_to_path(imp: str, changed_dirs: set[str], tree: list[str]) -> str | None:
+ """Try to map an import string to a file in the repo tree."""
+ # Python style: replace dots → /
+ candidates = [
+ imp.replace(".", "/") + ".py",
+ imp.replace(".", "/") + "/__init__.py",
+ imp.replace(".", "/") + ".ts",
+ imp.replace(".", "/") + ".js",
+ imp + ".py",
+ imp + ".ts",
+ imp + ".js",
+ imp,
+ ]
+ # Also try relative to changed directories
+ for d in changed_dirs:
+ candidates.append(f"{d}/{imp.split('.')[-1]}.py")
+ candidates.append(f"{d}/{imp.replace('.', '/')}.py")
+
+ tree_set = set(tree)
+ for c in candidates:
+ c_norm = c.lstrip("./")
+ if c_norm in tree_set:
+ return c_norm
+ return None
+
+
+def _add_dependency_context(
+ gh: GitHubClient,
+ changed_files: list[PRFile],
+ ref: str,
+ ctx: RelatedContext,
+ seen: set[str],
+ budget: int,
+) -> None:
+ tree: list[str] | None = None
+ changed_dirs = {str(PurePosixPath(f.filename).parent) for f in changed_files}
+
+ for f in changed_files:
+ if not f.patch:
+ continue
+ imports = _extract_imports(f.patch)
+ if not imports:
+ continue
+ if tree is None:
+ tree = gh.get_repo_tree(ref)
+ for imp in imports:
+ resolved = _resolve_import_to_path(imp, changed_dirs, tree)
+ if resolved:
+ _fetch_and_add(gh, resolved, ref, f"imported by {f.filename}", ctx, seen, budget)
+
+
+# ---- 2. Sibling / module context -------------------------------------------
+
+_SIBLING_EXTENSIONS = {".py", ".ts", ".tsx", ".js", ".jsx", ".go", ".java", ".kt", ".rs"}
+
+
+def _add_sibling_context(
+ gh: GitHubClient,
+ changed_files: list[PRFile],
+ ref: str,
+ ctx: RelatedContext,
+ seen: set[str],
+ budget: int,
+) -> None:
+ dirs_fetched: set[str] = set()
+ for f in changed_files:
+ parent = str(PurePosixPath(f.filename).parent)
+ if parent in dirs_fetched:
+ continue
+ dirs_fetched.add(parent)
+ siblings = gh.list_directory(parent, ref=ref)
+ for sib in siblings:
+ ext = PurePosixPath(sib).suffix
+ if ext in _SIBLING_EXTENSIONS:
+ _fetch_and_add(gh, sib, ref, f"sibling in {parent}/", ctx, seen, budget, max_chars=6000)
+
+
+# ---- 3. Test context -------------------------------------------------------
+
+_TEST_PATTERNS = [
+ # test_.py, _test.py, .test.ts, __tests__/.js …
+ re.compile(r"(?:test_|_test\.|\.test\.|\.spec\.|__tests__/)"),
+]
+
+
+def _test_candidates(filename: str) -> list[str]:
+ """Generate candidate test file names for a source file."""
+ p = PurePosixPath(filename)
+ stem, ext = p.stem, p.suffix
+ parent = str(p.parent)
+ candidates = [
+ f"{parent}/test_{stem}{ext}",
+ f"{parent}/{stem}_test{ext}",
+ f"{parent}/{stem}.test{ext}",
+ f"{parent}/{stem}.spec{ext}",
+ f"{parent}/__tests__/{stem}{ext}",
+ f"tests/{parent}/{stem}{ext}",
+ f"tests/test_{stem}{ext}",
+ f"test/{stem}{ext}",
+ ]
+ return candidates
+
+
+def _add_test_context(
+ gh: GitHubClient,
+ changed_files: list[PRFile],
+ ref: str,
+ ctx: RelatedContext,
+ seen: set[str],
+ budget: int,
+) -> None:
+ tree: list[str] | None = None
+ for f in changed_files:
+ # Skip if the changed file itself is already a test
+ if any(pat.search(f.filename) for pat in _TEST_PATTERNS):
+ continue
+ candidates = _test_candidates(f.filename)
+ if tree is None:
+ tree = gh.get_repo_tree(ref)
+ tree_set = set(tree)
+ for cand in candidates:
+ if cand in tree_set:
+ _fetch_and_add(gh, cand, ref, f"test for {f.filename}", ctx, seen, budget, max_chars=8000)
+
+
+# ---- 4. Caller / callee context via code search ----------------------------
+
+_SYMBOL_RE = re.compile(r"\b(?:def|class|function|func|interface|type)\s+(\w+)")
+
+
+def _extract_symbols(patch: str) -> list[str]:
+ """Extract function/class names defined or modified in a patch."""
+ return list({m.group(1) for m in _SYMBOL_RE.finditer(patch)})
+
+
+def _add_caller_callee_context(
+ gh: GitHubClient,
+ changed_files: list[PRFile],
+ ctx: RelatedContext,
+ seen: set[str],
+ budget: int,
+) -> None:
+ for f in changed_files:
+ if not f.patch:
+ continue
+ symbols = _extract_symbols(f.patch)
+ for sym in symbols[:5]: # cap to avoid excessive API calls
+ results = gh.search_code(sym, max_results=5)
+ for r in results:
+ path = r["path"]
+ if path not in seen and _room(ctx, budget):
+ # Just note the path + reason; we won't fetch full content to save budget
+ snippet = ""
+ for tm in r.get("text_matches", []):
+ snippet += tm.get("fragment", "") + "\n"
+ if snippet:
+ snippet = snippet[:4000]
+ ctx.files.append(
+ ContextFile(
+ path=path,
+ snippet=snippet,
+ reason=f"references symbol `{sym}` from {f.filename}",
+ )
+ )
+ seen.add(path)
+
+
+# ── Budget trimming ──────────────────────────────────────────────────────────
+
+def _trim_to_budget(ctx: RelatedContext, budget: int) -> None:
+ total = 0
+ keep: list[ContextFile] = []
+ for cf in ctx.files:
+ if total + len(cf.snippet) > budget:
+ remaining = budget - total
+ if remaining > 500:
+ cf.snippet = cf.snippet[:remaining] + "\n… (truncated)"
+ keep.append(cf)
+ break
+ keep.append(cf)
+ total += len(cf.snippet)
+ ctx.files = keep
diff --git a/src/services/github_client.py b/src/services/github_client.py
new file mode 100644
index 00000000..aff80f78
--- /dev/null
+++ b/src/services/github_client.py
@@ -0,0 +1,253 @@
+"""
+GitHub REST API client – fetches PR metadata, diffs, files, and posts review comments.
+"""
+
+from __future__ import annotations
+
+import logging
+import os
+import re
+from dataclasses import dataclass, field
+from typing import Any
+
+import requests
+
+logger = logging.getLogger(__name__)
+
+API_BASE = "https://api.github.com"
+
+
+# ── Data classes ──────────────────────────────────────────────────────────────
+
+@dataclass
+class PRFile:
+ filename: str
+ status: str
+ additions: int
+ deletions: int
+ patch: str | None
+ raw_url: str | None
+
+
+@dataclass
+class PRMetadata:
+ number: int
+ title: str
+ body: str
+ author: str
+ branch: str
+ base_branch: str
+ commit_sha: str
+ files: list[PRFile] = field(default_factory=list)
+
+
+# ── Client ────────────────────────────────────────────────────────────────────
+
+class GitHubClient:
+ """Thin wrapper around the GitHub REST API."""
+
+ def __init__(self, token: str | None = None, repo: str | None = None):
+ self.token = token or os.getenv("GITHUB_TOKEN", "")
+ self.repo = repo or os.getenv("GITHUB_REPOSITORY", "")
+ self.session = requests.Session()
+ self.session.headers.update(
+ {
+ "Authorization": f"Bearer {self.token}",
+ "Accept": "application/vnd.github+json",
+ "X-GitHub-Api-Version": "2022-11-28",
+ }
+ )
+
+ # ── helpers ───────────────────────────────────────────────────────────────
+
+ def _url(self, path: str) -> str:
+ return f"{API_BASE}/repos/{self.repo}/{path}"
+
+ def _get(self, path: str, **kwargs: Any) -> Any:
+ url = self._url(path)
+ resp = self.session.get(url, **kwargs)
+ resp.raise_for_status()
+ return resp.json()
+
+ def _post(self, path: str, json: dict, **kwargs: Any) -> Any:
+ url = self._url(path)
+ resp = self.session.post(url, json=json, **kwargs)
+ resp.raise_for_status()
+ return resp.json()
+
+ # ── PR metadata ──────────────────────────────────────────────────────────
+
+ def get_pr_metadata(self, pr_number: int) -> PRMetadata:
+ """Return basic PR metadata including commit SHA."""
+ data = self._get(f"pulls/{pr_number}")
+ return PRMetadata(
+ number=data["number"],
+ title=data["title"],
+ body=data.get("body") or "",
+ author=data["user"]["login"],
+ branch=data["head"]["ref"],
+ base_branch=data["base"]["ref"],
+ commit_sha=data["head"]["sha"],
+ )
+
+ # ── PR files & diff ──────────────────────────────────────────────────────
+
+ def get_pr_files_and_diff(self, pr_number: int) -> list[PRFile]:
+ """Return the list of changed files with their patches."""
+ page, all_files = 1, []
+ while True:
+ items = self._get(f"pulls/{pr_number}/files", params={"per_page": 100, "page": page})
+ if not items:
+ break
+ for f in items:
+ all_files.append(
+ PRFile(
+ filename=f["filename"],
+ status=f["status"],
+ additions=f.get("additions", 0),
+ deletions=f.get("deletions", 0),
+ patch=f.get("patch"),
+ raw_url=f.get("raw_url"),
+ )
+ )
+ if len(items) < 100:
+ break
+ page += 1
+ return all_files
+
+ # ── Fetch file content from repo ────────────────────────────────────────
+
+ def get_file_content(self, path: str, ref: str | None = None) -> str | None:
+ """Return the decoded text content of a file at a given ref."""
+ try:
+ params: dict[str, str] = {}
+ if ref:
+ params["ref"] = ref
+ data = self._get(f"contents/{path}", params=params)
+ if data.get("encoding") == "base64":
+ import base64
+ return base64.b64decode(data["content"]).decode("utf-8", errors="replace")
+ return data.get("content", "")
+ except requests.HTTPError:
+ logger.debug("Could not fetch file %s (ref=%s)", path, ref)
+ return None
+
+ # ── Search code in repo ─────────────────────────────────────────────────
+
+ def search_code(self, query: str, max_results: int = 10) -> list[dict]:
+ """Search code in the repository. Returns list of {path, text_matches}."""
+ try:
+ url = f"{API_BASE}/search/code"
+ resp = self.session.get(
+ url,
+ params={"q": f"{query} repo:{self.repo}", "per_page": max_results},
+ headers={"Accept": "application/vnd.github.text-match+json"},
+ )
+ resp.raise_for_status()
+ items = resp.json().get("items", [])
+ return [{"path": it["path"], "text_matches": it.get("text_matches", [])} for it in items]
+ except requests.HTTPError as exc:
+ logger.warning("Code search failed: %s", exc)
+ return []
+
+ # ── List directory tree (shallow) ───────────────────────────────────────
+
+ def list_directory(self, path: str, ref: str | None = None) -> list[str]:
+ """Return file names in a directory at a given ref."""
+ try:
+ params: dict[str, str] = {}
+ if ref:
+ params["ref"] = ref
+ data = self._get(f"contents/{path}", params=params)
+ if isinstance(data, list):
+ return [item["path"] for item in data]
+ return []
+ except requests.HTTPError:
+ return []
+
+ # ── Repo tree (recursive) ───────────────────────────────────────────────
+
+ def get_repo_tree(self, ref: str = "HEAD") -> list[str]:
+ """Return all file paths in the repo at a given ref."""
+ try:
+ data = self._get(f"git/trees/{ref}", params={"recursive": "1"})
+ return [item["path"] for item in data.get("tree", []) if item["type"] == "blob"]
+ except requests.HTTPError:
+ logger.warning("Could not fetch repo tree")
+ return []
+
+ # ── Post inline review comment ──────────────────────────────────────────
+
+ def post_inline_comments(
+ self,
+ pr_number: int,
+ commit_sha: str,
+ comments: list[dict],
+ ) -> list[dict]:
+ """
+ Post individual review comments on a PR.
+ Each item in *comments* must have keys: file, line, body.
+ Returns list of API responses (or error dicts).
+ """
+ results = []
+ for c in comments:
+ try:
+ body = {
+ "body": c["body"],
+ "commit_id": commit_sha,
+ "path": c["file"],
+ "line": c["line"],
+ "side": "RIGHT",
+ }
+ resp = self._post(f"pulls/{pr_number}/comments", json=body)
+ results.append(resp)
+ except requests.HTTPError as exc:
+ logger.warning(
+ "Failed to post inline comment on %s:%s – %s",
+ c["file"],
+ c["line"],
+ exc,
+ )
+ # Retry with subject_type=file if line mapping fails
+ try:
+ body_fallback = {
+ "body": c["body"],
+ "commit_id": commit_sha,
+ "path": c["file"],
+ "subject_type": "file",
+ }
+ resp = self._post(f"pulls/{pr_number}/comments", json=body_fallback)
+ results.append(resp)
+ except requests.HTTPError as exc2:
+ logger.error("Fallback comment also failed: %s", exc2)
+ results.append({"error": str(exc2), "file": c["file"], "line": c["line"]})
+ return results
+
+ # ── Post summary comment ────────────────────────────────────────────────
+
+ def post_summary_comment(self, pr_number: int, body: str) -> dict:
+ """Post a top-level issue comment as the review summary."""
+ return self._post(f"issues/{pr_number}/comments", json={"body": body})
+
+ # ── Helpers for diff parsing ────────────────────────────────────────────
+
+ @staticmethod
+ def parse_patch_line_numbers(patch: str) -> list[int]:
+ """
+ Extract the set of *new-side* line numbers that appear in a unified-diff patch.
+ Useful for validating inline comment positions.
+ """
+ if not patch:
+ return []
+ lines_in_patch: list[int] = []
+ current_line = 0
+ for raw in patch.splitlines():
+ hunk = re.match(r"^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@", raw)
+ if hunk:
+ current_line = int(hunk.group(1))
+ continue
+ if raw.startswith("-"):
+ continue # deleted line – not in new file
+ lines_in_patch.append(current_line)
+ current_line += 1
+ return lines_in_patch
diff --git a/src/services/review_service.py b/src/services/review_service.py
new file mode 100644
index 00000000..1712dff4
--- /dev/null
+++ b/src/services/review_service.py
@@ -0,0 +1,147 @@
+"""
+Review service – orchestrates the full review pipeline:
+ fetch → context → analyse → risk → format → post → store
+"""
+
+from __future__ import annotations
+
+import logging
+from typing import Any
+
+from src.services.claude_client import ClaudeClient
+from src.services.context_builder import get_related_code_context
+from src.services.github_client import GitHubClient, PRFile
+from src.services.storage_service import StorageService
+from src.utils.formatters import extract_inline_comments, format_summary_comment
+from src.utils.risk_engine import ensure_risk_fields
+
+logger = logging.getLogger(__name__)
+
+
+# ── Helpers ──────────────────────────────────────────────────────────────────
+
+def _build_files_and_diffs_text(files: list[PRFile]) -> str:
+ """Render changed files and their patches for the Claude prompt."""
+ parts: list[str] = []
+ for f in files:
+ header = f"### {f.filename} (status: {f.status}, +{f.additions}/−{f.deletions})"
+ patch = f.patch or "(binary or no patch available)"
+ parts.append(f"{header}\n```diff\n{patch}\n```")
+ return "\n\n".join(parts)
+
+
+def _valid_line_map(files: list[PRFile]) -> dict[str, list[int]]:
+ """Build a mapping of file → valid new-side line numbers from patches."""
+ mapping: dict[str, list[int]] = {}
+ for f in files:
+ if f.patch:
+ mapping[f.filename] = GitHubClient.parse_patch_line_numbers(f.patch)
+ return mapping
+
+
+# ── Main pipeline ────────────────────────────────────────────────────────────
+
+def run_review(
+ pr_number: int,
+ gh: GitHubClient,
+ claude: ClaudeClient,
+ storage: StorageService,
+) -> dict[str, Any]:
+ """
+ Execute the full review pipeline for a given PR and return the review dict.
+
+ Steps:
+ 1. Fetch PR metadata
+ 2. Fetch changed files + diffs
+ 3. Fetch related code context
+ 4. Build AI payload
+ 5. Call Claude
+ 6. Validate / recalculate risk fields
+ 7. Post inline comments
+ 8. Post summary comment
+ 9. Save review result
+ """
+
+ # 1 ─ PR metadata
+ logger.info("Fetching PR #%s metadata …", pr_number)
+ meta = gh.get_pr_metadata(pr_number)
+ logger.info(
+ "PR #%s: '%s' by %s (%s → %s) @ %s",
+ meta.number, meta.title, meta.author, meta.branch, meta.base_branch, meta.commit_sha[:8],
+ )
+
+ # 2 ─ Changed files & diffs
+ logger.info("Fetching changed files …")
+ files = gh.get_pr_files_and_diff(pr_number)
+ meta.files = files
+ logger.info("Changed files: %d", len(files))
+
+ # 3 ─ Related code context
+ logger.info("Building related code context …")
+ related = get_related_code_context(gh, files, meta.commit_sha)
+ logger.info("Related context files gathered: %d", len(related.files))
+
+ # 4 ─ Build AI payload
+ files_text = _build_files_and_diffs_text(files)
+ context_text = related.as_text()
+ user_msg = claude.build_user_message(
+ pr_number=meta.number,
+ pr_title=meta.title,
+ pr_author=meta.author,
+ pr_body=meta.body,
+ branch=meta.branch,
+ base_branch=meta.base_branch,
+ files_and_diffs=files_text,
+ related_context=context_text,
+ )
+
+ # 5 ─ Call Claude
+ logger.info("Sending review payload to Claude (%s) …", claude.model)
+ review = claude.analyze_with_claude(user_msg)
+ logger.info("Claude review received – raw risk_score=%s", review.get("risk_score"))
+
+ # 6 ─ Validate / recalculate risk
+ review = ensure_risk_fields(review)
+ logger.info(
+ "Final risk: score=%s level=%s decision=%s",
+ review["risk_score"], review["risk_level"], review["decision"],
+ )
+
+ # 7 ─ Post inline comments
+ valid_positions = _valid_line_map(files)
+ inline_comments = extract_inline_comments(review, valid_positions)
+ if inline_comments:
+ logger.info("Posting %d inline comments …", len(inline_comments))
+ try:
+ gh.post_inline_comments(meta.number, meta.commit_sha, inline_comments)
+ except Exception:
+ logger.exception("Error posting inline comments (non-fatal)")
+ else:
+ logger.info("No inline comments to post.")
+
+ # 8 ─ Post summary comment
+ summary_md = format_summary_comment(review, gh.repo, meta.number)
+ logger.info("Posting summary comment …")
+ try:
+ gh.post_summary_comment(meta.number, summary_md)
+ except Exception:
+ logger.exception("Error posting summary comment (non-fatal)")
+
+ # 9 ─ Save review result
+ review_record = {
+ "repo": gh.repo,
+ "pr_number": meta.number,
+ "pr_title": meta.title,
+ "pr_author": meta.author,
+ "branch": meta.branch,
+ "commit_sha": meta.commit_sha,
+ **review,
+ }
+ logger.info("Saving review result …")
+ try:
+ storage.save_review_result(review_record)
+ except Exception:
+ logger.exception("Error saving review result (non-fatal)")
+
+ logger.info("✅ Review pipeline complete for PR #%s", pr_number)
+ return review_record
diff --git a/src/services/storage_service.py b/src/services/storage_service.py
new file mode 100644
index 00000000..f3c721bc
--- /dev/null
+++ b/src/services/storage_service.py
@@ -0,0 +1,281 @@
+"""
+Storage service – persists and loads review results.
+
+Backends:
+ • SQLite (default, demo-friendly, shares DB between bot and dashboard)
+ • PostgreSQL / Supabase (production, same interface)
+"""
+
+from __future__ import annotations
+
+import json
+import logging
+import os
+import sqlite3
+from datetime import datetime, timezone
+from typing import Any
+
+logger = logging.getLogger(__name__)
+
+# ── Schema DDL ───────────────────────────────────────────────────────────────
+
+_SQLITE_CREATE_TABLE = """\
+CREATE TABLE IF NOT EXISTS reviews (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ repo TEXT NOT NULL,
+ pr_number INTEGER NOT NULL,
+ pr_title TEXT,
+ pr_author TEXT,
+ branch TEXT,
+ commit_sha TEXT,
+ summary TEXT,
+ overall_assessment TEXT,
+ risk_score INTEGER,
+ risk_level TEXT,
+ decision TEXT,
+ reasoning TEXT,
+ files TEXT, -- JSON array
+ cross_file_impact TEXT, -- JSON array
+ issues TEXT, -- JSON array
+ good_improvements TEXT, -- JSON array
+ bad_regressions TEXT, -- JSON array
+ recommended_actions TEXT, -- JSON array
+ created_at TEXT NOT NULL
+);
+"""
+
+_PG_CREATE_TABLE = """\
+CREATE TABLE IF NOT EXISTS reviews (
+ id SERIAL PRIMARY KEY,
+ repo TEXT NOT NULL,
+ pr_number INTEGER NOT NULL,
+ pr_title TEXT,
+ pr_author TEXT,
+ branch TEXT,
+ commit_sha TEXT,
+ summary TEXT,
+ overall_assessment TEXT,
+ risk_score INTEGER,
+ risk_level TEXT,
+ decision TEXT,
+ reasoning TEXT,
+ files JSONB,
+ cross_file_impact JSONB,
+ issues JSONB,
+ good_improvements JSONB,
+ bad_regressions JSONB,
+ recommended_actions JSONB,
+ created_at TEXT NOT NULL
+);
+"""
+
+
+# ── Helper to serialise JSON fields ─────────────────────────────────────────
+
+_JSON_FIELDS = (
+ "files",
+ "cross_file_impact",
+ "issues",
+ "good_improvements",
+ "bad_regressions",
+ "recommended_actions",
+)
+
+
+def _encode_json_fields(row: dict[str, Any]) -> dict[str, Any]:
+ """Ensure list/dict fields are JSON-encoded strings for SQLite."""
+ out = dict(row)
+ for key in _JSON_FIELDS:
+ val = out.get(key)
+ if val is not None and not isinstance(val, str):
+ out[key] = json.dumps(val, default=str)
+ return out
+
+
+def _decode_json_fields(row: dict[str, Any]) -> dict[str, Any]:
+ """Parse JSON-encoded strings back into lists/dicts."""
+ out = dict(row)
+ for key in _JSON_FIELDS:
+ val = out.get(key)
+ if isinstance(val, str):
+ try:
+ out[key] = json.loads(val)
+ except json.JSONDecodeError:
+ pass
+ return out
+
+
+# ── Abstract interface ──────────────────────────────────────────────────────
+
+class StorageService:
+ """Unified interface for saving and loading reviews."""
+
+ def save_review_result(self, review: dict[str, Any]) -> None:
+ raise NotImplementedError
+
+ def load_review_results(self, filters: dict[str, Any] | None = None) -> list[dict[str, Any]]:
+ raise NotImplementedError
+
+
+# ── SQLite backend ──────────────────────────────────────────────────────────
+
+class SQLiteStorage(StorageService):
+ def __init__(self, db_path: str | None = None):
+ self.db_path = db_path or os.getenv("SQLITE_DB_PATH", "reviews.db")
+ self._ensure_table()
+
+ def _conn(self) -> sqlite3.Connection:
+ conn = sqlite3.connect(self.db_path)
+ conn.row_factory = sqlite3.Row
+ return conn
+
+ def _ensure_table(self) -> None:
+ with self._conn() as conn:
+ conn.execute(_SQLITE_CREATE_TABLE)
+ conn.commit()
+
+ def save_review_result(self, review: dict[str, Any]) -> None:
+ row = _encode_json_fields(review)
+ row.setdefault("created_at", datetime.now(timezone.utc).isoformat())
+ cols = [
+ "repo", "pr_number", "pr_title", "pr_author", "branch", "commit_sha",
+ "summary", "overall_assessment", "risk_score", "risk_level", "decision",
+ "reasoning", "files", "cross_file_impact", "issues", "good_improvements",
+ "bad_regressions", "recommended_actions", "created_at",
+ ]
+ placeholders = ", ".join("?" for _ in cols)
+ col_names = ", ".join(cols)
+ values = [row.get(c) for c in cols]
+ try:
+ with self._conn() as conn:
+ conn.execute(f"INSERT INTO reviews ({col_names}) VALUES ({placeholders})", values)
+ conn.commit()
+ logger.info("Review saved for %s PR #%s", row.get("repo"), row.get("pr_number"))
+ except Exception:
+ logger.exception("Failed to save review result")
+ raise
+
+ def load_review_results(self, filters: dict[str, Any] | None = None) -> list[dict[str, Any]]:
+ query = "SELECT * FROM reviews WHERE 1=1"
+ params: list[Any] = []
+ if filters:
+ if filters.get("repo"):
+ query += " AND repo = ?"
+ params.append(filters["repo"])
+ if filters.get("risk_level"):
+ query += " AND risk_level = ?"
+ params.append(filters["risk_level"])
+ if filters.get("decision"):
+ query += " AND decision = ?"
+ params.append(filters["decision"])
+ if filters.get("date_from"):
+ query += " AND created_at >= ?"
+ params.append(filters["date_from"])
+ if filters.get("date_to"):
+ query += " AND created_at <= ?"
+ params.append(filters["date_to"])
+ query += " ORDER BY created_at DESC"
+ try:
+ with self._conn() as conn:
+ rows = conn.execute(query, params).fetchall()
+ return [_decode_json_fields(dict(r)) for r in rows]
+ except Exception:
+ logger.exception("Failed to load review results")
+ return []
+
+
+# ── PostgreSQL backend ──────────────────────────────────────────────────────
+
+class PostgresStorage(StorageService):
+ def __init__(self, dsn: str | None = None):
+ self.dsn = dsn or os.getenv("DATABASE_URL", "")
+ self._ensure_table()
+
+ def _conn(self): # type: ignore[override]
+ import psycopg2
+ import psycopg2.extras
+ conn = psycopg2.connect(self.dsn)
+ return conn
+
+ def _ensure_table(self) -> None:
+ try:
+ conn = self._conn()
+ with conn.cursor() as cur:
+ cur.execute(_PG_CREATE_TABLE)
+ conn.commit()
+ conn.close()
+ except Exception:
+ logger.exception("Could not ensure PG table")
+
+ def save_review_result(self, review: dict[str, Any]) -> None:
+ import psycopg2.extras # noqa: F811
+
+ row = dict(review)
+ row.setdefault("created_at", datetime.now(timezone.utc).isoformat())
+ # For Postgres JSONB, keep dicts/lists as-is; psycopg2 Json adapter handles them
+ for key in _JSON_FIELDS:
+ val = row.get(key)
+ if val is not None and not isinstance(val, str):
+ import psycopg2.extras as _ex
+ row[key] = _ex.Json(val)
+
+ cols = [
+ "repo", "pr_number", "pr_title", "pr_author", "branch", "commit_sha",
+ "summary", "overall_assessment", "risk_score", "risk_level", "decision",
+ "reasoning", "files", "cross_file_impact", "issues", "good_improvements",
+ "bad_regressions", "recommended_actions", "created_at",
+ ]
+ placeholders = ", ".join(f"%({c})s" for c in cols)
+ col_names = ", ".join(cols)
+ try:
+ conn = self._conn()
+ with conn.cursor() as cur:
+ cur.execute(f"INSERT INTO reviews ({col_names}) VALUES ({placeholders})", row)
+ conn.commit()
+ conn.close()
+ logger.info("Review saved (PG) for %s PR #%s", row.get("repo"), row.get("pr_number"))
+ except Exception:
+ logger.exception("Failed to save review result (PG)")
+ raise
+
+ def load_review_results(self, filters: dict[str, Any] | None = None) -> list[dict[str, Any]]:
+ query = "SELECT * FROM reviews WHERE TRUE"
+ params: list[Any] = []
+ if filters:
+ if filters.get("repo"):
+ query += " AND repo = %s"
+ params.append(filters["repo"])
+ if filters.get("risk_level"):
+ query += " AND risk_level = %s"
+ params.append(filters["risk_level"])
+ if filters.get("decision"):
+ query += " AND decision = %s"
+ params.append(filters["decision"])
+ if filters.get("date_from"):
+ query += " AND created_at >= %s"
+ params.append(filters["date_from"])
+ if filters.get("date_to"):
+ query += " AND created_at <= %s"
+ params.append(filters["date_to"])
+ query += " ORDER BY created_at DESC"
+ try:
+ conn = self._conn()
+ import psycopg2.extras # noqa: F811
+ with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
+ cur.execute(query, params)
+ rows = cur.fetchall()
+ conn.close()
+ return [_decode_json_fields(dict(r)) for r in rows]
+ except Exception:
+ logger.exception("Failed to load review results (PG)")
+ return []
+
+
+# ── Factory ─────────────────────────────────────────────────────────────────
+
+def get_storage() -> StorageService:
+ """Return the configured storage backend."""
+ backend = os.getenv("STORAGE_BACKEND", "sqlite").lower()
+ if backend == "postgres":
+ return PostgresStorage()
+ return SQLiteStorage()
diff --git a/src/utils/__init__.py b/src/utils/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/src/utils/__pycache__/formatters.cpython-311.pyc b/src/utils/__pycache__/formatters.cpython-311.pyc
new file mode 100644
index 00000000..a9d0aa35
Binary files /dev/null and b/src/utils/__pycache__/formatters.cpython-311.pyc differ
diff --git a/src/utils/__pycache__/risk_engine.cpython-311.pyc b/src/utils/__pycache__/risk_engine.cpython-311.pyc
new file mode 100644
index 00000000..535a7e19
Binary files /dev/null and b/src/utils/__pycache__/risk_engine.cpython-311.pyc differ
diff --git a/src/utils/formatters.py b/src/utils/formatters.py
new file mode 100644
index 00000000..766c04ce
--- /dev/null
+++ b/src/utils/formatters.py
@@ -0,0 +1,203 @@
+"""
+Markdown / text formatters for GitHub PR comments and Streamlit display.
+"""
+
+from __future__ import annotations
+
+from typing import Any
+
+
+# ── Inline comment body ──────────────────────────────────────────────────────
+
+def format_inline_comment(issue: dict[str, Any]) -> str:
+ """Build the markdown body for a single inline review comment."""
+ severity = issue.get("severity", "Medium")
+ emoji = {"High": "🔴", "Medium": "🟡", "Low": "🟢"}.get(severity, "⚪")
+ parts = [
+ f"**{emoji} {severity} Severity**",
+ "",
+ f"**Issue:** {issue.get('issue', '')}",
+ "",
+ f"**Risk:** {issue.get('risk', '')}",
+ ]
+
+ affected = issue.get("affected_related_code", [])
+ if affected:
+ parts.append("")
+ parts.append("**Affected related code:** " + ", ".join(f"`{a}`" for a in affected))
+
+ suggestion = issue.get("suggestion", "")
+ if suggestion:
+ parts.append("")
+ parts.append(f"**Suggestion:** {suggestion}")
+
+ code = issue.get("suggested_code", "")
+ if code:
+ parts.append("")
+ parts.append("```suggestion")
+ parts.append(code)
+ parts.append("```")
+
+ return "\n".join(parts)
+
+
+# ── PR summary comment ──────────────────────────────────────────────────────
+
+def format_summary_comment(review: dict[str, Any], repo: str, pr_number: int) -> str:
+ """Build the polished markdown summary posted as a PR comment."""
+ decision_badge = {
+ "Approve": "✅ Approve",
+ "Needs Changes": "⚠️ Needs Changes",
+ "Reject": "❌ Reject",
+ }.get(review.get("decision", ""), review.get("decision", ""))
+
+ risk_emoji = {
+ "Low": "🟢",
+ "Medium": "🟡",
+ "High": "🟠",
+ "Critical": "🔴",
+ }.get(review.get("risk_level", ""), "⚪")
+
+ lines: list[str] = []
+
+ # Header
+ lines.append("# 🤖 Code Review Autopilot")
+ lines.append("")
+
+ # 1. Summary
+ lines.append("## 1. Pull Request Review Summary")
+ lines.append("")
+ lines.append(review.get("summary", ""))
+ lines.append("")
+
+ # 2. Risk Assessment
+ lines.append("## 2. Risk Assessment")
+ lines.append("")
+ lines.append(f"| Metric | Value |")
+ lines.append(f"|--------|-------|")
+ lines.append(f"| **Risk Score** | **{review.get('risk_score', 'N/A')}** / 100 |")
+ lines.append(f"| **Risk Level** | {risk_emoji} {review.get('risk_level', 'N/A')} |")
+ lines.append(f"| **Decision** | {decision_badge} |")
+ lines.append(f"| **Overall** | {review.get('overall_assessment', 'N/A')} |")
+ lines.append("")
+ reasoning = review.get("reasoning", "")
+ if reasoning:
+ lines.append(f"> {reasoning}")
+ lines.append("")
+
+ # 3. File-wise Impact
+ files = review.get("files", [])
+ if files:
+ lines.append("## 3. File-wise Impact")
+ lines.append("")
+ lines.append("| File | Summary |")
+ lines.append("|------|---------|")
+ for f in files:
+ lines.append(f"| `{f.get('file', '')}` | {f.get('summary', '')} |")
+ lines.append("")
+
+ # 4. Cross-file Impact
+ cross = review.get("cross_file_impact", [])
+ if cross:
+ lines.append("## 4. Cross-file Impact")
+ lines.append("")
+ for c in cross:
+ lines.append(f"- **{c.get('component', '')}** – {c.get('impact', '')}")
+ lines.append("")
+
+ # 5. Key Issues Found
+ issues = review.get("issues", [])
+ if issues:
+ lines.append("## 5. Key Issues Found")
+ lines.append("")
+ for i, iss in enumerate(issues, 1):
+ sev = iss.get("severity", "Medium")
+ emoji = {"High": "🔴", "Medium": "🟡", "Low": "🟢"}.get(sev, "⚪")
+ lines.append(f"### {i}. {emoji} [{sev}] `{iss.get('file', '')}` (line {iss.get('line', '?')})")
+ lines.append("")
+ lines.append(f"**Issue:** {iss.get('issue', '')}")
+ lines.append("")
+ lines.append(f"**Risk:** {iss.get('risk', '')}")
+ affected = iss.get("affected_related_code", [])
+ if affected:
+ lines.append("")
+ lines.append("**Affected:** " + ", ".join(f"`{a}`" for a in affected))
+ lines.append("")
+ lines.append(f"**Suggestion:** {iss.get('suggestion', '')}")
+ code = iss.get("suggested_code", "")
+ if code:
+ lines.append("")
+ lines.append("```suggestion")
+ lines.append(code)
+ lines.append("```")
+ lines.append("")
+
+ # 6. Good Improvements
+ goods = review.get("good_improvements", [])
+ if goods:
+ lines.append("## 6. Good Improvements")
+ lines.append("")
+ for g in goods:
+ lines.append(f"- ✅ {g}")
+ lines.append("")
+
+ # 7. Bad Regressions
+ bads = review.get("bad_regressions", [])
+ if bads:
+ lines.append("## 7. Bad Regressions")
+ lines.append("")
+ for b in bads:
+ lines.append(f"- ❌ {b}")
+ lines.append("")
+
+ # 8. Recommended Actions
+ actions = review.get("recommended_actions", [])
+ if actions:
+ lines.append("## 8. Recommended Actions Before Merge")
+ lines.append("")
+ for a in actions:
+ lines.append(f"- {a}")
+ lines.append("")
+
+ # Footer
+ lines.append("---")
+ lines.append(f"*Generated by Code Review Autopilot for `{repo}` PR #{pr_number}*")
+
+ return "\n".join(lines)
+
+
+# ── Extract inline comments from review JSON ─────────────────────────────────
+
+def extract_inline_comments(
+ review: dict[str, Any],
+ valid_positions: dict[str, list[int]] | None = None,
+) -> list[dict[str, Any]]:
+ """
+ Convert the issues array into a list of {file, line, body} dicts
+ suitable for posting as inline PR comments.
+
+ If *valid_positions* is supplied (mapping file → list of valid new-side
+ line numbers), issues on invalid lines are skipped or snapped to the
+ nearest valid line.
+ """
+ comments: list[dict[str, Any]] = []
+ for iss in review.get("issues", []):
+ file_path = iss.get("file", "")
+ line = iss.get("line")
+ if not file_path or not isinstance(line, int) or line < 1:
+ continue
+
+ # Validate line position if we have the diff data
+ if valid_positions and file_path in valid_positions:
+ valid = valid_positions[file_path]
+ if line not in valid:
+ # Snap to nearest valid line
+ if valid:
+ line = min(valid, key=lambda v: abs(v - line))
+ else:
+ continue # no valid lines for this file
+
+ body = format_inline_comment(iss)
+ comments.append({"file": file_path, "line": line, "body": body})
+
+ return comments
diff --git a/src/utils/risk_engine.py b/src/utils/risk_engine.py
new file mode 100644
index 00000000..5c5e51c3
--- /dev/null
+++ b/src/utils/risk_engine.py
@@ -0,0 +1,107 @@
+"""
+Risk engine – computes risk score, risk level, and decision
+when Claude's own score is missing or needs recalculation.
+"""
+
+from __future__ import annotations
+
+from typing import Any
+
+
+def calculate_risk_score(review: dict[str, Any]) -> int:
+ """
+ Compute a risk score (0–100, higher = safer) based on issue counts and flags.
+
+ Rules
+ -----
+ - Start at 100
+ - −20 per High severity issue
+ - −10 per Medium severity issue
+ - −5 per Low severity issue
+ - −10 extra if core business-logic change impacts related modules
+ - −10 extra if determinism / backward-compatibility is flagged
+ - Clamped to [0, 100]
+ """
+ score = 100
+
+ issues: list[dict] = review.get("issues", [])
+ for iss in issues:
+ sev = (iss.get("severity") or "").lower()
+ if sev == "high":
+ score -= 20
+ elif sev == "medium":
+ score -= 10
+ elif sev == "low":
+ score -= 5
+
+ # Extra penalties based on cross-file impact & regressions
+ cross_impact = review.get("cross_file_impact", [])
+ if cross_impact:
+ for ci in cross_impact:
+ impact_text = (ci.get("impact") or "").lower()
+ if any(kw in impact_text for kw in ("business logic", "core", "critical")):
+ score -= 10
+ break
+
+ regressions = review.get("bad_regressions", [])
+ for reg in regressions:
+ reg_lower = reg.lower()
+ if any(kw in reg_lower for kw in ("determinism", "backward", "compatibility", "breaking")):
+ score -= 10
+ break
+
+ return max(0, min(100, score))
+
+
+def risk_level(score: int) -> str:
+ """Map a numeric risk score to a label."""
+ if score >= 80:
+ return "Low"
+ if score >= 50:
+ return "Medium"
+ if score >= 25:
+ return "High"
+ return "Critical"
+
+
+def generate_decision(score: int) -> str:
+ """Map a numeric risk score to a review decision."""
+ if score >= 80:
+ return "Approve"
+ if score >= 50:
+ return "Needs Changes"
+ return "Reject"
+
+
+def ensure_risk_fields(review: dict[str, Any]) -> dict[str, Any]:
+ """
+ Fill in risk_score / risk_level / decision if Claude left them out
+ or returned invalid values.
+ """
+ # Validate / recompute score
+ raw_score = review.get("risk_score")
+ if not isinstance(raw_score, (int, float)) or not (0 <= raw_score <= 100):
+ raw_score = calculate_risk_score(review)
+ score = int(raw_score)
+
+ review["risk_score"] = score
+ review["risk_level"] = risk_level(score)
+
+ # Validate / recompute decision
+ valid_decisions = {"Approve", "Needs Changes", "Reject"}
+ if review.get("decision") not in valid_decisions:
+ review["decision"] = generate_decision(score)
+
+ # Validate overall_assessment
+ valid_assessments = {"Good Improvement", "Mixed Change", "Risky Change", "Bad Change"}
+ if review.get("overall_assessment") not in valid_assessments:
+ if score >= 80:
+ review["overall_assessment"] = "Good Improvement"
+ elif score >= 50:
+ review["overall_assessment"] = "Mixed Change"
+ elif score >= 25:
+ review["overall_assessment"] = "Risky Change"
+ else:
+ review["overall_assessment"] = "Bad Change"
+
+ return review