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>
This commit is contained in:
co-authored by
Mohamed Boudra
parent
04d1ebdce0
commit
a8ebd390fa
@@ -28,7 +28,6 @@ import {
|
||||
CircleDot,
|
||||
FileText,
|
||||
GitPullRequest,
|
||||
Github,
|
||||
Image as ImageIcon,
|
||||
Paperclip,
|
||||
} from "lucide-react-native";
|
||||
@@ -94,7 +93,7 @@ import { submitAgentInput } from "@/composer/submit";
|
||||
import { ComposerKeyboardScopeProvider } from "@/composer/keyboard-scope";
|
||||
import { useAppSettings } from "@/hooks/use-settings";
|
||||
import { isWeb, isNative } from "@/constants/platform";
|
||||
import type { GitHubSearchItem } from "@getpaseo/protocol/messages";
|
||||
import type { ForgeSearchItem } from "@getpaseo/protocol/messages";
|
||||
import type {
|
||||
AttachmentMetadata,
|
||||
ComposerAttachment,
|
||||
@@ -102,6 +101,7 @@ import type {
|
||||
WorkspaceComposerAttachment,
|
||||
} from "@/attachments/types";
|
||||
import type { PickedFile } from "@/attachments/picked-file";
|
||||
import { resolveComposerAttachmentSubmitFormat } from "@/composer/attachments/submit";
|
||||
import { composerWorkspaceAttachment } from "@/composer/attachments/workspace";
|
||||
import { useWorkspaceAttachmentsForScopes } from "@/attachments/workspace-attachments-store";
|
||||
import { droppedItemsToPickedFiles } from "@/composer/attachments/drop";
|
||||
@@ -111,8 +111,11 @@ import { AttachmentLabel, AttachmentPill, AttachmentThumbnail } from "@/componen
|
||||
import { AttachmentLightbox } from "@/components/attachment-lightbox";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
import { useIsDictationReady } from "@/hooks/use-is-dictation-ready";
|
||||
import { useGithubSearchQuery } from "@/git/use-github-search-query";
|
||||
import { useForgeSearchQuery } from "@/git/use-forge-search-query";
|
||||
import { useCheckoutStatusQuery } from "@/git/use-status-query";
|
||||
import { useCheckoutPrStatusQuery } from "@/git/use-pr-status-query";
|
||||
import { getForgePresentation } from "@/git/forge";
|
||||
import { ForgeBrandIcon } from "@/git/forge-icon";
|
||||
import { useComposerGithubAutoAttach } from "./github/auto-attach";
|
||||
import { resolveClientSlashCommand, type ClientSlashCommand } from "@/client-slash-commands";
|
||||
|
||||
@@ -281,8 +284,8 @@ interface RenderAttachmentTrayArgs {
|
||||
openImage: string;
|
||||
removeImage: string;
|
||||
removeFile: string;
|
||||
openGithub: (kind: string, number: number) => string;
|
||||
removeGithub: (kind: string, number: number) => string;
|
||||
openGithub: (kind: string, numberLabel: string) => string;
|
||||
removeGithub: (kind: string, numberLabel: string) => string;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -616,13 +619,16 @@ function ImageAttachmentPill({
|
||||
}
|
||||
|
||||
interface GithubAttachmentPillProps {
|
||||
attachment: Extract<ComposerAttachment, { kind: "github_pr" | "github_issue" }>;
|
||||
attachment: Extract<
|
||||
ComposerAttachment,
|
||||
{ kind: "forge_change_request" | "forge_issue" | "github_pr" | "github_issue" }
|
||||
>;
|
||||
index: number;
|
||||
disabled: boolean;
|
||||
onOpen: (attachment: ComposerAttachment) => void;
|
||||
onRemove: (index: number) => void;
|
||||
openLabel: (kind: string, number: number) => string;
|
||||
removeLabel: (kind: string, number: number) => string;
|
||||
openLabel: (kind: string, numberLabel: string) => string;
|
||||
removeLabel: (kind: string, numberLabel: string) => string;
|
||||
}
|
||||
|
||||
function GithubAttachmentPill({
|
||||
@@ -635,7 +641,11 @@ function GithubAttachmentPill({
|
||||
removeLabel,
|
||||
}: GithubAttachmentPillProps) {
|
||||
const item = attachment.item;
|
||||
const kindLabel = item.kind === "pr" ? "PR" : "issue";
|
||||
const presentation = getForgePresentation(item.forge ?? "github");
|
||||
const isChangeRequest = item.kind === "change_request";
|
||||
const kindLabel = isChangeRequest ? presentation.changeRequestAbbrev : "issue";
|
||||
const subtitleKind = isChangeRequest ? presentation.changeRequestAbbrev : "Issue";
|
||||
const numberPrefix = isChangeRequest ? presentation.numberPrefix : presentation.issueNumberPrefix;
|
||||
const handleOpen = useCallback(() => {
|
||||
onOpen(attachment);
|
||||
}, [onOpen, attachment]);
|
||||
@@ -647,14 +657,14 @@ function GithubAttachmentPill({
|
||||
testID="composer-github-attachment-pill"
|
||||
onOpen={handleOpen}
|
||||
onRemove={handleRemove}
|
||||
openAccessibilityLabel={openLabel(kindLabel, item.number)}
|
||||
removeAccessibilityLabel={removeLabel(kindLabel, item.number)}
|
||||
openAccessibilityLabel={openLabel(kindLabel, `${numberPrefix}${item.number}`)}
|
||||
removeAccessibilityLabel={removeLabel(kindLabel, `${numberPrefix}${item.number}`)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<AttachmentLabel
|
||||
icon={item.kind === "pr" ? githubPrPillIcon : githubIssuePillIcon}
|
||||
icon={isChangeRequest ? githubPrPillIcon : githubIssuePillIcon}
|
||||
title={item.title}
|
||||
subtitle={`${item.kind === "pr" ? "PR" : "Issue"} #${item.number}`}
|
||||
subtitle={`${subtitleKind} ${numberPrefix}${item.number}`}
|
||||
/>
|
||||
</AttachmentPill>
|
||||
);
|
||||
@@ -703,8 +713,8 @@ interface GithubPickerOptionProps {
|
||||
testID: string;
|
||||
active: boolean;
|
||||
selected: boolean;
|
||||
item: GitHubSearchItem;
|
||||
onToggle: (item: GitHubSearchItem) => void;
|
||||
item: ForgeSearchItem;
|
||||
onToggle: (item: ForgeSearchItem) => void;
|
||||
}
|
||||
|
||||
function GithubPickerOption({
|
||||
@@ -720,7 +730,7 @@ function GithubPickerOption({
|
||||
}, [onToggle, item]);
|
||||
const leadingSlot = useMemo(
|
||||
() =>
|
||||
item.kind === "pr" ? (
|
||||
item.kind === "change_request" ? (
|
||||
<ThemedGitPullRequest size={ICON_SIZE.sm} uniProps={iconForegroundMutedMapping} />
|
||||
) : (
|
||||
<ThemedCircleDot size={ICON_SIZE.sm} uniProps={iconForegroundMutedMapping} />
|
||||
@@ -1045,6 +1055,9 @@ export function Composer({
|
||||
});
|
||||
const setSelectedAttachments = onChangeAttachments;
|
||||
const checkoutStatusQuery = useCheckoutStatusQuery({ serverId, cwd });
|
||||
const supportsForgeSearch = useSessionStore(
|
||||
(state) => state.sessions[serverId]?.serverInfo?.features?.forgeSearch === true,
|
||||
);
|
||||
const githubAutoAttach = useComposerGithubAutoAttach({
|
||||
text: userInput,
|
||||
remoteUrl: resolveCheckoutRemoteUrl(checkoutStatusQuery.status),
|
||||
@@ -1053,6 +1066,7 @@ export function Composer({
|
||||
isConnected,
|
||||
serverId,
|
||||
cwd,
|
||||
supportsForgeSearch,
|
||||
setAttachments: setSelectedAttachments,
|
||||
});
|
||||
const [cursorIndex, setCursorIndex] = useState(0);
|
||||
@@ -1212,12 +1226,23 @@ export function Composer({
|
||||
agentId: targetAgentId,
|
||||
text,
|
||||
attachments: sendAttachments,
|
||||
attachmentSubmitFormat: resolveComposerAttachmentSubmitFormat({
|
||||
supportsForgeAttachments: supportsForgeSearch,
|
||||
}),
|
||||
encodeImages,
|
||||
stream,
|
||||
});
|
||||
onAttentionPromptSend?.();
|
||||
};
|
||||
}, [client, onAttentionPromptSend, serverId, setAgentStreamTail, setAgentStreamHead, t]);
|
||||
}, [
|
||||
client,
|
||||
onAttentionPromptSend,
|
||||
serverId,
|
||||
setAgentStreamTail,
|
||||
setAgentStreamHead,
|
||||
supportsForgeSearch,
|
||||
t,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
onSubmitMessageRef.current = onSubmitMessage;
|
||||
@@ -1711,12 +1736,33 @@ export function Composer({
|
||||
[contextWindowMeter, isCompactLayout],
|
||||
);
|
||||
|
||||
const hasGithubAttachment = useMemo(
|
||||
() =>
|
||||
selectedAttachments.some(
|
||||
(attachment) =>
|
||||
attachment.kind === "forge_change_request" ||
|
||||
attachment.kind === "forge_issue" ||
|
||||
attachment.kind === "github_pr" ||
|
||||
attachment.kind === "github_issue",
|
||||
),
|
||||
[selectedAttachments],
|
||||
);
|
||||
// Composer stays mounted for each focused agent, so avoid a forge CLI call
|
||||
// until the forge-specific picker or attachment presentation is visible.
|
||||
const { forge } = useCheckoutPrStatusQuery({
|
||||
serverId,
|
||||
cwd,
|
||||
enabled: isConnected && cwd.trim().length > 0 && (isGithubPickerOpen || hasGithubAttachment),
|
||||
});
|
||||
const forgePresentation = useMemo(() => getForgePresentation(forge), [forge]);
|
||||
|
||||
const githubSearchQueryTrimmed = githubSearchQuery.trim();
|
||||
const githubSearchResultsQuery = useGithubSearchQuery({
|
||||
const githubSearchResultsQuery = useForgeSearchQuery({
|
||||
client,
|
||||
serverId,
|
||||
cwd,
|
||||
query: githubSearchQueryTrimmed,
|
||||
supportsForgeSearch,
|
||||
enabled: resolveGithubSearchEnabled(isGithubPickerOpen, isConnected, cwd),
|
||||
});
|
||||
|
||||
@@ -1724,11 +1770,18 @@ export function Composer({
|
||||
const githubSearchItems = useMemo(() => githubSearchItemsRaw ?? [], [githubSearchItemsRaw]);
|
||||
const githubSearchOptions: ComboboxOption[] = useMemo(
|
||||
() =>
|
||||
githubSearchItems.map((item) => ({
|
||||
id: `${item.kind}:${item.number}`,
|
||||
label: `#${item.number} ${item.title}`,
|
||||
description: githubSearchQueryTrimmed,
|
||||
})),
|
||||
githubSearchItems.map((item) => {
|
||||
const presentation = getForgePresentation(item.forge ?? "github");
|
||||
const numberPrefix =
|
||||
item.kind === "change_request"
|
||||
? presentation.numberPrefix
|
||||
: presentation.issueNumberPrefix;
|
||||
return {
|
||||
id: `${item.kind}:${item.number}`,
|
||||
label: `${numberPrefix}${item.number} ${item.title}`,
|
||||
description: githubSearchQueryTrimmed,
|
||||
};
|
||||
}),
|
||||
[githubSearchItems, githubSearchQueryTrimmed],
|
||||
);
|
||||
|
||||
@@ -1744,8 +1797,10 @@ export function Composer({
|
||||
},
|
||||
{
|
||||
id: "github",
|
||||
label: t("composer.attachments.addIssueOrPr"),
|
||||
icon: <ThemedGithub size={ICON_SIZE.md} uniProps={iconForegroundMutedMapping} />,
|
||||
label: t("composer.attachments.addIssueOrPr", {
|
||||
context: forgePresentation.changeRequestContext,
|
||||
}),
|
||||
icon: renderForgeAttachmentIcon(forgePresentation.icon),
|
||||
onSelect: () => {
|
||||
setIsGithubPickerOpen(true);
|
||||
},
|
||||
@@ -1759,11 +1814,11 @@ export function Composer({
|
||||
},
|
||||
},
|
||||
],
|
||||
[handlePickImage, handlePickFile, t],
|
||||
[handlePickImage, handlePickFile, t, forgePresentation],
|
||||
);
|
||||
|
||||
const handleToggleGithubItem = useCallback(
|
||||
(item: GitHubSearchItem) => {
|
||||
(item: ForgeSearchItem) => {
|
||||
const nextAttachments = toggleGithubAttachmentFromPicker({
|
||||
current: attachments,
|
||||
item,
|
||||
@@ -1868,10 +1923,10 @@ export function Composer({
|
||||
openImage: t("composer.attachments.openImage"),
|
||||
removeImage: t("composer.attachments.removeImage"),
|
||||
removeFile: t("composer.attachments.removeFile"),
|
||||
openGithub: (kind: string, number: number) =>
|
||||
t("composer.attachments.openGithub", { kind, number }),
|
||||
removeGithub: (kind: string, number: number) =>
|
||||
t("composer.attachments.removeGithub", { kind, number }),
|
||||
openGithub: (kind: string, numberLabel: string) =>
|
||||
t("composer.attachments.openGithub", { kind, number: numberLabel }),
|
||||
removeGithub: (kind: string, numberLabel: string) =>
|
||||
t("composer.attachments.removeGithub", { kind, number: numberLabel }),
|
||||
},
|
||||
}),
|
||||
[handleOpenAttachment, handleRemoveAttachment, isComposerLocked, selectedAttachments, t],
|
||||
@@ -1983,8 +2038,12 @@ export function Composer({
|
||||
onSelect={noop}
|
||||
keepOpenOnSelect
|
||||
searchable
|
||||
searchPlaceholder={t("composer.github.searchPlaceholder")}
|
||||
title={t("composer.github.title")}
|
||||
searchPlaceholder={t("composer.github.searchPlaceholder", {
|
||||
context: forgePresentation.changeRequestContext,
|
||||
})}
|
||||
title={t("composer.github.title", {
|
||||
context: forgePresentation.changeRequestContext,
|
||||
})}
|
||||
open={isGithubPickerOpen}
|
||||
onOpenChange={handleGithubPickerOpenChange}
|
||||
onSearchQueryChange={setGithubSearchQuery}
|
||||
@@ -2185,12 +2244,16 @@ const ThemedAudioLines = withUnistyles(AudioLines);
|
||||
const ThemedPaperclip = withUnistyles(Paperclip);
|
||||
const ThemedImageIcon = withUnistyles(ImageIcon);
|
||||
const ThemedFileText = withUnistyles(FileText);
|
||||
const ThemedGithub = withUnistyles(Github);
|
||||
|
||||
const iconForegroundMapping = (theme: Theme) => ({ color: theme.colors.foreground });
|
||||
const iconForegroundMutedMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
const iconAccentForegroundMapping = (theme: Theme) => ({ color: theme.colors.accentForeground });
|
||||
|
||||
function renderForgeAttachmentIcon(icon: string): ReactElement {
|
||||
return (
|
||||
<ForgeBrandIcon iconKind={icon} size={ICON_SIZE.md} uniProps={iconForegroundMutedMapping} />
|
||||
);
|
||||
}
|
||||
|
||||
const githubPrPillIcon = (
|
||||
<ThemedGitPullRequest size={ICON_SIZE.sm} uniProps={iconForegroundMutedMapping} />
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user