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
@@ -30,6 +30,7 @@ import type {
CheckoutPushResponse,
CheckoutPrCreateResponse,
CheckoutPrStatusResponse,
PullRequestTimelineResponse,
CheckoutSwitchBranchResponse,
StashSaveResponse,
StashPopResponse,
@@ -242,6 +243,7 @@ type CheckoutPullPayload = CheckoutPullResponse["payload"];
type CheckoutPushPayload = CheckoutPushResponse["payload"];
type CheckoutPrCreatePayload = CheckoutPrCreateResponse["payload"];
type CheckoutPrStatusPayload = CheckoutPrStatusResponse["payload"];
type PullRequestTimelinePayload = PullRequestTimelineResponse["payload"];
type CheckoutSwitchBranchPayload = CheckoutSwitchBranchResponse["payload"];
type StashSavePayload = StashSaveResponse["payload"];
type StashPopPayload = StashPopResponse["payload"];
@@ -2485,6 +2487,24 @@ export class DaemonClient {
});
}
async pullRequestTimeline(
input: { cwd: string; prNumber: number; repoOwner: string; repoName: string },
requestId?: string,
): Promise<PullRequestTimelinePayload> {
return this.sendCorrelatedSessionRequest({
requestId,
message: {
type: "pull_request_timeline_request",
cwd: input.cwd,
prNumber: input.prNumber,
repoOwner: input.repoOwner,
repoName: input.repoName,
},
responseType: "pull_request_timeline_response",
timeout: 60000,
});
}
async checkoutSwitchBranch(
cwd: string,
branch: string,
+288
View File
@@ -0,0 +1,288 @@
import pino from "pino";
import { afterEach, describe, expect, test, vi } from "vitest";
import { CheckoutPrStatusSchema } from "../shared/messages.js";
import { normalizeCheckoutPrStatusPayload, Session } from "./session.js";
const checkoutGitMocks = vi.hoisted(() => ({
getCheckoutStatus: vi.fn(),
mergeToBase: vi.fn(),
}));
vi.mock("../utils/checkout-git.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../utils/checkout-git.js")>();
return {
...actual,
getCheckoutStatus: checkoutGitMocks.getCheckoutStatus,
mergeToBase: checkoutGitMocks.mergeToBase,
};
});
function createSessionForTest(options?: {
github?: {
invalidate: ReturnType<typeof vi.fn>;
isAuthenticated?: ReturnType<typeof vi.fn>;
getPullRequestTimeline?: ReturnType<typeof vi.fn>;
};
checkoutDiffManager?: { scheduleRefreshForCwd: ReturnType<typeof vi.fn> };
messages?: unknown[];
}): Session {
const logger = pino({ level: "silent" });
const github = options?.github ?? { invalidate: vi.fn() };
const checkoutDiffManager = options?.checkoutDiffManager ?? {
scheduleRefreshForCwd: vi.fn(),
};
const messages = options?.messages ?? [];
return new Session({
clientId: "test-client",
onMessage: (message) => messages.push(message),
logger,
downloadTokenStore: {} as any,
pushTokenStore: {} as any,
paseoHome: "/tmp/paseo-home",
agentManager: {
subscribe: vi.fn(() => () => {}),
} as any,
agentStorage: {} as any,
projectRegistry: {} as any,
workspaceRegistry: {} as any,
chatService: {} as any,
scheduleService: {} as any,
loopService: {} as any,
checkoutDiffManager: checkoutDiffManager as any,
github: github as any,
workspaceGitService: {} as any,
daemonConfigStore: {} as any,
stt: null,
tts: null,
terminalManager: null,
});
}
afterEach(() => {
vi.clearAllMocks();
});
describe("session PR status payload normalization", () => {
test("includes repository identity fields on the wire", () => {
const payload = normalizeCheckoutPrStatusPayload({
number: 123,
repoOwner: "internal-owner",
repoName: "internal-repo",
url: "https://github.com/getpaseo/paseo/pull/123",
title: "Ship PR pane",
state: "open",
baseRefName: "main",
headRefName: "feature/pr-pane",
isMerged: false,
isDraft: true,
checks: [
{
name: "typecheck",
status: "success",
url: "https://github.com/getpaseo/paseo/actions/runs/1",
workflow: "CI",
duration: "1m 20s",
},
],
checksStatus: "success",
reviewDecision: "approved",
});
expect(payload).toHaveProperty("repoOwner", "internal-owner");
expect(payload).toHaveProperty("repoName", "internal-repo");
expect(CheckoutPrStatusSchema.parse(payload)).toEqual(payload);
});
});
describe("session checkout merge handling", () => {
test("invalidates the cwd mutated by merge-to-base", async () => {
const messages: unknown[] = [];
const github = { invalidate: vi.fn() };
const checkoutDiffManager = { scheduleRefreshForCwd: vi.fn() };
const session = createSessionForTest({ github, checkoutDiffManager, messages });
checkoutGitMocks.getCheckoutStatus.mockResolvedValue({
isGit: true,
baseRef: "main",
});
checkoutGitMocks.mergeToBase.mockResolvedValue("/tmp/base-worktree");
await (session as any).handleCheckoutMergeRequest({
type: "checkout_merge_request",
cwd: "/tmp/request-worktree",
baseRef: "main",
requestId: "request-1",
});
expect(checkoutGitMocks.mergeToBase).toHaveBeenCalledWith(
"/tmp/request-worktree",
{
baseRef: "main",
mode: "merge",
},
{ paseoHome: "/tmp/paseo-home" },
);
expect(github.invalidate).toHaveBeenCalledTimes(1);
expect(github.invalidate).toHaveBeenCalledWith({ cwd: "/tmp/base-worktree" });
expect(checkoutDiffManager.scheduleRefreshForCwd).toHaveBeenCalledWith("/tmp/request-worktree");
expect(messages).toContainEqual({
type: "checkout_merge_response",
payload: {
cwd: "/tmp/request-worktree",
success: true,
error: null,
requestId: "request-1",
},
});
});
});
describe("session pull request timeline handling", () => {
test("passes request identity to GitHubService and emits timeline items", async () => {
const messages: unknown[] = [];
const github = {
invalidate: vi.fn(),
isAuthenticated: vi.fn().mockResolvedValue(true),
getPullRequestTimeline: vi.fn().mockResolvedValue({
prNumber: 42,
repoOwner: "getpaseo",
repoName: "paseo",
items: [
{
id: "review-1",
kind: "review",
author: "octocat",
authorUrl: "https://github.com/octocat",
body: "Looks good",
createdAt: 1710000000000,
url: "https://github.com/getpaseo/paseo/pull/42#pullrequestreview-1",
reviewState: "approved",
},
],
truncated: false,
error: null,
}),
};
const session = createSessionForTest({ github, messages });
await session.handleMessage({
type: "pull_request_timeline_request",
cwd: "/tmp/repo",
prNumber: 42,
repoOwner: "getpaseo",
repoName: "paseo",
requestId: "request-1",
});
expect(github.getPullRequestTimeline).toHaveBeenCalledWith({
cwd: "/tmp/repo",
prNumber: 42,
repoOwner: "getpaseo",
repoName: "paseo",
});
expect(messages).toContainEqual({
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",
},
],
truncated: false,
error: null,
requestId: "request-1",
githubFeaturesEnabled: true,
},
});
});
test.each([
{ prNumber: 0, repoOwner: "getpaseo", repoName: "paseo" },
{ prNumber: -1, repoOwner: "getpaseo", repoName: "paseo" },
{ prNumber: 42, repoOwner: "get paseo", repoName: "paseo" },
{ prNumber: 42, repoOwner: "getpaseo/cli", repoName: "paseo" },
{ prNumber: 42, repoOwner: "get$paseo", repoName: "paseo" },
{ prNumber: 42, repoOwner: "getpaseo", repoName: "pa seo" },
{ prNumber: 42, repoOwner: "getpaseo", repoName: "paseo/app" },
{ prNumber: 42, repoOwner: "getpaseo", repoName: "paseo!" },
])("returns an unknown error when request identity is invalid: %j", async (identity) => {
const messages: unknown[] = [];
const github = {
invalidate: vi.fn(),
isAuthenticated: vi.fn().mockResolvedValue(true),
getPullRequestTimeline: vi.fn(),
};
const session = createSessionForTest({ github, messages });
await session.handleMessage({
type: "pull_request_timeline_request",
cwd: "/tmp/repo",
...identity,
requestId: "request-invalid",
});
expect(github.isAuthenticated).not.toHaveBeenCalled();
expect(github.getPullRequestTimeline).not.toHaveBeenCalled();
expect(messages).toContainEqual({
type: "pull_request_timeline_response",
payload: {
cwd: "/tmp/repo",
prNumber: identity.prNumber,
items: [],
truncated: false,
error: {
kind: "unknown",
message: "Pull request timeline request has invalid PR identity",
},
requestId: "request-invalid",
githubFeaturesEnabled: true,
},
});
});
test("disables GitHub features when gh auth is unavailable", async () => {
const messages: unknown[] = [];
const github = {
invalidate: vi.fn(),
isAuthenticated: vi.fn().mockResolvedValue(false),
getPullRequestTimeline: vi.fn(),
};
const session = createSessionForTest({ github, messages });
await session.handleMessage({
type: "pull_request_timeline_request",
cwd: "/tmp/repo",
prNumber: 42,
repoOwner: "getpaseo",
repoName: "paseo",
requestId: "request-3",
});
expect(github.getPullRequestTimeline).not.toHaveBeenCalled();
expect(messages).toContainEqual({
type: "pull_request_timeline_response",
payload: {
cwd: "/tmp/repo",
prNumber: 42,
items: [],
truncated: false,
error: {
kind: "unknown",
message: "GitHub CLI is unavailable or not authenticated",
},
requestId: "request-3",
githubFeaturesEnabled: false,
},
});
});
});
+148 -2
View File
@@ -191,7 +191,11 @@ import { notifyChatMentions } from "./chat/chat-mentions.js";
import { LoopService } from "./loop-service.js";
import { ScheduleService } from "./schedule/service.js";
import { execCommand } from "../utils/spawn.js";
import { createGitHubService, type GitHubService } from "../services/github-service.js";
import {
createGitHubService,
type GitHubService,
type PullRequestTimelineItem,
} from "../services/github-service.js";
import {
createPaseoWorktree,
type CreatePaseoWorktreeInput,
@@ -530,6 +534,17 @@ export type SessionLifecycleIntent =
reason?: string;
};
type CheckoutPrStatusPayload = Extract<
SessionOutboundMessage,
{ type: "checkout_pr_status_response" }
>["payload"];
type CheckoutPrStatusPayloadStatus = NonNullable<CheckoutPrStatusPayload["status"]>;
type PullRequestTimelinePayload = Extract<
SessionOutboundMessage,
{ type: "pull_request_timeline_response" }
>["payload"];
type PullRequestTimelinePayloadItem = PullRequestTimelinePayload["items"][number];
type VoiceFeatureUnavailableContext = {
reasonCode: SpeechReadinessSnapshot["voiceFeature"]["reasonCode"];
message: string;
@@ -1654,6 +1669,10 @@ export class Session {
await this.handleCheckoutPrStatusRequest(msg);
break;
case "pull_request_timeline_request":
await this.handlePullRequestTimelineRequest(msg);
break;
case "github_search_request":
await this.handleGitHubSearchRequest(msg);
break;
@@ -4753,7 +4772,7 @@ export class Session {
type: "checkout_pr_status_response",
payload: {
cwd,
status: snapshot.github.pullRequest,
status: normalizeCheckoutPrStatusPayload(snapshot.github.pullRequest),
githubFeaturesEnabled: snapshot.github.featuresEnabled,
error: snapshot.github.error
? {
@@ -4778,6 +4797,88 @@ export class Session {
}
}
private async handlePullRequestTimelineRequest(
msg: Extract<SessionInboundMessage, { type: "pull_request_timeline_request" }>,
): Promise<void> {
const { cwd, prNumber, repoOwner, repoName, requestId } = msg;
if (!isValidPullRequestTimelineIdentity({ prNumber, repoOwner, repoName })) {
this.emit({
type: "pull_request_timeline_response",
payload: {
cwd,
prNumber,
items: [],
truncated: false,
error: {
kind: "unknown",
message: "Pull request timeline request has invalid PR identity",
},
requestId,
githubFeaturesEnabled: true,
},
});
return;
}
const githubFeaturesEnabled = await this.github.isAuthenticated({ cwd });
if (!githubFeaturesEnabled) {
this.emit({
type: "pull_request_timeline_response",
payload: {
cwd,
prNumber,
items: [],
truncated: false,
error: {
kind: "unknown",
message: "GitHub CLI is unavailable or not authenticated",
},
requestId,
githubFeaturesEnabled: false,
},
});
return;
}
try {
const timeline = await this.github.getPullRequestTimeline({
cwd,
prNumber,
repoOwner,
repoName,
});
this.emit({
type: "pull_request_timeline_response",
payload: {
cwd,
prNumber: timeline.prNumber,
items: timeline.items.map(toPullRequestTimelinePayloadItem),
truncated: timeline.truncated,
error: timeline.error,
requestId,
githubFeaturesEnabled: true,
},
});
} catch (error) {
this.emit({
type: "pull_request_timeline_response",
payload: {
cwd,
prNumber,
items: [],
truncated: false,
error: {
kind: "unknown",
message: error instanceof Error ? error.message : String(error),
},
requestId,
githubFeaturesEnabled: true,
},
});
}
}
private async handlePaseoWorktreeListRequest(
msg: Extract<SessionInboundMessage, { type: "paseo_worktree_list_request" }>,
): Promise<void> {
@@ -8748,3 +8849,48 @@ export class Session {
}
}
}
export function normalizeCheckoutPrStatusPayload(
status: WorkspaceGitRuntimeSnapshot["github"]["pullRequest"],
): CheckoutPrStatusPayloadStatus | null {
if (!status) {
return null;
}
return {
number: status.number,
url: status.url,
title: status.title,
state: status.state,
repoOwner: status.repoOwner,
repoName: status.repoName,
baseRefName: status.baseRefName,
headRefName: status.headRefName,
isMerged: status.isMerged,
isDraft: status.isDraft ?? false,
checks: status.checks ?? [],
checksStatus: status.checksStatus,
reviewDecision: status.reviewDecision,
};
}
function isValidPullRequestTimelineIdentity(options: {
prNumber: number;
repoOwner: string;
repoName: string;
}): boolean {
if (!Number.isInteger(options.prNumber) || options.prNumber <= 0) {
return false;
}
return isValidGitHubRepoSegment(options.repoOwner) && isValidGitHubRepoSegment(options.repoName);
}
function isValidGitHubRepoSegment(value: string): boolean {
return /^[A-Za-z0-9._-]+$/.test(value);
}
function toPullRequestTimelinePayloadItem(
item: PullRequestTimelineItem,
): PullRequestTimelinePayloadItem {
const { authorUrl: _authorUrl, ...payload } = item;
return payload;
}
@@ -38,12 +38,25 @@ export type WorkspaceGitRuntimeSnapshot = {
github: {
featuresEnabled: boolean;
pullRequest: {
number?: number;
repoOwner?: string;
repoName?: string;
url: string;
title: string;
state: string;
baseRefName: string;
headRefName: string;
isMerged: boolean;
isDraft?: boolean;
checks?: Array<{
name: string;
status: "success" | "failure" | "pending" | "skipped" | "cancelled";
url: string | null;
workflow?: string;
duration?: string;
}>;
checksStatus?: "none" | "pending" | "success" | "failure";
reviewDecision?: "approved" | "changes_requested" | "pending" | null;
} | null;
error: { message: string } | null;
refreshedAt: string | null;
File diff suppressed because it is too large Load Diff
+615 -23
View File
@@ -38,6 +38,7 @@ const GitHubPullRequestSummarySchema = z.object({
const PullRequestCheckRunNodeSchema = z.object({
__typename: z.literal("CheckRun"),
name: z.string(),
workflowName: z.string().nullable().optional(),
conclusion: z.string().nullable().optional(),
status: z.string().nullable().optional(),
detailsUrl: z.string().nullable().optional(),
@@ -79,17 +80,151 @@ const PullRequestReviewDecisionSchema = z
.nullable()
.catch(null);
const HeadRepositoryOwnerSchema = z
.object({
login: z.string().optional(),
})
.nullable()
.optional();
const CurrentPullRequestStatusSchema = z.object({
number: z.number().optional(),
url: z.string().catch(""),
title: z.string().catch(""),
state: z.string().catch(""),
isDraft: z.boolean().optional().catch(false),
baseRefName: z.string().catch(""),
headRefName: z.string().catch(""),
mergedAt: z.string().nullable().optional(),
statusCheckRollup: z.unknown().optional(),
reviewDecision: z.unknown().optional(),
headRepositoryOwner: HeadRepositoryOwnerSchema,
});
const TimelineAuthorSchema = z
.object({
login: z.string().optional(),
url: z.string().nullable().optional(),
})
.nullable()
.optional();
const PullRequestTimelineReviewNodeSchema = z.object({
id: z.string().catch(""),
state: z.string().catch(""),
body: z.string().nullable().catch(null),
url: z.string().catch(""),
submittedAt: z.string().nullable().catch(null),
author: TimelineAuthorSchema,
});
const PullRequestTimelineCommentNodeSchema = z.object({
id: z.string().catch(""),
body: z.string().nullable().catch(null),
url: z.string().catch(""),
createdAt: z.string().nullable().catch(null),
author: TimelineAuthorSchema,
});
const PullRequestTimelinePageInfoSchema = z.object({
hasNextPage: z.boolean().catch(false),
});
const PullRequestTimelineGraphqlSchema = z.object({
data: z
.object({
repository: z
.object({
pullRequest: z
.object({
number: z.number().optional(),
reviews: z
.object({
nodes: z.array(PullRequestTimelineReviewNodeSchema).catch([]),
pageInfo: PullRequestTimelinePageInfoSchema.catch({ hasNextPage: false }),
})
.catch({ nodes: [], pageInfo: { hasNextPage: false } }),
comments: z
.object({
nodes: z.array(PullRequestTimelineCommentNodeSchema).catch([]),
pageInfo: PullRequestTimelinePageInfoSchema.catch({ hasNextPage: false }),
})
.catch({ nodes: [], pageInfo: { hasNextPage: false } }),
})
.nullable()
.optional(),
})
.nullable()
.optional(),
})
.optional(),
});
const GitHubRepoViewSchema = z.object({
owner: z
.object({
login: z.string().optional(),
})
.nullable()
.optional(),
name: z.string().optional(),
parent: z
.object({
owner: z
.object({
login: z.string().optional(),
})
.nullable()
.optional(),
name: z.string().optional(),
})
.nullable()
.optional(),
});
const CURRENT_PR_STATUS_FIELDS =
"number,url,title,state,isDraft,baseRefName,headRefName,mergedAt,statusCheckRollup,reviewDecision,headRepositoryOwner";
const PULL_REQUEST_TIMELINE_QUERY = `
query PullRequestTimeline($owner: String!, $name: String!, $number: Int!) {
repository(owner: $owner, name: $name) {
pullRequest(number: $number) {
number
reviews(first: 100) {
nodes {
id
state
body
url
submittedAt
author {
login
url
}
}
pageInfo {
hasNextPage
}
}
comments(first: 100) {
nodes {
id
body
url
createdAt
author {
login
url
}
}
pageInfo {
hasNextPage
}
}
}
}
}`;
interface CacheEntry {
value: unknown;
expiresAt: number;
@@ -144,23 +279,65 @@ export interface PullRequestCheck {
name: string;
status: PullRequestCheckStatus;
url: string | null;
workflow?: string;
duration?: string;
}
export type PullRequestChecksStatus = "none" | "pending" | "success" | "failure";
export type PullRequestReviewDecision = "approved" | "changes_requested" | "pending" | null;
export interface GitHubCurrentPullRequestStatus {
number?: number;
repoOwner?: string;
repoName?: string;
url: string;
title: string;
state: string;
baseRefName: string;
headRefName: string;
isMerged: boolean;
isDraft?: boolean;
checks: PullRequestCheck[];
checksStatus: PullRequestChecksStatus;
reviewDecision: PullRequestReviewDecision;
}
export type PullRequestTimelineReviewState = "approved" | "changes_requested" | "commented";
interface PullRequestTimelineItemBase {
id: string;
author: string;
authorUrl: string | null;
body: string;
createdAt: number;
url: string;
}
export type PullRequestTimelineItem =
| (PullRequestTimelineItemBase & {
kind: "review";
reviewState: PullRequestTimelineReviewState;
})
| (PullRequestTimelineItemBase & {
kind: "comment";
});
export type GitHubPullRequestTimelineErrorKind = "not_found" | "forbidden" | "unknown";
export interface GitHubPullRequestTimelineError {
kind: GitHubPullRequestTimelineErrorKind;
message: string;
}
export interface GitHubPullRequestTimeline {
prNumber: number;
repoOwner: string;
repoName: string;
items: PullRequestTimelineItem[];
truncated: boolean;
error: GitHubPullRequestTimelineError | null;
}
export interface GitHubPullRequestCreateResult {
url: string;
number: number;
@@ -183,6 +360,13 @@ export interface GetGitHubPullRequestOptions {
number: number;
}
export interface GetGitHubPullRequestTimelineOptions {
cwd: string;
prNumber: number;
repoOwner: string;
repoName: string;
}
export interface CreateGitHubPullRequestOptions {
cwd: string;
repo: string;
@@ -201,6 +385,9 @@ export interface GitHubService {
cwd: string;
headRef: string;
}): Promise<GitHubCurrentPullRequestStatus | null>;
getPullRequestTimeline(
options: GetGitHubPullRequestTimelineOptions,
): Promise<GitHubPullRequestTimeline>;
createPullRequest(
options: CreateGitHubPullRequestOptions,
): Promise<GitHubPullRequestCreateResult>;
@@ -261,6 +448,17 @@ interface CommandFailureLike {
type PullRequestCheckRunNode = z.infer<typeof PullRequestCheckRunNodeSchema>;
type PullRequestStatusContextNode = z.infer<typeof PullRequestStatusContextNodeSchema>;
type CurrentPullRequestStatusItem = z.infer<typeof CurrentPullRequestStatusSchema>;
interface InFlightCacheEntry {
cwd: string;
promise: Promise<unknown>;
}
interface ResolvedPullRequestCandidate {
status: GitHubCurrentPullRequestStatus;
headRepositoryOwner?: string;
}
export function createGitHubService(options: CreateGitHubServiceOptions = {}): GitHubService {
const ttlMs = options.ttlMs ?? DEFAULT_GITHUB_CACHE_TTL_MS;
@@ -270,7 +468,7 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
now: options.now ?? Date.now,
};
const cache = new Map<string, CacheEntry>();
const inFlight = new Map<string, Promise<unknown>>();
const inFlight = new Map<string, InFlightCacheEntry>();
async function cached<T>(params: {
cwd: string;
@@ -291,23 +489,27 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
const existing = inFlight.get(key);
if (existing) {
return existing as Promise<T>;
return existing.promise as Promise<T>;
}
const request = params
.load()
.then((value) => {
cache.set(key, {
value,
cwd: params.cwd,
expiresAt: deps.now() + ttlMs,
});
if (inFlight.get(key)?.promise === request) {
cache.set(key, {
value,
cwd: params.cwd,
expiresAt: deps.now() + ttlMs,
});
}
return value;
})
.finally(() => {
inFlight.delete(key);
if (inFlight.get(key)?.promise === request) {
inFlight.delete(key);
}
});
inFlight.set(key, request);
inFlight.set(key, { cwd: params.cwd, promise: request });
return request;
}
@@ -407,23 +609,52 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
cwd: options.cwd,
method: "getCurrentPullRequestStatus",
args: { headRef: options.headRef },
load: async () => {
return resolveCurrentPullRequestView({
cwd: options.cwd,
headRef: options.headRef,
run,
});
},
});
},
getPullRequestTimeline(options) {
return cached({
cwd: options.cwd,
method: "getPullRequestTimeline",
args: { prNumber: options.prNumber },
load: async () => {
try {
const stdout = await run(
[
"pr",
"view",
"--json",
"url,title,state,baseRefName,headRefName,mergedAt,statusCheckRollup,reviewDecision",
"api",
"graphql",
"-f",
`query=${PULL_REQUEST_TIMELINE_QUERY}`,
"-F",
`owner=${options.repoOwner}`,
"-F",
`name=${options.repoName}`,
"-F",
`number=${options.prNumber}`,
],
{ cwd: options.cwd },
);
return parseCurrentPullRequestStatus(stdout, options.headRef);
return parsePullRequestTimeline(stdout, {
prNumber: options.prNumber,
repoOwner: options.repoOwner,
repoName: options.repoName,
});
} catch (error) {
if (isNoPullRequestFoundError(error)) {
return null;
}
throw error;
return {
prNumber: options.prNumber,
repoOwner: options.repoOwner,
repoName: options.repoName,
items: [],
truncated: false,
error: mapPullRequestTimelineError(error),
};
}
},
});
@@ -476,11 +707,18 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
},
invalidate(options) {
// Local checkout mutations that can alter the current PR identity or PR status
// must call this with the affected cwd before broadcasting fresh git state.
for (const [key, entry] of cache.entries()) {
if (entry.cwd === options.cwd) {
cache.delete(key);
}
}
for (const [key, entry] of inFlight.entries()) {
if (entry.cwd === options.cwd) {
inFlight.delete(key);
}
}
},
};
}
@@ -541,14 +779,15 @@ function normalizeGitHubCommandError(
return new GitHubCliMissingError();
}
const stderr = bufferOrStringToString(failure.stderr);
if (isAuthFailureText(stderr) || isAuthFailureText(failure.message ?? "")) {
const message = failure.message ?? "";
if (isAuthFailureText(stderr) || isAuthFailureText(message)) {
return new GitHubAuthenticationError({ stderr });
}
return new GitHubCommandError({
args: context.args,
cwd: context.cwd,
exitCode: typeof failure.code === "number" ? failure.code : null,
stderr,
stderr: stderr || message,
});
}
@@ -602,7 +841,178 @@ function isNoPullRequestFoundError(error: unknown): boolean {
return false;
}
const text = error.stderr.toLowerCase();
return text.includes("no pull requests found") || text.includes("could not resolve");
return text.includes("no pull requests found");
}
async function resolveCurrentPullRequestView(options: {
cwd: string;
headRef: string;
run: (args: string[], options: GitHubCommandRunnerOptions) => Promise<string>;
}): Promise<GitHubCurrentPullRequestStatus | null> {
const viewCandidate = await tryCurrentPullRequestView(options);
if (viewCandidate && isCandidateForHeadRef(viewCandidate, options.headRef)) {
return viewCandidate.status;
}
const repo = await getGitHubRepoView(options);
const forkOwner = repo?.owner?.login;
const parentOwner = repo?.parent?.owner?.login;
const parentName = repo?.parent?.name;
if (!forkOwner || !parentOwner || !parentName) {
return null;
}
const parentCandidates = await listCurrentPullRequestCandidates({
cwd: options.cwd,
headRef: `${forkOwner}:${options.headRef}`,
run: options.run,
repo: `${parentOwner}/${parentName}`,
});
const parentMatch = pickPullRequestCandidate({
candidates: parentCandidates,
headRef: options.headRef,
headRepositoryOwner: forkOwner,
});
return parentMatch?.status ?? null;
}
async function tryCurrentPullRequestView(options: {
cwd: string;
headRef: string;
run: (args: string[], options: GitHubCommandRunnerOptions) => Promise<string>;
}): Promise<ResolvedPullRequestCandidate | null> {
try {
const stdout = await options.run(["pr", "view", "--json", CURRENT_PR_STATUS_FIELDS], {
cwd: options.cwd,
});
return parseCurrentPullRequestCandidate(stdout, options.headRef);
} catch (error) {
if (isNoPullRequestFoundError(error)) {
return null;
}
throw error;
}
}
async function listCurrentPullRequestCandidates(options: {
cwd: string;
headRef: string;
run: (args: string[], options: GitHubCommandRunnerOptions) => Promise<string>;
repo?: string;
}): Promise<ResolvedPullRequestCandidate[]> {
const args = ["pr", "list"];
if (options.repo) {
args.push("--repo", options.repo);
}
args.push(
"--state",
"all",
"--head",
options.headRef,
"--json",
CURRENT_PR_STATUS_FIELDS,
"--limit",
"10",
);
try {
const stdout = await options.run(args, { cwd: options.cwd });
return parseCurrentPullRequestCandidateList(stdout, options.headRef);
} catch (error) {
if (isNoPullRequestFoundError(error)) {
return [];
}
throw error;
}
}
async function getGitHubRepoView(options: {
cwd: string;
run: (args: string[], options: GitHubCommandRunnerOptions) => Promise<string>;
}): Promise<z.infer<typeof GitHubRepoViewSchema> | null> {
try {
const stdout = await options.run(["repo", "view", "--json", "owner,name,parent"], {
cwd: options.cwd,
});
return GitHubRepoViewSchema.parse(JSON.parse(stdout || "{}"));
} catch {
return null;
}
}
function parseCurrentPullRequestCandidate(
stdout: string,
fallbackHeadRefName: string,
): ResolvedPullRequestCandidate | null {
const item = CurrentPullRequestStatusSchema.parse(JSON.parse(stdout || "{}"));
return toCurrentPullRequestCandidate(item, fallbackHeadRefName);
}
function parseCurrentPullRequestCandidateList(
stdout: string,
fallbackHeadRefName: string,
): ResolvedPullRequestCandidate[] {
const items = z.array(CurrentPullRequestStatusSchema).parse(JSON.parse(stdout || "[]"));
return items
.map((item) => toCurrentPullRequestCandidate(item, fallbackHeadRefName))
.filter((candidate): candidate is ResolvedPullRequestCandidate => candidate !== null);
}
function toCurrentPullRequestCandidate(
item: CurrentPullRequestStatusItem,
fallbackHeadRefName: string,
): ResolvedPullRequestCandidate | null {
const status = toCurrentPullRequestStatus(item, fallbackHeadRefName);
if (!status) {
return null;
}
const headRepositoryOwner = item.headRepositoryOwner?.login;
return {
status,
...(headRepositoryOwner ? { headRepositoryOwner } : {}),
};
}
function isCandidateForHeadRef(candidate: ResolvedPullRequestCandidate, headRef: string): boolean {
return candidate.status.headRefName === headRef && hasResolvedRepoIdentity(candidate.status);
}
function hasResolvedRepoIdentity(status: GitHubCurrentPullRequestStatus): boolean {
return Boolean(status.repoOwner && status.repoName);
}
function pickPullRequestCandidate(options: {
candidates: ResolvedPullRequestCandidate[];
headRef: string;
headRepositoryOwner?: string;
}): ResolvedPullRequestCandidate | null {
const matching = options.candidates.filter((candidate) => {
if (!isCandidateForHeadRef(candidate, options.headRef)) {
return false;
}
if (!options.headRepositoryOwner) {
return true;
}
return candidate.headRepositoryOwner === options.headRepositoryOwner;
});
matching.sort(comparePullRequestCandidatePreference);
return matching[0] ?? null;
}
function comparePullRequestCandidatePreference(
left: ResolvedPullRequestCandidate,
right: ResolvedPullRequestCandidate,
): number {
return getPullRequestStateRank(left.status) - getPullRequestStateRank(right.status);
}
function getPullRequestStateRank(status: GitHubCurrentPullRequestStatus): number {
if (status.state === "open" || status.isDraft) {
return 0;
}
if (status.state === "merged") {
return 1;
}
return 2;
}
function parsePullRequestSummaries(stdout: string): GitHubPullRequestSummary[] {
@@ -626,6 +1036,7 @@ function toPullRequestSummary(
baseRefName: item.baseRefName,
headRefName: item.headRefName,
labels: item.labels.map((label) => label.name ?? "").filter((name) => name.length > 0),
updatedAt: item.updatedAt,
};
}
@@ -638,35 +1049,185 @@ function parseIssueSummaries(stdout: string): GitHubIssueSummary[] {
state: item.state,
body: item.body,
labels: item.labels.map((label) => label.name ?? "").filter((name) => name.length > 0),
updatedAt: item.updatedAt,
}));
}
function parseCurrentPullRequestStatus(
function parsePullRequestTimeline(
stdout: string,
identity: { prNumber: number; repoOwner: string; repoName: string },
): GitHubPullRequestTimeline {
const parsed = PullRequestTimelineGraphqlSchema.parse(JSON.parse(stdout || "{}"));
const pullRequest = parsed.data?.repository?.pullRequest;
const items = pullRequest
? [
...pullRequest.reviews.nodes.flatMap(toPullRequestTimelineReviewItem),
...pullRequest.comments.nodes.map(toPullRequestTimelineCommentItem),
].sort(compareTimelineItems)
: [];
return {
prNumber: pullRequest?.number ?? identity.prNumber,
repoOwner: identity.repoOwner,
repoName: identity.repoName,
items,
// S3 deliberately caps timeline fetches at the first 100 reviews and first 100 comments.
truncated: Boolean(
pullRequest?.reviews.pageInfo.hasNextPage || pullRequest?.comments.pageInfo.hasNextPage,
),
error: pullRequest ? null : { kind: "not_found", message: "Pull request not found" },
};
}
function toPullRequestTimelineReviewItem(
review: z.infer<typeof PullRequestTimelineReviewNodeSchema>,
): PullRequestTimelineItem[] {
const reviewState = mapTimelineReviewState(review.state, review.body ?? "");
if (!reviewState) {
return [];
}
return [
{
kind: "review",
id: review.id,
author: review.author?.login ?? "unknown",
authorUrl: review.author?.url ?? null,
body: review.body ?? "",
createdAt: parseOptionalTime(review.submittedAt ?? null),
url: review.url,
reviewState,
},
];
}
function toPullRequestTimelineCommentItem(
comment: z.infer<typeof PullRequestTimelineCommentNodeSchema>,
): PullRequestTimelineItem {
return {
kind: "comment",
id: comment.id,
author: comment.author?.login ?? "unknown",
authorUrl: comment.author?.url ?? null,
body: comment.body ?? "",
createdAt: parseOptionalTime(comment.createdAt ?? null),
url: comment.url,
};
}
function mapTimelineReviewState(
state: string,
body: string,
): PullRequestTimelineReviewState | null {
switch (state) {
case "APPROVED":
return "approved";
case "CHANGES_REQUESTED":
return "changes_requested";
case "COMMENTED":
return "commented";
case "DISMISSED":
case "PENDING":
return body.trim().length > 0 ? "commented" : null;
default:
return body.trim().length > 0 ? "commented" : null;
}
}
function compareTimelineItems(
left: PullRequestTimelineItem,
right: PullRequestTimelineItem,
): number {
if (left.createdAt !== right.createdAt) {
return left.createdAt - right.createdAt;
}
return left.id.localeCompare(right.id);
}
function mapPullRequestTimelineError(error: unknown): GitHubPullRequestTimelineError {
if (error instanceof GitHubCommandError) {
return {
kind: classifyPullRequestTimelineError(error.stderr),
message: error.stderr || error.message,
};
}
if (error instanceof GitHubAuthenticationError) {
return {
kind: "forbidden",
message: error.stderr || error.message,
};
}
return {
kind: "unknown",
message: error instanceof Error ? error.message : String(error),
};
}
function classifyPullRequestTimelineError(stderr: string): GitHubPullRequestTimelineErrorKind {
const normalized = stderr.toLowerCase();
if (
normalized.includes("could not resolve to a pullrequest") ||
normalized.includes("pull request not found") ||
normalized.includes("pullrequest not found")
) {
return "not_found";
}
if (
normalized.includes("forbidden") ||
normalized.includes("resource not accessible") ||
normalized.includes("permission") ||
normalized.includes("access denied") ||
normalized.includes("requires authentication") ||
normalized.includes("http 403")
) {
return "forbidden";
}
return "unknown";
}
function toCurrentPullRequestStatus(
item: CurrentPullRequestStatusItem,
fallbackHeadRefName: string,
): GitHubCurrentPullRequestStatus | null {
const item = CurrentPullRequestStatusSchema.parse(JSON.parse(stdout || "{}"));
if (!item.url || !item.title) {
return null;
}
const repoIdentity = parseGitHubPullRequestRepo(item.url);
const mergedAt =
typeof item.mergedAt === "string" && item.mergedAt.trim().length > 0 ? item.mergedAt : null;
const state =
mergedAt !== null ? "merged" : item.state.trim().length > 0 ? item.state.toLowerCase() : "";
const checks = parseStatusCheckRollup(item.statusCheckRollup);
return {
...(typeof item.number === "number" ? { number: item.number } : {}),
...(repoIdentity ? { repoOwner: repoIdentity.owner, repoName: repoIdentity.name } : {}),
url: item.url,
title: item.title,
state,
baseRefName: item.baseRefName,
headRefName: item.headRefName || fallbackHeadRefName,
isMerged: mergedAt !== null,
isDraft: item.isDraft ?? false,
checks,
checksStatus: computeChecksStatus(checks),
reviewDecision: mapReviewDecision(item.reviewDecision),
};
}
function parseGitHubPullRequestRepo(url: string): { owner: string; name: string } | null {
try {
const parsed = new URL(url);
if (parsed.hostname !== "github.com") {
return null;
}
const [owner, name, kind] = parsed.pathname.split("/").filter(Boolean);
if (!owner || !name || kind !== "pull") {
return null;
}
return { owner, name };
} catch {
return null;
}
}
export function parseStatusCheckRollup(value: unknown): PullRequestCheck[] {
const directContexts = PullRequestStatusCheckRollupArraySchema.safeParse(value);
if (!directContexts.success) {
@@ -704,6 +1265,10 @@ function buildPullRequestCheck(
name: context.name,
status: mapCheckRunStatus(context.status, context.conclusion),
url: typeof context.detailsUrl === "string" ? context.detailsUrl : null,
...(typeof context.workflowName === "string" && context.workflowName.trim().length > 0
? { workflow: context.workflowName }
: {}),
...formatCheckRunDuration(context),
recency: getCheckRunRecency(context),
};
}
@@ -762,6 +1327,33 @@ function getCheckRunRecency(context: PullRequestCheckRunNode): number {
return parseOptionalTime(context.completedAt ?? context.startedAt ?? null);
}
function formatCheckRunDuration(context: PullRequestCheckRunNode): { duration?: string } {
const startedAt = parseOptionalTime(context.startedAt ?? null);
const completedAt = parseOptionalTime(context.completedAt ?? null);
if (startedAt <= 0 || completedAt <= 0 || completedAt < startedAt) {
return {};
}
const durationSeconds = Math.floor((completedAt - startedAt) / 1_000);
return { duration: formatDurationSeconds(durationSeconds) };
}
function formatDurationSeconds(totalSeconds: number): string {
const hours = Math.floor(totalSeconds / 3_600);
const minutes = Math.floor((totalSeconds % 3_600) / 60);
const seconds = totalSeconds % 60;
const parts: string[] = [];
if (hours > 0) {
parts.push(`${hours}h`);
}
if (minutes > 0) {
parts.push(`${minutes}m`);
}
if (seconds > 0 || parts.length === 0) {
parts.push(`${seconds}s`);
}
return parts.join(" ");
}
function getStatusContextRecency(context: PullRequestStatusContextNode): number {
return parseOptionalTime(context.createdAt ?? null);
}
@@ -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>;
+49 -96
View File
@@ -24,7 +24,6 @@ import {
pushCurrentBranch,
resolveRepositoryDefaultBranch,
parseWorktreeList,
parseStatusCheckRollup,
isPaseoWorktreePath,
isDescendantPath,
searchGitHubIssuesAndPrs,
@@ -981,101 +980,6 @@ const x = 1;
});
});
it("parses real gh status check rollup output and dedupes by latest check run", () => {
expect(
parseStatusCheckRollup([
{
__typename: "CheckRun",
completedAt: "2026-04-02T13:53:59Z",
conclusion: "SUCCESS",
detailsUrl: "https://github.com/org/repo/actions/runs/123",
name: "review_app",
startedAt: "2026-04-02T13:49:31Z",
status: "COMPLETED",
workflowName: "Deploy PR Preview",
},
{
__typename: "CheckRun",
completedAt: "2026-04-02T13:58:59Z",
conclusion: "FAILURE",
detailsUrl: "https://github.com/org/repo/actions/runs/124",
name: "review_app",
startedAt: "2026-04-02T13:55:31Z",
status: "COMPLETED",
},
]),
).toEqual([
{
name: "review_app",
status: "failure",
url: "https://github.com/org/repo/actions/runs/124",
},
]);
});
it("parses mixed check run and status context entries", () => {
expect(
parseStatusCheckRollup([
{
__typename: "CheckRun",
name: "unit-tests",
status: "IN_PROGRESS",
conclusion: null,
detailsUrl: "https://github.com/org/repo/actions/runs/200",
startedAt: "2026-04-02T13:49:31Z",
},
{
__typename: "StatusContext",
context: "lint",
state: "SUCCESS",
targetUrl: "https://github.com/org/repo/status/300",
createdAt: "2026-04-02T13:48:00Z",
},
]),
).toEqual([
{
name: "unit-tests",
status: "pending",
url: "https://github.com/org/repo/actions/runs/200",
},
{
name: "lint",
status: "success",
url: "https://github.com/org/repo/status/300",
},
]);
});
it("returns an empty list for nullish or empty status check rollups", () => {
expect(parseStatusCheckRollup(undefined)).toEqual([]);
expect(parseStatusCheckRollup(null)).toEqual([]);
expect(parseStatusCheckRollup([])).toEqual([]);
});
it("ignores unknown status check rollup node types", () => {
expect(
parseStatusCheckRollup([
{
__typename: "Commit",
oid: "abc123",
},
{
__typename: "CheckRun",
name: "build",
status: "COMPLETED",
conclusion: "SUCCESS",
detailsUrl: "https://github.com/org/repo/actions/runs/500",
},
]),
).toEqual([
{
name: "build",
status: "success",
url: "https://github.com/org/repo/actions/runs/500",
},
]);
});
it("returns merged PR status when no open PR exists for the current branch", async () => {
execSync("git checkout -b feature", { cwd: repoDir });
execSync("git remote add origin https://github.com/getpaseo/paseo.git", { cwd: repoDir });
@@ -1098,6 +1002,55 @@ const x = 1;
expect(status.status?.state).toBe("merged");
});
it("propagates S1 PR metadata and check display fields through checkout PR status", async () => {
execSync("git checkout -b feature", { cwd: repoDir });
execSync("git remote add origin https://github.com/getpaseo/paseo.git", { cwd: repoDir });
const status = await getPullRequestStatus(
repoDir,
createGitHubServiceForStatus(
createPullRequestStatus({
number: 123,
isDraft: true,
checks: [
{
name: "server-tests",
status: "success",
url: "https://github.com/getpaseo/paseo/actions/runs/123",
workflow: "Server CI",
duration: "2m 14s",
},
],
}),
),
);
expect(status).toEqual({
githubFeaturesEnabled: true,
status: {
number: 123,
url: "https://github.com/getpaseo/paseo/pull/123",
title: "Ship feature",
state: "open",
baseRefName: "main",
headRefName: "feature",
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: "none",
reviewDecision: null,
},
});
});
it("returns closed-unmerged PR status without marking it as merged", async () => {
execSync("git checkout -b feature", { cwd: repoDir });
execSync("git remote add origin https://github.com/getpaseo/paseo.git", { cwd: repoDir });
+6 -1
View File
@@ -12,7 +12,6 @@ import {
resolveGitHubRepo,
type GitHubService,
} from "../services/github-service.js";
export { parseStatusCheckRollup } from "../services/github-service.js";
import { parseGitRevParsePath, resolveGitRevParsePath } from "./git-rev-parse-path.js";
import { runGitCommand } from "./run-git-command.js";
import { isPaseoOwnedWorktreeCwd } from "./worktree.js";
@@ -1819,12 +1818,16 @@ export interface CreatePullRequestOptions {
}
export interface PullRequestStatus {
number?: number;
repoOwner?: string;
repoName?: string;
url: string;
title: string;
state: string;
baseRefName: string;
headRefName: string;
isMerged: boolean;
isDraft?: boolean;
checks?: PullRequestCheck[];
checksStatus?: ChecksStatus;
reviewDecision?: ReviewDecision;
@@ -1839,6 +1842,8 @@ export type PullRequestCheck = {
name: string;
status: "success" | "failure" | "pending" | "skipped" | "cancelled";
url: string | null;
workflow?: string;
duration?: string;
};
export type ChecksStatus = "none" | "pending" | "success" | "failure";