Files
paseo/packages/app/src/git/use-forge-search-query.ts
T
nllptrxandMohamed Boudra 4a5716360b Feat/forge check semantics and followup fixes (#2337)
* feat(protocol): add forge check presentation metadata

Expose optional rawStatus, isManual, and requiresAction fields while keeping older check payloads backward-compatible.

* fix(gitea): model Actions jobs as tasks

Gitea's /actions/tasks endpoint returns individual jobs rather than workflow runs. Preserve sibling tasks from the latest execution, avoid collapsing same-name jobs, and enrich blocked tasks with run-level approval metadata.

* feat(app): distinguish forge check presentation states

* fix(gitlab): preserve pipeline job semantics

* refactor(app): centralize forge check presentation policy

Use one canonical category order, count shape, visual policy, and translated copy mapping across sidebar, hover-card, PR-pane, and GitLab pipeline surfaces.

* refactor: consolidate forge check handling

* feat(forge): auto-attach pasted forge references

* docs(compat): correct forge rollout metadata

* refactor(forge): model check nuances as traits

* fix(forge): harden checks from branch review

Structural Gitea Actions shadow dedup by run id (verified upstream),
rerun grouping without workflow_id, shared trait/status constants and
Gitea state mapping in the protocol package, all cloud host aliases in
pasted-link matching, logged glab job-detail failures, concurrent
approvals/pipeline fetch, checks aggregate from the same pipeline as
the job list, and removal of the unread check-level rawStatus. The
released pipeline-level rawStatus is staged for removal under
COMPAT(pipelineRawStatus).

* fix(gitea): detect fork-PR Actions approval hold as action_required

gitea.com represents a fork PR awaiting Actions approval as a run in
status "waiting" with no tasks and pending shadow commit statuses; it
does not expose need_approval. The previous mapping only set
action_required from a task with blocked+need_approval, so the gate
never surfaced (0 tasks -> early return; need_approval absent). Detect
the hold from /actions/runs: a waiting/blocked run whose PR is
cross-repo (head.repo.id != base.repo.id), or one that does expose
need_approval (self-hosted). Tag the run's pending shadow statuses
action_required. Runs are only fetched when a pending shadow has no
covering task, keeping the common path at two requests. Verified live
against gitea.com fork PR (run 715170, waiting, cross-repo).

* fix(gitea): resolve cross-fork PRs by head sha

The current-PR matcher gated candidates on the checkout owner
(giteaHeadOwner === expectedHeadOwner), which excludes every fork PR:
its head carries the fork owner while the checkout is the base repo, so
a fork PR - the only shape that can be approval-held on Gitea - never
resolved and its checks never showed in-app. GitLab has no such gate
(it resolves fork MRs from the target project by source branch). Accept
a recent PR whose head sha equals the workspace head sha regardless of
owner; the sha is an unambiguous identity, so the anti-collision owner
gate still applies when no sha match is available. Verified live against
gitea.com fork PR #6 (resolves + renders action_required).

* fix: resolve forge rebase drift

* fix: type forge auto-attach boundary

* fix(i18n): add Korean check status copy

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-08-25 23:37:50 +02:00

175 lines
5.1 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): use the legacy GitHub RPC with daemons that
// predate forge.search.*. Remove after 2027-01-17 once the supported
// daemon floor is >= v0.2.0.
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): normalize the legacy GitHub search response
// until 2027-01-17, when the supported daemon floor is >= v0.2.0.
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"),
}),
);
}