Files
paseo/packages/app/src/git/use-forge-search-query.ts
T
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

173 lines
4.9 KiB
TypeScript

import { useTranslation } from "react-i18next";
import {
ForgeSearchItemSchema,
GitHubSearchItemSchema,
type ForgeAuthState,
type ForgeSearchItem,
ForgeSearchKind,
type ForgeSearchResponse,
type GitHubSearchResponse,
} from "@getpaseo/protocol/messages";
import { i18n } from "@/i18n/i18next";
import { useFetchQuery } from "@/data/query";
import { parseForgeAuthState } from "@/git/forge";
export const FORGE_SEARCH_STALE_TIME = 30_000;
export interface ForgeSearchPayload {
items: ForgeSearchItem[];
authState: ForgeAuthState;
error: string | null;
requestId: string;
}
interface ForgeSearchOptions {
cwd: string;
query: string;
limit?: number;
kinds?: ForgeSearchKind[];
}
interface LegacyGitHubSearchOptions {
cwd: string;
query: string;
limit?: number;
kinds?: LegacyGitHubSearchKind[];
}
export interface ForgeSearchClient {
searchForge: (
options: ForgeSearchOptions,
requestId?: string,
) => Promise<ForgeSearchResponse["payload"]>;
searchGitHub?: (
options: LegacyGitHubSearchOptions,
requestId?: string,
) => Promise<GitHubSearchResponse["payload"]>;
}
type LegacyGitHubSearchKind = "github-issue" | "github-pr";
interface ForgeSearchQueryInput {
client: ForgeSearchClient | null;
serverId: string;
cwd: string;
query: string;
kinds?: ForgeSearchKind[];
enabled: boolean;
supportsForgeSearch?: boolean;
hostDisconnectedMessage?: string;
}
export function forgeSearchQueryKey(
serverId: string,
cwd: string,
query: string,
kinds?: ForgeSearchKind[],
transport: "forge" | "github" = "forge",
) {
const trimmedQuery = query.trim();
if (!kinds) {
return ["forge-search", serverId, cwd, transport, trimmedQuery] as const;
}
return [
"forge-search",
serverId,
cwd,
transport,
trimmedQuery,
[...kinds].sort().join(","),
] as const;
}
export function buildForgeSearchQueryOptions(input: ForgeSearchQueryInput) {
const query = input.query.trim();
const transport = input.supportsForgeSearch === true ? "forge" : "github";
return {
queryKey: forgeSearchQueryKey(input.serverId, input.cwd, query, input.kinds, transport),
queryFn: async (): Promise<ForgeSearchPayload> => {
if (!input.client) {
throw new Error(
input.hostDisconnectedMessage ?? i18n.t("workspace.terminal.hostDisconnected"),
);
}
const request = input.kinds
? { cwd: input.cwd, query, limit: 20, kinds: input.kinds }
: { cwd: input.cwd, query, limit: 20 };
// COMPAT(githubSearchRpc): added in v0.1.106, remove after 2026-12-28 once
// clients use forge.search.*.
if (transport === "github" && input.client.searchGitHub) {
return normalizeLegacyGitHubSearchPayload(
await input.client.searchGitHub(toLegacyGitHubSearchRequest(request)),
);
}
return normalizeForgeSearchPayload(await input.client.searchForge(request));
},
enabled: input.enabled && Boolean(input.client),
dataShape: "list" as const,
staleTimeMs: FORGE_SEARCH_STALE_TIME,
};
}
function normalizeForgeSearchPayload(payload: ForgeSearchResponse["payload"]): ForgeSearchPayload {
return {
items: payload.items.flatMap((item) => {
const result = ForgeSearchItemSchema.safeParse(item);
return result.success ? [result.data] : [];
}),
authState: parseForgeAuthState(payload.authState) ?? "unauthenticated",
error: payload.error,
requestId: payload.requestId,
};
}
function normalizeLegacyGitHubSearchPayload(
payload: GitHubSearchResponse["payload"],
): ForgeSearchPayload {
// COMPAT(githubSearchAuthState): added in v0.1.106, remove after 2026-12-28.
const featuresEnabled = payload.featuresEnabled ?? payload.githubFeaturesEnabled ?? true;
return {
items: payload.items.flatMap((item) => {
const result = GitHubSearchItemSchema.safeParse(item);
if (!result.success) {
return [];
}
return [
result.data.kind === "pr"
? { ...result.data, kind: "change_request" as const }
: { ...result.data, kind: "issue" as const },
];
}),
authState:
parseForgeAuthState(payload.authState) ??
(featuresEnabled ? "authenticated" : "unauthenticated"),
error: payload.error,
requestId: payload.requestId,
};
}
function toLegacyGitHubSearchKind(kind: ForgeSearchKind): LegacyGitHubSearchKind {
return kind === "change_request" ? "github-pr" : "github-issue";
}
function toLegacyGitHubSearchRequest(request: ForgeSearchOptions): LegacyGitHubSearchOptions {
if (!request.kinds) {
return { cwd: request.cwd, query: request.query, limit: request.limit };
}
return {
...request,
kinds: request.kinds.map(toLegacyGitHubSearchKind),
};
}
export function useForgeSearchQuery(input: ForgeSearchQueryInput) {
const { t } = useTranslation();
return useFetchQuery(
buildForgeSearchQueryOptions({
...input,
hostDisconnectedMessage: t("workspace.terminal.hostDisconnected"),
}),
);
}