Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 16 additions & 22 deletions webhook_server/libs/handlers/runner_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -745,12 +745,20 @@ async def _resolve_cherry_pick_with_ai(

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."
"Resolve ALL conflicts in ALL files.\n\n"
"How to handle each conflict type:\n"
"- Standard conflict markers (<<<<<<< HEAD, =======, >>>>>>>): "
"HEAD is the target branch. Adapt the cherry-picked changes to fit "
"the target branch code.\n"
"- File 'deleted in HEAD and modified in <commit>': This means the file "
"does not exist on the target branch. If the cherry-pick is introducing "
"this file to the target branch, keep the file and 'git add' it. "
"If the file was intentionally removed from the target branch and the "
"changes are not relevant, 'git rm' it.\n"
"- File 'added in both' or 'renamed': Merge the content, keeping both "
"sides' intent.\n\n"
"After resolving all conflicts, stage everything with 'git add' and "
"make sure the result is syntactically valid."
)

self.logger.info(f"{self.log_prefix} Attempting AI conflict resolution with {ai_provider}/{ai_model}")
Expand Down Expand Up @@ -801,22 +809,8 @@ async def _resolve_cherry_pick_with_ai(
mask_sensitive=self.github_webhook.mask_sensitive,
)
if not rc:
if "cherry-pick is now empty" in err:
self.logger.info(
f"{self.log_prefix} Cherry-pick is empty after AI resolution, committing with --allow-empty"
)
rc_empty, _, err_empty = await run_command(
command=f"{git_cmd} -c core.editor=true commit --allow-empty -C CHERRY_PICK_HEAD",
log_prefix=self.log_prefix,
redact_secrets=[github_token],
mask_sensitive=self.github_webhook.mask_sensitive,
)
if not rc_empty:
self.logger.error(f"{self.log_prefix} Failed to commit empty cherry-pick: {err_empty}")
return False
else:
self.logger.error(f"{self.log_prefix} cherry-pick --continue failed after AI resolution: {err}")
return False
self.logger.error(f"{self.log_prefix} cherry-pick --continue failed after AI resolution: {err}")
return False
else:
if err_check and "needed a single revision" not in err_check.lower():
self.logger.error(f"{self.log_prefix} Unexpected CHERRY_PICK_HEAD check error: {err_check}")
Expand Down
39 changes: 17 additions & 22 deletions webhook_server/tests/test_runner_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -1502,6 +1502,11 @@ async def run_command_side_effect(command: str, **kwargs: Any) -> tuple[bool, st
await runner_handler.cherry_pick(mock_pull_request, "main")
mock_set_success.assert_called_once()
mock_ai_cli.assert_called_once()
# Verify prompt includes delete/modify conflict guidance
ai_prompt = str(mock_ai_cli.call_args)
assert "deleted in HEAD and modified in" in ai_prompt, (
"AI prompt should include delete/modify conflict guidance"
)
# Verify AI comment was posted
comment_calls = mock_pull_request.create_issue_comment.call_args_list
ai_comment = any(
Expand Down Expand Up @@ -1599,10 +1604,10 @@ async def run_command_side_effect(command: str, **kwargs: Any) -> tuple[bool, st
assert "ai-resolved-conflicts" in gh_cmd_str

@pytest.mark.asyncio
async def test_cherry_pick_ai_resolves_empty_cherry_pick(
async def test_cherry_pick_ai_empty_result_falls_back_to_manual(
self, runner_handler: RunnerHandler, mock_pull_request: Mock
) -> None:
"""Cherry-pick conflict resolved by AI results in empty commit — committed with --allow-empty."""
"""Cherry-pick conflict resolved by AI but result is empty — falls back to manual instructions."""
runner_handler.github_webhook.ai_features = {
"ai-provider": "claude",
"ai-model": "sonnet",
Expand All @@ -1624,13 +1629,11 @@ async def run_command_side_effect(command: str, **kwargs: Any) -> tuple[bool, st
"The previous cherry-pick is now empty, possibly due to conflict resolution.\n"
"If you wish to commit it anyway, use:\n\n git commit --allow-empty\n",
)
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.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(
Expand All @@ -1654,29 +1657,21 @@ async def run_command_side_effect(command: str, **kwargs: Any) -> tuple[bool, st
return_value=None,
):
await runner_handler.cherry_pick(mock_pull_request, "main")
mock_set_success.assert_called_once()
mock_set_failure.assert_called()
mock_ai_cli.assert_called_once()
# Verify --allow-empty commit was called
# Verify --allow-empty was NOT called
allow_empty_calls = [
c for c in mock_run_cmd.call_args_list if "commit --allow-empty" in str(c)
]
assert allow_empty_calls, (
"git commit --allow-empty should be called for empty cherry-pick"
)
# Verify AI comment was posted
assert not allow_empty_calls, "git commit --allow-empty should NOT be called"
# Verify manual cherry-pick 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
manual_comment = any(
"Manual cherry-pick is needed" in str(c) for c in comment_calls
)
assert manual_comment, (
f"Expected manual cherry-pick comment, got: {comment_calls}"
)
assert ai_comment, f"Expected AI comment, got: {comment_calls}"
# Verify labels are in gh pr create command
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" in gh_cmd_str
assert "ai-resolved-conflicts" in gh_cmd_str

@pytest.mark.asyncio
async def test_cherry_pick_ai_fails_fallback(self, runner_handler: RunnerHandler, mock_pull_request: Mock) -> None:
Expand Down