diff --git a/apps/code/src/renderer/features/integrations/hooks/useGithubUserConnect.ts b/apps/code/src/renderer/features/integrations/hooks/useGithubUserConnect.ts index 5eeeb2945..3a51b71d9 100644 --- a/apps/code/src/renderer/features/integrations/hooks/useGithubUserConnect.ts +++ b/apps/code/src/renderer/features/integrations/hooks/useGithubUserConnect.ts @@ -86,7 +86,7 @@ export function invalidateGithubQueries( void queryClient.invalidateQueries({ queryKey: ["github_login"] }); } -async function openUrlInBrowser(url: string): Promise { +export async function openUrlInBrowser(url: string): Promise { try { await trpcClient.os.openExternal.mutate({ url }); } catch { diff --git a/apps/code/src/renderer/features/settings/components/SettingsDialog.tsx b/apps/code/src/renderer/features/settings/components/SettingsDialog.tsx index 0911dd08a..0f4ee8269 100644 --- a/apps/code/src/renderer/features/settings/components/SettingsDialog.tsx +++ b/apps/code/src/renderer/features/settings/components/SettingsDialog.tsx @@ -19,6 +19,7 @@ import { CreditCard, Folder, GearSix, + GithubLogo, HardDrives, Keyboard, Palette, @@ -36,6 +37,7 @@ import { ClaudeCodeSettings } from "./sections/ClaudeCodeSettings"; import { CloudEnvironmentsSettings } from "./sections/CloudEnvironmentsSettings"; import { EnvironmentsSettings } from "./sections/environments/EnvironmentsSettings"; import { GeneralSettings } from "./sections/GeneralSettings"; +import { GitHubSettings } from "./sections/GitHubSettings"; import { PersonalizationSettings } from "./sections/PersonalizationSettings"; import { PlanUsageSettings } from "./sections/PlanUsageSettings"; import { ShortcutsSettings } from "./sections/ShortcutsSettings"; @@ -73,6 +75,7 @@ const SIDEBAR_ITEMS: SidebarItem[] = [ }, { id: "claude-code", label: "Claude Code", icon: }, { id: "shortcuts", label: "Shortcuts", icon: }, + { id: "github", label: "GitHub", icon: }, { id: "signals", @@ -93,6 +96,7 @@ const CATEGORY_TITLES: Record = { personalization: "Personalization", "claude-code": "Claude Code", shortcuts: "Shortcuts", + github: "GitHub", signals: "Signals", updates: "Updates", @@ -109,6 +113,7 @@ const CATEGORY_COMPONENTS: Record = { personalization: PersonalizationSettings, "claude-code": ClaudeCodeSettings, shortcuts: ShortcutsSettings, + github: GitHubSettings, signals: SignalSourcesSettings, updates: UpdatesSettings, diff --git a/apps/code/src/renderer/features/settings/components/sections/GitHubSettings.tsx b/apps/code/src/renderer/features/settings/components/sections/GitHubSettings.tsx new file mode 100644 index 000000000..105e61f86 --- /dev/null +++ b/apps/code/src/renderer/features/settings/components/sections/GitHubSettings.tsx @@ -0,0 +1,338 @@ +import { useOptionalAuthenticatedClient } from "@features/auth/hooks/authClient"; +import { useAuthStateValue } from "@features/auth/hooks/authQueries"; +import { + describeGithubConnectError, + invalidateGithubQueries, + openUrlInBrowser, + useGithubUserConnect, +} from "@features/integrations/hooks/useGithubUserConnect"; +import { + useUserGithubIntegrations, + useUserRepositoryIntegration, +} from "@hooks/useIntegrations"; +import { + ArrowSquareOutIcon, + CaretDownIcon, + CaretRightIcon, + GearSixIcon, + GitBranchIcon, + GithubLogoIcon, + WarningIcon, +} from "@phosphor-icons/react"; +import { + AlertDialog, + Box, + Button, + Flex, + IconButton, + Spinner, + Text, + Tooltip, +} from "@radix-ui/themes"; +import type { UserGitHubIntegration } from "@renderer/api/posthogClient"; +import { formatRelativeTimeLong } from "@renderer/utils/time"; +import { toast } from "@renderer/utils/toast"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useState } from "react"; + +const REPO_PREVIEW_COUNT = 3; + +function githubInstallationSettingsUrl(integration: UserGitHubIntegration) { + const accountType = integration.account?.type?.toLowerCase(); + const accountName = integration.account?.name; + if (accountType === "organization" && accountName) { + return `https://github.com/organizations/${accountName}/settings/installations/${integration.installation_id}`; + } + return `https://github.com/settings/installations/${integration.installation_id}`; +} + +export function GitHubSettings() { + const projectId = useAuthStateValue((s) => s.projectId); + const cloudRegion = useAuthStateValue((s) => s.cloudRegion); + const { data: integrations = [], isLoading } = useUserGithubIntegrations(); + const { reposByInstallationId, failedInstallationIds, isLoadingRepos } = + useUserRepositoryIntegration(); + + const { state, error, connect, reset } = useGithubUserConnect({ projectId }); + const isConnecting = state === "connecting"; + const hasConnectError = state === "error"; + const canConnect = projectId != null && cloudRegion != null && !isConnecting; + + const handleConnect = () => { + if (hasConnectError) reset(); + void connect(); + }; + + const connectButtonLabel = + integrations.length === 0 ? "Connect GitHub" : "Connect another account"; + + return ( + + + + Personal GitHub installations linked to your PostHog account. + + + + + {hasConnectError && ( + + {describeGithubConnectError(error)} + + )} + + + {isLoading ? ( + + + Loading… + + ) : integrations.length === 0 ? ( + + + + + + No GitHub integrations yet. Connect one to enable cloud tasks. + + + ) : ( + integrations.map((integration) => ( + + )) + )} + + + ); +} + +interface GitHubIntegrationRowProps { + integration: UserGitHubIntegration; + repos: string[]; + hasRepoFetchFailed: boolean; + isLoadingRepos: boolean; +} + +function GitHubIntegrationRow({ + integration, + repos, + hasRepoFetchFailed, + isLoadingRepos, +}: GitHubIntegrationRowProps) { + const apiClient = useOptionalAuthenticatedClient(); + const projectId = useAuthStateValue((s) => s.projectId); + const queryClient = useQueryClient(); + const [confirmOpen, setConfirmOpen] = useState(false); + const [expanded, setExpanded] = useState(false); + + const disconnect = useMutation({ + mutationFn: async () => { + if (!apiClient) throw new Error("Not authenticated"); + await apiClient.disconnectGithubUserIntegration( + integration.installation_id, + ); + }, + onSuccess: () => { + setConfirmOpen(false); + toast.success("Disconnected GitHub account"); + invalidateGithubQueries(queryClient, projectId); + }, + onError: (err) => { + toast.error( + err instanceof Error ? err.message : "Failed to disconnect GitHub", + ); + }, + }); + + const accountName = integration.account?.name?.trim() || "GitHub account"; + const repoCount = repos.length; + const canExpand = repoCount > 0; + const settingsUrl = githubInstallationSettingsUrl(integration); + + const repoPreview = repos.slice(0, REPO_PREVIEW_COUNT).join(", "); + const repoRemainder = repoCount - REPO_PREVIEW_COUNT; + + const repoSummaryNode = isLoadingRepos ? ( + Loading repositories… + ) : hasRepoFetchFailed ? ( + + + + Couldn't load repositories + + + ) : repoCount === 0 ? ( + + No repositories accessible + + ) : ( + + {repoCount} {repoCount === 1 ? "repository" : "repositories"} accessible:{" "} + {repoPreview} + {repoRemainder > 0 ? ` and ${repoRemainder} more` : ""} + + ); + + return ( + <> + + + + + + + + + Connected to{" "} + + + {integration.created_at && ( + + Created {formatRelativeTimeLong(integration.created_at)} + + )} + + + + {canExpand ? ( + + ) : ( + repoSummaryNode + )} + + + void openUrlInBrowser(settingsUrl)} + className="shrink-0" + > + + + + + + + + + {expanded && canExpand && ( +
+ + {repos.map((repo) => ( + + {repo} + + ))} + +
+ )} +
+ + { + if (!disconnect.isPending) setConfirmOpen(open); + }} + > + + + Disconnect {accountName}? + + + + You won't be able to create cloud tasks against repos in this + installation until you reconnect. + + + + + + + + + + + + ); +} diff --git a/apps/code/src/renderer/features/settings/stores/settingsDialogStore.ts b/apps/code/src/renderer/features/settings/stores/settingsDialogStore.ts index 6e15a18c6..b17317569 100644 --- a/apps/code/src/renderer/features/settings/stores/settingsDialogStore.ts +++ b/apps/code/src/renderer/features/settings/stores/settingsDialogStore.ts @@ -10,6 +10,7 @@ export type SettingsCategory = | "personalization" | "claude-code" | "shortcuts" + | "github" | "signals" | "updates" | "advanced"; diff --git a/apps/code/src/renderer/features/task-detail/components/CloudGithubMissingNotice.tsx b/apps/code/src/renderer/features/task-detail/components/CloudGithubMissingNotice.tsx new file mode 100644 index 000000000..c3d71725c --- /dev/null +++ b/apps/code/src/renderer/features/task-detail/components/CloudGithubMissingNotice.tsx @@ -0,0 +1,54 @@ +import { useAuthStateValue } from "@features/auth/hooks/authQueries"; +import { + describeGithubConnectError, + useGithubUserConnect, +} from "@features/integrations/hooks/useGithubUserConnect"; +import { ArrowSquareOutIcon, InfoIcon } from "@phosphor-icons/react"; +import { Button, Callout, Flex, Spinner, Text } from "@radix-ui/themes"; + +export function CloudGithubMissingNotice() { + const projectId = useAuthStateValue((s) => s.projectId); + const cloudRegion = useAuthStateValue((s) => s.cloudRegion); + const { state, error, connect, reset } = useGithubUserConnect({ projectId }); + + const isConnecting = state === "connecting"; + const hasError = state === "error"; + const canConnect = projectId != null && cloudRegion != null; + + return ( + + + + + + + + + {hasError + ? describeGithubConnectError(error) + : "Connecting your personal GitHub is required to run cloud tasks."} + + + + + + + ); +} diff --git a/apps/code/src/renderer/features/task-detail/components/TaskInput.tsx b/apps/code/src/renderer/features/task-detail/components/TaskInput.tsx index 80cf5e094..1590b396a 100644 --- a/apps/code/src/renderer/features/task-detail/components/TaskInput.tsx +++ b/apps/code/src/renderer/features/task-detail/components/TaskInput.tsx @@ -45,6 +45,7 @@ import { FOCUSABLE_SELECTOR } from "@utils/overlay"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { usePreviewConfig } from "../hooks/usePreviewConfig"; import { useTaskCreation } from "../hooks/useTaskCreation"; +import { CloudGithubMissingNotice } from "./CloudGithubMissingNotice"; import { type WorkspaceMode, WorkspaceModeSelect } from "./WorkspaceModeSelect"; interface TaskInputProps { @@ -182,6 +183,7 @@ export function TaskInput({ isLoadingRepos, isRefreshingRepos, refreshRepositories, + hasGithubIntegration, } = useUserRepositoryIntegration(); const { repositories: visibleCloudRepositories, @@ -779,6 +781,13 @@ export function TaskInput({ )} + {effectiveWorkspaceMode === "cloud" && + !isLoadingRepos && + !hasGithubIntegration && ( +
+ +
+ )}