From patchwork Thu Jun 4 14:18:18 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 1709F1BC4354 for ; Thu, 04 Jun 2026 16:18:30 +0200 (CEST) From: Robin Jarry 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: X-Patchwork-Submitter: Robin Jarry X-Patchwork-Id: 106 Content-Type: text/plain; charset=utf-8 Content-Transfer-Encoding: 8bit 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 --- 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 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 +# +# 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 +# +# 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 +# +# 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', ''), + )