mbox series
Message ID20260604141826.2998337-10-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 1709F1BC4354
	for <pw@patches.jarry.cc>; Thu, 04 Jun 2026 16:18:30 +0200 (CEST)
From: Robin Jarry <robin@jarry.cc>
To: pw@patches.jarry.cc
Subject: [PATCH v3 09/16] forge: sync github pull requests to mailing list
Date: Thu,  4 Jun 2026 16:18:18 +0200
Message-ID: <20260604141826.2998337-10-robin@jarry.cc>
X-Mailer: git-send-email 2.54.0
In-Reply-To: <20260604141826.2998337-1-robin@jarry.cc>
References: <20260604141826.2998337-1-robin@jarry.cc>
MIME-Version: 1.0
List-ID: <pw.jarry.cc>
Content-Transfer-Encoding: 8bit
Series
Implement forge bidirectional sync
github_prhttps://github.com/rjarry/patchwork/pull/4
github_branchpatchwork/implement-forge-bidirectional-sync-c

Commit Message

Robin JarryJun. 4, 2026, 16:18. UTC
[v3,09/16] forge: sync github pull requests to mailing list

Add the GitHub forge backend with webhook signature verification, pull
request event parsing, and forge-to-mailing-list sync.

When a pull request is opened or force-pushed, fetch the PR head into
a local git mirror, generate patches via git format-patch, ingest them
into the database, store series metadata linking back to the PR, and
send the patches to the mailing list.

Respin detection increments the version number and optionally threads
the new series under the original when thread_respins is enabled.

Signed-off-by: Robin Jarry <robin@jarry.cc>
---
 patchwork/forge/github/__init__.py | 83 ++++++++++++++++++++++++++++++
 patchwork/forge/github/to_ml.py    | 54 +++++++++++++++++++
 patchwork/forge/github/webhook.py  | 43 ++++++++++++++++
 3 files changed, 180 insertions(+)
 create mode 100644 patchwork/forge/github/__init__.py
 create mode 100644 patchwork/forge/github/to_ml.py
 create mode 100644 patchwork/forge/github/webhook.py

Patch

