mirror of
https://github.com/jhd3197/ServerKit.git
synced 2026-10-27 23:28:03 +09:00
206 lines
8.5 KiB
Python
206 lines
8.5 KiB
Python
"""Pull-request webhook handling for PR Preview Environments.
|
|
|
|
The existing push/auto-deploy webhook lives in ``app/api/git.py`` +
|
|
``app/services/webhook_service.py`` and is left untouched. This blueprint adds a
|
|
``pull_request`` endpoint that, for a webhook bound to an application with PR
|
|
previews enabled, enqueues the right preview job:
|
|
|
|
* ``opened`` / ``reopened`` → ``preview.create``
|
|
* ``synchronize`` / ``edited`` → ``preview.sync`` (redeploy on new commits)
|
|
* ``closed`` → ``preview.destroy``
|
|
|
|
Signature verification reuses :meth:`WebhookService._verify_signature` so the
|
|
same secret as the push webhook is honored. If previews aren't enabled for the
|
|
app, the call is a no-op. Best-effort throughout — a provider that lacks Docker
|
|
or DNS still gets a clean 200.
|
|
|
|
The host mounts this blueprint; ``PreviewService.register_jobs()`` must be
|
|
called at startup for the enqueued jobs to have handlers.
|
|
"""
|
|
import logging
|
|
|
|
from flask import Blueprint, request, jsonify
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
webhooks_bp = Blueprint('preview_webhooks', __name__)
|
|
|
|
|
|
# (provider action) -> preview job kind. Normalized to lowercase actions.
|
|
_OPEN_ACTIONS = {'opened', 'reopened'}
|
|
_SYNC_ACTIONS = {'synchronize', 'synchronized', 'edited', 'ready_for_review'}
|
|
_CLOSE_ACTIONS = {'closed'}
|
|
|
|
|
|
def _detect_source(headers):
|
|
"""Identify the provider + pull-request signal from request headers,
|
|
mirroring the detection in app/api/git.py."""
|
|
if 'X-GitHub-Event' in headers:
|
|
return ('github', headers.get('X-GitHub-Event'),
|
|
headers.get('X-Hub-Signature-256'))
|
|
if 'X-Gitlab-Event' in headers:
|
|
return ('gitlab', headers.get('X-Gitlab-Event'),
|
|
headers.get('X-Gitlab-Token'))
|
|
if 'X-Event-Key' in headers:
|
|
return ('bitbucket', headers.get('X-Event-Key'),
|
|
headers.get('X-Hub-Signature'))
|
|
return (None, None, None)
|
|
|
|
|
|
def _is_pull_request_event(source, event_type):
|
|
if not event_type:
|
|
return False
|
|
et = event_type.lower()
|
|
if source == 'github':
|
|
return et == 'pull_request'
|
|
if source == 'gitlab':
|
|
return 'merge request' in et # 'Merge Request Hook'
|
|
if source == 'bitbucket':
|
|
return et.startswith('pullrequest:')
|
|
return False
|
|
|
|
|
|
def _extract_pr(source, payload):
|
|
"""Normalize a provider PR payload to
|
|
``{action, pr_number, branch, commit_sha, pr_title}``. Best-effort."""
|
|
payload = payload or {}
|
|
try:
|
|
if source == 'github':
|
|
pr = payload.get('pull_request') or {}
|
|
head = pr.get('head') or {}
|
|
return {
|
|
'action': (payload.get('action') or '').lower(),
|
|
'pr_number': payload.get('number') or pr.get('number'),
|
|
'branch': head.get('ref'),
|
|
'commit_sha': head.get('sha'),
|
|
'pr_title': pr.get('title'),
|
|
}
|
|
if source == 'gitlab':
|
|
attrs = payload.get('object_attributes') or {}
|
|
return {
|
|
'action': (attrs.get('action') or '').lower(),
|
|
'pr_number': attrs.get('iid') or attrs.get('id'),
|
|
'branch': attrs.get('source_branch'),
|
|
'commit_sha': (attrs.get('last_commit') or {}).get('id'),
|
|
'pr_title': attrs.get('title'),
|
|
}
|
|
if source == 'bitbucket':
|
|
pr = payload.get('pullrequest') or {}
|
|
src = (pr.get('source') or {})
|
|
branch = (src.get('branch') or {}).get('name')
|
|
commit = (src.get('commit') or {}).get('hash')
|
|
return {
|
|
'action': '', # bitbucket encodes the action in the event key
|
|
'pr_number': pr.get('id'),
|
|
'branch': branch,
|
|
'commit_sha': commit,
|
|
'pr_title': pr.get('title'),
|
|
}
|
|
except Exception as exc: # pragma: no cover - defensive
|
|
logger.debug('PR payload extraction failed (%s): %s', source, exc)
|
|
return {'action': '', 'pr_number': None, 'branch': None,
|
|
'commit_sha': None, 'pr_title': None}
|
|
|
|
|
|
def _action_to_job(source, event_type, action):
|
|
"""Map a normalized PR action to a preview job kind, or None to ignore."""
|
|
# Bitbucket carries the action in the event key (pullrequest:created /
|
|
# :updated / :fulfilled / :rejected) rather than an `action` field.
|
|
if source == 'bitbucket' and event_type:
|
|
key = event_type.lower()
|
|
if key == 'pullrequest:created':
|
|
return 'preview.create'
|
|
if key == 'pullrequest:updated':
|
|
return 'preview.sync'
|
|
if key in ('pullrequest:fulfilled', 'pullrequest:rejected'):
|
|
return 'preview.destroy'
|
|
return None
|
|
|
|
act = (action or '').lower()
|
|
if act in _CLOSE_ACTIONS or act in ('merge', 'merged', 'close'):
|
|
return 'preview.destroy'
|
|
if act in _OPEN_ACTIONS or act in ('open',):
|
|
return 'preview.create'
|
|
if act in _SYNC_ACTIONS or act in ('update', 'updated'):
|
|
return 'preview.sync'
|
|
return None
|
|
|
|
|
|
@webhooks_bp.route('/pull-request/<token>', methods=['POST'])
|
|
def receive_pull_request(token):
|
|
"""Receive a pull_request webhook and drive PR preview environments.
|
|
|
|
Public endpoint (no JWT) — authenticated by the webhook signature, exactly
|
|
like the push webhook. Always returns 200 with an ``action`` descriptor so a
|
|
misconfigured-but-harmless delivery never trips the provider's retry logic.
|
|
"""
|
|
from app.models import GitWebhook
|
|
from app.services.webhook_service import WebhookService
|
|
|
|
source, event_type, signature = _detect_source(request.headers)
|
|
if not source:
|
|
return jsonify({'error': 'Unknown webhook source'}), 400
|
|
|
|
webhook = GitWebhook.query.filter_by(webhook_token=token).first()
|
|
if not webhook:
|
|
return jsonify({'error': 'Unknown webhook'}), 404
|
|
if not webhook.is_active:
|
|
return jsonify({'success': True, 'action': 'ignored',
|
|
'reason': 'webhook inactive'}), 200
|
|
|
|
# Reuse the push webhook's signature verification (same secret).
|
|
if not WebhookService._verify_signature(webhook, source, signature, request.get_data()):
|
|
return jsonify({'error': 'Invalid signature'}), 400
|
|
|
|
if not _is_pull_request_event(source, event_type):
|
|
return jsonify({'success': True, 'action': 'ignored',
|
|
'reason': f'not a pull_request event ({event_type})'}), 200
|
|
|
|
if not webhook.app_id:
|
|
return jsonify({'success': True, 'action': 'ignored',
|
|
'reason': 'webhook not bound to an application'}), 200
|
|
|
|
# No-op unless previews are enabled for this application.
|
|
try:
|
|
from app.services.preview_service import PreviewService
|
|
settings = PreviewService.get_settings(webhook.app_id)
|
|
if not getattr(settings, 'enabled', False):
|
|
return jsonify({'success': True, 'action': 'ignored',
|
|
'reason': 'previews disabled for app'}), 200
|
|
except Exception as exc: # noqa: BLE001 - reported below, answered in-band
|
|
# A failing inbound hook gets disabled by the git provider, so the
|
|
# answer must stay 200 — but the crash still goes on the record
|
|
# instead of hiding in a debug log.
|
|
from app.error_reporting import record_unexpected
|
|
record_unexpected(exc)
|
|
return jsonify({'success': True, 'action': 'ignored',
|
|
'reason': 'previews unavailable'}), 200
|
|
|
|
pr = _extract_pr(source, request.get_json(silent=True))
|
|
job_kind = _action_to_job(source, event_type, pr.get('action'))
|
|
if not job_kind:
|
|
return jsonify({'success': True, 'action': 'ignored',
|
|
'reason': f"unhandled PR action ({pr.get('action')})"}), 200
|
|
|
|
payload = {'application_id': webhook.app_id, 'pr': {
|
|
'pr_number': pr.get('pr_number'),
|
|
'branch': pr.get('branch'),
|
|
'commit_sha': pr.get('commit_sha'),
|
|
'pr_title': pr.get('pr_title'),
|
|
}}
|
|
|
|
# Reserve desired state even when the create job has not started yet.
|
|
# A close must cancel a queued build rather than disappear for lack of a row.
|
|
enqueued = False
|
|
try:
|
|
from app.services.previews.requests import enqueue
|
|
enqueue(webhook.app_id, payload['pr'],
|
|
action='destroy' if job_kind == 'preview.destroy' else 'create')
|
|
enqueued = True
|
|
except Exception as exc:
|
|
logger.warning('failed to enqueue %s for app %s: %s',
|
|
job_kind, webhook.app_id, exc)
|
|
|
|
return jsonify({'success': True, 'action': job_kind, 'enqueued': enqueued,
|
|
'pr_number': pr.get('pr_number')}), 200
|