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:
nllptrx
2026-07-17 15:03:26 +08:00
committed by GitHub
co-authored by Mohamed Boudra
parent 04d1ebdce0
commit a8ebd390fa
187 changed files with 20988 additions and 2907 deletions
+60 -49
View File
@@ -183,66 +183,77 @@ describe("checkout-git-actions-store", () => {
).toBe("idle");
});
it("enables PR auto-merge when the daemon advertises auto-merge actions", async () => {
const client = {
checkoutGithubSetAutoMerge: vi.fn(async () => ({
for (const rpc of [
{
label: "forge",
method: "checkoutForgeSetAutoMerge",
feature: "checkoutForgeSetAutoMerge",
},
{
label: "legacy GitHub",
method: "checkoutGithubSetAutoMerge",
feature: "checkoutGithubSetAutoMerge",
},
] as const) {
it(`enables PR auto-merge through the ${rpc.label} RPC`, async () => {
const setAutoMerge = vi.fn(async () => ({
enabled: true,
success: true,
error: null,
})),
};
useSessionStore.getState().initializeSession(serverId, client as unknown as DaemonClient);
useSessionStore.getState().updateSessionServerInfo(serverId, {
serverId,
hostname: null,
version: null,
features: { checkoutGithubSetAutoMerge: true },
});
}));
const client = { [rpc.method]: setAutoMerge };
useSessionStore.getState().initializeSession(serverId, client as unknown as DaemonClient);
useSessionStore.getState().updateSessionServerInfo(serverId, {
serverId,
hostname: null,
version: null,
features: { [rpc.feature]: true },
});
await useCheckoutGitActionsStore
.getState()
.enablePrAutoMerge({ serverId, cwd, method: "squash" });
expect(client.checkoutGithubSetAutoMerge).toHaveBeenCalledWith(cwd, {
enabled: true,
method: "squash",
});
expect(
useCheckoutGitActionsStore
await useCheckoutGitActionsStore
.getState()
.getStatus({ serverId, cwd, actionId: "enable-pr-auto-merge-squash" }),
).toBe("success");
});
.enablePrAutoMerge({ serverId, cwd, method: "squash" });
it("disables PR auto-merge when the daemon advertises auto-merge actions", async () => {
const client = {
checkoutGithubSetAutoMerge: vi.fn(async () => ({
expect(setAutoMerge).toHaveBeenCalledWith(cwd, {
enabled: true,
method: "squash",
});
expect(
useCheckoutGitActionsStore
.getState()
.getStatus({ serverId, cwd, actionId: "enable-pr-auto-merge-squash" }),
).toBe("success");
});
it(`disables PR auto-merge through the ${rpc.label} RPC`, async () => {
const setAutoMerge = vi.fn(async () => ({
enabled: false,
success: true,
error: null,
})),
};
useSessionStore.getState().initializeSession(serverId, client as unknown as DaemonClient);
useSessionStore.getState().updateSessionServerInfo(serverId, {
serverId,
hostname: null,
version: null,
features: { checkoutGithubSetAutoMerge: true },
}));
const client = { [rpc.method]: setAutoMerge };
useSessionStore.getState().initializeSession(serverId, client as unknown as DaemonClient);
useSessionStore.getState().updateSessionServerInfo(serverId, {
serverId,
hostname: null,
version: null,
features: { [rpc.feature]: true },
});
await useCheckoutGitActionsStore.getState().disablePrAutoMerge({ serverId, cwd });
expect(setAutoMerge).toHaveBeenCalledWith(cwd, { enabled: false });
expect(
useCheckoutGitActionsStore
.getState()
.getStatus({ serverId, cwd, actionId: "disable-pr-auto-merge" }),
).toBe("success");
});
await useCheckoutGitActionsStore.getState().disablePrAutoMerge({ serverId, cwd });
expect(client.checkoutGithubSetAutoMerge).toHaveBeenCalledWith(cwd, { enabled: false });
expect(
useCheckoutGitActionsStore
.getState()
.getStatus({ serverId, cwd, actionId: "disable-pr-auto-merge" }),
).toBe("success");
});
}
it("does not call PR auto-merge RPCs when the daemon lacks the feature flag", async () => {
const client = {
checkoutGithubSetAutoMerge: vi.fn(async () => ({
checkoutForgeSetAutoMerge: vi.fn(async () => ({
enabled: true,
success: true,
error: null,
@@ -258,9 +269,9 @@ describe("checkout-git-actions-store", () => {
await expect(
useCheckoutGitActionsStore.getState().enablePrAutoMerge({ serverId, cwd, method: "merge" }),
).rejects.toThrow("Update the host to use GitHub auto-merge actions.");
).rejects.toThrow("Update the host to use auto-merge actions.");
expect(client.checkoutGithubSetAutoMerge).not.toHaveBeenCalled();
expect(client.checkoutForgeSetAutoMerge).not.toHaveBeenCalled();
expect(
useCheckoutGitActionsStore
.getState()
+25 -7
View File
@@ -42,11 +42,19 @@ function resolveClient(serverId: string) {
return client;
}
function assertGitHubAutoMergeActionsSupported(serverId: string) {
type AutoMergeActionsRpc = "forge" | "github";
function resolveAutoMergeActionsRpc(serverId: string): AutoMergeActionsRpc {
const session = useSessionStore.getState().sessions[serverId];
if (session?.serverInfo?.features?.checkoutGithubSetAutoMerge !== true) {
throw new Error("Update the host to use GitHub auto-merge actions.");
if (session?.serverInfo?.features?.checkoutForgeSetAutoMerge === true) {
return "forge";
}
// COMPAT(githubAutoMergeRpc): added in v0.1.106, remove after 2026-12-28 once
// all supported clients use checkout.forge.set_auto_merge.*.
if (session?.serverInfo?.features?.checkoutGithubSetAutoMerge === true) {
return "github";
}
throw new Error("Update the host to use auto-merge actions.");
}
function setStatus(
@@ -281,14 +289,19 @@ export const useCheckoutGitActionsStore = create<CheckoutGitActionsStoreState>()
},
enablePrAutoMerge: async ({ serverId, cwd, method }) => {
assertGitHubAutoMergeActionsSupported(serverId);
const rpc = resolveAutoMergeActionsRpc(serverId);
await runCheckoutAction({
serverId,
cwd,
actionId: `enable-pr-auto-merge-${method}`,
run: async () => {
const client = resolveClient(serverId);
const payload = await client.checkoutGithubSetAutoMerge(cwd, { enabled: true, method });
// COMPAT(githubAutoMergeRpc): added in v0.1.106, remove after 2026-12-28 once
// all supported clients use checkout.forge.set_auto_merge.*.
const payload =
rpc === "forge"
? await client.checkoutForgeSetAutoMerge(cwd, { enabled: true, method })
: await client.checkoutGithubSetAutoMerge(cwd, { enabled: true, method });
if (payload.error) {
throw new Error(payload.error.message);
}
@@ -297,14 +310,19 @@ export const useCheckoutGitActionsStore = create<CheckoutGitActionsStoreState>()
},
disablePrAutoMerge: async ({ serverId, cwd }) => {
assertGitHubAutoMergeActionsSupported(serverId);
const rpc = resolveAutoMergeActionsRpc(serverId);
await runCheckoutAction({
serverId,
cwd,
actionId: "disable-pr-auto-merge",
run: async () => {
const client = resolveClient(serverId);
const payload = await client.checkoutGithubSetAutoMerge(cwd, { enabled: false });
// COMPAT(githubAutoMergeRpc): added in v0.1.106, remove after 2026-12-28 once
// all supported clients use checkout.forge.set_auto_merge.*.
const payload =
rpc === "forge"
? await client.checkoutForgeSetAutoMerge(cwd, { enabled: false })
: await client.checkoutGithubSetAutoMerge(cwd, { enabled: false });
if (payload.error) {
throw new Error(payload.error.message);
}
@@ -1,11 +1,12 @@
// @vitest-environment jsdom
// The review draft store persists through AsyncStorage's web shim, which needs window.
import "@/test/window-local-storage";
import { QueryClient } from "@tanstack/react-query";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { CheckoutStatusUpdate } from "@getpaseo/protocol/messages";
import { checkoutPrStatusQueryKey, checkoutStatusQueryKey } from "@/git/query-keys";
import { prPaneTimelineQueryKey } from "@/git/pull-request-panel/query-keys";
import {
prPanePipelineQueryKey,
prPaneTimelineQueryKey,
} from "@/git/pull-request-panel/query-keys";
import { resetReviewDraftStore, useReviewDraftStore } from "@/review/store";
import {
applyCheckoutStatusUpdateFromEvent,
@@ -14,6 +15,14 @@ import {
fetchCheckoutStatus,
} from "./checkout-status-cache";
vi.mock("@react-native-async-storage/async-storage", () => ({
default: {
getItem: vi.fn(async () => null),
setItem: vi.fn(async () => undefined),
removeItem: vi.fn(async () => undefined),
},
}));
const serverId = "server-1";
const cwd = "/repo";
@@ -41,6 +50,7 @@ function prStatus(overrides: Partial<CheckoutPrStatusPayload> = {}): CheckoutPrS
return {
cwd,
status: {
forge: "github",
url: "https://github.com/getpaseo/paseo/pull/42",
title: "My PR",
state: "open",
@@ -54,6 +64,8 @@ function prStatus(overrides: Partial<CheckoutPrStatusPayload> = {}): CheckoutPrS
reviewDecision: null,
},
githubFeaturesEnabled: true,
authState: "authenticated",
forge: "github",
error: null,
requestId: "pr-status-1",
...overrides,
@@ -62,7 +74,7 @@ function prStatus(overrides: Partial<CheckoutPrStatusPayload> = {}): CheckoutPrS
function checkoutStatusUpdate(
payload: CheckoutStatusPayload,
extraPrStatus?: CheckoutPrStatusPayload,
extraPrStatus?: NonNullable<CheckoutStatusUpdate["payload"]["prStatus"]>,
): CheckoutStatusUpdate {
return {
type: "checkout_status_update",
@@ -140,6 +152,29 @@ describe("applyCheckoutStatusUpdateFromEvent", () => {
expect(queryClient.getQueryData(checkoutPrStatusQueryKey(serverId, otherCwd))).toBeUndefined();
});
it("normalizes legacy PR auth state at the pushed-cache boundary", () => {
const queryClient = createQueryClient();
const { authState: _authState, ...legacyPrStatus } = prStatus({
githubFeaturesEnabled: false,
});
applyCheckoutStatusUpdateFromEvent({
queryClient,
serverId,
message: checkoutStatusUpdate(checkoutStatus(), legacyPrStatus),
});
expect(
queryClient.getQueryData<CheckoutPrStatusPayload>(checkoutPrStatusQueryKey(serverId, cwd))
?.authState,
).toBe("unauthenticated");
expect(
queryClient.getQueryData<CheckoutStatusUpdate["payload"]>(
checkoutStatusQueryKey(serverId, cwd),
)?.prStatus?.authState,
).toBe("unauthenticated");
});
it("expires a manual diff-mode override when the pushed dirty state flipped", () => {
const queryClient = createQueryClient();
setDiffModeOverride(false);
@@ -166,14 +201,21 @@ describe("applyCheckoutStatusUpdateFromEvent", () => {
expect(useReviewDraftStore.getState().diffModeOverrides["review:scope"]).toBeDefined();
});
it("invalidates the PR timeline when the prStatus changes, ignoring the volatile requestId", () => {
it("invalidates PR detail queries when the prStatus changes, ignoring the volatile requestId", () => {
const queryClient = createQueryClient();
queryClient.setQueryData(
checkoutPrStatusQueryKey(serverId, cwd),
prStatus({ requestId: "pr-v1" }),
);
const timelineKey = prPaneTimelineQueryKey({ serverId, cwd, prNumber: 42 });
const pipelineKey = prPanePipelineQueryKey({
serverId,
cwd,
pipelineId: 9001,
changeRequestNumber: 1,
});
queryClient.setQueryData(timelineKey, { items: [] });
queryClient.setQueryData(pipelineKey, { stages: [] });
applyCheckoutStatusUpdateFromEvent({
queryClient,
@@ -181,6 +223,7 @@ describe("applyCheckoutStatusUpdateFromEvent", () => {
message: checkoutStatusUpdate(checkoutStatus(), prStatus({ requestId: "pr-v2" })),
});
expect(queryClient.getQueryState(timelineKey)?.isInvalidated).toBe(false);
expect(queryClient.getQueryState(pipelineKey)?.isInvalidated).toBe(false);
applyCheckoutStatusUpdateFromEvent({
queryClient,
@@ -194,14 +237,29 @@ describe("applyCheckoutStatusUpdateFromEvent", () => {
),
});
expect(queryClient.getQueryState(timelineKey)?.isInvalidated).toBe(true);
expect(queryClient.getQueryState(pipelineKey)?.isInvalidated).toBe(true);
});
it("invalidates the PR timeline on the first prStatus emission, scoped to its cwd", () => {
it("invalidates PR detail queries on the first prStatus emission, scoped to its cwd", () => {
const queryClient = createQueryClient();
const timelineKey = prPaneTimelineQueryKey({ serverId, cwd, prNumber: 42 });
const otherTimelineKey = prPaneTimelineQueryKey({ serverId, cwd: "/repo2", prNumber: 42 });
const pipelineKey = prPanePipelineQueryKey({
serverId,
cwd,
pipelineId: 9001,
changeRequestNumber: 1,
});
const otherPipelineKey = prPanePipelineQueryKey({
serverId,
cwd: "/repo2",
pipelineId: 9001,
changeRequestNumber: 1,
});
queryClient.setQueryData(timelineKey, { items: [] });
queryClient.setQueryData(otherTimelineKey, { items: [] });
queryClient.setQueryData(pipelineKey, { stages: [] });
queryClient.setQueryData(otherPipelineKey, { stages: [] });
applyCheckoutStatusUpdateFromEvent({
queryClient,
@@ -211,5 +269,7 @@ describe("applyCheckoutStatusUpdateFromEvent", () => {
expect(queryClient.getQueryState(timelineKey)?.isInvalidated).toBe(true);
expect(queryClient.getQueryState(otherTimelineKey)?.isInvalidated).toBe(false);
expect(queryClient.getQueryState(pipelineKey)?.isInvalidated).toBe(true);
expect(queryClient.getQueryState(otherPipelineKey)?.isInvalidated).toBe(false);
});
});
@@ -6,10 +6,11 @@ import {
checkoutStatusQueryKey,
invalidatePrPaneTimelineForCheckout,
} from "@/git/query-keys";
import { type CheckoutPrStatusPayload, normalizeCheckoutPrStatusPayload } from "@/git/pr-status";
import { expireStaleDiffModeOverrides } from "@/review/store";
export type CheckoutStatusPayload = CheckoutStatusResponse["payload"];
export type CheckoutPrStatusPayload = NonNullable<CheckoutStatusUpdate["payload"]["prStatus"]>;
export type { CheckoutPrStatusPayload } from "@/git/pr-status";
export interface CheckoutStatusClient {
getCheckoutStatus: (cwd: string) => Promise<CheckoutStatusPayload>;
@@ -43,14 +44,17 @@ export function applyCheckoutStatusUpdateFromEvent({
message: CheckoutStatusUpdate;
}): void {
const { payload } = message;
queryClient.setQueryData(checkoutStatusQueryKey(serverId, payload.cwd), payload);
const prStatus = payload.prStatus
? normalizeCheckoutPrStatusPayload(payload.prStatus)
: undefined;
const cachePayload = prStatus ? { ...payload, prStatus } : payload;
queryClient.setQueryData(checkoutStatusQueryKey(serverId, payload.cwd), cachePayload);
expireStaleDiffModeOverrides({
serverId,
cwd: payload.cwd,
isDirty: payload.isGit && payload.isDirty,
});
const prStatus = payload.prStatus;
if (!prStatus) {
return;
}
+199
View File
@@ -0,0 +1,199 @@
import type { ComponentType } from "react";
import type { ReactNode } from "react";
import type { z } from "zod";
import type { CheckoutPrMergeMethod, CheckoutPrStatusResponse } from "@getpaseo/protocol/messages";
import type { Theme } from "@/styles/theme";
import type { Forge } from "@/git/forge";
import type { PrPaneCheck } from "@/git/pull-request-panel/data";
export interface ForgeIconProps {
size?: number;
color?: string;
}
export type ForgeIconComponent = ComponentType<ForgeIconProps>;
export interface ForgeBrandColor {
light: string;
dark: string;
}
export type ForgeIconColorMapping = (theme: Theme) => { color: string };
export interface ForgeUrlGrammar {
/** Path infix before the branch in a tree URL, e.g. "/tree/", "/-/tree/". */
treeInfix: string;
/** Path infix before the branch in a blob URL, e.g. "/blob/", "/-/blob/". */
blobInfix: string;
/** Line/range anchor, e.g. github "#L1-L5" vs gitlab "#L1-5". */
lineAnchor: (start: number, end?: number) => string;
}
/** Line/range anchor shared by every GitHub-family forge (github, gitea, forgejo, codeberg). */
export function GITHUB_LINE_ANCHOR(start: number, end?: number): string {
return end && end > start ? `#L${start}-L${end}` : `#L${start}`;
}
/** URL grammar shared by the gitea-family forges (gitea, forgejo, codeberg). */
export const GITEA_FAMILY_URL_GRAMMAR: ForgeUrlGrammar = {
treeInfix: "/src/branch/",
blobInfix: "/src/branch/",
lineAnchor: GITHUB_LINE_ANCHOR,
};
export type ForgeSpecificEnvelope = { forge: string } & Record<string, unknown>;
type CheckoutPrStatus = NonNullable<CheckoutPrStatusResponse["payload"]["status"]>;
export type LegacyGithubMergeFacts = NonNullable<CheckoutPrStatus["github"]>;
export interface MergeCapability {
/** The change request can be merged directly right now. */
directMergeReady: boolean;
/** Auto-merge can be enabled right now. */
canEnableAutoMerge: boolean;
/** Auto-merge is already enabled on the change request. */
autoMergeEnabled: boolean;
/** The viewer is allowed to disable the active auto-merge. */
canDisableAutoMerge: boolean;
/** A merge queue is blocking both direct merge and auto-merge. */
mergeBlockedByQueue: boolean;
/** Merge methods the forge permits for this change request. */
allowedMethods: CheckoutPrMergeMethod[];
/** The forge's preferred/default merge method, if it reports one. */
preferredMethod: CheckoutPrMergeMethod | null;
}
export interface PaneChecksSlotContext {
serverId: string;
cwd: string;
/** Change request (PR/MR) number, so a section can address its head pipeline. */
changeRequestNumber: number;
open: boolean;
onToggle: () => void;
/** Whether the daemon advertises pluggable forge support (gates rich sections). */
enabled: boolean;
/**
* Whether the daemon can serve forge check/pipeline details over the
* forge-routed RPC. Single source of truth for gating on-demand detail
* fetches so a section never reaches an RPC the daemon lacks.
*/
canFetchCheckDetails: boolean;
}
export interface PaneNativeContribution {
guard: (facts: unknown) => boolean;
renderHeaderMeta: (facts: unknown) => ReactNode;
renderChecksSection: (facts: unknown, ctx: PaneChecksSlotContext) => ReactNode;
}
export interface NativeFallbackCheckEntry {
contribute: (facts: unknown, status: CheckoutPrStatus, forge: Forge) => PrPaneCheck | null;
}
interface TypedPaneContribution<TFacts extends ForgeSpecificEnvelope> {
renderHeaderMeta?: (facts: TFacts) => ReactNode;
renderChecksSection?: (facts: TFacts, ctx: PaneChecksSlotContext) => ReactNode;
}
interface TypedNativeFallbackCheck<TFacts extends ForgeSpecificEnvelope> {
contribute: (facts: TFacts, status: CheckoutPrStatus, forge: Forge) => PrPaneCheck | null;
}
export interface ClientForgeFactsEntry<TFacts extends ForgeSpecificEnvelope> {
readonly family: TFacts["forge"];
parse: (facts: unknown) => TFacts | null;
deriveMergeCapability: (facts: unknown) => MergeCapability | null;
readonly nativeFallbackChecks: readonly NativeFallbackCheckEntry[];
}
export interface ClientForgeFactsRegistration<TFacts extends ForgeSpecificEnvelope> {
readonly family: TFacts["forge"];
readonly schema: z.ZodType<TFacts>;
readonly deriveMergeCapability?: (facts: TFacts) => MergeCapability;
readonly nativeFallbackChecks?: readonly NativeFallbackCheckEntry[];
}
function parseFacts<TFacts extends ForgeSpecificEnvelope>(
schema: z.ZodType<TFacts>,
facts: unknown,
): TFacts | null {
if (!facts) {
return null;
}
const result = schema.safeParse(facts);
return result.success ? result.data : null;
}
export function defineNativeFallbackCheck<TFacts extends ForgeSpecificEnvelope>(
schema: z.ZodType<TFacts>,
contribution: TypedNativeFallbackCheck<TFacts>,
): NativeFallbackCheckEntry {
return {
contribute: (facts, status, forge) => {
const parsed = parseFacts(schema, facts);
return parsed ? contribution.contribute(parsed, status, forge) : null;
},
};
}
export function definePaneContribution<TFacts extends ForgeSpecificEnvelope>(
schema: z.ZodType<TFacts>,
contribution: TypedPaneContribution<TFacts>,
): PaneNativeContribution {
return {
guard: (facts) => parseFacts(schema, facts) !== null,
renderHeaderMeta: (facts) => {
const parsed = parseFacts(schema, facts);
return parsed && contribution.renderHeaderMeta ? contribution.renderHeaderMeta(parsed) : null;
},
renderChecksSection: (facts, ctx) => {
const parsed = parseFacts(schema, facts);
return parsed && contribution.renderChecksSection
? contribution.renderChecksSection(parsed, ctx)
: null;
},
};
}
export function defineForgeFacts<TFacts extends ForgeSpecificEnvelope>(
registration: ClientForgeFactsRegistration<TFacts>,
): ClientForgeFactsEntry<TFacts> {
return {
family: registration.family,
parse: (facts) => parseFacts(registration.schema, facts),
deriveMergeCapability: (facts) => {
if (!registration.deriveMergeCapability) {
return null;
}
const parsed = parseFacts(registration.schema, facts);
return parsed ? registration.deriveMergeCapability(parsed) : null;
},
nativeFallbackChecks: registration.nativeFallbackChecks ?? [],
};
}
/**
* Pure logic half of a forge: URL grammar and runtime-facts derivations. Kept
* free of any React/React-Native imports so logic consumers (URL builders,
* merge-capability, native-check fallbacks) — and the Node-based e2e harness
* that transitively imports them — never pull the client's rendering stack.
*/
export interface ClientForgeLogicModule<
TFacts extends ForgeSpecificEnvelope = ForgeSpecificEnvelope,
> {
readonly id: string;
readonly urlGrammar?: ForgeUrlGrammar;
readonly facts?: ClientForgeFactsEntry<TFacts>;
}
/**
* View half of a forge: the brand mark, brand color, and PR-pane render
* contributions. Imported only by rendering code (icon lookup, the PR pane).
*/
export interface ClientForgeViewModule {
readonly id: string;
readonly icon: ForgeIconComponent;
readonly brandColor?: ForgeBrandColor | null;
readonly paneContributions?: readonly PaneNativeContribution[];
}
+114 -9
View File
@@ -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,
+49
View File
@@ -0,0 +1,49 @@
import { describe, expect, it } from "vitest";
import { GitPullRequest } from "lucide-react-native";
import { getForgeBrandColorMapping, getForgeIconComponent } from "./forge-icon";
import { CLIENT_FORGE_VIEW_MODULES } from "./forges/view";
describe("getForgeIconComponent", () => {
it("resolves a dedicated brand icon for every registered forge module", () => {
for (const module of CLIENT_FORGE_VIEW_MODULES) {
expect(getForgeIconComponent(module.id)).toBe(module.icon);
}
});
it("falls back to the generic pull-request glyph for unknown icon kinds", () => {
expect(getForgeIconComponent("some-unknown-forge")).toBe(GitPullRequest);
expect(getForgeIconComponent("")).toBe(GitPullRequest);
});
});
describe("getForgeBrandColorMapping", () => {
it("returns a theme mapping only for forges that declare a brand color", () => {
for (const module of CLIENT_FORGE_VIEW_MODULES) {
const mapping = getForgeBrandColorMapping(module.id);
if (module.brandColor) {
expect(mapping).toBeTypeOf("function");
} else {
expect(mapping).toBeNull();
}
}
});
it("resolves the light or dark brand color from the theme color scheme", () => {
const branded = CLIENT_FORGE_VIEW_MODULES.find((module) => module.brandColor);
expect(branded).toBeDefined();
if (!branded?.brandColor) {
return;
}
const mapping = getForgeBrandColorMapping(branded.id);
expect(mapping?.({ colorScheme: "light" } as never)).toEqual({
color: branded.brandColor.light,
});
expect(mapping?.({ colorScheme: "dark" } as never)).toEqual({
color: branded.brandColor.dark,
});
});
it("returns null for unknown icon kinds", () => {
expect(getForgeBrandColorMapping("some-unknown-forge")).toBeNull();
});
});
+72
View File
@@ -0,0 +1,72 @@
/**
* Brand marks are SVG React components, so they cannot travel over the wire as
* a string. This file derives icon and brand-color lookup tables from
* CLIENT_FORGE_VIEW_MODULES; a new forge ships an icon component in the client
* bundle and references it from its view module. Unknown icon kinds fall back to
* a generic pull-request glyph.
*/
import { withUnistyles } from "react-native-unistyles";
import { GitPullRequest } from "lucide-react-native";
import {
type ForgeBrandColor,
type ForgeIconColorMapping,
type ForgeIconComponent,
} from "@/git/client-forge-module";
import { CLIENT_FORGE_VIEW_MODULES } from "@/git/forges/view";
const FORGE_ICON_BY_KIND = new Map(
CLIENT_FORGE_VIEW_MODULES.map((module) => [module.id, module.icon]),
);
/**
* Raw brand icon component for an `iconKind`, for call sites that style with a
* plain `color` prop. Falls back to a generic pull-request glyph.
*/
export function getForgeIconComponent(iconKind: string): ForgeIconComponent {
return FORGE_ICON_BY_KIND.get(iconKind) ?? GitPullRequest;
}
const THEMED_ICON_BY_KIND = Object.fromEntries(
CLIENT_FORGE_VIEW_MODULES.map((module) => [module.id, withUnistyles(module.icon)]),
);
const ThemedGitPullRequest = withUnistyles(GitPullRequest);
/** Theme-driven color mapping, e.g. `(theme) => ({ color: theme.colors.foregroundMuted })`. */
export type { ForgeIconColorMapping };
function brandColorMapping(colors: ForgeBrandColor): ForgeIconColorMapping {
return (theme) => ({ color: theme.colorScheme === "light" ? colors.light : colors.dark });
}
const BRAND_COLOR_MAPPING_BY_KIND = new Map(
CLIENT_FORGE_VIEW_MODULES.flatMap((module) =>
module.brandColor ? [[module.id, brandColorMapping(module.brandColor)] as const] : [],
),
);
/**
* The forge's brand color mapping, or null for forges with no dedicated brand
* color (github renders in a neutral tone; an unknown forge has none). Call
* sites pair this with {@link ForgeBrandIcon}: use it to tint the icon, or to
* decide whether to show a brand badge at all.
*/
export function getForgeBrandColorMapping(iconKind: string): ForgeIconColorMapping | null {
return BRAND_COLOR_MAPPING_BY_KIND.get(iconKind) ?? null;
}
/**
* Themed brand icon for an `iconKind`, for call sites that color via a unistyles
* mapping. Falls back to a generic pull-request glyph for unknown forges.
*/
export function ForgeBrandIcon({
iconKind,
size,
uniProps,
}: {
iconKind: string;
size: number;
uniProps: ForgeIconColorMapping;
}) {
const ThemedIcon = THEMED_ICON_BY_KIND[iconKind] ?? ThemedGitPullRequest;
return <ThemedIcon size={size} uniProps={uniProps} />;
}
+205
View File
@@ -0,0 +1,205 @@
import { describe, expect, it } from "vitest";
import { buildForgeBlobUrl, buildForgeBranchTreeUrl, hasForgeWebUrls } from "./forge-url";
describe("buildForgeBranchTreeUrl", () => {
it("builds a branch-specific GitHub tree URL", () => {
expect(
buildForgeBranchTreeUrl("github", {
remoteUrl: "git@github.com:acme/repo.git",
branch: "feature/workspace-button",
}),
).toBe("https://github.com/acme/repo/tree/feature/workspace-button");
});
it("encodes reserved branch characters while preserving slash-separated branch names", () => {
expect(
buildForgeBranchTreeUrl("github", {
remoteUrl: "https://github.com/acme/repo.git",
branch: "feature/ship #42",
}),
).toBe("https://github.com/acme/repo/tree/feature/ship%20%2342");
});
it("uses the GitLab /-/tree/ infix and supports subgroups", () => {
expect(
buildForgeBranchTreeUrl("gitlab", {
remoteUrl: "https://gitlab.com/group/sub/repo.git",
branch: "main",
}),
).toBe("https://gitlab.com/group/sub/repo/-/tree/main");
});
it("uses the Gitea-family /src/branch/ infix", () => {
expect(
buildForgeBranchTreeUrl("gitea", {
remoteUrl: "https://gitea.com/acme/repo.git",
branch: "main",
}),
).toBe("https://gitea.com/acme/repo/src/branch/main");
expect(
buildForgeBranchTreeUrl("codeberg", {
remoteUrl: "https://codeberg.org/acme/repo.git",
branch: "main",
}),
).toBe("https://codeberg.org/acme/repo/src/branch/main");
});
it("returns null when the current branch is unavailable", () => {
expect(
buildForgeBranchTreeUrl("github", {
remoteUrl: "https://github.com/acme/repo.git",
branch: "HEAD",
}),
).toBeNull();
});
it("returns null for a forge with no known URL grammar", () => {
expect(
buildForgeBranchTreeUrl("bitbucket", {
remoteUrl: "https://bitbucket.org/acme/repo.git",
branch: "main",
}),
).toBeNull();
});
});
describe("buildForgeBlobUrl", () => {
it("builds a blob URL for a file path", () => {
expect(
buildForgeBlobUrl("github", {
remoteUrl: "git@github.com:acme/repo.git",
branch: "main",
path: "src/index.ts",
}),
).toBe("https://github.com/acme/repo/blob/main/src/index.ts");
});
it("appends a single-line anchor", () => {
expect(
buildForgeBlobUrl("github", {
remoteUrl: "https://github.com/acme/repo.git",
branch: "main",
path: "src/index.ts",
lineStart: 12,
}),
).toBe("https://github.com/acme/repo/blob/main/src/index.ts#L12");
});
it("appends a GitHub line range anchor (#L12-L20)", () => {
expect(
buildForgeBlobUrl("github", {
remoteUrl: "https://github.com/acme/repo.git",
branch: "main",
path: "src/index.ts",
lineStart: 12,
lineEnd: 20,
}),
).toBe("https://github.com/acme/repo/blob/main/src/index.ts#L12-L20");
});
it("uses the GitLab /-/blob/ infix and #L12-20 range anchor", () => {
expect(
buildForgeBlobUrl("gitlab", {
remoteUrl: "https://gitlab.com/group/sub/repo.git",
branch: "main",
path: "src/index.ts",
lineStart: 12,
lineEnd: 20,
}),
).toBe("https://gitlab.com/group/sub/repo/-/blob/main/src/index.ts#L12-20");
});
it("uses the Gitea-family /src/branch/ blob path with a #L12-L20 anchor", () => {
expect(
buildForgeBlobUrl("forgejo", {
remoteUrl: "https://codeberg.org/acme/repo.git",
branch: "main",
path: "src/index.ts",
lineStart: 12,
lineEnd: 20,
}),
).toBe("https://codeberg.org/acme/repo/src/branch/main/src/index.ts#L12-L20");
});
it("derives the web host from a self-hosted remote (GitHub Enterprise)", () => {
expect(
buildForgeBlobUrl("github", {
remoteUrl: "git@github.acme.internal:team/repo.git",
branch: "main",
path: "src/index.ts",
}),
).toBe("https://github.acme.internal/team/repo/blob/main/src/index.ts");
});
it("canonicalizes the github.com SSH-alias host to the web host", () => {
expect(
buildForgeBlobUrl("github", {
remoteUrl: "ssh://git@ssh.github.com/acme/repo.git",
branch: "main",
path: "src/index.ts",
}),
).toBe("https://github.com/acme/repo/blob/main/src/index.ts");
});
it("strips leading slashes and encodes path segments", () => {
expect(
buildForgeBlobUrl("github", {
remoteUrl: "https://github.com/acme/repo.git",
branch: "main",
path: "/src/a b/c#d.ts",
}),
).toBe("https://github.com/acme/repo/blob/main/src/a%20b/c%23d.ts");
});
it("normalizes harmless dot segments in the blob path", () => {
expect(
buildForgeBlobUrl("github", {
remoteUrl: "https://github.com/acme/repo.git",
branch: "main",
path: "./src/../index.ts",
}),
).toBe("https://github.com/acme/repo/blob/main/index.ts");
});
it("returns null for blob paths that escape above the repo root", () => {
expect(
buildForgeBlobUrl("github", {
remoteUrl: "https://github.com/acme/repo.git",
branch: "main",
path: "../outside.ts",
}),
).toBeNull();
});
it("returns null when the path is missing", () => {
expect(
buildForgeBlobUrl("github", {
remoteUrl: "https://github.com/acme/repo.git",
branch: "main",
path: "",
}),
).toBeNull();
});
it("returns null for a forge with no known URL grammar", () => {
expect(
buildForgeBlobUrl("bitbucket", {
remoteUrl: "https://bitbucket.org/acme/repo.git",
branch: "main",
path: "src/index.ts",
}),
).toBeNull();
});
});
describe("hasForgeWebUrls", () => {
it("is true for forges with a known URL grammar", () => {
for (const forge of ["github", "gitlab", "gitea", "forgejo", "codeberg"]) {
expect(hasForgeWebUrls(forge)).toBe(true);
}
});
it("is false for an unknown forge", () => {
expect(hasForgeWebUrls("bitbucket")).toBe(false);
});
});
+127
View File
@@ -0,0 +1,127 @@
/**
* Forge-neutral web URL builders for "Open on <forge>" actions (a file blob or a
* branch tree). The host comes from the workspace remote — not a hardcoded
* cloud host — so self-hosted and Enterprise instances link correctly. Each
* forge contributes a small URL grammar (the path infixes and line-anchor
* format); an unknown forge has no grammar and yields null, so the action is
* simply absent rather than wrong.
*
* URL grammar lives on each client forge module. The repo identity and host
* both ride the manifest's `cloudHosts` only to canonicalize a cloud SSH alias
* (e.g. ssh.github.com -> github.com); a self-hosted host is used as-is.
*/
import { getForgeDefinition } from "@getpaseo/protocol/forge-manifest";
import { normalizeHost, parseGitRemoteLocation } from "@getpaseo/protocol/git-remote";
import { getClientForgeLogicModule } from "@/git/forges";
export interface ForgeBlobUrlInput {
remoteUrl: string | null | undefined;
branch: string | null | undefined;
path: string | null | undefined;
lineStart?: number;
lineEnd?: number;
}
export interface ForgeBranchTreeUrlInput {
remoteUrl: string | null | undefined;
branch: string | null | undefined;
}
interface ForgeWebLocation {
host: string;
repo: string;
}
/**
* Web host + repo path from a remote. The host is the remote's host, except a
* cloud SSH alias (a non-first entry in the forge's `cloudHosts`, e.g.
* ssh.github.com) is canonicalized to the forge's web host (`cloudHosts[0]`).
* Self-hosted hosts are returned untouched. The repo path supports nested
* groups (e.g. GitLab subgroups) since it is the full remote path.
*/
function isValidRepoPath(path: string): boolean {
const segments = path.split("/").filter((segment) => segment.length > 0);
if (segments.length === 0) {
return false;
}
return !segments.includes("..");
}
function resolveForgeWebLocation(
forge: string,
remoteUrl: string | null | undefined,
): ForgeWebLocation | null {
if (!remoteUrl) {
return null;
}
const location = parseGitRemoteLocation(remoteUrl);
if (!location || !isValidRepoPath(location.path)) {
return null;
}
const cloudHosts = getForgeDefinition(forge)?.cloudHosts;
const webHost =
cloudHosts && cloudHosts.length > 0 && cloudHosts.map(normalizeHost).includes(location.host)
? normalizeHost(cloudHosts[0])
: location.host;
return { host: webHost, repo: location.path };
}
function encodeBranch(branch: string): string {
return branch.split("/").map(encodeURIComponent).join("/");
}
function normalizeBlobPath(path: string | null | undefined): string | null {
const segments: string[] = [];
const trimmed = path?.trim().replace(/\\/g, "/").replace(/^\/+/, "");
if (!trimmed) {
return null;
}
for (const segment of trimmed.split("/")) {
if (!segment || segment === ".") {
continue;
}
if (segment === "..") {
if (segments.length === 0) {
return null;
}
segments.pop();
continue;
}
segments.push(segment);
}
return segments.join("/") || null;
}
export function buildForgeBranchTreeUrl(
forge: string,
input: ForgeBranchTreeUrlInput,
): string | null {
const grammar = getClientForgeLogicModule(forge)?.urlGrammar;
const location = resolveForgeWebLocation(forge, input.remoteUrl);
const branch = input.branch?.trim();
if (!grammar || !location || !branch || branch === "HEAD") {
return null;
}
return `https://${location.host}/${location.repo}${grammar.treeInfix}${encodeBranch(branch)}`;
}
export function buildForgeBlobUrl(forge: string, input: ForgeBlobUrlInput): string | null {
const grammar = getClientForgeLogicModule(forge)?.urlGrammar;
const location = resolveForgeWebLocation(forge, input.remoteUrl);
const branch = input.branch?.trim();
const filePath = normalizeBlobPath(input.path);
if (!grammar || !location || !branch || branch === "HEAD" || !filePath) {
return null;
}
const encodedPath = filePath.split("/").map(encodeURIComponent).join("/");
let url = `https://${location.host}/${location.repo}${grammar.blobInfix}${encodeBranch(branch)}/${encodedPath}`;
if (input.lineStart && input.lineStart > 0) {
url += grammar.lineAnchor(input.lineStart, input.lineEnd);
}
return url;
}
/** Whether the forge has web URL builders (i.e. a known URL grammar). */
export function hasForgeWebUrls(forge: string): boolean {
return getClientForgeLogicModule(forge)?.urlGrammar !== undefined;
}
+107
View File
@@ -0,0 +1,107 @@
import { describe, expect, it } from "vitest";
import {
buildForgeSignInCommand,
forgeFromRemoteUrl,
getForgePresentation,
normalizeForge,
} from "./forge";
describe("normalizeForge", () => {
it("maps the gitlab discriminant to gitlab", () => {
expect(normalizeForge("gitlab")).toBe("gitlab");
});
it("keeps any non-empty forge id and defaults only absent values to github", () => {
expect(normalizeForge("github")).toBe("github");
expect(normalizeForge("gitea")).toBe("gitea");
expect(normalizeForge("forgejo")).toBe("forgejo");
expect(normalizeForge(undefined)).toBe("github");
expect(normalizeForge(null)).toBe("github");
// An unknown forge id is preserved (rendered neutrally), not collapsed to
// GitHub, so a newer daemon's forge degrades gracefully on an older client.
expect(normalizeForge("bitbucket")).toBe("bitbucket");
});
});
describe("getForgePresentation", () => {
it("keeps GitHub on the pull-request noun and the # prefix", () => {
const github = getForgePresentation("github");
expect(github.brandLabel).toBe("GitHub");
expect(github.changeRequestAbbrev).toBe("PR");
expect(github.numberPrefix).toBe("#");
expect(github.issueNumberPrefix).toBe("#");
expect(github.changeRequestContext).toBeUndefined();
});
it("relabels GitLab to the merge-request noun and the ! prefix", () => {
const gitlab = getForgePresentation("gitlab");
expect(gitlab.brandLabel).toBe("GitLab");
expect(gitlab.changeRequestAbbrev).toBe("MR");
expect(gitlab.numberPrefix).toBe("!");
expect(gitlab.issueNumberPrefix).toBe("#");
expect(gitlab.changeRequestContext).toBe("mr");
});
it("presents Gitea and Forgejo with GitHub nouns and the tea CLI", () => {
expect(getForgePresentation("gitea")).toMatchObject({
forge: "gitea",
icon: "gitea",
brandLabel: "Gitea",
changeRequestAbbrev: "PR",
numberPrefix: "#",
issueNumberPrefix: "#",
signInCli: "tea",
});
expect(getForgePresentation("forgejo")).toMatchObject({
forge: "forgejo",
icon: "forgejo",
brandLabel: "Forgejo",
changeRequestAbbrev: "PR",
signInCli: "tea",
});
expect(getForgePresentation("codeberg")).toMatchObject({
forge: "codeberg",
icon: "codeberg",
brandLabel: "Codeberg",
changeRequestAbbrev: "PR",
signInCli: "tea",
});
});
});
describe("forgeFromRemoteUrl", () => {
it("detects only public forge hosts that are safe without daemon probing", () => {
expect(forgeFromRemoteUrl("https://codeberg.org/example/repo.git")).toBe("codeberg");
expect(forgeFromRemoteUrl("https://gitlab.com/example/repo.git")).toBe("gitlab");
expect(forgeFromRemoteUrl("https://gitea.com/example/repo.git")).toBe("gitea");
});
it("does not classify self-managed hosts by substring", () => {
expect(forgeFromRemoteUrl("git@gitlab.example.org:example/repo.git")).toBeNull();
expect(forgeFromRemoteUrl("git@forgejo.example.org:example/repo.git")).toBeNull();
expect(forgeFromRemoteUrl("https://notgitlab.example.org/example/repo.git")).toBeNull();
});
});
describe("buildForgeSignInCommand", () => {
it("uses tea login add for both Gitea-family presentations", () => {
expect(buildForgeSignInCommand("gitea", "gitea.com")).toBe("tea login add");
expect(buildForgeSignInCommand("forgejo", "forgejo.example.org")).toBe("tea login add");
expect(buildForgeSignInCommand("codeberg", "codeberg.org")).toBe("tea login add");
});
it("uses plain gh auth login for GitHub (incl. the ssh.github.com endpoint)", () => {
expect(buildForgeSignInCommand("github", "github.com")).toBe("gh auth login");
expect(buildForgeSignInCommand("github", "ssh.github.com")).toBe("gh auth login");
});
it("targets the workspace host for self-hosted GitLab", () => {
expect(buildForgeSignInCommand("gitlab", "gitlab.acme.com")).toBe(
"glab auth login --hostname gitlab.acme.com",
);
});
it("returns no sign-in command for an unknown forge with no known CLI", () => {
expect(buildForgeSignInCommand("bitbucket", "bitbucket.org")).toBeNull();
});
});
+126
View File
@@ -0,0 +1,126 @@
/**
* Forge-neutral presentation layer for the git-hosting UI, derived from the
* shared forge manifest.
*
* The daemon resolves which forge backs a workspace and reports it on the wire
* (`CheckoutPrStatusResponse.payload.forge`). The model keeps the `PullRequest`
* noun in code and types; the PR↔MR relabel, the number prefix, the brand mark,
* and the icon are a UI concern driven by the manifest entry for that id. An
* unknown/absent forge (e.g. a self-hosted forge a newer daemon reports to an
* older client) renders neutrally — never GitHub-branded. A null/empty forge
* still maps to GitHub so old daemons (which never send a forge) render exactly
* as before.
*/
import { FORGE_DEFINITIONS, getForgeDefinitionOrNeutral } from "@getpaseo/protocol/forge-manifest";
import { normalizeHost, parseGitRemoteLocation } from "@getpaseo/protocol/git-remote";
import type { ForgeAuthState } from "@getpaseo/protocol/messages";
import {
buildForgeBlobUrl,
buildForgeBranchTreeUrl,
hasForgeWebUrls,
type ForgeBlobUrlInput,
type ForgeBranchTreeUrlInput,
} from "@/git/forge-url";
/**
* A forge id. Open by design: any id the daemon reports is valid, and the
* manifest drives presentation with a neutral fallback for unknown ids. Kept as
* a named alias to document intent at call sites.
*/
export type Forge = string;
export function normalizeForge(raw: string | null | undefined): string {
return raw && raw.length > 0 ? raw : "github";
}
export function parseForgeAuthState(value: unknown): ForgeAuthState | undefined {
switch (value) {
case "authenticated":
case "unauthenticated":
case "cli_missing":
case "no_remote":
case "error":
return value;
default:
return undefined;
}
}
export function forgeFromRemoteUrl(remoteUrl: string | null | undefined): string | null {
if (!remoteUrl) {
return null;
}
const host = parseGitRemoteLocation(remoteUrl)?.host;
if (!host) {
return null;
}
const normalized = normalizeHost(host);
for (const definition of FORGE_DEFINITIONS) {
if (definition.cloudHosts?.some((cloudHost) => normalizeHost(cloudHost) === normalized)) {
return definition.id;
}
}
return null;
}
export interface ForgePresentation {
forge: string;
/** Icon key; render sites fall back to a generic git icon for unknown values. */
icon: string;
/** Human brand name, e.g. for "Open on GitLab". */
brandLabel: string;
/** Short change-request noun: "PR" for GitHub, "MR" for GitLab. */
changeRequestAbbrev: string;
/** Full change-request noun: "pull request" for GitHub, "merge request" for GitLab. */
changeRequestNoun: string;
/** Prefix the forge puts before a change-request number: "#" vs "!". */
numberPrefix: string;
/** Prefix the forge puts before an issue number ("#" on every forge so far). */
issueNumberPrefix: string;
/** Auth CLI binary for the install hint, or null for a forge with no Paseo-driven sign-in. */
signInCli: string | null;
/**
* i18next context selecting the change-request vocabulary family for any key
* that carries an `_mr` variant: `t(key, { context: changeRequestContext })`
* resolves `key_mr` for the merge-request family and falls back to the base
* (pull-request) string for undefined or unknown families.
*/
changeRequestContext: "mr" | undefined;
buildBlobUrl: ((input: ForgeBlobUrlInput) => string | null) | null;
buildBranchTreeUrl: ((input: ForgeBranchTreeUrlInput) => string | null) | null;
}
export function getForgePresentation(forge: string): ForgePresentation {
const definition = getForgeDefinitionOrNeutral(forge);
const isMergeRequest = definition.changeRequestAbbrev === "MR";
const hasWebUrls = hasForgeWebUrls(definition.id);
return {
forge: definition.id,
icon: definition.iconKind,
brandLabel: definition.displayName,
changeRequestAbbrev: definition.changeRequestAbbrev,
changeRequestNoun: definition.changeRequestNoun,
numberPrefix: definition.changeRequestNumberPrefix,
issueNumberPrefix: definition.issueNumberPrefix,
signInCli: definition.signIn?.cli ?? null,
changeRequestContext: isMergeRequest ? "mr" : undefined,
buildBlobUrl: hasWebUrls ? (input) => buildForgeBlobUrl(definition.id, input) : null,
buildBranchTreeUrl: hasWebUrls
? (input) => buildForgeBranchTreeUrl(definition.id, input)
: null,
};
}
export function buildForgeSignInCommand(forge: string, host: string | null): string | null {
const signIn = getForgeDefinitionOrNeutral(forge).signIn;
if (!signIn) {
return null;
}
// A forge that needs a --hostname only gets it when a host is known; forges
// whose command already targets the right API host (e.g. github's plain
// `gh auth login`, which must NOT receive ssh.github.com) omit hostnameFlag.
if (signIn.hostnameFlag && host) {
return `${signIn.command} ${signIn.hostnameFlag} ${host}`;
}
return signIn.command;
}
+6
View File
@@ -0,0 +1,6 @@
import { GITEA_FAMILY_URL_GRAMMAR, type ClientForgeLogicModule } from "@/git/client-forge-module";
export const codebergForgeLogic = {
id: "codeberg",
urlGrammar: GITEA_FAMILY_URL_GRAMMAR,
} satisfies ClientForgeLogicModule;
@@ -0,0 +1,11 @@
import { CodebergIcon } from "@/components/icons/codeberg-icon";
import type { ClientForgeViewModule } from "@/git/client-forge-module";
export const codebergForgeView = {
id: "codeberg",
icon: CodebergIcon,
brandColor: {
light: "#2185D0",
dark: "#2185D0",
},
} satisfies ClientForgeViewModule;
+6
View File
@@ -0,0 +1,6 @@
import { GITEA_FAMILY_URL_GRAMMAR, type ClientForgeLogicModule } from "@/git/client-forge-module";
export const forgejoForgeLogic = {
id: "forgejo",
urlGrammar: GITEA_FAMILY_URL_GRAMMAR,
} satisfies ClientForgeLogicModule;
@@ -0,0 +1,11 @@
import { ForgejoIcon } from "@/components/icons/forgejo-icon";
import type { ClientForgeViewModule } from "@/git/client-forge-module";
export const forgejoForgeView = {
id: "forgejo",
icon: ForgejoIcon,
brandColor: {
light: "#FB923C",
dark: "#FB923C",
},
} satisfies ClientForgeViewModule;
+72
View File
@@ -0,0 +1,72 @@
import { z } from "zod";
import {
defineForgeFacts,
defineNativeFallbackCheck,
GITEA_FAMILY_URL_GRAMMAR,
type ClientForgeLogicModule,
type MergeCapability,
} from "@/git/client-forge-module";
import type { CheckoutPrMergeMethod } from "@getpaseo/protocol/messages";
import { mapCheckStatus, type CheckStatus } from "@/git/pull-request-panel/check-status";
const GiteaMergeFactsSchema = z
.object({
forge: z.literal("gitea"),
mergeable: z.boolean().optional().default(false),
hasMerged: z.boolean().optional().default(false),
ciStatus: z.string().nullable().optional().default(null),
})
.passthrough();
type GiteaMergeFacts = z.infer<typeof GiteaMergeFactsSchema>;
// forgeSpecific.ciStatus carries Gitea's raw aggregate CI string. Server twin:
// packages/server/src/services/gitea-service.ts (mapGiteaCommitStatus) — "warning"
// and "error" are terminal, non-passing states, but the generic mapCheckStatus
// would show them as pending, so interpret them here where the module owns Gitea
// facts.
function mapGiteaCiStatus(ciStatus: string): CheckStatus {
if (ciStatus === "warning" || ciStatus === "error") {
return "failure";
}
return mapCheckStatus(ciStatus);
}
const GITEA_MERGE_METHODS: CheckoutPrMergeMethod[] = ["merge", "squash", "rebase"];
function deriveGiteaMergeCapability(gitea: GiteaMergeFacts): MergeCapability {
return {
directMergeReady: gitea.mergeable && !gitea.hasMerged,
canEnableAutoMerge: false,
autoMergeEnabled: false,
canDisableAutoMerge: false,
mergeBlockedByQueue: false,
allowedMethods: GITEA_MERGE_METHODS,
preferredMethod: null,
};
}
export const giteaForgeLogic = {
id: "gitea",
urlGrammar: GITEA_FAMILY_URL_GRAMMAR,
facts: defineForgeFacts({
family: "gitea",
schema: GiteaMergeFactsSchema,
deriveMergeCapability: deriveGiteaMergeCapability,
nativeFallbackChecks: [
defineNativeFallbackCheck(GiteaMergeFactsSchema, {
contribute: (facts, status, forge) => {
if (!facts.ciStatus) {
return null;
}
return {
provider: forge,
name: "CI",
status: mapGiteaCiStatus(facts.ciStatus),
url: status.url,
};
},
}),
],
}),
} satisfies ClientForgeLogicModule<GiteaMergeFacts>;
@@ -0,0 +1,11 @@
import { GiteaIcon } from "@/components/icons/gitea-icon";
import type { ClientForgeViewModule } from "@/git/client-forge-module";
export const giteaForgeView = {
id: "gitea",
icon: GiteaIcon,
brandColor: {
light: "#609926",
dark: "#609926",
},
} satisfies ClientForgeViewModule;
+95
View File
@@ -0,0 +1,95 @@
import { z } from "zod";
import {
defineForgeFacts,
GITHUB_LINE_ANCHOR,
type ClientForgeLogicModule,
type MergeCapability,
} from "@/git/client-forge-module";
import type { CheckoutPrMergeMethod } from "@getpaseo/protocol/messages";
const GithubAutoMergeRequestSchema = z
.object({
enabledAt: z.string().nullable().optional().default(null),
mergeMethod: z.string().nullable().optional().default(null),
enabledBy: z.string().nullable().optional().default(null),
})
.nullable()
.optional()
.default(null);
const GithubRepositoryPolicySchema = z
.object({
autoMergeAllowed: z.boolean().optional().default(false),
mergeCommitAllowed: z.boolean().optional().default(false),
squashMergeAllowed: z.boolean().optional().default(false),
rebaseMergeAllowed: z.boolean().optional().default(false),
viewerDefaultMergeMethod: z.string().nullable().optional().default(null),
})
.optional()
.default({
autoMergeAllowed: false,
mergeCommitAllowed: false,
squashMergeAllowed: false,
rebaseMergeAllowed: false,
viewerDefaultMergeMethod: null,
});
const GithubMergeFactsSchema = z
.object({
forge: z.literal("github"),
mergeStateStatus: z.string().nullable().optional().default(null),
autoMergeRequest: GithubAutoMergeRequestSchema,
viewerCanEnableAutoMerge: z.boolean().optional().default(false),
viewerCanDisableAutoMerge: z.boolean().optional().default(false),
viewerCanMergeAsAdmin: z.boolean().optional().default(false),
viewerCanUpdateBranch: z.boolean().optional().default(false),
repository: GithubRepositoryPolicySchema,
isMergeQueueEnabled: z.boolean().optional().default(false),
isInMergeQueue: z.boolean().optional().default(false),
})
.passthrough();
type GithubMergeFacts = z.infer<typeof GithubMergeFactsSchema>;
const GITHUB_DIRECT_MERGE_STATE_ALLOWLIST = new Set(["CLEAN", "HAS_HOOKS"]);
function normalizeGithubMergeMethod(value: string | null): CheckoutPrMergeMethod | null {
if (value === "SQUASH") return "squash";
if (value === "MERGE") return "merge";
if (value === "REBASE") return "rebase";
return null;
}
function deriveGithubMergeCapability(github: GithubMergeFacts): MergeCapability {
const repository = github.repository;
const allowedMethods: CheckoutPrMergeMethod[] = [];
if (repository.mergeCommitAllowed) allowedMethods.push("merge");
if (repository.squashMergeAllowed) allowedMethods.push("squash");
if (repository.rebaseMergeAllowed) allowedMethods.push("rebase");
return {
directMergeReady: GITHUB_DIRECT_MERGE_STATE_ALLOWLIST.has(github.mergeStateStatus ?? ""),
canEnableAutoMerge:
github.mergeStateStatus === "BLOCKED" &&
repository.autoMergeAllowed &&
github.viewerCanEnableAutoMerge,
autoMergeEnabled: github.autoMergeRequest !== null,
canDisableAutoMerge: github.viewerCanDisableAutoMerge === true,
mergeBlockedByQueue: github.isMergeQueueEnabled || github.isInMergeQueue,
allowedMethods,
preferredMethod: normalizeGithubMergeMethod(repository.viewerDefaultMergeMethod ?? null),
};
}
export const githubForgeLogic = {
id: "github",
urlGrammar: {
treeInfix: "/tree/",
blobInfix: "/blob/",
lineAnchor: GITHUB_LINE_ANCHOR,
},
facts: defineForgeFacts({
family: "github",
schema: GithubMergeFactsSchema,
deriveMergeCapability: deriveGithubMergeCapability,
}),
} satisfies ClientForgeLogicModule<GithubMergeFacts>;
@@ -0,0 +1,8 @@
import { GitHubIcon } from "@/components/icons/github-icon";
import type { ClientForgeViewModule } from "@/git/client-forge-module";
export const githubForgeView = {
id: "github",
icon: GitHubIcon,
brandColor: null,
} satisfies ClientForgeViewModule;
+155
View File
@@ -0,0 +1,155 @@
import { z } from "zod";
import {
defineForgeFacts,
type ClientForgeLogicModule,
type MergeCapability,
} from "@/git/client-forge-module";
import type { CheckoutPrMergeMethod } from "@getpaseo/protocol/messages";
import type { CheckStatus } from "@/git/pull-request-panel/check-status";
const gitlabLineAnchor = (start: number, end?: number): string =>
end && end > start ? `#L${start}-${end}` : `#L${start}`;
/**
* Canonical set of GitLab pipeline statuses that count as "still active" (a
* pipeline that has not reached a terminal state). Server-side twin:
* packages/server/src/services/gitlab-facts.ts (GITLAB_ACTIVE_PIPELINE_STATUSES),
* duplicated because the app can't depend on the server package.
*/
const GITLAB_ACTIVE_PIPELINE_STATUSES = [
"created",
"waiting_for_resource",
"preparing",
"pending",
"running",
"scheduled",
] as const;
const GITLAB_ACTIVE_PIPELINE_STATUS_SET = new Set<string>(GITLAB_ACTIVE_PIPELINE_STATUSES);
export function isPipelineActiveStatus(status: string): boolean {
return GITLAB_ACTIVE_PIPELINE_STATUS_SET.has(status);
}
export function mapPipelineStatus(status: string): CheckStatus {
switch (status) {
case "success":
case "passed":
return "success";
case "failed":
return "failure";
case "running":
case "pending":
case "created":
case "waiting_for_resource":
case "preparing":
case "scheduled":
case "manual":
return "pending";
case "canceled":
case "cancelled":
case "skipped":
return "skipped";
default:
return "pending";
}
}
const GitlabMergeFactsSchema = z
.object({
forge: z.literal("gitlab"),
detailedMergeStatus: z.string().nullable().optional().default(null),
mergeStatus: z.string().nullable().optional().default(null),
hasConflicts: z.boolean().optional().default(false),
blockingDiscussionsResolved: z.boolean().optional().default(true),
approvalsRequired: z.number().optional().default(0),
approvalsGiven: z.number().optional().default(0),
pipelineStatus: z.string().nullable().optional().default(null),
pipelineId: z.number().nullable().optional().default(null),
pipelineUrl: z.string().nullable().optional().default(null),
mergeWhenPipelineSucceeds: z.boolean().optional().default(false),
})
.passthrough();
export type GitlabMergeFacts = z.infer<typeof GitlabMergeFactsSchema>;
export { GitlabMergeFactsSchema };
const GITLAB_MERGEABLE_STATUS = "mergeable";
const GITLAB_LEGACY_MERGEABLE_STATUS = "can_be_merged";
const GITLAB_MERGE_METHODS: CheckoutPrMergeMethod[] = ["merge", "squash", "rebase"];
/**
* Direct-merge readiness from GitLab's merge signals. `detailedMergeStatus` is
* GitLab 15.6+ only; when it is absent (older self-managed instances) fall back
* to the legacy `mergeStatus === "can_be_merged"` with no conflicts so merge is
* not silently refused everywhere.
*/
function isGitlabDirectMergeReady(gitlab: GitlabMergeFacts): boolean {
if (gitlab.detailedMergeStatus != null) {
return gitlab.detailedMergeStatus === GITLAB_MERGEABLE_STATUS;
}
return gitlab.mergeStatus === GITLAB_LEGACY_MERGEABLE_STATUS && gitlab.hasConflicts !== true;
}
function deriveGitlabMergeCapability(gitlab: GitlabMergeFacts): MergeCapability {
const autoMergeEnabled = gitlab.mergeWhenPipelineSucceeds === true;
const hasActivePipeline =
gitlab.pipelineStatus !== null && isPipelineActiveStatus(gitlab.pipelineStatus);
return {
directMergeReady: isGitlabDirectMergeReady(gitlab),
canEnableAutoMerge: !autoMergeEnabled && hasActivePipeline,
autoMergeEnabled,
canDisableAutoMerge: autoMergeEnabled,
mergeBlockedByQueue: false,
allowedMethods: GITLAB_MERGE_METHODS,
preferredMethod: null,
};
}
export interface GitlabPipelineSummary {
id: number;
status: CheckStatus;
rawStatus: string;
url: string | null;
}
export interface GitlabApprovals {
given: number;
required: number;
}
export function deriveGitlabPipelineSummary(facts: GitlabMergeFacts): GitlabPipelineSummary | null {
if (facts.pipelineId == null) {
return null;
}
const rawStatus = facts.pipelineStatus ?? "";
return {
id: facts.pipelineId,
status: mapPipelineStatus(rawStatus),
rawStatus,
url: facts.pipelineUrl ?? null,
};
}
export function deriveGitlabApprovals(facts: GitlabMergeFacts): GitlabApprovals | null {
const required = facts.approvalsRequired ?? 0;
if (required <= 0) {
return null;
}
return { given: facts.approvalsGiven ?? 0, required };
}
export const gitlabForgeLogic = {
id: "gitlab",
urlGrammar: {
treeInfix: "/-/tree/",
blobInfix: "/-/blob/",
lineAnchor: gitlabLineAnchor,
},
facts: defineForgeFacts({
family: "gitlab",
schema: GitlabMergeFactsSchema,
deriveMergeCapability: deriveGitlabMergeCapability,
}),
} satisfies ClientForgeLogicModule<GitlabMergeFacts>;
+328
View File
@@ -0,0 +1,328 @@
import React, { useCallback, useMemo, type ReactNode } from "react";
import { Pressable, Text, View } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { CircleCheck, ExternalLink } from "lucide-react-native";
import { useTranslation } from "react-i18next";
import type { CheckoutPipelineJob, CheckoutPipelineStage } from "@getpaseo/protocol/messages";
import { GitLabIcon } from "@/components/icons/gitlab-icon";
import {
definePaneContribution,
type ClientForgeViewModule,
type PaneChecksSlotContext,
} from "@/git/client-forge-module";
import { openExternalUrl } from "@/utils/open-external-url";
import { formatDuration } from "@/utils/time";
import {
CheckStatusIcon,
Section,
SUMMARY_DANGER_ICON,
SUMMARY_SUCCESS_ICON,
SUMMARY_WARNING_ICON,
SummaryPill,
foregroundMutedColorMapping,
sectionKitStyles,
successColorMapping,
} from "@/git/pull-request-panel/section-kit";
import { useGitLabPipeline } from "@/git/pull-request-panel/use-pipeline";
import {
deriveGitlabApprovals,
deriveGitlabPipelineSummary,
GitlabMergeFactsSchema,
isPipelineActiveStatus,
mapPipelineStatus,
type GitlabApprovals,
type GitlabMergeFacts,
type GitlabPipelineSummary,
} from "./gitlab";
function renderGitlabHeaderMeta(facts: GitlabMergeFacts): ReactNode {
const approvals = deriveGitlabApprovals(facts);
if (!approvals) {
return null;
}
return <GitlabApprovalsBadge approvals={approvals} />;
}
function renderGitlabChecksSection(facts: GitlabMergeFacts, ctx: PaneChecksSlotContext): ReactNode {
if (!ctx.enabled) {
return null;
}
const summary = deriveGitlabPipelineSummary(facts);
if (!summary) {
return null;
}
return (
<GitLabPipelineSection
serverId={ctx.serverId}
cwd={ctx.cwd}
changeRequestNumber={ctx.changeRequestNumber}
summary={summary}
open={ctx.open}
onToggle={ctx.onToggle}
canFetchCheckDetails={ctx.canFetchCheckDetails}
/>
);
}
const ThemedCircleCheck = withUnistyles(CircleCheck);
const ThemedExternalLink = withUnistyles(ExternalLink);
function GitlabApprovalsBadge({ approvals }: { approvals: GitlabApprovals }) {
const { t } = useTranslation();
return (
<View style={styles.approvalsBadge} testID="pr-pane-approvals">
<ThemedCircleCheck
size={11}
uniProps={
approvals.given >= approvals.required ? successColorMapping : foregroundMutedColorMapping
}
/>
<Text style={styles.approvalsText}>
{t("workspace.git.pr.approvals", { given: approvals.given, required: approvals.required })}
</Text>
</View>
);
}
function rowPressableStyle({ hovered }: { hovered?: boolean }) {
return [sectionKitStyles.checkRow, Boolean(hovered) && styles.hoverable];
}
function jobRowPressableStyle({ hovered }: { hovered?: boolean }) {
return [styles.pipelineJobRow, Boolean(hovered) && styles.hoverable];
}
function formatPipelineDuration(seconds: number | null): string {
if (seconds === null || seconds <= 0) {
return "";
}
return formatDuration(seconds * 1000);
}
interface PipelineJobCounts {
passed: number;
failed: number;
pending: number;
}
function countPipelineJobs(jobs: CheckoutPipelineJob[]): PipelineJobCounts {
const counts: PipelineJobCounts = { passed: 0, failed: 0, pending: 0 };
for (const job of jobs) {
const status = mapPipelineStatus(job.status);
if (status === "success") counts.passed += 1;
else if (status === "failure") counts.failed += 1;
else if (status === "pending") counts.pending += 1;
}
return counts;
}
function GitLabPipelineSection({
serverId,
cwd,
changeRequestNumber,
summary,
open,
onToggle,
canFetchCheckDetails,
}: {
serverId: string;
cwd: string;
changeRequestNumber: number;
summary: GitlabPipelineSummary;
open: boolean;
onToggle: () => void;
canFetchCheckDetails: boolean;
}) {
const { t } = useTranslation();
const { pipeline, isLoading, isPlaceholderData, error } = useGitLabPipeline({
serverId,
cwd,
pipelineId: summary.id,
changeRequestNumber,
enabled: open && canFetchCheckDetails,
live: isPipelineActiveStatus(summary.rawStatus),
});
const counts = useMemo(
() => countPipelineJobs((pipeline?.stages ?? []).flatMap((stage) => stage.jobs)),
[pipeline],
);
const totalCounted = counts.passed + counts.failed + counts.pending;
const showBreakdown = !isPlaceholderData && totalCounted > 0;
const displayCounts = showBreakdown ? counts : { passed: 0, failed: 0, pending: 0 };
const handleOpenPipeline = useCallback(() => {
if (summary.url) {
void openExternalUrl(summary.url);
}
}, [summary.url]);
const sectionSummary = (
<>
<SummaryPill
count={displayCounts.passed}
icon={SUMMARY_SUCCESS_ICON}
variant="success"
testID="pr-pane-pipeline-passed"
/>
<SummaryPill
count={displayCounts.failed}
icon={SUMMARY_DANGER_ICON}
variant="danger"
testID="pr-pane-pipeline-failed"
/>
<SummaryPill
count={displayCounts.pending}
icon={SUMMARY_WARNING_ICON}
variant="warning"
testID="pr-pane-pipeline-pending"
/>
{showBreakdown ? null : <CheckStatusIcon status={summary.status} />}
</>
);
return (
<Section
title={t("workspace.git.pr.sections.pipeline")}
open={open}
onToggle={onToggle}
summary={sectionSummary}
>
<Pressable
onPress={handleOpenPipeline}
style={rowPressableStyle}
disabled={!summary.url}
testID="pr-pane-pipeline-link"
>
<CheckStatusIcon status={summary.status} />
<Text style={sectionKitStyles.checkName} numberOfLines={1}>
{`Pipeline #${summary.id}`}
</Text>
{summary.rawStatus ? (
<Text style={sectionKitStyles.checkWorkflow} numberOfLines={1}>
{summary.rawStatus}
</Text>
) : null}
{summary.url ? (
<View style={sectionKitStyles.checkTrailing}>
<ThemedExternalLink size={12} uniProps={foregroundMutedColorMapping} />
</View>
) : null}
</Pressable>
{isLoading ? (
<Text style={sectionKitStyles.emptyText}>
{t("workspace.git.pr.empty.loadingPipeline")}
</Text>
) : null}
{!isLoading && pipeline && pipeline.stages.length === 0 ? (
<Text style={sectionKitStyles.emptyText}>{t("workspace.git.pr.empty.noJobs")}</Text>
) : null}
{!isLoading && pipeline && pipeline.stages.length > 0
? pipeline.stages.map((stage) => <PipelineStageGroup key={stage.name} stage={stage} />)
: null}
{!isLoading && !pipeline && error ? (
<Text style={sectionKitStyles.emptyText}>
{t("workspace.git.pr.empty.pipelineJobsLoadFailed")}
</Text>
) : null}
</Section>
);
}
function PipelineStageGroup({ stage }: { stage: CheckoutPipelineStage }) {
return (
<View>
<View style={styles.pipelineStageHeader}>
<CheckStatusIcon status={mapPipelineStatus(stage.status)} />
<Text style={styles.pipelineStageName} numberOfLines={1}>
{stage.name}
</Text>
</View>
{stage.jobs.map((job) => (
<PipelineJobRow key={job.id} job={job} />
))}
</View>
);
}
function PipelineJobRow({ job }: { job: CheckoutPipelineJob }) {
const { t } = useTranslation();
const handlePress = useCallback(() => {
if (job.url) {
void openExternalUrl(job.url);
}
}, [job.url]);
const duration = formatPipelineDuration(job.durationSeconds);
return (
<Pressable onPress={handlePress} style={jobRowPressableStyle} disabled={!job.url}>
<CheckStatusIcon status={mapPipelineStatus(job.status)} />
<Text style={sectionKitStyles.checkName} numberOfLines={1}>
{job.name}
</Text>
{job.allowFailure ? (
<Text style={sectionKitStyles.checkWorkflow} numberOfLines={1}>
{t("workspace.git.pr.empty.allowedToFail")}
</Text>
) : null}
<View style={sectionKitStyles.checkTrailing}>
{duration ? <Text style={sectionKitStyles.checkDuration}>{duration}</Text> : null}
</View>
</Pressable>
);
}
export const gitlabForgeView = {
id: "gitlab",
icon: GitLabIcon,
brandColor: {
light: "#FC6D26",
dark: "#FC6D26",
},
paneContributions: [
definePaneContribution(GitlabMergeFactsSchema, {
renderHeaderMeta: renderGitlabHeaderMeta,
renderChecksSection: renderGitlabChecksSection,
}),
],
} satisfies ClientForgeViewModule;
const styles = StyleSheet.create((theme) => ({
approvalsBadge: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
},
approvalsText: {
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
},
hoverable: {
backgroundColor: theme.colors.surface1,
},
pipelineStageHeader: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
paddingTop: theme.spacing[2],
paddingBottom: theme.spacing[1],
},
pipelineStageName: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.medium,
color: theme.colors.foregroundMuted,
textTransform: "uppercase",
letterSpacing: 0.5,
flexShrink: 1,
},
pipelineJobRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
paddingVertical: theme.spacing[2],
paddingRight: theme.spacing[3],
paddingLeft: theme.spacing[6],
minHeight: 32,
},
}));
+59
View File
@@ -0,0 +1,59 @@
import { readdirSync } from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { CLIENT_FORGE_LOGIC_MODULES } from "./index";
import { CLIENT_FORGE_VIEW_MODULES } from "./view";
/**
* Adding a forge is "create forges/<id>.ts (logic) + forges/<id>.view.tsx (view)
* + register one line in each registry". This test removes the only failure mode
* of the explicit registries — forgetting a line — without resorting to
* bundler-specific auto-discovery that Metro, Vite, and tsgo would each need to
* understand differently. The logic/view split keeps logic consumers (URL
* builders, merge-capability, native checks, and the Node e2e harness) free of
* the client rendering stack.
*/
const forgesDir = path.dirname(new URL(import.meta.url).pathname);
const dirEntries = readdirSync(forgesDir);
const logicModuleIds = dirEntries
.filter((file) => /^[a-z0-9-]+\.ts$/.test(file) && file !== "index.ts" && file !== "view.ts")
.map((file) => file.replace(/\.ts$/, ""))
.sort();
const viewModuleIds = dirEntries
.filter((file) => /^[a-z0-9-]+\.view\.tsx$/.test(file))
.map((file) => file.replace(/\.view\.tsx$/, ""))
.sort();
describe("CLIENT_FORGE_LOGIC_MODULES completeness", () => {
it("registers every forge logic module file in the directory", () => {
const registeredIds = CLIENT_FORGE_LOGIC_MODULES.map((module) => module.id).sort();
expect(registeredIds).toEqual(logicModuleIds);
});
it("has no duplicate forge ids", () => {
const ids = CLIENT_FORGE_LOGIC_MODULES.map((module) => module.id);
expect(new Set(ids).size).toBe(ids.length);
});
});
describe("CLIENT_FORGE_VIEW_MODULES completeness", () => {
it("registers every forge view module file in the directory", () => {
const registeredIds = CLIENT_FORGE_VIEW_MODULES.map((module) => module.id).sort();
expect(registeredIds).toEqual(viewModuleIds);
});
it("has no duplicate forge ids", () => {
const ids = CLIENT_FORGE_VIEW_MODULES.map((module) => module.id);
expect(new Set(ids).size).toBe(ids.length);
});
});
describe("forge logic and view registries stay paired", () => {
it("has a view module for every logic module and vice versa", () => {
const logicIds = CLIENT_FORGE_LOGIC_MODULES.map((module) => module.id).sort();
const viewIds = CLIENT_FORGE_VIEW_MODULES.map((module) => module.id).sort();
expect(logicIds).toEqual(viewIds);
});
});
+34
View File
@@ -0,0 +1,34 @@
import type { ClientForgeLogicModule, ForgeSpecificEnvelope } from "@/git/client-forge-module";
import { codebergForgeLogic } from "./codeberg";
import { forgejoForgeLogic } from "./forgejo";
import { giteaForgeLogic } from "./gitea";
import { githubForgeLogic } from "./github";
import { gitlabForgeLogic } from "./gitlab";
/**
* Pure logic registry. Import this (never the view registry) from URL builders,
* merge-capability, and native-check derivations so those paths — and the
* Node-based e2e harness that transitively imports them — stay free of the
* client rendering stack (react-native, react-native-svg, unistyles).
*/
export const CLIENT_FORGE_LOGIC_MODULES: readonly ClientForgeLogicModule[] = [
githubForgeLogic,
gitlabForgeLogic,
giteaForgeLogic,
forgejoForgeLogic,
codebergForgeLogic,
];
export function getClientForgeLogicModule(id: string): ClientForgeLogicModule | null {
return CLIENT_FORGE_LOGIC_MODULES.find((module) => module.id === id) ?? null;
}
export function parseClientForgeFacts(facts: unknown): ForgeSpecificEnvelope | null {
for (const module of CLIENT_FORGE_LOGIC_MODULES) {
const parsed = module.facts?.parse(facts);
if (parsed) {
return parsed;
}
}
return null;
}
+23
View File
@@ -0,0 +1,23 @@
import type { ClientForgeViewModule } from "@/git/client-forge-module";
import { codebergForgeView } from "./codeberg.view";
import { forgejoForgeView } from "./forgejo.view";
import { giteaForgeView } from "./gitea.view";
import { githubForgeView } from "./github.view";
import { gitlabForgeView } from "./gitlab.view";
/**
* View registry: brand marks, brand colors, and PR-pane render contributions.
* Import this only from rendering code (icon lookup, the PR pane); it pulls the
* client rendering stack, so logic paths must use the logic registry instead.
*/
export const CLIENT_FORGE_VIEW_MODULES: readonly ClientForgeViewModule[] = [
githubForgeView,
gitlabForgeView,
giteaForgeView,
forgejoForgeView,
codebergForgeView,
];
export function getClientForgeViewModule(id: string): ClientForgeViewModule | null {
return CLIENT_FORGE_VIEW_MODULES.find((module) => module.id === id) ?? null;
}
-132
View File
@@ -1,132 +0,0 @@
import { describe, expect, it } from "vitest";
import {
buildGitHubBlobUrl,
buildGitHubBranchTreeUrl,
parseGitHubRepoFromRemote,
} from "./github-url";
describe("parseGitHubRepoFromRemote", () => {
it.each([
["https://github.com/acme/repo.git", "acme/repo"],
["https://github.com/acme/repo", "acme/repo"],
["http://github.com/acme/repo.git", "acme/repo"],
["git@github.com:acme/repo.git", "acme/repo"],
["ssh://git@github.com/acme/repo.git", "acme/repo"],
["ssh://git@ssh.github.com/acme/repo.git", "acme/repo"],
["https://github.com/acme/repo/", "acme/repo"],
])("extracts the repo from %s", (remoteUrl, expected) => {
expect(parseGitHubRepoFromRemote(remoteUrl)).toBe(expected);
});
it("returns null for non-GitHub remotes", () => {
expect(parseGitHubRepoFromRemote("git@gitlab.com:acme/repo.git")).toBeNull();
});
it("returns null for invalid URLs", () => {
expect(parseGitHubRepoFromRemote("not a url")).toBeNull();
});
});
describe("buildGitHubBranchTreeUrl", () => {
it("builds a branch-specific GitHub tree URL", () => {
expect(
buildGitHubBranchTreeUrl({
remoteUrl: "git@github.com:acme/repo.git",
branch: "feature/workspace-button",
}),
).toBe("https://github.com/acme/repo/tree/feature/workspace-button");
});
it("encodes reserved branch characters while preserving slash-separated branch names", () => {
expect(
buildGitHubBranchTreeUrl({
remoteUrl: "https://github.com/acme/repo.git",
branch: "feature/ship #42",
}),
).toBe("https://github.com/acme/repo/tree/feature/ship%20%2342");
});
it("returns null when the current branch is unavailable", () => {
expect(
buildGitHubBranchTreeUrl({
remoteUrl: "https://github.com/acme/repo.git",
branch: "HEAD",
}),
).toBeNull();
});
});
describe("buildGitHubBlobUrl", () => {
it("builds a blob URL for a file path", () => {
expect(
buildGitHubBlobUrl({
remoteUrl: "git@github.com:acme/repo.git",
branch: "main",
path: "src/index.ts",
}),
).toBe("https://github.com/acme/repo/blob/main/src/index.ts");
});
it("appends a single-line anchor", () => {
expect(
buildGitHubBlobUrl({
remoteUrl: "https://github.com/acme/repo.git",
branch: "main",
path: "src/index.ts",
lineStart: 12,
}),
).toBe("https://github.com/acme/repo/blob/main/src/index.ts#L12");
});
it("appends a line range anchor", () => {
expect(
buildGitHubBlobUrl({
remoteUrl: "https://github.com/acme/repo.git",
branch: "main",
path: "src/index.ts",
lineStart: 12,
lineEnd: 20,
}),
).toBe("https://github.com/acme/repo/blob/main/src/index.ts#L12-L20");
});
it("strips leading slashes and encodes path segments", () => {
expect(
buildGitHubBlobUrl({
remoteUrl: "https://github.com/acme/repo.git",
branch: "main",
path: "/src/a b/c#d.ts",
}),
).toBe("https://github.com/acme/repo/blob/main/src/a%20b/c%23d.ts");
});
it("normalizes harmless dot segments in the blob path", () => {
expect(
buildGitHubBlobUrl({
remoteUrl: "https://github.com/acme/repo.git",
branch: "main",
path: "./src/../index.ts",
}),
).toBe("https://github.com/acme/repo/blob/main/index.ts");
});
it("returns null for blob paths that escape above the repo root", () => {
expect(
buildGitHubBlobUrl({
remoteUrl: "https://github.com/acme/repo.git",
branch: "main",
path: "../outside.ts",
}),
).toBeNull();
});
it("returns null when the path is missing", () => {
expect(
buildGitHubBlobUrl({
remoteUrl: "https://github.com/acme/repo.git",
branch: "main",
path: "",
}),
).toBeNull();
});
});
-69
View File
@@ -1,69 +0,0 @@
import { parseGitHubRemoteUrl } from "@getpaseo/protocol/git-remote";
export function parseGitHubRepoFromRemote(remoteUrl: string | null | undefined): string | null {
const trimmed = remoteUrl?.trim();
if (!trimmed) {
return null;
}
return parseGitHubRemoteUrl(trimmed)?.repo ?? null;
}
export function buildGitHubBranchTreeUrl(input: {
remoteUrl: string | null | undefined;
branch: string | null | undefined;
}): string | null {
const repo = parseGitHubRepoFromRemote(input.remoteUrl);
const branch = input.branch?.trim();
if (!repo || !branch || branch === "HEAD") {
return null;
}
const encodedBranch = branch.split("/").map(encodeURIComponent).join("/");
return `https://github.com/${repo}/tree/${encodedBranch}`;
}
function normalizeGitHubBlobPath(path: string | null | undefined): string | null {
const segments: string[] = [];
const trimmed = path?.trim().replace(/\\/g, "/").replace(/^\/+/, "");
if (!trimmed) {
return null;
}
for (const segment of trimmed.split("/")) {
if (!segment || segment === ".") {
continue;
}
if (segment === "..") {
if (segments.length === 0) {
return null;
}
segments.pop();
continue;
}
segments.push(segment);
}
return segments.join("/") || null;
}
export function buildGitHubBlobUrl(input: {
remoteUrl: string | null | undefined;
branch: string | null | undefined;
path: string | null | undefined;
lineStart?: number;
lineEnd?: number;
}): string | null {
const repo = parseGitHubRepoFromRemote(input.remoteUrl);
const branch = input.branch?.trim();
const filePath = normalizeGitHubBlobPath(input.path);
if (!repo || !branch || branch === "HEAD" || !filePath) {
return null;
}
const encodedBranch = branch.split("/").map(encodeURIComponent).join("/");
const encodedPath = filePath.split("/").map(encodeURIComponent).join("/");
let url = `https://github.com/${repo}/blob/${encodedBranch}/${encodedPath}`;
if (input.lineStart && input.lineStart > 0) {
url += `#L${input.lineStart}`;
if (input.lineEnd && input.lineEnd > input.lineStart) {
url += `-L${input.lineEnd}`;
}
}
return url;
}
@@ -0,0 +1,300 @@
import { describe, expect, it } from "vitest";
import { deriveMergeCapability, type ForgeSpecificStatusFacts } from "./merge-capability";
type GithubMergeFactsFixture = ForgeSpecificStatusFacts & {
forge: "github";
mergeStateStatus: string | null;
autoMergeRequest: {
enabledAt: string | null;
mergeMethod: string | null;
enabledBy: string | null;
} | null;
viewerCanEnableAutoMerge: boolean;
viewerCanDisableAutoMerge: boolean;
viewerCanMergeAsAdmin: boolean;
viewerCanUpdateBranch: boolean;
repository: {
autoMergeAllowed: boolean;
mergeCommitAllowed: boolean;
squashMergeAllowed: boolean;
rebaseMergeAllowed: boolean;
viewerDefaultMergeMethod: string | null;
};
isMergeQueueEnabled: boolean;
isInMergeQueue: boolean;
};
type GitlabMergeFactsFixture = ForgeSpecificStatusFacts & {
forge: "gitlab";
detailedMergeStatus: string | null;
hasConflicts: boolean;
blockingDiscussionsResolved: boolean;
approvalsRequired: number;
approvalsGiven: number;
pipelineStatus: string | null;
pipelineId: number | null;
pipelineUrl: string | null;
mergeWhenPipelineSucceeds: boolean;
};
type GiteaMergeFactsFixture = ForgeSpecificStatusFacts & {
forge: "gitea";
mergeable: boolean;
hasMerged: boolean;
ciStatus: string | null;
};
function facts(overrides: Partial<GithubMergeFactsFixture> = {}): GithubMergeFactsFixture {
return {
forge: "github",
mergeStateStatus: "CLEAN",
autoMergeRequest: null,
viewerCanEnableAutoMerge: false,
viewerCanDisableAutoMerge: false,
viewerCanMergeAsAdmin: false,
viewerCanUpdateBranch: false,
repository: {
autoMergeAllowed: false,
mergeCommitAllowed: true,
squashMergeAllowed: true,
rebaseMergeAllowed: true,
viewerDefaultMergeMethod: "SQUASH",
},
isMergeQueueEnabled: false,
isInMergeQueue: false,
...overrides,
};
}
function gitlabFacts(overrides: Partial<GitlabMergeFactsFixture> = {}): GitlabMergeFactsFixture {
return {
forge: "gitlab",
detailedMergeStatus: "mergeable",
hasConflicts: false,
blockingDiscussionsResolved: true,
approvalsRequired: 0,
approvalsGiven: 0,
pipelineStatus: "success",
pipelineId: null,
pipelineUrl: null,
mergeWhenPipelineSucceeds: false,
...overrides,
};
}
function giteaFacts(overrides: Partial<GiteaMergeFactsFixture> = {}): GiteaMergeFactsFixture {
return {
forge: "gitea",
mergeable: true,
hasMerged: false,
ciStatus: "success",
...overrides,
};
}
describe("deriveMergeCapability", () => {
it("returns null when the forge supplied no merge facts", () => {
expect(deriveMergeCapability(null)).toBeNull();
expect(deriveMergeCapability(undefined)).toBeNull();
});
it("rejects untagged and schema-mismatched forge facts at the registry boundary", () => {
expect(deriveMergeCapability({ approvalsRequired: 2 })).toBeNull();
expect(
deriveMergeCapability({
...gitlabFacts(),
approvalsRequired: "two",
}),
).toBeNull();
});
it("marks direct merge ready only for the GitHub clean states", () => {
expect(deriveMergeCapability(facts({ mergeStateStatus: "CLEAN" }))?.directMergeReady).toBe(
true,
);
expect(deriveMergeCapability(facts({ mergeStateStatus: "HAS_HOOKS" }))?.directMergeReady).toBe(
true,
);
expect(deriveMergeCapability(facts({ mergeStateStatus: "BLOCKED" }))?.directMergeReady).toBe(
false,
);
expect(deriveMergeCapability(facts({ mergeStateStatus: null }))?.directMergeReady).toBe(false);
});
it("can enable auto-merge only when blocked, allowed, and the viewer may enable it", () => {
const ready = facts({
mergeStateStatus: "BLOCKED",
viewerCanEnableAutoMerge: true,
repository: {
autoMergeAllowed: true,
mergeCommitAllowed: true,
squashMergeAllowed: true,
rebaseMergeAllowed: true,
viewerDefaultMergeMethod: "SQUASH",
},
});
expect(deriveMergeCapability(ready)?.canEnableAutoMerge).toBe(true);
expect(
deriveMergeCapability(facts({ ...ready, viewerCanEnableAutoMerge: false }))
?.canEnableAutoMerge,
).toBe(false);
expect(
deriveMergeCapability(facts({ ...ready, mergeStateStatus: "CLEAN" }))?.canEnableAutoMerge,
).toBe(false);
});
it("reports whether auto-merge is already enabled and can be disabled", () => {
const cap = deriveMergeCapability(
facts({
autoMergeRequest: { enabledAt: "now", mergeMethod: "SQUASH", enabledBy: "octocat" },
viewerCanDisableAutoMerge: true,
}),
);
expect(cap?.autoMergeEnabled).toBe(true);
expect(cap?.canDisableAutoMerge).toBe(true);
expect(deriveMergeCapability(facts())?.autoMergeEnabled).toBe(false);
});
it("treats an enabled or in-progress merge queue as blocking", () => {
expect(deriveMergeCapability(facts({ isMergeQueueEnabled: true }))?.mergeBlockedByQueue).toBe(
true,
);
expect(deriveMergeCapability(facts({ isInMergeQueue: true }))?.mergeBlockedByQueue).toBe(true);
expect(deriveMergeCapability(facts())?.mergeBlockedByQueue).toBe(false);
});
it("derives allowed methods and the preferred method from the repository policy", () => {
const cap = deriveMergeCapability(
facts({
repository: {
autoMergeAllowed: false,
mergeCommitAllowed: false,
squashMergeAllowed: true,
rebaseMergeAllowed: true,
viewerDefaultMergeMethod: "REBASE",
},
}),
);
expect(cap?.allowedMethods).toEqual(["squash", "rebase"]);
expect(cap?.preferredMethod).toBe("rebase");
});
it("returns a null preferred method when the forge reports an unknown default", () => {
expect(
deriveMergeCapability(
facts({ repository: { ...facts().repository, viewerDefaultMergeMethod: null } }),
)?.preferredMethod,
).toBeNull();
});
});
describe("deriveMergeCapability (legacy github fallback)", () => {
it("synthesizes full GitHub capability from legacy status.github when forgeSpecific is absent", () => {
const { forge: _forge, ...legacy } = facts({
mergeStateStatus: "CLEAN",
repository: {
autoMergeAllowed: false,
mergeCommitAllowed: false,
squashMergeAllowed: true,
rebaseMergeAllowed: false,
viewerDefaultMergeMethod: "SQUASH",
},
});
const cap = deriveMergeCapability(undefined, legacy);
expect(cap).not.toBeNull();
expect(cap?.directMergeReady).toBe(true);
expect(cap?.allowedMethods).toEqual(["squash"]);
expect(cap?.preferredMethod).toBe("squash");
});
it("returns null when both forgeSpecific and legacy github facts are absent", () => {
expect(deriveMergeCapability(undefined, null)).toBeNull();
expect(deriveMergeCapability(undefined, undefined)).toBeNull();
});
});
describe("deriveMergeCapability (gitlab)", () => {
it("produces a non-null capability for the gitlab arm", () => {
expect(deriveMergeCapability(gitlabFacts())).not.toBeNull();
});
it("marks direct merge ready only when GitLab reports the mergeable status", () => {
expect(
deriveMergeCapability(gitlabFacts({ detailedMergeStatus: "mergeable" }))?.directMergeReady,
).toBe(true);
expect(
deriveMergeCapability(gitlabFacts({ detailedMergeStatus: "ci_still_running" }))
?.directMergeReady,
).toBe(false);
expect(
deriveMergeCapability(gitlabFacts({ detailedMergeStatus: "discussions_not_resolved" }))
?.directMergeReady,
).toBe(false);
expect(
deriveMergeCapability(gitlabFacts({ detailedMergeStatus: null }))?.directMergeReady,
).toBe(false);
});
it("reflects merge-when-pipeline-succeeds as an enabled auto-merge", () => {
const enabled = deriveMergeCapability(
gitlabFacts({ mergeWhenPipelineSucceeds: true, pipelineStatus: "running" }),
);
expect(enabled?.autoMergeEnabled).toBe(true);
expect(enabled?.canDisableAutoMerge).toBe(true);
expect(enabled?.canEnableAutoMerge).toBe(false);
expect(deriveMergeCapability(gitlabFacts())?.autoMergeEnabled).toBe(false);
});
it("can enable auto-merge only while a pipeline is still in flight", () => {
expect(
deriveMergeCapability(gitlabFacts({ pipelineStatus: "created" }))?.canEnableAutoMerge,
).toBe(true);
expect(
deriveMergeCapability(gitlabFacts({ pipelineStatus: "waiting_for_resource" }))
?.canEnableAutoMerge,
).toBe(true);
expect(
deriveMergeCapability(gitlabFacts({ pipelineStatus: "preparing" }))?.canEnableAutoMerge,
).toBe(true);
expect(
deriveMergeCapability(gitlabFacts({ pipelineStatus: "pending" }))?.canEnableAutoMerge,
).toBe(true);
expect(
deriveMergeCapability(gitlabFacts({ pipelineStatus: "running" }))?.canEnableAutoMerge,
).toBe(true);
expect(
deriveMergeCapability(gitlabFacts({ pipelineStatus: "scheduled" }))?.canEnableAutoMerge,
).toBe(true);
expect(
deriveMergeCapability(gitlabFacts({ pipelineStatus: "success" }))?.canEnableAutoMerge,
).toBe(false);
expect(deriveMergeCapability(gitlabFacts({ pipelineStatus: null }))?.canEnableAutoMerge).toBe(
false,
);
});
it("offers GitLab merge methods and never reports a merge queue", () => {
const cap = deriveMergeCapability(gitlabFacts());
expect(cap?.allowedMethods).toEqual(["merge", "squash", "rebase"]);
expect(cap?.mergeBlockedByQueue).toBe(false);
expect(cap?.canEnableAutoMerge).toBe(false);
});
});
describe("deriveMergeCapability (gitea)", () => {
it("allows direct merge only when the pull request is mergeable and unmerged", () => {
expect(deriveMergeCapability(giteaFacts())?.directMergeReady).toBe(true);
expect(deriveMergeCapability(giteaFacts({ mergeable: false }))?.directMergeReady).toBe(false);
expect(deriveMergeCapability(giteaFacts({ hasMerged: true }))?.directMergeReady).toBe(false);
});
it("offers direct merge styles without auto-merge", () => {
const capability = deriveMergeCapability(giteaFacts());
expect(capability?.allowedMethods).toEqual(["merge", "squash", "rebase"]);
expect(capability?.canEnableAutoMerge).toBe(false);
expect(capability?.autoMergeEnabled).toBe(false);
expect(capability?.canDisableAutoMerge).toBe(false);
});
});
+51
View File
@@ -0,0 +1,51 @@
import {
type ForgeSpecificEnvelope,
type LegacyGithubMergeFacts,
type MergeCapability,
} from "@/git/client-forge-module";
import { CLIENT_FORGE_LOGIC_MODULES } from "@/git/forges";
export type ForgeSpecificStatusFacts = ForgeSpecificEnvelope;
export type { LegacyGithubMergeFacts, MergeCapability };
/**
* Resolve the neutral merge capability from a registered forge's runtime facts.
* Returns null for an unregistered, unknown, or schema-mismatched facts family;
* callers then fall back to raw git state.
*/
function deriveForgeMergeCapability(facts: unknown): MergeCapability | null {
for (const module of CLIENT_FORGE_LOGIC_MODULES) {
const capability = module.facts?.deriveMergeCapability(facts);
if (capability) {
return capability;
}
}
return null;
}
/**
* Build the neutral merge capability from a forge's PR status facts. Returns
* null when the forge supplied no merge facts (e.g. a host that exposes none, or
* an unknown forge), in which case the caller falls back to raw git state.
* Per-forge derivation lives on client forge modules; this stays the single
* neutral entry point the action policy reads.
*
* COMPAT(forgeSpecific): added in v0.1.106, remove after 2026-12-27. A daemon
* predating forgeSpecific emits only the legacy `status.github` field. When
* forgeSpecific is absent we synthesize the github arm from those legacy facts
* so GitHub merge-method restrictions and auto-merge state are not lost against
* an old daemon.
*/
export function deriveMergeCapability(
forgeSpecific: unknown,
legacyGithubFacts?: LegacyGithubMergeFacts | null,
): MergeCapability | null {
if (forgeSpecific === null || forgeSpecific === undefined) {
if (legacyGithubFacts) {
return deriveForgeMergeCapability({ forge: "github", ...legacyGithubFacts });
}
return null;
}
return deriveForgeMergeCapability(forgeSpecific);
}
+98 -8
View File
@@ -3,11 +3,34 @@ import { CheckoutPrStatusSchema } from "@getpaseo/protocol/messages";
import { i18n } from "@/i18n/i18next";
import { buildGitActions, type BuildGitActionsInput } from "./policy";
import { deriveMergeCapability, type ForgeSpecificStatusFacts } from "./merge-capability";
function githubStatus(
overrides: Partial<NonNullable<BuildGitActionsInput["pullRequestGithub"]>> = {},
): NonNullable<BuildGitActionsInput["pullRequestGithub"]> {
type GithubMergeFactsFixture = ForgeSpecificStatusFacts & {
forge: "github";
mergeStateStatus: string | null;
autoMergeRequest: {
enabledAt: string | null;
mergeMethod: string | null;
enabledBy: string | null;
} | null;
viewerCanEnableAutoMerge: boolean;
viewerCanDisableAutoMerge: boolean;
viewerCanMergeAsAdmin: boolean;
viewerCanUpdateBranch: boolean;
repository: {
autoMergeAllowed: boolean;
mergeCommitAllowed: boolean;
squashMergeAllowed: boolean;
rebaseMergeAllowed: boolean;
viewerDefaultMergeMethod: string | null;
};
isMergeQueueEnabled: boolean;
isInMergeQueue: boolean;
};
function githubStatus(overrides: Partial<GithubMergeFactsFixture> = {}): GithubMergeFactsFixture {
return {
forge: "github",
mergeStateStatus: "CLEAN",
autoMergeRequest: null,
viewerCanEnableAutoMerge: false,
@@ -27,10 +50,17 @@ function githubStatus(
};
}
function createInput(overrides: Partial<BuildGitActionsInput> = {}): BuildGitActionsInput {
function createInput(
overrides: Partial<Omit<BuildGitActionsInput, "mergeCapability">> & {
pullRequestGithub?: unknown;
} = {},
): BuildGitActionsInput {
const { pullRequestGithub = null, ...rest } = overrides;
return {
isGit: true,
githubFeaturesEnabled: true,
forgeBrandLabel: "GitHub",
forgeChangeRequestNoun: "PR",
githubAutoMergeActionsEnabled: true,
hasPullRequest: false,
pullRequestUrl: null,
@@ -38,7 +68,7 @@ function createInput(overrides: Partial<BuildGitActionsInput> = {}): BuildGitAct
pullRequestIsDraft: false,
pullRequestIsMerged: false,
pullRequestMergeable: "UNKNOWN",
pullRequestGithub: null,
mergeCapability: deriveMergeCapability(pullRequestGithub),
hasRemote: false,
isPaseoOwnedWorktree: false,
isOnBaseBranch: true,
@@ -128,7 +158,7 @@ function createInput(overrides: Partial<BuildGitActionsInput> = {}): BuildGitAct
handler: () => undefined,
},
},
...overrides,
...rest,
};
}
@@ -344,6 +374,28 @@ describe("git-actions-policy", () => {
});
});
it("explains why pull-and-push is unavailable when there is nothing to pull first", () => {
const actions = buildGitActions(
createInput({ hasRemote: true, aheadOfOrigin: 1, behindOfOrigin: 0 }),
);
const action = actions.secondary.find((entry) => entry.id === "pull-and-push");
expect(action?.unavailableMessage).toBe(
"Pull and push isn't available because there are no incoming changes to pull first",
);
});
it("explains why pull-and-push is unavailable when there is nothing to push after pulling", () => {
const actions = buildGitActions(
createInput({ hasRemote: true, aheadOfOrigin: 0, behindOfOrigin: 1 }),
);
const action = actions.secondary.find((entry) => entry.id === "pull-and-push");
expect(action?.unavailableMessage).toBe(
"Pull and push isn't available because there is nothing new to send after pulling",
);
});
it("explains why pull-and-push is unavailable when there are uncommitted changes", () => {
const actions = buildGitActions(
createInput({
@@ -507,6 +559,44 @@ describe("git-actions-policy", () => {
});
});
it("uses the forge change-request noun in unavailable no-forge copy", () => {
const createActions = buildGitActions(
createInput({
githubFeaturesEnabled: false,
forgeBrandLabel: "GitLab",
forgeChangeRequestNoun: "MR",
hasRemote: true,
isOnBaseBranch: false,
aheadCount: 1,
}),
);
const viewActions = buildGitActions(
createInput({
githubFeaturesEnabled: false,
forgeBrandLabel: "GitLab",
forgeChangeRequestNoun: "MR",
hasRemote: true,
isOnBaseBranch: false,
hasPullRequest: true,
pullRequestUrl: "https://gitlab.com/example/repo/-/merge_requests/1",
}),
);
const createPrAction = [...createActions.secondary, ...createActions.menu].find(
(action) => action.id === "pr",
);
const viewPrAction = [viewActions.primary, ...viewActions.secondary, ...viewActions.menu].find(
(action) => action?.id === "pr",
);
expect(createPrAction?.unavailableMessage).toBe(
"Create MR isn't available right now because GitLab isn't connected",
);
expect(viewPrAction?.unavailableMessage).toBe(
"View MR isn't available right now because GitLab isn't connected",
);
});
it("uses local merge when merge is the stored ship default", () => {
const actions = buildGitActions(
createInput({
@@ -726,12 +816,12 @@ describe("git-actions-policy", () => {
pullRequestIsDraft: oldDaemonStatus.isDraft,
pullRequestIsMerged: oldDaemonStatus.isMerged,
pullRequestMergeable: oldDaemonStatus.mergeable,
pullRequestGithub: oldDaemonStatus.github,
pullRequestGithub: oldDaemonStatus.forgeSpecific,
shipDefault: "pr",
}),
);
expect(oldDaemonStatus.github).toBeUndefined();
expect(oldDaemonStatus.forgeSpecific).toBeUndefined();
expect(actions.primary).toMatchObject({
id: "merge-pr-squash",
label: "Merge PR (squash)",
+50 -70
View File
@@ -2,11 +2,9 @@ import type { ReactElement } from "react";
import type { ActionStatus } from "@/components/ui/dropdown-menu";
import { i18n } from "@/i18n/i18next";
import type {
CheckoutPrMergeMethod,
CheckoutPrStatusResponse,
PullRequestMergeable,
} from "@getpaseo/protocol/messages";
import type { CheckoutPrMergeMethod, PullRequestMergeable } from "@getpaseo/protocol/messages";
import type { MergeCapability } from "./merge-capability";
export type GitActionId =
| "commit"
@@ -55,6 +53,10 @@ interface GitActionRuntimeState {
export interface BuildGitActionsInput {
isGit: boolean;
githubFeaturesEnabled: boolean;
/** Forge brand label (e.g. "GitHub", "GitLab") for forge-neutral unavailable copy. */
forgeBrandLabel: string;
/** Short change-request noun label (e.g. "PR", "MR") for forge-neutral unavailable copy. */
forgeChangeRequestNoun: string;
githubAutoMergeActionsEnabled: boolean;
hasPullRequest: boolean;
pullRequestUrl: string | null;
@@ -62,7 +64,7 @@ export interface BuildGitActionsInput {
pullRequestIsDraft: boolean;
pullRequestIsMerged: boolean;
pullRequestMergeable: PullRequestMergeable;
pullRequestGithub: PullRequestGithubStatus | null;
mergeCapability: MergeCapability | null;
hasRemote: boolean;
isPaseoOwnedWorktree: boolean;
isOnBaseBranch: boolean;
@@ -98,7 +100,6 @@ type PullRequestAutoMergeEnableActionId = Extract<
"enable-pr-auto-merge-squash" | "enable-pr-auto-merge-merge" | "enable-pr-auto-merge-rebase"
>;
type PullRequestActionRole = "status" | "direct" | "auto";
type PullRequestGithubStatus = NonNullable<CheckoutPrStatusResponse["payload"]["status"]>["github"];
interface PullRequestActionModel {
readonly id: PullRequestActionId;
@@ -180,7 +181,6 @@ const PULL_REQUEST_ACTION_MODELS: readonly PullRequestActionModel[] = [
];
const REMOTE_ACTION_IDS: GitActionId[] = ["pull", "push", "pull-and-push"];
const GITHUB_DIRECT_MERGE_STATE_ALLOWLIST = new Set(["CLEAN", "HAS_HOOKS"]);
export function narrowPullRequestState(state: string | null | undefined): "open" | "closed" | null {
if (state === "open") return "open";
@@ -403,7 +403,10 @@ function buildPrAction(input: BuildGitActionsInput): GitAction {
unavailableMessage:
input.runtime.pr.disabled || input.githubFeaturesEnabled
? undefined
: i18n.t("workspace.git.actions.unavailable.viewPrNoGithub"),
: i18n.t("workspace.git.actions.unavailable.viewPrNoForge", {
brand: input.forgeBrandLabel,
noun: input.forgeChangeRequestNoun,
}),
icon: input.runtime.pr.icon,
startsGroup: false,
handler: input.runtime.pr.handler,
@@ -467,7 +470,7 @@ function buildEnablePullRequestAutoMergeAction(
function buildDisablePullRequestAutoMergeAction(input: BuildGitActionsInput): GitAction {
const runtime = input.runtime["disable-pr-auto-merge"];
const unavailableMessage =
input.pullRequestGithub?.viewerCanDisableAutoMerge === true
input.mergeCapability?.canDisableAutoMerge === true
? undefined
: i18n.t("workspace.git.actions.unavailable.autoMergeCannotDisable");
return {
@@ -475,7 +478,7 @@ function buildDisablePullRequestAutoMergeAction(input: BuildGitActionsInput): Gi
label: i18n.t("workspace.git.actions.autoMerge.enabled"),
pendingLabel: i18n.t("workspace.git.actions.autoMerge.disabling"),
successLabel: i18n.t("workspace.git.actions.autoMerge.disabled"),
disabled: runtime.disabled || input.pullRequestGithub?.viewerCanDisableAutoMerge !== true,
disabled: runtime.disabled || input.mergeCapability?.canDisableAutoMerge !== true,
status: runtime.status,
unavailableMessage: runtime.disabled ? undefined : unavailableMessage,
icon: runtime.icon,
@@ -543,7 +546,7 @@ function canUsePullRequestActionAsShipDefault(input: BuildGitActionsInput): bool
}
function canMergePr(input: BuildGitActionsInput): boolean {
const github = input.pullRequestGithub;
const capability = input.mergeCapability;
const canMergeFromPullRequestStatus =
input.githubFeaturesEnabled &&
input.hasPullRequest &&
@@ -558,7 +561,7 @@ function canMergePr(input: BuildGitActionsInput): boolean {
return false;
}
if (!hasPullRequestGithubFacts(github)) {
if (capability === null) {
return (
input.pullRequestMergeable === "MERGEABLE" &&
input.behindOfOrigin === 0 &&
@@ -568,16 +571,15 @@ function canMergePr(input: BuildGitActionsInput): boolean {
}
return (
GITHUB_DIRECT_MERGE_STATE_ALLOWLIST.has(github.mergeStateStatus ?? "") &&
github.autoMergeRequest === null &&
!github.isMergeQueueEnabled &&
!github.isInMergeQueue &&
capability.directMergeReady &&
!capability.autoMergeEnabled &&
!capability.mergeBlockedByQueue &&
getAllowedDirectPullRequestMergeActionModels(input).length > 0
);
}
function canEnablePrAutoMerge(input: BuildGitActionsInput): boolean {
const github = input.pullRequestGithub;
const capability = input.mergeCapability;
return (
input.githubFeaturesEnabled &&
input.githubAutoMergeActionsEnabled &&
@@ -586,13 +588,10 @@ function canEnablePrAutoMerge(input: BuildGitActionsInput): boolean {
!input.pullRequestIsDraft &&
!input.pullRequestIsMerged &&
input.pullRequestMergeable !== "CONFLICTING" &&
hasPullRequestGithubFacts(github) &&
github.autoMergeRequest === null &&
github.mergeStateStatus === "BLOCKED" &&
github.repository.autoMergeAllowed &&
github.viewerCanEnableAutoMerge &&
!github.isMergeQueueEnabled &&
!github.isInMergeQueue &&
capability !== null &&
!capability.autoMergeEnabled &&
capability.canEnableAutoMerge &&
!capability.mergeBlockedByQueue &&
getAllowedAutoMergeEnableActionModels(input).length > 0
);
}
@@ -602,8 +601,7 @@ function hasEnabledPrAutoMerge(input: BuildGitActionsInput): boolean {
input.githubFeaturesEnabled &&
input.hasPullRequest &&
input.pullRequestUrl !== null &&
hasPullRequestGithubFacts(input.pullRequestGithub) &&
input.pullRequestGithub.autoMergeRequest !== null
input.mergeCapability?.autoMergeEnabled === true
);
}
@@ -615,7 +613,7 @@ function getPullUnavailableMessage(input: BuildGitActionsInput): string | undefi
return i18n.t("workspace.git.actions.unavailable.pullDirty");
}
if (input.behindOfOrigin === null) {
return "Pull isn't available here because this branch is not connected to a remote yet";
return i18n.t("workspace.git.actions.unavailable.pullNoRemote");
}
if (input.behindOfOrigin === 0) {
return i18n.t("workspace.git.actions.unavailable.pullUpToDate");
@@ -644,23 +642,26 @@ function getPullAndPushUnavailableMessage(input: BuildGitActionsInput): string |
return i18n.t("workspace.git.actions.unavailable.pullAndPushDirty");
}
if (input.behindOfOrigin === null) {
return "Pull and push isn't available because there are no incoming changes to pull first";
return i18n.t("workspace.git.actions.unavailable.pullAndPushNoIncoming");
}
if (input.behindOfOrigin === 0 && input.aheadOfOrigin === 0) {
return i18n.t("workspace.git.actions.unavailable.pullAndPushInSync");
}
if (input.behindOfOrigin === 0) {
return "Pull and push isn't available because there are no incoming changes to pull first";
return i18n.t("workspace.git.actions.unavailable.pullAndPushNoIncoming");
}
if ((input.aheadOfOrigin ?? 0) === 0) {
return "Pull and push isn't available because there is nothing new to send after pulling";
return i18n.t("workspace.git.actions.unavailable.pullAndPushNothingToPush");
}
return undefined;
}
function getCreatePrUnavailableMessage(input: BuildGitActionsInput): string | undefined {
if (!input.githubFeaturesEnabled) {
return i18n.t("workspace.git.actions.unavailable.createPrNoGithub");
return i18n.t("workspace.git.actions.unavailable.createPrNoForge", {
brand: input.forgeBrandLabel,
noun: input.forgeChangeRequestNoun,
});
}
if (input.aheadCount === 0) {
return i18n.t("workspace.git.actions.unavailable.createPrNoCommits");
@@ -698,7 +699,10 @@ function getMergeFromBaseUnavailableMessage(input: BuildGitActionsInput): string
function getMergePrUnavailableMessage(input: BuildGitActionsInput): string | undefined {
if (!input.githubFeaturesEnabled) {
return i18n.t("workspace.git.actions.unavailable.mergePrNoGithub");
return i18n.t("workspace.git.actions.unavailable.mergePrNoForge", {
brand: input.forgeBrandLabel,
noun: input.forgeChangeRequestNoun,
});
}
if (!input.hasPullRequest) {
return i18n.t("workspace.git.actions.unavailable.mergePrMissing");
@@ -715,14 +719,17 @@ function getMergePrUnavailableMessage(input: BuildGitActionsInput): string | und
if (input.pullRequestMergeable === "CONFLICTING") {
return i18n.t("workspace.git.actions.unavailable.mergePrConflicts");
}
if (!hasPullRequestGithubFacts(input.pullRequestGithub)) {
if (input.mergeCapability === null) {
return undefined;
}
if (input.pullRequestGithub?.isMergeQueueEnabled || input.pullRequestGithub?.isInMergeQueue) {
if (input.mergeCapability.mergeBlockedByQueue) {
return i18n.t("workspace.git.actions.unavailable.mergePrQueue");
}
if (!GITHUB_DIRECT_MERGE_STATE_ALLOWLIST.has(input.pullRequestGithub?.mergeStateStatus ?? "")) {
return i18n.t("workspace.git.actions.unavailable.mergePrNotReady");
if (!input.mergeCapability.directMergeReady) {
return i18n.t("workspace.git.actions.unavailable.mergePrNotReady", {
brand: input.forgeBrandLabel,
noun: input.forgeChangeRequestNoun,
});
}
return undefined;
}
@@ -739,11 +746,7 @@ function shouldShowPullRequestAction(
return true;
}
if (id === "disable-pr-auto-merge") {
return (
input.githubAutoMergeActionsEnabled &&
hasPullRequestGithubFacts(input.pullRequestGithub) &&
input.pullRequestGithub.autoMergeRequest !== null
);
return input.githubAutoMergeActionsEnabled && input.mergeCapability?.autoMergeEnabled === true;
}
if (isDirectPullRequestMergeActionId(id)) {
return canMergePr(input) && getAllowedDirectPullRequestMergeActionIds(input).includes(id);
@@ -798,9 +801,7 @@ function getPreferredDirectPullRequestMergeActionModel(
input: BuildGitActionsInput,
): PullRequestDirectMergeActionModel | null {
const allowed = getAllowedDirectPullRequestMergeActionModels(input);
const preferred = normalizeGithubMergeMethod(
input.pullRequestGithub?.repository.viewerDefaultMergeMethod ?? null,
);
const preferred = input.mergeCapability?.preferredMethod ?? null;
return allowed.find((model) => model.method === preferred) ?? allowed[0] ?? null;
}
@@ -808,9 +809,7 @@ function getPreferredEnablePullRequestAutoMergeActionModel(
input: BuildGitActionsInput,
): PullRequestAutoMergeEnableActionModel | null {
const allowed = getAllowedAutoMergeEnableActionModels(input);
const preferred = normalizeGithubMergeMethod(
input.pullRequestGithub?.repository.viewerDefaultMergeMethod ?? null,
);
const preferred = input.mergeCapability?.preferredMethod ?? null;
return allowed.find((model) => model.method === preferred) ?? allowed[0] ?? null;
}
@@ -818,28 +817,9 @@ function isPullRequestMergeMethodAllowed(
input: BuildGitActionsInput,
method: CheckoutPrMergeMethod,
): boolean {
const repository = input.pullRequestGithub?.repository;
if (!repository) {
const capability = input.mergeCapability;
if (capability === null) {
return true;
}
if (method === "squash") {
return repository.squashMergeAllowed;
}
if (method === "merge") {
return repository.mergeCommitAllowed;
}
return repository.rebaseMergeAllowed;
}
function hasPullRequestGithubFacts(
github: PullRequestGithubStatus | null,
): github is NonNullable<PullRequestGithubStatus> {
return github !== null && github !== undefined;
}
function normalizeGithubMergeMethod(value: string | null): CheckoutPrMergeMethod | null {
if (value === "SQUASH") return "squash";
if (value === "MERGE") return "merge";
if (value === "REBASE") return "rebase";
return null;
return capability.allowedMethods.includes(method);
}
+43
View File
@@ -0,0 +1,43 @@
import { describe, expect, it } from "vitest";
import { selectPrHintFromStatus } from "./pr-hint";
const githubStatus = {
url: "https://github.com/acme/repo/pull/42",
state: "open",
isMerged: false,
};
const gitlabStatus = {
url: "https://gitlab.com/group/proj/-/merge_requests/7",
state: "open",
isMerged: false,
};
describe("selectPrHintFromStatus", () => {
it("defaults the forge to github when none is supplied (old daemon)", () => {
const hint = selectPrHintFromStatus(githubStatus);
expect(hint).toMatchObject({ number: 42, forge: "github" });
});
it("carries the resolved forge onto the hint", () => {
const hint = selectPrHintFromStatus(githubStatus, "github");
expect(hint?.forge).toBe("github");
});
it("parses a GitLab merge-request URL and carries the gitlab forge", () => {
const hint = selectPrHintFromStatus(gitlabStatus, "gitlab");
expect(hint).toMatchObject({ number: 7, forge: "gitlab" });
});
it("passes an unknown forge id through untouched", () => {
const hint = selectPrHintFromStatus(githubStatus, "bitbucket");
expect(hint?.forge).toBe("bitbucket");
});
it("returns null when the url has no parseable change-request number", () => {
expect(
selectPrHintFromStatus({ url: "https://example.com/x", state: "open", isMerged: false }),
).toBeNull();
});
});
+13 -2
View File
@@ -1,7 +1,11 @@
import { normalizeForge, type Forge } from "@/git/forge";
export interface PrHint {
url: string;
number: number;
state: "open" | "merged" | "closed";
/** Forge backing this change request, so badges render the right brand mark. */
forge: Forge;
checks?: Array<{ name: string; status: string; url: string | null }>;
checksStatus?: "none" | "pending" | "success" | "failure";
reviewDecision?: "approved" | "changes_requested" | "pending" | null;
@@ -14,12 +18,15 @@ interface PrStatusLike {
checks?: Array<{ name: string; status: string; url: string | null }>;
checksStatus?: string;
reviewDecision?: string | null;
forge?: string;
}
function parsePullRequestNumber(url: string): number | null {
try {
const pathname = new URL(url).pathname;
const match = pathname.match(/\/pull\/(\d+)(?:\/|$)/);
// GitHub uses /pull/N, Gitea/Forgejo /pulls/N, GitLab /-/merge_requests/N.
// Match any so a non-GitHub change-request summary yields a hint (and brand mark).
const match = pathname.match(/\/(?:pull|pulls|merge_requests)\/(\d+)(?:\/|$)/);
if (!match) {
return null;
}
@@ -31,7 +38,10 @@ function parsePullRequestNumber(url: string): number | null {
}
}
export function selectPrHintFromStatus(status: PrStatusLike | null | undefined): PrHint | null {
export function selectPrHintFromStatus(
status: PrStatusLike | null | undefined,
forge?: string | null,
): PrHint | null {
if (!status?.url) {
return null;
}
@@ -50,6 +60,7 @@ export function selectPrHintFromStatus(status: PrStatusLike | null | undefined):
url: status.url,
number,
state,
forge: normalizeForge(forge ?? status.forge),
checks: status.checks,
checksStatus: status.checksStatus as PrHint["checksStatus"],
reviewDecision: status.reviewDecision as PrHint["reviewDecision"],
+40
View File
@@ -0,0 +1,40 @@
import { describe, expect, it } from "vitest";
import type { CheckoutPrStatusResponse } from "@getpaseo/protocol/messages";
import { normalizeCheckoutPrStatusPayload } from "./pr-status";
function payload(
overrides: Partial<CheckoutPrStatusResponse["payload"]> = {},
): CheckoutPrStatusResponse["payload"] {
return {
cwd: "/repo",
status: null,
githubFeaturesEnabled: true,
forge: "github",
error: null,
requestId: "pr-status-1",
...overrides,
};
}
describe("normalizeCheckoutPrStatusPayload", () => {
it("preserves known auth states", () => {
expect(normalizeCheckoutPrStatusPayload(payload({ authState: "cli_missing" })).authState).toBe(
"cli_missing",
);
});
it("derives auth from the legacy feature flag when authState is absent", () => {
expect(normalizeCheckoutPrStatusPayload(payload()).authState).toBe("authenticated");
expect(
normalizeCheckoutPrStatusPayload(payload({ githubFeaturesEnabled: false })).authState,
).toBe("unauthenticated");
});
it("does not expose an unknown wire auth state to feature code", () => {
expect(
normalizeCheckoutPrStatusPayload(
payload({ authState: "future_auth_state", githubFeaturesEnabled: false }),
).authState,
).toBe("unauthenticated");
});
});
+21
View File
@@ -0,0 +1,21 @@
import type { CheckoutPrStatusResponse, ForgeAuthState } from "@getpaseo/protocol/messages";
import { parseForgeAuthState } from "@/git/forge";
type WireCheckoutPrStatusPayload = CheckoutPrStatusResponse["payload"];
export type CheckoutPrStatusPayload = Omit<WireCheckoutPrStatusPayload, "authState"> & {
authState: ForgeAuthState;
};
export function normalizeCheckoutPrStatusPayload(
payload: WireCheckoutPrStatusPayload,
): CheckoutPrStatusPayload {
return {
...payload,
// COMPAT(forgeAuthState): added in v0.1.106, remove after 2026-12-27 once
// all supported daemons send authState.
authState:
parseForgeAuthState(payload.authState) ??
(payload.githubFeaturesEnabled ? "authenticated" : "unauthenticated"),
};
}
@@ -41,6 +41,7 @@ function threadEntry(id: string, resolved = false, outdated = false): PrTimeline
kind: "thread",
id: `thread:${id}`,
location: { path: "a.ts", line: 1, isResolved: resolved, isOutdated: outdated },
isResolved: resolved,
comments: [activity(id)],
};
}
@@ -90,6 +91,18 @@ describe("pull request activity state", () => {
]);
});
it("collapses a resolved general thread that carries no location", () => {
const entries: PrTimelineEntry[] = [
{ kind: "thread", id: "thread:general", isResolved: true, comments: [activity("g1")] },
];
const visible = getVisibleEntries(getActivityState(), { prNumber: 42, entries });
expect(visible.map((v) => ({ id: v.entry.id, collapsed: v.collapsed }))).toEqual([
{ id: "thread:general", collapsed: true },
]);
});
it("lets user expand a default-collapsed entry", () => {
const entries = [threadEntry("resolved", true)];
const expanded = expandActivity(getActivityState(), {
@@ -25,7 +25,7 @@ export function getActivityStateKey(identity: PullRequestActivityIdentity): stri
function shouldCollapseByDefault(entry: PrTimelineEntry): boolean {
if (entry.kind === "thread") {
return entry.location.isResolved === true || entry.location.isOutdated === true;
return entry.isResolved === true || entry.location?.isOutdated === true;
}
if (entry.kind === "single") {
return (
@@ -0,0 +1,22 @@
/**
* Neutral check status mapping shared by the PR-pane data builder, the forge
* native-data contributions, and the pane render. Kept forge-agnostic: a forge
* maps its raw CI strings onto this frozen union. Forge-specific vocabularies
* (e.g. GitLab pipeline statuses) live in the owning forge module.
*/
export type CheckStatus = "success" | "failure" | "pending" | "skipped";
export function mapCheckStatus(status: string): CheckStatus {
if (
status === "success" ||
status === "failure" ||
status === "pending" ||
status === "skipped"
) {
return status;
}
if (status === "cancelled") {
return "skipped";
}
return "pending";
}
@@ -11,14 +11,15 @@ import {
import type { PrPaneActivity, PrPaneCheck } from "./data";
import type { PrThreadEntry } from "./timeline";
const baseInput: Omit<PullRequestContextBuilderInput, "activity"> = {
const baseInput = {
provider: { id: "github", label: "GitHub" },
forge: "github",
pullRequest: {
number: 42,
title: "Fix flaky build",
url: "https://github.com/getpaseo/paseo/pull/42",
},
};
} satisfies Omit<PullRequestContextBuilderInput, "activity">;
function comment(overrides: Partial<PrPaneActivity> = {}): PrPaneActivity {
return {
@@ -55,7 +56,7 @@ function check(overrides: Partial<PrPaneCheck> = {}): PrPaneCheck {
provider: "github",
status: "failure",
url: "https://github.com/getpaseo/paseo/actions/runs/456/job/789",
github: { checkRunId: 12345, workflowRunId: 456 },
detailRef: { checkRunId: 12345, workflowRunId: 456 },
...overrides,
};
}
@@ -76,7 +77,7 @@ describe("pull request context attachments", () => {
}),
}),
).toEqual({
kind: "github.pull_request_comment",
kind: "forge.change_request_comment",
id: "42:comment-1",
title: "octocat",
subtitle: "#42 Fix flaky build",
@@ -97,7 +98,7 @@ describe("pull request context attachments", () => {
it("formats a review with state as a workspace context attachment", () => {
expect(buildPullRequestReviewContextAttachment({ ...baseInput, activity: review() })).toEqual({
kind: "github.pull_request_review",
kind: "forge.change_request_review",
id: "42:review-1",
title: "reviewer",
subtitle: "#42 Fix flaky build",
@@ -116,6 +117,41 @@ describe("pull request context attachments", () => {
});
});
it("labels a GitLab comment as a merge request with the ! prefix", () => {
expect(
buildPullRequestCommentContextAttachment({
...baseInput,
provider: { id: "gitlab", label: "GitLab" },
forge: "gitlab",
pullRequest: {
number: 14,
title: "Wire up the timeline",
url: "https://gitlab.com/acme/app/-/merge_requests/14",
},
activity: comment({
provider: "gitlab",
url: "https://gitlab.com/acme/app/-/merge_requests/14#note_401",
}),
}),
).toEqual({
kind: "forge.change_request_comment",
id: "14:comment-1",
title: "octocat",
subtitle: "!14 Wire up the timeline",
url: "https://gitlab.com/acme/app/-/merge_requests/14#note_401",
text: [
"GitLab merge request comment",
"Merge request: !14 Wire up the timeline",
"Merge request URL: https://gitlab.com/acme/app/-/merge_requests/14",
"URL: https://gitlab.com/acme/app/-/merge_requests/14#note_401",
"Author: octocat",
"Created: 3d ago",
"",
"Looks good.",
].join("\n"),
});
});
it("does not add bodyless approvals to chat", () => {
const activity = review({ reviewState: "approved", body: "" });
@@ -185,7 +221,7 @@ describe("pull request context attachments", () => {
},
}),
).toEqual({
kind: "github.pull_request_check",
kind: "forge.change_request_check",
id: "42:check-run:12345",
title: "server-tests",
subtitle: "#42 Fix flaky build",
@@ -222,12 +258,12 @@ describe("pull request context attachments", () => {
it("keeps same-named GitHub checks distinct by check run", () => {
const ubuntu = buildPullRequestCheckContextAttachment({
...baseInput,
check: check({ github: { checkRunId: 12345, workflowRunId: 456 } }),
check: check({ detailRef: { checkRunId: 12345, workflowRunId: 456 } }),
githubDetails: null,
});
const windows = buildPullRequestCheckContextAttachment({
...baseInput,
check: check({ github: { checkRunId: 67890, workflowRunId: 456 } }),
check: check({ detailRef: { checkRunId: 67890, workflowRunId: 456 } }),
githubDetails: null,
});
@@ -242,12 +278,12 @@ describe("pull request context attachments", () => {
check: check({
name: "status/context",
url: "https://github.com/getpaseo/paseo/status/context",
github: undefined,
detailRef: undefined,
}),
githubDetails: null,
}),
).toEqual({
kind: "github.pull_request_check",
kind: "forge.change_request_check",
id: "42:check:status/context",
title: "status/context",
subtitle: "#42 Fix flaky build",
@@ -262,6 +298,42 @@ describe("pull request context attachments", () => {
].join("\n"),
});
});
it("formats GitLab failed check context as a merge request", () => {
expect(
buildPullRequestCheckContextAttachment({
...baseInput,
provider: { id: "gitlab", label: "GitLab" },
forge: "gitlab",
pullRequest: {
number: 14,
title: "Wire up pipelines",
url: "https://gitlab.com/acme/app/-/merge_requests/14",
},
check: check({
name: "pipeline",
provider: "gitlab",
url: "https://gitlab.com/acme/app/-/pipelines/99",
detailRef: undefined,
}),
githubDetails: null,
}),
).toEqual({
kind: "forge.change_request_check",
id: "14:check:pipeline",
title: "pipeline",
subtitle: "!14 Wire up pipelines",
url: "https://gitlab.com/acme/app/-/pipelines/99",
text: [
"GitLab merge request check",
"Merge request: !14 Wire up pipelines",
"Merge request URL: https://gitlab.com/acme/app/-/merge_requests/14",
"Check: pipeline",
"Status: failure",
"Check URL: https://gitlab.com/acme/app/-/pipelines/99",
].join("\n"),
});
});
});
describe("buildPullRequestThreadContextAttachment", () => {
@@ -284,7 +356,7 @@ describe("buildPullRequestThreadContextAttachment", () => {
it("bundles the whole thread conversation into one attachment", () => {
expect(buildPullRequestThreadContextAttachment({ ...baseInput, thread: thread() })).toEqual({
kind: "github.pull_request_comment",
kind: "forge.change_request_comment",
id: "42:thread:PRRT_1",
title: "src/a.ts:12",
subtitle: "#42 Fix flaky build",
@@ -1,5 +1,6 @@
import type { CheckoutGithubCheckDetails } from "@getpaseo/protocol/messages";
import type { CheckoutCheckDetails } from "@getpaseo/protocol/messages";
import type { PullRequestContextAttachment } from "@/attachments/types";
import { type Forge, getForgePresentation } from "@/git/forge";
import {
formatPullRequestActivityLocation,
formatPullRequestThreadPath,
@@ -15,21 +16,24 @@ export interface PullRequestContextMetadata {
export interface PullRequestContextBuilderInput {
provider: PullRequestProviderMetadata;
forge: Forge;
pullRequest: PullRequestContextMetadata;
activity: PrPaneActivity;
}
export interface PullRequestThreadContextBuilderInput {
provider: PullRequestProviderMetadata;
forge: Forge;
pullRequest: PullRequestContextMetadata;
thread: PrThreadEntry;
}
export interface PullRequestGithubCheckContextBuilderInput {
provider: PullRequestProviderMetadata & { id: "github" };
provider: PullRequestProviderMetadata;
forge: Forge;
pullRequest: PullRequestContextMetadata;
check: PrPaneCheck & { provider: "github" };
githubDetails?: CheckoutGithubCheckDetails | null;
check: PrPaneCheck;
githubDetails?: CheckoutCheckDetails | null;
}
export function canAddPullRequestActivityToChat(activity: PrPaneActivity): boolean {
@@ -46,14 +50,15 @@ export function canAddPullRequestCheckLogsToChat(check: PrPaneCheck): boolean {
export function buildPullRequestCommentContextAttachment(
input: PullRequestContextBuilderInput,
): PullRequestContextAttachment {
const presentation = getForgePresentation(input.forge);
return {
kind: "github.pull_request_comment",
kind: "forge.change_request_comment",
id: `${input.pullRequest.number}:${input.activity.id}`,
title: input.activity.author,
subtitle: formatPullRequestSubtitle(input.pullRequest),
subtitle: formatPullRequestSubtitle(input.pullRequest, input.forge),
text: formatActivityContextText({
...input,
heading: `${input.provider.label} pull request comment`,
heading: `${presentation.brandLabel} ${presentation.changeRequestNoun} comment`,
}),
url: input.activity.url,
};
@@ -66,14 +71,15 @@ export function buildPullRequestReviewContextAttachment(
return null;
}
const presentation = getForgePresentation(input.forge);
return {
kind: "github.pull_request_review",
kind: "forge.change_request_review",
id: `${input.pullRequest.number}:${input.activity.id}`,
title: input.activity.author,
subtitle: formatPullRequestSubtitle(input.pullRequest),
subtitle: formatPullRequestSubtitle(input.pullRequest, input.forge),
text: formatActivityContextText({
...input,
heading: `${input.provider.label} pull request review`,
heading: `${presentation.brandLabel} ${presentation.changeRequestNoun} review`,
reviewState: input.activity.reviewState,
}),
url: input.activity.url,
@@ -93,17 +99,23 @@ export function buildPullRequestThreadContextAttachment(
return null;
}
const presentation = getForgePresentation(input.forge);
const noun = capitalizeFirst(presentation.changeRequestNoun);
const location = input.thread.location;
const threadTitle = location ? formatPullRequestThreadPath(location) : "Discussion thread";
const lines = [
`${input.provider.label} pull request review thread`,
`Pull request: #${input.pullRequest.number} ${input.pullRequest.title}`,
`Pull request URL: ${input.pullRequest.url}`,
`${presentation.brandLabel} ${presentation.changeRequestNoun} review thread`,
`${noun}: ${presentation.numberPrefix}${input.pullRequest.number} ${input.pullRequest.title}`,
`${noun} URL: ${input.pullRequest.url}`,
`URL: ${root.url}`,
`Location: ${formatPullRequestThreadPath(input.thread.location)}`,
];
if (input.thread.location.isResolved !== undefined) {
lines.push(`Thread state: ${input.thread.location.isResolved ? "resolved" : "unresolved"}`);
if (location) {
lines.push(`Location: ${formatPullRequestThreadPath(location)}`);
}
if (input.thread.location.isOutdated) {
if (location?.isResolved !== undefined) {
lines.push(`Thread state: ${location.isResolved ? "resolved" : "unresolved"}`);
}
if (location?.isOutdated) {
lines.push("Note: this thread is outdated (the code it refers to has changed)");
}
@@ -112,10 +124,10 @@ export function buildPullRequestThreadContextAttachment(
);
return {
kind: "github.pull_request_comment",
kind: "forge.change_request_comment",
id: `${input.pullRequest.number}:${input.thread.id}`,
title: formatPullRequestThreadPath(input.thread.location),
subtitle: formatPullRequestSubtitle(input.pullRequest),
title: threadTitle,
subtitle: formatPullRequestSubtitle(input.pullRequest, input.forge),
text: [...lines, "", conversation.join("\n\n---\n\n")].join("\n"),
url: root.url,
};
@@ -125,11 +137,11 @@ export function buildPullRequestCheckContextAttachment(
input: PullRequestGithubCheckContextBuilderInput,
): PullRequestContextAttachment {
return {
kind: "github.pull_request_check",
kind: "forge.change_request_check",
id: formatPullRequestCheckContextId(input.pullRequest, input.check),
title: input.check.name,
subtitle: formatPullRequestSubtitle(input.pullRequest),
text: formatGitHubCheckContextText(input),
subtitle: formatPullRequestSubtitle(input.pullRequest, input.forge),
text: formatCheckContextText(input),
url: input.githubDetails?.detailsUrl ?? input.githubDetails?.url ?? input.check.url,
};
}
@@ -138,22 +150,25 @@ function formatPullRequestCheckContextId(
pullRequest: PullRequestContextMetadata,
check: PrPaneCheck,
): string {
if (check.github?.checkRunId !== undefined) {
return `${pullRequest.number}:check-run:${check.github.checkRunId}`;
if (check.detailRef?.checkRunId !== undefined) {
return `${pullRequest.number}:check-run:${check.detailRef.checkRunId}`;
}
return `${pullRequest.number}:check:${check.name}`;
}
function formatGitHubCheckContextText({
function formatCheckContextText({
provider,
forge,
pullRequest,
check,
githubDetails,
}: PullRequestGithubCheckContextBuilderInput): string {
const presentation = getForgePresentation(forge);
const noun = capitalizeFirst(presentation.changeRequestNoun);
const lines = [
`${provider.label} pull request check`,
`Pull request: #${pullRequest.number} ${pullRequest.title}`,
`Pull request URL: ${pullRequest.url}`,
`${provider.label} ${presentation.changeRequestNoun} check`,
`${noun}: ${presentation.numberPrefix}${pullRequest.number} ${pullRequest.title}`,
`${noun} URL: ${pullRequest.url}`,
`Check: ${check.name}`,
`Status: ${check.status}`,
];
@@ -170,7 +185,7 @@ function formatGitHubCheckContextText({
appendGitHubCheckAnnotations(lines, githubDetails);
appendGitHubFailedJobs(lines, githubDetails);
if (githubDetails?.truncated) {
lines.push("", "Note: Check details were truncated by GitHub/API or local caps.");
lines.push("", `Note: Check details were truncated by ${provider.label}/API or local caps.`);
}
return lines.join("\n");
@@ -178,7 +193,7 @@ function formatGitHubCheckContextText({
function appendGitHubCheckOutput(
lines: string[],
details: CheckoutGithubCheckDetails | null | undefined,
details: CheckoutCheckDetails | null | undefined,
) {
if (details?.output?.title) {
lines.push(`Output title: ${details.output.title}`);
@@ -193,7 +208,7 @@ function appendGitHubCheckOutput(
function appendGitHubCheckAnnotations(
lines: string[],
details: CheckoutGithubCheckDetails | null | undefined,
details: CheckoutCheckDetails | null | undefined,
) {
if (!details?.annotations?.length) {
return;
@@ -204,10 +219,7 @@ function appendGitHubCheckAnnotations(
}
}
function appendGitHubFailedJobs(
lines: string[],
details: CheckoutGithubCheckDetails | null | undefined,
) {
function appendGitHubFailedJobs(lines: string[], details: CheckoutCheckDetails | null | undefined) {
if (!details?.failedJobs?.length) {
return;
}
@@ -226,7 +238,7 @@ function appendGitHubFailedJobs(
}
}
function formatAnnotation(annotation: CheckoutGithubCheckDetails["annotations"][number]): string {
function formatAnnotation(annotation: CheckoutCheckDetails["annotations"][number]): string {
const location = annotation.path
? `${annotation.path}${formatAnnotationLines(annotation)}`
: "unknown location";
@@ -235,9 +247,7 @@ function formatAnnotation(annotation: CheckoutGithubCheckDetails["annotations"][
return `${location}${level}${message}`;
}
function formatAnnotationLines(
annotation: CheckoutGithubCheckDetails["annotations"][number],
): string {
function formatAnnotationLines(annotation: CheckoutCheckDetails["annotations"][number]): string {
if (annotation.startLine !== undefined && annotation.endLine !== undefined) {
return `:${annotation.startLine}-${annotation.endLine}`;
}
@@ -249,14 +259,17 @@ function formatAnnotationLines(
function formatActivityContextText({
heading,
forge,
pullRequest,
activity,
reviewState,
}: PullRequestContextBuilderInput & { heading: string; reviewState?: ReviewState }): string {
const presentation = getForgePresentation(forge);
const noun = capitalizeFirst(presentation.changeRequestNoun);
const lines = [
heading,
`Pull request: #${pullRequest.number} ${pullRequest.title}`,
`Pull request URL: ${pullRequest.url}`,
`${noun}: ${presentation.numberPrefix}${pullRequest.number} ${pullRequest.title}`,
`${noun} URL: ${pullRequest.url}`,
`URL: ${activity.url}`,
`Author: ${activity.author}`,
];
@@ -279,6 +292,10 @@ function formatActivityContextText({
return [...lines, "", body].join("\n");
}
function formatPullRequestSubtitle(pullRequest: PullRequestContextMetadata): string {
return `#${pullRequest.number} ${pullRequest.title}`;
function formatPullRequestSubtitle(pullRequest: PullRequestContextMetadata, forge: Forge): string {
return `${getForgePresentation(forge).numberPrefix}${pullRequest.number} ${pullRequest.title}`;
}
function capitalizeFirst(value: string): string {
return value.length === 0 ? value : `${value[0].toUpperCase()}${value.slice(1)}`;
}
@@ -3,6 +3,7 @@ import type {
CheckoutPrStatusResponse,
PullRequestTimelineResponse,
} from "@getpaseo/protocol/messages";
import { isPipelineActiveStatus, mapPipelineStatus } from "@/git/forges/gitlab";
import {
deriveAvatarColor,
formatAge,
@@ -33,6 +34,7 @@ const githubStatus: CheckoutPrStatus["github"] = {
};
const baseStatus: CheckoutPrStatus = {
forge: "github",
number: 42,
url: "https://github.com/getpaseo/paseo/pull/42",
title: "Wire PR pane data",
@@ -184,11 +186,30 @@ describe("mapPrPaneData", () => {
name: "server-tests",
status: "failure",
url: "https://github.com/getpaseo/paseo/actions/runs/456/job/789",
github: { checkRunId: 12345, workflowRunId: 456 },
detailRef: { checkRunId: 12345, workflowRunId: 456 },
},
]);
});
it("keeps a workflowRunId-only detail ref so Gitea Actions rows stay fetchable", () => {
const data = mapPrPaneData(
status({
forge: "gitea",
checks: [
{
name: "e2e",
status: "failure",
url: "https://gitea.com/acme/repo/actions/runs/7001",
workflowRunId: 7001,
},
],
}),
baseTimeline,
);
expect(data?.checks[0]?.detailRef).toEqual({ workflowRunId: 7001 });
});
it("preserves timeline item order while mapping mixed reviews and comments", () => {
const data = mapPrPaneData(
baseStatus,
@@ -272,6 +293,56 @@ describe("mapPrPaneData", () => {
]);
});
it("maps a top-level threadId onto general (non-file) discussion comments", () => {
const data = mapPrPaneData(
baseStatus,
timeline({
items: [
{
id: "note-1",
kind: "comment",
author: "reviewer-a",
body: "Can you clarify the rollout?",
createdAt: Date.UTC(2026, 0, 1, 11, 0, 0),
url: "https://gitlab.example.com/group/project/-/merge_requests/42#note_1",
threadId: "disc-1",
},
],
}),
Date.UTC(2026, 0, 1, 12, 0, 0),
"gitlab",
);
expect(data?.activity).toHaveLength(1);
expect(data?.activity[0]).toMatchObject({ id: "note-1", threadId: "disc-1" });
expect(data?.activity[0].location).toBeUndefined();
});
it("maps thread-level resolution onto general (non-file) discussion comments", () => {
const data = mapPrPaneData(
baseStatus,
timeline({
items: [
{
id: "note-2",
kind: "comment",
author: "reviewer-a",
body: "Resolved general discussion.",
createdAt: Date.UTC(2026, 0, 1, 11, 0, 0),
url: "https://gitlab.example.com/group/project/-/merge_requests/42#note_2",
threadId: "disc-2",
threadIsResolved: true,
},
],
}),
Date.UTC(2026, 0, 1, 12, 0, 0),
"gitlab",
);
expect(data?.activity[0]).toMatchObject({ id: "note-2", threadIsResolved: true });
expect(data?.activity[0].location).toBeUndefined();
});
it("filters empty commented reviews but keeps blocking review states", () => {
const data = mapPrPaneData(
baseStatus,
@@ -388,6 +459,23 @@ describe("mapPrPaneData", () => {
expect(mapPrPaneData(baseStatus, baseTimeline)?.awaitingReviewers).toEqual([]);
});
it("defaults the forge to github and omits the project path when neither is supplied", () => {
const data = mapPrPaneData(baseStatus, baseTimeline);
expect(data?.forge).toBe("github");
expect(data?.projectPath).toBeUndefined();
});
it("carries the resolved forge and the nested project path for GitLab", () => {
const data = mapPrPaneData(
status({ projectPath: "group/subgroup/repo" }),
baseTimeline,
undefined,
"gitlab",
);
expect(data?.forge).toBe("gitlab");
expect(data?.projectPath).toBe("group/subgroup/repo");
});
it("rejects stale timeline activity when the timeline PR number differs from status", () => {
const data = mapPrPaneData(
baseStatus,
@@ -409,6 +497,169 @@ describe("mapPrPaneData", () => {
expect(data?.activity).toEqual([]);
});
it("passes the forge native facts through for pane contributions to derive surfaces", () => {
const gitlabFacts = {
forge: "gitlab" as const,
detailedMergeStatus: "mergeable",
hasConflicts: false,
blockingDiscussionsResolved: true,
approvalsRequired: 2,
approvalsGiven: 1,
pipelineStatus: "running",
pipelineId: 306,
pipelineUrl: "https://gitlab.com/group/repo/-/pipelines/306",
mergeWhenPipelineSucceeds: false,
};
const data = mapPrPaneData(
status({
url: "https://gitlab.com/group/repo/-/merge_requests/7",
github: undefined,
forgeSpecific: gitlabFacts,
}),
baseTimeline,
undefined,
"gitlab",
);
expect(data?.forgeSpecific).toEqual({ ...gitlabFacts, mergeStatus: null });
});
it("omits forgeSpecific when the status carries no native facts", () => {
expect(mapPrPaneData(baseStatus, baseTimeline)?.forgeSpecific).toBeUndefined();
});
it("omits forgeSpecific when no registered forge schema accepts it", () => {
const data = mapPrPaneData(
status({ forgeSpecific: { forge: "gitlab", approvalsRequired: "two" } }),
baseTimeline,
);
expect(data?.forgeSpecific).toBeUndefined();
});
it("surfaces Gitea aggregate CI status as a check row", () => {
const data = mapPrPaneData(
status({
forge: "gitea",
url: "https://gitea.com/group/repo/pulls/7",
github: undefined,
forgeSpecific: {
forge: "gitea",
mergeable: true,
hasMerged: false,
ciStatus: "success",
},
}),
baseTimeline,
undefined,
"gitea",
);
expect(data?.checks).toEqual([
{
provider: "gitea",
name: "CI",
status: "success",
url: "https://gitea.com/group/repo/pulls/7",
},
]);
});
it("keeps Forgejo branding for aggregate Gitea-family CI status", () => {
const data = mapPrPaneData(
status({
forge: "forgejo",
url: "https://forgejo.example.com/group/repo/pulls/7",
github: undefined,
forgeSpecific: {
forge: "gitea",
mergeable: true,
hasMerged: false,
ciStatus: "failure",
},
}),
baseTimeline,
undefined,
"forgejo",
);
expect(data?.checks).toEqual([
{
provider: "forgejo",
name: "CI",
status: "failure",
url: "https://forgejo.example.com/group/repo/pulls/7",
},
]);
});
it("carries the resolved forge brand and activity provider for GitLab", () => {
const data = mapPrPaneData(
status({
url: "https://gitlab.com/group/repo/-/merge_requests/7",
github: undefined,
forgeSpecific: {
forge: "gitlab",
detailedMergeStatus: "mergeable",
hasConflicts: false,
blockingDiscussionsResolved: true,
approvalsRequired: 2,
approvalsGiven: 1,
pipelineStatus: null,
pipelineId: null,
pipelineUrl: null,
mergeWhenPipelineSucceeds: false,
},
}),
timeline({
items: [
{
id: "note-1",
kind: "comment",
author: "reviewer",
body: "Looks good",
createdAt: 1000,
url: "https://gitlab.com/group/repo/-/merge_requests/7#note_1",
},
],
}),
2000,
"gitlab",
);
expect(data?.provider).toEqual({ id: "gitlab", label: "GitLab" });
expect(data?.activity[0]?.provider).toBe("gitlab");
});
});
describe("mapPipelineStatus", () => {
it("maps GitLab and neutral pipeline statuses onto check statuses", () => {
expect(mapPipelineStatus("success")).toBe("success");
expect(mapPipelineStatus("passed")).toBe("success");
expect(mapPipelineStatus("failed")).toBe("failure");
expect(mapPipelineStatus("canceled")).toBe("skipped");
expect(mapPipelineStatus("skipped")).toBe("skipped");
expect(mapPipelineStatus("manual")).toBe("pending");
expect(mapPipelineStatus("running")).toBe("pending");
expect(mapPipelineStatus("pending")).toBe("pending");
expect(mapPipelineStatus("created")).toBe("pending");
expect(mapPipelineStatus("waiting_for_resource")).toBe("pending");
expect(mapPipelineStatus("preparing")).toBe("pending");
expect(mapPipelineStatus("scheduled")).toBe("pending");
expect(mapPipelineStatus("anything-else")).toBe("pending");
});
it("marks running and queued pipeline statuses as live for polling", () => {
expect(isPipelineActiveStatus("running")).toBe(true);
expect(isPipelineActiveStatus("pending")).toBe(true);
expect(isPipelineActiveStatus("created")).toBe(true);
expect(isPipelineActiveStatus("waiting_for_resource")).toBe(true);
expect(isPipelineActiveStatus("preparing")).toBe(true);
expect(isPipelineActiveStatus("scheduled")).toBe(true);
expect(isPipelineActiveStatus("success")).toBe(false);
expect(isPipelineActiveStatus("failed")).toBe(false);
expect(isPipelineActiveStatus("canceled")).toBe(false);
});
});
describe("deriveAvatarColor", () => {
+66 -29
View File
@@ -2,12 +2,18 @@ import type {
CheckoutPrStatusResponse,
PullRequestTimelineResponse,
} from "@getpaseo/protocol/messages";
import { type Forge, getForgePresentation } from "@/git/forge";
import { parseClientForgeFacts } from "@/git/forges";
import type { ForgeSpecificStatusFacts } from "@/git/merge-capability";
import { type CheckStatus, mapCheckStatus } from "./check-status";
import { getNativeFallbackChecks } from "./native-data";
export type { CheckStatus } from "./check-status";
export type PrState = "open" | "draft" | "merged" | "closed";
export type CheckStatus = "success" | "failure" | "pending" | "skipped";
export type ReviewState = "approved" | "changes_requested" | "commented";
export type ActivityKind = "review" | "comment";
export type PullRequestProvider = "github";
export type PullRequestProvider = Forge;
export interface PullRequestProviderMetadata {
id: PullRequestProvider;
@@ -15,8 +21,6 @@ export interface PullRequestProviderMetadata {
url?: string | null;
}
const GITHUB_PROVIDER: PullRequestProviderMetadata = { id: "github", label: "GitHub" };
export interface PrPaneCheck {
provider: PullRequestProvider;
name: string;
@@ -24,7 +28,12 @@ export interface PrPaneCheck {
status: CheckStatus;
duration?: string;
url: string;
github?: {
/**
* Forge-neutral reference for fetching this check's detail/logs on demand. Any
* forge that exposes a check-run id populates it; the daemon resolves the logs
* through the neutral check-details RPC.
*/
detailRef?: {
checkRunId?: number;
workflowRunId?: number;
};
@@ -44,6 +53,16 @@ export interface PrPaneActivity {
url: string;
/** For inline review comments: the review this comment was submitted with. */
reviewId?: string;
/**
* Forge-neutral discussion id, independent of a file position. Groups general
* (non-file) reply chains into one thread; file threads also carry it.
*/
threadId?: string;
/**
* Resolution state for a thread with no file position (e.g. a GitLab general
* discussion). File threads carry resolution under `location.isResolved`.
*/
threadIsResolved?: boolean;
location?: {
path: string;
line?: number;
@@ -56,15 +75,25 @@ export interface PrPaneActivity {
export interface PrPaneData {
provider: PullRequestProviderMetadata;
/** The forge hosting this change request. */
forge: Forge;
number: number;
repoOwner?: string;
repoName?: string;
/** Neutral project identity (GitLab namespaces nest beyond owner/name). */
projectPath?: string;
title: string;
state: PrState;
url: string;
reviewDecision: "approved" | "changes_requested" | "pending";
awaitingReviewers: string[];
checks: PrPaneCheck[];
/**
* The forge's already-validated native facts, passed through so pane native
* contributions (e.g. GitLab pipeline/approvals) derive their surfaces without
* the neutral data type carrying forge-specific fields.
*/
forgeSpecific?: ForgeSpecificStatusFacts;
activity: PrPaneActivity[];
}
@@ -87,6 +116,7 @@ export function mapPrPaneData(
status: CheckoutPrStatus,
timeline: PullRequestTimeline | null | undefined,
nowMs = Date.now(),
forge: Forge = "github",
): PrPaneData | null {
if (!status) {
return null;
@@ -98,25 +128,34 @@ export function mapPrPaneData(
}
const timelineMatchesStatus = timeline?.prNumber === number;
const provider = toProviderMetadata(forge);
const forgeSpecific = parseClientForgeFacts(status.forgeSpecific);
return {
provider: GITHUB_PROVIDER,
provider,
forge,
number,
repoOwner: status.repoOwner,
repoName: status.repoName,
projectPath: status.projectPath,
title: status.title,
state: derivePrState(status),
url: status.url,
reviewDecision: mapReviewDecision(status.reviewDecision),
// Requested reviewers are intentionally unwired until the server exposes them.
awaitingReviewers: [],
checks: (status.checks ?? []).flatMap(mapCheck),
checks: mapChecks(status, forge),
...(forgeSpecific ? { forgeSpecific } : {}),
activity: timelineMatchesStatus
? timeline.items.flatMap((item) => mapActivity(item, nowMs))
? timeline.items.flatMap((item) => mapActivity(item, nowMs, forge))
: [],
};
}
function toProviderMetadata(forge: Forge): PullRequestProviderMetadata {
return { id: forge, label: getForgePresentation(forge).brandLabel };
}
export function deriveAvatarColor(login: string): string {
return AVATAR_COLORS[hashLogin(login) % AVATAR_COLORS.length];
}
@@ -164,14 +203,25 @@ function derivePrState(status: NonNullable<CheckoutPrStatus>): PrState {
return "open";
}
function mapCheck(check: NonNullable<CheckoutPrStatus>["checks"][number]): PrPaneCheck[] {
function mapChecks(status: NonNullable<CheckoutPrStatus>, forge: Forge): PrPaneCheck[] {
const checks = (status.checks ?? []).flatMap((check) => mapCheck(check, forge));
if (checks.length > 0) {
return checks;
}
return getNativeFallbackChecks(status, forge);
}
function mapCheck(
check: NonNullable<CheckoutPrStatus>["checks"][number],
forge: Forge,
): PrPaneCheck[] {
if (check.url === null) {
return [];
}
return [
{
provider: "github",
provider: forge,
name: check.name,
status: mapCheckStatus(check.status),
url: check.url,
@@ -179,7 +229,7 @@ function mapCheck(check: NonNullable<CheckoutPrStatus>["checks"][number]): PrPan
...(check.duration ? { duration: check.duration } : {}),
...(check.checkRunId !== undefined || check.workflowRunId !== undefined
? {
github: {
detailRef: {
...(check.checkRunId !== undefined ? { checkRunId: check.checkRunId } : {}),
...(check.workflowRunId !== undefined ? { workflowRunId: check.workflowRunId } : {}),
},
@@ -189,22 +239,7 @@ function mapCheck(check: NonNullable<CheckoutPrStatus>["checks"][number]): PrPan
];
}
function mapCheckStatus(status: string): CheckStatus {
if (
status === "success" ||
status === "failure" ||
status === "pending" ||
status === "skipped"
) {
return status;
}
if (status === "cancelled") {
return "skipped";
}
return "pending";
}
function mapActivity(item: PullRequestTimelineItem, nowMs: number): PrPaneActivity[] {
function mapActivity(item: PullRequestTimelineItem, nowMs: number, forge: Forge): PrPaneActivity[] {
if (item.kind === "comment") {
if (item.body.trim() === "") {
return [];
@@ -212,7 +247,7 @@ function mapActivity(item: PullRequestTimelineItem, nowMs: number): PrPaneActivi
return [
{
id: item.id,
provider: "github",
provider: forge,
kind: "comment",
author: item.author,
authorUrl: item.authorUrl,
@@ -222,6 +257,8 @@ function mapActivity(item: PullRequestTimelineItem, nowMs: number): PrPaneActivi
age: formatAge(item.createdAt, nowMs),
url: item.url,
reviewId: item.reviewId,
threadId: item.threadId,
threadIsResolved: item.threadIsResolved,
location: item.location,
},
];
@@ -234,7 +271,7 @@ function mapActivity(item: PullRequestTimelineItem, nowMs: number): PrPaneActivi
return [
{
id: item.id,
provider: "github",
provider: forge,
kind: "review",
author: item.author,
authorUrl: item.authorUrl,
@@ -0,0 +1,160 @@
import { describe, expect, it } from "vitest";
import type { CheckoutPrStatusResponse } from "@getpaseo/protocol/messages";
import {
deriveGitlabApprovals,
deriveGitlabPipelineSummary,
type GitlabMergeFacts,
} from "@/git/forges/gitlab";
import { getNativeFallbackChecks } from "./native-data";
type CheckoutPrStatus = NonNullable<CheckoutPrStatusResponse["payload"]["status"]>;
function gitlabFacts(overrides: Partial<GitlabMergeFacts> = {}): GitlabMergeFacts {
return {
forge: "gitlab",
detailedMergeStatus: "mergeable",
mergeStatus: null,
hasConflicts: false,
blockingDiscussionsResolved: true,
approvalsRequired: 0,
approvalsGiven: 0,
pipelineStatus: null,
pipelineId: null,
pipelineUrl: null,
mergeWhenPipelineSucceeds: false,
...overrides,
};
}
function status(forgeSpecific: CheckoutPrStatus["forgeSpecific"], url: string): CheckoutPrStatus {
return {
forge: "gitea",
number: 7,
url,
title: "Native data",
state: "open",
baseRefName: "main",
headRefName: "feature",
isMerged: false,
isDraft: false,
mergeable: "UNKNOWN",
checks: [],
reviewDecision: null,
...(forgeSpecific ? { forgeSpecific } : {}),
};
}
describe("deriveGitlabPipelineSummary", () => {
it("summarizes a head pipeline, mapping the raw status onto a check status", () => {
expect(
deriveGitlabPipelineSummary(
gitlabFacts({
pipelineStatus: "running",
pipelineId: 306,
pipelineUrl: "https://gitlab.com/group/repo/-/pipelines/306",
}),
),
).toEqual({
id: 306,
status: "pending",
rawStatus: "running",
url: "https://gitlab.com/group/repo/-/pipelines/306",
});
});
it("returns null when the MR has no head pipeline", () => {
expect(deriveGitlabPipelineSummary(gitlabFacts({ pipelineId: null }))).toBeNull();
});
});
describe("deriveGitlabApprovals", () => {
it("surfaces N of M approvals", () => {
expect(deriveGitlabApprovals(gitlabFacts({ approvalsRequired: 2, approvalsGiven: 1 }))).toEqual(
{
given: 1,
required: 2,
},
);
});
it("returns null when no approvals are required", () => {
expect(deriveGitlabApprovals(gitlabFacts({ approvalsRequired: 0 }))).toBeNull();
});
});
describe("getNativeFallbackChecks", () => {
it("surfaces the Gitea aggregate CI status branded with the top-level forge", () => {
expect(
getNativeFallbackChecks(
status(
{ forge: "gitea", mergeable: true, hasMerged: false, ciStatus: "success" },
"https://gitea.com/group/repo/pulls/7",
),
"gitea",
),
).toEqual([
{
provider: "gitea",
name: "CI",
status: "success",
url: "https://gitea.com/group/repo/pulls/7",
},
]);
});
it("keeps Forgejo branding for the Gitea-family aggregate check", () => {
expect(
getNativeFallbackChecks(
status(
{ forge: "gitea", mergeable: true, hasMerged: false, ciStatus: "failure" },
"https://forgejo.example.com/group/repo/pulls/7",
),
"forgejo",
),
).toEqual([
{
provider: "forgejo",
name: "CI",
status: "failure",
url: "https://forgejo.example.com/group/repo/pulls/7",
},
]);
});
it("maps a Gitea 'warning' aggregate to a failing fallback check, not pending", () => {
expect(
getNativeFallbackChecks(
status(
{ forge: "gitea", mergeable: true, hasMerged: false, ciStatus: "warning" },
"https://gitea.com/group/repo/pulls/7",
),
"gitea",
),
).toEqual([
{
provider: "gitea",
name: "CI",
status: "failure",
url: "https://gitea.com/group/repo/pulls/7",
},
]);
});
it("returns no fallback checks for a forge that reports none", () => {
expect(
getNativeFallbackChecks(
status(
{ forge: "gitea", mergeable: true, hasMerged: false, ciStatus: null },
"https://gitea.com/group/repo/pulls/7",
),
"gitea",
),
).toEqual([]);
expect(
getNativeFallbackChecks(
status(gitlabFacts(), "https://gitlab.com/group/repo/-/merge_requests/7"),
"gitlab",
),
).toEqual([]);
});
});
@@ -0,0 +1,34 @@
/**
* Compatibility exports for tests and non-visual derivations. Rich PR-pane
* contributions now come from client forge modules and validate the open
* `forgeSpecific` envelope at runtime.
*/
import type { CheckoutPrStatusResponse } from "@getpaseo/protocol/messages";
import type { Forge } from "@/git/forge";
import { CLIENT_FORGE_LOGIC_MODULES } from "@/git/forges";
import type { PrPaneCheck } from "./data";
type CheckoutPrStatus = NonNullable<CheckoutPrStatusResponse["payload"]["status"]>;
const CLIENT_NATIVE_FALLBACK_CONTRIBUTIONS = CLIENT_FORGE_LOGIC_MODULES.flatMap(
(module) => module.facts?.nativeFallbackChecks ?? [],
);
/**
* Fallback check rows for a status whose forge reports no individual checks.
* Returns [] for forges that don't synthesize one.
*/
export function getNativeFallbackChecks(status: CheckoutPrStatus, forge: Forge): PrPaneCheck[] {
const facts = status.forgeSpecific;
if (!facts) {
return [];
}
const checks: PrPaneCheck[] = [];
for (const contribute of CLIENT_NATIVE_FALLBACK_CONTRIBUTIONS) {
const check = contribute.contribute(facts, status, forge);
if (check) {
checks.push(check);
}
}
return checks;
}
+180 -224
View File
@@ -10,11 +10,7 @@ import {
} from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import {
ChevronDown,
ChevronRight,
CircleCheck,
CircleDot,
CircleSlash,
CircleX,
Copy,
ExternalLink,
@@ -50,6 +46,9 @@ import { useCheckoutGitActionsStore } from "@/git/actions-store";
import { isNative } from "@/constants/platform";
import { useIsCompactFormFactor, WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
import { ICON_SIZE, type Theme } from "@/styles/theme";
import { getForgePresentation } from "@/git/forge";
import { CLIENT_FORGE_VIEW_MODULES } from "@/git/forges/view";
import type { PaneNativeContribution } from "@/git/client-forge-module";
import { PrActivitySkeleton } from "./activity-skeleton";
import {
collapseActivity,
@@ -68,19 +67,28 @@ import {
canAddPullRequestCheckLogsToChat,
} from "./context-attachment";
import { getActivityVerb, getStateLabel } from "./data";
import type { CheckStatus, PrPaneActivity, PrPaneCheck, PrPaneData, PrState } from "./data";
import type { PrPaneActivity, PrPaneCheck, PrPaneData, PrState } from "./data";
import type { ForgeSpecificStatusFacts } from "@/git/merge-capability";
import {
buildPrTimeline,
type PrReviewEntry,
type PrThreadEntry,
type PrTimelineEntry,
} from "./timeline";
import {
CheckStatusIcon,
Section,
SUMMARY_DANGER_ICON,
SUMMARY_SUCCESS_ICON,
SUMMARY_WARNING_ICON,
SummaryPill,
dangerColorMapping,
foregroundMutedColorMapping,
sectionKitStyles,
successColorMapping,
} from "./section-kit";
const ThemedChevronDown = withUnistyles(ChevronDown);
const ThemedChevronRight = withUnistyles(ChevronRight);
const ThemedCircleCheck = withUnistyles(CircleCheck);
const ThemedCircleDot = withUnistyles(CircleDot);
const ThemedCircleSlash = withUnistyles(CircleSlash);
const ThemedCircleX = withUnistyles(CircleX);
const ThemedCopy = withUnistyles(Copy);
const ThemedExternalLink = withUnistyles(ExternalLink);
@@ -95,12 +103,20 @@ const ThemedRotateCw = withUnistyles(RotateCw);
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
const successColorMapping = (theme: Theme) => ({ color: theme.colors.statusSuccess });
const dangerColorMapping = (theme: Theme) => ({ color: theme.colors.statusDanger });
const warningColorMapping = (theme: Theme) => ({ color: theme.colors.statusWarning });
const mergedColorMapping = (theme: Theme) => ({ color: theme.colors.statusMerged });
const CLIENT_PANE_CONTRIBUTIONS: readonly PaneNativeContribution[] =
CLIENT_FORGE_VIEW_MODULES.flatMap((module) => module.paneContributions ?? []);
function resolvePaneContribution(
facts: ForgeSpecificStatusFacts | undefined,
): PaneNativeContribution | null {
if (!facts) {
return null;
}
return CLIENT_PANE_CONTRIBUTIONS.find((contribution) => contribution.guard(facts)) ?? null;
}
type IconColorMapping = typeof foregroundColorMapping;
interface PrStatePresentation {
@@ -115,9 +131,6 @@ const PR_STATE_PRESENTATION: Record<PrState, PrStatePresentation> = {
closed: { Icon: ThemedGitPullRequestClosed, iconColor: dangerColorMapping },
};
const SUMMARY_SUCCESS_ICON = <ThemedCircleCheck size={12} uniProps={successColorMapping} />;
const SUMMARY_DANGER_ICON = <ThemedCircleX size={12} uniProps={dangerColorMapping} />;
const SUMMARY_WARNING_ICON = <ThemedCircleDot size={12} uniProps={warningColorMapping} />;
const SUMMARY_COMMENT_ICON = (
<ThemedMessageSquare size={11} uniProps={foregroundMutedColorMapping} />
);
@@ -133,7 +146,7 @@ function handleMarkdownLinkPress(url: string): boolean {
}
function rowPressableStyle({ hovered }: { hovered?: boolean }) {
return [styles.checkRow, Boolean(hovered) && styles.hoverable];
return [sectionKitStyles.checkRow, Boolean(hovered) && styles.hoverable];
}
function entryHeaderPressableStyle({ hovered }: { hovered?: boolean }) {
@@ -163,8 +176,11 @@ function renderKebabTriggerIcon({ hovered }: { hovered?: boolean }) {
}
function getCheckIdentity(check: PrPaneCheck): string {
if (check.github?.checkRunId !== undefined) {
return `${check.provider}:check-run:${check.github.checkRunId}`;
if (check.detailRef?.checkRunId !== undefined) {
return `${check.provider}:check-run:${check.detailRef.checkRunId}`;
}
if (check.detailRef?.workflowRunId !== undefined) {
return `${check.provider}:workflow-run:${check.detailRef.workflowRunId}`;
}
return `${check.provider}:${check.name}:${check.url}`;
}
@@ -203,9 +219,17 @@ export function PullRequestPane({
const { t } = useTranslation();
const toast = useToast();
const daemonClient = useHostRuntimeClient(serverId);
// COMPAT(githubCheckDetailsRpc): added in v0.1.106, remove after 2026-12-28 once
// all supported clients use checkout.forge.get_check_details.*.
const canFetchGitHubCheckDetails = useSessionStore(
(state) => state.sessions[serverId]?.serverInfo?.features?.githubCheckDetails === true,
);
const canFetchForgeCheckDetails = useSessionStore(
(state) => state.sessions[serverId]?.serverInfo?.features?.forgeCheckDetails === true,
);
const forgeProvidersEnabled = useSessionStore(
(state) => state.sessions[serverId]?.serverInfo?.features?.forgeProviders === true,
);
const addWorkspaceAttachment = useWorkspaceAttachmentsStore(
(state) => state.addWorkspaceAttachment,
);
@@ -281,6 +305,7 @@ export function PullRequestPane({
}
const input = {
provider: data.provider,
forge: data.forge,
pullRequest: { number: data.number, title: data.title, url: data.url },
activity,
};
@@ -298,6 +323,7 @@ export function PullRequestPane({
},
[
addWorkspaceAttachment,
data.forge,
data.number,
data.provider,
data.title,
@@ -313,6 +339,7 @@ export function PullRequestPane({
}
const attachment = buildPullRequestThreadContextAttachment({
provider: data.provider,
forge: data.forge,
pullRequest: { number: data.number, title: data.title, url: data.url },
thread,
});
@@ -326,6 +353,7 @@ export function PullRequestPane({
},
[
addWorkspaceAttachment,
data.forge,
data.number,
data.provider,
data.title,
@@ -345,7 +373,7 @@ export function PullRequestPane({
}
const threads = entry.kind === "thread" ? [entry] : entry.threads;
for (const thread of threads) {
if (thread.location.isResolved === true) {
if (thread.isResolved === true) {
continue;
}
handleAddThreadToChat(thread);
@@ -363,23 +391,32 @@ export function PullRequestPane({
let details = null;
try {
const ref = check.github;
const ref = check.detailRef;
// The neutral forge RPC fetches detail for any forge; fall back to the
// legacy github-only RPC only for GitHub against a daemon that predates
// it. A non-GitHub forge therefore needs the neutral capability present.
const canFetchDetail =
canFetchForgeCheckDetails || (check.provider === "github" && canFetchGitHubCheckDetails);
if (
canFetchGitHubCheckDetails &&
canFetchDetail &&
daemonClient &&
check.provider === "github" &&
ref?.checkRunId !== undefined &&
(ref?.checkRunId !== undefined || ref?.workflowRunId !== undefined) &&
data.repoOwner &&
data.repoName
) {
try {
const payload = await daemonClient.checkoutGithubGetCheckDetails({
const request = {
cwd,
repoOwner: data.repoOwner,
repoName: data.repoName,
checkRunId: ref.checkRunId,
workflowRunId: ref.workflowRunId,
});
};
// COMPAT(githubCheckDetailsRpc): added in v0.1.106, remove after 2026-12-28 once
// all supported clients use checkout.forge.get_check_details.*.
const payload = canFetchForgeCheckDetails
? await daemonClient.checkoutForgeGetCheckDetails(request)
: await daemonClient.checkoutGithubGetCheckDetails(request);
details = payload.success ? payload.details : null;
} catch {
details = null;
@@ -387,6 +424,7 @@ export function PullRequestPane({
}
const attachment = buildPullRequestCheckContextAttachment({
provider: data.provider,
forge: data.forge,
pullRequest: { number: data.number, title: data.title, url: data.url },
check,
githubDetails: details,
@@ -403,9 +441,11 @@ export function PullRequestPane({
},
[
addWorkspaceAttachment,
canFetchForgeCheckDetails,
canFetchGitHubCheckDetails,
cwd,
daemonClient,
data.forge,
data.number,
data.provider,
data.repoName,
@@ -428,6 +468,28 @@ export function PullRequestPane({
const statePresentation = PR_STATE_PRESENTATION[data.state];
const StateIcon = statePresentation.Icon;
const forgePresentation = getForgePresentation(data.forge);
const repoIdentity =
data.projectPath ??
(data.repoOwner && data.repoName ? `${data.repoOwner}/${data.repoName}` : null);
// Native forge surfaces (e.g. GitLab approvals/pipeline) come from a registry
// keyed by the facts-family, so the central render has no per-forge branch.
const nativeContribution = resolvePaneContribution(data.forgeSpecific);
const nativeHeaderMeta = data.forgeSpecific
? nativeContribution?.renderHeaderMeta(data.forgeSpecific)
: null;
const nativeChecksSection = data.forgeSpecific
? nativeContribution?.renderChecksSection(data.forgeSpecific, {
serverId,
cwd,
changeRequestNumber: data.number,
open: checksOpen,
onToggle: handleToggleChecks,
enabled: forgeProvidersEnabled,
canFetchCheckDetails: canFetchForgeCheckDetails,
})
: null;
return (
<View style={styles.root} testID="pr-pane">
@@ -451,7 +513,7 @@ export function PullRequestPane({
accessibilityLabel={
isRefreshing
? t("workspace.git.diff.refreshing")
: t("workspace.git.diff.refreshState")
: t("workspace.git.diff.refreshState", { brand: forgePresentation.brandLabel })
}
testID="pr-pane-refresh"
style={refreshButtonStyle}
@@ -478,16 +540,21 @@ export function PullRequestPane({
<>
<Text style={styles.title} testID="pr-pane-title">
{data.title}
<Text style={styles.titleNumber}> #{data.number}</Text>
<Text style={styles.titleNumber}>
{" "}
{forgePresentation.numberPrefix}
{data.number}
</Text>
</Text>
<View style={styles.metaLine}>
<StateIcon size={14} uniProps={statePresentation.iconColor} />
<Text style={stateLabelStyle(data.state)} testID="pr-pane-state">
{getStateLabel(data.state)}
</Text>
{data.repoOwner && data.repoName ? (
{nativeHeaderMeta}
{repoIdentity ? (
<Text style={styles.repoRef} numberOfLines={1}>
{data.repoOwner}/{data.repoName}
{repoIdentity}
</Text>
) : null}
<View style={hovered ? styles.headerLinkIcon : styles.headerLinkIconHidden}>
@@ -498,50 +565,52 @@ export function PullRequestPane({
)}
</Pressable>
<Section
title="Checks"
open={checksOpen}
onToggle={handleToggleChecks}
summary={
<>
<SummaryPill
count={passed}
icon={SUMMARY_SUCCESS_ICON}
variant="success"
testID="pr-pane-check-passed"
/>
<SummaryPill
count={failed}
icon={SUMMARY_DANGER_ICON}
variant="danger"
testID="pr-pane-check-failed"
/>
<SummaryPill
count={pending}
icon={SUMMARY_WARNING_ICON}
variant="warning"
testID="pr-pane-check-pending"
/>
</>
}
>
{data.checks.length === 0 ? (
<Text style={styles.emptyText}>No checks</Text>
) : (
data.checks.map((check) => {
const checkKey = getCheckIdentity(check);
return (
<CheckRow
key={checkKey}
check={check}
attachEnabled={attachEnabled}
isAddingLogsToChat={loadingCheckKeys.has(checkKey)}
onAddLogsToChat={handleAddCheckLogsToChat}
{nativeChecksSection ?? (
<Section
title="Checks"
open={checksOpen}
onToggle={handleToggleChecks}
summary={
<>
<SummaryPill
count={passed}
icon={SUMMARY_SUCCESS_ICON}
variant="success"
testID="pr-pane-check-passed"
/>
);
})
)}
</Section>
<SummaryPill
count={failed}
icon={SUMMARY_DANGER_ICON}
variant="danger"
testID="pr-pane-check-failed"
/>
<SummaryPill
count={pending}
icon={SUMMARY_WARNING_ICON}
variant="warning"
testID="pr-pane-check-pending"
/>
</>
}
>
{data.checks.length === 0 ? (
<Text style={sectionKitStyles.emptyText}>No checks</Text>
) : (
data.checks.map((check) => {
const checkKey = getCheckIdentity(check);
return (
<CheckRow
key={checkKey}
check={check}
attachEnabled={attachEnabled}
isAddingLogsToChat={loadingCheckKeys.has(checkKey)}
onAddLogsToChat={handleAddCheckLogsToChat}
/>
);
})
)}
</Section>
)}
<View style={styles.divider} />
@@ -572,7 +641,7 @@ export function PullRequestPane({
) : null}
{activityLoading ? <PrActivitySkeleton /> : null}
{!activityLoading && visibleEntries.length === 0 ? (
<Text style={styles.emptyText}>No activity yet</Text>
<Text style={sectionKitStyles.emptyText}>No activity yet</Text>
) : null}
{!activityLoading
? visibleEntries.map(({ entry, collapsed }) => (
@@ -582,6 +651,7 @@ export function PullRequestPane({
collapsed={collapsed}
collapsedEntryIds={collapsedEntryIds}
attachEnabled={attachEnabled}
brandLabel={forgePresentation.brandLabel}
onAddToChat={handleAddActivityToChat}
onAddThreadToChat={handleAddThreadToChat}
onToggleCollapsed={handleToggleEntryCollapsed}
@@ -601,58 +671,6 @@ function stateLabelStyle(state: PrState) {
return styles.stateLabelClosed;
}
interface SectionProps {
title: string;
open: boolean;
onToggle: () => void;
summary: React.ReactNode;
children: React.ReactNode;
}
function Section({ title, open, onToggle, summary, children }: SectionProps) {
return (
<View>
<Pressable style={styles.sectionHeader} onPress={onToggle}>
{open ? (
<ThemedChevronDown size={14} uniProps={foregroundMutedColorMapping} />
) : (
<ThemedChevronRight size={14} uniProps={foregroundMutedColorMapping} />
)}
<Text style={styles.sectionTitle}>{title}</Text>
<View style={styles.summaryWrap}>{summary}</View>
</Pressable>
{open ? <View style={styles.sectionBody}>{children}</View> : null}
</View>
);
}
function SummaryPill({
count,
icon,
variant,
testID,
}: {
count: number;
icon: React.ReactNode;
variant: "success" | "danger" | "warning" | "muted";
testID?: string;
}) {
if (count === 0) return null;
return (
<View style={styles.summaryPill} testID={testID}>
{icon}
<Text style={summaryPillTextStyle(variant)}>{count}</Text>
</View>
);
}
function summaryPillTextStyle(variant: "success" | "danger" | "warning" | "muted") {
if (variant === "success") return styles.summaryPillSuccessText;
if (variant === "danger") return styles.summaryPillDangerText;
if (variant === "warning") return styles.summaryPillWarningText;
return styles.summaryPillMutedText;
}
function CheckRow({
check,
attachEnabled,
@@ -677,15 +695,15 @@ function CheckRow({
return (
<Pressable onPress={handlePress} style={rowPressableStyle}>
<CheckStatusIcon status={check.status} />
<Text style={styles.checkName} numberOfLines={1}>
<Text style={sectionKitStyles.checkName} numberOfLines={1}>
{check.name}
</Text>
{check.workflow && (
<Text style={styles.checkWorkflow} numberOfLines={1}>
<Text style={sectionKitStyles.checkWorkflow} numberOfLines={1}>
{check.workflow}
</Text>
)}
<View style={styles.checkTrailing}>
<View style={sectionKitStyles.checkTrailing}>
{attachEnabled && canAddPullRequestCheckLogsToChat(check) ? (
<Button
variant="ghost"
@@ -698,21 +716,15 @@ function CheckRow({
{isAddingLogsToChat ? "Adding..." : "Add to chat"}
</Button>
) : null}
{check.duration && <Text style={styles.checkDuration}>{check.duration}</Text>}
{check.duration && <Text style={sectionKitStyles.checkDuration}>{check.duration}</Text>}
</View>
</Pressable>
);
}
function CheckStatusIcon({ status }: { status: CheckStatus }) {
if (status === "success") return <ThemedCircleCheck size={14} uniProps={successColorMapping} />;
if (status === "failure") return <ThemedCircleX size={14} uniProps={dangerColorMapping} />;
if (status === "pending") return <ThemedCircleDot size={14} uniProps={warningColorMapping} />;
return <ThemedCircleSlash size={14} uniProps={foregroundMutedColorMapping} />;
}
interface TimelineEntryCallbacks {
attachEnabled: boolean;
brandLabel: string;
onAddToChat: (activity: PrPaneActivity) => void;
onAddThreadToChat: (thread: PrThreadEntry) => void;
onToggleCollapsed: (entryId: string, collapsed: boolean) => void;
@@ -758,15 +770,18 @@ function ActivityKebab({
activity,
visible,
attachEnabled,
brandLabel,
onMenuOpenChange,
onAddToChat,
}: {
activity: PrPaneActivity;
visible: boolean;
attachEnabled: boolean;
brandLabel: string;
onMenuOpenChange: (open: boolean) => void;
onAddToChat: (activity: PrPaneActivity) => void;
}) {
const { t } = useTranslation();
const handleAddToChat = useCallback(() => onAddToChat(activity), [activity, onAddToChat]);
const handleCopy = useCallback(() => {
void writeMarkdownToRichClipboard(activity.body, getDefaultMarkdownClipboardEnvironment());
@@ -797,7 +812,7 @@ function ActivityKebab({
</DropdownMenuItem>
) : null}
<DropdownMenuItem leading={OPEN_MENU_ICON} onSelect={handleOpen}>
Open on GitHub
{t("workspace.git.pr.actions.openOn", { brand: brandLabel })}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@@ -887,6 +902,7 @@ function SingleActivityCard({
entry,
collapsed,
attachEnabled,
brandLabel,
onAddToChat,
onToggleCollapsed,
}: TimelineEntryCallbacks & {
@@ -920,6 +936,7 @@ function SingleActivityCard({
activity={activity}
visible={actionsVisible}
attachEnabled={attachEnabled}
brandLabel={brandLabel}
onMenuOpenChange={setMenuOpen}
onAddToChat={onAddToChat}
/>
@@ -942,6 +959,7 @@ function SingleActivityCard({
activity={activity}
visible={actionsVisible}
attachEnabled={attachEnabled}
brandLabel={brandLabel}
onMenuOpenChange={setMenuOpen}
onAddToChat={onAddToChat}
/>
@@ -974,6 +992,7 @@ function ThreadCard({
entry,
collapsed,
attachEnabled,
brandLabel,
onAddToChat,
onAddThreadToChat,
onToggleCollapsed,
@@ -987,6 +1006,7 @@ function ThreadCard({
thread={entry}
collapsed={collapsed}
attachEnabled={attachEnabled}
brandLabel={brandLabel}
onAddToChat={onAddToChat}
onAddThreadToChat={onAddThreadToChat}
onToggleCollapsed={onToggleCollapsed}
@@ -1000,6 +1020,7 @@ function ReviewCard({
collapsed,
collapsedEntryIds,
attachEnabled,
brandLabel,
onAddToChat,
onAddThreadToChat,
onToggleCollapsed,
@@ -1047,6 +1068,7 @@ function ReviewCard({
activity={review}
visible={actionsVisible}
attachEnabled={attachEnabled}
brandLabel={brandLabel}
onMenuOpenChange={setMenuOpen}
onAddToChat={onAddToChat}
/>
@@ -1079,6 +1101,7 @@ function ReviewCard({
thread={thread}
collapsed={collapsedEntryIds.has(thread.id)}
attachEnabled={attachEnabled}
brandLabel={brandLabel}
onAddToChat={onAddToChat}
onAddThreadToChat={onAddThreadToChat}
onToggleCollapsed={onToggleCollapsed}
@@ -1097,6 +1120,7 @@ function ThreadBlock({
thread,
collapsed,
attachEnabled,
brandLabel,
onAddToChat,
onAddThreadToChat,
onToggleCollapsed,
@@ -1104,10 +1128,12 @@ function ThreadBlock({
thread: PrThreadEntry;
collapsed: boolean;
attachEnabled: boolean;
brandLabel: string;
onAddToChat: (activity: PrPaneActivity) => void;
onAddThreadToChat: (thread: PrThreadEntry) => void;
onToggleCollapsed: (entryId: string, collapsed: boolean) => void;
}) {
const { t } = useTranslation();
const { actionsVisible, handlePointerEnter, handlePointerLeave, setMenuOpen } =
useRevealOnHover();
const handleHeaderPress = useCallback(() => {
@@ -1127,10 +1153,12 @@ function ThreadBlock({
<View onPointerEnter={handlePointerEnter} onPointerLeave={handlePointerLeave}>
<Pressable onPress={handleHeaderPress} style={threadHeaderPressableStyle}>
<Text style={styles.threadPath} numberOfLines={1}>
{formatPullRequestThreadPath(thread.location)}
{thread.location
? formatPullRequestThreadPath(thread.location)
: t("workspace.git.pr.thread.discussion")}
</Text>
{thread.location.isResolved ? <StatusBadge label="Resolved" variant="success" /> : null}
{thread.location.isOutdated ? <StatusBadge label="Outdated" /> : null}
{thread.isResolved ? <StatusBadge label="Resolved" variant="success" /> : null}
{thread.location?.isOutdated ? <StatusBadge label="Outdated" /> : null}
<View style={styles.headerTrailing}>
{collapsed ? (
<View style={styles.threadCount}>
@@ -1152,7 +1180,7 @@ function ThreadBlock({
</DropdownMenuTrigger>
<DropdownMenuContent align="end" width={200}>
<DropdownMenuItem leading={OPEN_MENU_ICON} onSelect={handleOpenThread}>
Open on GitHub
{t("workspace.git.pr.actions.openOn", { brand: brandLabel })}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@@ -1161,7 +1189,12 @@ function ThreadBlock({
</Pressable>
{collapsed ? null : (
<>
<ThreadComment comment={root} attachEnabled={attachEnabled} onAddToChat={onAddToChat} />
<ThreadComment
comment={root}
attachEnabled={attachEnabled}
brandLabel={brandLabel}
onAddToChat={onAddToChat}
/>
{replies.length > 0 ? (
<View style={styles.replyRail}>
{replies.map((reply) => (
@@ -1169,6 +1202,7 @@ function ThreadBlock({
<ThreadComment
comment={reply}
attachEnabled={attachEnabled}
brandLabel={brandLabel}
onAddToChat={onAddToChat}
contentStyle={styles.replyThreadComment}
/>
@@ -1205,11 +1239,13 @@ function threadCommentStyle(contentStyle?: ViewStyle) {
function ThreadComment({
comment,
attachEnabled,
brandLabel,
onAddToChat,
contentStyle,
}: {
comment: PrPaneActivity;
attachEnabled: boolean;
brandLabel: string;
onAddToChat: (activity: PrPaneActivity) => void;
contentStyle?: ViewStyle;
}) {
@@ -1227,6 +1263,7 @@ function ThreadComment({
activity={comment}
visible={actionsVisible}
attachEnabled={attachEnabled}
brandLabel={brandLabel}
onMenuOpenChange={setMenuOpen}
onAddToChat={onAddToChat}
/>
@@ -1357,87 +1394,6 @@ const styles = StyleSheet.create((theme) => ({
alignItems: "center",
justifyContent: "center",
},
sectionHeader: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
},
sectionTitle: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.medium,
color: theme.colors.foregroundMuted,
},
sectionBody: {
paddingBottom: theme.spacing[3],
},
summaryWrap: {
marginLeft: "auto",
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
summaryPill: {
flexDirection: "row",
alignItems: "center",
gap: 3,
},
summaryPillSuccessText: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal,
color: theme.colors.statusSuccess,
},
summaryPillDangerText: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal,
color: theme.colors.statusDanger,
},
summaryPillWarningText: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal,
color: theme.colors.statusWarning,
},
summaryPillMutedText: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal,
color: theme.colors.foregroundMuted,
},
emptyText: {
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
},
checkRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
minHeight: 32,
},
checkName: {
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.normal,
color: theme.colors.foreground,
flexShrink: 1,
},
checkWorkflow: {
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
flexShrink: 1,
},
checkTrailing: {
marginLeft: "auto",
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
checkDuration: {
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
},
checkAddButton: {
paddingVertical: 0,
},
@@ -11,3 +11,20 @@ export function prPaneTimelineQueryKey({
}) {
return [prPaneTimelineQueryKind, serverId, cwd, prNumber] as const;
}
export const prPanePipelineQueryKind = "prPanePipeline";
export function prPanePipelineQueryKey({
serverId,
cwd,
pipelineId,
changeRequestNumber,
}: {
serverId: string;
cwd: string;
pipelineId: number | null;
/** MR iid the pipeline is fetched by; part of the key since the fetch routes by it. */
changeRequestNumber: number;
}) {
return [prPanePipelineQueryKind, serverId, cwd, pipelineId, changeRequestNumber] as const;
}
@@ -0,0 +1,176 @@
import React, { type ReactNode } from "react";
import { Pressable, Text, View } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import {
ChevronDown,
ChevronRight,
CircleCheck,
CircleDot,
CircleSlash,
CircleX,
} from "lucide-react-native";
import type { Theme } from "@/styles/theme";
import type { CheckStatus } from "./check-status";
const ThemedChevronDown = withUnistyles(ChevronDown);
const ThemedChevronRight = withUnistyles(ChevronRight);
const ThemedCircleCheck = withUnistyles(CircleCheck);
const ThemedCircleDot = withUnistyles(CircleDot);
const ThemedCircleSlash = withUnistyles(CircleSlash);
const ThemedCircleX = withUnistyles(CircleX);
export const foregroundMutedColorMapping = (theme: Theme) => ({
color: theme.colors.foregroundMuted,
});
export const successColorMapping = (theme: Theme) => ({ color: theme.colors.statusSuccess });
export const dangerColorMapping = (theme: Theme) => ({ color: theme.colors.statusDanger });
export const warningColorMapping = (theme: Theme) => ({ color: theme.colors.statusWarning });
export const SUMMARY_SUCCESS_ICON = <ThemedCircleCheck size={12} uniProps={successColorMapping} />;
export const SUMMARY_DANGER_ICON = <ThemedCircleX size={12} uniProps={dangerColorMapping} />;
export const SUMMARY_WARNING_ICON = <ThemedCircleDot size={12} uniProps={warningColorMapping} />;
interface SectionProps {
title: string;
open: boolean;
onToggle: () => void;
summary: ReactNode;
children: ReactNode;
}
export function Section({ title, open, onToggle, summary, children }: SectionProps) {
return (
<View>
<Pressable style={sectionKitStyles.sectionHeader} onPress={onToggle}>
{open ? (
<ThemedChevronDown size={14} uniProps={foregroundMutedColorMapping} />
) : (
<ThemedChevronRight size={14} uniProps={foregroundMutedColorMapping} />
)}
<Text style={sectionKitStyles.sectionTitle}>{title}</Text>
<View style={sectionKitStyles.summaryWrap}>{summary}</View>
</Pressable>
{open ? <View style={sectionKitStyles.sectionBody}>{children}</View> : null}
</View>
);
}
export type SummaryPillVariant = "success" | "danger" | "warning" | "muted";
export function SummaryPill({
count,
icon,
variant,
testID,
}: {
count: number;
icon: ReactNode;
variant: SummaryPillVariant;
testID?: string;
}) {
if (count === 0) return null;
return (
<View style={sectionKitStyles.summaryPill} testID={testID}>
{icon}
<Text style={summaryPillTextStyle(variant)}>{count}</Text>
</View>
);
}
function summaryPillTextStyle(variant: SummaryPillVariant) {
if (variant === "success") return sectionKitStyles.summaryPillSuccessText;
if (variant === "danger") return sectionKitStyles.summaryPillDangerText;
if (variant === "warning") return sectionKitStyles.summaryPillWarningText;
return sectionKitStyles.summaryPillMutedText;
}
export function CheckStatusIcon({ status }: { status: CheckStatus }) {
if (status === "success") return <ThemedCircleCheck size={14} uniProps={successColorMapping} />;
if (status === "failure") return <ThemedCircleX size={14} uniProps={dangerColorMapping} />;
if (status === "pending") return <ThemedCircleDot size={14} uniProps={warningColorMapping} />;
return <ThemedCircleSlash size={14} uniProps={foregroundMutedColorMapping} />;
}
export const sectionKitStyles = StyleSheet.create((theme) => ({
sectionHeader: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
},
sectionTitle: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.medium,
color: theme.colors.foregroundMuted,
},
sectionBody: {
paddingBottom: theme.spacing[3],
},
summaryWrap: {
marginLeft: "auto",
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
summaryPill: {
flexDirection: "row",
alignItems: "center",
gap: 3,
},
summaryPillSuccessText: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal,
color: theme.colors.statusSuccess,
},
summaryPillDangerText: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal,
color: theme.colors.statusDanger,
},
summaryPillWarningText: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal,
color: theme.colors.statusWarning,
},
summaryPillMutedText: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal,
color: theme.colors.foregroundMuted,
},
emptyText: {
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
},
checkRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
minHeight: 32,
},
checkName: {
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.normal,
color: theme.colors.foreground,
flexShrink: 1,
},
checkWorkflow: {
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
flexShrink: 1,
},
checkTrailing: {
marginLeft: "auto",
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
checkDuration: {
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
},
}));
@@ -1,5 +1,15 @@
import { GitHubIcon } from "@/components/icons/github-icon";
import { getForgeIconComponent } from "@/git/forge-icon";
import { getForgePresentation, type Forge } from "@/git/forge";
export function PullRequestTabIcon({ size, color }: { size: number; color: string }) {
return <GitHubIcon size={size} color={color} />;
export function PullRequestTabIcon({
forge,
size,
color,
}: {
forge: Forge;
size: number;
color: string;
}) {
const Icon = getForgeIconComponent(getForgePresentation(forge).icon);
return <Icon size={size} color={color} />;
}
@@ -45,12 +45,33 @@ describe("buildPrTimeline", () => {
kind: "thread",
id: "thread:PRRT_1",
location: { path: "src/a.ts", line: 12, threadId: "PRRT_1", isResolved: false },
isResolved: false,
comments: [root, reply],
},
{ kind: "single", id: "c2", activity: between },
]);
});
it("groups comments sharing a top-level threadId with no location into one thread", () => {
const root = activity({ id: "n1", threadId: "disc-1" });
const between = activity({ id: "c1" });
const reply = activity({ id: "n2", author: "bob", threadId: "disc-1" });
expect(buildPrTimeline([root, between, reply])).toEqual([
{ kind: "thread", id: "thread:disc-1", comments: [root, reply] },
{ kind: "single", id: "c1", activity: between },
]);
});
it("surfaces general-discussion resolution as thread isResolved without a location", () => {
const root = activity({ id: "g1", threadId: "disc-2", threadIsResolved: true });
const reply = activity({ id: "g2", author: "bob", threadId: "disc-2", threadIsResolved: true });
expect(buildPrTimeline([root, reply])).toEqual([
{ kind: "thread", id: "thread:disc-2", isResolved: true, comments: [root, reply] },
]);
});
it("keeps located comments without a threadId as single entries", () => {
const located = activity({ id: "c1", location: { path: "src/a.ts", line: 3 } });
@@ -5,7 +5,14 @@ export type PrThreadLocation = NonNullable<PrPaneActivity["location"]>;
export interface PrThreadEntry {
kind: "thread";
id: string;
location: PrThreadLocation;
/** Absent for general (non-file) discussion threads that carry no position. */
location?: PrThreadLocation;
/**
* Thread resolution state, unified across file threads (`location.isResolved`)
* and general discussions (`threadIsResolved`). Absent when the forge exposes no
* resolution for the thread.
*/
isResolved?: boolean;
comments: PrPaneActivity[];
}
@@ -23,8 +30,10 @@ export type PrTimelineEntry =
/**
* Builds the GitHub-style nested timeline:
* - comments sharing a `location.threadId` collapse into one thread entry
* (root comment + replies), placed at the first comment's position;
* - comments sharing a thread id collapse into one thread entry (root comment +
* replies), placed at the first comment's position. The id is the top-level
* `threadId` (forge-neutral, used by GitLab general discussions) falling back
* to `location.threadId` (file-position threads);
* - threads whose root comment carries a `reviewId` nest under that review
* (GitHub's explicit signal: PullRequestReviewComment.pullRequestReview);
* - everything else stays a standalone entry in original order.
@@ -39,8 +48,8 @@ function groupThreads(activities: readonly PrPaneActivity[]): PrTimelineEntry[]
const threadsById = new Map<string, PrThreadEntry>();
for (const activity of activities) {
const threadId = activity.location?.threadId;
if (!activity.location || !threadId) {
const threadId = activity.threadId ?? activity.location?.threadId;
if (!threadId) {
entries.push({ kind: "single", id: activity.id, activity });
continue;
}
@@ -51,10 +60,12 @@ function groupThreads(activities: readonly PrPaneActivity[]): PrTimelineEntry[]
continue;
}
const resolved = activity.location?.isResolved ?? activity.threadIsResolved;
const thread: PrThreadEntry = {
kind: "thread",
id: `thread:${threadId}`,
location: activity.location,
...(activity.location ? { location: activity.location } : {}),
...(resolved !== undefined ? { isResolved: resolved } : {}),
comments: [activity],
};
threadsById.set(threadId, thread);
@@ -38,6 +38,7 @@ const githubStatus: CheckoutPrStatus["github"] = {
function prStatus(overrides: Partial<CheckoutPrStatus> = {}): CheckoutPrStatus {
return {
forge: "github",
number: 42,
url: "https://github.com/getpaseo/paseo/pull/42",
title: "Wire real PR pane data",
@@ -377,6 +378,34 @@ describe("selectPrPaneState", () => {
expect(state.data?.activity).toEqual([]);
});
it("passes GitLab native facts through the selected pane data", () => {
const gitlabFacts = {
forge: "gitlab" as const,
detailedMergeStatus: "mergeable",
hasConflicts: false,
blockingDiscussionsResolved: true,
approvalsRequired: 2,
approvalsGiven: 1,
pipelineStatus: null,
pipelineId: null,
pipelineUrl: null,
mergeWhenPipelineSucceeds: false,
};
const state = selectPrPaneState({
...baseSelectInput,
forge: "gitlab",
status: prStatus({
forge: "gitlab",
github: undefined,
forgeSpecific: gitlabFacts,
}),
timelinePayload: timelinePayload(),
});
expect(state.data?.provider).toEqual({ id: "gitlab", label: "GitLab" });
expect(state.data?.forgeSpecific).toEqual({ ...gitlabFacts, mergeStatus: null });
});
it("returns null data when the consumer disabled timeline rendering", () => {
const state = selectPrPaneState({
...baseSelectInput,
@@ -8,6 +8,7 @@ import type {
} 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";
@@ -25,6 +26,7 @@ export interface UsePrPaneDataOptions {
export interface UsePrPaneDataResult {
data: PrPaneData | null;
forge: Forge;
prNumber: number | null;
isLoading: boolean;
activityLoading: boolean;
@@ -150,6 +152,7 @@ export interface SelectPrPaneStateInput {
statusIsLoading: boolean;
statusIsFetching: boolean;
githubFeaturesEnabled: boolean;
forge?: Forge;
timelineEnabled: boolean;
shouldFetchTimeline: boolean;
timelinePayload: PullRequestTimeline | undefined;
@@ -165,7 +168,7 @@ export function selectPrPaneState(input: SelectPrPaneStateInput): UsePrPaneDataR
const data =
identity.prNumber === null || !input.timelineEnabled
? null
: mapPrPaneData(input.status, input.timelinePayload);
: mapPrPaneData(input.status, input.timelinePayload, undefined, input.forge ?? "github");
const statusRefreshing = input.statusIsFetching && !input.statusIsLoading;
const timelineRefreshing = input.timelineIsFetching && !input.timelineIsLoading;
const timelinePending =
@@ -173,6 +176,7 @@ export function selectPrPaneState(input: SelectPrPaneStateInput): UsePrPaneDataR
return {
data,
forge: input.forge ?? "github",
prNumber: identity.prNumber,
isLoading: input.statusIsLoading || timelinePending,
activityLoading: timelinePending,
@@ -262,6 +266,7 @@ export function usePrPaneData({
statusIsLoading: checkoutPrStatus.isLoading,
statusIsFetching: checkoutPrStatus.isFetching,
githubFeaturesEnabled,
forge: checkoutPrStatus.forge,
timelineEnabled,
shouldFetchTimeline,
timelinePayload: timelineQuery.data,
@@ -0,0 +1,87 @@
import { useMemo } from "react";
import type { CheckoutPipeline } from "@getpaseo/protocol/messages";
import { useFetchQuery } from "@/data/query";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { prPanePipelineQueryKey } from "./query-keys";
/** Poll cadence for an in-progress pipeline; finished pipelines are immutable. */
const LIVE_PIPELINE_REFETCH_MS = 15_000;
const FINISHED_PIPELINE_STALE_MS = 24 * 60 * 60 * 1_000;
export interface UseGitLabPipelineOptions {
serverId: string;
cwd: string;
pipelineId: number | null;
/** MR iid, so the fetch resolves a fork/detached head pipeline correctly. */
changeRequestNumber: number;
enabled: boolean;
/** True while the pipeline is still running, so jobs keep refreshing. */
live: boolean;
}
export interface UseGitLabPipelineResult {
pipeline: CheckoutPipeline | null;
isLoading: boolean;
isFetching: boolean;
/**
* True while the query is serving the previous change request's data as a
* placeholder after the query key changed (keepPreviousData). Callers use it
* to avoid rendering a stale job breakdown for the newly selected MR.
*/
isPlaceholderData: boolean;
error: Error | null;
}
/**
* Fetches a GitLab pipeline through the existing forge-routed check-details RPC.
* The pipeline id is carried in checkRunId; GitLab resolves its project from cwd.
*/
export function useGitLabPipeline({
serverId,
cwd,
pipelineId,
changeRequestNumber,
enabled,
live,
}: UseGitLabPipelineOptions): UseGitLabPipelineResult {
const daemonClient = useHostRuntimeClient(serverId);
const isConnected = useHostRuntimeIsConnected(serverId);
const shouldFetch = enabled && !!daemonClient && isConnected && !!cwd && pipelineId !== null;
const query = useFetchQuery<CheckoutPipeline | null>({
queryKey: useMemo(
() => prPanePipelineQueryKey({ serverId, cwd, pipelineId, changeRequestNumber }),
[serverId, cwd, pipelineId, changeRequestNumber],
),
queryFn: async () => {
if (!daemonClient || pipelineId === null) {
return null;
}
const payload = await daemonClient.checkoutForgeGetCheckDetails({
cwd,
checkRunId: pipelineId,
changeRequestNumber,
});
// A failed fetch must surface as a query error so the section shows its
// error state; null is reserved for a successful response with no pipeline.
if (!payload.success) {
throw new Error(payload.error?.message ?? "Could not load pipeline jobs");
}
return payload.details?.pipeline ?? null;
},
enabled: shouldFetch,
dataShape: "list",
staleTimeMs: live ? 0 : FINISHED_PIPELINE_STALE_MS,
refetchInterval: live && shouldFetch ? LIVE_PIPELINE_REFETCH_MS : false,
refetchOnReconnect: false,
refetchOnWindowFocus: false,
});
return {
pipeline: query.data ?? null,
isLoading: shouldFetch && query.isLoading,
isFetching: query.isFetching,
isPlaceholderData: query.isPlaceholderData,
error: query.error,
};
}
+45 -1
View File
@@ -7,7 +7,10 @@ import {
invalidateCheckoutGitQueriesForClient,
invalidateCheckoutGitQueriesForServer,
} from "@/git/query-keys";
import { prPaneTimelineQueryKey } from "@/git/pull-request-panel/query-keys";
import {
prPanePipelineQueryKey,
prPaneTimelineQueryKey,
} from "@/git/pull-request-panel/query-keys";
describe("checkout query keys", () => {
const serverId = "server-1";
@@ -27,10 +30,25 @@ describe("checkout query keys", () => {
queryClient.setQueryData(prPaneTimelineQueryKey({ serverId, cwd, prNumber: 13 }), {
items: [],
});
queryClient.setQueryData(
prPanePipelineQueryKey({ serverId, cwd, pipelineId: 9001, changeRequestNumber: 1 }),
{
stages: [],
},
);
queryClient.setQueryData(
prPaneTimelineQueryKey({ serverId, cwd: "/tmp/other", prNumber: 12 }),
{ items: [] },
);
queryClient.setQueryData(
prPanePipelineQueryKey({
serverId,
cwd: "/tmp/other",
pipelineId: 9001,
changeRequestNumber: 1,
}),
{ stages: [] },
);
await invalidateCheckoutGitQueriesForClient(queryClient, { serverId, cwd });
@@ -52,11 +70,26 @@ describe("checkout query keys", () => {
queryClient.getQueryState(prPaneTimelineQueryKey({ serverId, cwd, prNumber: 13 }))
?.isInvalidated,
).toBe(true);
expect(
queryClient.getQueryState(
prPanePipelineQueryKey({ serverId, cwd, pipelineId: 9001, changeRequestNumber: 1 }),
)?.isInvalidated,
).toBe(true);
expect(
queryClient.getQueryState(
prPaneTimelineQueryKey({ serverId, cwd: "/tmp/other", prNumber: 12 }),
)?.isInvalidated,
).toBe(false);
expect(
queryClient.getQueryState(
prPanePipelineQueryKey({
serverId,
cwd: "/tmp/other",
pipelineId: 9001,
changeRequestNumber: 1,
}),
)?.isInvalidated,
).toBe(false);
queryClient.clear();
});
@@ -72,6 +105,12 @@ describe("checkout query keys", () => {
queryClient.setQueryData(prPaneTimelineQueryKey({ serverId, cwd, prNumber: 12 }), {
items: [],
});
queryClient.setQueryData(
prPanePipelineQueryKey({ serverId, cwd, pipelineId: 9001, changeRequestNumber: 1 }),
{
stages: [],
},
);
// Subscription-fed diff queries are deliberately not part of the server-wide sweep.
queryClient.setQueryData(checkoutDiffQueryKey(serverId, cwd, "base", "main", true), {
files: [],
@@ -93,6 +132,11 @@ describe("checkout query keys", () => {
queryClient.getQueryState(prPaneTimelineQueryKey({ serverId, cwd, prNumber: 12 }))
?.isInvalidated,
).toBe(true);
expect(
queryClient.getQueryState(
prPanePipelineQueryKey({ serverId, cwd, pipelineId: 9001, changeRequestNumber: 1 }),
)?.isInvalidated,
).toBe(true);
expect(
queryClient.getQueryState(checkoutDiffQueryKey(serverId, cwd, "base", "main", true))
?.isInvalidated,
+18 -5
View File
@@ -1,5 +1,5 @@
import type { Query, QueryClient } from "@tanstack/react-query";
import { prPaneTimelineQueryKind } from "./pull-request-panel/query-keys";
import { prPanePipelineQueryKind, prPaneTimelineQueryKind } from "./pull-request-panel/query-keys";
interface CheckoutQueryIdentity {
serverId: string;
@@ -65,6 +65,9 @@ export async function invalidateCheckoutGitQueriesForClient(
queryClient.invalidateQueries({
predicate: checkoutQueryPredicate(prPaneTimelineQueryKind, identity),
}),
queryClient.invalidateQueries({
predicate: checkoutQueryPredicate(prPanePipelineQueryKind, identity),
}),
]);
}
@@ -75,7 +78,12 @@ export async function invalidateCheckoutGitQueriesForServer(
queryClient: QueryClient,
serverId: string,
) {
const kinds = ["checkoutStatus", "checkoutPrStatus", prPaneTimelineQueryKind];
const kinds = [
"checkoutStatus",
"checkoutPrStatus",
prPaneTimelineQueryKind,
prPanePipelineQueryKind,
];
await Promise.all(
kinds.map((kind) =>
queryClient.invalidateQueries({ predicate: checkoutQueryPredicate(kind, { serverId }) }),
@@ -87,9 +95,14 @@ export async function invalidatePrPaneTimelineForCheckout(
queryClient: QueryClient,
identity: CheckoutQueryIdentity,
) {
await queryClient.invalidateQueries({
predicate: checkoutQueryPredicate(prPaneTimelineQueryKind, identity),
});
await Promise.all([
queryClient.invalidateQueries({
predicate: checkoutQueryPredicate(prPaneTimelineQueryKind, identity),
}),
queryClient.invalidateQueries({
predicate: checkoutQueryPredicate(prPanePipelineQueryKind, identity),
}),
]);
}
function checkoutQueryPredicate(
+150 -101
View File
@@ -1,6 +1,9 @@
import { useState, useCallback, useEffect, useMemo, type ReactElement } from "react";
import AsyncStorage from "@react-native-async-storage/async-storage";
import { useTranslation } from "react-i18next";
import type { Theme } from "@/styles/theme";
import { getForgePresentation, type Forge } from "@/git/forge";
import { ForgeBrandIcon, getForgeBrandColorMapping } from "@/git/forge-icon";
import { type CheckoutGitActionStatus, useCheckoutGitActionsStore } from "@/git/actions-store";
import { type CheckoutStatusPayload, useCheckoutStatusQuery } from "@/git/use-status-query";
import { type CheckoutPrStatusPayload, useCheckoutPrStatusQuery } from "@/git/use-pr-status-query";
@@ -11,6 +14,7 @@ import {
type GitAction,
type GitActions,
} from "@/git/policy";
import { deriveMergeCapability } from "@/git/merge-capability";
import type { CheckoutPrMergeMethod } from "@getpaseo/protocol/messages";
import { openExternalUrl } from "@/utils/open-external-url";
import { useToast } from "@/contexts/toast-context";
@@ -26,6 +30,29 @@ import { resolveWorkspaceMapKeyByIdentity } from "@/utils/workspace-identity";
export type { GitActionId, GitAction, GitActions } from "@/git/policy";
const forgeMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
/**
* The leading icon for every change-request action (create/view/merge) is the
* forge's brand mark, tinted in its brand color (the GitLab tanuki orange, etc).
* GitHub and unknown forges have no brand color and render in a neutral tone.
* The merge variants all share this one icon, so we build it once.
*/
function renderForgePrIcon(forge: Forge): ReactElement {
const icon = getForgePresentation(forge).icon;
return (
<ForgeBrandIcon
iconKind={icon}
size={16}
uniProps={getForgeBrandColorMapping(icon) ?? forgeMutedColorMapping}
/>
);
}
function forgeVocabulary(forge: Forge): { context: "mr" | undefined } {
return { context: getForgePresentation(forge).changeRequestContext };
}
function openURLInNewTab(url: string): void {
void openExternalUrl(url);
}
@@ -144,11 +171,6 @@ interface UseGitActionsInput {
pull: ReactElement;
push: ReactElement;
pullAndPush: ReactElement;
viewPr: ReactElement;
createPr: ReactElement;
mergePrSquash: ReactElement;
mergePrMerge: ReactElement;
mergePrRebase: ReactElement;
merge: ReactElement;
mergeFromBase: ReactElement;
archive: ReactElement;
@@ -281,11 +303,16 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
const hasUncommittedChanges = Boolean(gitStatus?.isDirty);
const { status: prStatus, githubFeaturesEnabled } = useCheckoutPrStatusQuery({
const {
status: prStatus,
githubFeaturesEnabled,
forge,
} = useCheckoutPrStatusQuery({
serverId,
cwd,
enabled: isGit,
});
const prIcon = useMemo(() => renderForgePrIcon(forge), [forge]);
const baseRefLabel = useMemo(
() => formatBaseRefLabel(baseRef, t("workspace.git.diff.base")),
[baseRef, t],
@@ -403,7 +430,9 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
const runMergeBranch = useCheckoutGitActionsStore((s) => s.mergeBranch);
const runMergeFromBase = useCheckoutGitActionsStore((s) => s.mergeFromBase);
const githubAutoMergeActionsEnabled = useSessionStore(
(s) => s.sessions[serverId]?.serverInfo?.features?.checkoutGithubSetAutoMerge === true,
(s) =>
s.sessions[serverId]?.serverInfo?.features?.checkoutForgeSetAutoMerge === true ||
s.sessions[serverId]?.serverInfo?.features?.checkoutGithubSetAutoMerge === true,
);
const toastActionError = useCallback(
@@ -470,13 +499,22 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
void persistShipDefault("pr");
void runCreatePr({ serverId, cwd })
.then(() => {
toastActionSuccess(t("workspace.git.actions.createPr.success"));
toastActionSuccess(t("workspace.git.actions.createPr.success", forgeVocabulary(forge)));
return;
})
.catch((err) => {
toastActionError(err, t("workspace.git.actions.toasts.failedCreatePr"));
});
}, [cwd, persistShipDefault, runCreatePr, serverId, t, toastActionError, toastActionSuccess]);
}, [
cwd,
forge,
persistShipDefault,
runCreatePr,
serverId,
t,
toastActionError,
toastActionSuccess,
]);
const handleMergePr = useCallback(
(method: CheckoutPrMergeMethod) => {
@@ -484,14 +522,14 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
void runMergePr({ serverId, cwd, method })
.then(() => {
setPostShipArchiveSuggested(true);
toastActionSuccess(t("workspace.git.actions.mergePr.success"));
toastActionSuccess(t("workspace.git.actions.mergePr.success", forgeVocabulary(forge)));
return;
})
.catch((err) => {
toastActionError(err, t("workspace.git.actions.toasts.failedMergePr"));
});
},
[cwd, persistShipDefault, runMergePr, serverId, t, toastActionError, toastActionSuccess],
[cwd, forge, persistShipDefault, runMergePr, serverId, t, toastActionError, toastActionSuccess],
);
const handleEnablePrAutoMerge = useCallback(
@@ -615,10 +653,13 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
}, [prStatus?.url, handleCreatePr]);
// Build actions
const gitActionsInput = useMemo<BuildGitActionsInput>(
() => ({
const gitActionsInput = useMemo<BuildGitActionsInput>(() => {
const presentation = getForgePresentation(forge);
return {
isGit,
githubFeaturesEnabled,
forgeBrandLabel: presentation.brandLabel,
forgeChangeRequestNoun: presentation.changeRequestAbbrev,
githubAutoMergeActionsEnabled,
hasPullRequest,
pullRequestUrl: prStatus?.url ?? null,
@@ -626,7 +667,7 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
pullRequestIsDraft: prStatus?.isDraft ?? false,
pullRequestIsMerged: prStatus?.isMerged ?? false,
pullRequestMergeable: prStatus?.mergeable ?? "UNKNOWN",
pullRequestGithub: prStatus?.github ?? null,
mergeCapability: deriveMergeCapability(prStatus?.forgeSpecific, prStatus?.github),
hasRemote,
isPaseoOwnedWorktree,
isOnBaseBranch,
@@ -667,49 +708,49 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
pr: {
disabled: isActionDisabled(actionsDisabled, prCreateStatus),
status: hasPullRequest ? "idle" : prCreateStatus,
icon: hasPullRequest ? icons.viewPr : icons.createPr,
icon: prIcon,
handler: handlePrAction,
},
"merge-pr-squash": {
disabled: isActionDisabled(actionsDisabled, mergePrStatuses.squash),
status: mergePrStatuses.squash,
icon: icons.mergePrSquash,
icon: prIcon,
handler: () => handleMergePr("squash"),
},
"merge-pr-merge": {
disabled: isActionDisabled(actionsDisabled, mergePrStatuses.merge),
status: mergePrStatuses.merge,
icon: icons.mergePrMerge,
icon: prIcon,
handler: () => handleMergePr("merge"),
},
"merge-pr-rebase": {
disabled: isActionDisabled(actionsDisabled, mergePrStatuses.rebase),
status: mergePrStatuses.rebase,
icon: icons.mergePrRebase,
icon: prIcon,
handler: () => handleMergePr("rebase"),
},
"enable-pr-auto-merge-squash": {
disabled: isActionDisabled(actionsDisabled, enablePrAutoMergeStatuses.squash),
status: enablePrAutoMergeStatuses.squash,
icon: icons.mergePrSquash,
icon: prIcon,
handler: () => handleEnablePrAutoMerge("squash"),
},
"enable-pr-auto-merge-merge": {
disabled: isActionDisabled(actionsDisabled, enablePrAutoMergeStatuses.merge),
status: enablePrAutoMergeStatuses.merge,
icon: icons.mergePrMerge,
icon: prIcon,
handler: () => handleEnablePrAutoMerge("merge"),
},
"enable-pr-auto-merge-rebase": {
disabled: isActionDisabled(actionsDisabled, enablePrAutoMergeStatuses.rebase),
status: enablePrAutoMergeStatuses.rebase,
icon: icons.mergePrRebase,
icon: prIcon,
handler: () => handleEnablePrAutoMerge("rebase"),
},
"disable-pr-auto-merge": {
disabled: isActionDisabled(actionsDisabled, disablePrAutoMergeStatus),
status: disablePrAutoMergeStatus,
icon: icons.viewPr,
icon: prIcon,
handler: handleDisablePrAutoMerge,
},
"merge-branch": {
@@ -731,66 +772,73 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
handler: handleArchiveWorkspace,
},
},
}),
[
isGit,
hasRemote,
hasPullRequest,
prStatus?.url,
prStatus?.state,
prStatus?.isDraft,
prStatus?.isMerged,
prStatus?.mergeable,
prStatus?.github,
aheadCount,
behindBaseCount,
isPaseoOwnedWorktree,
isOnBaseBranch,
githubFeaturesEnabled,
githubAutoMergeActionsEnabled,
hasUncommittedChanges,
aheadOfOrigin,
behindOfOrigin,
shipDefault,
baseRefLabel,
shouldPromoteArchive,
actionsDisabled,
commitStatus,
pullStatus,
pushStatus,
pullAndPushStatus,
prCreateStatus,
mergePrStatuses.squash,
mergePrStatuses.merge,
mergePrStatuses.rebase,
enablePrAutoMergeStatuses.squash,
enablePrAutoMergeStatuses.merge,
enablePrAutoMergeStatuses.rebase,
disablePrAutoMergeStatus,
mergeStatus,
mergeFromBaseStatus,
archiveController.canArchive,
archiveController.isArchiving,
handleCommit,
handlePull,
handlePush,
handlePullAndPush,
handlePrAction,
handleMergePr,
handleEnablePrAutoMerge,
handleDisablePrAutoMerge,
handleMergeBranch,
handleMergeFromBase,
handleArchiveWorkspace,
icons,
baseRef,
],
);
};
}, [
isGit,
hasRemote,
hasPullRequest,
prStatus?.url,
prStatus?.state,
prStatus?.isDraft,
prStatus?.isMerged,
prStatus?.mergeable,
prStatus?.forgeSpecific,
prStatus?.github,
aheadCount,
behindBaseCount,
isPaseoOwnedWorktree,
isOnBaseBranch,
githubFeaturesEnabled,
forge,
githubAutoMergeActionsEnabled,
hasUncommittedChanges,
aheadOfOrigin,
behindOfOrigin,
shipDefault,
baseRefLabel,
shouldPromoteArchive,
actionsDisabled,
commitStatus,
pullStatus,
pushStatus,
pullAndPushStatus,
prCreateStatus,
mergePrStatuses.squash,
mergePrStatuses.merge,
mergePrStatuses.rebase,
enablePrAutoMergeStatuses.squash,
enablePrAutoMergeStatuses.merge,
enablePrAutoMergeStatuses.rebase,
disablePrAutoMergeStatus,
mergeStatus,
mergeFromBaseStatus,
archiveController.canArchive,
archiveController.isArchiving,
handleCommit,
handlePull,
handlePush,
handlePullAndPush,
handlePrAction,
handleMergePr,
handleEnablePrAutoMerge,
handleDisablePrAutoMerge,
handleMergeBranch,
handleMergeFromBase,
handleArchiveWorkspace,
icons,
prIcon,
baseRef,
]);
const gitActions: GitActions = useMemo(
() =>
translateGitActions(buildGitActions(gitActionsInput), { baseRefLabel, hasPullRequest, t }),
[gitActionsInput, baseRefLabel, hasPullRequest, t],
translateGitActions(buildGitActions(gitActionsInput), {
baseRefLabel,
hasPullRequest,
forge,
t,
}),
[gitActionsInput, baseRefLabel, hasPullRequest, forge, t],
);
return { gitActions, branchLabel, isGit };
@@ -801,6 +849,7 @@ function translateGitActions(
input: {
baseRefLabel: string;
hasPullRequest: boolean;
forge: Forge;
t: (key: string, options?: Record<string, unknown>) => string;
},
): GitActions {
@@ -816,14 +865,16 @@ function translateGitAction(
{
baseRefLabel,
hasPullRequest,
forge,
t,
}: {
baseRefLabel: string;
hasPullRequest: boolean;
forge: Forge;
t: (key: string, options?: Record<string, unknown>) => string;
},
): GitAction {
const labels = getTranslatedGitActionLabels(action, { baseRefLabel, hasPullRequest, t });
const labels = getTranslatedGitActionLabels(action, { baseRefLabel, hasPullRequest, forge, t });
return {
...action,
...labels,
@@ -839,10 +890,12 @@ function getTranslatedGitActionLabels(
{
baseRefLabel,
hasPullRequest,
forge,
t,
}: {
baseRefLabel: string;
hasPullRequest: boolean;
forge: Forge;
t: (key: string, options?: Record<string, unknown>) => string;
},
): Pick<GitAction, "label" | "pendingLabel" | "successLabel"> {
@@ -874,32 +927,32 @@ function getTranslatedGitActionLabels(
case "pr":
return hasPullRequest
? {
label: t("workspace.git.actions.viewPr"),
pendingLabel: t("workspace.git.actions.viewPr"),
successLabel: t("workspace.git.actions.viewPr"),
label: t("workspace.git.actions.viewPr", forgeVocabulary(forge)),
pendingLabel: t("workspace.git.actions.viewPr", forgeVocabulary(forge)),
successLabel: t("workspace.git.actions.viewPr", forgeVocabulary(forge)),
}
: {
label: t("workspace.git.actions.createPr.label"),
pendingLabel: t("workspace.git.actions.createPr.pending"),
successLabel: t("workspace.git.actions.createPr.success"),
label: t("workspace.git.actions.createPr.label", forgeVocabulary(forge)),
pendingLabel: t("workspace.git.actions.createPr.pending", forgeVocabulary(forge)),
successLabel: t("workspace.git.actions.createPr.success", forgeVocabulary(forge)),
};
case "merge-pr-squash":
return {
label: t("workspace.git.actions.mergePr.squash"),
pendingLabel: t("workspace.git.actions.mergePr.pending"),
successLabel: t("workspace.git.actions.mergePr.success"),
label: t("workspace.git.actions.mergePr.squash", forgeVocabulary(forge)),
pendingLabel: t("workspace.git.actions.mergePr.pending", forgeVocabulary(forge)),
successLabel: t("workspace.git.actions.mergePr.success", forgeVocabulary(forge)),
};
case "merge-pr-merge":
return {
label: t("workspace.git.actions.mergePr.merge"),
pendingLabel: t("workspace.git.actions.mergePr.pending"),
successLabel: t("workspace.git.actions.mergePr.success"),
label: t("workspace.git.actions.mergePr.merge", forgeVocabulary(forge)),
pendingLabel: t("workspace.git.actions.mergePr.pending", forgeVocabulary(forge)),
successLabel: t("workspace.git.actions.mergePr.success", forgeVocabulary(forge)),
};
case "merge-pr-rebase":
return {
label: t("workspace.git.actions.mergePr.rebase"),
pendingLabel: t("workspace.git.actions.mergePr.pending"),
successLabel: t("workspace.git.actions.mergePr.success"),
label: t("workspace.git.actions.mergePr.rebase", forgeVocabulary(forge)),
pendingLabel: t("workspace.git.actions.mergePr.pending", forgeVocabulary(forge)),
successLabel: t("workspace.git.actions.mergePr.success", forgeVocabulary(forge)),
};
case "enable-pr-auto-merge-squash":
return {
@@ -958,8 +1011,6 @@ function translateGitActionUnavailableMessage(
): string | undefined {
if (!message) return undefined;
const keyByMessage: Record<string, string> = {
"View PR isn't available right now because GitHub isn't connected":
"workspace.git.actions.unavailable.viewPrNoGithub",
"Pull isn't available here because this branch is not connected to a remote yet":
"workspace.git.actions.unavailable.pullNoRemote",
"Pull isn't available while you have local changes so commit or stash them first":
@@ -978,8 +1029,6 @@ function translateGitActionUnavailableMessage(
"workspace.git.actions.unavailable.pullAndPushDirty",
"Pull and push isn't available because this branch is already in sync":
"workspace.git.actions.unavailable.pullAndPushInSync",
"Create PR isn't available right now because GitHub isn't connected":
"workspace.git.actions.unavailable.createPrNoGithub",
"Create PR isn't available because this branch doesn't have any new commits yet":
"workspace.git.actions.unavailable.createPrNoCommits",
"Merge isn't available because we couldn't determine the base branch":
@@ -994,6 +1043,8 @@ function translateGitActionUnavailableMessage(
"workspace.git.actions.unavailable.updateDirty",
"Merge PR isn't available right now because GitHub isn't connected":
"workspace.git.actions.unavailable.mergePrNoGithub",
"Archive isn't available here because this workspace was not created as a Paseo worktree":
"workspace.git.actions.unavailable.archiveNotWorktree",
"Merge PR isn't available because there isn't a pull request yet":
"workspace.git.actions.unavailable.mergePrMissing",
"Merge PR isn't available because the pull request is still a draft":
@@ -1006,8 +1057,6 @@ function translateGitActionUnavailableMessage(
"workspace.git.actions.unavailable.mergePrConflicts",
"Merge PR isn't available here because this repository uses a merge queue":
"workspace.git.actions.unavailable.mergePrQueue",
"Merge PR isn't available until GitHub reports the pull request is ready to merge":
"workspace.git.actions.unavailable.mergePrNotReady",
"Auto-merge is enabled, but this account can't disable it":
"workspace.git.actions.unavailable.autoMergeCannotDisable",
};
@@ -0,0 +1,282 @@
import { describe, expect, it } from "vitest";
import { buildForgeSearchQueryOptions, forgeSearchQueryKey } from "./use-forge-search-query";
describe("forgeSearchQueryKey", () => {
it("keeps the shared cache key shape for no-kinds searches", () => {
expect(forgeSearchQueryKey("server-1", "/repo", " 123 ")).toEqual([
"forge-search",
"server-1",
"/repo",
"forge",
"123",
]);
});
it("adds a deterministic kinds key when kinds are specified", () => {
expect(forgeSearchQueryKey("server-1", "/repo", "123", ["change_request", "issue"])).toEqual([
"forge-search",
"server-1",
"/repo",
"forge",
"123",
"change_request,issue",
]);
});
it("separates legacy GitHub fallback results from forge search results", () => {
expect(forgeSearchQueryKey("server-1", "/repo", "123", undefined, "github")).toEqual([
"forge-search",
"server-1",
"/repo",
"github",
"123",
]);
});
});
describe("buildForgeSearchQueryOptions", () => {
it("forwards kinds to the forge search request when specified", async () => {
const requests: unknown[] = [];
const query = buildForgeSearchQueryOptions({
client: {
async searchForge(options) {
requests.push(options);
return {
items: [],
authState: "authenticated",
error: null,
requestId: "request-1",
};
},
},
serverId: "server-1",
cwd: "/repo",
query: " 123 ",
kinds: ["change_request"],
enabled: true,
});
await query.queryFn();
expect(requests).toEqual([
{ cwd: "/repo", query: "123", limit: 20, kinds: ["change_request"] },
]);
});
it("uses the legacy GitHub search request when forge search is unsupported", async () => {
const forgeRequests: unknown[] = [];
const githubRequests: unknown[] = [];
const query = buildForgeSearchQueryOptions({
client: {
async searchForge(options) {
forgeRequests.push(options);
return {
items: [],
authState: "authenticated",
error: null,
requestId: "forge-request",
};
},
async searchGitHub(options) {
githubRequests.push(options);
return {
items: [],
featuresEnabled: true,
authState: "authenticated",
githubFeaturesEnabled: true,
error: null,
requestId: "github-request",
};
},
},
serverId: "server-1",
cwd: "/repo",
query: " 456 ",
kinds: ["issue"],
enabled: true,
supportsForgeSearch: false,
});
await query.queryFn();
expect(forgeRequests).toEqual([]);
expect(githubRequests).toEqual([
{ cwd: "/repo", query: "456", limit: 20, kinds: ["github-issue"] },
]);
});
it("normalizes legacy GitHub PR items into neutral change-request items", async () => {
const query = buildForgeSearchQueryOptions({
client: {
async searchForge() {
throw new Error("unexpected forge search");
},
async searchGitHub() {
return {
items: [
{
kind: "pr" as const,
number: 17,
title: "Fix search",
url: "https://github.com/acme/repo/pull/17",
state: "open",
body: null,
labels: ["bug"],
baseRefName: "main",
headRefName: "fix-search",
},
],
featuresEnabled: true,
authState: "authenticated" as const,
githubFeaturesEnabled: true,
error: null,
requestId: "github-request",
};
},
},
serverId: "server-1",
cwd: "/repo",
query: " 456 ",
enabled: true,
supportsForgeSearch: false,
});
const result = await query.queryFn();
expect(result.items).toEqual([
{
kind: "change_request",
number: 17,
title: "Fix search",
url: "https://github.com/acme/repo/pull/17",
state: "open",
body: null,
labels: ["bug"],
baseRefName: "main",
headRefName: "fix-search",
},
]);
});
it("interprets modern search payloads at the query boundary", async () => {
const query = buildForgeSearchQueryOptions({
client: {
async searchForge() {
return {
items: [
{
kind: "issue",
number: 23,
title: "Keep this",
url: "https://gitlab.com/acme/repo/-/issues/23",
state: "open",
body: null,
labels: [],
},
{ kind: "future_kind", futureField: true },
],
authState: "future_auth_state",
error: null,
requestId: "forge-request",
};
},
},
serverId: "server-1",
cwd: "/repo",
query: "23",
enabled: true,
supportsForgeSearch: true,
});
const result = await query.queryFn();
expect(result.items).toHaveLength(1);
expect(result.items[0]?.kind).toBe("issue");
expect(result.authState).toBe("unauthenticated");
});
it("derives legacy search auth from legacy feature flags", async () => {
const query = buildForgeSearchQueryOptions({
client: {
async searchForge() {
throw new Error("unexpected forge search");
},
async searchGitHub() {
return {
items: [],
githubFeaturesEnabled: false,
error: null,
requestId: "github-request",
};
},
},
serverId: "server-1",
cwd: "/repo",
query: "23",
enabled: true,
supportsForgeSearch: false,
});
expect((await query.queryFn()).authState).toBe("unauthenticated");
});
it("invokes forge search bound to the client so this-dependent methods work", async () => {
const client = new ThisDependentSearchClient();
const query = buildForgeSearchQueryOptions({
client,
serverId: "server-1",
cwd: "/repo",
query: " 789 ",
enabled: true,
supportsForgeSearch: true,
});
const result = await query.queryFn();
expect(result.requestId).toBe("forge.search.request");
expect(client.requests).toEqual([{ cwd: "/repo", query: "789", limit: 20 }]);
});
it("invokes the legacy GitHub search bound to the client", async () => {
const client = new ThisDependentSearchClient();
const query = buildForgeSearchQueryOptions({
client,
serverId: "server-1",
cwd: "/repo",
query: " 789 ",
enabled: true,
supportsForgeSearch: false,
});
const result = await query.queryFn();
expect(result.requestId).toBe("github_search_request");
expect(client.requests).toEqual([{ cwd: "/repo", query: "789", limit: 20 }]);
});
});
class ThisDependentSearchClient {
readonly requests: unknown[] = [];
private send(requestId: string, options: { cwd: string; query: string; limit?: number }) {
this.requests.push(options);
return Promise.resolve({
items: [],
featuresEnabled: true,
authState: "authenticated" as const,
githubFeaturesEnabled: true,
error: null,
requestId,
});
}
async searchForge(options: { cwd: string; query: string; limit?: number }) {
return this.send("forge.search.request", options);
}
async searchGitHub(options: { cwd: string; query: string; limit?: number }) {
return this.send("github_search_request", options);
}
}
@@ -0,0 +1,172 @@
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"),
}),
);
}
@@ -1,42 +0,0 @@
import { describe, expect, it } from "vitest";
import { buildGithubSearchQueryOptions, githubSearchQueryKey } from "./use-github-search-query";
describe("githubSearchQueryKey", () => {
it("keeps the shared cache key shape for no-kinds searches", () => {
expect(githubSearchQueryKey("server-1", "/repo", " 123 ")).toEqual([
"github-search",
"server-1",
"/repo",
"123",
]);
});
it("adds a deterministic kinds key when kinds are specified", () => {
expect(githubSearchQueryKey("server-1", "/repo", "123", ["github-pr", "github-issue"])).toEqual(
["github-search", "server-1", "/repo", "123", "github-issue,github-pr"],
);
});
});
describe("buildGithubSearchQueryOptions", () => {
it("forwards kinds to the GitHub search request when specified", async () => {
const requests: unknown[] = [];
const query = buildGithubSearchQueryOptions({
client: {
async searchGitHub(options) {
requests.push(options);
return { items: [], githubFeaturesEnabled: true, error: null, requestId: "request-1" };
},
},
serverId: "server-1",
cwd: "/repo",
query: " 123 ",
kinds: ["github-pr"],
enabled: true,
});
await query.queryFn();
expect(requests).toEqual([{ cwd: "/repo", query: "123", limit: 20, kinds: ["github-pr"] }]);
});
});
@@ -1,75 +0,0 @@
import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import type { GitHubSearchRequest, GitHubSearchResponse } from "@getpaseo/protocol/messages";
import { i18n } from "@/i18n/i18next";
export const GITHUB_SEARCH_STALE_TIME = 30_000;
export type GitHubSearchPayload = GitHubSearchResponse["payload"];
export interface GitHubSearchClient {
searchGitHub: (
options: {
cwd: string;
query: string;
limit?: number;
kinds?: GitHubSearchRequest["kinds"];
},
requestId?: string,
) => Promise<GitHubSearchPayload>;
}
interface GitHubSearchQueryInput {
client: GitHubSearchClient | null;
serverId: string;
cwd: string;
query: string;
kinds?: GitHubSearchRequest["kinds"];
enabled: boolean;
hostDisconnectedMessage?: string;
}
export function githubSearchQueryKey(
serverId: string,
cwd: string,
query: string,
kinds?: GitHubSearchRequest["kinds"],
) {
const trimmedQuery = query.trim();
if (!kinds) {
return ["github-search", serverId, cwd, trimmedQuery] as const;
}
return ["github-search", serverId, cwd, trimmedQuery, [...kinds].sort().join(",")] as const;
}
export function buildGithubSearchQueryOptions(input: GitHubSearchQueryInput) {
const query = input.query.trim();
return {
queryKey: githubSearchQueryKey(input.serverId, input.cwd, query, input.kinds),
queryFn: async (): Promise<GitHubSearchPayload> => {
if (!input.client) {
throw new Error(
input.hostDisconnectedMessage ?? i18n.t("workspace.terminal.hostDisconnected"),
);
}
const request = { cwd: input.cwd, query, limit: 20 };
if (input.kinds) {
return input.client.searchGitHub({ ...request, kinds: input.kinds });
}
return input.client.searchGitHub(request);
},
enabled: input.enabled && Boolean(input.client),
staleTime: GITHUB_SEARCH_STALE_TIME,
};
}
export function useGithubSearchQuery(input: GitHubSearchQueryInput) {
const { t } = useTranslation();
return useQuery(
buildGithubSearchQueryOptions({
...input,
hostDisconnectedMessage: t("workspace.terminal.hostDisconnected"),
}),
);
}
+11 -5
View File
@@ -1,9 +1,10 @@
import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import type { CheckoutPrStatusResponse } from "@getpaseo/protocol/messages";
import { checkoutPrStatusQueryKey } from "@/git/query-keys";
import { normalizeForge } from "@/git/forge";
import { selectPrHintFromStatus, type PrHint } from "@/git/pr-hint";
import { type CheckoutPrStatusPayload, normalizeCheckoutPrStatusPayload } from "@/git/pr-status";
interface UseCheckoutPrStatusQueryOptions {
serverId: string;
@@ -11,11 +12,11 @@ interface UseCheckoutPrStatusQueryOptions {
enabled?: boolean;
}
export type CheckoutPrStatusPayload = CheckoutPrStatusResponse["payload"];
export type { CheckoutPrStatusPayload } from "@/git/pr-status";
export { selectPrHintFromStatus, type PrHint } from "@/git/pr-hint";
function selectWorkspacePrHint(payload: CheckoutPrStatusPayload): PrHint | null {
return selectPrHintFromStatus(payload.status);
return selectPrHintFromStatus(payload.status, payload.forge);
}
export function useCheckoutPrStatusQuery({
@@ -33,7 +34,7 @@ export function useCheckoutPrStatusQuery({
if (!client) {
throw new Error(t("common.errors.daemonClientUnavailable"));
}
return await client.checkoutPrStatus(cwd);
return normalizeCheckoutPrStatusPayload(await client.checkoutPrStatus(cwd));
},
enabled: !!client && isConnected && !!cwd && enabled,
staleTime: Infinity,
@@ -47,6 +48,11 @@ export function useCheckoutPrStatusQuery({
return {
status: query.data?.status ?? null,
githubFeaturesEnabled: query.data?.githubFeaturesEnabled ?? true,
authState: query.data?.authState,
forge: normalizeForge(query.data?.forge),
// Null until a response arrives, so callers that can infer the forge from
// the remote URL (e.g. web-URL grammar) don't act on the github default.
resolvedForge: query.data === undefined ? null : normalizeForge(query.data.forge),
payloadError: query.data?.error ?? null,
isLoading: query.isLoading,
isFetching: query.isFetching,
@@ -70,7 +76,7 @@ export function useWorkspacePrHint({
if (!client) {
throw new Error(t("common.errors.daemonClientUnavailable"));
}
return await client.checkoutPrStatus(cwd);
return normalizeCheckoutPrStatusPayload(await client.checkoutPrStatus(cwd));
},
enabled: !!client && isConnected && !!cwd && enabled,
staleTime: Infinity,
@@ -8,7 +8,6 @@ import {
RefreshCcw,
Upload,
} from "lucide-react-native";
import { GitHubIcon } from "@/components/icons/github-icon";
import { GitActionsSplitButton } from "@/git/actions-split-button";
import { useGitActions } from "@/git/use-actions";
import type { Theme } from "@/styles/theme";
@@ -23,7 +22,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);
@@ -37,11 +35,6 @@ const ICONS = {
pull: <ThemedDownload size={16} uniProps={mutedColorMapping} />,
push: <ThemedUpload size={16} uniProps={mutedColorMapping} />,
pullAndPush: <ThemedArrowDownUp size={16} uniProps={mutedColorMapping} />,
viewPr: <ThemedGitHubIcon size={16} uniProps={mutedColorMapping} />,
createPr: <ThemedGitHubIcon size={16} uniProps={mutedColorMapping} />,
mergePrSquash: <ThemedGitHubIcon size={16} uniProps={mutedColorMapping} />,
mergePrMerge: <ThemedGitHubIcon size={16} uniProps={mutedColorMapping} />,
mergePrRebase: <ThemedGitHubIcon size={16} uniProps={mutedColorMapping} />,
merge: <ThemedGitMerge size={16} uniProps={mutedColorMapping} />,
mergeFromBase: <ThemedRefreshCcw size={16} uniProps={mutedColorMapping} />,
archive: <ThemedArchive size={16} uniProps={mutedColorMapping} />,