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; 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 { return { ...baseStatus, ...overrides }; } function timeline(overrides: Partial = {}): 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"); }); });