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:
@@ -1,512 +0,0 @@
|
||||
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-pr-pane-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);
|
||||
});
|
||||
});
|
||||
@@ -1,323 +0,0 @@
|
||||
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 { mapPrPaneData, type PrPaneData } from "@/git/pr-pane-data";
|
||||
import { useCheckoutPrStatusQuery } from "@/git/use-pr-status-query";
|
||||
import { prPaneTimelineQueryKey } from "@/git/query-keys";
|
||||
import { i18n } from "@/i18n/i18next";
|
||||
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user