diff --git a/README.md b/README.md index 1f9d5a88..cee16a0a 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ management and pull request workflows. - [Usage](#usage) - [API Reference](#api-reference) - [Log Viewer](#log-viewer) +- [AI Features](#ai-features) - [User Commands](#user-commands) - [OWNERS File Format](#owners-file-format) - [Security](#security) @@ -1239,6 +1240,53 @@ The MCP integration is built using the `fastapi-mcp` library and provides: - **Error handling**: Graceful error responses with helpful debugging information - **Performance optimization**: Efficient data access patterns for AI processing +## AI Features + +Optional AI-powered enhancements. Requires `ai-features` configuration with a provider and model: + +```yaml +ai-features: + ai-provider: "claude" # claude | gemini | cursor + ai-model: "claude-opus-4-6[1m]" +``` + +### Conventional Title Validation + +AI-suggested fixes for PR titles that don't follow the Conventional Commits format: + +```yaml +ai-features: + ai-provider: "claude" + ai-model: "claude-opus-4-6[1m]" + conventional-title: "true" # "true": suggest in check run | "false": disabled | "fix": auto-update PR title +``` + +| Mode | Behavior | +|------|----------| +| `"true"` | Shows AI-suggested title in check run output when validation fails | +| `"false"` | Disabled (default) | +| `"fix"` | Automatically updates the PR title with the AI suggestion | + +### Cherry-Pick Conflict Resolution + +When cherry-pick encounters merge conflicts, the AI CLI can automatically resolve them: + +```yaml +ai-features: + ai-provider: "claude" + ai-model: "claude-opus-4-6[1m]" + resolve-cherry-pick-conflicts-with-ai: + enabled: true + timeout-minutes: 10 # Default: 10 +``` + +When enabled: +- The AI resolves conflicts with **upstream-first priority** (target branch changes are the baseline) +- Cherry-picked PRs are labeled `CherryPicked-from-` (e.g., `CherryPicked-from-main`) +- AI-resolved PRs get an additional `ai-resolved-conflicts` label +- AI-resolved PRs are **never auto-verified** — manual review is always required +- If AI fails, falls back to manual cherry-pick instructions + ## User Commands Users can interact with the webhook server through GitHub comments on pull requests and issues. @@ -1321,6 +1369,8 @@ Cherry-picked PRs can be automatically verified or require manual verification d auto-verify-cherry-picked-prs: true # Default: true (auto-verify). Set to false to require manual verification ``` +**AI Conflict Resolution**: Cherry-pick conflicts can be automatically resolved by AI. See [AI Features](#ai-features) for configuration. + ### Label Commands | Command | Description | Example | diff --git a/examples/.github-webhook-server.yaml b/examples/.github-webhook-server.yaml index 53698802..658098fd 100644 --- a/examples/.github-webhook-server.yaml +++ b/examples/.github-webhook-server.yaml @@ -152,6 +152,9 @@ 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 + resolve-cherry-pick-conflicts-with-ai: + enabled: true + timeout-minutes: 10 # Timeout in minutes for AI CLI (default: 10) # PR Test Oracle integration (overrides global config) # Analyzes PR diffs with AI and recommends which tests to run diff --git a/examples/config.yaml b/examples/config.yaml index f693ce6a..171f77a7 100644 --- a/examples/config.yaml +++ b/examples/config.yaml @@ -129,6 +129,9 @@ 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 + resolve-cherry-pick-conflicts-with-ai: + enabled: true + timeout-minutes: 10 # Timeout in minutes for AI CLI (default: 10) repositories: my-repository: diff --git a/webhook_server/config/schema.yaml b/webhook_server/config/schema.yaml index 0cac11c3..1c09ca64 100644 --- a/webhook_server/config/schema.yaml +++ b/webhook_server/config/schema.yaml @@ -30,6 +30,25 @@ $defs: - "false": Disabled (default) - "fix": Auto-fix PR title with AI suggestion when validation fails default: "false" + resolve-cherry-pick-conflicts-with-ai: + type: object + description: | + AI-powered cherry-pick conflict resolution configuration. + When enabled and a cherry-pick has conflicts, the AI CLI will attempt + to resolve them with upstream-first priority. AI-resolved cherry-picks + are never auto-verified - manual review is required. + properties: + enabled: + type: boolean + description: Enable AI conflict resolution for cherry-picks + timeout-minutes: + type: integer + minimum: 1 + description: Timeout in minutes for the AI CLI process (default 10) + default: 10 + required: + - enabled + additionalProperties: false required: - ai-provider - ai-model diff --git a/webhook_server/libs/handlers/issue_comment_handler.py b/webhook_server/libs/handlers/issue_comment_handler.py index f473bad7..e873803e 100644 --- a/webhook_server/libs/handlers/issue_comment_handler.py +++ b/webhook_server/libs/handlers/issue_comment_handler.py @@ -443,7 +443,7 @@ async def process_cherry_pick_command( ] if _exits_target_branches: - if not await asyncio.to_thread(pull_request.is_merged): + if not self.hook_data["issue"].get("pull_request", {}).get("merged_at"): info_msg: str = f""" Cherry-pick requested for PR: `{pull_request.title}` by user `{reviewed_user}` Adding label/s `{" ".join([_cp_label for _cp_label in cp_labels])}` for automatic cheery-pick once the PR is merged diff --git a/webhook_server/libs/handlers/labels_handler.py b/webhook_server/libs/handlers/labels_handler.py index 8e520cb7..49764cff 100644 --- a/webhook_server/libs/handlers/labels_handler.py +++ b/webhook_server/libs/handlers/labels_handler.py @@ -101,7 +101,7 @@ def is_label_enabled(self, label: str) -> bool: return "branch" in enabled_labels # Check cherry-pick labels - if label.startswith(CHERRY_PICK_LABEL_PREFIX) or label == CHERRY_PICKED_LABEL: + if label.startswith((CHERRY_PICK_LABEL_PREFIX, CHERRY_PICKED_LABEL)): return "cherry-pick" in enabled_labels # Unknown labels are allowed by default diff --git a/webhook_server/libs/handlers/pull_request_handler.py b/webhook_server/libs/handlers/pull_request_handler.py index 391d39c4..0a63aed1 100644 --- a/webhook_server/libs/handlers/pull_request_handler.py +++ b/webhook_server/libs/handlers/pull_request_handler.py @@ -15,6 +15,7 @@ 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, APPROVED_BY_LABEL_PREFIX, AUTOMERGE_LABEL_STR, BRANCH_LABEL_PREFIX, @@ -1075,7 +1076,16 @@ async def _process_verified_for_update_or_new_pull_request(self, pull_request: P # Check if this is a cherry-picked PR labels = await asyncio.to_thread(lambda: list(pull_request.labels)) - is_cherry_picked = any(label.name == CHERRY_PICKED_LABEL for label in labels) + + # AI-resolved cherry-picks are NEVER auto-verified (takes precedence over auto-verify-cherry-picked-prs) + is_ai_resolved = any(label.name == AI_RESOLVED_CONFLICTS_LABEL for label in labels) + if is_ai_resolved: + self.logger.info(f"{self.log_prefix} AI-resolved cherry-pick detected, skipping auto-verification") + await self.labels_handler._remove_label(pull_request=pull_request, label=VERIFIED_LABEL_STR) + await self.check_run_handler.set_check_queued(name=VERIFIED_LABEL_STR) + return + + is_cherry_picked = any(label.name.startswith(CHERRY_PICKED_LABEL) for label in labels) # If it's a cherry-picked PR and auto-verify is disabled for cherry-picks, skip auto-verification if is_cherry_picked and not self.github_webhook.auto_verify_cherry_picked_prs: @@ -1083,6 +1093,7 @@ async def _process_verified_for_update_or_new_pull_request(self, pull_request: P f"{self.log_prefix} Cherry-picked PR detected and auto-verify-cherry-picked-prs is disabled, " "skipping auto-verification" ) + await self.labels_handler._remove_label(pull_request=pull_request, label=VERIFIED_LABEL_STR) await self.check_run_handler.set_check_queued(name=VERIFIED_LABEL_STR) return diff --git a/webhook_server/libs/handlers/runner_handler.py b/webhook_server/libs/handlers/runner_handler.py index 4923050a..46ffa804 100644 --- a/webhook_server/libs/handlers/runner_handler.py +++ b/webhook_server/libs/handlers/runner_handler.py @@ -20,6 +20,7 @@ from webhook_server.libs.handlers.owners_files_handler import OwnersFileHandler from webhook_server.utils import helpers as helpers_module from webhook_server.utils.constants import ( + AI_RESOLVED_CONFLICTS_LABEL, BUILD_CONTAINER_STR, CHERRY_PICKED_LABEL, CONVENTIONAL_TITLE_STR, @@ -28,6 +29,7 @@ PYTHON_MODULE_INSTALL_STR, TOX_STR, ) +from webhook_server.utils.github_repository_settings import get_repository_github_app_token from webhook_server.utils.helpers import _redact_secrets, run_command from webhook_server.utils.notification_utils import send_slack_message @@ -687,6 +689,100 @@ async def run_custom_check( async def is_branch_exists(self, branch: str) -> Branch: return await asyncio.to_thread(self.repository.get_branch, branch) + async def _resolve_cherry_pick_with_ai( + self, + worktree_path: str, + git_cmd: str, + github_token: str, + ) -> bool: + """Attempt to resolve cherry-pick conflicts using AI CLI. + + Returns True if AI successfully resolved the conflicts, False otherwise. + """ + ai_config = self.github_webhook.ai_features + if not ai_config: + self.logger.debug(f"{self.log_prefix} AI cherry-pick conflict resolution not enabled") + return False + + cherry_pick_ai_config = ai_config.get("resolve-cherry-pick-conflicts-with-ai") + if not isinstance(cherry_pick_ai_config, dict) or not cherry_pick_ai_config.get("enabled"): + self.logger.debug(f"{self.log_prefix} AI cherry-pick conflict resolution not enabled") + return False + + ai_result = get_ai_config(ai_config) + if not ai_result: + self.logger.debug(f"{self.log_prefix} AI features not fully configured (missing provider/model)") + return False + + ai_provider, ai_model = ai_result + + cli_flags: list[str] = [] + if ai_provider == "claude": + cli_flags = ["--dangerously-skip-permissions"] + elif ai_provider == "gemini": + cli_flags = ["--yolo"] + elif ai_provider == "cursor": + cli_flags = ["--force"] + + prompt = ( + "You are in a git repository with cherry-pick merge conflicts. " + "The conflicted files contain git conflict markers (<<<<<<< HEAD, =======, >>>>>>>). " + "Resolve ALL conflicts in ALL files. " + "Priority: the target branch (HEAD/upstream) changes are the baseline. " + "Adapt the cherry-picked changes to fit the target branch's codebase. " + "If changes are incompatible, prefer the target branch version. " + "After resolving, ensure the code compiles/is syntactically valid." + ) + + self.logger.info(f"{self.log_prefix} Attempting AI conflict resolution with {ai_provider}/{ai_model}") + + timeout_minutes = cherry_pick_ai_config.get("timeout-minutes", 10) + + try: + success, result = await call_ai_cli( + prompt=prompt, + ai_provider=ai_provider, + ai_model=ai_model, + cwd=worktree_path, + cli_flags=cli_flags, + timeout_minutes=timeout_minutes, + ) + + if not success: + self.logger.warning(f"{self.log_prefix} AI conflict resolution failed: {result}") + return False + + self.logger.info(f"{self.log_prefix} AI conflict resolution completed, finalizing cherry-pick") + + # Stage resolved files + rc, _, err = await run_command( + command=f"{git_cmd} add -u", + log_prefix=self.log_prefix, + redact_secrets=[github_token], + mask_sensitive=self.github_webhook.mask_sensitive, + ) + if not rc: + self.logger.error(f"{self.log_prefix} Failed to stage AI-resolved files: {err}") + return False + + # Complete the cherry-pick + rc, _, err = await run_command( + command=f"{git_cmd} -c core.editor=true cherry-pick --continue", + log_prefix=self.log_prefix, + redact_secrets=[github_token], + mask_sensitive=self.github_webhook.mask_sensitive, + ) + if not rc: + self.logger.error(f"{self.log_prefix} cherry-pick --continue failed after AI resolution: {err}") + return False + + self.logger.info(f"{self.log_prefix} AI successfully resolved cherry-pick conflicts") + return True + + except Exception: + self.logger.exception(f"{self.log_prefix} AI conflict resolution failed unexpectedly") + return False + async def cherry_pick( self, pull_request: PullRequest, @@ -721,22 +817,15 @@ async def cherry_pick( f"Cherry-pick from `{source_branch}` branch, original PR: {pull_request_url}, PR owner: {pr_author}" ) repo_full_name = self.github_webhook.repository_full_name - git_commands: list[str] = [ + + setup_commands: list[str] = [ + f"{git_cmd} fetch origin {source_branch}", f"{git_cmd} checkout {target_branch}", f"{git_cmd} pull origin {target_branch}", f"{git_cmd} checkout -b {new_branch_name} origin/{target_branch}", - f"{git_cmd} cherry-pick {commit_hash}", - f"{git_cmd} push origin {new_branch_name}", ] - gh_pr_command = ( - f"gh pr create --repo {shlex.quote(repo_full_name)}" - f" --base {shlex.quote(target_branch)}" - f" --head {shlex.quote(new_branch_name)}" - f" --label {shlex.quote(CHERRY_PICKED_LABEL)}" - f"{assignee_flag}" - f" --title {shlex.quote(pr_title)}" - f" --body {shlex.quote(pr_body)}" - ) + cherry_pick_command = f"{git_cmd} cherry-pick {commit_hash}" + push_command = f"{git_cmd} push origin {new_branch_name}" output: CheckRunOutput = { "title": "Cherry-pick details", @@ -748,7 +837,7 @@ async def cherry_pick( await self.check_run_handler.set_check_failure(name=CHERRY_PICKED_LABEL, output=output) return - for cmd in git_commands: + for cmd in setup_commands: rc, out, err = await run_command( command=cmd, log_prefix=self.log_prefix, @@ -781,19 +870,125 @@ async def cherry_pick( f"git pull origin {target_branch}\n" f"git checkout -b {local_branch_name}\n" f"git cherry-pick {commit_hash}\n" + f"# If the above fails with 'is a merge but no -m option', run:\n" + f"# git cherry-pick -m 1 {commit_hash}\n" f"git push origin {local_branch_name}\n" "```", ) return - # Run gh pr create with GH_TOKEN passed via env (not command prefix) - # Each subprocess gets its own env copy, safe for parallel execution + # Run cherry-pick separately to detect conflicts rc, out, err = await run_command( - command=gh_pr_command, + command=cherry_pick_command, + log_prefix=self.log_prefix, + redact_secrets=[github_token], + mask_sensitive=self.github_webhook.mask_sensitive, + ) + + # Retry with -m 1 if the commit is a merge commit + if not rc and "is a merge but no -m option was given" in err: + self.logger.info(f"{self.log_prefix} Merge commit detected, retrying cherry-pick with -m 1") + cherry_pick_command_m1 = f"{git_cmd} cherry-pick -m 1 {commit_hash}" + rc, out, err = await run_command( + command=cherry_pick_command_m1, + log_prefix=self.log_prefix, + redact_secrets=[github_token], + mask_sensitive=self.github_webhook.mask_sensitive, + ) + + cherry_pick_had_conflicts = False + if not rc: + # Only attempt AI resolution for actual merge conflicts + is_conflict = "CONFLICT" in err or "CONFLICT" in out + if is_conflict: + ai_resolved = await self._resolve_cherry_pick_with_ai( + worktree_path=worktree_path, + git_cmd=git_cmd, + github_token=github_token, + ) + else: + ai_resolved = False + if not ai_resolved: + # AI not configured, disabled, or failed — manual fallback + output["text"] = self.check_run_handler.get_check_run_text(err=err, out=out) + await self.check_run_handler.set_check_failure(name=CHERRY_PICKED_LABEL, output=output) + redacted_out = _redact_secrets( + out, + [github_token], + mask_sensitive=self.github_webhook.mask_sensitive, + ) + redacted_err = _redact_secrets( + err, + [github_token], + mask_sensitive=self.github_webhook.mask_sensitive, + ) + self.logger.error(f"{self.log_prefix} Cherry pick failed: {redacted_out} --- {redacted_err}") + local_branch_name = f"{pull_request.head.ref}-{target_branch}" + await asyncio.to_thread( + pull_request.create_issue_comment, + f"**Manual cherry-pick is needed**\nCherry pick failed for " + f"{commit_hash} to {target_branch}:\n" + f"To cherry-pick run:\n" + "```\n" + f"git remote update\n" + f"git checkout {target_branch}\n" + f"git pull origin {target_branch}\n" + f"git checkout -b {local_branch_name}\n" + f"git cherry-pick {commit_hash}\n" + f"# If the above fails with 'is a merge but no -m option', run:\n" + f"# git cherry-pick -m 1 {commit_hash}\n" + f"git push origin {local_branch_name}\n" + "```", + ) + return + cherry_pick_had_conflicts = True + + # Push the branch + rc, out, err = await run_command( + command=push_command, log_prefix=self.log_prefix, redact_secrets=[github_token], mask_sensitive=self.github_webhook.mask_sensitive, - env={**os.environ, "GH_TOKEN": github_token}, + ) + if not rc: + output["text"] = self.check_run_handler.get_check_run_text(err=err, out=out) + await self.check_run_handler.set_check_failure(name=CHERRY_PICKED_LABEL, output=output) + self.logger.error(f"{self.log_prefix} Cherry pick push failed") + return + + cherry_picked_label = f"{CHERRY_PICKED_LABEL}-from-{source_branch}"[:49] + + gh_pr_command = ( + f"gh pr create --repo {shlex.quote(repo_full_name)}" + f" --base {shlex.quote(target_branch)}" + f" --head {shlex.quote(new_branch_name)}" + f"{assignee_flag}" + f" --title {shlex.quote(pr_title)}" + f" --body {shlex.quote(pr_body)}" + ) + + # Use GitHub App installation token for PR creation + # so the PR is owned by the app bot, allowing repo collaborators to push + try: + app_token = await asyncio.to_thread( + get_repository_github_app_token, + config_=self.github_webhook.config, + repository_name=self.github_webhook.repository_full_name, + ) + except Exception: + self.logger.exception( + f"{self.log_prefix} Failed to get GitHub App token, falling back to webhook token" + ) + app_token = None + pr_create_token = app_token or github_token + + # Run gh pr create with GH_TOKEN passed via env + rc, out, err = await run_command( + command=gh_pr_command, + log_prefix=self.log_prefix, + redact_secrets=[github_token, pr_create_token], + mask_sensitive=self.github_webhook.mask_sensitive, + env={**os.environ, "GH_TOKEN": pr_create_token}, ) if not rc: output["text"] = self.check_run_handler.get_check_run_text(err=err, out=out) @@ -807,19 +1002,20 @@ async def cherry_pick( f"gh pr create --repo {repo_full_name}" f" --base {target_branch}" f" --head {new_branch_name}" - f" --label {CHERRY_PICKED_LABEL}" - f" --title '{pr_title}'" + f" --label {cherry_picked_label}" + + (f" --label {AI_RESOLVED_CONFLICTS_LABEL}" if cherry_pick_had_conflicts else "") + + f" --title '{pr_title}'" f" --body '{pr_body}'\n" "```", ) redacted_out = _redact_secrets( out, - [github_token], + [github_token, pr_create_token], mask_sensitive=self.github_webhook.mask_sensitive, ) redacted_err = _redact_secrets( err, - [github_token], + [github_token, pr_create_token], mask_sensitive=self.github_webhook.mask_sensitive, ) self.logger.error( @@ -827,12 +1023,79 @@ async def cherry_pick( ) return - output["text"] = self.check_run_handler.get_check_run_text(err=err, out=out) + # gh pr create outputs the PR URL (e.g., https://github.com/org/repo/pull/123) + cherry_pick_pr_url = out.strip() + + # Get the cherry-pick PR object + try: + pr_number = int(cherry_pick_pr_url.rstrip("/").split("/")[-1]) + cherry_pick_pr = await asyncio.to_thread(self.repository.get_pull, pr_number) + except Exception: + self.logger.exception( + f"{self.log_prefix} Failed to get cherry-pick PR from URL: {cherry_pick_pr_url}" + ) + cherry_pick_pr = None + + if cherry_pick_pr: + # Add labels to the created PR via PyGithub (auto-creates labels if needed) + try: + labels_to_add = [cherry_picked_label] + if cherry_pick_had_conflicts: + labels_to_add.append(AI_RESOLVED_CONFLICTS_LABEL) + await asyncio.to_thread(cherry_pick_pr.add_to_labels, *labels_to_add) + self.logger.info( + f"{self.log_prefix} Added labels {labels_to_add} to cherry-pick PR #{cherry_pick_pr.number}" + ) + except Exception: + self.logger.exception(f"{self.log_prefix} Failed to add labels to cherry-pick PR") + # Labels are critical for auto-verify skip — warn if they couldn't be added + try: + await asyncio.to_thread( + pull_request.create_issue_comment, + f"**Warning:** Failed to add labels to cherry-pick PR {cherry_pick_pr_url}. " + f"Please manually add the `{cherry_picked_label}` label" + + (f" and `{AI_RESOLVED_CONFLICTS_LABEL}` label" if cherry_pick_had_conflicts else "") + + " to ensure correct auto-verify behavior.", + ) + except Exception: + self.logger.exception(f"{self.log_prefix} Failed to post label warning comment") + + # Request review from original PR author (independent of label success) + try: + await asyncio.to_thread(cherry_pick_pr.create_review_request, reviewers=[pr_author]) + except Exception: + self.logger.debug( + f"{self.log_prefix} Could not request review from {pr_author} (may not be a collaborator)" + ) + else: + # PR was created but we couldn't fetch it — labels/reviewer not added + await asyncio.to_thread( + pull_request.create_issue_comment, + f"**Warning:** Cherry-pick PR was created ({cherry_pick_pr_url}) but failed to add labels. " + f"Please manually add the `{cherry_picked_label}` label" + + (f" and `{AI_RESOLVED_CONFLICTS_LABEL}` label" if cherry_pick_had_conflicts else "") + + " to ensure correct auto-verify behavior.", + ) + output["text"] = self.check_run_handler.get_check_run_text(err=err, out=out) await self.check_run_handler.set_check_success(name=CHERRY_PICKED_LABEL, output=output) - await asyncio.to_thread( - pull_request.create_issue_comment, f"Cherry-picked PR {pull_request.title} into {target_branch}" - ) + + if cherry_pick_had_conflicts: + ai_config = self.github_webhook.ai_features + ai_result = get_ai_config(ai_config) + ai_provider, ai_model = ai_result if ai_result else ("unknown", "unknown") + await asyncio.to_thread( + pull_request.create_issue_comment, + f"**Cherry-pick conflicts were resolved by AI**\n\n" + f"Cherry-picked PR {pull_request.title} into {target_branch}: {cherry_pick_pr_url}\n" + f"Conflicts were automatically resolved by AI ({ai_provider}/{ai_model}).\n\n" + f"**Manual verification is required** — please review the changes and test before merging.", + ) + else: + await asyncio.to_thread( + pull_request.create_issue_comment, + f"Cherry-picked PR {pull_request.title} into {target_branch}: {cherry_pick_pr_url}", + ) async def run_retests(self, supported_retests: list[str], pull_request: PullRequest) -> None: """Run the specified retests for a pull request. diff --git a/webhook_server/tests/test_config_schema.py b/webhook_server/tests/test_config_schema.py index 046e1204..b6b6526f 100644 --- a/webhook_server/tests/test_config_schema.py +++ b/webhook_server/tests/test_config_schema.py @@ -816,3 +816,56 @@ def test_allow_commands_on_draft_prs_repository_level( assert repo_data["allow-commands-on-draft-prs"] == ["hold", "retest"] finally: shutil.rmtree(temp_dir) + + @pytest.mark.parametrize("enabled", [True, False]) + def test_ai_features_resolve_cherry_pick_conflicts_with_ai( + self, valid_minimal_config: dict[str, Any], monkeypatch: pytest.MonkeyPatch, *, enabled: bool + ) -> None: + """Test that ai-features with resolve-cherry-pick-conflicts-with-ai passes schema validation.""" + config = valid_minimal_config.copy() + config["ai-features"] = { + "ai-provider": "claude", + "ai-model": "sonnet", + "resolve-cherry-pick-conflicts-with-ai": {"enabled": enabled, "timeout-minutes": 10}, + } + + temp_dir = self.create_temp_config_dir_and_data(config) + + try: + monkeypatch.setenv("WEBHOOK_SERVER_DATA_DIR", temp_dir) + + config_obj = Config() + ai_features = config_obj.root_data["ai-features"] + assert ai_features["ai-provider"] == "claude" + assert ai_features["ai-model"] == "sonnet" + assert ai_features["resolve-cherry-pick-conflicts-with-ai"] == {"enabled": enabled, "timeout-minutes": 10} + finally: + shutil.rmtree(temp_dir) + + @pytest.mark.parametrize("enabled", [True, False]) + def test_ai_features_resolve_cherry_pick_conflicts_with_ai_repository_level( + self, valid_minimal_config: dict[str, Any], monkeypatch: pytest.MonkeyPatch, *, enabled: bool + ) -> None: + """Test that ai-features with resolve-cherry-pick-conflicts-with-ai works at repository level.""" + config = valid_minimal_config.copy() + config["repositories"]["test-repo"]["ai-features"] = { + "ai-provider": "claude", + "ai-model": "sonnet", + "resolve-cherry-pick-conflicts-with-ai": {"enabled": enabled, "timeout-minutes": 10}, + } + + temp_dir = self.create_temp_config_dir_and_data(config) + + try: + monkeypatch.setenv("WEBHOOK_SERVER_DATA_DIR", temp_dir) + + config_obj = Config() + repo_ai_features = config_obj.root_data["repositories"]["test-repo"]["ai-features"] + assert repo_ai_features["ai-provider"] == "claude" + assert repo_ai_features["ai-model"] == "sonnet" + assert repo_ai_features["resolve-cherry-pick-conflicts-with-ai"] == { + "enabled": enabled, + "timeout-minutes": 10, + } + finally: + shutil.rmtree(temp_dir) diff --git a/webhook_server/tests/test_github_repository_settings.py b/webhook_server/tests/test_github_repository_settings.py index d6f88cf2..216c9fde 100644 --- a/webhook_server/tests/test_github_repository_settings.py +++ b/webhook_server/tests/test_github_repository_settings.py @@ -4,7 +4,7 @@ from unittest.mock import AsyncMock, Mock, patch import pytest -from github.GithubException import UnknownObjectException +from github.GithubException import GithubException, UnknownObjectException from webhook_server.utils.constants import ( BUILD_CONTAINER_STR, @@ -20,6 +20,7 @@ get_branch_sampler, get_repo_branch_protection_rules, get_repository_github_app_api, + get_repository_github_app_token, get_required_status_checks, get_user_configures_status_checks, set_all_in_progress_check_runs_to_queued, @@ -777,3 +778,68 @@ def test_get_repository_github_app_api_exception(self, mock_logger: Mock, mock_o assert result is None mock_logger.error.assert_called_once() assert "Repository owner/repo not found by manage-repositories-app" in mock_logger.error.call_args[0][0] + + @patch("builtins.open", create=True) + @patch("webhook_server.utils.github_repository_settings.LOGGER") + def test_get_repository_github_app_token_success(self, _mock_logger: Mock, mock_open: Mock) -> None: + """Test successful GitHub app token retrieval.""" + mock_config = Mock() + mock_config.data_dir = "/test/dir" + mock_config.root_data = {"github-app-id": 12345} + + mock_file = Mock() + mock_file.read.return_value = "test-private-key" + mock_open.return_value.__enter__.return_value = mock_file + + with patch("webhook_server.utils.github_repository_settings.Auth") as mock_auth: + with patch("webhook_server.utils.github_repository_settings.GithubIntegration") as mock_integration: + mock_app_auth = Mock() + mock_auth.AppAuth.return_value = mock_app_auth + + mock_app_instance = Mock() + mock_integration.return_value = mock_app_instance + + mock_installation = Mock() + mock_installation.id = 67890 + mock_app_instance.get_repo_installation.return_value = mock_installation + + mock_access_token = Mock() + mock_access_token.token = "fake-installation-token" # pragma: allowlist secret + mock_app_instance.get_access_token.return_value = mock_access_token + + result = get_repository_github_app_token(mock_config, "owner/repo") + + assert result == "fake-installation-token" # pragma: allowlist secret + mock_auth.AppAuth.assert_called_once_with(app_id=12345, private_key="test-private-key") + mock_app_instance.get_repo_installation.assert_called_once_with(owner="owner", repo="repo") + mock_app_instance.get_access_token.assert_called_once_with(67890) + + @patch("builtins.open", create=True) + @patch("webhook_server.utils.github_repository_settings.LOGGER") + def test_get_repository_github_app_token_failure(self, mock_logger: Mock, mock_open: Mock) -> None: + """Test GitHub app token retrieval when exception occurs.""" + mock_config = Mock() + mock_config.data_dir = "/test/dir" + mock_config.root_data = {"github-app-id": 12345} + + mock_file = Mock() + mock_file.read.return_value = "test-private-key" + mock_open.return_value.__enter__.return_value = mock_file + + with patch("webhook_server.utils.github_repository_settings.Auth") as mock_auth: + with patch("webhook_server.utils.github_repository_settings.GithubIntegration") as mock_integration: + mock_app_auth = Mock() + mock_auth.AppAuth.return_value = mock_app_auth + + mock_app_instance = Mock() + mock_integration.return_value = mock_app_instance + mock_app_instance.get_repo_installation.side_effect = GithubException(404, "App not installed", None) + + result = get_repository_github_app_token(mock_config, "owner/repo") + + assert result is None + mock_logger.exception.assert_called_once() + assert ( + "Failed to get GitHub App installation token for owner/repo" + in mock_logger.exception.call_args[0][0] + ) diff --git a/webhook_server/tests/test_issue_comment_handler.py b/webhook_server/tests/test_issue_comment_handler.py index 0ff45c5b..e70c90fa 100644 --- a/webhook_server/tests/test_issue_comment_handler.py +++ b/webhook_server/tests/test_issue_comment_handler.py @@ -834,33 +834,33 @@ async def test_process_cherry_pick_command_non_existing_branches( async def test_process_cherry_pick_command_merged_pr(self, issue_comment_handler: IssueCommentHandler) -> None: """Test processing cherry pick command for merged PR.""" mock_pull_request = Mock() - # Patch is_merged as a method - with patch.object(mock_pull_request, "is_merged", new=Mock(return_value=True)): - with patch.object(issue_comment_handler.repository, "get_branch"): + # Set merged_at in hook_data to simulate a merged PR at comment time + issue_comment_handler.hook_data["issue"]["pull_request"] = {"merged_at": "2026-01-01T00:00:00Z"} + with patch.object(issue_comment_handler.repository, "get_branch"): + with patch.object( + issue_comment_handler.runner_handler, + "cherry_pick", + new_callable=AsyncMock, + ) as mock_cherry_pick: with patch.object( - issue_comment_handler.runner_handler, - "cherry_pick", + issue_comment_handler.labels_handler, + "_add_label", new_callable=AsyncMock, - ) as mock_cherry_pick: - with patch.object( - issue_comment_handler.labels_handler, - "_add_label", - new_callable=AsyncMock, - ) as mock_add_label: - await issue_comment_handler.process_cherry_pick_command( - pull_request=mock_pull_request, - command_args="branch1", - reviewed_user="test-user", - ) - mock_cherry_pick.assert_called_once_with( - pull_request=mock_pull_request, - target_branch="branch1", - assign_to_pr_owner=True, - ) - mock_add_label.assert_called_once_with( - pull_request=mock_pull_request, - label="cherry-pick-branch1", - ) + ) as mock_add_label: + await issue_comment_handler.process_cherry_pick_command( + pull_request=mock_pull_request, + command_args="branch1", + reviewed_user="test-user", + ) + mock_cherry_pick.assert_called_once_with( + pull_request=mock_pull_request, + target_branch="branch1", + assign_to_pr_owner=True, + ) + mock_add_label.assert_called_once_with( + pull_request=mock_pull_request, + label="cherry-pick-branch1", + ) @pytest.mark.asyncio async def test_process_cherry_pick_command_merged_pr_multiple_branches( @@ -876,49 +876,49 @@ async def test_process_cherry_pick_command_merged_pr_multiple_branches( mock_pull_request = Mock() mock_pull_request.title = "Test PR" - # Patch is_merged to return True (merged PR) - with patch.object(mock_pull_request, "is_merged", new=Mock(return_value=True)): - with patch.object(issue_comment_handler.repository, "get_branch"): + # Set merged_at in hook_data to simulate a merged PR at comment time + issue_comment_handler.hook_data["issue"]["pull_request"] = {"merged_at": "2026-01-01T00:00:00Z"} + with patch.object(issue_comment_handler.repository, "get_branch"): + with patch.object( + issue_comment_handler.runner_handler, + "cherry_pick", + new_callable=AsyncMock, + ) as mock_cherry_pick: with patch.object( - issue_comment_handler.runner_handler, - "cherry_pick", + issue_comment_handler.labels_handler, + "_add_label", new_callable=AsyncMock, - ) as mock_cherry_pick: - with patch.object( - issue_comment_handler.labels_handler, - "_add_label", - new_callable=AsyncMock, - ) as mock_add_label: - # Execute cherry-pick command with multiple branches - await issue_comment_handler.process_cherry_pick_command( - pull_request=mock_pull_request, - command_args="branch1 branch2 branch3", - reviewed_user="test-user", - ) + ) as mock_add_label: + # Execute cherry-pick command with multiple branches + await issue_comment_handler.process_cherry_pick_command( + pull_request=mock_pull_request, + command_args="branch1 branch2 branch3", + reviewed_user="test-user", + ) - # Verify cherry_pick was called for each branch - assert mock_cherry_pick.call_count == 3 - mock_cherry_pick.assert_any_call( - pull_request=mock_pull_request, - target_branch="branch1", - assign_to_pr_owner=True, - ) - mock_cherry_pick.assert_any_call( - pull_request=mock_pull_request, - target_branch="branch2", - assign_to_pr_owner=True, - ) - mock_cherry_pick.assert_any_call( - pull_request=mock_pull_request, - target_branch="branch3", - assign_to_pr_owner=True, - ) + # Verify cherry_pick was called for each branch + assert mock_cherry_pick.call_count == 3 + mock_cherry_pick.assert_any_call( + pull_request=mock_pull_request, + target_branch="branch1", + assign_to_pr_owner=True, + ) + mock_cherry_pick.assert_any_call( + pull_request=mock_pull_request, + target_branch="branch2", + assign_to_pr_owner=True, + ) + mock_cherry_pick.assert_any_call( + pull_request=mock_pull_request, + target_branch="branch3", + assign_to_pr_owner=True, + ) - # Verify labels were added exactly once for each branch (not duplicated) - assert mock_add_label.call_count == 3 - mock_add_label.assert_any_call(pull_request=mock_pull_request, label="cherry-pick-branch1") - mock_add_label.assert_any_call(pull_request=mock_pull_request, label="cherry-pick-branch2") - mock_add_label.assert_any_call(pull_request=mock_pull_request, label="cherry-pick-branch3") + # Verify labels were added exactly once for each branch (not duplicated) + assert mock_add_label.call_count == 3 + mock_add_label.assert_any_call(pull_request=mock_pull_request, label="cherry-pick-branch1") + mock_add_label.assert_any_call(pull_request=mock_pull_request, label="cherry-pick-branch2") + mock_add_label.assert_any_call(pull_request=mock_pull_request, label="cherry-pick-branch3") @pytest.mark.asyncio async def test_process_cherry_pick_command_merged_pr_assign_disabled( @@ -927,28 +927,29 @@ async def test_process_cherry_pick_command_merged_pr_assign_disabled( """Test cherry-pick on merged PR passes assign_to_pr_owner=False when config disabled.""" issue_comment_handler.github_webhook.cherry_pick_assign_to_pr_author = False mock_pull_request = Mock() - with patch.object(mock_pull_request, "is_merged", new=Mock(return_value=True)): - with patch.object(issue_comment_handler.repository, "get_branch"): + # Set merged_at in hook_data to simulate a merged PR at comment time + issue_comment_handler.hook_data["issue"]["pull_request"] = {"merged_at": "2026-01-01T00:00:00Z"} + with patch.object(issue_comment_handler.repository, "get_branch"): + with patch.object( + issue_comment_handler.runner_handler, + "cherry_pick", + new_callable=AsyncMock, + ) as mock_cherry_pick: with patch.object( - issue_comment_handler.runner_handler, - "cherry_pick", + issue_comment_handler.labels_handler, + "_add_label", new_callable=AsyncMock, - ) as mock_cherry_pick: - with patch.object( - issue_comment_handler.labels_handler, - "_add_label", - new_callable=AsyncMock, - ): - await issue_comment_handler.process_cherry_pick_command( - pull_request=mock_pull_request, - command_args="branch1", - reviewed_user="test-user", - ) - mock_cherry_pick.assert_called_once_with( - pull_request=mock_pull_request, - target_branch="branch1", - assign_to_pr_owner=False, - ) + ): + await issue_comment_handler.process_cherry_pick_command( + pull_request=mock_pull_request, + command_args="branch1", + reviewed_user="test-user", + ) + mock_cherry_pick.assert_called_once_with( + pull_request=mock_pull_request, + target_branch="branch1", + assign_to_pr_owner=False, + ) @pytest.mark.asyncio async def test_process_retest_command_no_target_tests(self, issue_comment_handler: IssueCommentHandler) -> None: diff --git a/webhook_server/tests/test_pull_request_handler.py b/webhook_server/tests/test_pull_request_handler.py index eff91f1e..dbe62d84 100644 --- a/webhook_server/tests/test_pull_request_handler.py +++ b/webhook_server/tests/test_pull_request_handler.py @@ -14,6 +14,7 @@ from webhook_server.libs.handlers.pull_request_handler import PullRequestHandler from webhook_server.tests.conftest import TEST_GITHUB_TOKEN from webhook_server.utils.constants import ( + AI_RESOLVED_CONFLICTS_LABEL, APPROVED_BY_LABEL_PREFIX, CAN_BE_MERGED_STR, CHANGED_REQUESTED_BY_LABEL_PREFIX, @@ -812,18 +813,18 @@ async def test_process_verified_cherry_picked_pr_auto_verify_enabled( mock_pull_request = Mock(spec=PullRequest) mock_label = Mock() - mock_label.name = CHERRY_PICKED_LABEL + mock_label.name = f"{CHERRY_PICKED_LABEL}-from-main" mock_pull_request.labels = [mock_label] with ( - patch.object(pull_request_handler.github_webhook, "auto_verify_cherry_picked_prs", True), + patch.object(pull_request_handler.github_webhook, "auto_verify_cherry_picked_prs", new=True), patch.object(pull_request_handler.labels_handler, "_add_label") as mock_add_label, patch.object(pull_request_handler.check_run_handler, "set_check_success") as mock_set_success, ): await pull_request_handler._process_verified_for_update_or_new_pull_request(mock_pull_request) # Should auto-verify since auto_verify_cherry_picked_prs is True and user is in auto_verified list - mock_add_label.assert_called_once() - mock_set_success.assert_called_once_with(name=VERIFIED_LABEL_STR) + mock_add_label.assert_awaited_once() + mock_set_success.assert_awaited_once_with(name=VERIFIED_LABEL_STR) @pytest.mark.asyncio async def test_process_verified_cherry_picked_pr_auto_verify_disabled( @@ -833,18 +834,72 @@ async def test_process_verified_cherry_picked_pr_auto_verify_disabled( mock_pull_request = Mock(spec=PullRequest) mock_label = Mock() - mock_label.name = CHERRY_PICKED_LABEL + mock_label.name = f"{CHERRY_PICKED_LABEL}-from-main" mock_pull_request.labels = [mock_label] with ( - patch.object(pull_request_handler.github_webhook, "auto_verify_cherry_picked_prs", False), + patch.object(pull_request_handler.github_webhook, "auto_verify_cherry_picked_prs", new=False), patch.object(pull_request_handler.labels_handler, "_add_label") as mock_add_label, + patch.object(pull_request_handler.labels_handler, "_remove_label") as mock_remove_label, patch.object(pull_request_handler.check_run_handler, "set_check_queued") as mock_set_queued, ): await pull_request_handler._process_verified_for_update_or_new_pull_request(mock_pull_request) # Should NOT auto-verify since auto_verify_cherry_picked_prs is False - mock_add_label.assert_not_called() - mock_set_queued.assert_called_once_with(name=VERIFIED_LABEL_STR) + mock_add_label.assert_not_awaited() + mock_remove_label.assert_awaited_once_with(pull_request=mock_pull_request, label=VERIFIED_LABEL_STR) + mock_set_queued.assert_awaited_once_with(name=VERIFIED_LABEL_STR) + + @pytest.mark.asyncio + async def test_verified_skipped_for_ai_resolved_cherry_pick(self, pull_request_handler: PullRequestHandler) -> None: + """Test that AI-resolved cherry-picks are never auto-verified.""" + + mock_pull_request = Mock(spec=PullRequest) + cherry_picked_label = Mock() + cherry_picked_label.name = f"{CHERRY_PICKED_LABEL}-from-main" + ai_resolved_label = Mock() + ai_resolved_label.name = AI_RESOLVED_CONFLICTS_LABEL + mock_pull_request.labels = [cherry_picked_label, ai_resolved_label] + + with ( + patch.object(pull_request_handler.github_webhook, "auto_verify_cherry_picked_prs", new=True), + patch.object(pull_request_handler.labels_handler, "_add_label") as _mock_add_label, + patch.object(pull_request_handler.labels_handler, "_remove_label") as mock_remove_label, + patch.object(pull_request_handler.check_run_handler, "set_check_queued") as mock_set_queued, + patch.object(pull_request_handler.check_run_handler, "set_check_success") as mock_set_success, + ): + await pull_request_handler._process_verified_for_update_or_new_pull_request(mock_pull_request) + # Should set check as queued (not success) since AI-resolved cherry-picks skip auto-verification + mock_remove_label.assert_awaited_once_with(pull_request=mock_pull_request, label=VERIFIED_LABEL_STR) + mock_set_queued.assert_awaited_once_with(name=VERIFIED_LABEL_STR) + mock_set_success.assert_not_awaited() + + @pytest.mark.asyncio + async def test_ai_resolved_takes_precedence_over_auto_verify( + self, pull_request_handler: PullRequestHandler + ) -> None: + """Test that AI-resolved label takes precedence over auto-verify-cherry-picked-prs.""" + + mock_pull_request = Mock(spec=PullRequest) + cherry_picked_label = Mock() + cherry_picked_label.name = f"{CHERRY_PICKED_LABEL}-from-main" + ai_resolved_label = Mock() + ai_resolved_label.name = AI_RESOLVED_CONFLICTS_LABEL + mock_pull_request.labels = [cherry_picked_label, ai_resolved_label] + + with ( + patch.object(pull_request_handler.github_webhook, "auto_verify_cherry_picked_prs", new=True), + patch.object(pull_request_handler.labels_handler, "_add_label") as mock_add_label, + patch.object(pull_request_handler.labels_handler, "_remove_label") as mock_remove_label, + patch.object(pull_request_handler.check_run_handler, "set_check_queued") as mock_set_queued, + patch.object(pull_request_handler.check_run_handler, "set_check_success") as mock_set_success, + ): + await pull_request_handler._process_verified_for_update_or_new_pull_request(mock_pull_request) + # AI-resolved should prevent auto-verification even though auto_verify_cherry_picked_prs is True + # and parent_committer is in auto_verified_and_merged_users + mock_add_label.assert_not_awaited() + mock_remove_label.assert_awaited_once_with(pull_request=mock_pull_request, label=VERIFIED_LABEL_STR) + mock_set_queued.assert_awaited_once_with(name=VERIFIED_LABEL_STR) + mock_set_success.assert_not_awaited() @pytest.mark.asyncio async def test_add_pull_request_owner_as_assingee( diff --git a/webhook_server/tests/test_runner_handler.py b/webhook_server/tests/test_runner_handler.py index 059284f2..b01cb7a8 100644 --- a/webhook_server/tests/test_runner_handler.py +++ b/webhook_server/tests/test_runner_handler.py @@ -1007,10 +1007,21 @@ async def test_cherry_pick_success(self, runner_handler: RunnerHandler, mock_pul new=AsyncMock(return_value=(True, "success", "")), ): with patch.object(mock_pull_request, "create_issue_comment", new=Mock()) as mock_comment: - await runner_handler.cherry_pick(mock_pull_request, "main") - mock_set_progress.assert_called_once() - mock_set_success.assert_called_once() - mock_comment.assert_called_once() + with patch( + "webhook_server.libs.handlers.runner_handler.get_repository_github_app_token", + return_value=None, + ): + with patch( + "asyncio.to_thread", + new=AsyncMock( + side_effect=lambda fn, *a, **kw: fn(*a, **kw) if a or kw else fn() + ), + ): + await runner_handler.cherry_pick(mock_pull_request, "main") + mock_set_progress.assert_called_once() + mock_set_success.assert_called_once() + # Called twice: success comment + label warning (mock URL can't parse PR number) + assert mock_comment.call_count >= 1 @pytest.mark.asyncio async def test_checkout_worktree_success(self, runner_handler: RunnerHandler, mock_pull_request: Mock) -> None: @@ -1236,22 +1247,32 @@ async def cherry_pick_setup( return_value=(True, "/tmp/worktree-path", "", "") ) mock_checkout.return_value.__aexit__ = AsyncMock(return_value=None) + + async def _run_command_side_effect(command: str, **kwargs: Any) -> tuple[bool, str, str]: + if "gh pr create" in command: + return (True, "https://github.com/test-org/test-repo/pull/99", "") + return (True, "success", "") + with patch( "webhook_server.libs.handlers.runner_handler.run_command", - new=AsyncMock(return_value=(True, "success", "")), + new=AsyncMock(side_effect=_run_command_side_effect), ) as mock_run_cmd: with patch.object(mock_pull_request, "create_issue_comment", new=Mock()) as mock_comment: with patch( "asyncio.to_thread", new=AsyncMock(side_effect=lambda fn, *a, **kw: fn(*a, **kw) if a or kw else fn()), ) as mock_to_thread: - yield CherryPickMocks( - set_progress=mock_set_progress, - set_success=mock_set_success, - run_cmd=mock_run_cmd, - comment=mock_comment, - to_thread=mock_to_thread, - ) + with patch( + "webhook_server.libs.handlers.runner_handler.get_repository_github_app_token", + return_value=None, + ): + yield CherryPickMocks( + set_progress=mock_set_progress, + set_success=mock_set_success, + run_cmd=mock_run_cmd, + comment=mock_comment, + to_thread=mock_to_thread, + ) @pytest.mark.asyncio async def test_cherry_pick_assigns_pr_author(self, runner_handler: RunnerHandler, mock_pull_request: Mock) -> None: @@ -1261,7 +1282,6 @@ async def test_cherry_pick_assigns_pr_author(self, runner_handler: RunnerHandler mocks.set_progress.assert_called_once() mocks.set_success.assert_called_once() mocks.comment.assert_called_once() - assert mocks.to_thread.call_count == 3 last_cmd = mocks.run_cmd.call_args_list[-1] gh_command = last_cmd.kwargs.get("command", last_cmd.args[0] if last_cmd.args else "") assert "--assignee" in gh_command @@ -1283,7 +1303,6 @@ async def test_cherry_pick_requested_by_uses_pr_owner( assert mock_pull_request.html_url in gh_command assert "test-pr-author" in gh_command assert "--assignee" in gh_command - assert mocks.to_thread.call_count == 3 @pytest.mark.asyncio async def test_checkout_worktree_branch_already_checked_out( @@ -1401,6 +1420,198 @@ async def test_run_build_container_prepare_failure( # Should NOT call run_podman_command (early return) mock_run_podman.assert_not_called() + @pytest.mark.asyncio + async def test_cherry_pick_ai_resolves_conflicts( + self, runner_handler: RunnerHandler, mock_pull_request: Mock + ) -> None: + """Cherry-pick conflicts resolved by AI — PR created with ai-resolved-conflicts label.""" + runner_handler.github_webhook.ai_features = { + "ai-provider": "claude", + "ai-model": "sonnet", + "resolve-cherry-pick-conflicts-with-ai": {"enabled": True, "timeout-minutes": 10}, + } + + async def run_command_side_effect(command: str, **kwargs: Any) -> tuple[bool, str, str]: + # Fail on cherry-pick (but not cherry-pick --continue) + if "cherry-pick" in command and "--continue" not in command: + return (False, "", "CONFLICT (content): Merge conflict in file.py") + if "gh pr create" in command: + return (True, "https://github.com/test-org/test-repo/pull/99", "") + return (True, "success", "") + + with patch.object(runner_handler, "is_branch_exists", new=AsyncMock(return_value=Mock())): + with patch.object(runner_handler.check_run_handler, "set_check_in_progress"): + with patch.object(runner_handler.check_run_handler, "set_check_success") as mock_set_success: + with patch.object(runner_handler, "_checkout_worktree") as mock_checkout: + mock_checkout.return_value = AsyncMock() + mock_checkout.return_value.__aenter__ = AsyncMock( + return_value=(True, "/tmp/worktree-path", "", "") + ) + mock_checkout.return_value.__aexit__ = AsyncMock(return_value=None) + with patch( + "webhook_server.libs.handlers.runner_handler.run_command", + new=AsyncMock(side_effect=run_command_side_effect), + ) as mock_run_cmd: + with patch( + "webhook_server.libs.handlers.runner_handler.call_ai_cli", + new=AsyncMock(return_value=(True, "resolved")), + ) as mock_ai_cli: + with patch( + "asyncio.to_thread", + new=AsyncMock(side_effect=lambda fn, *a, **kw: fn(*a, **kw) if a or kw else fn()), + ) as mock_to_thread: + with patch( + "webhook_server.libs.handlers.runner_handler.get_repository_github_app_token", + return_value=None, + ): + await runner_handler.cherry_pick(mock_pull_request, "main") + mock_set_success.assert_called_once() + mock_ai_cli.assert_called_once() + # Verify AI comment was posted + comment_calls = mock_pull_request.create_issue_comment.call_args_list + ai_comment = any( + "Cherry-pick conflicts were resolved by AI" in str(c) for c in comment_calls + ) + assert ai_comment, f"Expected AI comment, got: {comment_calls}" + # Verify gh pr create was called (without label flags) + gh_cmd_calls = [ + c for c in mock_run_cmd.call_args_list if "gh pr create" in str(c) + ] + assert gh_cmd_calls, "gh pr create not called" + gh_cmd_str = str(gh_cmd_calls[-1]) + assert "--label" not in gh_cmd_str, ( + "Labels should not be in gh pr create command" + ) + # Verify labels were added via PyGithub add_to_labels + add_labels_calls = [ + c + for c in mock_to_thread.call_args_list + if len(c.args) >= 1 and "add_to_labels" in str(c.args[0]) + ] + assert add_labels_calls, "add_to_labels not called via asyncio.to_thread" + labels_call_str = str(add_labels_calls[-1]) + assert "ai-resolved-conflicts" in labels_call_str + assert "CherryPicked-from-main" in labels_call_str + + @pytest.mark.asyncio + async def test_cherry_pick_ai_fails_fallback(self, runner_handler: RunnerHandler, mock_pull_request: Mock) -> None: + """Cherry-pick conflicts + AI fails — falls back to manual instructions.""" + runner_handler.github_webhook.ai_features = { + "ai-provider": "claude", + "ai-model": "sonnet", + "resolve-cherry-pick-conflicts-with-ai": {"enabled": True, "timeout-minutes": 10}, + } + + async def run_command_side_effect(command: str, **kwargs: Any) -> tuple[bool, str, str]: + if "cherry-pick" in command and "--continue" not in command: + return (False, "", "CONFLICT (content): Merge conflict in file.py") + return (True, "success", "") + + with patch.object(runner_handler, "is_branch_exists", new=AsyncMock(return_value=Mock())): + with patch.object(runner_handler.check_run_handler, "set_check_in_progress"): + with patch.object(runner_handler.check_run_handler, "set_check_failure") as mock_set_failure: + with patch.object(runner_handler, "_checkout_worktree") as mock_checkout: + mock_checkout.return_value = AsyncMock() + mock_checkout.return_value.__aenter__ = AsyncMock( + return_value=(True, "/tmp/worktree-path", "", "") + ) + mock_checkout.return_value.__aexit__ = AsyncMock(return_value=None) + with patch( + "webhook_server.libs.handlers.runner_handler.run_command", + new=AsyncMock(side_effect=run_command_side_effect), + ): + with patch( + "webhook_server.libs.handlers.runner_handler.call_ai_cli", + new=AsyncMock(return_value=(False, "AI failed")), + ): + with patch( + "asyncio.to_thread", + new=AsyncMock(side_effect=lambda fn, *a, **kw: fn(*a, **kw) if a or kw else fn()), + ): + await runner_handler.cherry_pick(mock_pull_request, "main") + mock_set_failure.assert_called() + comment_calls = mock_pull_request.create_issue_comment.call_args_list + manual_comment = any( + "Manual cherry-pick is needed" in str(c) for c in comment_calls + ) + assert manual_comment + + @pytest.mark.asyncio + async def test_cherry_pick_ai_not_configured_fallback( + self, runner_handler: RunnerHandler, mock_pull_request: Mock + ) -> None: + """Cherry-pick conflicts + AI not configured — manual fallback, call_ai_cli not called.""" + runner_handler.github_webhook.ai_features = None + + async def run_command_side_effect(command: str, **kwargs: Any) -> tuple[bool, str, str]: + if "cherry-pick" in command and "--continue" not in command: + return (False, "", "CONFLICT (content): Merge conflict in file.py") + return (True, "success", "") + + with patch.object(runner_handler, "is_branch_exists", new=AsyncMock(return_value=Mock())): + with patch.object(runner_handler.check_run_handler, "set_check_in_progress"): + with patch.object(runner_handler.check_run_handler, "set_check_failure") as mock_set_failure: + with patch.object(runner_handler, "_checkout_worktree") as mock_checkout: + mock_checkout.return_value = AsyncMock() + mock_checkout.return_value.__aenter__ = AsyncMock( + return_value=(True, "/tmp/worktree-path", "", "") + ) + mock_checkout.return_value.__aexit__ = AsyncMock(return_value=None) + with patch( + "webhook_server.libs.handlers.runner_handler.run_command", + new=AsyncMock(side_effect=run_command_side_effect), + ): + with patch( + "webhook_server.libs.handlers.runner_handler.call_ai_cli", + ) as mock_ai_cli: + with patch( + "asyncio.to_thread", + new=AsyncMock(side_effect=lambda fn, *a, **kw: fn(*a, **kw) if a or kw else fn()), + ): + await runner_handler.cherry_pick(mock_pull_request, "main") + mock_set_failure.assert_called() + mock_ai_cli.assert_not_called() + + @pytest.mark.asyncio + async def test_cherry_pick_ai_feature_disabled_fallback( + self, runner_handler: RunnerHandler, mock_pull_request: Mock + ) -> None: + """Cherry-pick conflicts + AI configured but resolve-cherry-pick disabled — manual fallback.""" + runner_handler.github_webhook.ai_features = { + "ai-provider": "claude", + "ai-model": "sonnet", + "resolve-cherry-pick-conflicts-with-ai": {"enabled": False}, + } + + async def run_command_side_effect(command: str, **kwargs: Any) -> tuple[bool, str, str]: + if "cherry-pick" in command and "--continue" not in command: + return (False, "", "CONFLICT (content): Merge conflict in file.py") + return (True, "success", "") + + with patch.object(runner_handler, "is_branch_exists", new=AsyncMock(return_value=Mock())): + with patch.object(runner_handler.check_run_handler, "set_check_in_progress"): + with patch.object(runner_handler.check_run_handler, "set_check_failure") as mock_set_failure: + with patch.object(runner_handler, "_checkout_worktree") as mock_checkout: + mock_checkout.return_value = AsyncMock() + mock_checkout.return_value.__aenter__ = AsyncMock( + return_value=(True, "/tmp/worktree-path", "", "") + ) + mock_checkout.return_value.__aexit__ = AsyncMock(return_value=None) + with patch( + "webhook_server.libs.handlers.runner_handler.run_command", + new=AsyncMock(side_effect=run_command_side_effect), + ): + with patch( + "webhook_server.libs.handlers.runner_handler.call_ai_cli", + ) as mock_ai_cli: + with patch( + "asyncio.to_thread", + new=AsyncMock(side_effect=lambda fn, *a, **kw: fn(*a, **kw) if a or kw else fn()), + ): + await runner_handler.cherry_pick(mock_pull_request, "main") + mock_set_failure.assert_called() + mock_ai_cli.assert_not_called() + class TestCheckConfig: """Test suite for CheckConfig dataclass.""" diff --git a/webhook_server/utils/constants.py b/webhook_server/utils/constants.py index 289552b1..8c2911e8 100644 --- a/webhook_server/utils/constants.py +++ b/webhook_server/utils/constants.py @@ -22,6 +22,7 @@ LABELS_SEPARATOR: str = "-" CHERRY_PICK_LABEL_PREFIX: str = f"cherry-pick{LABELS_SEPARATOR}" CHERRY_PICKED_LABEL: str = "CherryPicked" +AI_RESOLVED_CONFLICTS_LABEL: str = "ai-resolved-conflicts" APPROVED_BY_LABEL_PREFIX: str = f"approved{LABELS_SEPARATOR}" LGTM_BY_LABEL_PREFIX: str = f"{LGTM_STR}{LABELS_SEPARATOR}" CHANGED_REQUESTED_BY_LABEL_PREFIX: str = f"changes-requested{LABELS_SEPARATOR}" @@ -78,11 +79,13 @@ NEEDS_REBASE_LABEL_STR: "needs-rebase", HAS_CONFLICTS_LABEL_STR: "has-conflicts", CAN_BE_MERGED_STR: "can-be-merged", + AI_RESOLVED_CONFLICTS_LABEL: "cherry-pick", } STATIC_LABELS_DICT: dict[str, str] = { **USER_LABELS_DICT, CHERRY_PICKED_LABEL: "1D76DB", + AI_RESOLVED_CONFLICTS_LABEL: "FFA500", f"{SIZE_LABEL_PREFIX}L": "F5621C", f"{SIZE_LABEL_PREFIX}M": "F09C74", f"{SIZE_LABEL_PREFIX}S": "0E8A16", diff --git a/webhook_server/utils/github_repository_settings.py b/webhook_server/utils/github_repository_settings.py index bcef5185..5628c9fe 100644 --- a/webhook_server/utils/github_repository_settings.py +++ b/webhook_server/utils/github_repository_settings.py @@ -430,3 +430,30 @@ def get_repository_github_app_api(config_: Config, repository_name: str) -> Gith ) return None + + +def get_repository_github_app_token(config_: Config, repository_name: str) -> str | None: + """Get a raw GitHub App installation token string for use with CLI tools. + + Returns the token string or None if the app is not configured/installed. + """ + LOGGER.debug(f"Getting GitHub App installation token for {repository_name}") + + with open(os.path.join(config_.data_dir, "webhook-server.private-key.pem")) as fd: + private_key = fd.read() + + github_app_id: int = config_.root_data["github-app-id"] + auth: AppAuth = Auth.AppAuth(app_id=github_app_id, private_key=private_key) + app_instance: GithubIntegration = GithubIntegration(auth=auth) + owner, repo = repository_name.split("/", maxsplit=1) + + try: + installation = app_instance.get_repo_installation(owner=owner, repo=repo) + access_token = app_instance.get_access_token(installation.id) + return access_token.token + except GithubException: + LOGGER.exception( + f"Failed to get GitHub App installation token for {repository_name}, " + f"make sure the app is installed (https://github.com/apps/manage-repositories-app)" + ) + return None