mbox series| Message ID | cover.1780583783.git.pw@patches.jarry.cc |
|---|
| Date | Thu, 04 Jun 2026 16:36:23 +0200 |
|---|
| Headers | showMessage-ID: <cover.1780583783.git.pw@patches.jarry.cc>
From: "rjarry (via Patchwork)" <pw@patches.jarry.cc>
Date: Thu, 4 Jun 2026 14:36:23 +0000
Subject: [PATCH patchwork v4 00/15] Forge ml sync
Sender: pw@patches.jarry.cc
Reply-To: pw@patches.jarry.cc
List-ID: <pw.jarry.cc>
X-Patchwork-Hint: ignore
To: pw@patches.jarry.cc
Cc: Robin Jarry <rjarry@redhat.com>,
Robin Jarry <robin@jarry.cc> |
|---|
| Series | Forge ml sync- [v4,00/15] Forge ml sync
- [v4,01/15] parsemail: wrap parse_mail() in a single transaction
- [v4,02/15] parsemail: fix SeriesReference race with concurrent delivery
- [v4,03/15] series: add respin tracking for patch series versions
- [v4,04/15] series: add metadata key-value store
- [v4,05/15] forge: add per-project configuration model
- [v4,06/15] forge: add backend abstraction layer
- [v4,07/15] forge: add utilities for mailing-list sync
- [v4,08/15] forge: add git mirror utilities
- [v4,09/15] forge: sync github pull requests to mailing list
- [v4,10/15] forge: sync github comments and reviews to mailing list
- [v4,11/15] forge: sync github check results to patchwork
- [v4,12/15] forge: sync patch series to github pull requests
- [v4,13/15] forge: forward mailing list comments to forge pull requests
- [v4,14/15] docs: add forge integration documentation
- [v4,15/15] deploy
|
|---|
| github_pr | https://github.com/rjarry/patchwork/pull/3 |
|---|
| github_branch | forge |
|---|
Message
pw@patches.jarry.ccJun. 4, 2026, 16:36. UTC [v4,00/15] Forge ml sync
Test
Pull request: https://github.com/rjarry/patchwork/pull/3
Robin Jarry (15):
parsemail: wrap parse_mail() in a single transaction
parsemail: fix SeriesReference race with concurrent delivery
series: add respin tracking for patch series versions
series: add metadata key-value store
forge: add per-project configuration model
forge: add backend abstraction layer
forge: add utilities for mailing-list sync
forge: add git mirror utilities
forge: sync github pull requests to mailing list
forge: sync github comments and reviews to mailing list
forge: sync github check results to patchwork
forge: sync patch series to github pull requests
forge: forward mailing list comments to forge pull requests
docs: add forge integration documentation
deploy
Makefile | 147 +++++++
docs/deployment/configuration.rst | 37 ++
docs/usage/forge.rst | 294 +++++++++++++
docs/usage/index.rst | 1 +
nginx.conf | 62 +++
parsemail.sh | 14 +
patchwork.service | 22 +
patchwork/admin.py | 25 ++
patchwork/api/series.py | 20 +-
patchwork/forge/__init__.py | 273 ++++++++++++
patchwork/forge/git.py | 289 +++++++++++++
patchwork/forge/github/__init__.py | 107 +++++
patchwork/forge/github/api.py | 137 ++++++
patchwork/forge/github/from_ml.py | 133 ++++++
patchwork/forge/github/to_ml.py | 224 ++++++++++
patchwork/forge/github/webhook.py | 124 ++++++
patchwork/forge/urls.py | 16 +
patchwork/forge/util.py | 289 +++++++++++++
patchwork/forge/views.py | 82 ++++
patchwork/management/commands/parsemail.py | 4 +-
.../migrations/0049_series_respin_tracking.py | 33 ++
patchwork/migrations/0050_series_metadata.py | 41 ++
patchwork/migrations/0051_forgeconfig.py | 46 ++
patchwork/models.py | 108 +++++
patchwork/parser.py | 174 +++++++-
patchwork/settings/base.py | 32 ++
patchwork/settings/production.py | 67 +++
patchwork/templates/patchwork/submission.html | 25 ++
patchwork/templatetags/utils.py | 9 +
patchwork/tests/api/test_series.py | 2 +-
patchwork/tests/forge/__init__.py | 0
patchwork/tests/forge/test_git.py | 278 ++++++++++++
patchwork/tests/forge/test_util.py | 406 ++++++++++++++++++
patchwork/tests/test_series.py | 39 ++
patchwork/urls.py | 9 +
patchwork/views/cover.py | 3 +
patchwork/views/patch.py | 2 +
postfix/client_access | 2 +
postfix/header_checks | 2 +
postfix/main.cf | 53 +++
postfix/master.cf | 28 ++
postfix/recipient_bcc | 1 +
postfix/transport | 2 +
.../parsemail-race-fix-e5f6g7h8i9j0k1l2.yaml | 5 +
...arsemail-transaction-d4e5f6g7h8i9j0k1.yaml | 6 +
...ries-respin-tracking-c3d4e5f6g7h8i9j0.yaml | 16 +
uwsgi.ini | 10 +
47 files changed, 3677 insertions(+), 22 deletions(-)
create mode 100644 Makefile
create mode 100644 docs/usage/forge.rst
create mode 100644 nginx.conf
create mode 100755 parsemail.sh
create mode 100644 patchwork.service
create mode 100644 patchwork/forge/__init__.py
create mode 100644 patchwork/forge/git.py
create mode 100644 patchwork/forge/github/__init__.py
create mode 100644 patchwork/forge/github/api.py
create mode 100644 patchwork/forge/github/from_ml.py
create mode 100644 patchwork/forge/github/to_ml.py
create mode 100644 patchwork/forge/github/webhook.py
create mode 100644 patchwork/forge/urls.py
create mode 100644 patchwork/forge/util.py
create mode 100644 patchwork/forge/views.py
create mode 100644 patchwork/migrations/0049_series_respin_tracking.py
create mode 100644 patchwork/migrations/0050_series_metadata.py
create mode 100644 patchwork/migrations/0051_forgeconfig.py
create mode 100644 patchwork/settings/production.py
create mode 100644 patchwork/tests/forge/__init__.py
create mode 100644 patchwork/tests/forge/test_git.py
create mode 100644 patchwork/tests/forge/test_util.py
create mode 100644 postfix/client_access
create mode 100644 postfix/header_checks
create mode 100644 postfix/main.cf
create mode 100644 postfix/master.cf
create mode 100644 postfix/recipient_bcc
create mode 100644 postfix/transport
create mode 100644 releasenotes/notes/parsemail-race-fix-e5f6g7h8i9j0k1l2.yaml
create mode 100644 releasenotes/notes/parsemail-transaction-d4e5f6g7h8i9j0k1.yaml
create mode 100644 releasenotes/notes/series-respin-tracking-c3d4e5f6g7h8i9j0.yaml
create mode 100644 uwsgi.ini
Range-diff against v3:
1: c024c43 = 1: c024c43 parsemail: wrap parse_mail() in a single transaction
2: 04fd8b6 = 2: 04fd8b6 parsemail: fix SeriesReference race with concurrent delivery
3: 8ebf1ed = 3: 8ebf1ed series: add respin tracking for patch series versions
4: e1be2e2 = 4: e1be2e2 series: add metadata key-value store
5: d8a204e ! 5: f9b3a85 forge: add per-project configuration model
@@ Commit message
Signed-off-by: Robin Jarry <robin@jarry.cc>
+
+ ## Notes ##
+ https://github.com/rjarry/patchwork/pull/3/commits/f9b3a85367e3e284323e2754a3fa605bbf36f49b
+
## patchwork/admin.py ##
@@ patchwork/admin.py: from patchwork.models import Check
from patchwork.models import Cover
@@ patchwork/admin.py: from patchwork.models import Check
from patchwork.models import Patch
from patchwork.models import PatchComment
from patchwork.models import PatchRelation
-@@ patchwork/admin.py: class DelegationRuleInline(admin.TabularInline):
+@@ patchwork/admin.py: admin.site.register(User, UserAdmin)
+ class DelegationRuleInline(admin.TabularInline):
+ model = DelegationRule
fields = ('path', 'user', 'priority')
-
-
++ extra = 0
++
++
+class ForgeConfigInline(admin.TabularInline):
+ model = ForgeConfig
+ fields = (
@@ patchwork/admin.py: class DelegationRuleInline(admin.TabularInline):
+ 'thread_respins',
+ )
+ extra = 0
-+
-+
+
+
class ProjectAdmin(admin.ModelAdmin):
list_display = ('name', 'linkname', 'listid', 'listemail')
inlines = [
@@ patchwork/models.py: class Project(models.Model):
+ from_email = models.CharField(
+ max_length=255,
+ default='',
++ blank=True,
+ help_text='Sender address for forge-originated emails.',
+ )
+ sync_ml_to_forge = models.BooleanField(
6: 42302be ! 6: bf42660 forge: add backend abstraction layer
@@ Commit message
Signed-off-by: Robin Jarry <robin@jarry.cc>
+
+ ## Notes ##
+ https://github.com/rjarry/patchwork/pull/3/commits/bf4266003d78493c4b7eec5f5089ffa8853a9455
+
## patchwork/forge/__init__.py (new) ##
@@
+# Patchwork - automated patch tracking system
@@ patchwork/forge/__init__.py (new)
+ body: str = ''
+
+ # review fields
-+ path: str = ''
-+ diff_hunk: str = ''
++ review_id: int = 0
+ review_state: str = ''
-+ review_comments: list = field(default_factory=list)
+
+ # check fields
++ check_suite_id: int = 0
+ check_name: str = ''
+ check_status: str = ''
+ check_url: str = ''
+ check_description: str = ''
-+ check_runs: list = field(default_factory=list)
+
+ # pull request fields
+ pr_title: str = ''
@@ patchwork/forge/__init__.py (new)
+
+def load_backends():
+ for module_path in settings.FORGE_BACKENDS:
-+ logger.info('loading forge backend: %s', module_path)
+ importlib.import_module(module_path)
## patchwork/forge/urls.py (new) ##
7: 26e6e0a ! 7: a722470 forge: add utilities for mailing-list sync
@@ Commit message
Signed-off-by: Robin Jarry <robin@jarry.cc>
+
+ ## Notes ##
+ https://github.com/rjarry/patchwork/pull/3/commits/a722470ff0c2dc06de67310d9b31af887dbfdfe8
+
## patchwork/forge/util.py (new) ##
@@
+# Patchwork - automated patch tracking system
8: 2671551 ! 8: d65be41 forge: add git mirror utilities
@@ Commit message
Signed-off-by: Robin Jarry <robin@jarry.cc>
+
+ ## Notes ##
+ https://github.com/rjarry/patchwork/pull/3/commits/d65be41e49de036a67b7d11fbed64b406b1f2db1
+
## patchwork/forge/__init__.py ##
@@ patchwork/forge/__init__.py: class ForgeBackend(ABC):
auth.update(repo_overrides)
@@ patchwork/forge/git.py (new)
+ if os.path.isdir(self.repo_dir()):
+ cmd += ['-C', self.repo_dir()]
+ if self.__credentials:
-+ cmd += ['-c', f'credential.helper=store --file={self.__credentials}']
++ cmd += [
++ '-c',
++ f'credential.helper=store --file={self.__credentials}',
++ ]
+ cmd.extend(args)
+ logger.debug('+ %s', ' '.join(cmd))
+ return subprocess.run(cmd, env=env, text=False, check=True, **kwargs)
@@ patchwork/forge/git.py (new)
+ for addr, name in recipients.items():
+ yield email.utils.formataddr((name, addr))
+
++ def add_commit_notes(self, base_ref, note_fn):
++ """
++ Add a git note to each commit in base_ref..HEAD.
++
++ note_fn(sha) is called for each commit and should return the
++ note text, or None to skip.
++ """
++ out = self.git_output('rev-list', f'{base_ref}..HEAD')
++ for sha in out.splitlines():
++ sha = sha.strip()
++ if not sha:
++ continue
++ note = note_fn(sha)
++ if note:
++ self.git('notes', 'add', '-f', '-m', note, sha)
++
+ def format_patches(
+ self,
+ base_ref,
@@ patchwork/forge/git.py (new)
+ f'user.email={addr}',
+ 'format-patch',
+ '--stdout',
++ '--notes',
+ '--thread=shallow',
+ f'--subject-prefix=PATCH {self.forge_config.project.linkname}',
+ f'--to={self.forge_config.project.listemail}',
9: 148c994 ! 9: 2a3a61a forge: sync github pull requests to mailing list
@@ Commit message
Signed-off-by: Robin Jarry <robin@jarry.cc>
+
+ ## Notes ##
+ https://github.com/rjarry/patchwork/pull/3/commits/2a3a61a2327a3f79150e60990201474b67b38a8a
+
## patchwork/forge/github/__init__.py (new) ##
@@
+# Patchwork - automated patch tracking system
@@ patchwork/forge/github/to_ml.py (new)
+ 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=sanitize_pr_body(event.pr_body),
++ cover_body=cover_body,
+ range_diff_base=range_diff_base,
+ in_reply_to=in_reply_to,
+ )
@@ patchwork/forge/github/webhook.py (new)
+ 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.get('body', ''),
++ 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', ''),
10: 404cf67 ! 10: bf56cee forge: sync github comments and reviews to mailing list
@@ Commit message
Signed-off-by: Robin Jarry <robin@jarry.cc>
+
+ ## Notes ##
+ https://github.com/rjarry/patchwork/pull/3/commits/bf56ceed17b643838ef23302f5602f70bd7e1035
+
## patchwork/forge/github/__init__.py ##
@@ patchwork/forge/github/__init__.py: import logging
@@ patchwork/forge/github/__init__.py: class GitHubBackend(ForgeBackend):
parsers = {
+ 'issue_comment': parse_issue_comment,
-+ 'pull_request_review': parse_review,
'pull_request': parse_pull_request,
++ 'pull_request_review': parse_review,
}
+ parser = parsers.get(event_type)
@@ patchwork/forge/github/__init__.py: class GitHubBackend(ForgeBackend):
+
def process_webhook_event(self, forge_config, event):
handlers = {
- 'pull_request': handle_pull_request,
+ 'issue_comment': handle_issue_comment,
+ 'pull_request': handle_pull_request,
+ 'review': handle_review,
}
handler = handlers.get(event.type)
if handler:
+ ## patchwork/forge/github/api.py (new) ##
+@@
++# 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 import ReviewComment
++
++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 fetch_review_comments(gh, forge_config, pr_number, review_id):
++ """
++ Fetch inline comments for a review via the GitHub API.
++ """
++ owner, repo = forge_config.repo.split('/', 1)
++ results = gh_api_request(
++ gh,
++ forge_config,
++ 'GET',
++ f'/repos/{owner}/{repo}/pulls/{pr_number}/reviews/{review_id}/comments',
++ )
++ comments = []
++ for r in results:
++ body = r.get('body') or ''
++ if body == '':
++ continue
++ comments.append(
++ ReviewComment(
++ body=body,
++ path=r.get('path', '/dev/null'),
++ diff_hunk=r.get('diff_hunk', ''),
++ )
++ )
++ return comments
+
## patchwork/forge/github/to_ml.py ##
@@
#
@@ patchwork/forge/github/to_ml.py
+import logging
+
from patchwork.forge.git import GitMirror
++from patchwork.forge.github.api import fetch_review_comments
+from patchwork.forge.util import bytes_to_mbox
+from patchwork.forge.util import find_series_by_pr
from patchwork.forge.util import ingest_emails
@@ patchwork/forge/github/to_ml.py: def handle_pull_request(gh, forge_config, event
+ if not series:
+ logger.warning('no series found for PR #%d', event.pr_number)
+ return
++
+ subject = f'Re: {series.name} (review: {event.review_state})'
+
+ body = ''
@@ patchwork/forge/github/to_ml.py: def handle_pull_request(gh, forge_config, event
+ body = f'Review: {event.review_state}\n\n'
+ if event.body:
+ body += f'{event.body}\n\n'
-+ for c in event.review_comments:
++
++ comments = fetch_review_comments(
++ gh, forge_config, event.pr_number, event.review_id
++ )
++ for c in comments:
+ body += f'--- {c.path}\n'
+ if c.diff_hunk:
+ for line in c.diff_hunk.split('\n'):
@@ patchwork/forge/github/to_ml.py: def handle_pull_request(gh, forge_config, event
+ body += '\n'
+ body += f'{c.body}\n\n'
+
++ if not event.body and not comments:
++ return
++
+ reply(gh, forge_config, event, series, subject, body.rstrip())
+
+
@@ patchwork/forge/github/to_ml.py: def handle_pull_request(gh, forge_config, event
+ logger.warning('no message-id for series %d', series.id)
+ return
+
-+ _, addr = email.utils.parseaddr(forge_config.sender_addr)
++ name, addr = email.utils.parseaddr(forge_config.sender_email)
+ uid, domain = addr.rsplit('@', 1)
+
+ msg = email.mime.text.MIMEText(body)
+ msg['From'] = email.utils.formataddr(
+ sender_identity(event.author, forge_config)
+ )
-+ msg['Sender'] = email.utils.formataddr(
-+ email.utils.parseaddr(forge_config.sender_addr)
-+ )
++ msg['Sender'] = email.utils.formataddr( (name, addr))
+ msg['To'] = forge_config.project.listemail
+ msg['Subject'] = email.header.Header(subject, 'utf-8')
+ msg['Date'] = email.utils.formatdate(localtime=True)
@@ patchwork/forge/github/to_ml.py: def handle_pull_request(gh, forge_config, event
+ send_emails(mbox, forge_config)
## patchwork/forge/github/webhook.py ##
-@@
-
- from patchwork.forge import ForgeEvent
- from patchwork.forge import ForgeUser
-+from patchwork.forge import ReviewComment
-
-
- def parse_pull_request(payload):
@@ patchwork/forge/github/webhook.py: def parse_user(user):
name=user.get('name', ''),
email=user.get('email', ''),
@@ patchwork/forge/github/webhook.py: def parse_user(user):
+ if 'pull_request' not in issue:
+ return None
+ comment = payload.get('comment', {})
++ comment_body = comment.get('body') or ''
+ return ForgeEvent(
+ type='issue_comment',
+ repo_key=get_repo_key(payload),
+ pr_number=issue.get('number', 0),
+ author=parse_user(comment.get('user')),
-+ body=comment.get('body', ''),
++ body=comment_body,
+ )
+
+
-+def parse_review(self, payload):
++def parse_review(payload):
+ if payload.get('action') != 'submitted':
+ return None
+ review = payload.get('review', {})
+ pr = payload.get('pull_request', {})
-+ comments = []
-+ for c in review.get('comments', []):
-+ comments.append(
-+ ReviewComment(
-+ path=c.get('path', ''),
-+ diff_hunk=c.get('diff_hunk', ''),
-+ body=c.get('body', ''),
-+ )
-+ )
++ review_body = review.get('body') or ''
+ return ForgeEvent(
+ type='review',
+ repo_key=get_repo_key(payload),
+ pr_number=pr.get('number', 0),
++ review_id=review.get('id', 0),
+ author=parse_user(review.get('user')),
-+ body=review.get('body', ''),
++ body=review_body,
+ review_state=review.get('state', ''),
-+ review_comments=comments,
+ )
11: c7b7d4f ! 11: 4e68059 forge: sync github check results to patchwork
@@ Commit message
Signed-off-by: Robin Jarry <robin@jarry.cc>
+
+ ## Notes ##
+ https://github.com/rjarry/patchwork/pull/3/commits/4e680599885aa4ff251d5f20d01c916dee824334
+
## patchwork/forge/github/__init__.py ##
@@ patchwork/forge/github/__init__.py: import logging
@@ patchwork/forge/github/__init__.py: class GitHubBackend(ForgeBackend):
+ 'check_run': parse_check_run,
+ 'check_suite': parse_check_suite,
'issue_comment': parse_issue_comment,
-- 'pull_request_review': parse_review,
'pull_request': parse_pull_request,
-+ 'pull_request_review': parse_review,
- }
-
- parser = parsers.get(event_type)
+ 'pull_request_review': parse_review,
@@ patchwork/forge/github/__init__.py: class GitHubBackend(ForgeBackend):
def process_webhook_event(self, forge_config, event):
handlers = {
-- 'pull_request': handle_pull_request,
- 'issue_comment': handle_issue_comment,
-+ 'pull_request': handle_pull_request,
- 'review': handle_review,
+ 'check_pending': handle_check_pending,
+ 'check_result': handle_check_result,
- }
- handler = handlers.get(event.type)
- if handler:
+ 'issue_comment': handle_issue_comment,
+ 'pull_request': handle_pull_request,
+ 'review': handle_review,
+
+ ## patchwork/forge/github/api.py ##
+@@ patchwork/forge/github/api.py: import logging
+ import urllib.error
+ import urllib.request
+
++from patchwork.forge import CheckRun
+ from patchwork.forge import ReviewComment
+
+ logger = logging.getLogger(__name__)
+@@ patchwork/forge/github/api.py: def fetch_review_comments(gh, forge_config, pr_number, review_id):
+ )
+ )
+ return comments
++
++
++def fetch_check_runs(gh, forge_config, check_suite_id):
++ """
++ Fetch check runs for a check suite via the GitHub API.
++ """
++ owner, repo = forge_config.repo.split('/', 1)
++ result = gh_api_request(
++ gh,
++ forge_config,
++ 'GET',
++ f'/repos/{owner}/{repo}/check-suites/{check_suite_id}/check-runs',
++ )
++ runs = []
++ for run in result.get('check_runs', []):
++ status = run.get('conclusion') or run.get('status', '')
++ desc = ''
++ output = run.get('output')
++ if output:
++ desc = output.get('summary') or ''
++ runs.append(
++ CheckRun(
++ name=run.get('name', ''),
++ status=status,
++ url=run.get('html_url', ''),
++ description=desc,
++ )
++ )
++ return runs
## patchwork/forge/github/to_ml.py ##
@@
@@ patchwork/forge/github/to_ml.py
+from django.utils.text import slugify
+
from patchwork.forge.git import GitMirror
++from patchwork.forge.github.api import fetch_check_runs
+ from patchwork.forge.github.api import fetch_review_comments
from patchwork.forge.util import bytes_to_mbox
from patchwork.forge.util import find_series_by_pr
@@ patchwork/forge/github/to_ml.py: from patchwork.forge.util import reply_to_msgid
@@ patchwork/forge/github/to_ml.py: def handle_review(gh, forge_config, event):
+ if not series:
+ return
+
-+ for run in event.check_runs:
++ check_runs = fetch_check_runs(gh, forge_config, event)
++ for run in check_runs:
+ create_checks(
-+ gh,
++ series,
+ forge_config,
+ event,
+ run.name,
@@ patchwork/forge/github/to_ml.py: def handle_review(gh, forge_config, event):
+ )
+
+ lines = []
-+ for run in event.check_runs:
++ for run in check_runs:
+ line = f'{run.name} {run.status}'
+ if run.url:
+ line += f': {run.url}'
@@ patchwork/forge/github/to_ml.py: def reply(gh, forge_config, event, series, subj
+ )
## patchwork/forge/github/webhook.py ##
-@@
- #
- # SPDX-License-Identifier: GPL-2.0-or-later
-
-+from patchwork.forge import CheckRun
- from patchwork.forge import ForgeEvent
- from patchwork.forge import ForgeUser
- from patchwork.forge import ReviewComment
-@@ patchwork/forge/github/webhook.py: def parse_review(self, payload):
+@@ patchwork/forge/github/webhook.py: def parse_review(payload):
+ body=review_body,
review_state=review.get('state', ''),
- review_comments=comments,
)
+
+
@@ patchwork/forge/github/webhook.py: def parse_review(self, payload):
+ prs = suite.get('pull_requests', [])
+ if not prs:
+ return None
-+ check_runs = []
-+ for run in suite.get('check_runs', []):
-+ status = run.get('conclusion') or run.get('status', '')
-+ desc = ''
-+ output = run.get('output')
-+ if output:
-+ desc = output.get('summary', '')
-+ check_runs.append(
-+ CheckRun(
-+ name=run.get('name', ''),
-+ status=status,
-+ url=run.get('html_url', ''),
-+ description=desc,
-+ )
-+ )
+ app = suite.get('app', {})
+ return ForgeEvent(
+ type='check_result',
+ repo_key=get_repo_key(payload),
+ pr_number=prs[0].get('number', 0),
++ check_suite_id=suite.get('id', 0),
+ check_name=app.get('name', ''),
+ check_status=suite.get('conclusion', ''),
-+ check_runs=check_runs,
+ )
12: d79e9a5 ! 12: 2ed3ab5 forge: sync patch series to github pull requests
@@ Commit message
Signed-off-by: Robin Jarry <robin@jarry.cc>
+
+ ## Notes ##
+ https://github.com/rjarry/patchwork/pull/3/commits/2ed3ab5c820f49e156aeed87c2d030112d94f6c7
+
## patchwork/forge/__init__.py ##
@@ patchwork/forge/__init__.py: from dataclasses import dataclass
from dataclasses import field
@@ patchwork/forge/__init__.py: from dataclasses import dataclass
+
+from patchwork.models import Event
+from patchwork.models import ForgeConfig
-+
logger = logging.getLogger(__name__)
@@ patchwork/forge/__init__.py: def get_backend(name):
+ 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)
@@ patchwork/forge/git.py: class GitMirror:
+ """
+ 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):
@@ patchwork/forge/github/__init__.py: class GitHubBackend(ForgeBackend):
register_backend('github', GitHubBackend())
- ## patchwork/forge/github/from_ml.py (new) ##
-@@
-+# 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
+ ## patchwork/forge/github/api.py ##
+@@ patchwork/forge/github/api.py: def fetch_check_runs(gh, forge_config, check_suite_id):
+ )
+ )
+ return runs
+
+
+def create_pr(gh, forge_config, title, body, head, base):
@@ patchwork/forge/github/from_ml.py (new)
+ f'/repos/{owner}/{repo}/issues/{pr_number}/comments',
+ {'body': body},
+ )
+
+ ## patchwork/forge/github/from_ml.py (new) ##
+@@
++# 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):
@@ patchwork/forge/github/from_ml.py (new)
+ mirror.ensure_mirror()
+ mirror.fetch()
+
-+ base = gh.base_branch(forge_config)
++ base = base_branch(gh, forge_config)
+ pr_number, branch = find_previous_pr(gh, forge_config, series)
+ if not branch:
+ branch = forge_branch_name(series)
@@ patchwork/forge/github/from_ml.py (new)
+ body = build_pr_body(series)
+
+ if pr_number:
-+ comment = f'Series v{series.version} submitted.\n\n{body}'
++ comment = f'> Series v{series.version} submitted.\n\n{body}'
+ post_comment(gh, forge_config, pr_number, comment)
+ action = 'updated'
+ else:
@@ patchwork/forge/github/webhook.py
+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):
@@ patchwork/forge/github/webhook.py: 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', ''):
+ 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}/'):
@@ patchwork/forge/github/webhook.py: def parse_pull_request(payload):
type='pull_request',
repo_key=get_repo_key(payload),
@@ patchwork/forge/github/webhook.py: def parse_pull_request(payload):
- pr_body=pr.get('body', ''),
+ 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', ''),
@@ patchwork/forge/github/webhook.py: def parse_pull_request(payload):
pr_before=payload.get('before', ''),
)
@@ patchwork/forge/github/webhook.py: def parse_issue_comment(payload):
- if 'pull_request' not in issue:
return None
comment = payload.get('comment', {})
-+ if COMMENT_MARKER in comment.get('body', ''):
+ 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),
-@@ patchwork/forge/github/webhook.py: def parse_review(self, payload):
+@@ patchwork/forge/github/webhook.py: def parse_review(payload):
+ review = payload.get('review', {})
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', ''),
-@@ patchwork/forge/github/webhook.py: def parse_review(self, payload):
- body=c.get('body', ''),
- )
- )
-+ if COMMENT_MARKER in review.get('body', ''):
+ review_body = review.get('body') or ''
++ if COMMENT_MARKER in review_body:
+ return None
return ForgeEvent(
type='review',
@@ patchwork/forge/util.py: import mailbox
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
@@ patchwork/forge/util.py: def send_emails(mbox, forge_config):
+ """
+ Generate a branch name for a series on the forge.
+
-+ Format: {prefix}/{hex_id}/{slug}
++ 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}/{series.id:x}/{slug}'
++ 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 and loop
-+ prevention marker.
++ 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()
@@ patchwork/forge/util.py: def send_emails(mbox, forge_config):
+ 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
+
13: d671236 ! 13: ff3dd0b forge: forward mailing list comments to forge pull requests
@@ Commit message
Signed-off-by: Robin Jarry <robin@jarry.cc>
+
+ ## Notes ##
+ https://github.com/rjarry/patchwork/pull/3/commits/ff3dd0bb37af5dd582b51bbc67694e1d0b552c32
+
## patchwork/forge/__init__.py ##
+@@ patchwork/forge/__init__.py: from dataclasses import field
+
+ from django.conf import settings
+ from django.db import transaction
++from django.db.models.signals import post_save
+
+ from patchwork.models import Event
+ from patchwork.models import ForgeConfig
@@ patchwork/forge/__init__.py: class ForgeBackend(ABC):
"""
raise NotImplementedError
@@ patchwork/forge/__init__.py: def _on_series_completed(sender, instance, raw, **k
transaction.on_commit(do_sync)
+-def load_backends():
+- from django.db.models.signals import post_save
+def _on_comment_created(sender, instance, raw, **kwargs):
+ if raw:
+ return
@@ patchwork/forge/__init__.py: def _on_series_completed(sender, instance, raw, **k
+ )
+
+ transaction.on_commit(do_sync)
-+
-+
- def load_backends():
- from django.db.models.signals import post_save
-@@ patchwork/forge/__init__.py: def load_backends():
+- from patchwork.models import Event
+
++def load_backends():
+ for module_path in settings.FORGE_BACKENDS:
importlib.import_module(module_path)
post_save.connect(_on_series_completed, sender=Event)
@@ patchwork/forge/github/__init__.py: class GitHubBackend(ForgeBackend):
register_backend('github', GitHubBackend())
+ ## patchwork/forge/github/api.py ##
+@@ patchwork/forge/github/api.py: import urllib.request
+
+ from patchwork.forge import CheckRun
+ from patchwork.forge import ReviewComment
++from patchwork.forge.util import COMMENT_MARKER
+
+ logger = logging.getLogger(__name__)
+
+@@ patchwork/forge/github/api.py: def fetch_review_comments(gh, forge_config, pr_number, review_id):
+ comments = []
+ for r in results:
+ body = r.get('body') or ''
+- if body == '':
++ if body == '' or COMMENT_MARKER in body:
+ continue
+ comments.append(
+ ReviewComment(
+
## patchwork/forge/github/from_ml.py ##
@@
# SPDX-License-Identifier: GPL-2.0-or-later
+import email
- import json
import logging
- import urllib.error
- import urllib.request
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 COMMENT_MARKER
from patchwork.forge.util import build_pr_body
from patchwork.forge.util import forge_branch_name
from patchwork.forge.util import series_from_forge
-@@ patchwork/forge/github/from_ml.py: def create_pr(gh, forge_config, title, body, head, base):
- return result['number']
-
-
-+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 base_branch(gh, forge_config):
- """
- Return the default branch of the GitHub repository.
-@@ patchwork/forge/github/from_ml.py: def base_branch(gh, forge_config):
- 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
@@ patchwork/forge/github/from_ml.py: def store_series_metadata(gh, forge_config, series, pr_ref, branch):
key=f'{forge_config.backend}_branch',
defaults={'value': branch},
14: c1a789b ! 14: 4ceb863 docs: add forge integration documentation
@@ Commit message
Signed-off-by: Robin Jarry <robin@jarry.cc>
+
+ ## Notes ##
+ https://github.com/rjarry/patchwork/pull/3/commits/4ceb8635d908734a0b78657fe99f710d898b39b3
+
## docs/deployment/configuration.rst ##
@@ docs/deployment/configuration.rst: This is customizable on a per-user basis from the user configuration page.
This option was previously named ``DEFAULT_PATCHES_PER_PAGE``. It was
15: fc2614b ! 15: 6cbd321 deploy
@@ Commit message
Signed-off-by: Robin Jarry <robin@jarry.cc>
+
+ ## Notes ##
+ https://github.com/rjarry/patchwork/pull/3/commits/6cbd321133bc5cbd130ac582fdf684d3b87ea816
+
## Makefile (new) ##
@@
+# SPDX-License-Identifier: Apache-2.0
@@ patchwork/settings/production.py (new)
+Patchwork production settings for forge sync deployment.
+"""
+
-+import logging.handlers
+import os
+
+from .base import * # noqa
@@ patchwork/settings/production.py (new)
+ "disable_existing_loggers": False,
+ "formatters": {
+ "syslog": {
-+ "format": "%(message)s",
++ "format": "%(name)s: %(message)s",
+ },
+ },
+ "handlers": {
+ "syslog": {
+ "class": "logging.handlers.SysLogHandler",
+ "address": "/dev/log",
-+ "facility": logging.handlers.SysLogHandler.LOG_LOCAL0,
++ "facility": "daemon",
+ "formatter": "syslog",
+ },
+ },