Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
499a3a6
feat: re-trigger CI checks for out-of-date PRs when base branch is up…
rnetser Dec 28, 2025
d215e4b
Enable re-running checks after merge to base branch
rnetser Dec 29, 2025
40dc9ce
Enable re-running checks after merge to base branch
rnetser Dec 29, 2025
4e14715
Merge branch 'main' of github.com:myk-org/github-webhook-server into …
rnetser Dec 29, 2025
3a8072e
re-use code from process_retest_command to run checks
rnetser Dec 29, 2025
42968da
remove unuseful info from docstring
rnetser Dec 29, 2025
1746abf
address comments
rnetser Dec 29, 2025
084e88c
test: improve type safety and consistency in pull request handler tests
rnetser Dec 29, 2025
4696a7b
feat: re-trigger CI checks for out-of-date PRs on base branch push
rnetser Dec 30, 2025
90a0538
feat: make retrigger-checks-on-base-push configurable with selective …
rnetser Dec 30, 2025
e54fc9f
refactor: simplify re-trigger-pr-not-rebased config by removing expli…
rnetser Dec 30, 2025
56ab469
fix: address code review feedback for retrigger CI feature
rnetser Dec 30, 2025
b3104a8
refactor: extract duplicate retrigger check logic to shared method
rnetser Dec 30, 2025
26db0be
fix: add defensive checks and exception handling for retrigger functi…
rnetser Dec 30, 2025
9ce53b0
test: update retrigger exception test to match new handling behavior
rnetser Dec 30, 2025
2159a5c
refactor: address CodeRabbit review feedback
rnetser Dec 30, 2025
638dcf8
refactor: address CodeRabbit review comments
rnetser Dec 30, 2025
142ca7d
fix: address CodeRabbit review comments for PR processing
rnetser Dec 30, 2025
9a616a1
fix: address CodeRabbit review comments
rnetser Dec 30, 2025
50d817f
fix: remove PEP 695 generic syntax for Python 3.11 compatibility
rnetser Dec 30, 2025
e830d4b
fix: improve PR check run status detection and merge state handling
rnetser Dec 31, 2025
d48d996
fix: pass pull_request parameter to all check run status setters
rnetser Dec 31, 2025
030e723
fix: correct retest failure logging with parallel task names
rnetser Dec 31, 2025
7aa793b
fix: remove redundant fallback in retest failure logging
rnetser Dec 31, 2025
16c46f0
fix: re-raise CancelledError instead of logging in run_retests
rnetser Dec 31, 2025
af39f22
refactor: tighten type hints in run_retests method
rnetser Dec 31, 2025
1cc3955
fix: use keyword argument for pull_request in run_retests
rnetser Dec 31, 2025
c8d184a
fix: use tox-uv plugin to reduce CI disk usage
rnetser Jan 1, 2026
aa9a5fd
fix: handle None retrigger config explicitly to avoid log noise
rnetser Jan 1, 2026
d625470
revert: remove tox-uv plugin that breaks dependency installation
rnetser Jan 1, 2026
16048f8
fix: limit concurrent tox runs to prevent disk exhaustion
rnetser Jan 1, 2026
3ae35c5
feat: make tox concurrency limit configurable
rnetser Jan 1, 2026
7fd52a2
test: add tox_max_concurrent to mock fixtures
rnetser Jan 1, 2026
cf9823f
fix: set tox check to queued before acquiring semaphore
rnetser Jan 1, 2026
7e602ab
fix: set checks to queued only during retrigger flow
rnetser Jan 1, 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
20 changes: 20 additions & 0 deletions webhook_server/config/schema.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,16 @@ properties:
type: boolean
description: Create a tracking issue for new pull requests (global default)
default: true
retrigger-checks-on-base-push:
oneOf:
- type: string
enum: ["all"]
description: Re-trigger all available CI checks for out-of-date PRs
- type: array
items:
type: string
description: Re-trigger specific CI checks (e.g., ["tox", "pre-commit"])
description: Re-trigger CI checks for out-of-date PRs when their base branch is updated (triggered by both merged PRs and direct non-tag branch pushes). Value can be "all" (string) to re-trigger all checks, or an array of specific check names. If not configured, defaults to disabled.

