mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-16 19:29:26 +02:00
feat(github): mirror PR CI checks and merge conflict status (MUL-2228) (#2632)
* feat(github): mirror PR CI checks and merge conflict status (MUL-2228)
Surface "checks passed/failed" and "conflicts/no conflicts" badges under
each linked PR on the issue page so users can judge readiness without
flipping over to GitHub. CI state is fed by check_suite webhooks
(GitHub Actions + apps using the Checks API; legacy status events are
out of scope for MVP); conflicts are read from pull_request.mergeable_state.
Data model:
* github_pull_request: add head_sha + mergeable_state
* github_pull_request_check_suite: per-suite rows keyed by (pr_id, suite_id)
* Aggregation done at query time, filtering by current head_sha so
late-arriving suites for a stale head can't contaminate the new head's
pending view; per-app latest suite chosen first so a single app firing
multiple suites isn't counted N times.
Webhook hardening:
* synchronize/opened/reopened/edited(base) explicitly clear mergeable_state
* single-row ordering protection on the check_suite upsert prevents a
late-delivered older event from overwriting a newer one
* check_suite.pull_requests is iterated; unknown PRs are logged and dropped
UI:
* PR row shows Checks + Conflicts badges; opaque mergeable values
(blocked/behind/unstable/...) render as no badge, not as conflicts.
* Terminal PR states (merged/closed) suppress the status row entirely.
Tests: * Pure unit coverage for derivePRMergeableState + aggregateChecksConclusion
* Webhook integration tests: multi-app aggregation, old-head ignore,
late-older-event ignore, synchronize clears mergeable_state
* Vitest coverage for pull-request-list badge rendering across CI/conflict
combinations and the legacy (null) fallback.
Co-authored-by: multica-agent <github@multica.ai>
* fix(github): scope check_suite PR lookup; preserve mergeable on metadata
Addresses code review on PR #2632.
1. check_suite handler now resolves the PR through the workspace-scoped
GetGitHubPullRequest query instead of GetGitHubPullRequestByRepoNumber.
The (workspace_id, repo_owner, repo_name, pr_number) tuple is the real
uniqueness key, so a bare (owner, repo, number) lookup could return a
stale row from another workspace and either land the suite on the wrong
PR or skip the right one when the installation ids drifted. The old
unscoped query is removed.
2. derivePRMergeableState now returns (value, clear) and the upsert SQL
distinguishes three cases: state-changing actions clear the column to
NULL, non-empty payloads write the value, and metadata events with an
empty payload preserve the existing column. Previously every empty
payload became NULL, so a labeled/assigned event silently wiped a
known clean/dirty verdict in violation of the RFC's "metadata empty
payload preserves" rule.
3. ListPullRequestsByIssue narrows to the issue's PR ids before running
the per-app check_suite aggregation, avoiding a full-table scan over
github_pull_request_check_suite when only a handful of rows belong to
the requested issue.
New helper test covers labeled+empty preserves; new integration test
verifies a metadata event after a known mergeable_state keeps the value.
Co-authored-by: multica-agent <github@multica.ai>
* feat(github): PR card layout v3 increment — stats + segmented progress bar
Replaces the row + badge layout under "Pull requests" on the issue
detail sidebar with a card that mirrors the GitHub PR summary look:
title, author/avatar, +N −M · K files diff stats, segmented progress
bar (failed → pending → passed, failure leftmost), and a one-line
status caption following an explicit priority pass-through.
Backend
- Migration 092: github_pull_request adds additions / deletions /
changed_files (INT NOT NULL DEFAULT 0). Zero defaults are what the
new frontend treats as "legacy backend — hide the stats row" so old
PR rows that pre-date this migration don't render "+0 −0 · 0 files".
- pull_request webhook handler reads stats off the top-level payload.
- ListPullRequestsByIssue now surfaces per-suite counts
(checks_passed / failed / pending) alongside the existing aggregate
conclusion, so the segmented bar reuses the already-computed counts
with no new aggregation.
Frontend (packages)
- core/github/pull-request-status.{ts,test.ts}: pure-function module
for the status-kind priority table and the segment derivation; 15
cases covered, includes the "all-zero → hide stats" guard.
- views/issues/components/pull-request-list.tsx: PullRequestCard plus
a compact-row fallback used when count > 4 (first 3 as cards, the
remainder collapsed behind a Show more toggle).
- i18n: new `pull_request_card_*` keys in en + zh-Hans.
Tests
- 12 component tests covering each rule of the priority table, the
legacy-zero stats fallback, and the collapse threshold.
- Reuse of the v3 webhook handler tests confirmed.
Verification
- pnpm typecheck + pnpm test green (60 test files, 536 tests).
- go build ./... + go vet ./... clean.
- 6 demo issues (DEV-2..DEV-7) screenshotted via Playwright; see the
PR comments for the visual check matrix.
Co-authored-by: multica-agent <github@multica.ai>
* fix(views): collapse PR cards at N>=4, not N>4
The card-vs-collapse threshold used `>` so 4 PRs slipped past it and
all rendered as full cards, contrary to RFC v3 (N >= 4 collapses to
3 cards + compact tail). Switch to `>=` and update the threshold-
boundary test to expect "Show 1 more".
Co-authored-by: multica-agent <github@multica.ai>
* fix(views): align PR sidebar rows with existing list style
Co-authored-by: multica-agent <github@multica.ai>
* fix(views): hide terminal PR status badges
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -79,7 +79,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,6 +134,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",
|
||||
|
||||
@@ -133,6 +133,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