From patchwork Thu Jun 4 12:12:00 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 DADDA1BC434E for ; Thu, 04 Jun 2026 14:12:10 +0200 (CEST) From: Robin Jarry To: pw@patches.jarry.cc Subject: [PATCH 12/15] forge: sync patch series to github pull requests Date: Thu, 4 Jun 2026 14:12:00 +0200 Message-ID: <20260604121203.2955783-13-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: X-Patchwork-Submitter: Robin Jarry X-Patchwork-Id: 64 Content-Type: text/plain; charset=utf-8 Content-Transfer-Encoding: 8bit When a patch series is completed, create a pull request on the linked forge. A signal handler on Event.CATEGORY_SERIES_COMPLETED dispatches to each project's forge backend, which applies the series mbox to a git worktree and pushes a branch. The GitHub backend creates PRs via the REST API using stdlib urllib. The PR body is built from the cover letter or first patch content, with trailers stripped and a loop prevention marker appended. Series that originated from the forge (detected by X-Patchwork-Hint: ignore in patch headers) are skipped to prevent sync loops. Series that already have PR metadata are also skipped. The signal handler is registered by load_backends() so it only fires when ENABLE_FORGE is True. Signed-off-by: Robin Jarry --- patchwork/forge/__init__.py | 45 ++++++++ patchwork/forge/git.py | 13 +++ patchwork/forge/github/__init__.py | 4 + patchwork/forge/github/from_ml.py | 174 +++++++++++++++++++++++++++++ patchwork/forge/github/webhook.py | 16 ++- patchwork/forge/util.py | 72 ++++++++++++ patchwork/settings/base.py | 3 + 7 files changed, 326 insertions(+), 1 deletion(-) create mode 100644 patchwork/forge/github/from_ml.py diff --git a/patchwork/forge/__init__.py b/patchwork/forge/__init__.py index 3c7f5b8cc13b..25c76c26e1c1 100644 --- a/patchwork/forge/__init__.py +++ b/patchwork/forge/__init__.py @@ -27,6 +27,10 @@ from dataclasses import dataclass from dataclasses import field from django.conf import settings +from django.db import transaction + +from patchwork.models import Event +from patchwork.models import ForgeConfig logger = logging.getLogger(__name__) @@ -168,6 +172,16 @@ class ForgeBackend(ABC): """ raise NotImplementedError + @abstractmethod + def handle_series_completed(self, forge_config, series): + """ + Handle a completed patch series from the mailing list. + + Called by the series-completed signal handler. The backend + decides whether to create a pull request from the series. + """ + raise NotImplementedError + _backends = {} @@ -180,7 +194,38 @@ def get_backend(name): return _backends.get(name) +def _on_series_completed(sender, instance, raw, **kwargs): + if raw or instance.category != Event.CATEGORY_SERIES_COMPLETED: + return + + series = instance.series + if not series: + return + + def do_sync(): + for forge_config in ForgeConfig.objects.filter(project=series.project): + backend = get_backend(forge_config.backend) + if not backend: + continue + try: + backend.handle_series_completed(forge_config, series) + except Exception: + logger.exception( + 'forge sync failed for series %d on %s', + series.id, + forge_config.backend, + ) + + transaction.on_commit(do_sync) + + def load_backends(): + from django.db.models.signals import post_save + + from patchwork.models import Event + for module_path in settings.FORGE_BACKENDS: logger.info('loading forge backend: %s', module_path) importlib.import_module(module_path) + + post_save.connect(_on_series_completed, sender=Event) diff --git a/patchwork/forge/git.py b/patchwork/forge/git.py index 311c04288ba9..354bf41efee2 100644 --- a/patchwork/forge/git.py +++ b/patchwork/forge/git.py @@ -272,3 +272,16 @@ class GitMirror: result = self.git(*args, capture_output=True) return bytes_to_mbox(result.stdout) + + def apply_mbox(self, mbox_text): + """ + Apply patches from an mbox string via git am -3. + """ + self.git('am', '-3', input=mbox_text) + + def push(self, branch): + """ + Force-push HEAD to refs/heads/ on the remote. + """ + with self.credentials(): + self.git('push', '-f', self.repo_url, f'HEAD:refs/heads/{branch}') diff --git a/patchwork/forge/github/__init__.py b/patchwork/forge/github/__init__.py index ec9417b7c2e7..b2dbf23a5ee7 100644 --- a/patchwork/forge/github/__init__.py +++ b/patchwork/forge/github/__init__.py @@ -14,6 +14,7 @@ import logging from patchwork.forge import ForgeBackend from patchwork.forge import register_backend +from patchwork.forge.github.from_ml import create_or_update_pr from patchwork.forge.github.to_ml import handle_check_pending from patchwork.forge.github.to_ml import handle_check_result from patchwork.forge.github.to_ml import handle_issue_comment @@ -95,5 +96,8 @@ class GitHubBackend(ForgeBackend): if handler: handler(self, forge_config, event) + def handle_series_completed(self, forge_config, series): + create_or_update_pr(self, forge_config, series) + register_backend('github', GitHubBackend()) diff --git a/patchwork/forge/github/from_ml.py b/patchwork/forge/github/from_ml.py new file mode 100644 index 000000000000..f648711ff0f3 --- /dev/null +++ b/patchwork/forge/github/from_ml.py @@ -0,0 +1,174 @@ +# Patchwork - automated patch tracking system +# Copyright (C) 2026 Robin Jarry +# +# SPDX-License-Identifier: GPL-2.0-or-later + + +import json +import logging +import urllib.error +import urllib.request + +from patchwork.forge.git import GitMirror +from patchwork.forge.util import build_pr_body +from patchwork.forge.util import forge_branch_name +from patchwork.forge.util import series_from_forge +from patchwork.models import SeriesMetadata +from patchwork.views.utils import series_to_mbox + +logger = logging.getLogger(__name__) + + +class GitHubAPIError(Exception): + def __init__(self, method, path, code, body): + super().__init__(f'API {method} {path} failed ({code}): {body}') + + +def gh_api_request(gh, forge_config, method, path, data=None): + """ + Make a GitHub API request. Return the parsed JSON response. + """ + auth = gh.get_auth(forge_config) + token = auth.get('token', '') + url = f'https://api.github.com{path}' + body = json.dumps(data).encode() if data else None + req = urllib.request.Request(url, data=body, method=method) + req.add_header('Authorization', f'token {token}') + req.add_header('Accept', 'application/vnd.github+json') + if body: + req.add_header('Content-Type', 'application/json') + try: + with urllib.request.urlopen(req) as resp: + return json.loads(resp.read()) + except urllib.error.HTTPError as e: + error_body = e.read().decode('utf-8', errors='replace') + raise GitHubAPIError(method, path, e.code, error_body) from e + + +def create_pr(gh, forge_config, title, body, head, base): + """ + Create a pull request on GitHub. Return the PR number. + """ + owner, repo = forge_config.repo.split('/', 1) + result = gh_api_request( + gh, + forge_config, + 'POST', + f'/repos/{owner}/{repo}/pulls', + {'title': title, 'body': body, 'head': head, 'base': base}, + ) + return result['number'] + + +def base_branch(gh, forge_config): + """ + Return the default branch of the GitHub repository. + """ + owner, repo = forge_config.repo.split('/', 1) + result = gh_api_request( + gh, + forge_config, + 'GET', + f'/repos/{owner}/{repo}', + ) + return result['default_branch'] + + +def post_comment(gh, forge_config, pr_number, body): + owner, repo = forge_config.repo.split('/', 1) + gh_api_request( + gh, + forge_config, + 'POST', + f'/repos/{owner}/{repo}/issues/{pr_number}/comments', + {'body': body}, + ) + + +def create_or_update_pr(gh, forge_config, series): + if not forge_config.sync_ml_to_forge: + return + + if SeriesMetadata.objects.filter( + series=series, key=gh.meta_key_pr() + ).exists(): + return + + if series_from_forge(series): + return + + mirror = GitMirror(gh, forge_config) + mirror.ensure_mirror() + mirror.fetch() + + base = base_branch(gh, forge_config) + pr_number, branch = find_previous_pr(gh, forge_config, series) + if not branch: + branch = forge_branch_name(series) + + mbox = series_to_mbox(series) + with mirror.worktree(base): + mirror.apply_mbox(mbox) + mirror.push(branch) + + body = build_pr_body(series) + + if pr_number: + comment = f'> Series v{series.version} submitted.\n\n{body}' + post_comment(gh, forge_config, pr_number, comment) + action = 'updated' + else: + title = series.name or 'Untitled series' + pr_number = create_pr(gh, forge_config, title, body, branch, base) + action = 'created' + + pr_ref = gh.pr_ref(forge_config, pr_number) + store_series_metadata(gh, forge_config, series, pr_ref, branch) + logger.info( + '%s PR #%d for series %d (v%d): %s', + action, + pr_number, + series.id, + series.version, + pr_ref, + ) + + +def find_previous_pr(gh, forge_config, series): + """ + Walk the previous_series chain to find a prior version that + has an existing PR. Return (pr_number, branch) or (None, None). + """ + current = series + while current.previous_series_id: + current = current.previous_series + try: + pr_meta = SeriesMetadata.objects.get( + series=current, key=gh.meta_key_pr() + ) + except SeriesMetadata.DoesNotExist: + continue + try: + branch_meta = SeriesMetadata.objects.get( + series=current, + key=f'{forge_config.backend}_branch', + ) + except SeriesMetadata.DoesNotExist: + continue + pr_url = pr_meta.value + pr_number = int(pr_url.rsplit('/', 1)[-1]) + return pr_number, branch_meta.value + return None, None + + +def store_series_metadata(gh, forge_config, series, pr_ref, branch): + SeriesMetadata.objects.update_or_create( + series=series, + key=gh.meta_key_pr(), + defaults={'value': pr_ref}, + ) + SeriesMetadata.objects.update_or_create( + series=series, + key=f'{forge_config.backend}_branch', + defaults={'value': branch}, + ) diff --git a/patchwork/forge/github/webhook.py b/patchwork/forge/github/webhook.py index 2cd69c278c53..350be00af355 100644 --- a/patchwork/forge/github/webhook.py +++ b/patchwork/forge/github/webhook.py @@ -3,10 +3,13 @@ # # SPDX-License-Identifier: GPL-2.0-or-later +from django.conf import settings + from patchwork.forge import CheckRun from patchwork.forge import ForgeEvent from patchwork.forge import ForgeUser from patchwork.forge import ReviewComment +from patchwork.forge.util import COMMENT_MARKER def parse_pull_request(payload): @@ -14,6 +17,11 @@ def parse_pull_request(payload): if action not in ('opened', 'synchronize'): return None pr = payload.get('pull_request', {}) + if COMMENT_MARKER in pr.get('body', ''): + return None + branch = pr.get('head', {}).get('ref', '') + if branch.startswith(f'{settings.FORGE_BRANCH_PREFIX}/'): + return None return ForgeEvent( type='pull_request', repo_key=get_repo_key(payload), @@ -23,7 +31,7 @@ def parse_pull_request(payload): pr_body=pr.get('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_head_branch=branch, pr_action=action, pr_before=payload.get('before', ''), ) @@ -51,6 +59,8 @@ def parse_issue_comment(payload): if 'pull_request' not in issue: return None comment = payload.get('comment', {}) + if COMMENT_MARKER in comment.get('body', ''): + return None return ForgeEvent( type='issue_comment', repo_key=get_repo_key(payload), @@ -67,6 +77,8 @@ def parse_review(self, payload): pr = payload.get('pull_request', {}) comments = [] for c in review.get('comments', []): + if COMMENT_MARKER in c.get('body', ''): + return None comments.append( ReviewComment( path=c.get('path', ''), @@ -74,6 +86,8 @@ def parse_review(self, payload): body=c.get('body', ''), ) ) + if COMMENT_MARKER in review.get('body', ''): + return None return ForgeEvent( type='review', repo_key=get_repo_key(payload), diff --git a/patchwork/forge/util.py b/patchwork/forge/util.py index 0793d368b7bd..114e99bbd0ae 100644 --- a/patchwork/forge/util.py +++ b/patchwork/forge/util.py @@ -14,6 +14,8 @@ import mailbox import os import re +from django.conf import settings +from django.contrib.sites.models import Site from django.core.mail import get_connection from django.db import transaction @@ -215,3 +217,73 @@ def send_emails(mbox, forge_config): ) for rcpt, err in errs.items(): logger.warning('send patch to %s failed: %s', rcpt, err) + + +TRAILER_RE = re.compile( + r'^(Signed-off-by|Acked-by|Reviewed-by|Tested-by|Reported-by' + r'|Suggested-by|Co-authored-by|Cc):.*$', + re.MULTILINE, +) + +COMMENT_MARKER = '' + + +def forge_branch_name(series): + """ + Generate a branch name for a series on the forge. + + Format: {prefix}/{slug}-{hex_id} + """ + name = series.name or '' + slug = re.sub(r'[^a-z0-9]+', '-', name.lower()).strip('-') + if len(slug) > 50: + slug = slug[:50].rstrip('-') + return f'{settings.FORGE_BRANCH_PREFIX}/{slug}-{series.id:x}' + + +def build_pr_body(series): + """ + Build a pull request body from a series cover letter or first patch. + + Strips git trailers and appends a patchwork link with submitter + attribution and a loop prevention marker. + """ + if series.cover_letter and series.cover_letter.content: + body = series.cover_letter.content.strip() + else: + patches = list(series.patches.order_by('number')[:1]) + if patches and patches[0].content: + body = patches[0].content.strip() + else: + body = '' + + body = TRAILER_RE.sub('', body).strip() + + submitter = series.submitter + name = submitter.name or submitter.email + site = Site.objects.get_current() + project = series.project.linkname + url = f'https://{site.domain}/project/{project}/list/?series={series.id}' + + body += f'\n\n> Submitted by {name} on the mailing list.' + body += f'\n> [View on Patchwork]({url})' + body += f'\n\n{COMMENT_MARKER}' + return body + + +def series_from_forge(series): + """ + Return True if the series originated from a forge sync. + + Checks for X-Patchwork-Hint: ignore in patch headers, which is + added by format_patches() when sending forge PRs to the mailing + list. + """ + for patch in series.patches.all()[:1]: + if not patch.headers: + continue + parsed = email.message_from_string(patch.headers) + hint = parsed.get('X-Patchwork-Hint', '') + if hint.lower() == 'ignore': + return True + return False diff --git a/patchwork/settings/base.py b/patchwork/settings/base.py index bc42b14b2242..539ab83e3c3c 100644 --- a/patchwork/settings/base.py +++ b/patchwork/settings/base.py @@ -306,3 +306,6 @@ FORGE_AUTH = {} # Base directory for git mirror clones (one bare repo per project) FORGE_GIT_MIRROR_PATH = '' + +# Branch prefix for forge-created branches (loop prevention) +FORGE_BRANCH_PREFIX = 'patchwork'