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
@@ -9,6 +9,7 @@ import {
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { TFunction } from "i18next";
|
||||
import { DiffStat } from "@/components/diff-stat";
|
||||
import {
|
||||
View,
|
||||
@@ -83,11 +84,13 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
|
||||
import { GitHubIcon } from "@/components/icons/github-icon";
|
||||
import { lineNumberGutterWidth } from "@/components/code-insets";
|
||||
import { GitActionsSplitButton } from "@/git/actions-split-button";
|
||||
import { BranchSwitcher } from "@/components/branch-switcher";
|
||||
import { useGitActions } from "@/git/use-actions";
|
||||
import { buildForgeSignInCommand, getForgePresentation, type Forge } from "@/git/forge";
|
||||
import { parseGitRemoteLocation } from "@getpaseo/protocol/git-remote";
|
||||
import type { ForgeAuthState } from "@getpaseo/protocol/messages";
|
||||
import { useCheckoutGitActionsStore } from "@/git/actions-store";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
@@ -1256,7 +1259,6 @@ const ThemedGitCommitHorizontal = withUnistyles(GitCommitHorizontal);
|
||||
const ThemedDownload = withUnistyles(Download);
|
||||
const ThemedUpload = withUnistyles(Upload);
|
||||
const ThemedArrowDownUp = withUnistyles(ArrowDownUp);
|
||||
const ThemedGitHubIcon = withUnistyles(GitHubIcon);
|
||||
const ThemedGitMerge = withUnistyles(GitMerge);
|
||||
const ThemedRefreshCcw = withUnistyles(RefreshCcw);
|
||||
const ThemedArchive = withUnistyles(Archive);
|
||||
@@ -1403,6 +1405,7 @@ function DiffFilesToolbar({
|
||||
}
|
||||
|
||||
interface DiffOptionsMenuProps {
|
||||
brand: string;
|
||||
hideWhitespace: boolean;
|
||||
isMobile: boolean;
|
||||
isRefreshing: boolean;
|
||||
@@ -1415,6 +1418,7 @@ interface DiffOptionsMenuProps {
|
||||
}
|
||||
|
||||
function DiffOptionsMenu({
|
||||
brand,
|
||||
hideWhitespace,
|
||||
isMobile,
|
||||
isRefreshing,
|
||||
@@ -1489,7 +1493,11 @@ function DiffOptionsMenu({
|
||||
testID="changes-refresh"
|
||||
onSelect={onRefresh}
|
||||
>
|
||||
{isRefreshing ? t("workspace.git.diff.refreshing") : t("workspace.git.diff.refresh")}
|
||||
{isRefreshing
|
||||
? t("workspace.git.diff.refreshing")
|
||||
: t("workspace.git.diff.refreshState", {
|
||||
brand,
|
||||
})}
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
) : null}
|
||||
@@ -2078,6 +2086,62 @@ function computePrErrorMessage(
|
||||
return prPayloadError?.message ?? null;
|
||||
}
|
||||
|
||||
// The precise setup step a workspace needs before its forge features work, or
|
||||
// null when nothing is actionable (authenticated, or no forge remote at all).
|
||||
type ForgeSetupAction = "install_cli" | "sign_in" | null;
|
||||
|
||||
// Drive the onboarding callout from the forge's auth state so the message names
|
||||
// the exact next step (install the CLI vs sign in) for whichever forge backs the
|
||||
// workspace — GitHub included. GitLab additionally requires the host to advertise
|
||||
// GitLab support, matching the rest of the GitLab UI.
|
||||
function computeForgeSetupAction(input: {
|
||||
forge: Forge;
|
||||
forgeProvidersSupported: boolean;
|
||||
authState: ForgeAuthState | undefined;
|
||||
}): ForgeSetupAction {
|
||||
// A daemon without pluggable forge support can't operate any non-GitHub forge,
|
||||
// so don't offer a setup action for one it can't drive.
|
||||
if (input.forge !== "github" && !input.forgeProvidersSupported) {
|
||||
return null;
|
||||
}
|
||||
switch (input.authState) {
|
||||
case "cli_missing":
|
||||
return "install_cli";
|
||||
case "unauthenticated":
|
||||
return "sign_in";
|
||||
case "authenticated":
|
||||
case "no_remote":
|
||||
case "error":
|
||||
return null;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function parseForgeHost(url: string | null | undefined): string | null {
|
||||
return url ? (parseGitRemoteLocation(url)?.host ?? null) : null;
|
||||
}
|
||||
|
||||
function buildForgeSetupMessage(input: {
|
||||
action: Exclude<ForgeSetupAction, null>;
|
||||
forge: Forge;
|
||||
host: string | null;
|
||||
t: TFunction;
|
||||
}): string {
|
||||
const { brandLabel, signInCli } = getForgePresentation(input.forge);
|
||||
// A forge with no known CLI (an unknown/third-party forge rendered neutrally)
|
||||
// has no install/sign-in command to interpolate — show neutral guidance
|
||||
// rather than the GitLab-specific callout or a null command.
|
||||
if (signInCli === null) {
|
||||
return input.t("workspace.git.forgeSetup.generic", { brand: brandLabel });
|
||||
}
|
||||
if (input.action === "install_cli") {
|
||||
return input.t("workspace.git.forgeSetup.installCli", { cli: signInCli, brand: brandLabel });
|
||||
}
|
||||
const command = buildForgeSignInCommand(input.forge, input.host);
|
||||
return input.t("workspace.git.forgeSetup.signIn", { command, brand: brandLabel });
|
||||
}
|
||||
|
||||
function buildDiffModeTriggerStyle(): PressableStyleFn {
|
||||
return ({ hovered, pressed, open }) => [
|
||||
styles.diffModeTrigger,
|
||||
@@ -2285,11 +2349,36 @@ export function GitDiffPane({ serverId, workspaceId, cwd, enabled }: GitDiffPane
|
||||
setWorkspaceAttachments,
|
||||
workspaceAttachmentScopeKey,
|
||||
]);
|
||||
const { githubFeaturesEnabled, payloadError: prPayloadError } = useCheckoutPrStatusQuery({
|
||||
const {
|
||||
githubFeaturesEnabled,
|
||||
forge,
|
||||
authState,
|
||||
payloadError: prPayloadError,
|
||||
} = useCheckoutPrStatusQuery({
|
||||
serverId,
|
||||
cwd,
|
||||
enabled: isGit,
|
||||
});
|
||||
const forgeProvidersSupported = useSessionStore(
|
||||
(s) => s.sessions[serverId]?.serverInfo?.features?.forgeProviders === true,
|
||||
);
|
||||
const forgeSetupAction = computeForgeSetupAction({
|
||||
forge,
|
||||
forgeProvidersSupported,
|
||||
authState,
|
||||
});
|
||||
const forgeSetupMessage = useMemo(
|
||||
() =>
|
||||
forgeSetupAction
|
||||
? buildForgeSetupMessage({
|
||||
action: forgeSetupAction,
|
||||
forge,
|
||||
host: parseForgeHost(status?.remoteUrl),
|
||||
t,
|
||||
})
|
||||
: null,
|
||||
[forgeSetupAction, forge, status?.remoteUrl, t],
|
||||
);
|
||||
const normalizedWorkspaceRoot = useMemo(() => cwd.trim(), [cwd]);
|
||||
const workspaceStateKey = useMemo(
|
||||
() =>
|
||||
@@ -2401,11 +2490,6 @@ export function GitDiffPane({ serverId, workspaceId, cwd, enabled }: GitDiffPane
|
||||
pull: <ThemedDownload size={16} uniProps={foregroundMutedIconColorMapping} />,
|
||||
push: <ThemedUpload size={16} uniProps={foregroundMutedIconColorMapping} />,
|
||||
pullAndPush: <ThemedArrowDownUp size={16} uniProps={foregroundMutedIconColorMapping} />,
|
||||
viewPr: <ThemedGitHubIcon size={16} uniProps={foregroundMutedIconColorMapping} />,
|
||||
createPr: <ThemedGitHubIcon size={16} uniProps={foregroundMutedIconColorMapping} />,
|
||||
mergePrSquash: <ThemedGitHubIcon size={16} uniProps={foregroundMutedIconColorMapping} />,
|
||||
mergePrMerge: <ThemedGitHubIcon size={16} uniProps={foregroundMutedIconColorMapping} />,
|
||||
mergePrRebase: <ThemedGitHubIcon size={16} uniProps={foregroundMutedIconColorMapping} />,
|
||||
merge: <ThemedGitMerge size={16} uniProps={foregroundMutedIconColorMapping} />,
|
||||
mergeFromBase: <ThemedRefreshCcw size={16} uniProps={foregroundMutedIconColorMapping} />,
|
||||
archive: <ThemedArchive size={16} uniProps={foregroundMutedIconColorMapping} />,
|
||||
@@ -2531,6 +2615,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, enabled }: GitDiffPane
|
||||
/>
|
||||
) : null}
|
||||
<DiffOptionsMenu
|
||||
brand={getForgePresentation(forge).brandLabel}
|
||||
hideWhitespace={changesPreferences.hideWhitespace}
|
||||
isMobile={isMobile}
|
||||
isRefreshing={isRefreshing}
|
||||
@@ -2546,6 +2631,12 @@ export function GitDiffPane({ serverId, workspaceId, cwd, enabled }: GitDiffPane
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{forgeSetupMessage ? (
|
||||
<View style={styles.forgeSetupCallout} testID="forge-setup-callout">
|
||||
<Text style={styles.forgeSetupCalloutText}>{forgeSetupMessage}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{prErrorMessage ? <Text style={styles.actionErrorText}>{prErrorMessage}</Text> : null}
|
||||
|
||||
<View style={styles.diffContainer}>{bodyContent}</View>
|
||||
@@ -2653,6 +2744,20 @@ const styles = StyleSheet.create((theme) => ({
|
||||
fontSize: theme.fontSize.xs,
|
||||
color: theme.colors.destructive,
|
||||
},
|
||||
forgeSetupCallout: {
|
||||
marginHorizontal: theme.spacing[3],
|
||||
marginBottom: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[2],
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
borderRadius: theme.borderRadius.md,
|
||||
backgroundColor: theme.colors.surface1,
|
||||
},
|
||||
forgeSetupCalloutText: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
diffContainer: {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
|
||||
Reference in New Issue
Block a user