mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-27 11:42:52 +00:00
* refactor(forge): forge-neutral foundation (GitHub-only) Decouple git-hosting from GitHub behind a neutral abstraction (issue #1616), GitHub-only for now; existing GitHub behaviour is unchanged. - Forge manifest, neutral ForgeService contract, forge registry + resolver, and a client forge-module registry. - GitHub code renamed to the neutral shape; PR/Issue attachment wording preserved. - forge.search.response enums parse tolerantly (unknown kind/auth state degrade instead of breaking the client). - createPullRequest reports typed CLI/auth errors instead of a generic message. - forge-resolver host/remote caches are LRU-bounded. - Forge host trust is explicit: only a known cloud host or a CLI-authenticated host is ever talked to; an unauthenticated GitHub Enterprise host fails resolution instead of routing to github.com. - Docs: forge-providers guide, glossary and i18n forge-copy conventions, architecture and rpc-namespacing terminology. - Vitest React Native mocks (unistyles, svg, linking, lucide) consolidated into shared aliased test-stubs. * feat(forge): GitLab adapter, forge-aware UI, pipelines and approvals GitLab adapter over the glab CLI on the neutral contracts: MR status, forge-aware UI, pipeline tree, and N-of-M approvals. - threadIsResolved is part of the neutral timeline item. - Pipeline load failures show an error instead of an empty section. - Manual pipeline jobs render as pending. - Fork/detached MR head pipelines are fetched by MR iid (glab ci get --merge-request). * feat(forge): Gitea family adapter (Gitea, Forgejo, Codeberg) One adapter over the tea CLI serving Gitea, Forgejo, and Codeberg on the neutral contracts. - CI status aggregates commit statuses and Actions runs together. - Gitea's terminal "warning" state maps to failure on server and client. - Gitea Actions check details are reachable from the PR pane by workflowRunId. * refactor(forge): localize compatibility handling * test(forge): expect normalized GitLab facts --------- Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
731 lines
22 KiB
TypeScript
731 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 {
|
|
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",
|
|
duration: "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("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 deterministic color from the PR pane avatar palette", () => {
|
|
const palette = [
|
|
"#8b5cf6",
|
|
"#f97316",
|
|
"#0ea5e9",
|
|
"#10b981",
|
|
"#ef4444",
|
|
"#eab308",
|
|
"#ec4899",
|
|
"#6366f1",
|
|
];
|
|
|
|
expect(deriveAvatarColor("alice")).toBe(deriveAvatarColor("alice"));
|
|
expect(palette).toContain(deriveAvatarColor("alice"));
|
|
expect(palette).toContain(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");
|
|
});
|
|
});
|