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:
Mohamed Boudra
2026-04-19 14:26:52 +07:00
parent 6a36db9924
commit e373c33108
27 changed files with 4583 additions and 174 deletions
@@ -0,0 +1,282 @@
import { describe, expect, test } from "vitest";
import { z } from "zod";
import {
CheckoutPrStatusResponseSchema,
PullRequestTimelineRequestSchema,
PullRequestTimelineResponseSchema,
SessionInboundMessageSchema,
SessionOutboundMessageSchema,
} from "./messages.js";
describe("pull request timeline message schemas", () => {
test("requires request identity fields", () => {
expect(() =>
PullRequestTimelineRequestSchema.parse({
type: "pull_request_timeline_request",
}),
).toThrow();
});
test("parses request fields", () => {
const parsed = PullRequestTimelineRequestSchema.parse({
type: "pull_request_timeline_request",
cwd: "/tmp/repo",
prNumber: 42,
repoOwner: "getpaseo",
repoName: "paseo",
requestId: "request-1",
});
expect(parsed).toEqual({
type: "pull_request_timeline_request",
cwd: "/tmp/repo",
prNumber: 42,
repoOwner: "getpaseo",
repoName: "paseo",
requestId: "request-1",
});
});
test("parses request through the inbound message union", () => {
const parsed = SessionInboundMessageSchema.parse({
type: "pull_request_timeline_request",
cwd: "/tmp/repo",
prNumber: 42,
repoOwner: "getpaseo",
repoName: "paseo",
requestId: "request-1",
});
expect(parsed).toEqual({
type: "pull_request_timeline_request",
cwd: "/tmp/repo",
prNumber: 42,
repoOwner: "getpaseo",
repoName: "paseo",
requestId: "request-1",
});
});
test("defaults optional response payload and malformed timeline item fields", () => {
const parsed = PullRequestTimelineResponseSchema.parse({
type: "pull_request_timeline_response",
payload: {
items: [{}, { kind: "review" }],
},
});
expect(parsed).toEqual({
type: "pull_request_timeline_response",
payload: {
cwd: "",
prNumber: null,
items: [
{
id: "",
kind: "comment",
author: "unknown",
body: "",
createdAt: 0,
url: "",
},
{
id: "",
kind: "review",
author: "unknown",
body: "",
createdAt: 0,
url: "",
reviewState: "commented",
},
],
truncated: false,
error: null,
requestId: "",
githubFeaturesEnabled: true,
},
});
});
test("normalizes unknown timeline item kinds to comments", () => {
const parsed = PullRequestTimelineResponseSchema.parse({
type: "pull_request_timeline_response",
payload: {
items: [
{
id: "future-1",
kind: "unknown_future_kind",
author: "octocat",
body: "Future daemon item",
createdAt: 1710000000000,
url: "https://github.com/getpaseo/paseo/pull/42#future-1",
},
],
},
});
expect(parsed.payload.items).toEqual([
{
id: "future-1",
kind: "comment",
author: "octocat",
body: "Future daemon item",
createdAt: 1710000000000,
url: "https://github.com/getpaseo/paseo/pull/42#future-1",
},
]);
});
test("parses response through the outbound message union", () => {
const parsed = SessionOutboundMessageSchema.parse({
type: "pull_request_timeline_response",
payload: {
cwd: "/tmp/repo",
prNumber: 42,
items: [
{
id: "review-1",
kind: "review",
author: "octocat",
body: "Looks good",
createdAt: 1710000000000,
url: "https://github.com/getpaseo/paseo/pull/42#pullrequestreview-1",
reviewState: "approved",
},
{
id: "comment-1",
kind: "comment",
author: "hubot",
body: "Left a note",
createdAt: 1710000001000,
url: "https://github.com/getpaseo/paseo/pull/42#issuecomment-1",
},
],
truncated: true,
error: { kind: "unknown", message: "rate limited" },
requestId: "request-1",
githubFeaturesEnabled: true,
},
});
expect(parsed.type).toBe("pull_request_timeline_response");
});
test("normalizes future error kinds to unknown", () => {
const parsed = PullRequestTimelineResponseSchema.parse({
type: "pull_request_timeline_response",
payload: {
error: { kind: "future_kind", message: "x" },
},
});
expect(parsed.payload.error).toEqual({ kind: "unknown", message: "x" });
});
test.each([
"rate_limited",
"",
undefined,
])("normalizes malformed error kind %j to unknown", (kind) => {
const parsed = PullRequestTimelineResponseSchema.parse({
type: "pull_request_timeline_response",
payload: {
error: { kind },
},
});
expect(parsed.payload.error).toEqual({ kind: "unknown", message: "" });
});
});
describe("checkout PR status compatibility", () => {
test("an old client schema parses a new daemon checkout PR status response", () => {
const oldClientCheckoutPrStatusResponseSchema = z.object({
type: z.literal("checkout_pr_status_response"),
payload: z.object({
cwd: z.string(),
status: z
.object({
url: z.string(),
title: z.string(),
state: z.string(),
baseRefName: z.string(),
headRefName: z.string(),
isMerged: z.boolean(),
checks: z
.array(
z.object({
name: z.string(),
status: z.string(),
url: z.string().nullable(),
}),
)
.optional()
.default([]),
checksStatus: z.string().optional(),
reviewDecision: z.string().nullable().optional(),
})
.nullable(),
githubFeaturesEnabled: z.boolean(),
error: z.unknown().nullable(),
requestId: z.string(),
}),
});
const newDaemonPayload = CheckoutPrStatusResponseSchema.parse({
type: "checkout_pr_status_response",
payload: {
cwd: "/tmp/repo",
status: {
number: 42,
url: "https://github.com/getpaseo/paseo/pull/42",
title: "Wire real PR pane data",
state: "OPEN",
baseRefName: "main",
headRefName: "feature/pr-pane",
isMerged: false,
isDraft: true,
checks: [
{
name: "server-tests",
status: "success",
url: "https://github.com/getpaseo/paseo/actions/runs/123",
workflow: "Server CI",
duration: "2m 14s",
},
],
checksStatus: "success",
reviewDecision: "APPROVED",
},
githubFeaturesEnabled: true,
error: null,
requestId: "request-compat",
},
});
expect(oldClientCheckoutPrStatusResponseSchema.parse(newDaemonPayload)).toEqual({
type: "checkout_pr_status_response",
payload: {
cwd: "/tmp/repo",
status: {
url: "https://github.com/getpaseo/paseo/pull/42",
title: "Wire real PR pane data",
state: "OPEN",
baseRefName: "main",
headRefName: "feature/pr-pane",
isMerged: false,
checks: [
{
name: "server-tests",
status: "success",
url: "https://github.com/getpaseo/paseo/actions/runs/123",
},
],
checksStatus: "success",
reviewDecision: "APPROVED",
},
githubFeaturesEnabled: true,
error: null,
requestId: "request-compat",
},
});
});
});
+104 -2
View File
@@ -1133,6 +1133,15 @@ export const CheckoutPrStatusRequestSchema = z.object({
requestId: z.string(),
});
export const PullRequestTimelineRequestSchema = z.object({
type: z.literal("pull_request_timeline_request"),
cwd: z.string(),
prNumber: z.number(),
repoOwner: z.string(),
repoName: z.string(),
requestId: z.string(),
});
export const ValidateBranchRequestSchema = z.object({
type: z.literal("validate_branch_request"),
cwd: z.string(),
@@ -1554,6 +1563,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
CheckoutPushRequestSchema,
CheckoutPrCreateRequestSchema,
CheckoutPrStatusRequestSchema,
PullRequestTimelineRequestSchema,
CheckoutSwitchBranchRequestSchema,
StashSaveRequestSchema,
StashPopRequestSchema,
@@ -2505,24 +2515,31 @@ export const CheckoutPrCreateResponseSchema = z.object({
}),
});
const CheckoutPrStatusSchema = z.object({
export const CheckoutPrStatusSchema = z.object({
number: z.number().optional(),
url: z.string(),
title: z.string(),
state: z.string(),
baseRefName: z.string(),
headRefName: z.string(),
isMerged: z.boolean(),
isDraft: z.boolean().optional().default(false),
checks: z
.array(
z.object({
name: z.string(),
status: z.string(),
url: z.string().nullable(),
workflow: z.string().optional(),
duration: z.string().optional(),
}),
)
.optional(),
.optional()
.default([]),
checksStatus: z.string().optional(),
reviewDecision: z.string().nullable().optional(),
repoOwner: z.string().optional(),
repoName: z.string().optional(),
});
export const CheckoutPrStatusResponseSchema = z.object({
@@ -2536,6 +2553,87 @@ export const CheckoutPrStatusResponseSchema = z.object({
}),
});
const PullRequestTimelineKnownErrorSchema = z.discriminatedUnion("kind", [
z.object({
kind: z.literal("not_found"),
message: z.string().optional().default(""),
}),
z.object({
kind: z.literal("forbidden"),
message: z.string().optional().default(""),
}),
z.object({
kind: z.literal("unknown"),
message: z.string().optional().default(""),
}),
]);
const PullRequestTimelineErrorSchema = z.preprocess((value) => {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return { kind: "unknown", message: "" };
}
const error = value as Record<string, unknown>;
if (error.kind === "not_found" || error.kind === "forbidden" || error.kind === "unknown") {
return error;
}
return { ...error, kind: "unknown" };
}, PullRequestTimelineKnownErrorSchema);
const PullRequestTimelineReviewItemSchema = z.object({
id: z.string().optional().default(""),
kind: z.literal("review"),
author: z.string().optional().default("unknown"),
body: z.string().optional().default(""),
createdAt: z.number().optional().default(0),
url: z.string().optional().default(""),
reviewState: z
.enum(["approved", "changes_requested", "commented"])
.optional()
.default("commented"),
});
const PullRequestTimelineCommentItemSchema = z.object({
id: z.string().optional().default(""),
kind: z.literal("comment"),
author: z.string().optional().default("unknown"),
body: z.string().optional().default(""),
createdAt: z.number().optional().default(0),
url: z.string().optional().default(""),
});
export const PullRequestTimelineItemSchema = z.preprocess(
(value) => {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return value;
}
const item = value as Record<string, unknown>;
if (item.kind === "review" || item.kind === "comment") {
return item;
}
return { ...item, kind: "comment" };
},
z.discriminatedUnion("kind", [
PullRequestTimelineReviewItemSchema,
PullRequestTimelineCommentItemSchema,
]),
);
export const PullRequestTimelineResponseSchema = z.object({
type: z.literal("pull_request_timeline_response"),
payload: z
.object({
cwd: z.string().optional().default(""),
prNumber: z.number().nullable().optional().default(null),
items: z.array(PullRequestTimelineItemSchema).optional().default([]),
truncated: z.boolean().optional().default(false),
error: PullRequestTimelineErrorSchema.nullable().optional().default(null),
requestId: z.string().optional().default(""),
githubFeaturesEnabled: z.boolean().optional().default(true),
})
.optional()
.default({}),
});
export const CheckoutSwitchBranchResponseSchema = z.object({
type: z.literal("checkout_switch_branch_response"),
payload: z.object({
@@ -3000,6 +3098,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
CheckoutPushResponseSchema,
CheckoutPrCreateResponseSchema,
CheckoutPrStatusResponseSchema,
PullRequestTimelineResponseSchema,
CheckoutSwitchBranchResponseSchema,
StashSaveResponseSchema,
StashPopResponseSchema,
@@ -3234,6 +3333,9 @@ export type CheckoutPrCreateRequest = z.infer<typeof CheckoutPrCreateRequestSche
export type CheckoutPrCreateResponse = z.infer<typeof CheckoutPrCreateResponseSchema>;
export type CheckoutPrStatusRequest = z.infer<typeof CheckoutPrStatusRequestSchema>;
export type CheckoutPrStatusResponse = z.infer<typeof CheckoutPrStatusResponseSchema>;
export type PullRequestTimelineRequest = z.infer<typeof PullRequestTimelineRequestSchema>;
export type PullRequestTimelineItem = z.infer<typeof PullRequestTimelineItemSchema>;
export type PullRequestTimelineResponse = z.infer<typeof PullRequestTimelineResponseSchema>;
export type CheckoutSwitchBranchRequest = z.infer<typeof CheckoutSwitchBranchRequestSchema>;
export type CheckoutSwitchBranchResponse = z.infer<typeof CheckoutSwitchBranchResponseSchema>;
export type StashSaveRequest = z.infer<typeof StashSaveRequestSchema>;