Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
4a90312
feat(cherry-pick): add PR author attribution and optional assignee fo…
rnetser Feb 19, 2026
9452a38
fix(cherry-pick): always use PR author for assignee, not command requ…
rnetser Feb 19, 2026
d609248
fix(cherry-pick): handle empty reviewed_user in by_label path and fix…
rnetser Feb 19, 2026
c339f7e
fix(cherry-pick): tighten test assertions and add edge case coverage
rnetser Feb 19, 2026
a9395b7
fix(cherry-pick): strengthen test assertions with distinct values and…
rnetser Feb 19, 2026
9f51fd3
fix(cherry-pick): capture mock_to_thread and remove dead code in tests
rnetser Feb 23, 2026
ea8c541
refactor(cherry-pick): optimize API call, fix fallback message, and r…
rnetser Feb 23, 2026
7023d15
test(schema): add cherry-pick-assign-to-pr-author to schema validatio…
rnetser Feb 23, 2026
b10d527
remove config for username in cherrypick
rnetser Feb 24, 2026
c80d3e5
Merge branch 'main' of github.com:myk-org/github-webhook-server into …
rnetser Feb 24, 2026
033f90e
fix(cherry-pick): assign PR to original author instead of requester
rnetser Feb 24, 2026
9533625
refactor(cherry-pick): use walrus operator for cherry-pick label filt…
rnetser Feb 24, 2026
df7cb9f
Merge branch 'main' of github.com:myk-org/github-webhook-server into …
rnetser Feb 26, 2026
8057d0b
feat: cherry-pick author attribution and assignment
rnetser Feb 26, 2026
67a2da0
feat: cherry-pick author attribution and PR assignment
rnetser Feb 26, 2026
a0d9aca
add apostrophe around branch name
rnetser Feb 27, 2026
95d7b83
Merge branch 'main' of github.com:myk-org/github-webhook-server into …
rnetser Mar 1, 2026
4731f18
fix: address review comments for cherry-pick attribution
rnetser Mar 1, 2026
21dbdc3
Merge branch 'main' of github.com:myk-org/github-webhook-server into …
rnetser Mar 1, 2026
2e145ca
Merge branch 'main' of github.com:myk-org/github-webhook-server into …
rnetser Mar 1, 2026
cc29da5
Merge branch 'main' of github.com:myk-org/github-webhook-server into …
rnetser Mar 2, 2026
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
2 changes: 2 additions & 0 deletions examples/.github-webhook-server.yaml
Comment thread
myakove marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,8 @@ minimum-lgtm: 2
# Issue creation for new pull requests
create-issue-for-new-pr: true # Create tracking issues for new PRs

cherry-pick-assign-to-pr-author: true # Assign cherry-pick PRs to the original PR author (default: true)

# Custom PR size labels for this repository (overrides global configuration)
# Define custom categories based on total lines changed (additions + deletions)
# threshold: positive integer or 'inf' for unbounded largest category
Expand Down
2 changes: 2 additions & 0 deletions examples/config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ auto-verify-cherry-picked-prs: true # Default: true - automatically verify cher

create-issue-for-new-pr: true # Global default: create tracking issues for new PRs

cherry-pick-assign-to-pr-author: true # Default: true - assign cherry-pick PRs to the original PR author

# Commands allowed on draft PRs (optional)
# If not set: commands are blocked on draft PRs (default behavior)
# If empty list []: all commands allowed on draft PRs
Expand Down
8 changes: 8 additions & 0 deletions webhook_server/config/schema.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,10 @@ properties:
type: boolean
description: Create a tracking issue for new pull requests (global default)
default: true
cherry-pick-assign-to-pr-author:
type: boolean
description: Assign cherry-pick PRs to the original PR author (default true)
default: true
allow-commands-on-draft-prs:
type: array
items:
Expand Down Expand Up @@ -388,6 +392,10 @@ properties:
type: boolean
description: Create a tracking issue for new pull requests
default: true
cherry-pick-assign-to-pr-author:
type: boolean
description: Assign cherry-pick PRs to the original PR author (overrides global setting)
default: true
allow-commands-on-draft-prs:
type: array
items:
Expand Down
11 changes: 11 additions & 0 deletions webhook_server/libs/github_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -735,6 +735,17 @@ def _repo_data_from_config(self, repository_config: dict[str, Any]) -> None:
value="create-issue-for-new-pr", return_on_none=global_create_issue_for_new_pr, extra_dict=repository_config
)

