mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-30 21:23:04 +00:00
* 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".
743 lines
22 KiB
TypeScript
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");
|
|
});
|
|
});
|