mbox series
Message ID20260604141826.2998337-13-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 612B21BC4357
	for <pw@patches.jarry.cc>; Thu, 04 Jun 2026 16:18:30 +0200 (CEST)
From: Robin Jarry <robin@jarry.cc>
To: pw@patches.jarry.cc
Subject: [PATCH v3 12/16] forge: sync github check results to patchwork
Date: Thu,  4 Jun 2026 16:18:21 +0200
Message-ID: <20260604141826.2998337-13-robin@jarry.cc>
X-Mailer: git-send-email 2.54.0
In-Reply-To: <20260604141826.2998337-1-robin@jarry.cc>
References: <20260604141826.2998337-1-robin@jarry.cc>
MIME-Version: 1.0
List-ID: <pw.jarry.cc>
Content-Transfer-Encoding: 8bit
Series
Implement forge bidirectional sync
github_branchpatchwork/implement-forge-bidirectional-sync-c
github_prhttps://github.com/rjarry/patchwork/pull/4

Commit Message

Robin JarryJun. 4, 2026, 16:18. UTC
[v3,12/16] 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/api.py      | 30 +++++++++++
 patchwork/forge/github/to_ml.py    | 86 ++++++++++++++++++++++++++++++
 patchwork/forge/github/webhook.py  | 35 ++++++++++++
 4 files changed, 159 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/api.py b/patchwork/forge/github/api.py
index 6e99592a9eac..36bbab596a93 100644
--- a/patchwork/forge/github/api.py
+++ b/patchwork/forge/github/api.py
@@ -9,6 +9,7 @@ import logging
 import urllib.error
 import urllib.request
 
+from patchwork.forge import CheckRun
 from patchwork.forge import ReviewComment
 
 logger = logging.getLogger(__name__)
@@ -64,3 +65,32 @@ def fetch_review_comments(gh, forge_config, pr_number, review_id):
             )
         )
     return comments
+
+
+def fetch_check_runs(gh, forge_config, check_suite_id):
+    """
+    Fetch check runs for a check suite via the GitHub API.
+    """
+    owner, repo = forge_config.repo.split('/', 1)
+    result = gh_api_request(
+        gh,
+        forge_config,
+        'GET',
+        f'/repos/{owner}/{repo}/check-suites/{check_suite_id}/check-runs',
+    )
+    runs = []
+    for run in result.get('check_runs', []):
+        status = run.get('conclusion') or run.get('status', '')
+        desc = ''
+        output = run.get('output')
+        if output:
+            desc = output.get('summary') or ''
+        runs.append(
+            CheckRun(
+                name=run.get('name', ''),
+                status=status,
+                url=run.get('html_url', ''),
+                description=desc,
+            )
+        )
+    return runs
diff --git a/patchwork/forge/github/to_ml.py b/patchwork/forge/github/to_ml.py
index 27797094cbe7..74c6e4d9d403 100644
--- a/patchwork/forge/github/to_ml.py
+++ b/patchwork/forge/github/to_ml.py
@@ -6,7 +6,12 @@
 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.github.api import fetch_check_runs
 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
@@ -16,6 +21,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__)
 
@@ -105,6 +111,52 @@ 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
+
+    check_runs = fetch_check_runs(gh, forge_config, event)
+    for run in check_runs:
+        create_checks(
+            series,
+            forge_config,
+            event,
+            run.name,
+            map_check_state(run.status),
+            run.url,
+            run.description,
+        )
+
+    lines = []
+    for run in 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
@@ -138,3 +190,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 bc788512265a..17c5644eefd6 100644
--- a/patchwork/forge/github/webhook.py
+++ b/patchwork/forge/github/webhook.py
@@ -75,3 +75,38 @@ def parse_review(payload):
         body=review_body,
         review_state=review.get('state', ''),
     )
+
+
+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
+    app = suite.get('app', {})
+    return ForgeEvent(
+        type='check_result',
+        repo_key=get_repo_key(payload),
+        pr_number=prs[0].get('number', 0),
+        check_suite_id=suite.get('id', 0),
+        check_name=app.get('name', ''),
+        check_status=suite.get('conclusion', ''),
+    )