From patchwork Mon Jun 1 07:37:08 2026 Message-ID: <2a3a61a2327a3f79150e60990201474b67b38a8a.1780583783.git.pw@patches.jarry.cc> In-Reply-To: References: From: Robin Jarry Date: Mon, 1 Jun 2026 09:37:08 +0200 Subject: [PATCH patchwork v4 09/15] forge: sync github pull requests to mailing list Sender: pw@patches.jarry.cc Reply-To: pw@patches.jarry.cc List-ID: X-Patchwork-Hint: ignore To: pw@patches.jarry.cc Cc: Robin Jarry , Robin Jarry X-Patchwork-Submitter: Robin Jarry X-Patchwork-Id: 122 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 --- Notes: https://github.com/rjarry/patchwork/pull/3/commits/2a3a61a2327a3f79150e60990201474b67b38a8a 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 0000000..ded7402 --- /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 0000000..fac07b1 --- /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 0000000..3945a6f --- /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', ''), + )