feat(app,server): add pull request pane to the workspace sidebar
Introduce a PR tab in the explorer sidebar that shows status checks, reviews, and the merged timeline for the current branch's pull request. Extend checkout PR status with number, draft flag, repo identity, and per-check workflow/duration; add a new pull_request_timeline request/response pair backed by a GraphQL query; enrich the workspace git snapshot to carry the fields through to the client; and extend the checkout-git-actions store so timeline queries are invalidated alongside status when mutations land.
This commit is contained in:
@@ -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 (
|
||||
<View style={styles.sidebarContent} pointerEvents="auto">
|
||||
@@ -381,6 +396,23 @@ function SidebarContent({
|
||||
Files
|
||||
</Text>
|
||||
</Pressable>
|
||||
{isGit && hasPullRequest && (
|
||||
<Pressable
|
||||
testID="explorer-tab-pr"
|
||||
style={[styles.tab, resolvedTab === "pr" && styles.tabActive]}
|
||||
onPress={() => onTabPress("pr")}
|
||||
>
|
||||
<GitHubIcon
|
||||
size={13}
|
||||
color={
|
||||
resolvedTab === "pr" ? theme.colors.foreground : theme.colors.foregroundMuted
|
||||
}
|
||||
/>
|
||||
<Text style={[styles.tabText, resolvedTab === "pr" && styles.tabTextActive]}>
|
||||
{prTabLabel}
|
||||
</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
<View style={styles.headerRightSection}>
|
||||
{isMobile && (
|
||||
@@ -409,6 +441,7 @@ function SidebarContent({
|
||||
onOpenFile={onOpenFile}
|
||||
/>
|
||||
)}
|
||||
{resolvedTab === "pr" && prPane.data && <PrPane data={prPane.data} />}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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<string, unknown>) =>
|
||||
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("<!doctype html><html><body></body></html>");
|
||||
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> = {}): 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(<PrPane data={data} />);
|
||||
});
|
||||
}
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -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 (
|
||||
<View style={styles.root}>
|
||||
<Pressable onPress={() => void openExternalUrl(data.url)} style={styles.header}>
|
||||
{({ hovered }) => (
|
||||
<>
|
||||
<View style={styles.stateLine}>
|
||||
<StateIcon size={14} color={stateColor} />
|
||||
<Text style={[styles.stateLabel, { color: stateColor }]}>{stateLabel}</Text>
|
||||
</View>
|
||||
<Text style={styles.title} numberOfLines={3}>
|
||||
{data.title}
|
||||
{hovered ? (
|
||||
<Text>
|
||||
{" "}
|
||||
<ExternalLink size={12} color={theme.colors.foregroundMuted} />
|
||||
</Text>
|
||||
) : null}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</Pressable>
|
||||
|
||||
<View style={styles.divider} />
|
||||
|
||||
<Section
|
||||
title="Checks"
|
||||
open={checksOpen}
|
||||
onToggle={() => setChecksOpen((o) => !o)}
|
||||
summary={
|
||||
<>
|
||||
<SummaryPill
|
||||
count={passed}
|
||||
color={theme.colors.statusSuccess}
|
||||
icon={<CircleCheck size={12} color={theme.colors.statusSuccess} />}
|
||||
/>
|
||||
<SummaryPill
|
||||
count={failed}
|
||||
color={theme.colors.statusDanger}
|
||||
icon={<CircleX size={12} color={theme.colors.statusDanger} />}
|
||||
/>
|
||||
<SummaryPill
|
||||
count={pending}
|
||||
color={theme.colors.statusWarning}
|
||||
icon={<CircleDot size={12} color={theme.colors.statusWarning} />}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{data.checks.map((check, idx) => (
|
||||
<CheckRow key={`${check.name}-${idx}`} check={check} />
|
||||
))}
|
||||
</Section>
|
||||
|
||||
<View style={styles.divider} />
|
||||
|
||||
<Section
|
||||
title="Reviews"
|
||||
open={reviewsOpen}
|
||||
onToggle={() => setReviewsOpen((o) => !o)}
|
||||
summary={
|
||||
<>
|
||||
<SummaryPill
|
||||
count={approvals}
|
||||
color={theme.colors.statusSuccess}
|
||||
icon={<CircleCheck size={12} color={theme.colors.statusSuccess} />}
|
||||
/>
|
||||
<SummaryPill
|
||||
count={changesRequested}
|
||||
color={theme.colors.statusDanger}
|
||||
icon={<CircleX size={12} color={theme.colors.statusDanger} />}
|
||||
/>
|
||||
<SummaryPill
|
||||
count={commentCount}
|
||||
color={theme.colors.foregroundMuted}
|
||||
icon={<MessageSquare size={11} color={theme.colors.foregroundMuted} />}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{data.activity.map((item, idx) => (
|
||||
<ActivityRow key={`${item.author}-${idx}`} item={item} />
|
||||
))}
|
||||
</Section>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<View style={open ? styles.sectionOpen : undefined}>
|
||||
<Pressable style={styles.sectionHeader} onPress={onToggle}>
|
||||
{open ? (
|
||||
<ChevronDown size={14} color={theme.colors.foregroundMuted} />
|
||||
) : (
|
||||
<ChevronRight size={14} color={theme.colors.foregroundMuted} />
|
||||
)}
|
||||
<Text style={styles.sectionTitle}>{title}</Text>
|
||||
<View style={styles.summaryWrap}>{summary}</View>
|
||||
</Pressable>
|
||||
{open && (
|
||||
<ScrollView
|
||||
style={styles.sectionBody}
|
||||
contentContainerStyle={styles.sectionBodyContent}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{children}
|
||||
</ScrollView>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function SummaryPill({
|
||||
count,
|
||||
color,
|
||||
icon,
|
||||
}: {
|
||||
count: number;
|
||||
color: string;
|
||||
icon: React.ReactNode;
|
||||
}) {
|
||||
if (count === 0) return null;
|
||||
return (
|
||||
<View style={styles.summaryPill}>
|
||||
{icon}
|
||||
<Text style={[styles.summaryPillText, { color }]}>{count}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function CheckRow({ check }: { check: PrPaneCheck }) {
|
||||
return (
|
||||
<Pressable
|
||||
onPress={() => void openExternalUrl(check.url)}
|
||||
style={({ hovered }) => [styles.row, hovered && styles.hoverable]}
|
||||
>
|
||||
<CheckStatusIcon status={check.status} />
|
||||
<Text style={styles.rowTitle} numberOfLines={1}>
|
||||
{check.name}
|
||||
</Text>
|
||||
{check.workflow && (
|
||||
<Text style={styles.rowMetaMid} numberOfLines={1}>
|
||||
{check.workflow}
|
||||
</Text>
|
||||
)}
|
||||
{check.duration && <Text style={styles.rowMeta}>{check.duration}</Text>}
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
function CheckStatusIcon({ status }: { status: CheckStatus }) {
|
||||
const { theme } = useUnistyles();
|
||||
if (status === "success") return <CircleCheck size={14} color={theme.colors.statusSuccess} />;
|
||||
if (status === "failure") return <CircleX size={14} color={theme.colors.statusDanger} />;
|
||||
if (status === "pending") return <CircleDot size={14} color={theme.colors.statusWarning} />;
|
||||
return <CircleSlash size={14} color={theme.colors.foregroundMuted} />;
|
||||
}
|
||||
|
||||
function ActivityRow({ item }: { item: PrPaneActivity }) {
|
||||
const verb = getActivityVerb(item);
|
||||
return (
|
||||
<Pressable
|
||||
onPress={() => void openExternalUrl(item.url)}
|
||||
style={({ hovered }) => [styles.activityRow, hovered && styles.hoverable]}
|
||||
>
|
||||
<View style={[styles.avatar, { backgroundColor: item.avatarColor }]}>
|
||||
<Text style={styles.avatarText}>{item.author.slice(0, 1).toUpperCase()}</Text>
|
||||
</View>
|
||||
<View style={styles.activityMain}>
|
||||
<View style={styles.activityHeader}>
|
||||
<Text style={styles.rowTitle} numberOfLines={1}>
|
||||
{item.author}
|
||||
</Text>
|
||||
<Text style={styles.rowMetaMid}>{verb}</Text>
|
||||
<Text style={styles.rowMeta}>{item.age}</Text>
|
||||
</View>
|
||||
<Text style={styles.rowBody} numberOfLines={2}>
|
||||
{item.body}
|
||||
</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
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<typeof useUnistyles>["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",
|
||||
},
|
||||
}));
|
||||
@@ -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]);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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<CheckoutPrStatusResponse["payload"]["status"]>;
|
||||
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> = {}): 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> = {}): CheckoutPrStatusPayload {
|
||||
return {
|
||||
cwd,
|
||||
status: status(),
|
||||
githubFeaturesEnabled: true,
|
||||
error: null,
|
||||
requestId: "status-1",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function timelinePayload(
|
||||
overrides: Partial<PullRequestTimelinePayload> = {},
|
||||
): 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<typeof usePrPaneData>[0];
|
||||
} = {}) {
|
||||
let latest: UsePrPaneDataResult | null = null;
|
||||
let currentOptions = options;
|
||||
|
||||
function Probe({ hookOptions }: { hookOptions: Parameters<typeof usePrPaneData>[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<typeof usePrPaneData>[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<void> {
|
||||
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("<!doctype html><html><body><div id='root'></div></body></html>", {
|
||||
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<CheckoutPrStatusPayload>();
|
||||
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<CheckoutPrStatusPayload>();
|
||||
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<T>(): {
|
||||
promise: Promise<T>;
|
||||
resolve: (value: T) => void;
|
||||
} {
|
||||
let resolve: (value: T) => void = () => undefined;
|
||||
const promise = new Promise<T>((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;
|
||||
}
|
||||
@@ -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<string>();
|
||||
|
||||
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<PullRequestTimeline, Error>({
|
||||
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;
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,337 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type {
|
||||
CheckoutPrStatusResponse,
|
||||
PullRequestTimelineResponse,
|
||||
} from "@server/shared/messages";
|
||||
import { deriveAvatarColor, formatAge, mapPrPaneData } from "./pr-pane-data";
|
||||
|
||||
type CheckoutPrStatus = NonNullable<CheckoutPrStatusResponse["payload"]["status"]>;
|
||||
type PullRequestTimeline = PullRequestTimelineResponse["payload"];
|
||||
|
||||
const baseStatus: CheckoutPrStatus = {
|
||||
number: 42,
|
||||
url: "https://github.com/getpaseo/paseo/pull/42",
|
||||
title: "Wire PR pane data",
|
||||
state: "open",
|
||||
baseRefName: "main",
|
||||
headRefName: "feature/pr-pane",
|
||||
isMerged: false,
|
||||
isDraft: false,
|
||||
checks: [],
|
||||
reviewDecision: null,
|
||||
};
|
||||
|
||||
const baseTimeline: PullRequestTimeline = {
|
||||
cwd: "/repo",
|
||||
prNumber: 42,
|
||||
items: [],
|
||||
truncated: false,
|
||||
error: null,
|
||||
requestId: "timeline-1",
|
||||
githubFeaturesEnabled: true,
|
||||
};
|
||||
|
||||
function status(overrides: Partial<CheckoutPrStatus> = {}): CheckoutPrStatus {
|
||||
return { ...baseStatus, ...overrides };
|
||||
}
|
||||
|
||||
function timeline(overrides: Partial<PullRequestTimeline> = {}): PullRequestTimeline {
|
||||
return { ...baseTimeline, ...overrides };
|
||||
}
|
||||
|
||||
describe("mapPrPaneData", () => {
|
||||
it("returns null when no PR status exists", () => {
|
||||
expect(mapPrPaneData(null, baseTimeline)).toBeNull();
|
||||
});
|
||||
|
||||
it("derives the PR number from the status URL when the status number is absent", () => {
|
||||
const data = mapPrPaneData(
|
||||
status({
|
||||
number: undefined,
|
||||
url: "https://github.com/getpaseo/paseo/pull/1284",
|
||||
}),
|
||||
timeline({ prNumber: 1284 }),
|
||||
);
|
||||
|
||||
expect(data?.number).toBe(1284);
|
||||
});
|
||||
|
||||
it("returns null when status has no number and no parseable PR URL", () => {
|
||||
expect(
|
||||
mapPrPaneData(status({ number: undefined, url: "https://github.com/getpaseo/paseo" }), null),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("derives PR state with merged taking precedence over closed, draft, and open", () => {
|
||||
expect(
|
||||
mapPrPaneData(status({ isMerged: true, isDraft: true, state: "closed" }), baseTimeline)
|
||||
?.state,
|
||||
).toBe("merged");
|
||||
expect(
|
||||
mapPrPaneData(status({ isMerged: false, isDraft: true, state: "closed" }), baseTimeline)
|
||||
?.state,
|
||||
).toBe("closed");
|
||||
expect(mapPrPaneData(status({ isDraft: true, state: "open" }), baseTimeline)?.state).toBe(
|
||||
"draft",
|
||||
);
|
||||
expect(mapPrPaneData(status({ isDraft: false, state: "open" }), baseTimeline)?.state).toBe(
|
||||
"open",
|
||||
);
|
||||
});
|
||||
|
||||
it("drops checks with null URLs to preserve the pressable check contract", () => {
|
||||
const data = mapPrPaneData(
|
||||
status({
|
||||
checks: [
|
||||
{ name: "typecheck", status: "success", url: "https://example.com/checks/1" },
|
||||
{ name: "legacy status", status: "pending", url: null },
|
||||
],
|
||||
}),
|
||||
baseTimeline,
|
||||
);
|
||||
|
||||
expect(data?.checks).toEqual([
|
||||
{
|
||||
name: "typecheck",
|
||||
status: "success",
|
||||
url: "https://example.com/checks/1",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("maps server check statuses into the frozen check status union", () => {
|
||||
const data = mapPrPaneData(
|
||||
status({
|
||||
checks: [
|
||||
{
|
||||
name: "success",
|
||||
status: "success",
|
||||
url: "https://example.com/1",
|
||||
workflow: "CI",
|
||||
duration: "1m",
|
||||
},
|
||||
{ name: "failure", status: "failure", url: "https://example.com/2" },
|
||||
{ name: "pending", status: "pending", url: "https://example.com/3" },
|
||||
{ name: "skipped", status: "skipped", url: "https://example.com/4" },
|
||||
{ name: "cancelled", status: "cancelled", url: "https://example.com/5" },
|
||||
],
|
||||
}),
|
||||
baseTimeline,
|
||||
);
|
||||
|
||||
expect(data?.checks).toEqual([
|
||||
{
|
||||
name: "success",
|
||||
workflow: "CI",
|
||||
status: "success",
|
||||
duration: "1m",
|
||||
url: "https://example.com/1",
|
||||
},
|
||||
{ name: "failure", status: "failure", url: "https://example.com/2" },
|
||||
{ name: "pending", status: "pending", url: "https://example.com/3" },
|
||||
{ name: "skipped", status: "skipped", url: "https://example.com/4" },
|
||||
{ name: "cancelled", status: "skipped", url: "https://example.com/5" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("preserves timeline item order while mapping mixed reviews and comments", () => {
|
||||
const data = mapPrPaneData(
|
||||
baseStatus,
|
||||
timeline({
|
||||
items: [
|
||||
{
|
||||
id: "review-1",
|
||||
kind: "review",
|
||||
author: "alice",
|
||||
reviewState: "approved",
|
||||
body: "Looks good.",
|
||||
createdAt: Date.UTC(2026, 0, 1, 10, 0, 0),
|
||||
url: "https://example.com/review-1",
|
||||
},
|
||||
{
|
||||
id: "comment-1",
|
||||
kind: "comment",
|
||||
author: "bob",
|
||||
body: "One thought.",
|
||||
createdAt: Date.UTC(2026, 0, 1, 11, 0, 0),
|
||||
url: "https://example.com/comment-1",
|
||||
},
|
||||
],
|
||||
}),
|
||||
Date.UTC(2026, 0, 1, 12, 0, 0),
|
||||
);
|
||||
|
||||
expect(data?.activity.map((item) => item.kind)).toEqual(["review", "comment"]);
|
||||
expect(data?.activity.map((item) => item.author)).toEqual(["alice", "bob"]);
|
||||
});
|
||||
|
||||
it("filters empty commented reviews but keeps blocking review states", () => {
|
||||
const data = mapPrPaneData(
|
||||
baseStatus,
|
||||
timeline({
|
||||
items: [
|
||||
{
|
||||
id: "commented-empty",
|
||||
kind: "review",
|
||||
author: "alice",
|
||||
reviewState: "commented",
|
||||
body: " ",
|
||||
createdAt: 1000,
|
||||
url: "https://example.com/commented-empty",
|
||||
},
|
||||
{
|
||||
id: "approved-empty",
|
||||
kind: "review",
|
||||
author: "bob",
|
||||
reviewState: "approved",
|
||||
body: "",
|
||||
createdAt: 2000,
|
||||
url: "https://example.com/approved-empty",
|
||||
},
|
||||
{
|
||||
id: "changes-empty",
|
||||
kind: "review",
|
||||
author: "cam",
|
||||
reviewState: "changes_requested",
|
||||
body: "",
|
||||
createdAt: 3000,
|
||||
url: "https://example.com/changes-empty",
|
||||
},
|
||||
],
|
||||
}),
|
||||
4000,
|
||||
);
|
||||
|
||||
expect(data?.activity).toMatchObject([
|
||||
{ kind: "review", author: "bob", reviewState: "approved", body: "" },
|
||||
{ kind: "review", author: "cam", reviewState: "changes_requested", body: "" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("filters empty issue comments while preserving review activity with empty bodies", () => {
|
||||
const data = mapPrPaneData(
|
||||
baseStatus,
|
||||
timeline({
|
||||
items: [
|
||||
{
|
||||
id: "comment-empty",
|
||||
kind: "comment",
|
||||
author: "alice",
|
||||
body: " ",
|
||||
createdAt: 1000,
|
||||
url: "https://example.com/comment-empty",
|
||||
},
|
||||
{
|
||||
id: "approved-empty",
|
||||
kind: "review",
|
||||
author: "bob",
|
||||
reviewState: "approved",
|
||||
body: "",
|
||||
createdAt: 2000,
|
||||
url: "https://example.com/approved-empty",
|
||||
},
|
||||
{
|
||||
id: "approved-body",
|
||||
kind: "review",
|
||||
author: "cam",
|
||||
reviewState: "approved",
|
||||
body: "Looks good.",
|
||||
createdAt: 3000,
|
||||
url: "https://example.com/approved-body",
|
||||
},
|
||||
],
|
||||
}),
|
||||
4000,
|
||||
);
|
||||
|
||||
expect(data?.activity).toMatchObject([
|
||||
{ kind: "review", author: "bob", reviewState: "approved", body: "" },
|
||||
{ kind: "review", author: "cam", reviewState: "approved", body: "Looks good." },
|
||||
]);
|
||||
});
|
||||
|
||||
it("maps review decisions into the frozen pending fallback contract", () => {
|
||||
expect(
|
||||
mapPrPaneData(status({ reviewDecision: "approved" }), baseTimeline)?.reviewDecision,
|
||||
).toBe("approved");
|
||||
expect(
|
||||
mapPrPaneData(status({ reviewDecision: "changes_requested" }), baseTimeline)?.reviewDecision,
|
||||
).toBe("changes_requested");
|
||||
expect(
|
||||
mapPrPaneData(status({ reviewDecision: "review_required" }), baseTimeline)?.reviewDecision,
|
||||
).toBe("pending");
|
||||
expect(mapPrPaneData(status({ reviewDecision: null }), baseTimeline)?.reviewDecision).toBe(
|
||||
"pending",
|
||||
);
|
||||
expect(
|
||||
mapPrPaneData(
|
||||
status({ reviewDecision: undefined as CheckoutPrStatus["reviewDecision"] }),
|
||||
baseTimeline,
|
||||
)?.reviewDecision,
|
||||
).toBe("pending");
|
||||
expect(
|
||||
mapPrPaneData(
|
||||
status({ reviewDecision: "surprising" as CheckoutPrStatus["reviewDecision"] }),
|
||||
baseTimeline,
|
||||
)?.reviewDecision,
|
||||
).toBe("pending");
|
||||
});
|
||||
|
||||
it("leaves awaiting reviewers intentionally unwired", () => {
|
||||
expect(mapPrPaneData(baseStatus, baseTimeline)?.awaitingReviewers).toEqual([]);
|
||||
});
|
||||
|
||||
it("rejects stale timeline activity when the timeline PR number differs from status", () => {
|
||||
const data = mapPrPaneData(
|
||||
baseStatus,
|
||||
timeline({
|
||||
prNumber: 99,
|
||||
items: [
|
||||
{
|
||||
id: "comment-1",
|
||||
kind: "comment",
|
||||
author: "alice",
|
||||
body: "This belongs to another PR.",
|
||||
createdAt: 1000,
|
||||
url: "https://example.com/comment-1",
|
||||
},
|
||||
],
|
||||
}),
|
||||
2000,
|
||||
);
|
||||
|
||||
expect(data?.activity).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("deriveAvatarColor", () => {
|
||||
it("returns a deterministic color from the PR pane avatar palette", () => {
|
||||
const palette = [
|
||||
"#8b5cf6",
|
||||
"#f97316",
|
||||
"#0ea5e9",
|
||||
"#10b981",
|
||||
"#ef4444",
|
||||
"#eab308",
|
||||
"#ec4899",
|
||||
"#6366f1",
|
||||
];
|
||||
|
||||
expect(deriveAvatarColor("alice")).toBe(deriveAvatarColor("alice"));
|
||||
expect(palette).toContain(deriveAvatarColor("alice"));
|
||||
expect(palette).toContain(deriveAvatarColor("Alice"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatAge", () => {
|
||||
it("emits PR pane age labels", () => {
|
||||
const now = Date.UTC(2026, 0, 1, 12, 0, 0);
|
||||
|
||||
expect(formatAge(now - 20_000, now)).toBe("just now");
|
||||
expect(formatAge(now - 5 * 60_000, now)).toBe("5m ago");
|
||||
expect(formatAge(now - 2 * 60 * 60_000, now)).toBe("2h ago");
|
||||
expect(formatAge(now - 3 * 24 * 60 * 60_000, now)).toBe("3d ago");
|
||||
expect(formatAge(now - 90 * 24 * 60 * 60_000, now)).toBe("3mo ago");
|
||||
expect(formatAge(now - 365 * 24 * 60 * 60_000, now)).toBe("1y ago");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,227 @@
|
||||
import type {
|
||||
CheckoutPrStatusResponse,
|
||||
PullRequestTimelineResponse,
|
||||
} from "@server/shared/messages";
|
||||
|
||||
export type PrState = "open" | "draft" | "merged" | "closed";
|
||||
export type CheckStatus = "success" | "failure" | "pending" | "skipped";
|
||||
export type ReviewState = "approved" | "changes_requested" | "commented";
|
||||
export type ActivityKind = "review" | "comment";
|
||||
|
||||
export interface PrPaneCheck {
|
||||
name: string;
|
||||
workflow?: string;
|
||||
status: CheckStatus;
|
||||
duration?: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface PrPaneActivity {
|
||||
kind: ActivityKind;
|
||||
author: string;
|
||||
avatarColor: string;
|
||||
reviewState?: ReviewState;
|
||||
body: string;
|
||||
age: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface PrPaneData {
|
||||
number: number;
|
||||
title: string;
|
||||
state: PrState;
|
||||
url: string;
|
||||
reviewDecision: "approved" | "changes_requested" | "pending";
|
||||
awaitingReviewers: string[];
|
||||
checks: PrPaneCheck[];
|
||||
activity: PrPaneActivity[];
|
||||
}
|
||||
|
||||
type CheckoutPrStatus = CheckoutPrStatusResponse["payload"]["status"];
|
||||
type PullRequestTimeline = PullRequestTimelineResponse["payload"];
|
||||
type PullRequestTimelineItem = PullRequestTimeline["items"][number];
|
||||
|
||||
const AVATAR_COLORS = [
|
||||
"#8b5cf6",
|
||||
"#f97316",
|
||||
"#0ea5e9",
|
||||
"#10b981",
|
||||
"#ef4444",
|
||||
"#eab308",
|
||||
"#ec4899",
|
||||
"#6366f1",
|
||||
];
|
||||
|
||||
export function mapPrPaneData(
|
||||
status: CheckoutPrStatus,
|
||||
timeline: PullRequestTimeline | null | undefined,
|
||||
nowMs = Date.now(),
|
||||
): PrPaneData | null {
|
||||
if (!status) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const number = status.number ?? parsePullRequestNumber(status.url);
|
||||
if (number === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const timelineMatchesStatus = timeline?.prNumber === number;
|
||||
|
||||
return {
|
||||
number,
|
||||
title: status.title,
|
||||
state: derivePrState(status),
|
||||
url: status.url,
|
||||
reviewDecision: mapReviewDecision(status.reviewDecision),
|
||||
// Requested reviewers are intentionally unwired until the server exposes them.
|
||||
awaitingReviewers: [],
|
||||
checks: (status.checks ?? []).flatMap(mapCheck),
|
||||
activity: timelineMatchesStatus
|
||||
? timeline.items.flatMap((item) => mapActivity(item, nowMs))
|
||||
: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function deriveAvatarColor(login: string): string {
|
||||
return AVATAR_COLORS[hashLogin(login) % AVATAR_COLORS.length];
|
||||
}
|
||||
|
||||
export function formatAge(createdAtMs: number, nowMs = Date.now()): string {
|
||||
const elapsedMs = Math.max(0, nowMs - createdAtMs);
|
||||
const elapsedSeconds = Math.floor(elapsedMs / 1000);
|
||||
|
||||
if (elapsedSeconds < 60) {
|
||||
return "just now";
|
||||
}
|
||||
|
||||
const elapsedMinutes = Math.floor(elapsedSeconds / 60);
|
||||
if (elapsedMinutes < 60) {
|
||||
return `${elapsedMinutes}m ago`;
|
||||
}
|
||||
|
||||
const elapsedHours = Math.floor(elapsedMinutes / 60);
|
||||
if (elapsedHours < 24) {
|
||||
return `${elapsedHours}h ago`;
|
||||
}
|
||||
|
||||
const elapsedDays = Math.floor(elapsedHours / 24);
|
||||
if (elapsedDays < 30) {
|
||||
return `${elapsedDays}d ago`;
|
||||
}
|
||||
|
||||
if (elapsedDays < 365) {
|
||||
return `${Math.floor(elapsedDays / 30)}mo ago`;
|
||||
}
|
||||
|
||||
return `${Math.floor(elapsedDays / 365)}y ago`;
|
||||
}
|
||||
|
||||
function derivePrState(status: NonNullable<CheckoutPrStatus>): PrState {
|
||||
if (status.isMerged || status.state === "merged") {
|
||||
return "merged";
|
||||
}
|
||||
if (status.state !== "open") {
|
||||
return "closed";
|
||||
}
|
||||
if (status.isDraft) {
|
||||
return "draft";
|
||||
}
|
||||
return "open";
|
||||
}
|
||||
|
||||
function mapCheck(check: NonNullable<CheckoutPrStatus>["checks"][number]): PrPaneCheck[] {
|
||||
if (check.url === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
name: check.name,
|
||||
workflow: check.workflow,
|
||||
status: mapCheckStatus(check.status),
|
||||
duration: check.duration,
|
||||
url: check.url,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function mapCheckStatus(status: string): CheckStatus {
|
||||
if (
|
||||
status === "success" ||
|
||||
status === "failure" ||
|
||||
status === "pending" ||
|
||||
status === "skipped"
|
||||
) {
|
||||
return status;
|
||||
}
|
||||
if (status === "cancelled") {
|
||||
return "skipped";
|
||||
}
|
||||
return "pending";
|
||||
}
|
||||
|
||||
function mapActivity(item: PullRequestTimelineItem, nowMs: number): PrPaneActivity[] {
|
||||
if (item.kind === "comment") {
|
||||
if (item.body.trim() === "") {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
{
|
||||
kind: "comment",
|
||||
author: item.author,
|
||||
avatarColor: deriveAvatarColor(item.author),
|
||||
body: item.body,
|
||||
age: formatAge(item.createdAt, nowMs),
|
||||
url: item.url,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
if (item.reviewState === "commented" && item.body.trim() === "") {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
kind: "review",
|
||||
author: item.author,
|
||||
avatarColor: deriveAvatarColor(item.author),
|
||||
reviewState: item.reviewState,
|
||||
body: item.body,
|
||||
age: formatAge(item.createdAt, nowMs),
|
||||
url: item.url,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function mapReviewDecision(
|
||||
reviewDecision: NonNullable<CheckoutPrStatus>["reviewDecision"],
|
||||
): PrPaneData["reviewDecision"] {
|
||||
if (reviewDecision === "approved" || reviewDecision === "changes_requested") {
|
||||
return reviewDecision;
|
||||
}
|
||||
return "pending";
|
||||
}
|
||||
|
||||
function parsePullRequestNumber(url: string): number | null {
|
||||
try {
|
||||
const match = new URL(url).pathname.match(/\/pull\/(\d+)(?:\/|$)/);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const number = Number.parseInt(match[1], 10);
|
||||
return Number.isFinite(number) ? number : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function hashLogin(login: string): number {
|
||||
let hash = 0;
|
||||
for (const character of login.toLowerCase()) {
|
||||
hash = (hash * 31 + character.charCodeAt(0)) >>> 0;
|
||||
}
|
||||
return hash;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { PrPaneData } from "./pr-pane-data";
|
||||
import { formatPrTabLabel } from "./pr-tab-label";
|
||||
|
||||
function makePrPaneData(overrides: Partial<PrPaneData> = {}): PrPaneData {
|
||||
return {
|
||||
number: 42,
|
||||
title: "Sample PR",
|
||||
state: "open",
|
||||
url: "https://github.com/getpaseo/paseo/pull/42",
|
||||
reviewDecision: "pending",
|
||||
awaitingReviewers: [],
|
||||
checks: [],
|
||||
activity: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("formatPrTabLabel", () => {
|
||||
it("returns #<number> when data is present", () => {
|
||||
expect(formatPrTabLabel(makePrPaneData({ number: 42 }))).toBe("#42");
|
||||
});
|
||||
|
||||
it("returns #— fallback when data is null", () => {
|
||||
expect(formatPrTabLabel(null)).toBe("#—");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { PrPaneData } from "./pr-pane-data";
|
||||
|
||||
export function formatPrTabLabel(data: PrPaneData | null): string {
|
||||
return data ? `#${data.number}` : "#—";
|
||||
}
|
||||
Reference in New Issue
Block a user