feat(checkout): merge PR action with real-GitHub e2e test (#814)
* feat(checkout): merge PR action with real-GitHub e2e test Adds a `checkoutPrMerge` server RPC backed by `gh pr merge` (squash, merge, or rebase) and surfaces three new actions in the git-actions menu — "Squash and merge", "Create a merge commit", "Rebase and merge" — with squash taking the primary slot when a PR is open and mergeable. Also renames the existing local merge action to "Merge locally" to disambiguate it from the new GitHub-side merge. Adds an end-to-end test that creates a real temporary GitHub repo, opens a PR via the daemon, polls until mergeable, calls `checkoutPrMerge`, and verifies the merge landed on `main` — no mocks. The test pre-flights the `delete_repo` scope so it skips cleanly on tokens that can't tear down, and fails loud if cleanup ever errors so we don't leak repos. PR status gains an optional `mergeable` field (`MERGEABLE | CONFLICTING | UNKNOWN`) populated from `gh pr view --json mergeable`. The schema is backward compatible — old payloads without the field still parse and default to `UNKNOWN` via `z.catch`. * Update PR status snapshot expectations * refactor: collect git/checkout/PR feature into packages/app/src/git/ Move 21 git/checkout/PR files out of components/, hooks/, stores/, screens/, utils/ flat-peer slop into a single packages/app/src/git/ module with shrunk filenames (git-diff-pane.tsx -> diff-pane.tsx, use-git-actions.ts -> use-actions.ts, etc). Dedupe: drop the duplicate useGitActionHandlers from diff-pane.tsx — the shared useGitActions hook is the single source. Drop the buildGitActionsForPane wrapper. Collapse the merge-PR triple fan-out (mergePrSquash/Merge/RebaseStatus) into one record keyed by CheckoutPrMergeMethod. Streamline server-side single-use helpers (dispatchStash/PullRequestMessage adapters, GitHubPullRequestMergeError wrapper, legacy worktree test helpers). Schema-additive only: messages.ts exports CheckoutPrMergeMethod and PullRequestMergeable types; daemon-client.ts uses the typed alias. Backward-compatible. Net delta: 43 files, +219/-1212. * fix(app): use GitHub icon for all merge-PR menu actions * test: namespace temp GitHub repos under `paseotmp-` and centralize naming Old prefixes (`paseo-checkout-ship-`, `paseo-e2e-`, `paseo-checkout-pr-merge-`) collided with the `paseo-*` namespace of real repos, making bulk cleanup unsafe. Switch every test that creates real GitHub repos to a single unmistakable `paseotmp-` prefix, owned by one shared helper per test boundary so no caller can name a repo outside the namespace. - New `packages/server/src/server/test-utils/temp-github-repo.ts` exports `TEMP_GITHUB_REPO_PREFIX` + `createTempGithubRepoName(category)`; both server e2e tests (checkout-ship, checkout-pr-merge) consume it. - `packages/app/e2e/helpers/github-fixtures.ts` locks the prefix internally and now requires callers to pass a `category` instead of an arbitrary `prefix`. Updated pr-pane and composer-attachments specs accordingly.
This commit is contained in:
@@ -4,7 +4,7 @@ import {
|
||||
buildSplitDiffRows,
|
||||
buildUnifiedDiffLines,
|
||||
} from "./diff-layout";
|
||||
import type { ParsedDiffFile } from "@/hooks/use-checkout-diff-query";
|
||||
import type { ParsedDiffFile } from "@/git/use-diff-query";
|
||||
import type { ReviewableDiffTarget } from "./diff-layout";
|
||||
|
||||
function makeFile(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { DiffLine, ParsedDiffFile } from "@/hooks/use-checkout-diff-query";
|
||||
import type { DiffLine, ParsedDiffFile } from "@/git/use-diff-query";
|
||||
|
||||
type ReviewSide = "old" | "new";
|
||||
type ReviewableLineType = "add" | "remove" | "context";
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { shouldAnchorHeaderBeforeCollapse } from "./git-diff-scroll";
|
||||
|
||||
describe("shouldAnchorHeaderBeforeCollapse", () => {
|
||||
it("skips anchor when header is fully visible in viewport", () => {
|
||||
expect(
|
||||
shouldAnchorHeaderBeforeCollapse({
|
||||
headerOffset: 120,
|
||||
headerHeight: 44,
|
||||
viewportOffset: 80,
|
||||
viewportHeight: 400,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("skips anchor when header is partially visible in viewport", () => {
|
||||
expect(
|
||||
shouldAnchorHeaderBeforeCollapse({
|
||||
headerOffset: 60,
|
||||
headerHeight: 44,
|
||||
viewportOffset: 80,
|
||||
viewportHeight: 300,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("anchors when header is above viewport", () => {
|
||||
expect(
|
||||
shouldAnchorHeaderBeforeCollapse({
|
||||
headerOffset: 200,
|
||||
headerHeight: 44,
|
||||
viewportOffset: 500,
|
||||
viewportHeight: 300,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("anchors when header is below viewport", () => {
|
||||
expect(
|
||||
shouldAnchorHeaderBeforeCollapse({
|
||||
headerOffset: 600,
|
||||
headerHeight: 44,
|
||||
viewportOffset: 100,
|
||||
viewportHeight: 300,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("anchors when viewport metrics are unavailable", () => {
|
||||
expect(
|
||||
shouldAnchorHeaderBeforeCollapse({
|
||||
headerOffset: 0,
|
||||
headerHeight: 44,
|
||||
viewportOffset: 0,
|
||||
viewportHeight: 0,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,36 +0,0 @@
|
||||
interface AnchorVisibilityInput {
|
||||
headerOffset: number;
|
||||
headerHeight: number;
|
||||
viewportOffset: number;
|
||||
viewportHeight: number;
|
||||
edgeThreshold?: number;
|
||||
}
|
||||
|
||||
export function shouldAnchorHeaderBeforeCollapse({
|
||||
headerOffset,
|
||||
headerHeight,
|
||||
viewportOffset,
|
||||
viewportHeight,
|
||||
edgeThreshold = 1,
|
||||
}: AnchorVisibilityInput): boolean {
|
||||
if (
|
||||
!Number.isFinite(headerOffset) ||
|
||||
!Number.isFinite(headerHeight) ||
|
||||
!Number.isFinite(viewportOffset) ||
|
||||
!Number.isFinite(viewportHeight) ||
|
||||
viewportHeight <= 0
|
||||
) {
|
||||
// Preserve current behavior when metrics are unavailable.
|
||||
return true;
|
||||
}
|
||||
|
||||
const clampedThreshold = Math.max(0, edgeThreshold);
|
||||
const clampedHeaderHeight = Math.max(0, headerHeight);
|
||||
const headerStart = headerOffset;
|
||||
const headerEnd = headerOffset + clampedHeaderHeight;
|
||||
const viewportStart = viewportOffset + clampedThreshold;
|
||||
const viewportEnd = viewportOffset + viewportHeight - clampedThreshold;
|
||||
const headerVisible = headerEnd > viewportStart && headerStart < viewportEnd;
|
||||
|
||||
return !headerVisible;
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildGitHubBranchTreeUrl, parseGitHubRepoFromRemote } from "./github-repo-url";
|
||||
|
||||
describe("parseGitHubRepoFromRemote", () => {
|
||||
it.each([
|
||||
["https://github.com/acme/repo.git", "acme/repo"],
|
||||
["https://github.com/acme/repo", "acme/repo"],
|
||||
["git@github.com:acme/repo.git", "acme/repo"],
|
||||
["ssh://git@github.com/acme/repo.git", "acme/repo"],
|
||||
["https://github.com/acme/repo/", "acme/repo"],
|
||||
])("extracts the repo from %s", (remoteUrl, expected) => {
|
||||
expect(parseGitHubRepoFromRemote(remoteUrl)).toBe(expected);
|
||||
});
|
||||
|
||||
it("returns null for non-GitHub remotes", () => {
|
||||
expect(parseGitHubRepoFromRemote("git@gitlab.com:acme/repo.git")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for invalid URLs", () => {
|
||||
expect(parseGitHubRepoFromRemote("not a url")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildGitHubBranchTreeUrl", () => {
|
||||
it("builds a branch-specific GitHub tree URL", () => {
|
||||
expect(
|
||||
buildGitHubBranchTreeUrl({
|
||||
remoteUrl: "git@github.com:acme/repo.git",
|
||||
branch: "feature/workspace-button",
|
||||
}),
|
||||
).toBe("https://github.com/acme/repo/tree/feature/workspace-button");
|
||||
});
|
||||
|
||||
it("encodes reserved branch characters while preserving slash-separated branch names", () => {
|
||||
expect(
|
||||
buildGitHubBranchTreeUrl({
|
||||
remoteUrl: "https://github.com/acme/repo.git",
|
||||
branch: "feature/ship #42",
|
||||
}),
|
||||
).toBe("https://github.com/acme/repo/tree/feature/ship%20%2342");
|
||||
});
|
||||
|
||||
it("returns null when the current branch is unavailable", () => {
|
||||
expect(
|
||||
buildGitHubBranchTreeUrl({
|
||||
remoteUrl: "https://github.com/acme/repo.git",
|
||||
branch: "HEAD",
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,53 +0,0 @@
|
||||
// TODO: this duplicates parseGitHubRepoFromRemote in packages/server/src/services/github-service.ts.
|
||||
// Consolidate into a shared package once we have a third caller.
|
||||
|
||||
// Note: SSH host aliases (e.g. `git@github-work:acme/repo.git` resolved via ~/.ssh/config)
|
||||
// are not detected here, so the GitHub action will silently not appear for those remotes.
|
||||
export function parseGitHubRepoFromRemote(remoteUrl: string | null | undefined): string | null {
|
||||
const trimmed = remoteUrl?.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let cleaned = trimmed;
|
||||
if (cleaned.startsWith("git@github.com:")) {
|
||||
cleaned = cleaned.slice("git@github.com:".length);
|
||||
} else {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(cleaned);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (parsed.hostname !== "github.com") {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
cleaned = decodeURIComponent(parsed.pathname.replace(/^\/+/, ""));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
cleaned = cleaned.replace(/\/+$/, "");
|
||||
if (cleaned.endsWith(".git")) {
|
||||
cleaned = cleaned.slice(0, -".git".length);
|
||||
}
|
||||
if (!cleaned.includes("/")) {
|
||||
return null;
|
||||
}
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
export function buildGitHubBranchTreeUrl(input: {
|
||||
remoteUrl: string | null | undefined;
|
||||
branch: string | null | undefined;
|
||||
}): string | null {
|
||||
const repo = parseGitHubRepoFromRemote(input.remoteUrl);
|
||||
const branch = input.branch?.trim();
|
||||
if (!repo || !branch || branch === "HEAD") {
|
||||
return null;
|
||||
}
|
||||
const encodedBranch = branch.split("/").map(encodeURIComponent).join("/");
|
||||
return `https://github.com/${repo}/tree/${encodedBranch}`;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { CheckoutStatusPayload } from "@/hooks/use-checkout-status-query";
|
||||
import type { CheckoutStatusPayload } from "@/git/use-status-query";
|
||||
import {
|
||||
parseAgentKey,
|
||||
resolveNewAgentWorkingDir,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { CheckoutStatusPayload } from "@/hooks/use-checkout-status-query";
|
||||
import type { CheckoutStatusPayload } from "@/git/use-status-query";
|
||||
import {
|
||||
parseHostWorkspaceOpenIntentFromPathname,
|
||||
parseHostAgentRouteFromPathname,
|
||||
|
||||
@@ -1,378 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type {
|
||||
CheckoutPrStatusResponse,
|
||||
PullRequestTimelineResponse,
|
||||
} from "@server/shared/messages";
|
||||
import {
|
||||
deriveAvatarColor,
|
||||
formatAge,
|
||||
getActivityVerb,
|
||||
getStateLabel,
|
||||
mapPrPaneData,
|
||||
} from "./pr-pane-data";
|
||||
|
||||
type CheckoutPrStatus = NonNullable<CheckoutPrStatusResponse["payload"]["status"]>;
|
||||
type PullRequestTimeline = PullRequestTimelineResponse["payload"];
|
||||
|
||||
const baseStatus: CheckoutPrStatus = {
|
||||
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,
|
||||
checks: [],
|
||||
reviewDecision: null,
|
||||
};
|
||||
|
||||
const baseTimeline: PullRequestTimeline = {
|
||||
cwd: "/repo",
|
||||
prNumber: 42,
|
||||
items: [],
|
||||
truncated: false,
|
||||
error: null,
|
||||
requestId: "timeline-1",
|
||||
githubFeaturesEnabled: true,
|
||||
};
|
||||
|
||||
function status(overrides: Partial<CheckoutPrStatus> = {}): CheckoutPrStatus {
|
||||
return { ...baseStatus, ...overrides };
|
||||
}
|
||||
|
||||
function timeline(overrides: Partial<PullRequestTimeline> = {}): PullRequestTimeline {
|
||||
return { ...baseTimeline, ...overrides };
|
||||
}
|
||||
|
||||
describe("mapPrPaneData", () => {
|
||||
it("returns null when no PR status exists", () => {
|
||||
expect(mapPrPaneData(null, baseTimeline)).toBeNull();
|
||||
});
|
||||
|
||||
it("derives the PR number from the status URL when the status number is absent", () => {
|
||||
const data = mapPrPaneData(
|
||||
status({
|
||||
number: undefined,
|
||||
url: "https://github.com/getpaseo/paseo/pull/1284",
|
||||
}),
|
||||
timeline({ prNumber: 1284 }),
|
||||
);
|
||||
|
||||
expect(data?.number).toBe(1284);
|
||||
});
|
||||
|
||||
it("returns null when status has no number and no parseable PR URL", () => {
|
||||
expect(
|
||||
mapPrPaneData(status({ number: undefined, url: "https://github.com/getpaseo/paseo" }), null),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("derives PR state with merged taking precedence over closed, draft, and open", () => {
|
||||
expect(
|
||||
mapPrPaneData(status({ isMerged: true, isDraft: true, state: "closed" }), baseTimeline)
|
||||
?.state,
|
||||
).toBe("merged");
|
||||
expect(
|
||||
mapPrPaneData(status({ isMerged: false, isDraft: true, state: "closed" }), baseTimeline)
|
||||
?.state,
|
||||
).toBe("closed");
|
||||
expect(mapPrPaneData(status({ isDraft: true, state: "open" }), baseTimeline)?.state).toBe(
|
||||
"draft",
|
||||
);
|
||||
expect(mapPrPaneData(status({ isDraft: false, state: "open" }), baseTimeline)?.state).toBe(
|
||||
"open",
|
||||
);
|
||||
});
|
||||
|
||||
it("drops checks with null URLs to preserve the pressable check contract", () => {
|
||||
const data = mapPrPaneData(
|
||||
status({
|
||||
checks: [
|
||||
{ name: "typecheck", status: "success", url: "https://example.com/checks/1" },
|
||||
{ name: "legacy status", status: "pending", url: null },
|
||||
],
|
||||
}),
|
||||
baseTimeline,
|
||||
);
|
||||
|
||||
expect(data?.checks).toEqual([
|
||||
{
|
||||
name: "typecheck",
|
||||
status: "success",
|
||||
url: "https://example.com/checks/1",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("maps server check statuses into the frozen check status union", () => {
|
||||
const data = mapPrPaneData(
|
||||
status({
|
||||
checks: [
|
||||
{
|
||||
name: "success",
|
||||
status: "success",
|
||||
url: "https://example.com/1",
|
||||
workflow: "CI",
|
||||
duration: "1m",
|
||||
},
|
||||
{ name: "failure", status: "failure", url: "https://example.com/2" },
|
||||
{ name: "pending", status: "pending", url: "https://example.com/3" },
|
||||
{ name: "skipped", status: "skipped", url: "https://example.com/4" },
|
||||
{ name: "cancelled", status: "cancelled", url: "https://example.com/5" },
|
||||
],
|
||||
}),
|
||||
baseTimeline,
|
||||
);
|
||||
|
||||
expect(data?.checks).toEqual([
|
||||
{
|
||||
name: "success",
|
||||
workflow: "CI",
|
||||
status: "success",
|
||||
duration: "1m",
|
||||
url: "https://example.com/1",
|
||||
},
|
||||
{ name: "failure", status: "failure", url: "https://example.com/2" },
|
||||
{ name: "pending", status: "pending", url: "https://example.com/3" },
|
||||
{ name: "skipped", status: "skipped", url: "https://example.com/4" },
|
||||
{ name: "cancelled", status: "skipped", url: "https://example.com/5" },
|
||||
]);
|
||||
});
|
||||
|
||||
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("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("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([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("deriveAvatarColor", () => {
|
||||
it("returns a deterministic color from the PR pane avatar palette", () => {
|
||||
const palette = [
|
||||
"#8b5cf6",
|
||||
"#f97316",
|
||||
"#0ea5e9",
|
||||
"#10b981",
|
||||
"#ef4444",
|
||||
"#eab308",
|
||||
"#ec4899",
|
||||
"#6366f1",
|
||||
];
|
||||
|
||||
expect(deriveAvatarColor("alice")).toBe(deriveAvatarColor("alice"));
|
||||
expect(palette).toContain(deriveAvatarColor("alice"));
|
||||
expect(palette).toContain(deriveAvatarColor("Alice"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatAge", () => {
|
||||
it("emits PR pane age labels", () => {
|
||||
const now = Date.UTC(2026, 0, 1, 12, 0, 0);
|
||||
|
||||
expect(formatAge(now - 20_000, now)).toBe("just now");
|
||||
expect(formatAge(now - 5 * 60_000, now)).toBe("5m ago");
|
||||
expect(formatAge(now - 2 * 60 * 60_000, now)).toBe("2h ago");
|
||||
expect(formatAge(now - 3 * 24 * 60 * 60_000, now)).toBe("3d ago");
|
||||
expect(formatAge(now - 90 * 24 * 60 * 60_000, now)).toBe("3mo ago");
|
||||
expect(formatAge(now - 365 * 24 * 60 * 60_000, now)).toBe("1y ago");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getStateLabel", () => {
|
||||
it.each([
|
||||
["open", "Open"],
|
||||
["draft", "Draft"],
|
||||
["merged", "Merged"],
|
||||
["closed", "Closed"],
|
||||
] as const)("maps %s → %s", (state, expected) => {
|
||||
expect(getStateLabel(state)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getActivityVerb", () => {
|
||||
it("returns Commented for comment kind", () => {
|
||||
expect(getActivityVerb({ kind: "comment" })).toBe("Commented");
|
||||
});
|
||||
|
||||
it("returns Approved for approved review", () => {
|
||||
expect(getActivityVerb({ kind: "review", reviewState: "approved" })).toBe("Approved");
|
||||
});
|
||||
|
||||
it("returns Requested changes for changes_requested review", () => {
|
||||
expect(getActivityVerb({ kind: "review", reviewState: "changes_requested" })).toBe(
|
||||
"Requested changes",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns Reviewed for a commented review with body (generic case)", () => {
|
||||
expect(getActivityVerb({ kind: "review", reviewState: "commented" })).toBe("Reviewed");
|
||||
});
|
||||
|
||||
it("returns Reviewed when reviewState is undefined", () => {
|
||||
expect(getActivityVerb({ kind: "review" })).toBe("Reviewed");
|
||||
});
|
||||
});
|
||||
@@ -1,241 +0,0 @@
|
||||
import type {
|
||||
CheckoutPrStatusResponse,
|
||||
PullRequestTimelineResponse,
|
||||
} from "@server/shared/messages";
|
||||
|
||||
export type PrState = "open" | "draft" | "merged" | "closed";
|
||||
export type CheckStatus = "success" | "failure" | "pending" | "skipped";
|
||||
export type ReviewState = "approved" | "changes_requested" | "commented";
|
||||
export type ActivityKind = "review" | "comment";
|
||||
|
||||
export interface PrPaneCheck {
|
||||
name: string;
|
||||
workflow?: string;
|
||||
status: CheckStatus;
|
||||
duration?: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface PrPaneActivity {
|
||||
kind: ActivityKind;
|
||||
author: string;
|
||||
avatarColor: string;
|
||||
reviewState?: ReviewState;
|
||||
body: string;
|
||||
age: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface PrPaneData {
|
||||
number: number;
|
||||
title: string;
|
||||
state: PrState;
|
||||
url: string;
|
||||
reviewDecision: "approved" | "changes_requested" | "pending";
|
||||
awaitingReviewers: string[];
|
||||
checks: PrPaneCheck[];
|
||||
activity: PrPaneActivity[];
|
||||
}
|
||||
|
||||
type CheckoutPrStatus = CheckoutPrStatusResponse["payload"]["status"];
|
||||
type PullRequestTimeline = PullRequestTimelineResponse["payload"];
|
||||
type PullRequestTimelineItem = PullRequestTimeline["items"][number];
|
||||
|
||||
const AVATAR_COLORS = [
|
||||
"#8b5cf6",
|
||||
"#f97316",
|
||||
"#0ea5e9",
|
||||
"#10b981",
|
||||
"#ef4444",
|
||||
"#eab308",
|
||||
"#ec4899",
|
||||
"#6366f1",
|
||||
];
|
||||
|
||||
export function mapPrPaneData(
|
||||
status: CheckoutPrStatus,
|
||||
timeline: PullRequestTimeline | null | undefined,
|
||||
nowMs = Date.now(),
|
||||
): PrPaneData | null {
|
||||
if (!status) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const number = status.number ?? parsePullRequestNumber(status.url);
|
||||
if (number === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const timelineMatchesStatus = timeline?.prNumber === number;
|
||||
|
||||
return {
|
||||
number,
|
||||
title: status.title,
|
||||
state: derivePrState(status),
|
||||
url: status.url,
|
||||
reviewDecision: mapReviewDecision(status.reviewDecision),
|
||||
// Requested reviewers are intentionally unwired until the server exposes them.
|
||||
awaitingReviewers: [],
|
||||
checks: (status.checks ?? []).flatMap(mapCheck),
|
||||
activity: timelineMatchesStatus
|
||||
? timeline.items.flatMap((item) => mapActivity(item, nowMs))
|
||||
: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function deriveAvatarColor(login: string): string {
|
||||
return AVATAR_COLORS[hashLogin(login) % AVATAR_COLORS.length];
|
||||
}
|
||||
|
||||
export function formatAge(createdAtMs: number, nowMs = Date.now()): string {
|
||||
const elapsedMs = Math.max(0, nowMs - createdAtMs);
|
||||
const elapsedSeconds = Math.floor(elapsedMs / 1000);
|
||||
|
||||
if (elapsedSeconds < 60) {
|
||||
return "just now";
|
||||
}
|
||||
|
||||
const elapsedMinutes = Math.floor(elapsedSeconds / 60);
|
||||
if (elapsedMinutes < 60) {
|
||||
return `${elapsedMinutes}m ago`;
|
||||
}
|
||||
|
||||
const elapsedHours = Math.floor(elapsedMinutes / 60);
|
||||
if (elapsedHours < 24) {
|
||||
return `${elapsedHours}h ago`;
|
||||
}
|
||||
|
||||
const elapsedDays = Math.floor(elapsedHours / 24);
|
||||
if (elapsedDays < 30) {
|
||||
return `${elapsedDays}d ago`;
|
||||
}
|
||||
|
||||
if (elapsedDays < 365) {
|
||||
return `${Math.floor(elapsedDays / 30)}mo ago`;
|
||||
}
|
||||
|
||||
return `${Math.floor(elapsedDays / 365)}y ago`;
|
||||
}
|
||||
|
||||
function derivePrState(status: NonNullable<CheckoutPrStatus>): PrState {
|
||||
if (status.isMerged || status.state === "merged") {
|
||||
return "merged";
|
||||
}
|
||||
if (status.state !== "open") {
|
||||
return "closed";
|
||||
}
|
||||
if (status.isDraft) {
|
||||
return "draft";
|
||||
}
|
||||
return "open";
|
||||
}
|
||||
|
||||
function mapCheck(check: NonNullable<CheckoutPrStatus>["checks"][number]): PrPaneCheck[] {
|
||||
if (check.url === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
name: check.name,
|
||||
workflow: check.workflow,
|
||||
status: mapCheckStatus(check.status),
|
||||
duration: check.duration,
|
||||
url: check.url,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function mapCheckStatus(status: string): CheckStatus {
|
||||
if (
|
||||
status === "success" ||
|
||||
status === "failure" ||
|
||||
status === "pending" ||
|
||||
status === "skipped"
|
||||
) {
|
||||
return status;
|
||||
}
|
||||
if (status === "cancelled") {
|
||||
return "skipped";
|
||||
}
|
||||
return "pending";
|
||||
}
|
||||
|
||||
function mapActivity(item: PullRequestTimelineItem, nowMs: number): PrPaneActivity[] {
|
||||
if (item.kind === "comment") {
|
||||
if (item.body.trim() === "") {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
{
|
||||
kind: "comment",
|
||||
author: item.author,
|
||||
avatarColor: deriveAvatarColor(item.author),
|
||||
body: item.body,
|
||||
age: formatAge(item.createdAt, nowMs),
|
||||
url: item.url,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
if (item.reviewState === "commented" && item.body.trim() === "") {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
kind: "review",
|
||||
author: item.author,
|
||||
avatarColor: deriveAvatarColor(item.author),
|
||||
reviewState: item.reviewState,
|
||||
body: item.body,
|
||||
age: formatAge(item.createdAt, nowMs),
|
||||
url: item.url,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function mapReviewDecision(
|
||||
reviewDecision: NonNullable<CheckoutPrStatus>["reviewDecision"],
|
||||
): PrPaneData["reviewDecision"] {
|
||||
if (reviewDecision === "approved" || reviewDecision === "changes_requested") {
|
||||
return reviewDecision;
|
||||
}
|
||||
return "pending";
|
||||
}
|
||||
|
||||
function parsePullRequestNumber(url: string): number | null {
|
||||
try {
|
||||
const match = new URL(url).pathname.match(/\/pull\/(\d+)(?:\/|$)/);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const number = Number.parseInt(match[1], 10);
|
||||
return Number.isFinite(number) ? number : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function hashLogin(login: string): number {
|
||||
let hash = 0;
|
||||
for (const character of login.toLowerCase()) {
|
||||
hash = (hash * 31 + character.charCodeAt(0)) >>> 0;
|
||||
}
|
||||
return hash;
|
||||
}
|
||||
|
||||
export function getStateLabel(state: PrState): string {
|
||||
if (state === "draft") return "Draft";
|
||||
if (state === "merged") return "Merged";
|
||||
if (state === "closed") return "Closed";
|
||||
return "Open";
|
||||
}
|
||||
|
||||
export function getActivityVerb(item: Pick<PrPaneActivity, "kind" | "reviewState">): string {
|
||||
if (item.kind === "comment") return "Commented";
|
||||
if (item.reviewState === "approved") return "Approved";
|
||||
if (item.reviewState === "changes_requested") return "Requested changes";
|
||||
return "Reviewed";
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { PrPaneData } from "./pr-pane-data";
|
||||
import { formatPrTabLabel } from "./pr-tab-label";
|
||||
|
||||
function makePrPaneData(overrides: Partial<PrPaneData> = {}): PrPaneData {
|
||||
return {
|
||||
number: 42,
|
||||
title: "Sample PR",
|
||||
state: "open",
|
||||
url: "https://github.com/getpaseo/paseo/pull/42",
|
||||
reviewDecision: "pending",
|
||||
awaitingReviewers: [],
|
||||
checks: [],
|
||||
activity: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("formatPrTabLabel", () => {
|
||||
it("returns #<number> when data is present", () => {
|
||||
expect(formatPrTabLabel(makePrPaneData({ number: 42 }))).toBe("#42");
|
||||
});
|
||||
|
||||
it("returns #— fallback when data is null", () => {
|
||||
expect(formatPrTabLabel(null)).toBe("#—");
|
||||
});
|
||||
});
|
||||
@@ -1,5 +0,0 @@
|
||||
import type { PrPaneData } from "./pr-pane-data";
|
||||
|
||||
export function formatPrTabLabel(data: PrPaneData | null): string {
|
||||
return data ? `#${data.number}` : "#—";
|
||||
}
|
||||
Reference in New Issue
Block a user