mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-23 09:42:53 +00:00
* feat(protocol): add forge check presentation metadata Expose optional rawStatus, isManual, and requiresAction fields while keeping older check payloads backward-compatible. * fix(gitea): model Actions jobs as tasks Gitea's /actions/tasks endpoint returns individual jobs rather than workflow runs. Preserve sibling tasks from the latest execution, avoid collapsing same-name jobs, and enrich blocked tasks with run-level approval metadata. * feat(app): distinguish forge check presentation states * fix(gitlab): preserve pipeline job semantics * refactor(app): centralize forge check presentation policy Use one canonical category order, count shape, visual policy, and translated copy mapping across sidebar, hover-card, PR-pane, and GitLab pipeline surfaces. * refactor: consolidate forge check handling * feat(forge): auto-attach pasted forge references * docs(compat): correct forge rollout metadata * refactor(forge): model check nuances as traits * fix(forge): harden checks from branch review Structural Gitea Actions shadow dedup by run id (verified upstream), rerun grouping without workflow_id, shared trait/status constants and Gitea state mapping in the protocol package, all cloud host aliases in pasted-link matching, logged glab job-detail failures, concurrent approvals/pipeline fetch, checks aggregate from the same pipeline as the job list, and removal of the unread check-level rawStatus. The released pipeline-level rawStatus is staged for removal under COMPAT(pipelineRawStatus). * fix(gitea): detect fork-PR Actions approval hold as action_required gitea.com represents a fork PR awaiting Actions approval as a run in status "waiting" with no tasks and pending shadow commit statuses; it does not expose need_approval. The previous mapping only set action_required from a task with blocked+need_approval, so the gate never surfaced (0 tasks -> early return; need_approval absent). Detect the hold from /actions/runs: a waiting/blocked run whose PR is cross-repo (head.repo.id != base.repo.id), or one that does expose need_approval (self-hosted). Tag the run's pending shadow statuses action_required. Runs are only fetched when a pending shadow has no covering task, keeping the common path at two requests. Verified live against gitea.com fork PR (run 715170, waiting, cross-repo). * fix(gitea): resolve cross-fork PRs by head sha The current-PR matcher gated candidates on the checkout owner (giteaHeadOwner === expectedHeadOwner), which excludes every fork PR: its head carries the fork owner while the checkout is the base repo, so a fork PR - the only shape that can be approval-held on Gitea - never resolved and its checks never showed in-app. GitLab has no such gate (it resolves fork MRs from the target project by source branch). Accept a recent PR whose head sha equals the workspace head sha regardless of owner; the sha is an unambiguous identity, so the anti-collision owner gate still applies when no sha match is available. Verified live against gitea.com fork PR #6 (resolves + renders action_required). * fix: resolve forge rebase drift * fix: type forge auto-attach boundary * fix(i18n): add Korean check status copy --------- Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
816 lines
24 KiB
TypeScript
816 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 { 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("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");
|
|
});
|
|
});
|
|
|
|
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");
|
|
});
|
|
});
|