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
2 changes: 2 additions & 0 deletions apps/code/src/main/trpc/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -87,6 +88,7 @@ export const trpcRouter = router({
githubIntegration: githubIntegrationRouter,
githubReleases: githubReleasesRouter,
handoff: handoffRouter,
integration: integrationRouter,
linearIntegration: linearIntegrationRouter,
llmGateway: llmGatewayRouter,
localMcp: localMcpRouter,
Expand Down
4 changes: 4 additions & 0 deletions packages/core/src/integrations/identifiers.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
export const INTEGRATION_SERVICE = Symbol.for(
"posthog.core.integrationService",
);

export const GITHUB_INTEGRATION_SERVICE = Symbol.for(
"posthog.core.githubIntegrationService",
);
Expand Down
41 changes: 41 additions & 0 deletions packages/core/src/integrations/integration.test.ts
Original file line number Diff line number Diff line change
@@ -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",
});
});
});
44 changes: 44 additions & 0 deletions packages/core/src/integrations/integration.ts
Original file line number Diff line number Diff line change
@@ -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=<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<StartIntegrationFlowOutput> {
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",
};
}
}
}
3 changes: 3 additions & 0 deletions packages/core/src/integrations/integrations.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
14 changes: 14 additions & 0 deletions packages/core/src/integrations/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
2 changes: 2 additions & 0 deletions packages/host-router/src/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -76,6 +77,7 @@ export const hostRouter = router({
fs: fsRouter,
git: gitRouter,
handoff: handoffRouter,
integration: integrationRouter,
githubIntegration: githubIntegrationRouter,
githubReleases: githubReleasesRouter,
linearIntegration: linearIntegrationRouter,
Expand Down
23 changes: 23 additions & 0 deletions packages/host-router/src/routers/integration.router.ts
Original file line number Diff line number Diff line change
@@ -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<IntegrationService>(INTEGRATION_SERVICE)
.startFlow(input.kind, input.region, input.projectId);
}),
});
25 changes: 14 additions & 11 deletions packages/ui/src/features/inbox/components/DynamicSourceSetup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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<string | null>(null);
Expand All @@ -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;
Expand Down
Loading