From patchwork Thu Jun 4 12:11:58 2026 Return-Path: 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 ; Thu, 04 Jun 2026 14:12:10 +0200 (CEST) From: Robin Jarry 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: X-Patchwork-Submitter: Robin Jarry X-Patchwork-Id: 62 Content-Type: text/plain; charset=utf-8 Content-Transfer-Encoding: 8bit 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 --- patchwork/forge/github/__init__.py | 8 ++++ patchwork/forge/github/to_ml.py | 77 ++++++++++++++++++++++++++++++ patchwork/forge/github/webhook.py | 42 ++++++++++++++++ 3 files changed, 127 insertions(+) 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, + )