From 0f17f595c7dfbc1fa11b2a45dc6931cf0e9c5d45 Mon Sep 17 00:00:00 2001 From: Vojta Bartos Date: Mon, 4 May 2026 11:24:59 +0200 Subject: [PATCH 1/2] chore(code): unify cloud GitHub integration picker with team fallback Repo picker for cloud tasks now flows through useCloudRepositoryIntegration, which prefers the user's personal GitHub integration and falls back to the team integration when the user has not linked one. The saga forwards whichever integration ID is populated, dropping the cloudRunSource gate so team-fallback tasks no longer drop the integration ID before POST. Generated-By: PostHog Code Task-Id: 19c36b01-cbf3-4cd2-b99b-efa50da2256a --- .../task-detail/components/TaskInput.tsx | 47 ++++---- .../src/renderer/hooks/useIntegrations.ts | 103 ++++++++++++++++++ .../renderer/sagas/task/task-creation.test.ts | 37 +++++++ .../src/renderer/sagas/task/task-creation.ts | 20 +++- 4 files changed, 182 insertions(+), 25 deletions(-) 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..4857168d6 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"; @@ -175,26 +175,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 +213,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 +221,9 @@ export function TaskInput({ hasMore: cloudBranchesHasMore, loadMore: loadMoreCloudBranches, refresh: refreshCloudBranches, - } = useUserGithubBranches( - selectedInstallationId, + } = useCloudGithubBranches( + cloudRepoSource, + cloudRepoIntegration, selectedCloudRepository, cloudBranchSearchQuery, isCloudBranchPickerOpen, @@ -432,6 +436,7 @@ export function TaskInput({ editorRef, selectedDirectory, selectedRepository: selectedCloudRepository, + githubIntegrationId: selectedGithubIntegrationId, githubUserIntegrationId: selectedGithubUserIntegrationId, workspaceMode: effectiveWorkspaceMode, branch: branchForTaskCreation, 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 From 1a30985b6fbb76f3a438a377d16ba155f4f8f56a Mon Sep 17 00:00:00 2001 From: Vojta Bartos Date: Mon, 4 May 2026 11:35:30 +0200 Subject: [PATCH 2/2] chore(code): notify users when cloud picker falls back to team integration Renders an inline amber callout below the cloud repo picker when the user has no personal GitHub linked, with a one-click connect action so PRs can be authored as the user. Extracts the connect flow into a shared useConnectUserGithub hook reused by the inbox banner. Generated-By: PostHog Code Task-Id: 19c36b01-cbf3-4cd2-b99b-efa50da2256a --- .../list/GitHubConnectionBanner.tsx | 77 ++---------------- .../components/CloudRepoFallbackNotice.tsx | 37 +++++++++ .../task-detail/components/TaskInput.tsx | 5 ++ .../renderer/hooks/useConnectUserGithub.ts | 79 +++++++++++++++++++ 4 files changed, 128 insertions(+), 70 deletions(-) create mode 100644 apps/code/src/renderer/features/task-detail/components/CloudRepoFallbackNotice.tsx create mode 100644 apps/code/src/renderer/hooks/useConnectUserGithub.ts 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 4857168d6..9812207b5 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 { CloudRepoFallbackNotice } from "./CloudRepoFallbackNotice"; import { type WorkspaceMode, WorkspaceModeSelect } from "./WorkspaceModeSelect"; interface TaskInputProps { @@ -780,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 }; +}