mbox series
Message ID20260604141826.2998337-11-robin@jarry.cc
StateNew
Delegate
ArchivedNo
Headers
show
Return-Path: <robin@jarry.cc>
Received: from ringo (2a01cb00021ec0002e23edbec21b0e73.ipv6.abo.wanadoo.fr
 [IPv6:2a01:cb00:21e:c000:2e23:edbe:c21b:e73])
	by patches.jarry.cc (Postfix) with ESMTP id 2B38E1BC4355
	for <pw@patches.jarry.cc>; Thu, 04 Jun 2026 16:18:30 +0200 (CEST)
From: Robin Jarry <robin@jarry.cc>
To: pw@patches.jarry.cc
Subject: [PATCH v3 10/16] forge: sync github comments and reviews to mailing
 list
Date: Thu,  4 Jun 2026 16:18:19 +0200
Message-ID: <20260604141826.2998337-11-robin@jarry.cc>
X-Mailer: git-send-email 2.54.0
In-Reply-To: <20260604141826.2998337-1-robin@jarry.cc>
References: <20260604141826.2998337-1-robin@jarry.cc>
MIME-Version: 1.0
List-ID: <pw.jarry.cc>
Content-Transfer-Encoding: 8bit
Series
Implement forge bidirectional sync
github_prhttps://github.com/rjarry/patchwork/pull/4
github_branchpatchwork/implement-forge-bidirectional-sync-c

Commit Message

Robin JarryJun. 4, 2026, 16:18. UTC
[v3,10/16] forge: sync github comments and reviews to mailing list

Parse issue_comment and pull_request_review webhook events and forward
them to the mailing list as email replies to the corresponding patch
series.

Replies are built as proper RFC 2822 messages with From set to the forge
user, Sender to the patchwork bot, and In-Reply-To/References pointing
to the series cover letter or first patch. A X-Patchwork-Hint: ignore
header prevents parsemail from re-ingesting the emails on their way
back.

Each reply is ingested directly into the database as a comment before
being sent to the list, following the same ingest-then-send approach
used for patches.

Reviews include the review state, body text, and inline comments with
file paths and quoted diff context.

Signed-off-by: Robin Jarry <robin@jarry.cc>
---
 patchwork/forge/github/__init__.py |  8 +++
 patchwork/forge/github/api.py      | 66 ++++++++++++++++++++++
 patchwork/forge/github/to_ml.py    | 91 ++++++++++++++++++++++++++++++
 patchwork/forge/github/webhook.py  | 34 +++++++++++
 4 files changed, 199 insertions(+)
 create mode 100644 patchwork/forge/github/api.py

Patch

mbox series
diff --git a/patchwork/forge/github/__init__.py b/patchwork/forge/github/__init__.py
index ded7402cd5f3..4441c2b11ab0 100644
--- a/patchwork/forge/github/__init__.py
+++ b/patchwork/forge/github/__init__.py
@@ -14,8 +14,12 @@ import logging
 
 from patchwork.forge import ForgeBackend
 from patchwork.forge import register_backend
+from patchwork.forge.github.to_ml import handle_issue_comment
 from patchwork.forge.github.to_ml import handle_pull_request
+from patchwork.forge.github.to_ml import handle_review
+from patchwork.forge.github.webhook import parse_issue_comment
 from patchwork.forge.github.webhook import parse_pull_request
+from patchwork.forge.github.webhook import parse_review
 
 logger = logging.getLogger(__name__)
 
@@ -40,7 +44,9 @@ class GitHubBackend(ForgeBackend):
         payload = json.loads(body)
 
         parsers = {
+            'issue_comment': parse_issue_comment,
             'pull_request': parse_pull_request,
+            'pull_request_review': parse_review,
         }
 
         parser = parsers.get(event_type)
@@ -73,7 +79,9 @@ class GitHubBackend(ForgeBackend):
 
     def process_webhook_event(self, forge_config, event):
         handlers = {
+            'issue_comment': handle_issue_comment,
             'pull_request': handle_pull_request,
+            'review': handle_review,
         }
         handler = handlers.get(event.type)
         if handler:
