mbox series
Message ID20260604121203.2955783-13-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 DADDA1BC434E
	for <pw@patches.jarry.cc>; Thu, 04 Jun 2026 14:12:10 +0200 (CEST)
From: Robin Jarry <robin@jarry.cc>
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: <pw.jarry.cc>
Content-Transfer-Encoding: 8bit
Series
Implement forge bidirectional sync

Commit Message

Robin JarryJun. 4, 2026, 14:12. UTC
[12/15] forge: sync patch series to github pull requests

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 <robin@jarry.cc>
---
 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

Patch

mbox series
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/<branch> 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 <robin@jarry.cc>
+#
+# 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 = '<!-- patchwork -->'
+
+
+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'