Attach pull request feedback to chat (#1400)

* feat(app): add pull request panel context actions

Attach pull request comments, reviews, and failed check details to chat context. The PR panel now lives in its own module so the activity, query, and context policies have one home.

* fix(server): mark capped check details as truncated

* fix(app): preserve concurrent PR panel attachments

* fix(server): preserve annotation truncation state

* fix(app): keep matrix check attachments distinct

* fix(app): streamline pull request context attachments

* fix(app): simplify pull request feedback rendering

* feat(app): group pull request review threads

* fix(app): tidy pull request panel boundaries

* fix: use worktree cwd for dev services

* fix(app): keep git query keys UI-free

* feat(app): collapse resolved comments and add chat actions to headers

* fix: address PR feedback and Playwright flakes

* fix(app): hide check chat action without attachment scope

* fix(server): dedupe inline review timeline comments
This commit is contained in:
Mohamed Boudra
2026-06-12 13:43:01 +08:00
committed by GitHub
parent c5da7505da
commit 3163e212fb
64 changed files with 6934 additions and 1939 deletions
-453
View File
@@ -1,453 +0,0 @@
import { useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
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 "@/git/pr-pane-data";
function rowPressableStyle({ hovered }: { hovered?: boolean }) {
return [styles.row, Boolean(hovered) && styles.hoverable];
}
function activityPressableStyle({ hovered }: { hovered?: boolean }) {
return [styles.activityRow, Boolean(hovered) && styles.hoverable];
}
export function PrPane({ data }: { data: PrPaneData }) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const [checksOpen, setChecksOpen] = useState(true);
const [reviewsOpen, setReviewsOpen] = useState(true);
const handleOpenPrUrl = useCallback(() => {
void openExternalUrl(data.url);
}, [data.url]);
const handleToggleChecks = useCallback(() => {
setChecksOpen((o) => !o);
}, []);
const handleToggleReviews = useCallback(() => {
setReviewsOpen((o) => !o);
}, []);
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 = t(`workspace.git.pr.states.${data.state}`);
const stateLabelStyle = useMemo(() => [styles.stateLabel, { color: stateColor }], [stateColor]);
const keyedActivity = useMemo(
() => data.activity.map((item, idx) => ({ key: `${item.author}-${item.kind}-${idx}`, item })),
[data.activity],
);
const checkSuccessIcon = useMemo(
() => <CircleCheck size={12} color={theme.colors.statusSuccess} />,
[theme.colors.statusSuccess],
);
const checkDangerIcon = useMemo(
() => <CircleX size={12} color={theme.colors.statusDanger} />,
[theme.colors.statusDanger],
);
const checkWarningIcon = useMemo(
() => <CircleDot size={12} color={theme.colors.statusWarning} />,
[theme.colors.statusWarning],
);
const commentIcon = useMemo(
() => <MessageSquare size={11} color={theme.colors.foregroundMuted} />,
[theme.colors.foregroundMuted],
);
return (
<View style={styles.root} testID="pr-pane">
<Pressable onPress={handleOpenPrUrl} style={styles.header}>
{({ hovered }) => (
<>
<View style={styles.stateLine}>
<StateIcon size={14} color={stateColor} />
<Text style={stateLabelStyle} testID="pr-pane-state">
{stateLabel}
</Text>
</View>
<Text style={styles.title} numberOfLines={3} testID="pr-pane-title">
{data.title}
{hovered ? (
<Text>
{" "}
<ExternalLink size={12} color={theme.colors.foregroundMuted} />
</Text>
) : null}
</Text>
</>
)}
</Pressable>
<View style={styles.divider} />
<Section
title={t("workspace.git.pr.sections.checks")}
open={checksOpen}
onToggle={handleToggleChecks}
summary={
<>
<SummaryPill
count={passed}
color={theme.colors.statusSuccess}
icon={checkSuccessIcon}
testID="pr-pane-check-passed"
/>
<SummaryPill
count={failed}
color={theme.colors.statusDanger}
icon={checkDangerIcon}
testID="pr-pane-check-failed"
/>
<SummaryPill
count={pending}
color={theme.colors.statusWarning}
icon={checkWarningIcon}
testID="pr-pane-check-pending"
/>
</>
}
>
{data.checks.map((check) => (
<CheckRow key={check.name} check={check} />
))}
</Section>
<View style={styles.divider} />
<Section
title={t("workspace.git.pr.sections.reviews")}
open={reviewsOpen}
onToggle={handleToggleReviews}
summary={
<>
<SummaryPill
count={approvals}
color={theme.colors.statusSuccess}
icon={checkSuccessIcon}
/>
<SummaryPill
count={changesRequested}
color={theme.colors.statusDanger}
icon={checkDangerIcon}
/>
<SummaryPill
count={commentCount}
color={theme.colors.foregroundMuted}
icon={commentIcon}
/>
</>
}
>
{keyedActivity.map(({ key, item }) => (
<ActivityRow key={key} 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,
testID,
}: {
count: number;
color: string;
icon: React.ReactNode;
testID?: string;
}) {
const textStyle = useMemo(() => [styles.summaryPillText, { color }], [color]);
if (count === 0) return null;
return (
<View style={styles.summaryPill} testID={testID}>
{icon}
<Text style={textStyle}>{count}</Text>
</View>
);
}
function CheckRow({ check }: { check: PrPaneCheck }) {
const handlePress = useCallback(() => {
void openExternalUrl(check.url);
}, [check.url]);
return (
<Pressable onPress={handlePress} style={rowPressableStyle}>
<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 { t } = useTranslation();
const verb = getTranslatedActivityVerb(item, t);
const age = item.age === "just now" ? t("workspace.git.pr.time.justNow") : item.age;
const handlePress = useCallback(() => {
void openExternalUrl(item.url);
}, [item.url]);
const avatarStyle = useMemo(
() => [styles.avatar, { backgroundColor: item.avatarColor }],
[item.avatarColor],
);
return (
<Pressable onPress={handlePress} style={activityPressableStyle} testID="pr-pane-activity-row">
<View style={avatarStyle}>
<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}>{age}</Text>
</View>
<Text style={styles.rowBody} numberOfLines={2}>
{item.body}
</Text>
</View>
</Pressable>
);
}
function getTranslatedActivityVerb(
item: Pick<PrPaneActivity, "kind" | "reviewState">,
t: (key: string) => string,
): string {
if (item.kind === "comment") return t("workspace.git.pr.activity.commented");
if (item.reviewState === "approved") return t("workspace.git.pr.activity.approved");
if (item.reviewState === "changes_requested") {
return t("workspace.git.pr.activity.requestedChanges");
}
return t("workspace.git.pr.activity.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;
}
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",
},
}));
-27
View File
@@ -1,27 +0,0 @@
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("#—");
});
});
-5
View File
@@ -1,5 +0,0 @@
import type { PrPaneData } from "./pr-pane-data";
export function formatPrTabLabel(data: PrPaneData | null): string {
return data ? `#${data.number}` : "#—";
}
@@ -0,0 +1,48 @@
import { describe, expect, it } from "vitest";
import { formatPullRequestActivityLocation } from "./activity-location";
import type { PrPaneActivity } from "./data";
function activity(location: PrPaneActivity["location"]): PrPaneActivity {
return {
id: "comment-1",
provider: "github",
kind: "comment",
author: "octocat",
avatarColor: "#0ea5e9",
body: "Looks good.",
age: "3d ago",
url: "https://github.com/getpaseo/paseo/pull/42#discussion_r1",
location,
};
}
describe("pull request activity location labels", () => {
it("includes inline thread state and concise thread IDs", () => {
expect(
formatPullRequestActivityLocation(
activity({
path: "packages/app/src/panel.tsx",
startLine: 10,
line: 12,
threadId: "PRRT_1",
isResolved: false,
isOutdated: true,
}),
),
).toBe("packages/app/src/panel.tsx:10-12 · unresolved · outdated · thread PRRT_1");
});
it("includes current/resolved state without noisy long thread IDs", () => {
expect(
formatPullRequestActivityLocation(
activity({
path: "packages/app/src/panel.tsx",
line: 12,
threadId: "PRRT_kwDOAReallyLongOpaqueThreadIdentifier",
isResolved: true,
isOutdated: false,
}),
),
).toBe("packages/app/src/panel.tsx:12 · resolved · current");
});
});
@@ -0,0 +1,54 @@
import type { PrPaneActivity } from "./data";
export function formatPullRequestActivityLocation(activity: PrPaneActivity): string | null {
if (!activity.location) {
return null;
}
const parts = [formatPathAndLines(activity.location)];
const threadState = formatThreadState(activity.location);
if (threadState) {
parts.push(...threadState);
}
const threadLabel = formatThreadLabel(activity.location.threadId);
if (threadLabel) {
parts.push(threadLabel);
}
return parts.join(" · ");
}
function formatPathAndLines(location: NonNullable<PrPaneActivity["location"]>): string {
if (location.line !== undefined && location.startLine !== undefined) {
return `${location.path}:${location.startLine}-${location.line}`;
}
if (location.line !== undefined) {
return `${location.path}:${location.line}`;
}
return location.path;
}
/** Path-and-line label for a review thread header, e.g. "src/foo.ts:12-14". */
export function formatPullRequestThreadPath(
location: NonNullable<PrPaneActivity["location"]>,
): string {
return formatPathAndLines(location);
}
function formatThreadState(location: NonNullable<PrPaneActivity["location"]>): string[] | null {
const state = [];
if (location.isResolved !== undefined) {
state.push(location.isResolved ? "resolved" : "unresolved");
}
if (location.isOutdated !== undefined) {
state.push(location.isOutdated ? "outdated" : "current");
}
return state.length > 0 ? state : null;
}
function formatThreadLabel(threadId: string | undefined): string | null {
if (!threadId || threadId.length > 24) {
return null;
}
return `thread ${threadId}`;
}
@@ -0,0 +1,140 @@
import { describe, expect, it } from "vitest";
import {
collapseActivity,
expandActivity,
getActivityState,
getCollapsedEntryIds,
getVisibleEntries,
} from "./activity-state";
import type { PrTimelineEntry } from "./timeline";
import type { PrPaneActivity } from "./data";
function activity(id: string, overrides: Partial<PrPaneActivity> = {}): PrPaneActivity {
return {
id,
provider: "github",
kind: "comment",
author: "octocat",
avatarColor: "#0ea5e9",
body: "Looks good.",
age: "3d ago",
url: `https://github.com/getpaseo/paseo/pull/42#${id}`,
...overrides,
};
}
function singleEntry(id: string, resolved = false, outdated = false): PrTimelineEntry {
return {
kind: "single",
id,
activity: activity(id, {
location:
resolved || outdated
? { path: "a.ts", line: 1, isResolved: resolved, isOutdated: outdated }
: undefined,
}),
};
}
function threadEntry(id: string, resolved = false, outdated = false): PrTimelineEntry {
return {
kind: "thread",
id: `thread:${id}`,
location: { path: "a.ts", line: 1, isResolved: resolved, isOutdated: outdated },
comments: [activity(id)],
};
}
function reviewEntry(id: string, threads: PrTimelineEntry[]): PrTimelineEntry {
return {
kind: "review",
id,
review: activity(id, { kind: "review", reviewState: "commented" }),
threads: threads.filter((entry) => entry.kind === "thread"),
};
}
describe("pull request activity state", () => {
it("collapses and expands activity by PR-scoped stable key", () => {
const collapsed = collapseActivity(getActivityState(), {
prNumber: 42,
activityId: "comment-1",
});
expect(collapsed.collapsedKeys).toEqual(["42:comment-1"]);
expect(expandActivity(collapsed, { prNumber: 42, activityId: "comment-1" })).toEqual({
collapsedKeys: [],
expandedKeys: ["42:comment-1"],
});
});
it("collapses resolved and outdated entries by default", () => {
const entries = [
singleEntry("normal"),
singleEntry("resolved", true),
singleEntry("outdated", false, true),
threadEntry("thread-normal"),
threadEntry("thread-resolved", true),
threadEntry("thread-outdated", false, true),
];
const visible = getVisibleEntries(getActivityState(), { prNumber: 42, entries });
expect(visible.map((v) => ({ id: v.entry.id, collapsed: v.collapsed }))).toEqual([
{ id: "normal", collapsed: false },
{ id: "resolved", collapsed: true },
{ id: "outdated", collapsed: true },
{ id: "thread:thread-normal", collapsed: false },
{ id: "thread:thread-resolved", collapsed: true },
{ id: "thread:thread-outdated", collapsed: true },
]);
});
it("lets user expand a default-collapsed entry", () => {
const entries = [threadEntry("resolved", true)];
const expanded = expandActivity(getActivityState(), {
prNumber: 42,
activityId: "thread:resolved",
});
const visible = getVisibleEntries(expanded, { prNumber: 42, entries });
expect(visible[0].collapsed).toBe(false);
});
it("lets user collapse a default-expanded entry", () => {
const entries = [threadEntry("normal")];
const collapsed = collapseActivity(getActivityState(), {
prNumber: 42,
activityId: "thread:normal",
});
const visible = getVisibleEntries(collapsed, { prNumber: 42, entries });
expect(visible[0].collapsed).toBe(true);
});
it("includes default-collapsed nested review threads in collapsed IDs", () => {
const entries = [
reviewEntry("review", [
threadEntry("thread-normal"),
threadEntry("thread-resolved", true),
threadEntry("thread-outdated", false, true),
]),
];
const collapsedIds = getCollapsedEntryIds(getActivityState(), { prNumber: 42, entries });
expect([...collapsedIds].sort()).toEqual(["thread:thread-outdated", "thread:thread-resolved"]);
});
it("lets user expand a default-collapsed nested review thread", () => {
const entries = [reviewEntry("review", [threadEntry("thread-resolved", true)])];
const expanded = expandActivity(getActivityState(), {
prNumber: 42,
activityId: "thread:thread-resolved",
});
const collapsedIds = getCollapsedEntryIds(expanded, { prNumber: 42, entries });
expect(collapsedIds.has("thread:thread-resolved")).toBe(false);
});
});
@@ -0,0 +1,115 @@
import type { PrTimelineEntry } from "./timeline";
export interface PullRequestActivityIdentity {
prNumber: number;
activityId: string;
}
export interface PullRequestActivityState {
collapsedKeys: readonly string[];
expandedKeys: readonly string[];
}
export interface VisiblePullRequestEntry {
entry: PrTimelineEntry;
collapsed: boolean;
}
export function getActivityState(): PullRequestActivityState {
return { collapsedKeys: [], expandedKeys: [] };
}
export function getActivityStateKey(identity: PullRequestActivityIdentity): string {
return `${identity.prNumber}:${identity.activityId}`;
}
function shouldCollapseByDefault(entry: PrTimelineEntry): boolean {
if (entry.kind === "thread") {
return entry.location.isResolved === true || entry.location.isOutdated === true;
}
if (entry.kind === "single") {
return (
entry.activity.location?.isResolved === true || entry.activity.location?.isOutdated === true
);
}
return false;
}
export function collapseActivity(
state: PullRequestActivityState,
identity: PullRequestActivityIdentity,
): PullRequestActivityState {
const key = getActivityStateKey(identity);
if (state.collapsedKeys.includes(key)) {
return { ...state, expandedKeys: state.expandedKeys.filter((item) => item !== key) };
}
return {
...state,
collapsedKeys: [...state.collapsedKeys, key],
expandedKeys: state.expandedKeys.filter((item) => item !== key),
};
}
export function expandActivity(
state: PullRequestActivityState,
identity: PullRequestActivityIdentity,
): PullRequestActivityState {
const key = getActivityStateKey(identity);
const collapsedKeys = state.collapsedKeys.filter((item) => item !== key);
const expandedKeys = state.expandedKeys.includes(key)
? state.expandedKeys
: [...state.expandedKeys, key];
return { ...state, collapsedKeys, expandedKeys };
}
export function getVisibleEntries(
state: PullRequestActivityState,
input: { prNumber: number; entries: readonly PrTimelineEntry[] },
): VisiblePullRequestEntry[] {
return input.entries.map((entry) => {
const key = getActivityStateKey({ prNumber: input.prNumber, activityId: entry.id });
const collapsedByDefault = shouldCollapseByDefault(entry);
const isExplicitlyCollapsed = state.collapsedKeys.includes(key);
const isExplicitlyExpanded = state.expandedKeys.includes(key);
const collapsed = isExplicitlyCollapsed || (collapsedByDefault && !isExplicitlyExpanded);
return { entry, collapsed };
});
}
export function getCollapsedEntryIds(
state: PullRequestActivityState,
input: { prNumber: number; entries?: readonly PrTimelineEntry[] },
): ReadonlySet<string> {
const prefix = `${input.prNumber}:`;
const collapsedIds = new Set(
state.collapsedKeys
.filter((key) => key.startsWith(prefix))
.map((key) => key.slice(prefix.length)),
);
addDefaultCollapsedEntryIds(collapsedIds, state, input.prNumber, input.entries ?? []);
return collapsedIds;
}
function addDefaultCollapsedEntryIds(
collapsedIds: Set<string>,
state: PullRequestActivityState,
prNumber: number,
entries: readonly PrTimelineEntry[],
) {
for (const entry of entries) {
const key = getActivityStateKey({ prNumber, activityId: entry.id });
if (shouldCollapseByDefault(entry) && !state.expandedKeys.includes(key)) {
collapsedIds.add(entry.id);
}
if (state.expandedKeys.includes(key)) {
collapsedIds.delete(entry.id);
}
if (entry.kind === "review") {
addDefaultCollapsedEntryIds(collapsedIds, state, prNumber, entry.threads);
}
}
}
@@ -0,0 +1,317 @@
import { describe, expect, it } from "vitest";
import {
buildPullRequestCheckContextAttachment,
buildPullRequestCommentContextAttachment,
buildPullRequestReviewContextAttachment,
buildPullRequestThreadContextAttachment,
canAddPullRequestActivityToChat,
canAddPullRequestCheckLogsToChat,
type PullRequestContextBuilderInput,
} from "./context-attachment";
import type { PrPaneActivity, PrPaneCheck } from "./data";
import type { PrThreadEntry } from "./timeline";
const baseInput: Omit<PullRequestContextBuilderInput, "activity"> = {
provider: { id: "github", label: "GitHub" },
pullRequest: {
number: 42,
title: "Fix flaky build",
url: "https://github.com/getpaseo/paseo/pull/42",
},
};
function comment(overrides: Partial<PrPaneActivity> = {}): PrPaneActivity {
return {
id: "comment-1",
provider: "github",
kind: "comment",
author: "octocat",
avatarColor: "#0ea5e9",
body: "Looks good.",
age: "3d ago",
url: "https://github.com/getpaseo/paseo/pull/42#issuecomment-1",
...overrides,
};
}
function review(overrides: Partial<PrPaneActivity> = {}): PrPaneActivity {
return {
id: "review-1",
provider: "github",
kind: "review",
author: "reviewer",
avatarColor: "#8b5cf6",
reviewState: "commented",
body: "Please simplify this.",
age: "2d ago",
url: "https://github.com/getpaseo/paseo/pull/42#pullrequestreview-1",
...overrides,
};
}
function check(overrides: Partial<PrPaneCheck> = {}): PrPaneCheck {
return {
name: "server-tests",
provider: "github",
status: "failure",
url: "https://github.com/getpaseo/paseo/actions/runs/456/job/789",
github: { checkRunId: 12345, workflowRunId: 456 },
...overrides,
};
}
describe("pull request context attachments", () => {
it("formats a comment as a workspace context attachment", () => {
expect(
buildPullRequestCommentContextAttachment({
...baseInput,
activity: comment({
location: {
path: "packages/app/src/panel.tsx",
line: 42,
threadId: "PRRT_1",
isResolved: false,
isOutdated: true,
},
}),
}),
).toEqual({
kind: "github.pull_request_comment",
id: "42:comment-1",
title: "octocat",
subtitle: "#42 Fix flaky build",
url: "https://github.com/getpaseo/paseo/pull/42#issuecomment-1",
text: [
"GitHub pull request comment",
"Pull request: #42 Fix flaky build",
"Pull request URL: https://github.com/getpaseo/paseo/pull/42",
"URL: https://github.com/getpaseo/paseo/pull/42#issuecomment-1",
"Author: octocat",
"Created: 3d ago",
"Location: packages/app/src/panel.tsx:42 · unresolved · outdated · thread PRRT_1",
"",
"Looks good.",
].join("\n"),
});
});
it("formats a review with state as a workspace context attachment", () => {
expect(buildPullRequestReviewContextAttachment({ ...baseInput, activity: review() })).toEqual({
kind: "github.pull_request_review",
id: "42:review-1",
title: "reviewer",
subtitle: "#42 Fix flaky build",
url: "https://github.com/getpaseo/paseo/pull/42#pullrequestreview-1",
text: [
"GitHub pull request review",
"Pull request: #42 Fix flaky build",
"Pull request URL: https://github.com/getpaseo/paseo/pull/42",
"URL: https://github.com/getpaseo/paseo/pull/42#pullrequestreview-1",
"Author: reviewer",
"State: commented",
"Created: 2d ago",
"",
"Please simplify this.",
].join("\n"),
});
});
it("does not add bodyless approvals to chat", () => {
const activity = review({ reviewState: "approved", body: "" });
expect(canAddPullRequestActivityToChat(activity)).toBe(false);
expect(buildPullRequestReviewContextAttachment({ ...baseInput, activity })).toBeNull();
});
it("adds bodyless changes-requested reviews as metadata-only context", () => {
const activity = review({ reviewState: "changes_requested", body: "" });
expect(canAddPullRequestActivityToChat(activity)).toBe(true);
expect(buildPullRequestReviewContextAttachment({ ...baseInput, activity })?.text).toBe(
[
"GitHub pull request review",
"Pull request: #42 Fix flaky build",
"Pull request URL: https://github.com/getpaseo/paseo/pull/42",
"URL: https://github.com/getpaseo/paseo/pull/42#pullrequestreview-1",
"Author: reviewer",
"State: changes_requested",
"Created: 2d ago",
].join("\n"),
);
});
it("shows add-logs only for failed checks", () => {
expect(canAddPullRequestCheckLogsToChat(check({ status: "failure" }))).toBe(true);
expect(canAddPullRequestCheckLogsToChat(check({ status: "success" }))).toBe(false);
expect(canAddPullRequestCheckLogsToChat(check({ status: "pending" }))).toBe(false);
expect(canAddPullRequestCheckLogsToChat(check({ status: "skipped" }))).toBe(false);
});
it("formats failed check details as a workspace context attachment", () => {
expect(
buildPullRequestCheckContextAttachment({
...baseInput,
check: check(),
githubDetails: {
checkRunId: 12345,
workflowRunId: 456,
name: "server-tests",
status: "completed",
conclusion: "failure",
url: "https://github.com/getpaseo/paseo/actions/runs/456/job/789",
detailsUrl: "https://github.com/getpaseo/paseo/actions/runs/456/job/789",
output: { title: "Tests failed", summary: "1 failure", text: "Assertion failed" },
annotations: [
{
path: "packages/server/src/index.ts",
startLine: 10,
endLine: 12,
annotationLevel: "failure",
message: "Expected true",
},
],
failedJobs: [
{
jobId: 789,
name: "test",
status: "completed",
conclusion: "failure",
url: "https://github.com/getpaseo/paseo/actions/runs/456/job/789",
logTail: "last line",
logTruncated: false,
},
],
truncated: true,
},
}),
).toEqual({
kind: "github.pull_request_check",
id: "42:check-run:12345",
title: "server-tests",
subtitle: "#42 Fix flaky build",
url: "https://github.com/getpaseo/paseo/actions/runs/456/job/789",
text: [
"GitHub pull request check",
"Pull request: #42 Fix flaky build",
"Pull request URL: https://github.com/getpaseo/paseo/pull/42",
"Check: server-tests",
"Status: failure",
"Conclusion: failure",
"Check URL: https://github.com/getpaseo/paseo/actions/runs/456/job/789",
"Details URL: https://github.com/getpaseo/paseo/actions/runs/456/job/789",
"Output title: Tests failed",
"Output summary: 1 failure",
"Output text:",
"Assertion failed",
"",
"Annotations:",
"- packages/server/src/index.ts:10-12 failure: Expected true",
"",
"Failed jobs:",
"- test: failure",
" https://github.com/getpaseo/paseo/actions/runs/456/job/789",
" ```",
" last line",
" ```",
"",
"Note: Check details were truncated by GitHub/API or local caps.",
].join("\n"),
});
});
it("keeps same-named GitHub checks distinct by check run", () => {
const ubuntu = buildPullRequestCheckContextAttachment({
...baseInput,
check: check({ github: { checkRunId: 12345, workflowRunId: 456 } }),
githubDetails: null,
});
const windows = buildPullRequestCheckContextAttachment({
...baseInput,
check: check({ github: { checkRunId: 67890, workflowRunId: 456 } }),
githubDetails: null,
});
expect(ubuntu.id).toBe("42:check-run:12345");
expect(windows.id).toBe("42:check-run:67890");
});
it("formats metadata-only failed check context when details are unavailable", () => {
expect(
buildPullRequestCheckContextAttachment({
...baseInput,
check: check({
name: "status/context",
url: "https://github.com/getpaseo/paseo/status/context",
github: undefined,
}),
githubDetails: null,
}),
).toEqual({
kind: "github.pull_request_check",
id: "42:check:status/context",
title: "status/context",
subtitle: "#42 Fix flaky build",
url: "https://github.com/getpaseo/paseo/status/context",
text: [
"GitHub pull request check",
"Pull request: #42 Fix flaky build",
"Pull request URL: https://github.com/getpaseo/paseo/pull/42",
"Check: status/context",
"Status: failure",
"Check URL: https://github.com/getpaseo/paseo/status/context",
].join("\n"),
});
});
});
describe("buildPullRequestThreadContextAttachment", () => {
function thread(overrides: Partial<PrThreadEntry> = {}): PrThreadEntry {
return {
kind: "thread",
id: "thread:PRRT_1",
location: { path: "src/a.ts", line: 12, threadId: "PRRT_1", isResolved: false },
comments: [
comment({
id: "t1",
body: "This is frozen after initial registration.",
url: "https://github.com/getpaseo/paseo/pull/42#discussion_r1",
}),
comment({ id: "t2", author: "mo", age: "1d ago", body: "Good catch, fixing." }),
],
...overrides,
};
}
it("bundles the whole thread conversation into one attachment", () => {
expect(buildPullRequestThreadContextAttachment({ ...baseInput, thread: thread() })).toEqual({
kind: "github.pull_request_comment",
id: "42:thread:PRRT_1",
title: "src/a.ts:12",
subtitle: "#42 Fix flaky build",
url: "https://github.com/getpaseo/paseo/pull/42#discussion_r1",
text: [
"GitHub pull request review thread",
"Pull request: #42 Fix flaky build",
"Pull request URL: https://github.com/getpaseo/paseo/pull/42",
"URL: https://github.com/getpaseo/paseo/pull/42#discussion_r1",
"Location: src/a.ts:12",
"Thread state: unresolved",
"",
"octocat (3d ago):\nThis is frozen after initial registration.",
"",
"---",
"",
"mo (1d ago):\nGood catch, fixing.",
].join("\n"),
});
});
it("returns null when no comment has a body", () => {
expect(
buildPullRequestThreadContextAttachment({
...baseInput,
thread: thread({ comments: [comment({ body: " " })] }),
}),
).toBeNull();
});
});
@@ -0,0 +1,284 @@
import type { CheckoutGithubCheckDetails } from "@getpaseo/protocol/messages";
import type { PullRequestContextAttachment } from "@/attachments/types";
import {
formatPullRequestActivityLocation,
formatPullRequestThreadPath,
} from "./activity-location";
import type { PrPaneActivity, PrPaneCheck, PullRequestProviderMetadata, ReviewState } from "./data";
import type { PrThreadEntry } from "./timeline";
export interface PullRequestContextMetadata {
number: number;
title: string;
url: string;
}
export interface PullRequestContextBuilderInput {
provider: PullRequestProviderMetadata;
pullRequest: PullRequestContextMetadata;
activity: PrPaneActivity;
}
export interface PullRequestThreadContextBuilderInput {
provider: PullRequestProviderMetadata;
pullRequest: PullRequestContextMetadata;
thread: PrThreadEntry;
}
export interface PullRequestGithubCheckContextBuilderInput {
provider: PullRequestProviderMetadata & { id: "github" };
pullRequest: PullRequestContextMetadata;
check: PrPaneCheck & { provider: "github" };
githubDetails?: CheckoutGithubCheckDetails | null;
}
export function canAddPullRequestActivityToChat(activity: PrPaneActivity): boolean {
if (activity.kind === "comment") {
return activity.body.trim().length > 0;
}
return activity.body.trim().length > 0 || activity.reviewState === "changes_requested";
}
export function canAddPullRequestCheckLogsToChat(check: PrPaneCheck): boolean {
return check.status === "failure";
}
export function buildPullRequestCommentContextAttachment(
input: PullRequestContextBuilderInput,
): PullRequestContextAttachment {
return {
kind: "github.pull_request_comment",
id: `${input.pullRequest.number}:${input.activity.id}`,
title: input.activity.author,
subtitle: formatPullRequestSubtitle(input.pullRequest),
text: formatActivityContextText({
...input,
heading: `${input.provider.label} pull request comment`,
}),
url: input.activity.url,
};
}
export function buildPullRequestReviewContextAttachment(
input: PullRequestContextBuilderInput,
): PullRequestContextAttachment | null {
if (!canAddPullRequestActivityToChat(input.activity)) {
return null;
}
return {
kind: "github.pull_request_review",
id: `${input.pullRequest.number}:${input.activity.id}`,
title: input.activity.author,
subtitle: formatPullRequestSubtitle(input.pullRequest),
text: formatActivityContextText({
...input,
heading: `${input.provider.label} pull request review`,
reviewState: input.activity.reviewState,
}),
url: input.activity.url,
};
}
/**
* Attaches a whole review thread (root comment plus replies) as one
* attachment, so the agent gets the full conversation around a code location.
*/
export function buildPullRequestThreadContextAttachment(
input: PullRequestThreadContextBuilderInput,
): PullRequestContextAttachment | null {
const comments = input.thread.comments.filter((comment) => comment.body.trim().length > 0);
const root = input.thread.comments[0];
if (comments.length === 0 || !root) {
return null;
}
const lines = [
`${input.provider.label} pull request review thread`,
`Pull request: #${input.pullRequest.number} ${input.pullRequest.title}`,
`Pull request URL: ${input.pullRequest.url}`,
`URL: ${root.url}`,
`Location: ${formatPullRequestThreadPath(input.thread.location)}`,
];
if (input.thread.location.isResolved !== undefined) {
lines.push(`Thread state: ${input.thread.location.isResolved ? "resolved" : "unresolved"}`);
}
if (input.thread.location.isOutdated) {
lines.push("Note: this thread is outdated (the code it refers to has changed)");
}
const conversation = comments.map(
(comment) => `${comment.author} (${comment.age}):\n${comment.body.trim()}`,
);
return {
kind: "github.pull_request_comment",
id: `${input.pullRequest.number}:${input.thread.id}`,
title: formatPullRequestThreadPath(input.thread.location),
subtitle: formatPullRequestSubtitle(input.pullRequest),
text: [...lines, "", conversation.join("\n\n---\n\n")].join("\n"),
url: root.url,
};
}
export function buildPullRequestCheckContextAttachment(
input: PullRequestGithubCheckContextBuilderInput,
): PullRequestContextAttachment {
return {
kind: "github.pull_request_check",
id: formatPullRequestCheckContextId(input.pullRequest, input.check),
title: input.check.name,
subtitle: formatPullRequestSubtitle(input.pullRequest),
text: formatGitHubCheckContextText(input),
url: input.githubDetails?.detailsUrl ?? input.githubDetails?.url ?? input.check.url,
};
}
function formatPullRequestCheckContextId(
pullRequest: PullRequestContextMetadata,
check: PrPaneCheck,
): string {
if (check.github?.checkRunId !== undefined) {
return `${pullRequest.number}:check-run:${check.github.checkRunId}`;
}
return `${pullRequest.number}:check:${check.name}`;
}
function formatGitHubCheckContextText({
provider,
pullRequest,
check,
githubDetails,
}: PullRequestGithubCheckContextBuilderInput): string {
const lines = [
`${provider.label} pull request check`,
`Pull request: #${pullRequest.number} ${pullRequest.title}`,
`Pull request URL: ${pullRequest.url}`,
`Check: ${check.name}`,
`Status: ${check.status}`,
];
if (githubDetails?.conclusion) {
lines.push(`Conclusion: ${githubDetails.conclusion}`);
}
lines.push(`Check URL: ${check.url}`);
const detailsUrl = githubDetails?.detailsUrl ?? githubDetails?.url;
if (detailsUrl) {
lines.push(`Details URL: ${detailsUrl}`);
}
appendGitHubCheckOutput(lines, githubDetails);
appendGitHubCheckAnnotations(lines, githubDetails);
appendGitHubFailedJobs(lines, githubDetails);
if (githubDetails?.truncated) {
lines.push("", "Note: Check details were truncated by GitHub/API or local caps.");
}
return lines.join("\n");
}
function appendGitHubCheckOutput(
lines: string[],
details: CheckoutGithubCheckDetails | null | undefined,
) {
if (details?.output?.title) {
lines.push(`Output title: ${details.output.title}`);
}
if (details?.output?.summary) {
lines.push(`Output summary: ${details.output.summary}`);
}
if (details?.output?.text) {
lines.push("Output text:", details.output.text);
}
}
function appendGitHubCheckAnnotations(
lines: string[],
details: CheckoutGithubCheckDetails | null | undefined,
) {
if (!details?.annotations?.length) {
return;
}
lines.push("", "Annotations:");
for (const annotation of details.annotations) {
lines.push(`- ${formatAnnotation(annotation)}`);
}
}
function appendGitHubFailedJobs(
lines: string[],
details: CheckoutGithubCheckDetails | null | undefined,
) {
if (!details?.failedJobs?.length) {
return;
}
lines.push("", "Failed jobs:");
for (const job of details.failedJobs) {
lines.push(`- ${job.name}: ${job.conclusion ?? job.status ?? "unknown"}`);
if (job.url) {
lines.push(` ${job.url}`);
}
if (job.logTail) {
lines.push(" ```", ...job.logTail.split("\n").map((line) => ` ${line}`), " ```");
}
if (job.logTruncated) {
lines.push(" Log tail truncated to the latest capped lines.");
}
}
}
function formatAnnotation(annotation: CheckoutGithubCheckDetails["annotations"][number]): string {
const location = annotation.path
? `${annotation.path}${formatAnnotationLines(annotation)}`
: "unknown location";
const level = annotation.annotationLevel ? ` ${annotation.annotationLevel}` : "";
const message = annotation.message ? `: ${annotation.message}` : "";
return `${location}${level}${message}`;
}
function formatAnnotationLines(
annotation: CheckoutGithubCheckDetails["annotations"][number],
): string {
if (annotation.startLine !== undefined && annotation.endLine !== undefined) {
return `:${annotation.startLine}-${annotation.endLine}`;
}
if (annotation.startLine !== undefined) {
return `:${annotation.startLine}`;
}
return "";
}
function formatActivityContextText({
heading,
pullRequest,
activity,
reviewState,
}: PullRequestContextBuilderInput & { heading: string; reviewState?: ReviewState }): string {
const lines = [
heading,
`Pull request: #${pullRequest.number} ${pullRequest.title}`,
`Pull request URL: ${pullRequest.url}`,
`URL: ${activity.url}`,
`Author: ${activity.author}`,
];
if (reviewState) {
lines.push(`State: ${reviewState}`);
}
if (activity.age) {
lines.push(`Created: ${activity.age}`);
}
if (activity.location) {
lines.push(`Location: ${formatPullRequestActivityLocation(activity)}`);
}
const body = activity.body.trim();
if (body.length === 0) {
return lines.join("\n");
}
return [...lines, "", body].join("\n");
}
function formatPullRequestSubtitle(pullRequest: PullRequestContextMetadata): string {
return `#${pullRequest.number} ${pullRequest.title}`;
}
@@ -9,7 +9,7 @@ import {
getActivityVerb,
getStateLabel,
mapPrPaneData,
} from "./pr-pane-data";
} from "./data";
type CheckoutPrStatus = NonNullable<CheckoutPrStatusResponse["payload"]["status"]>;
type PullRequestTimeline = PullRequestTimelineResponse["payload"];
@@ -118,6 +118,7 @@ describe("mapPrPaneData", () => {
expect(data?.checks).toEqual([
{
provider: "github",
name: "typecheck",
status: "success",
url: "https://example.com/checks/1",
@@ -147,16 +148,44 @@ describe("mapPrPaneData", () => {
expect(data?.checks).toEqual([
{
provider: "github",
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" },
{ provider: "github", name: "failure", status: "failure", url: "https://example.com/2" },
{ provider: "github", name: "pending", status: "pending", url: "https://example.com/3" },
{ provider: "github", name: "skipped", status: "skipped", url: "https://example.com/4" },
{ provider: "github", name: "cancelled", status: "skipped", url: "https://example.com/5" },
]);
});
it("maps GitHub check identifiers into provider refs", () => {
const data = mapPrPaneData(
status({
checks: [
{
name: "server-tests",
status: "failure",
url: "https://github.com/getpaseo/paseo/actions/runs/456/job/789",
checkRunId: 12345,
workflowRunId: 456,
},
],
}),
baseTimeline,
);
expect(data?.checks).toEqual([
{
provider: "github",
name: "server-tests",
status: "failure",
url: "https://github.com/getpaseo/paseo/actions/runs/456/job/789",
github: { checkRunId: 12345, workflowRunId: 456 },
},
]);
});
@@ -191,6 +220,58 @@ describe("mapPrPaneData", () => {
expect(data?.activity.map((item) => item.author)).toEqual(["alice", "bob"]);
});
it("maps timeline author avatar URLs and inline comment location metadata", () => {
const data = mapPrPaneData(
baseStatus,
timeline({
items: [
{
id: "thread-comment-1",
kind: "comment",
author: "inline-reviewer",
authorUrl: "https://github.com/inline-reviewer",
avatarUrl: "https://avatars.githubusercontent.com/u/3?v=4",
body: "This should include line context.",
createdAt: Date.UTC(2026, 0, 1, 11, 0, 0),
url: "https://github.com/getpaseo/paseo/pull/42#discussion_r1",
location: {
path: "packages/app/src/git/pull-request-panel/data.ts",
line: 24,
startLine: 20,
threadId: "PRRT_1",
isResolved: true,
isOutdated: false,
},
},
],
}),
Date.UTC(2026, 0, 1, 12, 0, 0),
);
expect(data?.activity).toEqual([
{
id: "thread-comment-1",
provider: "github",
kind: "comment",
author: "inline-reviewer",
authorUrl: "https://github.com/inline-reviewer",
avatarColor: deriveAvatarColor("inline-reviewer"),
avatarUrl: "https://avatars.githubusercontent.com/u/3?v=4",
body: "This should include line context.",
age: "1h ago",
url: "https://github.com/getpaseo/paseo/pull/42#discussion_r1",
location: {
path: "packages/app/src/git/pull-request-panel/data.ts",
line: 24,
startLine: 20,
threadId: "PRRT_1",
isResolved: true,
isOutdated: false,
},
},
]);
});
it("filters empty commented reviews but keeps blocking review states", () => {
const data = mapPrPaneData(
baseStatus,
@@ -7,27 +7,58 @@ 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 type PullRequestProvider = "github";
export interface PullRequestProviderMetadata {
id: PullRequestProvider;
label: string;
url?: string | null;
}
const GITHUB_PROVIDER: PullRequestProviderMetadata = { id: "github", label: "GitHub" };
export interface PrPaneCheck {
provider: PullRequestProvider;
name: string;
workflow?: string;
status: CheckStatus;
duration?: string;
url: string;
github?: {
checkRunId?: number;
workflowRunId?: number;
};
}
export interface PrPaneActivity {
provider: PullRequestProvider;
id: string;
kind: ActivityKind;
author: string;
authorUrl?: string | null;
avatarColor: string;
avatarUrl?: string | null;
reviewState?: ReviewState;
body: string;
age: string;
url: string;
/** For inline review comments: the review this comment was submitted with. */
reviewId?: string;
location?: {
path: string;
line?: number;
startLine?: number;
threadId?: string;
isResolved?: boolean;
isOutdated?: boolean;
};
}
export interface PrPaneData {
provider: PullRequestProviderMetadata;
number: number;
repoOwner?: string;
repoName?: string;
title: string;
state: PrState;
url: string;
@@ -69,7 +100,10 @@ export function mapPrPaneData(
const timelineMatchesStatus = timeline?.prNumber === number;
return {
provider: GITHUB_PROVIDER,
number,
repoOwner: status.repoOwner,
repoName: status.repoName,
title: status.title,
state: derivePrState(status),
url: status.url,
@@ -137,11 +171,20 @@ function mapCheck(check: NonNullable<CheckoutPrStatus>["checks"][number]): PrPan
return [
{
provider: "github",
name: check.name,
workflow: check.workflow,
status: mapCheckStatus(check.status),
duration: check.duration,
url: check.url,
...(check.workflow ? { workflow: check.workflow } : {}),
...(check.duration ? { duration: check.duration } : {}),
...(check.checkRunId !== undefined || check.workflowRunId !== undefined
? {
github: {
...(check.checkRunId !== undefined ? { checkRunId: check.checkRunId } : {}),
...(check.workflowRunId !== undefined ? { workflowRunId: check.workflowRunId } : {}),
},
}
: {}),
},
];
}
@@ -168,12 +211,18 @@ function mapActivity(item: PullRequestTimelineItem, nowMs: number): PrPaneActivi
}
return [
{
id: item.id,
provider: "github",
kind: "comment",
author: item.author,
authorUrl: item.authorUrl,
avatarColor: deriveAvatarColor(item.author),
avatarUrl: item.avatarUrl,
body: item.body,
age: formatAge(item.createdAt, nowMs),
url: item.url,
reviewId: item.reviewId,
location: item.location,
},
];
}
@@ -184,9 +233,13 @@ function mapActivity(item: PullRequestTimelineItem, nowMs: number): PrPaneActivi
return [
{
id: item.id,
provider: "github",
kind: "review",
author: item.author,
authorUrl: item.authorUrl,
avatarColor: deriveAvatarColor(item.author),
avatarUrl: item.avatarUrl,
reviewState: item.reviewState,
body: item.body,
age: formatAge(item.createdAt, nowMs),
@@ -0,0 +1,5 @@
export { PullRequestPane } from "./pane";
export { PullRequestTabIcon } from "./tab-icon";
export { formatPrTabLabel } from "./tab-label";
export { prPaneTimelineQueryKind } from "./query-keys";
export { usePrPaneData } from "./use-data";
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,13 @@
export const prPaneTimelineQueryKind = "prPaneTimeline";
export function prPaneTimelineQueryKey({
serverId,
cwd,
prNumber,
}: {
serverId: string;
cwd: string;
prNumber: number | null;
}) {
return [prPaneTimelineQueryKind, serverId, cwd, prNumber] as const;
}
@@ -0,0 +1,5 @@
import { GitHubIcon } from "@/components/icons/github-icon";
export function PullRequestTabIcon({ size, color }: { size: number; color: string }) {
return <GitHubIcon size={size} color={color} />;
}
@@ -0,0 +1,12 @@
import { describe, expect, it } from "vitest";
import { formatPrTabLabel } from "./tab-label";
describe("formatPrTabLabel", () => {
it("returns #<number> when a pull request number is present", () => {
expect(formatPrTabLabel(42)).toBe("#42");
});
it("returns #— fallback when the pull request number is null", () => {
expect(formatPrTabLabel(null)).toBe("#—");
});
});
@@ -0,0 +1,3 @@
export function formatPrTabLabel(prNumber: number | null): string {
return prNumber === null ? "#—" : `#${prNumber}`;
}
@@ -0,0 +1,122 @@
import { describe, expect, it } from "vitest";
import { buildPrTimeline } from "./timeline";
import type { PrPaneActivity } from "./data";
function activity(overrides: Partial<PrPaneActivity> & { id: string }): PrPaneActivity {
return {
provider: "github",
kind: "comment",
author: "alice",
avatarColor: "#8b5cf6",
body: "body",
age: "1h ago",
url: "https://github.com/acme/app/pull/1#comment",
...overrides,
};
}
describe("buildPrTimeline", () => {
it("keeps standalone comments and reviews as single entries in order", () => {
const comment = activity({ id: "c1" });
const review = activity({ id: "r1", kind: "review", reviewState: "approved" });
expect(buildPrTimeline([comment, review])).toEqual([
{ kind: "single", id: "c1", activity: comment },
{ kind: "single", id: "r1", activity: review },
]);
});
it("groups comments sharing a threadId into one thread at the first comment's position", () => {
const before = activity({ id: "c1" });
const root = activity({
id: "t1-root",
location: { path: "src/a.ts", line: 12, threadId: "PRRT_1", isResolved: false },
});
const between = activity({ id: "c2" });
const reply = activity({
id: "t1-reply",
author: "bob",
location: { path: "src/a.ts", line: 12, threadId: "PRRT_1", isResolved: false },
});
expect(buildPrTimeline([before, root, between, reply])).toEqual([
{ kind: "single", id: "c1", activity: before },
{
kind: "thread",
id: "thread:PRRT_1",
location: { path: "src/a.ts", line: 12, threadId: "PRRT_1", isResolved: false },
comments: [root, reply],
},
{ kind: "single", id: "c2", activity: between },
]);
});
it("keeps located comments without a threadId as single entries", () => {
const located = activity({ id: "c1", location: { path: "src/a.ts", line: 3 } });
expect(buildPrTimeline([located])).toEqual([{ kind: "single", id: "c1", activity: located }]);
});
it("separates distinct threads", () => {
const a = activity({ id: "a", location: { path: "x.ts", threadId: "T1" } });
const b = activity({ id: "b", location: { path: "y.ts", threadId: "T2" } });
const a2 = activity({ id: "a2", location: { path: "x.ts", threadId: "T1" } });
const entries = buildPrTimeline([a, b, a2]);
expect(entries.map((entry) => entry.id)).toEqual(["thread:T1", "thread:T2"]);
expect(entries[0]).toMatchObject({ comments: [a, a2] });
expect(entries[1]).toMatchObject({ comments: [b] });
});
it("nests threads under the review they were submitted with", () => {
const review = activity({
id: "R1",
kind: "review",
reviewState: "changes_requested",
body: "Please fix these.",
});
const thread = activity({
id: "t1",
reviewId: "R1",
location: { path: "src/a.ts", line: 4, threadId: "T1" },
});
const reply = activity({
id: "t1-reply",
reviewId: "R2-later",
location: { path: "src/a.ts", line: 4, threadId: "T1" },
});
const unrelated = activity({
id: "t2",
reviewId: "R-unknown",
location: { path: "src/b.ts", threadId: "T2" },
});
expect(buildPrTimeline([review, thread, reply, unrelated])).toEqual([
{
kind: "review",
id: "R1",
review,
threads: [
{
kind: "thread",
id: "thread:T1",
location: { path: "src/a.ts", line: 4, threadId: "T1" },
comments: [thread, reply],
},
],
},
{
kind: "thread",
id: "thread:T2",
location: { path: "src/b.ts", threadId: "T2" },
comments: [unrelated],
},
]);
});
it("keeps reviews without threads as single entries", () => {
const review = activity({ id: "R1", kind: "review", reviewState: "approved" });
expect(buildPrTimeline([review])).toEqual([{ kind: "single", id: "R1", activity: review }]);
});
});
@@ -0,0 +1,105 @@
import type { PrPaneActivity } from "./data";
export type PrThreadLocation = NonNullable<PrPaneActivity["location"]>;
export interface PrThreadEntry {
kind: "thread";
id: string;
location: PrThreadLocation;
comments: PrPaneActivity[];
}
export interface PrReviewEntry {
kind: "review";
id: string;
review: PrPaneActivity;
threads: PrThreadEntry[];
}
export type PrTimelineEntry =
| { kind: "single"; id: string; activity: PrPaneActivity }
| PrThreadEntry
| PrReviewEntry;
/**
* Builds the GitHub-style nested timeline:
* - comments sharing a `location.threadId` collapse into one thread entry
* (root comment + replies), placed at the first comment's position;
* - threads whose root comment carries a `reviewId` nest under that review
* (GitHub's explicit signal: PullRequestReviewComment.pullRequestReview);
* - everything else stays a standalone entry in original order.
*/
export function buildPrTimeline(activities: readonly PrPaneActivity[]): PrTimelineEntry[] {
const entries = groupThreads(activities);
return nestThreadsUnderReviews(entries);
}
function groupThreads(activities: readonly PrPaneActivity[]): PrTimelineEntry[] {
const entries: PrTimelineEntry[] = [];
const threadsById = new Map<string, PrThreadEntry>();
for (const activity of activities) {
const threadId = activity.location?.threadId;
if (!activity.location || !threadId) {
entries.push({ kind: "single", id: activity.id, activity });
continue;
}
const existing = threadsById.get(threadId);
if (existing) {
existing.comments.push(activity);
continue;
}
const thread: PrThreadEntry = {
kind: "thread",
id: `thread:${threadId}`,
location: activity.location,
comments: [activity],
};
threadsById.set(threadId, thread);
entries.push(thread);
}
return entries;
}
function nestThreadsUnderReviews(entries: PrTimelineEntry[]): PrTimelineEntry[] {
const reviewIds = new Set(
entries.flatMap((entry) =>
entry.kind === "single" && entry.activity.kind === "review" ? [entry.activity.id] : [],
),
);
const threadsByReviewId = new Map<string, PrThreadEntry[]>();
for (const entry of entries) {
if (entry.kind !== "thread") {
continue;
}
const reviewId = entry.comments[0]?.reviewId;
if (!reviewId || !reviewIds.has(reviewId)) {
continue;
}
const threads = threadsByReviewId.get(reviewId) ?? [];
threads.push(entry);
threadsByReviewId.set(reviewId, threads);
}
if (threadsByReviewId.size === 0) {
return entries;
}
const nestedThreads = new Set([...threadsByReviewId.values()].flat());
return entries.flatMap((entry): PrTimelineEntry[] => {
if (entry.kind === "thread") {
return nestedThreads.has(entry) ? [] : [entry];
}
if (entry.kind === "single" && entry.activity.kind === "review") {
const threads = threadsByReviewId.get(entry.activity.id);
if (threads) {
return [{ kind: "review" as const, id: entry.id, review: entry.activity, threads }];
}
}
return [entry];
});
}
@@ -0,0 +1,512 @@
import { describe, expect, it } from "vitest";
import type {
CheckoutPrStatusResponse,
PullRequestTimelineResponse,
} from "@getpaseo/protocol/messages";
import {
createInMemoryUnsupportedTimelineRegistry,
extractPrRepoIdentity,
fetchPrPaneTimelinePage,
isUnsupportedTimelineError,
type PrPaneTimelineClient,
selectPrPaneState,
shouldFetchTimelineFrom,
unsupportedTimelineKey,
} from "./use-data";
type CheckoutPrStatus = NonNullable<CheckoutPrStatusResponse["payload"]["status"]>;
type PullRequestTimelinePayload = PullRequestTimelineResponse["payload"];
type PullRequestTimelineInput = Parameters<PrPaneTimelineClient["pullRequestTimeline"]>[0];
const githubStatus: CheckoutPrStatus["github"] = {
mergeStateStatus: null,
autoMergeRequest: null,
viewerCanEnableAutoMerge: false,
viewerCanDisableAutoMerge: false,
viewerCanMergeAsAdmin: false,
viewerCanUpdateBranch: false,
repository: {
autoMergeAllowed: false,
mergeCommitAllowed: false,
squashMergeAllowed: false,
rebaseMergeAllowed: false,
viewerDefaultMergeMethod: null,
},
isMergeQueueEnabled: false,
isInMergeQueue: false,
};
function prStatus(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,
mergeable: "UNKNOWN",
checks: [],
reviewDecision: null,
repoOwner: "getpaseo",
repoName: "paseo",
github: githubStatus,
...overrides,
};
}
function timelinePayload(
overrides: Partial<PullRequestTimelinePayload> = {},
): PullRequestTimelinePayload {
return {
cwd: "/repo",
prNumber: 42,
items: [],
truncated: false,
error: null,
requestId: "timeline-1",
githubFeaturesEnabled: true,
...overrides,
};
}
interface FakeTimelineClient extends PrPaneTimelineClient {
calls: PullRequestTimelineInput[];
}
function createTimelineClient(
respond: (input: PullRequestTimelineInput) => Promise<PullRequestTimelinePayload>,
): FakeTimelineClient {
const calls: PullRequestTimelineInput[] = [];
return {
calls,
pullRequestTimeline: async (input) => {
calls.push(input);
return respond(input);
},
};
}
function unsupportedTimelineError(): Error {
const error = new Error(
"Unknown request, try upgrading the daemon 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;
}
const baseSelectInput = {
status: null as CheckoutPrStatus | null,
statusPayloadError: null,
statusError: null as Error | null,
statusIsLoading: false,
statusIsFetching: false,
githubFeaturesEnabled: true,
timelineEnabled: true,
shouldFetchTimeline: true,
timelinePayload: undefined as PullRequestTimelinePayload | undefined,
timelineError: null as Error | null,
timelineIsLoading: false,
timelineIsFetching: false,
};
describe("extractPrRepoIdentity", () => {
it("reads the PR number, owner, and name from a status payload", () => {
expect(extractPrRepoIdentity(prStatus())).toEqual({
prNumber: 42,
repoOwner: "getpaseo",
repoName: "paseo",
});
});
it("returns null fields when the status is missing", () => {
expect(extractPrRepoIdentity(null)).toEqual({
prNumber: null,
repoOwner: null,
repoName: null,
});
});
it("treats empty owner or name strings as missing", () => {
expect(extractPrRepoIdentity(prStatus({ repoOwner: "", repoName: "" }))).toMatchObject({
repoOwner: null,
repoName: null,
});
});
it("reports no PR number when the status omits it", () => {
const status = prStatus();
delete status.number;
expect(extractPrRepoIdentity(status).prNumber).toBeNull();
});
});
describe("shouldFetchTimelineFrom", () => {
const baseGate = {
hasClient: true,
isConnected: true,
timelineEnabled: true,
githubFeaturesEnabled: true,
cwd: "/repo",
identity: { prNumber: 42, repoOwner: "getpaseo", repoName: "paseo" },
timelineUnsupported: false,
};
it("fetches when every gate is open", () => {
expect(shouldFetchTimelineFrom(baseGate)).toBe(true);
});
it.each([
{ name: "no client", overrides: { hasClient: false } },
{ name: "disconnected", overrides: { isConnected: false } },
{ name: "timeline disabled", overrides: { timelineEnabled: false } },
{ name: "github features off", overrides: { githubFeaturesEnabled: false } },
{ name: "empty cwd", overrides: { cwd: "" } },
{
name: "missing PR number",
overrides: { identity: { ...baseGate.identity, prNumber: null } },
},
{ name: "missing owner", overrides: { identity: { ...baseGate.identity, repoOwner: null } } },
{ name: "missing name", overrides: { identity: { ...baseGate.identity, repoName: null } } },
{ name: "tuple marked unsupported", overrides: { timelineUnsupported: true } },
])("skips fetching when $name", ({ overrides }) => {
expect(shouldFetchTimelineFrom({ ...baseGate, ...overrides })).toBe(false);
});
});
describe("createInMemoryUnsupportedTimelineRegistry", () => {
it("remembers added keys and answers has() against them", () => {
const registry = createInMemoryUnsupportedTimelineRegistry();
const key = unsupportedTimelineKey({ serverId: "host", cwd: "/repo", prNumber: 99 });
expect(registry.has(key)).toBe(false);
registry.add(key);
expect(registry.has(key)).toBe(true);
});
it("uses serverId, cwd, and prNumber to scope each key", () => {
expect(unsupportedTimelineKey({ serverId: "host-a", cwd: "/repo", prNumber: 1 })).not.toEqual(
unsupportedTimelineKey({ serverId: "host-b", cwd: "/repo", prNumber: 1 }),
);
expect(unsupportedTimelineKey({ serverId: "host", cwd: "/repo-a", prNumber: 1 })).not.toEqual(
unsupportedTimelineKey({ serverId: "host", cwd: "/repo-b", prNumber: 1 }),
);
expect(unsupportedTimelineKey({ serverId: "host", cwd: "/repo", prNumber: 1 })).not.toEqual(
unsupportedTimelineKey({ serverId: "host", cwd: "/repo", prNumber: 2 }),
);
});
});
describe("fetchPrPaneTimelinePage", () => {
it("forwards the cwd, PR number, owner, and name to the daemon client", async () => {
const client = createTimelineClient(async () => timelinePayload());
const registry = createInMemoryUnsupportedTimelineRegistry();
await fetchPrPaneTimelinePage({
client,
registry,
serverId: "host",
cwd: "/repo",
prNumber: 42,
repoOwner: "getpaseo",
repoName: "paseo",
});
expect(client.calls).toEqual([
{ cwd: "/repo", prNumber: 42, repoOwner: "getpaseo", repoName: "paseo" },
]);
});
it("returns the daemon's timeline payload on success", async () => {
const payload = timelinePayload({
items: [
{
id: "comment-1",
kind: "comment",
author: "octocat",
body: "Looks good",
createdAt: Date.now(),
url: "https://github.com/getpaseo/paseo/pull/42#c1",
},
],
});
const client = createTimelineClient(async () => payload);
const registry = createInMemoryUnsupportedTimelineRegistry();
const result = await fetchPrPaneTimelinePage({
client,
registry,
serverId: "host",
cwd: "/repo",
prNumber: 42,
repoOwner: "getpaseo",
repoName: "paseo",
});
expect(result).toBe(payload);
});
it("records the tuple in the registry when the daemon rejects the request as unsupported", async () => {
const error = unsupportedTimelineError();
const client = createTimelineClient(async () => {
throw error;
});
const registry = createInMemoryUnsupportedTimelineRegistry();
await expect(
fetchPrPaneTimelinePage({
client,
registry,
serverId: "host",
cwd: "/repo",
prNumber: 99,
repoOwner: "getpaseo",
repoName: "paseo",
}),
).rejects.toBe(error);
expect(
registry.has(unsupportedTimelineKey({ serverId: "host", cwd: "/repo", prNumber: 99 })),
).toBe(true);
});
it("leaves the registry alone when the failure is a generic error", async () => {
const error = new Error("network down");
const client = createTimelineClient(async () => {
throw error;
});
const registry = createInMemoryUnsupportedTimelineRegistry();
await expect(
fetchPrPaneTimelinePage({
client,
registry,
serverId: "host",
cwd: "/repo",
prNumber: 99,
repoOwner: "getpaseo",
repoName: "paseo",
}),
).rejects.toBe(error);
expect(
registry.has(unsupportedTimelineKey({ serverId: "host", cwd: "/repo", prNumber: 99 })),
).toBe(false);
});
it("scopes recorded tuples per serverId+cwd+prNumber so other PRs can still be tried", async () => {
const client = createTimelineClient(async (input) => {
if (input.cwd === "/repo-a") {
throw unsupportedTimelineError();
}
return timelinePayload({ cwd: input.cwd, prNumber: input.prNumber });
});
const registry = createInMemoryUnsupportedTimelineRegistry();
await expect(
fetchPrPaneTimelinePage({
client,
registry,
serverId: "host",
cwd: "/repo-a",
prNumber: 1,
repoOwner: "getpaseo",
repoName: "paseo",
}),
).rejects.toThrow();
const result = await fetchPrPaneTimelinePage({
client,
registry,
serverId: "host",
cwd: "/repo-b",
prNumber: 2,
repoOwner: "getpaseo",
repoName: "paseo",
});
expect(result.prNumber).toBe(2);
expect(
registry.has(unsupportedTimelineKey({ serverId: "host", cwd: "/repo-a", prNumber: 1 })),
).toBe(true);
expect(
registry.has(unsupportedTimelineKey({ serverId: "host", cwd: "/repo-b", prNumber: 2 })),
).toBe(false);
});
});
describe("isUnsupportedTimelineError", () => {
it("matches the daemon's unknown-schema error for pull_request_timeline_request", () => {
expect(isUnsupportedTimelineError(unsupportedTimelineError())).toBe(true);
});
it("rejects unrelated errors", () => {
expect(isUnsupportedTimelineError(new Error("network down"))).toBe(false);
expect(isUnsupportedTimelineError("not an error")).toBe(false);
});
it("rejects daemon errors for a different request type", () => {
const error = new Error("nope") as Error & { code: string; requestType: string };
error.name = "DaemonRpcError";
error.code = "unknown_schema";
error.requestType = "something_else_request";
expect(isUnsupportedTimelineError(error)).toBe(false);
});
});
describe("selectPrPaneState", () => {
it("returns null data and no PR number when the status payload has no PR", () => {
const state = selectPrPaneState({ ...baseSelectInput, status: null });
expect(state.data).toBeNull();
expect(state.prNumber).toBeNull();
});
it("composes data from status and timeline when both are present", () => {
const state = selectPrPaneState({
...baseSelectInput,
status: prStatus(),
timelinePayload: timelinePayload(),
});
expect(state.data?.number).toBe(42);
expect(state.data?.title).toBe("Wire real PR pane data");
expect(state.data?.activity).toEqual([]);
});
it("returns null data when the consumer disabled timeline rendering", () => {
const state = selectPrPaneState({
...baseSelectInput,
status: prStatus(),
timelineEnabled: false,
timelinePayload: timelinePayload(),
});
expect(state.data).toBeNull();
expect(state.prNumber).toBe(42);
});
it("drops timeline activity that belongs to a different PR number", () => {
const state = selectPrPaneState({
...baseSelectInput,
status: prStatus(),
timelinePayload: timelinePayload({
prNumber: 41,
items: [
{
id: "comment-1",
kind: "comment",
author: "octocat",
body: "Belongs to another PR",
createdAt: Date.now(),
url: "https://github.com/getpaseo/paseo/pull/41#c1",
},
],
}),
});
expect(state.data?.activity).toEqual([]);
});
it("reports loading while the status query is loading", () => {
expect(selectPrPaneState({ ...baseSelectInput, statusIsLoading: true }).isLoading).toBe(true);
});
it("reports loading while the timeline is still pending its first response", () => {
const state = selectPrPaneState({
...baseSelectInput,
status: prStatus(),
shouldFetchTimeline: true,
timelineIsLoading: true,
});
expect(state.isLoading).toBe(true);
});
it("does not report loading on the timeline when no fetch was scheduled", () => {
const state = selectPrPaneState({
...baseSelectInput,
status: prStatus(),
shouldFetchTimeline: false,
timelineIsLoading: true,
});
expect(state.isLoading).toBe(false);
});
it("reports refreshing during background revalidation of either query", () => {
expect(
selectPrPaneState({
...baseSelectInput,
statusIsFetching: true,
}).isRefreshing,
).toBe(true);
expect(
selectPrPaneState({
...baseSelectInput,
timelineIsFetching: true,
}).isRefreshing,
).toBe(true);
});
it("surfaces status payload errors with the daemon's message", () => {
const state = selectPrPaneState({
...baseSelectInput,
status: prStatus(),
statusPayloadError: { code: "UNKNOWN", message: "bad daemon payload" },
});
expect(state.error?.message).toContain("bad daemon payload");
});
it("falls back to a generic message when the status payload error is empty", () => {
const state = selectPrPaneState({
...baseSelectInput,
statusPayloadError: { code: "UNKNOWN", message: "" },
});
expect(state.error?.message).toBe("Unable to load pull request status");
});
it("prefers the status thrown error over the timeline error", () => {
const statusError = new Error("status broke");
const state = selectPrPaneState({
...baseSelectInput,
statusError,
timelineError: new Error("timeline broke"),
});
expect(state.error).toBe(statusError);
});
it("suppresses unsupported timeline errors", () => {
const state = selectPrPaneState({
...baseSelectInput,
status: prStatus(),
timelineError: unsupportedTimelineError(),
});
expect(state.error).toBeNull();
});
it("surfaces timeline payload errors when nothing earlier failed", () => {
const state = selectPrPaneState({
...baseSelectInput,
status: prStatus(),
timelinePayload: timelinePayload({ error: { kind: "unknown", message: "rate limited" } }),
});
expect(state.error?.message).toBe("rate limited");
});
it("passes the daemon's githubFeaturesEnabled flag through", () => {
expect(
selectPrPaneState({
...baseSelectInput,
status: prStatus(),
githubFeaturesEnabled: false,
}).githubFeaturesEnabled,
).toBe(false);
});
});
@@ -0,0 +1,323 @@
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import type {
CheckoutPrStatusResponse,
PullRequestTimelineResponse,
} from "@getpaseo/protocol/messages";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { useCheckoutPrStatusQuery } from "@/git/use-pr-status-query";
import { i18n } from "@/i18n/i18next";
import { mapPrPaneData, type PrPaneData } from "./data";
import { prPaneTimelineQueryKey } from "./query-keys";
type CheckoutPrStatus = CheckoutPrStatusResponse["payload"]["status"];
type CheckoutPrStatusPayloadError = CheckoutPrStatusResponse["payload"]["error"];
type PullRequestTimeline = PullRequestTimelineResponse["payload"];
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 interface PrRepoIdentity {
prNumber: number | null;
repoOwner: string | null;
repoName: string | null;
}
export function extractPrRepoIdentity(status: CheckoutPrStatus | null): PrRepoIdentity {
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;
return { prNumber, repoOwner, repoName };
}
export interface ShouldFetchTimelineArgs {
hasClient: boolean;
isConnected: boolean;
timelineEnabled: boolean;
githubFeaturesEnabled: boolean;
cwd: string;
identity: PrRepoIdentity;
timelineUnsupported: boolean;
}
export function shouldFetchTimelineFrom({
hasClient,
isConnected,
timelineEnabled,
githubFeaturesEnabled,
cwd,
identity,
timelineUnsupported,
}: ShouldFetchTimelineArgs): boolean {
return (
hasClient &&
isConnected &&
timelineEnabled &&
githubFeaturesEnabled &&
!!cwd &&
identity.prNumber !== null &&
identity.repoOwner !== null &&
identity.repoName !== null &&
!timelineUnsupported
);
}
export type PrPaneTimelineClient = Pick<DaemonClient, "pullRequestTimeline">;
export interface UnsupportedTimelineRegistry {
has(key: string): boolean;
add(key: string): void;
}
export function unsupportedTimelineKey({
serverId,
cwd,
prNumber,
}: {
serverId: string;
cwd: string;
prNumber: number;
}): string {
return `${serverId}\0${cwd}\0${prNumber}`;
}
export function createInMemoryUnsupportedTimelineRegistry(): UnsupportedTimelineRegistry {
const keys = new Set<string>();
return {
has: (key) => keys.has(key),
add: (key) => {
keys.add(key);
},
};
}
const defaultUnsupportedTimelineRegistry = createInMemoryUnsupportedTimelineRegistry();
export interface FetchPrPaneTimelinePageInput {
client: PrPaneTimelineClient;
registry: UnsupportedTimelineRegistry;
serverId: string;
cwd: string;
prNumber: number;
repoOwner: string;
repoName: string;
}
export async function fetchPrPaneTimelinePage(
input: FetchPrPaneTimelinePageInput,
): Promise<PullRequestTimeline> {
try {
return await input.client.pullRequestTimeline({
cwd: input.cwd,
prNumber: input.prNumber,
repoOwner: input.repoOwner,
repoName: input.repoName,
});
} catch (error) {
if (isUnsupportedTimelineError(error)) {
input.registry.add(
unsupportedTimelineKey({
serverId: input.serverId,
cwd: input.cwd,
prNumber: input.prNumber,
}),
);
}
throw error;
}
}
export interface SelectPrPaneStateInput {
status: CheckoutPrStatus | null;
statusPayloadError: CheckoutPrStatusPayloadError;
statusError: Error | null;
statusIsLoading: boolean;
statusIsFetching: boolean;
githubFeaturesEnabled: boolean;
timelineEnabled: boolean;
shouldFetchTimeline: boolean;
timelinePayload: PullRequestTimeline | undefined;
timelineError: Error | null;
timelineIsLoading: boolean;
timelineIsFetching: boolean;
statusLoadFailedLabel?: string;
activityLoadFailedLabel?: string;
}
export function selectPrPaneState(input: SelectPrPaneStateInput): UsePrPaneDataResult {
const identity = extractPrRepoIdentity(input.status);
const data =
identity.prNumber === null || !input.timelineEnabled
? null
: mapPrPaneData(input.status, input.timelinePayload);
const statusRefreshing = input.statusIsFetching && !input.statusIsLoading;
const timelineRefreshing = input.timelineIsFetching && !input.timelineIsLoading;
return {
data,
prNumber: identity.prNumber,
isLoading:
input.statusIsLoading ||
(input.shouldFetchTimeline && input.timelineIsLoading && input.timelinePayload === undefined),
isRefreshing: statusRefreshing || timelineRefreshing,
error: firstNonSuppressedError({
statusPayloadError: input.statusPayloadError,
statusError: input.statusError,
timelineError: input.timelineError,
timelinePayloadError: input.timelinePayload?.error ?? null,
statusLoadFailedLabel:
input.statusLoadFailedLabel ?? i18n.t("workspace.git.pr.errors.statusLoadFailed"),
activityLoadFailedLabel:
input.activityLoadFailedLabel ?? i18n.t("workspace.git.pr.errors.activityLoadFailed"),
}),
githubFeaturesEnabled: input.githubFeaturesEnabled,
};
}
export function usePrPaneData({
serverId,
cwd,
enabled = true,
timelineEnabled = enabled,
}: UsePrPaneDataOptions): UsePrPaneDataResult {
const { t } = useTranslation();
const daemonClient = useHostRuntimeClient(serverId);
const isConnected = useHostRuntimeIsConnected(serverId);
const checkoutPrStatus = useCheckoutPrStatusQuery({ serverId, cwd, enabled });
const status = checkoutPrStatus.status;
const identity = extractPrRepoIdentity(status);
const githubFeaturesEnabled = checkoutPrStatus.githubFeaturesEnabled;
const registry = defaultUnsupportedTimelineRegistry;
const unsupportedKey =
identity.prNumber === null
? null
: unsupportedTimelineKey({ serverId, cwd, prNumber: identity.prNumber });
const timelineUnsupported = unsupportedKey ? registry.has(unsupportedKey) : false;
const shouldFetchTimeline = shouldFetchTimelineFrom({
hasClient: !!daemonClient,
isConnected,
timelineEnabled,
githubFeaturesEnabled,
cwd,
identity,
timelineUnsupported,
});
const timelineQuery = useQuery<PullRequestTimeline>({
queryKey: useMemo(
() => prPaneTimelineQueryKey({ serverId, cwd, prNumber: identity.prNumber }),
[serverId, cwd, identity.prNumber],
),
queryFn: async () => {
if (
!daemonClient ||
identity.prNumber === null ||
identity.repoOwner === null ||
identity.repoName === null
) {
throw new Error(t("common.errors.daemonClientUnavailable"));
}
return fetchPrPaneTimelinePage({
client: daemonClient,
registry,
serverId,
cwd,
prNumber: identity.prNumber,
repoOwner: identity.repoOwner,
repoName: identity.repoName,
});
},
enabled: shouldFetchTimeline,
staleTime: Infinity,
refetchOnMount: false,
refetchOnReconnect: false,
refetchOnWindowFocus: false,
placeholderData: keepPreviousData,
retry: (failureCount, error) => !isUnsupportedTimelineError(error) && failureCount < 3,
});
return selectPrPaneState({
status,
statusPayloadError: checkoutPrStatus.payloadError,
statusError: checkoutPrStatus.error,
statusIsLoading: checkoutPrStatus.isLoading,
statusIsFetching: checkoutPrStatus.isFetching,
githubFeaturesEnabled,
timelineEnabled,
shouldFetchTimeline,
timelinePayload: timelineQuery.data,
timelineError: timelineQuery.error,
timelineIsLoading: timelineQuery.isLoading,
timelineIsFetching: timelineQuery.isFetching,
statusLoadFailedLabel: t("workspace.git.pr.errors.statusLoadFailed"),
activityLoadFailedLabel: t("workspace.git.pr.errors.activityLoadFailed"),
});
}
function firstNonSuppressedError({
statusPayloadError,
statusError,
timelineError,
timelinePayloadError,
statusLoadFailedLabel,
activityLoadFailedLabel,
}: {
statusPayloadError: CheckoutPrStatusPayloadError;
statusError: Error | null;
timelineError: Error | null;
timelinePayloadError: PullRequestTimeline["error"];
statusLoadFailedLabel: string;
activityLoadFailedLabel: string;
}): Error | null {
if (statusPayloadError) {
return new Error(statusPayloadError.message || statusLoadFailedLabel);
}
if (statusError) {
return statusError;
}
if (timelineError && !isUnsupportedTimelineError(timelineError)) {
return timelineError;
}
if (timelinePayloadError) {
return new Error(timelinePayloadError.message || activityLoadFailedLabel);
}
return null;
}
export 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;
}
+1 -1
View File
@@ -5,8 +5,8 @@ import {
checkoutPrStatusQueryKey,
checkoutStatusQueryKey,
invalidateCheckoutGitQueriesForClient,
prPaneTimelineQueryKey,
} from "@/git/query-keys";
import { prPaneTimelineQueryKey } from "@/git/pull-request-panel/query-keys";
describe("checkout query keys", () => {
const serverId = "server-1";
+2 -13
View File
@@ -1,4 +1,5 @@
import type { Query, QueryClient } from "@tanstack/react-query";
import { prPaneTimelineQueryKind } from "./pull-request-panel/query-keys";
interface CheckoutQueryIdentity {
serverId: string;
@@ -25,18 +26,6 @@ export function checkoutPrStatusQueryKey(serverId: string, cwd: string) {
return ["checkoutPrStatus", serverId, cwd] as const;
}
export function prPaneTimelineQueryKey({
serverId,
cwd,
prNumber,
}: {
serverId: string;
cwd: string;
prNumber: number | null;
}) {
return ["prPaneTimeline", serverId, cwd, prNumber] as const;
}
export async function invalidateCheckoutGitQueriesForClient(
queryClient: QueryClient,
identity: CheckoutQueryIdentity,
@@ -52,7 +41,7 @@ export async function invalidateCheckoutGitQueriesForClient(
predicate: checkoutQueryPredicate("checkoutPrStatus", identity),
}),
queryClient.invalidateQueries({
predicate: checkoutQueryPredicate("prPaneTimeline", identity),
predicate: checkoutQueryPredicate(prPaneTimelineQueryKind, identity),
}),
]);
}