From bc0ebd356202e9ffbc855053436495f9a96f7ded Mon Sep 17 00:00:00 2001 From: Akkala Venkata Sai Krishna Date: Thu, 23 Apr 2026 11:48:03 +0530 Subject: [PATCH] Add Code Review Autopilot --- .github/workflows/pr-review.yml | 43 +++ app.py | 268 ++++++++++++++ requirements.txt | Bin 7668 -> 79 bytes src/__init__.py | 0 src/__pycache__/__init__.cpython-311.pyc | Bin 0 -> 178 bytes src/__pycache__/review_bot.cpython-311.pyc | Bin 0 -> 3870 bytes src/review_bot.py | 84 +++++ src/services/__init__.py | 0 .../__pycache__/__init__.cpython-311.pyc | Bin 0 -> 187 bytes .../__pycache__/claude_client.cpython-311.pyc | Bin 0 -> 8166 bytes .../context_builder.cpython-311.pyc | Bin 0 -> 15664 bytes .../__pycache__/github_client.cpython-311.pyc | Bin 0 -> 13334 bytes .../review_service.cpython-311.pyc | Bin 0 -> 7270 bytes .../storage_service.cpython-311.pyc | Bin 0 -> 16875 bytes src/services/claude_client.py | 216 +++++++++++ src/services/context_builder.py | 341 ++++++++++++++++++ src/services/github_client.py | 253 +++++++++++++ src/services/review_service.py | 147 ++++++++ src/services/storage_service.py | 281 +++++++++++++++ src/utils/__init__.py | 0 .../__pycache__/formatters.cpython-311.pyc | Bin 0 -> 11611 bytes .../__pycache__/risk_engine.cpython-311.pyc | Bin 0 -> 4664 bytes src/utils/formatters.py | 203 +++++++++++ src/utils/risk_engine.py | 107 ++++++ 24 files changed, 1943 insertions(+) create mode 100644 .github/workflows/pr-review.yml create mode 100644 app.py create mode 100644 src/__init__.py create mode 100644 src/__pycache__/__init__.cpython-311.pyc create mode 100644 src/__pycache__/review_bot.cpython-311.pyc create mode 100644 src/review_bot.py create mode 100644 src/services/__init__.py create mode 100644 src/services/__pycache__/__init__.cpython-311.pyc create mode 100644 src/services/__pycache__/claude_client.cpython-311.pyc create mode 100644 src/services/__pycache__/context_builder.cpython-311.pyc create mode 100644 src/services/__pycache__/github_client.cpython-311.pyc create mode 100644 src/services/__pycache__/review_service.cpython-311.pyc create mode 100644 src/services/__pycache__/storage_service.cpython-311.pyc create mode 100644 src/services/claude_client.py create mode 100644 src/services/context_builder.py create mode 100644 src/services/github_client.py create mode 100644 src/services/review_service.py create mode 100644 src/services/storage_service.py create mode 100644 src/utils/__init__.py create mode 100644 src/utils/__pycache__/formatters.cpython-311.pyc create mode 100644 src/utils/__pycache__/risk_engine.cpython-311.pyc create mode 100644 src/utils/formatters.py create mode 100644 src/utils/risk_engine.py 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 10e5fbf9b73771631f3b114b5d130963cad243aa..c68302091bdbc96c8e389954d8839da39f8d4b36 100644 GIT binary patch literal 79 zcmWm4I}U@ubyU_5N7~n3bjglx}TTQsVvc5bP!hD@YQncV57)5wL$igW)@b-s3 c*U7U@aPNqU#&X_>C{$x82T_1QFy)A#AS z)YrQ^z5i8@_qwj9XD9t4S=_sp#Ce*fztV%QU)tTgKS{so`jhlSI@R^(`tPz<(lOC> z<8)~f`S!x3mPx#5NPLpMVX9;cmXBr6yq%43kiv^z3sU>!Mr4PIXGnAp&drBot-LmbSF(1k$j&S~Z}k}{J(~}p0TMjhtlk~p zW@eImoeeffAEp~a`h&i~lZ!$`BCbQT6={_+WuoVkWhNQgmq*y&k$miLtEa>42j!mZ zKQWo3t*YED<&#xsRLC|4m2tqm)$2@k-OSByotuGIrJNeu^2Hw2 z*7G_VQ+>Wx#&OlHYIr4dSNdeEcPDlS-1EC|p9%SSdepaG!$aLM(L30yN&|gI^>Huy z7UDR3@Twp--PY*r^CQ<9l|8fgkEOviFQf^p15dm*Jv9;GkBf4Y%N4Rz$nP`b*b2d^M~AqiMa z#L*GRfk;=#94?tVuzOdvyv7mUS?g-DD0*2Z{ixTT8S$#O`RugLve$M`EE7Dln*0tk zc<#+Netk654)+RMGG3pB(@a(C^2jd7sf}Y^``cv3$l_7T5-UaFWj}Hp@aV#Fc=y%R z8qDXN8Jn0Uyvyyoi`cKMS5u{dAon^8y}q|u-N6s}zHULb;9ENiuuNZ(o2s@Q!-;i2 z&(R#~;PKX67-u-ilwM`pLL*Hf)b4(PWZkf|=@-PqLxO{a}85>AQCuCgotJYLgdnug^kl z_xh~*Y`oyuO{8~P^BaBLr@%7o@UJwkL}@#=UU&3Z@55vDQ2LfnM_%_nJ(OA4##Fm? zRm!D&9{DUAoIj|4x1mgXGyZeQCep+TKJsU|ytm-J6uy;ud|$R)ZF0M3u`iuG1KCSS zyJ>h99DQsDxKvg$K{c^*<|)G}>KaytC76Sl^j7H)%Pme2e;Nkl)tR`L8Yt5d@x4;V zm??9H(y-Jkk1-WRluMC#U)P6o&{Um^$#<}cSBaJA|F36|m`lz!cHbK_fe5E3&;a9O zCXGOt6U|JShAc8HCi6pg0)Z*B6&dC;>C4{fUA`;>Lhg+sf2$bXn}xv_?~lxtTaIJrg-jH^}Q7N_K^*X&tTLb8SZ@V9+uR~ zw_s z>yUOQR^SvDCjRGqNnWA=hIxCU&zgp>LwSOI+o@SoWp!HFm@02=vwdg{#cr& zS;w6;WQGSD?@{!rvU-ZS#V94M}39dip?tx7!dMLYsiQa_v|eA{sBJezHPmbG*dpK0?3xWsOD@pSHc zbuT6pHbr?u020^H z`E(F|KZ=1K%}VMOzo{hr?g|Gm=Y+~Rnrs36m_5(yE6eI5@&c)BF8PgdE{r@6GeYi{ s>-S#}d>-`}mvTB)6dvLDl?z7yc?sD?a{(cPyX9sAmVuZ3*aZCg#Z8m 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 0000000000000000000000000000000000000000..b6be437a6f2576e8f9338998954133c6d055aa83 GIT binary patch literal 178 zcmZ3^%ge<81pn5&%mmSoK?DpiLK&agfQ;!3DGb33nv8xc8H$*I{LdiCU#`wpF`>n& zMa40W+1ZIXiD9XE*@-2I!HJpPMVZAhE~&-YCHVz0$)!cb`9-=JiOJa|8TolJ#YM?6 w@$s2?nI-Y@dIgogIBatBQ%ZAE?TT1|rh@D(<_8iVm>C%vKQO?EB4(f%07(lj2LJ#7 literal 0 HcmV?d00001 diff --git a/src/__pycache__/review_bot.cpython-311.pyc b/src/__pycache__/review_bot.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..e3726fd7f8b425fef7b0c17cdc141f681783008b GIT binary patch literal 3870 zcmb^!T}&HCde*zP_s50+h6K{)RwS-)1k@!Y*Q8PIfRaFQ06|hIe(1`02eM(k>ziGZ zU>V6hMLHTqYNV@lJo3cllZEFx%we}17!A%y--k!B0j0p?{JgU}z4ijqiWRG-ddd`TZe zVOD38to!AXocr}B{qW`VKqi@31fiZ@o2SbT)CdZ3lMr z4BmQ6cBx%z$3rd|!QE=-sw<0xV5P_r-PuK?!W)a^{sFuq!D|}6uPmANaCTk(SMm4q zL}FUThE3Mxtf?8ctQl+ODpuvCb@_&7Pvw^6v9zt3hBYjTx2@C)mIpMWITpJj%Fwdw z_FdDEhcdE7(!;GaL~#NCCZ`b)hmAFjm_`PclGjp1OD*Zxs&n6%zBP4wT)8zjGdVjb zH?W1t`MJgETXPF{2IaBYTT=^j^V1W`*!;9IGYLcU3(D;6o8yxU;tOh$7~@DO!!YfX ztLiMpbu*Wj*ytUp(G|h zo=iC0^z8My{DtU1$^xTgaLkhB=zxybux_LP9eUp#aA6n9V2j!;X65-b#`C$MaiOv-kGYRbmT8QVE7ubuAh6o=ivjc!o2(oN8Q}scMF8> z?ZYbLb(sxRK-RDg9L=`9?{3Wz`c-{iJ_gxBYyO_U39Ia3Isw_?<}8BMyz?83@vF1Q zZfjATLu3S`|96czkmU)P8QgvJfW3q6GjFhyUk!MrZ}L7kl zs?%|#Y#$V9VN zU2q_3#T>pl3k3!y6f12K+<@Iml<#TC%xYPzYX+8=Q<{#|*a^pHT226}AyiQ(pqndT zW5z2de2b%4$V17sZIHl+CB%LGlDn@9rAhcRJoMym2f)r#ym?KO+NaHN6 z)sBFhU`%krS)v%Z%n~LFN!@pZebfo9X!hOQlHyvC(4cg}DZ{=?z)WdsNXNHw*9oNE zD(?iFwHvCamD6pD)-ZZgaof)wa7}r)UhErJA&1MPG=p3KvUBiSaVR)N^h2cQi|MV^ za^!p^a=v)8DxIn!pFgt8w|yRZ6e@L(mHF`sKVIU;cX{bC|D@wH>9Mpa)qETfMNV#o z%aL~~k#{zQO<`|W>UNo1-M*NsUuVjEqQWOie8MAWlM3_0ihFx|uXqI7T;BTR=|Z_H zUg?S#6IJP?SD$ywe7wTPOMKiT<_9VK_;RUx;AwwZ8mdS`#Sg1eyO(pc%wMeV7fbv_ zFDErX>CAgyjg%voDv?XCvB4WSUj>lZvz2&imxR%>Fj^5ti{rcD-tD1MI9?9NE8%$Y z`fgkAXPL*D;te47OTU}>?M&&!wQsm@v@$ne;pR)+e9b3dK@m%jRCx#NRM z#|N7MYC7qWR1ICLhB`n0=+Q?{^5sx}CDh+U3+2$cO6c4n^hd!jh(96dYTj<|RS1P% zb3Xs%PkqmyD)qis=0__0NQocun1`NE>x8T*c+R(h_OTxHZO`TLPWHPF2Kv9o#|EbQ z(BJyrpYCD*-pxSo@QOnHO;KV#LX$0_e-~msPEdgxYg$npQBjt2aR0-KLg+os3DVeu zEJ4mt@_srLs9$agtwc^Ay?I-htZ8X%4cEg!NxQdQHx3UGG@_|NK%><;?UB%kj)uf~ z=)UElXoQ&3Byj^>T1r`3dcri8wH5LUDpr7aPq=x5$bh>cM)yze$cpkO^b!W)CJ5cn z{MC%9=5&0Gi~~xOy%mMN#xe|3^YIK+D3|3HARqWdAe;j)kqFN-CvDGTptXRF)-JE0HC;s2><{9 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..1cf4e945ac1ee74091399804eb079ec0bfe9cf6f GIT binary patch literal 187 zcmZ3^%ge<81pn5&%mmSoK?DpiLK&agfQ;!3DGb33nv8xc8H$*I{LdiCUw+P3F`>n& zMa40W+1ZIXiD9XE*@-2I!HJpPMVZAhE~&-YCHVz0$)!cb`9-=JiOJa|8TolJ#YM?6 zKwV{-$*ILL@$s2?nI-Y@dIgogIBatBQ%ZAE?TT1|#)BMC%nu|!Ff%eTeqewRMa)1k E09)oU&;S4c literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..e089cd22c4f379ad1b056dda13f69c6ce693d384 GIT binary patch literal 8166 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", + "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 0000000000000000000000000000000000000000..a9d0aa3500b717bef5ade9b418a43fde2780e989 GIT binary patch literal 11611 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..535a7e198ffa039421a2f9663251d18967bb4ed7 GIT binary patch literal 4664 zcmbUlYfl?j_Kx3ggLwrA=@oPXPKbe|d6R4sLN=6yc0r4@q>(lD3^2=hOz(_&)u>jr z+DuTSI@v}<{Zgb=vx%aW@^OE{ezYS?wMIgUx?jpSOVLUZpZ454wy{Ilc6&9PIrsS7 zbMHC#yoSH~{T>A4buif4IVRPcz*rv$NA#mQyGT!AGOCnQyslsTLv zSP?}51d4H9j;CZ^gOw4_4%H{j8D3sdk{TnbFtuJ)Rt*Ocmn3m{Hy}uH?T)IEehlQh zl&+s@_bxYcE z9o?N+x2CNJtg$|Y9bZ&;UE~DYR&`b@{wYjeR0-?0v`w%tu$_n_%?XaRHrS7Qa2Dv! z8oH}Y9|5|%Ot%BSr%eCkKyAGHw6Uk{&PL6doXdJKEZwZIMYth<=na}?fNA)-|*C(&;uuIfD<%8 zQNJn=A*8u0I0ElVbM+onKZ5{kN<9Ym@L(7tW>8_iCaQ4XAjI3y;9x(Vm*(b0f-hl} zpA|_M$9Po~a8iLVQd1&?WJ=Lg7I?EVKc_}KOz31v7FC9wpu@!Ql*SVc^BM-pWzvuO zqmzSp5q7u<8(|gVm`K{HDSeM;$mF>#3Q{WZel856PBTfjlFRRB0S*u(u4shE(kz^v zh&YylOdzW22{}0@#c_O|SLQ@4B^LQO=tV?G37~HxDeP%PcdC^mh?+h**n+joSpgB<}YXzApu?V8IB)$W729w-nzw8KblZJztS2bJ}ix|kT z(Yx4bW^dw=AJ)w8?Skfm=XR=s7p>9EGihB{FDO7M2aKDKM&m9aSm`U#$KRFsr=)D(VMfxj<~|q)xJb z)V~DuorfF&-muN^*7%{}s<@%yuk%Crfr$J96hA_ZYoO6`a!WnZ8zbGfP}DyfvmZtaQ=|OGpm@l`e*4V~#6Vm|2YG z_c;Xl2v{k*x(O-j?b{409Y+3!%;@X(55DX8wqxt&vsk`;sL(!?xls(Zl#tWj#(-`u z218#jZ!Blm^1<#xusav*+lu`V7Ww=$q#D|U8$cjntOTYdS?Exovao(b&WBZ^m`^wl`4cGGQL3`9Q1y&>JgwW3ZJwRMj33eH6BGhyLS2 zzM%g37R_8W0w^!$mDI_?dKDpLDyv;9S9xJH?6ohzfZGwc!W0{YMa%AUIb^=KFJ0~gT zFbl*EjYz{rD~jP7DMvqx*5@2Mv`Tv;Y&xNb&?1~cfj)0ee;^+o23 zVz60H758Rs`by2LMPDj%I zNRm)J&%)Ec0XCTId>E?Q}w$#q~Ep=><>>g;ex zU#-#ni2h~K+oYe{uQ|PND|LcbU}~!Q77+Fifva!C);SGURYscAY!w_K&9R5iTIyRG z5)>?HE^b+|EYPb(!~ssmFtt(PI8@snrP01UnR}Yjp>=|FFW6d2q0KjS;bFivnwH8V zZK>x29vkJEM}qxt9JJ~OY3z7sopp&LAq~E$D&swp9c9T*kZe0pvg;kmHjo@nTcBZQ z`hiV6a7q`IrE~$^Tc!J|^hTijtMovXZWn?TJPl1jxM59Z-O3F~ma(L;_>8hT0WHv8 zM8TxuRJ7wVzs9gE&T3ej7ZW&{(wG?d5HmlYpdpf!C0>>U3@{}^LrH6fVMeK0h-iW# zwDV!{dYL*58KK}R1=j!=uF9^LsRQkLuJHmkgJIWqTUvo#DziywS5_;thTkQV0v=1S zNJ@x`W`t-+@vi>5*0N?%Kg=> zB+7zHCV}M^{HcTB)KE=*sIhI+^T_jLIN#7$Xz0s~ZF^g?()T^zr?w8~y^(@9QbL@4 z=;i1C9Lt@(1n04QucrXD~x)Wz^=)-y2q3zD! zY>D~K{z7MenF1xW?NY9F7@o{0MW_GX+E;7YV|iy+!P&JH%{f1UXS*x%ES2v%UFbTk zJM^`@s{>ZG-{EqHX6R#UDX+_gj^>@c1!r$=;7ZO(pQ5)Rdu`*QezAD`l71uC)SLJ9 z<$Qfbe<&NaR6Yp`!D1%qi6sC literal 0 HcmV?d00001 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