Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ export function invalidateGithubQueries(
void queryClient.invalidateQueries({ queryKey: ["github_login"] });
}

async function openUrlInBrowser(url: string): Promise<void> {
export async function openUrlInBrowser(url: string): Promise<void> {
try {
await trpcClient.os.openExternal.mutate({ url });
} catch {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
CreditCard,
Folder,
GearSix,
GithubLogo,
HardDrives,
Keyboard,
Palette,
Expand All @@ -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";
Expand Down Expand Up @@ -73,6 +75,7 @@ const SIDEBAR_ITEMS: SidebarItem[] = [
},
{ id: "claude-code", label: "Claude Code", icon: <Code size={16} /> },
{ id: "shortcuts", label: "Shortcuts", icon: <Keyboard size={16} /> },
{ id: "github", label: "GitHub", icon: <GithubLogo size={16} /> },

{
id: "signals",
Expand All @@ -93,6 +96,7 @@ const CATEGORY_TITLES: Record<SettingsCategory, string> = {
personalization: "Personalization",
"claude-code": "Claude Code",
shortcuts: "Shortcuts",
github: "GitHub",

signals: "Signals",
updates: "Updates",
Expand All @@ -109,6 +113,7 @@ const CATEGORY_COMPONENTS: Record<SettingsCategory, React.ComponentType> = {
personalization: PersonalizationSettings,
"claude-code": ClaudeCodeSettings,
shortcuts: ShortcutsSettings,
github: GitHubSettings,

signals: SignalSourcesSettings,
updates: UpdatesSettings,
Expand Down
Original file line number Diff line number Diff line change
@@ -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 (
<Flex direction="column" gap="3">
<Flex align="center" justify="between" gap="3" wrap="wrap">
<Text className="text-(--gray-11) text-[13px]">
Personal GitHub installations linked to your PostHog account.
</Text>
<Button
size="1"
disabled={!canConnect}
onClick={handleConnect}
className="shrink-0"
>
{isConnecting ? (
<Spinner size="1" />
) : (
<ArrowSquareOutIcon size={12} />
)}
{isConnecting ? "Waiting…" : connectButtonLabel}
</Button>
</Flex>

{hasConnectError && (
<Text className="text-(--red-11) text-[13px]">
{describeGithubConnectError(error)}
</Text>
)}

<Flex direction="column" className="border-(--gray-5) border-t">
{isLoading ? (
<Flex align="center" gap="2" py="4">
<Spinner size="1" />
<Text className="text-(--gray-11) text-[13px]">Loading…</Text>
</Flex>
) : integrations.length === 0 ? (
<Flex align="center" gap="3" py="4">
<Box className="shrink-0 text-(--gray-11)">
<GithubLogoIcon size={20} />
</Box>
<Text className="text-(--gray-11) text-[13px]">
No GitHub integrations yet. Connect one to enable cloud tasks.
</Text>
</Flex>
) : (
integrations.map((integration) => (
<GitHubIntegrationRow
key={integration.installation_id}
integration={integration}
repos={reposByInstallationId[integration.installation_id] ?? []}
hasRepoFetchFailed={failedInstallationIds.includes(
integration.installation_id,
)}
isLoadingRepos={isLoadingRepos}
/>
))
)}
</Flex>
</Flex>
);
}

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 ? (
<Text className="text-(--gray-11) text-[13px]">Loading repositories…</Text>
) : hasRepoFetchFailed ? (
<Flex align="center" gap="1">
<WarningIcon
size={13}
weight="fill"
className="shrink-0 text-(--amber-9)"
/>
<Text className="text-(--amber-11) text-[13px]">
Couldn't load repositories
</Text>
</Flex>
) : repoCount === 0 ? (
<Text className="text-(--gray-11) text-[13px]">
No repositories accessible
</Text>
) : (
<Text className="text-(--gray-11) text-[13px]" truncate>
{repoCount} {repoCount === 1 ? "repository" : "repositories"} accessible:{" "}
<Text className="text-(--gray-12)">{repoPreview}</Text>
{repoRemainder > 0 ? ` and ${repoRemainder} more` : ""}
</Text>
);

return (
<>
<Flex
direction="column"
gap="2"
py="3"
className="border-(--gray-5) border-b"
>
<Flex align="start" justify="between" gap="4">
<Flex align="start" gap="3" className="min-w-0 flex-1">
<Box className="shrink-0 text-(--gray-11)">
<GithubLogoIcon size={28} />
</Box>
<Flex direction="column" gap="1" className="min-w-0">
<Text className="text-(--gray-12) text-sm">
<Text className="font-medium">Connected</Text> to{" "}
<button
type="button"
onClick={() => void openUrlInBrowser(settingsUrl)}
className="cursor-pointer font-medium underline hover:text-(--accent-11)"
>
{accountName}
</button>
</Text>
{integration.created_at && (
<Text className="text-(--gray-11) text-[13px]">
Created {formatRelativeTimeLong(integration.created_at)}
</Text>
)}
<Flex align="center" gap="2" className="min-w-0">
<Flex align="center" gap="1" className="min-w-0 flex-1">
<GitBranchIcon
size={13}
className="shrink-0 text-(--gray-10)"
/>
{canExpand ? (
<button
type="button"
onClick={() => setExpanded((v) => !v)}
className="-mx-1 flex min-w-0 cursor-pointer items-center gap-1 rounded px-1 text-left transition-colors hover:bg-(--gray-3)"
>
{expanded ? (
<CaretDownIcon
size={11}
className="shrink-0 text-(--gray-10)"
/>
) : (
<CaretRightIcon
size={11}
className="shrink-0 text-(--gray-10)"
/>
)}
{repoSummaryNode}
</button>
) : (
repoSummaryNode
)}
</Flex>
<Tooltip content="Manage on GitHub">
<IconButton
size="1"
variant="soft"
color="gray"
onClick={() => void openUrlInBrowser(settingsUrl)}
className="shrink-0"
>
<GearSixIcon size={12} />
</IconButton>
</Tooltip>
</Flex>
</Flex>
</Flex>
<Button
size="1"
variant="soft"
color="red"
disabled={disconnect.isPending}
onClick={() => setConfirmOpen(true)}
className="shrink-0"
>
{disconnect.isPending ? <Spinner size="1" /> : null}
Disconnect
</Button>
</Flex>
{expanded && canExpand && (
<div className="ml-9 max-h-48 overflow-y-auto rounded-(--radius-2) border border-(--gray-5) bg-(--gray-2)">
<Flex direction="column" py="1">
{repos.map((repo) => (
<Text
key={repo}
className="px-2 py-0.5 text-(--gray-11) text-[13px]"
truncate
>
{repo}
</Text>
))}
</Flex>
</div>
)}
</Flex>

<AlertDialog.Root
open={confirmOpen}
onOpenChange={(open) => {
if (!disconnect.isPending) setConfirmOpen(open);
}}
>
<AlertDialog.Content maxWidth="420px" size="1">
<AlertDialog.Title className="text-sm">
Disconnect {accountName}?
</AlertDialog.Title>
<AlertDialog.Description>
<Text color="gray" className="text-[13px]">
You won't be able to create cloud tasks against repos in this
installation until you reconnect.
</Text>
</AlertDialog.Description>
<Flex justify="end" gap="3" mt="3">
<AlertDialog.Cancel>
<Button variant="soft" color="gray" size="1">
Cancel
</Button>
</AlertDialog.Cancel>
<Button
variant="solid"
color="red"
size="1"
disabled={disconnect.isPending}
onClick={() => disconnect.mutate()}
>
{disconnect.isPending ? <Spinner size="1" /> : null}
Disconnect
</Button>
</Flex>
</AlertDialog.Content>
</AlertDialog.Root>
</>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export type SettingsCategory =
| "personalization"
| "claude-code"
| "shortcuts"
| "github"
| "signals"
| "updates"
| "advanced";
Expand Down
Loading
Loading