Files
multica/packages/core/github/pull-request-status.ts
Bohan Jiang ecce589867 MUL-5265: GitHub API-snapshot PR cards — CI status + mergeability (#5889)
* feat(github): API-snapshot PR cards — CI status + mergeability (MUL-5265)

Fetch each linked PR's CI checks and mergeability from the GitHub GraphQL
API as the single source of truth (Plan C). Webhooks, page visits and a
bounded TTL sweep are refresh triggers only; nothing is inferred from
webhook payloads anymore.

Backend (server/internal/integrations/ghsnapshot):
- installation-token cache + GraphQL client (private key / tokens never logged)
- one paginated pullRequest query -> normalized per-check snapshot
- outbound queue: (installation,repo,PR) dedup + single in-flight per PR,
  bounded worker pool, Retry-After / rate-limit backoff, jitter
- head-SHA-guarded atomic batch replace (a slow response for an old head
  can never overwrite a newer head's snapshot)
- bounded chase window (30s->5m, stops on terminal/closed) + page-visit +
  TTL refresh; clean degradation when no App private key is configured

Removes the old suite-level webhook aggregation display path (query +
handlers + tests). check_suite / check_run / status are now pure triggers.

Frontend: PR card shows two independent tri-state elements (CI status +
mergeability). "Ready to merge" only when merge state is clean; no-checks
and unknown-mergeable never assert a positive verdict; progress strip
removed; four locales; stale marker.

Docs: github-integration + environment-variables (four languages) — now
required App private key, read-only Checks/Commit-statuses permissions,
new event subscriptions, capability boundaries and troubleshooting.

Co-authored-by: multica-agent <github@multica.ai>

* fix(github): address PR snapshot review blockers

Co-authored-by: multica-agent <github@multica.ai>

* fix(github): bound snapshot refresh scheduling

Co-authored-by: multica-agent <github@multica.ai>

* fix(github): concurrent check-run index migration + singleflight token mint

Address Elon's third-round review on the MUL-5265 PR snapshot pipeline.

Must-fix — migration built a non-concurrent index. The
github_pull_request_check_run table declared PRIMARY KEY (pr_id, ordinal)
inside CREATE TABLE, which builds a unique index synchronously and violates
the repo rule that every migration-created index (including on a new table)
use CREATE UNIQUE INDEX CONCURRENTLY in its own single-statement file. Split:
222 now creates the table without a primary key; new 223 adds the
(pr_id, ordinal) unique index CONCURRENTLY. The atomic delete-all/insert
write path already guarantees ordinal uniqueness, so a plain unique index is
sufficient; the index also serves the pr_id-prefix list aggregation and the
workspace/PR cleanup deletes.

Nit — token mint now singleflights per installation. installationToken
released the lock before minting, so the N workers of one installation could
mint N tokens on a cold cache or a simultaneous renew. Concurrent callers for
the same installation are now collapsed via singleflight into one HTTP mint;
added a -race concurrent-mint test asserting a single mint under 16 callers.

Verified: fresh DB migrates through 223 (table has no PK, concurrent unique
index present); ghsnapshot suite + new test pass under -race; migration lint
and handler github/workspace-delete tests pass; sqlc produced no diff;
go build / vet / gofmt / git diff --check clean.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Bohan-J <bohan@devv.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-24 18:30:20 +08:00

164 lines
7.0 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type {
GitHubPullRequestChecksConclusion,
GitHubPullRequestChecksRollup,
GitHubPullRequestMergeable,
GitHubPullRequestMergeStateStatus,
} from "../types";
// The PR sidebar row surfaces TWO independent facts, each tri-state and each
// sourced from the GitHub API snapshot:
//
// 1. CI status — derived from `checks_rollup` (primary) + counts.
// 2. Mergeability — derived from `mergeable` + `merge_state_status`.
//
// The two are intentionally decoupled: a PR can have failing checks AND a merge
// conflict, and both must show. Neither element is derived from the other, and
// neither is shown for terminal PRs (merged / closed) — the row's leading state
// icon already conveys terminal state; the caller applies that gate.
//
// Every input field is optional because older backends omit the snapshot
// fields; each rule defaults defensively (`?? 0`, `?? []`, explicit `=== "..."`
// checks) so an absent field never fabricates a positive verdict.
// ---------------------------------------------------------------------------
// CI status
// ---------------------------------------------------------------------------
// Discriminated union for the CI element. `none` is a current snapshot with no
// checks; `unavailable` means there is no current snapshot region to render.
export type PullRequestChecksStatus =
| { kind: "failed"; failed: number; total: number; names: string[] }
| { kind: "pending"; passed: number; total: number; running: number }
| { kind: "passed"; total: number }
| { kind: "none" }
| { kind: "unavailable" };
export interface PullRequestChecksInput {
snapshot_available?: boolean;
checks_rollup?: GitHubPullRequestChecksRollup | null;
checks_conclusion?: GitHubPullRequestChecksConclusion | null;
checks_total?: number;
checks_passed?: number;
checks_failed?: number;
checks_running?: number;
checks_pending?: number;
failed_check_names?: string[];
}
// Priority (high → low):
// 0. explicitly unavailable snapshot → unavailable
// 1. rollup failure/error OR any failed count → failed
// 2. rollup pending/expected → pending
// 3. rollup success → passed
// 4. legacy provider conclusion → matching coarse state
// 5. current snapshot + null rollup → none ("no checks yet")
// 6. otherwise → unavailable
//
// Failure trusts the count as well as the rollup so a known legacy failure is
// surfaced even if its coarse conclusion lags. An explicit false availability
// gate always wins, so disabled or wrong-head GitHub data never leaks through.
export function deriveChecksStatus(input: PullRequestChecksInput): PullRequestChecksStatus {
if (input.snapshot_available === false) {
return { kind: "unavailable" };
}
const rollup = input.checks_rollup ?? null;
const total = input.checks_total ?? 0;
const passed = input.checks_passed ?? 0;
const failed = input.checks_failed ?? 0;
const running = input.checks_running ?? input.checks_pending ?? 0;
const names = input.failed_check_names ?? [];
if (rollup === "failure" || rollup === "error" || failed > 0) {
return { kind: "failed", failed, total, names };
}
if (rollup === "pending" || rollup === "expected") {
return { kind: "pending", passed, total, running };
}
if (rollup === "success") {
return { kind: "passed", total };
}
// Forgejo / Gitea / GitLab and older GitHub backends expose the coarse
// webhook-derived conclusion rather than a GraphQL rollup. Preserve those
// known passed/pending/failed states without confusing an absent API
// snapshot with a current "no checks" verdict.
if (input.checks_conclusion === "failed") {
return { kind: "failed", failed, total, names };
}
if (input.checks_conclusion === "pending") {
return { kind: "pending", passed, total, running };
}
if (input.checks_conclusion === "passed") {
return { kind: "passed", total };
}
return input.snapshot_available === true ? { kind: "none" } : { kind: "unavailable" };
}
// ---------------------------------------------------------------------------
// Mergeability
// ---------------------------------------------------------------------------
// Discriminated union for the mergeability element. `none` renders nothing:
// when GitHub has not decided (mergeable unknown/null and no decisive
// merge_state_status) the card asserts neither "conflict" nor "ready".
export type PullRequestMergeStatus =
| { kind: "conflicting" }
| { kind: "ready" }
| { kind: "blocked" }
| { kind: "behind" }
| { kind: "unstable" }
| { kind: "has_hooks" }
| { kind: "none" };
export interface PullRequestMergeInput {
snapshot_available?: boolean;
mergeable?: GitHubPullRequestMergeable | null;
merge_state_status?: GitHubPullRequestMergeStateStatus | null;
}
// Priority (high → low):
// 1. mergeable conflicting OR merge_state dirty → conflicting
// 2. merge_state clean → ready
// 3. merge_state blocked/behind/unstable/hooks → that faithful label
// 4. otherwise → none (render nothing)
//
// `mergeable` answers only "is there a conflict"; `merge_state_status === dirty`
// is GitHub's other view of the same fact (an unmergeable conflict), so both
// map to `conflicting`. "Ready" is asserted ONLY from `clean` — never inferred
// from `mergeable === "mergeable"`, which does not account for required checks
// or branch protection.
export function deriveMergeStatus(input: PullRequestMergeInput): PullRequestMergeStatus {
if (input.snapshot_available === false) return { kind: "none" };
const mergeable = input.mergeable ?? null;
const mergeState = input.merge_state_status ?? null;
if (mergeable === "conflicting" || mergeState === "dirty") return { kind: "conflicting" };
if (mergeState === "clean") return { kind: "ready" };
if (mergeState === "blocked") return { kind: "blocked" };
if (mergeState === "behind") return { kind: "behind" };
if (mergeState === "unstable") return { kind: "unstable" };
if (mergeState === "has_hooks") return { kind: "has_hooks" };
return { kind: "none" };
}
// ---------------------------------------------------------------------------
// Diff stats
// ---------------------------------------------------------------------------
export interface PullRequestStatsInput {
additions?: number;
deletions?: number;
changed_files?: number;
}
// shouldShowPullRequestStats encodes the "old backend → new frontend" guard:
// when the backend that served this PR row doesn't know about the stats
// columns yet, every numeric field defaults to 0. Rendering "+0 0 · 0 files"
// in that case would be a lie (the PR almost certainly has real changes),
// so we hide the entire stats row until at least one signal is non-zero.
export function shouldShowPullRequestStats(input: PullRequestStatsInput): boolean {
const a = input.additions ?? 0;
const d = input.deletions ?? 0;
const f = input.changed_files ?? 0;
return a + d + f > 0;
}