diff --git a/patchwork/forge/__init__.py b/patchwork/forge/__init__.py
index d9ac7486d609..cadca530daf8 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__)
@@ -166,6 +170,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 = {}
@@ -178,6 +192,37 @@ 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:
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..e243960d37a7 100644
--- a/patchwork/forge/git.py
+++ b/patchwork/forge/git.py
@@ -272,3 +272,18 @@ 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.
+ """
+ if isinstance(mbox_text, str):
+ mbox_text = mbox_text.encode('utf-8')
+ 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/api.py b/patchwork/forge/github/api.py
index 36bbab596a93..d2332a70f0ae 100644
--- a/patchwork/forge/github/api.py
+++ b/patchwork/forge/github/api.py
@@ -94,3 +94,43 @@ def fetch_check_runs(gh, forge_config, check_suite_id):
)
)
return runs
+
+
+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},
+ )
diff --git a/patchwork/forge/github/from_ml.py b/patchwork/forge/github/from_ml.py
new file mode 100644
index 000000000000..ccb44c8a78d3
--- /dev/null
+++ b/patchwork/forge/github/from_ml.py
@@ -0,0 +1,108 @@
+# Patchwork - automated patch tracking system
+# Copyright (C) 2026 Robin Jarry <robin@jarry.cc>
+#
+# SPDX-License-Identifier: GPL-2.0-or-later
+
+
+import logging
+
+from patchwork.forge.git import GitMirror
+from patchwork.forge.github.api import base_branch
+from patchwork.forge.github.api import create_pr
+from patchwork.forge.github.api import post_comment
+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__)
+
+
+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 17c5644eefd6..00b8cb11d2ad 100644
--- a/patchwork/forge/github/webhook.py
+++ b/patchwork/forge/github/webhook.py
@@ -3,8 +3,11 @@
#
# SPDX-License-Identifier: GPL-2.0-or-later
+from django.conf import settings
+
from patchwork.forge import ForgeEvent
from patchwork.forge import ForgeUser
+from patchwork.forge.util import COMMENT_MARKER
def parse_pull_request(payload):
@@ -13,6 +16,11 @@ def parse_pull_request(payload):
return None
pr = payload.get('pull_request', {})
pr_body = pr.get('body') or ''
+ if COMMENT_MARKER in pr_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),
@@ -22,7 +30,7 @@ def parse_pull_request(payload):
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_head_branch=branch,
pr_action=action,
pr_before=payload.get('before', ''),
)
@@ -51,6 +59,8 @@ def parse_issue_comment(payload):
return None
comment = payload.get('comment', {})
comment_body = comment.get('body') or ''
+ if COMMENT_MARKER in comment_body:
+ return None
return ForgeEvent(
type='issue_comment',
repo_key=get_repo_key(payload),
@@ -66,6 +76,8 @@ def parse_review(payload):
review = payload.get('review', {})
pr = payload.get('pull_request', {})
review_body = review.get('body') or ''
+ if COMMENT_MARKER in review_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'