From patchwork Thu Jun 4 12:11:59 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 C202A1BC434D for ; Thu, 04 Jun 2026 14:12:10 +0200 (CEST) From: Robin Jarry To: pw@patches.jarry.cc Subject: [PATCH 11/15] forge: sync github check results to patchwork Date: Thu, 4 Jun 2026 14:11:59 +0200 Message-ID: <20260604121203.2955783-12-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: 63 Content-Type: text/plain; charset=utf-8 Content-Transfer-Encoding: 8bit Parse check_run and check_suite webhook events. When a check run is created, record a pending Check on each patch of the linked series. When a check suite completes, record the final state (success, fail or warning) for each individual check run. A summary email listing all check run names, statuses and URLs is sent to the mailing list as a reply to the series, following the same ingest-then-send pipeline as comments and reviews. Checks are created under a dedicated "github" system user and use the check run name as context slug. Signed-off-by: Robin Jarry --- patchwork/forge/github/__init__.py | 8 +++ patchwork/forge/github/to_ml.py | 84 ++++++++++++++++++++++++++++++ patchwork/forge/github/webhook.py | 51 ++++++++++++++++++ 3 files changed, 143 insertions(+) diff --git a/patchwork/forge/github/__init__.py b/patchwork/forge/github/__init__.py index 4441c2b11ab0..ec9417b7c2e7 100644 --- a/patchwork/forge/github/__init__.py +++ b/patchwork/forge/github/__init__.py @@ -14,9 +14,13 @@ import logging from patchwork.forge import ForgeBackend from patchwork.forge import register_backend +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 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_check_run +from patchwork.forge.github.webhook import parse_check_suite 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 @@ -44,6 +48,8 @@ class GitHubBackend(ForgeBackend): payload = json.loads(body) parsers = { + 'check_run': parse_check_run, + 'check_suite': parse_check_suite, 'issue_comment': parse_issue_comment, 'pull_request': parse_pull_request, 'pull_request_review': parse_review, @@ -79,6 +85,8 @@ class GitHubBackend(ForgeBackend): def process_webhook_event(self, forge_config, event): handlers = { + 'check_pending': handle_check_pending, + 'check_result': handle_check_result, 'issue_comment': handle_issue_comment, 'pull_request': handle_pull_request, 'review': handle_review, diff --git a/patchwork/forge/github/to_ml.py b/patchwork/forge/github/to_ml.py index bfb4f3d42691..2043428131aa 100644 --- a/patchwork/forge/github/to_ml.py +++ b/patchwork/forge/github/to_ml.py @@ -6,6 +6,10 @@ 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.git import GitMirror from patchwork.forge.util import bytes_to_mbox from patchwork.forge.util import find_series_by_pr @@ -15,6 +19,7 @@ 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 +from patchwork.models import Check logger = logging.getLogger(__name__) @@ -96,6 +101,51 @@ def handle_review(gh, forge_config, event): reply(gh, forge_config, event, series, subject, body.rstrip()) +def handle_check_pending(gh, forge_config, event): + series = find_series_by_pr(gh, forge_config, event.pr_number).last() + if not series: + return + + create_checks( + gh, + forge_config, + event, + event.check_name, + Check.STATE_PENDING, + event.check_url, + '', + ) + + +def handle_check_result(gh, forge_config, event): + series = find_series_by_pr(gh, forge_config, event.pr_number).last() + if not series: + return + + for run in event.check_runs: + create_checks( + gh, + forge_config, + event, + run.name, + map_check_state(run.status), + run.url, + run.description, + ) + + lines = [] + for run in event.check_runs: + line = f'{run.name} {run.status}' + if run.url: + line += f': {run.url}' + lines.append(line) + + subject = ( + f'Re: {series.name} (GitHub: {event.check_name} {event.check_status})' + ) + reply(gh, forge_config, event, series, subject, '\n'.join(lines)) + + def reply(gh, forge_config, event, series, subject, body): """ Build a reply email as an mbox, ingest it into the database and @@ -129,3 +179,37 @@ def reply(gh, forge_config, event, series, subject, body): mbox = bytes_to_mbox(msg.as_bytes(unixfrom=True)) ingest_emails(mbox, gh, forge_config, event) send_emails(mbox, forge_config) + + +def map_check_state(conclusion): + """ + Map a GitHub check conclusion to a patchwork Check state. + """ + state_map = { + 'success': Check.STATE_SUCCESS, + 'failure': Check.STATE_FAIL, + 'timed_out': Check.STATE_FAIL, + 'cancelled': Check.STATE_FAIL, + 'action_required': Check.STATE_WARNING, + } + return state_map.get(conclusion, Check.STATE_PENDING) + + +@transaction.atomic +def create_checks(series, forge_config, event, context, state, url, desc): + # Use a dedicated 'github' user so that the checks have proper names. + user, _ = get_user_model().objects.get_or_create( + username='github', + defaults={'is_active': False}, + ) + for patch in series.patches.all(): + # New checks are created even when pending ones complete. + # The UI will deduplicate and only display the most recent ones. + Check.objects.create( + patch=patch, + user=user, + state=state, + target_url=url or '', + description=desc or '', + context=slugify(context), + ) diff --git a/patchwork/forge/github/webhook.py b/patchwork/forge/github/webhook.py index 5ae0f5f12b60..2cd69c278c53 100644 --- a/patchwork/forge/github/webhook.py +++ b/patchwork/forge/github/webhook.py @@ -3,6 +3,7 @@ # # SPDX-License-Identifier: GPL-2.0-or-later +from patchwork.forge import CheckRun from patchwork.forge import ForgeEvent from patchwork.forge import ForgeUser from patchwork.forge import ReviewComment @@ -82,3 +83,53 @@ def parse_review(self, payload): review_state=review.get('state', ''), review_comments=comments, ) + + +def parse_check_run(payload): + if payload.get('action') != 'created': + return None + run = payload.get('check_run', {}) + prs = run.get('pull_requests', []) + if not prs: + return None + return ForgeEvent( + type='check_pending', + repo_key=get_repo_key(payload), + pr_number=prs[0].get('number', 0), + check_name=run.get('name', ''), + check_status='pending', + check_url=run.get('html_url', ''), + ) + + +def parse_check_suite(payload): + if payload.get('action') != 'completed': + return None + suite = payload.get('check_suite', {}) + prs = suite.get('pull_requests', []) + if not prs: + return None + check_runs = [] + for run in suite.get('check_runs', []): + status = run.get('conclusion') or run.get('status', '') + desc = '' + output = run.get('output') + if output: + desc = output.get('summary', '') + check_runs.append( + CheckRun( + name=run.get('name', ''), + status=status, + url=run.get('html_url', ''), + description=desc, + ) + ) + app = suite.get('app', {}) + return ForgeEvent( + type='check_result', + repo_key=get_repo_key(payload), + pr_number=prs[0].get('number', 0), + check_name=app.get('name', ''), + check_status=suite.get('conclusion', ''), + check_runs=check_runs, + )