diff --git a/.github/scripts/ai_pr_review.py b/.github/scripts/ai_pr_review.py deleted file mode 100644 index a2ebdb7..0000000 --- a/.github/scripts/ai_pr_review.py +++ /dev/null @@ -1,381 +0,0 @@ -""" -Oxtools AI PR Review Agent -Posts inline code review comments on pull requests, similar to Cursor Bugbot. -""" - -import os -import sys -import json -import re -import urllib.request -import urllib.error - - -def get_env(key: str) -> str: - val = os.environ.get(key, "") - if not val: - print(f"::warning::Missing environment variable: {key}") - return val - - -def github_api(path: str, method: str = "GET", data: dict | None = None) -> dict: - token = get_env("GITHUB_TOKEN") - url = f"https://api.github.com{path}" - headers = { - "Authorization": f"Bearer {token}", - "Accept": "application/vnd.github+json", - "X-GitHub-Api-Version": "2022-11-28", - } - body = json.dumps(data).encode() if data else None - req = urllib.request.Request(url, data=body, headers=headers, method=method) - try: - with urllib.request.urlopen(req) as resp: - return json.loads(resp.read().decode()) - except urllib.error.HTTPError as e: - print(f"::error::GitHub API error {e.code}: {e.read().decode()}") - sys.exit(1) - - -def get_pr_diff(repo: str, pr_number: int) -> str: - token = get_env("GITHUB_TOKEN") - url = f"https://api.github.com/repos/{repo}/pulls/{pr_number}" - headers = { - "Authorization": f"Bearer {token}", - "Accept": "application/vnd.github.diff", - "X-GitHub-Api-Version": "2022-11-28", - } - req = urllib.request.Request(url, headers=headers) - try: - with urllib.request.urlopen(req) as resp: - return resp.read().decode() - except urllib.error.HTTPError as e: - print(f"::error::Failed to fetch diff: {e.code}") - return "" - - -def get_pr_files(repo: str, pr_number: int) -> list: - """Get the list of changed files with their patch data.""" - return github_api(f"/repos/{repo}/pulls/{pr_number}/files") - - -def sanitize_diff(diff: str) -> str: - """Remove potential secrets from diff before sending to LLM.""" - patterns = [ - r'(?i)(api[_-]?key|secret|password|token|credential)\s*[=:]\s*["\'`]?[A-Za-z0-9+/=_\-]{8,}["\'`]?', - r'(?i)(AKIA|ABIA|ACCA|ASIA)[A-Z0-9]{16}', - r'sk-[A-Za-z0-9]{20,}', - r'ghp_[A-Za-z0-9]{36}', - ] - sanitized = diff - for pattern in patterns: - sanitized = re.sub(pattern, "[REDACTED_SECRET]", sanitized) - return sanitized - - -def truncate_diff(diff: str, max_chars: int = 80000) -> str: - """Truncate diff to fit within token limits.""" - if len(diff) <= max_chars: - return diff - return diff[:max_chars] + "\n\n... [DIFF TRUNCATED - too large for review] ..." - - -def call_kimi(prompt: str, system_prompt: str) -> str: - """Call Kimi K2.6 via Azure Foundry OpenAI-compatible API.""" - endpoint = get_env("AZURE_AI_ENDPOINT") - api_key = get_env("AZURE_AI_KEY") - - if not endpoint or not api_key: - return "" - - base_url = endpoint.rstrip("/") - if not base_url.endswith("/openai/v1"): - base_url = base_url + "/openai/v1" - - url = f"{base_url}/chat/completions" - - payload = { - "model": "Kimi-K2.6", - "messages": [ - {"role": "system", "content": system_prompt}, - {"role": "user", "content": prompt}, - ], - "temperature": 0.2, - "max_tokens": 16384, - } - - headers = { - "Content-Type": "application/json", - "Authorization": f"Bearer {api_key}", - } - - req = urllib.request.Request( - url, - data=json.dumps(payload).encode(), - headers=headers, - method="POST", - ) - - try: - with urllib.request.urlopen(req, timeout=180) as resp: - result = json.loads(resp.read().decode()) - return result["choices"][0]["message"]["content"] - except urllib.error.HTTPError as e: - error_body = e.read().decode() - print(f"::error::API error {e.code}: {error_body}") - return "" - except Exception as e: - print(f"::error::API exception: {e}") - return "" - - -def parse_diff_line_map(diff: str) -> dict: - """Parse unified diff to map file paths to their diff line positions. - Returns {filename: {new_line_number: diff_position}} - """ - file_map = {} - current_file = None - position = 0 - new_line = 0 - - for line in diff.split("\n"): - if line.startswith("diff --git"): - # Extract filename: diff --git a/path b/path - match = re.search(r"b/(.+)$", line) - if match: - current_file = match.group(1) - file_map[current_file] = {} - position = 0 - new_line = 0 - elif line.startswith("@@"): - # Parse hunk header: @@ -old,count +new,count @@ - match = re.search(r"\+(\d+)", line) - if match: - new_line = int(match.group(1)) - 1 - position += 1 - elif current_file and position > 0: - position += 1 - if line.startswith("+"): - new_line += 1 - file_map[current_file][new_line] = position - elif line.startswith("-"): - pass # Deleted lines don't get new line numbers - else: - new_line += 1 - file_map[current_file][new_line] = position - - return file_map - - -def post_inline_review(repo: str, pr_number: int, commit_sha: str, - summary: str, inline_comments: list) -> None: - """Post a proper GitHub PR review with inline comments.""" - # Build review comments array - comments = [] - for c in inline_comments: - comment = { - "path": c["path"], - "body": f"**{c.get('severity', 'Note')}**: {c['body']}", - } - if c.get("line"): - comment["line"] = c["line"] - comment["side"] = "RIGHT" - elif c.get("position"): - comment["position"] = c["position"] - else: - continue - comments.append(comment) - - review_body = f"## OxBot Review\n\n{summary}" - if not comments: - review_body += "\n\n_No inline issues found._" - - review_data = { - "commit_id": commit_sha, - "body": review_body, - "event": "COMMENT", - "comments": comments, - } - - github_api( - f"/repos/{repo}/pulls/{pr_number}/reviews", - method="POST", - data=review_data, - ) - - -SYSTEM_PROMPT = """You are OxBot, the code reviewer for the Oxtools open-source project. -You review pull requests and provide inline feedback on specific lines of code. - -## Repository Structure -- `app/` - Next.js frontend (TypeScript, React, TailwindCSS) -- `services/python-tools/` - Python tool runner -- `projects/` - Community-contributed tool projects -- `.github/` - CI/CD workflows - -## Your Review Focus -1. Security: No leaked secrets, no dangerous patterns (eval, exec, SQL injection) -2. Bugs: Logic errors, edge cases, null/undefined risks -3. Performance: Unnecessary re-renders, N+1 patterns, memory leaks -4. Best Practices: Error handling, type safety, code duplication - -## CRITICAL: Output Format -You MUST respond with valid JSON only. No markdown, no explanation outside JSON. - -{ - "summary": "2-3 sentence overall summary of the PR", - "verdict": "LGTM | NEEDS_CHANGES | CRITICAL", - "inline_comments": [ - { - "path": "relative/path/to/file.ts", - "line": 42, - "severity": "Bug | Security | Performance | Suggestion", - "body": "Clear, concise explanation of the issue and how to fix it." - } - ] -} - -Rules: -- Only comment on genuinely important issues. Do NOT nitpick formatting or style. -- Each inline comment must reference a real file path and line number from the diff. -- The line number must be a line that was ADDED (prefixed with + in the diff). -- Keep comments actionable and specific. Explain WHY and suggest a fix. -- Maximum 10 inline comments. Focus on the most impactful issues. -- If the PR looks good, return an empty inline_comments array with verdict "LGTM". -- Do NOT comment on files under .github/ unless there are security issues.""" - - -def build_review_prompt(pr_info: dict, diff: str) -> str: - title = pr_info.get("title", "") - body = pr_info.get("body", "") or "" - author = pr_info.get("user", {}).get("login", "unknown") - base = pr_info.get("base", {}).get("ref", "") - head = pr_info.get("head", {}).get("ref", "") - changed_files = pr_info.get("changed_files", 0) - additions = pr_info.get("additions", 0) - deletions = pr_info.get("deletions", 0) - - return f"""Review this pull request and respond with JSON only. - -Title: {title} -Author: @{author} -Branch: {head} -> {base} -Stats: {changed_files} files changed, +{additions} -{deletions} - -PR Description: -{body[:2000] if body else "No description provided."} - -Diff: -```diff -{diff} -```""" - - -def main(): - event_path = get_env("GITHUB_EVENT_PATH") - repo = get_env("GITHUB_REPOSITORY") - - if not event_path or not repo: - print("::error::Missing required GitHub context") - sys.exit(1) - - with open(event_path) as f: - event = json.load(f) - - pr_data = event.get("pull_request", {}) - pr_number = pr_data.get("number") - if not pr_number: - print("::warning::No PR number found in event") - sys.exit(0) - - commit_sha = pr_data.get("head", {}).get("sha", "") - print(f"OxBot: reviewing PR #{pr_number} (commit {commit_sha[:7]})...") - - # Get PR details and diff - pr_info = github_api(f"/repos/{repo}/pulls/{pr_number}") - raw_diff = get_pr_diff(repo, pr_number) - if not raw_diff: - print("::warning::Empty diff, skipping review") - sys.exit(0) - - # Build line position map for inline comments - line_map = parse_diff_line_map(raw_diff) - - # Sanitize and truncate - safe_diff = sanitize_diff(raw_diff) - final_diff = truncate_diff(safe_diff) - - # Call LLM - prompt = build_review_prompt(pr_info, final_diff) - response = call_kimi(prompt, SYSTEM_PROMPT) - - if not response: - # Fallback: post simple comment if API fails - github_api( - f"/repos/{repo}/issues/{pr_number}/comments", - method="POST", - data={"body": "## OxBot Review\n\nReview unavailable: API credentials not configured.\n\n---\nAutomated code review by OxBot"}, - ) - print("OxBot: posted fallback comment (no API credentials)") - return - - # Parse JSON response - try: - # Strip markdown code fences if present - cleaned = response.strip() - if cleaned.startswith("```"): - cleaned = re.sub(r"^```(?:json)?\s*\n?", "", cleaned) - cleaned = re.sub(r"\n?```\s*$", "", cleaned) - review = json.loads(cleaned) - except json.JSONDecodeError: - # If LLM didn't return valid JSON, post as general comment - comment = f"## OxBot Review\n\n{response}\n\n---\nAutomated code review by OxBot" - github_api( - f"/repos/{repo}/issues/{pr_number}/comments", - method="POST", - data={"body": comment}, - ) - print("OxBot: posted general comment (non-JSON response)") - return - - # Process inline comments - resolve line numbers to diff positions - inline_comments = [] - for c in review.get("inline_comments", []): - path = c.get("path", "") - line = c.get("line", 0) - - # Try to find the diff position for this line - if path in line_map and line in line_map[path]: - c["position"] = line_map[path][line] - inline_comments.append(c) - elif path in line_map: - # Line not in diff, try closest line - available_lines = sorted(line_map[path].keys()) - closest = min(available_lines, key=lambda x: abs(x - line), default=None) - if closest and abs(closest - line) <= 5: - c["line"] = closest - c["position"] = line_map[path][closest] - inline_comments.append(c) - else: - # Can't map to diff, skip this comment - print(f"OxBot: skipping comment for {path}:{line} (not in diff)") - else: - print(f"OxBot: skipping comment for {path} (file not in diff)") - - # Build summary - summary = review.get("summary", "No summary provided.") - verdict = review.get("verdict", "COMMENT") - verdict_display = { - "LGTM": "Verdict: LGTM", - "NEEDS_CHANGES": "Verdict: Needs Changes", - "CRITICAL": "Verdict: Critical Issues Found", - }.get(verdict, f"Verdict: {verdict}") - - full_summary = f"{summary}\n\n**{verdict_display}** | {len(inline_comments)} inline comment(s)\n\n---\nAutomated code review by OxBot" - - # Post the review with inline comments - post_inline_review(repo, pr_number, commit_sha, full_summary, inline_comments) - print(f"OxBot: review posted on PR #{pr_number} with {len(inline_comments)} inline comments") - - -if __name__ == "__main__": - main() diff --git a/.github/workflows/pr-review.yml b/.github/workflows/pr-review.yml deleted file mode 100644 index 3132230..0000000 --- a/.github/workflows/pr-review.yml +++ /dev/null @@ -1,48 +0,0 @@ -# OxBot - AI-Powered PR Review Agent -# -# Automatically reviews every pull request. -# Posts inline code review comments on specific lines. -# -# SECURITY: Uses pull_request_target to always run the workflow -# from the BASE branch (main/dev), not the PR branch. This prevents -# malicious PRs from modifying the review script to exfiltrate secrets. -# -# Required secrets: -# AZURE_AI_ENDPOINT - Azure Foundry OpenAI-compatible endpoint -# AZURE_AI_KEY - Azure Foundry API key - -name: OxBot PR Review - -on: - pull_request_target: - types: [opened, synchronize, reopened] - -permissions: - contents: read - pull-requests: write - -jobs: - ai-review: - name: AI Code Review - runs-on: ubuntu-latest - # Skip draft PRs - if: github.event.pull_request.draft == false - - steps: - # IMPORTANT: checkout the BASE branch, not the PR branch. - # This ensures we always run our trusted review script, - # even if the PR tries to modify it. - - uses: actions/checkout@v4 - with: - ref: ${{ github.event.pull_request.base.ref }} - - - uses: actions/setup-python@v5 - with: - python-version: "3.12" - - - name: Run OxBot Review - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - AZURE_AI_ENDPOINT: ${{ secrets.AZURE_AI_ENDPOINT }} - AZURE_AI_KEY: ${{ secrets.AZURE_AI_KEY }} - run: python .github/scripts/ai_pr_review.py