mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-06 00:14:54 +00:00
* fix(app): show PR panel state, activity verbs, and thread badges in the app language The PR panel's state label, the verb after each activity author, and the Resolved and Outdated thread badges were English literals, so they stayed English in every other language. The state and verb translations already existed and were unused; the badges get new keys in every locale. Closes #6214 * fix(app): translate every PR panel verb and state, and phrase them after the author The zh-CN state and review labels still held English ("Open", "Draft", "已 review"), and the Spanish and Portuguese verbs were adjectives that do not read after an author's name. The translation test now checks each verb and state on its own, so one English label fails it. Co-authored-by: Dillon <dillonzq@outlook.com> --------- Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> Co-authored-by: Dillon <dillonzq@outlook.com>
814 lines
24 KiB
TypeScript
814 lines
24 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import type {
|
|
CheckoutPipelineJob,
|
|
CheckoutPrStatusResponse,
|
|
PullRequestTimelineResponse,
|
|
} from "@getpaseo/protocol/messages";
|
|
import {
|
|
countGitlabPipelineJobs,
|
|
isPipelineActiveStatus,
|
|
mapPipelineStatus,
|
|
} from "@/git/forges/gitlab";
|
|
import { i18n } from "@/i18n/i18next";
|
|
import { IDENTITY_COLOR_NAMES, identityColor } from "@/styles/identity-colors";
|
|
import {
|
|
deriveAvatarColor,
|
|
formatAge,
|
|
getActivityVerbKey,
|
|
getStateLabelKey,
|
|
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("keeps checks with null URLs by linking them to the pull request", () => {
|
|
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",
|
|
},
|
|
{
|
|
provider: "github",
|
|
name: "legacy status",
|
|
status: "pending",
|
|
url: "https://github.com/getpaseo/paseo/pull/42",
|
|
},
|
|
]);
|
|
});
|
|
|
|
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: "approval",
|
|
status: "pending",
|
|
traits: ["action_required"],
|
|
url: "https://example.com/3",
|
|
},
|
|
{ name: "skipped", status: "skipped", url: "https://example.com/4" },
|
|
{ name: "cancelled", status: "cancelled", url: "https://example.com/5" },
|
|
{
|
|
name: "manual",
|
|
status: "skipped",
|
|
traits: ["manual"],
|
|
url: "https://example.com/6",
|
|
},
|
|
],
|
|
}),
|
|
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: "approval",
|
|
status: "pending",
|
|
traits: ["action_required"],
|
|
url: "https://example.com/3",
|
|
},
|
|
{ provider: "github", name: "skipped", status: "skipped", url: "https://example.com/4" },
|
|
{
|
|
provider: "github",
|
|
name: "cancelled",
|
|
status: "cancelled",
|
|
url: "https://example.com/5",
|
|
},
|
|
{
|
|
provider: "github",
|
|
name: "manual",
|
|
status: "skipped",
|
|
traits: ["manual"],
|
|
url: "https://example.com/6",
|
|
},
|
|
]);
|
|
});
|
|
|
|
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 and warning presentation for aggregate 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: "warning",
|
|
},
|
|
}),
|
|
baseTimeline,
|
|
undefined,
|
|
"forgejo",
|
|
);
|
|
|
|
expect(data?.checks).toEqual([
|
|
{
|
|
provider: "forgejo",
|
|
name: "CI",
|
|
status: "failure",
|
|
traits: ["warning"],
|
|
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("canceling")).toBe("pending");
|
|
expect(mapPipelineStatus("scheduled")).toBe("pending");
|
|
expect(mapPipelineStatus("anything-else")).toBe("pending");
|
|
});
|
|
|
|
it("separates blocking outcomes from allowed failures and optional manual jobs", () => {
|
|
const job = (id: number, jobStatus: string, allowFailure: boolean): CheckoutPipelineJob => ({
|
|
id,
|
|
name: `job-${id}`,
|
|
stage: "test",
|
|
status: jobStatus,
|
|
rawStatus: jobStatus,
|
|
url: null,
|
|
allowFailure,
|
|
durationSeconds: null,
|
|
});
|
|
expect(
|
|
countGitlabPipelineJobs([
|
|
job(1, "success", false),
|
|
job(2, "failed", false),
|
|
job(3, "failed", true),
|
|
job(4, "pending", false),
|
|
job(5, "manual", true),
|
|
job(6, "manual", false),
|
|
]),
|
|
).toEqual({
|
|
success: 1,
|
|
failure: 1,
|
|
warning: 1,
|
|
actionRequired: 1,
|
|
manual: 1,
|
|
pending: 1,
|
|
});
|
|
});
|
|
|
|
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("canceling")).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");
|
|
});
|
|
});
|
|
|
|
const english = i18n.getFixedT("en");
|
|
const french = i18n.getFixedT("fr");
|
|
|
|
describe("getStateLabelKey", () => {
|
|
it.each([
|
|
["open", "Open", "Ouverte"],
|
|
["draft", "Draft", "Brouillon"],
|
|
["merged", "Merged", "Fusionnée"],
|
|
["closed", "Closed", "Fermée"],
|
|
] as const)("labels %s as %s, and %s in French", (state, englishLabel, frenchLabel) => {
|
|
expect(english(getStateLabelKey(state))).toBe(englishLabel);
|
|
expect(french(getStateLabelKey(state))).toBe(frenchLabel);
|
|
});
|
|
});
|
|
|
|
describe("getActivityVerbKey", () => {
|
|
it.each([
|
|
[{ kind: "comment" }, "Commented", "A commenté"],
|
|
[{ kind: "review", reviewState: "approved" }, "Approved", "A approuvé"],
|
|
[
|
|
{ kind: "review", reviewState: "changes_requested" },
|
|
"Requested changes",
|
|
"A demandé des modifications",
|
|
],
|
|
[{ kind: "review", reviewState: "commented" }, "Reviewed", "A relu"],
|
|
[{ kind: "review" }, "Reviewed", "A relu"],
|
|
] as const)("labels %o as %s, and %s in French", (item, englishVerb, frenchVerb) => {
|
|
expect(english(getActivityVerbKey(item))).toBe(englishVerb);
|
|
expect(french(getActivityVerbKey(item))).toBe(frenchVerb);
|
|
});
|
|
});
|