pr-size-thresholds:
type: object
Expand Down Expand Up @@ -299,6 +309,16 @@ properties:
type: boolean
description: Create a tracking issue for new pull requests
default: true
retrigger-checks-on-base-push:
oneOf:
- type: string
enum: ["all"]
description: Re-trigger all available CI checks for out-of-date PRs
- type: array
items:
type: string
description: Re-trigger specific CI checks (e.g., ["tox", "pre-commit"])
description: Re-trigger CI checks for out-of-date PRs when their base branch is updated (triggered by both merged PRs and direct non-tag branch pushes). Value can be "all" (string) to re-trigger all checks, or an array of specific check names. If not configured, defaults to disabled.
pr-size-thresholds:
type: object
description: Custom PR size thresholds with label names and colors (repository-specific override)
Expand Down
3 changes: 3 additions & 0 deletions webhook_server/libs/github_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -709,6 +709,9 @@ def _repo_data_from_config(self, repository_config: dict[str, Any]) -> None:
)

self.mask_sensitive = self.config.get_value("mask-sensitive-data", return_on_none=True)
self.retrigger_checks_on_base_push: list[str] | str | None = self.config.get_value(
value="retrigger-checks-on-base-push", return_on_none=None, extra_dict=repository_config
)

async def get_pull_request(self, number: int | None = None) -> PullRequest | None:
if number:
Expand Down
192 changes: 145 additions & 47 deletions webhook_server/libs/handlers/check_run_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
from typing import TYPE_CHECKING, Any

from github.CheckRun import CheckRun
from github.Commit import Commit
from github.CommitStatus import CommitStatus
from github.PullRequest import PullRequest
from github.Repository import Repository
Expand Down Expand Up @@ -138,37 +139,51 @@ async def set_verify_check_queued(self) -> None:
async def set_verify_check_success(self) -> None:
return await self.set_check_run_status(check_run=VERIFIED_LABEL_STR, conclusion=SUCCESS_STR)

async def set_run_tox_check_queued(self) -> None:
async def set_run_tox_check_queued(self, pull_request: PullRequest | None = None) -> None:
if not self.github_webhook.tox:
self.logger.debug(f"{self.log_prefix} tox is not configured, skipping.")
return

return await self.set_check_run_status(check_run=TOX_STR, status=QUEUED_STR)
return await self.set_check_run_status(check_run=TOX_STR, status=QUEUED_STR, pull_request=pull_request)

async def set_run_tox_check_in_progress(self) -> None:
return await self.set_check_run_status(check_run=TOX_STR, status=IN_PROGRESS_STR)
async def set_run_tox_check_in_progress(self, pull_request: PullRequest | None = None) -> None:
return await self.set_check_run_status(check_run=TOX_STR, status=IN_PROGRESS_STR, pull_request=pull_request)

async def set_run_tox_check_failure(self, output: dict[str, Any]) -> None:
return await self.set_check_run_status(check_run=TOX_STR, conclusion=FAILURE_STR, output=output)
async def set_run_tox_check_failure(self, output: dict[str, Any], pull_request: PullRequest | None = None) -> None:
return await self.set_check_run_status(
check_run=TOX_STR, conclusion=FAILURE_STR, output=output, pull_request=pull_request
)

async def set_run_tox_check_success(self, output: dict[str, Any]) -> None:
return await self.set_check_run_status(check_run=TOX_STR, conclusion=SUCCESS_STR, output=output)
async def set_run_tox_check_success(self, output: dict[str, Any], pull_request: PullRequest | None = None) -> None:
return await self.set_check_run_status(
check_run=TOX_STR, conclusion=SUCCESS_STR, output=output, pull_request=pull_request
)

async def set_run_pre_commit_check_queued(self) -> None:
async def set_run_pre_commit_check_queued(self, pull_request: PullRequest | None = None) -> None:
if not self.github_webhook.pre_commit:
self.logger.debug(f"{self.log_prefix} pre-commit is not configured, skipping.")
return

return await self.set_check_run_status(check_run=PRE_COMMIT_STR, status=QUEUED_STR)
return await self.set_check_run_status(check_run=PRE_COMMIT_STR, status=QUEUED_STR, pull_request=pull_request)

async def set_run_pre_commit_check_in_progress(self) -> None:
return await self.set_check_run_status(check_run=PRE_COMMIT_STR, status=IN_PROGRESS_STR)
async def set_run_pre_commit_check_in_progress(self, pull_request: PullRequest | None = None) -> None:
return await self.set_check_run_status(
check_run=PRE_COMMIT_STR, status=IN_PROGRESS_STR, pull_request=pull_request
)

