Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
135 changes: 135 additions & 0 deletions .github/workflows/claude-code-review.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
# SPDX-FileCopyrightText: Copyright (c) 2023-present NVIDIA CORPORATION & AFFILIATES.
# All rights reserved.
# SPDX-License-Identifier: BSD-3-Clause

name: Claude CLI PR Review
on:
pull_request:
types: [opened, synchronize, ready_for_review]

concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
cancel-in-progress: true

run-name: Claude review for PR ${{ github.event.pull_request.number }} - ${{ github.event.pull_request.head.sha }}

jobs:
claude-code-review:
name: Run Claude Code Review
# Skip if PR is in draft
if: github.event.pull_request.draft == false
runs-on: ubuntu-latest
permissions:
pull-requests: write
contents: read
env:
CLAUDE_OUTPUT_DIR: artifacts/claude_review/${{ github.event.pull_request.number }}-${{ github.event.pull_request.head.sha }}

steps:
- name: Checkout code
uses: actions/checkout@v4
Comment on lines +29 to +30
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

logic: missing fetch-depth: 0 - without full git history, git merge-base in pr_preflight_launcher.py:85 will fail when computing the merge base between branches

Suggested change
- name: Checkout code
uses: actions/checkout@v4
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0

with:
fetch-depth: 0

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'

- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: '3.10'

- name: Install Claude Code
run: |
npm install -g @anthropic-ai/claude-code
echo "$(npm config get prefix)/bin" >> $GITHUB_PATH

- name: Install Claude Code Router
run: npm install -g @musistudio/[email protected]

- name: Setup Claude Code Router config
run: |
mkdir -p $HOME/.claude-code-router
cat <<EOF > $HOME/.claude-code-router/config.json
{
"LOG": true,
"API_TIMEOUT_MS": 60000,
"NON_INTERACTIVE_MODE": true,
"Providers": [
{
"name": "anthropic",
"api_base_url": "\$ANTHROPIC_BASE_URL",
"api_key": "\$ANTHROPIC_API_KEY",
"models": [
"\$ANTHROPIC_LLM_MODEL"
],
"transformer": {
"use": ["anthropic", "proxy-handler"]
}
}
],
"Router": {
"default": "anthropic,\$ANTHROPIC_LLM_MODEL"
},
"transformers": [
{
"path": "$GITHUB_WORKSPACE/tools/proxy.js"
}
]
}
EOF
shell: bash

- name: Start Claude Code Router background service
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
ANTHROPIC_BASE_URL: ${{ secrets.ANTHROPIC_BASE_URL }}
ANTHROPIC_LLM_MODEL: ${{ secrets.ANTHROPIC_LLM_MODEL }}
run: |
nohup ccr start &
sleep 5 # Give it some time to start
shell: bash

- name: Check Claude CLI availability
run: |
echo "PATH=$PATH"
which claude || echo "claude not found on PATH"
claude --version || true
echo "npm prefix bin: $(npm config get prefix)/bin" || true

- name: Run Claude Code via wrapper
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
ANTHROPIC_BASE_URL: http://localhost:3456
PR_NUMBER: ${{ github.event.pull_request.number }}
PR_SHA: ${{ github.event.pull_request.head.sha }}
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
PR_BASE_REF: ${{ github.event.pull_request.base.ref }}
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
PR_HEAD_REF: ${{ github.event.pull_request.head.ref }}
run: |
python -m tools.pr_preflight_launcher --ai-backend claude --output-dir "${CLAUDE_OUTPUT_DIR}"

- name: Print Claude error (if any)
if: always()
run: |
if [ -f "${{ env.CLAUDE_OUTPUT_DIR }}/error.txt" ]; then
echo "===== Claude error.txt ====="
sed -n '1,200p' "${{ env.CLAUDE_OUTPUT_DIR }}/error.txt"
else
echo "No error.txt found in ${{ env.CLAUDE_OUTPUT_DIR }}"
fi

