mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-23 17:52:49 +00:00
* refactor(forge): forge-neutral foundation (GitHub-only) Decouple git-hosting from GitHub behind a neutral abstraction (issue #1616), GitHub-only for now; existing GitHub behaviour is unchanged. - Forge manifest, neutral ForgeService contract, forge registry + resolver, and a client forge-module registry. - GitHub code renamed to the neutral shape; PR/Issue attachment wording preserved. - forge.search.response enums parse tolerantly (unknown kind/auth state degrade instead of breaking the client). - createPullRequest reports typed CLI/auth errors instead of a generic message. - forge-resolver host/remote caches are LRU-bounded. - Forge host trust is explicit: only a known cloud host or a CLI-authenticated host is ever talked to; an unauthenticated GitHub Enterprise host fails resolution instead of routing to github.com. - Docs: forge-providers guide, glossary and i18n forge-copy conventions, architecture and rpc-namespacing terminology. - Vitest React Native mocks (unistyles, svg, linking, lucide) consolidated into shared aliased test-stubs. * feat(forge): GitLab adapter, forge-aware UI, pipelines and approvals GitLab adapter over the glab CLI on the neutral contracts: MR status, forge-aware UI, pipeline tree, and N-of-M approvals. - threadIsResolved is part of the neutral timeline item. - Pipeline load failures show an error instead of an empty section. - Manual pipeline jobs render as pending. - Fork/detached MR head pipelines are fetched by MR iid (glab ci get --merge-request). * feat(forge): Gitea family adapter (Gitea, Forgejo, Codeberg) One adapter over the tea CLI serving Gitea, Forgejo, and Codeberg on the neutral contracts. - CI status aggregates commit statuses and Actions runs together. - Gitea's terminal "warning" state maps to failure on server and client. - Gitea Actions check details are reachable from the PR pane by workflowRunId. * refactor(forge): localize compatibility handling * test(forge): expect normalized GitLab facts --------- Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
333 lines
9.8 KiB
TypeScript
333 lines
9.8 KiB
TypeScript
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 type { Forge } from "@/git/forge";
|
|
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;
|
|
forge: Forge;
|
|
prNumber: number | null;
|
|
isLoading: boolean;
|
|
activityLoading: 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;
|
|
forge?: Forge;
|
|
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, undefined, input.forge ?? "github");
|
|
const statusRefreshing = input.statusIsFetching && !input.statusIsLoading;
|
|
const timelineRefreshing = input.timelineIsFetching && !input.timelineIsLoading;
|
|
const timelinePending =
|
|
input.shouldFetchTimeline && input.timelineIsLoading && input.timelinePayload === undefined;
|
|
|
|
return {
|
|
data,
|
|
forge: input.forge ?? "github",
|
|
prNumber: identity.prNumber,
|
|
isLoading: input.statusIsLoading || timelinePending,
|
|
activityLoading: timelinePending,
|
|
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,
|
|
// Refetch on mount only after explicit invalidation (reconnect, or a pushed PR status
|
|
// change) — see useCheckoutStatusQuery for the rationale.
|
|
refetchOnMount: true,
|
|
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,
|
|
forge: checkoutPrStatus.forge,
|
|
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;
|
|
}
|