# Load global cherry_pick_assign_to_pr_author setting as fallback
global_cherry_pick_assign: bool = self.config.get_value(
value="cherry-pick-assign-to-pr-author", return_on_none=True
)
# Repository-specific setting overrides global setting
self.cherry_pick_assign_to_pr_author: bool = self.config.get_value(
value="cherry-pick-assign-to-pr-author",
return_on_none=global_cherry_pick_assign,
extra_dict=repository_config,
)

# Read required_conversation_resolution from branch-protection config
_bp_key = "required_conversation_resolution"
_bp_raw_default = DEFAULT_BRANCH_PROTECTION[_bp_key]
Expand Down
2 changes: 1 addition & 1 deletion webhook_server/libs/handlers/issue_comment_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -456,7 +456,7 @@ async def process_cherry_pick_command(
await self.runner_handler.cherry_pick(
pull_request=pull_request,
target_branch=_exits_target_branch,
reviewed_user=reviewed_user,
assign_to_pr_owner=self.github_webhook.cherry_pick_assign_to_pr_author,
)

for _cp_label in cp_labels:
Expand Down
15 changes: 11 additions & 4 deletions webhook_server/libs/handlers/pull_request_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -152,11 +152,18 @@ async def process_pull_request_webhook_data(self, pull_request: PullRequest) ->
self.logger.info(f"{self.log_prefix} PR is merged")

labels = await asyncio.to_thread(lambda: list(pull_request.labels))
for _label in labels:
_label_name = _label.name
if _label_name.startswith(CHERRY_PICK_LABEL_PREFIX):
if cherry_pick_labels := [
_label for _label in labels if _label.name.startswith(CHERRY_PICK_LABEL_PREFIX)
]:
for _label in cherry_pick_labels:
target_branch = _label.name.removeprefix(CHERRY_PICK_LABEL_PREFIX)
if not target_branch:
self.logger.warning(f"{self.log_prefix} Skipping invalid cherry-pick label: {_label.name}")
continue
await self.runner_handler.cherry_pick(
pull_request=pull_request, target_branch=_label_name.replace(CHERRY_PICK_LABEL_PREFIX, "")
pull_request=pull_request,
target_branch=target_branch,
assign_to_pr_owner=self.github_webhook.cherry_pick_assign_to_pr_author,
Comment thread
rnetser marked this conversation as resolved.
)

await self.runner_handler.run_build_container(
Expand Down
22 changes: 16 additions & 6 deletions webhook_server/libs/handlers/runner_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -536,9 +536,18 @@ async def run_custom_check(
async def is_branch_exists(self, branch: str) -> Branch:
return await asyncio.to_thread(self.repository.get_branch, branch)

async def cherry_pick(self, pull_request: PullRequest, target_branch: str, reviewed_user: str = "") -> None:
requested_by = reviewed_user or "by target-branch label"
self.logger.info(f"{self.log_prefix} Cherry-pick requested by user: {requested_by}")
async def cherry_pick(
self,
pull_request: PullRequest,
target_branch: str,
assign_to_pr_owner: bool = True,
) -> None:
pr_author = await asyncio.to_thread(lambda: pull_request.user.login)
source_branch = await asyncio.to_thread(lambda: pull_request.base.ref)

self.logger.info(
f"{self.log_prefix} Cherry-pick from {source_branch} to {target_branch}, PR owner: {pr_author}"
)

new_branch_name = f"{CHERRY_PICKED_LABEL}-{pull_request.head.ref}-{shortuuid.uuid()[:5]}"
if not await self.is_branch_exists(branch=target_branch):
Expand All @@ -556,17 +565,18 @@ async def cherry_pick(self, pull_request: PullRequest, target_branch: str, revie
async with self._checkout_worktree(pull_request=pull_request) as (success, worktree_path, out, err):
git_cmd = f"git --work-tree={worktree_path} --git-dir={worktree_path}/.git"
hub_cmd = f"GITHUB_TOKEN={github_token} hub --work-tree={worktree_path} --git-dir={worktree_path}/.git"
assignee_flag = f" -a {shlex.quote(pr_author)}" if assign_to_pr_owner else ""
commands: list[str] = [
f"{git_cmd} checkout {target_branch}",
f"{git_cmd} pull origin {target_branch}",
f"{git_cmd} checkout -b {new_branch_name} origin/{target_branch}",
f"{git_cmd} cherry-pick {commit_hash}",
f"{git_cmd} push origin {new_branch_name}",
f'bash -c "{hub_cmd} pull-request -b {target_branch} '
f"-h {new_branch_name} -l {CHERRY_PICKED_LABEL} "
f"-h {new_branch_name} -l {CHERRY_PICKED_LABEL} {assignee_flag} "
f"-m '{CHERRY_PICKED_LABEL}: [{target_branch}] "
f"{commit_msg_striped}' -m 'cherry-pick {pull_request_url} "
f"into {target_branch}' -m 'requested-by {requested_by}'\"",
f"{commit_msg_striped}' -m 'Cherry-pick from `{source_branch}` branch, "
f"original PR: {pull_request_url}, PR owner: {pr_author}'\"",
]

output: CheckRunOutput = {
Expand Down
1 change: 1 addition & 0 deletions webhook_server/tests/test_config_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ def valid_full_config(self) -> dict[str, Any]:
"docker": {"username": "dockeruser", "password": "dockerpass"}, # pragma: allowlist secret
"default-status-checks": ["WIP", "build"],
"auto-verified-and-merged-users": ["bot[bot]"],
"cherry-pick-assign-to-pr-author": True,
"branch-protection": {
"strict": True,
"require_code_owner_reviews": True,
Expand Down
39 changes: 35 additions & 4 deletions webhook_server/tests/test_issue_comment_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ def mock_github_webhook(self) -> Mock:
mock_webhook.current_pull_request_supported_retest = [TOX_STR, "pre-commit"]
mock_webhook.ctx = None
mock_webhook.custom_check_runs = []
mock_webhook.cherry_pick_assign_to_pr_author = True
# Mock config for draft PR command filtering
Comment thread
rnetser marked this conversation as resolved.
mock_webhook.config = Mock()
mock_webhook.config.get_value = Mock(return_value=None)
Expand Down Expand Up @@ -854,7 +855,7 @@ async def test_process_cherry_pick_command_merged_pr(self, issue_comment_handler
mock_cherry_pick.assert_called_once_with(
pull_request=mock_pull_request,
target_branch="branch1",
reviewed_user="test-user",
assign_to_pr_owner=True,
)
mock_add_label.assert_called_once_with(
pull_request=mock_pull_request,
Expand Down Expand Up @@ -900,17 +901,17 @@ async def test_process_cherry_pick_command_merged_pr_multiple_branches(
mock_cherry_pick.assert_any_call(
pull_request=mock_pull_request,
target_branch="branch1",
reviewed_user="test-user",
assign_to_pr_owner=True,
)
mock_cherry_pick.assert_any_call(
pull_request=mock_pull_request,
target_branch="branch2",
reviewed_user="test-user",
assign_to_pr_owner=True,
)
mock_cherry_pick.assert_any_call(
pull_request=mock_pull_request,
target_branch="branch3",
reviewed_user="test-user",
assign_to_pr_owner=True,
)

# Verify labels were added exactly once for each branch (not duplicated)
Expand All @@ -919,6 +920,36 @@ async def test_process_cherry_pick_command_merged_pr_multiple_branches(
mock_add_label.assert_any_call(pull_request=mock_pull_request, label="cherry-pick-branch2")
mock_add_label.assert_any_call(pull_request=mock_pull_request, label="cherry-pick-branch3")

@pytest.mark.asyncio
async def test_process_cherry_pick_command_merged_pr_assign_disabled(
self, issue_comment_handler: IssueCommentHandler
) -> None:
"""Test cherry-pick on merged PR passes assign_to_pr_owner=False when config disabled."""
issue_comment_handler.github_webhook.cherry_pick_assign_to_pr_author = False
mock_pull_request = Mock()
with patch.object(mock_pull_request, "is_merged", new=Mock(return_value=True)):
with patch.object(issue_comment_handler.repository, "get_branch"):
with patch.object(
issue_comment_handler.runner_handler,
"cherry_pick",
new_callable=AsyncMock,
) as mock_cherry_pick:
with patch.object(
issue_comment_handler.labels_handler,
"_add_label",
new_callable=AsyncMock,
):
await issue_comment_handler.process_cherry_pick_command(
pull_request=mock_pull_request,
command_args="branch1",
reviewed_user="test-user",
)
mock_cherry_pick.assert_called_once_with(
pull_request=mock_pull_request,
target_branch="branch1",
assign_to_pr_owner=False,
)

@pytest.mark.asyncio
async def test_process_retest_command_no_target_tests(self, issue_comment_handler: IssueCommentHandler) -> None:
"""Test processing retest command with no target tests."""
Expand Down
74 changes: 73 additions & 1 deletion webhook_server/tests/test_pull_request_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ def mock_github_webhook(self) -> Mock:
mock_webhook.pypi = False
mock_webhook.token = TEST_GITHUB_TOKEN
mock_webhook.auto_verify_cherry_picked_prs = True
mock_webhook.cherry_pick_assign_to_pr_author = True
mock_webhook.last_commit = Mock()
mock_webhook.ctx = None
mock_webhook.enabled_labels = None # Default: all labels enabled
Expand Down Expand Up @@ -294,7 +295,9 @@ async def test_process_pull_request_webhook_data_closed_action_merged(
)
mock_delete_tag.assert_called_once_with(pull_request=mock_pull_request)
mock_cherry_pick.assert_called_once_with(
pull_request=mock_pull_request, target_branch="branch1"
pull_request=mock_pull_request,
target_branch="branch1",
assign_to_pr_owner=True,
)
mock_build.assert_called_once_with(
push=True,
Expand All @@ -304,6 +307,75 @@ async def test_process_pull_request_webhook_data_closed_action_merged(
)
mock_label_all.assert_called_once()

@pytest.mark.asyncio
async def test_process_pull_request_cherry_pick_label_multiple_branches(
self, pull_request_handler: PullRequestHandler, mock_pull_request: Mock
) -> None:
"""Test cherry-pick is triggered for each cherry-pick label on merge."""
pull_request_handler.hook_data["action"] = "closed"
pull_request_handler.hook_data["pull_request"]["merged"] = True

mock_label1 = Mock()
mock_label1.name = f"{CHERRY_PICK_LABEL_PREFIX}branch1"
mock_label2 = Mock()
mock_label2.name = f"{CHERRY_PICK_LABEL_PREFIX}branch2"
mock_pull_request.labels = [mock_label1, mock_label2]

with patch.object(pull_request_handler, "close_issue_for_merged_or_closed_pr"):
with patch.object(pull_request_handler, "delete_remote_tag_for_merged_or_closed_pr"):
with patch.object(
pull_request_handler.runner_handler, "cherry_pick", new_callable=AsyncMock
) as mock_cherry_pick:
with patch.object(
pull_request_handler.runner_handler, "run_build_container", new_callable=AsyncMock
):
with patch.object(
pull_request_handler, "label_all_opened_pull_requests_merge_state_after_merged"
):
await pull_request_handler.process_pull_request_webhook_data(mock_pull_request)
assert mock_cherry_pick.call_count == 2
mock_cherry_pick.assert_any_call(
pull_request=mock_pull_request,
target_branch="branch1",
assign_to_pr_owner=True,
)
mock_cherry_pick.assert_any_call(
pull_request=mock_pull_request,
target_branch="branch2",
assign_to_pr_owner=True,
)

Comment thread
rnetser marked this conversation as resolved.
@pytest.mark.asyncio
async def test_process_pull_request_cherry_pick_label_assign_disabled(
self, pull_request_handler: PullRequestHandler, mock_pull_request: Mock
) -> None:
"""Test cherry-pick passes assign_to_pr_owner=False when config disabled."""
pull_request_handler.hook_data["action"] = "closed"
pull_request_handler.hook_data["pull_request"]["merged"] = True
pull_request_handler.github_webhook.cherry_pick_assign_to_pr_author = False

mock_label = Mock()
mock_label.name = f"{CHERRY_PICK_LABEL_PREFIX}target-branch"
mock_pull_request.labels = [mock_label]

with patch.object(pull_request_handler, "close_issue_for_merged_or_closed_pr"):
with patch.object(pull_request_handler, "delete_remote_tag_for_merged_or_closed_pr"):
with patch.object(
pull_request_handler.runner_handler, "cherry_pick", new_callable=AsyncMock
) as mock_cherry_pick:
with patch.object(
pull_request_handler.runner_handler, "run_build_container", new_callable=AsyncMock
):
with patch.object(
pull_request_handler, "label_all_opened_pull_requests_merge_state_after_merged"
):
await pull_request_handler.process_pull_request_webhook_data(mock_pull_request)
mock_cherry_pick.assert_called_once_with(
pull_request=mock_pull_request,
target_branch="target-branch",
assign_to_pr_owner=False,
)

@pytest.mark.asyncio
async def test_process_pull_request_webhook_data_labeled_action(
self, pull_request_handler: PullRequestHandler, mock_pull_request: Mock
Expand Down
Loading