feat(app,server): add pull request pane to the workspace sidebar
Introduce a PR tab in the explorer sidebar that shows status checks, reviews, and the merged timeline for the current branch's pull request. Extend checkout PR status with number, draft flag, repo identity, and per-check workflow/duration; add a new pull_request_timeline request/response pair backed by a GraphQL query; enrich the workspace git snapshot to carry the fields through to the client; and extend the checkout-git-actions store so timeline queries are invalidated alongside status when mutations land.
This commit is contained in:
@@ -0,0 +1,337 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type {
|
||||
CheckoutPrStatusResponse,
|
||||
PullRequestTimelineResponse,
|
||||
} from "@server/shared/messages";
|
||||
import { deriveAvatarColor, formatAge, 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");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,227 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
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("#—");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
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