From patchwork Mon Jun 1 07:52:22 2026 Message-ID: In-Reply-To: References: From: Robin Jarry 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: X-Patchwork-Hint: ignore To: pw@patches.jarry.cc Cc: Robin Jarry , Robin Jarry X-Patchwork-Submitter: Robin Jarry X-Patchwork-Id: 123 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 --- 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 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 +# +# 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', ''), + )