Skip to content

Commit 5ef48b4

Browse files
authored
fix: prevent duplicate cherry-pick PRs via label check (#1055)
## Summary - Check if cherry-pick label already exists on the PR before processing - Skip branches whose `cherry-pick-{branch}` label is already present - Post comment telling user to remove the label to re-trigger - Works for both merged and unmerged PRs - Early return when no valid target branches (avoids unnecessary API calls) Triggered by duplicate cherry-pick PRs on RedHatQE/mtv-api-tests#369 Closes #1054 ## Test plan - [x] `test_process_cherry_pick_command_skips_already_cherry_picked` - partial skip (merged PR) - [x] `test_process_cherry_pick_command_all_already_cherry_picked` - full skip (merged PR) - [x] `test_process_cherry_pick_command_skips_already_cherry_picked_unmerged` - partial skip (unmerged PR) - [x] All existing cherry-pick tests pass (9 total) - [x] All 200 tests pass <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Bug Fixes** * Cherry-pick operations now skip branches that are already labeled, post a comment listing skipped branches with re-trigger instructions, and return early if no valid targets remain. * Label naming/formatting standardized for consistency. * Per-branch handling ensures failed label additions skip that branch without affecting others. * **Tests** * Added idempotency and skip-case tests covering merged/unmerged PRs and label-add failures. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent 5a8886e commit 5ef48b4

2 files changed

Lines changed: 253 additions & 24 deletions

File tree

webhook_server/libs/handlers/issue_comment_handler.py

Lines changed: 59 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -391,7 +391,7 @@ async def process_cherry_pick_command(
391391
"""Process cherry-pick command for pull requests.
392392
393393
This method handles cherry-pick requests for both unmerged and merged PRs.
394-
Cherry-pick labels (cherry-pick/<branch-name>) are added in BOTH scenarios:
394+
Cherry-pick labels (cherry-pick-<branch-name>) are added in BOTH scenarios:
395395
396396
**Unmerged PRs:**
397397
- Labels indicate which branches need cherry-picking after the PR is merged
@@ -411,12 +411,17 @@ async def process_cherry_pick_command(
411411
412412
Example:
413413
# Unmerged PR: /cherry-pick v1.0 v2.0
414-
# - Adds labels: cherry-pick/v1.0, cherry-pick/v2.0
414+
# - Adds labels: cherry-pick-v1.0, cherry-pick-v2.0
415415
# - Posts comment explaining labels will trigger auto cherry-pick on merge
416416
417417
# Merged PR: /cherry-pick v1.0 v2.0
418+
# - Adds labels: cherry-pick-v1.0, cherry-pick-v2.0 as idempotency markers
418419
# - Executes cherry-pick to v1.0 and v2.0 immediately
419-
# - Adds labels: cherry-pick/v1.0, cherry-pick/v2.0 to track completion
420+
# - Remove the label to retry a failed cherry-pick
421+
422+
Duplicate prevention:
423+
If a cherry-pick label already exists for a target branch, that branch is
424+
skipped. To re-trigger a cherry-pick, remove the label and run the command again.
420425
"""
421426
_target_branches: list[str] = command_args.split()
422427
_exits_target_branches: set[str] = set()
@@ -438,29 +443,63 @@ async def process_cherry_pick_command(
438443
self.logger.info(f"{self.log_prefix} {_non_exits_target_branches_msg}")
439444
await asyncio.to_thread(pull_request.create_issue_comment, _non_exits_target_branches_msg)
440445

441-
cp_labels: list[str] = [
442-
f"{CHERRY_PICK_LABEL_PREFIX}{_target_branch}" for _target_branch in _exits_target_branches
443-
]
446+
if not _exits_target_branches:
447+
return
448+
449+
# Filter out branches that already have cherry-pick labels
450+
existing_labels = {label.name for label in await asyncio.to_thread(lambda: list(pull_request.labels))}
451+
_already_cherry_picked: list[str] = []
452+
_branches_to_process: set[str] = set()
453+
454+
for _branch in _exits_target_branches:
455+
label_name = f"{CHERRY_PICK_LABEL_PREFIX}{_branch}"
456+
if label_name in existing_labels:
457+
_already_cherry_picked.append(_branch)
458+
else:
459+
_branches_to_process.add(_branch)
460+
461+
if _already_cherry_picked:
462+
already_msg = ", ".join(f"`{b}`" for b in _already_cherry_picked)
463+
await asyncio.to_thread(
464+
pull_request.create_issue_comment,
465+
f"Cherry-pick label already present for: {already_msg}\n"
466+
"To re-trigger, remove the cherry-pick label(s) and run the command again.",
467+
)
468+
469+
if not _branches_to_process:
470+
return
444471

445-
if _exits_target_branches:
446-
if not self.hook_data["issue"].get("pull_request", {}).get("merged_at"):
447-
info_msg: str = f"""
472+
cp_labels: list[str] = [f"{CHERRY_PICK_LABEL_PREFIX}{_branch}" for _branch in _branches_to_process]
473+
474+
if not self.hook_data["issue"].get("pull_request", {}).get("merged_at"):
475+
info_msg: str = f"""
448476
Cherry-pick requested for PR: `{pull_request.title}` by user `{reviewed_user}`
449-
Adding label/s `{" ".join([_cp_label for _cp_label in cp_labels])}` for automatic cheery-pick once the PR is merged
477+
Adding label/s `{" ".join(cp_labels)}` for automatic cherry-pick once the PR is merged
450478
"""
451479

452-
self.logger.info(f"{self.log_prefix} {info_msg}")
453-
await asyncio.to_thread(pull_request.create_issue_comment, info_msg)
454-
else:
455-
for _exits_target_branch in _exits_target_branches:
456-
await self.runner_handler.cherry_pick(
457-
pull_request=pull_request,
458-
target_branch=_exits_target_branch,
459-
assign_to_pr_owner=self.github_webhook.cherry_pick_assign_to_pr_author,
480+
self.logger.info(f"{self.log_prefix} {info_msg}")
481+
await asyncio.to_thread(pull_request.create_issue_comment, info_msg)
482+
else:
483+
for _branch in _branches_to_process:
484+
label_added = await self.labels_handler._add_label(
485+
pull_request=pull_request,
486+
label=f"{CHERRY_PICK_LABEL_PREFIX}{_branch}",
487+
)
488+
if not label_added:
489+
self.logger.info(
490+
f"{self.log_prefix} Skipping cherry-pick for {_branch},"
491+
" label already exists or could not be added"
460492
)
493+
continue
494+
await self.runner_handler.cherry_pick(
495+
pull_request=pull_request,
496+
target_branch=_branch,
497+
assign_to_pr_owner=self.github_webhook.cherry_pick_assign_to_pr_author,
498+
)
499+
return
461500

462-
for _cp_label in cp_labels:
463-
await self.labels_handler._add_label(pull_request=pull_request, label=_cp_label)
501+
for _cp_label in cp_labels:
502+
await self.labels_handler._add_label(pull_request=pull_request, label=_cp_label)
464503

465504
async def process_retest_command(
466505
self, pull_request: PullRequest, command_args: str, reviewed_user: str, automerge: bool = False

webhook_server/tests/test_issue_comment_handler.py

Lines changed: 194 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from webhook_server.utils.constants import (
99
APPROVE_STR,
1010
BUILD_AND_PUSH_CONTAINER_STR,
11+
CHERRY_PICK_LABEL_PREFIX,
1112
COMMAND_ASSIGN_REVIEWER_STR,
1213
COMMAND_ASSIGN_REVIEWERS_STR,
1314
COMMAND_CHECK_CAN_MERGE_STR,
@@ -794,6 +795,7 @@ async def test_process_cherry_pick_command_existing_branches(
794795
"""Test processing cherry pick command with existing branches."""
795796
mock_pull_request = Mock()
796797
mock_pull_request.title = "Test PR"
798+
mock_pull_request.labels = []
797799
# Patch is_merged as a method
798800
with patch.object(mock_pull_request, "is_merged", new=Mock(return_value=False)):
799801
with patch.object(issue_comment_handler.repository, "get_branch") as mock_get_branch:
@@ -820,6 +822,7 @@ async def test_process_cherry_pick_command_non_existing_branches(
820822
) -> None:
821823
"""Test processing cherry pick command with non-existing branches."""
822824
mock_pull_request = Mock()
825+
mock_pull_request.labels = []
823826

824827
with patch.object(issue_comment_handler.repository, "get_branch", side_effect=Exception("Branch not found")):
825828
with patch.object(mock_pull_request, "create_issue_comment") as mock_comment:
@@ -834,6 +837,7 @@ async def test_process_cherry_pick_command_non_existing_branches(
834837
async def test_process_cherry_pick_command_merged_pr(self, issue_comment_handler: IssueCommentHandler) -> None:
835838
"""Test processing cherry pick command for merged PR."""
836839
mock_pull_request = Mock()
840+
mock_pull_request.labels = []
837841
# Set merged_at in hook_data to simulate a merged PR at comment time
838842
issue_comment_handler.hook_data["issue"]["pull_request"] = {"merged_at": "2026-01-01T00:00:00Z"}
839843
with patch.object(issue_comment_handler.repository, "get_branch"):
@@ -846,6 +850,7 @@ async def test_process_cherry_pick_command_merged_pr(self, issue_comment_handler
846850
issue_comment_handler.labels_handler,
847851
"_add_label",
848852
new_callable=AsyncMock,
853+
return_value=True,
849854
) as mock_add_label:
850855
await issue_comment_handler.process_cherry_pick_command(
851856
pull_request=mock_pull_request,
@@ -859,7 +864,7 @@ async def test_process_cherry_pick_command_merged_pr(self, issue_comment_handler
859864
)
860865
mock_add_label.assert_called_once_with(
861866
pull_request=mock_pull_request,
862-
label="cherry-pick-branch1",
867+
label=f"{CHERRY_PICK_LABEL_PREFIX}branch1",
863868
)
864869

865870
@pytest.mark.asyncio
@@ -875,6 +880,7 @@ async def test_process_cherry_pick_command_merged_pr_multiple_branches(
875880
"""
876881
mock_pull_request = Mock()
877882
mock_pull_request.title = "Test PR"
883+
mock_pull_request.labels = []
878884

879885
# Set merged_at in hook_data to simulate a merged PR at comment time
880886
issue_comment_handler.hook_data["issue"]["pull_request"] = {"merged_at": "2026-01-01T00:00:00Z"}
@@ -888,6 +894,7 @@ async def test_process_cherry_pick_command_merged_pr_multiple_branches(
888894
issue_comment_handler.labels_handler,
889895
"_add_label",
890896
new_callable=AsyncMock,
897+
return_value=True,
891898
) as mock_add_label:
892899
# Execute cherry-pick command with multiple branches
893900
await issue_comment_handler.process_cherry_pick_command(
@@ -916,9 +923,15 @@ async def test_process_cherry_pick_command_merged_pr_multiple_branches(
916923

917924
# Verify labels were added exactly once for each branch (not duplicated)
918925
assert mock_add_label.call_count == 3
919-
mock_add_label.assert_any_call(pull_request=mock_pull_request, label="cherry-pick-branch1")
920-
mock_add_label.assert_any_call(pull_request=mock_pull_request, label="cherry-pick-branch2")
921-
mock_add_label.assert_any_call(pull_request=mock_pull_request, label="cherry-pick-branch3")
926+
mock_add_label.assert_any_call(
927+
pull_request=mock_pull_request, label=f"{CHERRY_PICK_LABEL_PREFIX}branch1"
928+
)
929+
mock_add_label.assert_any_call(
930+
pull_request=mock_pull_request, label=f"{CHERRY_PICK_LABEL_PREFIX}branch2"
931+
)
932+
mock_add_label.assert_any_call(
933+
pull_request=mock_pull_request, label=f"{CHERRY_PICK_LABEL_PREFIX}branch3"
934+
)
922935

923936
@pytest.mark.asyncio
924937
async def test_process_cherry_pick_command_merged_pr_assign_disabled(
@@ -927,6 +940,7 @@ async def test_process_cherry_pick_command_merged_pr_assign_disabled(
927940
"""Test cherry-pick on merged PR passes assign_to_pr_owner=False when config disabled."""
928941
issue_comment_handler.github_webhook.cherry_pick_assign_to_pr_author = False
929942
mock_pull_request = Mock()
943+
mock_pull_request.labels = []
930944
# Set merged_at in hook_data to simulate a merged PR at comment time
931945
issue_comment_handler.hook_data["issue"]["pull_request"] = {"merged_at": "2026-01-01T00:00:00Z"}
932946
with patch.object(issue_comment_handler.repository, "get_branch"):
@@ -939,6 +953,7 @@ async def test_process_cherry_pick_command_merged_pr_assign_disabled(
939953
issue_comment_handler.labels_handler,
940954
"_add_label",
941955
new_callable=AsyncMock,
956+
return_value=True,
942957
):
943958
await issue_comment_handler.process_cherry_pick_command(
944959
pull_request=mock_pull_request,
@@ -951,6 +966,181 @@ async def test_process_cherry_pick_command_merged_pr_assign_disabled(
951966
assign_to_pr_owner=False,
952967
)
953968

969+
@pytest.mark.asyncio
970+
async def test_process_cherry_pick_command_skips_already_cherry_picked(
971+
self, issue_comment_handler: IssueCommentHandler
972+
) -> None:
973+
"""Test cherry-pick skips branches whose cherry-pick label already exists on the PR."""
974+
mock_pull_request = Mock()
975+
mock_pull_request.title = "Test PR"
976+
977+
# Simulate existing cherry-pick label for branch1
978+
existing_label = Mock()
979+
existing_label.name = f"{CHERRY_PICK_LABEL_PREFIX}branch1"
980+
mock_pull_request.labels = [existing_label]
981+
982+
# Set merged_at in hook_data to simulate a merged PR at comment time
983+
issue_comment_handler.hook_data["issue"]["pull_request"] = {"merged_at": "2026-01-01T00:00:00Z"}
984+
with (
985+
patch.object(issue_comment_handler.repository, "get_branch"),
986+
patch.object(
987+
issue_comment_handler.runner_handler,
988+
"cherry_pick",
989+
new_callable=AsyncMock,
990+
) as mock_cherry_pick,
991+
patch.object(
992+
issue_comment_handler.labels_handler,
993+
"_add_label",
994+
new_callable=AsyncMock,
995+
return_value=True,
996+
) as mock_add_label,
997+
patch.object(mock_pull_request, "create_issue_comment") as mock_comment,
998+
):
999+
await issue_comment_handler.process_cherry_pick_command(
1000+
pull_request=mock_pull_request,
1001+
command_args="branch1 branch2",
1002+
reviewed_user="test-user",
1003+
)
1004+
1005+
# branch1 should be skipped (label already exists), branch2 should proceed
1006+
mock_cherry_pick.assert_called_once_with(
1007+
pull_request=mock_pull_request,
1008+
target_branch="branch2",
1009+
assign_to_pr_owner=True,
1010+
)
1011+
1012+
# Only branch2 label should be added
1013+
mock_add_label.assert_called_once_with(
1014+
pull_request=mock_pull_request,
1015+
label=f"{CHERRY_PICK_LABEL_PREFIX}branch2",
1016+
)
1017+
1018+
# Verify "already present" comment was posted for branch1
1019+
assert any(
1020+
"already present" in call.args[0] and "branch1" in call.args[0] for call in mock_comment.call_args_list
1021+
), f"Expected 'already present' comment for branch1, got: {mock_comment.call_args_list}"
1022+
1023+
@pytest.mark.asyncio
1024+
async def test_process_cherry_pick_command_all_already_cherry_picked(
1025+
self, issue_comment_handler: IssueCommentHandler
1026+
) -> None:
1027+
"""Test cherry-pick does nothing when all branches already have cherry-pick labels."""
1028+
mock_pull_request = Mock()
1029+
mock_pull_request.title = "Test PR"
1030+
1031+
# All branches already cherry-picked
1032+
label1 = Mock()
1033+
label1.name = f"{CHERRY_PICK_LABEL_PREFIX}branch1"
1034+
label2 = Mock()
1035+
label2.name = f"{CHERRY_PICK_LABEL_PREFIX}branch2"
1036+
mock_pull_request.labels = [label1, label2]
1037+
1038+
issue_comment_handler.hook_data["issue"]["pull_request"] = {"merged_at": "2026-01-01T00:00:00Z"}
1039+
with (
1040+
patch.object(issue_comment_handler.repository, "get_branch"),
1041+
patch.object(
1042+
issue_comment_handler.runner_handler,
1043+
"cherry_pick",
1044+
new_callable=AsyncMock,
1045+
) as mock_cherry_pick,
1046+
patch.object(
1047+
issue_comment_handler.labels_handler,
1048+
"_add_label",
1049+
new_callable=AsyncMock,
1050+
) as mock_add_label,
1051+
patch.object(mock_pull_request, "create_issue_comment") as mock_comment,
1052+
):
1053+
await issue_comment_handler.process_cherry_pick_command(
1054+
pull_request=mock_pull_request,
1055+
command_args="branch1 branch2",
1056+
reviewed_user="test-user",
1057+
)
1058+
1059+
# No cherry-picks should be triggered
1060+
mock_cherry_pick.assert_not_called()
1061+
1062+
# No labels should be added
1063+
mock_add_label.assert_not_called()
1064+
1065+
# "already present" comment should be posted
1066+
assert any("already present" in call.args[0] for call in mock_comment.call_args_list), (
1067+
f"Expected 'already present' comment, got: {mock_comment.call_args_list}"
1068+
)
1069+
1070+
@pytest.mark.asyncio
1071+
async def test_process_cherry_pick_command_skips_when_add_label_fails(
1072+
self, issue_comment_handler: IssueCommentHandler
1073+
) -> None:
1074+
"""Test cherry_pick is skipped when _add_label returns False (TOCTOU protection)."""
1075+
mock_pull_request = Mock()
1076+
mock_pull_request.title = "Test PR"
1077+
mock_pull_request.labels = [] # No existing labels in snapshot
1078+
1079+
issue_comment_handler.hook_data["issue"]["pull_request"] = {"merged_at": "2024-01-01T00:00:00Z"}
1080+
1081+
with (
1082+
patch.object(issue_comment_handler.repository, "get_branch"),
1083+
# _add_label returns False (label was added by another webhook in the meantime)
1084+
patch.object(
1085+
issue_comment_handler.labels_handler,
1086+
"_add_label",
1087+
new_callable=AsyncMock,
1088+
return_value=False,
1089+
),
1090+
patch.object(
1091+
issue_comment_handler.runner_handler,
1092+
"cherry_pick",
1093+
new_callable=AsyncMock,
1094+
) as mock_cherry_pick,
1095+
):
1096+
await issue_comment_handler.process_cherry_pick_command(
1097+
pull_request=mock_pull_request,
1098+
command_args="branch1",
1099+
reviewed_user="test-user",
1100+
)
1101+
mock_cherry_pick.assert_not_called()
1102+
1103+
@pytest.mark.asyncio
1104+
async def test_process_cherry_pick_command_skips_already_cherry_picked_unmerged(
1105+
self, issue_comment_handler: IssueCommentHandler
1106+
) -> None:
1107+
"""Test cherry-pick label check works for unmerged PRs too."""
1108+
mock_pull_request = Mock()
1109+
mock_pull_request.title = "Test PR"
1110+
1111+
# Simulate existing cherry-pick label for branch1
1112+
existing_label = Mock()
1113+
existing_label.name = f"{CHERRY_PICK_LABEL_PREFIX}branch1"
1114+
mock_pull_request.labels = [existing_label]
1115+
1116+
# Set merged_at to None to simulate an unmerged PR
1117+
issue_comment_handler.hook_data["issue"]["pull_request"] = {"merged_at": None}
1118+
with (
1119+
patch.object(issue_comment_handler.repository, "get_branch"),
1120+
patch.object(
1121+
issue_comment_handler.labels_handler,
1122+
"_add_label",
1123+
new_callable=AsyncMock,
1124+
) as mock_add_label,
1125+
patch.object(mock_pull_request, "create_issue_comment") as mock_comment,
1126+
):
1127+
await issue_comment_handler.process_cherry_pick_command(
1128+
pull_request=mock_pull_request,
1129+
command_args="branch1 branch2",
1130+
reviewed_user="test-user",
1131+
)
1132+
1133+
# Verify "already present" comment was posted for branch1
1134+
assert any(
1135+
"already present" in call.args[0] and "branch1" in call.args[0] for call in mock_comment.call_args_list
1136+
), f"Expected 'already present' comment for branch1, got: {mock_comment.call_args_list}"
1137+
1138+
# Only branch2 label should be added (not branch1)
1139+
mock_add_label.assert_called_once_with(
1140+
pull_request=mock_pull_request,
1141+
label=f"{CHERRY_PICK_LABEL_PREFIX}branch2",
1142+
)
1143+
9541144
@pytest.mark.asyncio
9551145
async def test_process_retest_command_no_target_tests(self, issue_comment_handler: IssueCommentHandler) -> None:
9561146
"""Test processing retest command with no target tests."""

0 commit comments

Comments
 (0)