Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
6d6cec2
feat: cherry-pick retry/rebase commands + pre-commit auto-fix + secur…
myakove Jun 9, 2026
fbeb135
fix: address Qodo review findings β€” fork PR safety, rebase auth, pre-…
myakove Jun 9, 2026
ab46b6f
fix: handle git diff failure separately
myakove Jun 9, 2026
d7bfda9
security: redact secrets in git diff error logging
myakove Jun 9, 2026
b7c27e8
fix: use user.type == Bot for cherry-pick PR detection
myakove Jun 9, 2026
7190495
fix: narrow bot-PR detection to app-managed cherry-pick PRs only
myakove Jun 9, 2026
6355e4c
fix: cherry-pick-retry close logic β€” add debug logging, fix body URL …
myakove Jun 9, 2026
273ad40
fix: add back user.type == "Bot" check in cherry-pick-retry close logic
myakove Jun 9, 2026
d920be9
fix: use app_bot_login for precise bot-PR identification in retry and…
myakove Jun 9, 2026
d311c4d
fix: move app_bot_login init from blocking __init__ to async process()
myakove Jun 9, 2026
bb191a1
fix: app_bot_login error handling β€” use logger.exception, re-raise Ca…
myakove Jun 9, 2026
0f884d0
fix: wrap get_repository_github_app_api in github_api_call and guard …
myakove Jun 10, 2026
caddc3c
fix: abort cherry-pick retry and rebase when app_bot_login is not set
myakove Jun 10, 2026
5139ee8
fix: refine app_bot_login guards β€” don't block normal user operations
myakove Jun 10, 2026
1ac10fd
fix: replace defensive merged_at check with pull_request.merged, quot…
myakove Jun 10, 2026
e5044af
fix: materialize PaginatedList in thread pool and use direct property…
myakove Jun 10, 2026
47a6efd
fix: guard empty target_branch in cherry-pick-retry and fix generic a…
myakove Jun 10, 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
27 changes: 27 additions & 0 deletions webhook_server/libs/github_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,8 @@ def __init__(self, hook_data: dict[Any, Any], headers: Headers, logger: logging.
github_app_api=github_app_api, repository=self.repository_full_name
)

self.app_bot_login: str = "" # Initialized async in process()

if not (self.repository or self.repository_by_github_app):
self.logger.error(f"{self.log_prefix} Failed to get repository.")
return
Expand Down Expand Up @@ -537,6 +539,31 @@ async def process(self) -> Any:
# Initialize auto-verified users from API users (async operation)
await self.add_api_users_to_auto_verified_and_merged_users()

# Initialize app bot login for bot-PR identification (async)
if not self.app_bot_login:
_github_app_api = await github_api_call(
get_repository_github_app_api,
config_=self.config,
repository_name=self.repository_full_name,
logger=self.logger,
log_prefix=self.log_prefix,
)
if _github_app_api:
Comment thread
myakove marked this conversation as resolved.
try:
self.app_bot_login = await github_api_call(
lambda: _github_app_api.get_user().login,
logger=self.logger,
log_prefix=self.log_prefix,
)
except asyncio.CancelledError:
raise
except Exception:
self.logger.exception(
f"{self.log_prefix} Failed to get app bot login β€” bot-PR detection may not work"
)
Comment thread
myakove marked this conversation as resolved.
else:
self.logger.debug(f"{self.log_prefix} No GitHub App API available β€” app_bot_login not set")

event_log: str = f"Event type: {self.github_event}. event ID: {self.x_github_delivery}"