mbox series
diff --git a/patchwork/forge/github/__init__.py b/patchwork/forge/github/__init__.py
new file mode 100644
index 000000000000..ded7402cd5f3
--- /dev/null
+++ b/patchwork/forge/github/__init__.py
@@ -0,0 +1,83 @@
+# Patchwork - automated patch tracking system
+# Copyright (C) 2026 Robin Jarry <robin@jarry.cc>
+#
+# SPDX-License-Identifier: GPL-2.0-or-later
+
+"""
+GitHub forge backend.
+"""
+
+import hashlib
+import hmac
+import json
+import logging
+
+from patchwork.forge import ForgeBackend
+from patchwork.forge import register_backend
+from patchwork.forge.github.to_ml import handle_pull_request
+from patchwork.forge.github.webhook import parse_pull_request
+
+logger = logging.getLogger(__name__)
+
+
+class GitHubBackend(ForgeBackend):
+    def verify_webhook_signature(self, body, headers, secret):
+        if not secret:
+            return True
+        signature = headers.get('X-Hub-Signature-256', '')
+        prefix = 'sha256='
+        if not signature.startswith(prefix):
+            return False
+        try:
+            sig = bytes.fromhex(signature[len(prefix) :])
+        except ValueError:
+            return False
+        mac = hmac.new(secret.encode(), body, hashlib.sha256)
+        return hmac.compare_digest(sig, mac.digest())
+
+    def parse_webhook_event(self, body, headers):
+        event_type = headers.get('X-GitHub-Event', '')
+        payload = json.loads(body)
+
+        parsers = {
+            'pull_request': parse_pull_request,
+        }
+
+        parser = parsers.get(event_type)
+        if parser is None:
+            return None
+        return parser(payload)
+
+    def repo_url(self, forge_config):
+        return f'https://github.com/{forge_config.repo}.git'
+
+    def pr_ref(self, forge_config, pr_number):
+        return f'https://github.com/{forge_config.repo}/pull/{pr_number}'
+
+    def pr_refspec(self, pr_number):
+        return f'pull/{pr_number}/head'
+
+    def meta_key_pr(self):
+        return 'github_pr'
+
+    def series_metadata(self, forge_config, event):
+        return {
+            'github_pr': self.pr_ref(forge_config, event.pr_number),
+            'github_branch': event.pr_head_branch,
+        }
+
+    def git_credentials(self, forge_config):
+        auth = self.get_auth(forge_config)
+        token = auth.get('token', '')
+        return f'https://x-access-token:{token}@github.com\n'
+
+    def process_webhook_event(self, forge_config, event):
+        handlers = {
+            'pull_request': handle_pull_request,
+        }
+        handler = handlers.get(event.type)
+        if handler:
+            handler(self, forge_config, event)
+
+
+register_backend('github', GitHubBackend())
diff --git a/patchwork/forge/github/to_ml.py b/patchwork/forge/github/to_ml.py
new file mode 100644
index 000000000000..fac07b180a51
--- /dev/null
+++ b/patchwork/forge/github/to_ml.py
@@ -0,0 +1,54 @@
+# Patchwork - automated patch tracking system
+# Copyright (C) 2026 Robin Jarry <robin@jarry.cc>
+#
+# SPDX-License-Identifier: GPL-2.0-or-later
+
+from patchwork.forge.git import GitMirror
+from patchwork.forge.util import ingest_emails
+from patchwork.forge.util import next_version
+from patchwork.forge.util import sanitize_pr_body
+from patchwork.forge.util import send_emails
+
+
+def handle_pull_request(gh, forge_config, event):
+    if not forge_config.sync_forge_to_ml:
+        return
+
+    mirror = GitMirror(gh, forge_config)
+    mirror.ensure_mirror()
+    mirror.fetch()
+
+    version = 1
+    in_reply_to = ''
+    range_diff_base = ''
+    if event.pr_action == 'synchronize':
+        version, reply_msgid, range_diff_base = next_version(
+            gh, forge_config, event
+        )
+        if forge_config.thread_respins:
+            in_reply_to = reply_msgid
+
+    pr_url = gh.pr_ref(forge_config, event.pr_number)
+    cover_body = sanitize_pr_body(event.pr_body)
+    if cover_body:
+        cover_body += f'\n\nPull request: {pr_url}'
+    else:
+        cover_body = f'Pull request: {pr_url}'
+
+    with mirror.worktree(event.pr_head):
+        mirror.add_commit_notes(
+            event.pr_base,
+            lambda sha: f'{pr_url}/commits/{sha}',
+        )
+        mbox = mirror.format_patches(
+            event.pr_base,
+            event.author,
+            version=version,
+            cover_title=event.pr_title,
+            cover_body=cover_body,
+            range_diff_base=range_diff_base,
+            in_reply_to=in_reply_to,
+        )
+
+    ingest_emails(mbox, gh, forge_config, event)
+    send_emails(mbox, forge_config)
diff --git a/patchwork/forge/github/webhook.py b/patchwork/forge/github/webhook.py
new file mode 100644
index 000000000000..3945a6ff1ef4
--- /dev/null
+++ b/patchwork/forge/github/webhook.py
@@ -0,0 +1,43 @@
+# Patchwork - automated patch tracking system
+# Copyright (C) 2026 Robin Jarry <robin@jarry.cc>
+#
+# SPDX-License-Identifier: GPL-2.0-or-later
+
+from patchwork.forge import ForgeEvent
+from patchwork.forge import ForgeUser
+
+
+def parse_pull_request(payload):
+    action = payload.get('action', '')
+    if action not in ('opened', 'synchronize'):
+        return None
+    pr = payload.get('pull_request', {})
+    pr_body = pr.get('body') or ''
+    return ForgeEvent(
+        type='pull_request',
+        repo_key=get_repo_key(payload),
+        pr_number=pr.get('number', 0),
+        author=parse_user(pr.get('user')),
+        pr_title=pr.get('title', ''),
+        pr_body=pr_body,
+        pr_head=f'pull/{pr.get("number", 0)}/head',
+        pr_base=pr.get('base', {}).get('sha', ''),
+        pr_head_branch=pr.get('head', {}).get('ref', ''),
+        pr_action=action,
+        pr_before=payload.get('before', ''),
+    )
+
+
+def get_repo_key(payload):
+    repo = payload.get('repository', {})
+    return repo.get('full_name', '').lower()
+
+
+def parse_user(user):
+    if not user:
+        return ForgeUser()
+    return ForgeUser(
+        login=user.get('login', ''),
+        name=user.get('name', ''),
+        email=user.get('email', ''),
+    )