mbox series
Message ID20260604121203.2955783-7-robin@jarry.cc
StateNew
Delegate
ArchivedNo
Headers
show
Return-Path: <robin@jarry.cc>
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 <pw@patches.jarry.cc>; Thu, 04 Jun 2026 14:12:10 +0200 (CEST)
From: Robin Jarry <robin@jarry.cc>
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: <pw.jarry.cc>
Content-Transfer-Encoding: 8bit
Series
Implement forge bidirectional sync

Commit Message

Robin JarryJun. 4, 2026, 14:11. UTC
[06/15] forge: add backend abstraction layer

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/<backend_name>/ and
the whole feature is gated behind the ENABLE_FORGE setting.

Signed-off-by: Robin Jarry <robin@jarry.cc>
---
 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

Patch

mbox series
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 <robin@jarry.cc>
+#
+# 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 <robin@jarry.cc>
+#
+# SPDX-License-Identifier: GPL-2.0-or-later
+
+from django.urls import path
+
+from patchwork.forge import views
+
+urlpatterns = [
+    path(
+        '<str:backend_name>/',
+        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 <robin@jarry.cc>
+#
+# 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 += [