Files
multica/packages/views/issues/components/pull-request-list.tsx
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

328 lines
10 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.
"use client";
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import {
CheckCircle2,
Circle,
CircleDashed,
CircleSlash,
GitMerge,
GitPullRequest,
GitPullRequestArrow,
GitPullRequestClosed,
GitPullRequestDraft,
TriangleAlert,
XCircle,
} from "lucide-react";
import {
issuePullRequestsOptions,
deriveChecksStatus,
deriveMergeStatus,
shouldShowPullRequestStats,
type PullRequestChecksStatus,
type PullRequestMergeStatus,
} from "@multica/core/github";
import type { GitHubPullRequest, GitHubPullRequestState } from "@multica/core/types";
import { cn } from "@multica/ui/lib/utils";
import { useT, useTimeAgo } from "../../i18n";
type IssuesT = ReturnType<typeof useT<"issues">>["t"];
// Keep the existing sidebar density: show the first 3 PR rows inline, then
// collapse the rest once the section reaches 4 rows.
const PR_LIMIT_BEFORE_COLLAPSE = 4;
const STATE_ICON: Record<
GitHubPullRequestState,
{ icon: React.ComponentType<{ className?: string }>; className: string }
> = {
open: { icon: GitPullRequestArrow, className: "text-emerald-600 dark:text-emerald-400" },
draft: { icon: GitPullRequestDraft, className: "text-muted-foreground" },
merged: { icon: GitMerge, className: "text-violet-600 dark:text-violet-400" },
closed: { icon: GitPullRequestClosed, className: "text-rose-600 dark:text-rose-400" },
};
export function PullRequestList({ issueId }: { issueId: string }) {
const { t } = useT("issues");
const [expanded, setExpanded] = useState(false);
const { data, isLoading } = useQuery(issuePullRequestsOptions(issueId));
const prs = data?.pull_requests ?? [];
if (isLoading) {
return <p className="text-xs text-muted-foreground px-2">{t(($) => $.detail.pull_requests_loading)}</p>;
}
if (prs.length === 0) {
return (
<p className="text-xs text-muted-foreground px-2">
{t(($) => $.detail.pull_requests_empty)}
</p>
);
}
// Render rule:
// - < PR_LIMIT_BEFORE_COLLAPSE: every PR row is visible.
// - >= PR_LIMIT_BEFORE_COLLAPSE: first (LIMIT - 1) rows are visible and
// the remainder sits behind a toggle.
const useCollapse = prs.length >= PR_LIMIT_BEFORE_COLLAPSE;
const expandedHead = useCollapse ? prs.slice(0, PR_LIMIT_BEFORE_COLLAPSE - 1) : prs;
const collapsedTail = useCollapse ? prs.slice(PR_LIMIT_BEFORE_COLLAPSE - 1) : [];
return (
<div className="space-y-1">
{expandedHead.map((pr) => (
<PullRequestRow key={pr.id} pr={pr} />
))}
{useCollapse ? (
<div className="space-y-1">
{expanded
? collapsedTail.map((pr) => <PullRequestRow key={pr.id} pr={pr} />)
: null}
<button
type="button"
onClick={() => setExpanded((v) => !v)}
className="block w-[calc(100%+1rem)] -mx-2 rounded-md px-2 py-1.5 text-left text-[11px] text-muted-foreground hover:bg-accent/50 hover:text-foreground transition-colors"
>
{expanded
? t(($) => $.detail.pull_request_card_show_less)
: t(($) => $.detail.pull_request_card_show_more, { count: collapsedTail.length })}
</button>
</div>
) : null}
</div>
);
}
function PullRequestRow({ pr }: { pr: GitHubPullRequest }) {
const { t } = useT("issues");
const cfg = STATE_ICON[pr.state] ?? { icon: GitPullRequest, className: "" };
const StateIcon = cfg.icon;
const isDraft = pr.state === "draft";
const stateLabel = getStateLabel(pr.state, t);
return (
<a
data-testid="pull-request-row"
href={pr.html_url}
target="_blank"
rel="noreferrer noopener"
className={cn(
"flex items-start gap-2 rounded-md px-2 py-1.5 -mx-2 hover:bg-accent/50 transition-colors group",
isDraft ? "opacity-80" : null,
)}
>
<StateIcon className={cn("h-3.5 w-3.5 mt-0.5 shrink-0", cfg.className)} />
<div className="min-w-0 flex-1">
<p className="text-xs font-medium leading-snug truncate group-hover:text-foreground">
{pr.title}
</p>
<p className="text-[11px] text-muted-foreground truncate">
{pr.repo_owner}/{pr.repo_name}#{pr.number} · {stateLabel}
{pr.author_login ? ` · @${pr.author_login}` : null}
</p>
<PullRequestRowDetails pr={pr} />
</div>
</a>
);
}
function PullRequestRowDetails({ pr }: { pr: GitHubPullRequest }) {
const { t } = useT("issues");
const timeAgo = useTimeAgo();
const showStats = shouldShowPullRequestStats({
additions: pr.additions,
deletions: pr.deletions,
changed_files: pr.changed_files,
});
// Neither status element is shown for terminal PRs — the leading state icon
// already conveys merged / closed, and CI / mergeability are no longer
// actionable there.
const isTerminal = pr.state === "merged" || pr.state === "closed";
const checksBadge = isTerminal ? null : getChecksBadge(deriveChecksStatus(pr), t);
const mergeBadge = isTerminal ? null : getMergeBadge(deriveMergeStatus(pr), t);
// A stale snapshot (GitHub outage / revoked key) greys out both elements and
// annotates them with the snapshot age instead of hiding the last-known data.
const stale = !isTerminal && pr.snapshot_stale === true;
const staleTitle = stale
? pr.snapshot_fetched_at
? t(($) => $.detail.pull_request_snapshot_stale, { time: timeAgo(pr.snapshot_fetched_at) })
: t(($) => $.detail.pull_request_snapshot_stale_unknown)
: undefined;
if (!showStats && !checksBadge && !mergeBadge) return null;
return (
<div className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-[11px] text-muted-foreground">
{showStats ? <PullRequestStats pr={pr} /> : null}
{checksBadge ? <PullRequestBadge badge={checksBadge} stale={stale} title={staleTitle} /> : null}
{mergeBadge ? <PullRequestBadge badge={mergeBadge} stale={stale} title={staleTitle} /> : null}
</div>
);
}
function PullRequestStats({ pr }: { pr: GitHubPullRequest }) {
const { t } = useT("issues");
return (
<span className="inline-flex items-center gap-1.5 tabular-nums">
<span className="text-emerald-600 dark:text-emerald-400">+{pr.additions ?? 0}</span>
<span className="text-rose-600 dark:text-rose-400">{pr.deletions ?? 0}</span>
<span aria-hidden="true">·</span>
<span>
{t(($) => $.detail.pull_request_card_files_count, {
count: pr.changed_files ?? 0,
})}
</span>
</span>
);
}
interface PullRequestBadgeConfig {
icon: React.ComponentType<{ className?: string }>;
label: string;
className: string;
}
function PullRequestBadge({
badge,
stale,
title,
}: {
badge: PullRequestBadgeConfig;
stale?: boolean;
title?: string;
}) {
const Icon = badge.icon;
return (
<span
className={cn("inline-flex items-center gap-1", stale ? "opacity-60" : null)}
title={title}
>
<Icon className={cn("h-3 w-3", badge.className)} />
{badge.label}
</span>
);
}
// CI element. A current snapshot with a null rollup renders "no checks yet";
// an unavailable/disabled snapshot renders nothing.
function getChecksBadge(
status: PullRequestChecksStatus,
t: IssuesT,
): PullRequestBadgeConfig | null {
switch (status.kind) {
case "failed":
return {
icon: XCircle,
className: "text-rose-600 dark:text-rose-400",
label: checksFailedLabel(status, t),
};
case "pending":
return {
icon: CircleDashed,
className: "text-amber-600 dark:text-amber-400",
label: t(($) => $.detail.pull_request_checks_running, {
passed: status.passed,
total: status.total,
running: status.running,
}),
};
case "passed":
return {
icon: CheckCircle2,
className: "text-emerald-600 dark:text-emerald-400",
label: t(($) => $.detail.pull_request_checks_all_passed, { total: status.total }),
};
case "none":
return {
icon: Circle,
className: "text-muted-foreground",
label: t(($) => $.detail.pull_request_checks_none),
};
case "unavailable":
return null;
}
}
function checksFailedLabel(
status: Extract<PullRequestChecksStatus, { kind: "failed" }>,
t: IssuesT,
): string {
const shown = status.names.slice(0, 2);
if (shown.length === 0) {
return t(($) => $.detail.pull_request_checks_failed_count, {
failed: status.failed,
total: status.total,
});
}
const remaining = status.names.length - shown.length;
const parts = [...shown];
if (remaining > 0) {
parts.push(t(($) => $.detail.pull_request_checks_more, { count: remaining }));
}
return t(($) => $.detail.pull_request_checks_failed_named, {
failed: status.failed,
total: status.total,
names: parts.join(", "),
});
}
// Mergeability element. Returns null for the "none" state — when GitHub has not
// decided, the card asserts neither "conflict" nor "ready".
function getMergeBadge(status: PullRequestMergeStatus, t: IssuesT): PullRequestBadgeConfig | null {
switch (status.kind) {
case "conflicting":
return {
icon: TriangleAlert,
className: "text-amber-600 dark:text-amber-400",
label: t(($) => $.detail.pull_request_merge_conflicting),
};
case "ready":
return {
icon: CheckCircle2,
className: "text-emerald-600 dark:text-emerald-400",
label: t(($) => $.detail.pull_request_merge_ready),
};
case "blocked":
return {
icon: CircleSlash,
className: "text-muted-foreground",
label: t(($) => $.detail.pull_request_merge_blocked),
};
case "behind":
return {
icon: CircleSlash,
className: "text-muted-foreground",
label: t(($) => $.detail.pull_request_merge_behind),
};
case "unstable":
return {
icon: CircleSlash,
className: "text-muted-foreground",
label: t(($) => $.detail.pull_request_merge_unstable),
};
case "has_hooks":
return {
icon: CircleSlash,
className: "text-muted-foreground",
label: t(($) => $.detail.pull_request_merge_has_hooks),
};
case "none":
return null;
}
}
function getStateLabel(state: GitHubPullRequestState, t: IssuesT): string {
return state === "open"
? t(($) => $.detail.pull_request_state_open)
: state === "draft"
? t(($) => $.detail.pull_request_state_draft)
: state === "merged"
? t(($) => $.detail.pull_request_state_merged)
: state === "closed"
? t(($) => $.detail.pull_request_state_closed)
: state;
}