From 449739ee975b1685c9be9842986793653e87c86b Mon Sep 17 00:00:00 2001 From: RhenCloud Date: Thu, 27 Aug 2026 21:59:48 +0800 Subject: [PATCH] feat(check): resolve build log url for failed check suites via GitHub API check_suite payloads carry no details_url; the build log lives on the underlying check_run. For failed suites, fetch the suite's check runs with an installation token and inject the first run's details_url (preferring the Cloudflare run) as a Build Log field. --- server/lib/core/dispatch.ts | 23 ++++++++++++++ server/lib/github/check-run.ts | 55 ++++++++++++++++++++++++++++++++++ server/lib/github/oauth.ts | 2 +- 3 files changed, 79 insertions(+), 1 deletion(-) create mode 100644 server/lib/github/check-run.ts diff --git a/server/lib/core/dispatch.ts b/server/lib/core/dispatch.ts index 36ef0f2..56fa891 100644 --- a/server/lib/core/dispatch.ts +++ b/server/lib/core/dispatch.ts @@ -17,6 +17,7 @@ import { import { getDriver } from "../drivers"; import type { SendResult } from "../drivers/types"; import type { DispatchFailure, DispatchSummary } from "../queue/delivery"; +import { getCheckSuiteBuildLogUrl } from "../github/check-run"; /** One dispatch attempt (route × target), collected for the group webhook log. */ interface DispatchAttempt { @@ -170,6 +171,28 @@ export async function dispatchEvent( const tr = trMap.get(group?.lang ?? "en")!; const showEmoji = group?.emoji !== false; const message = formatEvent(route, event, tr, showEmoji); + if (event.event === "check_suite") { + const suite = event.payload.check_suite as { + conclusion?: string; + check_runs_url?: string; + } | undefined; + if (suite?.conclusion === "failure" && suite.check_runs_url) { + const buildLogUrl = await getCheckSuiteBuildLogUrl( + suite.check_runs_url, + env.GITHUB_APP_ID, + env.GITHUB_PRIVATE_KEY, + event.installationId, + ); + if (buildLogUrl) { + message.fields = message.fields ?? []; + message.fields.push({ + name: translate("fields.build_log", {}, undefined, tr), + value: `[${translate("fields.build_log", {}, undefined, tr)}](${buildLogUrl})`, + inline: false, + }); + } + } + } if (group?.forgeSources?.length) { message.forge = forgeInfo(event, group.forgeSources); } diff --git a/server/lib/github/check-run.ts b/server/lib/github/check-run.ts new file mode 100644 index 0000000..5b858c2 --- /dev/null +++ b/server/lib/github/check-run.ts @@ -0,0 +1,55 @@ +import { createAppJwt } from "./oauth"; + +const API_VERSION = "2022-11-28"; +const CLOUDFLARE_SLUG = "cloudflare-workers-and-pages"; + +/** + * Resolve a check suite's build log URL. The `check_suite` webhook payload + * carries no `details_url` of its own — only the underlying `check_run` does + * (Cloudflare sets it to the build log page). For a failed suite we fetch the + * suite's check runs via the GitHub API and return the first run's + * `details_url`, preferring the Cloudflare run when present. + * + * Returns undefined when the App credentials are missing, the call fails, or + * no run exposes a `details_url`. Best-effort: callers must not depend on it. + */ +export async function getCheckSuiteBuildLogUrl( + checkRunsUrl: string | undefined, + appId: string | undefined, + privateKey: string | undefined, + installationId: number | undefined, +): Promise { + if (!checkRunsUrl || !appId || !privateKey || !installationId) return undefined; + try { + const jwt = await createAppJwt(appId, privateKey); + const tokRes = await fetch(`https://api.github.com/app/installations/${installationId}/access_tokens`, { + method: "POST", + headers: { + Authorization: `Bearer ${jwt}`, + Accept: "application/vnd.github+json", + "X-GitHub-Api-Version": API_VERSION, + }, + }); + if (!tokRes.ok) return undefined; + const { token } = (await tokRes.json()) as { token?: string }; + if (!token) return undefined; + + const runsRes = await fetch(checkRunsUrl, { + headers: { + Authorization: `Bearer ${token}`, + Accept: "application/vnd.github+json", + "X-GitHub-Api-Version": API_VERSION, + }, + }); + if (!runsRes.ok) return undefined; + const data = (await runsRes.json()) as { + check_runs?: Array<{ details_url?: string; app?: { slug?: string } }>; + }; + const runs = data.check_runs ?? []; + const cloudflare = runs.find((r) => r.app?.slug === CLOUDFLARE_SLUG && r.details_url); + const any = runs.find((r) => r.details_url); + return (cloudflare ?? any)?.details_url; + } catch { + return undefined; + } +} diff --git a/server/lib/github/oauth.ts b/server/lib/github/oauth.ts index 50544d0..e66c729 100644 --- a/server/lib/github/oauth.ts +++ b/server/lib/github/oauth.ts @@ -24,7 +24,7 @@ function pemToBinary(pem: string): ArrayBuffer { } /** GitHub App JWT (RS256, PKCS#8 PEM key), valid ~10 minutes. */ -async function createAppJwt(appId: string, privateKey: string): Promise { +export async function createAppJwt(appId: string, privateKey: string): Promise { const now = Math.floor(Date.now() / 1000); const header = b64url(JSON.stringify({ alg: "RS256", typ: "JWT" })); const payload = b64url(JSON.stringify({ iat: now - 60, exp: now + 600, iss: appId }));