Files
paseo/packages/app/src/git/pull-request-panel/data.test.ts
T
Mohamed Boudra 1e2089cef7 Summarize pull request checks and group them by status (#3483)
* fix(app): keep check rows the same height with and without "Add to chat"

A check row was minHeight 32 plus 8pt of vertical padding, but the xs
Button in its trailing slot is 28pt tall. The button stacked on the
padding rather than fitting inside the row, so a row grew to 44pt the
moment "Add to chat" appeared and the list visibly shifted as rows
gained the button.

Rows are single-line by construction, so the row now declares a fixed
height instead of a floor a child can outgrow. That height comes from
CONTROL_HEIGHTS, the same source the button sizes from, so the two
cannot drift apart later. GitLab pipeline rows share this style and get
the fix too.

* feat(app): summarize pull request checks and group them by status

The checks section listed every check inline under a bare "Checks"
header with three count pills. On a repo with a real matrix that meant
a wall of undifferentiated rows that pushed the activity timeline off
the pane, and the pills made the reader add up numbers to work out
whether the run was actually in trouble.

The section now leads with the answer — a headline that names the
outcome, a one-line count breakdown, and a ring showing the statuses in
proportion — then groups the rows by status with failures first, and
scrolls in place past roughly eight rows instead of growing without
bound. Groups collapse individually.

The derivation lives in checks-summary.ts as one pure function, so the
headline, the ring, and the group labels cannot disagree about the same
run. The rendering, the row, and the check identity move out of pane.tsx
into an owned module; the pane now wires it and nothing more.

Running checks also gain a timing. formatCheckRunDuration required both
a start and a completion, so anything still going showed nothing at all.
It now measures an unfinished run to now. The wire deliberately carries
formatted strings rather than raw timestamps — there is a test asserting
check timestamps are stripped — so the daemon does the measuring and the
client only decides how to say it: a bare "2m" on a running row would
read as "took 2m", so those rows say "running 2m".
2026-08-17 17:23:03 +02:00

743 lines
22 KiB
TypeScript

import { describe, expect, it } from "vitest";
import type {
CheckoutPrStatusResponse,
PullRequestTimelineResponse,
} from "@getpaseo/protocol/messages";
import { isPipelineActiveStatus, mapPipelineStatus } from "@/git/forges/gitlab";
import { IDENTITY_COLOR_NAMES, identityColor } from "@/styles/identity-colors";
import {
deriveAvatarColor,
formatAge,
getActivityVerb,
getStateLabel,
mapPrPaneData,
} from "./data";
type CheckoutPrStatus = NonNullable<CheckoutPrStatusResponse["payload"]["status"]>;
type PullRequestTimeline = PullRequestTimelineResponse["payload"];
const githubStatus: CheckoutPrStatus["github"] = {
mergeStateStatus: null,
autoMergeRequest: null,
viewerCanEnableAutoMerge: false,
viewerCanDisableAutoMerge: false,
viewerCanMergeAsAdmin: false,
viewerCanUpdateBranch: false,
repository: {
autoMergeAllowed: false,
mergeCommitAllowed: false,
squashMergeAllowed: false,
rebaseMergeAllowed: false,
viewerDefaultMergeMethod: null,
},
isMergeQueueEnabled: false,
isInMergeQueue: false,
};
const baseStatus: CheckoutPrStatus = {
forge: "github",
number: 42,
url: "https://github.com/getpaseo/paseo/pull/42",
title: "Wire PR pane data",
state: "open",
baseRefName: "main",
headRefName: "feature/pr-pane",
isMerged: false,
isDraft: false,
mergeable: "UNKNOWN",
checks: [],
reviewDecision: null,
github: githubStatus,
};
const baseTimeline: PullRequestTimeline = {
cwd: "/repo",
prNumber: 42,
items: [],
truncated: false,
error: null,
requestId: "timeline-1",
githubFeaturesEnabled: true,
};
function status(overrides: Partial<CheckoutPrStatus> = {}): CheckoutPrStatus {
return { ...baseStatus, ...overrides };
}
function timeline(overrides: Partial<PullRequestTimeline> = {}): PullRequestTimeline {
return { ...baseTimeline, ...overrides };
}
describe("mapPrPaneData", () => {
it("returns null when no PR status exists", () => {
expect(mapPrPaneData(null, baseTimeline)).toBeNull();
});
it("derives the PR number from the status URL when the status number is absent", () => {
const data = mapPrPaneData(
status({
number: undefined,
url: "https://github.com/getpaseo/paseo/pull/1284",
}),
timeline({ prNumber: 1284 }),
);
expect(data?.number).toBe(1284);
});
it("returns null when status has no number and no parseable PR URL", () => {
expect(
mapPrPaneData(status({ number: undefined, url: "https://github.com/getpaseo/paseo" }), null),
).toBeNull();
});
it("derives PR state with merged taking precedence over closed, draft, and open", () => {
expect(
mapPrPaneData(status({ isMerged: true, isDraft: true, state: "closed" }), baseTimeline)
?.state,
).toBe("merged");
expect(
mapPrPaneData(status({ isMerged: false, isDraft: true, state: "closed" }), baseTimeline)
?.state,
).toBe("closed");
expect(mapPrPaneData(status({ isDraft: true, state: "open" }), baseTimeline)?.state).toBe(
"draft",
);
expect(mapPrPaneData(status({ isDraft: false, state: "open" }), baseTimeline)?.state).toBe(
"open",
);
});
it("drops checks with null URLs to preserve the pressable check contract", () => {
const data = mapPrPaneData(
status({
checks: [
{ name: "typecheck", status: "success", url: "https://example.com/checks/1" },
{ name: "legacy status", status: "pending", url: null },
],
}),
baseTimeline,
);
expect(data?.checks).toEqual([
{
provider: "github",
name: "typecheck",
status: "success",
url: "https://example.com/checks/1",
},
]);
});
it("maps server check statuses into the frozen check status union", () => {
const data = mapPrPaneData(
status({
checks: [
{
name: "success",
status: "success",
url: "https://example.com/1",
workflow: "CI",
duration: "1m",
},
{ name: "failure", status: "failure", url: "https://example.com/2" },
{ name: "pending", status: "pending", url: "https://example.com/3" },
{ name: "skipped", status: "skipped", url: "https://example.com/4" },
{ name: "cancelled", status: "cancelled", url: "https://example.com/5" },
],
}),
baseTimeline,
);
expect(data?.checks).toEqual([
{
provider: "github",
name: "success",
workflow: "CI",
status: "success",
timing: "1m",
url: "https://example.com/1",
},
{ provider: "github", name: "failure", status: "failure", url: "https://example.com/2" },
{ provider: "github", name: "pending", status: "pending", url: "https://example.com/3" },
{ provider: "github", name: "skipped", status: "skipped", url: "https://example.com/4" },
{ provider: "github", name: "cancelled", status: "skipped", url: "https://example.com/5" },
]);
});
it("marks a running check's elapsed time so it does not read as a finished duration", () => {
const data = mapPrPaneData(
status({
checks: [
{ name: "running", status: "pending", url: "https://example.com/1", duration: "7m" },
{ name: "done", status: "success", url: "https://example.com/2", duration: "1m 4s" },
{ name: "broke", status: "failure", url: "https://example.com/3", duration: "12s" },
{ name: "untimed", status: "pending", url: "https://example.com/4" },
],
}),
baseTimeline,
);
expect(data?.checks.map((check) => check.timing)).toEqual([
"running 7m",
"1m 4s",
"12s",
undefined,
]);
});
it("maps GitHub check identifiers into provider refs", () => {
const data = mapPrPaneData(
status({
checks: [
{
name: "server-tests",
status: "failure",
url: "https://github.com/getpaseo/paseo/actions/runs/456/job/789",
checkRunId: 12345,
workflowRunId: 456,
},
],
}),
baseTimeline,
);
expect(data?.checks).toEqual([
{
provider: "github",
name: "server-tests",
status: "failure",
url: "https://github.com/getpaseo/paseo/actions/runs/456/job/789",
detailRef: { checkRunId: 12345, workflowRunId: 456 },
},
]);
});
it("keeps a workflowRunId-only detail ref so Gitea Actions rows stay fetchable", () => {
const data = mapPrPaneData(
status({
forge: "gitea",
checks: [
{
name: "e2e",
status: "failure",
url: "https://gitea.com/acme/repo/actions/runs/7001",
workflowRunId: 7001,
},
],
}),
baseTimeline,
);
expect(data?.checks[0]?.detailRef).toEqual({ workflowRunId: 7001 });
});
it("preserves timeline item order while mapping mixed reviews and comments", () => {
const data = mapPrPaneData(
baseStatus,
timeline({
items: [
{
id: "review-1",
kind: "review",
author: "alice",
reviewState: "approved",
body: "Looks good.",
createdAt: Date.UTC(2026, 0, 1, 10, 0, 0),
url: "https://example.com/review-1",
},
{
id: "comment-1",
kind: "comment",
author: "bob",
body: "One thought.",
createdAt: Date.UTC(2026, 0, 1, 11, 0, 0),
url: "https://example.com/comment-1",
},
],
}),
Date.UTC(2026, 0, 1, 12, 0, 0),
);
expect(data?.activity.map((item) => item.kind)).toEqual(["review", "comment"]);
expect(data?.activity.map((item) => item.author)).toEqual(["alice", "bob"]);
});
it("maps timeline author avatar URLs and inline comment location metadata", () => {
const data = mapPrPaneData(
baseStatus,
timeline({
items: [
{
id: "thread-comment-1",
kind: "comment",
author: "inline-reviewer",
authorUrl: "https://github.com/inline-reviewer",
avatarUrl: "https://avatars.githubusercontent.com/u/3?v=4",
body: "This should include line context.",
createdAt: Date.UTC(2026, 0, 1, 11, 0, 0),
url: "https://github.com/getpaseo/paseo/pull/42#discussion_r1",
location: {
path: "packages/app/src/git/pull-request-panel/data.ts",
line: 24,
startLine: 20,
threadId: "PRRT_1",
isResolved: true,
isOutdated: false,
},
},
],
}),
Date.UTC(2026, 0, 1, 12, 0, 0),
);
expect(data?.activity).toEqual([
{
id: "thread-comment-1",
provider: "github",
kind: "comment",
author: "inline-reviewer",
authorUrl: "https://github.com/inline-reviewer",
avatarColor: deriveAvatarColor("inline-reviewer"),
avatarUrl: "https://avatars.githubusercontent.com/u/3?v=4",
body: "This should include line context.",
age: "1h ago",
url: "https://github.com/getpaseo/paseo/pull/42#discussion_r1",
location: {
path: "packages/app/src/git/pull-request-panel/data.ts",
line: 24,
startLine: 20,
threadId: "PRRT_1",
isResolved: true,
isOutdated: false,
},
},
]);
});
it("maps a top-level threadId onto general (non-file) discussion comments", () => {
const data = mapPrPaneData(
baseStatus,
timeline({
items: [
{
id: "note-1",
kind: "comment",
author: "reviewer-a",
body: "Can you clarify the rollout?",
createdAt: Date.UTC(2026, 0, 1, 11, 0, 0),
url: "https://gitlab.example.com/group/project/-/merge_requests/42#note_1",
threadId: "disc-1",
},
],
}),
Date.UTC(2026, 0, 1, 12, 0, 0),
"gitlab",
);
expect(data?.activity).toHaveLength(1);
expect(data?.activity[0]).toMatchObject({ id: "note-1", threadId: "disc-1" });
expect(data?.activity[0].location).toBeUndefined();
});
it("maps thread-level resolution onto general (non-file) discussion comments", () => {
const data = mapPrPaneData(
baseStatus,
timeline({
items: [
{
id: "note-2",
kind: "comment",
author: "reviewer-a",
body: "Resolved general discussion.",
createdAt: Date.UTC(2026, 0, 1, 11, 0, 0),
url: "https://gitlab.example.com/group/project/-/merge_requests/42#note_2",
threadId: "disc-2",
threadIsResolved: true,
},
],
}),
Date.UTC(2026, 0, 1, 12, 0, 0),
"gitlab",
);
expect(data?.activity[0]).toMatchObject({ id: "note-2", threadIsResolved: true });
expect(data?.activity[0].location).toBeUndefined();
});
it("filters empty commented reviews but keeps blocking review states", () => {
const data = mapPrPaneData(
baseStatus,
timeline({
items: [
{
id: "commented-empty",
kind: "review",
author: "alice",
reviewState: "commented",
body: " ",
createdAt: 1000,
url: "https://example.com/commented-empty",
},
{
id: "approved-empty",
kind: "review",
author: "bob",
reviewState: "approved",
body: "",
createdAt: 2000,
url: "https://example.com/approved-empty",
},
{
id: "changes-empty",
kind: "review",
author: "cam",
reviewState: "changes_requested",
body: "",
createdAt: 3000,
url: "https://example.com/changes-empty",
},
],
}),
4000,
);
expect(data?.activity).toMatchObject([
{ kind: "review", author: "bob", reviewState: "approved", body: "" },
{ kind: "review", author: "cam", reviewState: "changes_requested", body: "" },
]);
});
it("filters empty issue comments while preserving review activity with empty bodies", () => {
const data = mapPrPaneData(
baseStatus,
timeline({
items: [
{
id: "comment-empty",
kind: "comment",
author: "alice",
body: " ",
createdAt: 1000,
url: "https://example.com/comment-empty",
},
{
id: "approved-empty",
kind: "review",
author: "bob",
reviewState: "approved",
body: "",
createdAt: 2000,
url: "https://example.com/approved-empty",
},
{
id: "approved-body",
kind: "review",
author: "cam",
reviewState: "approved",
body: "Looks good.",
createdAt: 3000,
url: "https://example.com/approved-body",
},
],
}),
4000,
);
expect(data?.activity).toMatchObject([
{ kind: "review", author: "bob", reviewState: "approved", body: "" },
{ kind: "review", author: "cam", reviewState: "approved", body: "Looks good." },
]);
});
it("maps review decisions into the frozen pending fallback contract", () => {
expect(
mapPrPaneData(status({ reviewDecision: "approved" }), baseTimeline)?.reviewDecision,
).toBe("approved");
expect(
mapPrPaneData(status({ reviewDecision: "changes_requested" }), baseTimeline)?.reviewDecision,
).toBe("changes_requested");
expect(
mapPrPaneData(status({ reviewDecision: "review_required" }), baseTimeline)?.reviewDecision,
).toBe("pending");
expect(mapPrPaneData(status({ reviewDecision: null }), baseTimeline)?.reviewDecision).toBe(
"pending",
);
expect(
mapPrPaneData(
status({ reviewDecision: undefined as CheckoutPrStatus["reviewDecision"] }),
baseTimeline,
)?.reviewDecision,
).toBe("pending");
expect(
mapPrPaneData(
status({ reviewDecision: "surprising" as CheckoutPrStatus["reviewDecision"] }),
baseTimeline,
)?.reviewDecision,
).toBe("pending");
});
it("leaves awaiting reviewers intentionally unwired", () => {
expect(mapPrPaneData(baseStatus, baseTimeline)?.awaitingReviewers).toEqual([]);
});
it("defaults the forge to github and omits the project path when neither is supplied", () => {
const data = mapPrPaneData(baseStatus, baseTimeline);
expect(data?.forge).toBe("github");
expect(data?.projectPath).toBeUndefined();
});
it("carries the resolved forge and the nested project path for GitLab", () => {
const data = mapPrPaneData(
status({ projectPath: "group/subgroup/repo" }),
baseTimeline,
undefined,
"gitlab",
);
expect(data?.forge).toBe("gitlab");
expect(data?.projectPath).toBe("group/subgroup/repo");
});
it("rejects stale timeline activity when the timeline PR number differs from status", () => {
const data = mapPrPaneData(
baseStatus,
timeline({
prNumber: 99,
items: [
{
id: "comment-1",
kind: "comment",
author: "alice",
body: "This belongs to another PR.",
createdAt: 1000,
url: "https://example.com/comment-1",
},
],
}),
2000,
);
expect(data?.activity).toEqual([]);
});
it("passes the forge native facts through for pane contributions to derive surfaces", () => {
const gitlabFacts = {
forge: "gitlab" as const,
detailedMergeStatus: "mergeable",
hasConflicts: false,
blockingDiscussionsResolved: true,
approvalsRequired: 2,
approvalsGiven: 1,
pipelineStatus: "running",
pipelineId: 306,
pipelineUrl: "https://gitlab.com/group/repo/-/pipelines/306",
mergeWhenPipelineSucceeds: false,
};
const data = mapPrPaneData(
status({
url: "https://gitlab.com/group/repo/-/merge_requests/7",
github: undefined,
forgeSpecific: gitlabFacts,
}),
baseTimeline,
undefined,
"gitlab",
);
expect(data?.forgeSpecific).toEqual({ ...gitlabFacts, mergeStatus: null });
});
it("omits forgeSpecific when the status carries no native facts", () => {
expect(mapPrPaneData(baseStatus, baseTimeline)?.forgeSpecific).toBeUndefined();
});
it("omits forgeSpecific when no registered forge schema accepts it", () => {
const data = mapPrPaneData(
status({ forgeSpecific: { forge: "gitlab", approvalsRequired: "two" } }),
baseTimeline,
);
expect(data?.forgeSpecific).toBeUndefined();
});
it("surfaces Gitea aggregate CI status as a check row", () => {
const data = mapPrPaneData(
status({
forge: "gitea",
url: "https://gitea.com/group/repo/pulls/7",
github: undefined,
forgeSpecific: {
forge: "gitea",
mergeable: true,
hasMerged: false,
ciStatus: "success",
},
}),
baseTimeline,
undefined,
"gitea",
);
expect(data?.checks).toEqual([
{
provider: "gitea",
name: "CI",
status: "success",
url: "https://gitea.com/group/repo/pulls/7",
},
]);
});
it("keeps Forgejo branding for aggregate Gitea-family CI status", () => {
const data = mapPrPaneData(
status({
forge: "forgejo",
url: "https://forgejo.example.com/group/repo/pulls/7",
github: undefined,
forgeSpecific: {
forge: "gitea",
mergeable: true,
hasMerged: false,
ciStatus: "failure",
},
}),
baseTimeline,
undefined,
"forgejo",
);
expect(data?.checks).toEqual([
{
provider: "forgejo",
name: "CI",
status: "failure",
url: "https://forgejo.example.com/group/repo/pulls/7",
},
]);
});
it("carries the resolved forge brand and activity provider for GitLab", () => {
const data = mapPrPaneData(
status({
url: "https://gitlab.com/group/repo/-/merge_requests/7",
github: undefined,
forgeSpecific: {
forge: "gitlab",
detailedMergeStatus: "mergeable",
hasConflicts: false,
blockingDiscussionsResolved: true,
approvalsRequired: 2,
approvalsGiven: 1,
pipelineStatus: null,
pipelineId: null,
pipelineUrl: null,
mergeWhenPipelineSucceeds: false,
},
}),
timeline({
items: [
{
id: "note-1",
kind: "comment",
author: "reviewer",
body: "Looks good",
createdAt: 1000,
url: "https://gitlab.com/group/repo/-/merge_requests/7#note_1",
},
],
}),
2000,
"gitlab",
);
expect(data?.provider).toEqual({ id: "gitlab", label: "GitLab" });
expect(data?.activity[0]?.provider).toBe("gitlab");
});
});
describe("mapPipelineStatus", () => {
it("maps GitLab and neutral pipeline statuses onto check statuses", () => {
expect(mapPipelineStatus("success")).toBe("success");
expect(mapPipelineStatus("passed")).toBe("success");
expect(mapPipelineStatus("failed")).toBe("failure");
expect(mapPipelineStatus("canceled")).toBe("skipped");
expect(mapPipelineStatus("skipped")).toBe("skipped");
expect(mapPipelineStatus("manual")).toBe("pending");
expect(mapPipelineStatus("running")).toBe("pending");
expect(mapPipelineStatus("pending")).toBe("pending");
expect(mapPipelineStatus("created")).toBe("pending");
expect(mapPipelineStatus("waiting_for_resource")).toBe("pending");
expect(mapPipelineStatus("preparing")).toBe("pending");
expect(mapPipelineStatus("scheduled")).toBe("pending");
expect(mapPipelineStatus("anything-else")).toBe("pending");
});
it("marks running and queued pipeline statuses as live for polling", () => {
expect(isPipelineActiveStatus("running")).toBe(true);
expect(isPipelineActiveStatus("pending")).toBe(true);
expect(isPipelineActiveStatus("created")).toBe(true);
expect(isPipelineActiveStatus("waiting_for_resource")).toBe(true);
expect(isPipelineActiveStatus("preparing")).toBe(true);
expect(isPipelineActiveStatus("scheduled")).toBe(true);
expect(isPipelineActiveStatus("success")).toBe(false);
expect(isPipelineActiveStatus("failed")).toBe(false);
expect(isPipelineActiveStatus("canceled")).toBe(false);
});
});
describe("deriveAvatarColor", () => {
it("returns a shared identity color, matched case-insensitively", () => {
const palette = IDENTITY_COLOR_NAMES.map(identityColor);
expect(palette).toContain(deriveAvatarColor("alice"));
expect(deriveAvatarColor("Alice")).toBe(deriveAvatarColor("alice"));
});
});
describe("formatAge", () => {
it("emits PR pane age labels", () => {
const now = Date.UTC(2026, 0, 1, 12, 0, 0);
expect(formatAge(now - 20_000, now)).toBe("just now");
expect(formatAge(now - 5 * 60_000, now)).toBe("5m ago");
expect(formatAge(now - 2 * 60 * 60_000, now)).toBe("2h ago");
expect(formatAge(now - 3 * 24 * 60 * 60_000, now)).toBe("3d ago");
expect(formatAge(now - 90 * 24 * 60 * 60_000, now)).toBe("3mo ago");
expect(formatAge(now - 365 * 24 * 60 * 60_000, now)).toBe("1y ago");
});
});
describe("getStateLabel", () => {
it.each([
["open", "Open"],
["draft", "Draft"],
["merged", "Merged"],
["closed", "Closed"],
] as const)("maps %s → %s", (state, expected) => {
expect(getStateLabel(state)).toBe(expected);
});
});
describe("getActivityVerb", () => {
it("returns Commented for comment kind", () => {
expect(getActivityVerb({ kind: "comment" })).toBe("Commented");
});
it("returns Approved for approved review", () => {
expect(getActivityVerb({ kind: "review", reviewState: "approved" })).toBe("Approved");
});
it("returns Requested changes for changes_requested review", () => {
expect(getActivityVerb({ kind: "review", reviewState: "changes_requested" })).toBe(
"Requested changes",
);
});
it("returns Reviewed for a commented review with body (generic case)", () => {
expect(getActivityVerb({ kind: "review", reviewState: "commented" })).toBe("Reviewed");
});
it("returns Reviewed when reviewState is undefined", () => {
expect(getActivityVerb({ kind: "review" })).toBe("Reviewed");
});
});