From c6fcc3810a9c8169a673bcf20f14ac1fb39a7598 Mon Sep 17 00:00:00 2001 From: Tom Owers Date: Mon, 20 Jul 2026 17:17:37 +0100 Subject: [PATCH 1/4] feat(mobile): add "Any" option to inbox Source and Priority filters Ports #3493 to the mobile filter sheet. Adds an "Any" row at the top of the Source and Priority sections that reads as selected when the filter is empty and clears the filter back to empty when tapped. Source clears via a new clearSourceProductFilter store action; Priority reuses setPriorityFilter([]). Generated-By: PostHog Code Task-Id: be78b0df-c31b-4e0e-8d37-feaf22a054d0 --- .../src/features/inbox/components/FilterSheet.tsx | 14 ++++++++++++++ .../inbox/stores/inboxFilterStore.test.ts | 15 +++++++++++++++ .../src/features/inbox/stores/inboxFilterStore.ts | 2 ++ 3 files changed, 31 insertions(+) diff --git a/apps/mobile/src/features/inbox/components/FilterSheet.tsx b/apps/mobile/src/features/inbox/components/FilterSheet.tsx index 5243c67648..83163b2547 100644 --- a/apps/mobile/src/features/inbox/components/FilterSheet.tsx +++ b/apps/mobile/src/features/inbox/components/FilterSheet.tsx @@ -126,8 +126,12 @@ export function FilterSheet({ visible, onClose }: FilterSheetProps) { const toggleStatus = useInboxFilterStore((s) => s.toggleStatus); const sourceProductFilter = useInboxFilterStore((s) => s.sourceProductFilter); const toggleSourceProduct = useInboxFilterStore((s) => s.toggleSourceProduct); + const clearSourceProductFilter = useInboxFilterStore( + (s) => s.clearSourceProductFilter, + ); const priorityFilter = useInboxFilterStore((s) => s.priorityFilter); const togglePriority = useInboxFilterStore((s) => s.togglePriority); + const setPriorityFilter = useInboxFilterStore((s) => s.setPriorityFilter); const resetFilters = useInboxFilterStore((s) => s.resetFilters); const hasActiveFilters = @@ -213,6 +217,11 @@ export function FilterSheet({ visible, onClose }: FilterSheetProps) { {/* Priority */} + setPriorityFilter([])} + /> {FILTERABLE_PRIORITIES.map((priority) => ( + {SOURCE_PRODUCT_OPTIONS.map((option) => ( { expect(useInboxFilterStore.getState().sourceProductFilter).toEqual([]); }, ); + + it("clears the source filter", () => { + const { toggleSourceProduct, clearSourceProductFilter } = + useInboxFilterStore.getState(); + + toggleSourceProduct("github"); + toggleSourceProduct("linear"); + expect(useInboxFilterStore.getState().sourceProductFilter).toEqual([ + "github", + "linear", + ]); + + clearSourceProductFilter(); + expect(useInboxFilterStore.getState().sourceProductFilter).toEqual([]); + }); }); const INITIAL_STATE = useInboxFilterStore.getState(); diff --git a/apps/mobile/src/features/inbox/stores/inboxFilterStore.ts b/apps/mobile/src/features/inbox/stores/inboxFilterStore.ts index ea363b4e42..ab97b29525 100644 --- a/apps/mobile/src/features/inbox/stores/inboxFilterStore.ts +++ b/apps/mobile/src/features/inbox/stores/inboxFilterStore.ts @@ -47,6 +47,7 @@ interface InboxFilterActions { setStatusFilter: (statuses: SignalReportStatus[]) => void; toggleStatus: (status: SignalReportStatus) => void; toggleSourceProduct: (source: SourceProduct) => void; + clearSourceProductFilter: () => void; toggleSuggestedReviewer: (reviewerUuid: string) => void; setSuggestedReviewerFilter: (reviewerUuids: string[]) => void; togglePriority: (priority: SignalReportPriority) => void; @@ -85,6 +86,7 @@ export const useInboxFilterStore = create()( : [...current, source]; return { sourceProductFilter: next }; }), + clearSourceProductFilter: () => set({ sourceProductFilter: [] }), toggleSuggestedReviewer: (reviewerUuid) => set((state) => { const current = state.suggestedReviewerFilter; From 3a9c76c0beea3c2a12af5754158bc82ad7ee0785 Mon Sep 17 00:00:00 2001 From: Tom Owers Date: Mon, 20 Jul 2026 17:27:39 +0100 Subject: [PATCH 2/4] fix(mobile): prewarm the selected sandbox image (port #3511) Thread the selected sandbox environment / custom base image through the mobile cloud-task warm path so a warmed sandbox matches what the task will actually run on. - `warmTask()` now optionally includes `sandbox_environment_id` / `custom_image_id` in the request body (only when set). - `useWarmTask` folds both ids into the debounce/dedupe key, so changing the selection re-warms, and forwards them to the warm call. - `runTaskInCloud` carries the ids on the run so a reused warm sandbox matches the selection instead of a mismatched default. Generated-By: PostHog Code Task-Id: 5c715645-baa3-460e-91ca-73ee7e7fad86 --- apps/mobile/src/features/tasks/api.test.ts | 24 +++++++++ apps/mobile/src/features/tasks/api.ts | 20 +++++++ .../src/features/tasks/api.warm.test.ts | 52 +++++++++++++++++++ .../features/tasks/hooks/useWarmTask.test.tsx | 38 ++++++++++++++ .../src/features/tasks/hooks/useWarmTask.ts | 16 +++++- 5 files changed, 149 insertions(+), 1 deletion(-) diff --git a/apps/mobile/src/features/tasks/api.test.ts b/apps/mobile/src/features/tasks/api.test.ts index 19e6f93dc1..2ac4f23d57 100644 --- a/apps/mobile/src/features/tasks/api.test.ts +++ b/apps/mobile/src/features/tasks/api.test.ts @@ -63,6 +63,30 @@ describe("runTaskInCloud", () => { expect(init.body).toBeUndefined(); }); + it("forwards the selected sandbox environment and custom image", async () => { + await runTaskInCloud("task-1", { + sandboxEnvironmentId: "environment-123", + customImageId: "image-123", + }); + + expect(bodyOf(mockFetch.mock.calls[0])).toMatchObject({ + sandbox_environment_id: "environment-123", + custom_image_id: "image-123", + }); + }); + + it("omits the sandbox environment and custom image when unset", async () => { + await runTaskInCloud("task-1", { + model: "claude-opus-4-8", + sandboxEnvironmentId: null, + customImageId: null, + }); + + const body = bodyOf(mockFetch.mock.calls[0]); + expect(body).not.toHaveProperty("sandbox_environment_id"); + expect(body).not.toHaveProperty("custom_image_id"); + }); + it("sends rtk_enabled=false when the run opts out", async () => { await runTaskInCloud("task-1", { rtkEnabled: false }); diff --git a/apps/mobile/src/features/tasks/api.ts b/apps/mobile/src/features/tasks/api.ts index 6952fde9f6..fa703b7401 100644 --- a/apps/mobile/src/features/tasks/api.ts +++ b/apps/mobile/src/features/tasks/api.ts @@ -314,6 +314,8 @@ export async function warmTask(options: { runtime_adapter?: string | null; model?: string | null; reasoning_effort?: string | null; + sandbox_environment_id?: string | null; + custom_image_id?: string | null; }): Promise<{ task_id: string; run_id: string } | null> { const baseUrl = getBaseUrl(); const projectId = getProjectId(); @@ -329,6 +331,12 @@ export async function warmTask(options: { runtime_adapter: options.runtime_adapter ?? null, model: options.model ?? null, reasoning_effort: options.reasoning_effort ?? null, + ...(options.sandbox_environment_id + ? { sandbox_environment_id: options.sandbox_environment_id } + : {}), + ...(options.custom_image_id + ? { custom_image_id: options.custom_image_id } + : {}), }), }, ); @@ -431,6 +439,10 @@ export interface RunTaskInCloudOptions { model?: string; /** Reasoning effort: "low" | "medium" | "high" (model-dependent). */ reasoningEffort?: string; + /** Sandbox environment / custom base image to run on. Sent so a reused warm + * sandbox matches the selection instead of a mismatched default. */ + sandboxEnvironmentId?: string | null; + customImageId?: string | null; /** Permission mode: "default" | "acceptEdits" | "plan" | "auto". */ initialPermissionMode?: string; /** Source that triggered this run. */ @@ -463,6 +475,8 @@ export async function runTaskInCloud( options.runtimeAdapter !== undefined || options.model !== undefined || options.reasoningEffort !== undefined || + options.sandboxEnvironmentId !== undefined || + options.customImageId !== undefined || options.initialPermissionMode !== undefined || options.runSource !== undefined || options.signalReportId !== undefined || @@ -488,6 +502,12 @@ export async function runTaskInCloud( payload.reasoning_effort = options.reasoningEffort; } } + if (options?.sandboxEnvironmentId) { + payload.sandbox_environment_id = options.sandboxEnvironmentId; + } + if (options?.customImageId) { + payload.custom_image_id = options.customImageId; + } if (options?.initialPermissionMode) { payload.initial_permission_mode = options.initialPermissionMode; } diff --git a/apps/mobile/src/features/tasks/api.warm.test.ts b/apps/mobile/src/features/tasks/api.warm.test.ts index d8e8f882ee..06c9d1aba0 100644 --- a/apps/mobile/src/features/tasks/api.warm.test.ts +++ b/apps/mobile/src/features/tasks/api.warm.test.ts @@ -90,6 +90,58 @@ describe("warmTask", () => { ); }); + it("forwards the selected sandbox environment and custom image", async () => { + mockFetch.mockResolvedValueOnce( + new Response(JSON.stringify({ task_id: "task-1", run_id: "run-1" }), { + status: 200, + }), + ); + + await warmTask({ + repository: "posthog/posthog", + github_integration: 7, + branch: "main", + sandbox_environment_id: "environment-123", + custom_image_id: "image-123", + }); + + expect(mockFetch).toHaveBeenCalledWith( + "https://app.posthog.test/api/projects/42/tasks/warm/", + expect.objectContaining({ + body: JSON.stringify({ + repository: "posthog/posthog", + github_integration: 7, + branch: "main", + runtime_adapter: null, + model: null, + reasoning_effort: null, + sandbox_environment_id: "environment-123", + custom_image_id: "image-123", + }), + }), + ); + }); + + it("omits the sandbox environment and custom image when unset", async () => { + mockFetch.mockResolvedValueOnce( + new Response(JSON.stringify({ task_id: "task-1", run_id: "run-1" }), { + status: 200, + }), + ); + + await warmTask({ + repository: "posthog/posthog", + github_integration: 7, + sandbox_environment_id: null, + custom_image_id: null, + }); + + const [, init] = mockFetch.mock.calls[0] as [string, RequestInit]; + const body = JSON.parse(init.body as string); + expect(body).not.toHaveProperty("sandbox_environment_id"); + expect(body).not.toHaveProperty("custom_image_id"); + }); + it("serializes a missing branch as null", async () => { mockFetch.mockResolvedValueOnce( new Response(JSON.stringify({ task_id: "task-1", run_id: "run-1" }), { diff --git a/apps/mobile/src/features/tasks/hooks/useWarmTask.test.tsx b/apps/mobile/src/features/tasks/hooks/useWarmTask.test.tsx index 8ed33df816..5b757d40fc 100644 --- a/apps/mobile/src/features/tasks/hooks/useWarmTask.test.tsx +++ b/apps/mobile/src/features/tasks/hooks/useWarmTask.test.tsx @@ -32,6 +32,8 @@ interface Props { runtimeAdapter?: string | null; model?: string | null; reasoningEffort?: string | null; + sandboxEnvironmentId?: string | null; + customImageId?: string | null; } const composing: Props = { @@ -198,6 +200,42 @@ describe("useWarmTask", () => { }, ); + it("forwards the sandbox environment and custom image", async () => { + render({ + ...composing, + sandboxEnvironmentId: "environment-123", + customImageId: "image-123", + }); + await flushDebounce(); + + expect(mockWarmTask).toHaveBeenCalledWith({ + repository: "acme/repo", + github_integration: 42, + branch: "main", + ...NULL_RUNTIME, + sandbox_environment_id: "environment-123", + custom_image_id: "image-123", + }); + }); + + it("re-warms when the custom image changes", async () => { + const { rerender } = render({ ...composing, customImageId: "image-123" }); + await flushDebounce(); + expect(mockWarmTask).toHaveBeenCalledOnce(); + + rerender({ ...composing, customImageId: "image-456" }); + await flushDebounce(); + + expect(mockWarmTask).toHaveBeenCalledTimes(2); + expect(mockWarmTask).toHaveBeenLastCalledWith({ + repository: "acme/repo", + github_integration: 42, + branch: "main", + ...NULL_RUNTIME, + custom_image_id: "image-456", + }); + }); + it("warms again for a new selection after a failed warm", async () => { mockWarmTask.mockRejectedValueOnce(new Error("boom")); const { rerender } = render(composing); diff --git a/apps/mobile/src/features/tasks/hooks/useWarmTask.ts b/apps/mobile/src/features/tasks/hooks/useWarmTask.ts index e69ce6555c..935619f75f 100644 --- a/apps/mobile/src/features/tasks/hooks/useWarmTask.ts +++ b/apps/mobile/src/features/tasks/hooks/useWarmTask.ts @@ -16,6 +16,8 @@ interface UseWarmTaskOptions { runtimeAdapter?: string | null; model?: string | null; reasoningEffort?: string | null; + sandboxEnvironmentId?: string | null; + customImageId?: string | null; } export function useWarmTask({ @@ -26,6 +28,8 @@ export function useWarmTask({ runtimeAdapter, model, reasoningEffort, + sandboxEnvironmentId, + customImageId, }: UseWarmTaskOptions): void { const enabled = useFeatureFlag(TASKS_PREWARM_SANDBOX_FLAG); @@ -36,6 +40,8 @@ export function useWarmTask({ const normalizedRuntimeAdapter = runtimeAdapter ?? null; const normalizedModel = model ?? null; const normalizedReasoningEffort = reasoningEffort ?? null; + const normalizedSandboxEnvironmentId = sandboxEnvironmentId ?? null; + const normalizedCustomImageId = customImageId ?? null; const eligible = !!enabled && !!repository && @@ -43,7 +49,7 @@ export function useWarmTask({ !composerIsEmpty; const key = repository && githubIntegrationId != null - ? `${githubIntegrationId}:${repository}:${normalizedBranch ?? ""}:${normalizedRuntimeAdapter ?? ""}:${normalizedModel ?? ""}:${normalizedReasoningEffort ?? ""}` + ? `${githubIntegrationId}:${repository}:${normalizedBranch ?? ""}:${normalizedRuntimeAdapter ?? ""}:${normalizedModel ?? ""}:${normalizedReasoningEffort ?? ""}:${normalizedSandboxEnvironmentId ?? ""}:${normalizedCustomImageId ?? ""}` : null; useEffect(() => { @@ -68,6 +74,8 @@ export function useWarmTask({ const warmRuntimeAdapter = normalizedRuntimeAdapter; const warmModel = normalizedModel; const warmReasoningEffort = normalizedReasoningEffort; + const warmSandboxEnvironmentId = normalizedSandboxEnvironmentId; + const warmCustomImageId = normalizedCustomImageId; debounceRef.current = setTimeout(() => { debounceRef.current = null; lastWarmedKeyRef.current = key; @@ -78,6 +86,10 @@ export function useWarmTask({ runtime_adapter: warmRuntimeAdapter, model: warmModel, reasoning_effort: warmReasoningEffort, + ...(warmSandboxEnvironmentId + ? { sandbox_environment_id: warmSandboxEnvironmentId } + : {}), + ...(warmCustomImageId ? { custom_image_id: warmCustomImageId } : {}), }).catch((error) => { lastWarmedKeyRef.current = null; log.warn("Failed to warm task", error); @@ -94,5 +106,7 @@ export function useWarmTask({ normalizedRuntimeAdapter, normalizedModel, normalizedReasoningEffort, + normalizedSandboxEnvironmentId, + normalizedCustomImageId, ]); } From bbc66700d17d3d6f9dfdac11288b893247706f33 Mon Sep 17 00:00:00 2001 From: Tom Owers Date: Tue, 21 Jul 2026 10:59:48 +0000 Subject: [PATCH 3/4] feat(data-warehouse): generic OAuth flow starter for inbox sources MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generalize the inbox OAuth connect flow so any PostHog-supported OAuth integration kind works without provider-specific code. `DynamicSourceSetup` already renders `oauth` and `oauth-account-select` fields generically (account listing + server-side resource search); the only provider-specific gate was the flow starter, which hardcoded `kind === "linear"`. PostHog's `…/integrations/authorize/?kind=` endpoint is already generic, so this adds a kind-parameterized `IntegrationService` + `integration` tRPC router and points the OAuth field's connect button at it via `field.kind`. Result: adding an OAuth warehouse source (Intercom, HubSpot, Salesforce, Stripe, ad platforms, …) needs no bespoke setup form or per-kind router — just the source's registry entry, whose connect-form schema already carries the oauth field and kind. --- packages/core/src/integrations/identifiers.ts | 4 ++ .../core/src/integrations/integration.test.ts | 41 +++++++++++++++++ packages/core/src/integrations/integration.ts | 44 +++++++++++++++++++ .../src/integrations/integrations.module.ts | 3 ++ packages/core/src/integrations/schemas.ts | 14 ++++++ packages/host-router/src/router.ts | 2 + .../src/routers/integration.router.ts | 23 ++++++++++ .../inbox/components/DynamicSourceSetup.tsx | 25 ++++++----- 8 files changed, 145 insertions(+), 11 deletions(-) create mode 100644 packages/core/src/integrations/integration.test.ts create mode 100644 packages/core/src/integrations/integration.ts create mode 100644 packages/host-router/src/routers/integration.router.ts diff --git a/packages/core/src/integrations/identifiers.ts b/packages/core/src/integrations/identifiers.ts index a71c99eb94..d76d4a9b48 100644 --- a/packages/core/src/integrations/identifiers.ts +++ b/packages/core/src/integrations/identifiers.ts @@ -1,3 +1,7 @@ +export const INTEGRATION_SERVICE = Symbol.for( + "posthog.core.integrationService", +); + export const GITHUB_INTEGRATION_SERVICE = Symbol.for( "posthog.core.githubIntegrationService", ); diff --git a/packages/core/src/integrations/integration.test.ts b/packages/core/src/integrations/integration.test.ts new file mode 100644 index 0000000000..27a8a03805 --- /dev/null +++ b/packages/core/src/integrations/integration.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it, vi } from "vitest"; +import { IntegrationService } from "./integration"; + +function createService() { + const urlLauncher = { launch: vi.fn().mockResolvedValue(undefined) }; + const service = new IntegrationService(urlLauncher as never); + return { service, urlLauncher }; +} + +describe("IntegrationService.startFlow", () => { + it("launches an authorize URL for the given kind scoped to the project", async () => { + const { service, urlLauncher } = createService(); + + const result = await service.startFlow("intercom", "us", 42); + + expect(result).toEqual({ success: true }); + const launched = urlLauncher.launch.mock.calls[0][0]; + expect(launched).toContain("/api/environments/42/integrations/authorize/"); + expect(launched).toContain("kind=intercom"); + }); + + it("url-encodes the kind", async () => { + const { service, urlLauncher } = createService(); + + await service.startFlow("rapid7_insightvm", "eu", 7); + + expect(urlLauncher.launch.mock.calls[0][0]).toContain( + "kind=rapid7_insightvm", + ); + }); + + it("returns a failure result when launching the browser throws", async () => { + const { service, urlLauncher } = createService(); + urlLauncher.launch.mockRejectedValue(new Error("no browser")); + + expect(await service.startFlow("hubspot", "us", 42)).toEqual({ + success: false, + error: "no browser", + }); + }); +}); diff --git a/packages/core/src/integrations/integration.ts b/packages/core/src/integrations/integration.ts new file mode 100644 index 0000000000..0a3cb19c60 --- /dev/null +++ b/packages/core/src/integrations/integration.ts @@ -0,0 +1,44 @@ +import { + type IUrlLauncher, + URL_LAUNCHER_SERVICE, +} from "@posthog/platform/url-launcher"; +import { type CloudRegion, getCloudUrlFromRegion } from "@posthog/shared"; +import { inject, injectable } from "inversify"; +import type { StartIntegrationFlowOutput } from "./schemas"; + +/** + * Generic OAuth integration flow starter. PostHog's + * `…/integrations/authorize/?kind=` endpoint is generic over the integration kind, so a + * single service starts the flow for any supported OAuth provider (linear, intercom, hubspot, + * salesforce, …) — no per-kind service or router required. The OAuth grant, callback, and token + * storage all happen on PostHog Cloud; the caller then polls the integrations list for the new + * integration of this `kind`. + */ +@injectable() +export class IntegrationService { + constructor( + @inject(URL_LAUNCHER_SERVICE) + private readonly urlLauncher: IUrlLauncher, + ) {} + + public async startFlow( + kind: string, + region: CloudRegion, + projectId: number, + ): Promise { + try { + const cloudUrl = getCloudUrlFromRegion(region); + const next = `${cloudUrl}/project/${projectId}`; + const authorizeUrl = `${cloudUrl}/api/environments/${projectId}/integrations/authorize/?kind=${encodeURIComponent(kind)}&next=${encodeURIComponent(next)}`; + + await this.urlLauncher.launch(authorizeUrl); + + return { success: true }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : "Unknown error", + }; + } + } +} diff --git a/packages/core/src/integrations/integrations.module.ts b/packages/core/src/integrations/integrations.module.ts index bd57929177..3c3af8ab5f 100644 --- a/packages/core/src/integrations/integrations.module.ts +++ b/packages/core/src/integrations/integrations.module.ts @@ -2,13 +2,16 @@ import { ContainerModule } from "inversify"; import { GitHubIntegrationService } from "./github"; import { GITHUB_INTEGRATION_SERVICE, + INTEGRATION_SERVICE, LINEAR_INTEGRATION_SERVICE, SLACK_INTEGRATION_SERVICE, } from "./identifiers"; +import { IntegrationService } from "./integration"; import { LinearIntegrationService } from "./linear"; import { SlackIntegrationService } from "./slack"; export const integrationsModule = new ContainerModule(({ bind }) => { + bind(INTEGRATION_SERVICE).to(IntegrationService).inSingletonScope(); bind(GITHUB_INTEGRATION_SERVICE) .to(GitHubIntegrationService) .inSingletonScope(); diff --git a/packages/core/src/integrations/schemas.ts b/packages/core/src/integrations/schemas.ts index f2d3220591..b58c263f68 100644 --- a/packages/core/src/integrations/schemas.ts +++ b/packages/core/src/integrations/schemas.ts @@ -11,6 +11,20 @@ export type StartIntegrationFlowInput = z.infer< typeof startIntegrationFlowInput >; +/** + * Generic integration flow input: any OAuth `kind` PostHog supports. The per-kind routers + * (linear/slack/github) keep the narrower input above; this one drives the generic starter so + * new OAuth sources need no dedicated router. + */ +export const startGenericIntegrationFlowInput = z.object({ + kind: z.string(), + region: cloudRegion, + projectId: z.number(), +}); +export type StartGenericIntegrationFlowInput = z.infer< + typeof startGenericIntegrationFlowInput +>; + export const startIntegrationFlowOutput = z.object({ success: z.boolean(), error: z.string().optional(), diff --git a/packages/host-router/src/router.ts b/packages/host-router/src/router.ts index a9582f4172..17d323b1eb 100644 --- a/packages/host-router/src/router.ts +++ b/packages/host-router/src/router.ts @@ -25,6 +25,7 @@ import { gitRouter } from "./routers/git.router"; import { githubIntegrationRouter } from "./routers/github-integration.router"; import { githubReleasesRouter } from "./routers/github-releases.router"; import { handoffRouter } from "./routers/handoff.router"; +import { integrationRouter } from "./routers/integration.router"; import { linearIntegrationRouter } from "./routers/linear-integration.router"; import { llmGatewayRouter } from "./routers/llm-gateway.router"; import { localMcpRouter } from "./routers/local-mcp.router"; @@ -76,6 +77,7 @@ export const hostRouter = router({ fs: fsRouter, git: gitRouter, handoff: handoffRouter, + integration: integrationRouter, githubIntegration: githubIntegrationRouter, githubReleases: githubReleasesRouter, linearIntegration: linearIntegrationRouter, diff --git a/packages/host-router/src/routers/integration.router.ts b/packages/host-router/src/routers/integration.router.ts new file mode 100644 index 0000000000..680980f01c --- /dev/null +++ b/packages/host-router/src/routers/integration.router.ts @@ -0,0 +1,23 @@ +import { INTEGRATION_SERVICE } from "@posthog/core/integrations/identifiers"; +import type { IntegrationService } from "@posthog/core/integrations/integration"; +import { + startGenericIntegrationFlowInput, + startIntegrationFlowOutput, +} from "@posthog/core/integrations/schemas"; +import { publicProcedure, router } from "@posthog/host-trpc/trpc"; + +/** + * Generic OAuth integration flow starter, parameterized by `kind`. Replaces the need for a + * per-provider router when adding a new OAuth data source — the source's connect-form schema + * already carries the `kind`, so the UI passes it straight through. + */ +export const integrationRouter = router({ + startFlow: publicProcedure + .input(startGenericIntegrationFlowInput) + .output(startIntegrationFlowOutput) + .mutation(({ ctx, input }) => { + return ctx.container + .get(INTEGRATION_SERVICE) + .startFlow(input.kind, input.region, input.projectId); + }), +}); diff --git a/packages/ui/src/features/inbox/components/DynamicSourceSetup.tsx b/packages/ui/src/features/inbox/components/DynamicSourceSetup.tsx index afe1e5c36d..c5d57650c4 100644 --- a/packages/ui/src/features/inbox/components/DynamicSourceSetup.tsx +++ b/packages/ui/src/features/inbox/components/DynamicSourceSetup.tsx @@ -399,9 +399,9 @@ function SourceField({ /** * Renders an `oauth` config field: a connect button that launches the provider's * OAuth flow, polls for the resulting integration, and writes its id into the - * form. Mirrors the previous bespoke Linear setup. Only providers with a wired - * flow starter (currently `linear`) can be connected here; others surface a - * message. + * form. The flow is started generically by the field's `kind` (PostHog's + * `…/integrations/authorize/?kind=…` endpoint is generic), so any OAuth source + * PostHog supports works here without provider-specific code. */ function OAuthSourceField({ field, @@ -418,8 +418,8 @@ function OAuthSourceField({ const projectId = useAuthStateValue((state) => state.currentProjectId); const client = useAuthenticatedClient(); const trpc = useHostTRPC(); - const startLinearFlow = useMutation( - trpc.linearIntegration.startFlow.mutationOptions(), + const startIntegrationFlow = useMutation( + trpc.integration.startFlow.mutationOptions(), ); const [connecting, setConnecting] = useState(false); const [error, setError] = useState(null); @@ -441,13 +441,16 @@ function OAuthSourceField({ const connected = value !== undefined && value !== ""; const startFlow = useCallback(async () => { - if (field.kind === "linear") { - if (!region || !projectId) throw new Error("Missing project context"); - await startLinearFlow.mutateAsync({ region, projectId }); - return; + if (!region || !projectId) throw new Error("Missing project context"); + const result = await startIntegrationFlow.mutateAsync({ + kind: field.kind, + region, + projectId, + }); + if (!result.success) { + throw new Error(result.error ?? `Failed to connect ${providerName}`); } - throw new Error(`Connecting ${providerName} isn't supported here yet.`); - }, [field.kind, region, projectId, startLinearFlow, providerName]); + }, [field.kind, region, projectId, startIntegrationFlow, providerName]); const handleConnect = useCallback(async () => { if (!projectId || !client) return; From 43ca74c1f8de78f8e519d93b3a8446928c00f4f1 Mon Sep 17 00:00:00 2001 From: Tom Owers Date: Tue, 21 Jul 2026 14:14:44 +0100 Subject: [PATCH 4/4] fix(desktop): register integration router in main trpc assembly The HostRouter type declares an `integration` route, but the desktop app's trpcRouter assembly did not serve it, tripping the servesEveryHostRoute compile-time guard and failing typecheck. Generated-By: PostHog Code Task-Id: 9fd73e57-6627-4884-bb0e-953cc4fa6b33 --- apps/code/src/main/trpc/router.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/code/src/main/trpc/router.ts b/apps/code/src/main/trpc/router.ts index f2209456c6..a8bcb3623d 100644 --- a/apps/code/src/main/trpc/router.ts +++ b/apps/code/src/main/trpc/router.ts @@ -26,6 +26,7 @@ import { gitRouter } from "@posthog/host-router/routers/git.router"; import { githubIntegrationRouter } from "@posthog/host-router/routers/github-integration.router"; import { githubReleasesRouter } from "@posthog/host-router/routers/github-releases.router"; import { handoffRouter } from "@posthog/host-router/routers/handoff.router"; +import { integrationRouter } from "@posthog/host-router/routers/integration.router"; import { linearIntegrationRouter } from "@posthog/host-router/routers/linear-integration.router"; import { llmGatewayRouter } from "@posthog/host-router/routers/llm-gateway.router"; import { localMcpRouter } from "@posthog/host-router/routers/local-mcp.router"; @@ -87,6 +88,7 @@ export const trpcRouter = router({ githubIntegration: githubIntegrationRouter, githubReleases: githubReleasesRouter, handoff: handoffRouter, + integration: integrationRouter, linearIntegration: linearIntegrationRouter, llmGateway: llmGatewayRouter, localMcp: localMcpRouter,