diff --git a/apps/code/src/renderer/features/inbox/components/list/GitHubConnectionBanner.tsx b/apps/code/src/renderer/features/inbox/components/list/GitHubConnectionBanner.tsx index 9213526a0..3d57bcc43 100644 --- a/apps/code/src/renderer/features/inbox/components/list/GitHubConnectionBanner.tsx +++ b/apps/code/src/renderer/features/inbox/components/list/GitHubConnectionBanner.tsx @@ -1,7 +1,7 @@ import { Button } from "@components/ui/Button"; -import { useOptionalAuthenticatedClient } from "@features/auth/hooks/authClient"; import { useAuthStateValue } from "@features/auth/hooks/authQueries"; import { useAuthenticatedQuery } from "@hooks/useAuthenticatedQuery"; +import { useConnectUserGithub } from "@hooks/useConnectUserGithub"; import { useUserRepositoryIntegration } from "@hooks/useIntegrations"; import { ArrowSquareOutIcon, @@ -9,18 +9,6 @@ import { InfoIcon, } from "@phosphor-icons/react"; import { Spinner } from "@radix-ui/themes"; -import { trpcClient } from "@renderer/trpc/client"; -import { queryClient } from "@utils/queryClient"; -import { useEffect, useRef, useState } from "react"; -import { toast } from "sonner"; - -async function openUrlInBrowser(url: string): Promise { - try { - await trpcClient.os.openExternal.mutate({ url }); - } catch { - window.open(url, "_blank", "noopener,noreferrer"); - } -} export function GitHubConnectionBanner() { const { data: githubLogin, isLoading: loginLoading } = useAuthenticatedQuery( @@ -30,33 +18,12 @@ export function GitHubConnectionBanner() { ); const { hasGithubIntegration: hasGithubForProject } = useUserRepositoryIntegration(); - const apiClient = useOptionalAuthenticatedClient(); - const projectId = useAuthStateValue((s) => s.projectId); const cloudRegion = useAuthStateValue((s) => s.cloudRegion); - const awaitingLink = useRef(false); - const connectInFlight = useRef(false); - const [connecting, setConnecting] = useState(false); - - const canConnectCloud = - apiClient != null && projectId != null && cloudRegion != null; - - // After the user clicks connect and returns to the app, refetch to pick up the new github_login - useEffect(() => { - const onFocus = () => { - if (awaitingLink.current) { - awaitingLink.current = false; - void queryClient.invalidateQueries({ queryKey: ["github_login"] }); - void queryClient.invalidateQueries({ - queryKey: ["integrations", "list"], - }); - void queryClient.invalidateQueries({ - queryKey: ["user-github-integrations"], - }); - } - }; - window.addEventListener("focus", onFocus); - return () => window.removeEventListener("focus", onFocus); - }, []); + const { + connect, + isConnecting: connecting, + canConnect: canConnectCloud, + } = useConnectUserGithub(); if (loginLoading) { return null; @@ -109,37 +76,7 @@ export function GitHubConnectionBanner() { } onClick={() => { - if (!canConnectCloud || connectInFlight.current) { - return; - } - connectInFlight.current = true; - awaitingLink.current = true; - setConnecting(true); - void (async () => { - try { - const res = - await apiClient.startGithubUserIntegrationConnect(projectId); - const installUrl = res.install_url?.trim() ?? ""; - if (!installUrl) { - awaitingLink.current = false; - toast.error( - "GitHub connection did not return a URL. Please try again.", - ); - return; - } - await openUrlInBrowser(installUrl); - } catch (e) { - awaitingLink.current = false; - toast.error( - e instanceof Error - ? e.message - : "Failed to start GitHub connection", - ); - } finally { - connectInFlight.current = false; - setConnecting(false); - } - })(); + void connect(); }} > {connecting ? ( diff --git a/apps/code/src/renderer/features/task-detail/components/CloudRepoFallbackNotice.tsx b/apps/code/src/renderer/features/task-detail/components/CloudRepoFallbackNotice.tsx new file mode 100644 index 000000000..f9795c4d2 --- /dev/null +++ b/apps/code/src/renderer/features/task-detail/components/CloudRepoFallbackNotice.tsx @@ -0,0 +1,37 @@ +import { useConnectUserGithub } from "@hooks/useConnectUserGithub"; +import { ArrowSquareOut, Info } from "@phosphor-icons/react"; +import { Button, Callout, Flex, Spinner, Text } from "@radix-ui/themes"; + +export function CloudRepoFallbackNotice() { + const { connect, isConnecting, canConnect } = useConnectUserGithub(); + + return ( + + + + + + + + + Using your team's GitHub integration. Link your personal GitHub so + cloud PRs are authored as you. + + + + + + + ); +} 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 1ebc10ec4..9812207b5 100644 --- a/apps/code/src/renderer/features/task-detail/components/TaskInput.tsx +++ b/apps/code/src/renderer/features/task-detail/components/TaskInput.tsx @@ -24,9 +24,9 @@ import { useSettingsStore } from "@features/settings/stores/settingsStore"; import { useAutoFocusOnTyping } from "@hooks/useAutoFocusOnTyping"; import { useConnectivity } from "@hooks/useConnectivity"; import { - useUserGithubBranches, - useUserGithubRepositories, - useUserRepositoryIntegration, + useCloudGithubBranches, + useCloudGithubRepositories, + useCloudRepositoryIntegration, } from "@hooks/useIntegrations"; import { X } from "@phosphor-icons/react"; import { ButtonGroup } from "@posthog/quill"; @@ -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 { CloudRepoFallbackNotice } from "./CloudRepoFallbackNotice"; import { type WorkspaceMode, WorkspaceModeSelect } from "./WorkspaceModeSelect"; interface TaskInputProps { @@ -175,26 +176,36 @@ export function TaskInput({ const setAdapter = (newAdapter: AgentAdapter) => setLastUsedAdapter(newAdapter); + const [selectedRepository, setSelectedRepository] = useState( + () => + initialCloudRepository?.toLowerCase() ?? + lastUsedCloudRepository?.toLowerCase() ?? + null, + ); + + const cloudRepoIntegration = + useCloudRepositoryIntegration(selectedRepository); const { + source: cloudRepoSource, repositories, - getInstallationIdForRepo, - getUserIntegrationIdForRepo, isLoadingRepos, isRefreshingRepos, refreshRepositories, - } = useUserRepositoryIntegration(); + githubIntegrationId: selectedGithubIntegrationId, + githubUserIntegrationId: selectedGithubUserIntegrationId, + } = cloudRepoIntegration; + const { repositories: visibleCloudRepositories, isPending: cloudRepositoriesLoading, hasMore: cloudRepositoriesHasMore, loadMore: loadMoreCloudRepositories, - } = useUserGithubRepositories(cloudRepoSearchQuery, isCloudRepoPickerOpen); - const [selectedRepository, setSelectedRepository] = useState( - () => - initialCloudRepository?.toLowerCase() ?? - lastUsedCloudRepository?.toLowerCase() ?? - null, + } = useCloudGithubRepositories( + cloudRepoSource, + cloudRepoSearchQuery, + isCloudRepoPickerOpen, ); + const selectedCloudRepository = useMemo(() => { if (!selectedRepository) return null; const lower = selectedRepository.toLowerCase(); @@ -203,13 +214,6 @@ export function TaskInput({ const { currentBranch, branchLoading, defaultBranch } = useGitQueries(selectedDirectory); - const selectedGithubUserIntegrationId = selectedCloudRepository - ? getUserIntegrationIdForRepo(selectedCloudRepository) - : undefined; - const selectedInstallationId = selectedCloudRepository - ? getInstallationIdForRepo(selectedCloudRepository) - : undefined; - const { data: cloudBranchData, isPending: cloudBranchesLoading, @@ -218,8 +222,9 @@ export function TaskInput({ hasMore: cloudBranchesHasMore, loadMore: loadMoreCloudBranches, refresh: refreshCloudBranches, - } = useUserGithubBranches( - selectedInstallationId, + } = useCloudGithubBranches( + cloudRepoSource, + cloudRepoIntegration, selectedCloudRepository, cloudBranchSearchQuery, isCloudBranchPickerOpen, @@ -432,6 +437,7 @@ export function TaskInput({ editorRef, selectedDirectory, selectedRepository: selectedCloudRepository, + githubIntegrationId: selectedGithubIntegrationId, githubUserIntegrationId: selectedGithubUserIntegrationId, workspaceMode: effectiveWorkspaceMode, branch: branchForTaskCreation, @@ -775,6 +781,10 @@ export function TaskInput({ )} + + {workspaceMode === "cloud" && + cloudRepoSource === "team" && + !isLoadingRepos && } diff --git a/apps/code/src/renderer/hooks/useConnectUserGithub.ts b/apps/code/src/renderer/hooks/useConnectUserGithub.ts new file mode 100644 index 000000000..c55422140 --- /dev/null +++ b/apps/code/src/renderer/hooks/useConnectUserGithub.ts @@ -0,0 +1,79 @@ +import { useOptionalAuthenticatedClient } from "@features/auth/hooks/authClient"; +import { useAuthStateValue } from "@features/auth/hooks/authQueries"; +import { trpcClient } from "@renderer/trpc/client"; +import { toast } from "@renderer/utils/toast"; +import { useQueryClient } from "@tanstack/react-query"; +import { useCallback, useEffect, useRef, useState } from "react"; + +interface UseConnectUserGithubResult { + connect: () => Promise; + isConnecting: boolean; + canConnect: boolean; +} + +/** + * Starts the GitHub user-integration install flow and refreshes the relevant + * query caches once the user returns to the window. Shared between the inbox + * banner and the cloud-task fallback notice; onboarding has its own polling + * state machine and uses the underlying API directly. + */ +export function useConnectUserGithub(): UseConnectUserGithubResult { + const apiClient = useOptionalAuthenticatedClient(); + const projectId = useAuthStateValue((s) => s.projectId); + const cloudRegion = useAuthStateValue((s) => s.cloudRegion); + const queryClient = useQueryClient(); + + const [isConnecting, setIsConnecting] = useState(false); + const awaitingLink = useRef(false); + const inFlight = useRef(false); + + const canConnect = + apiClient != null && projectId != null && cloudRegion != null; + + useEffect(() => { + const onFocus = () => { + if (!awaitingLink.current) return; + awaitingLink.current = false; + void queryClient.invalidateQueries({ queryKey: ["github_login"] }); + void queryClient.invalidateQueries({ + queryKey: ["integrations", "list"], + }); + void queryClient.invalidateQueries({ + queryKey: ["user-github-integrations"], + }); + }; + window.addEventListener("focus", onFocus); + return () => window.removeEventListener("focus", onFocus); + }, [queryClient]); + + const connect = useCallback(async () => { + if (!canConnect || inFlight.current || !apiClient || !projectId) return; + inFlight.current = true; + awaitingLink.current = true; + setIsConnecting(true); + try { + const res = await apiClient.startGithubUserIntegrationConnect(projectId); + const installUrl = res.install_url?.trim() ?? ""; + if (!installUrl) { + awaitingLink.current = false; + toast.error( + "GitHub connection did not return a URL. Please try again.", + ); + return; + } + await trpcClient.os.openExternal.mutate({ url: installUrl }); + } catch (error) { + awaitingLink.current = false; + toast.error( + error instanceof Error + ? error.message + : "Failed to start GitHub connection", + ); + } finally { + inFlight.current = false; + setIsConnecting(false); + } + }, [apiClient, canConnect, projectId]); + + return { connect, isConnecting, canConnect }; +} diff --git a/apps/code/src/renderer/hooks/useIntegrations.ts b/apps/code/src/renderer/hooks/useIntegrations.ts index cb8137cbe..fbd923bba 100644 --- a/apps/code/src/renderer/hooks/useIntegrations.ts +++ b/apps/code/src/renderer/hooks/useIntegrations.ts @@ -7,6 +7,7 @@ import { } from "@features/integrations/stores/integrationStore"; import type { UserGitHubIntegration } from "@renderer/api/posthogClient"; import { useQueries, useQueryClient } from "@tanstack/react-query"; +import { logger } from "@utils/logger"; import { useCallback, useDeferredValue, @@ -291,6 +292,7 @@ export function useUserGithubRepositories( }, enabled: queryEnabled, staleTime: 5 * 60 * 1000, + placeholderData: (prev: unknown) => prev, meta: AUTH_SCOPED_QUERY_META, })), combine: (results) => { @@ -625,3 +627,104 @@ export function useRepositoryIntegration() { hasGithubIntegration, }; } + +const cloudRepoLog = logger.scope("cloud-repo-integration"); + +type CloudRepoSource = "user" | "team"; + +/** + * Unified accessor for the GitHub integration that backs cloud task creation. + * Prefers the user-level personal integration so cloud-run PRs are authored + * as the acting user; falls back to the org/team integration only when the + * user has not connected (or has an invalid) personal GitHub link. + */ +export function useCloudRepositoryIntegration(selectedRepo: string | null) { + const user = useUserRepositoryIntegration(); + const team = useRepositoryIntegration(); + const useTeamFallback = !user.isLoadingRepos && !user.hasGithubIntegration; + + useEffect(() => { + if (user.isLoadingRepos) return; + cloudRepoLog.info("Using cloud GitHub integration source", { + source: useTeamFallback ? "team" : "user", + hasUserIntegration: user.hasGithubIntegration, + hasTeamIntegration: team.hasGithubIntegration, + }); + }, [ + useTeamFallback, + user.isLoadingRepos, + user.hasGithubIntegration, + team.hasGithubIntegration, + ]); + + if (useTeamFallback) { + return { + source: "team" as CloudRepoSource, + repositories: team.repositories, + isLoadingRepos: user.isLoadingRepos || team.isLoadingRepos, + isRefreshingRepos: team.isRefreshingRepos, + refreshRepositories: team.refreshRepositories, + githubIntegrationId: selectedRepo + ? team.getIntegrationIdForRepo(selectedRepo) + : undefined, + githubUserIntegrationId: undefined as string | undefined, + installationId: undefined as string | undefined, + }; + } + + return { + source: "user" as CloudRepoSource, + repositories: user.repositories, + isLoadingRepos: user.isLoadingRepos, + isRefreshingRepos: user.isRefreshingRepos, + refreshRepositories: user.refreshRepositories, + githubIntegrationId: undefined as number | undefined, + githubUserIntegrationId: selectedRepo + ? user.getUserIntegrationIdForRepo(selectedRepo) + : undefined, + installationId: selectedRepo + ? user.getInstallationIdForRepo(selectedRepo) + : undefined, + }; +} + +/** + * Paginated repo picker that delegates to the user-level or team-level + * source. Both underlying hooks are always mounted so React's hook order + * stays stable; the inactive one is gated to `enabled: false` and never fires. + */ +export function useCloudGithubRepositories( + source: CloudRepoSource, + search: string | undefined, + enabled: boolean, +) { + const user = useUserGithubRepositories(search, enabled && source === "user"); + const team = useGithubRepositories(search, enabled && source === "team"); + return source === "team" ? team : user; +} + +/** + * Branches query for cloud tasks that delegates to the user-level or + * team-level source based on which integration produced the selected repo. + */ +export function useCloudGithubBranches( + source: CloudRepoSource, + ids: { githubIntegrationId?: number; installationId?: string }, + repo: string | null | undefined, + search: string | undefined, + enabled: boolean, +) { + const user = useUserGithubBranches( + ids.installationId, + repo, + search, + enabled && source === "user", + ); + const team = useGithubBranches( + ids.githubIntegrationId, + repo, + search, + enabled && source === "team", + ); + return source === "team" ? team : user; +} diff --git a/apps/code/src/renderer/sagas/task/task-creation.test.ts b/apps/code/src/renderer/sagas/task/task-creation.test.ts index ae214465e..7cd9090c4 100644 --- a/apps/code/src/renderer/sagas/task/task-creation.test.ts +++ b/apps/code/src/renderer/sagas/task/task-creation.test.ts @@ -462,4 +462,41 @@ describe("TaskCreationSaga", () => { }), ); }); + + it("forwards the team GitHub integration when no user integration is selected", async () => { + const createdTask = createTask({ github_user_integration: null }); + const startedTask = createTask({ latest_run: createRun() }); + const createTaskMock = vi.fn().mockResolvedValue(createdTask); + const createTaskRunMock = vi.fn().mockResolvedValue(createRun()); + const startTaskRunMock = vi.fn().mockResolvedValue(startedTask); + + const saga = new TaskCreationSaga({ + posthogClient: { + createTask: createTaskMock, + deleteTask: vi.fn(), + getTask: vi.fn(), + createTaskRun: createTaskRunMock, + startTaskRun: startTaskRunMock, + sendRunCommand: vi.fn(), + updateTask: vi.fn(), + } as never, + }); + + const result = await saga.run({ + content: "Ship the fix", + repository: "posthog/posthog", + workspaceMode: "cloud", + branch: "main", + githubIntegrationId: 42, + }); + + expect(result.success).toBe(true); + expect(createTaskMock).toHaveBeenCalledWith( + expect.objectContaining({ + repository: "posthog/posthog", + github_integration: 42, + github_user_integration: undefined, + }), + ); + }); }); diff --git a/apps/code/src/renderer/sagas/task/task-creation.ts b/apps/code/src/renderer/sagas/task/task-creation.ts index 523066bf0..02d30b3a9 100644 --- a/apps/code/src/renderer/sagas/task/task-creation.ts +++ b/apps/code/src/renderer/sagas/task/task-creation.ts @@ -445,17 +445,29 @@ export class TaskCreationSaga extends Saga< return this.step({ name: "task_creation", execute: async () => { + log.info("Creating task", { + repository, + workspaceMode: input.workspaceMode, + signalReportId: input.signalReportId ?? null, + cloudRunSource: input.cloudRunSource ?? null, + githubIntegrationId: + input.workspaceMode === "cloud" + ? (input.githubIntegrationId ?? null) + : null, + githubUserIntegrationId: + input.workspaceMode === "cloud" + ? (input.githubUserIntegrationId ?? null) + : null, + }); const result = await this.deps.posthogClient.createTask({ description: input.taskDescription ?? input.content ?? "", repository: repository ?? undefined, github_integration: - input.workspaceMode === "cloud" && - input.cloudRunSource === "signal_report" + input.workspaceMode === "cloud" ? input.githubIntegrationId : undefined, github_user_integration: - input.workspaceMode === "cloud" && - input.cloudRunSource !== "signal_report" + input.workspaceMode === "cloud" ? input.githubUserIntegrationId : undefined, origin_product: input.signalReportId