Files
paseo/packages/app/src/git/pull-request-panel/use-data.ts
T
nllptrxandMohamed Boudra a8ebd390fa feat(forge): pluggable forge abstraction + GitLab and Gitea/Forgejo/Codeberg (#1913)
* 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>
2026-07-17 15:03:26 +08:00

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;
}