async def set_run_pre_commit_check_failure(self, output: dict[str, Any] | None = None) -> None:
return await self.set_check_run_status(check_run=PRE_COMMIT_STR, conclusion=FAILURE_STR, output=output)
async def set_run_pre_commit_check_failure(
self, output: dict[str, Any] | None = None, pull_request: PullRequest | None = None
) -> None:
return await self.set_check_run_status(
check_run=PRE_COMMIT_STR, conclusion=FAILURE_STR, output=output, pull_request=pull_request
)

async def set_run_pre_commit_check_success(self, output: dict[str, Any] | None = None) -> None:
return await self.set_check_run_status(check_run=PRE_COMMIT_STR, conclusion=SUCCESS_STR, output=output)
async def set_run_pre_commit_check_success(
self, output: dict[str, Any] | None = None, pull_request: PullRequest | None = None
) -> None:
return await self.set_check_run_status(
check_run=PRE_COMMIT_STR, conclusion=SUCCESS_STR, output=output, pull_request=pull_request
)

async def set_merge_check_queued(self, output: dict[str, Any] | None = None) -> None:
return await self.set_check_run_status(check_run=CAN_BE_MERGED_STR, status=QUEUED_STR, output=output)
Expand All @@ -182,53 +197,85 @@ async def set_merge_check_success(self) -> None:
async def set_merge_check_failure(self, output: dict[str, Any]) -> None:
return await self.set_check_run_status(check_run=CAN_BE_MERGED_STR, conclusion=FAILURE_STR, output=output)

async def set_container_build_queued(self) -> None:
async def set_container_build_queued(self, pull_request: PullRequest | None = None) -> None:
if not self.github_webhook.build_and_push_container:
self.logger.debug(f"{self.log_prefix} build_and_push_container is not configured, skipping.")
return

return await self.set_check_run_status(check_run=BUILD_CONTAINER_STR, status=QUEUED_STR)
return await self.set_check_run_status(
check_run=BUILD_CONTAINER_STR, status=QUEUED_STR, pull_request=pull_request
)

async def set_container_build_in_progress(self) -> None:
return await self.set_check_run_status(check_run=BUILD_CONTAINER_STR, status=IN_PROGRESS_STR)
async def set_container_build_in_progress(self, pull_request: PullRequest | None = None) -> None:
return await self.set_check_run_status(
check_run=BUILD_CONTAINER_STR, status=IN_PROGRESS_STR, pull_request=pull_request
)

async def set_container_build_success(self, output: dict[str, Any]) -> None:
return await self.set_check_run_status(check_run=BUILD_CONTAINER_STR, conclusion=SUCCESS_STR, output=output)
async def set_container_build_success(
self, output: dict[str, Any], pull_request: PullRequest | None = None
) -> None:
return await self.set_check_run_status(
check_run=BUILD_CONTAINER_STR, conclusion=SUCCESS_STR, output=output, pull_request=pull_request
)

async def set_container_build_failure(self, output: dict[str, Any]) -> None:
return await self.set_check_run_status(check_run=BUILD_CONTAINER_STR, conclusion=FAILURE_STR, output=output)
async def set_container_build_failure(
self, output: dict[str, Any], pull_request: PullRequest | None = None
) -> None:
return await self.set_check_run_status(
check_run=BUILD_CONTAINER_STR, conclusion=FAILURE_STR, output=output, pull_request=pull_request
)

async def set_python_module_install_queued(self) -> None:
async def set_python_module_install_queued(self, pull_request: PullRequest | None = None) -> None:
if not self.github_webhook.pypi:
self.logger.debug(f"{self.log_prefix} pypi is not configured, skipping.")
return

return await self.set_check_run_status(check_run=PYTHON_MODULE_INSTALL_STR, status=QUEUED_STR)
return await self.set_check_run_status(
check_run=PYTHON_MODULE_INSTALL_STR, status=QUEUED_STR, pull_request=pull_request
)

async def set_python_module_install_in_progress(self) -> None:
return await self.set_check_run_status(check_run=PYTHON_MODULE_INSTALL_STR, status=IN_PROGRESS_STR)
async def set_python_module_install_in_progress(self, pull_request: PullRequest | None = None) -> None:
return await self.set_check_run_status(
check_run=PYTHON_MODULE_INSTALL_STR, status=IN_PROGRESS_STR, pull_request=pull_request
)

