Files
multica/server/migrations/222_github_pr_api_snapshot.up.sql
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

59 lines
3.2 KiB
SQL

-- MUL-5265: GitHub API snapshot for PR cards (Plan C).
--
-- The PR card's CI status and mergeability are now sourced from an
-- authenticated GitHub API snapshot (GraphQL pullRequest query) rather than
-- inferred from webhook check_suite events. Webhooks and page visits only
-- trigger a refresh; the API response is the single source of truth and is
-- written as one atomic batch replace per PR.
--
-- All new columns are nullable / defaulted so rows that pre-date the snapshot
-- (or deployments without a GitHub App private key, where the feature degrades
-- off) keep working: the card simply hides the CI / merge region until a
-- snapshot lands.
ALTER TABLE github_pull_request
-- GraphQL `mergeable`: MERGEABLE / CONFLICTING / UNKNOWN. Answers only
-- "is there a merge conflict". NULL until first snapshot.
ADD COLUMN api_mergeable TEXT,
-- GraphQL `mergeStateStatus`: CLEAN / DIRTY / BLOCKED / BEHIND / UNSTABLE /
-- DRAFT / HAS_HOOKS / UNKNOWN. "Ready to merge" is derived ONLY from CLEAN.
ADD COLUMN api_merge_state_status TEXT,
-- GraphQL statusCheckRollup.state: SUCCESS / FAILURE / PENDING / ERROR /
-- EXPECTED. NULL means statusCheckRollup was null → "no checks yet", which
-- must never be rendered as passed.
ADD COLUMN checks_rollup_state TEXT,
-- The head SHA the snapshot was fetched for. Pinned so a slow response for
-- an old head cannot overwrite a newer head's snapshot (head-SHA anti-stale
-- write). Empty until first snapshot.
ADD COLUMN snapshot_head_sha TEXT NOT NULL DEFAULT '',
-- When the snapshot was fetched. Drives the TTL / page-visit refresh and the
-- stale visual marker. NULL until first snapshot.
ADD COLUMN snapshot_fetched_at TIMESTAMPTZ;
-- Per-check snapshot rows for a PR's current head. Replaced atomically (delete
-- all + insert) on every successful API fetch — no incremental inference. Both
-- GraphQL CheckRun and StatusContext contexts are normalized into this shape at
-- write time (see ghsnapshot.normalizeContext). Rows are addressed by
-- (pr_id, ordinal): two checks can share a name (matrix jobs, re-runs), so name
-- is not unique. The (pr_id, ordinal) UNIQUE index is created CONCURRENTLY in
-- the next migration (223) — no index (including a PRIMARY KEY's) may be built
-- non-concurrently in a migration, even on a new table (see CLAUDE.md), so the
-- table is created without a primary key and the unique index is added in its
-- own single-statement migration.
CREATE TABLE github_pull_request_check_run (
pr_id UUID NOT NULL,
head_sha TEXT NOT NULL,
ordinal INTEGER NOT NULL,
name TEXT NOT NULL,
-- Normalized lifecycle: 'queued' / 'in_progress' / 'completed'.
status TEXT NOT NULL,
-- Normalized conclusion: 'success' / 'failure' / 'neutral' / 'cancelled' /
-- 'skipped' / 'timed_out' / 'action_required' / 'error' / ... ; NULL while
-- the check is still running.
conclusion TEXT,
details_url TEXT,
-- TRUE for legacy commit-status contexts (GraphQL StatusContext), FALSE for
-- Checks API runs (GraphQL CheckRun). Kept for display / debugging.
is_status_context BOOLEAN NOT NULL DEFAULT FALSE
);