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