diff --git a/packages/app/src/components/explorer-sidebar.tsx b/packages/app/src/components/explorer-sidebar.tsx index 99281e6a3..901999a35 100644 --- a/packages/app/src/components/explorer-sidebar.tsx +++ b/packages/app/src/components/explorer-sidebar.tsx @@ -12,6 +12,9 @@ import Animated, { useAnimatedStyle, useSharedValue, runOnJS } from "react-nativ import { Gesture, GestureDetector } from "react-native-gesture-handler"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { X } from "lucide-react-native"; +import { GitHubIcon } from "@/components/icons/github-icon"; +import { PrPane } from "./pr-pane"; +import { usePrPaneData } from "@/hooks/use-pr-pane-data"; import { usePanelStore, MIN_EXPLORER_SIDEBAR_WIDTH, @@ -353,7 +356,19 @@ function SidebarContent({ }: SidebarContentProps) { const { theme } = useUnistyles(); const padding = useWindowControlsPadding("explorerSidebar"); - const resolvedTab: ExplorerTab = !isGit && activeTab === "changes" ? "files" : activeTab; + const canQueryPullRequest = isGit && Boolean(workspaceRoot); + const prPane = usePrPaneData({ + serverId, + cwd: workspaceRoot, + enabled: canQueryPullRequest, + timelineEnabled: activeTab === "pr" && canQueryPullRequest, + }); + const hasPullRequest = prPane.prNumber !== null; + const requestedTab: ExplorerTab = + !isGit && (activeTab === "changes" || activeTab === "pr") ? "files" : activeTab; + const resolvedTab: ExplorerTab = + requestedTab === "pr" && !hasPullRequest ? "changes" : requestedTab; + const prTabLabel = prPane.prNumber === null ? "" : `#${prPane.prNumber}`; return ( @@ -381,6 +396,23 @@ function SidebarContent({ Files + {isGit && hasPullRequest && ( + onTabPress("pr")} + > + + + {prTabLabel} + + + )} {isMobile && ( @@ -409,6 +441,7 @@ function SidebarContent({ onOpenFile={onOpenFile} /> )} + {resolvedTab === "pr" && prPane.data && } ); diff --git a/packages/app/src/components/git-actions-split-button.tsx b/packages/app/src/components/git-actions-split-button.tsx index 234d789d3..4bc6cd8cb 100644 --- a/packages/app/src/components/git-actions-split-button.tsx +++ b/packages/app/src/components/git-actions-split-button.tsx @@ -19,22 +19,16 @@ interface GitActionsSplitButtonProps { hideLabels?: boolean; } -export function GitActionsSplitButton({ - gitActions, - hideLabels, -}: GitActionsSplitButtonProps) { +export function GitActionsSplitButton({ gitActions, hideLabels }: GitActionsSplitButtonProps) { const { theme } = useUnistyles(); const toast = useToast(); const archiveShortcutKeys = useShortcutKeys("archive-worktree"); - const getActionDisplayLabel = useCallback( - (action: GitAction): string => { - if (action.status === "pending") return action.pendingLabel; - if (action.status === "success") return action.successLabel; - return action.label; - }, - [], - ); + const getActionDisplayLabel = useCallback((action: GitAction): string => { + if (action.status === "pending") return action.pendingLabel; + if (action.status === "success") return action.successLabel; + return action.label; + }, []); const handleActionSelect = useCallback( (action: GitAction) => { diff --git a/packages/app/src/components/pr-pane.test.tsx b/packages/app/src/components/pr-pane.test.tsx new file mode 100644 index 000000000..dc2908905 --- /dev/null +++ b/packages/app/src/components/pr-pane.test.tsx @@ -0,0 +1,169 @@ +import React from "react"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { JSDOM } from "jsdom"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { PrPaneData } from "@/utils/pr-pane-data"; +import { PrPane } from "./pr-pane"; + +const { theme } = vi.hoisted(() => ({ + theme: { + spacing: { 1: 4, 2: 8, 3: 12, 4: 16 }, + fontSize: { xs: 11, sm: 13, base: 15 }, + fontWeight: { normal: "400" }, + colors: { + surfaceSidebar: "#0b0b0b", + surfaceSidebarHover: "#151515", + border: "#222", + foreground: "#fff", + foregroundMuted: "#aaa", + statusSuccess: "#30d158", + statusDanger: "#ff453a", + statusWarning: "#f2c945", + statusMerged: "#a371f7", + }, + }, +})); + +vi.mock("react-native-unistyles", () => ({ + StyleSheet: { + create: (factory: unknown) => (typeof factory === "function" ? factory(theme) : factory), + }, + useUnistyles: () => ({ theme }), +})); + +vi.mock("lucide-react-native", () => { + const createIcon = (name: string) => (props: Record) => + React.createElement("span", { ...props, "data-icon": name }); + return { + ChevronDown: createIcon("ChevronDown"), + ChevronRight: createIcon("ChevronRight"), + CircleCheck: createIcon("CircleCheck"), + CircleDot: createIcon("CircleDot"), + CircleSlash: createIcon("CircleSlash"), + CircleX: createIcon("CircleX"), + ExternalLink: createIcon("ExternalLink"), + GitMerge: createIcon("GitMerge"), + GitPullRequest: createIcon("GitPullRequest"), + GitPullRequestClosed: createIcon("GitPullRequestClosed"), + GitPullRequestDraft: createIcon("GitPullRequestDraft"), + MessageSquare: createIcon("MessageSquare"), + }; +}); + +vi.mock("@/utils/open-external-url", () => ({ + openExternalUrl: vi.fn(), +})); + +let root: Root | null = null; +let container: HTMLElement | null = null; + +beforeEach(() => { + const dom = new JSDOM(""); + vi.stubGlobal("React", React); + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.stubGlobal("window", dom.window); + vi.stubGlobal("document", dom.window.document); + vi.stubGlobal("HTMLElement", dom.window.HTMLElement); + vi.stubGlobal("Node", dom.window.Node); + vi.stubGlobal("navigator", dom.window.navigator); + + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); +}); + +afterEach(() => { + if (root) { + act(() => { + root?.unmount(); + }); + } + root = null; + container = null; + vi.unstubAllGlobals(); +}); + +function makeData(overrides: Partial = {}): PrPaneData { + return { + number: 1284, + title: "fix(server,app): coalesce tool call stream events", + state: "open", + url: "https://github.com/getpaseo/paseo/pull/1284", + reviewDecision: "changes_requested", + awaitingReviewers: [], + checks: [ + { name: "typecheck", workflow: "CI", status: "success", duration: "1m", url: "#" }, + { name: "test", workflow: "CI", status: "failure", duration: "2m", url: "#" }, + ], + activity: [ + { + kind: "review", + author: "alicek", + avatarColor: "#8b5cf6", + reviewState: "approved", + body: "LGTM", + age: "2h ago", + url: "#", + }, + { + kind: "comment", + author: "bmartin", + avatarColor: "#f97316", + body: "Worth a benchmark before/after.", + age: "3h ago", + url: "#", + }, + ], + ...overrides, + }; +} + +function render(data: PrPaneData) { + act(() => { + root?.render(); + }); +} + +describe("PrPane", () => { + it("renders the PR title and 'Open' state label from the data prop", () => { + render(makeData()); + + expect(container?.textContent).toContain("fix(server,app): coalesce tool call stream events"); + expect(container?.textContent).toContain("Open"); + }); + + it("renders the correct state label and icon for each PR state", () => { + render(makeData({ state: "draft" })); + expect(container?.textContent).toContain("Draft"); + expect(container?.querySelector('[data-icon="GitPullRequestDraft"]')).not.toBeNull(); + + render(makeData({ state: "merged" })); + expect(container?.textContent).toContain("Merged"); + expect(container?.querySelector('[data-icon="GitMerge"]')).not.toBeNull(); + + render(makeData({ state: "closed" })); + expect(container?.textContent).toContain("Closed"); + expect(container?.querySelector('[data-icon="GitPullRequestClosed"]')).not.toBeNull(); + }); + + it("derives check summary counts from the data prop", () => { + render(makeData()); + const text = container?.textContent ?? ""; + + expect(text).toContain("typecheck"); + expect(text).toContain("test"); + // 1 passed, 1 failed, 0 pending — so pending pill is hidden. + expect(text).toMatch(/1.*1/); + }); + + it("derives review summary counts and renders activity rows", () => { + render(makeData()); + const text = container?.textContent ?? ""; + + expect(text).toContain("alicek"); + expect(text).toContain("Approved"); + expect(text).toContain("bmartin"); + expect(text).toContain("Commented"); + }); +}); diff --git a/packages/app/src/components/pr-pane.tsx b/packages/app/src/components/pr-pane.tsx new file mode 100644 index 000000000..c3e7c3718 --- /dev/null +++ b/packages/app/src/components/pr-pane.tsx @@ -0,0 +1,397 @@ +import { useState } from "react"; +import { Pressable, ScrollView, Text, View } from "react-native"; +import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import { + ChevronDown, + ChevronRight, + CircleCheck, + CircleDot, + CircleSlash, + CircleX, + ExternalLink, + GitMerge, + GitPullRequest, + GitPullRequestClosed, + GitPullRequestDraft, + MessageSquare, +} from "lucide-react-native"; +import { openExternalUrl } from "@/utils/open-external-url"; +import type { + CheckStatus, + PrPaneActivity, + PrPaneCheck, + PrPaneData, + PrState, +} from "@/utils/pr-pane-data"; + +export function PrPane({ data }: { data: PrPaneData }) { + const { theme } = useUnistyles(); + const [checksOpen, setChecksOpen] = useState(true); + const [reviewsOpen, setReviewsOpen] = useState(true); + + const passed = data.checks.filter((c) => c.status === "success").length; + const failed = data.checks.filter((c) => c.status === "failure").length; + const pending = data.checks.filter((c) => c.status === "pending").length; + + const approvals = data.activity.filter( + (a) => a.kind === "review" && a.reviewState === "approved", + ).length; + const changesRequested = data.activity.filter( + (a) => a.kind === "review" && a.reviewState === "changes_requested", + ).length; + const commentCount = data.activity.filter( + (a) => a.kind === "comment" || (a.kind === "review" && a.reviewState === "commented"), + ).length; + + const stateColor = getStateColor(data.state, theme); + const StateIcon = getStateIcon(data.state); + const stateLabel = getStateLabel(data.state); + + return ( + + void openExternalUrl(data.url)} style={styles.header}> + {({ hovered }) => ( + <> + + + {stateLabel} + + + {data.title} + {hovered ? ( + + {" "} + + + ) : null} + + + )} + + + + +
setChecksOpen((o) => !o)} + summary={ + <> + } + /> + } + /> + } + /> + + } + > + {data.checks.map((check, idx) => ( + + ))} +
+ + + +
setReviewsOpen((o) => !o)} + summary={ + <> + } + /> + } + /> + } + /> + + } + > + {data.activity.map((item, idx) => ( + + ))} +
+
+ ); +} + +interface SectionProps { + title: string; + open: boolean; + onToggle: () => void; + summary: React.ReactNode; + children: React.ReactNode; +} + +function Section({ title, open, onToggle, summary, children }: SectionProps) { + const { theme } = useUnistyles(); + return ( + + + {open ? ( + + ) : ( + + )} + {title} + {summary} + + {open && ( + + {children} + + )} + + ); +} + +function SummaryPill({ + count, + color, + icon, +}: { + count: number; + color: string; + icon: React.ReactNode; +}) { + if (count === 0) return null; + return ( + + {icon} + {count} + + ); +} + +function CheckRow({ check }: { check: PrPaneCheck }) { + return ( + void openExternalUrl(check.url)} + style={({ hovered }) => [styles.row, hovered && styles.hoverable]} + > + + + {check.name} + + {check.workflow && ( + + {check.workflow} + + )} + {check.duration && {check.duration}} + + ); +} + +function CheckStatusIcon({ status }: { status: CheckStatus }) { + const { theme } = useUnistyles(); + if (status === "success") return ; + if (status === "failure") return ; + if (status === "pending") return ; + return ; +} + +function ActivityRow({ item }: { item: PrPaneActivity }) { + const verb = getActivityVerb(item); + return ( + void openExternalUrl(item.url)} + style={({ hovered }) => [styles.activityRow, hovered && styles.hoverable]} + > + + {item.author.slice(0, 1).toUpperCase()} + + + + + {item.author} + + {verb} + {item.age} + + + {item.body} + + + + ); +} + +function getActivityVerb(item: PrPaneActivity): string { + if (item.kind === "comment") return "Commented"; + if (item.reviewState === "approved") return "Approved"; + if (item.reviewState === "changes_requested") return "Requested changes"; + return "Reviewed"; +} + +function getStateColor(state: PrState, theme: ReturnType["theme"]): string { + if (state === "open") return theme.colors.statusSuccess; + if (state === "draft") return theme.colors.foregroundMuted; + if (state === "merged") return theme.colors.statusMerged; + return theme.colors.statusDanger; +} + +function getStateIcon(state: PrState) { + if (state === "draft") return GitPullRequestDraft; + if (state === "merged") return GitMerge; + if (state === "closed") return GitPullRequestClosed; + return GitPullRequest; +} + +function getStateLabel(state: PrState): string { + if (state === "draft") return "Draft"; + if (state === "merged") return "Merged"; + if (state === "closed") return "Closed"; + return "Open"; +} + +const styles = StyleSheet.create((theme) => ({ + root: { + flex: 1, + minHeight: 0, + backgroundColor: theme.colors.surfaceSidebar, + }, + hoverable: { + backgroundColor: theme.colors.surfaceSidebarHover, + }, + header: { + flexDirection: "column", + gap: theme.spacing[1], + paddingHorizontal: theme.spacing[4], + paddingVertical: theme.spacing[4], + }, + stateLine: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[1], + }, + stateLabel: { + fontSize: theme.fontSize.xs, + fontWeight: theme.fontWeight.normal, + }, + title: { + fontSize: theme.fontSize.sm, + fontWeight: theme.fontWeight.normal, + color: theme.colors.foreground, + lineHeight: 19, + }, + divider: { + height: 1, + backgroundColor: theme.colors.border, + }, + sectionOpen: { + flexShrink: 1, + minHeight: 0, + }, + sectionBody: { + flexShrink: 1, + minHeight: 0, + }, + sectionBodyContent: { + paddingBottom: theme.spacing[3], + }, + sectionHeader: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + paddingHorizontal: theme.spacing[3], + paddingVertical: theme.spacing[2], + }, + sectionTitle: { + fontSize: theme.fontSize.sm, + fontWeight: theme.fontWeight.normal, + color: theme.colors.foregroundMuted, + }, + summaryWrap: { + marginLeft: "auto", + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + }, + summaryPill: { + flexDirection: "row", + alignItems: "center", + gap: 3, + }, + summaryPillText: { + fontSize: theme.fontSize.xs, + fontWeight: theme.fontWeight.normal, + }, + row: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + paddingHorizontal: theme.spacing[3], + paddingVertical: theme.spacing[2], + }, + activityRow: { + flexDirection: "row", + alignItems: "flex-start", + gap: theme.spacing[2], + paddingHorizontal: theme.spacing[3], + paddingVertical: theme.spacing[2], + }, + activityMain: { flex: 1, minWidth: 0, gap: 2 }, + activityHeader: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + }, + rowTitle: { + fontSize: theme.fontSize.sm, + fontWeight: theme.fontWeight.normal, + color: theme.colors.foreground, + flexShrink: 1, + }, + rowMeta: { + fontSize: theme.fontSize.xs, + color: theme.colors.foregroundMuted, + marginLeft: "auto", + }, + rowMetaMid: { + fontSize: theme.fontSize.xs, + color: theme.colors.foregroundMuted, + flexShrink: 1, + }, + rowBody: { + fontSize: theme.fontSize.xs, + color: theme.colors.foregroundMuted, + lineHeight: 16, + }, + avatar: { + width: 20, + height: 20, + borderRadius: 10, + alignItems: "center", + justifyContent: "center", + marginTop: 1, + }, + avatarText: { + fontSize: 10, + fontWeight: theme.fontWeight.normal, + color: "#fff", + }, +})); diff --git a/packages/app/src/hooks/use-branch-switcher.ts b/packages/app/src/hooks/use-branch-switcher.ts index ea63dd58e..0665fb86b 100644 --- a/packages/app/src/hooks/use-branch-switcher.ts +++ b/packages/app/src/hooks/use-branch-switcher.ts @@ -3,7 +3,7 @@ import { useQuery, type QueryClient } from "@tanstack/react-query"; import type { DaemonClient } from "@server/client/daemon-client"; import type { ComboboxOption } from "@/components/ui/combobox"; import type { ToastApi } from "@/components/toast-host"; -import { checkoutStatusQueryKey } from "@/hooks/use-checkout-status-query"; +import { invalidateCheckoutGitQueriesForClient } from "@/stores/checkout-git-actions-store"; import { confirmDialog } from "@/utils/confirm-dialog"; interface UseBranchSwitcherInput { @@ -70,8 +70,9 @@ export function useBranchSwitcher({ const invalidateStashAndCheckout = useCallback(async () => { await Promise.all([ queryClient.invalidateQueries({ queryKey: stashListQueryKey }), - queryClient.invalidateQueries({ - queryKey: checkoutStatusQueryKey(normalizedServerId, normalizedWorkspaceId), + invalidateCheckoutGitQueriesForClient(queryClient, { + serverId: normalizedServerId, + cwd: normalizedWorkspaceId, }), ]); }, [queryClient, stashListQueryKey, normalizedServerId, normalizedWorkspaceId]); diff --git a/packages/app/src/hooks/use-checkout-pr-status-query.ts b/packages/app/src/hooks/use-checkout-pr-status-query.ts index 801b1033a..77cd9bfcc 100644 --- a/packages/app/src/hooks/use-checkout-pr-status-query.ts +++ b/packages/app/src/hooks/use-checkout-pr-status-query.ts @@ -5,7 +5,7 @@ import type { CheckoutPrStatusResponse } from "@server/shared/messages"; const CHECKOUT_PR_STATUS_STALE_TIME = 20_000; const WORKSPACE_PR_HINT_REFETCH_INTERVAL = 60_000; -function checkoutPrStatusQueryKey(serverId: string, cwd: string) { +export function checkoutPrStatusQueryKey(serverId: string, cwd: string) { return ["checkoutPrStatus", serverId, cwd] as const; } diff --git a/packages/app/src/hooks/use-pr-pane-data.test.ts b/packages/app/src/hooks/use-pr-pane-data.test.ts new file mode 100644 index 000000000..b82f1222b --- /dev/null +++ b/packages/app/src/hooks/use-pr-pane-data.test.ts @@ -0,0 +1,583 @@ +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { JSDOM } from "jsdom"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { + CheckoutPrStatusResponse, + PullRequestTimelineResponse, +} from "@server/shared/messages"; +import { + prPaneTimelineQueryKey, + usePrPaneData, + type UsePrPaneDataResult, +} from "./use-pr-pane-data"; +import { useWorkspacePrHint } from "./use-checkout-pr-status-query"; + +type CheckoutPrStatus = NonNullable; +type CheckoutPrStatusPayload = CheckoutPrStatusResponse["payload"]; +type PullRequestTimelinePayload = PullRequestTimelineResponse["payload"]; + +const { mockRuntime, mockClient } = vi.hoisted(() => { + const mockClient = { + checkoutPrStatus: vi.fn(), + pullRequestTimeline: vi.fn(), + }; + + return { + mockClient, + mockRuntime: { + client: mockClient, + isConnected: true, + }, + }; +}); + +vi.mock("@/runtime/host-runtime", () => ({ + useHostRuntimeClient: () => mockRuntime.client, + useHostRuntimeIsConnected: () => mockRuntime.isConnected, +})); + +const cwd = "/repo"; +const serverId = "server-1"; + +function status(overrides: Partial = {}): CheckoutPrStatus { + return { + 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: false, + checks: [], + reviewDecision: null, + repoOwner: "getpaseo", + repoName: "paseo", + ...overrides, + }; +} + +function statusPayload(overrides: Partial = {}): CheckoutPrStatusPayload { + return { + cwd, + status: status(), + githubFeaturesEnabled: true, + error: null, + requestId: "status-1", + ...overrides, + }; +} + +function timelinePayload( + overrides: Partial = {}, +): PullRequestTimelinePayload { + return { + cwd, + prNumber: 42, + items: [], + truncated: false, + error: null, + requestId: "timeline-1", + githubFeaturesEnabled: true, + ...overrides, + }; +} + +function unsupportedTimelineError(): Error { + const error = new Error( + "Unknown request schema requestType=pull_request_timeline_request code=unknown_schema", + ) as Error & { code: string; requestType: string }; + error.name = "DaemonRpcError"; + error.code = "unknown_schema"; + error.requestType = "pull_request_timeline_request"; + return error; +} + +function createTestQueryClient(): QueryClient { + return new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }); +} + +function renderPrPaneHook({ + queryClient = createTestQueryClient(), + options = { serverId, cwd }, +}: { + queryClient?: QueryClient; + options?: Parameters[0]; +} = {}) { + let latest: UsePrPaneDataResult | null = null; + let currentOptions = options; + + function Probe({ hookOptions }: { hookOptions: Parameters[0] }) { + latest = usePrPaneData(hookOptions); + return null; + } + + const container = document.getElementById("root"); + if (!container) { + throw new Error("Missing root container"); + } + + const root = createRoot(container); + + function render() { + root.render( + React.createElement( + QueryClientProvider, + { client: queryClient }, + React.createElement(Probe, { hookOptions: currentOptions }), + ), + ); + } + + return { + get latest() { + if (!latest) { + throw new Error("Expected hook result"); + } + return latest; + }, + queryClient, + async mount() { + await act(async () => { + render(); + }); + }, + async rerender(nextOptions: Parameters[0]) { + currentOptions = nextOptions; + await act(async () => { + render(); + }); + }, + async unmount() { + await act(async () => { + root.unmount(); + }); + }, + }; +} + +function renderSharedStatusConsumers({ queryClient = createTestQueryClient() } = {}) { + let latest: UsePrPaneDataResult | null = null; + + function Probe() { + latest = usePrPaneData({ serverId, cwd }); + useWorkspacePrHint({ serverId, cwd }); + return null; + } + + const container = document.getElementById("root"); + if (!container) { + throw new Error("Missing root container"); + } + + const root = createRoot(container); + + return { + get latest() { + if (!latest) { + throw new Error("Expected hook result"); + } + return latest; + }, + async mount() { + await act(async () => { + root.render( + React.createElement( + QueryClientProvider, + { client: queryClient }, + React.createElement(Probe), + ), + ); + }); + }, + }; +} + +async function waitForExpectation(assertion: () => void): Promise { + let lastError: unknown; + + for (let attempt = 0; attempt < 60; attempt += 1) { + try { + assertion(); + return; + } catch (error) { + lastError = error; + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 10)); + }); + } + } + + throw lastError; +} + +describe("usePrPaneData", () => { + beforeEach(() => { + const dom = new JSDOM("
", { + url: "http://localhost", + }); + + Object.defineProperty(globalThis, "document", { + value: dom.window.document, + configurable: true, + }); + Object.defineProperty(globalThis, "window", { + value: dom.window, + configurable: true, + }); + Object.defineProperty(globalThis, "navigator", { + value: dom.window.navigator, + configurable: true, + }); + Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", { + value: true, + configurable: true, + }); + + mockRuntime.client = mockClient; + mockRuntime.isConnected = true; + mockClient.checkoutPrStatus.mockReset(); + mockClient.pullRequestTimeline.mockReset(); + }); + + it("returns null when status has no PR number", async () => { + const statusWithoutNumber = status(); + delete statusWithoutNumber.number; + mockClient.checkoutPrStatus.mockResolvedValue(statusPayload({ status: statusWithoutNumber })); + mockClient.pullRequestTimeline.mockResolvedValue(timelinePayload()); + + const hook = renderPrPaneHook(); + await hook.mount(); + + await waitForExpectation(() => { + expect(hook.latest.isLoading).toBe(false); + }); + + expect(hook.latest.data).toBeNull(); + expect(mockClient.pullRequestTimeline).not.toHaveBeenCalled(); + }); + + it("wires the timeline query only when a PR number is known", async () => { + mockClient.checkoutPrStatus.mockResolvedValue(statusPayload({ status: null })); + mockClient.pullRequestTimeline.mockResolvedValue(timelinePayload()); + + const hook = renderPrPaneHook(); + await hook.mount(); + + await waitForExpectation(() => { + expect(hook.latest.isLoading).toBe(false); + }); + + expect(mockClient.pullRequestTimeline).not.toHaveBeenCalled(); + expect( + hook.queryClient.getQueryState(prPaneTimelineQueryKey({ serverId, cwd, prNumber: 42 })), + ).toBeUndefined(); + + mockClient.checkoutPrStatus.mockResolvedValue(statusPayload()); + hook.queryClient.invalidateQueries({ queryKey: ["checkoutPrStatus", serverId, cwd] }); + + await waitForExpectation(() => { + expect(mockClient.pullRequestTimeline).toHaveBeenCalledWith({ + cwd, + prNumber: 42, + repoOwner: "getpaseo", + repoName: "paseo", + }); + }); + }); + + it("checks PR status while timeline activity is disabled", async () => { + mockClient.checkoutPrStatus.mockResolvedValue(statusPayload()); + mockClient.pullRequestTimeline.mockResolvedValue(timelinePayload()); + + const hook = renderPrPaneHook({ + options: { serverId, cwd, enabled: true, timelineEnabled: false }, + }); + await hook.mount(); + + await waitForExpectation(() => { + expect(hook.latest.prNumber).toBe(42); + }); + + expect(hook.latest.data).toBeNull(); + expect(mockClient.checkoutPrStatus).toHaveBeenCalledTimes(1); + expect(mockClient.pullRequestTimeline).not.toHaveBeenCalled(); + }); + + it("does not request timeline activity until the PR repo identity is known", async () => { + mockClient.checkoutPrStatus.mockResolvedValue( + statusPayload({ + status: status({ + repoOwner: undefined, + repoName: undefined, + }), + }), + ); + mockClient.pullRequestTimeline.mockResolvedValue(timelinePayload()); + + const hook = renderPrPaneHook(); + await hook.mount(); + + await waitForExpectation(() => { + expect(hook.latest.isLoading).toBe(false); + }); + + expect(hook.latest.data?.number).toBe(42); + expect(hook.latest.data?.activity).toEqual([]); + expect(mockClient.pullRequestTimeline).not.toHaveBeenCalled(); + }); + + it("shares the checkout PR status query with workspace hint consumers", async () => { + mockClient.checkoutPrStatus.mockResolvedValue(statusPayload()); + mockClient.pullRequestTimeline.mockResolvedValue(timelinePayload()); + + const hook = renderSharedStatusConsumers(); + await hook.mount(); + + await waitForExpectation(() => { + expect(hook.latest.data?.number).toBe(42); + }); + + expect(mockClient.checkoutPrStatus).toHaveBeenCalledTimes(1); + }); + + it("passes repoOwner and repoName to the timeline request when present", async () => { + mockClient.checkoutPrStatus.mockResolvedValue( + statusPayload({ + status: { + ...status(), + repoOwner: "fork-parent", + repoName: "paseo", + }, + }), + ); + mockClient.pullRequestTimeline.mockResolvedValue(timelinePayload()); + + const hook = renderPrPaneHook(); + await hook.mount(); + + await waitForExpectation(() => { + expect(mockClient.pullRequestTimeline).toHaveBeenCalledWith({ + cwd, + prNumber: 42, + repoOwner: "fork-parent", + repoName: "paseo", + }); + }); + }); + + it("lets the mapper reject stale timeline activity for a mismatched PR number", async () => { + mockClient.checkoutPrStatus.mockResolvedValue(statusPayload()); + mockClient.pullRequestTimeline.mockResolvedValue( + timelinePayload({ + prNumber: 41, + items: [ + { + id: "comment-1", + kind: "comment", + author: "octocat", + body: "This belongs to another PR", + createdAt: Date.now(), + url: "https://github.com/getpaseo/paseo/pull/41#issuecomment-1", + }, + ], + }), + ); + + const hook = renderPrPaneHook(); + await hook.mount(); + + await waitForExpectation(() => { + expect(hook.latest.data?.number).toBe(42); + }); + + expect(hook.latest.data?.activity).toEqual([]); + }); + + it("suppresses old-daemon errors and prevents future timeline requests for that tuple", async () => { + const unsupportedError = unsupportedTimelineError(); + mockClient.checkoutPrStatus.mockResolvedValue( + statusPayload({ status: status({ number: 99 }) }), + ); + mockClient.pullRequestTimeline.mockRejectedValue(unsupportedError); + + const hook = renderPrPaneHook(); + await hook.mount(); + + await waitForExpectation(() => { + expect(mockClient.pullRequestTimeline).toHaveBeenCalledTimes(1); + }); + + await waitForExpectation(() => { + expect(hook.latest.error).toBeNull(); + }); + + await hook.rerender({ serverId, cwd }); + await hook.queryClient.invalidateQueries({ + queryKey: prPaneTimelineQueryKey({ serverId, cwd, prNumber: 99 }), + }); + + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 20)); + }); + + expect(mockClient.pullRequestTimeline).toHaveBeenCalledTimes(1); + expect(hook.latest.data?.number).toBe(99); + }); + + it("surfaces checkout PR status payload errors", async () => { + mockClient.checkoutPrStatus.mockResolvedValue( + statusPayload({ + error: { + code: "UNKNOWN", + message: "bad daemon payload", + }, + }), + ); + mockClient.pullRequestTimeline.mockResolvedValue(timelinePayload()); + + const hook = renderPrPaneHook(); + await hook.mount(); + + await waitForExpectation(() => { + expect(hook.latest.error).not.toBeNull(); + expect(hook.latest.error?.message).toContain("bad daemon payload"); + }); + }); + + it("retries timeline requests when the unsupported timeline tuple changes", async () => { + const cwdA = "/repo-a"; + const cwdB = "/repo-b"; + const prNumberA = 123; + const prNumberB = 124; + const unsupportedError = unsupportedTimelineError(); + + mockClient.checkoutPrStatus.mockImplementation(async (requestedCwd: string) => + statusPayload({ + cwd: requestedCwd, + status: status({ number: requestedCwd === cwdA ? prNumberA : prNumberB }), + }), + ); + mockClient.pullRequestTimeline.mockImplementation(async (input: { cwd: string }) => { + if (input.cwd === cwdA) { + throw unsupportedError; + } + return timelinePayload({ cwd: input.cwd, prNumber: prNumberB }); + }); + + const hook = renderPrPaneHook({ options: { serverId, cwd: cwdA } }); + await hook.mount(); + + await waitForExpectation(() => { + expect(countTimelineCalls({ cwd: cwdA, prNumber: prNumberA })).toBe(1); + }); + + await hook.rerender({ serverId, cwd: cwdA }); + await hook.queryClient.invalidateQueries({ + queryKey: prPaneTimelineQueryKey({ serverId, cwd: cwdA, prNumber: prNumberA }), + }); + + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 20)); + }); + + expect(countTimelineCalls({ cwd: cwdA, prNumber: prNumberA })).toBe(1); + + await hook.rerender({ serverId, cwd: cwdB }); + + await waitForExpectation(() => { + expect(countTimelineCalls({ cwd: cwdB, prNumber: prNumberB })).toBe(1); + }); + expect(countTimelineCalls({ cwd: cwdA, prNumber: prNumberA })).toBe(1); + }); + + it("disables the timeline query when githubFeaturesEnabled is false", async () => { + mockClient.checkoutPrStatus.mockResolvedValue( + statusPayload({ githubFeaturesEnabled: false, status: status() }), + ); + mockClient.pullRequestTimeline.mockResolvedValue(timelinePayload()); + + const hook = renderPrPaneHook(); + await hook.mount(); + + await waitForExpectation(() => { + expect(hook.latest.data?.number).toBe(42); + }); + + expect(hook.latest.githubFeaturesEnabled).toBe(false); + expect(hook.latest.data?.activity).toEqual([]); + expect(mockClient.pullRequestTimeline).not.toHaveBeenCalled(); + }); + + it("reports first-load, background refresh, and non-suppressed errors", async () => { + const statusDeferred = createDeferred(); + mockClient.checkoutPrStatus.mockReturnValue(statusDeferred.promise); + mockClient.pullRequestTimeline.mockResolvedValue( + timelinePayload({ + error: { + kind: "unknown", + message: "rate limited", + }, + }), + ); + + const hook = renderPrPaneHook(); + await hook.mount(); + + expect(hook.latest.isLoading).toBe(true); + expect(hook.latest.isRefreshing).toBe(false); + + await act(async () => { + statusDeferred.resolve(statusPayload()); + }); + + await waitForExpectation(() => { + expect(hook.latest.data?.number).toBe(42); + expect(hook.latest.error?.message).toBe("rate limited"); + }); + + const refreshDeferred = createDeferred(); + mockClient.checkoutPrStatus.mockReturnValue(refreshDeferred.promise); + hook.queryClient.invalidateQueries({ queryKey: ["checkoutPrStatus", serverId, cwd] }); + + await waitForExpectation(() => { + expect(hook.latest.isLoading).toBe(false); + expect(hook.latest.isRefreshing).toBe(true); + }); + + await act(async () => { + refreshDeferred.resolve(statusPayload()); + }); + + await waitForExpectation(() => { + expect(hook.latest.isRefreshing).toBe(false); + }); + }); +}); + +function createDeferred(): { + promise: Promise; + resolve: (value: T) => void; +} { + let resolve: (value: T) => void = () => undefined; + const promise = new Promise((promiseResolve) => { + resolve = promiseResolve; + }); + + return { promise, resolve }; +} + +function countTimelineCalls({ cwd, prNumber }: { cwd: string; prNumber: number }): number { + return mockClient.pullRequestTimeline.mock.calls.filter(([input]) => { + const request = input as { cwd?: string; prNumber?: number | null }; + return request.cwd === cwd && request.prNumber === prNumber; + }).length; +} diff --git a/packages/app/src/hooks/use-pr-pane-data.ts b/packages/app/src/hooks/use-pr-pane-data.ts new file mode 100644 index 000000000..a51de0e51 --- /dev/null +++ b/packages/app/src/hooks/use-pr-pane-data.ts @@ -0,0 +1,183 @@ +import { keepPreviousData, useQuery } from "@tanstack/react-query"; +import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; +import type { + CheckoutPrStatusResponse, + PullRequestTimelineResponse, +} from "@server/shared/messages"; +import { mapPrPaneData, type PrPaneData } from "../utils/pr-pane-data"; +import { useCheckoutPrStatusQuery } from "./use-checkout-pr-status-query"; + +const PR_PANE_TIMELINE_STALE_TIME = 15_000; +const PR_PANE_TIMELINE_REFETCH_INTERVAL = 30_000; + +type CheckoutPrStatusPayloadError = CheckoutPrStatusResponse["payload"]["error"]; +type PullRequestTimeline = PullRequestTimelineResponse["payload"]; + +const unsupportedTimelineKeys = new Set(); + +export interface UsePrPaneDataOptions { + serverId: string; + cwd: string; + enabled?: boolean; + timelineEnabled?: boolean; +} + +export interface UsePrPaneDataResult { + data: PrPaneData | null; + prNumber: number | null; + isLoading: boolean; + isRefreshing: boolean; + error: Error | null; + githubFeaturesEnabled: boolean; +} + +export function usePrPaneData({ + serverId, + cwd, + enabled = true, + timelineEnabled = enabled, +}: UsePrPaneDataOptions): UsePrPaneDataResult { + const daemonClient = useHostRuntimeClient(serverId); + const isConnected = useHostRuntimeIsConnected(serverId); + const checkoutPrStatus = useCheckoutPrStatusQuery({ serverId, cwd, enabled }); + const status = checkoutPrStatus.status; + const prNumber = status?.number ?? null; + const repoOwner = status?.repoOwner && status.repoOwner.length > 0 ? status.repoOwner : null; + const repoName = status?.repoName && status.repoName.length > 0 ? status.repoName : null; + const githubFeaturesEnabled = checkoutPrStatus.githubFeaturesEnabled !== false; + const unsupportedKey = + prNumber === null ? null : timelineUnsupportedKey({ serverId, cwd, prNumber }); + const timelineUnsupported = unsupportedKey ? unsupportedTimelineKeys.has(unsupportedKey) : false; + const shouldFetchTimeline = + !!daemonClient && + isConnected && + timelineEnabled && + githubFeaturesEnabled && + !!cwd && + prNumber !== null && + repoOwner !== null && + repoName !== null && + !timelineUnsupported; + + const timelineQuery = useQuery({ + queryKey: prPaneTimelineQueryKey({ serverId, cwd, prNumber }), + queryFn: async () => { + if (!daemonClient || prNumber === null || repoOwner === null || repoName === null) { + throw new Error("Daemon client not available"); + } + + try { + return await daemonClient.pullRequestTimeline({ + cwd, + prNumber, + repoOwner, + repoName, + }); + } catch (error) { + if (unsupportedKey && isUnsupportedTimelineError(error)) { + unsupportedTimelineKeys.add(unsupportedKey); + } + throw error; + } + }, + enabled: shouldFetchTimeline, + staleTime: PR_PANE_TIMELINE_STALE_TIME, + refetchInterval: PR_PANE_TIMELINE_REFETCH_INTERVAL, + placeholderData: keepPreviousData, + retry: (failureCount, error) => !isUnsupportedTimelineError(error) && failureCount < 3, + }); + + const data = + prNumber === null || !timelineEnabled ? null : mapPrPaneData(status, timelineQuery.data); + const statusRefreshing = checkoutPrStatus.isFetching && !checkoutPrStatus.isLoading; + const timelineRefreshing = timelineQuery.isFetching && !timelineQuery.isLoading; + + return { + data, + prNumber, + isLoading: + checkoutPrStatus.isLoading || + (shouldFetchTimeline && timelineQuery.isLoading && timelineQuery.data === undefined), + isRefreshing: statusRefreshing || timelineRefreshing, + error: firstNonSuppressedError({ + statusPayloadError: checkoutPrStatus.payloadError, + statusError: checkoutPrStatus.error, + timelineError: timelineQuery.error, + timelinePayloadError: timelineQuery.data?.error ?? null, + }), + githubFeaturesEnabled, + }; +} + +export function prPaneTimelineQueryKey({ + serverId, + cwd, + prNumber, +}: { + serverId: string; + cwd: string; + prNumber: number | null; +}) { + return ["prPaneTimeline", serverId, cwd, prNumber] as const; +} + +function firstNonSuppressedError({ + statusPayloadError, + statusError, + timelineError, + timelinePayloadError, +}: { + statusPayloadError: CheckoutPrStatusPayloadError; + statusError: Error | null; + timelineError: Error | null; + timelinePayloadError: PullRequestTimeline["error"]; +}): Error | null { + if (statusPayloadError) { + return new Error(statusPayloadError.message || "Unable to load pull request status"); + } + + if (statusError) { + return statusError; + } + + if (timelineError && !isUnsupportedTimelineError(timelineError)) { + return timelineError; + } + + if (timelinePayloadError) { + return new Error(timelinePayloadError.message || "Unable to load pull request activity"); + } + + return null; +} + +function timelineUnsupportedKey({ + serverId, + cwd, + prNumber, +}: { + serverId: string; + cwd: string; + prNumber: number; +}): string { + return `${serverId}\0${cwd}\0${prNumber}`; +} + +function isUnsupportedTimelineError(error: unknown): boolean { + if (!(error instanceof Error)) { + return false; + } + + const name = error.name.toLowerCase(); + const rpcError = error as Error & { code?: unknown; requestType?: unknown }; + + if ( + name === "daemonrpcerror" && + rpcError.code === "unknown_schema" && + rpcError.requestType === "pull_request_timeline_request" + ) { + return true; + } + + return false; +} diff --git a/packages/app/src/screens/workspace/workspace-git-actions.tsx b/packages/app/src/screens/workspace/workspace-git-actions.tsx index b30fe7bf4..1b5f65b10 100644 --- a/packages/app/src/screens/workspace/workspace-git-actions.tsx +++ b/packages/app/src/screens/workspace/workspace-git-actions.tsx @@ -18,11 +18,7 @@ interface WorkspaceGitActionsProps { hideLabels?: boolean; } -export function WorkspaceGitActions({ - serverId, - cwd, - hideLabels, -}: WorkspaceGitActionsProps) { +export function WorkspaceGitActions({ serverId, cwd, hideLabels }: WorkspaceGitActionsProps) { const { theme } = useUnistyles(); const icons = useMemo( diff --git a/packages/app/src/stores/checkout-git-actions-store.test.ts b/packages/app/src/stores/checkout-git-actions-store.test.ts index 7de1558e9..24c04d634 100644 --- a/packages/app/src/stores/checkout-git-actions-store.test.ts +++ b/packages/app/src/stores/checkout-git-actions-store.test.ts @@ -1,7 +1,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { QueryClient } from "@tanstack/react-query"; import { useSessionStore } from "@/stores/session-store"; import { __resetCheckoutGitActionsStoreForTests, + invalidateCheckoutGitQueriesForClient, useCheckoutGitActionsStore, } from "@/stores/checkout-git-actions-store"; @@ -61,4 +63,30 @@ describe("checkout-git-actions-store", () => { vi.advanceTimersByTime(1000); expect(store.getStatus({ serverId, cwd, actionId: "commit" })).toBe("idle"); }); + + it("invalidates checkout PR status and every PR pane timeline for a checkout", async () => { + const queryClient = new QueryClient(); + + queryClient.setQueryData(["checkoutPrStatus", serverId, cwd], { status: { number: 12 } }); + queryClient.setQueryData(["prPaneTimeline", serverId, cwd, 12], { items: [] }); + queryClient.setQueryData(["prPaneTimeline", serverId, cwd, 13], { items: [] }); + queryClient.setQueryData(["prPaneTimeline", serverId, "/tmp/other", 12], { items: [] }); + + await invalidateCheckoutGitQueriesForClient(queryClient, { serverId, cwd }); + + expect(queryClient.getQueryState(["checkoutPrStatus", serverId, cwd])?.isInvalidated).toBe( + true, + ); + expect(queryClient.getQueryState(["prPaneTimeline", serverId, cwd, 12])?.isInvalidated).toBe( + true, + ); + expect(queryClient.getQueryState(["prPaneTimeline", serverId, cwd, 13])?.isInvalidated).toBe( + true, + ); + expect( + queryClient.getQueryState(["prPaneTimeline", serverId, "/tmp/other", 12])?.isInvalidated, + ).toBe(false); + + queryClient.clear(); + }); }); diff --git a/packages/app/src/stores/checkout-git-actions-store.ts b/packages/app/src/stores/checkout-git-actions-store.ts index c49ce2fd1..b964602e5 100644 --- a/packages/app/src/stores/checkout-git-actions-store.ts +++ b/packages/app/src/stores/checkout-git-actions-store.ts @@ -1,10 +1,11 @@ +import type { QueryClient } from "@tanstack/react-query"; import { create } from "zustand"; -import { useSessionStore } from "@/stores/session-store"; -import { queryClient } from "@/query/query-client"; +import { queryClient as appQueryClient } from "@/query/query-client"; import { buildWorkspaceTabPersistenceKey, useWorkspaceLayoutStore, } from "@/stores/workspace-layout-store"; +import { useSessionStore } from "@/stores/session-store"; import { useWorkspaceTabsStore } from "@/stores/workspace-tabs-store"; const SUCCESS_DISPLAY_MS = 1000; @@ -59,34 +60,54 @@ function setStatus( }); } +export async function invalidateCheckoutGitQueriesForClient( + queryClient: QueryClient, + { serverId, cwd }: { serverId: string; cwd: string }, +) { + await Promise.all([ + queryClient.invalidateQueries({ + queryKey: ["checkoutStatus", serverId, cwd], + }), + queryClient.invalidateQueries({ + predicate: (query) => { + const key = query.queryKey; + return ( + Array.isArray(key) && key[0] === "checkoutDiff" && key[1] === serverId && key[2] === cwd + ); + }, + }), + queryClient.invalidateQueries({ + predicate: (query) => { + const key = query.queryKey; + return ( + Array.isArray(key) && + key[0] === "checkoutPrStatus" && + key[1] === serverId && + key[2] === cwd + ); + }, + }), + queryClient.invalidateQueries({ + predicate: (query) => { + const key = query.queryKey; + return ( + Array.isArray(key) && key[0] === "prPaneTimeline" && key[1] === serverId && key[2] === cwd + ); + }, + }), + ]); +} + function invalidateCheckoutGitQueries(serverId: string, cwd: string) { - void queryClient.invalidateQueries({ - queryKey: ["checkoutStatus", serverId, cwd], - }); - void queryClient.invalidateQueries({ - predicate: (query) => { - const key = query.queryKey; - return ( - Array.isArray(key) && key[0] === "checkoutDiff" && key[1] === serverId && key[2] === cwd - ); - }, - }); - void queryClient.invalidateQueries({ - predicate: (query) => { - const key = query.queryKey; - return ( - Array.isArray(key) && key[0] === "checkoutPrStatus" && key[1] === serverId && key[2] === cwd - ); - }, - }); + return invalidateCheckoutGitQueriesForClient(appQueryClient, { serverId, cwd }); } function invalidateWorktreeList() { - void queryClient.invalidateQueries({ + void appQueryClient.invalidateQueries({ predicate: (query) => Array.isArray(query.queryKey) && query.queryKey[0] === "paseoWorktreeList", }); - void queryClient.invalidateQueries({ + void appQueryClient.invalidateQueries({ predicate: (query) => Array.isArray(query.queryKey) && query.queryKey[0] === "sidebarPaseoWorktreeList", }); @@ -107,7 +128,7 @@ function removeWorktreeFromCachedLists(input: { serverId: string; worktreePath: return filtered.length === current.length ? current : filtered; }; - queryClient.setQueriesData( + appQueryClient.setQueriesData( { predicate: (query) => Array.isArray(query.queryKey) && @@ -117,7 +138,7 @@ function removeWorktreeFromCachedLists(input: { serverId: string; worktreePath: removeFromList, ); - queryClient.setQueriesData( + appQueryClient.setQueriesData( { predicate: (query) => Array.isArray(query.queryKey) && @@ -201,7 +222,7 @@ async function runCheckoutAction({ const promise = (async () => { try { await run(); - invalidateCheckoutGitQueries(serverId, cwd); + await invalidateCheckoutGitQueries(serverId, cwd); setStatus(key, actionId, "success"); const timer = setTimeout(() => { setStatus(key, actionId, "idle"); diff --git a/packages/app/src/stores/explorer-tab-memory.ts b/packages/app/src/stores/explorer-tab-memory.ts index 7a5e92225..3e26c0e94 100644 --- a/packages/app/src/stores/explorer-tab-memory.ts +++ b/packages/app/src/stores/explorer-tab-memory.ts @@ -1,7 +1,7 @@ -export type ExplorerTab = "changes" | "files"; +export type ExplorerTab = "changes" | "files" | "pr"; export function isExplorerTab(value: unknown): value is ExplorerTab { - return value === "changes" || value === "files"; + return value === "changes" || value === "files" || value === "pr"; } export function buildExplorerCheckoutKey(serverId: string, cwd: string): string | null { diff --git a/packages/app/src/styles/theme.ts b/packages/app/src/styles/theme.ts index 48a9addd4..d597d3cd5 100644 --- a/packages/app/src/styles/theme.ts +++ b/packages/app/src/styles/theme.ts @@ -117,6 +117,23 @@ const darkDiffColors = { diffDeletion: "#ef4444", // red-500 }; +// Status colors — semantic signals for success/danger/warning/merged. Used by +// check statuses, PR states, and review decisions. Kept a step darker than the +// raw palette so they read as signals, not neon. +const lightStatusColors = { + statusSuccess: "#15803d", // green-700 + statusDanger: "#b91c1c", // red-700 + statusWarning: "#d97706", // amber-600 + statusMerged: "#7c3aed", // purple-600 +}; + +const darkStatusColors = { + statusSuccess: "#16a34a", // green-600 + statusDanger: "#dc2626", // red-600 + statusWarning: "#f59e0b", // amber-500 + statusMerged: "#9333ea", // purple-600 +}; + // Semantic color tokens - Layer-based system const lightSemanticColors = { // Surfaces (layers) - shifted one step lighter @@ -167,6 +184,7 @@ const lightSemanticColors = { ring: "#18181b", ...lightDiffColors, + ...lightStatusColors, terminal: { background: "#ffffff", @@ -278,6 +296,7 @@ function buildDarkSemanticColors(tint: DarkThemeConfig) { ring: "#d4d4d8", ...darkDiffColors, + ...darkStatusColors, terminal: { background: tint.surface0, diff --git a/packages/app/src/utils/pr-pane-data.test.ts b/packages/app/src/utils/pr-pane-data.test.ts new file mode 100644 index 000000000..e16d69462 --- /dev/null +++ b/packages/app/src/utils/pr-pane-data.test.ts @@ -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; +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 { + return { ...baseStatus, ...overrides }; +} + +function timeline(overrides: Partial = {}): 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"); + }); +}); diff --git a/packages/app/src/utils/pr-pane-data.ts b/packages/app/src/utils/pr-pane-data.ts new file mode 100644 index 000000000..7fcf424ef --- /dev/null +++ b/packages/app/src/utils/pr-pane-data.ts @@ -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): 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["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["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; +} diff --git a/packages/app/src/utils/pr-tab-label.test.ts b/packages/app/src/utils/pr-tab-label.test.ts new file mode 100644 index 000000000..8150fc198 --- /dev/null +++ b/packages/app/src/utils/pr-tab-label.test.ts @@ -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 { + 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 # when data is present", () => { + expect(formatPrTabLabel(makePrPaneData({ number: 42 }))).toBe("#42"); + }); + + it("returns #— fallback when data is null", () => { + expect(formatPrTabLabel(null)).toBe("#—"); + }); +}); diff --git a/packages/app/src/utils/pr-tab-label.ts b/packages/app/src/utils/pr-tab-label.ts new file mode 100644 index 000000000..9bc079ffd --- /dev/null +++ b/packages/app/src/utils/pr-tab-label.ts @@ -0,0 +1,5 @@ +import type { PrPaneData } from "./pr-pane-data"; + +export function formatPrTabLabel(data: PrPaneData | null): string { + return data ? `#${data.number}` : "#—"; +} diff --git a/packages/server/src/client/daemon-client.ts b/packages/server/src/client/daemon-client.ts index 54e3ba304..98f07cb9f 100644 --- a/packages/server/src/client/daemon-client.ts +++ b/packages/server/src/client/daemon-client.ts @@ -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 { + 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, diff --git a/packages/server/src/server/session.test.ts b/packages/server/src/server/session.test.ts new file mode 100644 index 000000000..53a18b69d --- /dev/null +++ b/packages/server/src/server/session.test.ts @@ -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(); + return { + ...actual, + getCheckoutStatus: checkoutGitMocks.getCheckoutStatus, + mergeToBase: checkoutGitMocks.mergeToBase, + }; +}); + +function createSessionForTest(options?: { + github?: { + invalidate: ReturnType; + isAuthenticated?: ReturnType; + getPullRequestTimeline?: ReturnType; + }; + checkoutDiffManager?: { scheduleRefreshForCwd: ReturnType }; + 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, + }, + }); + }); +}); diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index c4d51b2b4..ee3e5dc5a 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -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; +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, + ): Promise { + 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, ): Promise { @@ -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; +} diff --git a/packages/server/src/server/workspace-git-service.ts b/packages/server/src/server/workspace-git-service.ts index fae8bd9bd..469f890d4 100644 --- a/packages/server/src/server/workspace-git-service.ts +++ b/packages/server/src/server/workspace-git-service.ts @@ -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; diff --git a/packages/server/src/services/github-service.test.ts b/packages/server/src/services/github-service.test.ts index 431e01e76..2b0f92160 100644 --- a/packages/server/src/services/github-service.test.ts +++ b/packages/server/src/services/github-service.test.ts @@ -7,6 +7,7 @@ import { type GitHubCommandRunner, type GitHubCommandRunnerOptions, } from "./github-service.js"; +import { CheckoutPrStatusResponseSchema } from "../shared/messages.js"; interface RunnerCall { args: string[]; @@ -19,6 +20,14 @@ interface TestRunner { resolveNext: (stdout: string) => void; } +type RunnerStep = + | string + | { + stdout?: string; + stderr?: string; + error?: Error; + }; + function createRunner(stdoutByCall: string[]): TestRunner { const calls: RunnerCall[] = []; @@ -33,6 +42,26 @@ function createRunner(stdoutByCall: string[]): TestRunner { }; } +function createScriptedRunner(steps: RunnerStep[]): TestRunner { + const calls: RunnerCall[] = []; + + return { + calls, + runner: async (args: string[], options: GitHubCommandRunnerOptions) => { + calls.push({ args, cwd: options.cwd }); + const step = steps.shift() ?? ""; + if (typeof step === "string") { + return { stdout: step, stderr: "" }; + } + if (step.error) { + throw step.error; + } + return { stdout: step.stdout ?? "", stderr: step.stderr ?? "" }; + }, + resolveNext: () => {}, + }; +} + function createDeferredRunner(): TestRunner { const calls: RunnerCall[] = []; let resolveNext: ((stdout: string) => void) | null = null; @@ -54,6 +83,31 @@ function createDeferredRunner(): TestRunner { }; } +function currentPullRequestJson(overrides: Record = {}): string { + return JSON.stringify({ + number: 42, + url: "https://github.com/parentOwner/parentRepo/pull/42", + title: "Fork PR", + state: "OPEN", + isDraft: false, + baseRefName: "main", + headRefName: "feature/fork", + mergedAt: null, + statusCheckRollup: [], + reviewDecision: "REVIEW_REQUIRED", + ...overrides, + }); +} + +function noPullRequestError(args: string[] = ["pr", "view"]): GitHubCommandError { + return new GitHubCommandError({ + args, + cwd: "/repo", + exitCode: 1, + stderr: "no pull requests found for branch", + }); +} + function pullRequestJson(title: string): string { return JSON.stringify([ { @@ -68,7 +122,939 @@ function pullRequestJson(title: string): string { ]); } +function pullRequestTimelineJson(overrides: Record = {}): string { + return JSON.stringify({ + data: { + repository: { + pullRequest: { + number: 42, + reviews: { + nodes: [ + { + id: "PRR_approved", + state: "APPROVED", + body: "Looks good to me.", + url: "https://github.com/parentOwner/parentRepo/pull/42#pullrequestreview-1", + submittedAt: "2026-04-02T13:52:14Z", + author: { + login: "reviewer", + url: "https://github.com/reviewer", + }, + }, + { + id: "PRR_empty_commented", + state: "COMMENTED", + body: "", + url: "https://github.com/parentOwner/parentRepo/pull/42#pullrequestreview-2", + submittedAt: "2026-04-02T13:50:00Z", + author: null, + }, + ], + pageInfo: { hasNextPage: false }, + }, + comments: { + nodes: [ + { + id: "IC_later", + body: "Can we add a regression test?", + url: "https://github.com/parentOwner/parentRepo/pull/42#issuecomment-3", + createdAt: "2026-04-02T13:55:00Z", + author: { + login: "commenter", + url: "https://github.com/commenter", + }, + }, + ], + pageInfo: { hasNextPage: false }, + }, + ...overrides, + }, + }, + }, + }); +} + describe("GitHubService", () => { + it("fetches PR reviews and issue comments with one GraphQL call sorted chronologically", async () => { + const runner = createRunner([pullRequestTimelineJson()]); + const service = createGitHubService({ + runner: runner.runner, + resolveGhPath: async () => "/usr/bin/gh", + now: () => 100, + }); + + const timeline = await service.getPullRequestTimeline({ + cwd: "/repo", + prNumber: 42, + repoOwner: "parentOwner", + repoName: "parentRepo", + }); + + expect(runner.calls).toHaveLength(1); + expect(runner.calls[0]).toMatchObject({ + cwd: "/repo", + args: [ + "api", + "graphql", + "-f", + expect.stringContaining("query="), + "-F", + "owner=parentOwner", + "-F", + "name=parentRepo", + "-F", + "number=42", + ], + }); + expect(runner.calls[0]?.args[3]).toContain("reviews(first: 100)"); + expect(runner.calls[0]?.args[3]).toContain("comments(first: 100)"); + expect(timeline).toEqual({ + prNumber: 42, + repoOwner: "parentOwner", + repoName: "parentRepo", + truncated: false, + error: null, + items: [ + { + kind: "review", + id: "PRR_empty_commented", + author: "unknown", + authorUrl: null, + body: "", + createdAt: Date.parse("2026-04-02T13:50:00Z"), + url: "https://github.com/parentOwner/parentRepo/pull/42#pullrequestreview-2", + reviewState: "commented", + }, + { + kind: "review", + id: "PRR_approved", + author: "reviewer", + authorUrl: "https://github.com/reviewer", + body: "Looks good to me.", + createdAt: Date.parse("2026-04-02T13:52:14Z"), + url: "https://github.com/parentOwner/parentRepo/pull/42#pullrequestreview-1", + reviewState: "approved", + }, + { + kind: "comment", + id: "IC_later", + author: "commenter", + authorUrl: "https://github.com/commenter", + body: "Can we add a regression test?", + createdAt: Date.parse("2026-04-02T13:55:00Z"), + url: "https://github.com/parentOwner/parentRepo/pull/42#issuecomment-3", + }, + ], + }); + }); + + it("uses the passed parent repository identity for fork PR timelines", async () => { + const runner = createRunner([pullRequestTimelineJson()]); + const service = createGitHubService({ + runner: runner.runner, + resolveGhPath: async () => "/usr/bin/gh", + now: () => 100, + }); + + await service.getPullRequestTimeline({ + cwd: "/local/fork", + prNumber: 42, + repoOwner: "parentOwner", + repoName: "parentRepo", + }); + + expect(runner.calls[0]?.args).toEqual([ + "api", + "graphql", + "-f", + expect.stringContaining("repository(owner: $owner, name: $name)"), + "-F", + "owner=parentOwner", + "-F", + "name=parentRepo", + "-F", + "number=42", + ]); + }); + + it("marks PR timeline results truncated when reviews or comments hit the pagination cap", async () => { + const runner = createRunner([ + pullRequestTimelineJson({ + reviews: { + nodes: [], + pageInfo: { hasNextPage: true }, + }, + comments: { + nodes: [], + pageInfo: { hasNextPage: false }, + }, + }), + ]); + const service = createGitHubService({ + runner: runner.runner, + resolveGhPath: async () => "/usr/bin/gh", + now: () => 100, + }); + + const timeline = await service.getPullRequestTimeline({ + cwd: "/repo", + prNumber: 42, + repoOwner: "parentOwner", + repoName: "parentRepo", + }); + + expect(timeline).toMatchObject({ + items: [], + truncated: true, + error: null, + }); + }); + + it("maps PR timeline GraphQL access failures to typed internal errors", async () => { + const runner = createScriptedRunner([ + { + error: new GitHubCommandError({ + args: ["api", "graphql"], + cwd: "/repo", + exitCode: 1, + stderr: "GraphQL: Resource not accessible by integration", + }), + }, + ]); + const service = createGitHubService({ + runner: runner.runner, + resolveGhPath: async () => "/usr/bin/gh", + now: () => 100, + }); + + const timeline = await service.getPullRequestTimeline({ + cwd: "/repo", + prNumber: 42, + repoOwner: "parentOwner", + repoName: "parentRepo", + }); + + expect(timeline).toEqual({ + prNumber: 42, + repoOwner: "parentOwner", + repoName: "parentRepo", + items: [], + truncated: false, + error: { + kind: "forbidden", + message: "GraphQL: Resource not accessible by integration", + }, + }); + }); + + it("maps PR timeline missing PR failures to typed internal errors", async () => { + const runner = createScriptedRunner([ + { + error: new GitHubCommandError({ + args: ["api", "graphql"], + cwd: "/repo", + exitCode: 1, + stderr: "GraphQL: Could not resolve to a PullRequest", + }), + }, + ]); + const service = createGitHubService({ + runner: runner.runner, + resolveGhPath: async () => "/usr/bin/gh", + now: () => 100, + }); + + const timeline = await service.getPullRequestTimeline({ + cwd: "/repo", + prNumber: 404, + repoOwner: "parentOwner", + repoName: "parentRepo", + }); + + expect(timeline.error).toEqual({ + kind: "not_found", + message: "GraphQL: Could not resolve to a PullRequest", + }); + }); + + it("does not classify unrelated not found failures as missing PR timeline errors", async () => { + const runner = createScriptedRunner([ + { + error: new GitHubCommandError({ + args: ["api", "graphql"], + cwd: "/repo", + exitCode: 1, + stderr: "fatal: remote not found", + }), + }, + ]); + const service = createGitHubService({ + runner: runner.runner, + resolveGhPath: async () => "/usr/bin/gh", + now: () => 100, + }); + + const timeline = await service.getPullRequestTimeline({ + cwd: "/repo", + prNumber: 42, + repoOwner: "parentOwner", + repoName: "parentRepo", + }); + + expect(timeline.error).toEqual({ + kind: "unknown", + message: "fatal: remote not found", + }); + }); + + it("maps a missing gh binary during PR timeline fetch to an unknown timeline error", async () => { + const runner = createRunner([]); + const service = createGitHubService({ + runner: runner.runner, + resolveGhPath: async () => null, + now: () => 100, + }); + + const timeline = await service.getPullRequestTimeline({ + cwd: "/repo", + prNumber: 42, + repoOwner: "parentOwner", + repoName: "parentRepo", + }); + + expect(runner.calls).toHaveLength(0); + expect(timeline.error).toEqual({ + kind: "unknown", + message: "GitHub CLI (gh) is not installed or not in PATH", + }); + }); + + it("maps PR timeline authentication failures to forbidden timeline errors", async () => { + const runner = createScriptedRunner([ + { + error: new GitHubCommandError({ + args: ["api", "graphql"], + cwd: "/repo", + exitCode: 1, + stderr: "To authenticate, run: gh auth login", + }), + }, + ]); + const service = createGitHubService({ + runner: runner.runner, + resolveGhPath: async () => "/usr/bin/gh", + now: () => 100, + }); + + const timeline = await service.getPullRequestTimeline({ + cwd: "/repo", + prNumber: 42, + repoOwner: "parentOwner", + repoName: "parentRepo", + }); + + expect(timeline.error).toEqual({ + kind: "forbidden", + message: "To authenticate, run: gh auth login", + }); + }); + + it("maps PR timeline rate limits to unknown timeline errors", async () => { + const runner = createScriptedRunner([ + { + error: new GitHubCommandError({ + args: ["api", "graphql"], + cwd: "/repo", + exitCode: 1, + stderr: "GraphQL: API rate limit exceeded for user ID 123", + }), + }, + ]); + const service = createGitHubService({ + runner: runner.runner, + resolveGhPath: async () => "/usr/bin/gh", + now: () => 100, + }); + + const timeline = await service.getPullRequestTimeline({ + cwd: "/repo", + prNumber: 42, + repoOwner: "parentOwner", + repoName: "parentRepo", + }); + + expect(timeline.error).toEqual({ + kind: "unknown", + message: "GraphQL: API rate limit exceeded for user ID 123", + }); + }); + + it("maps PR timeline network timeouts to unknown timeline errors with runner details", async () => { + const runner = createScriptedRunner([ + { + error: Object.assign(new Error("request timed out after 10000ms"), { + code: "ETIMEDOUT", + }), + }, + ]); + const service = createGitHubService({ + runner: runner.runner, + resolveGhPath: async () => "/usr/bin/gh", + now: () => 100, + }); + + const timeline = await service.getPullRequestTimeline({ + cwd: "/repo", + prNumber: 42, + repoOwner: "parentOwner", + repoName: "parentRepo", + }); + + expect(timeline.error).toEqual({ + kind: "unknown", + message: "request timed out after 10000ms", + }); + }); + + it("maps PR timeline JSON parse failures to unknown timeline errors", async () => { + const runner = createRunner(["{"]); + const service = createGitHubService({ + runner: runner.runner, + resolveGhPath: async () => "/usr/bin/gh", + now: () => 100, + }); + + const timeline = await service.getPullRequestTimeline({ + cwd: "/repo", + prNumber: 42, + repoOwner: "parentOwner", + repoName: "parentRepo", + }); + + expect(timeline.error).toMatchObject({ + kind: "unknown", + }); + expect(timeline.error?.message).toContain("JSON"); + }); + + it("caches PR timelines by cwd and PR number until invalidated", async () => { + const runner = createRunner([ + pullRequestTimelineJson({ + comments: { + nodes: [ + { + id: "IC_first", + body: "First cached result", + url: "https://github.com/parentOwner/parentRepo/pull/42#issuecomment-1", + createdAt: "2026-04-02T13:55:00Z", + author: { login: "commenter", url: "https://github.com/commenter" }, + }, + ], + pageInfo: { hasNextPage: false }, + }, + }), + pullRequestTimelineJson({ + comments: { + nodes: [ + { + id: "IC_refreshed", + body: "Refreshed result", + url: "https://github.com/parentOwner/parentRepo/pull/42#issuecomment-2", + createdAt: "2026-04-02T13:56:00Z", + author: { login: "commenter", url: "https://github.com/commenter" }, + }, + ], + pageInfo: { hasNextPage: false }, + }, + }), + ]); + const service = createGitHubService({ + ttlMs: 1_000, + runner: runner.runner, + resolveGhPath: async () => "/usr/bin/gh", + now: () => 100, + }); + const request = { + cwd: "/repo", + prNumber: 42, + repoOwner: "parentOwner", + repoName: "parentRepo", + }; + + const first = await service.getPullRequestTimeline(request); + const second = await service.getPullRequestTimeline(request); + service.invalidate({ cwd: "/repo" }); + const refreshed = await service.getPullRequestTimeline(request); + + expect(first.items.at(-1)?.body).toBe("First cached result"); + expect(second.items.at(-1)?.body).toBe("First cached result"); + expect(refreshed.items.at(-1)?.body).toBe("Refreshed result"); + expect(runner.calls).toHaveLength(2); + }); + + it("does not cache a PR timeline result that resolves after cwd invalidation", async () => { + const runner = createDeferredRunner(); + const service = createGitHubService({ + ttlMs: 1_000, + runner: runner.runner, + resolveGhPath: async () => "/usr/bin/gh", + now: () => 100, + }); + const request = { + cwd: "/repo", + prNumber: 42, + repoOwner: "parentOwner", + repoName: "parentRepo", + }; + + const staleRequest = service.getPullRequestTimeline(request); + await Promise.resolve(); + expect(runner.calls).toHaveLength(1); + + service.invalidate({ cwd: "/repo" }); + runner.resolveNext( + pullRequestTimelineJson({ + comments: { + nodes: [ + { + id: "IC_stale", + body: "Stale pre-invalidation result", + url: "https://github.com/parentOwner/parentRepo/pull/42#issuecomment-1", + createdAt: "2026-04-02T13:55:00Z", + author: { login: "commenter", url: "https://github.com/commenter" }, + }, + ], + pageInfo: { hasNextPage: false }, + }, + }), + ); + const stale = await staleRequest; + expect(stale.items.at(-1)?.body).toBe("Stale pre-invalidation result"); + + const freshRequest = service.getPullRequestTimeline(request); + await Promise.resolve(); + expect(runner.calls).toHaveLength(2); + runner.resolveNext( + pullRequestTimelineJson({ + comments: { + nodes: [ + { + id: "IC_fresh", + body: "Fresh post-invalidation result", + url: "https://github.com/parentOwner/parentRepo/pull/42#issuecomment-2", + createdAt: "2026-04-02T13:56:00Z", + author: { login: "commenter", url: "https://github.com/commenter" }, + }, + ], + pageInfo: { hasNextPage: false }, + }, + }), + ); + + const fresh = await freshRequest; + expect(fresh.items.at(-1)?.body).toBe("Fresh post-invalidation result"); + expect(runner.calls).toHaveLength(2); + }); + + it("requests and surfaces current PR number, draft state, workflow names, and formatted check durations", async () => { + const runner = createRunner([ + JSON.stringify({ + number: 42, + url: "https://github.com/acme/repo/pull/42", + title: "Wire real PR pane data", + state: "OPEN", + isDraft: true, + baseRefName: "main", + headRefName: "feature/pr-pane", + mergedAt: null, + reviewDecision: "REVIEW_REQUIRED", + statusCheckRollup: [ + { + __typename: "CheckRun", + completedAt: "2026-04-02T13:52:14Z", + conclusion: "SUCCESS", + detailsUrl: "https://github.com/acme/repo/actions/runs/123", + name: "server-tests", + startedAt: "2026-04-02T13:50:00Z", + status: "COMPLETED", + workflowName: "Server CI", + checkSuite: { + workflowRun: { + databaseId: 123, + }, + }, + }, + { + __typename: "StatusContext", + context: "deploy/preview", + state: "SUCCESS", + targetUrl: "https://github.com/acme/repo/status/preview", + createdAt: "2026-04-02T13:51:00Z", + }, + ], + }), + ]); + const service = createGitHubService({ + runner: runner.runner, + resolveGhPath: async () => "/usr/bin/gh", + now: () => 100, + }); + + const status = await service.getCurrentPullRequestStatus({ + cwd: "/repo", + headRef: "feature/pr-pane", + }); + + expect(runner.calls[0]?.args).toEqual([ + "pr", + "view", + "--json", + "number,url,title,state,isDraft,baseRefName,headRefName,mergedAt,statusCheckRollup,reviewDecision,headRepositoryOwner", + ]); + expect(status).toEqual({ + number: 42, + repoOwner: "acme", + repoName: "repo", + url: "https://github.com/acme/repo/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/acme/repo/actions/runs/123", + workflow: "Server CI", + duration: "2m 14s", + }, + { + name: "deploy/preview", + status: "success", + url: "https://github.com/acme/repo/status/preview", + }, + ], + checksStatus: "success", + reviewDecision: "pending", + }); + }); + + it("resolves fork PR heads to the parent repository when gh pr view returns a stale branch match", async () => { + const runner = createScriptedRunner([ + currentPullRequestJson({ + number: 7, + url: "https://github.com/parentOwner/parentRepo/pull/7", + title: "Stale tracking PR", + headRefName: "old-branch", + }), + JSON.stringify({ + owner: { login: "forkOwner" }, + name: "parentRepo", + parent: { owner: { login: "parentOwner" }, name: "parentRepo" }, + }), + JSON.stringify([ + { + number: 41, + url: "https://github.com/parentOwner/parentRepo/pull/41", + title: "Wrong fork owner", + state: "OPEN", + isDraft: false, + baseRefName: "main", + headRefName: "feature/fork", + mergedAt: null, + statusCheckRollup: [], + reviewDecision: "REVIEW_REQUIRED", + headRepositoryOwner: { login: "otherFork" }, + }, + { + number: 42, + url: "https://github.com/parentOwner/parentRepo/pull/42", + title: "Real fork PR", + state: "OPEN", + isDraft: false, + baseRefName: "main", + headRefName: "feature/fork", + mergedAt: null, + statusCheckRollup: [], + reviewDecision: "REVIEW_REQUIRED", + headRepositoryOwner: { login: "forkOwner" }, + }, + ]), + ]); + const service = createGitHubService({ + runner: runner.runner, + resolveGhPath: async () => "/usr/bin/gh", + now: () => 100, + }); + + const status = await service.getCurrentPullRequestStatus({ + cwd: "/repo", + headRef: "feature/fork", + }); + + expect(status).toMatchObject({ + number: 42, + repoOwner: "parentOwner", + repoName: "parentRepo", + title: "Real fork PR", + headRefName: "feature/fork", + }); + expect(runner.calls.map((call) => call.args)).toEqual([ + [ + "pr", + "view", + "--json", + "number,url,title,state,isDraft,baseRefName,headRefName,mergedAt,statusCheckRollup,reviewDecision,headRepositoryOwner", + ], + ["repo", "view", "--json", "owner,name,parent"], + [ + "pr", + "list", + "--repo", + "parentOwner/parentRepo", + "--state", + "all", + "--head", + "forkOwner:feature/fork", + "--json", + "number,url,title,state,isDraft,baseRefName,headRefName,mergedAt,statusCheckRollup,reviewDecision,headRepositoryOwner", + "--limit", + "10", + ], + ]); + }); + + it("does not match another fork owner's PR when the current branch is main", async () => { + const calls: RunnerCall[] = []; + const runner: GitHubCommandRunner = async (args, options) => { + calls.push({ args, cwd: options.cwd }); + if (args[0] === "pr" && args[1] === "view") { + throw noPullRequestError(args); + } + if (args[0] === "pr" && args[1] === "list" && args.includes("--head")) { + return { + stdout: JSON.stringify([ + { + number: 77, + url: "https://github.com/parentOwner/parentRepo/pull/77", + title: "Unrelated fork main branch", + state: "OPEN", + isDraft: false, + baseRefName: "main", + headRefName: "main", + mergedAt: null, + statusCheckRollup: [], + reviewDecision: "REVIEW_REQUIRED", + headRepositoryOwner: { login: "otherForkOwner" }, + }, + ]), + stderr: "", + }; + } + return { + stdout: JSON.stringify({ + owner: { login: "repoOwner" }, + name: "repo", + parent: null, + }), + stderr: "", + }; + }; + const service = createGitHubService({ + runner, + resolveGhPath: async () => "/usr/bin/gh", + now: () => 100, + }); + + await expect( + service.getCurrentPullRequestStatus({ + cwd: "/repo", + headRef: "main", + }), + ).resolves.toBeNull(); + expect(calls.map((call) => call.args)).toEqual([ + [ + "pr", + "view", + "--json", + "number,url,title,state,isDraft,baseRefName,headRefName,mergedAt,statusCheckRollup,reviewDecision,headRepositoryOwner", + ], + ["repo", "view", "--json", "owner,name,parent"], + ]); + }); + + it("finds a fork PR in the parent repo when the direct current branch view is unavailable", async () => { + const runner = createScriptedRunner([ + { error: noPullRequestError() }, + JSON.stringify({ + owner: { login: "forkOwner" }, + name: "repo", + parent: { owner: { login: "parentOwner" }, name: "repo" }, + }), + JSON.stringify([ + { + number: 88, + url: "https://github.com/parentOwner/repo/pull/88", + title: "Fork-only PR", + state: "OPEN", + isDraft: false, + baseRefName: "main", + headRefName: "feature/fork", + mergedAt: null, + statusCheckRollup: [], + reviewDecision: null, + headRepositoryOwner: { login: "forkOwner" }, + }, + ]), + ]); + const service = createGitHubService({ + runner: runner.runner, + resolveGhPath: async () => "/usr/bin/gh", + now: () => 100, + }); + + const status = await service.getCurrentPullRequestStatus({ + cwd: "/repo", + headRef: "feature/fork", + }); + + expect(status).toMatchObject({ + number: 88, + repoOwner: "parentOwner", + repoName: "repo", + headRefName: "feature/fork", + }); + expect(runner.calls[2]?.args).toContain("forkOwner:feature/fork"); + }); + + it("propagates DNS errors while resolving the current PR view", async () => { + const dnsError = new GitHubCommandError({ + args: ["pr", "view"], + cwd: "/repo", + exitCode: 1, + stderr: "could not resolve host: github.com", + }); + const runner = createScriptedRunner([{ error: dnsError }]); + const service = createGitHubService({ + runner: runner.runner, + resolveGhPath: async () => "/usr/bin/gh", + now: () => 100, + }); + + await expect( + service.getCurrentPullRequestStatus({ + cwd: "/repo", + headRef: "feature/pr-pane", + }), + ).rejects.toBe(dnsError); + }); + + it("returns null when no current branch PR is matched by view or qualified fork lookup", async () => { + const runner = createScriptedRunner([ + { error: noPullRequestError() }, + JSON.stringify({ + owner: { login: "forkOwner" }, + name: "repo", + parent: { owner: { login: "parentOwner" }, name: "repo" }, + }), + "[]", + ]); + const service = createGitHubService({ + runner: runner.runner, + resolveGhPath: async () => "/usr/bin/gh", + now: () => 100, + }); + + await expect( + service.getCurrentPullRequestStatus({ + cwd: "/repo", + headRef: "feature/missing", + }), + ).resolves.toBeNull(); + }); + + it("keeps S1 PR status schema additions optional and strips internal check timestamps", () => { + const oldDaemonResponse = CheckoutPrStatusResponseSchema.parse({ + type: "checkout_pr_status_response", + payload: { + cwd: "/repo", + status: { + url: "https://github.com/acme/repo/pull/42", + title: "Old daemon payload", + state: "open", + baseRefName: "main", + headRefName: "feature", + isMerged: false, + }, + githubFeaturesEnabled: true, + error: null, + requestId: "req-old", + }, + }); + expect(oldDaemonResponse.payload.status).toMatchObject({ + isDraft: false, + checks: [], + }); + + const newDaemonResponse = CheckoutPrStatusResponseSchema.parse({ + type: "checkout_pr_status_response", + payload: { + cwd: "/repo", + status: { + number: 42, + url: "https://github.com/acme/repo/pull/42", + title: "New daemon payload", + state: "open", + baseRefName: "main", + headRefName: "feature", + isMerged: false, + isDraft: true, + checks: [ + { + name: "server-tests", + status: "success", + url: "https://github.com/acme/repo/actions/runs/123", + workflow: "Server CI", + duration: "2m 14s", + startedAt: "2026-04-02T13:50:00Z", + completedAt: "2026-04-02T13:52:14Z", + workflowRunDatabaseId: 123, + }, + ], + checksStatus: "success", + reviewDecision: "pending", + }, + githubFeaturesEnabled: true, + error: null, + requestId: "req-new", + }, + }); + + expect(newDaemonResponse.payload.status).toEqual({ + number: 42, + url: "https://github.com/acme/repo/pull/42", + title: "New daemon payload", + state: "open", + baseRefName: "main", + headRefName: "feature", + isMerged: false, + isDraft: true, + checks: [ + { + name: "server-tests", + status: "success", + url: "https://github.com/acme/repo/actions/runs/123", + workflow: "Server CI", + duration: "2m 14s", + }, + ], + checksStatus: "success", + reviewDecision: "pending", + }); + }); + it("returns cached results for identical calls within the TTL", async () => { const runner = createRunner([pullRequestJson("First result")]); const service = createGitHubService({ @@ -133,6 +1119,7 @@ describe("GitHubService", () => { baseRefName: "main", headRefName: "feature", labels: ["bug"], + updatedAt: "", }, ], [ @@ -145,6 +1132,7 @@ describe("GitHubService", () => { baseRefName: "main", headRefName: "feature", labels: ["bug"], + updatedAt: "", }, ], ]); diff --git a/packages/server/src/services/github-service.ts b/packages/server/src/services/github-service.ts index 5d6a42f63..8e44aad81 100644 --- a/packages/server/src/services/github-service.ts +++ b/packages/server/src/services/github-service.ts @@ -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; + getPullRequestTimeline( + options: GetGitHubPullRequestTimelineOptions, + ): Promise; createPullRequest( options: CreateGitHubPullRequestOptions, ): Promise; @@ -261,6 +448,17 @@ interface CommandFailureLike { type PullRequestCheckRunNode = z.infer; type PullRequestStatusContextNode = z.infer; +type CurrentPullRequestStatusItem = z.infer; + +interface InFlightCacheEntry { + cwd: string; + promise: Promise; +} + +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(); - const inFlight = new Map>(); + const inFlight = new Map(); async function cached(params: { cwd: string; @@ -291,23 +489,27 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G const existing = inFlight.get(key); if (existing) { - return existing as Promise; + return existing.promise as Promise; } 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; +}): Promise { + 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; +}): Promise { + 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; + repo?: string; +}): Promise { + 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; +}): Promise | 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, +): 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, +): 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); } diff --git a/packages/server/src/shared/messages.pull-request-timeline.test.ts b/packages/server/src/shared/messages.pull-request-timeline.test.ts new file mode 100644 index 000000000..804f4ea2f --- /dev/null +++ b/packages/server/src/shared/messages.pull-request-timeline.test.ts @@ -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", + }, + }); + }); +}); diff --git a/packages/server/src/shared/messages.ts b/packages/server/src/shared/messages.ts index faa788577..edb6bdeeb 100644 --- a/packages/server/src/shared/messages.ts +++ b/packages/server/src/shared/messages.ts @@ -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; + 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; + 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; export type CheckoutPrStatusRequest = z.infer; export type CheckoutPrStatusResponse = z.infer; +export type PullRequestTimelineRequest = z.infer; +export type PullRequestTimelineItem = z.infer; +export type PullRequestTimelineResponse = z.infer; export type CheckoutSwitchBranchRequest = z.infer; export type CheckoutSwitchBranchResponse = z.infer; export type StashSaveRequest = z.infer; diff --git a/packages/server/src/utils/checkout-git.test.ts b/packages/server/src/utils/checkout-git.test.ts index da83fec69..23a27e050 100644 --- a/packages/server/src/utils/checkout-git.test.ts +++ b/packages/server/src/utils/checkout-git.test.ts @@ -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 }); diff --git a/packages/server/src/utils/checkout-git.ts b/packages/server/src/utils/checkout-git.ts index 56e8e6476..cadc61258 100644 --- a/packages/server/src/utils/checkout-git.ts +++ b/packages/server/src/utils/checkout-git.ts @@ -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";