diff --git a/CLAUDE.md b/CLAUDE.md index 8ae14469..31ea0493 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -672,3 +672,18 @@ External AI service integration for test recommendations via [pr-test-oracle](ht - Health check failure: PR comment posted, continue flow - Analyze errors: log only, no PR comment - Never breaks webhook processing + +### AI Features + +AI-powered enhancements controlled by `ai-features` config (global or per-repo). + +**Config keys:** `ai-provider` (required: claude/gemini/cursor), `ai-model` (required), `conventional-title` (optional: "true"/"false"/"fix", default: "false") + +**Conventional title modes:** +- `"true"`: Show AI-suggested title in check run output when validation fails +- `"false"`: Disabled (default) +- `"fix"`: Auto-update PR title with AI suggestion when validation fails (suggestion is validated before applying) + +**On AI CLI failure:** Error is logged, flow continues without suggestion + +**Module:** `webhook_server/libs/ai_cli.py` - shared AI CLI wrapper diff --git a/Dockerfile b/Dockerfile index 31faa40b..e8c839a8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -40,10 +40,6 @@ RUN mkdir -p $BIN_DIR \ && mkdir -p $DATA_DIR \ && mkdir -p $DATA_DIR/logs -COPY entrypoint.py pyproject.toml uv.lock README.md $APP_DIR/ -COPY webhook_server $APP_DIR/webhook_server/ -COPY scripts $APP_DIR/scripts/ - RUN usermod --add-subuids 100000-165535 --add-subgids 100000-165535 $USERNAME \ && chown -R $USERNAME:$USERNAME $HOME_DIR @@ -59,7 +55,7 @@ ENV UV_PYTHON=python3.13 \ COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx ${BIN_DIR}/ RUN uv tool install pre-commit && uv tool install poetry && uv tool install prek && uv tool install tox -# Install AI CLI tools for pr-test-oracle integration +# Install AI CLI tools # Claude Code CLI (installs to ~/.local/bin) RUN /bin/bash -o pipefail -c "curl -fsSL https://claude.ai/install.sh | bash" @@ -79,10 +75,17 @@ RUN set -ex \ && curl --fail -vL https://github.com/mislav/hub/releases/download/v2.14.2/hub-linux-amd64-2.14.2.tgz | tar --wildcards --strip-components=2 -C $BIN_DIR -xzvf - '*/bin/hub' \ && chmod +x $BIN_DIR/hub -WORKDIR $APP_DIR +# Copy dependency manifests first for uv sync cache stability +COPY --chown=$USERNAME:$USERNAME pyproject.toml uv.lock README.md $APP_DIR/ +WORKDIR $APP_DIR RUN uv sync +# Copy application code after dependency install +COPY --chown=$USERNAME:$USERNAME entrypoint.py $APP_DIR/ +COPY --chown=$USERNAME:$USERNAME webhook_server $APP_DIR/webhook_server/ +COPY --chown=$USERNAME:$USERNAME scripts $APP_DIR/scripts/ + HEALTHCHECK CMD curl --fail http://127.0.0.1:5000/webhook_server/healthcheck || exit 1 ENTRYPOINT ["tini", "--", "uv", "run", "entrypoint.py"] diff --git a/examples/.github-webhook-server.yaml b/examples/.github-webhook-server.yaml index b3f45f37..53698802 100644 --- a/examples/.github-webhook-server.yaml +++ b/examples/.github-webhook-server.yaml @@ -67,7 +67,7 @@ auto-verified-and-merged-users: - "trusted-user" # Auto-verify cherry-picked PRs (default: true) -auto-verify-cherry-picked-prs: false # Set to false to require manual verification for cherry-picked PRs +auto-verify-cherry-picked-prs: false # Set to false to require manual verification for cherry-picked PRs # Repository-specific GitHub tokens github-tokens: @@ -119,9 +119,9 @@ conventional-title: "feat,fix,build,chore,ci,docs,style,refactor,perf,test,rever minimum-lgtm: 2 # Issue creation for new pull requests -create-issue-for-new-pr: true # Create tracking issues for new PRs +create-issue-for-new-pr: true # Create tracking issues for new PRs -cherry-pick-assign-to-pr-author: true # Assign cherry-pick PRs to the original PR author (default: true) +cherry-pick-assign-to-pr-author: true # Assign cherry-pick PRs to the original PR author (default: true) # Custom PR size labels for this repository (overrides global configuration) # Define custom categories based on total lines changed (additions + deletions) @@ -131,28 +131,35 @@ cherry-pick-assign-to-pr-author: true # Assign cherry-pick PRs to the original # Always sorted last, regardless of definition order pr-size-thresholds: Quick: - threshold: 20 # PRs with 0-19 lines changed + threshold: 20 # PRs with 0-19 lines changed color: lightgreen Normal: - threshold: 100 # PRs with 20-99 lines changed + threshold: 100 # PRs with 20-99 lines changed color: green Complex: - threshold: 300 # PRs with 100-299 lines changed + threshold: 300 # PRs with 100-299 lines changed color: orange Critical: - threshold: 1000 # PRs with 300-999 lines changed + threshold: 1000 # PRs with 300-999 lines changed color: darkred Extreme: - threshold: inf # PRs with 1000+ lines changed (unbounded largest category) - color: black # 'inf' means no upper limit - catches all PRs above 1000 lines + threshold: inf # PRs with 1000+ lines changed (unbounded largest category) + color: black # 'inf' means no upper limit - catches all PRs above 1000 lines + +# 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: "true" # "true": suggest in checkrun | "false": disabled | "fix": auto-update PR title # PR Test Oracle integration (overrides global config) # Analyzes PR diffs with AI and recommends which tests to run # See: https://github.com/myk-org/pr-test-oracle test-oracle: server-url: "http://localhost:8000" - ai-provider: "claude" # claude | gemini | cursor - ai-model: "sonnet" + ai-provider: "claude" # claude | gemini | cursor + ai-model: "claude-opus-4-6[1m]" test-patterns: - "tests/**/*.py" triggers: diff --git a/examples/config.yaml b/examples/config.yaml index dcd88a7b..f693ce6a 100644 --- a/examples/config.yaml +++ b/examples/config.yaml @@ -29,11 +29,11 @@ auto-verified-and-merged-users: - "renovate[bot]" - "pre-commit-ci[bot]" -auto-verify-cherry-picked-prs: true # Default: true - automatically verify cherry-picked PRs. Set to false to require manual verification. +auto-verify-cherry-picked-prs: true # Default: true - automatically verify cherry-picked PRs. Set to false to require manual verification. -create-issue-for-new-pr: true # Global default: create tracking issues for new PRs +create-issue-for-new-pr: true # Global default: create tracking issues for new PRs -cherry-pick-assign-to-pr-author: true # Default: true - assign cherry-pick PRs to the original PR author +cherry-pick-assign-to-pr-author: true # Default: true - assign cherry-pick PRs to the original PR author # Commands allowed on draft PRs (optional) # If not set: commands are blocked on draft PRs (default behavior) @@ -86,20 +86,20 @@ labels: # Always sorted last, regardless of definition order pr-size-thresholds: Tiny: - threshold: 10 # PRs with 0-9 lines changed + threshold: 10 # PRs with 0-9 lines changed color: lightgray Small: - threshold: 50 # PRs with 10-49 lines changed + threshold: 50 # PRs with 10-49 lines changed color: green Medium: - threshold: 150 # PRs with 50-149 lines changed + threshold: 150 # PRs with 50-149 lines changed color: orange Large: - threshold: 300 # PRs with 150-299 lines changed + threshold: 300 # PRs with 150-299 lines changed color: red Massive: - threshold: inf # PRs with 300+ lines changed (unbounded largest category) - color: darkred # 'inf' means no upper limit - catches all PRs above 300 lines + threshold: inf # PRs with 300+ lines changed (unbounded largest category) + color: darkred # 'inf' means no upper limit - catches all PRs above 300 lines branch-protection: strict: True @@ -114,15 +114,22 @@ branch-protection: # See: https://github.com/myk-org/pr-test-oracle test-oracle: server-url: "http://localhost:8000" - ai-provider: "claude" # claude | gemini | cursor - ai-model: "sonnet" + ai-provider: "claude" # claude | gemini | cursor + ai-model: "claude-opus-4-6[1m]" test-patterns: - "tests/**/*.py" - triggers: # Default: [approved] - - approved # Run when PR gets approved + triggers: # Default: [approved] + - approved # Run when /approve command is used # - pr-opened # Run when PR is opened # - pr-synchronized # Run when new commits pushed +# 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: "true" # "true": suggest in checkrun | "false": disabled | "fix": auto-update PR title + repositories: my-repository: name: my-org/my-repository @@ -169,7 +176,7 @@ repositories: auto-verified-and-merged-users: # override auto verified users per repository - "my[bot]" - auto-verify-cherry-picked-prs: false # Disable auto-verification for cherry-picked PRs in this repository + auto-verify-cherry-picked-prs: false # Disable auto-verification for cherry-picked PRs in this repository github-tokens: # override GitHub tokens per repository - @@ -216,7 +223,7 @@ repositories: # Allow commands on draft PRs (overrides global setting) allow-commands-on-draft-prs: - - build-and-push-container # Allow building containers on draft PRs + - build-and-push-container # Allow building containers on draft PRs # Repository-specific labels configuration (overrides global) labels: @@ -230,18 +237,24 @@ repositories: # Repository-specific PR size labels (overrides global configuration) pr-size-thresholds: Express: - threshold: 25 # PRs with 0-24 lines changed + threshold: 25 # PRs with 0-24 lines changed color: lightblue Standard: - threshold: 100 # PRs with 25-99 lines changed + threshold: 100 # PRs with 25-99 lines changed color: green Premium: - threshold: 500 # PRs with 100-499 lines changed - color: orange # PRs with 500+ lines changed get this category + threshold: 500 # PRs with 100-499 lines changed + color: orange # PRs with 500+ lines changed get this category set-auto-merge-prs: - main + # AI Features configuration (overrides global) + # Enables AI-powered enhancements (e.g., conventional title suggestions) + # ai-features: + # ai-provider: "claude" # claude | gemini | cursor + # ai-model: "sonnet" + # PR Test Oracle (overrides global) # See: https://github.com/myk-org/pr-test-oracle test-oracle: diff --git a/webhook_server/config/schema.yaml b/webhook_server/config/schema.yaml index 6a36b0fc..0cac11c3 100644 --- a/webhook_server/config/schema.yaml +++ b/webhook_server/config/schema.yaml @@ -1,5 +1,39 @@ $schema: https://json-schema.org/draft-07/schema# title: Webhook Server Configuration +$defs: + ai-features: + type: object + description: | + AI features configuration. Enables AI-powered enhancements such as + conventional title suggestions when validation fails. + Supported AI providers: claude, gemini, cursor. + properties: + ai-provider: + type: string + enum: + - claude + - gemini + - cursor + description: AI CLI provider to use + ai-model: + type: string + description: AI model identifier (e.g., claude-opus-4-6[1m], sonnet, gemini-2.5-pro) + conventional-title: + type: string + enum: + - "true" + - "false" + - "fix" + description: | + AI-powered conventional title suggestions. + - "true": Show AI-suggested title in check run output when validation fails + - "false": Disabled (default) + - "fix": Auto-fix PR title with AI suggestion when validation fails + default: "false" + required: + - ai-provider + - ai-model + additionalProperties: false type: object properties: log-level: @@ -143,6 +177,8 @@ properties: - ai-provider - ai-model additionalProperties: false + ai-features: + $ref: '#/$defs/ai-features' labels: type: object description: Configure which labels are enabled and their colors @@ -505,6 +541,8 @@ properties: - ai-provider - ai-model additionalProperties: false + ai-features: + $ref: '#/$defs/ai-features' custom-check-runs: type: array description: | diff --git a/webhook_server/libs/ai_cli.py b/webhook_server/libs/ai_cli.py new file mode 100644 index 00000000..2f2ea978 --- /dev/null +++ b/webhook_server/libs/ai_cli.py @@ -0,0 +1,119 @@ +from __future__ import annotations + +import asyncio +import subprocess +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any + + +@dataclass(frozen=True) +class ProviderConfig: + """Configuration for an AI CLI provider.""" + + binary: str + build_cmd: Callable[[str, str, str], list[str]] + + +def _build_claude_cmd(binary: str, model: str, _cwd: str) -> list[str]: + return [binary, "--model", model, "--dangerously-skip-permissions", "-p"] + + +def _build_gemini_cmd(binary: str, model: str, _cwd: str) -> list[str]: + return [binary, "--model", model, "--yolo"] + + +def _build_cursor_cmd(binary: str, model: str, cwd: str) -> list[str]: + return [binary, "--force", "--model", model, "--print", "--workspace", cwd] + + +PROVIDER_CONFIG: dict[str, ProviderConfig] = { + "claude": ProviderConfig(binary="claude", build_cmd=_build_claude_cmd), + "gemini": ProviderConfig(binary="gemini", build_cmd=_build_gemini_cmd), + "cursor": ProviderConfig(binary="agent", build_cmd=_build_cursor_cmd), +} + + +async def call_ai_cli( + prompt: str, + ai_provider: str, + ai_model: str, + cwd: str, + logger: Any, + timeout_minutes: int = 10, +) -> tuple[bool, str]: + """Call an AI CLI tool with a prompt via stdin. + + Args: + prompt: The prompt text to send. + ai_provider: Provider name (claude, gemini, cursor). + ai_model: Model identifier. + cwd: Working directory for the CLI (repo clone path). Required for AI + to have access to repository context (diff, files, etc.). + logger: Contextual logger instance for structured logging. + timeout_minutes: Timeout in minutes. + + Returns: + Tuple of (success, output_or_error). + """ + provider = PROVIDER_CONFIG.get(ai_provider) + if not provider: + return False, f"Unknown AI provider: {ai_provider}" + + cmd = provider.build_cmd(provider.binary, ai_model, cwd) + timeout_seconds = timeout_minutes * 60 + + # For cursor, cwd is passed via --workspace flag, not subprocess cwd + subprocess_cwd = cwd if ai_provider != "cursor" else None + + try: + result = await asyncio.to_thread( + subprocess.run, + cmd, + capture_output=True, + text=True, + timeout=timeout_seconds, + input=prompt, + cwd=subprocess_cwd, + ) + + if result.returncode != 0: + error = result.stderr.strip() or result.stdout.strip() or "Unknown error" + logger.error(f"AI CLI ({ai_provider}) failed: {error}") + return False, error + + output = result.stdout.strip() + if not output: + return False, "AI CLI returned empty response" + + return True, output + + except subprocess.TimeoutExpired: + logger.exception(f"AI CLI ({ai_provider}) timed out after {timeout_minutes} minutes") + return False, f"AI CLI timed out after {timeout_minutes} minutes" + except FileNotFoundError: + logger.exception(f"AI CLI binary '{provider.binary}' not found") + return False, f"AI CLI binary '{provider.binary}' not found" + except asyncio.CancelledError: + raise + except Exception: + logger.exception(f"AI CLI ({ai_provider}) unexpected error") + return False, "Unexpected error calling AI CLI" + + +def get_ai_config(config_value: dict[str, Any] | None) -> tuple[str, str] | None: + """Extract AI provider and model from ai-features config. + + Returns: + Tuple of (ai_provider, ai_model) or None if not configured or incomplete. + """ + if not config_value: + return None + + ai_provider = config_value.get("ai-provider") + ai_model = config_value.get("ai-model") + + if not ai_provider or not ai_model: + return None + + return ai_provider, ai_model diff --git a/webhook_server/libs/github_api.py b/webhook_server/libs/github_api.py index 23704bd3..734d1953 100644 --- a/webhook_server/libs/github_api.py +++ b/webhook_server/libs/github_api.py @@ -719,6 +719,9 @@ def _repo_data_from_config(self, repository_config: dict[str, Any]) -> None: ) self.can_be_merged_required_labels: list[str] = _required_labels if isinstance(_required_labels, list) else [] self.conventional_title: str = self.config.get_value(value="conventional-title", extra_dict=repository_config) + self.ai_features: dict[str, Any] | None = self.config.get_value( + value="ai-features", 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 ) diff --git a/webhook_server/libs/handlers/runner_handler.py b/webhook_server/libs/handlers/runner_handler.py index 8fbc3f23..371bffa7 100644 --- a/webhook_server/libs/handlers/runner_handler.py +++ b/webhook_server/libs/handlers/runner_handler.py @@ -14,6 +14,7 @@ from github.PullRequest import PullRequest from github.Repository import Repository +from webhook_server.libs.ai_cli import call_ai_cli, get_ai_config from webhook_server.libs.handlers.check_run_handler import CheckRunHandler, CheckRunOutput from webhook_server.libs.handlers.owners_files_handler import OwnersFileHandler from webhook_server.utils import helpers as helpers_module @@ -518,8 +519,129 @@ async def run_conventional_title_check(self, pull_request: PullRequest) -> None: **Resources:** - [Conventional Commits v1.0.0 Specification](https://www.conventionalcommits.org/en/v1.0.0/) """ + # AI-suggested title (if ai-features configured) + ai_suggestion = await self._get_ai_title_suggestion( + title=title, + allowed_names=allowed_names, + is_wildcard=is_wildcard, + ) + + ai_mode = self._get_ai_conventional_title_mode() + + if ai_suggestion and ai_mode == "fix": + # Validate the suggestion before applying + if is_wildcard: + suggestion_valid = bool(re.match(r"^[\w-]+(\([^)]+\))?!?: .+", ai_suggestion)) + else: + suggestion_valid = any( + re.match(rf"^{re.escape(_name)}(\([^)]+\))?!?: .+", ai_suggestion) for _name in allowed_names + ) + + if suggestion_valid and ai_suggestion != title: + self.logger.info(f"{self.log_prefix} AI fixing PR title from '{title}' to '{ai_suggestion}'") + try: + await asyncio.to_thread(pull_request.edit, title=ai_suggestion) + if output["text"] is not None: + output["text"] += ( + f"\n\n---\n\n### AI Auto-Fix\n\n" + f"Title updated to: `{ai_suggestion}`\n" + f"Check will re-run automatically." + ) + except Exception: + self.logger.exception(f"{self.log_prefix} Failed to auto-fix PR title") + if output["text"] is not None: + output["text"] += ( + f"\n\n---\n\n### AI Auto-Fix Failed\n\n" + f"Suggested title: `{ai_suggestion}`\n" + f"Failed to update PR title automatically. Please update manually." + ) + else: + self.logger.warning( + f"{self.log_prefix} AI suggestion invalid or unchanged, skipping auto-fix: {ai_suggestion}" + ) + if output["text"] is not None: + output["text"] += ( + f"\n\n---\n\n### AI Auto-Fix Skipped\n\n" + f"AI suggested: `{ai_suggestion}`\n" + f"Suggestion was invalid or unchanged." + ) + + elif ai_suggestion and ai_mode == "true" and output["text"] is not None: + output["text"] += f"\n\n---\n\n### AI-Suggested Title\n\n> {ai_suggestion}\n" + await self.check_run_handler.set_check_failure(name=CONVENTIONAL_TITLE_STR, output=output) + def _get_ai_conventional_title_mode(self) -> str | None: + """Get the conventional-title AI mode from config. + + Returns: + "true" for suggestion mode, "fix" for auto-fix mode, or None if disabled. + """ + ai_config = self.github_webhook.ai_features + if not ai_config: + return None + + ct_value = ai_config.get("conventional-title", "false") + if ct_value == "true": + return "true" + if ct_value == "fix": + return "fix" + return None + + async def _get_ai_title_suggestion(self, title: str, allowed_names: list[str], *, is_wildcard: bool) -> str | None: + """Get an AI-suggested conventional title when validation fails. + + Returns the suggestion string or None if AI features are not configured or on error. + """ + mode = self._get_ai_conventional_title_mode() + if not mode: + return None + + ai_result = get_ai_config(self.github_webhook.ai_features) + if not ai_result: + return None + + ai_provider, ai_model = ai_result + + if is_wildcard: + types_info = "Any type name is accepted (wildcard mode)." + else: + types_info = f"Allowed types: {', '.join(allowed_names)}" + + # The repository clone is already checked out to the PR branch + # (done by _clone_repository in github_api.py), so the AI CLI has + # full access to the PR's commits and changes from the cwd. + prompt = ( + "You are in a git repository checked out to a PR branch.\n" + "Look at the recent commits and changes to understand what this PR does.\n" + f"Current PR title: {title}\n" + f"{types_info}\n" + f"Format: [optional scope]: \n" + "Reply with ONLY the suggested title, nothing else." + ) + + try: + success, result = await call_ai_cli( + prompt=prompt, + ai_provider=ai_provider, + ai_model=ai_model, + cwd=self.github_webhook.clone_repo_dir, + logger=self.logger, + ) + + if success: + # Clean up the response - take first line, strip backticks/quotes + suggestion = result.strip().splitlines()[0].strip().strip("`").strip('"').strip("'") + self.logger.info(f"{self.log_prefix} AI suggested title: {suggestion}") + return suggestion + + self.logger.warning(f"{self.log_prefix} AI title suggestion failed: {result}") + return None + + except Exception: + self.logger.exception(f"{self.log_prefix} AI title suggestion failed unexpectedly") + return None + async def run_custom_check( self, pull_request: PullRequest, diff --git a/webhook_server/tests/test_ai_cli.py b/webhook_server/tests/test_ai_cli.py new file mode 100644 index 00000000..f589fe32 --- /dev/null +++ b/webhook_server/tests/test_ai_cli.py @@ -0,0 +1,250 @@ +"""Tests for webhook_server.libs.ai_cli module.""" + +from __future__ import annotations + +import asyncio +import subprocess +from unittest.mock import AsyncMock, Mock, patch + +import pytest + +from webhook_server.libs.ai_cli import PROVIDER_CONFIG, call_ai_cli, get_ai_config + + +class TestCallAiCli: + """Test suite for call_ai_cli function.""" + + @pytest.fixture + def mock_logger(self) -> Mock: + return Mock() + + @pytest.mark.asyncio + async def test_successful_call(self, mock_logger: Mock) -> None: + """Test successful AI CLI call.""" + mock_result = Mock() + mock_result.returncode = 0 + mock_result.stdout = "feat: add new feature" + mock_result.stderr = "" + + with patch("asyncio.to_thread", new_callable=AsyncMock, return_value=mock_result): + success, output = await call_ai_cli( + prompt="suggest a title", + ai_provider="claude", + ai_model="sonnet", + cwd="/tmp/repo", + logger=mock_logger, + ) + + assert success is True + assert output == "feat: add new feature" + + @pytest.mark.asyncio + async def test_unknown_provider(self, mock_logger: Mock) -> None: + """Test unknown AI provider returns error.""" + success, output = await call_ai_cli( + prompt="test", + ai_provider="unknown", + ai_model="model", + cwd="/tmp/repo", + logger=mock_logger, + ) + + assert success is False + assert "Unknown AI provider" in output + + @pytest.mark.asyncio + async def test_cli_failure(self, mock_logger: Mock) -> None: + """Test CLI returning non-zero exit code.""" + mock_result = Mock() + mock_result.returncode = 1 + mock_result.stdout = "" + mock_result.stderr = "Error: invalid model" + + with patch("asyncio.to_thread", new_callable=AsyncMock, return_value=mock_result): + success, output = await call_ai_cli( + prompt="test", + ai_provider="claude", + ai_model="bad-model", + cwd="/tmp/repo", + logger=mock_logger, + ) + + assert success is False + assert "invalid model" in output + mock_logger.error.assert_called_once() + + @pytest.mark.asyncio + async def test_timeout(self, mock_logger: Mock) -> None: + """Test CLI timeout.""" + with patch( + "asyncio.to_thread", + new_callable=AsyncMock, + side_effect=subprocess.TimeoutExpired(cmd="claude", timeout=300), + ): + success, output = await call_ai_cli( + prompt="test", + ai_provider="claude", + ai_model="sonnet", + cwd="/tmp/repo", + logger=mock_logger, + timeout_minutes=5, + ) + + assert success is False + assert "timed out" in output + mock_logger.exception.assert_called_once() + + @pytest.mark.asyncio + async def test_binary_not_found(self, mock_logger: Mock) -> None: + """Test CLI binary not found.""" + with patch( + "asyncio.to_thread", + new_callable=AsyncMock, + side_effect=FileNotFoundError(), + ): + success, output = await call_ai_cli( + prompt="test", + ai_provider="claude", + ai_model="sonnet", + cwd="/tmp/repo", + logger=mock_logger, + ) + + assert success is False + assert "not found" in output + mock_logger.exception.assert_called_once() + + @pytest.mark.asyncio + async def test_empty_response(self, mock_logger: Mock) -> None: + """Test CLI returning empty response.""" + mock_result = Mock() + mock_result.returncode = 0 + mock_result.stdout = "" + mock_result.stderr = "" + + with patch("asyncio.to_thread", new_callable=AsyncMock, return_value=mock_result): + success, output = await call_ai_cli( + prompt="test", + ai_provider="gemini", + ai_model="gemini-2.5-pro", + cwd="/tmp/repo", + logger=mock_logger, + ) + + assert success is False + assert "empty response" in output + + @pytest.mark.asyncio + async def test_cancelled_error_reraised(self, mock_logger: Mock) -> None: + """Test that CancelledError is re-raised, not caught.""" + with patch("asyncio.to_thread", new_callable=AsyncMock, side_effect=asyncio.CancelledError()): + with pytest.raises(asyncio.CancelledError): + await call_ai_cli( + prompt="test", ai_provider="claude", ai_model="sonnet", cwd="/tmp/repo", logger=mock_logger + ) + + @pytest.mark.asyncio + async def test_unexpected_exception(self, mock_logger: Mock) -> None: + """Test that unexpected exceptions return failure tuple.""" + with patch("asyncio.to_thread", new_callable=AsyncMock, side_effect=RuntimeError("boom")): + success, output = await call_ai_cli( + prompt="test", ai_provider="claude", ai_model="sonnet", cwd="/tmp/repo", logger=mock_logger + ) + assert success is False + assert "Unexpected error" in output + mock_logger.exception.assert_called_once() + + def test_provider_configs(self) -> None: + """Test provider configurations are correct.""" + assert "claude" in PROVIDER_CONFIG + assert "gemini" in PROVIDER_CONFIG + assert "cursor" in PROVIDER_CONFIG + + assert PROVIDER_CONFIG["claude"].binary == "claude" + assert PROVIDER_CONFIG["gemini"].binary == "gemini" + assert PROVIDER_CONFIG["cursor"].binary == "agent" + + def test_claude_command(self) -> None: + """Test Claude command construction.""" + config = PROVIDER_CONFIG["claude"] + cmd = config.build_cmd(config.binary, "sonnet", "/tmp/repo") + assert cmd == ["claude", "--model", "sonnet", "--dangerously-skip-permissions", "-p"] + + def test_gemini_command(self) -> None: + """Test Gemini command construction.""" + config = PROVIDER_CONFIG["gemini"] + cmd = config.build_cmd(config.binary, "gemini-2.5-pro", "/tmp/repo") + assert cmd == ["gemini", "--model", "gemini-2.5-pro", "--yolo"] + + def test_cursor_command(self) -> None: + """Test Cursor command construction with cwd.""" + config = PROVIDER_CONFIG["cursor"] + cmd = config.build_cmd(config.binary, "cursor-model", "/tmp/repo") + assert cmd == ["agent", "--force", "--model", "cursor-model", "--print", "--workspace", "/tmp/repo"] + + @pytest.mark.asyncio + async def test_cwd_passed_to_subprocess(self, mock_logger: Mock) -> None: + """Test that cwd is passed to subprocess.run for non-cursor providers.""" + mock_result = Mock() + mock_result.returncode = 0 + mock_result.stdout = "feat: suggestion" + mock_result.stderr = "" + + with patch("asyncio.to_thread", new_callable=AsyncMock, return_value=mock_result) as mock_to_thread: + await call_ai_cli( + prompt="test", + ai_provider="claude", + ai_model="sonnet", + cwd="/tmp/test-repo", + logger=mock_logger, + ) + + # Verify subprocess.run was called with cwd + call_kwargs = mock_to_thread.call_args[1] + assert call_kwargs.get("cwd") == "/tmp/test-repo" + + @pytest.mark.asyncio + async def test_cursor_cwd_not_in_subprocess(self, mock_logger: Mock) -> None: + """Test that cursor provider does not pass cwd to subprocess (uses --workspace instead).""" + mock_result = Mock() + mock_result.returncode = 0 + mock_result.stdout = "feat: suggestion" + mock_result.stderr = "" + + with patch("asyncio.to_thread", new_callable=AsyncMock, return_value=mock_result) as mock_to_thread: + await call_ai_cli( + prompt="test", + ai_provider="cursor", + ai_model="cursor-model", + cwd="/tmp/test-repo", + logger=mock_logger, + ) + + # Verify subprocess.run was called without cwd (None) for cursor + call_kwargs = mock_to_thread.call_args[1] + assert call_kwargs.get("cwd") is None + + +class TestGetAiConfig: + """Test suite for get_ai_config function.""" + + def test_get_ai_config_returns_tuple(self) -> None: + """Test that valid config returns (provider, model) tuple.""" + result = get_ai_config({"ai-provider": "claude", "ai-model": "sonnet"}) + assert result == ("claude", "sonnet") + + def test_get_ai_config_returns_none_for_none(self) -> None: + """Test that None config returns None.""" + assert get_ai_config(None) is None + + def test_get_ai_config_returns_none_for_empty_dict(self) -> None: + """Test that empty dict returns None.""" + assert get_ai_config({}) is None + + def test_get_ai_config_partial_missing_model(self) -> None: + """Test partial config with missing ai-model returns None.""" + assert get_ai_config({"ai-provider": "claude"}) is None + + def test_get_ai_config_partial_missing_provider(self) -> None: + """Test partial config with missing ai-provider returns None.""" + assert get_ai_config({"ai-model": "sonnet"}) is None diff --git a/webhook_server/tests/test_runner_handler.py b/webhook_server/tests/test_runner_handler.py index fc8739b2..319a40fd 100644 --- a/webhook_server/tests/test_runner_handler.py +++ b/webhook_server/tests/test_runner_handler.py @@ -1,6 +1,7 @@ from collections.abc import AsyncGenerator, Generator from contextlib import asynccontextmanager from dataclasses import dataclass +from typing import Any from unittest.mock import AsyncMock, Mock, patch import pytest @@ -55,6 +56,9 @@ def mock_github_webhook(self) -> Mock: mock_webhook.container_command_args = [] mock_webhook.ctx = None mock_webhook.custom_check_runs = [] + mock_webhook.ai_features = None + mock_webhook.config = Mock() + mock_webhook.config.get_value = Mock(return_value=None) return mock_webhook @pytest.fixture @@ -695,6 +699,242 @@ async def test_conventional_title_wildcard( ) mock_set_success.assert_not_awaited() + @pytest.mark.asyncio + async def test_conventional_title_failure_with_ai_suggestion( + self, runner_handler: RunnerHandler, mock_pull_request: Mock + ) -> None: + """Test that AI suggestion is included in failure output when ai-features configured.""" + runner_handler.github_webhook.conventional_title = "feat,fix" + mock_pull_request.title = "bad title" + runner_handler.github_webhook.ai_features = { + "ai-provider": "claude", + "ai-model": "sonnet", + "conventional-title": "true", + } + runner_handler.github_webhook.clone_repo_dir = "/tmp/test-clone" + + with patch.object( + runner_handler.check_run_handler, "is_check_run_in_progress", new=AsyncMock(return_value=False) + ): + with patch.object(runner_handler.check_run_handler, "set_check_in_progress", new=AsyncMock()): + with patch.object(runner_handler.check_run_handler, "set_check_success", new=AsyncMock()): + with patch.object( + runner_handler.check_run_handler, "set_check_failure", new=AsyncMock() + ) as mock_set_failure: + with patch( + "webhook_server.libs.handlers.runner_handler.call_ai_cli", + new_callable=AsyncMock, + return_value=(True, "fix: correct the bad title"), + ) as mock_ai_cli: + await runner_handler.run_conventional_title_check(mock_pull_request) + + mock_set_failure.assert_awaited_once() + output = mock_set_failure.call_args[1]["output"] + assert "AI-Suggested Title" in output["text"] + assert "fix: correct the bad title" in output["text"] + + # Verify cwd was passed to call_ai_cli + mock_ai_cli.assert_awaited_once() + call_kwargs = mock_ai_cli.call_args[1] + assert call_kwargs["cwd"] == "/tmp/test-clone" + + @pytest.mark.asyncio + async def test_conventional_title_failure_without_ai_features( + self, runner_handler: RunnerHandler, mock_pull_request: Mock + ) -> None: + """Test that no AI suggestion when ai-features not configured.""" + runner_handler.github_webhook.conventional_title = "feat,fix" + mock_pull_request.title = "bad title" + runner_handler.github_webhook.ai_features = None + + with patch.object( + runner_handler.check_run_handler, "is_check_run_in_progress", new=AsyncMock(return_value=False) + ): + with patch.object(runner_handler.check_run_handler, "set_check_in_progress", new=AsyncMock()): + with patch.object(runner_handler.check_run_handler, "set_check_success", new=AsyncMock()): + with patch.object( + runner_handler.check_run_handler, "set_check_failure", new=AsyncMock() + ) as mock_set_failure: + await runner_handler.run_conventional_title_check(mock_pull_request) + + mock_set_failure.assert_awaited_once() + output = mock_set_failure.call_args[1]["output"] + assert "AI-Suggested Title" not in output["text"] + + @pytest.mark.asyncio + async def test_conventional_title_fix_mode_updates_pr_title( + self, runner_handler: RunnerHandler, mock_pull_request: Mock + ) -> None: + """Test that fix mode auto-updates PR title with AI suggestion.""" + runner_handler.github_webhook.conventional_title = "feat,fix" + mock_pull_request.title = "bad title" + runner_handler.github_webhook.ai_features = { + "ai-provider": "claude", + "ai-model": "sonnet", + "conventional-title": "fix", + } + runner_handler.github_webhook.clone_repo_dir = "/tmp/test-clone" + + with patch.object( + runner_handler.check_run_handler, "is_check_run_in_progress", new=AsyncMock(return_value=False) + ): + with patch.object(runner_handler.check_run_handler, "set_check_in_progress", new=AsyncMock()): + with patch.object(runner_handler.check_run_handler, "set_check_success", new=AsyncMock()): + with patch.object( + runner_handler.check_run_handler, "set_check_failure", new=AsyncMock() + ) as mock_set_failure: + with patch( + "webhook_server.libs.handlers.runner_handler.call_ai_cli", + new_callable=AsyncMock, + return_value=(True, "fix: correct the title"), + ): + with patch("asyncio.to_thread", new_callable=AsyncMock) as mock_to_thread: + await runner_handler.run_conventional_title_check(mock_pull_request) + + # Verify PR title was updated + mock_to_thread.assert_any_call(mock_pull_request.edit, title="fix: correct the title") + # Verify check was set to failure (will re-run automatically on title change) + mock_set_failure.assert_awaited_once() + output = mock_set_failure.call_args[1]["output"] + assert "AI Auto-Fix" in output["text"] + + @pytest.mark.asyncio + async def test_conventional_title_fix_mode_edit_failure( + self, runner_handler: RunnerHandler, mock_pull_request: Mock + ) -> None: + """Test that fix mode handles PR title edit failure gracefully.""" + runner_handler.github_webhook.conventional_title = "feat,fix" + mock_pull_request.title = "bad title" + runner_handler.github_webhook.ai_features = { + "ai-provider": "claude", + "ai-model": "sonnet", + "conventional-title": "fix", + } + runner_handler.github_webhook.clone_repo_dir = "/tmp/test-clone" + + with patch.object( + runner_handler.check_run_handler, "is_check_run_in_progress", new=AsyncMock(return_value=False) + ): + with patch.object(runner_handler.check_run_handler, "set_check_in_progress", new=AsyncMock()): + with patch.object(runner_handler.check_run_handler, "set_check_success", new=AsyncMock()): + with patch.object( + runner_handler.check_run_handler, "set_check_failure", new=AsyncMock() + ) as mock_set_failure: + with patch( + "webhook_server.libs.handlers.runner_handler.call_ai_cli", + new_callable=AsyncMock, + return_value=(True, "fix: correct the title"), + ): + # Make edit raise an exception + async def mock_to_thread_side_effect(func: Any, *args: Any, **kwargs: Any) -> Any: + if func == mock_pull_request.edit: + raise Exception("GitHub API error") + return func(*args, **kwargs) + + with patch( + "asyncio.to_thread", new_callable=AsyncMock, side_effect=mock_to_thread_side_effect + ): + await runner_handler.run_conventional_title_check(mock_pull_request) + + # Check should still be set to failure + mock_set_failure.assert_awaited_once() + output = mock_set_failure.call_args[1]["output"] + assert "AI Auto-Fix Failed" in output["text"] + + @pytest.mark.asyncio + async def test_conventional_title_disabled_mode( + self, runner_handler: RunnerHandler, mock_pull_request: Mock + ) -> None: + """Test that conventional-title: "false" disables AI suggestion.""" + runner_handler.github_webhook.conventional_title = "feat,fix" + mock_pull_request.title = "bad title" + runner_handler.github_webhook.ai_features = { + "ai-provider": "claude", + "ai-model": "sonnet", + "conventional-title": "false", + } + + with patch.object( + runner_handler.check_run_handler, "is_check_run_in_progress", new=AsyncMock(return_value=False) + ): + with patch.object(runner_handler.check_run_handler, "set_check_in_progress", new=AsyncMock()): + with patch.object(runner_handler.check_run_handler, "set_check_success", new=AsyncMock()): + with patch.object( + runner_handler.check_run_handler, "set_check_failure", new=AsyncMock() + ) as mock_set_failure: + with patch( + "webhook_server.libs.handlers.runner_handler.call_ai_cli", + new_callable=AsyncMock, + ) as mock_ai: + await runner_handler.run_conventional_title_check(mock_pull_request) + + mock_ai.assert_not_called() + mock_set_failure.assert_awaited_once() + output = mock_set_failure.call_args[1]["output"] + assert "AI-Suggested Title" not in output["text"] + + @pytest.mark.asyncio + async def test_conventional_title_ai_cli_failure( + self, runner_handler: RunnerHandler, mock_pull_request: Mock + ) -> None: + """Test that AI CLI failure doesn't break the check flow.""" + runner_handler.github_webhook.conventional_title = "feat,fix" + mock_pull_request.title = "bad title" + runner_handler.github_webhook.ai_features = { + "ai-provider": "claude", + "ai-model": "sonnet", + "conventional-title": "true", + } + runner_handler.github_webhook.clone_repo_dir = "/tmp/test-clone" + + with patch.object( + runner_handler.check_run_handler, "is_check_run_in_progress", new=AsyncMock(return_value=False) + ): + with patch.object(runner_handler.check_run_handler, "set_check_in_progress", new=AsyncMock()): + with patch.object(runner_handler.check_run_handler, "set_check_success", new=AsyncMock()): + with patch.object( + runner_handler.check_run_handler, "set_check_failure", new=AsyncMock() + ) as mock_set_failure: + with patch( + "webhook_server.libs.handlers.runner_handler.call_ai_cli", + new_callable=AsyncMock, + return_value=(False, "CLI timeout"), + ): + await runner_handler.run_conventional_title_check(mock_pull_request) + mock_set_failure.assert_awaited_once() + output = mock_set_failure.call_args[1]["output"] + assert "AI-Suggested Title" not in output["text"] + + @pytest.mark.asyncio + async def test_conventional_title_ai_cli_exception( + self, runner_handler: RunnerHandler, mock_pull_request: Mock + ) -> None: + """Test that AI CLI exception doesn't break the check flow.""" + runner_handler.github_webhook.conventional_title = "feat,fix" + mock_pull_request.title = "bad title" + runner_handler.github_webhook.ai_features = { + "ai-provider": "claude", + "ai-model": "sonnet", + "conventional-title": "true", + } + runner_handler.github_webhook.clone_repo_dir = "/tmp/test-clone" + + with patch.object( + runner_handler.check_run_handler, "is_check_run_in_progress", new=AsyncMock(return_value=False) + ): + with patch.object(runner_handler.check_run_handler, "set_check_in_progress", new=AsyncMock()): + with patch.object(runner_handler.check_run_handler, "set_check_success", new=AsyncMock()): + with patch.object( + runner_handler.check_run_handler, "set_check_failure", new=AsyncMock() + ) as mock_set_failure: + with patch( + "webhook_server.libs.handlers.runner_handler.call_ai_cli", + new_callable=AsyncMock, + side_effect=RuntimeError("boom"), + ): + await runner_handler.run_conventional_title_check(mock_pull_request) + mock_set_failure.assert_awaited_once() + @pytest.mark.asyncio async def test_is_branch_exists(self, runner_handler: RunnerHandler) -> None: """Test is_branch_exists."""