- name: Upload Claude review artifacts
if: always()
uses: actions/upload-artifact@v4
with:
name: claude-review-${{ github.event.pull_request.number }}-${{ github.event.pull_request.head.sha }}
path: ${{ env.CLAUDE_OUTPUT_DIR }}/**

# TODO: Add step to post results to PR
# - name: Post results to PR
# run: |
# # Parse output and post to PR
87 changes: 87 additions & 0 deletions .github/workflows/gemini-cli-review.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
name: Gemini CLI PR Review
on:
pull_request:
types: [opened, synchronize, ready_for_review]

concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
cancel-in-progress: true

run-name: Gemini review for PR ${{ github.event.pull_request.number }} - ${{ github.event.pull_request.head.sha }}

jobs:
gemini-pr-review:
# Skip if PR is in draft
if: github.event.pull_request.draft == false
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'

- name: Install Gemini CLI
shell: bash
run: |
npm install -g @google/gemini-cli@latest
echo "$(npm config get prefix)/bin" >> $GITHUB_PATH

- name: Verify Gemini CLI
shell: bash
run: |
which gemini
gemini --version

- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: '3.10'

- name: Install/Upgrade Google Python Client
shell: bash
run: |
python -m pip install --upgrade pip
pip install --upgrade google-generativeai
# If you have a requirements.txt file, you might use this instead:
# pip install -r requirements.txt --upgrade

- name: Run Gemini PR review
env:
#GEMINI_MODEL: gemini-1.5-flash-latest
GEMINI_OUTPUT_DIR: artifacts/gemini_review/${{ github.event.pull_request.number }}-${{ github.event.pull_request.head.sha }}
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
PR_NUMBER: ${{ github.event.pull_request.number }}
PR_SHA: ${{ github.event.pull_request.head.sha }}
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
PR_BASE_REF: ${{ github.event.pull_request.base.ref }}
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
PR_HEAD_REF: ${{ github.event.pull_request.head.ref }}
run: |
python -m tools.pr_preflight_launcher --ai-backend gemini --output-dir "${GEMINI_OUTPUT_DIR}"

- name: Print Gemini error (if any)
if: always()
run: |
if [ -f "${{ env.GEMINI_OUTPUT_DIR }}/error.txt" ]; then
echo "===== Gemini error.txt ====="
sed -n '1,200p' "${{ env.GEMINI_OUTPUT_DIR }}/error.txt"
else
echo "No error.txt found in ${{ env.GEMINI_OUTPUT_DIR }}"
fi

- name: Upload Gemini review artifacts
if: always()
uses: actions/upload-artifact@v4
with:
name: gemini-review-${{ github.event.pull_request.number }}-${{ github.event.pull_request.head.sha }}
path: ${{ env.GEMINI_OUTPUT_DIR }}/**


11 changes: 11 additions & 0 deletions .github/workflows/review_test.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"action": "opened",
"pull_request": {
"draft": false,
"number": 5489,
"head": { "sha": "9f9a6b2f91519b3dc02fe7ec7a5f2a3b98398338", "ref": "feature/branch" },
"base": { "sha": "f8b8551a720cd5c3a9aa8950e5a50fb7d420cbe5", "ref": "main" }
}
}


25 changes: 25 additions & 0 deletions tools/ai_cli_docker_test/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# start from the official, clean Node.js 20 image (Debian-based)
# Gemini seems to go haywire with older node versions (e.g. 18)
FROM node:20

COPY pr_preflight_launcher.py /usr/local/bin/
COPY utils.py /usr/local/bin/tools/
COPY git_helpers.py /usr/local/bin/tools/
COPY ai_cli_wrapper.py /usr/local/bin/tools/

# Add Python 3 and vim
RUN apt-get update && apt-get install -y python3 python3-pip vim

# Install the latest Gemini CLI globally inside the container
RUN npm install -g @google/gemini-cli --no-update-notifier

RUN node -v
RUN python3 --version
RUN gemini --version

# 5. Set a working directory (good practice)
WORKDIR /app

# 6. Set the default command. When the container starts,
# it will just run "bash", giving you an interactive shell.
CMD ["/bin/bash"]
5 changes: 5 additions & 0 deletions tools/ai_cli_docker_test/build_ai_cli_docker
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
#!/usr/bin/env bash

# run me from the 'ai_cli_docker_test' directory

(cd .. && docker build -f ai_cli_docker_test/Dockerfile -t ai_cli_docker_test_shell .)
6 changes: 6 additions & 0 deletions tools/ai_cli_docker_test/run_ai_cli_docker
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# once inside, can test with
# python3 /usr/local/bin/pr_preflight_launcher.py --output-dir=/tmp --base-sha=f8b8551a720cd5c3a9aa8950e5a50fb7d420cbe5 --head-sha=9f9a6b2f91519b3dc02fe7ec7a5f2a3b98398338

# this assumes you have 'Fuser" at ~/dev/Fuser, which it will mount into /root/Fuser so it can
# do some git work
docker run -it -v ~/dev/Fuser:/root/Fuser -e GEMINI_API_KEY="$GEMINI_API_KEY" --rm ai_cli_docker_test_shell
115 changes: 115 additions & 0 deletions tools/ai_cli_wrapper.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
#!/usr/bin/env python3
"""
Core Wrapper script for running Gemini or Claude CLI in CI.
Accepts a prompt string, expected verdict marker, and output directory path.

Exit codes:
0 - Success (CLI ran, verdict was PASSED)
1 - Network/timeout errors, 'gemini' command not found, or unexpected error
2 - API errors (rate limits, etc.) [best-effort; CLI may not distinguish]
3 - Review Failed (CLI ran, verdict was FAILED)
4 - Parsing Error (CLI ran, but the required verdict marker was missing or ambiguous)
"""

import subprocess
from pathlib import Path
from tools.utils import ensure_dir, write_to_path


def launch_ai_cli(
prompt: str,
tool: str | None,
tool_args : list[str],
verdict_marker: str,
output_dir: Path | str,
timeout_seconds: int = 180
) -> int:


"""
Run Gemini / Claude CLI with the given prompt, check for verdict, and write outputs.
Returns an exit code: 0 (success), 3 (review failed), 4 (parsing error), 1/2 (errors).
"""
OUTPUT_DIR = Path(output_dir)
VERDICT_MARKER = verdict_marker.strip().upper()
ensure_dir(OUTPUT_DIR)

if tool is None:
write_to_path(OUTPUT_DIR, "error.txt", f"Error (Exit 1 - no tool specified)")
return 1

try:
# Invoke CLI; pass prompt as a single argument
safety_instructions = (
"CRITICAL RULE: If a tool execution fails, "
"OR if I have exceeded my API quote, DO NOT retry. "
"Stop immediately and report the error."
)

prompt = f"{safety_instructions}\n\n{prompt}"

result = subprocess.run(
[tool] + tool_args + [prompt],
capture_output=True,
text=True,
timeout=timeout_seconds,
check=False,
)

# Combine stdout and stderr for complete output
full_output = result.stdout
if result.stderr:
full_output += f"\n\n--- STDERR ---\n{result.stderr}"

# --- Phase 1: Check for Tool/API Failure (Exit Codes 1 or 2) ---
if result.returncode != 0:
error_text = (result.stderr or result.stdout or "").lower()
# Try to detect API-ish errors
if any(phrase in error_text for phrase in ["rate limit", "too many requests", "quota", "api error", "unauthorized", "permission"]):
write_to_path(OUTPUT_DIR, "error.txt", f"API Error (Exit 2):\n{full_output}")
return 2

# All other subprocess errors (Exit 1)
write_to_path(OUTPUT_DIR, "error.txt", f"Error (Exit 1 - Subprocess failed with code {result.returncode}):\n{full_output}")
return 1

# --- Phase 2: Tool Success - Analyze Output (Exit Codes 0, 3, or 4) ---
write_to_path(OUTPUT_DIR, "success_raw_output.txt", full_output)

# Look for the verdict marker
verdict_line = next(
(line.strip().upper() for line in full_output.splitlines() if line.strip().upper().startswith(VERDICT_MARKER)),
None
)

if verdict_line is None:
# Verdict marker not found
write_to_path(OUTPUT_DIR, "error.txt", f"Parsing Error (Exit 4): Verdict marker '{VERDICT_MARKER}' not found in output.")
return 4

# Check the verdict
if "PASSED" in verdict_line:
write_to_path(OUTPUT_DIR, "review_verdict.txt", "VERDICT: PASSED")
return 0
elif "FAILED" in verdict_line:
write_to_path(OUTPUT_DIR, "review_verdict.txt", "VERDICT: FAILED")
return 3
else:
# Marker found, but value is ambiguous
write_to_path(OUTPUT_DIR, "error.txt", f"Parsing Error (Exit 4): Found verdict line but value is ambiguous: {verdict_line}")
return 4

except subprocess.TimeoutExpired:
error_msg = f"{tool} command timed out after {timeout_seconds} seconds"
write_to_path(OUTPUT_DIR, "error.txt", error_msg)
return 1

except FileNotFoundError:
error_msg = f"Error: '{tool}' command not found. Is it installed and in PATH?"
write_to_path(OUTPUT_DIR, "error.txt", error_msg)
return 1

except Exception as e:
error_msg = f"Unexpected error: {type(e).__name__}: {str(e)}"
write_to_path(OUTPUT_DIR, "error.txt", error_msg)
return 1
Loading
Loading