Files
multica/packages/core/github/pull-request-status.test.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

171 lines
5.9 KiB
TypeScript

import { describe, expect, it } from "vitest";
import {
deriveChecksStatus,
deriveMergeStatus,
shouldShowPullRequestStats,
} from "./pull-request-status";
describe("deriveChecksStatus", () => {
it("maps a `failure` rollup to failed and carries counts + names", () => {
expect(
deriveChecksStatus({
checks_rollup: "failure",
checks_total: 7,
checks_failed: 2,
failed_check_names: ["backend", "e2e"],
}),
).toEqual({ kind: "failed", failed: 2, total: 7, names: ["backend", "e2e"] });
});
it("maps an `error` rollup to failed", () => {
expect(deriveChecksStatus({ checks_rollup: "error", checks_total: 3 }).kind).toBe("failed");
});
it("treats any failed count as failed even when the rollup is absent", () => {
// Failure is trusted from the count so a known failure surfaces even if the
// rollup verdict lags.
expect(deriveChecksStatus({ checks_failed: 1 }).kind).toBe("failed");
});
it("failure beats pending and passed", () => {
expect(
deriveChecksStatus({
checks_rollup: "failure",
checks_failed: 1,
checks_running: 3,
checks_passed: 5,
}).kind,
).toBe("failed");
});
it("maps `pending` / `expected` rollups to pending with running count", () => {
expect(
deriveChecksStatus({
checks_rollup: "pending",
checks_total: 7,
checks_passed: 5,
checks_running: 2,
}),
).toEqual({ kind: "pending", passed: 5, total: 7, running: 2 });
expect(deriveChecksStatus({ checks_rollup: "expected" }).kind).toBe("pending");
});
it("maps a `success` rollup to passed", () => {
expect(deriveChecksStatus({ checks_rollup: "success", checks_total: 7 })).toEqual({
kind: "passed",
total: 7,
});
});
it("renders `none` only for a current snapshot whose rollup is absent", () => {
expect(deriveChecksStatus({ snapshot_available: true }).kind).toBe("none");
expect(
deriveChecksStatus({
snapshot_available: true,
checks_rollup: null,
checks_passed: 5,
checks_total: 5,
}).kind,
).toBe("none");
});
it("hides CI when the API snapshot is disabled or has not landed", () => {
expect(deriveChecksStatus({}).kind).toBe("unavailable");
expect(
deriveChecksStatus({
snapshot_available: false,
checks_rollup: "success",
checks_conclusion: "passed",
checks_total: 5,
}).kind,
).toBe("unavailable");
});
it("preserves legacy provider passed, pending, and failed conclusions", () => {
expect(
deriveChecksStatus({ checks_conclusion: "passed", checks_total: 3 }),
).toEqual({ kind: "passed", total: 3 });
expect(
deriveChecksStatus({
checks_conclusion: "pending",
checks_total: 3,
checks_passed: 2,
checks_pending: 1,
}),
).toEqual({ kind: "pending", passed: 2, total: 3, running: 1 });
expect(
deriveChecksStatus({
checks_conclusion: "failed",
checks_total: 3,
checks_failed: 1,
}).kind,
).toBe("failed");
});
});
describe("deriveMergeStatus", () => {
it("maps `conflicting` to conflicting", () => {
expect(deriveMergeStatus({ mergeable: "conflicting" }).kind).toBe("conflicting");
});
it("folds a `dirty` merge state into conflicting", () => {
expect(deriveMergeStatus({ merge_state_status: "dirty" }).kind).toBe("conflicting");
});
it("asserts ready ONLY from a `clean` merge state", () => {
expect(deriveMergeStatus({ merge_state_status: "clean" }).kind).toBe("ready");
});
it("never infers ready from `mergeable === mergeable` alone", () => {
// "No conflict" is not "ready" — required checks / branch protection live in
// merge_state_status, so mergeable without a clean state renders nothing.
expect(deriveMergeStatus({ mergeable: "mergeable" }).kind).toBe("none");
expect(deriveMergeStatus({ mergeable: "mergeable", merge_state_status: null }).kind).toBe("none");
});
it("surfaces blocked / behind / unstable / has_hooks faithfully", () => {
expect(deriveMergeStatus({ merge_state_status: "blocked" }).kind).toBe("blocked");
expect(deriveMergeStatus({ merge_state_status: "behind" }).kind).toBe("behind");
expect(deriveMergeStatus({ merge_state_status: "unstable" }).kind).toBe("unstable");
expect(deriveMergeStatus({ merge_state_status: "has_hooks" }).kind).toBe("has_hooks");
});
it("renders nothing when GitHub has not decided", () => {
// unknown / null shows neither conflict nor ready.
expect(deriveMergeStatus({}).kind).toBe("none");
expect(deriveMergeStatus({ mergeable: "unknown" }).kind).toBe("none");
expect(deriveMergeStatus({ mergeable: null, merge_state_status: "unknown" }).kind).toBe("none");
expect(deriveMergeStatus({ merge_state_status: "draft" }).kind).toBe("none");
});
it("renders nothing when the API snapshot feature is unavailable", () => {
expect(
deriveMergeStatus({
snapshot_available: false,
mergeable: "conflicting",
merge_state_status: "dirty",
}).kind,
).toBe("none");
});
it("conflict wins over an otherwise decisive merge state", () => {
expect(
deriveMergeStatus({ mergeable: "conflicting", merge_state_status: "blocked" }).kind,
).toBe("conflicting");
});
});
describe("shouldShowPullRequestStats", () => {
it("hides when every field is 0 or missing (legacy backend)", () => {
expect(shouldShowPullRequestStats({})).toBe(false);
expect(shouldShowPullRequestStats({ additions: 0, deletions: 0, changed_files: 0 })).toBe(false);
});
it("shows when at least one number is non-zero", () => {
expect(shouldShowPullRequestStats({ additions: 1 })).toBe(true);
expect(shouldShowPullRequestStats({ deletions: 1 })).toBe(true);
expect(shouldShowPullRequestStats({ changed_files: 1 })).toBe(true);
expect(shouldShowPullRequestStats({ additions: 437, deletions: 6, changed_files: 6 })).toBe(true);
});
});