Skip to content

Commit f392601

Browse files
authored
fix: handle cherry-pick edge cases after AI conflict resolution (#1045)
## Summary - Check `CHERRY_PICK_HEAD` before running `--continue` to handle auto-completed cherry-picks (e.g. modify/delete conflicts) - Handle empty cherry-pick result with `--allow-empty` commit when AI resolution produces no changes vs target branch - Add `--label` flags to `gh pr create` to prevent auto-verify/auto-merge race condition on AI-resolved cherry-pick PRs - Add `ai-resolved-conflicts` guard in `set_pull_request_automerge()` with active `disable_automerge` when already enabled - Return `False` for unexpected `rev-parse` errors instead of silently proceeding ## Test plan - [x] New test `test_cherry_pick_ai_resolves_modify_delete_conflict` validates auto-complete scenario - [x] New test `test_cherry_pick_ai_resolves_empty_cherry_pick` validates empty commit fallback - [x] Updated `test_cherry_pick_ai_resolves_conflicts` verifies labels in `gh pr create` command - [x] Full test suite passes (1436 tests, 90.22% coverage) Closes #1043 Closes #1044 <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Cherry-pick pull requests now include appropriate labels for better tracking and organization. * **Improvements** * Enhanced handling of AI-resolved conflicts in automated cherry-pick operations. * Auto-merge now prevents activation for cherry-picks containing resolved conflicts. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent 88ffe55 commit f392601

3 files changed

Lines changed: 134 additions & 17 deletions

File tree

webhook_server/libs/handlers/pull_request_handler.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -905,6 +905,23 @@ async def set_pull_request_automerge(self, pull_request: PullRequest) -> None:
905905
self.logger.debug(f"{self.log_prefix} auto_merge: {auto_merge}, branch: {pull_request.base.ref}")
906906

907907
if auto_merge:
908+
# AI-resolved cherry-picks should NEVER be auto-merged
909+
labels = await asyncio.to_thread(lambda: list(pull_request.labels))
910+
if any(label.name == AI_RESOLVED_CONFLICTS_LABEL for label in labels):
911+
if pull_request.raw_data.get("auto_merge"):
912+
try:
913+
self.logger.info(
914+
f"{self.log_prefix} AI-resolved cherry-pick has auto-merge enabled, disabling it"
915+
)
916+
await asyncio.to_thread(pull_request.disable_automerge)
917+
except Exception:
918+
self.logger.exception(
919+
f"{self.log_prefix} Failed to disable auto-merge for AI-resolved cherry-pick"
920+
)
921+
else:
922+
self.logger.info(f"{self.log_prefix} AI-resolved cherry-pick detected, skipping auto-merge")
923+
return
924+
908925
try:
909926
if not pull_request.raw_data.get("auto_merge"):
910927
self.logger.info(

webhook_server/libs/handlers/runner_handler.py

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -786,7 +786,7 @@ async def _resolve_cherry_pick_with_ai(
786786

787787
# Check if cherry-pick is still in progress (it may have auto-completed
788788
# after staging resolved files, e.g. for modify/delete conflicts)
789-
rc_check, _, _ = await run_command(
789+
rc_check, _, err_check = await run_command(
790790
command=f"{git_cmd} rev-parse --verify CHERRY_PICK_HEAD",
791791
log_prefix=self.log_prefix,
792792
redact_secrets=[github_token],
@@ -801,9 +801,26 @@ async def _resolve_cherry_pick_with_ai(
801801
mask_sensitive=self.github_webhook.mask_sensitive,
802802
)
803803
if not rc:
804-
self.logger.error(f"{self.log_prefix} cherry-pick --continue failed after AI resolution: {err}")
805-
return False
804+
if "cherry-pick is now empty" in err:
805+
self.logger.info(
806+
f"{self.log_prefix} Cherry-pick is empty after AI resolution, committing with --allow-empty"
807+
)
808+
rc_empty, _, err_empty = await run_command(
809+
command=f"{git_cmd} -c core.editor=true commit --allow-empty -C CHERRY_PICK_HEAD",
810+
log_prefix=self.log_prefix,
811+
redact_secrets=[github_token],
812+
mask_sensitive=self.github_webhook.mask_sensitive,
813+
)
814+
if not rc_empty:
815+
self.logger.error(f"{self.log_prefix} Failed to commit empty cherry-pick: {err_empty}")
816+
return False
817+
else:
818+
self.logger.error(f"{self.log_prefix} cherry-pick --continue failed after AI resolution: {err}")
819+
return False
806820
else:
821+
if err_check and "needed a single revision" not in err_check.lower():
822+
self.logger.error(f"{self.log_prefix} Unexpected CHERRY_PICK_HEAD check error: {err_check}")
823+
return False
807824
self.logger.info(f"{self.log_prefix} Cherry-pick already completed after staging resolved files")
808825

809826
self.logger.info(f"{self.log_prefix} AI successfully resolved cherry-pick conflicts")
@@ -988,11 +1005,16 @@ async def cherry_pick(
9881005

9891006
cherry_picked_label = f"{CHERRY_PICKED_LABEL}-from-{source_branch}"[:49]
9901007

1008+
label_flags = f" --label {shlex.quote(cherry_picked_label)}"
1009+
if cherry_pick_had_conflicts:
1010+
label_flags += f" --label {shlex.quote(AI_RESOLVED_CONFLICTS_LABEL)}"
1011+
9911012
gh_pr_command = (
9921013
f"gh pr create --repo {shlex.quote(repo_full_name)}"
9931014
f" --base {shlex.quote(target_branch)}"
9941015
f" --head {shlex.quote(new_branch_name)}"
9951016
f"{assignee_flag}"
1017+
f"{label_flags}"
9961018
f" --title {shlex.quote(pr_title)}"
9971019
f" --body {shlex.quote(pr_body)}"
9981020
)

webhook_server/tests/test_runner_handler.py

Lines changed: 92 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1494,7 +1494,7 @@ async def run_command_side_effect(command: str, **kwargs: Any) -> tuple[bool, st
14941494
with patch(
14951495
"asyncio.to_thread",
14961496
new=AsyncMock(side_effect=lambda fn, *a, **kw: fn(*a, **kw) if a or kw else fn()),
1497-
) as mock_to_thread:
1497+
):
14981498
with patch(
14991499
"webhook_server.libs.handlers.runner_handler.get_repository_github_app_token",
15001500
return_value=None,
@@ -1514,19 +1514,9 @@ async def run_command_side_effect(command: str, **kwargs: Any) -> tuple[bool, st
15141514
]
15151515
assert gh_cmd_calls, "gh pr create not called"
15161516
gh_cmd_str = str(gh_cmd_calls[-1])
1517-
assert "--label" not in gh_cmd_str, (
1518-
"Labels should not be in gh pr create command"
1519-
)
1520-
# Verify labels were added via PyGithub add_to_labels
1521-
add_labels_calls = [
1522-
c
1523-
for c in mock_to_thread.call_args_list
1524-
if len(c.args) >= 1 and "add_to_labels" in str(c.args[0])
1525-
]
1526-
assert add_labels_calls, "add_to_labels not called via asyncio.to_thread"
1527-
labels_call_str = str(add_labels_calls[-1])
1528-
assert "ai-resolved-conflicts" in labels_call_str
1529-
assert "CherryPicked-from-main" in labels_call_str
1517+
assert "--label" in gh_cmd_str, "Labels should be in gh pr create command"
1518+
assert "ai-resolved-conflicts" in gh_cmd_str
1519+
assert "CherryPicked-from-main" in gh_cmd_str
15301520

15311521
@pytest.mark.asyncio
15321522
async def test_cherry_pick_ai_resolves_modify_delete_conflict(
@@ -1599,6 +1589,94 @@ async def run_command_side_effect(command: str, **kwargs: Any) -> tuple[bool, st
15991589
"Cherry-pick conflicts were resolved by AI" in str(c) for c in comment_calls
16001590
)
16011591
assert ai_comment, f"Expected AI comment, got: {comment_calls}"
1592+
# Verify labels are in gh pr create command
1593+
gh_cmd_calls = [
1594+
c for c in mock_run_cmd.call_args_list if "gh pr create" in str(c)
1595+
]
1596+
assert gh_cmd_calls, "gh pr create not called"
1597+
gh_cmd_str = str(gh_cmd_calls[-1])
1598+
assert "--label" in gh_cmd_str
1599+
assert "ai-resolved-conflicts" in gh_cmd_str
1600+
1601+
@pytest.mark.asyncio
1602+
async def test_cherry_pick_ai_resolves_empty_cherry_pick(
1603+
self, runner_handler: RunnerHandler, mock_pull_request: Mock
1604+
) -> None:
1605+
"""Cherry-pick conflict resolved by AI results in empty commit — committed with --allow-empty."""
1606+
runner_handler.github_webhook.ai_features = {
1607+
"ai-provider": "claude",
1608+
"ai-model": "sonnet",
1609+
"resolve-cherry-pick-conflicts-with-ai": {"enabled": True, "timeout-minutes": 10},
1610+
}
1611+
1612+
async def run_command_side_effect(command: str, **kwargs: Any) -> tuple[bool, str, str]:
1613+
# Fail on cherry-pick (conflict)
1614+
if "cherry-pick" in command and "--continue" not in command and "rev-parse" not in command:
1615+
return (False, "", "CONFLICT (modify/delete): file.sh deleted in HEAD and modified in abc1234")
1616+
# CHERRY_PICK_HEAD exists — cherry-pick still in progress
1617+
if "rev-parse --verify CHERRY_PICK_HEAD" in command:
1618+
return (True, "abc123", "")
1619+
# cherry-pick --continue fails with empty cherry-pick
1620+
if "cherry-pick --continue" in command:
1621+
return (
1622+
False,
1623+
"",
1624+
"The previous cherry-pick is now empty, possibly due to conflict resolution.\n"
1625+
"If you wish to commit it anyway, use:\n\n git commit --allow-empty\n",
1626+
)
1627+
if "gh pr create" in command:
1628+
return (True, "https://github.com/test-org/test-repo/pull/99", "")
1629+
return (True, "success", "")
1630+
1631+
with patch.object(runner_handler, "is_branch_exists", new=AsyncMock(return_value=Mock())):
1632+
with patch.object(runner_handler.check_run_handler, "set_check_in_progress"):
1633+
with patch.object(runner_handler.check_run_handler, "set_check_success") as mock_set_success:
1634+
with patch.object(runner_handler, "_checkout_worktree") as mock_checkout:
1635+
mock_checkout.return_value = AsyncMock()
1636+
mock_checkout.return_value.__aenter__ = AsyncMock(
1637+
return_value=(True, "/tmp/worktree-path", "", "")
1638+
)
1639+
mock_checkout.return_value.__aexit__ = AsyncMock(return_value=None)
1640+
with patch(
1641+
"webhook_server.libs.handlers.runner_handler.run_command",
1642+
new=AsyncMock(side_effect=run_command_side_effect),
1643+
) as mock_run_cmd:
1644+
with patch(
1645+
"webhook_server.libs.handlers.runner_handler.call_ai_cli",
1646+
new=AsyncMock(return_value=(True, "resolved")),
1647+
) as mock_ai_cli:
1648+
with patch(
1649+
"asyncio.to_thread",
1650+
new=AsyncMock(side_effect=lambda fn, *a, **kw: fn(*a, **kw) if a or kw else fn()),
1651+
):
1652+
with patch(
1653+
"webhook_server.libs.handlers.runner_handler.get_repository_github_app_token",
1654+
return_value=None,
1655+
):
1656+
await runner_handler.cherry_pick(mock_pull_request, "main")
1657+
mock_set_success.assert_called_once()
1658+
mock_ai_cli.assert_called_once()
1659+
# Verify --allow-empty commit was called
1660+
allow_empty_calls = [
1661+
c for c in mock_run_cmd.call_args_list if "commit --allow-empty" in str(c)
1662+
]
1663+
assert allow_empty_calls, (
1664+
"git commit --allow-empty should be called for empty cherry-pick"
1665+
)
1666+
# Verify AI comment was posted
1667+
comment_calls = mock_pull_request.create_issue_comment.call_args_list
1668+
ai_comment = any(
1669+
"Cherry-pick conflicts were resolved by AI" in str(c) for c in comment_calls
1670+
)
1671+
assert ai_comment, f"Expected AI comment, got: {comment_calls}"
1672+
# Verify labels are in gh pr create command
1673+
gh_cmd_calls = [
1674+
c for c in mock_run_cmd.call_args_list if "gh pr create" in str(c)
1675+
]
1676+
assert gh_cmd_calls, "gh pr create not called"
1677+
gh_cmd_str = str(gh_cmd_calls[-1])
1678+
assert "--label" in gh_cmd_str
1679+
assert "ai-resolved-conflicts" in gh_cmd_str
16021680

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

0 commit comments

Comments
 (0)