From patchwork Mon Jun 1 15:02:10 2026 Message-ID: <4e680599885aa4ff251d5f20d01c916dee824334.1780583783.git.pw@patches.jarry.cc> In-Reply-To: References: From: Robin Jarry Date: Mon, 1 Jun 2026 17:02:10 +0200 Subject: [PATCH patchwork v4 11/15] forge: sync github check results to patchwork Sender: pw@patches.jarry.cc Reply-To: pw@patches.jarry.cc List-ID: X-Patchwork-Hint: ignore To: pw@patches.jarry.cc Cc: Robin Jarry , Robin Jarry X-Patchwork-Submitter: Robin Jarry X-Patchwork-Id: 124 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 --- Notes: https://github.com/rjarry/patchwork/pull/3/commits/4e680599885aa4ff251d5f20d01c916dee824334 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(+) diff --git a/patchwork/forge/github/__init__.py b/patchwork/forge/github/__init__.py index 4441c2b..ec9417b 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 6e99592..36bbab5 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 7129698..c40a6e1 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 @@ -136,3 +188,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 bc78851..17c5644 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', ''), + )