Supported GitHub Events
github-webhook-server automates a focused set of GitHub webhook events for pull requests, merge gating, and release workflows. You can subscribe the webhook to more events than this, but only the events on this page have built-in behavior.
Note: The webhook endpoint returns
200 OKafter the payload is validated and queued. That means GitHub reached the server successfully. It does not mean every downstream action finished successfully.
# Start background task immediately using asyncio.create_task
# This ensures the HTTP response is sent immediately without waiting
# Store task reference for observability and graceful shutdown
task = asyncio.create_task(
process_with_error_handling(
_hook_data=hook_data,
_headers=request.headers,
_delivery_id=delivery_id,
_event_type=event_type,
)
)
_background_tasks.add(task)
task.add_done_callback(_background_tasks.discard)
# Return 200 immediately with JSONResponse for fastest serialization
return JSONResponse(
status_code=status.HTTP_200_OK,
content={
"status": status.HTTP_200_OK,
"message": "Webhook queued for processing",
"delivery_id": delivery_id,
"event_type": event_type,
},
)
Configure Events
Use the events list in config.yaml or .github-webhook-server.yaml to control which GitHub deliveries this server should receive for a repository.
events: # To listen to all events do not send events
- push
- pull_request
- pull_request_review
- pull_request_review_thread
- issue_comment
- check_run
- status
If you want GitHub to send every event type, omit the events key entirely.
Event Summary
| Event | What it is used for | Intentionally skipped cases |
|---|---|---|
ping |
Webhook connectivity check | Always stops after logging |
push |
Tag-based release automation | Branch pushes, branch/tag deletions |
pull_request |
PR lifecycle automation | Draft PRs, unhandled PR actions |
issue_comment |
Slash commands on PR comments | Plain issue comments, edited/deleted comments, non-command comments, draft restrictions |
pull_request_review |
Review labels and /approve handling |
Review actions other than submitted |
check_run |
Mergeability re-checks and auto-merge | action != completed, failing can-be-merged runs |
status |
Mergeability re-checks for commit statuses | pending, no matching PR |
pull_request_review_thread |
Conversation-resolution merge gating | Actions other than resolved/unresolved, conversation resolution disabled |
Note: Several GitHub events are broader than pull requests. When the server cannot map an
issue_comment,status, orcheck_rundelivery back to a pull request, it logs the delivery and skips it.
ping
ping is just GitHub's connectivity test. The server logs it and stops. No pull request lookup, labels, checks, or builds happen for this event.
push
push is used for releases, not normal branch CI. The server only does real work for tag pushes such as refs/tags/v1.2.3.
When a matching tag push arrives, the server can:
- publish a package to PyPI if
pypiis configured - build and push a release container image if container release settings are enabled
Branch pushes are intentionally ignored. Delete events are also ignored.
# Skip branch/tag deletions - no processing needed
if self.hook_data.get("deleted"):
self.logger.info(f"{self.log_prefix} Branch/tag deletion detected, skipping processing")
token_metrics = await self._get_token_metrics()
self.logger.info(
f"{self.log_prefix} Webhook processing completed: deletion event (skipped) - {token_metrics}"
)
await self._update_context_metrics()
return None
ref = self.hook_data["ref"]
# Only clone for tag pushes - branch pushes don't require cloning
# because PushHandler only processes tags (PyPI upload, container build)
if ref.startswith("refs/tags/"):
await self._clone_repository(checkout_ref=ref)
await PushHandler(github_webhook=self).process_push_webhook_data()
token_metrics = await self._get_token_metrics()
self.logger.info(
f"{self.log_prefix} Webhook processing completed successfully: push - {token_metrics}",
)
else:
self.logger.debug(f"{self.log_prefix} Skipping clone for branch push: {ref}")
token_metrics = await self._get_token_metrics()
self.logger.info(
f"{self.log_prefix} Webhook processing completed: branch push (skipped) - {token_metrics}"
)
If neither PyPI publishing nor container release is configured, even a tag push is effectively a no-op.
pull_request
This is the main automation event. The server has first-class behavior for these action values:
openedreopenedready_for_reviewsynchronizeclosededitedlabeledunlabeled
In practice, that means:
openedinitializes the PR: welcome comment, optional tracking issue, reviewer assignment, labels, queued checks, CI tasks, possible auto-merge setup, and an optional Test Oracle run whenpr-openedis enabled.reopenedreruns the main PR setup flow.ready_for_reviewreruns the main PR setup flow and posts the welcome comment.synchronizereruns setup and CI, removes old review-state labels, and can trigger Test Oracle whenpr-synchronizedis enabled.closedcloses PR tracking artifacts. If the PR was merged, it can also run queued cherry-picks, push the merged container build, and refresh merge-state labels on other open PRs.editedrecalculateswipand rerunsconventional-titlewhen the PR title changed.labeledandunlabeledrefreshverifiedand re-check mergeability when merge-control labels change.
The main setup and CI work is visible in the handler itself:
setup_tasks.append(self.owners_file_handler.assign_reviewers(pull_request=pull_request))
setup_tasks.append(
self.labels_handler._add_label(
pull_request=pull_request,
label=f"{BRANCH_LABEL_PREFIX}{pull_request.base.ref}",
)
)
setup_tasks.append(self.label_pull_request_by_merge_state(pull_request=pull_request))
setup_tasks.append(self.check_run_handler.set_check_queued(name=CAN_BE_MERGED_STR))
# Only queue built-in checks when their corresponding feature is enabled
if self.github_webhook.tox:
setup_tasks.append(self.check_run_handler.set_check_queued(name=TOX_STR))
if self.github_webhook.pre_commit:
setup_tasks.append(self.check_run_handler.set_check_queued(name=PRE_COMMIT_STR))
if self.github_webhook.pypi:
setup_tasks.append(self.check_run_handler.set_check_queued(name=PYTHON_MODULE_INSTALL_STR))
if self.github_webhook.build_and_push_container:
setup_tasks.append(self.check_run_handler.set_check_queued(name=BUILD_CONTAINER_STR))
setup_tasks.append(self._process_verified_for_update_or_new_pull_request(pull_request=pull_request))
setup_tasks.append(self.labels_handler.add_size_label(pull_request=pull_request))
setup_tasks.append(self.add_pull_request_owner_as_assingee(pull_request=pull_request))
if self.github_webhook.conventional_title:
setup_tasks.append(self.check_run_handler.set_check_queued(name=CONVENTIONAL_TITLE_STR))
# Queue custom check runs (same as built-in checks)
# Note: custom checks are validated in GithubWebhook._validate_custom_check_runs()
# so name is guaranteed to exist
for custom_check in self.github_webhook.custom_check_runs:
check_name = custom_check["name"]
setup_tasks.append(self.check_run_handler.set_check_queued(name=check_name))
self.logger.info(f"{self.log_prefix} Executing setup tasks")
setup_results = await asyncio.gather(*setup_tasks, return_exceptions=True)
for result in setup_results:
if isinstance(result, Exception):
self.logger.error(f"{self.log_prefix} Setup task failed: {result}")
if self.ctx:
self.ctx.complete_step("pr_workflow_setup")
# Stage 2: CI/CD execution tasks
if self.ctx:
self.ctx.start_step("pr_cicd_execution")
ci_tasks: list[Coroutine[Any, Any, Any]] = []
ci_tasks.append(self.runner_handler.run_tox(pull_request=pull_request))
ci_tasks.append(self.runner_handler.run_pre_commit(pull_request=pull_request))
ci_tasks.append(self.runner_handler.run_install_python_module(pull_request=pull_request))
ci_tasks.append(self.runner_handler.run_build_container(pull_request=pull_request))
if self.github_webhook.conventional_title:
ci_tasks.append(self.runner_handler.run_conventional_title_check(pull_request=pull_request))
# Launch custom check runs (same as built-in checks)
for custom_check in self.github_webhook.custom_check_runs:
ci_tasks.append(
self.runner_handler.run_custom_check(
pull_request=pull_request,
check_config=custom_check,
)
)
Note: Draft PRs are intentionally quiet. Normal
pull_requestautomation stops early for drafts. Draft-specific comment behavior is handled separately throughissue_comment.
# 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
# If list with values: only those commands allowed on draft PRs
# allow-commands-on-draft-prs: [] # Uncomment to allow all commands on draft PRs
# allow-commands-on-draft-prs: # Or allow only specific commands:
# - build-and-push-container
# - retest
Other pull_request actions are currently ignored.
issue_comment
GitHub sends issue_comment for both issues and pull requests. This server only acts when the comment belongs to a pull request.
It intentionally ignores:
- edited comments
- deleted comments
- its own welcome-message comment
- comments that do not contain slash commands on lines starting with
/
Supported commands include retest, reprocess, check-can-merge, assign-reviewers, assign-reviewer, cherry-pick, build-and-push-container, regenerate-welcome, test-oracle, and label-style commands such as wip, hold, verified, lgtm, approve, and automerge.
A few especially important behaviors:
/retest <check>reruns supported configured checks./retest allreruns every supported check for that PR./cherry-pick <branch...>adds cherry-pick labels on unmerged PRs, or immediately creates cherry-pick PRs for merged PRs. If AI cherry-pick conflict resolution is enabled, the immediate flow can use it./approveis the project's label-driven approval command and can trigger Test Oracle'sapprovedtrigger./test-oraclecan always be run manually when Test Oracle is configured.
Note: Slash commands are permission-checked. Commands such as
/retest,/reprocess,/hold, and/automergemay be ignored or rejected if the commenter is not allowed to run them.
For draft PRs, the server uses allow-commands-on-draft-prs. An empty list means "allow all commands." A non-empty list means "allow only these commands." /test-oracle is the one built-in exception that bypasses this draft filter.
# Check if command is allowed on draft PRs
if is_draft and _command != COMMAND_TEST_ORACLE_STR:
allow_commands_on_draft = self.github_webhook.config.get_value("allow-commands-on-draft-prs")
if not isinstance(allow_commands_on_draft, list):
self.logger.debug(
f"{self.log_prefix} Command {_command} blocked: "
"draft PR and allow-commands-on-draft-prs not configured"
)
return
# Empty list means all commands allowed; non-empty list means only those commands
if len(allow_commands_on_draft) > 0:
# Sanitize: ensure all entries are strings for safe join and comparison
allow_commands_on_draft = [str(cmd) for cmd in allow_commands_on_draft]
if _command not in allow_commands_on_draft:
self.logger.debug(
f"{self.log_prefix} Command {_command} is not allowed on draft PRs. "
f"Allowed commands: {allow_commands_on_draft}"
)
await asyncio.to_thread(
pull_request.create_issue_comment,
f"Command `/{_command}` is not allowed on draft PRs.\n"
f"Allowed commands on draft PRs: {', '.join(allow_commands_on_draft)}",
)
return
pull_request_review
This event matters only when the review action is submitted. The server updates its review labels based on the review state, such as comment, approval, or requested changes.
If the review body contains a literal /approve, the server also applies the project's approval label and can trigger Test Oracle's approved trigger.
if self.hook_data["action"] == "submitted":
"""
Available actions:
commented
approved
changes_requested
"""
reviewed_user = self.hook_data["review"]["user"]["login"]
review_state = self.hook_data["review"]["state"]
self.github_webhook.logger.debug(
f"{self.github_webhook.log_prefix} "
f"Processing pull request review for user {reviewed_user} with state {review_state}"
)
await self.labels_handler.manage_reviewed_by_label(
pull_request=pull_request,
review_state=review_state,
action=ADD_STR,
reviewed_user=reviewed_user,
)
if body := self.hook_data["review"]["body"]:
self.github_webhook.logger.debug(f"{self.github_webhook.log_prefix} Found review body: {body}")
# In this project, "approved" means a maintainer uses the /approve command
# (which adds an approved-<user> label), NOT GitHub's review approval state.
# The oracle trigger fires only when /approve is found in the review body.
if any(line.strip() == f"/{APPROVE_STR}" for line in body.splitlines()):
await self.labels_handler.label_by_user_comment(
pull_request=pull_request,
user_requested_label=APPROVE_STR,
remove=False,
reviewed_user=reviewed_user,
)
task = asyncio.create_task(
call_test_oracle(
github_webhook=self.github_webhook,
pull_request=pull_request,
trigger="approved",
)
)
_background_tasks.add(task)
task.add_done_callback(_background_tasks.discard)
Note: In this project, GitHub's green "Approved" review state is not the same as the
/approvecommand. The specialapprovedTest Oracle trigger follows the command, not the raw GitHub review state.
Review actions other than submitted, such as edited or dismissed, are intentionally ignored.
check_run
check_run is where the server reacts to finished GitHub checks.
When a completed can-be-merged check succeeds and the PR has the automerge label, the server attempts a squash merge. Other completed check runs cause the server to re-evaluate whether the PR can be merged.
if self.hook_data.get("action", "") != "completed":
self.logger.debug(
f"{self.log_prefix} check run {check_run_name} action is "
f"{self.hook_data.get('action', 'N/A')} and not completed, skipping"
)
if self.ctx:
self.ctx.complete_step("check_run_handler")
return False
check_run_status: str = _check_run["status"]
check_run_conclusion: str = _check_run["conclusion"]
self.logger.debug(
f"{self.log_prefix} processing check_run - Name: {check_run_name} "
f"Status: {check_run_status} Conclusion: {check_run_conclusion}"
)
if check_run_name == CAN_BE_MERGED_STR:
if getattr(self, "labels_handler", None) and pull_request and check_run_conclusion == SUCCESS_STR:
if await self.labels_handler.label_exists_in_pull_request(
label=AUTOMERGE_LABEL_STR, pull_request=pull_request
):
try:
await asyncio.to_thread(pull_request.merge, merge_method="SQUASH")
self.logger.info(
f"{self.log_prefix} Successfully auto-merged pull request #{pull_request.number}"
)
if self.ctx:
self.ctx.complete_step("check_run_handler")
return False
except Exception as ex:
self.logger.error(
f"{self.log_prefix} Failed to auto-merge pull request #{pull_request.number}: {ex}"
)
if self.ctx:
self.ctx.complete_step("check_run_handler")
return True
else:
self.logger.debug(f"{self.log_prefix} check run is {CAN_BE_MERGED_STR}, skipping")
if self.ctx:
self.ctx.complete_step("check_run_handler")
return False
if self.ctx:
self.ctx.complete_step("check_run_handler")
return True
The server intentionally skips:
check_rundeliveries whoseactionis notcompleted- extra work when the finished check is
can-be-mergedbut the conclusion is notsuccess
That skip behavior keeps the server from re-processing every queued or in-progress check update.
status
status is the older commit-status event. It is useful for tools that report commit statuses instead of GitHub check runs.
When the status reaches a terminal state such as success, failure, or error, the server re-checks mergeability for the matching PR.
It intentionally ignores:
pendingstatuses- any status delivery that cannot be mapped back to a PR
pull_request_review_thread
pull_request_review_thread is used only for conversation-resolution gating.
When a review thread becomes resolved or unresolved, the server re-evaluates can-be-merged so unresolved conversations can block merging when that rule is enabled.
branch-protection:
strict: True
require_code_owner_reviews: True
dismiss_stale_reviews: False
required_approving_review_count: 1
required_linear_history: True
required_conversation_resolution: True
It intentionally skips:
- thread actions other than
resolvedandunresolved - all review-thread deliveries when
branch-protection.required_conversation_resolutionisfalse
Tip: Turn
required_conversation_resolutionoff if you do not want unresolved review threads to participate in merge gating.
Events That Are Not Automated
The webhook subscription and the application logic are not the same thing. GitHub can send an event that the server accepts but does not automate.
Warning: The repo-local example file still lists
pull_request_review_comment, but the server does not have first-class handling for that event. If GitHub sends it, the delivery is accepted and then effectively ignored.
# GitHub events to listen to
events:
- push
- pull_request
- pull_request_review
- pull_request_review_comment
- pull_request_review_thread
- issue_comment
- check_run
- status
The same rule applies to any other GitHub event that is not listed on this page. If it is not one of the supported events above, the server does not have a dedicated automation path for it.