# Start webhook routing context step
Expand Down
211 changes: 210 additions & 1 deletion webhook_server/libs/handlers/issue_comment_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,9 @@
COMMAND_ASSIGN_REVIEWER_STR,
COMMAND_ASSIGN_REVIEWERS_STR,
COMMAND_CHECK_CAN_MERGE_STR,
COMMAND_CHERRY_PICK_RETRY_STR,
COMMAND_CHERRY_PICK_STR,
COMMAND_REBASE_STR,
COMMAND_REGENERATE_WELCOME_STR,
COMMAND_REPROCESS_STR,
COMMAND_RETEST_STR,
Expand Down Expand Up @@ -160,6 +162,8 @@ async def user_commands(
COMMAND_RETEST_STR,
COMMAND_REPROCESS_STR,
COMMAND_CHERRY_PICK_STR,
COMMAND_CHERRY_PICK_RETRY_STR,
COMMAND_REBASE_STR,
COMMAND_ASSIGN_REVIEWERS_STR,
COMMAND_CHECK_CAN_MERGE_STR,
BUILD_AND_PUSH_CONTAINER_STR,
Expand Down Expand Up @@ -219,6 +223,7 @@ async def user_commands(
COMMAND_RETEST_STR,
COMMAND_ASSIGN_REVIEWER_STR,
COMMAND_ADD_ALLOWED_USER_STR,
COMMAND_CHERRY_PICK_RETRY_STR,
)
and not _args
):
Comment thread
myakove marked this conversation as resolved.
Expand Down Expand Up @@ -253,6 +258,10 @@ async def user_commands(
self.logger.debug(f"{self.log_prefix} Added reaction to comment.")

if _command == COMMAND_ASSIGN_REVIEWER_STR:
if not await self.owners_file_handler.is_user_valid_to_run_commands(
pull_request=pull_request, reviewed_user=reviewed_user
):
return
await self._add_reviewer_by_user_comment(pull_request=pull_request, reviewer=_args)

elif _command == COMMAND_ADD_ALLOWED_USER_STR:
Expand All @@ -264,16 +273,44 @@ async def user_commands(
)

elif _command == COMMAND_ASSIGN_REVIEWERS_STR:
if not await self.owners_file_handler.is_user_valid_to_run_commands(
pull_request=pull_request, reviewed_user=reviewed_user
):
return
await self.owners_file_handler.assign_reviewers(pull_request=pull_request)

elif _command == COMMAND_CHECK_CAN_MERGE_STR:
if not await self.owners_file_handler.is_user_valid_to_run_commands(
pull_request=pull_request, reviewed_user=reviewed_user
):
return
await self.pull_request_handler.check_if_can_be_merged(pull_request=pull_request)

elif _command == COMMAND_CHERRY_PICK_STR:
if not await self.owners_file_handler.is_user_valid_to_run_commands(
pull_request=pull_request, reviewed_user=reviewed_user
):
return
await self.process_cherry_pick_command(
pull_request=pull_request, command_args=_args, reviewed_user=reviewed_user
)

elif _command == COMMAND_CHERRY_PICK_RETRY_STR:
if not await self.owners_file_handler.is_user_valid_to_run_commands(
pull_request=pull_request, reviewed_user=reviewed_user
):
return
await self.process_cherry_pick_retry_command(
pull_request=pull_request, command_args=_args, reviewed_user=reviewed_user
)

elif _command == COMMAND_REBASE_STR:
if not await self.owners_file_handler.is_user_valid_to_run_commands(
pull_request=pull_request, reviewed_user=reviewed_user
):
return
await self.runner_handler.rebase_pr(pull_request=pull_request, reviewed_user=reviewed_user)
Comment thread
myakove marked this conversation as resolved.

Comment thread
myakove marked this conversation as resolved.
elif _command == COMMAND_RETEST_STR:
await self.process_retest_command(
pull_request=pull_request, command_args=_args, reviewed_user=reviewed_user
Expand All @@ -295,6 +332,10 @@ async def user_commands(
await self.pull_request_handler.regenerate_welcome_message(pull_request=pull_request)

elif _command == COMMAND_TEST_ORACLE_STR:
if not await self.owners_file_handler.is_user_valid_to_run_commands(
pull_request=pull_request, reviewed_user=reviewed_user
):
return
task = asyncio.create_task(
call_test_oracle(
github_webhook=self.github_webhook,
Expand Down Expand Up @@ -322,6 +363,10 @@ async def user_commands(
)

elif _command == WIP_STR:
if not await self.owners_file_handler.is_user_valid_to_run_commands(
pull_request=pull_request, reviewed_user=reviewed_user
):
return
wip_for_title: str = f"{WIP_STR.upper()}:"
if remove:
label_changed = await self.labels_handler._remove_label(pull_request=pull_request, label=WIP_STR)
Expand Down Expand Up @@ -374,6 +419,10 @@ async def user_commands(
await self.labels_handler._add_label(pull_request=pull_request, label=HOLD_LABEL_STR)

elif _command == VERIFIED_LABEL_STR:
if not await self.owners_file_handler.is_user_valid_to_run_commands(
pull_request=pull_request, reviewed_user=reviewed_user
):
return
if remove:
await self.labels_handler._remove_label(pull_request=pull_request, label=VERIFIED_LABEL_STR)
await self.check_run_handler.set_check_queued(name=VERIFIED_LABEL_STR)
Expand Down Expand Up @@ -526,7 +575,8 @@ async def process_cherry_pick_command(

cp_labels: list[str] = [f"{CHERRY_PICK_LABEL_PREFIX}{_branch}" for _branch in _branches_to_process]

if not self.hook_data["issue"].get("pull_request", {}).get("merged_at"):
is_merged = await github_api_call(lambda: pull_request.merged, logger=self.logger, log_prefix=self.log_prefix)
if not is_merged:
Comment thread
myakove marked this conversation as resolved.
info_msg: str = f"""
Cherry-pick requested for PR: `{pull_request.title}` by user `{reviewed_user}`
Adding label/s `{" ".join(cp_labels)}` for automatic cherry-pick once the PR is merged
Expand Down Expand Up @@ -558,6 +608,165 @@ async def process_cherry_pick_command(
for _cp_label in cp_labels:
await self.labels_handler._add_label(pull_request=pull_request, label=_cp_label)

async def process_cherry_pick_retry_command(
self, pull_request: PullRequest, command_args: str, reviewed_user: str
) -> None:
"""Process cherry-pick-retry command on a merged PR.

Retries a cherry-pick for a specific branch when the original cherry-pick
failed or the cherry-pick PR has issues. Only works on merged PRs where
the cherry-pick label already exists.

Steps:
1. Validate the PR is merged
2. Validate the cherry-pick label exists (this is a retry, not a new cherry-pick)
3. Close any existing failed cherry-pick PR for that branch (created by bot)
4. Re-run cherry_pick() for the target branch

Args:
pull_request: The original merged pull request
command_args: Target branch name to retry cherry-pick for
reviewed_user: User who requested the retry
"""
target_branch = command_args.strip()

if not target_branch:
msg = "cherry-pick-retry requires a branch name"
self.logger.debug(f"{self.log_prefix} {msg}")
await github_api_call(
pull_request.create_issue_comment, msg, logger=self.logger, log_prefix=self.log_prefix
)
return

# Validate exactly one branch name
if " " in target_branch:
msg = "cherry-pick-retry accepts exactly one branch name"
self.logger.debug(f"{self.log_prefix} {msg}")
await github_api_call(
pull_request.create_issue_comment, msg, logger=self.logger, log_prefix=self.log_prefix
)
return

self.logger.info(f"{self.log_prefix} Processing cherry-pick retry for branch {target_branch}")

# Validate PR is merged
is_merged = await github_api_call(lambda: pull_request.merged, logger=self.logger, log_prefix=self.log_prefix)
if not is_merged:
msg = "Cherry-pick retry can only be used on merged PRs"
self.logger.debug(f"{self.log_prefix} {msg}")
await github_api_call(
pull_request.create_issue_comment, msg, logger=self.logger, log_prefix=self.log_prefix
)
return

# Validate the cherry-pick label exists (this is a retry, not a new cherry-pick)
cherry_pick_label = f"{CHERRY_PICK_LABEL_PREFIX}{target_branch}"
existing_labels = {
Comment thread
myakove marked this conversation as resolved.
label.name
for label in await github_api_call(
lambda: list(pull_request.labels), logger=self.logger, log_prefix=self.log_prefix
)
}
if cherry_pick_label not in existing_labels:
msg = (
f"Cherry-pick label `{cherry_pick_label}` not found on this PR.\n"
f"Use `/{COMMAND_CHERRY_PICK_STR} {target_branch}` to create a new cherry-pick."
)
self.logger.debug(f"{self.log_prefix} {msg}")
await github_api_call(
pull_request.create_issue_comment, msg, logger=self.logger, log_prefix=self.log_prefix
)
return

# Find and close existing failed cherry-pick PR for this branch (created by bot)
pr_title_prefix = f"CherryPicked: [{target_branch}]"
original_pr_url = await github_api_call(
lambda: pull_request.html_url, logger=self.logger, log_prefix=self.log_prefix
)
self.logger.debug(
f"{self.log_prefix} Cherry-pick retry: looking for open PRs with title prefix "
f"'{pr_title_prefix}' referencing {original_pr_url}"
)
# Convert PaginatedList to list inside github_api_call to avoid blocking iteration.
# After materialization, basic properties (.title, .number, .user.login, .body) are cached.
open_pulls = await github_api_call(
lambda: list(self.repository.get_pulls(state="open")),
logger=self.logger,
log_prefix=self.log_prefix,
)
self.logger.debug(f"{self.log_prefix} Cherry-pick retry: found {len(open_pulls)} open PRs to scan")
closed_old_pr = False
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
for open_pr in open_pulls:
if not open_pr.title.startswith(pr_title_prefix):
self.logger.debug(
f"{self.log_prefix} Cherry-pick retry: PR #{open_pr.number} title "
f"'{open_pr.title}' does not match prefix '{pr_title_prefix}', skipping"
)
continue

self.logger.debug(f"{self.log_prefix} Cherry-pick retry: PR #{open_pr.number} title matches prefix")

# Verify the PR was created by our app's bot
# If app_bot_login is not set, skip the close step entirely β€” can't verify ownership
if not self.github_webhook.app_bot_login:
self.logger.error(
f"{self.log_prefix} Cherry-pick retry: app_bot_login not set β€” "
"cannot verify PR ownership, skipping close step"
)
break

if open_pr.user.login != self.github_webhook.app_bot_login:
self.logger.debug(
f"{self.log_prefix} Cherry-pick retry: PR #{open_pr.number} author "
f"'{open_pr.user.login}' is not our app bot "
f"'{self.github_webhook.app_bot_login}', skipping"
)
continue

self.logger.debug(f"{self.log_prefix} Cherry-pick retry: PR #{open_pr.number} author matches our app bot")

# Check if the PR body references the original PR
pr_body = open_pr.body or ""
if original_pr_url not in pr_body:
self.logger.debug(
f"{self.log_prefix} Cherry-pick retry: PR #{open_pr.number} body does not "
f"contain original PR URL '{original_pr_url}', skipping"
)
continue

self.logger.info(f"{self.log_prefix} Closing existing cherry-pick PR #{open_pr.number} for retry")
await github_api_call(
open_pr.edit,
state="closed",
logger=self.logger,
log_prefix=self.log_prefix,
)
await github_api_call(
open_pr.create_issue_comment,
f"Closed by cherry-pick retry requested by @{reviewed_user} on {original_pr_url}",
logger=self.logger,
log_prefix=self.log_prefix,
)
closed_old_pr = True
break
Comment thread
myakove marked this conversation as resolved.

if not closed_old_pr:
self.logger.debug(f"{self.log_prefix} Cherry-pick retry: no existing cherry-pick PR found to close")

# Re-run cherry-pick for the target branch
self.logger.info(f"{self.log_prefix} Retrying cherry-pick to {target_branch}")
await github_api_call(
pull_request.create_issue_comment,
f"Retrying cherry-pick to `{target_branch}` requested by @{reviewed_user}",
logger=self.logger,
log_prefix=self.log_prefix,
)
await self.runner_handler.cherry_pick(
pull_request=pull_request,
target_branch=target_branch,
assign_to_pr_owner=self.github_webhook.cherry_pick_assign_to_pr_author,
)

async def process_retest_command(
self, pull_request: PullRequest, command_args: str, reviewed_user: str, automerge: bool = False
) -> None:
Expand Down
2 changes: 1 addition & 1 deletion webhook_server/libs/handlers/owners_files_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -481,7 +481,7 @@ async def is_user_valid_to_run_commands(self, pull_request: PullRequest, reviewe
allow_user_comment = f"/{COMMAND_ADD_ALLOWED_USER_STR} @{reviewed_user}"

comment_msg = f"""
{reviewed_user} is not allowed to run retest commands.
{reviewed_user} is not allowed to run commands.
maintainers can allow it by comment `{allow_user_comment}`
Maintainers:
- {"\n - ".join(allowed_user_to_approve)}
Expand Down
6 changes: 5 additions & 1 deletion webhook_server/libs/handlers/pull_request_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -795,8 +795,12 @@ def _prepare_cherry_pick_section(self) -> str:
return """#### Cherry-pick Operations
* `/cherry-pick <branch>` - Schedule cherry-pick to target branch when PR is merged
* Multiple branches: `/cherry-pick branch1 branch2 branch3`
* `/cherry-pick-retry <branch>` - Retry a failed cherry-pick (merged PRs only)

#### Branch Management
* `/rebase` - Rebase this PR branch onto its base branch
"""
return ""
return "\n#### Branch Management\n* `/rebase` - Rebase this PR branch onto its base branch\n"

async def label_all_opened_pull_requests_merge_state_after_merged(self) -> None:
"""
Expand Down
Loading