diff --git a/patchwork/forge/github/api.py b/patchwork/forge/github/api.py
new file mode 100644
index 000000000000..6e99592a9eac
--- /dev/null
+++ b/patchwork/forge/github/api.py
@@ -0,0 +1,66 @@
+# 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
diff --git a/patchwork/forge/github/to_ml.py b/patchwork/forge/github/to_ml.py
index fac07b180a51..55ee03a9ebe0 100644
--- a/patchwork/forge/github/to_ml.py
+++ b/patchwork/forge/github/to_ml.py
@@ -3,11 +3,26 @@
 #
 # SPDX-License-Identifier: GPL-2.0-or-later
 
+import email
+import logging
+
+from django.contrib.auth import get_user_model
+from django.db import transaction
+from django.utils.text import slugify
+
+from patchwork.forge import ReviewComment
 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
 from patchwork.forge.util import next_version
+from patchwork.forge.util import reply_to_msgid
 from patchwork.forge.util import sanitize_pr_body
 from patchwork.forge.util import send_emails
+from patchwork.forge.util import sender_identity
+
+logger = logging.getLogger(__name__)
 
 
 def handle_pull_request(gh, forge_config, event):
@@ -52,3 +67,79 @@ def handle_pull_request(gh, forge_config, event):
 
     ingest_emails(mbox, gh, forge_config, event)
     send_emails(mbox, forge_config)
+
+
+def handle_issue_comment(gh, forge_config, event):
+    series = find_series_by_pr(gh, forge_config, event.pr_number).last()
+    if not series:
+        logger.warning('no series found for PR #%d', event.pr_number)
+        return
+
+    subject = f'Re: {series.name} (comment)'
+    reply(gh, forge_config, event, series, subject, event.body)
+
+
+def handle_review(gh, forge_config, event):
+    series = find_series_by_pr(gh, forge_config, event.pr_number).last()
+    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 = ''
+    if event.review_state:
+        body = f'Review: {event.review_state}\n\n'
+    if event.body:
+        body += f'{event.body}\n\n'
+
+    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'):
+                body += f'> {line}\n'
+            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())
+
+
+def reply(gh, forge_config, event, series, subject, body):
+    """
+    Build a reply email as an mbox, ingest it into the database and
+    send it to the mailing list.
+    """
+    in_reply_to = reply_to_msgid(series)
+    if not in_reply_to:
+        logger.warning('no message-id for series %d', series.id)
+        return
+
+    _, addr = email.utils.parseaddr(forge_config.sender_addr)
+    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['To'] = forge_config.project.listemail
+    msg['Subject'] = email.header.Header(subject, 'utf-8')
+    msg['Date'] = email.utils.formatdate(localtime=True)
+    msg['Message-ID'] = email.utils.make_msgid(uid, domain)
+    msg['In-Reply-To'] = in_reply_to
+    msg['References'] = in_reply_to
+    msg['Reply-To'] = forge_config.project.listemail
+    msg['List-ID'] = f'<{forge_config.project.listid}>'
+    msg['X-Patchwork-Hint'] = 'ignore'
+
+    mbox = bytes_to_mbox(msg.as_bytes(unixfrom=True))
+    ingest_emails(mbox, gh, forge_config, event)
+    send_emails(mbox, forge_config)
diff --git a/patchwork/forge/github/webhook.py b/patchwork/forge/github/webhook.py
index 3945a6ff1ef4..bc788512265a 100644
--- a/patchwork/forge/github/webhook.py
+++ b/patchwork/forge/github/webhook.py
@@ -41,3 +41,37 @@ def parse_user(user):
         name=user.get('name', ''),
         email=user.get('email', ''),
     )
+
+
+def parse_issue_comment(payload):
+    if payload.get('action') != 'created':
+        return None
+    issue = payload.get('issue', {})
+    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_body,
+    )
+
+
+def parse_review(payload):
+    if payload.get('action') != 'submitted':
+        return None
+    review = payload.get('review', {})
+    pr = payload.get('pull_request', {})
+    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_body,
+        review_state=review.get('state', ''),
+    )