From 0f27c1970948a6bb24eb1269bd9e80431a6d2620 Mon Sep 17 00:00:00 2001 From: Mateo Date: Mon, 20 Jul 2026 12:09:24 +0200 Subject: [PATCH] feat(deep-links): make posthog-code a Linear custom coding tool target Linear can launch custom coding tools from a URL template with {{issue.identifier}} and {{context}} variables. Extend the existing posthog-code://new deep link with optional source and issue params so Linear-launched opens are attributed in analytics and carry the issue identifier, add a Settings > Linear section with a copyable URL template, and document the flow in DEEP-LINKS.md. Generated-By: PostHog Code Task-Id: 8c8167bc-4619-470c-a11c-b87b6012f6c7 --- docs/DEEP-LINKS.md | 12 +++++ .../deep-links/newTaskLinkResolver.test.ts | 28 +++++++++++ .../src/deep-links/newTaskLinkResolver.ts | 5 ++ packages/core/src/links/new-task-link.test.ts | 47 +++++++++++++++++++ packages/core/src/links/new-task-link.ts | 6 +++ packages/shared/src/analytics-events.ts | 5 ++ packages/shared/src/deep-links.ts | 9 +++- .../src/features/settings/CopyableCommand.tsx | 34 ++++++++++++++ .../settings/components/SettingsPanel.tsx | 6 +++ .../settings/sections/ClaudeCodeSettings.tsx | 35 +------------- .../settings/sections/LinearSettings.tsx | 20 ++++++++ packages/ui/src/features/settings/types.ts | 2 + 12 files changed, 175 insertions(+), 34 deletions(-) create mode 100644 packages/ui/src/features/settings/CopyableCommand.tsx create mode 100644 packages/ui/src/features/settings/sections/LinearSettings.tsx diff --git a/docs/DEEP-LINKS.md b/docs/DEEP-LINKS.md index 1567db9f33..02e80267d9 100644 --- a/docs/DEEP-LINKS.md +++ b/docs/DEEP-LINKS.md @@ -28,6 +28,8 @@ Open the new-task input, optionally pre-filled. | `repo` | No* | Cloud repository slug (e.g. `posthog/posthog`) | | `mode` | No | Initial mode for the task (ignored unless it matches a known mode) | | `model` | No | Initial model for the task (ignored unless it matches a known model) | +| `source` | No | Attribution for the launching tool (e.g. `linear`), recorded in analytics | +| `issue` | No | External issue identifier (e.g. a Linear `ENG-123`), carried for attribution — not fetched | *At least one of `prompt` or `repo` must be present. `mode` and `model` alone are not enough to open a task with meaningful context. @@ -36,6 +38,16 @@ posthog-code://new?prompt=Fix%20the%20login%20bug&repo=posthog%2Fposthog posthog-code://new?repo=posthog%2Fposthog&model=claude-opus-4-7&mode=plan ``` +#### Using PostHog Code as a Linear custom coding tool + +Linear can open issues directly in PostHog Code. In Linear, go to **Settings → Code & reviews**, add a **custom coding tool**, and use this URL template (replace `OWNER/REPO` with your repository): + +``` +posthog-code://new?repo=OWNER/REPO&source=linear&issue={{issue.identifier}}&prompt={{context}} +``` + +`{{issue.identifier}}` and `{{context}}` are Linear template variables substituted at launch — `{{context}}` carries the full issue description, comments, and linked references, so the new-task composer opens pre-filled with everything needed. Keep `prompt` as the **last** parameter: very large issue contexts can be truncated by OS-level URL length limits (notably on Windows), and with `prompt` last only prompt text is clipped, never `repo`/`source`/`issue`. Dev builds use `posthog-code-dev://` instead. The same template is available to copy in **Settings → Linear** inside the app. + ### `posthog-code://plan` Open the new-task input with a longer, base64-encoded plan as the initial prompt. Use this when the prompt is too large or contains characters that are awkward to URL-encode. diff --git a/packages/core/src/deep-links/newTaskLinkResolver.test.ts b/packages/core/src/deep-links/newTaskLinkResolver.test.ts index b417fde5ff..5deeaa96f2 100644 --- a/packages/core/src/deep-links/newTaskLinkResolver.test.ts +++ b/packages/core/src/deep-links/newTaskLinkResolver.test.ts @@ -47,6 +47,34 @@ describe("NewTaskLinkResolver", () => { expect(result.analytics.event).toBe(ANALYTICS_EVENTS.DEEP_LINK_NEW_TASK); if (result.analytics.event !== ANALYTICS_EVENTS.DEEP_LINK_NEW_TASK) return; expect(result.analytics.properties.has_prompt).toBe(true); + expect(result.analytics.properties.source).toBeUndefined(); + expect(result.analytics.properties.issue_identifier).toBeUndefined(); + }); + + it("carries source and issue identifier into analytics without touching navigation", async () => { + const resolver = makeResolver(vi.fn()); + const payload: NewTaskLinkPayload = { + action: "new", + prompt: "do a thing", + repo: "acme/web", + source: "linear", + issueIdentifier: "ENG-123", + }; + + const result = await resolver.resolve(payload); + + expect(result.kind).toBe("navigate"); + if (result.kind !== "navigate") return; + expect(result.navigation).toEqual({ + initialPrompt: "do a thing", + initialCloudRepository: "acme/web", + initialModel: undefined, + initialMode: undefined, + }); + if (result.analytics.event !== ANALYTICS_EVENTS.DEEP_LINK_NEW_TASK) return; + expect(result.analytics.properties.source).toBe("linear"); + expect(result.analytics.properties.issue_identifier).toBe("ENG-123"); + expect(result.analytics.properties.prompt_length_chars).toBe(10); }); it("uses the decoded plan as the prompt for a plan-action payload", async () => { diff --git a/packages/core/src/deep-links/newTaskLinkResolver.ts b/packages/core/src/deep-links/newTaskLinkResolver.ts index 25d0c7b7e8..9d2d6bd7be 100644 --- a/packages/core/src/deep-links/newTaskLinkResolver.ts +++ b/packages/core/src/deep-links/newTaskLinkResolver.ts @@ -46,6 +46,9 @@ export class NewTaskLinkResolver { has_repo: !!payload.repo, mode: payload.mode, model: payload.model, + source: payload.source, + issue_identifier: payload.issueIdentifier, + prompt_length_chars: payload.prompt?.length, }, }, }; @@ -68,6 +71,7 @@ export class NewTaskLinkResolver { has_repo: !!payload.repo, mode: payload.mode, model: payload.model, + source: payload.source, plan_length_chars: payload.plan.length, }, }, @@ -141,6 +145,7 @@ export class NewTaskLinkResolver { issue_number: payload.issueNumber, mode: payload.mode, model: payload.model, + source: payload.source, }, }, }; diff --git a/packages/core/src/links/new-task-link.test.ts b/packages/core/src/links/new-task-link.test.ts index d0f4458b62..3ed3076782 100644 --- a/packages/core/src/links/new-task-link.test.ts +++ b/packages/core/src/links/new-task-link.test.ts @@ -170,6 +170,53 @@ describe("NewTaskLinkService", () => { model: "opus", }); }); + + it("parses source and issue params", () => { + const listener = vi.fn(); + service.on(NewTaskLinkEvent.Action, listener); + + const result = mockDeepLink._invoke( + "new", + new URLSearchParams( + "repo=org/repo&source=linear&issue=ENG-123&prompt=ctx", + ), + ); + + expect(result).toBe(true); + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ + action: "new", + prompt: "ctx", + repo: "org/repo", + source: "linear", + issueIdentifier: "ENG-123", + }), + ); + }); + + it("rejects source and issue without prompt or repo", () => { + const result = mockDeepLink._invoke( + "new", + new URLSearchParams("source=linear&issue=ENG-123"), + ); + expect(result).toBe(false); + }); + + it("round-trips encoded newlines and unicode in prompt", () => { + const listener = vi.fn(); + service.on(NewTaskLinkEvent.Action, listener); + + const prompt = "Fix login 🐛\n\nSee comments — café"; + const result = mockDeepLink._invoke( + "new", + new URLSearchParams(`prompt=${encodeURIComponent(prompt)}`), + ); + + expect(result).toBe(true); + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ prompt }), + ); + }); }); describe("handlePlan", () => { diff --git a/packages/core/src/links/new-task-link.ts b/packages/core/src/links/new-task-link.ts index 510f4937c0..24b9390fb5 100644 --- a/packages/core/src/links/new-task-link.ts +++ b/packages/core/src/links/new-task-link.ts @@ -59,12 +59,14 @@ export class NewTaskLinkService extends TypedEventEmitter { repo: params.get("repo") ?? undefined, mode: params.get("mode") ?? undefined, model: params.get("model") ?? undefined, + source: params.get("source") ?? undefined, }; } private handleNew(params: URLSearchParams): boolean { const shared = this.extractSharedParams(params); const prompt = params.get("prompt") ?? undefined; + const issueIdentifier = params.get("issue") ?? undefined; if (!prompt && !shared.repo) { this.log.warn("New task link requires at least prompt or repo"); @@ -74,12 +76,16 @@ export class NewTaskLinkService extends TypedEventEmitter { const payload: NewTaskLinkPayload = { action: "new", prompt, + issueIdentifier, ...shared, }; this.log.info("Handling new task link", { hasPrompt: !!prompt, + promptLength: prompt?.length, repo: shared.repo, + source: shared.source, + hasIssue: !!issueIdentifier, }); return this.emitOrQueue(payload); } diff --git a/packages/shared/src/analytics-events.ts b/packages/shared/src/analytics-events.ts index 984314b2b1..2c8649e8f3 100644 --- a/packages/shared/src/analytics-events.ts +++ b/packages/shared/src/analytics-events.ts @@ -349,12 +349,16 @@ export interface DeepLinkNewTaskProperties { has_repo: boolean; mode?: string; model?: string; + source?: string; + issue_identifier?: string; + prompt_length_chars?: number; } export interface DeepLinkPlanProperties { has_repo: boolean; mode?: string; model?: string; + source?: string; plan_length_chars: number; } @@ -364,6 +368,7 @@ export interface DeepLinkIssueProperties { issue_number: number; mode?: string; model?: string; + source?: string; } export interface DeepLinkIssueFailedProperties { diff --git a/packages/shared/src/deep-links.ts b/packages/shared/src/deep-links.ts index 5a4801af4b..33d5b1cf05 100644 --- a/packages/shared/src/deep-links.ts +++ b/packages/shared/src/deep-links.ts @@ -102,10 +102,17 @@ export interface NewTaskSharedParams { repo?: string; mode?: string; model?: string; + /** Attribution for links launched by an external tool, e.g. "linear". */ + source?: string; } export type NewTaskLinkPayload = - | ({ action: "new"; prompt?: string } & NewTaskSharedParams) + | ({ + action: "new"; + prompt?: string; + /** External issue identifier carried for attribution, e.g. a Linear "ENG-123". */ + issueIdentifier?: string; + } & NewTaskSharedParams) | ({ action: "plan"; plan: string } & NewTaskSharedParams) | ({ action: "issue"; diff --git a/packages/ui/src/features/settings/CopyableCommand.tsx b/packages/ui/src/features/settings/CopyableCommand.tsx new file mode 100644 index 0000000000..0675256185 --- /dev/null +++ b/packages/ui/src/features/settings/CopyableCommand.tsx @@ -0,0 +1,34 @@ +import { Check, Copy } from "@phosphor-icons/react"; +import { Tooltip } from "@posthog/ui/primitives/Tooltip"; +import { Flex, IconButton, Text } from "@radix-ui/themes"; +import { useCallback, useState } from "react"; + +export function CopyableCommand({ command }: { command: string }) { + const [copied, setCopied] = useState(false); + + const handleCopy = useCallback(() => { + navigator.clipboard.writeText(command); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }, [command]); + + return ( + + {command} + + + {copied ? : } + + + + ); +} diff --git a/packages/ui/src/features/settings/components/SettingsPanel.tsx b/packages/ui/src/features/settings/components/SettingsPanel.tsx index 87f698a212..bf421a5b0f 100644 --- a/packages/ui/src/features/settings/components/SettingsPanel.tsx +++ b/packages/ui/src/features/settings/components/SettingsPanel.tsx @@ -10,6 +10,7 @@ import { Folder, GearSix, GithubLogo, + Kanban, Keyboard, Palette, SignOut, @@ -33,6 +34,7 @@ import { DiscordSettings } from "@posthog/ui/features/settings/sections/DiscordS import { EnvironmentsSettings } from "@posthog/ui/features/settings/sections/environments/EnvironmentsSettings"; import { GeneralSettings } from "@posthog/ui/features/settings/sections/GeneralSettings"; import { GitHubSettings } from "@posthog/ui/features/settings/sections/GitHubSettings"; +import { LinearSettings } from "@posthog/ui/features/settings/sections/LinearSettings"; import { NotificationsSettings } from "@posthog/ui/features/settings/sections/NotificationsSettings"; import { PersonalizationSettings } from "@posthog/ui/features/settings/sections/PersonalizationSettings"; import { PlanUsageSettings } from "@posthog/ui/features/settings/sections/PlanUsageSettings"; @@ -76,6 +78,7 @@ const SIDEBAR_ITEMS: SidebarItem[] = [ { id: "shortcuts", label: "Shortcuts", icon: }, { id: "github", label: "GitHub", icon: }, { id: "slack", label: "Slack", icon: }, + { id: "linear", label: "Linear", icon: }, { id: "discord", label: "Discord", icon: }, { id: "signals", label: "Self-driving", icon: }, { id: "updates", label: "Updates", icon: }, @@ -92,6 +95,7 @@ const LOCAL_ONLY_CATEGORIES: ReadonlySet = new Set([ "claude-code", "discord", "updates", + "linear", ]); const CATEGORY_TITLES: Record = { @@ -108,6 +112,7 @@ const CATEGORY_TITLES: Record = { shortcuts: "Shortcuts", github: "GitHub", slack: "Slack integration", + linear: "Linear", discord: "Discord", signals: "Self-driving", updates: "Updates", @@ -128,6 +133,7 @@ const CATEGORY_COMPONENTS: Record = { shortcuts: ShortcutsSettings, github: GitHubSettings, slack: SlackSettings, + linear: LinearSettings, discord: DiscordSettings, // Slack notification config lives in the dedicated Slack section; the Signals // section links out to it rather than duplicating the controls. diff --git a/packages/ui/src/features/settings/sections/ClaudeCodeSettings.tsx b/packages/ui/src/features/settings/sections/ClaudeCodeSettings.tsx index 92f62bbdce..079839602b 100644 --- a/packages/ui/src/features/settings/sections/ClaudeCodeSettings.tsx +++ b/packages/ui/src/features/settings/sections/ClaudeCodeSettings.tsx @@ -1,52 +1,21 @@ -import { ArrowSquareOut, Check, Copy, Warning } from "@phosphor-icons/react"; +import { ArrowSquareOut, Warning } from "@phosphor-icons/react"; import { ANALYTICS_EVENTS } from "@posthog/shared"; +import { CopyableCommand } from "@posthog/ui/features/settings/CopyableCommand"; import { SettingRow } from "@posthog/ui/features/settings/SettingRow"; import { PermissionsSettings } from "@posthog/ui/features/settings/sections/PermissionsSettings"; import { useSettingsStore } from "@posthog/ui/features/settings/settingsStore"; -import { Tooltip } from "@posthog/ui/primitives/Tooltip"; import { track } from "@posthog/ui/shell/analytics"; import { AlertDialog, Button, Callout, Flex, - IconButton, Link, Switch, Text, } from "@radix-ui/themes"; import { useCallback, useState } from "react"; -function CopyableCommand({ command }: { command: string }) { - const [copied, setCopied] = useState(false); - - const handleCopy = useCallback(() => { - navigator.clipboard.writeText(command); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - }, [command]); - - return ( - - {command} - - - {copied ? : } - - - - ); -} - function SettingDescription({ text, docsUrl, diff --git a/packages/ui/src/features/settings/sections/LinearSettings.tsx b/packages/ui/src/features/settings/sections/LinearSettings.tsx new file mode 100644 index 0000000000..9daf40624a --- /dev/null +++ b/packages/ui/src/features/settings/sections/LinearSettings.tsx @@ -0,0 +1,20 @@ +import { getDeeplinkProtocol } from "@posthog/shared"; +import { CopyableCommand } from "@posthog/ui/features/settings/CopyableCommand"; +import { SettingRow } from "@posthog/ui/features/settings/SettingRow"; +import { Flex } from "@radix-ui/themes"; + +const DEEPLINK_TEMPLATE = `${getDeeplinkProtocol(import.meta.env.DEV)}://new?repo=OWNER/REPO&source=linear&issue={{issue.identifier}}&prompt={{context}}`; + +export function LinearSettings() { + return ( + + + + + + ); +} diff --git a/packages/ui/src/features/settings/types.ts b/packages/ui/src/features/settings/types.ts index 325494103b..a8f274b024 100644 --- a/packages/ui/src/features/settings/types.ts +++ b/packages/ui/src/features/settings/types.ts @@ -12,6 +12,7 @@ export type SettingsCategory = | "shortcuts" | "github" | "slack" + | "linear" | "signals" | "updates" | "advanced" @@ -31,6 +32,7 @@ export const SETTINGS_CATEGORIES: readonly SettingsCategory[] = [ "shortcuts", "github", "slack", + "linear", "signals", "updates", "advanced",