Skip to content
Closed
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
23 changes: 23 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -682,3 +682,26 @@ AI-powered enhancements controlled by `ai-features` config (global or per-repo).
**On AI CLI failure:** Error is logged, flow continues without suggestion

**Module:** `webhook_server/libs/ai_cli.py` - shared AI CLI wrapper

### AI Code Review Integration

External AI service integration for PR code review via [pr-ai-reviewer](https://github.com/myk-org/pr-ai-reviewer).

**Schema:** `webhook_server/config/schema.yaml` (`ai-review`), configurable globally or per-repo

**Triggers:** `pr-opened`, `pr-synchronized` (skips clean rebases). Default: both enabled.

**Module:** `webhook_server/libs/ai_review.py` - `call_ai_reviewer()` thin HTTP client

**Features:**

- 3 specialized review agents (quality, guidelines, security) from [pi-config](https://github.com/myk-org/pi-config)
- Single provider: standard AI review
- Multiple providers: peer review with consensus loop
- Posts inline review comments on PR diff lines via GitHub Pull Request Review API

**Error handling:**

- Health check failure: PR comment posted, continue flow
- Review errors: log only, no PR comment
- Never breaks webhook processing
21 changes: 19 additions & 2 deletions examples/config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -123,18 +123,35 @@ test-oracle:
# - pr-opened # Run when PR is opened
# - pr-synchronized # Run when new commits pushed

# AI Code Review integration
# Reviews PR diffs using specialized AI review agents
# See: https://github.com/myk-org/pr-ai-reviewer
ai-review:
server-url: "http://localhost:8001"
providers:
- ai-provider: "claude"
ai-model: "claude-opus-4-6[1m]"
# Add more providers for peer review:
# - ai-provider: "gemini"
# ai-model: "gemini-2.5-pro"
max-rounds: 3 # Max peer review rounds (default: 3)
timeout-minutes: 30 # Timeout per AI CLI call (default: 30)
triggers: # Default: [pr-opened, pr-synchronized]
- pr-opened
- pr-synchronized

# AI Features configuration
# Enables AI-powered enhancements (e.g., conventional title suggestions)
ai-features:
ai-provider: "claude" # claude | gemini | cursor
ai-model: "claude-opus-4-6[1m]"
conventional-title:
enabled: true
mode: suggest # suggest: show in checkrun | fix: auto-update PR title
mode: suggest # suggest: show in checkrun | fix: auto-update PR title
timeout-minutes: 10
resolve-cherry-pick-conflicts-with-ai:
enabled: true
timeout-minutes: 10 # Timeout in minutes for AI CLI (default: 10)
timeout-minutes: 10 # Timeout in minutes for AI CLI (default: 10)

repositories:
my-repository:
Expand Down
71 changes: 71 additions & 0 deletions webhook_server/config/schema.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,73 @@ $defs:
- ai-provider
- ai-model
additionalProperties: false
ai-review:
type: object
description: |
AI-powered code review for pull requests.
See https://github.com/myk-org/pr-ai-reviewer for server setup.
Each provider uses 3 specialized review agents (quality, guidelines, security).
Single provider: standard review.
Multiple providers: peer review (first = main, rest = peers).
They debate until consensus, then final comments are posted.
properties:
server-url:
type: string
format: uri
description: URL of the pr-ai-reviewer server (e.g., http://localhost:8001)
providers:
type: array
description: |
List of AI providers for code review.
Single provider: standard review.
Multiple providers: peer review (first = main reviewer, rest = peers).
items:
type: object
properties:
ai-provider:
type: string
enum:
- claude
- gemini
- cursor
description: AI CLI provider to use for review
ai-model:
type: string
description: AI model identifier
required:
- ai-provider
- ai-model
additionalProperties: false
minItems: 1
max-rounds:
type: integer
minimum: 1
maximum: 5
default: 3
description: Maximum peer review debate rounds before accepting main AI result
timeout-minutes:
type: integer
minimum: 1
default: 30
description: Timeout in minutes for each AI CLI call
triggers:
type: array
default:
- pr-opened
- pr-synchronized
items:
type: string
enum:
- pr-opened
- pr-synchronized
description: |
When to automatically run AI code review. Default: [pr-opened, pr-synchronized].
- pr-opened: Run when a new PR is opened
- pr-synchronized: Run when new commits are pushed to a PR (skips clean rebases)
required:
- server-url
- providers
additionalProperties: false
type: object
properties:
log-level:
Expand Down Expand Up @@ -213,6 +280,8 @@ properties:
additionalProperties: false
ai-features:
$ref: '#/$defs/ai-features'
ai-review:
$ref: '#/$defs/ai-review'
labels:
type: object
description: Configure which labels are enabled and their colors
Expand Down Expand Up @@ -577,6 +646,8 @@ properties:
additionalProperties: false
ai-features:
$ref: '#/$defs/ai-features'
ai-review:
$ref: '#/$defs/ai-review'
custom-check-runs:
type: array
description: |
Expand Down
160 changes: 160 additions & 0 deletions webhook_server/libs/ai_review.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
from __future__ import annotations

import asyncio
from typing import TYPE_CHECKING, Any

import httpx

from webhook_server.utils.constants import AI_REVIEW_STR

if TYPE_CHECKING:
from github.PullRequest import PullRequest

from webhook_server.libs.github_api import GithubWebhook
from webhook_server.libs.handlers.check_run_handler import CheckRunHandler

DEFAULT_TRIGGERS: list[str] = ["pr-opened", "pr-synchronized"]


async def call_ai_reviewer(
github_webhook: GithubWebhook,
pull_request: PullRequest,
check_run_handler: CheckRunHandler,
trigger: str | None = None,
) -> None:
"""Call the pr-ai-reviewer service to review a PR.

Args:
github_webhook: The GithubWebhook instance with config and token.
pull_request: The PyGithub PullRequest object.
check_run_handler: CheckRunHandler for updating check run status.
trigger: The event trigger (e.g., "pr-opened", "pr-synchronized").
None means command-triggered (always runs if configured).
Command trigger is reserved for a future /ai-review comment command.
"""
config = github_webhook.ai_review_config
if not config:
return

# trigger=None means command-triggered (e.g., future /ai-review command).
# Currently only webhook-triggered via pr-opened/pr-synchronized.

if trigger is not None:
triggers: list[str] = config.get("triggers", DEFAULT_TRIGGERS)
if trigger not in triggers:
github_webhook.logger.debug(
f"{github_webhook.log_prefix} AI reviewer trigger '{trigger}' not in configured triggers {triggers}"
)
return

server_url: str = config["server-url"]
log_prefix: str = github_webhook.log_prefix

check_in_progress = False
try:
await check_run_handler.set_check_in_progress(name=AI_REVIEW_STR)
check_in_progress = True

async with httpx.AsyncClient(base_url=server_url) as client:
# Health check
try:
health_response = await client.get("/health", timeout=5.0)
health_response.raise_for_status()
except httpx.HTTPError as e:
status_info = ""
if isinstance(e, httpx.HTTPStatusError):
status_info = f" (status {e.response.status_code})"

msg = f"AI Reviewer server at {server_url} is not responding{status_info}"
github_webhook.logger.warning(f"{log_prefix} {msg}")
await check_run_handler.set_check_failure(
name=AI_REVIEW_STR,
output={"title": "AI Review Failed", "summary": msg},
)
return

# Build review payload
pr_url: str = pull_request.html_url

# Convert provider configs from YAML format to API format
providers_config: list[dict[str, str]] = config.get("providers", [])
providers_payload: list[dict[str, str]] = [
{"ai_provider": p["ai-provider"], "ai_model": p["ai-model"]} for p in providers_config
]
Comment thread
myakove marked this conversation as resolved.

payload: dict[str, Any] = {
"pr_url": pr_url,
"providers": providers_payload,
"github_token": github_webhook.token,
}

if "max-rounds" in config:
payload["max_rounds"] = config["max-rounds"]

if "timeout-minutes" in config:
payload["timeout_minutes"] = config["timeout-minutes"]

# Call review endpoint
try:
github_webhook.logger.info(f"{log_prefix} Calling AI Reviewer for {pr_url}")
# Long timeout: AI review with peer consensus can take up to timeout_minutes * max_rounds
timeout_minutes = config.get("timeout-minutes", 30)
max_rounds = config.get("max-rounds", 3)
# Worst-case: each round takes timeout_minutes (sequential AI calls) + 60s buffer
request_timeout = float(timeout_minutes * max_rounds * 60 + 60)

response = await client.post("/review", json=payload, timeout=request_timeout)
response.raise_for_status()

result = response.json()
review_posted = result.get("review_posted", False)
comments_count = len(result.get("comments", []))
summary = result.get("summary", "no summary")

github_webhook.logger.info(
f"{log_prefix} AI Reviewer complete: {comments_count} comment(s), "
f"review_posted={review_posted}, summary={summary}"
)

await check_run_handler.set_check_success(
name=AI_REVIEW_STR,
output={
"title": "AI Review Complete",
"summary": f"{comments_count} comment(s) posted" if comments_count else "No issues found",
},
)
except httpx.HTTPError as e:
err_detail = ""
if isinstance(e, httpx.HTTPStatusError):
err_detail = f": {e.response.text[:2000]}"
error_msg = f"AI Reviewer request failed{err_detail}"
github_webhook.logger.error(f"{log_prefix} {error_msg}")
await check_run_handler.set_check_failure(
name=AI_REVIEW_STR,
output={"title": "AI Review Failed", "summary": error_msg},
)
except ValueError:
github_webhook.logger.error(f"{log_prefix} AI Reviewer returned invalid JSON response")
await check_run_handler.set_check_failure(
name=AI_REVIEW_STR,
output={"title": "AI Review Failed", "summary": "Invalid JSON response from AI Reviewer"},
)
except asyncio.CancelledError:
if check_in_progress:
try:
await check_run_handler.set_check_failure(
name=AI_REVIEW_STR,
output={"title": "AI Review Cancelled", "summary": "Review was cancelled"},
)
except Exception:
github_webhook.logger.exception(f"{log_prefix} Failed to set check run failure on cancellation")
raise
except Exception:
github_webhook.logger.exception(f"{log_prefix} AI Reviewer call failed unexpectedly")
try:
await check_run_handler.set_check_failure(
name=AI_REVIEW_STR,
output={"title": "AI Review Failed", "summary": "Unexpected error"},
)
except Exception:
github_webhook.logger.exception(f"{log_prefix} Failed to set check run failure")
3 changes: 3 additions & 0 deletions webhook_server/libs/github_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -808,6 +808,9 @@ def _repo_data_from_config(self, repository_config: dict[str, Any]) -> None:
self.ai_features: dict[str, Any] | None = self.config.get_value(
value="ai-features", return_on_none=None, extra_dict=repository_config
)
self.ai_review_config: dict[str, Any] | None = self.config.get_value(
value="ai-review", return_on_none=None, extra_dict=repository_config
)
_auto_merge_prs = self.config.get_value(
value="set-auto-merge-prs", return_on_none=[], extra_dict=repository_config
)
Expand Down
29 changes: 29 additions & 0 deletions webhook_server/libs/handlers/pull_request_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,15 @@
from github.Repository import Repository
from timeout_sampler import TimeoutExpiredError, TimeoutSampler

from webhook_server.libs.ai_review import call_ai_reviewer
from webhook_server.libs.handlers.check_run_handler import CheckRunHandler, CheckRunOutput
from webhook_server.libs.handlers.labels_handler import LabelsHandler
from webhook_server.libs.handlers.owners_files_handler import OwnersFileHandler
from webhook_server.libs.handlers.runner_handler import RunnerHandler
from webhook_server.libs.test_oracle import call_test_oracle
from webhook_server.utils.constants import (
AI_RESOLVED_CONFLICTS_LABEL,
AI_REVIEW_STR,
APPROVED_BY_LABEL_PREFIX,
AUTOMERGE_LABEL_STR,
BRANCH_LABEL_PREFIX,
Expand Down Expand Up @@ -275,6 +277,18 @@ async def process_pull_request_webhook_data(self, pull_request: PullRequest) ->
_background_tasks.add(task)
task.add_done_callback(_background_tasks.discard)

if hook_action == "opened" and self.github_webhook.ai_review_config:
ai_review_task = asyncio.create_task(
call_ai_reviewer(
github_webhook=self.github_webhook,
pull_request=pull_request,
check_run_handler=self.check_run_handler,
trigger="pr-opened",
)
)
_background_tasks.add(ai_review_task)
ai_review_task.add_done_callback(_background_tasks.discard)

if self.ctx:
self.ctx.complete_step("pr_handler", action=hook_action)
return
Expand Down Expand Up @@ -315,6 +329,18 @@ async def process_pull_request_webhook_data(self, pull_request: PullRequest) ->
_background_tasks.add(task)
task.add_done_callback(_background_tasks.discard)

if not clean_rebase and self.github_webhook.ai_review_config:
ai_review_task = asyncio.create_task(
call_ai_reviewer(
github_webhook=self.github_webhook,
pull_request=pull_request,
check_run_handler=self.check_run_handler,
trigger="pr-synchronized",
)
)
_background_tasks.add(ai_review_task)
ai_review_task.add_done_callback(_background_tasks.discard)

if self.ctx:
self.ctx.complete_step("pr_handler", action=hook_action)
return
Expand Down Expand Up @@ -1042,6 +1068,9 @@ async def process_opened_or_synchronize_pull_request(
if self.github_webhook.build_and_push_container:
setup_tasks.append(self.check_run_handler.set_check_queued(name=BUILD_CONTAINER_STR))

if self.github_webhook.ai_review_config:
setup_tasks.append(self.check_run_handler.set_check_queued(name=AI_REVIEW_STR))

if is_clean_rebase:
# label_names is guaranteed non-None when is_clean_rebase=True (caller always provides it)
setup_tasks.append(
Expand Down
Loading