mbox series
Message ID20260604121203.2955783-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 AD0E81BC434C
	for <pw@patches.jarry.cc>; Thu, 04 Jun 2026 14:12:10 +0200 (CEST)
From: Robin Jarry <robin@jarry.cc>
To: pw@patches.jarry.cc
Subject: [PATCH 10/15] forge: sync github comments and reviews to mailing list
Date: Thu,  4 Jun 2026 14:11:58 +0200
Message-ID: <20260604121203.2955783-11-robin@jarry.cc>
X-Mailer: git-send-email 2.54.0
In-Reply-To: <20260604121203.2955783-1-robin@jarry.cc>
References: <20260604121203.2955783-1-robin@jarry.cc>
MIME-Version: 1.0
List-ID: <pw.jarry.cc>
Content-Transfer-Encoding: 8bit
Series
Implement forge bidirectional sync

Commit Message

Robin JarryJun. 4, 2026, 14:11. UTC
[10/15] 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/to_ml.py    | 77 ++++++++++++++++++++++++++++++
 patchwork/forge/github/webhook.py  | 42 ++++++++++++++++
 3 files changed, 127 insertions(+)

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/to_ml.py b/patchwork/forge/github/to_ml.py
index fac07b180a51..bfb4f3d42691 100644
--- a/patchwork/forge/github/to_ml.py
+++ b/patchwork/forge/github/to_ml.py
@@ -3,11 +3,20 @@
 #
 # SPDX-License-Identifier: GPL-2.0-or-later
 
+import email
+import logging
+
 from patchwork.forge.git import GitMirror
+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 +61,71 @@ 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'
+    for c in event.review_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'
+
+    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 f85cc5db1ddf..5ae0f5f12b60 100644
--- a/patchwork/forge/github/webhook.py
+++ b/patchwork/forge/github/webhook.py
@@ -5,6 +5,7 @@
 
 from patchwork.forge import ForgeEvent
 from patchwork.forge import ForgeUser
+from patchwork.forge import ReviewComment
 
 
 def parse_pull_request(payload):
@@ -40,3 +41,44 @@ 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', {})
+    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', ''),
+    )
+
+
+def parse_review(self, 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', ''),
+            )
+        )
+    return ForgeEvent(
+        type='review',
+        repo_key=get_repo_key(payload),
+        pr_number=pr.get('number', 0),
+        author=parse_user(review.get('user')),
+        body=review.get('body', ''),
+        review_state=review.get('state', ''),
+        review_comments=comments,
+    )