Files
paseo/packages/app/src/git/pull-request-panel/data.test.ts
T
nllptrxandMohamed Boudra 4a5716360b Feat/forge check semantics and followup fixes (#2337)
* 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>
2026-08-25 23:37:50 +02:00

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");
});
});