async def set_python_module_install_success(self, output: dict[str, Any]) -> None:
async def set_python_module_install_success(
self, output: dict[str, Any], pull_request: PullRequest | None = None
) -> None:
return await self.set_check_run_status(
check_run=PYTHON_MODULE_INSTALL_STR, conclusion=SUCCESS_STR, output=output
check_run=PYTHON_MODULE_INSTALL_STR, conclusion=SUCCESS_STR, output=output, pull_request=pull_request
)

async def set_python_module_install_failure(self, output: dict[str, Any]) -> None:
async def set_python_module_install_failure(
self, output: dict[str, Any], pull_request: PullRequest | None = None
) -> None:
return await self.set_check_run_status(
check_run=PYTHON_MODULE_INSTALL_STR, conclusion=FAILURE_STR, output=output
check_run=PYTHON_MODULE_INSTALL_STR, conclusion=FAILURE_STR, output=output, pull_request=pull_request
)

async def set_conventional_title_queued(self) -> None:
return await self.set_check_run_status(check_run=CONVENTIONAL_TITLE_STR, status=QUEUED_STR)
async def set_conventional_title_queued(self, pull_request: PullRequest | None = None) -> None:
return await self.set_check_run_status(
check_run=CONVENTIONAL_TITLE_STR, status=QUEUED_STR, pull_request=pull_request
)

async def set_conventional_title_in_progress(self) -> None:
return await self.set_check_run_status(check_run=CONVENTIONAL_TITLE_STR, status=IN_PROGRESS_STR)
async def set_conventional_title_in_progress(self, pull_request: PullRequest | None = None) -> None:
return await self.set_check_run_status(
check_run=CONVENTIONAL_TITLE_STR, status=IN_PROGRESS_STR, pull_request=pull_request
)

async def set_conventional_title_success(self, output: dict[str, Any]) -> None:
return await self.set_check_run_status(check_run=CONVENTIONAL_TITLE_STR, conclusion=SUCCESS_STR, output=output)
async def set_conventional_title_success(
self, output: dict[str, Any], pull_request: PullRequest | None = None
) -> None:
return await self.set_check_run_status(
check_run=CONVENTIONAL_TITLE_STR, conclusion=SUCCESS_STR, output=output, pull_request=pull_request
)

async def set_conventional_title_failure(self, output: dict[str, Any]) -> None:
return await self.set_check_run_status(check_run=CONVENTIONAL_TITLE_STR, conclusion=FAILURE_STR, output=output)
async def set_conventional_title_failure(
self, output: dict[str, Any], pull_request: PullRequest | None = None
) -> None:
return await self.set_check_run_status(
check_run=CONVENTIONAL_TITLE_STR, conclusion=FAILURE_STR, output=output, pull_request=pull_request
)

async def set_cherry_pick_in_progress(self) -> None:
return await self.set_check_run_status(check_run=CHERRY_PICKED_LABEL_PREFIX, status=IN_PROGRESS_STR)
Expand All @@ -249,8 +296,28 @@ async def set_check_run_status(
status: str = "",
conclusion: str = "",
output: dict[str, str] | None = None,
pull_request: PullRequest | None = None,
) -> None:
kwargs: dict[str, Any] = {"name": check_run, "head_sha": self.github_webhook.last_commit.sha}
# Get head_sha from pull_request or fall back to github_webhook.last_commit
if pull_request:
# Use single-pass iteration to find last commit
def get_last_commit_sha() -> str:
last_commit = None
for commit in pull_request.get_commits():
last_commit = commit
if last_commit is None:
raise ValueError("Pull request has no commits")
return last_commit.sha

head_sha = await asyncio.to_thread(get_last_commit_sha)
else:
# Fall back to github_webhook.last_commit for backward compatibility
if not hasattr(self.github_webhook, "last_commit") or self.github_webhook.last_commit is None:
self.logger.warning(f"{self.log_prefix} Cannot set check run status: no last_commit available")
return
head_sha = self.github_webhook.last_commit.sha

kwargs: dict[str, Any] = {"name": check_run, "head_sha": head_sha}

if status:
kwargs["status"] = status
Expand Down Expand Up @@ -353,12 +420,43 @@ def get_check_run_text(self, err: str, out: str) -> str:

return _output

