mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-17 19:59:20 +02:00
Compare commits
6 Commits
fix/cloud-
...
pr-2632
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
430a616f1d | ||
|
|
fe77eef7d0 | ||
|
|
297d2ef5de | ||
|
|
e9dab0022e | ||
|
|
8be60142d4 | ||
|
|
d2cd22a674 |
@@ -1 +1,2 @@
|
||||
export * from "./queries";
|
||||
export * from "./pull-request-status";
|
||||
|
||||
146
packages/core/github/pull-request-status.test.ts
Normal file
146
packages/core/github/pull-request-status.test.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
derivePullRequestStatusKind,
|
||||
derivePullRequestProgressSegments,
|
||||
shouldShowPullRequestStats,
|
||||
type PullRequestStatusInput,
|
||||
} from "./pull-request-status";
|
||||
|
||||
const base: PullRequestStatusInput = { state: "open" };
|
||||
|
||||
describe("derivePullRequestStatusKind", () => {
|
||||
it("closed beats every other signal", () => {
|
||||
expect(
|
||||
derivePullRequestStatusKind({
|
||||
state: "closed",
|
||||
mergeable_state: "dirty",
|
||||
checks_failed: 99,
|
||||
checks_pending: 99,
|
||||
checks_passed: 99,
|
||||
}),
|
||||
).toBe("closed");
|
||||
});
|
||||
|
||||
it("merged beats every other signal except closed", () => {
|
||||
expect(
|
||||
derivePullRequestStatusKind({
|
||||
state: "merged",
|
||||
mergeable_state: "dirty",
|
||||
checks_failed: 5,
|
||||
}),
|
||||
).toBe("merged");
|
||||
});
|
||||
|
||||
it("dirty conflicts wins over check signals", () => {
|
||||
expect(
|
||||
derivePullRequestStatusKind({
|
||||
...base,
|
||||
mergeable_state: "dirty",
|
||||
checks_passed: 3,
|
||||
}),
|
||||
).toBe("conflicts");
|
||||
});
|
||||
|
||||
it("any failed check beats pending and passed", () => {
|
||||
expect(
|
||||
derivePullRequestStatusKind({
|
||||
...base,
|
||||
checks_failed: 1,
|
||||
checks_pending: 3,
|
||||
checks_passed: 5,
|
||||
}),
|
||||
).toBe("checks_failed");
|
||||
});
|
||||
|
||||
it("pending beats passed when no failure", () => {
|
||||
expect(
|
||||
derivePullRequestStatusKind({
|
||||
...base,
|
||||
checks_pending: 1,
|
||||
checks_passed: 5,
|
||||
}),
|
||||
).toBe("checks_pending");
|
||||
});
|
||||
|
||||
it("all-passed is checks_passed regardless of mergeable=clean", () => {
|
||||
expect(
|
||||
derivePullRequestStatusKind({
|
||||
...base,
|
||||
mergeable_state: "clean",
|
||||
checks_passed: 5,
|
||||
}),
|
||||
).toBe("checks_passed");
|
||||
});
|
||||
|
||||
it("clean + no suites is ready-to-merge", () => {
|
||||
expect(
|
||||
derivePullRequestStatusKind({ ...base, mergeable_state: "clean" }),
|
||||
).toBe("ready");
|
||||
});
|
||||
|
||||
it("opaque mergeable values render as unknown", () => {
|
||||
for (const m of ["blocked", "behind", "unstable", "has_hooks", "unknown", null, undefined]) {
|
||||
expect(derivePullRequestStatusKind({ ...base, mergeable_state: m })).toBe("unknown");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("derivePullRequestProgressSegments", () => {
|
||||
it("returns null for terminal PRs (merged / closed)", () => {
|
||||
expect(derivePullRequestProgressSegments({ state: "merged", checks_passed: 5 })).toBeNull();
|
||||
expect(derivePullRequestProgressSegments({ state: "closed", checks_failed: 3 })).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when no suite has been observed", () => {
|
||||
expect(derivePullRequestProgressSegments({ ...base })).toBeNull();
|
||||
expect(
|
||||
derivePullRequestProgressSegments({ ...base, checks_failed: 0, checks_pending: 0, checks_passed: 0 }),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("orders segments failed → pending → passed (failure leftmost)", () => {
|
||||
const segs = derivePullRequestProgressSegments({
|
||||
...base,
|
||||
checks_failed: 1,
|
||||
checks_pending: 2,
|
||||
checks_passed: 3,
|
||||
});
|
||||
expect(segs).not.toBeNull();
|
||||
expect(segs!.map((s) => s.kind)).toEqual(["failed", "pending", "passed"]);
|
||||
});
|
||||
|
||||
it("emits a zero-width segment-free output (no entry with ratio 0)", () => {
|
||||
const segs = derivePullRequestProgressSegments({
|
||||
...base,
|
||||
checks_failed: 0,
|
||||
checks_pending: 0,
|
||||
checks_passed: 4,
|
||||
});
|
||||
expect(segs).toEqual([{ kind: "passed", ratio: 1 }]);
|
||||
});
|
||||
|
||||
it("ratios sum to ~1 across segments", () => {
|
||||
const segs = derivePullRequestProgressSegments({
|
||||
...base,
|
||||
checks_failed: 1,
|
||||
checks_pending: 1,
|
||||
checks_passed: 2,
|
||||
})!;
|
||||
const total = segs.reduce((acc, s) => acc + s.ratio, 0);
|
||||
expect(total).toBeCloseTo(1, 6);
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
101
packages/core/github/pull-request-status.ts
Normal file
101
packages/core/github/pull-request-status.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import type { GitHubPullRequest } from "../types";
|
||||
|
||||
// Status kinds rendered in the PR sidebar row's detail line. Order in the
|
||||
// pass-through table matters — the first matching rule wins. The order is
|
||||
// chosen so terminal PR states (closed / merged) short-circuit before any
|
||||
// transient CI/conflict signal, since those signals are no longer actionable
|
||||
// on a terminal PR.
|
||||
//
|
||||
// Priority (high → low):
|
||||
// 1. closed (not merged) → status_closed
|
||||
// 2. merged → status_merged
|
||||
// 3. mergeable_state = "dirty" → status_conflicts
|
||||
// 4. any failed suite → status_checks_failed
|
||||
// 5. any pending suite → status_checks_pending
|
||||
// 6. any passed suite → status_checks_passed
|
||||
// 7. no suite + mergeable=clean → status_ready
|
||||
// 8. otherwise → status_unknown
|
||||
//
|
||||
// Note: this table is the single source of truth for the sidebar PR row. The
|
||||
// older row-with-badges implementation used a separate "hide status row for
|
||||
// terminal PRs" branch — the current row renders
|
||||
// with status_closed / status_merged text, never falling through to a
|
||||
// conflicts / checks line on a terminal PR. Keep this priority order in sync
|
||||
// with the i18n keys `pull_request_card_status_*` and with the progress-strip
|
||||
// derivation in `derivePullRequestProgressSegments` (terminal kinds get a
|
||||
// solid bar; the rest map onto the per-suite counts).
|
||||
export type PullRequestStatusKind =
|
||||
| "closed"
|
||||
| "merged"
|
||||
| "conflicts"
|
||||
| "checks_failed"
|
||||
| "checks_pending"
|
||||
| "checks_passed"
|
||||
| "ready"
|
||||
| "unknown";
|
||||
|
||||
export interface PullRequestStatusInput {
|
||||
state: GitHubPullRequest["state"];
|
||||
mergeable_state?: string | null;
|
||||
checks_failed?: number;
|
||||
checks_pending?: number;
|
||||
checks_passed?: number;
|
||||
}
|
||||
|
||||
export function derivePullRequestStatusKind(input: PullRequestStatusInput): PullRequestStatusKind {
|
||||
if (input.state === "closed") return "closed";
|
||||
if (input.state === "merged") return "merged";
|
||||
if (input.mergeable_state === "dirty") return "conflicts";
|
||||
if ((input.checks_failed ?? 0) > 0) return "checks_failed";
|
||||
if ((input.checks_pending ?? 0) > 0) return "checks_pending";
|
||||
if ((input.checks_passed ?? 0) > 0) return "checks_passed";
|
||||
if (input.mergeable_state === "clean") return "ready";
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
export interface PullRequestProgressSegment {
|
||||
kind: "failed" | "pending" | "passed";
|
||||
ratio: number;
|
||||
}
|
||||
|
||||
// Segmented progress bar input. Returns null when:
|
||||
// - the PR is terminal (closed/merged) — the card paints a solid bar
|
||||
// in a state-specific color, no segmentation needed;
|
||||
// - no check_suite has been observed (total === 0) — the card hides
|
||||
// the bar entirely.
|
||||
// Otherwise emits the segments left-to-right: failed → pending → passed.
|
||||
// "Failure first" is intentional: problems should be visible before signal
|
||||
// that everything is fine.
|
||||
export function derivePullRequestProgressSegments(
|
||||
input: PullRequestStatusInput,
|
||||
): PullRequestProgressSegment[] | null {
|
||||
if (input.state === "closed" || input.state === "merged") return null;
|
||||
const failed = input.checks_failed ?? 0;
|
||||
const pending = input.checks_pending ?? 0;
|
||||
const passed = input.checks_passed ?? 0;
|
||||
const total = failed + pending + passed;
|
||||
if (total === 0) return null;
|
||||
const segments: PullRequestProgressSegment[] = [];
|
||||
if (failed > 0) segments.push({ kind: "failed", ratio: failed / total });
|
||||
if (pending > 0) segments.push({ kind: "pending", ratio: pending / total });
|
||||
if (passed > 0) segments.push({ kind: "passed", ratio: passed / total });
|
||||
return segments;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -1,5 +1,16 @@
|
||||
export type GitHubPullRequestState = "open" | "closed" | "merged" | "draft";
|
||||
|
||||
/** Aggregated CI status for a PR's current head SHA, computed server-side from
|
||||
* the latest check_suite per app. `null` when no completed suite has been seen
|
||||
* yet (e.g. PR just opened, or repository has no CI configured). */
|
||||
export type GitHubPullRequestChecksConclusion = "passed" | "failed" | "pending";
|
||||
|
||||
/** Raw mirror of GitHub's `mergeable_state`. The UI only surfaces `clean` and
|
||||
* `dirty`; the other values (`blocked`, `behind`, `unstable`, `unknown`,
|
||||
* `has_hooks`, `draft`) round-trip but render as unknown to avoid asserting
|
||||
* "conflicts" for blocking reasons that aren't actual conflicts. */
|
||||
export type GitHubMergeableState = string;
|
||||
|
||||
export interface GitHubInstallation {
|
||||
id: string;
|
||||
workspace_id: string;
|
||||
@@ -26,6 +37,20 @@ export interface GitHubPullRequest {
|
||||
closed_at: string | null;
|
||||
pr_created_at: string;
|
||||
pr_updated_at: string;
|
||||
/** Optional; older backends omit this field. */
|
||||
mergeable_state?: GitHubMergeableState | null;
|
||||
/** Optional; older backends omit this field. */
|
||||
checks_conclusion?: GitHubPullRequestChecksConclusion | null;
|
||||
/** Per-suite counts that feed the segmented progress bar. Older backends
|
||||
* omit these; treat absence as 0 (the card renders only when sum > 0). */
|
||||
checks_passed?: number;
|
||||
checks_failed?: number;
|
||||
checks_pending?: number;
|
||||
/** Diff stats from GitHub's `pull_request` payload. Older backends omit
|
||||
* these fields; we treat 0/0/0 as "unknown" and hide the stats row. */
|
||||
additions?: number;
|
||||
deletions?: number;
|
||||
changed_files?: number;
|
||||
}
|
||||
|
||||
export interface ListGitHubInstallationsResponse {
|
||||
|
||||
@@ -78,7 +78,9 @@ export type {
|
||||
export type { PinnedItem, PinnedItemType, CreatePinRequest, ReorderPinsRequest } from "./pin";
|
||||
export type {
|
||||
GitHubInstallation,
|
||||
GitHubMergeableState,
|
||||
GitHubPullRequest,
|
||||
GitHubPullRequestChecksConclusion,
|
||||
GitHubPullRequestState,
|
||||
ListGitHubInstallationsResponse,
|
||||
GitHubConnectResponse,
|
||||
|
||||
211
packages/views/issues/components/pull-request-list.test.tsx
Normal file
211
packages/views/issues/components/pull-request-list.test.tsx
Normal file
@@ -0,0 +1,211 @@
|
||||
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",
|
||||
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_state: null,
|
||||
checks_conclusion: null,
|
||||
checks_passed: 0,
|
||||
checks_failed: 0,
|
||||
checks_pending: 0,
|
||||
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");
|
||||
});
|
||||
|
||||
it("renders All-checks-passed status when only passed counts are non-zero", async () => {
|
||||
mockPRs = [makePR({ checks_passed: 3 })];
|
||||
renderList();
|
||||
await waitForRender();
|
||||
expect(screen.getByText("All checks passed")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders Some-checks-failed when any failed count is non-zero", async () => {
|
||||
mockPRs = [makePR({ checks_failed: 1, checks_passed: 5 })];
|
||||
renderList();
|
||||
await waitForRender();
|
||||
expect(screen.getByText("Some checks failed")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders pending status when only pending suites remain", async () => {
|
||||
mockPRs = [makePR({ checks_pending: 2, checks_passed: 1 })];
|
||||
renderList();
|
||||
await waitForRender();
|
||||
expect(screen.getByText("Some checks haven't completed yet")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders conflicts status when mergeable_state=dirty", async () => {
|
||||
mockPRs = [makePR({ mergeable_state: "dirty" })];
|
||||
renderList();
|
||||
await waitForRender();
|
||||
expect(screen.getByText("Has merge conflicts")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders Ready-to-merge when mergeable=clean and no suites observed", async () => {
|
||||
mockPRs = [makePR({ mergeable_state: "clean" })];
|
||||
renderList();
|
||||
await waitForRender();
|
||||
expect(screen.getByText("Ready to merge")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders Merged status for merged PRs, suppressing conflict/check text", async () => {
|
||||
mockPRs = [
|
||||
makePR({
|
||||
state: "merged",
|
||||
mergeable_state: "dirty",
|
||||
checks_conclusion: "failed",
|
||||
checks_failed: 5,
|
||||
}),
|
||||
];
|
||||
renderList();
|
||||
await waitForRender();
|
||||
expect(screen.getByText("Merged")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Has merge conflicts")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Some checks failed")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Conflicts")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Checks failed")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders Closed-without-merging status for closed PRs, suppressing conflict/check badges", async () => {
|
||||
mockPRs = [
|
||||
makePR({
|
||||
state: "closed",
|
||||
mergeable_state: "clean",
|
||||
checks_conclusion: "passed",
|
||||
checks_passed: 3,
|
||||
}),
|
||||
];
|
||||
renderList();
|
||||
await waitForRender();
|
||||
expect(screen.getByText("Closed without merging")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Ready to merge")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("All checks passed")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("No conflicts")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Checks passed")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -1,18 +1,40 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
CheckCircle2,
|
||||
CircleDashed,
|
||||
GitMerge,
|
||||
GitPullRequest,
|
||||
GitPullRequestArrow,
|
||||
GitPullRequestClosed,
|
||||
GitMerge,
|
||||
GitPullRequestDraft,
|
||||
TriangleAlert,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
import { issuePullRequestsOptions } from "@multica/core/github/queries";
|
||||
import type { GitHubPullRequest, GitHubPullRequestState } from "@multica/core/types";
|
||||
import {
|
||||
issuePullRequestsOptions,
|
||||
derivePullRequestStatusKind,
|
||||
derivePullRequestProgressSegments,
|
||||
shouldShowPullRequestStats,
|
||||
type PullRequestStatusKind,
|
||||
type PullRequestProgressSegment,
|
||||
} from "@multica/core/github";
|
||||
import type {
|
||||
GitHubPullRequest,
|
||||
GitHubPullRequestChecksConclusion,
|
||||
GitHubPullRequestState,
|
||||
} from "@multica/core/types";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import { useT } 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 }
|
||||
@@ -23,8 +45,18 @@ const STATE_ICON: Record<
|
||||
closed: { icon: GitPullRequestClosed, className: "text-rose-600 dark:text-rose-400" },
|
||||
};
|
||||
|
||||
const CHECKS_ICON: Record<
|
||||
GitHubPullRequestChecksConclusion,
|
||||
{ icon: React.ComponentType<{ className?: string }>; className: string }
|
||||
> = {
|
||||
passed: { icon: CheckCircle2, className: "text-emerald-600 dark:text-emerald-400" },
|
||||
failed: { icon: XCircle, className: "text-rose-600 dark:text-rose-400" },
|
||||
pending: { icon: CircleDashed, className: "text-amber-600 dark:text-amber-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 ?? [];
|
||||
|
||||
@@ -39,11 +71,35 @@ export function PullRequestList({ issueId }: { issueId: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
// 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">
|
||||
{prs.map((pr) => (
|
||||
{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>
|
||||
);
|
||||
}
|
||||
@@ -51,32 +107,230 @@ export function PullRequestList({ issueId }: { issueId: string }) {
|
||||
function PullRequestRow({ pr }: { pr: GitHubPullRequest }) {
|
||||
const { t } = useT("issues");
|
||||
const cfg = STATE_ICON[pr.state] ?? { icon: GitPullRequest, className: "" };
|
||||
const Icon = cfg.icon;
|
||||
const label =
|
||||
pr.state === "open"
|
||||
? t(($) => $.detail.pull_request_state_open)
|
||||
: pr.state === "draft"
|
||||
? t(($) => $.detail.pull_request_state_draft)
|
||||
: pr.state === "merged"
|
||||
? t(($) => $.detail.pull_request_state_merged)
|
||||
: pr.state === "closed"
|
||||
? t(($) => $.detail.pull_request_state_closed)
|
||||
: pr.state;
|
||||
const StateIcon = cfg.icon;
|
||||
const kind = derivePullRequestStatusKind({
|
||||
state: pr.state,
|
||||
mergeable_state: pr.mergeable_state,
|
||||
checks_failed: pr.checks_failed,
|
||||
checks_pending: pr.checks_pending,
|
||||
checks_passed: pr.checks_passed,
|
||||
});
|
||||
const segments = derivePullRequestProgressSegments({
|
||||
state: pr.state,
|
||||
checks_failed: pr.checks_failed,
|
||||
checks_pending: pr.checks_pending,
|
||||
checks_passed: pr.checks_passed,
|
||||
});
|
||||
const showStats = shouldShowPullRequestStats({
|
||||
additions: pr.additions,
|
||||
deletions: pr.deletions,
|
||||
changed_files: pr.changed_files,
|
||||
});
|
||||
const statusText = useStatusText(kind);
|
||||
const draftPrefix = 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="flex items-start gap-2 rounded-md px-2 py-1.5 -mx-2 hover:bg-accent/50 transition-colors group"
|
||||
className={cn(
|
||||
"flex items-start gap-2 rounded-md px-2 py-1.5 -mx-2 hover:bg-accent/50 transition-colors group",
|
||||
draftPrefix ? "opacity-80" : null,
|
||||
)}
|
||||
>
|
||||
<Icon className={cn("h-3.5 w-3.5 mt-0.5 shrink-0", cfg.className)} />
|
||||
<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 truncate group-hover:text-foreground">{pr.title}</p>
|
||||
<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} · {label}
|
||||
{pr.repo_owner}/{pr.repo_name}#{pr.number} · {stateLabel}
|
||||
{pr.author_login ? ` · @${pr.author_login}` : null}
|
||||
</p>
|
||||
<PullRequestRowDetails
|
||||
pr={pr}
|
||||
segments={segments}
|
||||
showStats={showStats}
|
||||
statusText={
|
||||
draftPrefix
|
||||
? t(($) => $.detail.pull_request_card_draft_prefix, { status: statusText })
|
||||
: statusText
|
||||
}
|
||||
statusKind={kind}
|
||||
/>
|
||||
</div>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
function PullRequestRowDetails({
|
||||
pr,
|
||||
segments,
|
||||
showStats,
|
||||
statusText,
|
||||
statusKind,
|
||||
}: {
|
||||
pr: GitHubPullRequest;
|
||||
segments: PullRequestProgressSegment[] | null;
|
||||
showStats: boolean;
|
||||
statusText: string;
|
||||
statusKind: PullRequestStatusKind;
|
||||
}) {
|
||||
const { t } = useT("issues");
|
||||
const checksBadge = getChecksBadge(pr, t);
|
||||
const conflictsBadge = getConflictsBadge(pr, t);
|
||||
const isTerminal = statusKind === "closed" || statusKind === "merged";
|
||||
const showChecksBadge =
|
||||
!isTerminal &&
|
||||
!!checksBadge &&
|
||||
statusKind !== "checks_failed" &&
|
||||
statusKind !== "checks_pending" &&
|
||||
statusKind !== "checks_passed";
|
||||
const showConflictsBadge =
|
||||
!isTerminal && !!conflictsBadge && statusKind !== "conflicts" && statusKind !== "ready";
|
||||
|
||||
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}
|
||||
<PullRequestProgressStrip segments={segments} />
|
||||
<span className="truncate">{statusText}</span>
|
||||
{showChecksBadge ? <PullRequestBadge badge={checksBadge} /> : null}
|
||||
{showConflictsBadge ? <PullRequestBadge badge={conflictsBadge} /> : 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>
|
||||
);
|
||||
}
|
||||
|
||||
function PullRequestProgressStrip({
|
||||
segments,
|
||||
}: {
|
||||
segments: PullRequestProgressSegment[] | null;
|
||||
}) {
|
||||
if (!segments) return null;
|
||||
return (
|
||||
<span className="flex h-1 w-12 shrink-0 overflow-hidden rounded-full bg-muted" aria-hidden="true">
|
||||
{segments.map((seg) => (
|
||||
<span
|
||||
key={seg.kind}
|
||||
className={cn(
|
||||
"h-full block",
|
||||
seg.kind === "failed" && "bg-rose-500 dark:bg-rose-400",
|
||||
seg.kind === "pending" && "bg-amber-500 dark:bg-amber-400",
|
||||
seg.kind === "passed" && "bg-emerald-500 dark:bg-emerald-400",
|
||||
)}
|
||||
style={{ width: `${seg.ratio * 100}%` }}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
interface PullRequestBadgeConfig {
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
label: string;
|
||||
className: string;
|
||||
}
|
||||
|
||||
function PullRequestBadge({ badge }: { badge: PullRequestBadgeConfig }) {
|
||||
const Icon = badge.icon;
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Icon className={cn("h-3 w-3", badge.className)} />
|
||||
{badge.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function getConflictsBadge(
|
||||
pr: GitHubPullRequest,
|
||||
t: IssuesT,
|
||||
): PullRequestBadgeConfig | null {
|
||||
const mergeable = pr.mergeable_state ?? null;
|
||||
return mergeable === "dirty"
|
||||
? {
|
||||
icon: TriangleAlert,
|
||||
label: t(($) => $.detail.pull_request_conflicts_dirty),
|
||||
className: "text-rose-600 dark:text-rose-400",
|
||||
}
|
||||
: mergeable === "clean"
|
||||
? {
|
||||
icon: CheckCircle2,
|
||||
label: t(($) => $.detail.pull_request_conflicts_clean),
|
||||
className: "text-emerald-600 dark:text-emerald-400",
|
||||
}
|
||||
: null;
|
||||
}
|
||||
|
||||
function getChecksBadge(
|
||||
pr: GitHubPullRequest,
|
||||
t: IssuesT,
|
||||
): PullRequestBadgeConfig | null {
|
||||
const checks = pr.checks_conclusion ?? null;
|
||||
return checks && CHECKS_ICON[checks]
|
||||
? {
|
||||
icon: CHECKS_ICON[checks].icon,
|
||||
className: CHECKS_ICON[checks].className,
|
||||
label:
|
||||
checks === "passed"
|
||||
? t(($) => $.detail.pull_request_checks_passed)
|
||||
: checks === "failed"
|
||||
? t(($) => $.detail.pull_request_checks_failed)
|
||||
: t(($) => $.detail.pull_request_checks_pending),
|
||||
}
|
||||
: 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;
|
||||
}
|
||||
|
||||
function useStatusText(kind: PullRequestStatusKind): string {
|
||||
const { t } = useT("issues");
|
||||
switch (kind) {
|
||||
case "closed":
|
||||
return t(($) => $.detail.pull_request_card_status_closed);
|
||||
case "merged":
|
||||
return t(($) => $.detail.pull_request_card_status_merged);
|
||||
case "conflicts":
|
||||
return t(($) => $.detail.pull_request_card_status_conflicts);
|
||||
case "checks_failed":
|
||||
return t(($) => $.detail.pull_request_card_status_checks_failed);
|
||||
case "checks_pending":
|
||||
return t(($) => $.detail.pull_request_card_status_checks_pending);
|
||||
case "checks_passed":
|
||||
return t(($) => $.detail.pull_request_card_status_checks_passed);
|
||||
case "ready":
|
||||
return t(($) => $.detail.pull_request_card_status_ready);
|
||||
case "unknown":
|
||||
return t(($) => $.detail.pull_request_card_status_unknown);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,6 +108,24 @@
|
||||
"pull_request_state_draft": "Draft",
|
||||
"pull_request_state_merged": "Merged",
|
||||
"pull_request_state_closed": "Closed",
|
||||
"pull_request_checks_passed": "Checks passed",
|
||||
"pull_request_checks_failed": "Checks failed",
|
||||
"pull_request_checks_pending": "Checks pending",
|
||||
"pull_request_conflicts_clean": "No conflicts",
|
||||
"pull_request_conflicts_dirty": "Conflicts",
|
||||
"pull_request_card_status_closed": "Closed without merging",
|
||||
"pull_request_card_status_merged": "Merged",
|
||||
"pull_request_card_status_conflicts": "Has merge conflicts",
|
||||
"pull_request_card_status_checks_failed": "Some checks failed",
|
||||
"pull_request_card_status_checks_pending": "Some checks haven't completed yet",
|
||||
"pull_request_card_status_checks_passed": "All checks passed",
|
||||
"pull_request_card_status_ready": "Ready to merge",
|
||||
"pull_request_card_status_unknown": "Checks haven't reported yet",
|
||||
"pull_request_card_draft_prefix": "Draft · {{status}}",
|
||||
"pull_request_card_files_count_one": "{{count}} file",
|
||||
"pull_request_card_files_count_other": "{{count}} files",
|
||||
"pull_request_card_show_more": "Show {{count}} more",
|
||||
"pull_request_card_show_less": "Show less",
|
||||
"prop_status": "Status",
|
||||
"prop_priority": "Priority",
|
||||
"prop_assignee": "Assignee",
|
||||
|
||||
@@ -107,6 +107,24 @@
|
||||
"pull_request_state_draft": "Draft",
|
||||
"pull_request_state_merged": "Merged",
|
||||
"pull_request_state_closed": "Closed",
|
||||
"pull_request_checks_passed": "Checks 通过",
|
||||
"pull_request_checks_failed": "Checks 失败",
|
||||
"pull_request_checks_pending": "Checks 运行中",
|
||||
"pull_request_conflicts_clean": "无冲突",
|
||||
"pull_request_conflicts_dirty": "存在冲突",
|
||||
"pull_request_card_status_closed": "已关闭,未合入",
|
||||
"pull_request_card_status_merged": "已合入",
|
||||
"pull_request_card_status_conflicts": "存在合并冲突",
|
||||
"pull_request_card_status_checks_failed": "部分检查失败",
|
||||
"pull_request_card_status_checks_pending": "部分检查仍在运行",
|
||||
"pull_request_card_status_checks_passed": "全部检查通过",
|
||||
"pull_request_card_status_ready": "可以合入",
|
||||
"pull_request_card_status_unknown": "暂无检查信息",
|
||||
"pull_request_card_draft_prefix": "Draft · {{status}}",
|
||||
"pull_request_card_files_count_one": "{{count}} 个文件",
|
||||
"pull_request_card_files_count_other": "{{count}} 个文件",
|
||||
"pull_request_card_show_more": "展开剩余 {{count}} 个",
|
||||
"pull_request_card_show_less": "收起",
|
||||
"prop_status": "状态",
|
||||
"prop_priority": "优先级",
|
||||
"prop_assignee": "负责人",
|
||||
|
||||
91
scripts/screenshot-pr-cards.mjs
Normal file
91
scripts/screenshot-pr-cards.mjs
Normal file
@@ -0,0 +1,91 @@
|
||||
// Standalone screenshot capture for the 6 PR-card demo issues. Logs in as
|
||||
// dev@localhost via the local /auth send-code → verify-code flow, then opens
|
||||
// each /dev/issues/DEV-N and saves a clipped PNG focused on the right sidebar.
|
||||
//
|
||||
// Run: pnpm exec node scripts/screenshot-pr-cards.mjs
|
||||
// Output: ./.screenshots/pr-card-DEV-{2..7}.png
|
||||
|
||||
import { chromium } from "@playwright/test";
|
||||
import pg from "pg";
|
||||
import { mkdirSync } from "node:fs";
|
||||
|
||||
const FRONTEND = process.env.FRONTEND_ORIGIN || "http://localhost:13101";
|
||||
const API = process.env.NEXT_PUBLIC_API_URL || "http://localhost:18181";
|
||||
const DB = process.env.DATABASE_URL || "postgres://multica:multica@localhost:5432/multica_multica_101?sslmode=disable";
|
||||
const EMAIL = "dev@localhost";
|
||||
const SLUG = "dev";
|
||||
const ISSUES = [2, 3, 4, 5, 6, 7];
|
||||
|
||||
async function loginAndGetToken() {
|
||||
const client = new pg.Client(DB);
|
||||
await client.connect();
|
||||
try {
|
||||
await client.query("DELETE FROM verification_code WHERE email = $1", [EMAIL]);
|
||||
const sendRes = await fetch(`${API}/auth/send-code`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email: EMAIL }),
|
||||
});
|
||||
if (!sendRes.ok) throw new Error(`send-code: ${sendRes.status}`);
|
||||
const row = await client.query(
|
||||
"SELECT code FROM verification_code WHERE email=$1 AND used=FALSE AND expires_at>now() ORDER BY created_at DESC LIMIT 1",
|
||||
[EMAIL],
|
||||
);
|
||||
if (row.rows.length === 0) throw new Error("no verification code");
|
||||
const verifyRes = await fetch(`${API}/auth/verify-code`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email: EMAIL, code: row.rows[0].code }),
|
||||
});
|
||||
if (!verifyRes.ok) throw new Error(`verify-code: ${verifyRes.status}`);
|
||||
const data = await verifyRes.json();
|
||||
return data.token;
|
||||
} finally {
|
||||
await client.end();
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
mkdirSync(".screenshots", { recursive: true });
|
||||
const token = await loginAndGetToken();
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const ctx = await browser.newContext({ viewport: { width: 1600, height: 1200 } });
|
||||
const page = await ctx.newPage();
|
||||
|
||||
// Set token before navigation so the renderer hits an authenticated state.
|
||||
await page.goto(`${FRONTEND}/login`);
|
||||
await page.evaluate((t) => localStorage.setItem("multica_token", t), token);
|
||||
|
||||
for (const n of ISSUES) {
|
||||
const url = `${FRONTEND}/${SLUG}/issues/DEV-${n}`;
|
||||
console.log("→", url);
|
||||
await page.goto(url, { waitUntil: "domcontentloaded" });
|
||||
// Wait for the PR section header to appear so the card has rendered.
|
||||
const prSection = page.locator("text=Pull requests").first();
|
||||
await prSection.waitFor({ timeout: 15000 }).catch(() => undefined);
|
||||
|
||||
// Close the floating chat widget — it overlays the right sidebar and
|
||||
// hides exactly the area we want to capture. The minimize button has
|
||||
// aria-label or just an icon; brute-force any chat close/minimize.
|
||||
const closeChat = page.locator('[aria-label="Minimize" i], [aria-label="Close" i], button[title="Minimize" i]').first();
|
||||
if (await closeChat.count()) {
|
||||
await closeChat.click({ trial: false }).catch(() => undefined);
|
||||
}
|
||||
// Fallback: click on the chat header to collapse it.
|
||||
await page.locator("text=New chat").first().click({ timeout: 1000 }).catch(() => undefined);
|
||||
|
||||
await page.waitForTimeout(400);
|
||||
// Scroll the PR section into view.
|
||||
await prSection.scrollIntoViewIfNeeded().catch(() => undefined);
|
||||
await page.waitForTimeout(300);
|
||||
const out = `.screenshots/pr-card-DEV-${n}.png`;
|
||||
await page.screenshot({ path: out, fullPage: false });
|
||||
console.log(" saved", out);
|
||||
}
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -54,6 +54,27 @@ type GitHubPullRequestResponse struct {
|
||||
ClosedAt *string `json:"closed_at"`
|
||||
PRCreatedAt string `json:"pr_created_at"`
|
||||
PRUpdatedAt string `json:"pr_updated_at"`
|
||||
// Mergeable state mirrors GitHub's `mergeable_state` field. We only
|
||||
// surface `clean`/`dirty` in the UI today; other values (`blocked`,
|
||||
// `behind`, `unstable`, `unknown`) round-trip but render as unknown.
|
||||
MergeableState *string `json:"mergeable_state"`
|
||||
// ChecksConclusion is the aggregated state of the latest CI check
|
||||
// suites for the PR's current head SHA. One of "passed", "failed",
|
||||
// "pending", or nil when no completed suite has been observed.
|
||||
ChecksConclusion *string `json:"checks_conclusion"`
|
||||
// Per-suite counts that drive the card's segmented progress bar.
|
||||
// Always present on list rows; bare upsert broadcasts default to 0
|
||||
// and the frontend hides the bar when total == 0.
|
||||
ChecksPassed int64 `json:"checks_passed"`
|
||||
ChecksFailed int64 `json:"checks_failed"`
|
||||
ChecksPending int64 `json:"checks_pending"`
|
||||
// Diff stats (lines added/removed and file count) sourced from the
|
||||
// `pull_request` webhook payload. Legacy rows that pre-date this
|
||||
// field default to 0; the frontend treats total == 0 as "unknown"
|
||||
// and hides the stats row.
|
||||
Additions int32 `json:"additions"`
|
||||
Deletions int32 `json:"deletions"`
|
||||
ChangedFiles int32 `json:"changed_files"`
|
||||
}
|
||||
|
||||
type GitHubConnectResponse struct {
|
||||
@@ -90,9 +111,69 @@ func githubPullRequestToResponse(p db.GithubPullRequest) GitHubPullRequestRespon
|
||||
ClosedAt: timestampToPtr(p.ClosedAt),
|
||||
PRCreatedAt: timestampToString(p.PrCreatedAt),
|
||||
PRUpdatedAt: timestampToString(p.PrUpdatedAt),
|
||||
MergeableState: textToPtr(p.MergeableState),
|
||||
// A bare PR row has no aggregated check counts — webhook
|
||||
// broadcasts of a single PR fall through here and the frontend
|
||||
// re-queries the list for fresh counts.
|
||||
ChecksConclusion: nil,
|
||||
Additions: p.Additions,
|
||||
Deletions: p.Deletions,
|
||||
ChangedFiles: p.ChangedFiles,
|
||||
}
|
||||
}
|
||||
|
||||
func issuePullRequestRowToResponse(p db.ListPullRequestsByIssueRow) GitHubPullRequestResponse {
|
||||
return GitHubPullRequestResponse{
|
||||
ID: uuidToString(p.ID),
|
||||
WorkspaceID: uuidToString(p.WorkspaceID),
|
||||
RepoOwner: p.RepoOwner,
|
||||
RepoName: p.RepoName,
|
||||
Number: p.PrNumber,
|
||||
Title: p.Title,
|
||||
State: p.State,
|
||||
HtmlURL: p.HtmlUrl,
|
||||
Branch: textToPtr(p.Branch),
|
||||
AuthorLogin: textToPtr(p.AuthorLogin),
|
||||
AuthorAvatarURL: textToPtr(p.AuthorAvatarUrl),
|
||||
MergedAt: timestampToPtr(p.MergedAt),
|
||||
ClosedAt: timestampToPtr(p.ClosedAt),
|
||||
PRCreatedAt: timestampToString(p.PrCreatedAt),
|
||||
PRUpdatedAt: timestampToString(p.PrUpdatedAt),
|
||||
MergeableState: textToPtr(p.MergeableState),
|
||||
ChecksConclusion: aggregateChecksConclusion(p.ChecksFailed, p.ChecksPassed, p.ChecksPending, p.ChecksTotal),
|
||||
ChecksPassed: p.ChecksPassed,
|
||||
ChecksFailed: p.ChecksFailed,
|
||||
ChecksPending: p.ChecksPending,
|
||||
Additions: p.Additions,
|
||||
Deletions: p.Deletions,
|
||||
ChangedFiles: p.ChangedFiles,
|
||||
}
|
||||
}
|
||||
|
||||
// aggregateChecksConclusion collapses the per-PR check_suite counts into a
|
||||
// single status surfaced to the UI:
|
||||
// - any failed-class suite wins ("failed");
|
||||
// - any not-yet-completed suite makes the PR "pending";
|
||||
// - all completed and in the passed-class is "passed";
|
||||
// - no observed suite at all is nil (rendered as "no checks" / hidden).
|
||||
func aggregateChecksConclusion(failed, passed, pending, total int64) *string {
|
||||
if total == 0 {
|
||||
return nil
|
||||
}
|
||||
var v string
|
||||
switch {
|
||||
case failed > 0:
|
||||
v = "failed"
|
||||
case pending > 0:
|
||||
v = "pending"
|
||||
case passed > 0:
|
||||
v = "passed"
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
return &v
|
||||
}
|
||||
|
||||
// ── Connect / state token ───────────────────────────────────────────────────
|
||||
|
||||
// githubAppSlug returns the GitHub App slug used to build the install URL.
|
||||
@@ -350,7 +431,7 @@ func (h *Handler) ListPullRequestsForIssue(w http.ResponseWriter, r *http.Reques
|
||||
}
|
||||
out := make([]GitHubPullRequestResponse, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
out = append(out, githubPullRequestToResponse(row))
|
||||
out = append(out, issuePullRequestRowToResponse(row))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"pull_requests": out})
|
||||
}
|
||||
@@ -396,6 +477,8 @@ func (h *Handler) HandleGitHubWebhook(w http.ResponseWriter, r *http.Request) {
|
||||
h.handleInstallationEvent(ctx, body)
|
||||
case "pull_request":
|
||||
h.handlePullRequestEvent(ctx, body)
|
||||
case "check_suite":
|
||||
h.handleCheckSuiteEvent(ctx, body)
|
||||
default:
|
||||
// Acknowledge every event so GitHub doesn't mark the endpoint failing,
|
||||
// but ignore types we don't model.
|
||||
@@ -481,25 +564,31 @@ func (h *Handler) handleInstallationEvent(ctx context.Context, body []byte) {
|
||||
type ghPullRequestPayload struct {
|
||||
Action string `json:"action"`
|
||||
PullRequest struct {
|
||||
Number int32 `json:"number"`
|
||||
HTMLURL string `json:"html_url"`
|
||||
Title string `json:"title"`
|
||||
Body string `json:"body"`
|
||||
State string `json:"state"`
|
||||
Draft bool `json:"draft"`
|
||||
Merged bool `json:"merged"`
|
||||
MergedAt string `json:"merged_at"`
|
||||
ClosedAt string `json:"closed_at"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
Head struct {
|
||||
Number int32 `json:"number"`
|
||||
HTMLURL string `json:"html_url"`
|
||||
Title string `json:"title"`
|
||||
Body string `json:"body"`
|
||||
State string `json:"state"`
|
||||
Draft bool `json:"draft"`
|
||||
Merged bool `json:"merged"`
|
||||
MergedAt string `json:"merged_at"`
|
||||
ClosedAt string `json:"closed_at"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
MergeableState string `json:"mergeable_state"`
|
||||
Additions int32 `json:"additions"`
|
||||
Deletions int32 `json:"deletions"`
|
||||
ChangedFiles int32 `json:"changed_files"`
|
||||
Head struct {
|
||||
Ref string `json:"ref"`
|
||||
SHA string `json:"sha"`
|
||||
} `json:"head"`
|
||||
User struct {
|
||||
Login string `json:"login"`
|
||||
AvatarURL string `json:"avatar_url"`
|
||||
} `json:"user"`
|
||||
} `json:"pull_request"`
|
||||
Changes *ghPRChanges `json:"changes"`
|
||||
Repository struct {
|
||||
Name string `json:"name"`
|
||||
Owner struct {
|
||||
@@ -531,22 +620,29 @@ func (h *Handler) handlePullRequestEvent(ctx context.Context, body []byte) {
|
||||
}
|
||||
|
||||
state := derivePRState(p.PullRequest.State, p.PullRequest.Draft, p.PullRequest.Merged)
|
||||
mergeable, clearMergeable := derivePRMergeableState(p.Action, p.PullRequest.MergeableState, baseRefChanged(p.Changes))
|
||||
pr, err := h.Queries.UpsertGitHubPullRequest(ctx, db.UpsertGitHubPullRequestParams{
|
||||
WorkspaceID: inst.WorkspaceID,
|
||||
InstallationID: inst.InstallationID,
|
||||
RepoOwner: p.Repository.Owner.Login,
|
||||
RepoName: p.Repository.Name,
|
||||
PrNumber: p.PullRequest.Number,
|
||||
Title: p.PullRequest.Title,
|
||||
State: state,
|
||||
HtmlUrl: p.PullRequest.HTMLURL,
|
||||
Branch: ptrToText(strPtrOrNil(p.PullRequest.Head.Ref)),
|
||||
AuthorLogin: ptrToText(strPtrOrNil(p.PullRequest.User.Login)),
|
||||
AuthorAvatarUrl: ptrToText(strPtrOrNil(p.PullRequest.User.AvatarURL)),
|
||||
MergedAt: parseGHTime(p.PullRequest.MergedAt),
|
||||
ClosedAt: parseGHTime(p.PullRequest.ClosedAt),
|
||||
PrCreatedAt: parseGHTimeRequired(p.PullRequest.CreatedAt),
|
||||
PrUpdatedAt: parseGHTimeRequired(p.PullRequest.UpdatedAt),
|
||||
WorkspaceID: inst.WorkspaceID,
|
||||
InstallationID: inst.InstallationID,
|
||||
RepoOwner: p.Repository.Owner.Login,
|
||||
RepoName: p.Repository.Name,
|
||||
PrNumber: p.PullRequest.Number,
|
||||
Title: p.PullRequest.Title,
|
||||
State: state,
|
||||
HtmlUrl: p.PullRequest.HTMLURL,
|
||||
Branch: ptrToText(strPtrOrNil(p.PullRequest.Head.Ref)),
|
||||
AuthorLogin: ptrToText(strPtrOrNil(p.PullRequest.User.Login)),
|
||||
AuthorAvatarUrl: ptrToText(strPtrOrNil(p.PullRequest.User.AvatarURL)),
|
||||
MergedAt: parseGHTime(p.PullRequest.MergedAt),
|
||||
ClosedAt: parseGHTime(p.PullRequest.ClosedAt),
|
||||
PrCreatedAt: parseGHTimeRequired(p.PullRequest.CreatedAt),
|
||||
PrUpdatedAt: parseGHTimeRequired(p.PullRequest.UpdatedAt),
|
||||
HeadSha: p.PullRequest.Head.SHA,
|
||||
MergeableState: mergeable,
|
||||
ClearMergeableState: pgtype.Bool{Bool: clearMergeable, Valid: true},
|
||||
Additions: p.PullRequest.Additions,
|
||||
Deletions: p.PullRequest.Deletions,
|
||||
ChangedFiles: p.PullRequest.ChangedFiles,
|
||||
})
|
||||
if err != nil {
|
||||
slog.Warn("github: upsert pr failed", "err", err)
|
||||
@@ -611,6 +707,184 @@ func (h *Handler) handlePullRequestEvent(ctx context.Context, body []byte) {
|
||||
})
|
||||
}
|
||||
|
||||
// ── check_suite webhook ────────────────────────────────────────────────────
|
||||
|
||||
type ghCheckSuitePayload struct {
|
||||
Action string `json:"action"`
|
||||
CheckSuite struct {
|
||||
ID int64 `json:"id"`
|
||||
HeadSHA string `json:"head_sha"`
|
||||
Status string `json:"status"`
|
||||
Conclusion string `json:"conclusion"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
App struct {
|
||||
ID int64 `json:"id"`
|
||||
} `json:"app"`
|
||||
PullRequests []struct {
|
||||
Number int32 `json:"number"`
|
||||
} `json:"pull_requests"`
|
||||
} `json:"check_suite"`
|
||||
Repository struct {
|
||||
Name string `json:"name"`
|
||||
Owner struct {
|
||||
Login string `json:"login"`
|
||||
} `json:"owner"`
|
||||
} `json:"repository"`
|
||||
Installation struct {
|
||||
ID int64 `json:"id"`
|
||||
} `json:"installation"`
|
||||
}
|
||||
|
||||
// handleCheckSuiteEvent records the CI suite state for each PR the suite
|
||||
// references. MVP only persists terminal events (`completed`); GitHub sends
|
||||
// `requested`/`rerequested` for some apps but those carry no useful
|
||||
// conclusion and the RFC restricts us to suite-level aggregation.
|
||||
//
|
||||
// The suite payload may reference multiple PRs (e.g. the same head SHA is
|
||||
// open against several base branches), so we iterate. A reference whose PR
|
||||
// hasn't been mirrored locally is logged and skipped — auto-backfill from
|
||||
// GitHub's REST API is a v2 enhancement.
|
||||
func (h *Handler) handleCheckSuiteEvent(ctx context.Context, body []byte) {
|
||||
var p ghCheckSuitePayload
|
||||
if err := json.Unmarshal(body, &p); err != nil {
|
||||
slog.Warn("github: bad check_suite payload", "err", err)
|
||||
return
|
||||
}
|
||||
if p.Action != "completed" {
|
||||
// MVP scope: only completed suites carry a conclusion we can
|
||||
// surface. queued / in_progress events would feed a future
|
||||
// "real pending" display path.
|
||||
return
|
||||
}
|
||||
if p.Installation.ID == 0 {
|
||||
return
|
||||
}
|
||||
inst, err := h.Queries.GetGitHubInstallationByInstallationID(ctx, p.Installation.ID)
|
||||
if err != nil {
|
||||
if !errors.Is(err, pgx.ErrNoRows) {
|
||||
slog.Warn("github: lookup installation failed", "err", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if len(p.CheckSuite.PullRequests) == 0 {
|
||||
// Forks emit suites whose `pull_requests` array is empty for
|
||||
// the upstream repo. We have no way to attribute the result
|
||||
// without polling, so drop with a hint.
|
||||
slog.Info("github: check_suite has no associated PRs", "suite_id", p.CheckSuite.ID)
|
||||
return
|
||||
}
|
||||
updatedAt := parseGHTimeRequired(p.CheckSuite.UpdatedAt)
|
||||
|
||||
affectedWorkspaces := map[string]struct{}{}
|
||||
affectedIssues := map[string]struct{}{}
|
||||
for _, prRef := range p.CheckSuite.PullRequests {
|
||||
// Scope the lookup to the installation's workspace. The
|
||||
// (workspace_id, repo_owner, repo_name, pr_number) tuple is the
|
||||
// real uniqueness key: if the same repo lived under a different
|
||||
// workspace historically, a bare (owner, repo, number) lookup
|
||||
// could return either row arbitrarily and land this suite on
|
||||
// the wrong PR (or skip the right one because the installation
|
||||
// ids no longer match).
|
||||
pr, err := h.Queries.GetGitHubPullRequest(ctx, db.GetGitHubPullRequestParams{
|
||||
WorkspaceID: inst.WorkspaceID,
|
||||
RepoOwner: p.Repository.Owner.Login,
|
||||
RepoName: p.Repository.Name,
|
||||
PrNumber: prRef.Number,
|
||||
})
|
||||
if err != nil {
|
||||
if !errors.Is(err, pgx.ErrNoRows) {
|
||||
slog.Warn("github: lookup pr for check_suite failed", "err", err)
|
||||
}
|
||||
slog.Info("github: check_suite for unknown PR — skipping",
|
||||
"repo", p.Repository.Owner.Login+"/"+p.Repository.Name,
|
||||
"pr", prRef.Number,
|
||||
"suite_id", p.CheckSuite.ID,
|
||||
)
|
||||
continue
|
||||
}
|
||||
if err := h.Queries.UpsertPullRequestCheckSuite(ctx, db.UpsertPullRequestCheckSuiteParams{
|
||||
PrID: pr.ID,
|
||||
SuiteID: p.CheckSuite.ID,
|
||||
HeadSha: p.CheckSuite.HeadSHA,
|
||||
AppID: p.CheckSuite.App.ID,
|
||||
Conclusion: strToText(p.CheckSuite.Conclusion),
|
||||
Status: p.CheckSuite.Status,
|
||||
UpdatedAt: updatedAt,
|
||||
}); err != nil {
|
||||
slog.Warn("github: upsert check_suite failed", "err", err, "suite_id", p.CheckSuite.ID)
|
||||
continue
|
||||
}
|
||||
affectedWorkspaces[uuidToString(pr.WorkspaceID)] = struct{}{}
|
||||
issues, err := h.Queries.ListIssueIDsForPullRequest(ctx, pr.ID)
|
||||
if err == nil {
|
||||
for _, id := range issues {
|
||||
affectedIssues[uuidToString(id)] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Broadcast on the existing event so the issue page just re-queries
|
||||
// the PR list. We don't pass a single pull_request payload here
|
||||
// because a suite can touch several and the listener already
|
||||
// invalidates by issue.
|
||||
for ws := range affectedWorkspaces {
|
||||
linked := make([]string, 0, len(affectedIssues))
|
||||
for id := range affectedIssues {
|
||||
linked = append(linked, id)
|
||||
}
|
||||
h.publish(protocol.EventPullRequestUpdated, ws, "system", "", map[string]any{
|
||||
"linked_issue_ids": linked,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// derivePRMergeableState resolves the upsert behaviour for the PR row's
|
||||
// mergeable_state column on a `pull_request` webhook. It returns three
|
||||
// states encoded as (value, clear):
|
||||
//
|
||||
// - clear=true → force the column to NULL. State-changing actions (`opened`,
|
||||
// `synchronize`, `reopened`, or a base-branch swap) must blank the value
|
||||
// because GitHub re-computes mergeability asynchronously; the payload may
|
||||
// still carry the previous head's clean/dirty answer, and trusting it
|
||||
// would surface a stale verdict against the new head.
|
||||
// - clear=false, value valid → write the value. The event carried a
|
||||
// concrete verdict we should persist.
|
||||
// - clear=false, value invalid → preserve the existing column. Metadata
|
||||
// events (labeled/assigned/edited-without-base-swap) ship pull_request
|
||||
// payloads with mergeable_state empty even when the previous verdict is
|
||||
// still accurate, and silently overwriting clean/dirty with NULL would
|
||||
// drop information GitHub only refreshes lazily.
|
||||
func derivePRMergeableState(action, payload string, baseRefChanged bool) (pgtype.Text, bool) {
|
||||
if action == "opened" || action == "synchronize" || action == "reopened" {
|
||||
return pgtype.Text{}, true
|
||||
}
|
||||
if action == "edited" && baseRefChanged {
|
||||
return pgtype.Text{}, true
|
||||
}
|
||||
if payload == "" {
|
||||
return pgtype.Text{}, false
|
||||
}
|
||||
return pgtype.Text{String: payload, Valid: true}, false
|
||||
}
|
||||
|
||||
// ghPRChanges captures the only field of `pull_request.edited`'s `changes`
|
||||
// payload we care about: a base-branch swap. Everything else (title, body)
|
||||
// leaves mergeability intact.
|
||||
type ghPRChanges struct {
|
||||
Base *struct {
|
||||
Ref *struct {
|
||||
From string `json:"from"`
|
||||
} `json:"ref"`
|
||||
} `json:"base"`
|
||||
}
|
||||
|
||||
// baseRefChanged returns true when a pull_request.edited event indicates the
|
||||
// PR's base branch was swapped. Only this kind of edit invalidates the
|
||||
// existing mergeable_state.
|
||||
func baseRefChanged(c *ghPRChanges) bool {
|
||||
return c != nil && c.Base != nil && c.Base.Ref != nil && c.Base.Ref.From != ""
|
||||
}
|
||||
|
||||
func derivePRState(state string, draft, merged bool) string {
|
||||
if merged {
|
||||
return "merged"
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"net/http/httptest"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
||||
)
|
||||
@@ -670,3 +671,370 @@ func TestWebhook_AllClosedWithoutMerge(t *testing.T) {
|
||||
t.Errorf("issue must stay in_progress when no linked PR ever merged, got %q", final.Status)
|
||||
}
|
||||
}
|
||||
|
||||
// ── CI / mergeable_state tests ─────────────────────────────────────────────
|
||||
|
||||
func TestDerivePRMergeableState(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
action string
|
||||
payload string
|
||||
baseRefChanged bool
|
||||
wantValid bool
|
||||
wantStr string
|
||||
wantClear bool
|
||||
}{
|
||||
{"opened_clears", "opened", "clean", false, false, "", true},
|
||||
{"synchronize_clears", "synchronize", "clean", false, false, "", true},
|
||||
{"reopened_clears", "reopened", "dirty", false, false, "", true},
|
||||
{"edited_base_changed_clears", "edited", "clean", true, false, "", true},
|
||||
{"edited_title_only_keeps_value", "edited", "clean", false, true, "clean", false},
|
||||
{"labeled_keeps_value", "labeled", "clean", false, true, "clean", false},
|
||||
{"labeled_empty_payload_preserves", "labeled", "", false, false, "", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, clear := derivePRMergeableState(tc.action, tc.payload, tc.baseRefChanged)
|
||||
if got.Valid != tc.wantValid {
|
||||
t.Errorf("Valid=%v want %v", got.Valid, tc.wantValid)
|
||||
}
|
||||
if got.String != tc.wantStr {
|
||||
t.Errorf("String=%q want %q", got.String, tc.wantStr)
|
||||
}
|
||||
if clear != tc.wantClear {
|
||||
t.Errorf("clear=%v want %v", clear, tc.wantClear)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAggregateChecksConclusion(t *testing.T) {
|
||||
str := func(p *string) string {
|
||||
if p == nil {
|
||||
return "<nil>"
|
||||
}
|
||||
return *p
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
failed, passed, pending, total int64
|
||||
want string
|
||||
}{
|
||||
{"no_suites_nil", 0, 0, 0, 0, "<nil>"},
|
||||
{"any_failure_wins", 1, 5, 0, 6, "failed"},
|
||||
{"failure_beats_pending", 1, 0, 3, 4, "failed"},
|
||||
{"pending_when_no_failure", 0, 1, 2, 3, "pending"},
|
||||
{"all_passed", 0, 3, 0, 3, "passed"},
|
||||
{"counts_zero_but_total_nonzero_returns_nil", 0, 0, 0, 1, "<nil>"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := aggregateChecksConclusion(tc.failed, tc.passed, tc.pending, tc.total)
|
||||
if str(got) != tc.want {
|
||||
t.Errorf("aggregateChecksConclusion = %s, want %s", str(got), tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// firePullRequestWebhookWithHead is like firePullRequestWebhook but lets the
|
||||
// caller control the head SHA and mergeable_state on the payload. The CI
|
||||
// tests need both knobs to exercise head-change semantics.
|
||||
func firePullRequestWebhookWithHead(t *testing.T, secret, identifier string, installationID int64, repo string, prNumber int32, action, headSHA, mergeableState string) {
|
||||
t.Helper()
|
||||
payload := map[string]any{
|
||||
"action": action,
|
||||
"pull_request": map[string]any{
|
||||
"number": prNumber,
|
||||
"html_url": "https://github.com/acme/" + repo + "/pull/1",
|
||||
"title": "Fix " + identifier,
|
||||
"body": "",
|
||||
"state": "open",
|
||||
"draft": false,
|
||||
"merged": false,
|
||||
"merged_at": nil,
|
||||
"closed_at": nil,
|
||||
"created_at": "2026-04-28T00:00:00Z",
|
||||
"updated_at": "2026-04-29T00:00:00Z",
|
||||
"mergeable_state": mergeableState,
|
||||
"head": map[string]any{"ref": "fix/foo", "sha": headSHA},
|
||||
"user": map[string]any{"login": "octocat"},
|
||||
},
|
||||
"repository": map[string]any{
|
||||
"name": repo,
|
||||
"owner": map[string]any{"login": "acme"},
|
||||
},
|
||||
"installation": map[string]any{"id": installationID},
|
||||
}
|
||||
raw, _ := json.Marshal(payload)
|
||||
mac := hmac.New(sha256.New, []byte(secret))
|
||||
mac.Write(raw)
|
||||
sig := "sha256=" + hex.EncodeToString(mac.Sum(nil))
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
hookReq := httptest.NewRequest("POST", "/api/webhooks/github", bytes.NewReader(raw))
|
||||
hookReq.Header.Set("X-GitHub-Event", "pull_request")
|
||||
hookReq.Header.Set("X-Hub-Signature-256", sig)
|
||||
testHandler.HandleGitHubWebhook(rec, hookReq)
|
||||
if rec.Code != http.StatusAccepted {
|
||||
t.Fatalf("webhook %s pr=%d action=%s: expected 202, got %d (%s)",
|
||||
repo, prNumber, action, rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func fireCheckSuiteWebhook(t *testing.T, secret string, installationID int64, repo string, prNumbers []int32, suiteID, appID int64, headSHA, conclusion, updatedAt string) {
|
||||
t.Helper()
|
||||
prRefs := make([]map[string]any, 0, len(prNumbers))
|
||||
for _, n := range prNumbers {
|
||||
prRefs = append(prRefs, map[string]any{"number": n})
|
||||
}
|
||||
payload := map[string]any{
|
||||
"action": "completed",
|
||||
"check_suite": map[string]any{
|
||||
"id": suiteID,
|
||||
"head_sha": headSHA,
|
||||
"status": "completed",
|
||||
"conclusion": conclusion,
|
||||
"updated_at": updatedAt,
|
||||
"app": map[string]any{"id": appID},
|
||||
"pull_requests": prRefs,
|
||||
},
|
||||
"repository": map[string]any{
|
||||
"name": repo,
|
||||
"owner": map[string]any{"login": "acme"},
|
||||
},
|
||||
"installation": map[string]any{"id": installationID},
|
||||
}
|
||||
raw, _ := json.Marshal(payload)
|
||||
mac := hmac.New(sha256.New, []byte(secret))
|
||||
mac.Write(raw)
|
||||
sig := "sha256=" + hex.EncodeToString(mac.Sum(nil))
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
hookReq := httptest.NewRequest("POST", "/api/webhooks/github", bytes.NewReader(raw))
|
||||
hookReq.Header.Set("X-GitHub-Event", "check_suite")
|
||||
hookReq.Header.Set("X-Hub-Signature-256", sig)
|
||||
testHandler.HandleGitHubWebhook(rec, hookReq)
|
||||
if rec.Code != http.StatusAccepted {
|
||||
t.Fatalf("check_suite webhook: expected 202, got %d (%s)", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func setupPRTestIssue(t *testing.T, ctx context.Context, secret string) (IssueResponse, int64) {
|
||||
t.Helper()
|
||||
t.Setenv("GITHUB_WEBHOOK_SECRET", secret)
|
||||
w := httptest.NewRecorder()
|
||||
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
|
||||
"title": "PR CI test",
|
||||
"status": "in_progress",
|
||||
})
|
||||
testHandler.CreateIssue(w, req)
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("CreateIssue: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
var created IssueResponse
|
||||
json.NewDecoder(w.Body).Decode(&created)
|
||||
|
||||
installationID := int64(33445566) + int64(time.Now().UnixNano()%1000000)
|
||||
t.Cleanup(func() {
|
||||
testPool.Exec(ctx, `DELETE FROM github_pull_request_check_suite WHERE pr_id IN (SELECT id FROM github_pull_request WHERE workspace_id = $1)`, testWorkspaceID)
|
||||
testPool.Exec(ctx, `DELETE FROM issue_pull_request WHERE issue_id = $1`, created.ID)
|
||||
testPool.Exec(ctx, `DELETE FROM github_pull_request WHERE workspace_id = $1`, testWorkspaceID)
|
||||
testPool.Exec(ctx, `DELETE FROM github_installation WHERE installation_id = $1`, installationID)
|
||||
testPool.Exec(ctx, `DELETE FROM activity_log WHERE issue_id = $1`, created.ID)
|
||||
testPool.Exec(ctx, `DELETE FROM issue WHERE id = $1`, created.ID)
|
||||
})
|
||||
if _, err := testHandler.Queries.CreateGitHubInstallation(ctx, db.CreateGitHubInstallationParams{
|
||||
WorkspaceID: parseUUID(testWorkspaceID),
|
||||
InstallationID: installationID,
|
||||
AccountLogin: "ci-acct",
|
||||
AccountType: "User",
|
||||
}); err != nil {
|
||||
t.Fatalf("CreateGitHubInstallation: %v", err)
|
||||
}
|
||||
return created, installationID
|
||||
}
|
||||
|
||||
// TestWebhook_CheckSuite_AggregatesAcrossApps ensures the list query reports
|
||||
// "failed" when one app's latest suite is a failure and another app's is a
|
||||
// success on the same head. Without per-app aggregation, the last-completed
|
||||
// suite would silently flip the verdict.
|
||||
func TestWebhook_CheckSuite_AggregatesAcrossApps(t *testing.T) {
|
||||
if testHandler == nil {
|
||||
t.Skip("handler test fixture not initialized (no DB?)")
|
||||
}
|
||||
ctx := context.Background()
|
||||
const secret = "ci-aggregate-secret"
|
||||
created, installationID := setupPRTestIssue(t, ctx, secret)
|
||||
|
||||
head := "abc1234567890"
|
||||
firePullRequestWebhookWithHead(t, secret, created.Identifier, installationID, "ci-repo-a", 11, "opened", head, "")
|
||||
// App A → success, App B → failure. The list query must report failed.
|
||||
fireCheckSuiteWebhook(t, secret, installationID, "ci-repo-a", []int32{11}, 1001, 7001, head, "success", "2026-05-01T00:00:00Z")
|
||||
fireCheckSuiteWebhook(t, secret, installationID, "ci-repo-a", []int32{11}, 1002, 7002, head, "failure", "2026-05-01T00:01:00Z")
|
||||
|
||||
rows, err := testHandler.Queries.ListPullRequestsByIssue(ctx, parseUUID(created.ID))
|
||||
if err != nil {
|
||||
t.Fatalf("ListPullRequestsByIssue: %v", err)
|
||||
}
|
||||
if len(rows) != 1 {
|
||||
t.Fatalf("expected 1 PR row, got %d", len(rows))
|
||||
}
|
||||
got := aggregateChecksConclusion(rows[0].ChecksFailed, rows[0].ChecksPassed, rows[0].ChecksPending, rows[0].ChecksTotal)
|
||||
if got == nil || *got != "failed" {
|
||||
t.Errorf("expected aggregate failed, got %v (counts: failed=%d passed=%d pending=%d total=%d)",
|
||||
got, rows[0].ChecksFailed, rows[0].ChecksPassed, rows[0].ChecksPending, rows[0].ChecksTotal)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWebhook_CheckSuite_OldHeadIgnored asserts that a late-arriving
|
||||
// check_suite for a stale head SHA doesn't contaminate the current head's
|
||||
// pending view. Without the head_sha filter in the aggregation query, the
|
||||
// new head would inherit the old head's "passed" verdict.
|
||||
func TestWebhook_CheckSuite_OldHeadIgnored(t *testing.T) {
|
||||
if testHandler == nil {
|
||||
t.Skip("handler test fixture not initialized (no DB?)")
|
||||
}
|
||||
ctx := context.Background()
|
||||
const secret = "ci-oldhead-secret"
|
||||
created, installationID := setupPRTestIssue(t, ctx, secret)
|
||||
|
||||
oldHead := "old1111111111"
|
||||
newHead := "new2222222222"
|
||||
|
||||
// First: open the PR at old head, run a passing suite.
|
||||
firePullRequestWebhookWithHead(t, secret, created.Identifier, installationID, "ci-repo-b", 22, "opened", oldHead, "")
|
||||
fireCheckSuiteWebhook(t, secret, installationID, "ci-repo-b", []int32{22}, 2001, 8001, oldHead, "success", "2026-05-01T00:00:00Z")
|
||||
|
||||
rows, err := testHandler.Queries.ListPullRequestsByIssue(ctx, parseUUID(created.ID))
|
||||
if err != nil {
|
||||
t.Fatalf("ListPullRequestsByIssue: %v", err)
|
||||
}
|
||||
got := aggregateChecksConclusion(rows[0].ChecksFailed, rows[0].ChecksPassed, rows[0].ChecksPending, rows[0].ChecksTotal)
|
||||
if got == nil || *got != "passed" {
|
||||
t.Fatalf("setup: expected passed on old head, got %v", got)
|
||||
}
|
||||
|
||||
// Then: synchronize to new head — no new suite yet. Then a late suite
|
||||
// for the OLD head fires (e.g. a delayed delivery). The current aggregate
|
||||
// must be nil (no suite for the new head).
|
||||
firePullRequestWebhookWithHead(t, secret, created.Identifier, installationID, "ci-repo-b", 22, "synchronize", newHead, "")
|
||||
fireCheckSuiteWebhook(t, secret, installationID, "ci-repo-b", []int32{22}, 2002, 8001, oldHead, "success", "2026-05-01T00:05:00Z")
|
||||
|
||||
rows, err = testHandler.Queries.ListPullRequestsByIssue(ctx, parseUUID(created.ID))
|
||||
if err != nil {
|
||||
t.Fatalf("ListPullRequestsByIssue: %v", err)
|
||||
}
|
||||
got = aggregateChecksConclusion(rows[0].ChecksFailed, rows[0].ChecksPassed, rows[0].ChecksPending, rows[0].ChecksTotal)
|
||||
if got != nil {
|
||||
t.Errorf("expected no aggregate (nil) after head change, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWebhook_CheckSuite_LateOlderEventIgnored guards the single-row ordering
|
||||
// rule: for the same (pr_id, suite_id) the upsert must not let a later-
|
||||
// delivered older event overwrite the latest one. We send the newer state
|
||||
// (failure) first and then the older (success) and assert the row still
|
||||
// reads failure.
|
||||
func TestWebhook_CheckSuite_LateOlderEventIgnored(t *testing.T) {
|
||||
if testHandler == nil {
|
||||
t.Skip("handler test fixture not initialized (no DB?)")
|
||||
}
|
||||
ctx := context.Background()
|
||||
const secret = "ci-ordering-secret"
|
||||
created, installationID := setupPRTestIssue(t, ctx, secret)
|
||||
|
||||
head := "ord1234567890"
|
||||
firePullRequestWebhookWithHead(t, secret, created.Identifier, installationID, "ci-repo-c", 33, "opened", head, "")
|
||||
// Latest event first.
|
||||
fireCheckSuiteWebhook(t, secret, installationID, "ci-repo-c", []int32{33}, 3001, 9001, head, "failure", "2026-05-01T01:00:00Z")
|
||||
// Late-arriving older event for the same suite.
|
||||
fireCheckSuiteWebhook(t, secret, installationID, "ci-repo-c", []int32{33}, 3001, 9001, head, "success", "2026-05-01T00:00:00Z")
|
||||
|
||||
rows, err := testHandler.Queries.ListPullRequestsByIssue(ctx, parseUUID(created.ID))
|
||||
if err != nil {
|
||||
t.Fatalf("ListPullRequestsByIssue: %v", err)
|
||||
}
|
||||
got := aggregateChecksConclusion(rows[0].ChecksFailed, rows[0].ChecksPassed, rows[0].ChecksPending, rows[0].ChecksTotal)
|
||||
if got == nil || *got != "failed" {
|
||||
t.Errorf("expected failure to win against later-delivered older success, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWebhook_PullRequest_SynchronizeClearsMergeable verifies that
|
||||
// `synchronize` sets mergeable_state to NULL even when the payload still
|
||||
// carries the previous "clean" verdict — the old answer no longer applies
|
||||
// to the new head SHA.
|
||||
func TestWebhook_PullRequest_SynchronizeClearsMergeable(t *testing.T) {
|
||||
if testHandler == nil {
|
||||
t.Skip("handler test fixture not initialized (no DB?)")
|
||||
}
|
||||
ctx := context.Background()
|
||||
const secret = "ci-mergeable-secret"
|
||||
created, installationID := setupPRTestIssue(t, ctx, secret)
|
||||
|
||||
// Open with no mergeable verdict, then a metadata event populates clean.
|
||||
firePullRequestWebhookWithHead(t, secret, created.Identifier, installationID, "ci-repo-d", 44, "opened", "head1", "")
|
||||
firePullRequestWebhookWithHead(t, secret, created.Identifier, installationID, "ci-repo-d", 44, "labeled", "head1", "clean")
|
||||
|
||||
rows, err := testHandler.Queries.ListPullRequestsByIssue(ctx, parseUUID(created.ID))
|
||||
if err != nil {
|
||||
t.Fatalf("ListPullRequestsByIssue: %v", err)
|
||||
}
|
||||
if !rows[0].MergeableState.Valid || rows[0].MergeableState.String != "clean" {
|
||||
t.Fatalf("setup: expected mergeable_state=clean, got %+v", rows[0].MergeableState)
|
||||
}
|
||||
|
||||
// Synchronize — payload still claims clean, but we must blank it.
|
||||
firePullRequestWebhookWithHead(t, secret, created.Identifier, installationID, "ci-repo-d", 44, "synchronize", "head2", "clean")
|
||||
|
||||
rows, err = testHandler.Queries.ListPullRequestsByIssue(ctx, parseUUID(created.ID))
|
||||
if err != nil {
|
||||
t.Fatalf("ListPullRequestsByIssue: %v", err)
|
||||
}
|
||||
if rows[0].MergeableState.Valid {
|
||||
t.Errorf("expected mergeable_state cleared on synchronize, got %q", rows[0].MergeableState.String)
|
||||
}
|
||||
if rows[0].HeadSha != "head2" {
|
||||
t.Errorf("expected head_sha updated to head2, got %q", rows[0].HeadSha)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWebhook_PullRequest_MetadataPreservesMergeable verifies that a
|
||||
// metadata-only event (labeled/assigned/edited-without-base-swap) whose
|
||||
// payload omits mergeable_state does NOT clobber an existing clean/dirty
|
||||
// verdict. GitHub re-computes mergeability lazily and metadata events ship
|
||||
// with the field empty even when the previous verdict is still accurate;
|
||||
// silently overwriting it with NULL would drop a real signal.
|
||||
func TestWebhook_PullRequest_MetadataPreservesMergeable(t *testing.T) {
|
||||
if testHandler == nil {
|
||||
t.Skip("handler test fixture not initialized (no DB?)")
|
||||
}
|
||||
ctx := context.Background()
|
||||
const secret = "ci-mergeable-preserve-secret"
|
||||
created, installationID := setupPRTestIssue(t, ctx, secret)
|
||||
|
||||
// Open, then set a known verdict via a labeled event carrying clean.
|
||||
firePullRequestWebhookWithHead(t, secret, created.Identifier, installationID, "ci-repo-e", 55, "opened", "headA", "")
|
||||
firePullRequestWebhookWithHead(t, secret, created.Identifier, installationID, "ci-repo-e", 55, "labeled", "headA", "clean")
|
||||
|
||||
rows, err := testHandler.Queries.ListPullRequestsByIssue(ctx, parseUUID(created.ID))
|
||||
if err != nil {
|
||||
t.Fatalf("ListPullRequestsByIssue: %v", err)
|
||||
}
|
||||
if !rows[0].MergeableState.Valid || rows[0].MergeableState.String != "clean" {
|
||||
t.Fatalf("setup: expected mergeable_state=clean, got %+v", rows[0].MergeableState)
|
||||
}
|
||||
|
||||
// A second labeled event arrives with mergeable_state empty (typical for
|
||||
// metadata events). The existing clean must survive.
|
||||
firePullRequestWebhookWithHead(t, secret, created.Identifier, installationID, "ci-repo-e", 55, "labeled", "headA", "")
|
||||
|
||||
rows, err = testHandler.Queries.ListPullRequestsByIssue(ctx, parseUUID(created.ID))
|
||||
if err != nil {
|
||||
t.Fatalf("ListPullRequestsByIssue: %v", err)
|
||||
}
|
||||
if !rows[0].MergeableState.Valid || rows[0].MergeableState.String != "clean" {
|
||||
t.Errorf("expected mergeable_state preserved as clean after metadata event, got %+v", rows[0].MergeableState)
|
||||
}
|
||||
}
|
||||
|
||||
6
server/migrations/091_pr_ci_conflict.down.sql
Normal file
6
server/migrations/091_pr_ci_conflict.down.sql
Normal file
@@ -0,0 +1,6 @@
|
||||
DROP INDEX IF EXISTS idx_github_pr_check_suite_aggregate;
|
||||
DROP TABLE IF EXISTS github_pull_request_check_suite;
|
||||
|
||||
ALTER TABLE github_pull_request
|
||||
DROP COLUMN IF EXISTS mergeable_state,
|
||||
DROP COLUMN IF EXISTS head_sha;
|
||||
22
server/migrations/091_pr_ci_conflict.up.sql
Normal file
22
server/migrations/091_pr_ci_conflict.up.sql
Normal file
@@ -0,0 +1,22 @@
|
||||
-- PR CI checks + merge conflict status. Adds head_sha + mergeable_state to
|
||||
-- github_pull_request, plus a per-check_suite table whose rows are filtered
|
||||
-- by the PR's current head_sha at query time. This lets late-arriving suites
|
||||
-- for an old head SHA land in the table without polluting the current view.
|
||||
|
||||
ALTER TABLE github_pull_request
|
||||
ADD COLUMN head_sha TEXT NOT NULL DEFAULT '',
|
||||
ADD COLUMN mergeable_state TEXT;
|
||||
|
||||
CREATE TABLE github_pull_request_check_suite (
|
||||
pr_id UUID NOT NULL REFERENCES github_pull_request(id) ON DELETE CASCADE,
|
||||
suite_id BIGINT NOT NULL,
|
||||
head_sha TEXT NOT NULL,
|
||||
app_id BIGINT NOT NULL,
|
||||
conclusion TEXT,
|
||||
status TEXT NOT NULL,
|
||||
updated_at TIMESTAMPTZ NOT NULL,
|
||||
PRIMARY KEY (pr_id, suite_id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_github_pr_check_suite_aggregate
|
||||
ON github_pull_request_check_suite (pr_id, head_sha, app_id, updated_at DESC);
|
||||
4
server/migrations/092_pr_stats.down.sql
Normal file
4
server/migrations/092_pr_stats.down.sql
Normal file
@@ -0,0 +1,4 @@
|
||||
ALTER TABLE github_pull_request
|
||||
DROP COLUMN IF EXISTS changed_files,
|
||||
DROP COLUMN IF EXISTS deletions,
|
||||
DROP COLUMN IF EXISTS additions;
|
||||
12
server/migrations/092_pr_stats.up.sql
Normal file
12
server/migrations/092_pr_stats.up.sql
Normal file
@@ -0,0 +1,12 @@
|
||||
-- PR diff stats (additions / deletions / changed_files) for the card layout.
|
||||
-- Source: top-level `pull_request` object on every pull_request webhook event
|
||||
-- (opened / synchronize / edited / labeled / ...). NOT NULL DEFAULT 0 means
|
||||
-- legacy rows that pre-date this migration read as zero, which the frontend
|
||||
-- detects via `total === 0` and hides the entire stats row — so the card never
|
||||
-- renders a misleading "+0 −0 · 0 files" caption for rows that just haven't
|
||||
-- been refreshed by a webhook yet.
|
||||
|
||||
ALTER TABLE github_pull_request
|
||||
ADD COLUMN additions INT NOT NULL DEFAULT 0,
|
||||
ADD COLUMN deletions INT NOT NULL DEFAULT 0,
|
||||
ADD COLUMN changed_files INT NOT NULL DEFAULT 0;
|
||||
@@ -136,7 +136,7 @@ func (q *Queries) GetGitHubInstallationByInstallationID(ctx context.Context, ins
|
||||
}
|
||||
|
||||
const getGitHubPullRequest = `-- name: GetGitHubPullRequest :one
|
||||
SELECT id, workspace_id, installation_id, repo_owner, repo_name, pr_number, title, state, html_url, branch, author_login, author_avatar_url, merged_at, closed_at, pr_created_at, pr_updated_at, created_at, updated_at FROM github_pull_request
|
||||
SELECT id, workspace_id, installation_id, repo_owner, repo_name, pr_number, title, state, html_url, branch, author_login, author_avatar_url, merged_at, closed_at, pr_created_at, pr_updated_at, created_at, updated_at, head_sha, mergeable_state, additions, deletions, changed_files FROM github_pull_request
|
||||
WHERE workspace_id = $1 AND repo_owner = $2 AND repo_name = $3 AND pr_number = $4
|
||||
`
|
||||
|
||||
@@ -174,6 +174,11 @@ func (q *Queries) GetGitHubPullRequest(ctx context.Context, arg GetGitHubPullReq
|
||||
&i.PrUpdatedAt,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.HeadSha,
|
||||
&i.MergeableState,
|
||||
&i.Additions,
|
||||
&i.Deletions,
|
||||
&i.ChangedFiles,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -307,22 +312,101 @@ func (q *Queries) ListIssueIDsForPullRequest(ctx context.Context, pullRequestID
|
||||
}
|
||||
|
||||
const listPullRequestsByIssue = `-- name: ListPullRequestsByIssue :many
|
||||
SELECT pr.id, pr.workspace_id, pr.installation_id, pr.repo_owner, pr.repo_name, pr.pr_number, pr.title, pr.state, pr.html_url, pr.branch, pr.author_login, pr.author_avatar_url, pr.merged_at, pr.closed_at, pr.pr_created_at, pr.pr_updated_at, pr.created_at, pr.updated_at
|
||||
WITH issue_prs AS (
|
||||
SELECT pr.id, pr.head_sha
|
||||
FROM github_pull_request pr
|
||||
JOIN issue_pull_request ipr ON ipr.pull_request_id = pr.id
|
||||
WHERE ipr.issue_id = $1
|
||||
),
|
||||
per_app_latest AS (
|
||||
SELECT DISTINCT ON (cs.pr_id, cs.app_id)
|
||||
cs.pr_id, cs.app_id, cs.conclusion, cs.status
|
||||
FROM github_pull_request_check_suite cs
|
||||
JOIN issue_prs ip ON ip.id = cs.pr_id
|
||||
WHERE cs.head_sha = ip.head_sha AND ip.head_sha <> ''
|
||||
ORDER BY cs.pr_id, cs.app_id, cs.updated_at DESC
|
||||
),
|
||||
checks AS (
|
||||
SELECT
|
||||
pr_id,
|
||||
COUNT(*)::bigint AS total,
|
||||
SUM(CASE WHEN status = 'completed' AND conclusion IN
|
||||
('failure','cancelled','timed_out','action_required','startup_failure','stale')
|
||||
THEN 1 ELSE 0 END)::bigint AS failed,
|
||||
SUM(CASE WHEN status = 'completed' AND conclusion IN
|
||||
('success','neutral','skipped')
|
||||
THEN 1 ELSE 0 END)::bigint AS passed,
|
||||
SUM(CASE WHEN status <> 'completed' OR conclusion IS NULL
|
||||
THEN 1 ELSE 0 END)::bigint AS pending
|
||||
FROM per_app_latest
|
||||
GROUP BY pr_id
|
||||
)
|
||||
SELECT
|
||||
pr.id, pr.workspace_id, pr.installation_id, pr.repo_owner, pr.repo_name,
|
||||
pr.pr_number, pr.title, pr.state, pr.html_url, pr.branch, pr.author_login,
|
||||
pr.author_avatar_url, pr.merged_at, pr.closed_at, pr.pr_created_at,
|
||||
pr.pr_updated_at, pr.head_sha, pr.mergeable_state,
|
||||
pr.additions, pr.deletions, pr.changed_files,
|
||||
pr.created_at, pr.updated_at,
|
||||
COALESCE(c.total, 0)::bigint AS checks_total,
|
||||
COALESCE(c.passed, 0)::bigint AS checks_passed,
|
||||
COALESCE(c.failed, 0)::bigint AS checks_failed,
|
||||
COALESCE(c.pending, 0)::bigint AS checks_pending
|
||||
FROM github_pull_request pr
|
||||
JOIN issue_pull_request ipr ON ipr.pull_request_id = pr.id
|
||||
LEFT JOIN checks c ON c.pr_id = pr.id
|
||||
WHERE ipr.issue_id = $1
|
||||
ORDER BY pr.pr_created_at DESC
|
||||
`
|
||||
|
||||
func (q *Queries) ListPullRequestsByIssue(ctx context.Context, issueID pgtype.UUID) ([]GithubPullRequest, error) {
|
||||
type ListPullRequestsByIssueRow struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
InstallationID int64 `json:"installation_id"`
|
||||
RepoOwner string `json:"repo_owner"`
|
||||
RepoName string `json:"repo_name"`
|
||||
PrNumber int32 `json:"pr_number"`
|
||||
Title string `json:"title"`
|
||||
State string `json:"state"`
|
||||
HtmlUrl string `json:"html_url"`
|
||||
Branch pgtype.Text `json:"branch"`
|
||||
AuthorLogin pgtype.Text `json:"author_login"`
|
||||
AuthorAvatarUrl pgtype.Text `json:"author_avatar_url"`
|
||||
MergedAt pgtype.Timestamptz `json:"merged_at"`
|
||||
ClosedAt pgtype.Timestamptz `json:"closed_at"`
|
||||
PrCreatedAt pgtype.Timestamptz `json:"pr_created_at"`
|
||||
PrUpdatedAt pgtype.Timestamptz `json:"pr_updated_at"`
|
||||
HeadSha string `json:"head_sha"`
|
||||
MergeableState pgtype.Text `json:"mergeable_state"`
|
||||
Additions int32 `json:"additions"`
|
||||
Deletions int32 `json:"deletions"`
|
||||
ChangedFiles int32 `json:"changed_files"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
ChecksTotal int64 `json:"checks_total"`
|
||||
ChecksPassed int64 `json:"checks_passed"`
|
||||
ChecksFailed int64 `json:"checks_failed"`
|
||||
ChecksPending int64 `json:"checks_pending"`
|
||||
}
|
||||
|
||||
// Returns the issue's linked PRs with the aggregated check-suite counts for
|
||||
// the PR's CURRENT head SHA. The `issue_prs` CTE narrows to this issue's PR
|
||||
// ids first so the per-app aggregation only touches suite rows for those
|
||||
// PRs — without that scoping the planner has to scan/aggregate every PR's
|
||||
// suites in the workspace before joining on issue. Per-app latest suite is
|
||||
// selected so a single app firing multiple suites on the same head doesn't
|
||||
// get counted N times. Late-arriving suites for an OLD head are stored but
|
||||
// excluded by the head_sha filter, so they can't override the new head's
|
||||
// pending view.
|
||||
func (q *Queries) ListPullRequestsByIssue(ctx context.Context, issueID pgtype.UUID) ([]ListPullRequestsByIssueRow, error) {
|
||||
rows, err := q.db.Query(ctx, listPullRequestsByIssue, issueID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []GithubPullRequest{}
|
||||
items := []ListPullRequestsByIssueRow{}
|
||||
for rows.Next() {
|
||||
var i GithubPullRequest
|
||||
var i ListPullRequestsByIssueRow
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.WorkspaceID,
|
||||
@@ -340,8 +424,17 @@ func (q *Queries) ListPullRequestsByIssue(ctx context.Context, issueID pgtype.UU
|
||||
&i.ClosedAt,
|
||||
&i.PrCreatedAt,
|
||||
&i.PrUpdatedAt,
|
||||
&i.HeadSha,
|
||||
&i.MergeableState,
|
||||
&i.Additions,
|
||||
&i.Deletions,
|
||||
&i.ChangedFiles,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.ChecksTotal,
|
||||
&i.ChecksPassed,
|
||||
&i.ChecksFailed,
|
||||
&i.ChecksPending,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -373,11 +466,15 @@ const upsertGitHubPullRequest = `-- name: UpsertGitHubPullRequest :one
|
||||
INSERT INTO github_pull_request (
|
||||
workspace_id, installation_id, repo_owner, repo_name, pr_number,
|
||||
title, state, html_url, branch, author_login, author_avatar_url,
|
||||
merged_at, closed_at, pr_created_at, pr_updated_at
|
||||
merged_at, closed_at, pr_created_at, pr_updated_at,
|
||||
head_sha, mergeable_state,
|
||||
additions, deletions, changed_files
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5,
|
||||
$6, $7, $8, $11, $12, $13,
|
||||
$14, $15, $9, $10
|
||||
$6, $7, $8, $15, $16, $17,
|
||||
$18, $19, $9, $10,
|
||||
$11, $20,
|
||||
$12, $13, $14
|
||||
)
|
||||
ON CONFLICT (workspace_id, repo_owner, repo_name, pr_number) DO UPDATE SET
|
||||
installation_id = EXCLUDED.installation_id,
|
||||
@@ -390,31 +487,56 @@ ON CONFLICT (workspace_id, repo_owner, repo_name, pr_number) DO UPDATE SET
|
||||
merged_at = EXCLUDED.merged_at,
|
||||
closed_at = EXCLUDED.closed_at,
|
||||
pr_updated_at = EXCLUDED.pr_updated_at,
|
||||
head_sha = EXCLUDED.head_sha,
|
||||
mergeable_state = CASE
|
||||
WHEN COALESCE($21::boolean, FALSE) THEN NULL
|
||||
WHEN EXCLUDED.mergeable_state IS NOT NULL THEN EXCLUDED.mergeable_state
|
||||
ELSE github_pull_request.mergeable_state
|
||||
END,
|
||||
additions = EXCLUDED.additions,
|
||||
deletions = EXCLUDED.deletions,
|
||||
changed_files = EXCLUDED.changed_files,
|
||||
updated_at = now()
|
||||
RETURNING id, workspace_id, installation_id, repo_owner, repo_name, pr_number, title, state, html_url, branch, author_login, author_avatar_url, merged_at, closed_at, pr_created_at, pr_updated_at, created_at, updated_at
|
||||
RETURNING id, workspace_id, installation_id, repo_owner, repo_name, pr_number, title, state, html_url, branch, author_login, author_avatar_url, merged_at, closed_at, pr_created_at, pr_updated_at, created_at, updated_at, head_sha, mergeable_state, additions, deletions, changed_files
|
||||
`
|
||||
|
||||
type UpsertGitHubPullRequestParams struct {
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
InstallationID int64 `json:"installation_id"`
|
||||
RepoOwner string `json:"repo_owner"`
|
||||
RepoName string `json:"repo_name"`
|
||||
PrNumber int32 `json:"pr_number"`
|
||||
Title string `json:"title"`
|
||||
State string `json:"state"`
|
||||
HtmlUrl string `json:"html_url"`
|
||||
PrCreatedAt pgtype.Timestamptz `json:"pr_created_at"`
|
||||
PrUpdatedAt pgtype.Timestamptz `json:"pr_updated_at"`
|
||||
Branch pgtype.Text `json:"branch"`
|
||||
AuthorLogin pgtype.Text `json:"author_login"`
|
||||
AuthorAvatarUrl pgtype.Text `json:"author_avatar_url"`
|
||||
MergedAt pgtype.Timestamptz `json:"merged_at"`
|
||||
ClosedAt pgtype.Timestamptz `json:"closed_at"`
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
InstallationID int64 `json:"installation_id"`
|
||||
RepoOwner string `json:"repo_owner"`
|
||||
RepoName string `json:"repo_name"`
|
||||
PrNumber int32 `json:"pr_number"`
|
||||
Title string `json:"title"`
|
||||
State string `json:"state"`
|
||||
HtmlUrl string `json:"html_url"`
|
||||
PrCreatedAt pgtype.Timestamptz `json:"pr_created_at"`
|
||||
PrUpdatedAt pgtype.Timestamptz `json:"pr_updated_at"`
|
||||
HeadSha string `json:"head_sha"`
|
||||
Additions int32 `json:"additions"`
|
||||
Deletions int32 `json:"deletions"`
|
||||
ChangedFiles int32 `json:"changed_files"`
|
||||
Branch pgtype.Text `json:"branch"`
|
||||
AuthorLogin pgtype.Text `json:"author_login"`
|
||||
AuthorAvatarUrl pgtype.Text `json:"author_avatar_url"`
|
||||
MergedAt pgtype.Timestamptz `json:"merged_at"`
|
||||
ClosedAt pgtype.Timestamptz `json:"closed_at"`
|
||||
MergeableState pgtype.Text `json:"mergeable_state"`
|
||||
ClearMergeableState pgtype.Bool `json:"clear_mergeable_state"`
|
||||
}
|
||||
|
||||
// =====================
|
||||
// GitHub Pull Request
|
||||
// =====================
|
||||
// mergeable_state has three-state semantics on UPDATE:
|
||||
// 1. clear_mergeable_state=true → write NULL (state-changing actions like
|
||||
// opened/synchronize/reopened/edited(base) invalidate the prior verdict).
|
||||
// 2. clear_mergeable_state=false, mergeable_state non-null → write the value.
|
||||
// 3. clear_mergeable_state=false, mergeable_state null → preserve existing
|
||||
// column. Metadata events (labeled/assigned/etc.) ship payloads without
|
||||
// mergeability, and silently clobbering a known clean/dirty would lose
|
||||
// information that GitHub only re-computes lazily.
|
||||
//
|
||||
// INSERT path always writes the incoming value (NULL acceptable for a new row).
|
||||
func (q *Queries) UpsertGitHubPullRequest(ctx context.Context, arg UpsertGitHubPullRequestParams) (GithubPullRequest, error) {
|
||||
row := q.db.QueryRow(ctx, upsertGitHubPullRequest,
|
||||
arg.WorkspaceID,
|
||||
@@ -427,11 +549,17 @@ func (q *Queries) UpsertGitHubPullRequest(ctx context.Context, arg UpsertGitHubP
|
||||
arg.HtmlUrl,
|
||||
arg.PrCreatedAt,
|
||||
arg.PrUpdatedAt,
|
||||
arg.HeadSha,
|
||||
arg.Additions,
|
||||
arg.Deletions,
|
||||
arg.ChangedFiles,
|
||||
arg.Branch,
|
||||
arg.AuthorLogin,
|
||||
arg.AuthorAvatarUrl,
|
||||
arg.MergedAt,
|
||||
arg.ClosedAt,
|
||||
arg.MergeableState,
|
||||
arg.ClearMergeableState,
|
||||
)
|
||||
var i GithubPullRequest
|
||||
err := row.Scan(
|
||||
@@ -453,6 +581,59 @@ func (q *Queries) UpsertGitHubPullRequest(ctx context.Context, arg UpsertGitHubP
|
||||
&i.PrUpdatedAt,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.HeadSha,
|
||||
&i.MergeableState,
|
||||
&i.Additions,
|
||||
&i.Deletions,
|
||||
&i.ChangedFiles,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const upsertPullRequestCheckSuite = `-- name: UpsertPullRequestCheckSuite :exec
|
||||
|
||||
INSERT INTO github_pull_request_check_suite (
|
||||
pr_id, suite_id, head_sha, app_id, conclusion, status, updated_at
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $7, $5, $6
|
||||
)
|
||||
ON CONFLICT (pr_id, suite_id) DO UPDATE SET
|
||||
head_sha = EXCLUDED.head_sha,
|
||||
app_id = EXCLUDED.app_id,
|
||||
conclusion = EXCLUDED.conclusion,
|
||||
status = EXCLUDED.status,
|
||||
updated_at = EXCLUDED.updated_at
|
||||
WHERE EXCLUDED.updated_at >= github_pull_request_check_suite.updated_at
|
||||
`
|
||||
|
||||
type UpsertPullRequestCheckSuiteParams struct {
|
||||
PrID pgtype.UUID `json:"pr_id"`
|
||||
SuiteID int64 `json:"suite_id"`
|
||||
HeadSha string `json:"head_sha"`
|
||||
AppID int64 `json:"app_id"`
|
||||
Status string `json:"status"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
Conclusion pgtype.Text `json:"conclusion"`
|
||||
}
|
||||
|
||||
// =====================
|
||||
// GitHub PR check suite
|
||||
// =====================
|
||||
// Upserts a single check_suite row keyed by (pr_id, suite_id). The WHERE
|
||||
// clause on the DO UPDATE branch prevents a late-arriving older event from
|
||||
// overwriting a newer one — same-PR/same-suite ordering protection. Late
|
||||
// events targeting an old head still land here (their head_sha is stored
|
||||
// on the row); the head_sha filter in ListPullRequestsByIssue keeps them
|
||||
// out of the current aggregate.
|
||||
func (q *Queries) UpsertPullRequestCheckSuite(ctx context.Context, arg UpsertPullRequestCheckSuiteParams) error {
|
||||
_, err := q.db.Exec(ctx, upsertPullRequestCheckSuite,
|
||||
arg.PrID,
|
||||
arg.SuiteID,
|
||||
arg.HeadSha,
|
||||
arg.AppID,
|
||||
arg.Status,
|
||||
arg.UpdatedAt,
|
||||
arg.Conclusion,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -272,6 +272,21 @@ type GithubPullRequest struct {
|
||||
PrUpdatedAt pgtype.Timestamptz `json:"pr_updated_at"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
HeadSha string `json:"head_sha"`
|
||||
MergeableState pgtype.Text `json:"mergeable_state"`
|
||||
Additions int32 `json:"additions"`
|
||||
Deletions int32 `json:"deletions"`
|
||||
ChangedFiles int32 `json:"changed_files"`
|
||||
}
|
||||
|
||||
type GithubPullRequestCheckSuite struct {
|
||||
PrID pgtype.UUID `json:"pr_id"`
|
||||
SuiteID int64 `json:"suite_id"`
|
||||
HeadSha string `json:"head_sha"`
|
||||
AppID int64 `json:"app_id"`
|
||||
Conclusion pgtype.Text `json:"conclusion"`
|
||||
Status string `json:"status"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
}
|
||||
|
||||
type InboxItem struct {
|
||||
|
||||
@@ -42,14 +42,27 @@ RETURNING id, workspace_id;
|
||||
-- =====================
|
||||
|
||||
-- name: UpsertGitHubPullRequest :one
|
||||
-- mergeable_state has three-state semantics on UPDATE:
|
||||
-- 1. clear_mergeable_state=true → write NULL (state-changing actions like
|
||||
-- opened/synchronize/reopened/edited(base) invalidate the prior verdict).
|
||||
-- 2. clear_mergeable_state=false, mergeable_state non-null → write the value.
|
||||
-- 3. clear_mergeable_state=false, mergeable_state null → preserve existing
|
||||
-- column. Metadata events (labeled/assigned/etc.) ship payloads without
|
||||
-- mergeability, and silently clobbering a known clean/dirty would lose
|
||||
-- information that GitHub only re-computes lazily.
|
||||
-- INSERT path always writes the incoming value (NULL acceptable for a new row).
|
||||
INSERT INTO github_pull_request (
|
||||
workspace_id, installation_id, repo_owner, repo_name, pr_number,
|
||||
title, state, html_url, branch, author_login, author_avatar_url,
|
||||
merged_at, closed_at, pr_created_at, pr_updated_at
|
||||
merged_at, closed_at, pr_created_at, pr_updated_at,
|
||||
head_sha, mergeable_state,
|
||||
additions, deletions, changed_files
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5,
|
||||
$6, $7, $8, sqlc.narg('branch'), sqlc.narg('author_login'), sqlc.narg('author_avatar_url'),
|
||||
sqlc.narg('merged_at'), sqlc.narg('closed_at'), $9, $10
|
||||
sqlc.narg('merged_at'), sqlc.narg('closed_at'), $9, $10,
|
||||
$11, sqlc.narg('mergeable_state'),
|
||||
$12, $13, $14
|
||||
)
|
||||
ON CONFLICT (workspace_id, repo_owner, repo_name, pr_number) DO UPDATE SET
|
||||
installation_id = EXCLUDED.installation_id,
|
||||
@@ -62,6 +75,15 @@ ON CONFLICT (workspace_id, repo_owner, repo_name, pr_number) DO UPDATE SET
|
||||
merged_at = EXCLUDED.merged_at,
|
||||
closed_at = EXCLUDED.closed_at,
|
||||
pr_updated_at = EXCLUDED.pr_updated_at,
|
||||
head_sha = EXCLUDED.head_sha,
|
||||
mergeable_state = CASE
|
||||
WHEN COALESCE(sqlc.narg('clear_mergeable_state')::boolean, FALSE) THEN NULL
|
||||
WHEN EXCLUDED.mergeable_state IS NOT NULL THEN EXCLUDED.mergeable_state
|
||||
ELSE github_pull_request.mergeable_state
|
||||
END,
|
||||
additions = EXCLUDED.additions,
|
||||
deletions = EXCLUDED.deletions,
|
||||
changed_files = EXCLUDED.changed_files,
|
||||
updated_at = now()
|
||||
RETURNING *;
|
||||
|
||||
@@ -70,10 +92,59 @@ SELECT * FROM github_pull_request
|
||||
WHERE workspace_id = $1 AND repo_owner = $2 AND repo_name = $3 AND pr_number = $4;
|
||||
|
||||
-- name: ListPullRequestsByIssue :many
|
||||
SELECT pr.*
|
||||
-- Returns the issue's linked PRs with the aggregated check-suite counts for
|
||||
-- the PR's CURRENT head SHA. The `issue_prs` CTE narrows to this issue's PR
|
||||
-- ids first so the per-app aggregation only touches suite rows for those
|
||||
-- PRs — without that scoping the planner has to scan/aggregate every PR's
|
||||
-- suites in the workspace before joining on issue. Per-app latest suite is
|
||||
-- selected so a single app firing multiple suites on the same head doesn't
|
||||
-- get counted N times. Late-arriving suites for an OLD head are stored but
|
||||
-- excluded by the head_sha filter, so they can't override the new head's
|
||||
-- pending view.
|
||||
WITH issue_prs AS (
|
||||
SELECT pr.id, pr.head_sha
|
||||
FROM github_pull_request pr
|
||||
JOIN issue_pull_request ipr ON ipr.pull_request_id = pr.id
|
||||
WHERE ipr.issue_id = sqlc.arg('issue_id')
|
||||
),
|
||||
per_app_latest AS (
|
||||
SELECT DISTINCT ON (cs.pr_id, cs.app_id)
|
||||
cs.pr_id, cs.app_id, cs.conclusion, cs.status
|
||||
FROM github_pull_request_check_suite cs
|
||||
JOIN issue_prs ip ON ip.id = cs.pr_id
|
||||
WHERE cs.head_sha = ip.head_sha AND ip.head_sha <> ''
|
||||
ORDER BY cs.pr_id, cs.app_id, cs.updated_at DESC
|
||||
),
|
||||
checks AS (
|
||||
SELECT
|
||||
pr_id,
|
||||
COUNT(*)::bigint AS total,
|
||||
SUM(CASE WHEN status = 'completed' AND conclusion IN
|
||||
('failure','cancelled','timed_out','action_required','startup_failure','stale')
|
||||
THEN 1 ELSE 0 END)::bigint AS failed,
|
||||
SUM(CASE WHEN status = 'completed' AND conclusion IN
|
||||
('success','neutral','skipped')
|
||||
THEN 1 ELSE 0 END)::bigint AS passed,
|
||||
SUM(CASE WHEN status <> 'completed' OR conclusion IS NULL
|
||||
THEN 1 ELSE 0 END)::bigint AS pending
|
||||
FROM per_app_latest
|
||||
GROUP BY pr_id
|
||||
)
|
||||
SELECT
|
||||
pr.id, pr.workspace_id, pr.installation_id, pr.repo_owner, pr.repo_name,
|
||||
pr.pr_number, pr.title, pr.state, pr.html_url, pr.branch, pr.author_login,
|
||||
pr.author_avatar_url, pr.merged_at, pr.closed_at, pr.pr_created_at,
|
||||
pr.pr_updated_at, pr.head_sha, pr.mergeable_state,
|
||||
pr.additions, pr.deletions, pr.changed_files,
|
||||
pr.created_at, pr.updated_at,
|
||||
COALESCE(c.total, 0)::bigint AS checks_total,
|
||||
COALESCE(c.passed, 0)::bigint AS checks_passed,
|
||||
COALESCE(c.failed, 0)::bigint AS checks_failed,
|
||||
COALESCE(c.pending, 0)::bigint AS checks_pending
|
||||
FROM github_pull_request pr
|
||||
JOIN issue_pull_request ipr ON ipr.pull_request_id = pr.id
|
||||
WHERE ipr.issue_id = $1
|
||||
LEFT JOIN checks c ON c.pr_id = pr.id
|
||||
WHERE ipr.issue_id = sqlc.arg('issue_id')
|
||||
ORDER BY pr.pr_created_at DESC;
|
||||
|
||||
-- name: ListIssueIDsForPullRequest :many
|
||||
@@ -95,6 +166,30 @@ JOIN issue_pull_request ipr ON ipr.pull_request_id = pr.id
|
||||
WHERE ipr.issue_id = $1
|
||||
AND pr.id <> $2;
|
||||
|
||||
-- =====================
|
||||
-- GitHub PR check suite
|
||||
-- =====================
|
||||
|
||||
-- name: UpsertPullRequestCheckSuite :exec
|
||||
-- Upserts a single check_suite row keyed by (pr_id, suite_id). The WHERE
|
||||
-- clause on the DO UPDATE branch prevents a late-arriving older event from
|
||||
-- overwriting a newer one — same-PR/same-suite ordering protection. Late
|
||||
-- events targeting an old head still land here (their head_sha is stored
|
||||
-- on the row); the head_sha filter in ListPullRequestsByIssue keeps them
|
||||
-- out of the current aggregate.
|
||||
INSERT INTO github_pull_request_check_suite (
|
||||
pr_id, suite_id, head_sha, app_id, conclusion, status, updated_at
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, sqlc.narg('conclusion'), $5, $6
|
||||
)
|
||||
ON CONFLICT (pr_id, suite_id) DO UPDATE SET
|
||||
head_sha = EXCLUDED.head_sha,
|
||||
app_id = EXCLUDED.app_id,
|
||||
conclusion = EXCLUDED.conclusion,
|
||||
status = EXCLUDED.status,
|
||||
updated_at = EXCLUDED.updated_at
|
||||
WHERE EXCLUDED.updated_at >= github_pull_request_check_suite.updated_at;
|
||||
|
||||
-- =====================
|
||||
-- Issue ↔ Pull Request link
|
||||
-- =====================
|
||||
|
||||
Reference in New Issue
Block a user