From 8b129a9dc66b8edde022d4c3a945bd292ca3cf17 Mon Sep 17 00:00:00 2001 From: Meni Yakove Date: Wed, 18 Mar 2026 11:47:55 +0200 Subject: [PATCH] fix: handle cherry-pick auto-completion after AI conflict resolution After AI resolves cherry-pick conflicts (e.g. modify/delete conflicts), git may auto-complete the cherry-pick when all conflicts are staged. Check CHERRY_PICK_HEAD before running --continue to avoid "no cherry-pick in progress" errors. Closes #1041 --- .../libs/handlers/runner_handler.py | 23 ++++-- webhook_server/tests/test_runner_handler.py | 72 +++++++++++++++++++ 2 files changed, 89 insertions(+), 6 deletions(-) diff --git a/webhook_server/libs/handlers/runner_handler.py b/webhook_server/libs/handlers/runner_handler.py index c2fef8d6..64859345 100644 --- a/webhook_server/libs/handlers/runner_handler.py +++ b/webhook_server/libs/handlers/runner_handler.py @@ -784,16 +784,27 @@ async def _resolve_cherry_pick_with_ai( 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", + # Check if cherry-pick is still in progress (it may have auto-completed + # after staging resolved files, e.g. for modify/delete conflicts) + rc_check, _, _ = await run_command( + command=f"{git_cmd} rev-parse --verify CHERRY_PICK_HEAD", 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 + if rc_check: + # Cherry-pick still in progress, finalize it + 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 + else: + self.logger.info(f"{self.log_prefix} Cherry-pick already completed after staging resolved files") self.logger.info(f"{self.log_prefix} AI successfully resolved cherry-pick conflicts") return True diff --git a/webhook_server/tests/test_runner_handler.py b/webhook_server/tests/test_runner_handler.py index 822e5781..aaaf403f 100644 --- a/webhook_server/tests/test_runner_handler.py +++ b/webhook_server/tests/test_runner_handler.py @@ -1528,6 +1528,78 @@ async def run_command_side_effect(command: str, **kwargs: Any) -> tuple[bool, st 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_resolves_modify_delete_conflict( + self, runner_handler: RunnerHandler, mock_pull_request: Mock + ) -> None: + """Cherry-pick modify/delete conflict — AI resolves, cherry-pick auto-completes without --continue.""" + 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 (conflict) + if "cherry-pick" in command and "--continue" not in command and "rev-parse" not in command: + return (False, "", "CONFLICT (modify/delete): file.sh deleted in HEAD and modified in abc1234") + # CHERRY_PICK_HEAD does not exist — cherry-pick auto-completed + if "rev-parse --verify CHERRY_PICK_HEAD" in command: + return (False, "", "fatal: Needed a single revision") + 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()), + ): + 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 cherry-pick --continue was NOT called + continue_calls = [ + c for c in mock_run_cmd.call_args_list if "cherry-pick --continue" in str(c) + ] + assert not continue_calls, ( + "cherry-pick --continue should not be called" + " when cherry-pick auto-completed" + ) + # Verify CHERRY_PICK_HEAD check was called + rev_parse_calls = [ + c for c in mock_run_cmd.call_args_list if "rev-parse" in str(c) + ] + assert rev_parse_calls, ( + "CHERRY_PICK_HEAD check (rev-parse) should have been called" + ) + # 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}" + @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."""