mbox series
Message IDbf56ceed17b643838ef23302f5602f70bd7e1035.1780583783.git.pw@patches.jarry.cc
StateNew
Delegate
ArchivedNo
Headers
show
Message-ID: 
 <bf56ceed17b643838ef23302f5602f70bd7e1035.1780583783.git.pw@patches.jarry.cc>
In-Reply-To: <cover.1780583783.git.pw@patches.jarry.cc>
References: <cover.1780583783.git.pw@patches.jarry.cc>
From: Robin Jarry <robin@jarry.cc>
Date: Mon, 1 Jun 2026 09:52:22 +0200
Subject: [PATCH patchwork v4 10/15] forge: sync github comments and reviews to
 mailing list
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

Commit Message

Robin JarryJun. 1, 2026, 09:52. UTC
[v4,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>
---

Notes:
    https://github.com/rjarry/patchwork/pull/3/commits/bf56ceed17b643838ef23302f5602f70bd7e1035

 patchwork/forge/github/__init__.py |  8 +++
 patchwork/forge/github/api.py      | 66 +++++++++++++++++++++++
 patchwork/forge/github/to_ml.py    | 84 ++++++++++++++++++++++++++++++
 patchwork/forge/github/webhook.py  | 34 ++++++++++++
 4 files changed, 192 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 ded7402..4441c2b 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 0000000..6e99592
--- /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 fac07b1..7129698 100644
--- a/patchwork/forge/github/to_ml.py
+++ b/patchwork/forge/github/to_ml.py
@@ -3,11 +3,21 @@
 #
 # SPDX-License-Identifier: GPL-2.0-or-later
 
+import email
+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
 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 +62,77 @@ 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
+
+    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( (name, 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 3945a6f..bc78851 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', ''),
+    )