feat(app,server): add pull request pane to the workspace sidebar
Introduce a PR tab in the explorer sidebar that shows status checks, reviews, and the merged timeline for the current branch's pull request. Extend checkout PR status with number, draft flag, repo identity, and per-check workflow/duration; add a new pull_request_timeline request/response pair backed by a GraphQL query; enrich the workspace git snapshot to carry the fields through to the client; and extend the checkout-git-actions store so timeline queries are invalidated alongside status when mutations land.
This commit is contained in:
@@ -0,0 +1,183 @@
|
||||
import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import type {
|
||||
CheckoutPrStatusResponse,
|
||||
PullRequestTimelineResponse,
|
||||
} from "@server/shared/messages";
|
||||
import { mapPrPaneData, type PrPaneData } from "../utils/pr-pane-data";
|
||||
import { useCheckoutPrStatusQuery } from "./use-checkout-pr-status-query";
|
||||
|
||||
const PR_PANE_TIMELINE_STALE_TIME = 15_000;
|
||||
const PR_PANE_TIMELINE_REFETCH_INTERVAL = 30_000;
|
||||
|
||||
type CheckoutPrStatusPayloadError = CheckoutPrStatusResponse["payload"]["error"];
|
||||
type PullRequestTimeline = PullRequestTimelineResponse["payload"];
|
||||
|
||||
const unsupportedTimelineKeys = new Set<string>();
|
||||
|
||||
export interface UsePrPaneDataOptions {
|
||||
serverId: string;
|
||||
cwd: string;
|
||||
enabled?: boolean;
|
||||
timelineEnabled?: boolean;
|
||||
}
|
||||
|
||||
export interface UsePrPaneDataResult {
|
||||
data: PrPaneData | null;
|
||||
prNumber: number | null;
|
||||
isLoading: boolean;
|
||||
isRefreshing: boolean;
|
||||
error: Error | null;
|
||||
githubFeaturesEnabled: boolean;
|
||||
}
|
||||
|
||||
export function usePrPaneData({
|
||||
serverId,
|
||||
cwd,
|
||||
enabled = true,
|
||||
timelineEnabled = enabled,
|
||||
}: UsePrPaneDataOptions): UsePrPaneDataResult {
|
||||
const daemonClient = useHostRuntimeClient(serverId);
|
||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||
const checkoutPrStatus = useCheckoutPrStatusQuery({ serverId, cwd, enabled });
|
||||
const status = checkoutPrStatus.status;
|
||||
const prNumber = status?.number ?? null;
|
||||
const repoOwner = status?.repoOwner && status.repoOwner.length > 0 ? status.repoOwner : null;
|
||||
const repoName = status?.repoName && status.repoName.length > 0 ? status.repoName : null;
|
||||
const githubFeaturesEnabled = checkoutPrStatus.githubFeaturesEnabled !== false;
|
||||
const unsupportedKey =
|
||||
prNumber === null ? null : timelineUnsupportedKey({ serverId, cwd, prNumber });
|
||||
const timelineUnsupported = unsupportedKey ? unsupportedTimelineKeys.has(unsupportedKey) : false;
|
||||
const shouldFetchTimeline =
|
||||
!!daemonClient &&
|
||||
isConnected &&
|
||||
timelineEnabled &&
|
||||
githubFeaturesEnabled &&
|
||||
!!cwd &&
|
||||
prNumber !== null &&
|
||||
repoOwner !== null &&
|
||||
repoName !== null &&
|
||||
!timelineUnsupported;
|
||||
|
||||
const timelineQuery = useQuery<PullRequestTimeline, Error>({
|
||||
queryKey: prPaneTimelineQueryKey({ serverId, cwd, prNumber }),
|
||||
queryFn: async () => {
|
||||
if (!daemonClient || prNumber === null || repoOwner === null || repoName === null) {
|
||||
throw new Error("Daemon client not available");
|
||||
}
|
||||
|
||||
try {
|
||||
return await daemonClient.pullRequestTimeline({
|
||||
cwd,
|
||||
prNumber,
|
||||
repoOwner,
|
||||
repoName,
|
||||
});
|
||||
} catch (error) {
|
||||
if (unsupportedKey && isUnsupportedTimelineError(error)) {
|
||||
unsupportedTimelineKeys.add(unsupportedKey);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
enabled: shouldFetchTimeline,
|
||||
staleTime: PR_PANE_TIMELINE_STALE_TIME,
|
||||
refetchInterval: PR_PANE_TIMELINE_REFETCH_INTERVAL,
|
||||
placeholderData: keepPreviousData,
|
||||
retry: (failureCount, error) => !isUnsupportedTimelineError(error) && failureCount < 3,
|
||||
});
|
||||
|
||||
const data =
|
||||
prNumber === null || !timelineEnabled ? null : mapPrPaneData(status, timelineQuery.data);
|
||||
const statusRefreshing = checkoutPrStatus.isFetching && !checkoutPrStatus.isLoading;
|
||||
const timelineRefreshing = timelineQuery.isFetching && !timelineQuery.isLoading;
|
||||
|
||||
return {
|
||||
data,
|
||||
prNumber,
|
||||
isLoading:
|
||||
checkoutPrStatus.isLoading ||
|
||||
(shouldFetchTimeline && timelineQuery.isLoading && timelineQuery.data === undefined),
|
||||
isRefreshing: statusRefreshing || timelineRefreshing,
|
||||
error: firstNonSuppressedError({
|
||||
statusPayloadError: checkoutPrStatus.payloadError,
|
||||
statusError: checkoutPrStatus.error,
|
||||
timelineError: timelineQuery.error,
|
||||
timelinePayloadError: timelineQuery.data?.error ?? null,
|
||||
}),
|
||||
githubFeaturesEnabled,
|
||||
};
|
||||
}
|
||||
|
||||
export function prPaneTimelineQueryKey({
|
||||
serverId,
|
||||
cwd,
|
||||
prNumber,
|
||||
}: {
|
||||
serverId: string;
|
||||
cwd: string;
|
||||
prNumber: number | null;
|
||||
}) {
|
||||
return ["prPaneTimeline", serverId, cwd, prNumber] as const;
|
||||
}
|
||||
|
||||
function firstNonSuppressedError({
|
||||
statusPayloadError,
|
||||
statusError,
|
||||
timelineError,
|
||||
timelinePayloadError,
|
||||
}: {
|
||||
statusPayloadError: CheckoutPrStatusPayloadError;
|
||||
statusError: Error | null;
|
||||
timelineError: Error | null;
|
||||
timelinePayloadError: PullRequestTimeline["error"];
|
||||
}): Error | null {
|
||||
if (statusPayloadError) {
|
||||
return new Error(statusPayloadError.message || "Unable to load pull request status");
|
||||
}
|
||||
|
||||
if (statusError) {
|
||||
return statusError;
|
||||
}
|
||||
|
||||
if (timelineError && !isUnsupportedTimelineError(timelineError)) {
|
||||
return timelineError;
|
||||
}
|
||||
|
||||
if (timelinePayloadError) {
|
||||
return new Error(timelinePayloadError.message || "Unable to load pull request activity");
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function timelineUnsupportedKey({
|
||||
serverId,
|
||||
cwd,
|
||||
prNumber,
|
||||
}: {
|
||||
serverId: string;
|
||||
cwd: string;
|
||||
prNumber: number;
|
||||
}): string {
|
||||
return `${serverId}\0${cwd}\0${prNumber}`;
|
||||
}
|
||||
|
||||
function isUnsupportedTimelineError(error: unknown): boolean {
|
||||
if (!(error instanceof Error)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const name = error.name.toLowerCase();
|
||||
const rpcError = error as Error & { code?: unknown; requestType?: unknown };
|
||||
|
||||
if (
|
||||
name === "daemonrpcerror" &&
|
||||
rpcError.code === "unknown_schema" &&
|
||||
rpcError.requestType === "pull_request_timeline_request"
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
Reference in New Issue
Block a user