async def is_check_run_in_progress(self, check_run: str) -> bool:
if self.github_webhook.last_commit:
for run in await asyncio.to_thread(self.github_webhook.last_commit.get_check_runs):
if run.name == check_run and run.status == IN_PROGRESS_STR:
self.logger.debug(f"{self.log_prefix} Check run {check_run} is in progress.")
return True
async def is_check_run_in_progress(self, check_run: str, pull_request: PullRequest | None = None) -> bool:
"""Check if a specific check run is in progress.

Args:
check_run: Name of the check run to check
pull_request: Optional pull request to get last commit from. If provided,
gets last commit from PR. Otherwise, falls back to github_webhook.last_commit

Returns:
True if check run is in progress, False otherwise
"""
last_commit = None
if pull_request:
# Use single-pass iteration to find last commit - O(1) memory instead of O(N)
def get_last_commit_from_pr() -> Commit | None:
last = None
for commit in pull_request.get_commits():
last = commit
return last

last_commit = await asyncio.to_thread(get_last_commit_from_pr)
else:
# last_commit may not exist on github_webhook for push events (optional attribute)
last_commit = self.github_webhook.last_commit if hasattr(self.github_webhook, "last_commit") else None

if last_commit:
# Optimize PaginatedList iteration with early exit
def find_check_run_in_progress() -> bool:
for run in last_commit.get_check_runs():
if run.name == check_run and run.status == IN_PROGRESS_STR:
return True
return False

is_in_progress = await asyncio.to_thread(find_check_run_in_progress)
if is_in_progress:
self.logger.debug(f"{self.log_prefix} Check run {check_run} is in progress.")
return True
return False

async def required_check_failed_or_no_status(
Expand Down
28 changes: 3 additions & 25 deletions webhook_server/libs/handlers/issue_comment_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import asyncio
from asyncio import Task
from collections.abc import Callable, Coroutine
from collections.abc import Coroutine
from typing import TYPE_CHECKING, Any

from github.PullRequest import PullRequest
Expand All @@ -16,7 +16,6 @@
from webhook_server.utils.constants import (
AUTOMERGE_LABEL_STR,
BUILD_AND_PUSH_CONTAINER_STR,
BUILD_CONTAINER_STR,
CHERRY_PICK_LABEL_PREFIX,
COMMAND_ADD_ALLOWED_USER_STR,
COMMAND_ASSIGN_REVIEWER_STR,
Expand All @@ -25,12 +24,8 @@
COMMAND_CHERRY_PICK_STR,
COMMAND_REPROCESS_STR,
COMMAND_RETEST_STR,
CONVENTIONAL_TITLE_STR,
HOLD_LABEL_STR,
PRE_COMMIT_STR,
PYTHON_MODULE_INSTALL_STR,
REACTIONS,
TOX_STR,
USER_LABELS_DICT,
VERIFIED_LABEL_STR,
WIP_STR,
Expand Down Expand Up @@ -415,14 +410,6 @@ async def process_retest_command(
self.logger.debug(f"{self.log_prefix} Target tests for re-test: {_target_tests}")
_not_supported_retests: list[str] = []
_supported_retests: list[str] = []
_retests_to_func_map: dict[str, Callable] = {
TOX_STR: self.runner_handler.run_tox,
PRE_COMMIT_STR: self.runner_handler.run_pre_commit,
BUILD_CONTAINER_STR: self.runner_handler.run_build_container,
PYTHON_MODULE_INSTALL_STR: self.runner_handler.run_install_python_module,
CONVENTIONAL_TITLE_STR: self.runner_handler.run_conventional_title_check,
}
self.logger.debug(f"{self.log_prefix} Retest map is {_retests_to_func_map}")

if not _target_tests:
msg = "No test defined to retest"
Expand Down Expand Up @@ -459,17 +446,8 @@ async def process_retest_command(
self.logger.debug(error_msg)
await asyncio.to_thread(pull_request.create_issue_comment, msg)

if _supported_retests:
tasks: list[Coroutine[Any, Any, Any] | Task[Any]] = []
for _test in _supported_retests:
self.logger.debug(f"{self.log_prefix} running retest {_test}")
task = asyncio.create_task(_retests_to_func_map[_test](pull_request=pull_request))
tasks.append(task)

results = await asyncio.gather(*tasks, return_exceptions=True)
for result in results:
if isinstance(result, Exception):
self.logger.error(f"{self.log_prefix} Async task failed: {result}")
# Run all supported retests using the shared runner handler method
await self.runner_handler.run_retests(supported_retests=_supported_retests, pull_request=pull_request)

if automerge:
await self.labels_handler._add_label(pull_request=pull_request, label=AUTOMERGE_LABEL_STR)
Loading