feat(forge): pluggable forge abstraction + GitLab and Gitea/Forgejo/Codeberg (#1913)

* refactor(forge): forge-neutral foundation (GitHub-only)

Decouple git-hosting from GitHub behind a neutral abstraction (issue #1616), GitHub-only for now; existing GitHub behaviour is unchanged.

- Forge manifest, neutral ForgeService contract, forge registry + resolver, and a client forge-module registry.
- GitHub code renamed to the neutral shape; PR/Issue attachment wording preserved.
- forge.search.response enums parse tolerantly (unknown kind/auth state degrade instead of breaking the client).
- createPullRequest reports typed CLI/auth errors instead of a generic message.
- forge-resolver host/remote caches are LRU-bounded.
- Forge host trust is explicit: only a known cloud host or a CLI-authenticated host is ever talked to; an unauthenticated GitHub Enterprise host fails resolution instead of routing to github.com.
- Docs: forge-providers guide, glossary and i18n forge-copy conventions, architecture and rpc-namespacing terminology.
- Vitest React Native mocks (unistyles, svg, linking, lucide) consolidated into shared aliased test-stubs.

* feat(forge): GitLab adapter, forge-aware UI, pipelines and approvals

GitLab adapter over the glab CLI on the neutral contracts: MR status, forge-aware UI, pipeline tree, and N-of-M approvals.

- threadIsResolved is part of the neutral timeline item.
- Pipeline load failures show an error instead of an empty section.
- Manual pipeline jobs render as pending.
- Fork/detached MR head pipelines are fetched by MR iid (glab ci get --merge-request).

* feat(forge): Gitea family adapter (Gitea, Forgejo, Codeberg)

One adapter over the tea CLI serving Gitea, Forgejo, and Codeberg on the neutral contracts.

- CI status aggregates commit statuses and Actions runs together.
- Gitea's terminal "warning" state maps to failure on server and client.
- Gitea Actions check details are reachable from the PR pane by workflowRunId.

* refactor(forge): localize compatibility handling

* test(forge): expect normalized GitLab facts

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
This commit is contained in:
nllptrx
2026-07-17 15:03:26 +08:00
committed by GitHub
co-authored by Mohamed Boudra
parent 04d1ebdce0
commit a8ebd390fa
187 changed files with 20988 additions and 2907 deletions
@@ -12,6 +12,7 @@ import type { AgentAttachment } from "@getpaseo/protocol/messages";
import type { WorkspaceComposerAttachment } from "@/attachments/types";
import { getFileTypeLabel } from "@/attachments/file-types";
import { isPullRequestContextAttachment } from "@/attachments/workspace-attachment-utils";
import { getForgePresentation } from "@/git/forge";
import { ICON_SIZE, type Theme } from "@/styles/theme";
export interface AttachmentPillContent {
@@ -27,10 +28,16 @@ function getReviewSubtitle(count: number, t: TFunction): string {
}
function getPullRequestContextSubtitle(attachment: WorkspaceComposerAttachment): string {
if (attachment.kind === "github.pull_request_check") {
if (
attachment.kind === "forge.change_request_check" ||
attachment.kind === "github.pull_request_check"
) {
return "Check logs";
}
if (attachment.kind === "github.pull_request_comment") {
if (
attachment.kind === "forge.change_request_comment" ||
attachment.kind === "github.pull_request_comment"
) {
return "Comment";
}
return "Review";
@@ -57,12 +64,21 @@ export function getAgentAttachmentPillContent(
title: t("message.attachments.review"),
subtitle: getReviewSubtitle(attachment.comments.length, t),
};
case "forge_change_request": {
const presentation = getForgePresentation(attachment.forge ?? "github");
return {
icon: attachmentGithubPrIcon,
title: attachment.title,
subtitle: `${presentation.changeRequestAbbrev} ${presentation.numberPrefix}${attachment.number}`,
};
}
case "github_pr":
return {
icon: attachmentGithubPrIcon,
title: attachment.title,
subtitle: `PR #${attachment.number}`,
};
case "forge_issue":
case "github_issue":
return {
icon: attachmentGithubIssueIcon,
+12 -3
View File
@@ -1,6 +1,6 @@
import type {
AgentAttachment,
GitHubSearchItem,
ForgeSearchItem,
UploadedFileAttachment,
} from "@getpaseo/protocol/messages";
@@ -54,6 +54,9 @@ export interface BrowserElementAttachment {
}
export type PullRequestContextAttachmentKind =
| "forge.change_request_comment"
| "forge.change_request_review"
| "forge.change_request_check"
| "github.pull_request_comment"
| "github.pull_request_review"
| "github.pull_request_check";
@@ -67,6 +70,9 @@ interface PullRequestContextAttachmentFields {
}
export type PullRequestContextAttachment =
| ({ kind: "forge.change_request_comment" } & PullRequestContextAttachmentFields)
| ({ kind: "forge.change_request_review" } & PullRequestContextAttachmentFields)
| ({ kind: "forge.change_request_check" } & PullRequestContextAttachmentFields)
| ({ kind: "github.pull_request_comment" } & PullRequestContextAttachmentFields)
| ({ kind: "github.pull_request_review" } & PullRequestContextAttachmentFields)
| ({ kind: "github.pull_request_check" } & PullRequestContextAttachmentFields);
@@ -89,10 +95,13 @@ export const NEW_WORKSPACE_PICKER_ATTACHMENT_OWNER = "new-workspace-picker";
export type UserComposerAttachment =
| { kind: "image"; metadata: AttachmentMetadata }
| { kind: "file"; attachment: UploadedFileAttachment }
| { kind: "github_issue"; item: GitHubSearchItem }
| { kind: "forge_issue"; item: ForgeSearchItem }
| { kind: "forge_change_request"; item: ForgeSearchItem }
// COMPAT(githubAttachmentKinds): added in v0.1.106, remove after 2026-12-28 once daemon floor >= v0.1.106
| { kind: "github_issue"; item: ForgeSearchItem }
| {
kind: "github_pr";
item: GitHubSearchItem;
item: ForgeSearchItem;
owner?: typeof NEW_WORKSPACE_PICKER_ATTACHMENT_OWNER;
};
@@ -10,6 +10,9 @@ export function isPullRequestContextAttachment(
attachment: ComposerAttachment | undefined,
): attachment is PullRequestContextAttachment {
return (
attachment?.kind === "forge.change_request_comment" ||
attachment?.kind === "forge.change_request_review" ||
attachment?.kind === "forge.change_request_check" ||
attachment?.kind === "github.pull_request_comment" ||
attachment?.kind === "github.pull_request_review" ||
attachment?.kind === "github.pull_request_check"
@@ -81,12 +81,15 @@ function areWorkspaceAttachmentsEqual(
}
function getContextAttachmentKey(attachment: WorkspaceComposerAttachment): string | null {
const isContextAttachment =
attachment.kind === "chat_history" ||
attachment.kind === "github.pull_request_comment" ||
attachment.kind === "github.pull_request_review" ||
attachment.kind === "github.pull_request_check";
if (!isContextAttachment) {
if (
attachment.kind !== "chat_history" &&
attachment.kind !== "forge.change_request_comment" &&
attachment.kind !== "forge.change_request_review" &&
attachment.kind !== "forge.change_request_check" &&
attachment.kind !== "github.pull_request_comment" &&
attachment.kind !== "github.pull_request_review" &&
attachment.kind !== "github.pull_request_check"
) {
return null;
}
return JSON.stringify({
@@ -347,6 +347,7 @@ function ExplorerSidebarContent({
testID="explorer-tab-pr"
>
<PullRequestTabIcon
forge={prPane.forge}
size={13}
color={
resolvedTab === "pr" ? theme.colors.foreground : theme.colors.foregroundMuted
@@ -0,0 +1,8 @@
import { SvgPathIcon, type SvgPathIconProps } from "./svg-path-icon";
const CODEBERG_ICON_PATH =
"M11.999.747A11.974 11.974 0 0 0 0 12.75c0 2.254.635 4.465 1.833 6.376L11.837 6.19c.072-.092.251-.092.323 0l4.178 5.402h-2.992l.065.239h3.113l.882 1.138h-3.674l.103.374h3.86l.777 1.003h-4.358l.135.483h4.593l.695.894h-5.038l.165.589h5.326l.609.785h-5.717l.182.65h6.038l.562.727h-6.397l.183.65h6.717A12.003 12.003 0 0 0 24 12.75 11.977 11.977 0 0 0 11.999.747zm3.654 19.104.182.65h5.326c.173-.204.353-.433.513-.65zm.385 1.377.18.65h3.563c.233-.198.485-.428.712-.65zm.383 1.377.182.648h1.203c.356-.204.685-.412 1.042-.648z";
export function CodebergIcon(props: SvgPathIconProps) {
return <SvgPathIcon {...props} viewBox="0 0 24 24" path={CODEBERG_ICON_PATH} />;
}
@@ -0,0 +1,8 @@
import { SvgPathIcon, type SvgPathIconProps } from "./svg-path-icon";
const FORGEJO_ICON_PATH =
"M16.7773 0c1.6018 0 2.9004 1.2986 2.9004 2.9005s-1.2986 2.9004-2.9004 2.9004c-1.0854 0-2.0315-.596-2.5288-1.4787H12.91c-2.3322 0-4.2272 1.8718-4.2649 4.195l-.0007 2.1175a7.0759 7.0759 0 0 1 4.148-1.4205l.1176-.001 1.3385.0002c.4973-.8827 1.4434-1.4788 2.5288-1.4788 1.6018 0 2.9004 1.2986 2.9004 2.9005s-1.2986 2.9004-2.9004 2.9004c-1.0854 0-2.0315-.596-2.5288-1.4787H12.91c-2.3322 0-4.2272 1.8718-4.2649 4.195l-.0007 2.319c.8827.4973 1.4788 1.4434 1.4788 2.5287 0 1.602-1.2986 2.9005-2.9005 2.9005-1.6018 0-2.9004-1.2986-2.9004-2.9005 0-1.0853.596-2.0314 1.4788-2.5287l-.0002-9.9831c0-3.887 3.1195-7.0453 6.9915-7.108l.1176-.001h1.3385C14.7458.5962 15.692 0 16.7773 0ZM7.2227 19.9052c-.6596 0-1.1943.5347-1.1943 1.1943s.5347 1.1943 1.1943 1.1943 1.1944-.5347 1.1944-1.1943-.5348-1.1943-1.1944-1.1943Zm9.5546-10.4644c-.6596 0-1.1944.5347-1.1944 1.1943s.5348 1.1943 1.1944 1.1943c.6596 0 1.1943-.5347 1.1943-1.1943s-.5347-1.1943-1.1943-1.1943Zm0-7.7346c-.6596 0-1.1944.5347-1.1944 1.1943s.5348 1.1943 1.1944 1.1943c.6596 0 1.1943-.5347 1.1943-1.1943s-.5347-1.1943-1.1943-1.1943Z";
export function ForgejoIcon(props: SvgPathIconProps) {
return <SvgPathIcon {...props} viewBox="0 0 24 24" path={FORGEJO_ICON_PATH} />;
}
@@ -0,0 +1,8 @@
import { SvgPathIcon, type SvgPathIconProps } from "./svg-path-icon";
const GITEA_ICON_PATH =
"M4.209 4.603c-.247 0-.525.02-.84.088-.333.07-1.28.283-2.054 1.027C-.403 7.25.035 9.685.089 10.052c.065.446.263 1.687 1.21 2.768 1.749 2.141 5.513 2.092 5.513 2.092s.462 1.103 1.168 2.119c.955 1.263 1.936 2.248 2.89 2.367 2.406 0 7.212-.004 7.212-.004s.458.004 1.08-.394c.535-.324 1.013-.893 1.013-.893s.492-.527 1.18-1.73c.21-.37.385-.729.538-1.068 0 0 2.107-4.471 2.107-8.823-.042-1.318-.367-1.55-.443-1.627-.156-.156-.366-.153-.366-.153s-4.475.252-6.792.306c-.508.011-1.012.023-1.512.027v4.474l-.634-.301c0-1.39-.004-4.17-.004-4.17-1.107.016-3.405-.084-3.405-.084s-5.399-.27-5.987-.324c-.187-.011-.401-.032-.648-.032zm.354 1.832h.111s.271 2.269.6 3.597C5.549 11.147 6.22 13 6.22 13s-.996-.119-1.641-.348c-.99-.324-1.409-.714-1.409-.714s-.73-.511-1.096-1.52C1.444 8.73 2.021 7.7 2.021 7.7s.32-.859 1.47-1.145c.395-.106.863-.12 1.072-.12zm8.33 2.554c.26.003.509.127.509.127l.868.422-.529 1.075a.686.686 0 0 0-.614.359.685.685 0 0 0 .072.756l-.939 1.924a.69.69 0 0 0-.66.527.687.687 0 0 0 .347.763.686.686 0 0 0 .867-.206.688.688 0 0 0-.069-.882l.916-1.874a.667.667 0 0 0 .237-.02.657.657 0 0 0 .271-.137 8.826 8.826 0 0 1 1.016.512.761.761 0 0 1 .286.282c.073.21-.073.569-.073.569-.087.29-.702 1.55-.702 1.55a.692.692 0 0 0-.676.477.681.681 0 1 0 1.157-.252c.073-.141.141-.282.214-.431.19-.397.515-1.16.515-1.16.035-.066.218-.394.103-.814-.095-.435-.48-.638-.48-.638-.467-.301-1.116-.58-1.116-.58s0-.156-.042-.27a.688.688 0 0 0-.148-.241l.516-1.062 2.89 1.401s.48.218.583.619c.073.282-.019.534-.069.657-.24.587-2.1 4.317-2.1 4.317s-.232.554-.748.588a1.065 1.065 0 0 1-.393-.045l-.202-.08-4.31-2.1s-.417-.218-.49-.596c-.083-.31.104-.691.104-.691l2.073-4.272s.183-.37.466-.497a.855.855 0 0 1 .35-.077z";
export function GiteaIcon(props: SvgPathIconProps) {
return <SvgPathIcon {...props} viewBox="0 0 24 24" path={GITEA_ICON_PATH} />;
}
@@ -0,0 +1,8 @@
import { SvgPathIcon, type SvgPathIconProps } from "./svg-path-icon";
const GITLAB_ICON_PATH =
"M23.6004 9.5927l-.0337-.0862L20.3 .9814a.851.851 0 0 0-.3362-.405.8748.8748 0 0 0-.9997.0539.8748.8748 0 0 0-.29.4399l-2.2055 6.748H7.5375l-2.2057-6.748a.8573.8573 0 0 0-.29-.4412.8748.8748 0 0 0-.9997-.0537.8585.8585 0 0 0-.3362.4049L.4332 9.5015l-.0325.0862a6.0657 6.0657 0 0 0 2.0119 7.0105l.0113.0087.03.0213 4.976 3.7264 2.462 1.8633 1.4995 1.1321a1.0085 1.0085 0 0 0 1.2197 0l1.4995-1.1321 2.462-1.8633 5.006-3.7489.0125-.01a6.0682 6.0682 0 0 0 2.0094-7.003z";
export function GitLabIcon(props: SvgPathIconProps) {
return <SvgPathIcon {...props} viewBox="0 0 24 24" path={GITLAB_ICON_PATH} />;
}
@@ -0,0 +1,24 @@
import Svg, { Path } from "react-native-svg";
export interface SvgPathIconProps {
size?: number;
color?: string;
}
interface SvgPathIconInput extends SvgPathIconProps {
path: string;
viewBox: string;
}
export function SvgPathIcon({
size = 16,
color = "currentColor",
path,
viewBox,
}: SvgPathIconInput) {
return (
<Svg width={size} height={size} viewBox={viewBox} fill={color}>
<Path d={path} />
</Svg>
);
}
@@ -84,6 +84,7 @@ import {
} from "@/components/ui/dropdown-menu";
import { SyncedLoader } from "@/components/synced-loader";
import { useToast } from "@/contexts/toast-context";
import { getForgePresentation, normalizeForge } from "@/git/forge";
import { toWorktreeArchiveRisk } from "@/git/worktree-archive-warning";
import { hasVisibleOrderChanged, mergeWithRemainder } from "@/utils/sidebar-reorder";
import { decideLongPressMove } from "@/utils/sidebar-gesture-arbitration";
@@ -319,12 +320,14 @@ export function PrBadge({ hint }: { hint: PrHint }) {
const textStyle = isHovered ? prBadgeTextHoveredCombined : prBadgeStyles.text;
const iconUniProps = isHovered ? foregroundColorMapping : getPrIconUniMapping(hint.state);
const presentation = getForgePresentation(normalizeForge(hint.forge));
return (
<Pressable
accessibilityRole="link"
accessibilityLabel={t("workspace.git.pr.accessibility.pullRequest", {
number: hint.number,
context: presentation.changeRequestContext,
})}
hitSlop={4}
onPressIn={handlePressIn}
@@ -339,6 +342,7 @@ export function PrBadge({ hint }: { hint: PrHint }) {
<ThemedGitPullRequest size={12} uniProps={iconUniProps} />
)}
<Text style={textStyle} numberOfLines={1}>
{presentation.numberPrefix}
{hint.number}
</Text>
</Pressable>
@@ -1,4 +1,5 @@
import { memo, useCallback, useMemo, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import {
ActivityIndicator,
Pressable,
@@ -18,13 +19,14 @@ import {
Monitor,
SquareTerminal,
} from "lucide-react-native";
import { GitHubIcon } from "@/components/icons/github-icon";
import { WorkspaceHoverCard } from "@/components/workspace-hover-card";
import { SyncedLoader } from "@/components/synced-loader";
import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
import { useAppSettings } from "@/hooks/use-settings";
import type { Theme } from "@/styles/theme";
import type { PrHint } from "@/git/use-pr-status-query";
import { getForgePresentation, normalizeForge } from "@/git/forge";
import { ForgeBrandIcon } from "@/git/forge-icon";
import type { SidebarStateBucket } from "@/utils/sidebar-agent-state";
import { isEmphasizedStatusDotBucket } from "@/utils/status-dot-color";
import { shouldRenderSyncedStatusLoader } from "@/utils/status-loader";
@@ -52,7 +54,6 @@ const purpleColorMapping = (theme: Theme) => ({ color: theme.colors.palette.purp
const ThemedExternalLink = withUnistyles(ExternalLink);
const ThemedGitPullRequest = withUnistyles(GitPullRequest);
const ThemedGitHubIcon = withUnistyles(GitHubIcon);
const ThemedActivityIndicator = withUnistyles(ActivityIndicator);
const ThemedCircleAlert = withUnistyles(CircleAlert);
const ThemedSyncedLoader = withUnistyles(SyncedLoader);
@@ -62,6 +63,10 @@ const ThemedFolderGit2 = withUnistyles(FolderGit2);
const ThemedGlobe = withUnistyles(Globe);
const ThemedSquareTerminal = withUnistyles(SquareTerminal);
function renderChecksBadgeForgeIcon(icon: string) {
return <ForgeBrandIcon iconKind={icon} size={10} uniProps={redColorMapping} />;
}
type SidebarWorkspaceScriptIconKind = "service" | "command";
export function SidebarWorkspaceRowFrame({
@@ -156,7 +161,7 @@ export const SidebarWorkspaceRowContent = memo(function SidebarWorkspaceRowConte
{workspace.prHint ? (
<View style={styles.workspacePrBadgeRow}>
<PrBadge hint={workspace.prHint} />
<ChecksBadge checks={workspace.prHint.checks} />
<ChecksBadge checks={workspace.prHint.checks} forge={workspace.prHint.forge} />
</View>
) : null}
</View>
@@ -290,6 +295,7 @@ function StatusDotOverlay({
}
function PrBadge({ hint }: { hint: PrHint }) {
const { t } = useTranslation();
const [isHovered, setIsHovered] = useState(false);
const handlePress = useCallback(
(event: GestureResponderEvent) => {
@@ -303,6 +309,7 @@ function PrBadge({ hint }: { hint: PrHint }) {
[isHovered],
);
const iconUniProps = isHovered ? foregroundColorMapping : getPrIconUniMapping(hint.state);
const presentation = getForgePresentation(normalizeForge(hint.forge));
const handlePressIn = useCallback((event: GestureResponderEvent) => event.stopPropagation(), []);
const handleHoverIn = useCallback(() => setIsHovered(true), []);
@@ -316,7 +323,10 @@ function PrBadge({ hint }: { hint: PrHint }) {
return (
<Pressable
accessibilityRole="link"
accessibilityLabel={`Pull request #${hint.number}`}
accessibilityLabel={t("workspace.git.pr.accessibility.pullRequest", {
number: hint.number,
context: presentation.changeRequestContext,
})}
hitSlop={4}
onPressIn={handlePressIn}
onPress={handlePress}
@@ -330,19 +340,21 @@ function PrBadge({ hint }: { hint: PrHint }) {
<ThemedGitPullRequest size={12} uniProps={iconUniProps} />
)}
<Text style={textStyle} numberOfLines={1}>
{presentation.numberPrefix}
{hint.number}
</Text>
</Pressable>
);
}
function ChecksBadge({ checks }: { checks: PrHint["checks"] }) {
function ChecksBadge({ checks, forge }: { checks: PrHint["checks"]; forge: PrHint["forge"] }) {
if (!checks || checks.length === 0) return null;
const failed = checks.filter((check) => check.status === "failure").length;
if (failed === 0) return null;
const icon = getForgePresentation(normalizeForge(forge)).icon;
return (
<View style={checksBadgeStyles.badge}>
<ThemedGitHubIcon size={10} uniProps={redColorMapping} />
{renderChecksBadgeForgeIcon(icon)}
<Text style={checksBadgeStyles.text}>{failed} failed</Text>
</View>
);
@@ -23,7 +23,8 @@ import {
GitBranch,
Server,
} from "lucide-react-native";
import { GitHubIcon } from "@/components/icons/github-icon";
import { getForgePresentation, normalizeForge } from "@/git/forge";
import { ForgeBrandIcon } from "@/git/forge-icon";
import type { Theme } from "@/styles/theme";
import { DiffStat } from "@/components/diff-stat";
import { Pressable } from "react-native";
@@ -319,7 +320,11 @@ function WorkspaceHoverCardContent({
{prHint?.checks && prHint.checks.length > 0 ? (
<>
<View style={styles.separator} />
<ChecksSummaryPressable checks={prHint.checks} url={prHint.url} />
<ChecksSummaryPressable
checks={prHint.checks}
url={prHint.url}
forge={prHint.forge}
/>
</>
) : null}
</FloatingSurface>
@@ -343,7 +348,6 @@ function HostRow({ serverId }: { serverId: string }): ReactElement | null {
}
const ThemedExternalLink = withUnistyles(ExternalLink);
const ThemedGitHubIcon = withUnistyles(GitHubIcon);
const ThemedCircleCheck = withUnistyles(CircleCheck);
const ThemedCircleDot = withUnistyles(CircleDot);
const ThemedCircleX = withUnistyles(CircleX);
@@ -375,6 +379,10 @@ function InfoRow({
);
}
function renderChecksSummaryForgeIcon(icon: string, iconUniProps: typeof foregroundColorMapping) {
return <ForgeBrandIcon iconKind={icon} size={12} uniProps={iconUniProps} />;
}
function CopyableInfoRow({
icon: Icon,
value,
@@ -488,9 +496,11 @@ function ChecksSummaryPill({
function ChecksSummaryContent({
checks,
forge,
hovered,
}: {
checks: NonNullable<PrHint["checks"]>;
forge: PrHint["forge"];
hovered: boolean;
}) {
const { t } = useTranslation();
@@ -498,13 +508,14 @@ function ChecksSummaryContent({
const labelStyle = hovered ? checksSummaryLabelHoveredCombined : styles.checksSummaryLabel;
const iconUniProps = hovered ? foregroundColorMapping : foregroundMutedColorMapping;
const icon = getForgePresentation(normalizeForge(forge)).icon;
return (
<>
{hovered ? (
<ThemedExternalLink size={12} uniProps={iconUniProps} />
) : (
<ThemedGitHubIcon size={12} uniProps={iconUniProps} />
renderChecksSummaryForgeIcon(icon, iconUniProps)
)}
<Text style={labelStyle}>{t("workspace.git.pr.sections.checks")}</Text>
<View style={styles.checksSummaryCounts}>
@@ -518,9 +529,11 @@ function ChecksSummaryContent({
function ChecksSummaryPressable({
checks,
forge,
url,
}: {
checks: NonNullable<PrHint["checks"]>;
forge: PrHint["forge"];
url: string;
}) {
const handlePress = useCallback(() => {
@@ -529,9 +542,9 @@ function ChecksSummaryPressable({
const renderChildren = useCallback(
({ hovered }: { pressed: boolean; hovered?: boolean }) => (
<ChecksSummaryContent checks={checks} hovered={Boolean(hovered)} />
<ChecksSummaryContent checks={checks} forge={forge} hovered={Boolean(hovered)} />
),
[checks],
[checks, forge],
);
return (
@@ -18,7 +18,10 @@ import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
import { applyLegacyDaemonWorkspaceOwnership } from "@/workspace/legacy-daemon-workspaces";
import { encodeImages } from "@/utils/encode-images";
import { toErrorMessage } from "@/utils/error-messages";
import { splitComposerAttachmentsForSubmit } from "@/composer/attachments/submit";
import {
resolveComposerAttachmentSubmitFormat,
splitComposerAttachmentsForSubmit,
} from "@/composer/attachments/submit";
import type {
CreateAgentRequestOptions,
DaemonClient,
@@ -171,6 +174,9 @@ export function WorkspaceSetupDialog() {
const [pendingAction, setPendingAction] = useState<"chat" | null>(null);
const serverId = pendingWorkspaceSetup?.serverId ?? "";
const supportsForgeSearch = useSessionStore(
(state) => state.sessions[serverId]?.serverInfo?.features?.forgeSearch === true,
);
const sourceDirectory = pendingWorkspaceSetup?.sourceDirectory ?? "";
const displayName = pendingWorkspaceSetup?.displayName?.trim() ?? "";
const workspace = createdWorkspace;
@@ -309,7 +315,11 @@ export function WorkspaceSetupDialog() {
throw new Error(t("workspaceSetup.errors.selectModel"));
}
const wirePayload = splitComposerAttachmentsForSubmit(attachments);
const wirePayload = splitComposerAttachmentsForSubmit(attachments, {
format: resolveComposerAttachmentSubmitFormat({
supportsForgeAttachments: supportsForgeSearch,
}),
});
const encodedImages = await encodeImages(wirePayload.images);
const workspaceDirectory = requireWorkspaceDirectory({
workspaceId: ensuredWorkspace.id,
@@ -363,6 +373,7 @@ export function WorkspaceSetupDialog() {
t,
toast,
withConnectedClient,
supportsForgeSearch,
],
);
+42 -13
View File
@@ -1,5 +1,5 @@
import { describe, expect, it, vi } from "vitest";
import type { AgentAttachment, GitHubSearchItem } from "@getpaseo/protocol/messages";
import type { AgentAttachment, ForgeSearchItem } from "@getpaseo/protocol/messages";
import type {
AttachmentMetadata,
ComposerAttachment,
@@ -38,7 +38,7 @@ const imageMetadata: AttachmentMetadata = {
createdAt: 1,
};
const issueItem: GitHubSearchItem = {
const issueItem: ForgeSearchItem = {
kind: "issue",
number: 101,
title: "Fix composer attachments",
@@ -50,8 +50,8 @@ const issueItem: GitHubSearchItem = {
headRefName: null,
};
const prItem: GitHubSearchItem = {
kind: "pr",
const prItem: ForgeSearchItem = {
kind: "change_request",
number: 202,
title: "Refactor composer attachments",
url: "https://github.com/acme/paseo/pull/202",
@@ -357,8 +357,9 @@ describe("dispatchComposerAgentMessage", () => {
expect(call.options.images).toEqual([{ data: image.id, mimeType: image.mimeType }]);
expect(call.options.attachments).toEqual([
{
type: "github_pr",
mimeType: "application/github-pr",
type: "forge_change_request",
mimeType: "application/paseo-forge-change-request",
forge: "github",
number: 202,
title: "Refactor composer attachments",
url: "https://github.com/acme/paseo/pull/202",
@@ -380,6 +381,34 @@ describe("dispatchComposerAgentMessage", () => {
expect(userMessage.optimistic).toBe(true);
});
it("can send legacy GitHub attachment payloads for old daemons", async () => {
const client = createFakeSendClient();
const stream = createFakeStream();
await dispatchComposerAgentMessage({
client,
agentId: "agent",
text: "send old attachment",
attachments: [{ kind: "forge_change_request", item: prItem }],
attachmentSubmitFormat: "legacy-github",
encodeImages: passthroughEncodeImages,
stream,
});
expect(client.calls[0].options.attachments).toEqual([
{
type: "github_pr",
mimeType: "application/github-pr",
number: 202,
title: "Refactor composer attachments",
url: "https://github.com/acme/paseo/pull/202",
body: "PR body",
baseRefName: "main",
headRefName: "composer-attachments",
},
]);
});
it("appends to the existing head when one is present", async () => {
const existingItem: StreamItem = {
kind: "user_message",
@@ -694,12 +723,12 @@ describe("openComposerAttachment", () => {
describe("toggleGithubAttachment", () => {
it("appends a GitHub issue when not already attached", () => {
const next = toggleGithubAttachment([], issueItem);
expect(next).toEqual([{ kind: "github_issue", item: issueItem }]);
expect(next).toEqual([{ kind: "forge_issue", item: issueItem }]);
});
it("appends a GitHub PR when not already attached", () => {
const next = toggleGithubAttachment([], prItem);
expect(next).toEqual([{ kind: "github_pr", item: prItem }]);
expect(next).toEqual([{ kind: "forge_change_request", item: prItem }]);
});
it("removes an existing GitHub item with the same kind+number", () => {
@@ -712,12 +741,12 @@ describe("toggleGithubAttachment", () => {
{ kind: "github_issue", item: issueItem },
{ kind: "github_pr", item: prItem },
];
const otherIssue: GitHubSearchItem = { ...issueItem, number: 999 };
const otherIssue: ForgeSearchItem = { ...issueItem, number: 999 };
const next = toggleGithubAttachment(start, otherIssue);
expect(next).toEqual([
{ kind: "github_issue", item: issueItem },
{ kind: "github_pr", item: prItem },
{ kind: "github_issue", item: otherIssue },
{ kind: "forge_issue", item: otherIssue },
]);
});
});
@@ -747,7 +776,7 @@ describe("toggleGithubAttachmentFromPicker", () => {
markGithubAttachmentRemoved,
});
expect(next).toEqual([{ kind: "github_issue", item: issueItem }]);
expect(next).toEqual([{ kind: "forge_issue", item: issueItem }]);
expect(markGithubAttachmentRemoved).not.toHaveBeenCalled();
});
});
@@ -755,8 +784,8 @@ describe("toggleGithubAttachmentFromPicker", () => {
describe("findGithubItemByOption / isAttachmentSelectedForGithubItem", () => {
it("locates items via their composite kind:number id", () => {
expect(findGithubItemByOption([issueItem, prItem], "issue:101")).toBe(issueItem);
expect(findGithubItemByOption([issueItem, prItem], "pr:202")).toBe(prItem);
expect(findGithubItemByOption([issueItem], "pr:404")).toBeUndefined();
expect(findGithubItemByOption([issueItem, prItem], "change_request:202")).toBe(prItem);
expect(findGithubItemByOption([issueItem], "change_request:404")).toBeUndefined();
});
it("recognizes when an attachment list already contains a matching GitHub item", () => {
+38 -19
View File
@@ -1,4 +1,4 @@
import type { GitHubSearchItem } from "@getpaseo/protocol/messages";
import type { ForgeSearchItem } from "@getpaseo/protocol/messages";
import type {
AttachmentMetadata,
ComposerAttachment,
@@ -8,7 +8,10 @@ import {
isWorkspaceAttachment,
userAttachmentsOnly,
} from "@/attachments/workspace-attachment-utils";
import { splitComposerAttachmentsForSubmit } from "@/composer/attachments/submit";
import {
splitComposerAttachmentsForSubmit,
type ComposerAttachmentSubmitFormat,
} from "@/composer/attachments/submit";
import {
appendOptimisticUserMessageToStream,
buildOptimisticUserMessage,
@@ -162,6 +165,7 @@ export interface DispatchComposerAgentMessageInput {
agentId: string;
text: string;
attachments: ComposerAttachment[];
attachmentSubmitFormat?: ComposerAttachmentSubmitFormat;
encodeImages: (
images: AttachmentMetadata[],
) => Promise<Array<{ data: string; mimeType: string }> | undefined>;
@@ -171,7 +175,9 @@ export interface DispatchComposerAgentMessageInput {
export async function dispatchComposerAgentMessage(
input: DispatchComposerAgentMessageInput,
): Promise<void> {
const wirePayload = splitComposerAttachmentsForSubmit(input.attachments);
const wirePayload = splitComposerAttachmentsForSubmit(input.attachments, {
format: input.attachmentSubmitFormat,
});
const messageId = generateMessageId();
const userMessage = buildOptimisticUserMessage({
id: messageId,
@@ -342,33 +348,44 @@ export function openComposerAttachment(input: OpenComposerAttachmentInput): void
input.openExternalUrl(input.attachment.item.url);
}
export function buildGithubAttachment(item: GitHubSearchItem): UserComposerAttachment {
return item.kind === "pr" ? { kind: "github_pr", item } : { kind: "github_issue", item };
export function buildForgeAttachment(item: ForgeSearchItem): UserComposerAttachment {
return item.kind === "change_request"
? { kind: "forge_change_request", item }
: { kind: "forge_issue", item };
}
function isGithubAttachment(
function isForgeAttachment(
attachment: UserComposerAttachment,
): attachment is Extract<UserComposerAttachment, { kind: "github_issue" } | { kind: "github_pr" }> {
return attachment.kind === "github_issue" || attachment.kind === "github_pr";
): attachment is Extract<
UserComposerAttachment,
{ kind: "forge_issue" | "forge_change_request" | "github_issue" | "github_pr" }
> {
return (
attachment.kind === "forge_issue" ||
attachment.kind === "forge_change_request" ||
// COMPAT(githubAttachmentKinds): added in v0.1.106, remove after 2026-12-28 once daemon floor >= v0.1.106
attachment.kind === "github_issue" ||
attachment.kind === "github_pr"
);
}
export function toggleGithubAttachment(
export function toggleForgeAttachment(
current: UserComposerAttachment[],
item: GitHubSearchItem,
item: ForgeSearchItem,
): UserComposerAttachment[] {
const matches = (attachment: UserComposerAttachment) =>
isGithubAttachment(attachment) &&
isForgeAttachment(attachment) &&
attachment.item.kind === item.kind &&
attachment.item.number === item.number;
if (current.some(matches)) {
return current.filter((attachment) => !matches(attachment));
}
return [...current, buildGithubAttachment(item)];
return [...current, buildForgeAttachment(item)];
}
interface ToggleGithubAttachmentFromPickerInput {
current: UserComposerAttachment[];
item: GitHubSearchItem;
item: ForgeSearchItem;
markGithubAttachmentRemoved: (attachment: UserComposerAttachment) => void;
}
@@ -379,31 +396,33 @@ export function toggleGithubAttachmentFromPicker({
}: ToggleGithubAttachmentFromPickerInput): UserComposerAttachment[] {
const existingAttachment = current.find(
(attachment) =>
isGithubAttachment(attachment) &&
isForgeAttachment(attachment) &&
attachment.item.kind === item.kind &&
attachment.item.number === item.number,
);
if (existingAttachment) {
markGithubAttachmentRemoved(existingAttachment);
}
return toggleGithubAttachment(current, item);
return toggleForgeAttachment(current, item);
}
export function findGithubItemByOption(
items: readonly GitHubSearchItem[],
items: readonly ForgeSearchItem[],
optionId: string,
): GitHubSearchItem | undefined {
): ForgeSearchItem | undefined {
return items.find((candidate) => `${candidate.kind}:${candidate.number}` === optionId);
}
export function isAttachmentSelectedForGithubItem(
current: readonly ComposerAttachment[],
item: GitHubSearchItem,
item: ForgeSearchItem,
): boolean {
return userAttachmentsOnly(current).some(
(attachment) =>
isGithubAttachment(attachment) &&
isForgeAttachment(attachment) &&
attachment.item.kind === item.kind &&
attachment.item.number === item.number,
);
}
export const toggleGithubAttachment = toggleForgeAttachment;
@@ -5,14 +5,38 @@ import {
workspaceAttachmentToSubmitAttachment,
} from "@/attachments/workspace-attachment-utils";
import type { AgentAttachment } from "@getpaseo/protocol/messages";
import { buildGitHubAttachmentFromSearchItem } from "@/utils/review-attachments";
import {
buildForgeAttachmentFromSearchItem,
buildLegacyGitHubAttachmentFromSearchItem,
} from "@/utils/review-attachments";
export function splitComposerAttachmentsForSubmit(attachments: ComposerAttachment[]): {
export type ComposerAttachmentSubmitFormat = "forge" | "legacy-github";
interface SplitComposerAttachmentsOptions {
format?: ComposerAttachmentSubmitFormat;
}
export function resolveComposerAttachmentSubmitFormat(input: {
supportsForgeAttachments?: boolean;
}): ComposerAttachmentSubmitFormat {
// COMPAT(forgeSearch): added in v0.1.106, remove github_search fallback after 2026-12-28.
return input.supportsForgeAttachments === false ? "legacy-github" : "forge";
}
export function splitComposerAttachmentsForSubmit(
attachments: ComposerAttachment[],
options: SplitComposerAttachmentsOptions = {},
): {
images: ImageAttachment[];
attachments: AgentAttachment[];
} {
const images: ImageAttachment[] = [];
const agentAttachments: AgentAttachment[] = [];
// COMPAT(forgeSearch): added in v0.1.106, remove github_search fallback after 2026-12-28.
const buildSearchAttachment =
options.format === "legacy-github"
? buildLegacyGitHubAttachmentFromSearchItem
: buildForgeAttachmentFromSearchItem;
for (const attachment of attachments) {
if (attachment.kind === "image") {
@@ -36,7 +60,7 @@ export function splitComposerAttachmentsForSubmit(attachments: ComposerAttachmen
continue;
}
const reviewAttachment = buildGitHubAttachmentFromSearchItem(attachment.item);
const reviewAttachment = buildSearchAttachment(attachment.item);
if (reviewAttachment) {
agentAttachments.push(reviewAttachment);
}
+19 -10
View File
@@ -1,7 +1,10 @@
import { useCallback, useMemo, useReducer } from "react";
import { useTranslation } from "react-i18next";
import type { ComposerAttachment } from "@/attachments/types";
import { splitComposerAttachmentsForSubmit } from "@/composer/attachments/submit";
import {
resolveComposerAttachmentSubmitFormat,
splitComposerAttachmentsForSubmit,
} from "@/composer/attachments/submit";
import { useCreateFlowStore } from "@/stores/create-flow-store";
import { useSessionStore } from "@/stores/session-store";
import {
@@ -236,10 +239,23 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
}
dispatch({ type: "DRAFT_SET_ERROR", message: "" });
const wirePayload = splitComposerAttachmentsForSubmit(attachments);
const trimmedPrompt = text.trim();
const pendingServerId = getPendingServerId();
if (!pendingServerId) {
const error = new Error(t("composer.errors.noHostSelected"));
dispatch({ type: "DRAFT_SET_ERROR", message: error.message });
throw error;
}
const supportsForgeSearch =
useSessionStore.getState().sessions[pendingServerId]?.serverInfo?.features?.forgeSearch ===
true;
const wirePayload = splitComposerAttachmentsForSubmit(attachments, {
format: resolveComposerAttachmentSubmitFormat({
supportsForgeAttachments: supportsForgeSearch,
}),
});
const images = wirePayload.images;
const trimmedPrompt = text.trim();
const hasAttachmentContent = images.length > 0 || wirePayload.attachments.length > 0;
if (!trimmedPrompt && !hasAttachmentContent && !allowEmptyText) {
const error = new Error(t("composer.errors.initialPromptRequired"));
@@ -258,13 +274,6 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
throw error;
}
const pendingServerId = getPendingServerId();
if (!pendingServerId) {
const error = new Error(t("composer.errors.noHostSelected"));
dispatch({ type: "DRAFT_SET_ERROR", message: error.message });
throw error;
}
const attempt: CreateAttempt = {
clientMessageId: generateMessageId(),
text: trimmedPrompt,
@@ -7,17 +7,17 @@ import React, { type ReactNode } from "react";
import { useState } from "react";
import { describe, expect, it, vi } from "vitest";
import type { UserComposerAttachment } from "@/attachments/types";
import type { GitHubSearchClient } from "@/git/use-github-search-query";
import type { GitHubSearchItem, GitHubSearchResponse } from "@getpaseo/protocol/messages";
import type { ForgeSearchClient } from "@/git/use-forge-search-query";
import type { ForgeSearchItem, ForgeSearchResponse } from "@getpaseo/protocol/messages";
import { useComposerGithubAutoAttach } from "./auto-attach";
type GitHubSearchPayload = GitHubSearchResponse["payload"];
type ForgeSearchPayload = ForgeSearchResponse["payload"];
const remoteUrl = "git@github.com:acme/paseo.git";
const cwd = "/repo";
const pr101: GitHubSearchItem = {
kind: "pr",
const pr101: ForgeSearchItem = {
kind: "change_request",
number: 101,
title: "Attach PR",
url: "https://github.com/acme/paseo/pull/101",
@@ -28,7 +28,7 @@ const pr101: GitHubSearchItem = {
headRefName: "feature",
};
const issue202: GitHubSearchItem = {
const issue202: ForgeSearchItem = {
kind: "issue",
number: 202,
title: "Attach issue",
@@ -52,22 +52,20 @@ interface HarnessInput {
remote?: string | null;
}
function githubPayload(items: GitHubSearchItem[], requestId: string): GitHubSearchPayload {
function githubPayload(items: ForgeSearchItem[], requestId: string): ForgeSearchPayload {
return {
items,
githubFeaturesEnabled: true,
authState: "authenticated",
error: null,
requestId,
};
}
function createSearchClient(
items: GitHubSearchItem[],
): GitHubSearchClient & { calls: SearchCall[] } {
function createSearchClient(items: ForgeSearchItem[]): ForgeSearchClient & { calls: SearchCall[] } {
const calls: SearchCall[] = [];
return {
calls,
async searchGitHub(options) {
async searchForge(options) {
calls.push(options);
return githubPayload(items, `search-${options.query}`);
},
@@ -87,7 +85,7 @@ function createWrapper() {
};
}
function useHarness(client: GitHubSearchClient, input: HarnessInput = {}) {
function useHarness(client: ForgeSearchClient, input: HarnessInput = {}) {
const [text, setText] = useState(input.initialText ?? "");
const [attachments, setAttachments] = useState<UserComposerAttachment[]>(
input.initialAttachments ?? [],
@@ -130,7 +128,7 @@ describe("useComposerGithubAutoAttach", () => {
});
await flushDebounce();
expect(result.current.attachments).toEqual([{ kind: "github_pr", item: pr101 }]);
expect(result.current.attachments).toEqual([{ kind: "forge_change_request", item: pr101 }]);
expect(client.calls).toEqual([{ cwd, query: "101", limit: 20 }]);
vi.useRealTimers();
});
@@ -201,8 +199,8 @@ describe("useComposerGithubAutoAttach", () => {
await flushDebounce();
expect(result.current.attachments).toEqual([
{ kind: "github_pr", item: pr101 },
{ kind: "github_issue", item: issue202 },
{ kind: "forge_change_request", item: pr101 },
{ kind: "forge_issue", item: issue202 },
]);
expect(client.calls).toEqual([
{ cwd, query: "101", limit: 20 },
+14 -11
View File
@@ -9,12 +9,9 @@ import {
type SetStateAction,
} from "react";
import type { ComposerAttachment, UserComposerAttachment } from "@/attachments/types";
import {
buildGithubSearchQueryOptions,
type GitHubSearchClient,
} from "@/git/use-github-search-query";
import { buildForgeSearchQueryOptions, type ForgeSearchClient } from "@/git/use-forge-search-query";
import { extractGithubRefs, type GithubRef } from "@/utils/github-refs";
import type { GitHubSearchItem } from "@getpaseo/protocol/messages";
import type { ForgeSearchItem } from "@getpaseo/protocol/messages";
import { isAttachmentSelectedForGithubItem, toggleGithubAttachment } from "../actions";
const AUTO_ATTACH_DEBOUNCE_MS = 300;
@@ -23,10 +20,11 @@ interface ComposerGithubAutoAttachInput {
text: string;
remoteUrl: string | null | undefined;
attachments: UserComposerAttachment[];
client: GitHubSearchClient | null;
client: ForgeSearchClient | null;
isConnected: boolean;
serverId: string;
cwd: string;
supportsForgeSearch?: boolean;
setAttachments: Dispatch<SetStateAction<UserComposerAttachment[]>>;
}
@@ -75,6 +73,7 @@ export function useComposerGithubAutoAttach(
params.isConnected,
params.serverId,
params.cwd,
params.supportsForgeSearch,
queryClient,
]);
@@ -193,11 +192,12 @@ async function fetchGithubRefSearch({
try {
return await queryClient.fetchQuery(
buildGithubSearchQueryOptions({
buildForgeSearchQueryOptions({
client: snapshot.client,
serverId: snapshot.serverId,
cwd: snapshot.cwd,
query: String(ref.number),
supportsForgeSearch: snapshot.supportsForgeSearch,
enabled: true,
}),
);
@@ -216,12 +216,12 @@ function hasGithubAttachment(attachments: UserComposerAttachment[], ref: GithubR
return attachments.some((attachment) => attachmentKey(attachment) === githubRefKey(ref));
}
function githubItemMatchesRef(item: GitHubSearchItem, ref: GithubRef): boolean {
function githubItemMatchesRef(item: ForgeSearchItem, ref: GithubRef): boolean {
return item.kind === githubItemKind(ref) && item.number === ref.number;
}
function githubItemKind(ref: GithubRef): GitHubSearchItem["kind"] {
return ref.kind === "pull" ? "pr" : "issue";
function githubItemKind(ref: GithubRef): ForgeSearchItem["kind"] {
return ref.kind === "pull" ? "change_request" : "issue";
}
function githubRefKey(ref: GithubRef): string {
@@ -232,7 +232,10 @@ function attachmentKey(attachment: ComposerAttachment | undefined): string | nul
if (
!attachment ||
attachment.kind === "image" ||
(attachment.kind !== "github_pr" && attachment.kind !== "github_issue")
(attachment.kind !== "forge_change_request" &&
attachment.kind !== "forge_issue" &&
attachment.kind !== "github_pr" &&
attachment.kind !== "github_issue")
) {
return null;
}
+98 -35
View File
@@ -28,7 +28,6 @@ import {
CircleDot,
FileText,
GitPullRequest,
Github,
Image as ImageIcon,
Paperclip,
} from "lucide-react-native";
@@ -94,7 +93,7 @@ import { submitAgentInput } from "@/composer/submit";
import { ComposerKeyboardScopeProvider } from "@/composer/keyboard-scope";
import { useAppSettings } from "@/hooks/use-settings";
import { isWeb, isNative } from "@/constants/platform";
import type { GitHubSearchItem } from "@getpaseo/protocol/messages";
import type { ForgeSearchItem } from "@getpaseo/protocol/messages";
import type {
AttachmentMetadata,
ComposerAttachment,
@@ -102,6 +101,7 @@ import type {
WorkspaceComposerAttachment,
} from "@/attachments/types";
import type { PickedFile } from "@/attachments/picked-file";
import { resolveComposerAttachmentSubmitFormat } from "@/composer/attachments/submit";
import { composerWorkspaceAttachment } from "@/composer/attachments/workspace";
import { useWorkspaceAttachmentsForScopes } from "@/attachments/workspace-attachments-store";
import { droppedItemsToPickedFiles } from "@/composer/attachments/drop";
@@ -111,8 +111,11 @@ import { AttachmentLabel, AttachmentPill, AttachmentThumbnail } from "@/componen
import { AttachmentLightbox } from "@/components/attachment-lightbox";
import { openExternalUrl } from "@/utils/open-external-url";
import { useIsDictationReady } from "@/hooks/use-is-dictation-ready";
import { useGithubSearchQuery } from "@/git/use-github-search-query";
import { useForgeSearchQuery } from "@/git/use-forge-search-query";
import { useCheckoutStatusQuery } from "@/git/use-status-query";
import { useCheckoutPrStatusQuery } from "@/git/use-pr-status-query";
import { getForgePresentation } from "@/git/forge";
import { ForgeBrandIcon } from "@/git/forge-icon";
import { useComposerGithubAutoAttach } from "./github/auto-attach";
import { resolveClientSlashCommand, type ClientSlashCommand } from "@/client-slash-commands";
@@ -281,8 +284,8 @@ interface RenderAttachmentTrayArgs {
openImage: string;
removeImage: string;
removeFile: string;
openGithub: (kind: string, number: number) => string;
removeGithub: (kind: string, number: number) => string;
openGithub: (kind: string, numberLabel: string) => string;
removeGithub: (kind: string, numberLabel: string) => string;
};
}
@@ -616,13 +619,16 @@ function ImageAttachmentPill({
}
interface GithubAttachmentPillProps {
attachment: Extract<ComposerAttachment, { kind: "github_pr" | "github_issue" }>;
attachment: Extract<
ComposerAttachment,
{ kind: "forge_change_request" | "forge_issue" | "github_pr" | "github_issue" }
>;
index: number;
disabled: boolean;
onOpen: (attachment: ComposerAttachment) => void;
onRemove: (index: number) => void;
openLabel: (kind: string, number: number) => string;
removeLabel: (kind: string, number: number) => string;
openLabel: (kind: string, numberLabel: string) => string;
removeLabel: (kind: string, numberLabel: string) => string;
}
function GithubAttachmentPill({
@@ -635,7 +641,11 @@ function GithubAttachmentPill({
removeLabel,
}: GithubAttachmentPillProps) {
const item = attachment.item;
const kindLabel = item.kind === "pr" ? "PR" : "issue";
const presentation = getForgePresentation(item.forge ?? "github");
const isChangeRequest = item.kind === "change_request";
const kindLabel = isChangeRequest ? presentation.changeRequestAbbrev : "issue";
const subtitleKind = isChangeRequest ? presentation.changeRequestAbbrev : "Issue";
const numberPrefix = isChangeRequest ? presentation.numberPrefix : presentation.issueNumberPrefix;
const handleOpen = useCallback(() => {
onOpen(attachment);
}, [onOpen, attachment]);
@@ -647,14 +657,14 @@ function GithubAttachmentPill({
testID="composer-github-attachment-pill"
onOpen={handleOpen}
onRemove={handleRemove}
openAccessibilityLabel={openLabel(kindLabel, item.number)}
removeAccessibilityLabel={removeLabel(kindLabel, item.number)}
openAccessibilityLabel={openLabel(kindLabel, `${numberPrefix}${item.number}`)}
removeAccessibilityLabel={removeLabel(kindLabel, `${numberPrefix}${item.number}`)}
disabled={disabled}
>
<AttachmentLabel
icon={item.kind === "pr" ? githubPrPillIcon : githubIssuePillIcon}
icon={isChangeRequest ? githubPrPillIcon : githubIssuePillIcon}
title={item.title}
subtitle={`${item.kind === "pr" ? "PR" : "Issue"} #${item.number}`}
subtitle={`${subtitleKind} ${numberPrefix}${item.number}`}
/>
</AttachmentPill>
);
@@ -703,8 +713,8 @@ interface GithubPickerOptionProps {
testID: string;
active: boolean;
selected: boolean;
item: GitHubSearchItem;
onToggle: (item: GitHubSearchItem) => void;
item: ForgeSearchItem;
onToggle: (item: ForgeSearchItem) => void;
}
function GithubPickerOption({
@@ -720,7 +730,7 @@ function GithubPickerOption({
}, [onToggle, item]);
const leadingSlot = useMemo(
() =>
item.kind === "pr" ? (
item.kind === "change_request" ? (
<ThemedGitPullRequest size={ICON_SIZE.sm} uniProps={iconForegroundMutedMapping} />
) : (
<ThemedCircleDot size={ICON_SIZE.sm} uniProps={iconForegroundMutedMapping} />
@@ -1045,6 +1055,9 @@ export function Composer({
});
const setSelectedAttachments = onChangeAttachments;
const checkoutStatusQuery = useCheckoutStatusQuery({ serverId, cwd });
const supportsForgeSearch = useSessionStore(
(state) => state.sessions[serverId]?.serverInfo?.features?.forgeSearch === true,
);
const githubAutoAttach = useComposerGithubAutoAttach({
text: userInput,
remoteUrl: resolveCheckoutRemoteUrl(checkoutStatusQuery.status),
@@ -1053,6 +1066,7 @@ export function Composer({
isConnected,
serverId,
cwd,
supportsForgeSearch,
setAttachments: setSelectedAttachments,
});
const [cursorIndex, setCursorIndex] = useState(0);
@@ -1212,12 +1226,23 @@ export function Composer({
agentId: targetAgentId,
text,
attachments: sendAttachments,
attachmentSubmitFormat: resolveComposerAttachmentSubmitFormat({
supportsForgeAttachments: supportsForgeSearch,
}),
encodeImages,
stream,
});
onAttentionPromptSend?.();
};
}, [client, onAttentionPromptSend, serverId, setAgentStreamTail, setAgentStreamHead, t]);
}, [
client,
onAttentionPromptSend,
serverId,
setAgentStreamTail,
setAgentStreamHead,
supportsForgeSearch,
t,
]);
useEffect(() => {
onSubmitMessageRef.current = onSubmitMessage;
@@ -1711,12 +1736,33 @@ export function Composer({
[contextWindowMeter, isCompactLayout],
);
const hasGithubAttachment = useMemo(
() =>
selectedAttachments.some(
(attachment) =>
attachment.kind === "forge_change_request" ||
attachment.kind === "forge_issue" ||
attachment.kind === "github_pr" ||
attachment.kind === "github_issue",
),
[selectedAttachments],
);
// Composer stays mounted for each focused agent, so avoid a forge CLI call
// until the forge-specific picker or attachment presentation is visible.
const { forge } = useCheckoutPrStatusQuery({
serverId,
cwd,
enabled: isConnected && cwd.trim().length > 0 && (isGithubPickerOpen || hasGithubAttachment),
});
const forgePresentation = useMemo(() => getForgePresentation(forge), [forge]);
const githubSearchQueryTrimmed = githubSearchQuery.trim();
const githubSearchResultsQuery = useGithubSearchQuery({
const githubSearchResultsQuery = useForgeSearchQuery({
client,
serverId,
cwd,
query: githubSearchQueryTrimmed,
supportsForgeSearch,
enabled: resolveGithubSearchEnabled(isGithubPickerOpen, isConnected, cwd),
});
@@ -1724,11 +1770,18 @@ export function Composer({
const githubSearchItems = useMemo(() => githubSearchItemsRaw ?? [], [githubSearchItemsRaw]);
const githubSearchOptions: ComboboxOption[] = useMemo(
() =>
githubSearchItems.map((item) => ({
id: `${item.kind}:${item.number}`,
label: `#${item.number} ${item.title}`,
description: githubSearchQueryTrimmed,
})),
githubSearchItems.map((item) => {
const presentation = getForgePresentation(item.forge ?? "github");
const numberPrefix =
item.kind === "change_request"
? presentation.numberPrefix
: presentation.issueNumberPrefix;
return {
id: `${item.kind}:${item.number}`,
label: `${numberPrefix}${item.number} ${item.title}`,
description: githubSearchQueryTrimmed,
};
}),
[githubSearchItems, githubSearchQueryTrimmed],
);
@@ -1744,8 +1797,10 @@ export function Composer({
},
{
id: "github",
label: t("composer.attachments.addIssueOrPr"),
icon: <ThemedGithub size={ICON_SIZE.md} uniProps={iconForegroundMutedMapping} />,
label: t("composer.attachments.addIssueOrPr", {
context: forgePresentation.changeRequestContext,
}),
icon: renderForgeAttachmentIcon(forgePresentation.icon),
onSelect: () => {
setIsGithubPickerOpen(true);
},
@@ -1759,11 +1814,11 @@ export function Composer({
},
},
],
[handlePickImage, handlePickFile, t],
[handlePickImage, handlePickFile, t, forgePresentation],
);
const handleToggleGithubItem = useCallback(
(item: GitHubSearchItem) => {
(item: ForgeSearchItem) => {
const nextAttachments = toggleGithubAttachmentFromPicker({
current: attachments,
item,
@@ -1868,10 +1923,10 @@ export function Composer({
openImage: t("composer.attachments.openImage"),
removeImage: t("composer.attachments.removeImage"),
removeFile: t("composer.attachments.removeFile"),
openGithub: (kind: string, number: number) =>
t("composer.attachments.openGithub", { kind, number }),
removeGithub: (kind: string, number: number) =>
t("composer.attachments.removeGithub", { kind, number }),
openGithub: (kind: string, numberLabel: string) =>
t("composer.attachments.openGithub", { kind, number: numberLabel }),
removeGithub: (kind: string, numberLabel: string) =>
t("composer.attachments.removeGithub", { kind, number: numberLabel }),
},
}),
[handleOpenAttachment, handleRemoveAttachment, isComposerLocked, selectedAttachments, t],
@@ -1983,8 +2038,12 @@ export function Composer({
onSelect={noop}
keepOpenOnSelect
searchable
searchPlaceholder={t("composer.github.searchPlaceholder")}
title={t("composer.github.title")}
searchPlaceholder={t("composer.github.searchPlaceholder", {
context: forgePresentation.changeRequestContext,
})}
title={t("composer.github.title", {
context: forgePresentation.changeRequestContext,
})}
open={isGithubPickerOpen}
onOpenChange={handleGithubPickerOpenChange}
onSearchQueryChange={setGithubSearchQuery}
@@ -2185,12 +2244,16 @@ const ThemedAudioLines = withUnistyles(AudioLines);
const ThemedPaperclip = withUnistyles(Paperclip);
const ThemedImageIcon = withUnistyles(ImageIcon);
const ThemedFileText = withUnistyles(FileText);
const ThemedGithub = withUnistyles(Github);
const iconForegroundMapping = (theme: Theme) => ({ color: theme.colors.foreground });
const iconForegroundMutedMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
const iconAccentForegroundMapping = (theme: Theme) => ({ color: theme.colors.accentForeground });
function renderForgeAttachmentIcon(icon: string): ReactElement {
return (
<ForgeBrandIcon iconKind={icon} size={ICON_SIZE.md} uniProps={iconForegroundMutedMapping} />
);
}
const githubPrPillIcon = (
<ThemedGitPullRequest size={ICON_SIZE.sm} uniProps={iconForegroundMutedMapping} />
);
+12 -2
View File
@@ -62,7 +62,10 @@ import { derivePendingPermissionKey, normalizeAgentSnapshot } from "@/utils/agen
import { resolveProjectPlacement } from "@/utils/project-placement";
import { buildDraftStoreKey } from "@/stores/draft-keys";
import type { AttachmentMetadata } from "@/attachments/types";
import { splitComposerAttachmentsForSubmit } from "@/composer/attachments/submit";
import {
resolveComposerAttachmentSubmitFormat,
splitComposerAttachmentsForSubmit,
} from "@/composer/attachments/submit";
import { reconcilePreviousAgentStatuses } from "@/contexts/session-status-tracking";
import { patchWorkspaceScripts } from "@/contexts/session-workspace-scripts";
import {
@@ -728,7 +731,14 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
if (queue && queue.length > 0) {
const [next, ...rest] = queue;
if (sendAgentMessageRef.current) {
const wirePayload = splitComposerAttachmentsForSubmit(next.attachments);
const supportsForgeSearch =
useSessionStore.getState().sessions[serverId]?.serverInfo?.features?.forgeSearch ===
true;
const wirePayload = splitComposerAttachmentsForSubmit(next.attachments, {
format: resolveComposerAttachmentSubmitFormat({
supportsForgeAttachments: supportsForgeSearch,
}),
});
void sendAgentMessageRef.current(
agent.id,
next.text,
+60 -49
View File
@@ -183,66 +183,77 @@ describe("checkout-git-actions-store", () => {
).toBe("idle");
});
it("enables PR auto-merge when the daemon advertises auto-merge actions", async () => {
const client = {
checkoutGithubSetAutoMerge: vi.fn(async () => ({
for (const rpc of [
{
label: "forge",
method: "checkoutForgeSetAutoMerge",
feature: "checkoutForgeSetAutoMerge",
},
{
label: "legacy GitHub",
method: "checkoutGithubSetAutoMerge",
feature: "checkoutGithubSetAutoMerge",
},
] as const) {
it(`enables PR auto-merge through the ${rpc.label} RPC`, async () => {
const setAutoMerge = vi.fn(async () => ({
enabled: true,
success: true,
error: null,
})),
};
useSessionStore.getState().initializeSession(serverId, client as unknown as DaemonClient);
useSessionStore.getState().updateSessionServerInfo(serverId, {
serverId,
hostname: null,
version: null,
features: { checkoutGithubSetAutoMerge: true },
});
}));
const client = { [rpc.method]: setAutoMerge };
useSessionStore.getState().initializeSession(serverId, client as unknown as DaemonClient);
useSessionStore.getState().updateSessionServerInfo(serverId, {
serverId,
hostname: null,
version: null,
features: { [rpc.feature]: true },
});
await useCheckoutGitActionsStore
.getState()
.enablePrAutoMerge({ serverId, cwd, method: "squash" });
expect(client.checkoutGithubSetAutoMerge).toHaveBeenCalledWith(cwd, {
enabled: true,
method: "squash",
});
expect(
useCheckoutGitActionsStore
await useCheckoutGitActionsStore
.getState()
.getStatus({ serverId, cwd, actionId: "enable-pr-auto-merge-squash" }),
).toBe("success");
});
.enablePrAutoMerge({ serverId, cwd, method: "squash" });
it("disables PR auto-merge when the daemon advertises auto-merge actions", async () => {
const client = {
checkoutGithubSetAutoMerge: vi.fn(async () => ({
expect(setAutoMerge).toHaveBeenCalledWith(cwd, {
enabled: true,
method: "squash",
});
expect(
useCheckoutGitActionsStore
.getState()
.getStatus({ serverId, cwd, actionId: "enable-pr-auto-merge-squash" }),
).toBe("success");
});
it(`disables PR auto-merge through the ${rpc.label} RPC`, async () => {
const setAutoMerge = vi.fn(async () => ({
enabled: false,
success: true,
error: null,
})),
};
useSessionStore.getState().initializeSession(serverId, client as unknown as DaemonClient);
useSessionStore.getState().updateSessionServerInfo(serverId, {
serverId,
hostname: null,
version: null,
features: { checkoutGithubSetAutoMerge: true },
}));
const client = { [rpc.method]: setAutoMerge };
useSessionStore.getState().initializeSession(serverId, client as unknown as DaemonClient);
useSessionStore.getState().updateSessionServerInfo(serverId, {
serverId,
hostname: null,
version: null,
features: { [rpc.feature]: true },
});
await useCheckoutGitActionsStore.getState().disablePrAutoMerge({ serverId, cwd });
expect(setAutoMerge).toHaveBeenCalledWith(cwd, { enabled: false });
expect(
useCheckoutGitActionsStore
.getState()
.getStatus({ serverId, cwd, actionId: "disable-pr-auto-merge" }),
).toBe("success");
});
await useCheckoutGitActionsStore.getState().disablePrAutoMerge({ serverId, cwd });
expect(client.checkoutGithubSetAutoMerge).toHaveBeenCalledWith(cwd, { enabled: false });
expect(
useCheckoutGitActionsStore
.getState()
.getStatus({ serverId, cwd, actionId: "disable-pr-auto-merge" }),
).toBe("success");
});
}
it("does not call PR auto-merge RPCs when the daemon lacks the feature flag", async () => {
const client = {
checkoutGithubSetAutoMerge: vi.fn(async () => ({
checkoutForgeSetAutoMerge: vi.fn(async () => ({
enabled: true,
success: true,
error: null,
@@ -258,9 +269,9 @@ describe("checkout-git-actions-store", () => {
await expect(
useCheckoutGitActionsStore.getState().enablePrAutoMerge({ serverId, cwd, method: "merge" }),
).rejects.toThrow("Update the host to use GitHub auto-merge actions.");
).rejects.toThrow("Update the host to use auto-merge actions.");
expect(client.checkoutGithubSetAutoMerge).not.toHaveBeenCalled();
expect(client.checkoutForgeSetAutoMerge).not.toHaveBeenCalled();
expect(
useCheckoutGitActionsStore
.getState()
+25 -7
View File
@@ -42,11 +42,19 @@ function resolveClient(serverId: string) {
return client;
}
function assertGitHubAutoMergeActionsSupported(serverId: string) {
type AutoMergeActionsRpc = "forge" | "github";
function resolveAutoMergeActionsRpc(serverId: string): AutoMergeActionsRpc {
const session = useSessionStore.getState().sessions[serverId];
if (session?.serverInfo?.features?.checkoutGithubSetAutoMerge !== true) {
throw new Error("Update the host to use GitHub auto-merge actions.");
if (session?.serverInfo?.features?.checkoutForgeSetAutoMerge === true) {
return "forge";
}
// COMPAT(githubAutoMergeRpc): added in v0.1.106, remove after 2026-12-28 once
// all supported clients use checkout.forge.set_auto_merge.*.
if (session?.serverInfo?.features?.checkoutGithubSetAutoMerge === true) {
return "github";
}
throw new Error("Update the host to use auto-merge actions.");
}
function setStatus(
@@ -281,14 +289,19 @@ export const useCheckoutGitActionsStore = create<CheckoutGitActionsStoreState>()
},
enablePrAutoMerge: async ({ serverId, cwd, method }) => {
assertGitHubAutoMergeActionsSupported(serverId);
const rpc = resolveAutoMergeActionsRpc(serverId);
await runCheckoutAction({
serverId,
cwd,
actionId: `enable-pr-auto-merge-${method}`,
run: async () => {
const client = resolveClient(serverId);
const payload = await client.checkoutGithubSetAutoMerge(cwd, { enabled: true, method });
// COMPAT(githubAutoMergeRpc): added in v0.1.106, remove after 2026-12-28 once
// all supported clients use checkout.forge.set_auto_merge.*.
const payload =
rpc === "forge"
? await client.checkoutForgeSetAutoMerge(cwd, { enabled: true, method })
: await client.checkoutGithubSetAutoMerge(cwd, { enabled: true, method });
if (payload.error) {
throw new Error(payload.error.message);
}
@@ -297,14 +310,19 @@ export const useCheckoutGitActionsStore = create<CheckoutGitActionsStoreState>()
},
disablePrAutoMerge: async ({ serverId, cwd }) => {
assertGitHubAutoMergeActionsSupported(serverId);
const rpc = resolveAutoMergeActionsRpc(serverId);
await runCheckoutAction({
serverId,
cwd,
actionId: "disable-pr-auto-merge",
run: async () => {
const client = resolveClient(serverId);
const payload = await client.checkoutGithubSetAutoMerge(cwd, { enabled: false });
// COMPAT(githubAutoMergeRpc): added in v0.1.106, remove after 2026-12-28 once
// all supported clients use checkout.forge.set_auto_merge.*.
const payload =
rpc === "forge"
? await client.checkoutForgeSetAutoMerge(cwd, { enabled: false })
: await client.checkoutGithubSetAutoMerge(cwd, { enabled: false });
if (payload.error) {
throw new Error(payload.error.message);
}
@@ -1,11 +1,12 @@
// @vitest-environment jsdom
// The review draft store persists through AsyncStorage's web shim, which needs window.
import "@/test/window-local-storage";
import { QueryClient } from "@tanstack/react-query";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { CheckoutStatusUpdate } from "@getpaseo/protocol/messages";
import { checkoutPrStatusQueryKey, checkoutStatusQueryKey } from "@/git/query-keys";
import { prPaneTimelineQueryKey } from "@/git/pull-request-panel/query-keys";
import {
prPanePipelineQueryKey,
prPaneTimelineQueryKey,
} from "@/git/pull-request-panel/query-keys";
import { resetReviewDraftStore, useReviewDraftStore } from "@/review/store";
import {
applyCheckoutStatusUpdateFromEvent,
@@ -14,6 +15,14 @@ import {
fetchCheckoutStatus,
} from "./checkout-status-cache";
vi.mock("@react-native-async-storage/async-storage", () => ({
default: {
getItem: vi.fn(async () => null),
setItem: vi.fn(async () => undefined),
removeItem: vi.fn(async () => undefined),
},
}));
const serverId = "server-1";
const cwd = "/repo";
@@ -41,6 +50,7 @@ function prStatus(overrides: Partial<CheckoutPrStatusPayload> = {}): CheckoutPrS
return {
cwd,
status: {
forge: "github",
url: "https://github.com/getpaseo/paseo/pull/42",
title: "My PR",
state: "open",
@@ -54,6 +64,8 @@ function prStatus(overrides: Partial<CheckoutPrStatusPayload> = {}): CheckoutPrS
reviewDecision: null,
},
githubFeaturesEnabled: true,
authState: "authenticated",
forge: "github",
error: null,
requestId: "pr-status-1",
...overrides,
@@ -62,7 +74,7 @@ function prStatus(overrides: Partial<CheckoutPrStatusPayload> = {}): CheckoutPrS
function checkoutStatusUpdate(
payload: CheckoutStatusPayload,
extraPrStatus?: CheckoutPrStatusPayload,
extraPrStatus?: NonNullable<CheckoutStatusUpdate["payload"]["prStatus"]>,
): CheckoutStatusUpdate {
return {
type: "checkout_status_update",
@@ -140,6 +152,29 @@ describe("applyCheckoutStatusUpdateFromEvent", () => {
expect(queryClient.getQueryData(checkoutPrStatusQueryKey(serverId, otherCwd))).toBeUndefined();
});
it("normalizes legacy PR auth state at the pushed-cache boundary", () => {
const queryClient = createQueryClient();
const { authState: _authState, ...legacyPrStatus } = prStatus({
githubFeaturesEnabled: false,
});
applyCheckoutStatusUpdateFromEvent({
queryClient,
serverId,
message: checkoutStatusUpdate(checkoutStatus(), legacyPrStatus),
});
expect(
queryClient.getQueryData<CheckoutPrStatusPayload>(checkoutPrStatusQueryKey(serverId, cwd))
?.authState,
).toBe("unauthenticated");
expect(
queryClient.getQueryData<CheckoutStatusUpdate["payload"]>(
checkoutStatusQueryKey(serverId, cwd),
)?.prStatus?.authState,
).toBe("unauthenticated");
});
it("expires a manual diff-mode override when the pushed dirty state flipped", () => {
const queryClient = createQueryClient();
setDiffModeOverride(false);
@@ -166,14 +201,21 @@ describe("applyCheckoutStatusUpdateFromEvent", () => {
expect(useReviewDraftStore.getState().diffModeOverrides["review:scope"]).toBeDefined();
});
it("invalidates the PR timeline when the prStatus changes, ignoring the volatile requestId", () => {
it("invalidates PR detail queries when the prStatus changes, ignoring the volatile requestId", () => {
const queryClient = createQueryClient();
queryClient.setQueryData(
checkoutPrStatusQueryKey(serverId, cwd),
prStatus({ requestId: "pr-v1" }),
);
const timelineKey = prPaneTimelineQueryKey({ serverId, cwd, prNumber: 42 });
const pipelineKey = prPanePipelineQueryKey({
serverId,
cwd,
pipelineId: 9001,
changeRequestNumber: 1,
});
queryClient.setQueryData(timelineKey, { items: [] });
queryClient.setQueryData(pipelineKey, { stages: [] });
applyCheckoutStatusUpdateFromEvent({
queryClient,
@@ -181,6 +223,7 @@ describe("applyCheckoutStatusUpdateFromEvent", () => {
message: checkoutStatusUpdate(checkoutStatus(), prStatus({ requestId: "pr-v2" })),
});
expect(queryClient.getQueryState(timelineKey)?.isInvalidated).toBe(false);
expect(queryClient.getQueryState(pipelineKey)?.isInvalidated).toBe(false);
applyCheckoutStatusUpdateFromEvent({
queryClient,
@@ -194,14 +237,29 @@ describe("applyCheckoutStatusUpdateFromEvent", () => {
),
});
expect(queryClient.getQueryState(timelineKey)?.isInvalidated).toBe(true);
expect(queryClient.getQueryState(pipelineKey)?.isInvalidated).toBe(true);
});
it("invalidates the PR timeline on the first prStatus emission, scoped to its cwd", () => {
it("invalidates PR detail queries on the first prStatus emission, scoped to its cwd", () => {
const queryClient = createQueryClient();
const timelineKey = prPaneTimelineQueryKey({ serverId, cwd, prNumber: 42 });
const otherTimelineKey = prPaneTimelineQueryKey({ serverId, cwd: "/repo2", prNumber: 42 });
const pipelineKey = prPanePipelineQueryKey({
serverId,
cwd,
pipelineId: 9001,
changeRequestNumber: 1,
});
const otherPipelineKey = prPanePipelineQueryKey({
serverId,
cwd: "/repo2",
pipelineId: 9001,
changeRequestNumber: 1,
});
queryClient.setQueryData(timelineKey, { items: [] });
queryClient.setQueryData(otherTimelineKey, { items: [] });
queryClient.setQueryData(pipelineKey, { stages: [] });
queryClient.setQueryData(otherPipelineKey, { stages: [] });
applyCheckoutStatusUpdateFromEvent({
queryClient,
@@ -211,5 +269,7 @@ describe("applyCheckoutStatusUpdateFromEvent", () => {
expect(queryClient.getQueryState(timelineKey)?.isInvalidated).toBe(true);
expect(queryClient.getQueryState(otherTimelineKey)?.isInvalidated).toBe(false);
expect(queryClient.getQueryState(pipelineKey)?.isInvalidated).toBe(true);
expect(queryClient.getQueryState(otherPipelineKey)?.isInvalidated).toBe(false);
});
});
@@ -6,10 +6,11 @@ import {
checkoutStatusQueryKey,
invalidatePrPaneTimelineForCheckout,
} from "@/git/query-keys";
import { type CheckoutPrStatusPayload, normalizeCheckoutPrStatusPayload } from "@/git/pr-status";
import { expireStaleDiffModeOverrides } from "@/review/store";
export type CheckoutStatusPayload = CheckoutStatusResponse["payload"];
export type CheckoutPrStatusPayload = NonNullable<CheckoutStatusUpdate["payload"]["prStatus"]>;
export type { CheckoutPrStatusPayload } from "@/git/pr-status";
export interface CheckoutStatusClient {
getCheckoutStatus: (cwd: string) => Promise<CheckoutStatusPayload>;
@@ -43,14 +44,17 @@ export function applyCheckoutStatusUpdateFromEvent({
message: CheckoutStatusUpdate;
}): void {
const { payload } = message;
queryClient.setQueryData(checkoutStatusQueryKey(serverId, payload.cwd), payload);
const prStatus = payload.prStatus
? normalizeCheckoutPrStatusPayload(payload.prStatus)
: undefined;
const cachePayload = prStatus ? { ...payload, prStatus } : payload;
queryClient.setQueryData(checkoutStatusQueryKey(serverId, payload.cwd), cachePayload);
expireStaleDiffModeOverrides({
serverId,
cwd: payload.cwd,
isDirty: payload.isGit && payload.isDirty,
});
const prStatus = payload.prStatus;
if (!prStatus) {
return;
}
+199
View File
@@ -0,0 +1,199 @@
import type { ComponentType } from "react";
import type { ReactNode } from "react";
import type { z } from "zod";
import type { CheckoutPrMergeMethod, CheckoutPrStatusResponse } from "@getpaseo/protocol/messages";
import type { Theme } from "@/styles/theme";
import type { Forge } from "@/git/forge";
import type { PrPaneCheck } from "@/git/pull-request-panel/data";
export interface ForgeIconProps {
size?: number;
color?: string;
}
export type ForgeIconComponent = ComponentType<ForgeIconProps>;
export interface ForgeBrandColor {
light: string;
dark: string;
}
export type ForgeIconColorMapping = (theme: Theme) => { color: string };
export interface ForgeUrlGrammar {
/** Path infix before the branch in a tree URL, e.g. "/tree/", "/-/tree/". */
treeInfix: string;
/** Path infix before the branch in a blob URL, e.g. "/blob/", "/-/blob/". */
blobInfix: string;
/** Line/range anchor, e.g. github "#L1-L5" vs gitlab "#L1-5". */
lineAnchor: (start: number, end?: number) => string;
}
/** Line/range anchor shared by every GitHub-family forge (github, gitea, forgejo, codeberg). */
export function GITHUB_LINE_ANCHOR(start: number, end?: number): string {
return end && end > start ? `#L${start}-L${end}` : `#L${start}`;
}
/** URL grammar shared by the gitea-family forges (gitea, forgejo, codeberg). */
export const GITEA_FAMILY_URL_GRAMMAR: ForgeUrlGrammar = {
treeInfix: "/src/branch/",
blobInfix: "/src/branch/",
lineAnchor: GITHUB_LINE_ANCHOR,
};
export type ForgeSpecificEnvelope = { forge: string } & Record<string, unknown>;
type CheckoutPrStatus = NonNullable<CheckoutPrStatusResponse["payload"]["status"]>;
export type LegacyGithubMergeFacts = NonNullable<CheckoutPrStatus["github"]>;
export interface MergeCapability {
/** The change request can be merged directly right now. */
directMergeReady: boolean;
/** Auto-merge can be enabled right now. */
canEnableAutoMerge: boolean;
/** Auto-merge is already enabled on the change request. */
autoMergeEnabled: boolean;
/** The viewer is allowed to disable the active auto-merge. */
canDisableAutoMerge: boolean;
/** A merge queue is blocking both direct merge and auto-merge. */
mergeBlockedByQueue: boolean;
/** Merge methods the forge permits for this change request. */
allowedMethods: CheckoutPrMergeMethod[];
/** The forge's preferred/default merge method, if it reports one. */
preferredMethod: CheckoutPrMergeMethod | null;
}
export interface PaneChecksSlotContext {
serverId: string;
cwd: string;
/** Change request (PR/MR) number, so a section can address its head pipeline. */
changeRequestNumber: number;
open: boolean;
onToggle: () => void;
/** Whether the daemon advertises pluggable forge support (gates rich sections). */
enabled: boolean;
/**
* Whether the daemon can serve forge check/pipeline details over the
* forge-routed RPC. Single source of truth for gating on-demand detail
* fetches so a section never reaches an RPC the daemon lacks.
*/
canFetchCheckDetails: boolean;
}
export interface PaneNativeContribution {
guard: (facts: unknown) => boolean;
renderHeaderMeta: (facts: unknown) => ReactNode;
renderChecksSection: (facts: unknown, ctx: PaneChecksSlotContext) => ReactNode;
}
export interface NativeFallbackCheckEntry {
contribute: (facts: unknown, status: CheckoutPrStatus, forge: Forge) => PrPaneCheck | null;
}
interface TypedPaneContribution<TFacts extends ForgeSpecificEnvelope> {
renderHeaderMeta?: (facts: TFacts) => ReactNode;
renderChecksSection?: (facts: TFacts, ctx: PaneChecksSlotContext) => ReactNode;
}
interface TypedNativeFallbackCheck<TFacts extends ForgeSpecificEnvelope> {
contribute: (facts: TFacts, status: CheckoutPrStatus, forge: Forge) => PrPaneCheck | null;
}
export interface ClientForgeFactsEntry<TFacts extends ForgeSpecificEnvelope> {
readonly family: TFacts["forge"];
parse: (facts: unknown) => TFacts | null;
deriveMergeCapability: (facts: unknown) => MergeCapability | null;
readonly nativeFallbackChecks: readonly NativeFallbackCheckEntry[];
}
export interface ClientForgeFactsRegistration<TFacts extends ForgeSpecificEnvelope> {
readonly family: TFacts["forge"];
readonly schema: z.ZodType<TFacts>;
readonly deriveMergeCapability?: (facts: TFacts) => MergeCapability;
readonly nativeFallbackChecks?: readonly NativeFallbackCheckEntry[];
}
function parseFacts<TFacts extends ForgeSpecificEnvelope>(
schema: z.ZodType<TFacts>,
facts: unknown,
): TFacts | null {
if (!facts) {
return null;
}
const result = schema.safeParse(facts);
return result.success ? result.data : null;
}
export function defineNativeFallbackCheck<TFacts extends ForgeSpecificEnvelope>(
schema: z.ZodType<TFacts>,
contribution: TypedNativeFallbackCheck<TFacts>,
): NativeFallbackCheckEntry {
return {
contribute: (facts, status, forge) => {
const parsed = parseFacts(schema, facts);
return parsed ? contribution.contribute(parsed, status, forge) : null;
},
};
}
export function definePaneContribution<TFacts extends ForgeSpecificEnvelope>(
schema: z.ZodType<TFacts>,
contribution: TypedPaneContribution<TFacts>,
): PaneNativeContribution {
return {
guard: (facts) => parseFacts(schema, facts) !== null,
renderHeaderMeta: (facts) => {
const parsed = parseFacts(schema, facts);
return parsed && contribution.renderHeaderMeta ? contribution.renderHeaderMeta(parsed) : null;
},
renderChecksSection: (facts, ctx) => {
const parsed = parseFacts(schema, facts);
return parsed && contribution.renderChecksSection
? contribution.renderChecksSection(parsed, ctx)
: null;
},
};
}
export function defineForgeFacts<TFacts extends ForgeSpecificEnvelope>(
registration: ClientForgeFactsRegistration<TFacts>,
): ClientForgeFactsEntry<TFacts> {
return {
family: registration.family,
parse: (facts) => parseFacts(registration.schema, facts),
deriveMergeCapability: (facts) => {
if (!registration.deriveMergeCapability) {
return null;
}
const parsed = parseFacts(registration.schema, facts);
return parsed ? registration.deriveMergeCapability(parsed) : null;
},
nativeFallbackChecks: registration.nativeFallbackChecks ?? [],
};
}
/**
* Pure logic half of a forge: URL grammar and runtime-facts derivations. Kept
* free of any React/React-Native imports so logic consumers (URL builders,
* merge-capability, native-check fallbacks) — and the Node-based e2e harness
* that transitively imports them — never pull the client's rendering stack.
*/
export interface ClientForgeLogicModule<
TFacts extends ForgeSpecificEnvelope = ForgeSpecificEnvelope,
> {
readonly id: string;
readonly urlGrammar?: ForgeUrlGrammar;
readonly facts?: ClientForgeFactsEntry<TFacts>;
}
/**
* View half of a forge: the brand mark, brand color, and PR-pane render
* contributions. Imported only by rendering code (icon lookup, the PR pane).
*/
export interface ClientForgeViewModule {
readonly id: string;
readonly icon: ForgeIconComponent;
readonly brandColor?: ForgeBrandColor | null;
readonly paneContributions?: readonly PaneNativeContribution[];
}
+114 -9
View File
@@ -9,6 +9,7 @@ import {
type ReactNode,
} from "react";
import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
import { DiffStat } from "@/components/diff-stat";
import {
View,
@@ -83,11 +84,13 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
import { GitHubIcon } from "@/components/icons/github-icon";
import { lineNumberGutterWidth } from "@/components/code-insets";
import { GitActionsSplitButton } from "@/git/actions-split-button";
import { BranchSwitcher } from "@/components/branch-switcher";
import { useGitActions } from "@/git/use-actions";
import { buildForgeSignInCommand, getForgePresentation, type Forge } from "@/git/forge";
import { parseGitRemoteLocation } from "@getpaseo/protocol/git-remote";
import type { ForgeAuthState } from "@getpaseo/protocol/messages";
import { useCheckoutGitActionsStore } from "@/git/actions-store";
import { useToast } from "@/contexts/toast-context";
import { useSessionStore } from "@/stores/session-store";
@@ -1256,7 +1259,6 @@ const ThemedGitCommitHorizontal = withUnistyles(GitCommitHorizontal);
const ThemedDownload = withUnistyles(Download);
const ThemedUpload = withUnistyles(Upload);
const ThemedArrowDownUp = withUnistyles(ArrowDownUp);
const ThemedGitHubIcon = withUnistyles(GitHubIcon);
const ThemedGitMerge = withUnistyles(GitMerge);
const ThemedRefreshCcw = withUnistyles(RefreshCcw);
const ThemedArchive = withUnistyles(Archive);
@@ -1403,6 +1405,7 @@ function DiffFilesToolbar({
}
interface DiffOptionsMenuProps {
brand: string;
hideWhitespace: boolean;
isMobile: boolean;
isRefreshing: boolean;
@@ -1415,6 +1418,7 @@ interface DiffOptionsMenuProps {
}
function DiffOptionsMenu({
brand,
hideWhitespace,
isMobile,
isRefreshing,
@@ -1489,7 +1493,11 @@ function DiffOptionsMenu({
testID="changes-refresh"
onSelect={onRefresh}
>
{isRefreshing ? t("workspace.git.diff.refreshing") : t("workspace.git.diff.refresh")}
{isRefreshing
? t("workspace.git.diff.refreshing")
: t("workspace.git.diff.refreshState", {
brand,
})}
</DropdownMenuItem>
</>
) : null}
@@ -2078,6 +2086,62 @@ function computePrErrorMessage(
return prPayloadError?.message ?? null;
}
// The precise setup step a workspace needs before its forge features work, or
// null when nothing is actionable (authenticated, or no forge remote at all).
type ForgeSetupAction = "install_cli" | "sign_in" | null;
// Drive the onboarding callout from the forge's auth state so the message names
// the exact next step (install the CLI vs sign in) for whichever forge backs the
// workspace — GitHub included. GitLab additionally requires the host to advertise
// GitLab support, matching the rest of the GitLab UI.
function computeForgeSetupAction(input: {
forge: Forge;
forgeProvidersSupported: boolean;
authState: ForgeAuthState | undefined;
}): ForgeSetupAction {
// A daemon without pluggable forge support can't operate any non-GitHub forge,
// so don't offer a setup action for one it can't drive.
if (input.forge !== "github" && !input.forgeProvidersSupported) {
return null;
}
switch (input.authState) {
case "cli_missing":
return "install_cli";
case "unauthenticated":
return "sign_in";
case "authenticated":
case "no_remote":
case "error":
return null;
default:
return null;
}
}
function parseForgeHost(url: string | null | undefined): string | null {
return url ? (parseGitRemoteLocation(url)?.host ?? null) : null;
}
function buildForgeSetupMessage(input: {
action: Exclude<ForgeSetupAction, null>;
forge: Forge;
host: string | null;
t: TFunction;
}): string {
const { brandLabel, signInCli } = getForgePresentation(input.forge);
// A forge with no known CLI (an unknown/third-party forge rendered neutrally)
// has no install/sign-in command to interpolate — show neutral guidance
// rather than the GitLab-specific callout or a null command.
if (signInCli === null) {
return input.t("workspace.git.forgeSetup.generic", { brand: brandLabel });
}
if (input.action === "install_cli") {
return input.t("workspace.git.forgeSetup.installCli", { cli: signInCli, brand: brandLabel });
}
const command = buildForgeSignInCommand(input.forge, input.host);
return input.t("workspace.git.forgeSetup.signIn", { command, brand: brandLabel });
}
function buildDiffModeTriggerStyle(): PressableStyleFn {
return ({ hovered, pressed, open }) => [
styles.diffModeTrigger,
@@ -2285,11 +2349,36 @@ export function GitDiffPane({ serverId, workspaceId, cwd, enabled }: GitDiffPane
setWorkspaceAttachments,
workspaceAttachmentScopeKey,
]);
const { githubFeaturesEnabled, payloadError: prPayloadError } = useCheckoutPrStatusQuery({
const {
githubFeaturesEnabled,
forge,
authState,
payloadError: prPayloadError,
} = useCheckoutPrStatusQuery({
serverId,
cwd,
enabled: isGit,
});
const forgeProvidersSupported = useSessionStore(
(s) => s.sessions[serverId]?.serverInfo?.features?.forgeProviders === true,
);
const forgeSetupAction = computeForgeSetupAction({
forge,
forgeProvidersSupported,
authState,
});
const forgeSetupMessage = useMemo(
() =>
forgeSetupAction
? buildForgeSetupMessage({
action: forgeSetupAction,
forge,
host: parseForgeHost(status?.remoteUrl),
t,
})
: null,
[forgeSetupAction, forge, status?.remoteUrl, t],
);
const normalizedWorkspaceRoot = useMemo(() => cwd.trim(), [cwd]);
const workspaceStateKey = useMemo(
() =>
@@ -2401,11 +2490,6 @@ export function GitDiffPane({ serverId, workspaceId, cwd, enabled }: GitDiffPane
pull: <ThemedDownload size={16} uniProps={foregroundMutedIconColorMapping} />,
push: <ThemedUpload size={16} uniProps={foregroundMutedIconColorMapping} />,
pullAndPush: <ThemedArrowDownUp size={16} uniProps={foregroundMutedIconColorMapping} />,
viewPr: <ThemedGitHubIcon size={16} uniProps={foregroundMutedIconColorMapping} />,
createPr: <ThemedGitHubIcon size={16} uniProps={foregroundMutedIconColorMapping} />,
mergePrSquash: <ThemedGitHubIcon size={16} uniProps={foregroundMutedIconColorMapping} />,
mergePrMerge: <ThemedGitHubIcon size={16} uniProps={foregroundMutedIconColorMapping} />,
mergePrRebase: <ThemedGitHubIcon size={16} uniProps={foregroundMutedIconColorMapping} />,
merge: <ThemedGitMerge size={16} uniProps={foregroundMutedIconColorMapping} />,
mergeFromBase: <ThemedRefreshCcw size={16} uniProps={foregroundMutedIconColorMapping} />,
archive: <ThemedArchive size={16} uniProps={foregroundMutedIconColorMapping} />,
@@ -2531,6 +2615,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, enabled }: GitDiffPane
/>
) : null}
<DiffOptionsMenu
brand={getForgePresentation(forge).brandLabel}
hideWhitespace={changesPreferences.hideWhitespace}
isMobile={isMobile}
isRefreshing={isRefreshing}
@@ -2546,6 +2631,12 @@ export function GitDiffPane({ serverId, workspaceId, cwd, enabled }: GitDiffPane
</View>
) : null}
{forgeSetupMessage ? (
<View style={styles.forgeSetupCallout} testID="forge-setup-callout">
<Text style={styles.forgeSetupCalloutText}>{forgeSetupMessage}</Text>
</View>
) : null}
{prErrorMessage ? <Text style={styles.actionErrorText}>{prErrorMessage}</Text> : null}
<View style={styles.diffContainer}>{bodyContent}</View>
@@ -2653,6 +2744,20 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.xs,
color: theme.colors.destructive,
},
forgeSetupCallout: {
marginHorizontal: theme.spacing[3],
marginBottom: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
borderWidth: 1,
borderColor: theme.colors.border,
borderRadius: theme.borderRadius.md,
backgroundColor: theme.colors.surface1,
},
forgeSetupCalloutText: {
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
},
diffContainer: {
flex: 1,
minHeight: 0,
+49
View File
@@ -0,0 +1,49 @@
import { describe, expect, it } from "vitest";
import { GitPullRequest } from "lucide-react-native";
import { getForgeBrandColorMapping, getForgeIconComponent } from "./forge-icon";
import { CLIENT_FORGE_VIEW_MODULES } from "./forges/view";
describe("getForgeIconComponent", () => {
it("resolves a dedicated brand icon for every registered forge module", () => {
for (const module of CLIENT_FORGE_VIEW_MODULES) {
expect(getForgeIconComponent(module.id)).toBe(module.icon);
}
});
it("falls back to the generic pull-request glyph for unknown icon kinds", () => {
expect(getForgeIconComponent("some-unknown-forge")).toBe(GitPullRequest);
expect(getForgeIconComponent("")).toBe(GitPullRequest);
});
});
describe("getForgeBrandColorMapping", () => {
it("returns a theme mapping only for forges that declare a brand color", () => {
for (const module of CLIENT_FORGE_VIEW_MODULES) {
const mapping = getForgeBrandColorMapping(module.id);
if (module.brandColor) {
expect(mapping).toBeTypeOf("function");
} else {
expect(mapping).toBeNull();
}
}
});
it("resolves the light or dark brand color from the theme color scheme", () => {
const branded = CLIENT_FORGE_VIEW_MODULES.find((module) => module.brandColor);
expect(branded).toBeDefined();
if (!branded?.brandColor) {
return;
}
const mapping = getForgeBrandColorMapping(branded.id);
expect(mapping?.({ colorScheme: "light" } as never)).toEqual({
color: branded.brandColor.light,
});
expect(mapping?.({ colorScheme: "dark" } as never)).toEqual({
color: branded.brandColor.dark,
});
});
it("returns null for unknown icon kinds", () => {
expect(getForgeBrandColorMapping("some-unknown-forge")).toBeNull();
});
});
+72
View File
@@ -0,0 +1,72 @@
/**
* Brand marks are SVG React components, so they cannot travel over the wire as
* a string. This file derives icon and brand-color lookup tables from
* CLIENT_FORGE_VIEW_MODULES; a new forge ships an icon component in the client
* bundle and references it from its view module. Unknown icon kinds fall back to
* a generic pull-request glyph.
*/
import { withUnistyles } from "react-native-unistyles";
import { GitPullRequest } from "lucide-react-native";
import {
type ForgeBrandColor,
type ForgeIconColorMapping,
type ForgeIconComponent,
} from "@/git/client-forge-module";
import { CLIENT_FORGE_VIEW_MODULES } from "@/git/forges/view";
const FORGE_ICON_BY_KIND = new Map(
CLIENT_FORGE_VIEW_MODULES.map((module) => [module.id, module.icon]),
);
/**
* Raw brand icon component for an `iconKind`, for call sites that style with a
* plain `color` prop. Falls back to a generic pull-request glyph.
*/
export function getForgeIconComponent(iconKind: string): ForgeIconComponent {
return FORGE_ICON_BY_KIND.get(iconKind) ?? GitPullRequest;
}
const THEMED_ICON_BY_KIND = Object.fromEntries(
CLIENT_FORGE_VIEW_MODULES.map((module) => [module.id, withUnistyles(module.icon)]),
);
const ThemedGitPullRequest = withUnistyles(GitPullRequest);
/** Theme-driven color mapping, e.g. `(theme) => ({ color: theme.colors.foregroundMuted })`. */
export type { ForgeIconColorMapping };
function brandColorMapping(colors: ForgeBrandColor): ForgeIconColorMapping {
return (theme) => ({ color: theme.colorScheme === "light" ? colors.light : colors.dark });
}
const BRAND_COLOR_MAPPING_BY_KIND = new Map(
CLIENT_FORGE_VIEW_MODULES.flatMap((module) =>
module.brandColor ? [[module.id, brandColorMapping(module.brandColor)] as const] : [],
),
);
/**
* The forge's brand color mapping, or null for forges with no dedicated brand
* color (github renders in a neutral tone; an unknown forge has none). Call
* sites pair this with {@link ForgeBrandIcon}: use it to tint the icon, or to
* decide whether to show a brand badge at all.
*/
export function getForgeBrandColorMapping(iconKind: string): ForgeIconColorMapping | null {
return BRAND_COLOR_MAPPING_BY_KIND.get(iconKind) ?? null;
}
/**
* Themed brand icon for an `iconKind`, for call sites that color via a unistyles
* mapping. Falls back to a generic pull-request glyph for unknown forges.
*/
export function ForgeBrandIcon({
iconKind,
size,
uniProps,
}: {
iconKind: string;
size: number;
uniProps: ForgeIconColorMapping;
}) {
const ThemedIcon = THEMED_ICON_BY_KIND[iconKind] ?? ThemedGitPullRequest;
return <ThemedIcon size={size} uniProps={uniProps} />;
}
+205
View File
@@ -0,0 +1,205 @@
import { describe, expect, it } from "vitest";
import { buildForgeBlobUrl, buildForgeBranchTreeUrl, hasForgeWebUrls } from "./forge-url";
describe("buildForgeBranchTreeUrl", () => {
it("builds a branch-specific GitHub tree URL", () => {
expect(
buildForgeBranchTreeUrl("github", {
remoteUrl: "git@github.com:acme/repo.git",
branch: "feature/workspace-button",
}),
).toBe("https://github.com/acme/repo/tree/feature/workspace-button");
});
it("encodes reserved branch characters while preserving slash-separated branch names", () => {
expect(
buildForgeBranchTreeUrl("github", {
remoteUrl: "https://github.com/acme/repo.git",
branch: "feature/ship #42",
}),
).toBe("https://github.com/acme/repo/tree/feature/ship%20%2342");
});
it("uses the GitLab /-/tree/ infix and supports subgroups", () => {
expect(
buildForgeBranchTreeUrl("gitlab", {
remoteUrl: "https://gitlab.com/group/sub/repo.git",
branch: "main",
}),
).toBe("https://gitlab.com/group/sub/repo/-/tree/main");
});
it("uses the Gitea-family /src/branch/ infix", () => {
expect(
buildForgeBranchTreeUrl("gitea", {
remoteUrl: "https://gitea.com/acme/repo.git",
branch: "main",
}),
).toBe("https://gitea.com/acme/repo/src/branch/main");
expect(
buildForgeBranchTreeUrl("codeberg", {
remoteUrl: "https://codeberg.org/acme/repo.git",
branch: "main",
}),
).toBe("https://codeberg.org/acme/repo/src/branch/main");
});
it("returns null when the current branch is unavailable", () => {
expect(
buildForgeBranchTreeUrl("github", {
remoteUrl: "https://github.com/acme/repo.git",
branch: "HEAD",
}),
).toBeNull();
});
it("returns null for a forge with no known URL grammar", () => {
expect(
buildForgeBranchTreeUrl("bitbucket", {
remoteUrl: "https://bitbucket.org/acme/repo.git",
branch: "main",
}),
).toBeNull();
});
});
describe("buildForgeBlobUrl", () => {
it("builds a blob URL for a file path", () => {
expect(
buildForgeBlobUrl("github", {
remoteUrl: "git@github.com:acme/repo.git",
branch: "main",
path: "src/index.ts",
}),
).toBe("https://github.com/acme/repo/blob/main/src/index.ts");
});
it("appends a single-line anchor", () => {
expect(
buildForgeBlobUrl("github", {
remoteUrl: "https://github.com/acme/repo.git",
branch: "main",
path: "src/index.ts",
lineStart: 12,
}),
).toBe("https://github.com/acme/repo/blob/main/src/index.ts#L12");
});
it("appends a GitHub line range anchor (#L12-L20)", () => {
expect(
buildForgeBlobUrl("github", {
remoteUrl: "https://github.com/acme/repo.git",
branch: "main",
path: "src/index.ts",
lineStart: 12,
lineEnd: 20,
}),
).toBe("https://github.com/acme/repo/blob/main/src/index.ts#L12-L20");
});
it("uses the GitLab /-/blob/ infix and #L12-20 range anchor", () => {
expect(
buildForgeBlobUrl("gitlab", {
remoteUrl: "https://gitlab.com/group/sub/repo.git",
branch: "main",
path: "src/index.ts",
lineStart: 12,
lineEnd: 20,
}),
).toBe("https://gitlab.com/group/sub/repo/-/blob/main/src/index.ts#L12-20");
});
it("uses the Gitea-family /src/branch/ blob path with a #L12-L20 anchor", () => {
expect(
buildForgeBlobUrl("forgejo", {
remoteUrl: "https://codeberg.org/acme/repo.git",
branch: "main",
path: "src/index.ts",
lineStart: 12,
lineEnd: 20,
}),
).toBe("https://codeberg.org/acme/repo/src/branch/main/src/index.ts#L12-L20");
});
it("derives the web host from a self-hosted remote (GitHub Enterprise)", () => {
expect(
buildForgeBlobUrl("github", {
remoteUrl: "git@github.acme.internal:team/repo.git",
branch: "main",
path: "src/index.ts",
}),
).toBe("https://github.acme.internal/team/repo/blob/main/src/index.ts");
});
it("canonicalizes the github.com SSH-alias host to the web host", () => {
expect(
buildForgeBlobUrl("github", {
remoteUrl: "ssh://git@ssh.github.com/acme/repo.git",
branch: "main",
path: "src/index.ts",
}),
).toBe("https://github.com/acme/repo/blob/main/src/index.ts");
});
it("strips leading slashes and encodes path segments", () => {
expect(
buildForgeBlobUrl("github", {
remoteUrl: "https://github.com/acme/repo.git",
branch: "main",
path: "/src/a b/c#d.ts",
}),
).toBe("https://github.com/acme/repo/blob/main/src/a%20b/c%23d.ts");
});
it("normalizes harmless dot segments in the blob path", () => {
expect(
buildForgeBlobUrl("github", {
remoteUrl: "https://github.com/acme/repo.git",
branch: "main",
path: "./src/../index.ts",
}),
).toBe("https://github.com/acme/repo/blob/main/index.ts");
});
it("returns null for blob paths that escape above the repo root", () => {
expect(
buildForgeBlobUrl("github", {
remoteUrl: "https://github.com/acme/repo.git",
branch: "main",
path: "../outside.ts",
}),
).toBeNull();
});
it("returns null when the path is missing", () => {
expect(
buildForgeBlobUrl("github", {
remoteUrl: "https://github.com/acme/repo.git",
branch: "main",
path: "",
}),
).toBeNull();
});
it("returns null for a forge with no known URL grammar", () => {
expect(
buildForgeBlobUrl("bitbucket", {
remoteUrl: "https://bitbucket.org/acme/repo.git",
branch: "main",
path: "src/index.ts",
}),
).toBeNull();
});
});
describe("hasForgeWebUrls", () => {
it("is true for forges with a known URL grammar", () => {
for (const forge of ["github", "gitlab", "gitea", "forgejo", "codeberg"]) {
expect(hasForgeWebUrls(forge)).toBe(true);
}
});
it("is false for an unknown forge", () => {
expect(hasForgeWebUrls("bitbucket")).toBe(false);
});
});
+127
View File
@@ -0,0 +1,127 @@
/**
* Forge-neutral web URL builders for "Open on <forge>" actions (a file blob or a
* branch tree). The host comes from the workspace remote — not a hardcoded
* cloud host — so self-hosted and Enterprise instances link correctly. Each
* forge contributes a small URL grammar (the path infixes and line-anchor
* format); an unknown forge has no grammar and yields null, so the action is
* simply absent rather than wrong.
*
* URL grammar lives on each client forge module. The repo identity and host
* both ride the manifest's `cloudHosts` only to canonicalize a cloud SSH alias
* (e.g. ssh.github.com -> github.com); a self-hosted host is used as-is.
*/
import { getForgeDefinition } from "@getpaseo/protocol/forge-manifest";
import { normalizeHost, parseGitRemoteLocation } from "@getpaseo/protocol/git-remote";
import { getClientForgeLogicModule } from "@/git/forges";
export interface ForgeBlobUrlInput {
remoteUrl: string | null | undefined;
branch: string | null | undefined;
path: string | null | undefined;
lineStart?: number;
lineEnd?: number;
}
export interface ForgeBranchTreeUrlInput {
remoteUrl: string | null | undefined;
branch: string | null | undefined;
}
interface ForgeWebLocation {
host: string;
repo: string;
}
/**
* Web host + repo path from a remote. The host is the remote's host, except a
* cloud SSH alias (a non-first entry in the forge's `cloudHosts`, e.g.
* ssh.github.com) is canonicalized to the forge's web host (`cloudHosts[0]`).
* Self-hosted hosts are returned untouched. The repo path supports nested
* groups (e.g. GitLab subgroups) since it is the full remote path.
*/
function isValidRepoPath(path: string): boolean {
const segments = path.split("/").filter((segment) => segment.length > 0);
if (segments.length === 0) {
return false;
}
return !segments.includes("..");
}
function resolveForgeWebLocation(
forge: string,
remoteUrl: string | null | undefined,
): ForgeWebLocation | null {
if (!remoteUrl) {
return null;
}
const location = parseGitRemoteLocation(remoteUrl);
if (!location || !isValidRepoPath(location.path)) {
return null;
}
const cloudHosts = getForgeDefinition(forge)?.cloudHosts;
const webHost =
cloudHosts && cloudHosts.length > 0 && cloudHosts.map(normalizeHost).includes(location.host)
? normalizeHost(cloudHosts[0])
: location.host;
return { host: webHost, repo: location.path };
}
function encodeBranch(branch: string): string {
return branch.split("/").map(encodeURIComponent).join("/");
}
function normalizeBlobPath(path: string | null | undefined): string | null {
const segments: string[] = [];
const trimmed = path?.trim().replace(/\\/g, "/").replace(/^\/+/, "");
if (!trimmed) {
return null;
}
for (const segment of trimmed.split("/")) {
if (!segment || segment === ".") {
continue;
}
if (segment === "..") {
if (segments.length === 0) {
return null;
}
segments.pop();
continue;
}
segments.push(segment);
}
return segments.join("/") || null;
}
export function buildForgeBranchTreeUrl(
forge: string,
input: ForgeBranchTreeUrlInput,
): string | null {
const grammar = getClientForgeLogicModule(forge)?.urlGrammar;
const location = resolveForgeWebLocation(forge, input.remoteUrl);
const branch = input.branch?.trim();
if (!grammar || !location || !branch || branch === "HEAD") {
return null;
}
return `https://${location.host}/${location.repo}${grammar.treeInfix}${encodeBranch(branch)}`;
}
export function buildForgeBlobUrl(forge: string, input: ForgeBlobUrlInput): string | null {
const grammar = getClientForgeLogicModule(forge)?.urlGrammar;
const location = resolveForgeWebLocation(forge, input.remoteUrl);
const branch = input.branch?.trim();
const filePath = normalizeBlobPath(input.path);
if (!grammar || !location || !branch || branch === "HEAD" || !filePath) {
return null;
}
const encodedPath = filePath.split("/").map(encodeURIComponent).join("/");
let url = `https://${location.host}/${location.repo}${grammar.blobInfix}${encodeBranch(branch)}/${encodedPath}`;
if (input.lineStart && input.lineStart > 0) {
url += grammar.lineAnchor(input.lineStart, input.lineEnd);
}
return url;
}
/** Whether the forge has web URL builders (i.e. a known URL grammar). */
export function hasForgeWebUrls(forge: string): boolean {
return getClientForgeLogicModule(forge)?.urlGrammar !== undefined;
}
+107
View File
@@ -0,0 +1,107 @@
import { describe, expect, it } from "vitest";
import {
buildForgeSignInCommand,
forgeFromRemoteUrl,
getForgePresentation,
normalizeForge,
} from "./forge";
describe("normalizeForge", () => {
it("maps the gitlab discriminant to gitlab", () => {
expect(normalizeForge("gitlab")).toBe("gitlab");
});
it("keeps any non-empty forge id and defaults only absent values to github", () => {
expect(normalizeForge("github")).toBe("github");
expect(normalizeForge("gitea")).toBe("gitea");
expect(normalizeForge("forgejo")).toBe("forgejo");
expect(normalizeForge(undefined)).toBe("github");
expect(normalizeForge(null)).toBe("github");
// An unknown forge id is preserved (rendered neutrally), not collapsed to
// GitHub, so a newer daemon's forge degrades gracefully on an older client.
expect(normalizeForge("bitbucket")).toBe("bitbucket");
});
});
describe("getForgePresentation", () => {
it("keeps GitHub on the pull-request noun and the # prefix", () => {
const github = getForgePresentation("github");
expect(github.brandLabel).toBe("GitHub");
expect(github.changeRequestAbbrev).toBe("PR");
expect(github.numberPrefix).toBe("#");
expect(github.issueNumberPrefix).toBe("#");
expect(github.changeRequestContext).toBeUndefined();
});
it("relabels GitLab to the merge-request noun and the ! prefix", () => {
const gitlab = getForgePresentation("gitlab");
expect(gitlab.brandLabel).toBe("GitLab");
expect(gitlab.changeRequestAbbrev).toBe("MR");
expect(gitlab.numberPrefix).toBe("!");
expect(gitlab.issueNumberPrefix).toBe("#");
expect(gitlab.changeRequestContext).toBe("mr");
});
it("presents Gitea and Forgejo with GitHub nouns and the tea CLI", () => {
expect(getForgePresentation("gitea")).toMatchObject({
forge: "gitea",
icon: "gitea",
brandLabel: "Gitea",
changeRequestAbbrev: "PR",
numberPrefix: "#",
issueNumberPrefix: "#",
signInCli: "tea",
});
expect(getForgePresentation("forgejo")).toMatchObject({
forge: "forgejo",
icon: "forgejo",
brandLabel: "Forgejo",
changeRequestAbbrev: "PR",
signInCli: "tea",
});
expect(getForgePresentation("codeberg")).toMatchObject({
forge: "codeberg",
icon: "codeberg",
brandLabel: "Codeberg",
changeRequestAbbrev: "PR",
signInCli: "tea",
});
});
});
describe("forgeFromRemoteUrl", () => {
it("detects only public forge hosts that are safe without daemon probing", () => {
expect(forgeFromRemoteUrl("https://codeberg.org/example/repo.git")).toBe("codeberg");
expect(forgeFromRemoteUrl("https://gitlab.com/example/repo.git")).toBe("gitlab");
expect(forgeFromRemoteUrl("https://gitea.com/example/repo.git")).toBe("gitea");
});
it("does not classify self-managed hosts by substring", () => {
expect(forgeFromRemoteUrl("git@gitlab.example.org:example/repo.git")).toBeNull();
expect(forgeFromRemoteUrl("git@forgejo.example.org:example/repo.git")).toBeNull();
expect(forgeFromRemoteUrl("https://notgitlab.example.org/example/repo.git")).toBeNull();
});
});
describe("buildForgeSignInCommand", () => {
it("uses tea login add for both Gitea-family presentations", () => {
expect(buildForgeSignInCommand("gitea", "gitea.com")).toBe("tea login add");
expect(buildForgeSignInCommand("forgejo", "forgejo.example.org")).toBe("tea login add");
expect(buildForgeSignInCommand("codeberg", "codeberg.org")).toBe("tea login add");
});
it("uses plain gh auth login for GitHub (incl. the ssh.github.com endpoint)", () => {
expect(buildForgeSignInCommand("github", "github.com")).toBe("gh auth login");
expect(buildForgeSignInCommand("github", "ssh.github.com")).toBe("gh auth login");
});
it("targets the workspace host for self-hosted GitLab", () => {
expect(buildForgeSignInCommand("gitlab", "gitlab.acme.com")).toBe(
"glab auth login --hostname gitlab.acme.com",
);
});
it("returns no sign-in command for an unknown forge with no known CLI", () => {
expect(buildForgeSignInCommand("bitbucket", "bitbucket.org")).toBeNull();
});
});
+126
View File
@@ -0,0 +1,126 @@
/**
* Forge-neutral presentation layer for the git-hosting UI, derived from the
* shared forge manifest.
*
* The daemon resolves which forge backs a workspace and reports it on the wire
* (`CheckoutPrStatusResponse.payload.forge`). The model keeps the `PullRequest`
* noun in code and types; the PR↔MR relabel, the number prefix, the brand mark,
* and the icon are a UI concern driven by the manifest entry for that id. An
* unknown/absent forge (e.g. a self-hosted forge a newer daemon reports to an
* older client) renders neutrally — never GitHub-branded. A null/empty forge
* still maps to GitHub so old daemons (which never send a forge) render exactly
* as before.
*/
import { FORGE_DEFINITIONS, getForgeDefinitionOrNeutral } from "@getpaseo/protocol/forge-manifest";
import { normalizeHost, parseGitRemoteLocation } from "@getpaseo/protocol/git-remote";
import type { ForgeAuthState } from "@getpaseo/protocol/messages";
import {
buildForgeBlobUrl,
buildForgeBranchTreeUrl,
hasForgeWebUrls,
type ForgeBlobUrlInput,
type ForgeBranchTreeUrlInput,
} from "@/git/forge-url";
/**
* A forge id. Open by design: any id the daemon reports is valid, and the
* manifest drives presentation with a neutral fallback for unknown ids. Kept as
* a named alias to document intent at call sites.
*/
export type Forge = string;
export function normalizeForge(raw: string | null | undefined): string {
return raw && raw.length > 0 ? raw : "github";
}
export function parseForgeAuthState(value: unknown): ForgeAuthState | undefined {
switch (value) {
case "authenticated":
case "unauthenticated":
case "cli_missing":
case "no_remote":
case "error":
return value;
default:
return undefined;
}
}
export function forgeFromRemoteUrl(remoteUrl: string | null | undefined): string | null {
if (!remoteUrl) {
return null;
}
const host = parseGitRemoteLocation(remoteUrl)?.host;
if (!host) {
return null;
}
const normalized = normalizeHost(host);
for (const definition of FORGE_DEFINITIONS) {
if (definition.cloudHosts?.some((cloudHost) => normalizeHost(cloudHost) === normalized)) {
return definition.id;
}
}
return null;
}
export interface ForgePresentation {
forge: string;
/** Icon key; render sites fall back to a generic git icon for unknown values. */
icon: string;
/** Human brand name, e.g. for "Open on GitLab". */
brandLabel: string;
/** Short change-request noun: "PR" for GitHub, "MR" for GitLab. */
changeRequestAbbrev: string;
/** Full change-request noun: "pull request" for GitHub, "merge request" for GitLab. */
changeRequestNoun: string;
/** Prefix the forge puts before a change-request number: "#" vs "!". */
numberPrefix: string;
/** Prefix the forge puts before an issue number ("#" on every forge so far). */
issueNumberPrefix: string;
/** Auth CLI binary for the install hint, or null for a forge with no Paseo-driven sign-in. */
signInCli: string | null;
/**
* i18next context selecting the change-request vocabulary family for any key
* that carries an `_mr` variant: `t(key, { context: changeRequestContext })`
* resolves `key_mr` for the merge-request family and falls back to the base
* (pull-request) string for undefined or unknown families.
*/
changeRequestContext: "mr" | undefined;
buildBlobUrl: ((input: ForgeBlobUrlInput) => string | null) | null;
buildBranchTreeUrl: ((input: ForgeBranchTreeUrlInput) => string | null) | null;
}
export function getForgePresentation(forge: string): ForgePresentation {
const definition = getForgeDefinitionOrNeutral(forge);
const isMergeRequest = definition.changeRequestAbbrev === "MR";
const hasWebUrls = hasForgeWebUrls(definition.id);
return {
forge: definition.id,
icon: definition.iconKind,
brandLabel: definition.displayName,
changeRequestAbbrev: definition.changeRequestAbbrev,
changeRequestNoun: definition.changeRequestNoun,
numberPrefix: definition.changeRequestNumberPrefix,
issueNumberPrefix: definition.issueNumberPrefix,
signInCli: definition.signIn?.cli ?? null,
changeRequestContext: isMergeRequest ? "mr" : undefined,
buildBlobUrl: hasWebUrls ? (input) => buildForgeBlobUrl(definition.id, input) : null,
buildBranchTreeUrl: hasWebUrls
? (input) => buildForgeBranchTreeUrl(definition.id, input)
: null,
};
}
export function buildForgeSignInCommand(forge: string, host: string | null): string | null {
const signIn = getForgeDefinitionOrNeutral(forge).signIn;
if (!signIn) {
return null;
}
// A forge that needs a --hostname only gets it when a host is known; forges
// whose command already targets the right API host (e.g. github's plain
// `gh auth login`, which must NOT receive ssh.github.com) omit hostnameFlag.
if (signIn.hostnameFlag && host) {
return `${signIn.command} ${signIn.hostnameFlag} ${host}`;
}
return signIn.command;
}
+6
View File
@@ -0,0 +1,6 @@
import { GITEA_FAMILY_URL_GRAMMAR, type ClientForgeLogicModule } from "@/git/client-forge-module";
export const codebergForgeLogic = {
id: "codeberg",
urlGrammar: GITEA_FAMILY_URL_GRAMMAR,
} satisfies ClientForgeLogicModule;
@@ -0,0 +1,11 @@
import { CodebergIcon } from "@/components/icons/codeberg-icon";
import type { ClientForgeViewModule } from "@/git/client-forge-module";
export const codebergForgeView = {
id: "codeberg",
icon: CodebergIcon,
brandColor: {
light: "#2185D0",
dark: "#2185D0",
},
} satisfies ClientForgeViewModule;
+6
View File
@@ -0,0 +1,6 @@
import { GITEA_FAMILY_URL_GRAMMAR, type ClientForgeLogicModule } from "@/git/client-forge-module";
export const forgejoForgeLogic = {
id: "forgejo",
urlGrammar: GITEA_FAMILY_URL_GRAMMAR,
} satisfies ClientForgeLogicModule;
@@ -0,0 +1,11 @@
import { ForgejoIcon } from "@/components/icons/forgejo-icon";
import type { ClientForgeViewModule } from "@/git/client-forge-module";
export const forgejoForgeView = {
id: "forgejo",
icon: ForgejoIcon,
brandColor: {
light: "#FB923C",
dark: "#FB923C",
},
} satisfies ClientForgeViewModule;
+72
View File
@@ -0,0 +1,72 @@
import { z } from "zod";
import {
defineForgeFacts,
defineNativeFallbackCheck,
GITEA_FAMILY_URL_GRAMMAR,
type ClientForgeLogicModule,
type MergeCapability,
} from "@/git/client-forge-module";
import type { CheckoutPrMergeMethod } from "@getpaseo/protocol/messages";
import { mapCheckStatus, type CheckStatus } from "@/git/pull-request-panel/check-status";
const GiteaMergeFactsSchema = z
.object({
forge: z.literal("gitea"),
mergeable: z.boolean().optional().default(false),
hasMerged: z.boolean().optional().default(false),
ciStatus: z.string().nullable().optional().default(null),
})
.passthrough();
type GiteaMergeFacts = z.infer<typeof GiteaMergeFactsSchema>;
// forgeSpecific.ciStatus carries Gitea's raw aggregate CI string. Server twin:
// packages/server/src/services/gitea-service.ts (mapGiteaCommitStatus) — "warning"
// and "error" are terminal, non-passing states, but the generic mapCheckStatus
// would show them as pending, so interpret them here where the module owns Gitea
// facts.
function mapGiteaCiStatus(ciStatus: string): CheckStatus {
if (ciStatus === "warning" || ciStatus === "error") {
return "failure";
}
return mapCheckStatus(ciStatus);
}
const GITEA_MERGE_METHODS: CheckoutPrMergeMethod[] = ["merge", "squash", "rebase"];
function deriveGiteaMergeCapability(gitea: GiteaMergeFacts): MergeCapability {
return {
directMergeReady: gitea.mergeable && !gitea.hasMerged,
canEnableAutoMerge: false,
autoMergeEnabled: false,
canDisableAutoMerge: false,
mergeBlockedByQueue: false,
allowedMethods: GITEA_MERGE_METHODS,
preferredMethod: null,
};
}
export const giteaForgeLogic = {
id: "gitea",
urlGrammar: GITEA_FAMILY_URL_GRAMMAR,
facts: defineForgeFacts({
family: "gitea",
schema: GiteaMergeFactsSchema,
deriveMergeCapability: deriveGiteaMergeCapability,
nativeFallbackChecks: [
defineNativeFallbackCheck(GiteaMergeFactsSchema, {
contribute: (facts, status, forge) => {
if (!facts.ciStatus) {
return null;
}
return {
provider: forge,
name: "CI",
status: mapGiteaCiStatus(facts.ciStatus),
url: status.url,
};
},
}),
],
}),
} satisfies ClientForgeLogicModule<GiteaMergeFacts>;
@@ -0,0 +1,11 @@
import { GiteaIcon } from "@/components/icons/gitea-icon";
import type { ClientForgeViewModule } from "@/git/client-forge-module";
export const giteaForgeView = {
id: "gitea",
icon: GiteaIcon,
brandColor: {
light: "#609926",
dark: "#609926",
},
} satisfies ClientForgeViewModule;
+95
View File
@@ -0,0 +1,95 @@
import { z } from "zod";
import {
defineForgeFacts,
GITHUB_LINE_ANCHOR,
type ClientForgeLogicModule,
type MergeCapability,
} from "@/git/client-forge-module";
import type { CheckoutPrMergeMethod } from "@getpaseo/protocol/messages";
const GithubAutoMergeRequestSchema = z
.object({
enabledAt: z.string().nullable().optional().default(null),
mergeMethod: z.string().nullable().optional().default(null),
enabledBy: z.string().nullable().optional().default(null),
})
.nullable()
.optional()
.default(null);
const GithubRepositoryPolicySchema = z
.object({
autoMergeAllowed: z.boolean().optional().default(false),
mergeCommitAllowed: z.boolean().optional().default(false),
squashMergeAllowed: z.boolean().optional().default(false),
rebaseMergeAllowed: z.boolean().optional().default(false),
viewerDefaultMergeMethod: z.string().nullable().optional().default(null),
})
.optional()
.default({
autoMergeAllowed: false,
mergeCommitAllowed: false,
squashMergeAllowed: false,
rebaseMergeAllowed: false,
viewerDefaultMergeMethod: null,
});
const GithubMergeFactsSchema = z
.object({
forge: z.literal("github"),
mergeStateStatus: z.string().nullable().optional().default(null),
autoMergeRequest: GithubAutoMergeRequestSchema,
viewerCanEnableAutoMerge: z.boolean().optional().default(false),
viewerCanDisableAutoMerge: z.boolean().optional().default(false),
viewerCanMergeAsAdmin: z.boolean().optional().default(false),
viewerCanUpdateBranch: z.boolean().optional().default(false),
repository: GithubRepositoryPolicySchema,
isMergeQueueEnabled: z.boolean().optional().default(false),
isInMergeQueue: z.boolean().optional().default(false),
})
.passthrough();
type GithubMergeFacts = z.infer<typeof GithubMergeFactsSchema>;
const GITHUB_DIRECT_MERGE_STATE_ALLOWLIST = new Set(["CLEAN", "HAS_HOOKS"]);
function normalizeGithubMergeMethod(value: string | null): CheckoutPrMergeMethod | null {
if (value === "SQUASH") return "squash";
if (value === "MERGE") return "merge";
if (value === "REBASE") return "rebase";
return null;
}
function deriveGithubMergeCapability(github: GithubMergeFacts): MergeCapability {
const repository = github.repository;
const allowedMethods: CheckoutPrMergeMethod[] = [];
if (repository.mergeCommitAllowed) allowedMethods.push("merge");
if (repository.squashMergeAllowed) allowedMethods.push("squash");
if (repository.rebaseMergeAllowed) allowedMethods.push("rebase");
return {
directMergeReady: GITHUB_DIRECT_MERGE_STATE_ALLOWLIST.has(github.mergeStateStatus ?? ""),
canEnableAutoMerge:
github.mergeStateStatus === "BLOCKED" &&
repository.autoMergeAllowed &&
github.viewerCanEnableAutoMerge,
autoMergeEnabled: github.autoMergeRequest !== null,
canDisableAutoMerge: github.viewerCanDisableAutoMerge === true,
mergeBlockedByQueue: github.isMergeQueueEnabled || github.isInMergeQueue,
allowedMethods,
preferredMethod: normalizeGithubMergeMethod(repository.viewerDefaultMergeMethod ?? null),
};
}
export const githubForgeLogic = {
id: "github",
urlGrammar: {
treeInfix: "/tree/",
blobInfix: "/blob/",
lineAnchor: GITHUB_LINE_ANCHOR,
},
facts: defineForgeFacts({
family: "github",
schema: GithubMergeFactsSchema,
deriveMergeCapability: deriveGithubMergeCapability,
}),
} satisfies ClientForgeLogicModule<GithubMergeFacts>;
@@ -0,0 +1,8 @@
import { GitHubIcon } from "@/components/icons/github-icon";
import type { ClientForgeViewModule } from "@/git/client-forge-module";
export const githubForgeView = {
id: "github",
icon: GitHubIcon,
brandColor: null,
} satisfies ClientForgeViewModule;
+155
View File
@@ -0,0 +1,155 @@
import { z } from "zod";
import {
defineForgeFacts,
type ClientForgeLogicModule,
type MergeCapability,
} from "@/git/client-forge-module";
import type { CheckoutPrMergeMethod } from "@getpaseo/protocol/messages";
import type { CheckStatus } from "@/git/pull-request-panel/check-status";
const gitlabLineAnchor = (start: number, end?: number): string =>
end && end > start ? `#L${start}-${end}` : `#L${start}`;
/**
* Canonical set of GitLab pipeline statuses that count as "still active" (a
* pipeline that has not reached a terminal state). Server-side twin:
* packages/server/src/services/gitlab-facts.ts (GITLAB_ACTIVE_PIPELINE_STATUSES),
* duplicated because the app can't depend on the server package.
*/
const GITLAB_ACTIVE_PIPELINE_STATUSES = [
"created",
"waiting_for_resource",
"preparing",
"pending",
"running",
"scheduled",
] as const;
const GITLAB_ACTIVE_PIPELINE_STATUS_SET = new Set<string>(GITLAB_ACTIVE_PIPELINE_STATUSES);
export function isPipelineActiveStatus(status: string): boolean {
return GITLAB_ACTIVE_PIPELINE_STATUS_SET.has(status);
}
export function mapPipelineStatus(status: string): CheckStatus {
switch (status) {
case "success":
case "passed":
return "success";
case "failed":
return "failure";
case "running":
case "pending":
case "created":
case "waiting_for_resource":
case "preparing":
case "scheduled":
case "manual":
return "pending";
case "canceled":
case "cancelled":
case "skipped":
return "skipped";
default:
return "pending";
}
}
const GitlabMergeFactsSchema = z
.object({
forge: z.literal("gitlab"),
detailedMergeStatus: z.string().nullable().optional().default(null),
mergeStatus: z.string().nullable().optional().default(null),
hasConflicts: z.boolean().optional().default(false),
blockingDiscussionsResolved: z.boolean().optional().default(true),
approvalsRequired: z.number().optional().default(0),
approvalsGiven: z.number().optional().default(0),
pipelineStatus: z.string().nullable().optional().default(null),
pipelineId: z.number().nullable().optional().default(null),
pipelineUrl: z.string().nullable().optional().default(null),
mergeWhenPipelineSucceeds: z.boolean().optional().default(false),
})
.passthrough();
export type GitlabMergeFacts = z.infer<typeof GitlabMergeFactsSchema>;
export { GitlabMergeFactsSchema };
const GITLAB_MERGEABLE_STATUS = "mergeable";
const GITLAB_LEGACY_MERGEABLE_STATUS = "can_be_merged";
const GITLAB_MERGE_METHODS: CheckoutPrMergeMethod[] = ["merge", "squash", "rebase"];
/**
* Direct-merge readiness from GitLab's merge signals. `detailedMergeStatus` is
* GitLab 15.6+ only; when it is absent (older self-managed instances) fall back
* to the legacy `mergeStatus === "can_be_merged"` with no conflicts so merge is
* not silently refused everywhere.
*/
function isGitlabDirectMergeReady(gitlab: GitlabMergeFacts): boolean {
if (gitlab.detailedMergeStatus != null) {
return gitlab.detailedMergeStatus === GITLAB_MERGEABLE_STATUS;
}
return gitlab.mergeStatus === GITLAB_LEGACY_MERGEABLE_STATUS && gitlab.hasConflicts !== true;
}
function deriveGitlabMergeCapability(gitlab: GitlabMergeFacts): MergeCapability {
const autoMergeEnabled = gitlab.mergeWhenPipelineSucceeds === true;
const hasActivePipeline =
gitlab.pipelineStatus !== null && isPipelineActiveStatus(gitlab.pipelineStatus);
return {
directMergeReady: isGitlabDirectMergeReady(gitlab),
canEnableAutoMerge: !autoMergeEnabled && hasActivePipeline,
autoMergeEnabled,
canDisableAutoMerge: autoMergeEnabled,
mergeBlockedByQueue: false,
allowedMethods: GITLAB_MERGE_METHODS,
preferredMethod: null,
};
}
export interface GitlabPipelineSummary {
id: number;
status: CheckStatus;
rawStatus: string;
url: string | null;
}
export interface GitlabApprovals {
given: number;
required: number;
}
export function deriveGitlabPipelineSummary(facts: GitlabMergeFacts): GitlabPipelineSummary | null {
if (facts.pipelineId == null) {
return null;
}
const rawStatus = facts.pipelineStatus ?? "";
return {
id: facts.pipelineId,
status: mapPipelineStatus(rawStatus),
rawStatus,
url: facts.pipelineUrl ?? null,
};
}
export function deriveGitlabApprovals(facts: GitlabMergeFacts): GitlabApprovals | null {
const required = facts.approvalsRequired ?? 0;
if (required <= 0) {
return null;
}
return { given: facts.approvalsGiven ?? 0, required };
}
export const gitlabForgeLogic = {
id: "gitlab",
urlGrammar: {
treeInfix: "/-/tree/",
blobInfix: "/-/blob/",
lineAnchor: gitlabLineAnchor,
},
facts: defineForgeFacts({
family: "gitlab",
schema: GitlabMergeFactsSchema,
deriveMergeCapability: deriveGitlabMergeCapability,
}),
} satisfies ClientForgeLogicModule<GitlabMergeFacts>;
+328
View File
@@ -0,0 +1,328 @@
import React, { useCallback, useMemo, type ReactNode } from "react";
import { Pressable, Text, View } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { CircleCheck, ExternalLink } from "lucide-react-native";
import { useTranslation } from "react-i18next";
import type { CheckoutPipelineJob, CheckoutPipelineStage } from "@getpaseo/protocol/messages";
import { GitLabIcon } from "@/components/icons/gitlab-icon";
import {
definePaneContribution,
type ClientForgeViewModule,
type PaneChecksSlotContext,
} from "@/git/client-forge-module";
import { openExternalUrl } from "@/utils/open-external-url";
import { formatDuration } from "@/utils/time";
import {
CheckStatusIcon,
Section,
SUMMARY_DANGER_ICON,
SUMMARY_SUCCESS_ICON,
SUMMARY_WARNING_ICON,
SummaryPill,
foregroundMutedColorMapping,
sectionKitStyles,
successColorMapping,
} from "@/git/pull-request-panel/section-kit";
import { useGitLabPipeline } from "@/git/pull-request-panel/use-pipeline";
import {
deriveGitlabApprovals,
deriveGitlabPipelineSummary,
GitlabMergeFactsSchema,
isPipelineActiveStatus,
mapPipelineStatus,
type GitlabApprovals,
type GitlabMergeFacts,
type GitlabPipelineSummary,
} from "./gitlab";
function renderGitlabHeaderMeta(facts: GitlabMergeFacts): ReactNode {
const approvals = deriveGitlabApprovals(facts);
if (!approvals) {
return null;
}
return <GitlabApprovalsBadge approvals={approvals} />;
}
function renderGitlabChecksSection(facts: GitlabMergeFacts, ctx: PaneChecksSlotContext): ReactNode {
if (!ctx.enabled) {
return null;
}
const summary = deriveGitlabPipelineSummary(facts);
if (!summary) {
return null;
}
return (
<GitLabPipelineSection
serverId={ctx.serverId}
cwd={ctx.cwd}
changeRequestNumber={ctx.changeRequestNumber}
summary={summary}
open={ctx.open}
onToggle={ctx.onToggle}
canFetchCheckDetails={ctx.canFetchCheckDetails}
/>
);
}
const ThemedCircleCheck = withUnistyles(CircleCheck);
const ThemedExternalLink = withUnistyles(ExternalLink);
function GitlabApprovalsBadge({ approvals }: { approvals: GitlabApprovals }) {
const { t } = useTranslation();
return (
<View style={styles.approvalsBadge} testID="pr-pane-approvals">
<ThemedCircleCheck
size={11}
uniProps={
approvals.given >= approvals.required ? successColorMapping : foregroundMutedColorMapping
}
/>
<Text style={styles.approvalsText}>
{t("workspace.git.pr.approvals", { given: approvals.given, required: approvals.required })}
</Text>
</View>
);
}
function rowPressableStyle({ hovered }: { hovered?: boolean }) {
return [sectionKitStyles.checkRow, Boolean(hovered) && styles.hoverable];
}
function jobRowPressableStyle({ hovered }: { hovered?: boolean }) {
return [styles.pipelineJobRow, Boolean(hovered) && styles.hoverable];
}
function formatPipelineDuration(seconds: number | null): string {
if (seconds === null || seconds <= 0) {
return "";
}
return formatDuration(seconds * 1000);
}
interface PipelineJobCounts {
passed: number;
failed: number;
pending: number;
}
function countPipelineJobs(jobs: CheckoutPipelineJob[]): PipelineJobCounts {
const counts: PipelineJobCounts = { passed: 0, failed: 0, pending: 0 };
for (const job of jobs) {
const status = mapPipelineStatus(job.status);
if (status === "success") counts.passed += 1;
else if (status === "failure") counts.failed += 1;
else if (status === "pending") counts.pending += 1;
}
return counts;
}
function GitLabPipelineSection({
serverId,
cwd,
changeRequestNumber,
summary,
open,
onToggle,
canFetchCheckDetails,
}: {
serverId: string;
cwd: string;
changeRequestNumber: number;
summary: GitlabPipelineSummary;
open: boolean;
onToggle: () => void;
canFetchCheckDetails: boolean;
}) {
const { t } = useTranslation();
const { pipeline, isLoading, isPlaceholderData, error } = useGitLabPipeline({
serverId,
cwd,
pipelineId: summary.id,
changeRequestNumber,
enabled: open && canFetchCheckDetails,
live: isPipelineActiveStatus(summary.rawStatus),
});
const counts = useMemo(
() => countPipelineJobs((pipeline?.stages ?? []).flatMap((stage) => stage.jobs)),
[pipeline],
);
const totalCounted = counts.passed + counts.failed + counts.pending;
const showBreakdown = !isPlaceholderData && totalCounted > 0;
const displayCounts = showBreakdown ? counts : { passed: 0, failed: 0, pending: 0 };
const handleOpenPipeline = useCallback(() => {
if (summary.url) {
void openExternalUrl(summary.url);
}
}, [summary.url]);
const sectionSummary = (
<>
<SummaryPill
count={displayCounts.passed}
icon={SUMMARY_SUCCESS_ICON}
variant="success"
testID="pr-pane-pipeline-passed"
/>
<SummaryPill
count={displayCounts.failed}
icon={SUMMARY_DANGER_ICON}
variant="danger"
testID="pr-pane-pipeline-failed"
/>
<SummaryPill
count={displayCounts.pending}
icon={SUMMARY_WARNING_ICON}
variant="warning"
testID="pr-pane-pipeline-pending"
/>
{showBreakdown ? null : <CheckStatusIcon status={summary.status} />}
</>
);
return (
<Section
title={t("workspace.git.pr.sections.pipeline")}
open={open}
onToggle={onToggle}
summary={sectionSummary}
>
<Pressable
onPress={handleOpenPipeline}
style={rowPressableStyle}
disabled={!summary.url}
testID="pr-pane-pipeline-link"
>
<CheckStatusIcon status={summary.status} />
<Text style={sectionKitStyles.checkName} numberOfLines={1}>
{`Pipeline #${summary.id}`}
</Text>
{summary.rawStatus ? (
<Text style={sectionKitStyles.checkWorkflow} numberOfLines={1}>
{summary.rawStatus}
</Text>
) : null}
{summary.url ? (
<View style={sectionKitStyles.checkTrailing}>
<ThemedExternalLink size={12} uniProps={foregroundMutedColorMapping} />
</View>
) : null}
</Pressable>
{isLoading ? (
<Text style={sectionKitStyles.emptyText}>
{t("workspace.git.pr.empty.loadingPipeline")}
</Text>
) : null}
{!isLoading && pipeline && pipeline.stages.length === 0 ? (
<Text style={sectionKitStyles.emptyText}>{t("workspace.git.pr.empty.noJobs")}</Text>
) : null}
{!isLoading && pipeline && pipeline.stages.length > 0
? pipeline.stages.map((stage) => <PipelineStageGroup key={stage.name} stage={stage} />)
: null}
{!isLoading && !pipeline && error ? (
<Text style={sectionKitStyles.emptyText}>
{t("workspace.git.pr.empty.pipelineJobsLoadFailed")}
</Text>
) : null}
</Section>
);
}
function PipelineStageGroup({ stage }: { stage: CheckoutPipelineStage }) {
return (
<View>
<View style={styles.pipelineStageHeader}>
<CheckStatusIcon status={mapPipelineStatus(stage.status)} />
<Text style={styles.pipelineStageName} numberOfLines={1}>
{stage.name}
</Text>
</View>
{stage.jobs.map((job) => (
<PipelineJobRow key={job.id} job={job} />
))}
</View>
);
}
function PipelineJobRow({ job }: { job: CheckoutPipelineJob }) {
const { t } = useTranslation();
const handlePress = useCallback(() => {
if (job.url) {
void openExternalUrl(job.url);
}
}, [job.url]);
const duration = formatPipelineDuration(job.durationSeconds);
return (
<Pressable onPress={handlePress} style={jobRowPressableStyle} disabled={!job.url}>
<CheckStatusIcon status={mapPipelineStatus(job.status)} />
<Text style={sectionKitStyles.checkName} numberOfLines={1}>
{job.name}
</Text>
{job.allowFailure ? (
<Text style={sectionKitStyles.checkWorkflow} numberOfLines={1}>
{t("workspace.git.pr.empty.allowedToFail")}
</Text>
) : null}
<View style={sectionKitStyles.checkTrailing}>
{duration ? <Text style={sectionKitStyles.checkDuration}>{duration}</Text> : null}
</View>
</Pressable>
);
}
export const gitlabForgeView = {
id: "gitlab",
icon: GitLabIcon,
brandColor: {
light: "#FC6D26",
dark: "#FC6D26",
},
paneContributions: [
definePaneContribution(GitlabMergeFactsSchema, {
renderHeaderMeta: renderGitlabHeaderMeta,
renderChecksSection: renderGitlabChecksSection,
}),
],
} satisfies ClientForgeViewModule;
const styles = StyleSheet.create((theme) => ({
approvalsBadge: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
},
approvalsText: {
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
},
hoverable: {
backgroundColor: theme.colors.surface1,
},
pipelineStageHeader: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
paddingTop: theme.spacing[2],
paddingBottom: theme.spacing[1],
},
pipelineStageName: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.medium,
color: theme.colors.foregroundMuted,
textTransform: "uppercase",
letterSpacing: 0.5,
flexShrink: 1,
},
pipelineJobRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
paddingVertical: theme.spacing[2],
paddingRight: theme.spacing[3],
paddingLeft: theme.spacing[6],
minHeight: 32,
},
}));
+59
View File
@@ -0,0 +1,59 @@
import { readdirSync } from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { CLIENT_FORGE_LOGIC_MODULES } from "./index";
import { CLIENT_FORGE_VIEW_MODULES } from "./view";
/**
* Adding a forge is "create forges/<id>.ts (logic) + forges/<id>.view.tsx (view)
* + register one line in each registry". This test removes the only failure mode
* of the explicit registries — forgetting a line — without resorting to
* bundler-specific auto-discovery that Metro, Vite, and tsgo would each need to
* understand differently. The logic/view split keeps logic consumers (URL
* builders, merge-capability, native checks, and the Node e2e harness) free of
* the client rendering stack.
*/
const forgesDir = path.dirname(new URL(import.meta.url).pathname);
const dirEntries = readdirSync(forgesDir);
const logicModuleIds = dirEntries
.filter((file) => /^[a-z0-9-]+\.ts$/.test(file) && file !== "index.ts" && file !== "view.ts")
.map((file) => file.replace(/\.ts$/, ""))
.sort();
const viewModuleIds = dirEntries
.filter((file) => /^[a-z0-9-]+\.view\.tsx$/.test(file))
.map((file) => file.replace(/\.view\.tsx$/, ""))
.sort();
describe("CLIENT_FORGE_LOGIC_MODULES completeness", () => {
it("registers every forge logic module file in the directory", () => {
const registeredIds = CLIENT_FORGE_LOGIC_MODULES.map((module) => module.id).sort();
expect(registeredIds).toEqual(logicModuleIds);
});
it("has no duplicate forge ids", () => {
const ids = CLIENT_FORGE_LOGIC_MODULES.map((module) => module.id);
expect(new Set(ids).size).toBe(ids.length);
});
});
describe("CLIENT_FORGE_VIEW_MODULES completeness", () => {
it("registers every forge view module file in the directory", () => {
const registeredIds = CLIENT_FORGE_VIEW_MODULES.map((module) => module.id).sort();
expect(registeredIds).toEqual(viewModuleIds);
});
it("has no duplicate forge ids", () => {
const ids = CLIENT_FORGE_VIEW_MODULES.map((module) => module.id);
expect(new Set(ids).size).toBe(ids.length);
});
});
describe("forge logic and view registries stay paired", () => {
it("has a view module for every logic module and vice versa", () => {
const logicIds = CLIENT_FORGE_LOGIC_MODULES.map((module) => module.id).sort();
const viewIds = CLIENT_FORGE_VIEW_MODULES.map((module) => module.id).sort();
expect(logicIds).toEqual(viewIds);
});
});
+34
View File
@@ -0,0 +1,34 @@
import type { ClientForgeLogicModule, ForgeSpecificEnvelope } from "@/git/client-forge-module";
import { codebergForgeLogic } from "./codeberg";
import { forgejoForgeLogic } from "./forgejo";
import { giteaForgeLogic } from "./gitea";
import { githubForgeLogic } from "./github";
import { gitlabForgeLogic } from "./gitlab";
/**
* Pure logic registry. Import this (never the view registry) from URL builders,
* merge-capability, and native-check derivations so those paths — and the
* Node-based e2e harness that transitively imports them — stay free of the
* client rendering stack (react-native, react-native-svg, unistyles).
*/
export const CLIENT_FORGE_LOGIC_MODULES: readonly ClientForgeLogicModule[] = [
githubForgeLogic,
gitlabForgeLogic,
giteaForgeLogic,
forgejoForgeLogic,
codebergForgeLogic,
];
export function getClientForgeLogicModule(id: string): ClientForgeLogicModule | null {
return CLIENT_FORGE_LOGIC_MODULES.find((module) => module.id === id) ?? null;
}
export function parseClientForgeFacts(facts: unknown): ForgeSpecificEnvelope | null {
for (const module of CLIENT_FORGE_LOGIC_MODULES) {
const parsed = module.facts?.parse(facts);
if (parsed) {
return parsed;
}
}
return null;
}
+23
View File
@@ -0,0 +1,23 @@
import type { ClientForgeViewModule } from "@/git/client-forge-module";
import { codebergForgeView } from "./codeberg.view";
import { forgejoForgeView } from "./forgejo.view";
import { giteaForgeView } from "./gitea.view";
import { githubForgeView } from "./github.view";
import { gitlabForgeView } from "./gitlab.view";
/**
* View registry: brand marks, brand colors, and PR-pane render contributions.
* Import this only from rendering code (icon lookup, the PR pane); it pulls the
* client rendering stack, so logic paths must use the logic registry instead.
*/
export const CLIENT_FORGE_VIEW_MODULES: readonly ClientForgeViewModule[] = [
githubForgeView,
gitlabForgeView,
giteaForgeView,
forgejoForgeView,
codebergForgeView,
];
export function getClientForgeViewModule(id: string): ClientForgeViewModule | null {
return CLIENT_FORGE_VIEW_MODULES.find((module) => module.id === id) ?? null;
}
-132
View File
@@ -1,132 +0,0 @@
import { describe, expect, it } from "vitest";
import {
buildGitHubBlobUrl,
buildGitHubBranchTreeUrl,
parseGitHubRepoFromRemote,
} from "./github-url";
describe("parseGitHubRepoFromRemote", () => {
it.each([
["https://github.com/acme/repo.git", "acme/repo"],
["https://github.com/acme/repo", "acme/repo"],
["http://github.com/acme/repo.git", "acme/repo"],
["git@github.com:acme/repo.git", "acme/repo"],
["ssh://git@github.com/acme/repo.git", "acme/repo"],
["ssh://git@ssh.github.com/acme/repo.git", "acme/repo"],
["https://github.com/acme/repo/", "acme/repo"],
])("extracts the repo from %s", (remoteUrl, expected) => {
expect(parseGitHubRepoFromRemote(remoteUrl)).toBe(expected);
});
it("returns null for non-GitHub remotes", () => {
expect(parseGitHubRepoFromRemote("git@gitlab.com:acme/repo.git")).toBeNull();
});
it("returns null for invalid URLs", () => {
expect(parseGitHubRepoFromRemote("not a url")).toBeNull();
});
});
describe("buildGitHubBranchTreeUrl", () => {
it("builds a branch-specific GitHub tree URL", () => {
expect(
buildGitHubBranchTreeUrl({
remoteUrl: "git@github.com:acme/repo.git",
branch: "feature/workspace-button",
}),
).toBe("https://github.com/acme/repo/tree/feature/workspace-button");
});
it("encodes reserved branch characters while preserving slash-separated branch names", () => {
expect(
buildGitHubBranchTreeUrl({
remoteUrl: "https://github.com/acme/repo.git",
branch: "feature/ship #42",
}),
).toBe("https://github.com/acme/repo/tree/feature/ship%20%2342");
});
it("returns null when the current branch is unavailable", () => {
expect(
buildGitHubBranchTreeUrl({
remoteUrl: "https://github.com/acme/repo.git",
branch: "HEAD",
}),
).toBeNull();
});
});
describe("buildGitHubBlobUrl", () => {
it("builds a blob URL for a file path", () => {
expect(
buildGitHubBlobUrl({
remoteUrl: "git@github.com:acme/repo.git",
branch: "main",
path: "src/index.ts",
}),
).toBe("https://github.com/acme/repo/blob/main/src/index.ts");
});
it("appends a single-line anchor", () => {
expect(
buildGitHubBlobUrl({
remoteUrl: "https://github.com/acme/repo.git",
branch: "main",
path: "src/index.ts",
lineStart: 12,
}),
).toBe("https://github.com/acme/repo/blob/main/src/index.ts#L12");
});
it("appends a line range anchor", () => {
expect(
buildGitHubBlobUrl({
remoteUrl: "https://github.com/acme/repo.git",
branch: "main",
path: "src/index.ts",
lineStart: 12,
lineEnd: 20,
}),
).toBe("https://github.com/acme/repo/blob/main/src/index.ts#L12-L20");
});
it("strips leading slashes and encodes path segments", () => {
expect(
buildGitHubBlobUrl({
remoteUrl: "https://github.com/acme/repo.git",
branch: "main",
path: "/src/a b/c#d.ts",
}),
).toBe("https://github.com/acme/repo/blob/main/src/a%20b/c%23d.ts");
});
it("normalizes harmless dot segments in the blob path", () => {
expect(
buildGitHubBlobUrl({
remoteUrl: "https://github.com/acme/repo.git",
branch: "main",
path: "./src/../index.ts",
}),
).toBe("https://github.com/acme/repo/blob/main/index.ts");
});
it("returns null for blob paths that escape above the repo root", () => {
expect(
buildGitHubBlobUrl({
remoteUrl: "https://github.com/acme/repo.git",
branch: "main",
path: "../outside.ts",
}),
).toBeNull();
});
it("returns null when the path is missing", () => {
expect(
buildGitHubBlobUrl({
remoteUrl: "https://github.com/acme/repo.git",
branch: "main",
path: "",
}),
).toBeNull();
});
});
-69
View File
@@ -1,69 +0,0 @@
import { parseGitHubRemoteUrl } from "@getpaseo/protocol/git-remote";
export function parseGitHubRepoFromRemote(remoteUrl: string | null | undefined): string | null {
const trimmed = remoteUrl?.trim();
if (!trimmed) {
return null;
}
return parseGitHubRemoteUrl(trimmed)?.repo ?? null;
}
export function buildGitHubBranchTreeUrl(input: {
remoteUrl: string | null | undefined;
branch: string | null | undefined;
}): string | null {
const repo = parseGitHubRepoFromRemote(input.remoteUrl);
const branch = input.branch?.trim();
if (!repo || !branch || branch === "HEAD") {
return null;
}
const encodedBranch = branch.split("/").map(encodeURIComponent).join("/");
return `https://github.com/${repo}/tree/${encodedBranch}`;
}
function normalizeGitHubBlobPath(path: string | null | undefined): string | null {
const segments: string[] = [];
const trimmed = path?.trim().replace(/\\/g, "/").replace(/^\/+/, "");
if (!trimmed) {
return null;
}
for (const segment of trimmed.split("/")) {
if (!segment || segment === ".") {
continue;
}
if (segment === "..") {
if (segments.length === 0) {
return null;
}
segments.pop();
continue;
}
segments.push(segment);
}
return segments.join("/") || null;
}
export function buildGitHubBlobUrl(input: {
remoteUrl: string | null | undefined;
branch: string | null | undefined;
path: string | null | undefined;
lineStart?: number;
lineEnd?: number;
}): string | null {
const repo = parseGitHubRepoFromRemote(input.remoteUrl);
const branch = input.branch?.trim();
const filePath = normalizeGitHubBlobPath(input.path);
if (!repo || !branch || branch === "HEAD" || !filePath) {
return null;
}
const encodedBranch = branch.split("/").map(encodeURIComponent).join("/");
const encodedPath = filePath.split("/").map(encodeURIComponent).join("/");
let url = `https://github.com/${repo}/blob/${encodedBranch}/${encodedPath}`;
if (input.lineStart && input.lineStart > 0) {
url += `#L${input.lineStart}`;
if (input.lineEnd && input.lineEnd > input.lineStart) {
url += `-L${input.lineEnd}`;
}
}
return url;
}
@@ -0,0 +1,300 @@
import { describe, expect, it } from "vitest";
import { deriveMergeCapability, type ForgeSpecificStatusFacts } from "./merge-capability";
type GithubMergeFactsFixture = ForgeSpecificStatusFacts & {
forge: "github";
mergeStateStatus: string | null;
autoMergeRequest: {
enabledAt: string | null;
mergeMethod: string | null;
enabledBy: string | null;
} | null;
viewerCanEnableAutoMerge: boolean;
viewerCanDisableAutoMerge: boolean;
viewerCanMergeAsAdmin: boolean;
viewerCanUpdateBranch: boolean;
repository: {
autoMergeAllowed: boolean;
mergeCommitAllowed: boolean;
squashMergeAllowed: boolean;
rebaseMergeAllowed: boolean;
viewerDefaultMergeMethod: string | null;
};
isMergeQueueEnabled: boolean;
isInMergeQueue: boolean;
};
type GitlabMergeFactsFixture = ForgeSpecificStatusFacts & {
forge: "gitlab";
detailedMergeStatus: string | null;
hasConflicts: boolean;
blockingDiscussionsResolved: boolean;
approvalsRequired: number;
approvalsGiven: number;
pipelineStatus: string | null;
pipelineId: number | null;
pipelineUrl: string | null;
mergeWhenPipelineSucceeds: boolean;
};
type GiteaMergeFactsFixture = ForgeSpecificStatusFacts & {
forge: "gitea";
mergeable: boolean;
hasMerged: boolean;
ciStatus: string | null;
};
function facts(overrides: Partial<GithubMergeFactsFixture> = {}): GithubMergeFactsFixture {
return {
forge: "github",
mergeStateStatus: "CLEAN",
autoMergeRequest: null,
viewerCanEnableAutoMerge: false,
viewerCanDisableAutoMerge: false,
viewerCanMergeAsAdmin: false,
viewerCanUpdateBranch: false,
repository: {
autoMergeAllowed: false,
mergeCommitAllowed: true,
squashMergeAllowed: true,
rebaseMergeAllowed: true,
viewerDefaultMergeMethod: "SQUASH",
},
isMergeQueueEnabled: false,
isInMergeQueue: false,
...overrides,
};
}
function gitlabFacts(overrides: Partial<GitlabMergeFactsFixture> = {}): GitlabMergeFactsFixture {
return {
forge: "gitlab",
detailedMergeStatus: "mergeable",
hasConflicts: false,
blockingDiscussionsResolved: true,
approvalsRequired: 0,
approvalsGiven: 0,
pipelineStatus: "success",
pipelineId: null,
pipelineUrl: null,
mergeWhenPipelineSucceeds: false,
...overrides,
};
}
function giteaFacts(overrides: Partial<GiteaMergeFactsFixture> = {}): GiteaMergeFactsFixture {
return {
forge: "gitea",
mergeable: true,
hasMerged: false,
ciStatus: "success",
...overrides,
};
}
describe("deriveMergeCapability", () => {
it("returns null when the forge supplied no merge facts", () => {
expect(deriveMergeCapability(null)).toBeNull();
expect(deriveMergeCapability(undefined)).toBeNull();
});
it("rejects untagged and schema-mismatched forge facts at the registry boundary", () => {
expect(deriveMergeCapability({ approvalsRequired: 2 })).toBeNull();
expect(
deriveMergeCapability({
...gitlabFacts(),
approvalsRequired: "two",
}),
).toBeNull();
});
it("marks direct merge ready only for the GitHub clean states", () => {
expect(deriveMergeCapability(facts({ mergeStateStatus: "CLEAN" }))?.directMergeReady).toBe(
true,
);
expect(deriveMergeCapability(facts({ mergeStateStatus: "HAS_HOOKS" }))?.directMergeReady).toBe(
true,
);
expect(deriveMergeCapability(facts({ mergeStateStatus: "BLOCKED" }))?.directMergeReady).toBe(
false,
);
expect(deriveMergeCapability(facts({ mergeStateStatus: null }))?.directMergeReady).toBe(false);
});
it("can enable auto-merge only when blocked, allowed, and the viewer may enable it", () => {
const ready = facts({
mergeStateStatus: "BLOCKED",
viewerCanEnableAutoMerge: true,
repository: {
autoMergeAllowed: true,
mergeCommitAllowed: true,
squashMergeAllowed: true,
rebaseMergeAllowed: true,
viewerDefaultMergeMethod: "SQUASH",
},
});
expect(deriveMergeCapability(ready)?.canEnableAutoMerge).toBe(true);
expect(
deriveMergeCapability(facts({ ...ready, viewerCanEnableAutoMerge: false }))
?.canEnableAutoMerge,
).toBe(false);
expect(
deriveMergeCapability(facts({ ...ready, mergeStateStatus: "CLEAN" }))?.canEnableAutoMerge,
).toBe(false);
});
it("reports whether auto-merge is already enabled and can be disabled", () => {
const cap = deriveMergeCapability(
facts({
autoMergeRequest: { enabledAt: "now", mergeMethod: "SQUASH", enabledBy: "octocat" },
viewerCanDisableAutoMerge: true,
}),
);
expect(cap?.autoMergeEnabled).toBe(true);
expect(cap?.canDisableAutoMerge).toBe(true);
expect(deriveMergeCapability(facts())?.autoMergeEnabled).toBe(false);
});
it("treats an enabled or in-progress merge queue as blocking", () => {
expect(deriveMergeCapability(facts({ isMergeQueueEnabled: true }))?.mergeBlockedByQueue).toBe(
true,
);
expect(deriveMergeCapability(facts({ isInMergeQueue: true }))?.mergeBlockedByQueue).toBe(true);
expect(deriveMergeCapability(facts())?.mergeBlockedByQueue).toBe(false);
});
it("derives allowed methods and the preferred method from the repository policy", () => {
const cap = deriveMergeCapability(
facts({
repository: {
autoMergeAllowed: false,
mergeCommitAllowed: false,
squashMergeAllowed: true,
rebaseMergeAllowed: true,
viewerDefaultMergeMethod: "REBASE",
},
}),
);
expect(cap?.allowedMethods).toEqual(["squash", "rebase"]);
expect(cap?.preferredMethod).toBe("rebase");
});
it("returns a null preferred method when the forge reports an unknown default", () => {
expect(
deriveMergeCapability(
facts({ repository: { ...facts().repository, viewerDefaultMergeMethod: null } }),
)?.preferredMethod,
).toBeNull();
});
});
describe("deriveMergeCapability (legacy github fallback)", () => {
it("synthesizes full GitHub capability from legacy status.github when forgeSpecific is absent", () => {
const { forge: _forge, ...legacy } = facts({
mergeStateStatus: "CLEAN",
repository: {
autoMergeAllowed: false,
mergeCommitAllowed: false,
squashMergeAllowed: true,
rebaseMergeAllowed: false,
viewerDefaultMergeMethod: "SQUASH",
},
});
const cap = deriveMergeCapability(undefined, legacy);
expect(cap).not.toBeNull();
expect(cap?.directMergeReady).toBe(true);
expect(cap?.allowedMethods).toEqual(["squash"]);
expect(cap?.preferredMethod).toBe("squash");
});
it("returns null when both forgeSpecific and legacy github facts are absent", () => {
expect(deriveMergeCapability(undefined, null)).toBeNull();
expect(deriveMergeCapability(undefined, undefined)).toBeNull();
});
});
describe("deriveMergeCapability (gitlab)", () => {
it("produces a non-null capability for the gitlab arm", () => {
expect(deriveMergeCapability(gitlabFacts())).not.toBeNull();
});
it("marks direct merge ready only when GitLab reports the mergeable status", () => {
expect(
deriveMergeCapability(gitlabFacts({ detailedMergeStatus: "mergeable" }))?.directMergeReady,
).toBe(true);
expect(
deriveMergeCapability(gitlabFacts({ detailedMergeStatus: "ci_still_running" }))
?.directMergeReady,
).toBe(false);
expect(
deriveMergeCapability(gitlabFacts({ detailedMergeStatus: "discussions_not_resolved" }))
?.directMergeReady,
).toBe(false);
expect(
deriveMergeCapability(gitlabFacts({ detailedMergeStatus: null }))?.directMergeReady,
).toBe(false);
});
it("reflects merge-when-pipeline-succeeds as an enabled auto-merge", () => {
const enabled = deriveMergeCapability(
gitlabFacts({ mergeWhenPipelineSucceeds: true, pipelineStatus: "running" }),
);
expect(enabled?.autoMergeEnabled).toBe(true);
expect(enabled?.canDisableAutoMerge).toBe(true);
expect(enabled?.canEnableAutoMerge).toBe(false);
expect(deriveMergeCapability(gitlabFacts())?.autoMergeEnabled).toBe(false);
});
it("can enable auto-merge only while a pipeline is still in flight", () => {
expect(
deriveMergeCapability(gitlabFacts({ pipelineStatus: "created" }))?.canEnableAutoMerge,
).toBe(true);
expect(
deriveMergeCapability(gitlabFacts({ pipelineStatus: "waiting_for_resource" }))
?.canEnableAutoMerge,
).toBe(true);
expect(
deriveMergeCapability(gitlabFacts({ pipelineStatus: "preparing" }))?.canEnableAutoMerge,
).toBe(true);
expect(
deriveMergeCapability(gitlabFacts({ pipelineStatus: "pending" }))?.canEnableAutoMerge,
).toBe(true);
expect(
deriveMergeCapability(gitlabFacts({ pipelineStatus: "running" }))?.canEnableAutoMerge,
).toBe(true);
expect(
deriveMergeCapability(gitlabFacts({ pipelineStatus: "scheduled" }))?.canEnableAutoMerge,
).toBe(true);
expect(
deriveMergeCapability(gitlabFacts({ pipelineStatus: "success" }))?.canEnableAutoMerge,
).toBe(false);
expect(deriveMergeCapability(gitlabFacts({ pipelineStatus: null }))?.canEnableAutoMerge).toBe(
false,
);
});
it("offers GitLab merge methods and never reports a merge queue", () => {
const cap = deriveMergeCapability(gitlabFacts());
expect(cap?.allowedMethods).toEqual(["merge", "squash", "rebase"]);
expect(cap?.mergeBlockedByQueue).toBe(false);
expect(cap?.canEnableAutoMerge).toBe(false);
});
});
describe("deriveMergeCapability (gitea)", () => {
it("allows direct merge only when the pull request is mergeable and unmerged", () => {
expect(deriveMergeCapability(giteaFacts())?.directMergeReady).toBe(true);
expect(deriveMergeCapability(giteaFacts({ mergeable: false }))?.directMergeReady).toBe(false);
expect(deriveMergeCapability(giteaFacts({ hasMerged: true }))?.directMergeReady).toBe(false);
});
it("offers direct merge styles without auto-merge", () => {
const capability = deriveMergeCapability(giteaFacts());
expect(capability?.allowedMethods).toEqual(["merge", "squash", "rebase"]);
expect(capability?.canEnableAutoMerge).toBe(false);
expect(capability?.autoMergeEnabled).toBe(false);
expect(capability?.canDisableAutoMerge).toBe(false);
});
});
+51
View File
@@ -0,0 +1,51 @@
import {
type ForgeSpecificEnvelope,
type LegacyGithubMergeFacts,
type MergeCapability,
} from "@/git/client-forge-module";
import { CLIENT_FORGE_LOGIC_MODULES } from "@/git/forges";
export type ForgeSpecificStatusFacts = ForgeSpecificEnvelope;
export type { LegacyGithubMergeFacts, MergeCapability };
/**
* Resolve the neutral merge capability from a registered forge's runtime facts.
* Returns null for an unregistered, unknown, or schema-mismatched facts family;
* callers then fall back to raw git state.
*/
function deriveForgeMergeCapability(facts: unknown): MergeCapability | null {
for (const module of CLIENT_FORGE_LOGIC_MODULES) {
const capability = module.facts?.deriveMergeCapability(facts);
if (capability) {
return capability;
}
}
return null;
}
/**
* Build the neutral merge capability from a forge's PR status facts. Returns
* null when the forge supplied no merge facts (e.g. a host that exposes none, or
* an unknown forge), in which case the caller falls back to raw git state.
* Per-forge derivation lives on client forge modules; this stays the single
* neutral entry point the action policy reads.
*
* COMPAT(forgeSpecific): added in v0.1.106, remove after 2026-12-27. A daemon
* predating forgeSpecific emits only the legacy `status.github` field. When
* forgeSpecific is absent we synthesize the github arm from those legacy facts
* so GitHub merge-method restrictions and auto-merge state are not lost against
* an old daemon.
*/
export function deriveMergeCapability(
forgeSpecific: unknown,
legacyGithubFacts?: LegacyGithubMergeFacts | null,
): MergeCapability | null {
if (forgeSpecific === null || forgeSpecific === undefined) {
if (legacyGithubFacts) {
return deriveForgeMergeCapability({ forge: "github", ...legacyGithubFacts });
}
return null;
}
return deriveForgeMergeCapability(forgeSpecific);
}
+98 -8
View File
@@ -3,11 +3,34 @@ import { CheckoutPrStatusSchema } from "@getpaseo/protocol/messages";
import { i18n } from "@/i18n/i18next";
import { buildGitActions, type BuildGitActionsInput } from "./policy";
import { deriveMergeCapability, type ForgeSpecificStatusFacts } from "./merge-capability";
function githubStatus(
overrides: Partial<NonNullable<BuildGitActionsInput["pullRequestGithub"]>> = {},
): NonNullable<BuildGitActionsInput["pullRequestGithub"]> {
type GithubMergeFactsFixture = ForgeSpecificStatusFacts & {
forge: "github";
mergeStateStatus: string | null;
autoMergeRequest: {
enabledAt: string | null;
mergeMethod: string | null;
enabledBy: string | null;
} | null;
viewerCanEnableAutoMerge: boolean;
viewerCanDisableAutoMerge: boolean;
viewerCanMergeAsAdmin: boolean;
viewerCanUpdateBranch: boolean;
repository: {
autoMergeAllowed: boolean;
mergeCommitAllowed: boolean;
squashMergeAllowed: boolean;
rebaseMergeAllowed: boolean;
viewerDefaultMergeMethod: string | null;
};
isMergeQueueEnabled: boolean;
isInMergeQueue: boolean;
};
function githubStatus(overrides: Partial<GithubMergeFactsFixture> = {}): GithubMergeFactsFixture {
return {
forge: "github",
mergeStateStatus: "CLEAN",
autoMergeRequest: null,
viewerCanEnableAutoMerge: false,
@@ -27,10 +50,17 @@ function githubStatus(
};
}
function createInput(overrides: Partial<BuildGitActionsInput> = {}): BuildGitActionsInput {
function createInput(
overrides: Partial<Omit<BuildGitActionsInput, "mergeCapability">> & {
pullRequestGithub?: unknown;
} = {},
): BuildGitActionsInput {
const { pullRequestGithub = null, ...rest } = overrides;
return {
isGit: true,
githubFeaturesEnabled: true,
forgeBrandLabel: "GitHub",
forgeChangeRequestNoun: "PR",
githubAutoMergeActionsEnabled: true,
hasPullRequest: false,
pullRequestUrl: null,
@@ -38,7 +68,7 @@ function createInput(overrides: Partial<BuildGitActionsInput> = {}): BuildGitAct
pullRequestIsDraft: false,
pullRequestIsMerged: false,
pullRequestMergeable: "UNKNOWN",
pullRequestGithub: null,
mergeCapability: deriveMergeCapability(pullRequestGithub),
hasRemote: false,
isPaseoOwnedWorktree: false,
isOnBaseBranch: true,
@@ -128,7 +158,7 @@ function createInput(overrides: Partial<BuildGitActionsInput> = {}): BuildGitAct
handler: () => undefined,
},
},
...overrides,
...rest,
};
}
@@ -344,6 +374,28 @@ describe("git-actions-policy", () => {
});
});
it("explains why pull-and-push is unavailable when there is nothing to pull first", () => {
const actions = buildGitActions(
createInput({ hasRemote: true, aheadOfOrigin: 1, behindOfOrigin: 0 }),
);
const action = actions.secondary.find((entry) => entry.id === "pull-and-push");
expect(action?.unavailableMessage).toBe(
"Pull and push isn't available because there are no incoming changes to pull first",
);
});
it("explains why pull-and-push is unavailable when there is nothing to push after pulling", () => {
const actions = buildGitActions(
createInput({ hasRemote: true, aheadOfOrigin: 0, behindOfOrigin: 1 }),
);
const action = actions.secondary.find((entry) => entry.id === "pull-and-push");
expect(action?.unavailableMessage).toBe(
"Pull and push isn't available because there is nothing new to send after pulling",
);
});
it("explains why pull-and-push is unavailable when there are uncommitted changes", () => {
const actions = buildGitActions(
createInput({
@@ -507,6 +559,44 @@ describe("git-actions-policy", () => {
});
});
it("uses the forge change-request noun in unavailable no-forge copy", () => {
const createActions = buildGitActions(
createInput({
githubFeaturesEnabled: false,
forgeBrandLabel: "GitLab",
forgeChangeRequestNoun: "MR",
hasRemote: true,
isOnBaseBranch: false,
aheadCount: 1,
}),
);
const viewActions = buildGitActions(
createInput({
githubFeaturesEnabled: false,
forgeBrandLabel: "GitLab",
forgeChangeRequestNoun: "MR",
hasRemote: true,
isOnBaseBranch: false,
hasPullRequest: true,
pullRequestUrl: "https://gitlab.com/example/repo/-/merge_requests/1",
}),
);
const createPrAction = [...createActions.secondary, ...createActions.menu].find(
(action) => action.id === "pr",
);
const viewPrAction = [viewActions.primary, ...viewActions.secondary, ...viewActions.menu].find(
(action) => action?.id === "pr",
);
expect(createPrAction?.unavailableMessage).toBe(
"Create MR isn't available right now because GitLab isn't connected",
);
expect(viewPrAction?.unavailableMessage).toBe(
"View MR isn't available right now because GitLab isn't connected",
);
});
it("uses local merge when merge is the stored ship default", () => {
const actions = buildGitActions(
createInput({
@@ -726,12 +816,12 @@ describe("git-actions-policy", () => {
pullRequestIsDraft: oldDaemonStatus.isDraft,
pullRequestIsMerged: oldDaemonStatus.isMerged,
pullRequestMergeable: oldDaemonStatus.mergeable,
pullRequestGithub: oldDaemonStatus.github,
pullRequestGithub: oldDaemonStatus.forgeSpecific,
shipDefault: "pr",
}),
);
expect(oldDaemonStatus.github).toBeUndefined();
expect(oldDaemonStatus.forgeSpecific).toBeUndefined();
expect(actions.primary).toMatchObject({
id: "merge-pr-squash",
label: "Merge PR (squash)",
+50 -70
View File
@@ -2,11 +2,9 @@ import type { ReactElement } from "react";
import type { ActionStatus } from "@/components/ui/dropdown-menu";
import { i18n } from "@/i18n/i18next";
import type {
CheckoutPrMergeMethod,
CheckoutPrStatusResponse,
PullRequestMergeable,
} from "@getpaseo/protocol/messages";
import type { CheckoutPrMergeMethod, PullRequestMergeable } from "@getpaseo/protocol/messages";
import type { MergeCapability } from "./merge-capability";
export type GitActionId =
| "commit"
@@ -55,6 +53,10 @@ interface GitActionRuntimeState {
export interface BuildGitActionsInput {
isGit: boolean;
githubFeaturesEnabled: boolean;
/** Forge brand label (e.g. "GitHub", "GitLab") for forge-neutral unavailable copy. */
forgeBrandLabel: string;
/** Short change-request noun label (e.g. "PR", "MR") for forge-neutral unavailable copy. */
forgeChangeRequestNoun: string;
githubAutoMergeActionsEnabled: boolean;
hasPullRequest: boolean;
pullRequestUrl: string | null;
@@ -62,7 +64,7 @@ export interface BuildGitActionsInput {
pullRequestIsDraft: boolean;
pullRequestIsMerged: boolean;
pullRequestMergeable: PullRequestMergeable;
pullRequestGithub: PullRequestGithubStatus | null;
mergeCapability: MergeCapability | null;
hasRemote: boolean;
isPaseoOwnedWorktree: boolean;
isOnBaseBranch: boolean;
@@ -98,7 +100,6 @@ type PullRequestAutoMergeEnableActionId = Extract<
"enable-pr-auto-merge-squash" | "enable-pr-auto-merge-merge" | "enable-pr-auto-merge-rebase"
>;
type PullRequestActionRole = "status" | "direct" | "auto";
type PullRequestGithubStatus = NonNullable<CheckoutPrStatusResponse["payload"]["status"]>["github"];
interface PullRequestActionModel {
readonly id: PullRequestActionId;
@@ -180,7 +181,6 @@ const PULL_REQUEST_ACTION_MODELS: readonly PullRequestActionModel[] = [
];
const REMOTE_ACTION_IDS: GitActionId[] = ["pull", "push", "pull-and-push"];
const GITHUB_DIRECT_MERGE_STATE_ALLOWLIST = new Set(["CLEAN", "HAS_HOOKS"]);
export function narrowPullRequestState(state: string | null | undefined): "open" | "closed" | null {
if (state === "open") return "open";
@@ -403,7 +403,10 @@ function buildPrAction(input: BuildGitActionsInput): GitAction {
unavailableMessage:
input.runtime.pr.disabled || input.githubFeaturesEnabled
? undefined
: i18n.t("workspace.git.actions.unavailable.viewPrNoGithub"),
: i18n.t("workspace.git.actions.unavailable.viewPrNoForge", {
brand: input.forgeBrandLabel,
noun: input.forgeChangeRequestNoun,
}),
icon: input.runtime.pr.icon,
startsGroup: false,
handler: input.runtime.pr.handler,
@@ -467,7 +470,7 @@ function buildEnablePullRequestAutoMergeAction(
function buildDisablePullRequestAutoMergeAction(input: BuildGitActionsInput): GitAction {
const runtime = input.runtime["disable-pr-auto-merge"];
const unavailableMessage =
input.pullRequestGithub?.viewerCanDisableAutoMerge === true
input.mergeCapability?.canDisableAutoMerge === true
? undefined
: i18n.t("workspace.git.actions.unavailable.autoMergeCannotDisable");
return {
@@ -475,7 +478,7 @@ function buildDisablePullRequestAutoMergeAction(input: BuildGitActionsInput): Gi
label: i18n.t("workspace.git.actions.autoMerge.enabled"),
pendingLabel: i18n.t("workspace.git.actions.autoMerge.disabling"),
successLabel: i18n.t("workspace.git.actions.autoMerge.disabled"),
disabled: runtime.disabled || input.pullRequestGithub?.viewerCanDisableAutoMerge !== true,
disabled: runtime.disabled || input.mergeCapability?.canDisableAutoMerge !== true,
status: runtime.status,
unavailableMessage: runtime.disabled ? undefined : unavailableMessage,
icon: runtime.icon,
@@ -543,7 +546,7 @@ function canUsePullRequestActionAsShipDefault(input: BuildGitActionsInput): bool
}
function canMergePr(input: BuildGitActionsInput): boolean {
const github = input.pullRequestGithub;
const capability = input.mergeCapability;
const canMergeFromPullRequestStatus =
input.githubFeaturesEnabled &&
input.hasPullRequest &&
@@ -558,7 +561,7 @@ function canMergePr(input: BuildGitActionsInput): boolean {
return false;
}
if (!hasPullRequestGithubFacts(github)) {
if (capability === null) {
return (
input.pullRequestMergeable === "MERGEABLE" &&
input.behindOfOrigin === 0 &&
@@ -568,16 +571,15 @@ function canMergePr(input: BuildGitActionsInput): boolean {
}
return (
GITHUB_DIRECT_MERGE_STATE_ALLOWLIST.has(github.mergeStateStatus ?? "") &&
github.autoMergeRequest === null &&
!github.isMergeQueueEnabled &&
!github.isInMergeQueue &&
capability.directMergeReady &&
!capability.autoMergeEnabled &&
!capability.mergeBlockedByQueue &&
getAllowedDirectPullRequestMergeActionModels(input).length > 0
);
}
function canEnablePrAutoMerge(input: BuildGitActionsInput): boolean {
const github = input.pullRequestGithub;
const capability = input.mergeCapability;
return (
input.githubFeaturesEnabled &&
input.githubAutoMergeActionsEnabled &&
@@ -586,13 +588,10 @@ function canEnablePrAutoMerge(input: BuildGitActionsInput): boolean {
!input.pullRequestIsDraft &&
!input.pullRequestIsMerged &&
input.pullRequestMergeable !== "CONFLICTING" &&
hasPullRequestGithubFacts(github) &&
github.autoMergeRequest === null &&
github.mergeStateStatus === "BLOCKED" &&
github.repository.autoMergeAllowed &&
github.viewerCanEnableAutoMerge &&
!github.isMergeQueueEnabled &&
!github.isInMergeQueue &&
capability !== null &&
!capability.autoMergeEnabled &&
capability.canEnableAutoMerge &&
!capability.mergeBlockedByQueue &&
getAllowedAutoMergeEnableActionModels(input).length > 0
);
}
@@ -602,8 +601,7 @@ function hasEnabledPrAutoMerge(input: BuildGitActionsInput): boolean {
input.githubFeaturesEnabled &&
input.hasPullRequest &&
input.pullRequestUrl !== null &&
hasPullRequestGithubFacts(input.pullRequestGithub) &&
input.pullRequestGithub.autoMergeRequest !== null
input.mergeCapability?.autoMergeEnabled === true
);
}
@@ -615,7 +613,7 @@ function getPullUnavailableMessage(input: BuildGitActionsInput): string | undefi
return i18n.t("workspace.git.actions.unavailable.pullDirty");
}
if (input.behindOfOrigin === null) {
return "Pull isn't available here because this branch is not connected to a remote yet";
return i18n.t("workspace.git.actions.unavailable.pullNoRemote");
}
if (input.behindOfOrigin === 0) {
return i18n.t("workspace.git.actions.unavailable.pullUpToDate");
@@ -644,23 +642,26 @@ function getPullAndPushUnavailableMessage(input: BuildGitActionsInput): string |
return i18n.t("workspace.git.actions.unavailable.pullAndPushDirty");
}
if (input.behindOfOrigin === null) {
return "Pull and push isn't available because there are no incoming changes to pull first";
return i18n.t("workspace.git.actions.unavailable.pullAndPushNoIncoming");
}
if (input.behindOfOrigin === 0 && input.aheadOfOrigin === 0) {
return i18n.t("workspace.git.actions.unavailable.pullAndPushInSync");
}
if (input.behindOfOrigin === 0) {
return "Pull and push isn't available because there are no incoming changes to pull first";
return i18n.t("workspace.git.actions.unavailable.pullAndPushNoIncoming");
}
if ((input.aheadOfOrigin ?? 0) === 0) {
return "Pull and push isn't available because there is nothing new to send after pulling";
return i18n.t("workspace.git.actions.unavailable.pullAndPushNothingToPush");
}
return undefined;
}
function getCreatePrUnavailableMessage(input: BuildGitActionsInput): string | undefined {
if (!input.githubFeaturesEnabled) {
return i18n.t("workspace.git.actions.unavailable.createPrNoGithub");
return i18n.t("workspace.git.actions.unavailable.createPrNoForge", {
brand: input.forgeBrandLabel,
noun: input.forgeChangeRequestNoun,
});
}
if (input.aheadCount === 0) {
return i18n.t("workspace.git.actions.unavailable.createPrNoCommits");
@@ -698,7 +699,10 @@ function getMergeFromBaseUnavailableMessage(input: BuildGitActionsInput): string
function getMergePrUnavailableMessage(input: BuildGitActionsInput): string | undefined {
if (!input.githubFeaturesEnabled) {
return i18n.t("workspace.git.actions.unavailable.mergePrNoGithub");
return i18n.t("workspace.git.actions.unavailable.mergePrNoForge", {
brand: input.forgeBrandLabel,
noun: input.forgeChangeRequestNoun,
});
}
if (!input.hasPullRequest) {
return i18n.t("workspace.git.actions.unavailable.mergePrMissing");
@@ -715,14 +719,17 @@ function getMergePrUnavailableMessage(input: BuildGitActionsInput): string | und
if (input.pullRequestMergeable === "CONFLICTING") {
return i18n.t("workspace.git.actions.unavailable.mergePrConflicts");
}
if (!hasPullRequestGithubFacts(input.pullRequestGithub)) {
if (input.mergeCapability === null) {
return undefined;
}
if (input.pullRequestGithub?.isMergeQueueEnabled || input.pullRequestGithub?.isInMergeQueue) {
if (input.mergeCapability.mergeBlockedByQueue) {
return i18n.t("workspace.git.actions.unavailable.mergePrQueue");
}
if (!GITHUB_DIRECT_MERGE_STATE_ALLOWLIST.has(input.pullRequestGithub?.mergeStateStatus ?? "")) {
return i18n.t("workspace.git.actions.unavailable.mergePrNotReady");
if (!input.mergeCapability.directMergeReady) {
return i18n.t("workspace.git.actions.unavailable.mergePrNotReady", {
brand: input.forgeBrandLabel,
noun: input.forgeChangeRequestNoun,
});
}
return undefined;
}
@@ -739,11 +746,7 @@ function shouldShowPullRequestAction(
return true;
}
if (id === "disable-pr-auto-merge") {
return (
input.githubAutoMergeActionsEnabled &&
hasPullRequestGithubFacts(input.pullRequestGithub) &&
input.pullRequestGithub.autoMergeRequest !== null
);
return input.githubAutoMergeActionsEnabled && input.mergeCapability?.autoMergeEnabled === true;
}
if (isDirectPullRequestMergeActionId(id)) {
return canMergePr(input) && getAllowedDirectPullRequestMergeActionIds(input).includes(id);
@@ -798,9 +801,7 @@ function getPreferredDirectPullRequestMergeActionModel(
input: BuildGitActionsInput,
): PullRequestDirectMergeActionModel | null {
const allowed = getAllowedDirectPullRequestMergeActionModels(input);
const preferred = normalizeGithubMergeMethod(
input.pullRequestGithub?.repository.viewerDefaultMergeMethod ?? null,
);
const preferred = input.mergeCapability?.preferredMethod ?? null;
return allowed.find((model) => model.method === preferred) ?? allowed[0] ?? null;
}
@@ -808,9 +809,7 @@ function getPreferredEnablePullRequestAutoMergeActionModel(
input: BuildGitActionsInput,
): PullRequestAutoMergeEnableActionModel | null {
const allowed = getAllowedAutoMergeEnableActionModels(input);
const preferred = normalizeGithubMergeMethod(
input.pullRequestGithub?.repository.viewerDefaultMergeMethod ?? null,
);
const preferred = input.mergeCapability?.preferredMethod ?? null;
return allowed.find((model) => model.method === preferred) ?? allowed[0] ?? null;
}
@@ -818,28 +817,9 @@ function isPullRequestMergeMethodAllowed(
input: BuildGitActionsInput,
method: CheckoutPrMergeMethod,
): boolean {
const repository = input.pullRequestGithub?.repository;
if (!repository) {
const capability = input.mergeCapability;
if (capability === null) {
return true;
}
if (method === "squash") {
return repository.squashMergeAllowed;
}
if (method === "merge") {
return repository.mergeCommitAllowed;
}
return repository.rebaseMergeAllowed;
}
function hasPullRequestGithubFacts(
github: PullRequestGithubStatus | null,
): github is NonNullable<PullRequestGithubStatus> {
return github !== null && github !== undefined;
}
function normalizeGithubMergeMethod(value: string | null): CheckoutPrMergeMethod | null {
if (value === "SQUASH") return "squash";
if (value === "MERGE") return "merge";
if (value === "REBASE") return "rebase";
return null;
return capability.allowedMethods.includes(method);
}
+43
View File
@@ -0,0 +1,43 @@
import { describe, expect, it } from "vitest";
import { selectPrHintFromStatus } from "./pr-hint";
const githubStatus = {
url: "https://github.com/acme/repo/pull/42",
state: "open",
isMerged: false,
};
const gitlabStatus = {
url: "https://gitlab.com/group/proj/-/merge_requests/7",
state: "open",
isMerged: false,
};
describe("selectPrHintFromStatus", () => {
it("defaults the forge to github when none is supplied (old daemon)", () => {
const hint = selectPrHintFromStatus(githubStatus);
expect(hint).toMatchObject({ number: 42, forge: "github" });
});
it("carries the resolved forge onto the hint", () => {
const hint = selectPrHintFromStatus(githubStatus, "github");
expect(hint?.forge).toBe("github");
});
it("parses a GitLab merge-request URL and carries the gitlab forge", () => {
const hint = selectPrHintFromStatus(gitlabStatus, "gitlab");
expect(hint).toMatchObject({ number: 7, forge: "gitlab" });
});
it("passes an unknown forge id through untouched", () => {
const hint = selectPrHintFromStatus(githubStatus, "bitbucket");
expect(hint?.forge).toBe("bitbucket");
});
it("returns null when the url has no parseable change-request number", () => {
expect(
selectPrHintFromStatus({ url: "https://example.com/x", state: "open", isMerged: false }),
).toBeNull();
});
});
+13 -2
View File
@@ -1,7 +1,11 @@
import { normalizeForge, type Forge } from "@/git/forge";
export interface PrHint {
url: string;
number: number;
state: "open" | "merged" | "closed";
/** Forge backing this change request, so badges render the right brand mark. */
forge: Forge;
checks?: Array<{ name: string; status: string; url: string | null }>;
checksStatus?: "none" | "pending" | "success" | "failure";
reviewDecision?: "approved" | "changes_requested" | "pending" | null;
@@ -14,12 +18,15 @@ interface PrStatusLike {
checks?: Array<{ name: string; status: string; url: string | null }>;
checksStatus?: string;
reviewDecision?: string | null;
forge?: string;
}
function parsePullRequestNumber(url: string): number | null {
try {
const pathname = new URL(url).pathname;
const match = pathname.match(/\/pull\/(\d+)(?:\/|$)/);
// GitHub uses /pull/N, Gitea/Forgejo /pulls/N, GitLab /-/merge_requests/N.
// Match any so a non-GitHub change-request summary yields a hint (and brand mark).
const match = pathname.match(/\/(?:pull|pulls|merge_requests)\/(\d+)(?:\/|$)/);
if (!match) {
return null;
}
@@ -31,7 +38,10 @@ function parsePullRequestNumber(url: string): number | null {
}
}
export function selectPrHintFromStatus(status: PrStatusLike | null | undefined): PrHint | null {
export function selectPrHintFromStatus(
status: PrStatusLike | null | undefined,
forge?: string | null,
): PrHint | null {
if (!status?.url) {
return null;
}
@@ -50,6 +60,7 @@ export function selectPrHintFromStatus(status: PrStatusLike | null | undefined):
url: status.url,
number,
state,
forge: normalizeForge(forge ?? status.forge),
checks: status.checks,
checksStatus: status.checksStatus as PrHint["checksStatus"],
reviewDecision: status.reviewDecision as PrHint["reviewDecision"],
+40
View File
@@ -0,0 +1,40 @@
import { describe, expect, it } from "vitest";
import type { CheckoutPrStatusResponse } from "@getpaseo/protocol/messages";
import { normalizeCheckoutPrStatusPayload } from "./pr-status";
function payload(
overrides: Partial<CheckoutPrStatusResponse["payload"]> = {},
): CheckoutPrStatusResponse["payload"] {
return {
cwd: "/repo",
status: null,
githubFeaturesEnabled: true,
forge: "github",
error: null,
requestId: "pr-status-1",
...overrides,
};
}
describe("normalizeCheckoutPrStatusPayload", () => {
it("preserves known auth states", () => {
expect(normalizeCheckoutPrStatusPayload(payload({ authState: "cli_missing" })).authState).toBe(
"cli_missing",
);
});
it("derives auth from the legacy feature flag when authState is absent", () => {
expect(normalizeCheckoutPrStatusPayload(payload()).authState).toBe("authenticated");
expect(
normalizeCheckoutPrStatusPayload(payload({ githubFeaturesEnabled: false })).authState,
).toBe("unauthenticated");
});
it("does not expose an unknown wire auth state to feature code", () => {
expect(
normalizeCheckoutPrStatusPayload(
payload({ authState: "future_auth_state", githubFeaturesEnabled: false }),
).authState,
).toBe("unauthenticated");
});
});
+21
View File
@@ -0,0 +1,21 @@
import type { CheckoutPrStatusResponse, ForgeAuthState } from "@getpaseo/protocol/messages";
import { parseForgeAuthState } from "@/git/forge";
type WireCheckoutPrStatusPayload = CheckoutPrStatusResponse["payload"];
export type CheckoutPrStatusPayload = Omit<WireCheckoutPrStatusPayload, "authState"> & {
authState: ForgeAuthState;
};
export function normalizeCheckoutPrStatusPayload(
payload: WireCheckoutPrStatusPayload,
): CheckoutPrStatusPayload {
return {
...payload,
// COMPAT(forgeAuthState): added in v0.1.106, remove after 2026-12-27 once
// all supported daemons send authState.
authState:
parseForgeAuthState(payload.authState) ??
(payload.githubFeaturesEnabled ? "authenticated" : "unauthenticated"),
};
}
@@ -41,6 +41,7 @@ function threadEntry(id: string, resolved = false, outdated = false): PrTimeline
kind: "thread",
id: `thread:${id}`,
location: { path: "a.ts", line: 1, isResolved: resolved, isOutdated: outdated },
isResolved: resolved,
comments: [activity(id)],
};
}
@@ -90,6 +91,18 @@ describe("pull request activity state", () => {
]);
});
it("collapses a resolved general thread that carries no location", () => {
const entries: PrTimelineEntry[] = [
{ kind: "thread", id: "thread:general", isResolved: true, comments: [activity("g1")] },
];
const visible = getVisibleEntries(getActivityState(), { prNumber: 42, entries });
expect(visible.map((v) => ({ id: v.entry.id, collapsed: v.collapsed }))).toEqual([
{ id: "thread:general", collapsed: true },
]);
});
it("lets user expand a default-collapsed entry", () => {
const entries = [threadEntry("resolved", true)];
const expanded = expandActivity(getActivityState(), {
@@ -25,7 +25,7 @@ export function getActivityStateKey(identity: PullRequestActivityIdentity): stri
function shouldCollapseByDefault(entry: PrTimelineEntry): boolean {
if (entry.kind === "thread") {
return entry.location.isResolved === true || entry.location.isOutdated === true;
return entry.isResolved === true || entry.location?.isOutdated === true;
}
if (entry.kind === "single") {
return (
@@ -0,0 +1,22 @@
/**
* Neutral check status mapping shared by the PR-pane data builder, the forge
* native-data contributions, and the pane render. Kept forge-agnostic: a forge
* maps its raw CI strings onto this frozen union. Forge-specific vocabularies
* (e.g. GitLab pipeline statuses) live in the owning forge module.
*/
export type CheckStatus = "success" | "failure" | "pending" | "skipped";
export function mapCheckStatus(status: string): CheckStatus {
if (
status === "success" ||
status === "failure" ||
status === "pending" ||
status === "skipped"
) {
return status;
}
if (status === "cancelled") {
return "skipped";
}
return "pending";
}
@@ -11,14 +11,15 @@ import {
import type { PrPaneActivity, PrPaneCheck } from "./data";
import type { PrThreadEntry } from "./timeline";
const baseInput: Omit<PullRequestContextBuilderInput, "activity"> = {
const baseInput = {
provider: { id: "github", label: "GitHub" },
forge: "github",
pullRequest: {
number: 42,
title: "Fix flaky build",
url: "https://github.com/getpaseo/paseo/pull/42",
},
};
} satisfies Omit<PullRequestContextBuilderInput, "activity">;
function comment(overrides: Partial<PrPaneActivity> = {}): PrPaneActivity {
return {
@@ -55,7 +56,7 @@ function check(overrides: Partial<PrPaneCheck> = {}): PrPaneCheck {
provider: "github",
status: "failure",
url: "https://github.com/getpaseo/paseo/actions/runs/456/job/789",
github: { checkRunId: 12345, workflowRunId: 456 },
detailRef: { checkRunId: 12345, workflowRunId: 456 },
...overrides,
};
}
@@ -76,7 +77,7 @@ describe("pull request context attachments", () => {
}),
}),
).toEqual({
kind: "github.pull_request_comment",
kind: "forge.change_request_comment",
id: "42:comment-1",
title: "octocat",
subtitle: "#42 Fix flaky build",
@@ -97,7 +98,7 @@ describe("pull request context attachments", () => {
it("formats a review with state as a workspace context attachment", () => {
expect(buildPullRequestReviewContextAttachment({ ...baseInput, activity: review() })).toEqual({
kind: "github.pull_request_review",
kind: "forge.change_request_review",
id: "42:review-1",
title: "reviewer",
subtitle: "#42 Fix flaky build",
@@ -116,6 +117,41 @@ describe("pull request context attachments", () => {
});
});
it("labels a GitLab comment as a merge request with the ! prefix", () => {
expect(
buildPullRequestCommentContextAttachment({
...baseInput,
provider: { id: "gitlab", label: "GitLab" },
forge: "gitlab",
pullRequest: {
number: 14,
title: "Wire up the timeline",
url: "https://gitlab.com/acme/app/-/merge_requests/14",
},
activity: comment({
provider: "gitlab",
url: "https://gitlab.com/acme/app/-/merge_requests/14#note_401",
}),
}),
).toEqual({
kind: "forge.change_request_comment",
id: "14:comment-1",
title: "octocat",
subtitle: "!14 Wire up the timeline",
url: "https://gitlab.com/acme/app/-/merge_requests/14#note_401",
text: [
"GitLab merge request comment",
"Merge request: !14 Wire up the timeline",
"Merge request URL: https://gitlab.com/acme/app/-/merge_requests/14",
"URL: https://gitlab.com/acme/app/-/merge_requests/14#note_401",
"Author: octocat",
"Created: 3d ago",
"",
"Looks good.",
].join("\n"),
});
});
it("does not add bodyless approvals to chat", () => {
const activity = review({ reviewState: "approved", body: "" });
@@ -185,7 +221,7 @@ describe("pull request context attachments", () => {
},
}),
).toEqual({
kind: "github.pull_request_check",
kind: "forge.change_request_check",
id: "42:check-run:12345",
title: "server-tests",
subtitle: "#42 Fix flaky build",
@@ -222,12 +258,12 @@ describe("pull request context attachments", () => {
it("keeps same-named GitHub checks distinct by check run", () => {
const ubuntu = buildPullRequestCheckContextAttachment({
...baseInput,
check: check({ github: { checkRunId: 12345, workflowRunId: 456 } }),
check: check({ detailRef: { checkRunId: 12345, workflowRunId: 456 } }),
githubDetails: null,
});
const windows = buildPullRequestCheckContextAttachment({
...baseInput,
check: check({ github: { checkRunId: 67890, workflowRunId: 456 } }),
check: check({ detailRef: { checkRunId: 67890, workflowRunId: 456 } }),
githubDetails: null,
});
@@ -242,12 +278,12 @@ describe("pull request context attachments", () => {
check: check({
name: "status/context",
url: "https://github.com/getpaseo/paseo/status/context",
github: undefined,
detailRef: undefined,
}),
githubDetails: null,
}),
).toEqual({
kind: "github.pull_request_check",
kind: "forge.change_request_check",
id: "42:check:status/context",
title: "status/context",
subtitle: "#42 Fix flaky build",
@@ -262,6 +298,42 @@ describe("pull request context attachments", () => {
].join("\n"),
});
});
it("formats GitLab failed check context as a merge request", () => {
expect(
buildPullRequestCheckContextAttachment({
...baseInput,
provider: { id: "gitlab", label: "GitLab" },
forge: "gitlab",
pullRequest: {
number: 14,
title: "Wire up pipelines",
url: "https://gitlab.com/acme/app/-/merge_requests/14",
},
check: check({
name: "pipeline",
provider: "gitlab",
url: "https://gitlab.com/acme/app/-/pipelines/99",
detailRef: undefined,
}),
githubDetails: null,
}),
).toEqual({
kind: "forge.change_request_check",
id: "14:check:pipeline",
title: "pipeline",
subtitle: "!14 Wire up pipelines",
url: "https://gitlab.com/acme/app/-/pipelines/99",
text: [
"GitLab merge request check",
"Merge request: !14 Wire up pipelines",
"Merge request URL: https://gitlab.com/acme/app/-/merge_requests/14",
"Check: pipeline",
"Status: failure",
"Check URL: https://gitlab.com/acme/app/-/pipelines/99",
].join("\n"),
});
});
});
describe("buildPullRequestThreadContextAttachment", () => {
@@ -284,7 +356,7 @@ describe("buildPullRequestThreadContextAttachment", () => {
it("bundles the whole thread conversation into one attachment", () => {
expect(buildPullRequestThreadContextAttachment({ ...baseInput, thread: thread() })).toEqual({
kind: "github.pull_request_comment",
kind: "forge.change_request_comment",
id: "42:thread:PRRT_1",
title: "src/a.ts:12",
subtitle: "#42 Fix flaky build",
@@ -1,5 +1,6 @@
import type { CheckoutGithubCheckDetails } from "@getpaseo/protocol/messages";
import type { CheckoutCheckDetails } from "@getpaseo/protocol/messages";
import type { PullRequestContextAttachment } from "@/attachments/types";
import { type Forge, getForgePresentation } from "@/git/forge";
import {
formatPullRequestActivityLocation,
formatPullRequestThreadPath,
@@ -15,21 +16,24 @@ export interface PullRequestContextMetadata {
export interface PullRequestContextBuilderInput {
provider: PullRequestProviderMetadata;
forge: Forge;
pullRequest: PullRequestContextMetadata;
activity: PrPaneActivity;
}
export interface PullRequestThreadContextBuilderInput {
provider: PullRequestProviderMetadata;
forge: Forge;
pullRequest: PullRequestContextMetadata;
thread: PrThreadEntry;
}
export interface PullRequestGithubCheckContextBuilderInput {
provider: PullRequestProviderMetadata & { id: "github" };
provider: PullRequestProviderMetadata;
forge: Forge;
pullRequest: PullRequestContextMetadata;
check: PrPaneCheck & { provider: "github" };
githubDetails?: CheckoutGithubCheckDetails | null;
check: PrPaneCheck;
githubDetails?: CheckoutCheckDetails | null;
}
export function canAddPullRequestActivityToChat(activity: PrPaneActivity): boolean {
@@ -46,14 +50,15 @@ export function canAddPullRequestCheckLogsToChat(check: PrPaneCheck): boolean {
export function buildPullRequestCommentContextAttachment(
input: PullRequestContextBuilderInput,
): PullRequestContextAttachment {
const presentation = getForgePresentation(input.forge);
return {
kind: "github.pull_request_comment",
kind: "forge.change_request_comment",
id: `${input.pullRequest.number}:${input.activity.id}`,
title: input.activity.author,
subtitle: formatPullRequestSubtitle(input.pullRequest),
subtitle: formatPullRequestSubtitle(input.pullRequest, input.forge),
text: formatActivityContextText({
...input,
heading: `${input.provider.label} pull request comment`,
heading: `${presentation.brandLabel} ${presentation.changeRequestNoun} comment`,
}),
url: input.activity.url,
};
@@ -66,14 +71,15 @@ export function buildPullRequestReviewContextAttachment(
return null;
}
const presentation = getForgePresentation(input.forge);
return {
kind: "github.pull_request_review",
kind: "forge.change_request_review",
id: `${input.pullRequest.number}:${input.activity.id}`,
title: input.activity.author,
subtitle: formatPullRequestSubtitle(input.pullRequest),
subtitle: formatPullRequestSubtitle(input.pullRequest, input.forge),
text: formatActivityContextText({
...input,
heading: `${input.provider.label} pull request review`,
heading: `${presentation.brandLabel} ${presentation.changeRequestNoun} review`,
reviewState: input.activity.reviewState,
}),
url: input.activity.url,
@@ -93,17 +99,23 @@ export function buildPullRequestThreadContextAttachment(
return null;
}
const presentation = getForgePresentation(input.forge);
const noun = capitalizeFirst(presentation.changeRequestNoun);
const location = input.thread.location;
const threadTitle = location ? formatPullRequestThreadPath(location) : "Discussion thread";
const lines = [
`${input.provider.label} pull request review thread`,
`Pull request: #${input.pullRequest.number} ${input.pullRequest.title}`,
`Pull request URL: ${input.pullRequest.url}`,
`${presentation.brandLabel} ${presentation.changeRequestNoun} review thread`,
`${noun}: ${presentation.numberPrefix}${input.pullRequest.number} ${input.pullRequest.title}`,
`${noun} URL: ${input.pullRequest.url}`,
`URL: ${root.url}`,
`Location: ${formatPullRequestThreadPath(input.thread.location)}`,
];
if (input.thread.location.isResolved !== undefined) {
lines.push(`Thread state: ${input.thread.location.isResolved ? "resolved" : "unresolved"}`);
if (location) {
lines.push(`Location: ${formatPullRequestThreadPath(location)}`);
}
if (input.thread.location.isOutdated) {
if (location?.isResolved !== undefined) {
lines.push(`Thread state: ${location.isResolved ? "resolved" : "unresolved"}`);
}
if (location?.isOutdated) {
lines.push("Note: this thread is outdated (the code it refers to has changed)");
}
@@ -112,10 +124,10 @@ export function buildPullRequestThreadContextAttachment(
);
return {
kind: "github.pull_request_comment",
kind: "forge.change_request_comment",
id: `${input.pullRequest.number}:${input.thread.id}`,
title: formatPullRequestThreadPath(input.thread.location),
subtitle: formatPullRequestSubtitle(input.pullRequest),
title: threadTitle,
subtitle: formatPullRequestSubtitle(input.pullRequest, input.forge),
text: [...lines, "", conversation.join("\n\n---\n\n")].join("\n"),
url: root.url,
};
@@ -125,11 +137,11 @@ export function buildPullRequestCheckContextAttachment(
input: PullRequestGithubCheckContextBuilderInput,
): PullRequestContextAttachment {
return {
kind: "github.pull_request_check",
kind: "forge.change_request_check",
id: formatPullRequestCheckContextId(input.pullRequest, input.check),
title: input.check.name,
subtitle: formatPullRequestSubtitle(input.pullRequest),
text: formatGitHubCheckContextText(input),
subtitle: formatPullRequestSubtitle(input.pullRequest, input.forge),
text: formatCheckContextText(input),
url: input.githubDetails?.detailsUrl ?? input.githubDetails?.url ?? input.check.url,
};
}
@@ -138,22 +150,25 @@ function formatPullRequestCheckContextId(
pullRequest: PullRequestContextMetadata,
check: PrPaneCheck,
): string {
if (check.github?.checkRunId !== undefined) {
return `${pullRequest.number}:check-run:${check.github.checkRunId}`;
if (check.detailRef?.checkRunId !== undefined) {
return `${pullRequest.number}:check-run:${check.detailRef.checkRunId}`;
}
return `${pullRequest.number}:check:${check.name}`;
}
function formatGitHubCheckContextText({
function formatCheckContextText({
provider,
forge,
pullRequest,
check,
githubDetails,
}: PullRequestGithubCheckContextBuilderInput): string {
const presentation = getForgePresentation(forge);
const noun = capitalizeFirst(presentation.changeRequestNoun);
const lines = [
`${provider.label} pull request check`,
`Pull request: #${pullRequest.number} ${pullRequest.title}`,
`Pull request URL: ${pullRequest.url}`,
`${provider.label} ${presentation.changeRequestNoun} check`,
`${noun}: ${presentation.numberPrefix}${pullRequest.number} ${pullRequest.title}`,
`${noun} URL: ${pullRequest.url}`,
`Check: ${check.name}`,
`Status: ${check.status}`,
];
@@ -170,7 +185,7 @@ function formatGitHubCheckContextText({
appendGitHubCheckAnnotations(lines, githubDetails);
appendGitHubFailedJobs(lines, githubDetails);
if (githubDetails?.truncated) {
lines.push("", "Note: Check details were truncated by GitHub/API or local caps.");
lines.push("", `Note: Check details were truncated by ${provider.label}/API or local caps.`);
}
return lines.join("\n");
@@ -178,7 +193,7 @@ function formatGitHubCheckContextText({
function appendGitHubCheckOutput(
lines: string[],
details: CheckoutGithubCheckDetails | null | undefined,
details: CheckoutCheckDetails | null | undefined,
) {
if (details?.output?.title) {
lines.push(`Output title: ${details.output.title}`);
@@ -193,7 +208,7 @@ function appendGitHubCheckOutput(
function appendGitHubCheckAnnotations(
lines: string[],
details: CheckoutGithubCheckDetails | null | undefined,
details: CheckoutCheckDetails | null | undefined,
) {
if (!details?.annotations?.length) {
return;
@@ -204,10 +219,7 @@ function appendGitHubCheckAnnotations(
}
}
function appendGitHubFailedJobs(
lines: string[],
details: CheckoutGithubCheckDetails | null | undefined,
) {
function appendGitHubFailedJobs(lines: string[], details: CheckoutCheckDetails | null | undefined) {
if (!details?.failedJobs?.length) {
return;
}
@@ -226,7 +238,7 @@ function appendGitHubFailedJobs(
}
}
function formatAnnotation(annotation: CheckoutGithubCheckDetails["annotations"][number]): string {
function formatAnnotation(annotation: CheckoutCheckDetails["annotations"][number]): string {
const location = annotation.path
? `${annotation.path}${formatAnnotationLines(annotation)}`
: "unknown location";
@@ -235,9 +247,7 @@ function formatAnnotation(annotation: CheckoutGithubCheckDetails["annotations"][
return `${location}${level}${message}`;
}
function formatAnnotationLines(
annotation: CheckoutGithubCheckDetails["annotations"][number],
): string {
function formatAnnotationLines(annotation: CheckoutCheckDetails["annotations"][number]): string {
if (annotation.startLine !== undefined && annotation.endLine !== undefined) {
return `:${annotation.startLine}-${annotation.endLine}`;
}
@@ -249,14 +259,17 @@ function formatAnnotationLines(
function formatActivityContextText({
heading,
forge,
pullRequest,
activity,
reviewState,
}: PullRequestContextBuilderInput & { heading: string; reviewState?: ReviewState }): string {
const presentation = getForgePresentation(forge);
const noun = capitalizeFirst(presentation.changeRequestNoun);
const lines = [
heading,
`Pull request: #${pullRequest.number} ${pullRequest.title}`,
`Pull request URL: ${pullRequest.url}`,
`${noun}: ${presentation.numberPrefix}${pullRequest.number} ${pullRequest.title}`,
`${noun} URL: ${pullRequest.url}`,
`URL: ${activity.url}`,
`Author: ${activity.author}`,
];
@@ -279,6 +292,10 @@ function formatActivityContextText({
return [...lines, "", body].join("\n");
}
function formatPullRequestSubtitle(pullRequest: PullRequestContextMetadata): string {
return `#${pullRequest.number} ${pullRequest.title}`;
function formatPullRequestSubtitle(pullRequest: PullRequestContextMetadata, forge: Forge): string {
return `${getForgePresentation(forge).numberPrefix}${pullRequest.number} ${pullRequest.title}`;
}
function capitalizeFirst(value: string): string {
return value.length === 0 ? value : `${value[0].toUpperCase()}${value.slice(1)}`;
}
@@ -3,6 +3,7 @@ import type {
CheckoutPrStatusResponse,
PullRequestTimelineResponse,
} from "@getpaseo/protocol/messages";
import { isPipelineActiveStatus, mapPipelineStatus } from "@/git/forges/gitlab";
import {
deriveAvatarColor,
formatAge,
@@ -33,6 +34,7 @@ const githubStatus: CheckoutPrStatus["github"] = {
};
const baseStatus: CheckoutPrStatus = {
forge: "github",
number: 42,
url: "https://github.com/getpaseo/paseo/pull/42",
title: "Wire PR pane data",
@@ -184,11 +186,30 @@ describe("mapPrPaneData", () => {
name: "server-tests",
status: "failure",
url: "https://github.com/getpaseo/paseo/actions/runs/456/job/789",
github: { checkRunId: 12345, workflowRunId: 456 },
detailRef: { checkRunId: 12345, workflowRunId: 456 },
},
]);
});
it("keeps a workflowRunId-only detail ref so Gitea Actions rows stay fetchable", () => {
const data = mapPrPaneData(
status({
forge: "gitea",
checks: [
{
name: "e2e",
status: "failure",
url: "https://gitea.com/acme/repo/actions/runs/7001",
workflowRunId: 7001,
},
],
}),
baseTimeline,
);
expect(data?.checks[0]?.detailRef).toEqual({ workflowRunId: 7001 });
});
it("preserves timeline item order while mapping mixed reviews and comments", () => {
const data = mapPrPaneData(
baseStatus,
@@ -272,6 +293,56 @@ describe("mapPrPaneData", () => {
]);
});
it("maps a top-level threadId onto general (non-file) discussion comments", () => {
const data = mapPrPaneData(
baseStatus,
timeline({
items: [
{
id: "note-1",
kind: "comment",
author: "reviewer-a",
body: "Can you clarify the rollout?",
createdAt: Date.UTC(2026, 0, 1, 11, 0, 0),
url: "https://gitlab.example.com/group/project/-/merge_requests/42#note_1",
threadId: "disc-1",
},
],
}),
Date.UTC(2026, 0, 1, 12, 0, 0),
"gitlab",
);
expect(data?.activity).toHaveLength(1);
expect(data?.activity[0]).toMatchObject({ id: "note-1", threadId: "disc-1" });
expect(data?.activity[0].location).toBeUndefined();
});
it("maps thread-level resolution onto general (non-file) discussion comments", () => {
const data = mapPrPaneData(
baseStatus,
timeline({
items: [
{
id: "note-2",
kind: "comment",
author: "reviewer-a",
body: "Resolved general discussion.",
createdAt: Date.UTC(2026, 0, 1, 11, 0, 0),
url: "https://gitlab.example.com/group/project/-/merge_requests/42#note_2",
threadId: "disc-2",
threadIsResolved: true,
},
],
}),
Date.UTC(2026, 0, 1, 12, 0, 0),
"gitlab",
);
expect(data?.activity[0]).toMatchObject({ id: "note-2", threadIsResolved: true });
expect(data?.activity[0].location).toBeUndefined();
});
it("filters empty commented reviews but keeps blocking review states", () => {
const data = mapPrPaneData(
baseStatus,
@@ -388,6 +459,23 @@ describe("mapPrPaneData", () => {
expect(mapPrPaneData(baseStatus, baseTimeline)?.awaitingReviewers).toEqual([]);
});
it("defaults the forge to github and omits the project path when neither is supplied", () => {
const data = mapPrPaneData(baseStatus, baseTimeline);
expect(data?.forge).toBe("github");
expect(data?.projectPath).toBeUndefined();
});
it("carries the resolved forge and the nested project path for GitLab", () => {
const data = mapPrPaneData(
status({ projectPath: "group/subgroup/repo" }),
baseTimeline,
undefined,
"gitlab",
);
expect(data?.forge).toBe("gitlab");
expect(data?.projectPath).toBe("group/subgroup/repo");
});
it("rejects stale timeline activity when the timeline PR number differs from status", () => {
const data = mapPrPaneData(
baseStatus,
@@ -409,6 +497,169 @@ describe("mapPrPaneData", () => {
expect(data?.activity).toEqual([]);
});
it("passes the forge native facts through for pane contributions to derive surfaces", () => {
const gitlabFacts = {
forge: "gitlab" as const,
detailedMergeStatus: "mergeable",
hasConflicts: false,
blockingDiscussionsResolved: true,
approvalsRequired: 2,
approvalsGiven: 1,
pipelineStatus: "running",
pipelineId: 306,
pipelineUrl: "https://gitlab.com/group/repo/-/pipelines/306",
mergeWhenPipelineSucceeds: false,
};
const data = mapPrPaneData(
status({
url: "https://gitlab.com/group/repo/-/merge_requests/7",
github: undefined,
forgeSpecific: gitlabFacts,
}),
baseTimeline,
undefined,
"gitlab",
);
expect(data?.forgeSpecific).toEqual({ ...gitlabFacts, mergeStatus: null });
});
it("omits forgeSpecific when the status carries no native facts", () => {
expect(mapPrPaneData(baseStatus, baseTimeline)?.forgeSpecific).toBeUndefined();
});
it("omits forgeSpecific when no registered forge schema accepts it", () => {
const data = mapPrPaneData(
status({ forgeSpecific: { forge: "gitlab", approvalsRequired: "two" } }),
baseTimeline,
);
expect(data?.forgeSpecific).toBeUndefined();
});
it("surfaces Gitea aggregate CI status as a check row", () => {
const data = mapPrPaneData(
status({
forge: "gitea",
url: "https://gitea.com/group/repo/pulls/7",
github: undefined,
forgeSpecific: {
forge: "gitea",
mergeable: true,
hasMerged: false,
ciStatus: "success",
},
}),
baseTimeline,
undefined,
"gitea",
);
expect(data?.checks).toEqual([
{
provider: "gitea",
name: "CI",
status: "success",
url: "https://gitea.com/group/repo/pulls/7",
},
]);
});
it("keeps Forgejo branding for aggregate Gitea-family CI status", () => {
const data = mapPrPaneData(
status({
forge: "forgejo",
url: "https://forgejo.example.com/group/repo/pulls/7",
github: undefined,
forgeSpecific: {
forge: "gitea",
mergeable: true,
hasMerged: false,
ciStatus: "failure",
},
}),
baseTimeline,
undefined,
"forgejo",
);
expect(data?.checks).toEqual([
{
provider: "forgejo",
name: "CI",
status: "failure",
url: "https://forgejo.example.com/group/repo/pulls/7",
},
]);
});
it("carries the resolved forge brand and activity provider for GitLab", () => {
const data = mapPrPaneData(
status({
url: "https://gitlab.com/group/repo/-/merge_requests/7",
github: undefined,
forgeSpecific: {
forge: "gitlab",
detailedMergeStatus: "mergeable",
hasConflicts: false,
blockingDiscussionsResolved: true,
approvalsRequired: 2,
approvalsGiven: 1,
pipelineStatus: null,
pipelineId: null,
pipelineUrl: null,
mergeWhenPipelineSucceeds: false,
},
}),
timeline({
items: [
{
id: "note-1",
kind: "comment",
author: "reviewer",
body: "Looks good",
createdAt: 1000,
url: "https://gitlab.com/group/repo/-/merge_requests/7#note_1",
},
],
}),
2000,
"gitlab",
);
expect(data?.provider).toEqual({ id: "gitlab", label: "GitLab" });
expect(data?.activity[0]?.provider).toBe("gitlab");
});
});
describe("mapPipelineStatus", () => {
it("maps GitLab and neutral pipeline statuses onto check statuses", () => {
expect(mapPipelineStatus("success")).toBe("success");
expect(mapPipelineStatus("passed")).toBe("success");
expect(mapPipelineStatus("failed")).toBe("failure");
expect(mapPipelineStatus("canceled")).toBe("skipped");
expect(mapPipelineStatus("skipped")).toBe("skipped");
expect(mapPipelineStatus("manual")).toBe("pending");
expect(mapPipelineStatus("running")).toBe("pending");
expect(mapPipelineStatus("pending")).toBe("pending");
expect(mapPipelineStatus("created")).toBe("pending");
expect(mapPipelineStatus("waiting_for_resource")).toBe("pending");
expect(mapPipelineStatus("preparing")).toBe("pending");
expect(mapPipelineStatus("scheduled")).toBe("pending");
expect(mapPipelineStatus("anything-else")).toBe("pending");
});
it("marks running and queued pipeline statuses as live for polling", () => {
expect(isPipelineActiveStatus("running")).toBe(true);
expect(isPipelineActiveStatus("pending")).toBe(true);
expect(isPipelineActiveStatus("created")).toBe(true);
expect(isPipelineActiveStatus("waiting_for_resource")).toBe(true);
expect(isPipelineActiveStatus("preparing")).toBe(true);
expect(isPipelineActiveStatus("scheduled")).toBe(true);
expect(isPipelineActiveStatus("success")).toBe(false);
expect(isPipelineActiveStatus("failed")).toBe(false);
expect(isPipelineActiveStatus("canceled")).toBe(false);
});
});
describe("deriveAvatarColor", () => {
+66 -29
View File
@@ -2,12 +2,18 @@ import type {
CheckoutPrStatusResponse,
PullRequestTimelineResponse,
} from "@getpaseo/protocol/messages";
import { type Forge, getForgePresentation } from "@/git/forge";
import { parseClientForgeFacts } from "@/git/forges";
import type { ForgeSpecificStatusFacts } from "@/git/merge-capability";
import { type CheckStatus, mapCheckStatus } from "./check-status";
import { getNativeFallbackChecks } from "./native-data";
export type { CheckStatus } from "./check-status";
export type PrState = "open" | "draft" | "merged" | "closed";
export type CheckStatus = "success" | "failure" | "pending" | "skipped";
export type ReviewState = "approved" | "changes_requested" | "commented";
export type ActivityKind = "review" | "comment";
export type PullRequestProvider = "github";
export type PullRequestProvider = Forge;
export interface PullRequestProviderMetadata {
id: PullRequestProvider;
@@ -15,8 +21,6 @@ export interface PullRequestProviderMetadata {
url?: string | null;
}
const GITHUB_PROVIDER: PullRequestProviderMetadata = { id: "github", label: "GitHub" };
export interface PrPaneCheck {
provider: PullRequestProvider;
name: string;
@@ -24,7 +28,12 @@ export interface PrPaneCheck {
status: CheckStatus;
duration?: string;
url: string;
github?: {
/**
* Forge-neutral reference for fetching this check's detail/logs on demand. Any
* forge that exposes a check-run id populates it; the daemon resolves the logs
* through the neutral check-details RPC.
*/
detailRef?: {
checkRunId?: number;
workflowRunId?: number;
};
@@ -44,6 +53,16 @@ export interface PrPaneActivity {
url: string;
/** For inline review comments: the review this comment was submitted with. */
reviewId?: string;
/**
* Forge-neutral discussion id, independent of a file position. Groups general
* (non-file) reply chains into one thread; file threads also carry it.
*/
threadId?: string;
/**
* Resolution state for a thread with no file position (e.g. a GitLab general
* discussion). File threads carry resolution under `location.isResolved`.
*/
threadIsResolved?: boolean;
location?: {
path: string;
line?: number;
@@ -56,15 +75,25 @@ export interface PrPaneActivity {
export interface PrPaneData {
provider: PullRequestProviderMetadata;
/** The forge hosting this change request. */
forge: Forge;
number: number;
repoOwner?: string;
repoName?: string;
/** Neutral project identity (GitLab namespaces nest beyond owner/name). */
projectPath?: string;
title: string;
state: PrState;
url: string;
reviewDecision: "approved" | "changes_requested" | "pending";
awaitingReviewers: string[];
checks: PrPaneCheck[];
/**
* The forge's already-validated native facts, passed through so pane native
* contributions (e.g. GitLab pipeline/approvals) derive their surfaces without
* the neutral data type carrying forge-specific fields.
*/
forgeSpecific?: ForgeSpecificStatusFacts;
activity: PrPaneActivity[];
}
@@ -87,6 +116,7 @@ export function mapPrPaneData(
status: CheckoutPrStatus,
timeline: PullRequestTimeline | null | undefined,
nowMs = Date.now(),
forge: Forge = "github",
): PrPaneData | null {
if (!status) {
return null;
@@ -98,25 +128,34 @@ export function mapPrPaneData(
}
const timelineMatchesStatus = timeline?.prNumber === number;
const provider = toProviderMetadata(forge);
const forgeSpecific = parseClientForgeFacts(status.forgeSpecific);
return {
provider: GITHUB_PROVIDER,
provider,
forge,
number,
repoOwner: status.repoOwner,
repoName: status.repoName,
projectPath: status.projectPath,
title: status.title,
state: derivePrState(status),
url: status.url,
reviewDecision: mapReviewDecision(status.reviewDecision),
// Requested reviewers are intentionally unwired until the server exposes them.
awaitingReviewers: [],
checks: (status.checks ?? []).flatMap(mapCheck),
checks: mapChecks(status, forge),
...(forgeSpecific ? { forgeSpecific } : {}),
activity: timelineMatchesStatus
? timeline.items.flatMap((item) => mapActivity(item, nowMs))
? timeline.items.flatMap((item) => mapActivity(item, nowMs, forge))
: [],
};
}
function toProviderMetadata(forge: Forge): PullRequestProviderMetadata {
return { id: forge, label: getForgePresentation(forge).brandLabel };
}
export function deriveAvatarColor(login: string): string {
return AVATAR_COLORS[hashLogin(login) % AVATAR_COLORS.length];
}
@@ -164,14 +203,25 @@ function derivePrState(status: NonNullable<CheckoutPrStatus>): PrState {
return "open";
}
function mapCheck(check: NonNullable<CheckoutPrStatus>["checks"][number]): PrPaneCheck[] {
function mapChecks(status: NonNullable<CheckoutPrStatus>, forge: Forge): PrPaneCheck[] {
const checks = (status.checks ?? []).flatMap((check) => mapCheck(check, forge));
if (checks.length > 0) {
return checks;
}
return getNativeFallbackChecks(status, forge);
}
function mapCheck(
check: NonNullable<CheckoutPrStatus>["checks"][number],
forge: Forge,
): PrPaneCheck[] {
if (check.url === null) {
return [];
}
return [
{
provider: "github",
provider: forge,
name: check.name,
status: mapCheckStatus(check.status),
url: check.url,
@@ -179,7 +229,7 @@ function mapCheck(check: NonNullable<CheckoutPrStatus>["checks"][number]): PrPan
...(check.duration ? { duration: check.duration } : {}),
...(check.checkRunId !== undefined || check.workflowRunId !== undefined
? {
github: {
detailRef: {
...(check.checkRunId !== undefined ? { checkRunId: check.checkRunId } : {}),
...(check.workflowRunId !== undefined ? { workflowRunId: check.workflowRunId } : {}),
},
@@ -189,22 +239,7 @@ function mapCheck(check: NonNullable<CheckoutPrStatus>["checks"][number]): PrPan
];
}
function mapCheckStatus(status: string): CheckStatus {
if (
status === "success" ||
status === "failure" ||
status === "pending" ||
status === "skipped"
) {
return status;
}
if (status === "cancelled") {
return "skipped";
}
return "pending";
}
function mapActivity(item: PullRequestTimelineItem, nowMs: number): PrPaneActivity[] {
function mapActivity(item: PullRequestTimelineItem, nowMs: number, forge: Forge): PrPaneActivity[] {
if (item.kind === "comment") {
if (item.body.trim() === "") {
return [];
@@ -212,7 +247,7 @@ function mapActivity(item: PullRequestTimelineItem, nowMs: number): PrPaneActivi
return [
{
id: item.id,
provider: "github",
provider: forge,
kind: "comment",
author: item.author,
authorUrl: item.authorUrl,
@@ -222,6 +257,8 @@ function mapActivity(item: PullRequestTimelineItem, nowMs: number): PrPaneActivi
age: formatAge(item.createdAt, nowMs),
url: item.url,
reviewId: item.reviewId,
threadId: item.threadId,
threadIsResolved: item.threadIsResolved,
location: item.location,
},
];
@@ -234,7 +271,7 @@ function mapActivity(item: PullRequestTimelineItem, nowMs: number): PrPaneActivi
return [
{
id: item.id,
provider: "github",
provider: forge,
kind: "review",
author: item.author,
authorUrl: item.authorUrl,
@@ -0,0 +1,160 @@
import { describe, expect, it } from "vitest";
import type { CheckoutPrStatusResponse } from "@getpaseo/protocol/messages";
import {
deriveGitlabApprovals,
deriveGitlabPipelineSummary,
type GitlabMergeFacts,
} from "@/git/forges/gitlab";
import { getNativeFallbackChecks } from "./native-data";
type CheckoutPrStatus = NonNullable<CheckoutPrStatusResponse["payload"]["status"]>;
function gitlabFacts(overrides: Partial<GitlabMergeFacts> = {}): GitlabMergeFacts {
return {
forge: "gitlab",
detailedMergeStatus: "mergeable",
mergeStatus: null,
hasConflicts: false,
blockingDiscussionsResolved: true,
approvalsRequired: 0,
approvalsGiven: 0,
pipelineStatus: null,
pipelineId: null,
pipelineUrl: null,
mergeWhenPipelineSucceeds: false,
...overrides,
};
}
function status(forgeSpecific: CheckoutPrStatus["forgeSpecific"], url: string): CheckoutPrStatus {
return {
forge: "gitea",
number: 7,
url,
title: "Native data",
state: "open",
baseRefName: "main",
headRefName: "feature",
isMerged: false,
isDraft: false,
mergeable: "UNKNOWN",
checks: [],
reviewDecision: null,
...(forgeSpecific ? { forgeSpecific } : {}),
};
}
describe("deriveGitlabPipelineSummary", () => {
it("summarizes a head pipeline, mapping the raw status onto a check status", () => {
expect(
deriveGitlabPipelineSummary(
gitlabFacts({
pipelineStatus: "running",
pipelineId: 306,
pipelineUrl: "https://gitlab.com/group/repo/-/pipelines/306",
}),
),
).toEqual({
id: 306,
status: "pending",
rawStatus: "running",
url: "https://gitlab.com/group/repo/-/pipelines/306",
});
});
it("returns null when the MR has no head pipeline", () => {
expect(deriveGitlabPipelineSummary(gitlabFacts({ pipelineId: null }))).toBeNull();
});
});
describe("deriveGitlabApprovals", () => {
it("surfaces N of M approvals", () => {
expect(deriveGitlabApprovals(gitlabFacts({ approvalsRequired: 2, approvalsGiven: 1 }))).toEqual(
{
given: 1,
required: 2,
},
);
});
it("returns null when no approvals are required", () => {
expect(deriveGitlabApprovals(gitlabFacts({ approvalsRequired: 0 }))).toBeNull();
});
});
describe("getNativeFallbackChecks", () => {
it("surfaces the Gitea aggregate CI status branded with the top-level forge", () => {
expect(
getNativeFallbackChecks(
status(
{ forge: "gitea", mergeable: true, hasMerged: false, ciStatus: "success" },
"https://gitea.com/group/repo/pulls/7",
),
"gitea",
),
).toEqual([
{
provider: "gitea",
name: "CI",
status: "success",
url: "https://gitea.com/group/repo/pulls/7",
},
]);
});
it("keeps Forgejo branding for the Gitea-family aggregate check", () => {
expect(
getNativeFallbackChecks(
status(
{ forge: "gitea", mergeable: true, hasMerged: false, ciStatus: "failure" },
"https://forgejo.example.com/group/repo/pulls/7",
),
"forgejo",
),
).toEqual([
{
provider: "forgejo",
name: "CI",
status: "failure",
url: "https://forgejo.example.com/group/repo/pulls/7",
},
]);
});
it("maps a Gitea 'warning' aggregate to a failing fallback check, not pending", () => {
expect(
getNativeFallbackChecks(
status(
{ forge: "gitea", mergeable: true, hasMerged: false, ciStatus: "warning" },
"https://gitea.com/group/repo/pulls/7",
),
"gitea",
),
).toEqual([
{
provider: "gitea",
name: "CI",
status: "failure",
url: "https://gitea.com/group/repo/pulls/7",
},
]);
});
it("returns no fallback checks for a forge that reports none", () => {
expect(
getNativeFallbackChecks(
status(
{ forge: "gitea", mergeable: true, hasMerged: false, ciStatus: null },
"https://gitea.com/group/repo/pulls/7",
),
"gitea",
),
).toEqual([]);
expect(
getNativeFallbackChecks(
status(gitlabFacts(), "https://gitlab.com/group/repo/-/merge_requests/7"),
"gitlab",
),
).toEqual([]);
});
});
@@ -0,0 +1,34 @@
/**
* Compatibility exports for tests and non-visual derivations. Rich PR-pane
* contributions now come from client forge modules and validate the open
* `forgeSpecific` envelope at runtime.
*/
import type { CheckoutPrStatusResponse } from "@getpaseo/protocol/messages";
import type { Forge } from "@/git/forge";
import { CLIENT_FORGE_LOGIC_MODULES } from "@/git/forges";
import type { PrPaneCheck } from "./data";
type CheckoutPrStatus = NonNullable<CheckoutPrStatusResponse["payload"]["status"]>;
const CLIENT_NATIVE_FALLBACK_CONTRIBUTIONS = CLIENT_FORGE_LOGIC_MODULES.flatMap(
(module) => module.facts?.nativeFallbackChecks ?? [],
);
/**
* Fallback check rows for a status whose forge reports no individual checks.
* Returns [] for forges that don't synthesize one.
*/
export function getNativeFallbackChecks(status: CheckoutPrStatus, forge: Forge): PrPaneCheck[] {
const facts = status.forgeSpecific;
if (!facts) {
return [];
}
const checks: PrPaneCheck[] = [];
for (const contribute of CLIENT_NATIVE_FALLBACK_CONTRIBUTIONS) {
const check = contribute.contribute(facts, status, forge);
if (check) {
checks.push(check);
}
}
return checks;
}
+180 -224
View File
@@ -10,11 +10,7 @@ import {
} from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import {
ChevronDown,
ChevronRight,
CircleCheck,
CircleDot,
CircleSlash,
CircleX,
Copy,
ExternalLink,
@@ -50,6 +46,9 @@ import { useCheckoutGitActionsStore } from "@/git/actions-store";
import { isNative } from "@/constants/platform";
import { useIsCompactFormFactor, WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
import { ICON_SIZE, type Theme } from "@/styles/theme";
import { getForgePresentation } from "@/git/forge";
import { CLIENT_FORGE_VIEW_MODULES } from "@/git/forges/view";
import type { PaneNativeContribution } from "@/git/client-forge-module";
import { PrActivitySkeleton } from "./activity-skeleton";
import {
collapseActivity,
@@ -68,19 +67,28 @@ import {
canAddPullRequestCheckLogsToChat,
} from "./context-attachment";
import { getActivityVerb, getStateLabel } from "./data";
import type { CheckStatus, PrPaneActivity, PrPaneCheck, PrPaneData, PrState } from "./data";
import type { PrPaneActivity, PrPaneCheck, PrPaneData, PrState } from "./data";
import type { ForgeSpecificStatusFacts } from "@/git/merge-capability";
import {
buildPrTimeline,
type PrReviewEntry,
type PrThreadEntry,
type PrTimelineEntry,
} from "./timeline";
import {
CheckStatusIcon,
Section,
SUMMARY_DANGER_ICON,
SUMMARY_SUCCESS_ICON,
SUMMARY_WARNING_ICON,
SummaryPill,
dangerColorMapping,
foregroundMutedColorMapping,
sectionKitStyles,
successColorMapping,
} from "./section-kit";
const ThemedChevronDown = withUnistyles(ChevronDown);
const ThemedChevronRight = withUnistyles(ChevronRight);
const ThemedCircleCheck = withUnistyles(CircleCheck);
const ThemedCircleDot = withUnistyles(CircleDot);
const ThemedCircleSlash = withUnistyles(CircleSlash);
const ThemedCircleX = withUnistyles(CircleX);
const ThemedCopy = withUnistyles(Copy);
const ThemedExternalLink = withUnistyles(ExternalLink);
@@ -95,12 +103,20 @@ const ThemedRotateCw = withUnistyles(RotateCw);
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
const successColorMapping = (theme: Theme) => ({ color: theme.colors.statusSuccess });
const dangerColorMapping = (theme: Theme) => ({ color: theme.colors.statusDanger });
const warningColorMapping = (theme: Theme) => ({ color: theme.colors.statusWarning });
const mergedColorMapping = (theme: Theme) => ({ color: theme.colors.statusMerged });
const CLIENT_PANE_CONTRIBUTIONS: readonly PaneNativeContribution[] =
CLIENT_FORGE_VIEW_MODULES.flatMap((module) => module.paneContributions ?? []);
function resolvePaneContribution(
facts: ForgeSpecificStatusFacts | undefined,
): PaneNativeContribution | null {
if (!facts) {
return null;
}
return CLIENT_PANE_CONTRIBUTIONS.find((contribution) => contribution.guard(facts)) ?? null;
}
type IconColorMapping = typeof foregroundColorMapping;
interface PrStatePresentation {
@@ -115,9 +131,6 @@ const PR_STATE_PRESENTATION: Record<PrState, PrStatePresentation> = {
closed: { Icon: ThemedGitPullRequestClosed, iconColor: dangerColorMapping },
};
const SUMMARY_SUCCESS_ICON = <ThemedCircleCheck size={12} uniProps={successColorMapping} />;
const SUMMARY_DANGER_ICON = <ThemedCircleX size={12} uniProps={dangerColorMapping} />;
const SUMMARY_WARNING_ICON = <ThemedCircleDot size={12} uniProps={warningColorMapping} />;
const SUMMARY_COMMENT_ICON = (
<ThemedMessageSquare size={11} uniProps={foregroundMutedColorMapping} />
);
@@ -133,7 +146,7 @@ function handleMarkdownLinkPress(url: string): boolean {
}
function rowPressableStyle({ hovered }: { hovered?: boolean }) {
return [styles.checkRow, Boolean(hovered) && styles.hoverable];
return [sectionKitStyles.checkRow, Boolean(hovered) && styles.hoverable];
}
function entryHeaderPressableStyle({ hovered }: { hovered?: boolean }) {
@@ -163,8 +176,11 @@ function renderKebabTriggerIcon({ hovered }: { hovered?: boolean }) {
}
function getCheckIdentity(check: PrPaneCheck): string {
if (check.github?.checkRunId !== undefined) {
return `${check.provider}:check-run:${check.github.checkRunId}`;
if (check.detailRef?.checkRunId !== undefined) {
return `${check.provider}:check-run:${check.detailRef.checkRunId}`;
}
if (check.detailRef?.workflowRunId !== undefined) {
return `${check.provider}:workflow-run:${check.detailRef.workflowRunId}`;
}
return `${check.provider}:${check.name}:${check.url}`;
}
@@ -203,9 +219,17 @@ export function PullRequestPane({
const { t } = useTranslation();
const toast = useToast();
const daemonClient = useHostRuntimeClient(serverId);
// COMPAT(githubCheckDetailsRpc): added in v0.1.106, remove after 2026-12-28 once
// all supported clients use checkout.forge.get_check_details.*.
const canFetchGitHubCheckDetails = useSessionStore(
(state) => state.sessions[serverId]?.serverInfo?.features?.githubCheckDetails === true,
);
const canFetchForgeCheckDetails = useSessionStore(
(state) => state.sessions[serverId]?.serverInfo?.features?.forgeCheckDetails === true,
);
const forgeProvidersEnabled = useSessionStore(
(state) => state.sessions[serverId]?.serverInfo?.features?.forgeProviders === true,
);
const addWorkspaceAttachment = useWorkspaceAttachmentsStore(
(state) => state.addWorkspaceAttachment,
);
@@ -281,6 +305,7 @@ export function PullRequestPane({
}
const input = {
provider: data.provider,
forge: data.forge,
pullRequest: { number: data.number, title: data.title, url: data.url },
activity,
};
@@ -298,6 +323,7 @@ export function PullRequestPane({
},
[
addWorkspaceAttachment,
data.forge,
data.number,
data.provider,
data.title,
@@ -313,6 +339,7 @@ export function PullRequestPane({
}
const attachment = buildPullRequestThreadContextAttachment({
provider: data.provider,
forge: data.forge,
pullRequest: { number: data.number, title: data.title, url: data.url },
thread,
});
@@ -326,6 +353,7 @@ export function PullRequestPane({
},
[
addWorkspaceAttachment,
data.forge,
data.number,
data.provider,
data.title,
@@ -345,7 +373,7 @@ export function PullRequestPane({
}
const threads = entry.kind === "thread" ? [entry] : entry.threads;
for (const thread of threads) {
if (thread.location.isResolved === true) {
if (thread.isResolved === true) {
continue;
}
handleAddThreadToChat(thread);
@@ -363,23 +391,32 @@ export function PullRequestPane({
let details = null;
try {
const ref = check.github;
const ref = check.detailRef;
// The neutral forge RPC fetches detail for any forge; fall back to the
// legacy github-only RPC only for GitHub against a daemon that predates
// it. A non-GitHub forge therefore needs the neutral capability present.
const canFetchDetail =
canFetchForgeCheckDetails || (check.provider === "github" && canFetchGitHubCheckDetails);
if (
canFetchGitHubCheckDetails &&
canFetchDetail &&
daemonClient &&
check.provider === "github" &&
ref?.checkRunId !== undefined &&
(ref?.checkRunId !== undefined || ref?.workflowRunId !== undefined) &&
data.repoOwner &&
data.repoName
) {
try {
const payload = await daemonClient.checkoutGithubGetCheckDetails({
const request = {
cwd,
repoOwner: data.repoOwner,
repoName: data.repoName,
checkRunId: ref.checkRunId,
workflowRunId: ref.workflowRunId,
});
};
// COMPAT(githubCheckDetailsRpc): added in v0.1.106, remove after 2026-12-28 once
// all supported clients use checkout.forge.get_check_details.*.
const payload = canFetchForgeCheckDetails
? await daemonClient.checkoutForgeGetCheckDetails(request)
: await daemonClient.checkoutGithubGetCheckDetails(request);
details = payload.success ? payload.details : null;
} catch {
details = null;
@@ -387,6 +424,7 @@ export function PullRequestPane({
}
const attachment = buildPullRequestCheckContextAttachment({
provider: data.provider,
forge: data.forge,
pullRequest: { number: data.number, title: data.title, url: data.url },
check,
githubDetails: details,
@@ -403,9 +441,11 @@ export function PullRequestPane({
},
[
addWorkspaceAttachment,
canFetchForgeCheckDetails,
canFetchGitHubCheckDetails,
cwd,
daemonClient,
data.forge,
data.number,
data.provider,
data.repoName,
@@ -428,6 +468,28 @@ export function PullRequestPane({
const statePresentation = PR_STATE_PRESENTATION[data.state];
const StateIcon = statePresentation.Icon;
const forgePresentation = getForgePresentation(data.forge);
const repoIdentity =
data.projectPath ??
(data.repoOwner && data.repoName ? `${data.repoOwner}/${data.repoName}` : null);
// Native forge surfaces (e.g. GitLab approvals/pipeline) come from a registry
// keyed by the facts-family, so the central render has no per-forge branch.
const nativeContribution = resolvePaneContribution(data.forgeSpecific);
const nativeHeaderMeta = data.forgeSpecific
? nativeContribution?.renderHeaderMeta(data.forgeSpecific)
: null;
const nativeChecksSection = data.forgeSpecific
? nativeContribution?.renderChecksSection(data.forgeSpecific, {
serverId,
cwd,
changeRequestNumber: data.number,
open: checksOpen,
onToggle: handleToggleChecks,
enabled: forgeProvidersEnabled,
canFetchCheckDetails: canFetchForgeCheckDetails,
})
: null;
return (
<View style={styles.root} testID="pr-pane">
@@ -451,7 +513,7 @@ export function PullRequestPane({
accessibilityLabel={
isRefreshing
? t("workspace.git.diff.refreshing")
: t("workspace.git.diff.refreshState")
: t("workspace.git.diff.refreshState", { brand: forgePresentation.brandLabel })
}
testID="pr-pane-refresh"
style={refreshButtonStyle}
@@ -478,16 +540,21 @@ export function PullRequestPane({
<>
<Text style={styles.title} testID="pr-pane-title">
{data.title}
<Text style={styles.titleNumber}> #{data.number}</Text>
<Text style={styles.titleNumber}>
{" "}
{forgePresentation.numberPrefix}
{data.number}
</Text>
</Text>
<View style={styles.metaLine}>
<StateIcon size={14} uniProps={statePresentation.iconColor} />
<Text style={stateLabelStyle(data.state)} testID="pr-pane-state">
{getStateLabel(data.state)}
</Text>
{data.repoOwner && data.repoName ? (
{nativeHeaderMeta}
{repoIdentity ? (
<Text style={styles.repoRef} numberOfLines={1}>
{data.repoOwner}/{data.repoName}
{repoIdentity}
</Text>
) : null}
<View style={hovered ? styles.headerLinkIcon : styles.headerLinkIconHidden}>
@@ -498,50 +565,52 @@ export function PullRequestPane({
)}
</Pressable>
<Section
title="Checks"
open={checksOpen}
onToggle={handleToggleChecks}
summary={
<>
<SummaryPill
count={passed}
icon={SUMMARY_SUCCESS_ICON}
variant="success"
testID="pr-pane-check-passed"
/>
<SummaryPill
count={failed}
icon={SUMMARY_DANGER_ICON}
variant="danger"
testID="pr-pane-check-failed"
/>
<SummaryPill
count={pending}
icon={SUMMARY_WARNING_ICON}
variant="warning"
testID="pr-pane-check-pending"
/>
</>
}
>
{data.checks.length === 0 ? (
<Text style={styles.emptyText}>No checks</Text>
) : (
data.checks.map((check) => {
const checkKey = getCheckIdentity(check);
return (
<CheckRow
key={checkKey}
check={check}
attachEnabled={attachEnabled}
isAddingLogsToChat={loadingCheckKeys.has(checkKey)}
onAddLogsToChat={handleAddCheckLogsToChat}
{nativeChecksSection ?? (
<Section
title="Checks"
open={checksOpen}
onToggle={handleToggleChecks}
summary={
<>
<SummaryPill
count={passed}
icon={SUMMARY_SUCCESS_ICON}
variant="success"
testID="pr-pane-check-passed"
/>
);
})
)}
</Section>
<SummaryPill
count={failed}
icon={SUMMARY_DANGER_ICON}
variant="danger"
testID="pr-pane-check-failed"
/>
<SummaryPill
count={pending}
icon={SUMMARY_WARNING_ICON}
variant="warning"
testID="pr-pane-check-pending"
/>
</>
}
>
{data.checks.length === 0 ? (
<Text style={sectionKitStyles.emptyText}>No checks</Text>
) : (
data.checks.map((check) => {
const checkKey = getCheckIdentity(check);
return (
<CheckRow
key={checkKey}
check={check}
attachEnabled={attachEnabled}
isAddingLogsToChat={loadingCheckKeys.has(checkKey)}
onAddLogsToChat={handleAddCheckLogsToChat}
/>
);
})
)}
</Section>
)}
<View style={styles.divider} />
@@ -572,7 +641,7 @@ export function PullRequestPane({
) : null}
{activityLoading ? <PrActivitySkeleton /> : null}
{!activityLoading && visibleEntries.length === 0 ? (
<Text style={styles.emptyText}>No activity yet</Text>
<Text style={sectionKitStyles.emptyText}>No activity yet</Text>
) : null}
{!activityLoading
? visibleEntries.map(({ entry, collapsed }) => (
@@ -582,6 +651,7 @@ export function PullRequestPane({
collapsed={collapsed}
collapsedEntryIds={collapsedEntryIds}
attachEnabled={attachEnabled}
brandLabel={forgePresentation.brandLabel}
onAddToChat={handleAddActivityToChat}
onAddThreadToChat={handleAddThreadToChat}
onToggleCollapsed={handleToggleEntryCollapsed}
@@ -601,58 +671,6 @@ function stateLabelStyle(state: PrState) {
return styles.stateLabelClosed;
}
interface SectionProps {
title: string;
open: boolean;
onToggle: () => void;
summary: React.ReactNode;
children: React.ReactNode;
}
function Section({ title, open, onToggle, summary, children }: SectionProps) {
return (
<View>
<Pressable style={styles.sectionHeader} onPress={onToggle}>
{open ? (
<ThemedChevronDown size={14} uniProps={foregroundMutedColorMapping} />
) : (
<ThemedChevronRight size={14} uniProps={foregroundMutedColorMapping} />
)}
<Text style={styles.sectionTitle}>{title}</Text>
<View style={styles.summaryWrap}>{summary}</View>
</Pressable>
{open ? <View style={styles.sectionBody}>{children}</View> : null}
</View>
);
}
function SummaryPill({
count,
icon,
variant,
testID,
}: {
count: number;
icon: React.ReactNode;
variant: "success" | "danger" | "warning" | "muted";
testID?: string;
}) {
if (count === 0) return null;
return (
<View style={styles.summaryPill} testID={testID}>
{icon}
<Text style={summaryPillTextStyle(variant)}>{count}</Text>
</View>
);
}
function summaryPillTextStyle(variant: "success" | "danger" | "warning" | "muted") {
if (variant === "success") return styles.summaryPillSuccessText;
if (variant === "danger") return styles.summaryPillDangerText;
if (variant === "warning") return styles.summaryPillWarningText;
return styles.summaryPillMutedText;
}
function CheckRow({
check,
attachEnabled,
@@ -677,15 +695,15 @@ function CheckRow({
return (
<Pressable onPress={handlePress} style={rowPressableStyle}>
<CheckStatusIcon status={check.status} />
<Text style={styles.checkName} numberOfLines={1}>
<Text style={sectionKitStyles.checkName} numberOfLines={1}>
{check.name}
</Text>
{check.workflow && (
<Text style={styles.checkWorkflow} numberOfLines={1}>
<Text style={sectionKitStyles.checkWorkflow} numberOfLines={1}>
{check.workflow}
</Text>
)}
<View style={styles.checkTrailing}>
<View style={sectionKitStyles.checkTrailing}>
{attachEnabled && canAddPullRequestCheckLogsToChat(check) ? (
<Button
variant="ghost"
@@ -698,21 +716,15 @@ function CheckRow({
{isAddingLogsToChat ? "Adding..." : "Add to chat"}
</Button>
) : null}
{check.duration && <Text style={styles.checkDuration}>{check.duration}</Text>}
{check.duration && <Text style={sectionKitStyles.checkDuration}>{check.duration}</Text>}
</View>
</Pressable>
);
}
function CheckStatusIcon({ status }: { status: CheckStatus }) {
if (status === "success") return <ThemedCircleCheck size={14} uniProps={successColorMapping} />;
if (status === "failure") return <ThemedCircleX size={14} uniProps={dangerColorMapping} />;
if (status === "pending") return <ThemedCircleDot size={14} uniProps={warningColorMapping} />;
return <ThemedCircleSlash size={14} uniProps={foregroundMutedColorMapping} />;
}
interface TimelineEntryCallbacks {
attachEnabled: boolean;
brandLabel: string;
onAddToChat: (activity: PrPaneActivity) => void;
onAddThreadToChat: (thread: PrThreadEntry) => void;
onToggleCollapsed: (entryId: string, collapsed: boolean) => void;
@@ -758,15 +770,18 @@ function ActivityKebab({
activity,
visible,
attachEnabled,
brandLabel,
onMenuOpenChange,
onAddToChat,
}: {
activity: PrPaneActivity;
visible: boolean;
attachEnabled: boolean;
brandLabel: string;
onMenuOpenChange: (open: boolean) => void;
onAddToChat: (activity: PrPaneActivity) => void;
}) {
const { t } = useTranslation();
const handleAddToChat = useCallback(() => onAddToChat(activity), [activity, onAddToChat]);
const handleCopy = useCallback(() => {
void writeMarkdownToRichClipboard(activity.body, getDefaultMarkdownClipboardEnvironment());
@@ -797,7 +812,7 @@ function ActivityKebab({
</DropdownMenuItem>
) : null}
<DropdownMenuItem leading={OPEN_MENU_ICON} onSelect={handleOpen}>
Open on GitHub
{t("workspace.git.pr.actions.openOn", { brand: brandLabel })}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@@ -887,6 +902,7 @@ function SingleActivityCard({
entry,
collapsed,
attachEnabled,
brandLabel,
onAddToChat,
onToggleCollapsed,
}: TimelineEntryCallbacks & {
@@ -920,6 +936,7 @@ function SingleActivityCard({
activity={activity}
visible={actionsVisible}
attachEnabled={attachEnabled}
brandLabel={brandLabel}
onMenuOpenChange={setMenuOpen}
onAddToChat={onAddToChat}
/>
@@ -942,6 +959,7 @@ function SingleActivityCard({
activity={activity}
visible={actionsVisible}
attachEnabled={attachEnabled}
brandLabel={brandLabel}
onMenuOpenChange={setMenuOpen}
onAddToChat={onAddToChat}
/>
@@ -974,6 +992,7 @@ function ThreadCard({
entry,
collapsed,
attachEnabled,
brandLabel,
onAddToChat,
onAddThreadToChat,
onToggleCollapsed,
@@ -987,6 +1006,7 @@ function ThreadCard({
thread={entry}
collapsed={collapsed}
attachEnabled={attachEnabled}
brandLabel={brandLabel}
onAddToChat={onAddToChat}
onAddThreadToChat={onAddThreadToChat}
onToggleCollapsed={onToggleCollapsed}
@@ -1000,6 +1020,7 @@ function ReviewCard({
collapsed,
collapsedEntryIds,
attachEnabled,
brandLabel,
onAddToChat,
onAddThreadToChat,
onToggleCollapsed,
@@ -1047,6 +1068,7 @@ function ReviewCard({
activity={review}
visible={actionsVisible}
attachEnabled={attachEnabled}
brandLabel={brandLabel}
onMenuOpenChange={setMenuOpen}
onAddToChat={onAddToChat}
/>
@@ -1079,6 +1101,7 @@ function ReviewCard({
thread={thread}
collapsed={collapsedEntryIds.has(thread.id)}
attachEnabled={attachEnabled}
brandLabel={brandLabel}
onAddToChat={onAddToChat}
onAddThreadToChat={onAddThreadToChat}
onToggleCollapsed={onToggleCollapsed}
@@ -1097,6 +1120,7 @@ function ThreadBlock({
thread,
collapsed,
attachEnabled,
brandLabel,
onAddToChat,
onAddThreadToChat,
onToggleCollapsed,
@@ -1104,10 +1128,12 @@ function ThreadBlock({
thread: PrThreadEntry;
collapsed: boolean;
attachEnabled: boolean;
brandLabel: string;
onAddToChat: (activity: PrPaneActivity) => void;
onAddThreadToChat: (thread: PrThreadEntry) => void;
onToggleCollapsed: (entryId: string, collapsed: boolean) => void;
}) {
const { t } = useTranslation();
const { actionsVisible, handlePointerEnter, handlePointerLeave, setMenuOpen } =
useRevealOnHover();
const handleHeaderPress = useCallback(() => {
@@ -1127,10 +1153,12 @@ function ThreadBlock({
<View onPointerEnter={handlePointerEnter} onPointerLeave={handlePointerLeave}>
<Pressable onPress={handleHeaderPress} style={threadHeaderPressableStyle}>
<Text style={styles.threadPath} numberOfLines={1}>
{formatPullRequestThreadPath(thread.location)}
{thread.location
? formatPullRequestThreadPath(thread.location)
: t("workspace.git.pr.thread.discussion")}
</Text>
{thread.location.isResolved ? <StatusBadge label="Resolved" variant="success" /> : null}
{thread.location.isOutdated ? <StatusBadge label="Outdated" /> : null}
{thread.isResolved ? <StatusBadge label="Resolved" variant="success" /> : null}
{thread.location?.isOutdated ? <StatusBadge label="Outdated" /> : null}
<View style={styles.headerTrailing}>
{collapsed ? (
<View style={styles.threadCount}>
@@ -1152,7 +1180,7 @@ function ThreadBlock({
</DropdownMenuTrigger>
<DropdownMenuContent align="end" width={200}>
<DropdownMenuItem leading={OPEN_MENU_ICON} onSelect={handleOpenThread}>
Open on GitHub
{t("workspace.git.pr.actions.openOn", { brand: brandLabel })}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@@ -1161,7 +1189,12 @@ function ThreadBlock({
</Pressable>
{collapsed ? null : (
<>
<ThreadComment comment={root} attachEnabled={attachEnabled} onAddToChat={onAddToChat} />
<ThreadComment
comment={root}
attachEnabled={attachEnabled}
brandLabel={brandLabel}
onAddToChat={onAddToChat}
/>
{replies.length > 0 ? (
<View style={styles.replyRail}>
{replies.map((reply) => (
@@ -1169,6 +1202,7 @@ function ThreadBlock({
<ThreadComment
comment={reply}
attachEnabled={attachEnabled}
brandLabel={brandLabel}
onAddToChat={onAddToChat}
contentStyle={styles.replyThreadComment}
/>
@@ -1205,11 +1239,13 @@ function threadCommentStyle(contentStyle?: ViewStyle) {
function ThreadComment({
comment,
attachEnabled,
brandLabel,
onAddToChat,
contentStyle,
}: {
comment: PrPaneActivity;
attachEnabled: boolean;
brandLabel: string;
onAddToChat: (activity: PrPaneActivity) => void;
contentStyle?: ViewStyle;
}) {
@@ -1227,6 +1263,7 @@ function ThreadComment({
activity={comment}
visible={actionsVisible}
attachEnabled={attachEnabled}
brandLabel={brandLabel}
onMenuOpenChange={setMenuOpen}
onAddToChat={onAddToChat}
/>
@@ -1357,87 +1394,6 @@ const styles = StyleSheet.create((theme) => ({
alignItems: "center",
justifyContent: "center",
},
sectionHeader: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
},
sectionTitle: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.medium,
color: theme.colors.foregroundMuted,
},
sectionBody: {
paddingBottom: theme.spacing[3],
},
summaryWrap: {
marginLeft: "auto",
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
summaryPill: {
flexDirection: "row",
alignItems: "center",
gap: 3,
},
summaryPillSuccessText: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal,
color: theme.colors.statusSuccess,
},
summaryPillDangerText: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal,
color: theme.colors.statusDanger,
},
summaryPillWarningText: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal,
color: theme.colors.statusWarning,
},
summaryPillMutedText: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal,
color: theme.colors.foregroundMuted,
},
emptyText: {
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
},
checkRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
minHeight: 32,
},
checkName: {
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.normal,
color: theme.colors.foreground,
flexShrink: 1,
},
checkWorkflow: {
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
flexShrink: 1,
},
checkTrailing: {
marginLeft: "auto",
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
checkDuration: {
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
},
checkAddButton: {
paddingVertical: 0,
},
@@ -11,3 +11,20 @@ export function prPaneTimelineQueryKey({
}) {
return [prPaneTimelineQueryKind, serverId, cwd, prNumber] as const;
}
export const prPanePipelineQueryKind = "prPanePipeline";
export function prPanePipelineQueryKey({
serverId,
cwd,
pipelineId,
changeRequestNumber,
}: {
serverId: string;
cwd: string;
pipelineId: number | null;
/** MR iid the pipeline is fetched by; part of the key since the fetch routes by it. */
changeRequestNumber: number;
}) {
return [prPanePipelineQueryKind, serverId, cwd, pipelineId, changeRequestNumber] as const;
}
@@ -0,0 +1,176 @@
import React, { type ReactNode } from "react";
import { Pressable, Text, View } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import {
ChevronDown,
ChevronRight,
CircleCheck,
CircleDot,
CircleSlash,
CircleX,
} from "lucide-react-native";
import type { Theme } from "@/styles/theme";
import type { CheckStatus } from "./check-status";
const ThemedChevronDown = withUnistyles(ChevronDown);
const ThemedChevronRight = withUnistyles(ChevronRight);
const ThemedCircleCheck = withUnistyles(CircleCheck);
const ThemedCircleDot = withUnistyles(CircleDot);
const ThemedCircleSlash = withUnistyles(CircleSlash);
const ThemedCircleX = withUnistyles(CircleX);
export const foregroundMutedColorMapping = (theme: Theme) => ({
color: theme.colors.foregroundMuted,
});
export const successColorMapping = (theme: Theme) => ({ color: theme.colors.statusSuccess });
export const dangerColorMapping = (theme: Theme) => ({ color: theme.colors.statusDanger });
export const warningColorMapping = (theme: Theme) => ({ color: theme.colors.statusWarning });
export const SUMMARY_SUCCESS_ICON = <ThemedCircleCheck size={12} uniProps={successColorMapping} />;
export const SUMMARY_DANGER_ICON = <ThemedCircleX size={12} uniProps={dangerColorMapping} />;
export const SUMMARY_WARNING_ICON = <ThemedCircleDot size={12} uniProps={warningColorMapping} />;
interface SectionProps {
title: string;
open: boolean;
onToggle: () => void;
summary: ReactNode;
children: ReactNode;
}
export function Section({ title, open, onToggle, summary, children }: SectionProps) {
return (
<View>
<Pressable style={sectionKitStyles.sectionHeader} onPress={onToggle}>
{open ? (
<ThemedChevronDown size={14} uniProps={foregroundMutedColorMapping} />
) : (
<ThemedChevronRight size={14} uniProps={foregroundMutedColorMapping} />
)}
<Text style={sectionKitStyles.sectionTitle}>{title}</Text>
<View style={sectionKitStyles.summaryWrap}>{summary}</View>
</Pressable>
{open ? <View style={sectionKitStyles.sectionBody}>{children}</View> : null}
</View>
);
}
export type SummaryPillVariant = "success" | "danger" | "warning" | "muted";
export function SummaryPill({
count,
icon,
variant,
testID,
}: {
count: number;
icon: ReactNode;
variant: SummaryPillVariant;
testID?: string;
}) {
if (count === 0) return null;
return (
<View style={sectionKitStyles.summaryPill} testID={testID}>
{icon}
<Text style={summaryPillTextStyle(variant)}>{count}</Text>
</View>
);
}
function summaryPillTextStyle(variant: SummaryPillVariant) {
if (variant === "success") return sectionKitStyles.summaryPillSuccessText;
if (variant === "danger") return sectionKitStyles.summaryPillDangerText;
if (variant === "warning") return sectionKitStyles.summaryPillWarningText;
return sectionKitStyles.summaryPillMutedText;
}
export function CheckStatusIcon({ status }: { status: CheckStatus }) {
if (status === "success") return <ThemedCircleCheck size={14} uniProps={successColorMapping} />;
if (status === "failure") return <ThemedCircleX size={14} uniProps={dangerColorMapping} />;
if (status === "pending") return <ThemedCircleDot size={14} uniProps={warningColorMapping} />;
return <ThemedCircleSlash size={14} uniProps={foregroundMutedColorMapping} />;
}
export const sectionKitStyles = StyleSheet.create((theme) => ({
sectionHeader: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
},
sectionTitle: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.medium,
color: theme.colors.foregroundMuted,
},
sectionBody: {
paddingBottom: theme.spacing[3],
},
summaryWrap: {
marginLeft: "auto",
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
summaryPill: {
flexDirection: "row",
alignItems: "center",
gap: 3,
},
summaryPillSuccessText: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal,
color: theme.colors.statusSuccess,
},
summaryPillDangerText: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal,
color: theme.colors.statusDanger,
},
summaryPillWarningText: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal,
color: theme.colors.statusWarning,
},
summaryPillMutedText: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal,
color: theme.colors.foregroundMuted,
},
emptyText: {
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
},
checkRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
minHeight: 32,
},
checkName: {
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.normal,
color: theme.colors.foreground,
flexShrink: 1,
},
checkWorkflow: {
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
flexShrink: 1,
},
checkTrailing: {
marginLeft: "auto",
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
checkDuration: {
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
},
}));
@@ -1,5 +1,15 @@
import { GitHubIcon } from "@/components/icons/github-icon";
import { getForgeIconComponent } from "@/git/forge-icon";
import { getForgePresentation, type Forge } from "@/git/forge";
export function PullRequestTabIcon({ size, color }: { size: number; color: string }) {
return <GitHubIcon size={size} color={color} />;
export function PullRequestTabIcon({
forge,
size,
color,
}: {
forge: Forge;
size: number;
color: string;
}) {
const Icon = getForgeIconComponent(getForgePresentation(forge).icon);
return <Icon size={size} color={color} />;
}
@@ -45,12 +45,33 @@ describe("buildPrTimeline", () => {
kind: "thread",
id: "thread:PRRT_1",
location: { path: "src/a.ts", line: 12, threadId: "PRRT_1", isResolved: false },
isResolved: false,
comments: [root, reply],
},
{ kind: "single", id: "c2", activity: between },
]);
});
it("groups comments sharing a top-level threadId with no location into one thread", () => {
const root = activity({ id: "n1", threadId: "disc-1" });
const between = activity({ id: "c1" });
const reply = activity({ id: "n2", author: "bob", threadId: "disc-1" });
expect(buildPrTimeline([root, between, reply])).toEqual([
{ kind: "thread", id: "thread:disc-1", comments: [root, reply] },
{ kind: "single", id: "c1", activity: between },
]);
});
it("surfaces general-discussion resolution as thread isResolved without a location", () => {
const root = activity({ id: "g1", threadId: "disc-2", threadIsResolved: true });
const reply = activity({ id: "g2", author: "bob", threadId: "disc-2", threadIsResolved: true });
expect(buildPrTimeline([root, reply])).toEqual([
{ kind: "thread", id: "thread:disc-2", isResolved: true, comments: [root, reply] },
]);
});
it("keeps located comments without a threadId as single entries", () => {
const located = activity({ id: "c1", location: { path: "src/a.ts", line: 3 } });
@@ -5,7 +5,14 @@ export type PrThreadLocation = NonNullable<PrPaneActivity["location"]>;
export interface PrThreadEntry {
kind: "thread";
id: string;
location: PrThreadLocation;
/** Absent for general (non-file) discussion threads that carry no position. */
location?: PrThreadLocation;
/**
* Thread resolution state, unified across file threads (`location.isResolved`)
* and general discussions (`threadIsResolved`). Absent when the forge exposes no
* resolution for the thread.
*/
isResolved?: boolean;
comments: PrPaneActivity[];
}
@@ -23,8 +30,10 @@ export type PrTimelineEntry =
/**
* Builds the GitHub-style nested timeline:
* - comments sharing a `location.threadId` collapse into one thread entry
* (root comment + replies), placed at the first comment's position;
* - comments sharing a thread id collapse into one thread entry (root comment +
* replies), placed at the first comment's position. The id is the top-level
* `threadId` (forge-neutral, used by GitLab general discussions) falling back
* to `location.threadId` (file-position threads);
* - threads whose root comment carries a `reviewId` nest under that review
* (GitHub's explicit signal: PullRequestReviewComment.pullRequestReview);
* - everything else stays a standalone entry in original order.
@@ -39,8 +48,8 @@ function groupThreads(activities: readonly PrPaneActivity[]): PrTimelineEntry[]
const threadsById = new Map<string, PrThreadEntry>();
for (const activity of activities) {
const threadId = activity.location?.threadId;
if (!activity.location || !threadId) {
const threadId = activity.threadId ?? activity.location?.threadId;
if (!threadId) {
entries.push({ kind: "single", id: activity.id, activity });
continue;
}
@@ -51,10 +60,12 @@ function groupThreads(activities: readonly PrPaneActivity[]): PrTimelineEntry[]
continue;
}
const resolved = activity.location?.isResolved ?? activity.threadIsResolved;
const thread: PrThreadEntry = {
kind: "thread",
id: `thread:${threadId}`,
location: activity.location,
...(activity.location ? { location: activity.location } : {}),
...(resolved !== undefined ? { isResolved: resolved } : {}),
comments: [activity],
};
threadsById.set(threadId, thread);
@@ -38,6 +38,7 @@ const githubStatus: CheckoutPrStatus["github"] = {
function prStatus(overrides: Partial<CheckoutPrStatus> = {}): CheckoutPrStatus {
return {
forge: "github",
number: 42,
url: "https://github.com/getpaseo/paseo/pull/42",
title: "Wire real PR pane data",
@@ -377,6 +378,34 @@ describe("selectPrPaneState", () => {
expect(state.data?.activity).toEqual([]);
});
it("passes GitLab native facts through the selected pane data", () => {
const gitlabFacts = {
forge: "gitlab" as const,
detailedMergeStatus: "mergeable",
hasConflicts: false,
blockingDiscussionsResolved: true,
approvalsRequired: 2,
approvalsGiven: 1,
pipelineStatus: null,
pipelineId: null,
pipelineUrl: null,
mergeWhenPipelineSucceeds: false,
};
const state = selectPrPaneState({
...baseSelectInput,
forge: "gitlab",
status: prStatus({
forge: "gitlab",
github: undefined,
forgeSpecific: gitlabFacts,
}),
timelinePayload: timelinePayload(),
});
expect(state.data?.provider).toEqual({ id: "gitlab", label: "GitLab" });
expect(state.data?.forgeSpecific).toEqual({ ...gitlabFacts, mergeStatus: null });
});
it("returns null data when the consumer disabled timeline rendering", () => {
const state = selectPrPaneState({
...baseSelectInput,
@@ -8,6 +8,7 @@ import type {
} from "@getpaseo/protocol/messages";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { useCheckoutPrStatusQuery } from "@/git/use-pr-status-query";
import type { Forge } from "@/git/forge";
import { i18n } from "@/i18n/i18next";
import { mapPrPaneData, type PrPaneData } from "./data";
import { prPaneTimelineQueryKey } from "./query-keys";
@@ -25,6 +26,7 @@ export interface UsePrPaneDataOptions {
export interface UsePrPaneDataResult {
data: PrPaneData | null;
forge: Forge;
prNumber: number | null;
isLoading: boolean;
activityLoading: boolean;
@@ -150,6 +152,7 @@ export interface SelectPrPaneStateInput {
statusIsLoading: boolean;
statusIsFetching: boolean;
githubFeaturesEnabled: boolean;
forge?: Forge;
timelineEnabled: boolean;
shouldFetchTimeline: boolean;
timelinePayload: PullRequestTimeline | undefined;
@@ -165,7 +168,7 @@ export function selectPrPaneState(input: SelectPrPaneStateInput): UsePrPaneDataR
const data =
identity.prNumber === null || !input.timelineEnabled
? null
: mapPrPaneData(input.status, input.timelinePayload);
: mapPrPaneData(input.status, input.timelinePayload, undefined, input.forge ?? "github");
const statusRefreshing = input.statusIsFetching && !input.statusIsLoading;
const timelineRefreshing = input.timelineIsFetching && !input.timelineIsLoading;
const timelinePending =
@@ -173,6 +176,7 @@ export function selectPrPaneState(input: SelectPrPaneStateInput): UsePrPaneDataR
return {
data,
forge: input.forge ?? "github",
prNumber: identity.prNumber,
isLoading: input.statusIsLoading || timelinePending,
activityLoading: timelinePending,
@@ -262,6 +266,7 @@ export function usePrPaneData({
statusIsLoading: checkoutPrStatus.isLoading,
statusIsFetching: checkoutPrStatus.isFetching,
githubFeaturesEnabled,
forge: checkoutPrStatus.forge,
timelineEnabled,
shouldFetchTimeline,
timelinePayload: timelineQuery.data,
@@ -0,0 +1,87 @@
import { useMemo } from "react";
import type { CheckoutPipeline } from "@getpaseo/protocol/messages";
import { useFetchQuery } from "@/data/query";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { prPanePipelineQueryKey } from "./query-keys";
/** Poll cadence for an in-progress pipeline; finished pipelines are immutable. */
const LIVE_PIPELINE_REFETCH_MS = 15_000;
const FINISHED_PIPELINE_STALE_MS = 24 * 60 * 60 * 1_000;
export interface UseGitLabPipelineOptions {
serverId: string;
cwd: string;
pipelineId: number | null;
/** MR iid, so the fetch resolves a fork/detached head pipeline correctly. */
changeRequestNumber: number;
enabled: boolean;
/** True while the pipeline is still running, so jobs keep refreshing. */
live: boolean;
}
export interface UseGitLabPipelineResult {
pipeline: CheckoutPipeline | null;
isLoading: boolean;
isFetching: boolean;
/**
* True while the query is serving the previous change request's data as a
* placeholder after the query key changed (keepPreviousData). Callers use it
* to avoid rendering a stale job breakdown for the newly selected MR.
*/
isPlaceholderData: boolean;
error: Error | null;
}
/**
* Fetches a GitLab pipeline through the existing forge-routed check-details RPC.
* The pipeline id is carried in checkRunId; GitLab resolves its project from cwd.
*/
export function useGitLabPipeline({
serverId,
cwd,
pipelineId,
changeRequestNumber,
enabled,
live,
}: UseGitLabPipelineOptions): UseGitLabPipelineResult {
const daemonClient = useHostRuntimeClient(serverId);
const isConnected = useHostRuntimeIsConnected(serverId);
const shouldFetch = enabled && !!daemonClient && isConnected && !!cwd && pipelineId !== null;
const query = useFetchQuery<CheckoutPipeline | null>({
queryKey: useMemo(
() => prPanePipelineQueryKey({ serverId, cwd, pipelineId, changeRequestNumber }),
[serverId, cwd, pipelineId, changeRequestNumber],
),
queryFn: async () => {
if (!daemonClient || pipelineId === null) {
return null;
}
const payload = await daemonClient.checkoutForgeGetCheckDetails({
cwd,
checkRunId: pipelineId,
changeRequestNumber,
});
// A failed fetch must surface as a query error so the section shows its
// error state; null is reserved for a successful response with no pipeline.
if (!payload.success) {
throw new Error(payload.error?.message ?? "Could not load pipeline jobs");
}
return payload.details?.pipeline ?? null;
},
enabled: shouldFetch,
dataShape: "list",
staleTimeMs: live ? 0 : FINISHED_PIPELINE_STALE_MS,
refetchInterval: live && shouldFetch ? LIVE_PIPELINE_REFETCH_MS : false,
refetchOnReconnect: false,
refetchOnWindowFocus: false,
});
return {
pipeline: query.data ?? null,
isLoading: shouldFetch && query.isLoading,
isFetching: query.isFetching,
isPlaceholderData: query.isPlaceholderData,
error: query.error,
};
}
+45 -1
View File
@@ -7,7 +7,10 @@ import {
invalidateCheckoutGitQueriesForClient,
invalidateCheckoutGitQueriesForServer,
} from "@/git/query-keys";
import { prPaneTimelineQueryKey } from "@/git/pull-request-panel/query-keys";
import {
prPanePipelineQueryKey,
prPaneTimelineQueryKey,
} from "@/git/pull-request-panel/query-keys";
describe("checkout query keys", () => {
const serverId = "server-1";
@@ -27,10 +30,25 @@ describe("checkout query keys", () => {
queryClient.setQueryData(prPaneTimelineQueryKey({ serverId, cwd, prNumber: 13 }), {
items: [],
});
queryClient.setQueryData(
prPanePipelineQueryKey({ serverId, cwd, pipelineId: 9001, changeRequestNumber: 1 }),
{
stages: [],
},
);
queryClient.setQueryData(
prPaneTimelineQueryKey({ serverId, cwd: "/tmp/other", prNumber: 12 }),
{ items: [] },
);
queryClient.setQueryData(
prPanePipelineQueryKey({
serverId,
cwd: "/tmp/other",
pipelineId: 9001,
changeRequestNumber: 1,
}),
{ stages: [] },
);
await invalidateCheckoutGitQueriesForClient(queryClient, { serverId, cwd });
@@ -52,11 +70,26 @@ describe("checkout query keys", () => {
queryClient.getQueryState(prPaneTimelineQueryKey({ serverId, cwd, prNumber: 13 }))
?.isInvalidated,
).toBe(true);
expect(
queryClient.getQueryState(
prPanePipelineQueryKey({ serverId, cwd, pipelineId: 9001, changeRequestNumber: 1 }),
)?.isInvalidated,
).toBe(true);
expect(
queryClient.getQueryState(
prPaneTimelineQueryKey({ serverId, cwd: "/tmp/other", prNumber: 12 }),
)?.isInvalidated,
).toBe(false);
expect(
queryClient.getQueryState(
prPanePipelineQueryKey({
serverId,
cwd: "/tmp/other",
pipelineId: 9001,
changeRequestNumber: 1,
}),
)?.isInvalidated,
).toBe(false);
queryClient.clear();
});
@@ -72,6 +105,12 @@ describe("checkout query keys", () => {
queryClient.setQueryData(prPaneTimelineQueryKey({ serverId, cwd, prNumber: 12 }), {
items: [],
});
queryClient.setQueryData(
prPanePipelineQueryKey({ serverId, cwd, pipelineId: 9001, changeRequestNumber: 1 }),
{
stages: [],
},
);
// Subscription-fed diff queries are deliberately not part of the server-wide sweep.
queryClient.setQueryData(checkoutDiffQueryKey(serverId, cwd, "base", "main", true), {
files: [],
@@ -93,6 +132,11 @@ describe("checkout query keys", () => {
queryClient.getQueryState(prPaneTimelineQueryKey({ serverId, cwd, prNumber: 12 }))
?.isInvalidated,
).toBe(true);
expect(
queryClient.getQueryState(
prPanePipelineQueryKey({ serverId, cwd, pipelineId: 9001, changeRequestNumber: 1 }),
)?.isInvalidated,
).toBe(true);
expect(
queryClient.getQueryState(checkoutDiffQueryKey(serverId, cwd, "base", "main", true))
?.isInvalidated,
+18 -5
View File
@@ -1,5 +1,5 @@
import type { Query, QueryClient } from "@tanstack/react-query";
import { prPaneTimelineQueryKind } from "./pull-request-panel/query-keys";
import { prPanePipelineQueryKind, prPaneTimelineQueryKind } from "./pull-request-panel/query-keys";
interface CheckoutQueryIdentity {
serverId: string;
@@ -65,6 +65,9 @@ export async function invalidateCheckoutGitQueriesForClient(
queryClient.invalidateQueries({
predicate: checkoutQueryPredicate(prPaneTimelineQueryKind, identity),
}),
queryClient.invalidateQueries({
predicate: checkoutQueryPredicate(prPanePipelineQueryKind, identity),
}),
]);
}
@@ -75,7 +78,12 @@ export async function invalidateCheckoutGitQueriesForServer(
queryClient: QueryClient,
serverId: string,
) {
const kinds = ["checkoutStatus", "checkoutPrStatus", prPaneTimelineQueryKind];
const kinds = [
"checkoutStatus",
"checkoutPrStatus",
prPaneTimelineQueryKind,
prPanePipelineQueryKind,
];
await Promise.all(
kinds.map((kind) =>
queryClient.invalidateQueries({ predicate: checkoutQueryPredicate(kind, { serverId }) }),
@@ -87,9 +95,14 @@ export async function invalidatePrPaneTimelineForCheckout(
queryClient: QueryClient,
identity: CheckoutQueryIdentity,
) {
await queryClient.invalidateQueries({
predicate: checkoutQueryPredicate(prPaneTimelineQueryKind, identity),
});
await Promise.all([
queryClient.invalidateQueries({
predicate: checkoutQueryPredicate(prPaneTimelineQueryKind, identity),
}),
queryClient.invalidateQueries({
predicate: checkoutQueryPredicate(prPanePipelineQueryKind, identity),
}),
]);
}
function checkoutQueryPredicate(
+150 -101
View File
@@ -1,6 +1,9 @@
import { useState, useCallback, useEffect, useMemo, type ReactElement } from "react";
import AsyncStorage from "@react-native-async-storage/async-storage";
import { useTranslation } from "react-i18next";
import type { Theme } from "@/styles/theme";
import { getForgePresentation, type Forge } from "@/git/forge";
import { ForgeBrandIcon, getForgeBrandColorMapping } from "@/git/forge-icon";
import { type CheckoutGitActionStatus, useCheckoutGitActionsStore } from "@/git/actions-store";
import { type CheckoutStatusPayload, useCheckoutStatusQuery } from "@/git/use-status-query";
import { type CheckoutPrStatusPayload, useCheckoutPrStatusQuery } from "@/git/use-pr-status-query";
@@ -11,6 +14,7 @@ import {
type GitAction,
type GitActions,
} from "@/git/policy";
import { deriveMergeCapability } from "@/git/merge-capability";
import type { CheckoutPrMergeMethod } from "@getpaseo/protocol/messages";
import { openExternalUrl } from "@/utils/open-external-url";
import { useToast } from "@/contexts/toast-context";
@@ -26,6 +30,29 @@ import { resolveWorkspaceMapKeyByIdentity } from "@/utils/workspace-identity";
export type { GitActionId, GitAction, GitActions } from "@/git/policy";
const forgeMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
/**
* The leading icon for every change-request action (create/view/merge) is the
* forge's brand mark, tinted in its brand color (the GitLab tanuki orange, etc).
* GitHub and unknown forges have no brand color and render in a neutral tone.
* The merge variants all share this one icon, so we build it once.
*/
function renderForgePrIcon(forge: Forge): ReactElement {
const icon = getForgePresentation(forge).icon;
return (
<ForgeBrandIcon
iconKind={icon}
size={16}
uniProps={getForgeBrandColorMapping(icon) ?? forgeMutedColorMapping}
/>
);
}
function forgeVocabulary(forge: Forge): { context: "mr" | undefined } {
return { context: getForgePresentation(forge).changeRequestContext };
}
function openURLInNewTab(url: string): void {
void openExternalUrl(url);
}
@@ -144,11 +171,6 @@ interface UseGitActionsInput {
pull: ReactElement;
push: ReactElement;
pullAndPush: ReactElement;
viewPr: ReactElement;
createPr: ReactElement;
mergePrSquash: ReactElement;
mergePrMerge: ReactElement;
mergePrRebase: ReactElement;
merge: ReactElement;
mergeFromBase: ReactElement;
archive: ReactElement;
@@ -281,11 +303,16 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
const hasUncommittedChanges = Boolean(gitStatus?.isDirty);
const { status: prStatus, githubFeaturesEnabled } = useCheckoutPrStatusQuery({
const {
status: prStatus,
githubFeaturesEnabled,
forge,
} = useCheckoutPrStatusQuery({
serverId,
cwd,
enabled: isGit,
});
const prIcon = useMemo(() => renderForgePrIcon(forge), [forge]);
const baseRefLabel = useMemo(
() => formatBaseRefLabel(baseRef, t("workspace.git.diff.base")),
[baseRef, t],
@@ -403,7 +430,9 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
const runMergeBranch = useCheckoutGitActionsStore((s) => s.mergeBranch);
const runMergeFromBase = useCheckoutGitActionsStore((s) => s.mergeFromBase);
const githubAutoMergeActionsEnabled = useSessionStore(
(s) => s.sessions[serverId]?.serverInfo?.features?.checkoutGithubSetAutoMerge === true,
(s) =>
s.sessions[serverId]?.serverInfo?.features?.checkoutForgeSetAutoMerge === true ||
s.sessions[serverId]?.serverInfo?.features?.checkoutGithubSetAutoMerge === true,
);
const toastActionError = useCallback(
@@ -470,13 +499,22 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
void persistShipDefault("pr");
void runCreatePr({ serverId, cwd })
.then(() => {
toastActionSuccess(t("workspace.git.actions.createPr.success"));
toastActionSuccess(t("workspace.git.actions.createPr.success", forgeVocabulary(forge)));
return;
})
.catch((err) => {
toastActionError(err, t("workspace.git.actions.toasts.failedCreatePr"));
});
}, [cwd, persistShipDefault, runCreatePr, serverId, t, toastActionError, toastActionSuccess]);
}, [
cwd,
forge,
persistShipDefault,
runCreatePr,
serverId,
t,
toastActionError,
toastActionSuccess,
]);
const handleMergePr = useCallback(
(method: CheckoutPrMergeMethod) => {
@@ -484,14 +522,14 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
void runMergePr({ serverId, cwd, method })
.then(() => {
setPostShipArchiveSuggested(true);
toastActionSuccess(t("workspace.git.actions.mergePr.success"));
toastActionSuccess(t("workspace.git.actions.mergePr.success", forgeVocabulary(forge)));
return;
})
.catch((err) => {
toastActionError(err, t("workspace.git.actions.toasts.failedMergePr"));
});
},
[cwd, persistShipDefault, runMergePr, serverId, t, toastActionError, toastActionSuccess],
[cwd, forge, persistShipDefault, runMergePr, serverId, t, toastActionError, toastActionSuccess],
);
const handleEnablePrAutoMerge = useCallback(
@@ -615,10 +653,13 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
}, [prStatus?.url, handleCreatePr]);
// Build actions
const gitActionsInput = useMemo<BuildGitActionsInput>(
() => ({
const gitActionsInput = useMemo<BuildGitActionsInput>(() => {
const presentation = getForgePresentation(forge);
return {
isGit,
githubFeaturesEnabled,
forgeBrandLabel: presentation.brandLabel,
forgeChangeRequestNoun: presentation.changeRequestAbbrev,
githubAutoMergeActionsEnabled,
hasPullRequest,
pullRequestUrl: prStatus?.url ?? null,
@@ -626,7 +667,7 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
pullRequestIsDraft: prStatus?.isDraft ?? false,
pullRequestIsMerged: prStatus?.isMerged ?? false,
pullRequestMergeable: prStatus?.mergeable ?? "UNKNOWN",
pullRequestGithub: prStatus?.github ?? null,
mergeCapability: deriveMergeCapability(prStatus?.forgeSpecific, prStatus?.github),
hasRemote,
isPaseoOwnedWorktree,
isOnBaseBranch,
@@ -667,49 +708,49 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
pr: {
disabled: isActionDisabled(actionsDisabled, prCreateStatus),
status: hasPullRequest ? "idle" : prCreateStatus,
icon: hasPullRequest ? icons.viewPr : icons.createPr,
icon: prIcon,
handler: handlePrAction,
},
"merge-pr-squash": {
disabled: isActionDisabled(actionsDisabled, mergePrStatuses.squash),
status: mergePrStatuses.squash,
icon: icons.mergePrSquash,
icon: prIcon,
handler: () => handleMergePr("squash"),
},
"merge-pr-merge": {
disabled: isActionDisabled(actionsDisabled, mergePrStatuses.merge),
status: mergePrStatuses.merge,
icon: icons.mergePrMerge,
icon: prIcon,
handler: () => handleMergePr("merge"),
},
"merge-pr-rebase": {
disabled: isActionDisabled(actionsDisabled, mergePrStatuses.rebase),
status: mergePrStatuses.rebase,
icon: icons.mergePrRebase,
icon: prIcon,
handler: () => handleMergePr("rebase"),
},
"enable-pr-auto-merge-squash": {
disabled: isActionDisabled(actionsDisabled, enablePrAutoMergeStatuses.squash),
status: enablePrAutoMergeStatuses.squash,
icon: icons.mergePrSquash,
icon: prIcon,
handler: () => handleEnablePrAutoMerge("squash"),
},
"enable-pr-auto-merge-merge": {
disabled: isActionDisabled(actionsDisabled, enablePrAutoMergeStatuses.merge),
status: enablePrAutoMergeStatuses.merge,
icon: icons.mergePrMerge,
icon: prIcon,
handler: () => handleEnablePrAutoMerge("merge"),
},
"enable-pr-auto-merge-rebase": {
disabled: isActionDisabled(actionsDisabled, enablePrAutoMergeStatuses.rebase),
status: enablePrAutoMergeStatuses.rebase,
icon: icons.mergePrRebase,
icon: prIcon,
handler: () => handleEnablePrAutoMerge("rebase"),
},
"disable-pr-auto-merge": {
disabled: isActionDisabled(actionsDisabled, disablePrAutoMergeStatus),
status: disablePrAutoMergeStatus,
icon: icons.viewPr,
icon: prIcon,
handler: handleDisablePrAutoMerge,
},
"merge-branch": {
@@ -731,66 +772,73 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
handler: handleArchiveWorkspace,
},
},
}),
[
isGit,
hasRemote,
hasPullRequest,
prStatus?.url,
prStatus?.state,
prStatus?.isDraft,
prStatus?.isMerged,
prStatus?.mergeable,
prStatus?.github,
aheadCount,
behindBaseCount,
isPaseoOwnedWorktree,
isOnBaseBranch,
githubFeaturesEnabled,
githubAutoMergeActionsEnabled,
hasUncommittedChanges,
aheadOfOrigin,
behindOfOrigin,
shipDefault,
baseRefLabel,
shouldPromoteArchive,
actionsDisabled,
commitStatus,
pullStatus,
pushStatus,
pullAndPushStatus,
prCreateStatus,
mergePrStatuses.squash,
mergePrStatuses.merge,
mergePrStatuses.rebase,
enablePrAutoMergeStatuses.squash,
enablePrAutoMergeStatuses.merge,
enablePrAutoMergeStatuses.rebase,
disablePrAutoMergeStatus,
mergeStatus,
mergeFromBaseStatus,
archiveController.canArchive,
archiveController.isArchiving,
handleCommit,
handlePull,
handlePush,
handlePullAndPush,
handlePrAction,
handleMergePr,
handleEnablePrAutoMerge,
handleDisablePrAutoMerge,
handleMergeBranch,
handleMergeFromBase,
handleArchiveWorkspace,
icons,
baseRef,
],
);
};
}, [
isGit,
hasRemote,
hasPullRequest,
prStatus?.url,
prStatus?.state,
prStatus?.isDraft,
prStatus?.isMerged,
prStatus?.mergeable,
prStatus?.forgeSpecific,
prStatus?.github,
aheadCount,
behindBaseCount,
isPaseoOwnedWorktree,
isOnBaseBranch,
githubFeaturesEnabled,
forge,
githubAutoMergeActionsEnabled,
hasUncommittedChanges,
aheadOfOrigin,
behindOfOrigin,
shipDefault,
baseRefLabel,
shouldPromoteArchive,
actionsDisabled,
commitStatus,
pullStatus,
pushStatus,
pullAndPushStatus,
prCreateStatus,
mergePrStatuses.squash,
mergePrStatuses.merge,
mergePrStatuses.rebase,
enablePrAutoMergeStatuses.squash,
enablePrAutoMergeStatuses.merge,
enablePrAutoMergeStatuses.rebase,
disablePrAutoMergeStatus,
mergeStatus,
mergeFromBaseStatus,
archiveController.canArchive,
archiveController.isArchiving,
handleCommit,
handlePull,
handlePush,
handlePullAndPush,
handlePrAction,
handleMergePr,
handleEnablePrAutoMerge,
handleDisablePrAutoMerge,
handleMergeBranch,
handleMergeFromBase,
handleArchiveWorkspace,
icons,
prIcon,
baseRef,
]);
const gitActions: GitActions = useMemo(
() =>
translateGitActions(buildGitActions(gitActionsInput), { baseRefLabel, hasPullRequest, t }),
[gitActionsInput, baseRefLabel, hasPullRequest, t],
translateGitActions(buildGitActions(gitActionsInput), {
baseRefLabel,
hasPullRequest,
forge,
t,
}),
[gitActionsInput, baseRefLabel, hasPullRequest, forge, t],
);
return { gitActions, branchLabel, isGit };
@@ -801,6 +849,7 @@ function translateGitActions(
input: {
baseRefLabel: string;
hasPullRequest: boolean;
forge: Forge;
t: (key: string, options?: Record<string, unknown>) => string;
},
): GitActions {
@@ -816,14 +865,16 @@ function translateGitAction(
{
baseRefLabel,
hasPullRequest,
forge,
t,
}: {
baseRefLabel: string;
hasPullRequest: boolean;
forge: Forge;
t: (key: string, options?: Record<string, unknown>) => string;
},
): GitAction {
const labels = getTranslatedGitActionLabels(action, { baseRefLabel, hasPullRequest, t });
const labels = getTranslatedGitActionLabels(action, { baseRefLabel, hasPullRequest, forge, t });
return {
...action,
...labels,
@@ -839,10 +890,12 @@ function getTranslatedGitActionLabels(
{
baseRefLabel,
hasPullRequest,
forge,
t,
}: {
baseRefLabel: string;
hasPullRequest: boolean;
forge: Forge;
t: (key: string, options?: Record<string, unknown>) => string;
},
): Pick<GitAction, "label" | "pendingLabel" | "successLabel"> {
@@ -874,32 +927,32 @@ function getTranslatedGitActionLabels(
case "pr":
return hasPullRequest
? {
label: t("workspace.git.actions.viewPr"),
pendingLabel: t("workspace.git.actions.viewPr"),
successLabel: t("workspace.git.actions.viewPr"),
label: t("workspace.git.actions.viewPr", forgeVocabulary(forge)),
pendingLabel: t("workspace.git.actions.viewPr", forgeVocabulary(forge)),
successLabel: t("workspace.git.actions.viewPr", forgeVocabulary(forge)),
}
: {
label: t("workspace.git.actions.createPr.label"),
pendingLabel: t("workspace.git.actions.createPr.pending"),
successLabel: t("workspace.git.actions.createPr.success"),
label: t("workspace.git.actions.createPr.label", forgeVocabulary(forge)),
pendingLabel: t("workspace.git.actions.createPr.pending", forgeVocabulary(forge)),
successLabel: t("workspace.git.actions.createPr.success", forgeVocabulary(forge)),
};
case "merge-pr-squash":
return {
label: t("workspace.git.actions.mergePr.squash"),
pendingLabel: t("workspace.git.actions.mergePr.pending"),
successLabel: t("workspace.git.actions.mergePr.success"),
label: t("workspace.git.actions.mergePr.squash", forgeVocabulary(forge)),
pendingLabel: t("workspace.git.actions.mergePr.pending", forgeVocabulary(forge)),
successLabel: t("workspace.git.actions.mergePr.success", forgeVocabulary(forge)),
};
case "merge-pr-merge":
return {
label: t("workspace.git.actions.mergePr.merge"),
pendingLabel: t("workspace.git.actions.mergePr.pending"),
successLabel: t("workspace.git.actions.mergePr.success"),
label: t("workspace.git.actions.mergePr.merge", forgeVocabulary(forge)),
pendingLabel: t("workspace.git.actions.mergePr.pending", forgeVocabulary(forge)),
successLabel: t("workspace.git.actions.mergePr.success", forgeVocabulary(forge)),
};
case "merge-pr-rebase":
return {
label: t("workspace.git.actions.mergePr.rebase"),
pendingLabel: t("workspace.git.actions.mergePr.pending"),
successLabel: t("workspace.git.actions.mergePr.success"),
label: t("workspace.git.actions.mergePr.rebase", forgeVocabulary(forge)),
pendingLabel: t("workspace.git.actions.mergePr.pending", forgeVocabulary(forge)),
successLabel: t("workspace.git.actions.mergePr.success", forgeVocabulary(forge)),
};
case "enable-pr-auto-merge-squash":
return {
@@ -958,8 +1011,6 @@ function translateGitActionUnavailableMessage(
): string | undefined {
if (!message) return undefined;
const keyByMessage: Record<string, string> = {
"View PR isn't available right now because GitHub isn't connected":
"workspace.git.actions.unavailable.viewPrNoGithub",
"Pull isn't available here because this branch is not connected to a remote yet":
"workspace.git.actions.unavailable.pullNoRemote",
"Pull isn't available while you have local changes so commit or stash them first":
@@ -978,8 +1029,6 @@ function translateGitActionUnavailableMessage(
"workspace.git.actions.unavailable.pullAndPushDirty",
"Pull and push isn't available because this branch is already in sync":
"workspace.git.actions.unavailable.pullAndPushInSync",
"Create PR isn't available right now because GitHub isn't connected":
"workspace.git.actions.unavailable.createPrNoGithub",
"Create PR isn't available because this branch doesn't have any new commits yet":
"workspace.git.actions.unavailable.createPrNoCommits",
"Merge isn't available because we couldn't determine the base branch":
@@ -994,6 +1043,8 @@ function translateGitActionUnavailableMessage(
"workspace.git.actions.unavailable.updateDirty",
"Merge PR isn't available right now because GitHub isn't connected":
"workspace.git.actions.unavailable.mergePrNoGithub",
"Archive isn't available here because this workspace was not created as a Paseo worktree":
"workspace.git.actions.unavailable.archiveNotWorktree",
"Merge PR isn't available because there isn't a pull request yet":
"workspace.git.actions.unavailable.mergePrMissing",
"Merge PR isn't available because the pull request is still a draft":
@@ -1006,8 +1057,6 @@ function translateGitActionUnavailableMessage(
"workspace.git.actions.unavailable.mergePrConflicts",
"Merge PR isn't available here because this repository uses a merge queue":
"workspace.git.actions.unavailable.mergePrQueue",
"Merge PR isn't available until GitHub reports the pull request is ready to merge":
"workspace.git.actions.unavailable.mergePrNotReady",
"Auto-merge is enabled, but this account can't disable it":
"workspace.git.actions.unavailable.autoMergeCannotDisable",
};
@@ -0,0 +1,282 @@
import { describe, expect, it } from "vitest";
import { buildForgeSearchQueryOptions, forgeSearchQueryKey } from "./use-forge-search-query";
describe("forgeSearchQueryKey", () => {
it("keeps the shared cache key shape for no-kinds searches", () => {
expect(forgeSearchQueryKey("server-1", "/repo", " 123 ")).toEqual([
"forge-search",
"server-1",
"/repo",
"forge",
"123",
]);
});
it("adds a deterministic kinds key when kinds are specified", () => {
expect(forgeSearchQueryKey("server-1", "/repo", "123", ["change_request", "issue"])).toEqual([
"forge-search",
"server-1",
"/repo",
"forge",
"123",
"change_request,issue",
]);
});
it("separates legacy GitHub fallback results from forge search results", () => {
expect(forgeSearchQueryKey("server-1", "/repo", "123", undefined, "github")).toEqual([
"forge-search",
"server-1",
"/repo",
"github",
"123",
]);
});
});
describe("buildForgeSearchQueryOptions", () => {
it("forwards kinds to the forge search request when specified", async () => {
const requests: unknown[] = [];
const query = buildForgeSearchQueryOptions({
client: {
async searchForge(options) {
requests.push(options);
return {
items: [],
authState: "authenticated",
error: null,
requestId: "request-1",
};
},
},
serverId: "server-1",
cwd: "/repo",
query: " 123 ",
kinds: ["change_request"],
enabled: true,
});
await query.queryFn();
expect(requests).toEqual([
{ cwd: "/repo", query: "123", limit: 20, kinds: ["change_request"] },
]);
});
it("uses the legacy GitHub search request when forge search is unsupported", async () => {
const forgeRequests: unknown[] = [];
const githubRequests: unknown[] = [];
const query = buildForgeSearchQueryOptions({
client: {
async searchForge(options) {
forgeRequests.push(options);
return {
items: [],
authState: "authenticated",
error: null,
requestId: "forge-request",
};
},
async searchGitHub(options) {
githubRequests.push(options);
return {
items: [],
featuresEnabled: true,
authState: "authenticated",
githubFeaturesEnabled: true,
error: null,
requestId: "github-request",
};
},
},
serverId: "server-1",
cwd: "/repo",
query: " 456 ",
kinds: ["issue"],
enabled: true,
supportsForgeSearch: false,
});
await query.queryFn();
expect(forgeRequests).toEqual([]);
expect(githubRequests).toEqual([
{ cwd: "/repo", query: "456", limit: 20, kinds: ["github-issue"] },
]);
});
it("normalizes legacy GitHub PR items into neutral change-request items", async () => {
const query = buildForgeSearchQueryOptions({
client: {
async searchForge() {
throw new Error("unexpected forge search");
},
async searchGitHub() {
return {
items: [
{
kind: "pr" as const,
number: 17,
title: "Fix search",
url: "https://github.com/acme/repo/pull/17",
state: "open",
body: null,
labels: ["bug"],
baseRefName: "main",
headRefName: "fix-search",
},
],
featuresEnabled: true,
authState: "authenticated" as const,
githubFeaturesEnabled: true,
error: null,
requestId: "github-request",
};
},
},
serverId: "server-1",
cwd: "/repo",
query: " 456 ",
enabled: true,
supportsForgeSearch: false,
});
const result = await query.queryFn();
expect(result.items).toEqual([
{
kind: "change_request",
number: 17,
title: "Fix search",
url: "https://github.com/acme/repo/pull/17",
state: "open",
body: null,
labels: ["bug"],
baseRefName: "main",
headRefName: "fix-search",
},
]);
});
it("interprets modern search payloads at the query boundary", async () => {
const query = buildForgeSearchQueryOptions({
client: {
async searchForge() {
return {
items: [
{
kind: "issue",
number: 23,
title: "Keep this",
url: "https://gitlab.com/acme/repo/-/issues/23",
state: "open",
body: null,
labels: [],
},
{ kind: "future_kind", futureField: true },
],
authState: "future_auth_state",
error: null,
requestId: "forge-request",
};
},
},
serverId: "server-1",
cwd: "/repo",
query: "23",
enabled: true,
supportsForgeSearch: true,
});
const result = await query.queryFn();
expect(result.items).toHaveLength(1);
expect(result.items[0]?.kind).toBe("issue");
expect(result.authState).toBe("unauthenticated");
});
it("derives legacy search auth from legacy feature flags", async () => {
const query = buildForgeSearchQueryOptions({
client: {
async searchForge() {
throw new Error("unexpected forge search");
},
async searchGitHub() {
return {
items: [],
githubFeaturesEnabled: false,
error: null,
requestId: "github-request",
};
},
},
serverId: "server-1",
cwd: "/repo",
query: "23",
enabled: true,
supportsForgeSearch: false,
});
expect((await query.queryFn()).authState).toBe("unauthenticated");
});
it("invokes forge search bound to the client so this-dependent methods work", async () => {
const client = new ThisDependentSearchClient();
const query = buildForgeSearchQueryOptions({
client,
serverId: "server-1",
cwd: "/repo",
query: " 789 ",
enabled: true,
supportsForgeSearch: true,
});
const result = await query.queryFn();
expect(result.requestId).toBe("forge.search.request");
expect(client.requests).toEqual([{ cwd: "/repo", query: "789", limit: 20 }]);
});
it("invokes the legacy GitHub search bound to the client", async () => {
const client = new ThisDependentSearchClient();
const query = buildForgeSearchQueryOptions({
client,
serverId: "server-1",
cwd: "/repo",
query: " 789 ",
enabled: true,
supportsForgeSearch: false,
});
const result = await query.queryFn();
expect(result.requestId).toBe("github_search_request");
expect(client.requests).toEqual([{ cwd: "/repo", query: "789", limit: 20 }]);
});
});
class ThisDependentSearchClient {
readonly requests: unknown[] = [];
private send(requestId: string, options: { cwd: string; query: string; limit?: number }) {
this.requests.push(options);
return Promise.resolve({
items: [],
featuresEnabled: true,
authState: "authenticated" as const,
githubFeaturesEnabled: true,
error: null,
requestId,
});
}
async searchForge(options: { cwd: string; query: string; limit?: number }) {
return this.send("forge.search.request", options);
}
async searchGitHub(options: { cwd: string; query: string; limit?: number }) {
return this.send("github_search_request", options);
}
}
@@ -0,0 +1,172 @@
import { useTranslation } from "react-i18next";
import {
ForgeSearchItemSchema,
GitHubSearchItemSchema,
type ForgeAuthState,
type ForgeSearchItem,
ForgeSearchKind,
type ForgeSearchResponse,
type GitHubSearchResponse,
} from "@getpaseo/protocol/messages";
import { i18n } from "@/i18n/i18next";
import { useFetchQuery } from "@/data/query";
import { parseForgeAuthState } from "@/git/forge";
export const FORGE_SEARCH_STALE_TIME = 30_000;
export interface ForgeSearchPayload {
items: ForgeSearchItem[];
authState: ForgeAuthState;
error: string | null;
requestId: string;
}
interface ForgeSearchOptions {
cwd: string;
query: string;
limit?: number;
kinds?: ForgeSearchKind[];
}
interface LegacyGitHubSearchOptions {
cwd: string;
query: string;
limit?: number;
kinds?: LegacyGitHubSearchKind[];
}
export interface ForgeSearchClient {
searchForge: (
options: ForgeSearchOptions,
requestId?: string,
) => Promise<ForgeSearchResponse["payload"]>;
searchGitHub?: (
options: LegacyGitHubSearchOptions,
requestId?: string,
) => Promise<GitHubSearchResponse["payload"]>;
}
type LegacyGitHubSearchKind = "github-issue" | "github-pr";
interface ForgeSearchQueryInput {
client: ForgeSearchClient | null;
serverId: string;
cwd: string;
query: string;
kinds?: ForgeSearchKind[];
enabled: boolean;
supportsForgeSearch?: boolean;
hostDisconnectedMessage?: string;
}
export function forgeSearchQueryKey(
serverId: string,
cwd: string,
query: string,
kinds?: ForgeSearchKind[],
transport: "forge" | "github" = "forge",
) {
const trimmedQuery = query.trim();
if (!kinds) {
return ["forge-search", serverId, cwd, transport, trimmedQuery] as const;
}
return [
"forge-search",
serverId,
cwd,
transport,
trimmedQuery,
[...kinds].sort().join(","),
] as const;
}
export function buildForgeSearchQueryOptions(input: ForgeSearchQueryInput) {
const query = input.query.trim();
const transport = input.supportsForgeSearch === true ? "forge" : "github";
return {
queryKey: forgeSearchQueryKey(input.serverId, input.cwd, query, input.kinds, transport),
queryFn: async (): Promise<ForgeSearchPayload> => {
if (!input.client) {
throw new Error(
input.hostDisconnectedMessage ?? i18n.t("workspace.terminal.hostDisconnected"),
);
}
const request = input.kinds
? { cwd: input.cwd, query, limit: 20, kinds: input.kinds }
: { cwd: input.cwd, query, limit: 20 };
// COMPAT(githubSearchRpc): added in v0.1.106, remove after 2026-12-28 once
// clients use forge.search.*.
if (transport === "github" && input.client.searchGitHub) {
return normalizeLegacyGitHubSearchPayload(
await input.client.searchGitHub(toLegacyGitHubSearchRequest(request)),
);
}
return normalizeForgeSearchPayload(await input.client.searchForge(request));
},
enabled: input.enabled && Boolean(input.client),
dataShape: "list" as const,
staleTimeMs: FORGE_SEARCH_STALE_TIME,
};
}
function normalizeForgeSearchPayload(payload: ForgeSearchResponse["payload"]): ForgeSearchPayload {
return {
items: payload.items.flatMap((item) => {
const result = ForgeSearchItemSchema.safeParse(item);
return result.success ? [result.data] : [];
}),
authState: parseForgeAuthState(payload.authState) ?? "unauthenticated",
error: payload.error,
requestId: payload.requestId,
};
}
function normalizeLegacyGitHubSearchPayload(
payload: GitHubSearchResponse["payload"],
): ForgeSearchPayload {
// COMPAT(githubSearchAuthState): added in v0.1.106, remove after 2026-12-28.
const featuresEnabled = payload.featuresEnabled ?? payload.githubFeaturesEnabled ?? true;
return {
items: payload.items.flatMap((item) => {
const result = GitHubSearchItemSchema.safeParse(item);
if (!result.success) {
return [];
}
return [
result.data.kind === "pr"
? { ...result.data, kind: "change_request" as const }
: { ...result.data, kind: "issue" as const },
];
}),
authState:
parseForgeAuthState(payload.authState) ??
(featuresEnabled ? "authenticated" : "unauthenticated"),
error: payload.error,
requestId: payload.requestId,
};
}
function toLegacyGitHubSearchKind(kind: ForgeSearchKind): LegacyGitHubSearchKind {
return kind === "change_request" ? "github-pr" : "github-issue";
}
function toLegacyGitHubSearchRequest(request: ForgeSearchOptions): LegacyGitHubSearchOptions {
if (!request.kinds) {
return { cwd: request.cwd, query: request.query, limit: request.limit };
}
return {
...request,
kinds: request.kinds.map(toLegacyGitHubSearchKind),
};
}
export function useForgeSearchQuery(input: ForgeSearchQueryInput) {
const { t } = useTranslation();
return useFetchQuery(
buildForgeSearchQueryOptions({
...input,
hostDisconnectedMessage: t("workspace.terminal.hostDisconnected"),
}),
);
}
@@ -1,42 +0,0 @@
import { describe, expect, it } from "vitest";
import { buildGithubSearchQueryOptions, githubSearchQueryKey } from "./use-github-search-query";
describe("githubSearchQueryKey", () => {
it("keeps the shared cache key shape for no-kinds searches", () => {
expect(githubSearchQueryKey("server-1", "/repo", " 123 ")).toEqual([
"github-search",
"server-1",
"/repo",
"123",
]);
});
it("adds a deterministic kinds key when kinds are specified", () => {
expect(githubSearchQueryKey("server-1", "/repo", "123", ["github-pr", "github-issue"])).toEqual(
["github-search", "server-1", "/repo", "123", "github-issue,github-pr"],
);
});
});
describe("buildGithubSearchQueryOptions", () => {
it("forwards kinds to the GitHub search request when specified", async () => {
const requests: unknown[] = [];
const query = buildGithubSearchQueryOptions({
client: {
async searchGitHub(options) {
requests.push(options);
return { items: [], githubFeaturesEnabled: true, error: null, requestId: "request-1" };
},
},
serverId: "server-1",
cwd: "/repo",
query: " 123 ",
kinds: ["github-pr"],
enabled: true,
});
await query.queryFn();
expect(requests).toEqual([{ cwd: "/repo", query: "123", limit: 20, kinds: ["github-pr"] }]);
});
});
@@ -1,75 +0,0 @@
import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import type { GitHubSearchRequest, GitHubSearchResponse } from "@getpaseo/protocol/messages";
import { i18n } from "@/i18n/i18next";
export const GITHUB_SEARCH_STALE_TIME = 30_000;
export type GitHubSearchPayload = GitHubSearchResponse["payload"];
export interface GitHubSearchClient {
searchGitHub: (
options: {
cwd: string;
query: string;
limit?: number;
kinds?: GitHubSearchRequest["kinds"];
},
requestId?: string,
) => Promise<GitHubSearchPayload>;
}
interface GitHubSearchQueryInput {
client: GitHubSearchClient | null;
serverId: string;
cwd: string;
query: string;
kinds?: GitHubSearchRequest["kinds"];
enabled: boolean;
hostDisconnectedMessage?: string;
}
export function githubSearchQueryKey(
serverId: string,
cwd: string,
query: string,
kinds?: GitHubSearchRequest["kinds"],
) {
const trimmedQuery = query.trim();
if (!kinds) {
return ["github-search", serverId, cwd, trimmedQuery] as const;
}
return ["github-search", serverId, cwd, trimmedQuery, [...kinds].sort().join(",")] as const;
}
export function buildGithubSearchQueryOptions(input: GitHubSearchQueryInput) {
const query = input.query.trim();
return {
queryKey: githubSearchQueryKey(input.serverId, input.cwd, query, input.kinds),
queryFn: async (): Promise<GitHubSearchPayload> => {
if (!input.client) {
throw new Error(
input.hostDisconnectedMessage ?? i18n.t("workspace.terminal.hostDisconnected"),
);
}
const request = { cwd: input.cwd, query, limit: 20 };
if (input.kinds) {
return input.client.searchGitHub({ ...request, kinds: input.kinds });
}
return input.client.searchGitHub(request);
},
enabled: input.enabled && Boolean(input.client),
staleTime: GITHUB_SEARCH_STALE_TIME,
};
}
export function useGithubSearchQuery(input: GitHubSearchQueryInput) {
const { t } = useTranslation();
return useQuery(
buildGithubSearchQueryOptions({
...input,
hostDisconnectedMessage: t("workspace.terminal.hostDisconnected"),
}),
);
}
+11 -5
View File
@@ -1,9 +1,10 @@
import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import type { CheckoutPrStatusResponse } from "@getpaseo/protocol/messages";
import { checkoutPrStatusQueryKey } from "@/git/query-keys";
import { normalizeForge } from "@/git/forge";
import { selectPrHintFromStatus, type PrHint } from "@/git/pr-hint";
import { type CheckoutPrStatusPayload, normalizeCheckoutPrStatusPayload } from "@/git/pr-status";
interface UseCheckoutPrStatusQueryOptions {
serverId: string;
@@ -11,11 +12,11 @@ interface UseCheckoutPrStatusQueryOptions {
enabled?: boolean;
}
export type CheckoutPrStatusPayload = CheckoutPrStatusResponse["payload"];
export type { CheckoutPrStatusPayload } from "@/git/pr-status";
export { selectPrHintFromStatus, type PrHint } from "@/git/pr-hint";
function selectWorkspacePrHint(payload: CheckoutPrStatusPayload): PrHint | null {
return selectPrHintFromStatus(payload.status);
return selectPrHintFromStatus(payload.status, payload.forge);
}
export function useCheckoutPrStatusQuery({
@@ -33,7 +34,7 @@ export function useCheckoutPrStatusQuery({
if (!client) {
throw new Error(t("common.errors.daemonClientUnavailable"));
}
return await client.checkoutPrStatus(cwd);
return normalizeCheckoutPrStatusPayload(await client.checkoutPrStatus(cwd));
},
enabled: !!client && isConnected && !!cwd && enabled,
staleTime: Infinity,
@@ -47,6 +48,11 @@ export function useCheckoutPrStatusQuery({
return {
status: query.data?.status ?? null,
githubFeaturesEnabled: query.data?.githubFeaturesEnabled ?? true,
authState: query.data?.authState,
forge: normalizeForge(query.data?.forge),
// Null until a response arrives, so callers that can infer the forge from
// the remote URL (e.g. web-URL grammar) don't act on the github default.
resolvedForge: query.data === undefined ? null : normalizeForge(query.data.forge),
payloadError: query.data?.error ?? null,
isLoading: query.isLoading,
isFetching: query.isFetching,
@@ -70,7 +76,7 @@ export function useWorkspacePrHint({
if (!client) {
throw new Error(t("common.errors.daemonClientUnavailable"));
}
return await client.checkoutPrStatus(cwd);
return normalizeCheckoutPrStatusPayload(await client.checkoutPrStatus(cwd));
},
enabled: !!client && isConnected && !!cwd && enabled,
staleTime: Infinity,
@@ -8,7 +8,6 @@ import {
RefreshCcw,
Upload,
} from "lucide-react-native";
import { GitHubIcon } from "@/components/icons/github-icon";
import { GitActionsSplitButton } from "@/git/actions-split-button";
import { useGitActions } from "@/git/use-actions";
import type { Theme } from "@/styles/theme";
@@ -23,7 +22,6 @@ const ThemedGitCommitHorizontal = withUnistyles(GitCommitHorizontal);
const ThemedDownload = withUnistyles(Download);
const ThemedUpload = withUnistyles(Upload);
const ThemedArrowDownUp = withUnistyles(ArrowDownUp);
const ThemedGitHubIcon = withUnistyles(GitHubIcon);
const ThemedGitMerge = withUnistyles(GitMerge);
const ThemedRefreshCcw = withUnistyles(RefreshCcw);
const ThemedArchive = withUnistyles(Archive);
@@ -37,11 +35,6 @@ const ICONS = {
pull: <ThemedDownload size={16} uniProps={mutedColorMapping} />,
push: <ThemedUpload size={16} uniProps={mutedColorMapping} />,
pullAndPush: <ThemedArrowDownUp size={16} uniProps={mutedColorMapping} />,
viewPr: <ThemedGitHubIcon size={16} uniProps={mutedColorMapping} />,
createPr: <ThemedGitHubIcon size={16} uniProps={mutedColorMapping} />,
mergePrSquash: <ThemedGitHubIcon size={16} uniProps={mutedColorMapping} />,
mergePrMerge: <ThemedGitHubIcon size={16} uniProps={mutedColorMapping} />,
mergePrRebase: <ThemedGitHubIcon size={16} uniProps={mutedColorMapping} />,
merge: <ThemedGitMerge size={16} uniProps={mutedColorMapping} />,
mergeFromBase: <ThemedRefreshCcw size={16} uniProps={mutedColorMapping} />,
archive: <ThemedArchive size={16} uniProps={mutedColorMapping} />,
@@ -8,11 +8,62 @@ import {
buildSidebarWorkspacePlacementModel,
buildSidebarProjectsFromStructure,
computeSidebarOrderUpdates,
createSidebarWorkspaceEntry,
deriveSidebarLoadingState,
shouldShowSidebarHostLabels,
type SidebarProjectEntry,
} from "./sidebar-workspaces-view-model";
function workspaceWithForge(forge: string | undefined, prUrl: string): WorkspaceDescriptor {
return {
id: "ws-1",
projectId: "proj",
projectDisplayName: "repo",
projectRootPath: "/repo",
workspaceDirectory: "/repo",
projectKind: "git",
workspaceKind: "worktree",
name: "feature",
title: null,
status: "done",
statusEnteredAt: null,
archivingAt: null,
diffStat: null,
scripts: [],
forge,
githubRuntime: {
featuresEnabled: true,
pullRequest: {
url: prUrl,
title: "Change",
state: "open",
baseRefName: "main",
headRefName: "feature",
isMerged: false,
},
error: null,
},
};
}
describe("createSidebarWorkspaceEntry forge threading", () => {
it("threads a gitlab summary forge onto the prHint", () => {
const entry = createSidebarWorkspaceEntry({
serverId: "srv",
workspace: workspaceWithForge("gitlab", "https://gitlab.com/group/proj/-/merge_requests/7"),
});
expect(entry.prHint).toMatchObject({ number: 7, forge: "gitlab" });
});
it("falls back to github when the summary omits forge (old daemon)", () => {
const entry = createSidebarWorkspaceEntry({
serverId: "srv",
workspace: workspaceWithForge(undefined, "https://github.com/acme/repo/pull/42"),
});
expect(entry.prHint).toMatchObject({ number: 42, forge: "github" });
});
});
interface OrderedItem {
key: string;
}
@@ -164,7 +164,10 @@ export function createSidebarWorkspaceEntry(input: {
statusEnteredAt: effectiveStatus.enteredAt,
archivingAt: input.workspace.archivingAt,
diffStat: input.workspace.diffStat,
prHint: selectPrHintFromStatus(input.workspace.githubRuntime?.pullRequest),
prHint: selectPrHintFromStatus(
input.workspace.githubRuntime?.pullRequest,
input.workspace.forge,
),
archiveHasUncommittedChanges: input.workspace.gitRuntime?.isDirty ?? null,
archiveUnpushedCommitCount: input.workspace.gitRuntime?.aheadOfOrigin ?? null,
scripts: input.workspace.scripts,
+10
View File
@@ -194,7 +194,10 @@ describe("translation resources", () => {
expect(en.composer.input.sendMessage).toBe("Send message");
expect(en.composer.voice.startDictation).toBe("Start dictation");
expect(en.composer.attachments.addIssueOrPr).toBe("Add issue or PR");
expect(en.composer.attachments.addIssueOrPr_mr).toBe("Add issue or MR");
expect(en.composer.github.title).toBe("Attach issue or PR");
expect(en.composer.github.title_mr).toBe("Attach issue or MR");
expect(en.composer.github.searchPlaceholder_mr).toBe("Search issues and MRs...");
expect(en.agentControls.provider.fallback).toBe("Provider");
expect(en.agentControls.hints.model).toBe("Change model");
expect(en.agentControls.hints.mode).toBe("Change mode");
@@ -258,7 +261,14 @@ describe("translation resources", () => {
expect(en.workspace.git.actions.commit.label).toBe("Commit");
expect(en.workspace.git.diff.binaryFile).toBe("Binary file");
expect(en.workspace.git.pr.sections.checks).toBe("Checks");
expect(en.workspace.git.pr.sections.pipeline).toBe("Pipeline");
expect(en.workspace.git.pr.actions.viewPullRequest).toBe("View");
expect(en.workspace.git.pr.actions.openOn).toBe("Open on {{brand}}");
expect(en.workspace.git.pr.empty.noJobs).toBe("No jobs");
expect(en.workspace.git.pr.empty.loadingPipeline).toBe("Loading pipeline…");
expect(en.workspace.git.pr.empty.pipelineJobsLoadFailed).toBe("Could not load pipeline jobs");
expect(en.workspace.git.pr.empty.allowedToFail).toBe("allowed to fail");
expect(en.workspace.git.pr.approvals).toBe("{{given}} of {{required}} approvals");
expect(en.review.comment.placeholder).toBe("Leave a comment");
});
+45 -10
View File
@@ -102,6 +102,7 @@ export const ar: TranslationResources = {
addImage: "أضف صورة",
addFile: "Upload file",
addIssueOrPr: "أضف مشكلة أو PR",
addIssueOrPr_mr: "أضف مشكلة أو MR",
dropImagesHere: "إسقاط الصور هنا",
dropFilesHere: "Drop files here",
editQueuedMessage: "تحرير الرسالة في قائمة الانتظار",
@@ -109,8 +110,8 @@ export const ar: TranslationResources = {
openImage: "فتح مرفق الصورة",
removeImage: "إزالة مرفق الصورة",
removeFile: "Remove file attachment",
openGithub: "افتح{{kind}}#{{number}}",
removeGithub: "إزالة{{kind}}#{{number}}",
openGithub: "افتح {{kind}} {{number}}",
removeGithub: "إزالة {{kind}} {{number}}",
element: "عنصر",
openBrowserElement: "افتح مرفق عنصر المتصفح",
removeBrowserElement: "إزالة مرفق عنصر المتصفح",
@@ -134,7 +135,9 @@ export const ar: TranslationResources = {
searching: "جارٍ البحث...",
noResults: "لم يتم العثور على نتائج.",
searchPlaceholder: "بحث القضايا والعلاقات العامة...",
searchPlaceholder_mr: "بحث القضايا و MR...",
title: "إرفاق المشكلة أو PR",
title_mr: "إرفاق المشكلة أو MR",
},
},
agentControls: {
@@ -618,6 +621,9 @@ export const ar: TranslationResources = {
label: "إنشاء PR",
pending: "إنشاء PR...",
success: "تم إنشاء PR",
label_mr: "إنشاء MR",
pending_mr: "إنشاء MR...",
success_mr: "تم إنشاء MR",
},
mergeBranch: {
label: "دمج محليا",
@@ -640,7 +646,13 @@ export const ar: TranslationResources = {
rebase: "دمج PR (rebase)",
pending: "دمج PR...",
success: "تم دمج PR",
squash_mr: "دمج MR (squash)",
merge_mr: "دمج MR (merge)",
rebase_mr: "دمج MR (rebase)",
pending_mr: "دمج MR...",
success_mr: "تم دمج MR",
},
viewPr_mr: "عرض MR",
autoMerge: {
enableSquash: "دمج تلقائي (squash)",
enableMerge: "دمج تلقائي (merge)",
@@ -651,7 +663,7 @@ export const ar: TranslationResources = {
disabled: "تم تعطيل الدمج التلقائي",
},
unavailable: {
viewPrNoGithub: "عرض PR غير متاح الآن لأن GitHub غير متصل",
viewPrNoForge: "عرض {{noun}} غير متاح الآن لأن {{brand}} غير متصل",
pullNoRemote: "السحب غير متاح هنا لأن هذا الفرع غير متصل بجهاز التحكم عن بعد بعد",
pullDirty: "السحب غير متاح أثناء وجود تغييرات محلية، لذا قم بتنفيذها أو تخزينها أولاً",
pullUpToDate: "السحب غير متاح لأن هذا الفرع محدث بالفعل",
@@ -662,8 +674,10 @@ export const ar: TranslationResources = {
"السحب والدفع غير متاح هنا لأن هذا الفرع غير متصل بجهاز التحكم عن بعد بعد",
pullAndPushDirty:
"لا يتوفر السحب والدفع أثناء وجود تغييرات محلية، لذا قم بتنفيذها أو تخزينها أولاً",
pullAndPushNoIncoming: "السحب والدفع غير متاحين لأنه لا توجد تغييرات واردة للسحب أولاً",
pullAndPushInSync: "السحب والدفع غير متاح لأن هذا الفرع متزامن بالفعل",
createPrNoGithub: "إنشاء PR غير متاح حاليًا لأن GitHub غير متصل",
pullAndPushNothingToPush: "السحب والدفع غير متاحين لأنه لا يوجد شيء جديد لإرساله بعد السحب",
createPrNoForge: "إنشاء {{noun}} غير متاح حاليًا لأن {{brand}} غير متصل",
createPrNoCommits: "إنشاء PR غير متاح لأن هذا الفرع ليس لديه أي التزامات جديدة حتى الآن",
mergeNoBase: "الدمج غير متاح لأننا لم نتمكن من تحديد الفرع الأساسي",
mergeDirty:
@@ -673,13 +687,16 @@ export const ar: TranslationResources = {
updateDirty: "التحديث غير متاح أثناء وجود تغييرات محلية، لذا قم بتنفيذها أو تخزينها أولاً",
updateCurrent: "التحديث غير متاح لأن هذا الفرع محدث بالفعل باستخدام{{baseRef}}",
mergePrNoGithub: "دمج PR غير متاح الآن لأن GitHub غير متصل",
archiveNotWorktree:
"الأرشيف غير متاح هنا لأنه لم يتم إنشاء مساحة العمل هذه كشجرة عمل Paseo",
mergePrNoForge: "دمج {{noun}} غير متاح الآن لأن {{brand}} غير متصل",
mergePrMissing: "دمج PR غير متاح لأنه لا يوجد طلب سحب حتى الآن",
mergePrDraft: "دمج PR غير متاح لأن طلب السحب لا يزال مسودة",
mergePrMerged: "دمج PR غير متاح لأن طلب السحب مدمج بالفعل",
mergePrClosed: "دمج PR غير متاح لأن طلب السحب مغلق",
mergePrConflicts: "دمج PR غير متاح لأن طلب السحب به تعارضات",
mergePrQueue: "دمج PR غير متاح هنا لأن هذا المستودع يستخدم قائمة انتظار دمج",
mergePrNotReady: "دمج PR غير متاح حتى يبلغ GitHub أن طلب السحب جاهز للدمج",
mergePrNotReady: "دمج {{noun}} غير متاح حتى يبلغ {{brand}} أن {{noun}} جاهز للدمج",
autoMergeCannotDisable: "تم تمكين الدمج التلقائي، ولكن لا يمكن لهذا الحساب تعطيله",
},
toasts: {
@@ -729,7 +746,7 @@ export const ar: TranslationResources = {
expandAllFolders: "توسيع كافة المجلدات",
refreshing: "منعش",
refresh: "ينعش",
refreshState: "تحديث بوابة وحالة GitHub",
refreshState: "تحديث حالة git و{{brand}}",
failedRefresh: "فشل تحديث حالة git.",
emptyHiddenWhitespace: "لا توجد تغييرات مرئية بعد إخفاء المسافة البيضاء",
emptyUncommitted: "لا توجد تغييرات غير ملتزم بها",
@@ -765,13 +782,23 @@ export const ar: TranslationResources = {
pr: {
actions: {
viewPullRequest: "عرض",
openOn: "فتح على {{brand}}",
},
sections: {
checks: "الشيكات",
pipeline: "خط المعالجة",
reviews: "التعليقات",
},
empty: {
noJobs: "لا توجد مهام",
loadingPipeline: "جارٍ تحميل خط المعالجة...",
pipelineJobsLoadFailed: "تعذر تحميل مهام خط المعالجة",
allowedToFail: "مسموح بالفشل",
},
approvals: "{{given}} من {{required}} موافقات",
accessibility: {
pullRequest: "سحب الطلب #{{number}}",
pullRequest_mr: "طلب دمج !{{number}}",
},
states: {
draft: "مسودة",
@@ -788,11 +815,19 @@ export const ar: TranslationResources = {
time: {
justNow: "الآن",
},
thread: {
discussion: "سلسلة المناقشة",
},
errors: {
statusLoadFailed: "غير قادر على تحميل حالة طلب السحب",
activityLoadFailed: "غير قادر على تحميل نشاط طلب السحب",
},
},
forgeSetup: {
installCli: "ثبّت واجهة سطر الأوامر {{cli}} لاستخدام ميزات {{brand}}.",
signIn: "نفّذ {{command}} لاستخدام ميزات {{brand}}.",
generic: "قم بإعداد {{brand}} على هذا المضيف لاستخدام ميزاته.",
},
},
},
sidebar: {
@@ -924,10 +959,10 @@ export const ar: TranslationResources = {
refPicker: {
startingRef: "بدء المرجع",
chooseStart: "اختر من أين تبدأ",
checkoutHint: "تحقق من PR#{{number}}؟",
checkoutPr: "تحقق من PR#{{number}}",
dismissCheckoutHint: "تجاهل تلميح الخروج PR#{{number}}",
intoBase: "إلى{{baseRef}}",
checkoutHint: "تحقق من {{noun}} {{numberPrefix}}{{number}}؟",
checkoutPr: "تحقق من {{noun}} {{numberPrefix}}{{number}}",
dismissCheckoutHint: "تجاهل تلميح الخروج {{noun}} {{numberPrefix}}{{number}}",
intoBase: "إلى {{baseRef}}",
searching: "جارٍ البحث...",
noMatchingRefs: "لا توجد مراجع مطابقة.",
searchPlaceholder: "بحث الفروع والعلاقات العامة",
+49 -9
View File
@@ -100,6 +100,7 @@ export const en = {
addImage: "Add image",
addFile: "Upload file",
addIssueOrPr: "Add issue or PR",
addIssueOrPr_mr: "Add issue or MR",
dropImagesHere: "Drop images here",
dropFilesHere: "Drop files here",
editQueuedMessage: "Edit queued message",
@@ -107,8 +108,8 @@ export const en = {
openImage: "Open image attachment",
removeImage: "Remove image attachment",
removeFile: "Remove file attachment",
openGithub: "Open {{kind}} #{{number}}",
removeGithub: "Remove {{kind}} #{{number}}",
openGithub: "Open {{kind}} {{number}}",
removeGithub: "Remove {{kind}} {{number}}",
element: "Element",
openBrowserElement: "Open browser element attachment",
removeBrowserElement: "Remove browser element attachment",
@@ -132,7 +133,9 @@ export const en = {
searching: "Searching...",
noResults: "No results found.",
searchPlaceholder: "Search issues and PRs...",
searchPlaceholder_mr: "Search issues and MRs...",
title: "Attach issue or PR",
title_mr: "Attach issue or MR",
},
},
agentControls: {
@@ -612,10 +615,14 @@ export const en = {
success: "Pulled and pushed",
},
viewPr: "View PR",
viewPr_mr: "View MR",
createPr: {
label: "Create PR",
pending: "Creating PR...",
success: "PR Created",
label_mr: "Create MR",
pending_mr: "Creating MR...",
success_mr: "MR Created",
},
mergeBranch: {
label: "Merge locally",
@@ -638,6 +645,11 @@ export const en = {
rebase: "Merge PR (rebase)",
pending: "Merging PR...",
success: "PR merged",
squash_mr: "Merge MR (squash)",
merge_mr: "Merge MR (merge)",
rebase_mr: "Merge MR (rebase)",
pending_mr: "Merging MR...",
success_mr: "MR merged",
},
autoMerge: {
enableSquash: "Auto merge (squash)",
@@ -649,7 +661,8 @@ export const en = {
disabled: "Auto-merge disabled",
},
unavailable: {
viewPrNoGithub: "View PR isn't available right now because GitHub isn't connected",
viewPrNoForge:
"View {{noun}} isn't available right now because {{brand}} isn't connected",
pullNoRemote:
"Pull isn't available here because this branch is not connected to a remote yet",
pullDirty:
@@ -663,8 +676,13 @@ export const en = {
"Pull and push isn't available here because this branch is not connected to a remote yet",
pullAndPushDirty:
"Pull and push isn't available while you have local changes so commit or stash them first",
pullAndPushNoIncoming:
"Pull and push isn't available because there are no incoming changes to pull first",
pullAndPushInSync: "Pull and push isn't available because this branch is already in sync",
createPrNoGithub: "Create PR isn't available right now because GitHub isn't connected",
pullAndPushNothingToPush:
"Pull and push isn't available because there is nothing new to send after pulling",
createPrNoForge:
"Create {{noun}} isn't available right now because {{brand}} isn't connected",
createPrNoCommits:
"Create PR isn't available because this branch doesn't have any new commits yet",
mergeNoBase: "Merge isn't available because we couldn't determine the base branch",
@@ -678,6 +696,10 @@ export const en = {
updateCurrent:
"Update isn't available because this branch is already up to date with {{baseRef}}",
mergePrNoGithub: "Merge PR isn't available right now because GitHub isn't connected",
archiveNotWorktree:
"Archive isn't available here because this workspace was not created as a Paseo worktree",
mergePrNoForge:
"Merge {{noun}} isn't available right now because {{brand}} isn't connected",
mergePrMissing: "Merge PR isn't available because there isn't a pull request yet",
mergePrDraft: "Merge PR isn't available because the pull request is still a draft",
mergePrMerged: "Merge PR isn't available because the pull request is already merged",
@@ -685,7 +707,7 @@ export const en = {
mergePrConflicts: "Merge PR isn't available because the pull request has conflicts",
mergePrQueue: "Merge PR isn't available here because this repository uses a merge queue",
mergePrNotReady:
"Merge PR isn't available until GitHub reports the pull request is ready to merge",
"Merge {{noun}} isn't available until {{brand}} reports the {{noun}} is ready to merge",
autoMergeCannotDisable: "Auto-merge is enabled, but this account can't disable it",
},
toasts: {
@@ -735,7 +757,7 @@ export const en = {
expandAllFolders: "Expand all folders",
refreshing: "Refreshing",
refresh: "Refresh",
refreshState: "Refresh git and GitHub state",
refreshState: "Refresh git and {{brand}} state",
failedRefresh: "Failed to refresh git state.",
emptyHiddenWhitespace: "No visible changes after hiding whitespace",
emptyUncommitted: "No uncommitted changes",
@@ -771,13 +793,23 @@ export const en = {
pr: {
actions: {
viewPullRequest: "View",
openOn: "Open on {{brand}}",
},
sections: {
checks: "Checks",
pipeline: "Pipeline",
reviews: "Reviews",
},
empty: {
noJobs: "No jobs",
loadingPipeline: "Loading pipeline…",
pipelineJobsLoadFailed: "Could not load pipeline jobs",
allowedToFail: "allowed to fail",
},
approvals: "{{given}} of {{required}} approvals",
accessibility: {
pullRequest: "Pull request #{{number}}",
pullRequest_mr: "Merge request !{{number}}",
},
states: {
draft: "Draft",
@@ -794,11 +826,19 @@ export const en = {
time: {
justNow: "just now",
},
thread: {
discussion: "Discussion thread",
},
errors: {
statusLoadFailed: "Unable to load pull request status",
activityLoadFailed: "Unable to load pull request activity",
},
},
forgeSetup: {
installCli: "Install the {{cli}} CLI to use {{brand}} features.",
signIn: "Run {{command}} to use {{brand}} features.",
generic: "Set up {{brand}} on this host to use its features.",
},
},
},
sidebar: {
@@ -930,9 +970,9 @@ export const en = {
refPicker: {
startingRef: "Starting ref",
chooseStart: "Choose where to start from",
checkoutHint: "Check out PR #{{number}}?",
checkoutPr: "Check out PR #{{number}}",
dismissCheckoutHint: "Dismiss PR #{{number}} checkout hint",
checkoutHint: "Check out {{noun}} {{numberPrefix}}{{number}}?",
checkoutPr: "Check out {{noun}} {{numberPrefix}}{{number}}",
dismissCheckoutHint: "Dismiss {{noun}} {{numberPrefix}}{{number}} checkout hint",
intoBase: "into {{baseRef}}",
searching: "Searching...",
noMatchingRefs: "No matching refs.",
+50 -11
View File
@@ -102,6 +102,7 @@ export const es: TranslationResources = {
addImage: "Agregar imagen",
addFile: "Upload file",
addIssueOrPr: "Agregar problema oPR",
addIssueOrPr_mr: "Agregar problema o MR",
dropImagesHere: "Suelta imágenes aquí",
dropFilesHere: "Drop files here",
editQueuedMessage: "Editar mensaje en cola",
@@ -109,8 +110,8 @@ export const es: TranslationResources = {
openImage: "Abrir imagen adjunta",
removeImage: "Quitar imagen adjunta",
removeFile: "Remove file attachment",
openGithub: "Abrir{{kind}}#{{number}}",
removeGithub: "Quitar{{kind}}#{{number}}",
openGithub: "Abrir {{kind}} {{number}}",
removeGithub: "Quitar {{kind}} {{number}}",
element: "Elemento",
openBrowserElement: "Abrir archivo adjunto de elemento del navegador",
removeBrowserElement: "Eliminar el archivo adjunto del elemento del navegador",
@@ -134,7 +135,9 @@ export const es: TranslationResources = {
searching: "Búsqueda...",
noResults: "No se encontraron resultados.",
searchPlaceholder: "Problemas de búsqueda y relaciones públicas...",
searchPlaceholder_mr: "Problemas de búsqueda y MRs...",
title: "Adjuntar problema oPR",
title_mr: "Adjuntar problema o MR",
},
},
agentControls: {
@@ -624,6 +627,9 @@ export const es: TranslationResources = {
label: "CrearPR",
pending: "CreandoPR...",
success: "PRcreado",
label_mr: "Crear MR",
pending_mr: "Creando MR...",
success_mr: "MR creado",
},
mergeBranch: {
label: "Fusionar localmente",
@@ -646,7 +652,13 @@ export const es: TranslationResources = {
rebase: "Fusionar PR (rebase)",
pending: "Fusionando PR...",
success: "PR fusionado",
squash_mr: "Fusionar MR (squash)",
merge_mr: "Fusionar MR (merge)",
rebase_mr: "Fusionar MR (rebase)",
pending_mr: "Fusionando MR...",
success_mr: "MR fusionado",
},
viewPr_mr: "Ver MR",
autoMerge: {
enableSquash: "Fusión automática (squash)",
enableMerge: "Fusión automática (merge)",
@@ -657,7 +669,8 @@ export const es: TranslationResources = {
disabled: "Fusión automática deshabilitada",
},
unavailable: {
viewPrNoGithub: "VerPRno está disponible en este momento porqueGitHubno está conectado",
viewPrNoForge:
"Ver {{noun}} no está disponible en este momento porque {{brand}} no está conectado",
pullNoRemote:
"Pull no está disponible aquí porque esta rama aún no está conectada a un control remoto",
pullDirty:
@@ -672,10 +685,14 @@ export const es: TranslationResources = {
"Tirar y empujar no está disponible aquí porque esta rama aún no está conectada a un control remoto",
pullAndPushDirty:
"Tirar y empujar no está disponible mientras tenga cambios locales, así que confírmelos o guárdelos primero",
pullAndPushNoIncoming:
"Pull and push no está disponible porque no hay cambios entrantes que traer primero",
pullAndPushInSync:
"Pull and push no está disponible porque esta rama ya está sincronizada",
createPrNoGithub:
"CrearPRno está disponible en este momento porqueGitHubno está conectado",
pullAndPushNothingToPush:
"Pull and push no está disponible porque no hay nada nuevo que enviar después de pull",
createPrNoForge:
"Crear {{noun}} no está disponible en este momento porque {{brand}} no está conectado",
createPrNoCommits:
"CrearPRno está disponible porque esta rama aún no tiene nuevas confirmaciones",
mergeNoBase:
@@ -692,6 +709,10 @@ export const es: TranslationResources = {
"La actualización no está disponible porque esta rama ya está actualizada con{{baseRef}}",
mergePrNoGithub:
"FusionarPRno está disponible en este momento porqueGitHubno está conectado",
archiveNotWorktree:
"El archivo no está disponible aquí porque este espacio de trabajo no se creó como un árbol de trabajoPaseo",
mergePrNoForge:
"Fusionar {{noun}} no está disponible en este momento porque {{brand}} no está conectado",
mergePrMissing:
"FusionarPRno está disponible porque aún no hay una solicitud de extracción",
mergePrDraft:
@@ -705,7 +726,7 @@ export const es: TranslationResources = {
mergePrQueue:
"FusionarPRno está disponible aquí porque este repositorio utiliza una cola de fusión",
mergePrNotReady:
"FusionarPRno está disponible hasta queGitHubinforme que la solicitud de extracción está lista para fusionarse",
"Fusionar {{noun}} no está disponible hasta que {{brand}} informe que {{noun}} está listo para fusionarse",
autoMergeCannotDisable:
"La combinación automática está habilitada, pero esta cuenta no puede deshabilitarla",
},
@@ -756,7 +777,7 @@ export const es: TranslationResources = {
expandAllFolders: "Expandir todas las carpetas",
refreshing: "Refrescante",
refresh: "Refrescar",
refreshState: "Actualizar el estado de git yGitHub",
refreshState: "Actualizar el estado de git y {{brand}}",
failedRefresh: "No se pudo actualizar el estado de git.",
emptyHiddenWhitespace: "No hay cambios visibles después de ocultar espacios en blanco",
emptyUncommitted: "Sin cambios no confirmados",
@@ -792,13 +813,23 @@ export const es: TranslationResources = {
pr: {
actions: {
viewPullRequest: "Ver",
openOn: "Abrir en {{brand}}",
},
sections: {
checks: "cheques",
pipeline: "Pipeline",
reviews: "Reseñas",
},
empty: {
noJobs: "Sin trabajos",
loadingPipeline: "Cargando pipeline...",
pipelineJobsLoadFailed: "No se pudieron cargar los trabajos del pipeline",
allowedToFail: "permitido fallar",
},
approvals: "{{given}} de {{required}} aprobaciones",
accessibility: {
pullRequest: "Solicitud de extracción n.°{{number}}",
pullRequest_mr: "Solicitud de fusión !{{number}}",
},
states: {
draft: "Borrador",
@@ -815,11 +846,19 @@ export const es: TranslationResources = {
time: {
justNow: "En este momento",
},
thread: {
discussion: "Hilo de discusión",
},
errors: {
statusLoadFailed: "No se puede cargar el estado de la solicitud de extracción",
activityLoadFailed: "No se puede cargar la actividad de solicitud de extracción",
},
},
forgeSetup: {
installCli: "Instala la CLI de {{cli}} para usar las funciones de {{brand}}.",
signIn: "Ejecuta {{command}} para usar las funciones de {{brand}}.",
generic: "Configura {{brand}} en este host para usar sus funciones.",
},
},
},
sidebar: {
@@ -951,10 +990,10 @@ export const es: TranslationResources = {
refPicker: {
startingRef: "Árbitro inicial",
chooseStart: "Elige por dónde empezar",
checkoutHint: "¿MiraPR#{{number}}?",
checkoutPr: "Echa un vistazo aPR#{{number}}",
dismissCheckoutHint: "Descartar la sugerencia de pago dePR#{{number}}",
intoBase: "en{{baseRef}}",
checkoutHint: "¿Mira {{noun}} {{numberPrefix}}{{number}}?",
checkoutPr: "Echa un vistazo a {{noun}} {{numberPrefix}}{{number}}",
dismissCheckoutHint: "Descartar la sugerencia de pago de {{noun}} {{numberPrefix}}{{number}}",
intoBase: "en {{baseRef}}",
searching: "Búsqueda...",
noMatchingRefs: "No hay árbitros coincidentes.",
searchPlaceholder: "Buscar sucursales y relaciones públicas",
+50 -11
View File
@@ -103,6 +103,7 @@ export const fr: TranslationResources = {
addImage: "Ajouter une image",
addFile: "Upload file",
addIssueOrPr: "Ajouter un problème ouPR",
addIssueOrPr_mr: "Ajouter un problème ou MR",
dropImagesHere: "Déposez des images ici",
dropFilesHere: "Drop files here",
editQueuedMessage: "Modifier le message en file d'attente",
@@ -110,8 +111,8 @@ export const fr: TranslationResources = {
openImage: "Ouvrir la pièce jointe de l'image",
removeImage: "Supprimer l'image jointe",
removeFile: "Remove file attachment",
openGithub: "Ouvrir{{kind}}#{{number}}",
removeGithub: "Supprimer{{kind}}#{{number}}",
openGithub: "Ouvrir {{kind}} {{number}}",
removeGithub: "Supprimer {{kind}} {{number}}",
element: "Élément",
openBrowserElement: "Ouvrir la pièce jointe de l'élément de navigateur",
removeBrowserElement: "Supprimer la pièce jointe d'un élément de navigateur",
@@ -135,7 +136,9 @@ export const fr: TranslationResources = {
searching: "Recherche...",
noResults: "Aucun résultat trouvé.",
searchPlaceholder: "Problèmes de recherche et PR...",
searchPlaceholder_mr: "Problèmes de recherche et MR...",
title: "Joindre le problème ouPR",
title_mr: "Joindre le problème ou MR",
},
},
agentControls: {
@@ -623,6 +626,9 @@ export const fr: TranslationResources = {
label: "CréerPR",
pending: "Création dePR...",
success: "PRcréé",
label_mr: "Créer MR",
pending_mr: "Création de MR...",
success_mr: "MR créé",
},
mergeBranch: {
label: "Fusionner localement",
@@ -645,7 +651,13 @@ export const fr: TranslationResources = {
rebase: "Fusionner PR (rebase)",
pending: "Fusion de PR...",
success: "PR fusionné",
squash_mr: "Fusionner MR (squash)",
merge_mr: "Fusionner MR (merge)",
rebase_mr: "Fusionner MR (rebase)",
pending_mr: "Fusion de MR...",
success_mr: "MR fusionné",
},
viewPr_mr: "Voir MR",
autoMerge: {
enableSquash: "Fusion automatique (squash)",
enableMerge: "Fusion automatique (merge)",
@@ -656,7 +668,8 @@ export const fr: TranslationResources = {
disabled: "Fusion automatique désactivée",
},
unavailable: {
viewPrNoGithub: "ViewPRn'est pas disponible pour le moment carGitHubn'est pas connecté",
viewPrNoForge:
"Voir {{noun}} n'est pas disponible pour le moment car {{brand}} n'est pas connecté",
pullNoRemote:
"Pull n'est pas disponible ici car cette branche n'est pas encore connectée à une télécommande",
pullDirty:
@@ -671,10 +684,14 @@ export const fr: TranslationResources = {
"Le pull et le push ne sont pas disponibles ici car cette branche n'est pas encore connectée à une télécommande",
pullAndPushDirty:
"Pull et push ne sont pas disponibles tant que vous avez des modifications locales, alors validez-les ou cachez-les d'abord",
pullAndPushNoIncoming:
"Pull et push ne sont pas disponibles car il n'y a aucun changement entrant à récupérer d'abord",
pullAndPushInSync:
"Les fonctions Pull et Push ne sont pas disponibles car cette branche est déjà synchronisée",
createPrNoGithub:
"CréerPRn'est pas disponible pour le moment carGitHubn'est pas connecté",
pullAndPushNothingToPush:
"Pull et push ne sont pas disponibles car il n'y a rien de nouveau à envoyer après le pull",
createPrNoForge:
"Créer {{noun}} n'est pas disponible pour le moment car {{brand}} n'est pas connecté",
createPrNoCommits:
"CréerPRn'est pas disponible car cette branche n'a pas encore de nouveaux commits",
mergeNoBase:
@@ -691,6 +708,10 @@ export const fr: TranslationResources = {
"La mise à jour n'est pas disponible car cette branche est déjà à jour avec{{baseRef}}",
mergePrNoGithub:
"La fusionPRn'est pas disponible pour le moment carGitHubn'est pas connecté",
archiveNotWorktree:
"L'archive n'est pas disponible ici car cet espace de travail n'a pas été créé en tant qu'arbre de travailPaseo",
mergePrNoForge:
"La fusion {{noun}} n'est pas disponible pour le moment car {{brand}} n'est pas connecté",
mergePrMissing:
"La fusionPRn'est pas disponible car il n'y a pas encore de demande d'extraction",
mergePrDraft:
@@ -703,7 +724,7 @@ export const fr: TranslationResources = {
mergePrQueue:
"MergePRn'est pas disponible ici car ce référentiel utilise une file d'attente de fusion",
mergePrNotReady:
"La fusionPRn'est pas disponible jusqu'à ce queGitHubsignale que la demande d'extraction est prête à fusionner",
"La fusion {{noun}} n'est pas disponible jusqu'à ce que {{brand}} signale que {{noun}} est prête à fusionner",
autoMergeCannotDisable:
"La fusion automatique est activée, mais ce compte ne peut pas la désactiver",
},
@@ -754,7 +775,7 @@ export const fr: TranslationResources = {
expandAllFolders: "Développer tous les dossiers",
refreshing: "Rafraîchissant",
refresh: "Rafraîchir",
refreshState: "Actualiser l'état de git etGitHub",
refreshState: "Actualiser l'état de git et de {{brand}}",
failedRefresh: "Échec de l'actualisation de l'état git.",
emptyHiddenWhitespace: "Aucun changement visible après avoir masqué les espaces",
emptyUncommitted: "Aucune modification non validée",
@@ -790,13 +811,23 @@ export const fr: TranslationResources = {
pr: {
actions: {
viewPullRequest: "Voir",
openOn: "Ouvrir sur {{brand}}",
},
sections: {
checks: "Chèques",
pipeline: "Pipeline",
reviews: "Avis",
},
empty: {
noJobs: "Aucune tâche",
loadingPipeline: "Chargement du pipeline...",
pipelineJobsLoadFailed: "Impossible de charger les tâches du pipeline",
allowedToFail: "autorisé à échouer",
},
approvals: "{{given}} sur {{required}} approbations",
accessibility: {
pullRequest: "Demande de tirage #{{number}}",
pullRequest_mr: "Demande de fusion !{{number}}",
},
states: {
draft: "Brouillon",
@@ -813,11 +844,19 @@ export const fr: TranslationResources = {
time: {
justNow: "tout à l' heure",
},
thread: {
discussion: "Fil de discussion",
},
errors: {
statusLoadFailed: "Impossible de charger le statut de la demande d'extraction",
activityLoadFailed: "Impossible de charger l'activité de demande d'extraction",
},
},
forgeSetup: {
installCli: "Installez la CLI {{cli}} pour utiliser les fonctionnalités {{brand}}.",
signIn: "Exécutez {{command}} pour utiliser les fonctionnalités {{brand}}.",
generic: "Configurez {{brand}} sur cet hôte pour utiliser ses fonctionnalités.",
},
},
},
sidebar: {
@@ -949,10 +988,10 @@ export const fr: TranslationResources = {
refPicker: {
startingRef: "Réf de départ",
chooseStart: "Choisissez par où commencer",
checkoutHint: "DécouvrezPR#{{number}}?",
checkoutPr: "DécouvrezPR#{{number}}",
dismissCheckoutHint: "Ignorer l'indice de paiementPR#{{number}}",
intoBase: "dans{{baseRef}}",
checkoutHint: "Découvrez {{noun}} {{numberPrefix}}{{number}} ?",
checkoutPr: "Découvrez {{noun}} {{numberPrefix}}{{number}}",
dismissCheckoutHint: "Ignorer l'indice de paiement {{noun}} {{numberPrefix}}{{number}}",
intoBase: "dans {{baseRef}}",
searching: "Recherche...",
noMatchingRefs: "Aucune référence correspondante.",
searchPlaceholder: "Rechercher des succursales et des PR",
+45 -9
View File
@@ -102,6 +102,7 @@ export const ja: TranslationResources = {
addImage: "画像を追加",
addFile: "ファイルをアップロード",
addIssueOrPr: "イシューまたはPRを追加",
addIssueOrPr_mr: "イシューまたはMRを追加",
dropImagesHere: "ここに画像をドロップ",
dropFilesHere: "ここにファイルをドロップ",
editQueuedMessage: "キューに入れたメッセージを編集",
@@ -109,8 +110,8 @@ export const ja: TranslationResources = {
openImage: "画像添付ファイルを開く",
removeImage: "画像添付ファイルを削除",
removeFile: "ファイル添付ファイルを削除",
openGithub: "{{kind}} #{{number}}を開く",
removeGithub: "{{kind}} #{{number}}を削除",
openGithub: "{{kind}} {{number}}を開く",
removeGithub: "{{kind}} {{number}}を削除",
element: "要素",
openBrowserElement: "ブラウザ要素の添付ファイルを開く",
removeBrowserElement: "ブラウザ要素の添付ファイルを削除",
@@ -134,7 +135,9 @@ export const ja: TranslationResources = {
searching: "検索中...",
noResults: "結果が見つかりません。",
searchPlaceholder: "イシューとPRを検索...",
searchPlaceholder_mr: "イシューとMRを検索...",
title: "イシューまたはPRを添付",
title_mr: "イシューまたはMRを添付",
},
},
agentControls: {
@@ -622,6 +625,9 @@ export const ja: TranslationResources = {
label: "PRを作成",
pending: "PRを作成中...",
success: "PRが作成されました",
label_mr: "MRを作成",
pending_mr: "MRを作成中...",
success_mr: "MRが作成されました",
},
mergeBranch: {
label: "ローカルでマージ",
@@ -644,7 +650,13 @@ export const ja: TranslationResources = {
rebase: "PRをマージ(リベース)",
pending: "PRをマージ中...",
success: "PRがマージされました",
squash_mr: "MRをマージ(スカッシュ)",
merge_mr: "MRをマージ(マージ)",
rebase_mr: "MRをマージ(リベース)",
pending_mr: "MRをマージ中...",
success_mr: "MRがマージされました",
},
viewPr_mr: "MRを表示",
autoMerge: {
enableSquash: "自動マージ(スカッシュ)",
enableMerge: "自動マージ(マージ)",
@@ -655,7 +667,7 @@ export const ja: TranslationResources = {
disabled: "自動マージが無効になりました",
},
unavailable: {
viewPrNoGithub: "GitHubが接続されていないため、PRの表示は現在利用できません",
viewPrNoForge: "{{brand}}が接続されていないため、{{noun}}の表示は現在利用できません",
pullNoRemote:
"このブランチはまだリモートに接続されていないため、プルはここでは利用できません",
pullDirty:
@@ -669,9 +681,12 @@ export const ja: TranslationResources = {
"このブランチはまだリモートに接続されていないため、プル&プッシュはここでは利用できません",
pullAndPushDirty:
"ローカルに変更があるためプル&プッシュは利用できません。先にコミットまたはスタッシュしてください",
pullAndPushNoIncoming: "先にプルする受信変更がないため、プル&プッシュは利用できません",
pullAndPushInSync:
"このブランチはすでに同期されているため、プル&プッシュは利用できません",
createPrNoGithub: "GitHubが接続されていないため、PRの作成は現在利用できません",
pullAndPushNothingToPush:
"プル後に送信する新しい変更がないため、プル&プッシュは利用できません",
createPrNoForge: "{{brand}}が接続されていないため、{{noun}}の作成は現在利用できません",
createPrNoCommits: "このブランチにまだ新しいコミットがないため、PRの作成は利用できません",
mergeNoBase: "ベースブランチを特定できなかったため、マージは利用できません",
mergeDirty:
@@ -682,6 +697,9 @@ export const ja: TranslationResources = {
"ローカルに変更があるため更新は利用できません。先にコミットまたはスタッシュしてください",
updateCurrent: "このブランチはすでに{{baseRef}}と最新の状態のため、更新は利用できません",
mergePrNoGithub: "GitHubが接続されていないため、PRのマージは現在利用できません",
archiveNotWorktree:
"このワークスペースはPaseoワークツリーとして作成されていないため、アーカイブはここでは利用できません",
mergePrNoForge: "{{brand}}が接続されていないため、{{noun}}のマージは現在利用できません",
mergePrMissing: "プルリクエストがまだないため、PRのマージは利用できません",
mergePrDraft: "プルリクエストがまだドラフトのため、PRのマージは利用できません",
mergePrMerged: "プルリクエストはすでにマージされているため、PRのマージは利用できません",
@@ -690,7 +708,7 @@ export const ja: TranslationResources = {
mergePrQueue:
"このリポジトリはマージキューを使用しているため、PRのマージはここでは利用できません",
mergePrNotReady:
"GitHub上でプルリクエストがマージ可能になるまで、PRのマージは利用できません",
"{{brand}}上で{{noun}}がマージ可能になるまで、{{noun}}のマージは利用できません",
autoMergeCannotDisable:
"自動マージは有効になっていますが、このアカウントでは無効にできません",
},
@@ -741,7 +759,7 @@ export const ja: TranslationResources = {
expandAllFolders: "すべてのフォルダを展開",
refreshing: "更新中",
refresh: "更新",
refreshState: "gitとGitHubの状態を更新",
refreshState: "gitと{{brand}}の状態を更新",
failedRefresh: "gitの状態の更新に失敗しました。",
emptyHiddenWhitespace: "空白を非表示にすると変更は表示されません",
emptyUncommitted: "未コミットの変更なし",
@@ -777,13 +795,23 @@ export const ja: TranslationResources = {
pr: {
actions: {
viewPullRequest: "表示",
openOn: "{{brand}}で開く",
},
sections: {
checks: "チェック",
pipeline: "パイプライン",
reviews: "レビュー",
},
empty: {
noJobs: "ジョブなし",
loadingPipeline: "パイプラインを読み込み中...",
pipelineJobsLoadFailed: "パイプラインのジョブを読み込めませんでした",
allowedToFail: "失敗を許可",
},
approvals: "{{given}} / {{required}} 承認",
accessibility: {
pullRequest: "プルリクエスト#{{number}}",
pullRequest_mr: "マージリクエスト !{{number}}",
},
states: {
draft: "ドラフト",
@@ -800,11 +828,19 @@ export const ja: TranslationResources = {
time: {
justNow: "たった今",
},
thread: {
discussion: "ディスカッションスレッド",
},
errors: {
statusLoadFailed: "プルリクエストのステータスを読み込めません",
activityLoadFailed: "プルリクエストのアクティビティを読み込めません",
},
},
forgeSetup: {
installCli: "{{brand}} の機能を使うには {{cli}} CLI をインストールしてください。",
signIn: "{{brand}} の機能を使うには {{command}} を実行してください。",
generic: "このホストで {{brand}} をセットアップすると、その機能を使えます。",
},
},
},
sidebar: {
@@ -936,9 +972,9 @@ export const ja: TranslationResources = {
refPicker: {
startingRef: "開始Ref",
chooseStart: "開始点を選択",
checkoutHint: "PR #{{number}}をチェックアウトしますか?",
checkoutPr: "PR #{{number}}をチェックアウト",
dismissCheckoutHint: "PR #{{number}}のチェックアウトヒントを閉じる",
checkoutHint: "{{noun}} {{numberPrefix}}{{number}}をチェックアウトしますか?",
checkoutPr: "{{noun}} {{numberPrefix}}{{number}}をチェックアウト",
dismissCheckoutHint: "{{noun}} {{numberPrefix}}{{number}}のチェックアウトヒントを閉じる",
intoBase: "{{baseRef}}に",
searching: "検索中...",
noMatchingRefs: "一致するRefがありません。",
+49 -9
View File
@@ -102,6 +102,7 @@ export const ptBR: TranslationResources = {
addImage: "Adicionar imagem",
addFile: "Enviar arquivo",
addIssueOrPr: "Adicionar issue ou PR",
addIssueOrPr_mr: "Adicionar issue ou MR",
dropImagesHere: "Solte imagens aqui",
dropFilesHere: "Solte arquivos aqui",
editQueuedMessage: "Editar mensagem na fila",
@@ -109,8 +110,8 @@ export const ptBR: TranslationResources = {
openImage: "Abrir anexo de imagem",
removeImage: "Remover anexo de imagem",
removeFile: "Remover anexo de arquivo",
openGithub: "Abrir {{kind}} #{{number}}",
removeGithub: "Remover {{kind}} #{{number}}",
openGithub: "Abrir {{kind}} {{number}}",
removeGithub: "Remover {{kind}} {{number}}",
element: "Elemento",
openBrowserElement: "Abrir anexo de elemento do navegador",
removeBrowserElement: "Remover anexo de elemento do navegador",
@@ -134,7 +135,9 @@ export const ptBR: TranslationResources = {
searching: "Buscando...",
noResults: "Nenhum resultado encontrado.",
searchPlaceholder: "Buscar issues e PRs...",
searchPlaceholder_mr: "Buscar issues e MRs...",
title: "Anexar issue ou PR",
title_mr: "Anexar issue ou MR",
},
},
agentControls: {
@@ -622,6 +625,9 @@ export const ptBR: TranslationResources = {
label: "Criar PR",
pending: "Criando PR...",
success: "PR criada",
label_mr: "Criar MR",
pending_mr: "Criando MR...",
success_mr: "MR criada",
},
mergeBranch: {
label: "Fazer merge localmente",
@@ -644,7 +650,13 @@ export const ptBR: TranslationResources = {
rebase: "Merge",
pending: "Fazendo merge da PR...",
success: "PR mergeada",
squash_mr: "Merge",
merge_mr: "Merge",
rebase_mr: "Merge",
pending_mr: "Fazendo merge da MR...",
success_mr: "MR mergeada",
},
viewPr_mr: "Ver MR",
autoMerge: {
enableSquash: "Merge automático",
enableMerge: "Merge automático",
@@ -655,7 +667,8 @@ export const ptBR: TranslationResources = {
disabled: "Merge automático desativado",
},
unavailable: {
viewPrNoGithub: "Ver PR não está disponível agora porque o GitHub não está conectado",
viewPrNoForge:
"Ver {{noun}} não está disponível agora porque o {{brand}} não está conectado",
pullNoRemote:
"Pull não está disponível aqui porque esta branch ainda não está conectada a um remoto",
pullDirty:
@@ -670,9 +683,14 @@ export const ptBR: TranslationResources = {
"Pull e push não estão disponíveis aqui porque esta branch ainda não está conectada a um remoto",
pullAndPushDirty:
"Pull e push não estão disponíveis enquanto há alterações locais. Faça commit ou stash primeiro",
pullAndPushNoIncoming:
"Pull e push não estão disponíveis porque não há alterações de entrada para puxar primeiro",
pullAndPushInSync:
"Pull e push não estão disponíveis porque esta branch já está sincronizada",
createPrNoGithub: "Criar PR não está disponível agora porque o GitHub não está conectado",
pullAndPushNothingToPush:
"Pull e push não estão disponíveis porque não há nada novo para enviar depois do pull",
createPrNoForge:
"Criar {{noun}} não está disponível agora porque o {{brand}} não está conectado",
createPrNoCommits:
"Criar PR não está disponível porque esta branch ainda não tem commits novos",
mergeNoBase: "Merge não está disponível porque não foi possível determinar a branch base",
@@ -688,6 +706,10 @@ export const ptBR: TranslationResources = {
"Atualizar não está disponível porque esta branch já está atualizada com {{baseRef}}",
mergePrNoGithub:
"Merge da PR não está disponível agora porque o GitHub não está conectado",
archiveNotWorktree:
"Arquivar não está disponível aqui porque este workspace não foi criado como um worktree do Paseo",
mergePrNoForge:
"Merge da {{noun}} não está disponível agora porque o {{brand}} não está conectado",
mergePrMissing: "Merge da PR não está disponível porque ainda não há uma pull request",
mergePrDraft: "Merge da PR não está disponível porque a pull request ainda é um rascunho",
mergePrMerged: "Merge da PR não está disponível porque a pull request já foi mergeada",
@@ -696,7 +718,7 @@ export const ptBR: TranslationResources = {
mergePrQueue:
"Merge da PR não está disponível aqui porque este repositório usa uma merge queue",
mergePrNotReady:
"Merge da PR não está disponível até o GitHub informar que a pull request está pronta para merge",
"Merge da {{noun}} não está disponível até o {{brand}} informar que a {{noun}} está pronta para merge",
autoMergeCannotDisable:
"O merge automático está ativado, mas esta conta não pode desativá-lo",
},
@@ -747,7 +769,7 @@ export const ptBR: TranslationResources = {
expandAllFolders: "Expandir todas as pastas",
refreshing: "Atualizando",
refresh: "Atualizar",
refreshState: "Atualizar estado do git e do GitHub",
refreshState: "Atualizar estado do git e do {{brand}}",
failedRefresh: "Falha ao atualizar estado do git.",
emptyHiddenWhitespace: "Nenhuma alteração visível após ocultar espaços em branco",
emptyUncommitted: "Nenhuma alteração sem commit",
@@ -783,13 +805,23 @@ export const ptBR: TranslationResources = {
pr: {
actions: {
viewPullRequest: "Ver",
openOn: "Abrir no {{brand}}",
},
sections: {
checks: "Verificações",
pipeline: "Pipeline",
reviews: "Revisões",
},
empty: {
noJobs: "Sem jobs",
loadingPipeline: "Carregando pipeline...",
pipelineJobsLoadFailed: "Não foi possível carregar os jobs do pipeline",
allowedToFail: "permitido falhar",
},
approvals: "{{given}} de {{required}} aprovações",
accessibility: {
pullRequest: "Pull request #{{number}}",
pullRequest_mr: "Merge request !{{number}}",
},
states: {
draft: "Rascunho",
@@ -806,11 +838,19 @@ export const ptBR: TranslationResources = {
time: {
justNow: "agora mesmo",
},
thread: {
discussion: "Tópico de discussão",
},
errors: {
statusLoadFailed: "Não foi possível carregar o status da pull request",
activityLoadFailed: "Não foi possível carregar a atividade da pull request",
},
},
forgeSetup: {
installCli: "Instale a CLI {{cli}} para usar os recursos do {{brand}}.",
signIn: "Execute {{command}} para usar os recursos do {{brand}}.",
generic: "Configure o {{brand}} neste host para usar seus recursos.",
},
},
},
sidebar: {
@@ -942,9 +982,9 @@ export const ptBR: TranslationResources = {
refPicker: {
startingRef: "Ref inicial",
chooseStart: "Escolha de onde começar",
checkoutHint: "Fazer checkout da PR #{{number}}?",
checkoutPr: "Fazer checkout da PR #{{number}}",
dismissCheckoutHint: "Dispensar dica de checkout da PR #{{number}}",
checkoutHint: "Fazer checkout da {{noun}} {{numberPrefix}}{{number}}?",
checkoutPr: "Fazer checkout da {{noun}} {{numberPrefix}}{{number}}",
dismissCheckoutHint: "Dispensar dica de checkout da {{noun}} {{numberPrefix}}{{number}}",
intoBase: "em {{baseRef}}",
searching: "Buscando...",
noMatchingRefs: "Nenhuma ref correspondente.",
+50 -11
View File
@@ -102,6 +102,7 @@ export const ru: TranslationResources = {
addImage: "Добавить изображение",
addFile: "Upload file",
addIssueOrPr: "Добавить проблему или PR",
addIssueOrPr_mr: "Добавить проблему или MR",
dropImagesHere: "Скиньте изображения сюда",
dropFilesHere: "Drop files here",
editQueuedMessage: "Изменить сообщение в очереди",
@@ -109,8 +110,8 @@ export const ru: TranslationResources = {
openImage: "Открыть прикрепленное изображение",
removeImage: "Удалить прикрепленное изображение",
removeFile: "Remove file attachment",
openGithub: "Открыть{{kind}}#{{number}}",
removeGithub: "Удалить{{kind}}#{{number}}",
openGithub: "Открыть {{kind}} {{number}}",
removeGithub: "Удалить {{kind}} {{number}}",
element: "Элемент",
openBrowserElement: "Открыть вложение элемента браузера",
removeBrowserElement: "Удалить вложение элемента браузера",
@@ -134,7 +135,9 @@ export const ru: TranslationResources = {
searching: "Идет поиск...",
noResults: "Результаты не найдены.",
searchPlaceholder: "Поиск проблем и пиар...",
searchPlaceholder_mr: "Поиск проблем и MR...",
title: "Прикрепите проблему или PR",
title_mr: "Прикрепите проблему или MR",
},
},
agentControls: {
@@ -623,6 +626,9 @@ export const ru: TranslationResources = {
label: "Создать PR",
pending: "Создание PR...",
success: "PR создано",
label_mr: "Создать MR",
pending_mr: "Создание MR...",
success_mr: "MR создано",
},
mergeBranch: {
label: "Объединить локально",
@@ -645,7 +651,13 @@ export const ru: TranslationResources = {
rebase: "Объединить PR (rebase)",
pending: "Объединение PR...",
success: "PR объединен",
squash_mr: "Объединить MR (squash)",
merge_mr: "Объединить MR (merge)",
rebase_mr: "Объединить MR (rebase)",
pending_mr: "Объединение MR...",
success_mr: "MR объединен",
},
viewPr_mr: "Посмотреть MR",
autoMerge: {
enableSquash: "Автообъединение (squash)",
enableMerge: "Автообъединение (merge)",
@@ -656,7 +668,7 @@ export const ru: TranslationResources = {
disabled: "Автоматическое объединение отключено",
},
unavailable: {
viewPrNoGithub: "Просмотр PR сейчас недоступен, поскольку GitHub не подключен.",
viewPrNoForge: "Просмотр {{noun}} сейчас недоступен, поскольку {{brand}} не подключен.",
pullNoRemote:
"Функция Pull здесь недоступна, поскольку эта ветка еще не подключена к удаленному серверу.",
pullDirty:
@@ -670,10 +682,14 @@ export const ru: TranslationResources = {
"Функция Pull and Push здесь недоступна, поскольку эта ветка еще не подключена к удаленному устройству.",
pullAndPushDirty:
"Функция извлечения и отправки недоступна, пока у вас есть локальные изменения, поэтому сначала зафиксируйте или сохраните их.",
pullAndPushNoIncoming:
"Функция Pull and Push недоступна, поскольку сначала нечего получать.",
pullAndPushInSync:
"Функция Pull and Push недоступна, поскольку эта ветвь уже синхронизирована.",
createPrNoGithub:
"Функция «Создать PR» сейчас недоступна, поскольку GitHub не подключен.",
pullAndPushNothingToPush:
"Функция Pull and Push недоступна, поскольку после pull нечего отправлять.",
createPrNoForge:
"Функция «Создать {{noun}}» сейчас недоступна, поскольку {{brand}} не подключен.",
createPrNoCommits:
"Функция «Создать PR» недоступна, поскольку в этой ветке еще нет новых коммитов.",
mergeNoBase: "Объединение недоступно, поскольку нам не удалось определить базовую ветку.",
@@ -687,6 +703,10 @@ export const ru: TranslationResources = {
updateCurrent:
"Обновление недоступно, поскольку эта ветка уже обновлена ​​до версии{{baseRef}}.",
mergePrNoGithub: "Объединение PR сейчас недоступно, поскольку GitHub не подключен.",
archiveNotWorktree:
"Архив здесь недоступен, поскольку это рабочее пространство не было создано как рабочее дерево Paseo.",
mergePrNoForge:
"Объединение {{noun}} сейчас недоступно, поскольку {{brand}} не подключен.",
mergePrMissing: "Объединение PR недоступно, поскольку еще нет запроса на включение",
mergePrDraft:
"Объединение PR недоступно, поскольку запрос на включение все еще находится на стадии черновика.",
@@ -697,7 +717,7 @@ export const ru: TranslationResources = {
mergePrQueue:
"Слияние PR здесь недоступно, поскольку этот репозиторий использует очередь слияния.",
mergePrNotReady:
"Функция слияния PR недоступна до тех пор, пока GitHub не сообщит, что запрос на включение готов к слиянию.",
"Функция слияния {{noun}} недоступна до тех пор, пока {{brand}} не сообщит, что {{noun}} готов к слиянию.",
autoMergeCannotDisable:
"Автоматическое объединение включено, но этот аккаунт не может его отключить.",
},
@@ -748,7 +768,7 @@ export const ru: TranslationResources = {
expandAllFolders: "Развернуть все папки",
refreshing: "Освежающий",
refresh: "Обновить",
refreshState: "Обновить состояние git и GitHub.",
refreshState: "Обновить состояние git и {{brand}}",
failedRefresh: "Не удалось обновить состояние git.",
emptyHiddenWhitespace: "Никаких видимых изменений после скрытия пробелов",
emptyUncommitted: "Нет незафиксированных изменений",
@@ -784,13 +804,23 @@ export const ru: TranslationResources = {
pr: {
actions: {
viewPullRequest: "Открыть",
openOn: "Открыть на {{brand}}",
},
sections: {
checks: "Чеки",
pipeline: "Пайплайн",
reviews: "Отзывы",
},
empty: {
noJobs: "Нет задач",
loadingPipeline: "Загрузка пайплайна...",
pipelineJobsLoadFailed: "Не удалось загрузить задачи пайплайна",
allowedToFail: "допускается сбой",
},
approvals: "{{given}} из {{required}} одобрений",
accessibility: {
pullRequest: "Запрос на извлечение №{{number}}",
pullRequest_mr: "Запрос на слияние !{{number}}",
},
states: {
draft: "Черновик",
@@ -807,11 +837,19 @@ export const ru: TranslationResources = {
time: {
justNow: "прямо сейчас",
},
thread: {
discussion: "Обсуждение",
},
errors: {
statusLoadFailed: "Невозможно загрузить статус запроса на включение",
activityLoadFailed: "Невозможно загрузить активность запроса на включение",
},
},
forgeSetup: {
installCli: "Установите CLI {{cli}}, чтобы использовать возможности {{brand}}.",
signIn: "Выполните {{command}}, чтобы использовать возможности {{brand}}.",
generic: "Настройте {{brand}} на этом хосте, чтобы использовать его функции.",
},
},
},
sidebar: {
@@ -943,10 +981,11 @@ export const ru: TranslationResources = {
refPicker: {
startingRef: "Начальная ссылка",
chooseStart: "Выберите, с чего начать",
checkoutHint: "Проверьте PR#{{number}}?",
checkoutPr: "Проверьте PR#{{number}}",
dismissCheckoutHint: "Отклонить подсказку по оформлению заказа PR#{{number}}",
intoBase: "в{{baseRef}}",
checkoutHint: "Проверьте {{noun}} {{numberPrefix}}{{number}}?",
checkoutPr: "Проверьте {{noun}} {{numberPrefix}}{{number}}",
dismissCheckoutHint:
"Отклонить подсказку по оформлению заказа {{noun}} {{numberPrefix}}{{number}}",
intoBase: "в {{baseRef}}",
searching: "Идет поиск...",
noMatchingRefs: "Нет подходящих ссылок.",
searchPlaceholder: "Поиск филиалов и PR",
+43 -9
View File
@@ -102,6 +102,7 @@ export const zhCN: TranslationResources = {
addImage: "添加图片",
addFile: "Upload file",
addIssueOrPr: "添加 issue 或 PR",
addIssueOrPr_mr: "添加 issue 或 MR",
dropImagesHere: "将图片拖放到这里",
dropFilesHere: "Drop files here",
editQueuedMessage: "编辑排队消息",
@@ -109,8 +110,8 @@ export const zhCN: TranslationResources = {
openImage: "打开图片附件",
removeImage: "移除图片附件",
removeFile: "Remove file attachment",
openGithub: "打开 {{kind}} #{{number}}",
removeGithub: "移除 {{kind}} #{{number}}",
openGithub: "打开 {{kind}} {{number}}",
removeGithub: "移除 {{kind}} {{number}}",
element: "元素",
openBrowserElement: "打开浏览器元素附件",
removeBrowserElement: "移除浏览器元素附件",
@@ -134,7 +135,9 @@ export const zhCN: TranslationResources = {
searching: "正在搜索...",
noResults: "没有结果。",
searchPlaceholder: "搜索 issues 和 PRs...",
searchPlaceholder_mr: "搜索 issues 和 MRs...",
title: "附加 issue 或 PR",
title_mr: "附加 issue 或 MR",
},
},
agentControls: {
@@ -616,6 +619,9 @@ export const zhCN: TranslationResources = {
label: "创建 PR",
pending: "正在创建 PR...",
success: "PR 已创建",
label_mr: "创建 MR",
pending_mr: "正在创建 MR...",
success_mr: "MR 已创建",
},
mergeBranch: {
label: "本地 merge",
@@ -638,7 +644,13 @@ export const zhCN: TranslationResources = {
rebase: "Merge PR (rebase)",
pending: "正在 merge PR...",
success: "PR 已 merge",
squash_mr: "Merge MR (squash)",
merge_mr: "Merge MR (merge)",
rebase_mr: "Merge MR (rebase)",
pending_mr: "正在 merge MR...",
success_mr: "MR 已 merge",
},
viewPr_mr: "查看 MR",
autoMerge: {
enableSquash: "Auto merge (squash)",
enableMerge: "Auto merge (merge)",
@@ -649,7 +661,7 @@ export const zhCN: TranslationResources = {
disabled: "Auto-merge 已禁用",
},
unavailable: {
viewPrNoGithub: "当前无法查看 PR,因为 GitHub 未连接",
viewPrNoForge: "当前无法查看 {{noun}},因为 {{brand}} 未连接",
pullNoRemote: "此处无法 pull,因为此分支尚未连接到 remote",
pullDirty: "有本地变更时无法 pull,请先 commit 或 stash",
pullUpToDate: "无法 pull,因为此分支已是最新",
@@ -658,8 +670,10 @@ export const zhCN: TranslationResources = {
pushNothing: "无法 push,因为没有新的内容可发送",
pullAndPushNoRemote: "此处无法 pull 并 push,因为此分支尚未连接到 remote",
pullAndPushDirty: "有本地变更时无法 pull 并 push,请先 commit 或 stash",
pullAndPushNoIncoming: "无法 pull 并 push,因为没有需要先 pull 的传入变更",
pullAndPushInSync: "无法 pull 并 push,因为此分支已同步",
createPrNoGithub: "当前无法创建 PR,因为 GitHub 未连接",
pullAndPushNothingToPush: "无法 pull 并 push,因为 pull 之后没有新的内容可发送",
createPrNoForge: "当前无法创建 {{noun}},因为 {{brand}} 未连接",
createPrNoCommits: "无法创建 PR,因为此分支还没有新的 commit",
mergeNoBase: "无法 merge,因为无法确定 base branch",
mergeDirty: "有本地变更时无法 merge,请先 commit 或 stash",
@@ -668,13 +682,15 @@ export const zhCN: TranslationResources = {
updateDirty: "有本地变更时无法更新,请先 commit 或 stash",
updateCurrent: "无法更新,因为此分支已与 {{baseRef}} 保持最新",
mergePrNoGithub: "当前无法 merge PR,因为 GitHub 未连接",
archiveNotWorktree: "此处无法归档,因为此 workspace 不是作为 Paseo worktree 创建的",
mergePrNoForge: "当前无法 merge {{noun}},因为 {{brand}} 未连接",
mergePrMissing: "无法 merge PR,因为还没有 pull request",
mergePrDraft: "无法 merge PR,因为 pull request 仍是 draft",
mergePrMerged: "无法 merge PR,因为 pull request 已 merge",
mergePrClosed: "无法 merge PR,因为 pull request 已关闭",
mergePrConflicts: "无法 merge PR,因为 pull request 存在冲突",
mergePrQueue: "此处无法 merge PR,因为此 repository 使用 merge queue",
mergePrNotReady: "GitHub 报告 pull request 可 merge 后才能 merge PR",
mergePrNotReady: "{{brand}} 报告 {{noun}} 可 merge 后才能 merge {{noun}}",
autoMergeCannotDisable: "Auto-merge 已启用,但此账号无法禁用",
},
toasts: {
@@ -724,7 +740,7 @@ export const zhCN: TranslationResources = {
expandAllFolders: "展开所有文件夹",
refreshing: "正在刷新",
refresh: "刷新",
refreshState: "刷新 git 和 GitHub 状态",
refreshState: "刷新 git 和 {{brand}} 状态",
failedRefresh: "刷新 git 状态失败。",
emptyHiddenWhitespace: "隐藏空白差异后没有可见变更",
emptyUncommitted: "没有未 commit 的变更",
@@ -760,13 +776,23 @@ export const zhCN: TranslationResources = {
pr: {
actions: {
viewPullRequest: "查看",
openOn: "在 {{brand}} 上打开",
},
sections: {
checks: "Checks",
pipeline: "流水线",
reviews: "Reviews",
},
empty: {
noJobs: "无作业",
loadingPipeline: "正在加载流水线...",
pipelineJobsLoadFailed: "无法加载流水线作业",
allowedToFail: "允许失败",
},
approvals: "{{given}} / {{required}} 批准",
accessibility: {
pullRequest: "Pull request #{{number}}",
pullRequest_mr: "合并请求 !{{number}}",
},
states: {
draft: "Draft",
@@ -783,11 +809,19 @@ export const zhCN: TranslationResources = {
time: {
justNow: "刚刚",
},
thread: {
discussion: "讨论主题",
},
errors: {
statusLoadFailed: "无法加载 Pull Request 状态",
activityLoadFailed: "无法加载 Pull Request 活动",
},
},
forgeSetup: {
installCli: "安装 {{cli}} CLI 以使用 {{brand}} 功能。",
signIn: "运行 {{command}} 以使用 {{brand}} 功能。",
generic: "在此主机上设置 {{brand}} 以使用其功能。",
},
},
},
sidebar: {
@@ -916,9 +950,9 @@ export const zhCN: TranslationResources = {
refPicker: {
startingRef: "起始 ref",
chooseStart: "选择起始位置",
checkoutHint: "Checkout PR #{{number}}?",
checkoutPr: "Checkout PR #{{number}}",
dismissCheckoutHint: "忽略 PR #{{number}} checkout 提示",
checkoutHint: "Checkout {{noun}} {{numberPrefix}}{{number}}?",
checkoutPr: "Checkout {{noun}} {{numberPrefix}}{{number}}",
dismissCheckoutHint: "忽略 {{noun}} {{numberPrefix}}{{number}} checkout 提示",
intoBase: "进入 {{baseRef}}",
searching: "正在搜索...",
noMatchingRefs: "没有匹配的 refs。",
@@ -1,9 +1,9 @@
import { describe, expect, it } from "vitest";
import type { GitHubSearchItem } from "@getpaseo/protocol/messages";
import type { ForgeSearchItem } from "@getpaseo/protocol/messages";
import { pickerItemToCheckoutRequest, type PickerItem } from "./new-workspace-picker-item";
const prItem: GitHubSearchItem = {
kind: "pr",
const prItem: ForgeSearchItem = {
kind: "change_request",
number: 42,
title: "Add picker",
url: "https://example.com/pull/42",
@@ -35,6 +35,7 @@ describe("pickerItemToCheckoutRequest", () => {
expect(pickerItemToCheckoutRequest(item)).toEqual({
action: "checkout",
refName: "feature/picker",
checkoutSource: { kind: "change_request", forge: "github", number: 42 },
githubPrNumber: 42,
});
});
@@ -53,7 +54,31 @@ describe("pickerItemToCheckoutRequest", () => {
expect(pickerItemToCheckoutRequest(item)).toEqual({
action: "checkout",
refName: "orphan",
checkoutSource: { kind: "change_request", forge: "github", number: 7 },
githubPrNumber: 7,
});
});
it("does not send the legacy githubPrNumber for non-GitHub change requests", () => {
const item: PickerItem = {
kind: "github-pr",
item: {
...prItem,
forge: "gitlab",
number: 21,
projectPath: "acme/repo",
url: "https://gitlab.example.com/acme/repo/-/merge_requests/21",
},
};
expect(pickerItemToCheckoutRequest(item)).toEqual({
action: "checkout",
refName: "feature/picker",
checkoutSource: {
kind: "change_request",
forge: "gitlab",
number: 21,
projectPath: "acme/repo",
},
});
});
});
@@ -1,16 +1,16 @@
import type { CreatePaseoWorktreeInput } from "@getpaseo/client/internal/daemon-client";
import type { GitHubSearchItem } from "@getpaseo/protocol/messages";
import type { ForgeSearchItem } from "@getpaseo/protocol/messages";
export type PickerItem =
| { kind: "branch"; name: string }
| {
kind: "github-pr";
item: GitHubSearchItem;
item: ForgeSearchItem;
};
export type PickerCheckoutRequest = Pick<
CreatePaseoWorktreeInput,
"action" | "refName" | "githubPrNumber"
"action" | "refName" | "checkoutSource" | "githubPrNumber"
>;
export function pickerItemToCheckoutRequest(
@@ -22,10 +22,23 @@ export function pickerItemToCheckoutRequest(
return { action: "branch-off", refName: item.name };
case "github-pr": {
const headRefName = item.item.headRefName?.trim();
const forge = item.item.forge ?? "github";
return {
action: "checkout",
...(headRefName ? { refName: headRefName } : {}),
githubPrNumber: item.item.number,
checkoutSource: {
kind: "change_request",
forge,
number: item.item.number,
...(item.item.projectPath ? { projectPath: item.item.projectPath } : {}),
},
...(forge === "github"
? {
// COMPAT(githubPrNumber): added in v0.1.106, remove after 2026-12-28 once
// daemon floor parses checkoutSource.
githubPrNumber: item.item.number,
}
: {}),
};
}
}
@@ -1,15 +1,15 @@
import { describe, expect, it } from "vitest";
import type { UserComposerAttachment } from "@/attachments/types";
import type { GitHubSearchItem } from "@getpaseo/protocol/messages";
import {
clearPickerPrAttachmentForTargetChange,
findCheckoutHintPrAttachment,
syncPickerPrAttachment,
} from "./new-workspace-picker-state";
import type { ForgeSearchItem } from "@getpaseo/protocol/messages";
function makePrItem(number: number, title: string, headRefName = "feature/x"): GitHubSearchItem {
function makePrItem(number: number, title: string, headRefName = "feature/x"): ForgeSearchItem {
return {
kind: "pr",
kind: "change_request",
number,
title,
url: `https://example.com/pull/${number}`,
@@ -22,7 +22,7 @@ function makePrItem(number: number, title: string, headRefName = "feature/x"): G
}
function prAttachment(
item: GitHubSearchItem,
item: ForgeSearchItem,
owner?: "new-workspace-picker",
): Extract<UserComposerAttachment, { kind: "github_pr" }> {
return { kind: "github_pr", item, ...(owner ? { owner } : {}) };
@@ -21,7 +21,10 @@ import {
import { Composer } from "@/composer";
import { FileDropZone } from "@/components/file-drop/file-drop-zone";
import { DraftAgentModeControl } from "@/composer/agent-controls/mode-control";
import { splitComposerAttachmentsForSubmit } from "@/composer/attachments/submit";
import {
resolveComposerAttachmentSubmitFormat,
splitComposerAttachmentsForSubmit,
} from "@/composer/attachments/submit";
import { HostStatusDot } from "@/components/host-status-dot";
import { HostPicker } from "@/components/hosts/host-picker";
import { ProjectIconView } from "@/components/project-icon-view";
@@ -36,7 +39,7 @@ import { ScreenHeader } from "@/components/headers/screen-header";
import { HEADER_INNER_HEIGHT, MAX_CONTENT_WIDTH, useIsCompactFormFactor } from "@/constants/layout";
import { useToast } from "@/contexts/toast-context";
import { useAgentInputDraft } from "@/composer/draft/input-draft";
import { useGithubSearchQuery } from "@/git/use-github-search-query";
import { useForgeSearchQuery } from "@/git/use-forge-search-query";
import {
useHostRuntimeClient,
useHostRuntimeConnectionStatuses,
@@ -63,6 +66,7 @@ import {
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
import { useFormPreferences } from "@/hooks/use-form-preferences";
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
import { getForgePresentation } from "@/git/forge";
import type { CreateAgentInitialValues } from "@/hooks/use-agent-form-state";
import { generateMessageId } from "@/types/stream";
import { toErrorMessage } from "@/utils/error-messages";
@@ -79,7 +83,7 @@ import { ICON_SIZE, type Theme } from "@/styles/theme";
import type { ComposerAttachment, UserComposerAttachment } from "@/attachments/types";
import { useDraftWorkspaceAttachmentScopeKey } from "@/attachments/workspace-attachments-store";
import type { MessagePayload } from "@/composer/types";
import type { AgentAttachment, GitHubSearchItem } from "@getpaseo/protocol/messages";
import type { AgentAttachment, ForgeSearchItem } from "@getpaseo/protocol/messages";
import type { CreatePaseoWorktreeInput } from "@getpaseo/client/internal/daemon-client";
import type { AgentProvider } from "@getpaseo/protocol/agent-types";
import type { WorkspaceDraftTabSetup, WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
@@ -637,8 +641,17 @@ function AddProjectPickerAction({ onPress }: { onPress: () => void }) {
);
}
function formatPrLabel(item: { number: number; title: string }): string {
return `#${item.number} ${item.title}`;
function formatPrLabel(item: Pick<ForgeSearchItem, "forge" | "number" | "title">): string {
const presentation = getForgePresentation(item.forge ?? "github");
return `${presentation.numberPrefix}${item.number} ${item.title}`;
}
function getCheckoutHintPresentation(item: ForgeSearchItem) {
const presentation = getForgePresentation(item.forge ?? "github");
return {
noun: presentation.changeRequestAbbrev,
numberPrefix: presentation.numberPrefix,
};
}
function pickerItemLabel(item: PickerItem): string {
@@ -655,7 +668,7 @@ function newWorkspaceHostOptionTestID(serverId: string): string {
function computePickerOptionData(
branchDetails: ReadonlyArray<{ name: string; committerDate: number }>,
prItems: ReadonlyArray<GitHubSearchItem>,
prItems: ReadonlyArray<ForgeSearchItem>,
): PickerOptionData {
const idMap = new Map<string, PickerItem>();
@@ -817,6 +830,7 @@ interface SubmitDraftInput {
attachments: ComposerAttachment[];
provider: AgentProvider;
composerState: NewWorkspaceComposerState;
supportsForgeSearch: boolean;
}
type NewWorkspaceComposerState = NonNullable<
@@ -922,6 +936,7 @@ interface CreateChatAgentInput {
serverId: string;
draftKey: string;
draftId?: string;
supportsForgeSearch: boolean;
labels: {
composerStateRequired: string;
selectModel: string;
@@ -992,7 +1007,12 @@ async function runCreateChatAgent(input: CreateChatAgentInput): Promise<void> {
if (!provider) {
throw new Error(input.labels.selectModel);
}
const { attachments: reviewAttachments } = splitComposerAttachmentsForSubmit(attachments);
const attachmentSubmitFormat = resolveComposerAttachmentSubmitFormat({
supportsForgeAttachments: input.supportsForgeSearch,
});
const { attachments: reviewAttachments } = splitComposerAttachmentsForSubmit(attachments, {
format: attachmentSubmitFormat,
});
const workspaceNamingAttachments = getWorkspaceNamingAttachments(reviewAttachments);
const ensuredWorkspace = await ensureWorkspace({
cwd,
@@ -1017,6 +1037,7 @@ async function runCreateChatAgent(input: CreateChatAgentInput): Promise<void> {
attachments,
provider,
composerState,
supportsForgeSearch: input.supportsForgeSearch,
});
}
@@ -1138,7 +1159,11 @@ function submitWorkspaceDraft(input: SubmitDraftInput): void {
const draftId = draftIdInput?.trim() || generateDraftId();
const clientMessageId = generateMessageId();
const timestamp = Date.now();
const wirePayload = splitComposerAttachmentsForSubmit(attachments);
const wirePayload = splitComposerAttachmentsForSubmit(attachments, {
format: resolveComposerAttachmentSubmitFormat({
supportsForgeAttachments: input.supportsForgeSearch,
}),
});
const submission = resolveWorkspaceDraftSubmissionConfig({
draftId,
workspaceDirectory,
@@ -1616,6 +1641,7 @@ export function NewWorkspaceScreen({
});
// COMPAT(workspaceMultiplicity): added in v0.1.97, drop the gate when floor >= v0.1.97
const supportsWorkspaceMultiplicity = useHostFeature(selectedServerId, "workspaceMultiplicity");
const supportsForgeSearch = useHostFeature(selectedServerId, "forgeSearch");
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [createdWorkspace, setCreatedWorkspace] = useState<ReturnType<
typeof normalizeWorkspaceDescriptor
@@ -1753,12 +1779,13 @@ export function NewWorkspaceScreen({
staleTime: 15_000,
});
const githubPrSearchQuery = useGithubSearchQuery({
const githubPrSearchQuery = useForgeSearchQuery({
client,
serverId: selectedServerId,
cwd: selectedSourceDirectory ?? "",
query: debouncedPickerSearchQuery,
kinds: ["github-pr"],
kinds: ["change_request"],
supportsForgeSearch,
enabled: pickerQueryEnabled,
});
@@ -1766,11 +1793,12 @@ export function NewWorkspaceScreen({
() => normalizeBranchDetails(branchSuggestionsQuery.data),
[branchSuggestionsQuery.data],
);
const githubFeaturesEnabled = githubPrSearchQuery.data?.githubFeaturesEnabled !== false;
const prItems: GitHubSearchItem[] = useMemo(() => {
if (!githubFeaturesEnabled) return [];
const forgeSearchAuthenticated =
!githubPrSearchQuery.data || githubPrSearchQuery.data.authState === "authenticated";
const prItems: ForgeSearchItem[] = useMemo(() => {
if (!forgeSearchAuthenticated) return [];
return githubPrSearchQuery.data?.items ?? [];
}, [githubFeaturesEnabled, githubPrSearchQuery.data?.items]);
}, [forgeSearchAuthenticated, githubPrSearchQuery.data?.items]);
const { options, itemById }: PickerOptionData = useMemo(
() => computePickerOptionData(branchDetails, prItems),
@@ -2059,6 +2087,7 @@ export function NewWorkspaceScreen({
serverId: selectedServerId,
draftKey,
draftId,
supportsForgeSearch,
labels: {
composerStateRequired: t("newWorkspace.errors.composerStateRequired"),
selectModel: t("newWorkspace.errors.selectModel"),
@@ -2071,7 +2100,17 @@ export function NewWorkspaceScreen({
toast.error(message);
}
},
[composerState, draftId, draftKey, ensureWorkspace, forkDraftSetup, selectedServerId, t, toast],
[
composerState,
draftId,
draftKey,
ensureWorkspace,
forkDraftSetup,
selectedServerId,
supportsForgeSearch,
t,
toast,
],
);
const renderPickerOption = useCallback(
@@ -2204,12 +2243,15 @@ export function NewWorkspaceScreen({
<CheckoutHintBadge
label={t("newWorkspace.refPicker.checkoutHint", {
number: checkoutHintPrAttachment.item.number,
...getCheckoutHintPresentation(checkoutHintPrAttachment.item),
})}
acceptLabel={t("newWorkspace.refPicker.checkoutPr", {
number: checkoutHintPrAttachment.item.number,
...getCheckoutHintPresentation(checkoutHintPrAttachment.item),
})}
dismissLabel={t("newWorkspace.refPicker.dismissCheckoutHint", {
number: checkoutHintPrAttachment.item.number,
...getCheckoutHintPresentation(checkoutHintPrAttachment.item),
})}
onAccept={acceptCheckoutHint}
onDismiss={dismissCheckoutHint}
@@ -11,7 +11,6 @@ import { useMutation } from "@tanstack/react-query";
import { Check, ChevronDown } from "lucide-react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { EditorTargetIcon } from "@/components/icons/editor-target-icon";
import { GitHubIcon } from "@/components/icons/github-icon";
import {
DropdownMenu,
DropdownMenuContent,
@@ -20,6 +19,7 @@ import {
} from "@/components/ui/dropdown-menu";
import { useToast } from "@/contexts/toast-context";
import { useCheckoutStatusQuery } from "@/git/use-status-query";
import { useCheckoutPrStatusQuery } from "@/git/use-pr-status-query";
import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon";
import { useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { resolvePreferredEditorId, usePreferredEditor } from "@/hooks/use-preferred-editor";
@@ -30,6 +30,8 @@ import { openDesktopTarget, useDesktopOpenTargets } from "@/workspace/desktop-op
import { resolveWorkspaceFilePaths, type WorkspaceFileLocation } from "@/workspace/file-open";
import { planWorkspaceOpenTargets } from "@/workspace/open-target-planner";
import type { Theme } from "@/styles/theme";
import { ForgeBrandIcon } from "@/git/forge-icon";
import { getForgePresentation } from "@/git/forge";
interface WorkspaceOpenInEditorButtonProps {
serverId: string;
@@ -47,13 +49,16 @@ interface OpenTarget {
const ThemedActivityIndicator = withUnistyles(ActivityIndicator);
const ThemedEditorTargetIcon = withUnistyles(EditorTargetIcon);
const ThemedGitHubIcon = withUnistyles(GitHubIcon);
const ThemedChevronDown = withUnistyles(ChevronDown);
const ThemedCheckIcon = withUnistyles(Check);
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
function renderForgeOpenTargetIcon(icon: string): ReactElement {
return <ForgeBrandIcon iconKind={icon} size={16} uniProps={mutedColorMapping} />;
}
interface OpenTargetMenuItemProps {
target: OpenTarget;
isPreferred: boolean;
@@ -111,6 +116,10 @@ export function WorkspaceOpenInEditorButton({
serverId,
cwd: shouldQueryCheckout ? cwd : "",
});
const { resolvedForge } = useCheckoutPrStatusQuery({
serverId,
cwd: shouldQueryCheckout ? cwd : "",
});
const targets = useMemo<OpenTarget[]>(
() =>
@@ -122,12 +131,14 @@ export function WorkspaceOpenInEditorButton({
canUseDesktopBridge: isDesktopOpenAvailable,
isLocalExecution: isLocalDaemon,
checkoutStatus,
forge: resolvedForge,
}).map((target) => {
if (target.source === "github") {
if (target.source === "forge") {
const presentation = getForgePresentation(target.forge);
return {
id: target.id,
label: target.label,
icon: <ThemedGitHubIcon size={16} uniProps={mutedColorMapping} />,
icon: renderForgeOpenTargetIcon(presentation.icon),
onOpen: () => openExternalUrl(target.url),
};
}
@@ -145,6 +156,7 @@ export function WorkspaceOpenInEditorButton({
checkoutStatus,
cwd,
desktopOpenTargets,
resolvedForge,
isDesktopOpenAvailable,
isLocalDaemon,
resolvedFile,

Some files were not shown because too many files have changed in this diff Show More