mbox series
Message IDcover.1780583783.git.pw@patches.jarry.cc
DateThu, 04 Jun 2026 16:36:23 +0200
Headers
show
Message-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
github_prhttps://github.com/rjarry/patchwork/pull/3
github_branchforge

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",
     +        },
     +    },