88from 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