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 += [