diff --git a/scripts/__pycache__/ai_code_review.cpython-312.pyc b/scripts/__pycache__/ai_code_review.cpython-312.pyc new file mode 100644 index 0000000..2ee98f9 Binary files /dev/null and b/scripts/__pycache__/ai_code_review.cpython-312.pyc differ diff --git a/scripts/ai_code_review.py b/scripts/ai_code_review.py new file mode 100644 index 0000000..9ac07f4 --- /dev/null +++ b/scripts/ai_code_review.py @@ -0,0 +1,146 @@ +#!/usr/bin/env python3 + +import os +import subprocess +import sys +from typing import Optional + + +MAX_DIFF_CHARS = 12000 + + +def run_git_command(*args: str) -> str: + result = subprocess.run( + ["git", *args], + check=True, + capture_output=True, + text=True, + ) + return result.stdout.strip() + + +def try_git_command(*args: str) -> Optional[str]: + try: + return run_git_command(*args) + except subprocess.CalledProcessError: + return None + + +def resolve_base_commit() -> Optional[str]: + for env_name in ("GIT_PREVIOUS_SUCCESSFUL_COMMIT", "GIT_PREVIOUS_COMMIT"): + value = os.getenv(env_name) + if value: + return value + + change_target = os.getenv("CHANGE_TARGET") + if change_target: + base = try_git_command("merge-base", "HEAD", f"origin/{change_target}") + if base: + return base + + return try_git_command("rev-parse", "HEAD~1") + + +def collect_diff() -> str: + base_commit = resolve_base_commit() + if base_commit: + diff = try_git_command("diff", f"{base_commit}..HEAD", "--") + if diff: + return diff + + staged_diff = try_git_command("diff", "--cached", "--") + if staged_diff: + return staged_diff + + working_tree_diff = try_git_command("diff", "--") + if working_tree_diff: + return working_tree_diff + + return "" + + +def build_prompt(diff_text: str) -> str: + truncated_diff = diff_text[:MAX_DIFF_CHARS] + suffix = "" + if len(diff_text) > MAX_DIFF_CHARS: + suffix = "\n\nDiff was truncated to fit the token budget." + + return ( + "Review the following git diff. Focus on correctness, regressions, missing validation, " + "build/test issues, and security concerns. " + "Return either 'No issues found.' or a short flat list where each item includes severity, file, and issue.\n\n" + f"{truncated_diff}{suffix}" + ) + + +def request_review(diff_text: str) -> str: + try: + from openai import OpenAI + except ImportError as exc: + raise RuntimeError( + "The 'openai' package is not installed. Install it with 'pip install openai'." + ) from exc + + api_key = os.getenv("OPENAI_API_KEY") + if not api_key: + raise RuntimeError("OPENAI_API_KEY is not set.") + + model = os.getenv("CODEX_MODEL") or os.getenv("OPENAI_MODEL") or "gpt-4.1" + client = OpenAI(api_key=api_key) + + response = client.responses.create( + model=model, + input=[ + { + "role": "system", + "content": [ + { + "type": "input_text", + "text": ( + "You are a strict CI code reviewer. Be concise, concrete, and prioritize real defects." + ), + } + ], + }, + { + "role": "user", + "content": [ + { + "type": "input_text", + "text": build_prompt(diff_text), + } + ], + }, + ], + ) + + return response.output_text.strip() + + +def main() -> int: + diff_text = collect_diff() + if not diff_text: + print("No git changes detected. Skipping AI review.") + return 0 + + try: + review = request_review(diff_text) + except Exception as exc: + print(f"AI review failed: {exc}", file=sys.stderr) + return 1 + + print("AI review result:\n") + print(review or "No issues found.") + + fail_on_findings = os.getenv("AI_REVIEW_FAIL_ON_FINDINGS", "false").lower() == "true" + normalized = (review or "").strip().lower() + no_findings = normalized == "no issues found." + + if fail_on_findings and not no_findings: + return 1 + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) \ No newline at end of file