From patchwork Thu Jun 4 12:11:54 2026 Return-Path: Received: from ringo (2a01cb00021ec0002e23edbec21b0e73.ipv6.abo.wanadoo.fr [IPv6:2a01:cb00:21e:c000:2e23:edbe:c21b:e73]) by patches.jarry.cc (Postfix) with ESMTP id 4D8041BC4348 for ; Thu, 04 Jun 2026 14:12:10 +0200 (CEST) From: Robin Jarry To: pw@patches.jarry.cc Subject: [PATCH 06/15] forge: add backend abstraction layer Date: Thu, 4 Jun 2026 14:11:54 +0200 Message-ID: <20260604121203.2955783-7-robin@jarry.cc> X-Mailer: git-send-email 2.54.0 In-Reply-To: <20260604121203.2955783-1-robin@jarry.cc> References: <20260604121203.2955783-1-robin@jarry.cc> MIME-Version: 1.0 List-ID: X-Patchwork-Submitter: Robin Jarry X-Patchwork-Id: 58 Content-Type: text/plain; charset=utf-8 Content-Transfer-Encoding: 8bit Add a forge integration framework that allows patchwork to receive webhooks from code forges. The abstraction is designed so that new forge backends can be added without changing the core logic. A ForgeBackend abstract base class defines the interface that each backend must implement: webhook signature verification, event parsing, and sync event processing. Backends register themselves at import time and are only loaded when explicitly listed in the FORGE_BACKENDS setting, keeping forge-specific dependencies entirely optional. ForgeConfig.clean() validates that the backend name matches a registered backend. Webhook requests are received at /webhook/forge// and the whole feature is gated behind the ENABLE_FORGE setting. Signed-off-by: Robin Jarry --- patchwork/forge/__init__.py | 178 ++++++++++++++++++++++++++++++++++++ patchwork/forge/urls.py | 16 ++++ patchwork/forge/views.py | 82 +++++++++++++++++ patchwork/models.py | 9 ++ patchwork/settings/base.py | 26 ++++++ patchwork/urls.py | 9 ++ 6 files changed, 320 insertions(+) create mode 100644 patchwork/forge/__init__.py create mode 100644 patchwork/forge/urls.py create mode 100644 patchwork/forge/views.py diff --git a/patchwork/forge/__init__.py b/patchwork/forge/__init__.py new file mode 100644 index 000000000000..fff3e25d3bf5 --- /dev/null +++ b/patchwork/forge/__init__.py @@ -0,0 +1,178 @@ +# Patchwork - automated patch tracking system +# Copyright (C) 2026 Robin Jarry +# +# SPDX-License-Identifier: GPL-2.0-or-later + +""" +Forge integration framework. + +Provides an abstraction layer for synchronizing patch workflows between +patchwork and code forges (GitHub, GitLab, etc.). Each forge platform is +implemented as a backend that registers itself at import time and is only +loaded when listed in settings.FORGE_BACKENDS. + +Backends are responsible for webhook signature verification and event parsing. +Sync logic (creating patches from PRs, forwarding comments) is implemented +per-backend but can reuse generic utilities from patchwork.forge.sync. + +Forge-specific dependencies remain entirely optional: a backend module is never +imported unless explicitly enabled in settings. +""" + +import importlib +import logging +from abc import ABC +from abc import abstractmethod +from dataclasses import dataclass +from dataclasses import field + +from django.conf import settings + +logger = logging.getLogger(__name__) + + +@dataclass +class ForgeUser: + login: str = '' + name: str = '' + email: str = '' + + +@dataclass +class ReviewComment: + path: str = '' + diff_hunk: str = '' + body: str = '' + + +@dataclass +class CheckRun: + name: str = '' + status: str = '' + url: str = '' + description: str = '' + + +@dataclass +class ForgeEvent: + type: str = '' + repo_key: str = '' + pr_number: int = 0 + author: ForgeUser = field(default_factory=ForgeUser) + body: str = '' + + # review fields + path: str = '' + diff_hunk: str = '' + review_state: str = '' + review_comments: list = field(default_factory=list) + + # check fields + check_name: str = '' + check_status: str = '' + check_url: str = '' + check_description: str = '' + check_runs: list = field(default_factory=list) + + # pull request fields + pr_title: str = '' + pr_body: str = '' + pr_head: str = '' + pr_base: str = '' + pr_head_branch: str = '' + pr_action: str = '' + pr_before: str = '' + + +class ForgeBackend(ABC): + """ + Base class for forge backend implementations. + + Each backend handles webhook signature verification and event parsing for + a specific forge platform (GitHub, GitLab, etc.). Backends register + themselves at import time via register_backend(). + """ + + @abstractmethod + def verify_webhook_signature(self, body, headers, secret): + """ + Verify the HMAC signature of an incoming webhook request. + + Args: + body (bytes): Raw request body. + headers (dict): HTTP headers. + secret (str): Shared secret for signature verification. + If empty, verification is skipped. + + Returns: + True if the signature is valid or no secret is configured. + """ + raise NotImplementedError + + @abstractmethod + def parse_webhook_event(self, body, headers): + """ + Parse a webhook payload into a ForgeEvent. + + Args: + body (bytes): Raw request body. + headers (dict): HTTP headers. + + Returns: + A ForgeEvent instance, or None if the event should be ignored. + """ + raise NotImplementedError + + @abstractmethod + def process_webhook_event(self, forge_config, event): + """ + Handle a parsed webhook event for a given project. + + Called by the webhook view after signature verification, event + parsing and project routing. The backend decides which event + types to act on and what sync actions to perform. + """ + raise NotImplementedError + + @abstractmethod + def series_metadata(self, forge_config, event): + """ + Return a dict of SeriesMetadata key-value pairs to associate + with a series created from a forge event. + + Used after ingesting patches to link the series back to its + forge pull request and branch. + """ + raise NotImplementedError + + def get_auth(self, forge_config): + """ + Resolve authentication credentials for a forge config. + + Merges backend-level defaults from FORGE_AUTH[backend] with per-repo + overrides from FORGE_AUTH[backend]["repos"][repo]. + """ + backend_auth = settings.FORGE_AUTH.get(forge_config.backend, {}) + auth = {k: v for k, v in backend_auth.items() if k != 'repos'} + repo_overrides = backend_auth.get('repos', {}).get( + forge_config.repo, {} + ) + auth.update(repo_overrides) + return auth + + +_backends = {} + + +def register_backend(name, backend): + _backends[name] = backend + + +def get_backend(name): + return _backends.get(name) + + +def load_backends(): + for module_path in settings.FORGE_BACKENDS: + logger.info('loading forge backend: %s', module_path) + importlib.import_module(module_path) diff --git a/patchwork/forge/urls.py b/patchwork/forge/urls.py new file mode 100644 index 000000000000..ece29f5e59d1 --- /dev/null +++ b/patchwork/forge/urls.py @@ -0,0 +1,16 @@ +# Patchwork - automated patch tracking system +# Copyright (C) 2026 Robin Jarry +# +# SPDX-License-Identifier: GPL-2.0-or-later + +from django.urls import path + +from patchwork.forge import views + +urlpatterns = [ + path( + '/', + views.forge_webhook, + name='forge-webhook', + ), +] diff --git a/patchwork/forge/views.py b/patchwork/forge/views.py new file mode 100644 index 000000000000..e8d0db35c79b --- /dev/null +++ b/patchwork/forge/views.py @@ -0,0 +1,82 @@ +# Patchwork - automated patch tracking system +# Copyright (C) 2026 Robin Jarry +# +# SPDX-License-Identifier: GPL-2.0-or-later + +import logging + +from django.conf import settings +from django.http import HttpResponse +from django.http import HttpResponseNotAllowed +from django.http import HttpResponseNotFound +from django.http import JsonResponse +from django.views.decorators.csrf import csrf_exempt + +from patchwork.forge import get_backend +from patchwork.models import ForgeConfig + +logger = logging.getLogger(__name__) + + +@csrf_exempt +def forge_webhook(request, backend_name): + """ + Receive and process a webhook from a forge backend. + + Verifies the request signature, parses the event payload and + dispatches it to the appropriate backend sync handler. + """ + if request.method != 'POST': + return HttpResponseNotAllowed(['POST']) + + backend = get_backend(backend_name) + if backend is None: + return HttpResponseNotFound() + + secret = settings.FORGE_WEBHOOK_SECRETS.get(backend_name, '') + if not backend.verify_webhook_signature( + request.body, request.headers, secret + ): + return HttpResponse(status=403) + + try: + event = backend.parse_webhook_event(request.body, request.headers) + except Exception: + logger.exception('failed to parse %s webhook', backend_name) + return HttpResponse(status=400) + + if event is None: + return JsonResponse({'status': 'ignored'}) + + try: + forge_config = ForgeConfig.objects.select_related('project').get( + backend=backend_name, repo=event.repo_key + ) + except ForgeConfig.DoesNotExist: + logger.warning( + '%s webhook: no project for repo %s', + backend_name, + event.repo_key, + ) + return JsonResponse({'status': 'unlinked'}) + + logger.info( + '%s webhook: type=%s repo=%s pr=%s project=%s', + backend_name, + event.type, + event.repo_key, + event.pr_number, + forge_config.project.linkname, + ) + + try: + backend.process_webhook_event(forge_config, event) + except Exception: + logger.exception( + 'failed to handle %s event for %s', + event.type, + forge_config.project.linkname, + ) + return HttpResponse(status=500) + + return JsonResponse({'status': 'ok'}) diff --git a/patchwork/models.py b/patchwork/models.py index 3f7974d92ccb..79e6259976ad 100644 --- a/patchwork/models.py +++ b/patchwork/models.py @@ -168,6 +168,15 @@ class ForgeConfig(models.Model): """ return self.from_email or settings.DEFAULT_FROM_EMAIL + def clean(self): + from patchwork.forge import get_backend + + backend = get_backend(self.backend) if self.backend else None + if self.backend and backend is None: + raise ValidationError( + {'backend': f'Unknown forge backend: {self.backend}'} + ) + def __str__(self): return '%s (%s)' % (self.repo, self.backend) diff --git a/patchwork/settings/base.py b/patchwork/settings/base.py index dccab6c36dba..e5bcbacf05dc 100644 --- a/patchwork/settings/base.py +++ b/patchwork/settings/base.py @@ -277,3 +277,29 @@ FORCE_HTTPS_LINKS = False # Set to True to hide admin details from the about page (/about) ADMINS_HIDE = False + +# Set to True to enable forge integration (webhook endpoints) +ENABLE_FORGE = False + +# List of forge backend modules to load (e.g. ['patchwork.forge.github']) +FORGE_BACKENDS = [] + +# Webhook secrets keyed by backend name (e.g. {'github': 'secret'}) +FORGE_WEBHOOK_SECRETS = {} + +# Authentication credentials keyed by backend name. Each backend +# defines its own auth fields. Per-repo overrides can be specified +# under a "repos" sub-dict. +# +# FORGE_AUTH = { +# "github": { +# "token": "ghp_default", # default for all repos +# "repos": { +# "owner/repo-a": { # per-repo override +# "app_id": 123456, +# "private_key_file": "/etc/patchwork/repo-a.pem", +# }, +# }, +# }, +# } +FORGE_AUTH = {} diff --git a/patchwork/urls.py b/patchwork/urls.py index 11cd8e7c152a..297119b8e816 100644 --- a/patchwork/urls.py +++ b/patchwork/urls.py @@ -366,6 +366,15 @@ if settings.ENABLE_REST_API: ] +if settings.ENABLE_FORGE: + from patchwork.forge import load_backends + + load_backends() + urlpatterns += [ + path('webhook/forge/', include('patchwork.forge.urls')), + ] + + # redirect from old urls if settings.COMPAT_REDIR: urlpatterns += [