Files

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