From patchwork Thu Jun 4 12:12:01 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 F31B21BC434F for ; Thu, 04 Jun 2026 14:12:10 +0200 (CEST) From: Robin Jarry To: pw@patches.jarry.cc Subject: [PATCH 13/15] forge: forward mailing list comments to forge pull requests Date: Thu, 4 Jun 2026 14:12:01 +0200 Message-ID: <20260604121203.2955783-14-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: 65 Content-Type: text/plain; charset=utf-8 Content-Transfer-Encoding: 8bit Add a signal handler for patch and cover letter comment events that forwards them to the linked forge pull request. Comments are posted with the original author name (without email address) and the comment body formatted as a GitHub quote block. Comments that originated from the forge (detected by X-Patchwork-Hint: ignore in the comment headers) are skipped to prevent sync loops. Signed-off-by: Robin Jarry --- patchwork/forge/__init__.py | 48 ++++++++++++++++++++++++++++++ patchwork/forge/github/__init__.py | 4 +++ patchwork/forge/github/from_ml.py | 47 ++++++++++++++++++++++------- 3 files changed, 88 insertions(+), 11 deletions(-) diff --git a/patchwork/forge/__init__.py b/patchwork/forge/__init__.py index 25c76c26e1c1..8ac13d747874 100644 --- a/patchwork/forge/__init__.py +++ b/patchwork/forge/__init__.py @@ -182,6 +182,16 @@ class ForgeBackend(ABC): """ raise NotImplementedError + @abstractmethod + def handle_comment_created(self, forge_config, comment, series): + """ + Handle a comment on a patch or cover letter. + + Called by the comment-created signal handler. The backend + decides whether to forward the comment to the forge PR. + """ + raise NotImplementedError + _backends = {} @@ -219,6 +229,43 @@ def _on_series_completed(sender, instance, raw, **kwargs): transaction.on_commit(do_sync) +def _on_comment_created(sender, instance, raw, **kwargs): + if raw: + return + + if instance.category == Event.CATEGORY_PATCH_COMMENT_CREATED: + comment = instance.patch_comment + if not comment: + return + series = comment.patch.series + elif instance.category == Event.CATEGORY_COVER_COMMENT_CREATED: + comment = instance.cover_comment + if not comment: + return + series = comment.cover.series + else: + return + + if not series: + return + + def do_sync(): + for forge_config in ForgeConfig.objects.filter(project=series.project): + backend = get_backend(forge_config.backend) + if not backend: + continue + try: + backend.handle_comment_created(forge_config, comment, series) + except Exception: + logger.exception( + 'forge comment sync failed for series %d on %s', + series.id, + forge_config.backend, + ) + + transaction.on_commit(do_sync) + + def load_backends(): from django.db.models.signals import post_save @@ -229,3 +276,4 @@ def load_backends(): importlib.import_module(module_path) post_save.connect(_on_series_completed, sender=Event) + post_save.connect(_on_comment_created, sender=Event) diff --git a/patchwork/forge/github/__init__.py b/patchwork/forge/github/__init__.py index b2dbf23a5ee7..18200a4fdb23 100644 --- a/patchwork/forge/github/__init__.py +++ b/patchwork/forge/github/__init__.py @@ -15,6 +15,7 @@ import logging from patchwork.forge import ForgeBackend from patchwork.forge import register_backend from patchwork.forge.github.from_ml import create_or_update_pr +from patchwork.forge.github.from_ml import post_pr_comment from patchwork.forge.github.to_ml import handle_check_pending from patchwork.forge.github.to_ml import handle_check_result from patchwork.forge.github.to_ml import handle_issue_comment @@ -99,5 +100,8 @@ class GitHubBackend(ForgeBackend): def handle_series_completed(self, forge_config, series): create_or_update_pr(self, forge_config, series) + def handle_comment_created(self, forge_config, comment, series): + post_pr_comment(self, forge_config, comment, series) + register_backend('github', GitHubBackend()) diff --git a/patchwork/forge/github/from_ml.py b/patchwork/forge/github/from_ml.py index f648711ff0f3..1db2acc5463b 100644 --- a/patchwork/forge/github/from_ml.py +++ b/patchwork/forge/github/from_ml.py @@ -4,12 +4,14 @@ # SPDX-License-Identifier: GPL-2.0-or-later +import email import json import logging import urllib.error import urllib.request from patchwork.forge.git import GitMirror +from patchwork.forge.util import COMMENT_MARKER from patchwork.forge.util import build_pr_body from patchwork.forge.util import forge_branch_name from patchwork.forge.util import series_from_forge @@ -60,6 +62,17 @@ def create_pr(gh, forge_config, title, body, head, base): return result['number'] +def post_comment(gh, forge_config, pr_number, body): + owner, repo = forge_config.repo.split('/', 1) + gh_api_request( + gh, + forge_config, + 'POST', + f'/repos/{owner}/{repo}/issues/{pr_number}/comments', + {'body': body}, + ) + + def base_branch(gh, forge_config): """ Return the default branch of the GitHub repository. @@ -74,17 +87,6 @@ def base_branch(gh, forge_config): return result['default_branch'] -def post_comment(gh, forge_config, pr_number, body): - owner, repo = forge_config.repo.split('/', 1) - gh_api_request( - gh, - forge_config, - 'POST', - f'/repos/{owner}/{repo}/issues/{pr_number}/comments', - {'body': body}, - ) - - def create_or_update_pr(gh, forge_config, series): if not forge_config.sync_ml_to_forge: return @@ -172,3 +174,26 @@ def store_series_metadata(gh, forge_config, series, pr_ref, branch): key=f'{forge_config.backend}_branch', defaults={'value': branch}, ) + + +def post_pr_comment(gh, forge_config, comment, series): + if not forge_config.sync_ml_to_forge: + return + + if comment.headers: + parsed = email.message_from_string(comment.headers) + hint = parsed.get('X-Patchwork-Hint', '') + if hint.lower() == 'ignore': + return + + pr_meta = SeriesMetadata.objects.filter( + series=series, key=gh.meta_key_pr() + ).first() + if not pr_meta: + return + + pr_number = int(pr_meta.value.rsplit('/', 1)[-1]) + author = comment.submitter.name or comment.submitter.email + quoted = '\n'.join(f'> {line}' for line in comment.content.splitlines()) + body = f'**{author}** commented:\n\n{quoted}\n\n{COMMENT_MARKER}' + post_comment(gh, forge_config, pr_number, body)