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', ''),
+ )