mbox series
Message ID20260604121203.2955783-12-robin@jarry.cc
StateNew
Delegate
ArchivedNo
Headers
show
Return-Path: <robin@jarry.cc>
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 <pw@patches.jarry.cc>; Thu, 04 Jun 2026 14:12:10 +0200 (CEST)
From: Robin Jarry <robin@jarry.cc>
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: <pw.jarry.cc>
Content-Transfer-Encoding: 8bit
Series
Implement forge bidirectional sync

Commit Message

Robin JarryJun. 4, 2026, 14:11. UTC
[11/15] forge: sync github check results to patchwork

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 <robin@jarry.cc>
---
 patchwork/forge/github/__init__.py |  8 +++
 patchwork/forge/github/to_ml.py    | 84 ++++++++++++++++++++++++++++++
 patchwork/forge/github/webhook.py  | 51 ++++++++++++++++++
 3 files changed, 143 insertions(+)

Patch

mbox series
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,
+    )