From 30983a63ea9c169035d9544fa862ca212c901f60 Mon Sep 17 00:00:00 2001 From: nghialuutrung Date: Sun, 25 Jan 2026 11:55:56 +0700 Subject: [PATCH 1/6] add review bot by AI --- .github/workflows/ai-review.yml | 32 ++++ docs/PLAN-ai-code-reviewer.md | 54 ++++++ .../__pycache__/ai_reviewer.cpython-314.pyc | Bin 0 -> 7920 bytes scripts/ai_reviewer.py | 175 ++++++++++++++++++ scripts/requirements.txt | 2 + 5 files changed, 263 insertions(+) create mode 100644 .github/workflows/ai-review.yml create mode 100644 docs/PLAN-ai-code-reviewer.md create mode 100644 scripts/__pycache__/ai_reviewer.cpython-314.pyc create mode 100644 scripts/ai_reviewer.py create mode 100644 scripts/requirements.txt diff --git a/.github/workflows/ai-review.yml b/.github/workflows/ai-review.yml new file mode 100644 index 00000000..fd7ce3ef --- /dev/null +++ b/.github/workflows/ai-review.yml @@ -0,0 +1,32 @@ +name: AI Code Reviewer + +on: + pull_request: + types: [opened, synchronize] + +permissions: + contents: read + pull-requests: write + +jobs: + review: + runs-on: ubuntu-latest + steps: + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.10' + + - name: Install Dependencies + run: | + pip install -r scripts/requirements.txt + + - name: Run AI Reviewer + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }} + run: | + python scripts/ai_reviewer.py diff --git a/docs/PLAN-ai-code-reviewer.md b/docs/PLAN-ai-code-reviewer.md new file mode 100644 index 00000000..3b38ecb5 --- /dev/null +++ b/docs/PLAN-ai-code-reviewer.md @@ -0,0 +1,54 @@ +# Plan: Custom AI Code Reviewer (GitHub Actions + Gemini) + +## Goal Description +Implement a "White Box" AI Code Review system where a custom Python script runs inside GitHub Actions. It will fetch Pull Request changes, filter them, send them to Google Gemini for analysis, and post review comments back to the PR. This approach offers maximum control over costs, context, and review quality. + +## User Review Required +> [!IMPORTANT] +> **API Key Required**: You will need a Google Gemini API Key. It must be stored in your GitHub Repository Secrets as `GEMINI_API_KEY`. + +> [!NOTE] +> **Token Usage**: This script will be optimized to only send necessary text (code diffs). However, large PRs might still consume significant tokens. We will implement basic filtering to mitigate this. + +## Proposed Changes + +### Architecture +1. **GitHub Action Workflow**: Triggers on `pull_request` types (opened, synchronized). Sets up Python, installs dependencies, and runs the script. +2. **Python Script (`scripts/ai_reviewer.py`)**: + * **Input**: `GITHUB_TOKEN`, `GEMINI_API_KEY`, and PR Context (from `GITHUB_EVENT_PATH`). + * **Logic**: + 1. Connect to GitHub API via `PyGithub`. + 2. Get the current Pull Request object. + 3. Iterate through `pr.get_files()`. + 4. **Filter**: Ignore `package-lock.json`, `dist/`, images, deleted files. + 5. **Prompting**: Construct a prompt for Gemini `1.5-flash` (balanced speed/cost) asking for review in JSON format. + 6. **Response Handling**: Parse JSON response. + 7. **Commenting**: Post comments to the exact line in the PR diff. + +### Component: Scripts +#### [NEW] [ai_reviewer.py](file:///Users/nghialuutrung/Desktop/antigravity-kit/scripts/ai_reviewer.py) +The core logic script. It will use: +* `PyGithub`: To interact with the repository and PRs. +* `google-generativeai`: To communicate with Gemini. + +#### [NEW] [requirements.txt](file:///Users/nghialuutrung/Desktop/antigravity-kit/scripts/requirements.txt) +Dependencies for the script: +```text +PyGithub +google-generativeai +``` + +### Component: Workflows +#### [NEW] [ai-review.yml](file:///Users/nghialuutrung/Desktop/antigravity-kit/.github/workflows/ai-review.yml) +The workflow file to orchestrate the process. + +## Verification Plan + +### Automated Tests +* Since this relies on external APIs (GitHub, Google), true automated unit tests are mocking-heavy. +* **Dry Run Mode**: We can implement a `--dry-run` flag in the script to print what *would* be commented instead of actually commenting. + +### Manual Verification +1. Create a dummy PR with some obvious "bad code" (e.g., `console.log` with secrets, infinite loop). +2. Watch the Action run. +3. Verify the bot comments on the correct lines with relevant feedback in Vietnamese. diff --git a/scripts/__pycache__/ai_reviewer.cpython-314.pyc b/scripts/__pycache__/ai_reviewer.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..29692a7329820c1ff098dbd29bb74c043531b133 GIT binary patch literal 7920 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 literal 0 HcmV?d00001 diff --git a/scripts/ai_reviewer.py b/scripts/ai_reviewer.py new file mode 100644 index 00000000..8bc6bc21 --- /dev/null +++ b/scripts/ai_reviewer.py @@ -0,0 +1,175 @@ +import os +import json +import logging +from github import Github +from openai import OpenAI + +# Configure Logging +logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') + +# Constants +IGNORED_EXTENSIONS = ['.json', '.md', '.txt', '.yml', '.yaml', '.lock', '.png', '.jpg', '.jpeg', '.gif', '.svg'] +IGNORED_DIRS = ['dist', 'build', 'node_modules', '.github'] +MODEL_NAME = "mistralai/devstral-2512:free" + +def should_review(filename): + """Check if file should be reviewed based on extension and path.""" + if any(filename.endswith(ext) for ext in IGNORED_EXTENSIONS): + return False + if any(part in filename.split('/') for part in IGNORED_DIRS): + return False + return True + +def get_pr_diff(repo, pr_number): + """Fetch PR diff using PyGithub.""" + pr = repo.get_pull(pr_number) + files_data = [] + + for file in pr.get_files(): + if not should_review(file.filename): + continue + + # Only review added or modified files (not deleted) + if file.status == 'removed': + continue + + files_data.append({ + "filename": file.filename, + "patch": file.patch + }) + return pr, files_data + +def analyze_code_with_openrouter(files_data): + """Send code diff to OpenRouter for review.""" + api_key = os.getenv("OPENROUTER_API_KEY") + if not api_key: + logging.error("OPENROUTER_API_KEY not found in environment variables.") + return [] + + client = OpenAI( + base_url="https://openrouter.ai/api/v1", + api_key=api_key, + ) + + # Construct Prompt + # We ask for a strict JSON response. + prompt = """ + Bạn là một Senior Code Reviewer. Nhiệm vụ của bạn là review các đoạn code thay đổi trong Pull Request này. + + Hãy chỉ ra các vấn đề nghiêm trọng: + 1. Lỗi Logic (Logic Errors) - Rất quan trọng. + 2. Vấn đề Bảo mật (Security Vulnerabilities) - Rất quan trọng. + 3. Hiệu năng (Performance Issues). + 4. Code xấu, khó bảo trì (Bad Practices). + + Bỏ qua: + - Các lỗi format/style (đã có linter). + - Các thay đổi không quan trọng. + + Dữ liệu input là JSON list các file kèm patch. + Hãy trả về kết quả là một JSON list thuần túy (không markdown block, không giải thích thêm), mỗi item có format sau: + [ + { + "filename": "tên_file", + "line_number": số_dòng_trong_patch_để_comment, + "comment": "Nội dung review bằng tiếng Việt, ngắn gọn, súc tích." + } + ] + + Nếu code tốt hoàn toàn, hãy trả về danh sách rỗng []. + + CODE DIFF TO REVIEW: + """ + json.dumps(files_data) + + try: + response = client.chat.completions.create( + model=MODEL_NAME, + messages=[ + {"role": "system", "content": "You are a helpful code reviewer. Always respond with valid JSON code only. No markdown formatting."}, + {"role": "user", "content": prompt} + ], + # Note: response_format={"type": "json_object"} sometimes requires 'json' in prompt or specific model support. + # We trust the prompt instruction for now. + ) + + content = response.choices[0].message.content.strip() + + # Strip markdown code blocks if present (common issue with LLMs) + if content.startswith("```json"): + content = content[7:] + if content.startswith("```"): + content = content[3:] + if content.endswith("```"): + content = content[:-3] + + content = content.strip() + + logging.info("OpenRouter response received.") + return json.loads(content) + except json.JSONDecodeError: + logging.error(f"Failed to parse JSON response: {content}") + return [] + except Exception as e: + logging.error(f"Error calling OpenRouter: {e}") + return [] + +def post_comments(pr, comments): + """Post comments to the PR.""" + commit = pr.get_commits().reversed[0] # Get latest commit + + if not comments: + logging.info("No issues found. LGTM!") + return + + logging.info(f"Posting {len(comments)} comments...") + + for note in comments: + filename = note.get('filename') + line = note.get('line_number') # AI guess of the line + body = f"🤖 **AI Review**: {note.get('comment')}" + + try: + if not line: + pr.create_issue_comment(f"File `{filename}`: {body}") + continue + + pr.create_review_comment(body, commit, filename, line=int(line), side="RIGHT") + except Exception as e: + logging.warning(f"Failed to post comment on {filename}:{line}. Error: {e}") + pr.create_issue_comment(f"Could not comment on line {line} of {filename}. \n\n{body}") + +def main(): + github_token = os.getenv("GITHUB_TOKEN") + event_path = os.getenv("GITHUB_EVENT_PATH") + + if not github_token or not event_path: + logging.error("Missing GITHUB_TOKEN or GITHUB_EVENT_PATH.") + return + + with open(event_path, 'r') as f: + event_data = json.load(f) + + if 'pull_request' not in event_data: + logging.info("Not a pull_request event. Exiting.") + return + + pr_number = event_data['pull_request']['number'] + repo_name = event_data['repository']['full_name'] + + logging.info(f"Starting review for PR #{pr_number} in {repo_name}") + + g = Github(github_token) + repo = g.get_repo(repo_name) + pr, files_data = get_pr_diff(repo, pr_number) + + if not files_data: + logging.info("No reviewable files found.") + return + + logging.info(f"Analyzing {len(files_data)} files...") + comments = analyze_code_with_openrouter(files_data) + post_comments(pr, comments) + logging.info("Review complete.") + +if __name__ == "__main__": + main() diff --git a/scripts/requirements.txt b/scripts/requirements.txt new file mode 100644 index 00000000..881d5508 --- /dev/null +++ b/scripts/requirements.txt @@ -0,0 +1,2 @@ +PyGithub==2.1.1 +openai==1.12.0 From 35ebdca70fa7ea3fbb35c5ff66391c56feee9636 Mon Sep 17 00:00:00 2001 From: hapo-nghialuu Date: Sun, 25 Jan 2026 12:13:46 +0700 Subject: [PATCH 2/6] feat: implement AI code reviewer using OpenRouter (Mistral) --- docs/PLAN-ai-code-reviewer.md | 68 ++++++++++++++--------------------- scripts/ai_reviewer.py | 4 +++ scripts/requirements.txt | 4 +-- 3 files changed, 32 insertions(+), 44 deletions(-) diff --git a/docs/PLAN-ai-code-reviewer.md b/docs/PLAN-ai-code-reviewer.md index 3b38ecb5..b5898ad1 100644 --- a/docs/PLAN-ai-code-reviewer.md +++ b/docs/PLAN-ai-code-reviewer.md @@ -1,54 +1,38 @@ -# Plan: Custom AI Code Reviewer (GitHub Actions + Gemini) +# Plan: Custom AI Code Reviewer (GitHub Actions + OpenRouter) ## Goal Description -Implement a "White Box" AI Code Review system where a custom Python script runs inside GitHub Actions. It will fetch Pull Request changes, filter them, send them to Google Gemini for analysis, and post review comments back to the PR. This approach offers maximum control over costs, context, and review quality. +Implement AI Code Reviewer using **OpenRouter** (Mistral) instead of Gemini. Fix dependency conflicts and ensure compliance with OpenRouter API standards. ## User Review Required > [!IMPORTANT] -> **API Key Required**: You will need a Google Gemini API Key. It must be stored in your GitHub Repository Secrets as `GEMINI_API_KEY`. - -> [!NOTE] -> **Token Usage**: This script will be optimized to only send necessary text (code diffs). However, large PRs might still consume significant tokens. We will implement basic filtering to mitigate this. +> **API Key**: Ensure `OPENROUTER_API_KEY` is set in GitHub Secrets. +> **Model**: Using `mistralai/devstral-2512:free` as requested (Note: verification of exact model ID recommended). ## Proposed Changes -### Architecture -1. **GitHub Action Workflow**: Triggers on `pull_request` types (opened, synchronized). Sets up Python, installs dependencies, and runs the script. -2. **Python Script (`scripts/ai_reviewer.py`)**: - * **Input**: `GITHUB_TOKEN`, `GEMINI_API_KEY`, and PR Context (from `GITHUB_EVENT_PATH`). - * **Logic**: - 1. Connect to GitHub API via `PyGithub`. - 2. Get the current Pull Request object. - 3. Iterate through `pr.get_files()`. - 4. **Filter**: Ignore `package-lock.json`, `dist/`, images, deleted files. - 5. **Prompting**: Construct a prompt for Gemini `1.5-flash` (balanced speed/cost) asking for review in JSON format. - 6. **Response Handling**: Parse JSON response. - 7. **Commenting**: Post comments to the exact line in the PR diff. - -### Component: Scripts -#### [NEW] [ai_reviewer.py](file:///Users/nghialuutrung/Desktop/antigravity-kit/scripts/ai_reviewer.py) -The core logic script. It will use: -* `PyGithub`: To interact with the repository and PRs. -* `google-generativeai`: To communicate with Gemini. - -#### [NEW] [requirements.txt](file:///Users/nghialuutrung/Desktop/antigravity-kit/scripts/requirements.txt) -Dependencies for the script: -```text -PyGithub -google-generativeai -``` - -### Component: Workflows -#### [NEW] [ai-review.yml](file:///Users/nghialuutrung/Desktop/antigravity-kit/.github/workflows/ai-review.yml) -The workflow file to orchestrate the process. +### Dependencies +#### [MODIFY] [requirements.txt](file:///Users/nghialuutrung/Desktop/antigravity-kit/scripts/requirements.txt) +* Update `openai>=1.55.0` to resolve `httpx` proxy argument conflict. +* Keep `PyGithub`. + +### Logic Script +#### [MODIFY] [ai_reviewer.py](file:///Users/nghialuutrung/Desktop/antigravity-kit/scripts/ai_reviewer.py) +* **Client Initialization**: + ```python + client = OpenAI( + base_url="https://openrouter.ai/api/v1", + api_key=api_key, + default_headers={ + "HTTP-Referer": "https://github.com/hapo-nghialuu/antigravity-kit", # Attribution + "X-Title": "Antigravity AI Reviewer" + } + ) + ``` +* **Model**: Set `MODEL_NAME = "mistralai/devstral-2512:free"`. +* **JSON Handling**: Add robust try-catch for JSON parsing as free models might chatter. ## Verification Plan -### Automated Tests -* Since this relies on external APIs (GitHub, Google), true automated unit tests are mocking-heavy. -* **Dry Run Mode**: We can implement a `--dry-run` flag in the script to print what *would* be commented instead of actually commenting. - ### Manual Verification -1. Create a dummy PR with some obvious "bad code" (e.g., `console.log` with secrets, infinite loop). -2. Watch the Action run. -3. Verify the bot comments on the correct lines with relevant feedback in Vietnamese. +1. **Re-run GitHub Action**: Trigger the workflow again on the existing PR. +2. **Check Logs**: Verify "OpenRouter response received" and no 404/401 errors. diff --git a/scripts/ai_reviewer.py b/scripts/ai_reviewer.py index 8bc6bc21..e2de9925 100644 --- a/scripts/ai_reviewer.py +++ b/scripts/ai_reviewer.py @@ -49,6 +49,10 @@ def analyze_code_with_openrouter(files_data): client = OpenAI( base_url="https://openrouter.ai/api/v1", api_key=api_key, + default_headers={ + "HTTP-Referer": "https://github.com/hapo-nghialuu/antigravity-kit", + "X-Title": "Antigravity AI Reviewer" + } ) # Construct Prompt diff --git a/scripts/requirements.txt b/scripts/requirements.txt index 881d5508..af700426 100644 --- a/scripts/requirements.txt +++ b/scripts/requirements.txt @@ -1,2 +1,2 @@ -PyGithub==2.1.1 -openai==1.12.0 +PyGithub>=2.1.1 +openai>=1.55.0 From 77bfef87cd16042936f12a00d2777b3879620df8 Mon Sep 17 00:00:00 2001 From: hapo-nghialuu Date: Sun, 25 Jan 2026 12:14:44 +0700 Subject: [PATCH 3/6] add git ignore --- .../scripts/__pycache__/core.cpython-313.pyc | Bin 12003 -> 0 bytes .../__pycache__/design_system.cpython-313.pyc | Bin 58773 -> 0 bytes .gitignore | 1 + scripts/__pycache__/ai_reviewer.cpython-314.pyc | Bin 7920 -> 0 bytes 4 files changed, 1 insertion(+) delete mode 100644 .agent/.shared/ui-ux-pro-max/scripts/__pycache__/core.cpython-313.pyc delete mode 100644 .agent/.shared/ui-ux-pro-max/scripts/__pycache__/design_system.cpython-313.pyc delete mode 100644 scripts/__pycache__/ai_reviewer.cpython-314.pyc diff --git a/.agent/.shared/ui-ux-pro-max/scripts/__pycache__/core.cpython-313.pyc b/.agent/.shared/ui-ux-pro-max/scripts/__pycache__/core.cpython-313.pyc deleted file mode 100644 index fbe98ab6e62b76fb81eb43a7d4a07a3d2d7a77cc..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 12003 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 diff --git a/.agent/.shared/ui-ux-pro-max/scripts/__pycache__/design_system.cpython-313.pyc b/.agent/.shared/ui-ux-pro-max/scripts/__pycache__/design_system.cpython-313.pyc deleted file mode 100644 index 34ee8b528d1633bc9c6da20de0ea981d21714eb5..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 58773 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 diff --git a/.gitignore b/.gitignore index f5504e33..a8221573 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ node_modules/ .temp_ag_kit/ antigravity-doc tests +__pycache__ diff --git a/scripts/__pycache__/ai_reviewer.cpython-314.pyc b/scripts/__pycache__/ai_reviewer.cpython-314.pyc deleted file mode 100644 index 29692a7329820c1ff098dbd29bb74c043531b133..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 7920 zcmbt3Yit|GnX}~bO^KxFhos2X$Z}*+mgxA^$g<;?UX~w{x>o7Lmd&il6}2%R>)oYn zv3CHI0JUtOu${xz3R1wzrA2hb6?HFnfMMZKU+D{S|L91L`n|?h%g;J8U$Hzk-Z(SIK5x=TwJ3-O1=<#v2n7Vp43`9^BnB9Osph2va~w9xVLK@ObhZ%<>?=!Q#ig%=g)&C|XY_twkZU zli`^4C~hAt@Q;wUNPU+Zp)G}#G1#vL9djYH5oCU%(m|f)6Km?eV~<@0x{Q6IbRW)K z|LruflqSLlZT}~lzHyIlrinAvqktvzLi>miIgiC*JS@fqtc;}6@hBb=uq=#B?Idp#V zX#;cyL%-fPwd;E8&HXp_Pu9=)8$NP1ytl8w46bKD!e%}>rd9NG_x5p}9pTOw2ReIC z_Vo3h)ZE5gM-O*0!01@Xp$=1=TA8jq1>wqY?8rl~_Y>?{^qI~1x-#oL_^{=`jPu}S z<+AeH@^H>}@I%|dN1ipmFF^hCzEU7MD#AQ+d7i+YC{n!0FtEW7G=Y6m4kTsas(!n^ zNv0r@%pp@h(Vi8}Flk=Y1=d7j2MrUALTtZv1uzVa(=#r9!GK^y;O$?B=NM{2sd+dC zx)L(?lM`IgIMeFbI5W(XLtfFgnG8DM9-Y&*=T!Ypa?RPyhAu&kjNpC_N5$b`oL0nS z4EK-e<~gX@WFe6n6{7S^H4EJB$VkAZ*<*qlmeTRK<{)q6#wqk%bvUb{@@iVqth^+_ z6_r6{!K7%cEJ!KMA<5xnIx!^3nn{v1CtVzl@+z;f^qmN`p#fv8YmbZKlEZ{Pc{6-l z4?pE1bXoL?qwLy-D;utl%{c0^hv$9%?9orW6*t{C+&?KFXBRv*69c!-zIk@ev-LyI z)<^C&OIGCeUfX+R??hnMRSy%N*emAjRS)b{^X_#M?5(ml%jVtw>jO7W-#EQwF+bz{ zFJyMO{^Ucx+NDb5E+5~YXRBmFz}K!eRWUm#Ua6<)FI7ZXMOq#BLgPRrCd3q#1N?st z8f0}SgjQ21SX*+KK`@4cMK$WDk%X~Ye}ZRXIP>!b16x}Py^5mLp+RC9Xe&}^;@H7r z9I;yDK)jy;-E1s}r(}Kx!8KZ2GF5mDnK=t*-NtY>BjSLow9GfL4=Wn*Z8vZL7t8d3 z@m$$}9=4xo+=^zbuSXaS))*hHDB3{eE?G(_Smi1M1&#rW8|`>X{%O+pC)0SiPp z^eHkT+H2l`z(u(-n@@uF<47th=(kHv;cgnUUFo(0>hB33>wGD* z1RhkQNjbCv%fyK}rPej^y4M!;z82=_^>~_1#7n z@BTbVcX$;iVJHOBFYs6e4L^RL5{A9Zw1}KO}<36oqfyZ)Xr6AreaC znXrj?SEWVM^CPE?!^ftBOO=|8JN$Q6-H9rjmq7n-J2H=#%=^No_?jMt} zFjdkG5-GTG|v4?8|Y{5@5Zn+0{n@TLAsPuscSu5PS2Xiy8x&I z#Q;^LaHFU&lOU`#T!Z&-^B|C4CP#2;cvulsatGhP4a-5KZ;3cYGb0Svpc(ML4_7!Q z0{$c(p|=Qj#j(Y^zXJFKFX7SLWD4sDOB5imLI?%Uh@g7G7Kk{ShV_Np4LbU37^#5e zH}DJ*#Za5!RuU|L3+`kyR&sYEglf=)O+^9!HGMlvd)oEtED{T&0--7%N#!Qt_(|Wa zGf<*EQ66NVfU=KZxcN5$_nEULR-?VIqZ4=Zbaml@KFoE7dOA<(3ZYfVf+D4oiV#jf z<``DTBq8I4SR*b*cop)HZLcV)B-lhBiSK1P1&5m)7Gs&Jf&n&m03k#K5h9#mW*s#y z7y^ILnOMP6x8dG^M>C}$n1MJ1tgmK^r(!Vx&@2KmMw$hX@S^6>5u{~7^K=)`jtVD0 z6miXY0$4d7?mcv(Q)3BjnkAY}NQwq2EG1AN0QB|-PLN^&Y8LE2+*q%nd*92kk9j2NntPxA4x3_%kW!X#q~>&dhH z0zfvwPkA4@Ec&$EdriD5<|`X#$^+T%-@7Un?4E07SIhG2j?UVT&3k;~j!#*~<-XVY zCb}Q8n-|=^Id|QM?z*2f&A3~#olExWa{K4Ikjrze>1xyU(=$%|pS!Ee>#|)-tyW9L zyuWhJA9&yoOf}>?U&^0JM z8uRwXFBe>EK1U`?k)q1r)P<=?e%+2)`%WP2$BsWQS>W?mN-ePRTk9cbyAS1jt`5eW ztEq0^XU=_(A@BPv&_9(SkidzCZxRjPR?iTO*nhQ>$a*X$uvp=Uklo z`9dZJ>wj3tk3o(Q=oL#{5a@N%K+jep-8IH;%TEaO$_5B!t{c~O-+Y-3UOakQybQ71%w%E z7jgA3XUs3dtcC08J~9BsHkNFnv5FWKGSyu?fJ6ypQVFS~_Mn*tEo3TIgGchCp1(;0ASG_4~rqN)(%H7dwZPLfFmA@7DxGlO&2%wTl2H4$0h zRUu3zVR&h6ZDCgTV#R4Qghp~J*}!&@my;l)fL*R5W4j??&8p+oSfEp&GLsJz2zDqH z9h3LLt3uK$nuHc@Xc=MMK%>at2Y}NAvJtYYELy5YU^>rTIX6?bVXmy}XBp zytiV`yXAp*%T&Yd%G=I6^>?CoLixsyS#RefZ{?C3m3ijen;*D0PY&E}y$L=Z=0roOsCT&Wa!^PsVWEw0c?WM}$U_gqM0P&Q>}i9)Yu$ z&Tx*>(NpR2>iC@0zPmysX)dwvB^G@7w5%a(KPgDAXv;QUvJDms38z#;q8CB3@|BRI z=x_cqNw}B24LvQ^K-<4MCuM`A;zL`=VXP${qSV_C0`8SCl5_AGWA@ZlgK8*Xd>2NK zfdx((&I+TgMGcJs&oxLi)uIic1LUuhIc_bK9K4#lyJz6Y4-SV1`i^z>YQDm#Gt}8T z5bi%Ta3s@o0?a6lZ%Y6%)CIl<8tl~=S#y&zACzusVxQU63znV7rE?fecM_7A&P$L> zCu2d)TBz(>>Op?bDjTttEq=qSDvYUhK zMA_Atm`vhP>Vov>{R9jZQr897Vl=(f$tHL)DM#Sbj|faX^(+hiwKw;?v1j7kjK4X1 ze8K0xS@lL${+Zy6Z(H{0XRh_PGQ|-6@T|R^_!mdn_$w3jbB>L(j*XLb3u~)xzVyaR z6BqtwaNPNY898g0?8v@u;`FR-OVKs$nYFjU(oekA6Z;=}o2FVHdUs`e=3%(+p|^4B znTOup*`9*q%KNuY^-S%(9lf*X&epuIYnJW))LuURS2OmS?4fy=`&!$Tw(H`I3uikP z*5b)EGi$fM+M7N6nQLtk-S%1gjuKDU_S48@(_5+hhTxQ#_qEQldlo!frkpdLo!KJ` ztnd1!S+;gT_nLWM{YUKPFSY|4z+m37@hgRtGXFKOzVijkzrEnwufH^4{gZ>pMMpj4y8N|e4=#|k}fYsP2R7Nz* zP+E*fHFuJvQILP7q4<`u0U-HjX27p29nmkUbdWOA54wi=W2sauE(DF7bX0(>(Oo){ zgRoL_!T;67NIOkeHMXa>t4}5|oJ{PWyq$EUK&q>0QA4TH5WdM33y1$6(IU03RrY;4 zrTgU+ECiiG3VB&FGYs=Fa(s+jAEWZ$p{n1ZdZ>>x_BrJL5cwyz+-iQaIlt}nLv-e` z6|K9#zhkpKVy From 62899e64a8e42a99cfb25e1968da8ca45670d1e7 Mon Sep 17 00:00:00 2001 From: hapo-nghialuu Date: Sun, 25 Jan 2026 12:22:30 +0700 Subject: [PATCH 4/6] fix: improve robustness of ai reviewer script based on feedback --- scripts/ai_reviewer.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/scripts/ai_reviewer.py b/scripts/ai_reviewer.py index e2de9925..7456b70d 100644 --- a/scripts/ai_reviewer.py +++ b/scripts/ai_reviewer.py @@ -108,6 +108,10 @@ def analyze_code_with_openrouter(files_data): content = content.strip() + if not content: + logging.warning("Received empty response from OpenRouter.") + return [] + logging.info("OpenRouter response received.") return json.loads(content) except json.JSONDecodeError: @@ -130,10 +134,16 @@ def post_comments(pr, comments): for note in comments: filename = note.get('filename') line = note.get('line_number') # AI guess of the line - body = f"🤖 **AI Review**: {note.get('comment')}" + comment_text = note.get('comment') + + if not comment_text: + continue + + body = f"🤖 **AI Review**: {comment_text}" try: - if not line: + # Validate line number + if not line or not str(line).isdigit(): pr.create_issue_comment(f"File `{filename}`: {body}") continue From 5302c5246ead2d9628fe11459f53eaa5976c6895 Mon Sep 17 00:00:00 2001 From: hapo-nghialuu Date: Sun, 25 Jan 2026 12:28:38 +0700 Subject: [PATCH 5/6] feat: implement auto-resolve for outdated bot comments using GraphQL --- scripts/ai_reviewer.py | 129 +++++++++++++++++++++++++++++++++++++++ scripts/requirements.txt | 1 + 2 files changed, 130 insertions(+) diff --git a/scripts/ai_reviewer.py b/scripts/ai_reviewer.py index 7456b70d..f57d4c48 100644 --- a/scripts/ai_reviewer.py +++ b/scripts/ai_reviewer.py @@ -1,5 +1,6 @@ import os import json +import requests import logging from github import Github from openai import OpenAI @@ -11,6 +12,7 @@ IGNORED_EXTENSIONS = ['.json', '.md', '.txt', '.yml', '.yaml', '.lock', '.png', '.jpg', '.jpeg', '.gif', '.svg'] IGNORED_DIRS = ['dist', 'build', 'node_modules', '.github'] MODEL_NAME = "mistralai/devstral-2512:free" +GITHUB_GRAPHQL_URL = "https://api.github.com/graphql" def should_review(filename): """Check if file should be reviewed based on extension and path.""" @@ -39,6 +41,117 @@ def get_pr_diff(repo, pr_number): }) return pr, files_data +def resolve_thread(thread_id, token): + """Resolve a specific review thread using GraphQL.""" + mutation = """ + mutation ResolveThread($threadId: ID!) { + resolveReviewThread(input: {threadId: $threadId}) { + thread { + isResolved + } + } + } + """ + headers = { + "Authorization": f"Bearer {token}", + "Content-Type": "application/json" + } + payload = { + "query": mutation, + "variables": {"threadId": thread_id} + } + + try: + response = requests.post(GITHUB_GRAPHQL_URL, json=payload, headers=headers) + response.raise_for_status() + data = response.json() + if 'errors' in data: + logging.error(f"GraphQL Error resolving thread {thread_id}: {data['errors']}") + else: + logging.info(f"Resolved thread {thread_id}") + except Exception as e: + logging.error(f"Failed to resolve thread {thread_id}: {e}") + +def resolve_existing_comments(repo_owner, repo_name, pr_number, token): + """ + Fetch all unresolved review threads on the PR and resolve them if they were created by the bot. + Since we can't easily check 'author' of a thread in a simple query without pagination complexity, + we will assume for this V1 that we want to auto-resolve ALL unresolved threads (or try to filter). + + Refined Strategy: Fetch threads, check if author is 'github-actions[bot]', then resolve. + """ + query = """ + query($owner: String!, $repo: String!, $prNumber: Int!) { + repository(owner: $owner, name: $repo) { + pullRequest(number: $prNumber) { + id + reviewThreads(last: 50) { + nodes { + id + isResolved + comments(first: 1) { + nodes { + author { + login + } + } + } + } + } + } + } + } + """ + headers = { + "Authorization": f"Bearer {token}", + "Content-Type": "application/json" + } + payload = { + "query": query, + "variables": { + "owner": repo_owner, + "repo": repo_name, + "prNumber": pr_number + } + } + + try: + response = requests.post(GITHUB_GRAPHQL_URL, json=payload, headers=headers) + response.raise_for_status() + data = response.json() + + if 'errors' in data: + logging.error(f"GraphQL Error fetching threads: {data['errors']}") + return + + threads = data['data']['repository']['pullRequest']['reviewThreads']['nodes'] + bot_threads = [] + + for thread in threads: + if thread['isResolved']: + continue + + # Check author of the first comment in thread + comments = thread['comments']['nodes'] + if not comments: + continue + + author = comments[0]['author'] + # Author can be None if user is deleted + if author and author.get('login') == 'github-actions[bot]': + bot_threads.append(thread['id']) + + if not bot_threads: + logging.info("No unresolved bot threads found.") + return + + logging.info(f"Found {len(bot_threads)} unresolved bot threads. Resolving...") + for thread_id in bot_threads: + resolve_thread(thread_id, token) + + except Exception as e: + logging.error(f"Failed to fetch/resolve threads: {e}") + def analyze_code_with_openrouter(files_data): """Send code diff to OpenRouter for review.""" api_key = os.getenv("OPENROUTER_API_KEY") @@ -163,6 +276,7 @@ def main(): with open(event_path, 'r') as f: event_data = json.load(f) + # Handle pull_request event if 'pull_request' not in event_data: logging.info("Not a pull_request event. Exiting.") return @@ -170,10 +284,25 @@ def main(): pr_number = event_data['pull_request']['number'] repo_name = event_data['repository']['full_name'] + # Extract owner and repo name properly + # repo_name is usually "owner/repo" + try: + owner_name, repository_name = repo_name.split('/') + except ValueError: + logging.error(f"Invalid repo name format: {repo_name}") + return + logging.info(f"Starting review for PR #{pr_number} in {repo_name}") + # 1. Initialize PyGithub g = Github(github_token) repo = g.get_repo(repo_name) + + # 2. AUTO-RESOLVE OLD THREADS + logging.info("Checking for unresolved bot threads...") + resolve_existing_comments(owner_name, repository_name, pr_number, github_token) + + # 3. Analyze new code pr, files_data = get_pr_diff(repo, pr_number) if not files_data: diff --git a/scripts/requirements.txt b/scripts/requirements.txt index af700426..48304c40 100644 --- a/scripts/requirements.txt +++ b/scripts/requirements.txt @@ -1,2 +1,3 @@ PyGithub>=2.1.1 openai>=1.55.0 +requests>=2.31.0 From 1315cfeceb98e2358187b610d0684f0a339d9e18 Mon Sep 17 00:00:00 2001 From: hapo-nghialuu Date: Sun, 25 Jan 2026 12:37:34 +0700 Subject: [PATCH 6/6] fix: address all AI review feedback - add error handling, safe navigation, timeouts --- scripts/ai_reviewer.py | 146 ++++++++++++++++++++++++++++++----------- 1 file changed, 108 insertions(+), 38 deletions(-) diff --git a/scripts/ai_reviewer.py b/scripts/ai_reviewer.py index f57d4c48..bbbef449 100644 --- a/scripts/ai_reviewer.py +++ b/scripts/ai_reviewer.py @@ -1,8 +1,18 @@ +""" +AI Code Reviewer Script +======================= +Automatically reviews Pull Requests using OpenRouter AI (Mistral) and posts comments. +Includes auto-resolve functionality for outdated bot comments using GitHub GraphQL API. + +Model: mistralai/devstral-2512:free +Note: This is a free-tier model. For production use, consider a paid model for better reliability. +""" + import os import json import requests import logging -from github import Github +from github import Github, GithubException from openai import OpenAI # Configure Logging @@ -11,9 +21,11 @@ # Constants IGNORED_EXTENSIONS = ['.json', '.md', '.txt', '.yml', '.yaml', '.lock', '.png', '.jpg', '.jpeg', '.gif', '.svg'] IGNORED_DIRS = ['dist', 'build', 'node_modules', '.github'] +# Free tier model from OpenRouter - may have rate limits or availability issues MODEL_NAME = "mistralai/devstral-2512:free" GITHUB_GRAPHQL_URL = "https://api.github.com/graphql" + def should_review(filename): """Check if file should be reviewed based on extension and path.""" if any(filename.endswith(ext) for ext in IGNORED_EXTENSIONS): @@ -22,6 +34,7 @@ def should_review(filename): return False return True + def get_pr_diff(repo, pr_number): """Fetch PR diff using PyGithub.""" pr = repo.get_pull(pr_number) @@ -41,6 +54,7 @@ def get_pr_diff(repo, pr_number): }) return pr, files_data + def resolve_thread(thread_id, token): """Resolve a specific review thread using GraphQL.""" mutation = """ @@ -62,23 +76,23 @@ def resolve_thread(thread_id, token): } try: - response = requests.post(GITHUB_GRAPHQL_URL, json=payload, headers=headers) + response = requests.post(GITHUB_GRAPHQL_URL, json=payload, headers=headers, timeout=30) response.raise_for_status() data = response.json() if 'errors' in data: logging.error(f"GraphQL Error resolving thread {thread_id}: {data['errors']}") else: logging.info(f"Resolved thread {thread_id}") + except requests.exceptions.RequestException as e: + logging.error(f"Network error resolving thread {thread_id}: {e}") except Exception as e: logging.error(f"Failed to resolve thread {thread_id}: {e}") + def resolve_existing_comments(repo_owner, repo_name, pr_number, token): """ Fetch all unresolved review threads on the PR and resolve them if they were created by the bot. - Since we can't easily check 'author' of a thread in a simple query without pagination complexity, - we will assume for this V1 that we want to auto-resolve ALL unresolved threads (or try to filter). - - Refined Strategy: Fetch threads, check if author is 'github-actions[bot]', then resolve. + Uses safe navigation to handle unexpected API response structures. """ query = """ query($owner: String!, $repo: String!, $prNumber: Int!) { @@ -116,7 +130,7 @@ def resolve_existing_comments(repo_owner, repo_name, pr_number, token): } try: - response = requests.post(GITHUB_GRAPHQL_URL, json=payload, headers=headers) + response = requests.post(GITHUB_GRAPHQL_URL, json=payload, headers=headers, timeout=30) response.raise_for_status() data = response.json() @@ -124,22 +138,32 @@ def resolve_existing_comments(repo_owner, repo_name, pr_number, token): logging.error(f"GraphQL Error fetching threads: {data['errors']}") return - threads = data['data']['repository']['pullRequest']['reviewThreads']['nodes'] + # Safe navigation to handle unexpected response structures + pr_data = data.get('data', {}).get('repository', {}).get('pullRequest') + if not pr_data: + logging.warning("Could not find PR data in GraphQL response. Skipping auto-resolve.") + return + + threads = pr_data.get('reviewThreads', {}).get('nodes', []) + if not threads: + logging.info("No review threads found.") + return + bot_threads = [] for thread in threads: - if thread['isResolved']: + if thread.get('isResolved'): continue # Check author of the first comment in thread - comments = thread['comments']['nodes'] + comments = thread.get('comments', {}).get('nodes', []) if not comments: continue - author = comments[0]['author'] + author = comments[0].get('author') # Author can be None if user is deleted if author and author.get('login') == 'github-actions[bot]': - bot_threads.append(thread['id']) + bot_threads.append(thread.get('id')) if not bot_threads: logging.info("No unresolved bot threads found.") @@ -149,9 +173,12 @@ def resolve_existing_comments(repo_owner, repo_name, pr_number, token): for thread_id in bot_threads: resolve_thread(thread_id, token) + except requests.exceptions.RequestException as e: + logging.error(f"Network error fetching threads: {e}") except Exception as e: logging.error(f"Failed to fetch/resolve threads: {e}") + def analyze_code_with_openrouter(files_data): """Send code diff to OpenRouter for review.""" api_key = os.getenv("OPENROUTER_API_KEY") @@ -159,17 +186,20 @@ def analyze_code_with_openrouter(files_data): logging.error("OPENROUTER_API_KEY not found in environment variables.") return [] - client = OpenAI( - base_url="https://openrouter.ai/api/v1", - api_key=api_key, - default_headers={ - "HTTP-Referer": "https://github.com/hapo-nghialuu/antigravity-kit", - "X-Title": "Antigravity AI Reviewer" - } - ) + try: + client = OpenAI( + base_url="https://openrouter.ai/api/v1", + api_key=api_key, + default_headers={ + "HTTP-Referer": "https://github.com/hapo-nghialuu/antigravity-kit", + "X-Title": "Antigravity AI Reviewer" + } + ) + except Exception as e: + logging.error(f"Failed to initialize OpenAI client: {e}") + return [] # Construct Prompt - # We ask for a strict JSON response. prompt = """ Bạn là một Senior Code Reviewer. Nhiệm vụ của bạn là review các đoạn code thay đổi trong Pull Request này. @@ -205,11 +235,19 @@ def analyze_code_with_openrouter(files_data): {"role": "system", "content": "You are a helpful code reviewer. Always respond with valid JSON code only. No markdown formatting."}, {"role": "user", "content": prompt} ], - # Note: response_format={"type": "json_object"} sometimes requires 'json' in prompt or specific model support. - # We trust the prompt instruction for now. ) - content = response.choices[0].message.content.strip() + # Safely access response content + if not response.choices: + logging.warning("OpenRouter returned no choices.") + return [] + + content = response.choices[0].message.content + if not content: + logging.warning("OpenRouter response has empty content.") + return [] + + content = content.strip() # Strip markdown code blocks if present (common issue with LLMs) if content.startswith("```json"): @@ -222,21 +260,26 @@ def analyze_code_with_openrouter(files_data): content = content.strip() if not content: - logging.warning("Received empty response from OpenRouter.") + logging.warning("Received empty response from OpenRouter after cleanup.") return [] logging.info("OpenRouter response received.") return json.loads(content) - except json.JSONDecodeError: - logging.error(f"Failed to parse JSON response: {content}") + except json.JSONDecodeError as e: + logging.error(f"Failed to parse JSON response: {e}") return [] except Exception as e: logging.error(f"Error calling OpenRouter: {e}") return [] + def post_comments(pr, comments): """Post comments to the PR.""" - commit = pr.get_commits().reversed[0] # Get latest commit + try: + commit = pr.get_commits().reversed[0] # Get latest commit + except Exception as e: + logging.error(f"Failed to get latest commit: {e}") + return if not comments: logging.info("No issues found. LGTM!") @@ -246,7 +289,7 @@ def post_comments(pr, comments): for note in comments: filename = note.get('filename') - line = note.get('line_number') # AI guess of the line + line = note.get('line_number') comment_text = note.get('comment') if not comment_text: @@ -255,15 +298,31 @@ def post_comments(pr, comments): body = f"🤖 **AI Review**: {comment_text}" try: - # Validate line number - if not line or not str(line).isdigit(): + # Validate line number - must be a positive integer + if not line: pr.create_issue_comment(f"File `{filename}`: {body}") continue - pr.create_review_comment(body, commit, filename, line=int(line), side="RIGHT") + # Handle both int and string types for line number + try: + line_int = int(line) + if line_int <= 0: + raise ValueError("Line number must be positive") + except (ValueError, TypeError): + logging.warning(f"Invalid line number '{line}' for {filename}. Posting as issue comment.") + pr.create_issue_comment(f"File `{filename}`: {body}") + continue + + pr.create_review_comment(body, commit, filename, line=line_int, side="RIGHT") + except GithubException as e: + logging.warning(f"GitHub API error posting comment on {filename}:{line}. Error: {e}") + try: + pr.create_issue_comment(f"Could not comment on line {line} of {filename}.\n\n{body}") + except Exception as fallback_error: + logging.error(f"Failed to post fallback comment: {fallback_error}") except Exception as e: logging.warning(f"Failed to post comment on {filename}:{line}. Error: {e}") - pr.create_issue_comment(f"Could not comment on line {line} of {filename}. \n\n{body}") + def main(): github_token = os.getenv("GITHUB_TOKEN") @@ -273,8 +332,12 @@ def main(): logging.error("Missing GITHUB_TOKEN or GITHUB_EVENT_PATH.") return - with open(event_path, 'r') as f: - event_data = json.load(f) + try: + with open(event_path, 'r') as f: + event_data = json.load(f) + except (IOError, json.JSONDecodeError) as e: + logging.error(f"Failed to read event file: {e}") + return # Handle pull_request event if 'pull_request' not in event_data: @@ -285,7 +348,6 @@ def main(): repo_name = event_data['repository']['full_name'] # Extract owner and repo name properly - # repo_name is usually "owner/repo" try: owner_name, repository_name = repo_name.split('/') except ValueError: @@ -294,9 +356,16 @@ def main(): logging.info(f"Starting review for PR #{pr_number} in {repo_name}") - # 1. Initialize PyGithub + # 1. Initialize PyGithub with permission check g = Github(github_token) - repo = g.get_repo(repo_name) + try: + repo = g.get_repo(repo_name) + except GithubException as e: + logging.error(f"Failed to access repo {repo_name}. Check token permissions. Error: {e}") + return + except Exception as e: + logging.error(f"Unexpected error accessing repo: {e}") + return # 2. AUTO-RESOLVE OLD THREADS logging.info("Checking for unresolved bot threads...") @@ -314,5 +383,6 @@ def main(): post_comments(pr, comments) logging.info("Review complete.") + if __name__ == "__main__": main()