mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-26 04:25:46 +02:00
* 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>
365 lines
12 KiB
TypeScript
365 lines
12 KiB
TypeScript
import { describe, expect, it, vi } from "vitest";
|
||
import { render, screen } from "@testing-library/react";
|
||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||
import { I18nProvider } from "@multica/core/i18n/react";
|
||
import type { GitHubPullRequest } from "@multica/core/types";
|
||
import enCommon from "../../locales/en/common.json";
|
||
import enIssues from "../../locales/en/issues.json";
|
||
|
||
const TEST_RESOURCES = { en: { common: enCommon, issues: enIssues } };
|
||
|
||
vi.mock("@multica/core/github/queries", async () => {
|
||
const actual = await vi.importActual<typeof import("@multica/core/github/queries")>(
|
||
"@multica/core/github/queries",
|
||
);
|
||
return {
|
||
...actual,
|
||
issuePullRequestsOptions: (issueId: string) => ({
|
||
queryKey: ["github", "pull-requests", issueId],
|
||
queryFn: async () => ({ pull_requests: mockPRs }),
|
||
enabled: !!issueId,
|
||
}),
|
||
};
|
||
});
|
||
|
||
import { PullRequestList } from "./pull-request-list";
|
||
|
||
let mockPRs: GitHubPullRequest[] = [];
|
||
|
||
function makePR(overrides: Partial<GitHubPullRequest> = {}): GitHubPullRequest {
|
||
return {
|
||
id: "pr-1",
|
||
provider: "github",
|
||
workspace_id: "ws-1",
|
||
repo_owner: "acme",
|
||
repo_name: "widget",
|
||
number: 1,
|
||
title: "Test PR",
|
||
state: "open",
|
||
html_url: "https://example.test/pr/1",
|
||
branch: "feat/x",
|
||
author_login: "octocat",
|
||
author_avatar_url: null,
|
||
merged_at: null,
|
||
closed_at: null,
|
||
pr_created_at: "2026-01-01T00:00:00Z",
|
||
pr_updated_at: "2026-01-01T00:00:00Z",
|
||
mergeable: null,
|
||
merge_state_status: null,
|
||
snapshot_available: true,
|
||
checks_rollup: null,
|
||
checks_total: 0,
|
||
checks_passed: 0,
|
||
checks_failed: 0,
|
||
checks_running: 0,
|
||
failed_check_names: [],
|
||
snapshot_stale: false,
|
||
snapshot_fetched_at: null,
|
||
additions: 0,
|
||
deletions: 0,
|
||
changed_files: 0,
|
||
...overrides,
|
||
};
|
||
}
|
||
|
||
function renderList() {
|
||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||
return render(
|
||
<QueryClientProvider client={qc}>
|
||
<I18nProvider resources={TEST_RESOURCES} locale="en">
|
||
<PullRequestList issueId="issue-1" />
|
||
</I18nProvider>
|
||
</QueryClientProvider>,
|
||
);
|
||
}
|
||
|
||
async function waitForRender() {
|
||
return screen.findAllByRole("link");
|
||
}
|
||
|
||
describe("PullRequestList sidebar rows", () => {
|
||
it("uses the sidebar list-row surface instead of a card surface", async () => {
|
||
mockPRs = [makePR({ title: "Visual row" })];
|
||
renderList();
|
||
await waitForRender();
|
||
const row = screen.getByTestId("pull-request-row");
|
||
expect(row).toHaveClass("rounded-md", "-mx-2", "hover:bg-accent/50");
|
||
expect(row).not.toHaveClass("rounded-lg", "border", "bg-card");
|
||
});
|
||
|
||
// --- CI status element ---------------------------------------------------
|
||
|
||
it("renders all-checks-passed only when the rollup is success", async () => {
|
||
mockPRs = [makePR({ checks_rollup: "success", checks_total: 7 })];
|
||
renderList();
|
||
await waitForRender();
|
||
expect(screen.getByText("All checks passed (7/7)")).toBeInTheDocument();
|
||
});
|
||
|
||
it("renders 'No checks yet' when the rollup is absent — never passed", async () => {
|
||
// Acceptance criterion 5: absent snapshot must not read as a green build.
|
||
mockPRs = [makePR({ checks_rollup: null, checks_passed: 5, checks_total: 5 })];
|
||
renderList();
|
||
await waitForRender();
|
||
expect(screen.getByText("No checks yet")).toBeInTheDocument();
|
||
expect(screen.queryByText(/All checks passed/)).not.toBeInTheDocument();
|
||
});
|
||
|
||
it("hides snapshot status when the GitHub App key is unavailable, even with old data", async () => {
|
||
mockPRs = [
|
||
makePR({
|
||
snapshot_available: false,
|
||
checks_rollup: "failure",
|
||
checks_conclusion: "failed",
|
||
checks_total: 2,
|
||
checks_failed: 2,
|
||
mergeable: "conflicting",
|
||
merge_state_status: "dirty",
|
||
}),
|
||
];
|
||
renderList();
|
||
await waitForRender();
|
||
expect(screen.queryByText(/failed/)).not.toBeInTheDocument();
|
||
expect(screen.queryByText("No checks yet")).not.toBeInTheDocument();
|
||
expect(screen.queryByText("Has merge conflicts")).not.toBeInTheDocument();
|
||
});
|
||
|
||
it("renders failed count with the first failing check names", async () => {
|
||
mockPRs = [
|
||
makePR({
|
||
checks_rollup: "failure",
|
||
checks_total: 7,
|
||
checks_failed: 2,
|
||
failed_check_names: ["backend", "e2e"],
|
||
}),
|
||
];
|
||
renderList();
|
||
await waitForRender();
|
||
const badge = screen.getByText(/2\/7 failed/);
|
||
expect(badge).toHaveTextContent("2/7 failed");
|
||
expect(badge).toHaveTextContent("backend, e2e");
|
||
});
|
||
|
||
it("truncates the failing names to two and appends a +N more count", async () => {
|
||
mockPRs = [
|
||
makePR({
|
||
checks_rollup: "failure",
|
||
checks_total: 7,
|
||
checks_failed: 4,
|
||
failed_check_names: ["a", "b", "c", "d"],
|
||
}),
|
||
];
|
||
renderList();
|
||
await waitForRender();
|
||
const badge = screen.getByText(/4\/7 failed/);
|
||
expect(badge).toHaveTextContent("4/7 failed");
|
||
expect(badge).toHaveTextContent("a, b, +2 more");
|
||
});
|
||
|
||
it("renders the running count when the rollup is pending", async () => {
|
||
mockPRs = [
|
||
makePR({ checks_rollup: "pending", checks_total: 7, checks_passed: 5, checks_running: 2 }),
|
||
];
|
||
renderList();
|
||
await waitForRender();
|
||
const badge = screen.getByText(/2 running/);
|
||
expect(badge).toHaveTextContent("5/7");
|
||
expect(badge).toHaveTextContent("2 running");
|
||
});
|
||
|
||
it.each([
|
||
["forgejo", "passed", "All checks passed (3/3)"],
|
||
["gitea", "pending", "2/3 · 1 running"],
|
||
["gitlab", "failed", "1/3 failed"],
|
||
] as const)(
|
||
"preserves %s legacy %s check status",
|
||
async (provider, conclusion, expected) => {
|
||
mockPRs = [
|
||
makePR({
|
||
provider,
|
||
snapshot_available: undefined,
|
||
checks_rollup: undefined,
|
||
checks_conclusion: conclusion,
|
||
checks_total: 3,
|
||
checks_passed: conclusion === "passed" ? 3 : 2,
|
||
checks_failed: conclusion === "failed" ? 1 : 0,
|
||
checks_running: conclusion === "pending" ? 1 : 0,
|
||
checks_pending: conclusion === "pending" ? 1 : 0,
|
||
}),
|
||
];
|
||
renderList();
|
||
await waitForRender();
|
||
expect(screen.getByText(expected, { exact: false })).toBeInTheDocument();
|
||
},
|
||
);
|
||
|
||
// --- Mergeability element ------------------------------------------------
|
||
|
||
it("renders 'Ready to merge' only when the merge state is clean", async () => {
|
||
mockPRs = [makePR({ merge_state_status: "clean" })];
|
||
renderList();
|
||
await waitForRender();
|
||
expect(screen.getByText("Ready to merge")).toBeInTheDocument();
|
||
});
|
||
|
||
it("never infers 'Ready to merge' from mergeable alone", async () => {
|
||
// Acceptance criterion 8: mergeable without a clean state shows neither.
|
||
mockPRs = [makePR({ mergeable: "mergeable", merge_state_status: null })];
|
||
renderList();
|
||
await waitForRender();
|
||
expect(screen.queryByText("Ready to merge")).not.toBeInTheDocument();
|
||
expect(screen.queryByText("Has merge conflicts")).not.toBeInTheDocument();
|
||
});
|
||
|
||
it("renders 'Has merge conflicts' when mergeable is conflicting", async () => {
|
||
mockPRs = [makePR({ mergeable: "conflicting" })];
|
||
renderList();
|
||
await waitForRender();
|
||
expect(screen.getByText("Has merge conflicts")).toBeInTheDocument();
|
||
});
|
||
|
||
it("shows neither conflict nor ready when the merge verdict is unknown", async () => {
|
||
// Acceptance criterion 5: unknown mergeability shows neither element.
|
||
mockPRs = [makePR({ mergeable: "unknown", merge_state_status: "unknown" })];
|
||
renderList();
|
||
await waitForRender();
|
||
expect(screen.queryByText("Has merge conflicts")).not.toBeInTheDocument();
|
||
expect(screen.queryByText("Ready to merge")).not.toBeInTheDocument();
|
||
});
|
||
|
||
// --- The two elements are independent ------------------------------------
|
||
|
||
it("shows a failed CI element and a conflict element together", async () => {
|
||
mockPRs = [
|
||
makePR({
|
||
checks_rollup: "failure",
|
||
checks_total: 7,
|
||
checks_failed: 2,
|
||
failed_check_names: ["backend"],
|
||
mergeable: "conflicting",
|
||
}),
|
||
];
|
||
renderList();
|
||
await waitForRender();
|
||
expect(screen.getByText(/2\/7 failed/)).toHaveTextContent("2/7 failed");
|
||
expect(screen.getByText("Has merge conflicts")).toBeInTheDocument();
|
||
});
|
||
|
||
// --- Terminal PRs suppress both elements ---------------------------------
|
||
|
||
it("shows neither status element for merged PRs", async () => {
|
||
mockPRs = [
|
||
makePR({
|
||
state: "merged",
|
||
checks_rollup: "failure",
|
||
checks_failed: 5,
|
||
checks_total: 5,
|
||
mergeable: "conflicting",
|
||
}),
|
||
];
|
||
renderList();
|
||
await waitForRender();
|
||
expect(screen.queryByText(/failed/)).not.toBeInTheDocument();
|
||
expect(screen.queryByText("Has merge conflicts")).not.toBeInTheDocument();
|
||
expect(screen.queryByText("No checks yet")).not.toBeInTheDocument();
|
||
});
|
||
|
||
it("shows neither status element for closed PRs", async () => {
|
||
mockPRs = [
|
||
makePR({
|
||
state: "closed",
|
||
checks_rollup: "success",
|
||
checks_passed: 3,
|
||
checks_total: 3,
|
||
merge_state_status: "clean",
|
||
}),
|
||
];
|
||
renderList();
|
||
await waitForRender();
|
||
expect(screen.queryByText(/All checks passed/)).not.toBeInTheDocument();
|
||
expect(screen.queryByText("Ready to merge")).not.toBeInTheDocument();
|
||
expect(screen.queryByText("No checks yet")).not.toBeInTheDocument();
|
||
});
|
||
|
||
// --- Stale snapshot ------------------------------------------------------
|
||
|
||
it("greys out the status elements and annotates the age when the snapshot is stale", async () => {
|
||
mockPRs = [
|
||
makePR({
|
||
checks_rollup: "success",
|
||
checks_total: 3,
|
||
snapshot_stale: true,
|
||
snapshot_fetched_at: "2026-01-01T00:00:00Z",
|
||
}),
|
||
];
|
||
renderList();
|
||
await waitForRender();
|
||
const badge = screen.getByText("All checks passed (3/3)");
|
||
expect(badge).toHaveClass("opacity-60");
|
||
expect(badge).toHaveAttribute("title");
|
||
expect(badge.getAttribute("title")).toBeTruthy();
|
||
});
|
||
|
||
// --- Diff stats ----------------------------------------------------------
|
||
|
||
it("hides stats row when all stats are 0 (legacy backend)", async () => {
|
||
mockPRs = [makePR()];
|
||
renderList();
|
||
await waitForRender();
|
||
expect(screen.queryByText(/files?$/)).not.toBeInTheDocument();
|
||
expect(screen.queryByText(/^\+0/)).not.toBeInTheDocument();
|
||
});
|
||
|
||
it("shows stats row with additions / deletions / file count when present", async () => {
|
||
mockPRs = [makePR({ additions: 437, deletions: 6, changed_files: 6 })];
|
||
renderList();
|
||
await waitForRender();
|
||
expect(screen.getByText("+437")).toBeInTheDocument();
|
||
expect(screen.getByText("−6")).toBeInTheDocument();
|
||
expect(screen.getByText("6 files")).toBeInTheDocument();
|
||
});
|
||
|
||
it("uses singular file copy when changed_files=1", async () => {
|
||
mockPRs = [makePR({ additions: 1, changed_files: 1 })];
|
||
renderList();
|
||
await waitForRender();
|
||
expect(screen.getByText("1 file")).toBeInTheDocument();
|
||
});
|
||
|
||
// --- Collapse behaviour --------------------------------------------------
|
||
|
||
it("collapses extra PR rows past the visible limit behind Show more toggle", async () => {
|
||
mockPRs = [
|
||
makePR({ id: "a", number: 1, title: "PR-A" }),
|
||
makePR({ id: "b", number: 2, title: "PR-B" }),
|
||
makePR({ id: "c", number: 3, title: "PR-C" }),
|
||
makePR({ id: "d", number: 4, title: "PR-D" }),
|
||
makePR({ id: "e", number: 5, title: "PR-E" }),
|
||
];
|
||
renderList();
|
||
await waitForRender();
|
||
expect(screen.getByText("PR-A")).toBeInTheDocument();
|
||
expect(screen.getByText("PR-B")).toBeInTheDocument();
|
||
expect(screen.getByText("PR-C")).toBeInTheDocument();
|
||
expect(screen.queryByText("PR-D")).not.toBeInTheDocument();
|
||
expect(screen.queryByText("PR-E")).not.toBeInTheDocument();
|
||
expect(screen.getByText("Show 2 more")).toBeInTheDocument();
|
||
});
|
||
|
||
it("collapses to 3 rows + hidden tail when count == threshold", async () => {
|
||
mockPRs = [
|
||
makePR({ id: "a", number: 1, title: "PR-A" }),
|
||
makePR({ id: "b", number: 2, title: "PR-B" }),
|
||
makePR({ id: "c", number: 3, title: "PR-C" }),
|
||
makePR({ id: "d", number: 4, title: "PR-D" }),
|
||
];
|
||
renderList();
|
||
await waitForRender();
|
||
expect(screen.getByText("PR-A")).toBeInTheDocument();
|
||
expect(screen.getByText("PR-B")).toBeInTheDocument();
|
||
expect(screen.getByText("PR-C")).toBeInTheDocument();
|
||
expect(screen.queryByText("PR-D")).not.toBeInTheDocument();
|
||
expect(screen.getByText("Show 1 more")).toBeInTheDocument();
|
||
});
|
||
});
|