Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
4 changes: 2 additions & 2 deletions .github/workflows/ai-review.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ on:
agents:
description: 'Number of agents (1-3)'
required: false
default: '2'
default: '3'
type: choice
options:
- '1'
Expand Down Expand Up @@ -63,7 +63,7 @@ jobs:
echo "agents=${{ github.event.inputs.agents }}" >> $GITHUB_OUTPUT
else
echo "number=${{ github.event.pull_request.number }}" >> $GITHUB_OUTPUT
echo "agents=2" >> $GITHUB_OUTPUT
echo "agents=3" >> $GITHUB_OUTPUT
fi

- name: Run AI Code Review
Expand Down
38 changes: 33 additions & 5 deletions src/ai_reviewer/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ def cli(verbose: bool) -> None:
@click.option("--output", type=click.Choice(["github", "json", "markdown"]), default="github")
@click.option("--dry-run", is_flag=True, help="Don't post to GitHub")
@click.option(
"--agents", type=int, default=1, help="Number of agents (1-3): 1=comprehensive, 2+=specialized"
"--agents", type=int, default=3, help="Number of agents (1-3): 1=comprehensive, 2+=specialized (default: 3)"
)
@click.option(
"--no-approve", is_flag=True, help="Don't use APPROVE action (auto-enabled in GitHub Actions)"
Expand All @@ -57,6 +57,19 @@ def cli(verbose: bool) -> None:
"--reviewer-name", default="AI Code Reviewer", help="Custom name to display in review header"
)
@click.option("--config", "config_path", type=click.Path(exists=True), help="Config file path")
@click.option(
"--no-cross-review",
"no_cross_review",
is_flag=True,
help="Disable second round where agents validate and rank findings (default: cross-review on when --agents>=2)",
)
@click.option(
"--min-agreement",
"min_agreement",
type=click.FloatRange(0.0, 1.0),
default=2 / 3,
help="Fraction of assessing agents that must mark a finding valid (default: 2/3; with 2 agents, both must agree)",
)
def review_pr(
repo: str,
pr_number: int,
Expand All @@ -66,12 +79,16 @@ def review_pr(
no_approve: bool,
reviewer_name: str,
config_path: str | None,
no_cross_review: bool,
min_agreement: float,
) -> None:
"""Review a GitHub pull request using Cursor AI agent(s).

With --agents=1 (default): Single comprehensive review
With --agents=2: Security + Performance agents
With --agents=3: Security + Performance + Quality agents
With --agents=1: Single comprehensive review
With --agents=2: Security + Performance agents (cross-review on by default)
With --agents=3 (default): Security + Performance + Quality agents (cross-review on by default)
Use --no-cross-review to skip the second round (validate & rank findings).
Use --min-agreement to tune how many agents must validate a finding to keep it (0-1).
"""
asyncio.run(
review_pr_async(
Expand All @@ -83,6 +100,8 @@ def review_pr(
no_approve=no_approve,
reviewer_name=reviewer_name,
config_path=Path(config_path) if config_path else None,
enable_cross_review=not no_cross_review,
min_validation_agreement=min_agreement,
)
)

Expand All @@ -92,10 +111,12 @@ async def review_pr_async(
pr_number: int,
output: str = "github",
dry_run: bool = False,
num_agents: int = 1,
num_agents: int = 3,
no_approve: bool = False,
reviewer_name: str = "AI Code Reviewer",
config_path: Path | None = None,
enable_cross_review: bool = True,
min_validation_agreement: float = 2 / 3,
) -> None:
"""Async implementation of PR review using Cursor Background Agent(s)."""
# Auto-detect GitHub Actions environment - never allow APPROVE there
Expand All @@ -120,6 +141,11 @@ async def review_pr_async(
console.print(
f"[yellow]Using {num_agents} specialized agents: {', '.join(agent_types)} (3-8 min)[/yellow]"
)
if num_agents > 1:
if enable_cross_review:
console.print("[dim]Cross-review enabled: agents will validate and rank findings[/dim]")
else:
console.print("[dim]Cross-review disabled[/dim]")

# Status callback
last_status = [None]
Expand All @@ -144,6 +170,8 @@ def on_status(status: str) -> None:
github_token=config.github.token,
on_status=on_status,
num_agents=num_agents,
enable_cross_review=enable_cross_review,
min_validation_agreement=min_validation_agreement,
)
except Exception as e:
console.print(f"[red]Error:[/red] {e}")
Expand Down
23 changes: 12 additions & 11 deletions src/ai_reviewer/github/formatter.py
Original file line number Diff line number Diff line change
Expand Up @@ -410,6 +410,9 @@ def get_review_action_with_delta(
) -> str:
"""Determine GitHub review action considering the delta.

LGTM-with-comments: REQUEST_CHANGES only for critical findings. When there are
only warnings, suggestions, or nitpicks, returns COMMENT so the author isn't blocked.

Args:
review: Consolidated review
delta: Review delta
Expand All @@ -418,25 +421,25 @@ def get_review_action_with_delta(
Returns:
GitHub review action
"""
# If all issues are resolved, approve (if allowed)
if delta.all_issues_resolved and allow_approve:
return "APPROVE"

# If there are critical issues (new or open), request changes
# But only if allow_approve is True (i.e., not in GitHub Actions)
# GitHub Actions can't approve, and REQUEST_CHANGES blocks merging
# Block merge only when there are critical findings (not warnings/suggestions/nitpicks)
has_critical = any(
f.severity.value == "critical" for f in delta.new_findings + delta.open_findings
)
if has_critical and allow_approve:
return "REQUEST_CHANGES"

# In GitHub Actions or no critical issues, just comment
# No critical: COMMENT (includes only nits/suggestions/warnings — don't block author)
return "COMMENT"

def get_review_action(self, review: ConsolidatedReview, allow_approve: bool = True) -> str:
"""Determine the GitHub review action based on findings.

LGTM-with-comments: REQUEST_CHANGES only for critical findings. When there are
only warnings, suggestions, or nitpicks, returns COMMENT so the author isn't blocked.

Args:
review: Consolidated review
allow_approve: Whether to allow APPROVE/REQUEST_CHANGES actions
Expand All @@ -445,14 +448,12 @@ def get_review_action(self, review: ConsolidatedReview, allow_approve: bool = Tr
Returns:
GitHub review action: "APPROVE", "REQUEST_CHANGES", or "COMMENT"
"""
# REQUEST_CHANGES blocks merging - only use when allow_approve is True
# (i.e., running locally with proper permissions)
if not review.findings and allow_approve:
return "APPROVE"
# Block merge only on critical; warnings/suggestions/nitpicks → COMMENT
if review.has_critical_issues and allow_approve:
return "REQUEST_CHANGES"
elif not review.findings and allow_approve:
return "APPROVE"
else:
return "COMMENT"
return "COMMENT"


def format_review_as_json(review: ConsolidatedReview) -> dict:
Expand Down
35 changes: 31 additions & 4 deletions src/ai_reviewer/github/webhook.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import hmac
import json
import logging
import os
from collections.abc import Callable
from dataclasses import dataclass

Expand All @@ -13,6 +14,24 @@
logger = logging.getLogger(__name__)


def _get_env_int(key: str, default: int) -> int:
"""Parse env var as int; on ValueError log and return default."""
try:
return int(os.environ.get(key, str(default)))
except ValueError:
logger.warning("%s invalid, using default %s", key, default)
return default


def _get_env_float(key: str, default: float) -> float:
"""Parse env var as float; on ValueError log and return default."""
try:
return float(os.environ.get(key, str(default)))
except ValueError:
logger.warning("%s invalid, using default %s", key, default)
return default


@dataclass
class PREvent:
"""Represents a PR webhook event."""
Expand Down Expand Up @@ -109,8 +128,6 @@ def _setup_default_review_handler() -> None:
This is used when running as a standalone server (e.g., Cloud Run)
without the CLI's explicit handler setup.
"""
import os

from ai_reviewer.review import review_pr_with_cursor_agent
from ai_reviewer.agents.cursor_client import CursorConfig
from ai_reviewer.github.client import GitHubClient
Expand Down Expand Up @@ -140,10 +157,18 @@ async def default_review_handler(repo: str, pr_number: int) -> None:
logger.error("GITHUB_TOKEN not set")
return

cursor_timeout = _get_env_int("CURSOR_TIMEOUT", 300)
num_agents = _get_env_int("NUM_AGENTS", 3)
min_agreement = _get_env_float("MIN_VALIDATION_AGREEMENT", 2 / 3)

cursor_config = CursorConfig(
api_key=cursor_api_key,
base_url=os.environ.get("CURSOR_BASE_URL", "https://api.cursor.com/v0"),
timeout=int(os.environ.get("CURSOR_TIMEOUT", "300")),
timeout=cursor_timeout,
)

enable_cross_review = (
os.environ.get("ENABLE_CROSS_REVIEW", "true").lower() != "false"
)

try:
Expand All @@ -152,7 +177,9 @@ async def default_review_handler(repo: str, pr_number: int) -> None:
pr_number=pr_number,
cursor_config=cursor_config,
github_token=github_token,
num_agents=int(os.environ.get("NUM_AGENTS", "1")),
num_agents=num_agents,
enable_cross_review=enable_cross_review,
min_validation_agreement=min_agreement,
)

if review.all_agents_failed:
Expand Down
Loading