feat(forge): pluggable forge abstraction + GitLab and Gitea/Forgejo/Codeberg (#1913)
* refactor(forge): forge-neutral foundation (GitHub-only) Decouple git-hosting from GitHub behind a neutral abstraction (issue #1616), GitHub-only for now; existing GitHub behaviour is unchanged. - Forge manifest, neutral ForgeService contract, forge registry + resolver, and a client forge-module registry. - GitHub code renamed to the neutral shape; PR/Issue attachment wording preserved. - forge.search.response enums parse tolerantly (unknown kind/auth state degrade instead of breaking the client). - createPullRequest reports typed CLI/auth errors instead of a generic message. - forge-resolver host/remote caches are LRU-bounded. - Forge host trust is explicit: only a known cloud host or a CLI-authenticated host is ever talked to; an unauthenticated GitHub Enterprise host fails resolution instead of routing to github.com. - Docs: forge-providers guide, glossary and i18n forge-copy conventions, architecture and rpc-namespacing terminology. - Vitest React Native mocks (unistyles, svg, linking, lucide) consolidated into shared aliased test-stubs. * feat(forge): GitLab adapter, forge-aware UI, pipelines and approvals GitLab adapter over the glab CLI on the neutral contracts: MR status, forge-aware UI, pipeline tree, and N-of-M approvals. - threadIsResolved is part of the neutral timeline item. - Pipeline load failures show an error instead of an empty section. - Manual pipeline jobs render as pending. - Fork/detached MR head pipelines are fetched by MR iid (glab ci get --merge-request). * feat(forge): Gitea family adapter (Gitea, Forgejo, Codeberg) One adapter over the tea CLI serving Gitea, Forgejo, and Codeberg on the neutral contracts. - CI status aggregates commit statuses and Actions runs together. - Gitea's terminal "warning" state maps to failure on server and client. - Gitea Actions check details are reachable from the PR pane by workflowRunId. * refactor(forge): localize compatibility handling * test(forge): expect normalized GitLab facts --------- Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
This commit is contained in:
co-authored by
Mohamed Boudra
parent
04d1ebdce0
commit
a8ebd390fa
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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>;
|
||||
@@ -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,
|
||||
},
|
||||
}));
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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)",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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"],
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"),
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -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} />,
|
||||
|
||||
Reference in New Issue
Block a user