From 396ceb34bb4d1f296bd3b83698d2e3500e86be33 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 16:55:40 +0000 Subject: [PATCH] feat: Unfurl Linear review (Diffs) urls as pull request mentions Linear's new Diffs feature exposes pull request reviews at linear.review/{owner}/{repo}/pull/{number}, mirroring GitHub pull request urls. Pasting such a url into a document can now be converted to a mention that renders with pull request state, matching the GitHub plugin. Linear's public API does not expose pull requests directly yet, so the unfurl reads the pull request data Linear syncs onto the attachments of linked issues via the attachmentsForURL query. https://claude.ai/code/session_01F9cLTRp6WsFHDxGtd5euGW --- app/utils/mention.ts | 12 ++ plugins/linear/client/index.tsx | 2 +- plugins/linear/server/linear.test.ts | 66 +++++++++++ plugins/linear/server/linear.ts | 158 +++++++++++++++++++++------ plugins/linear/shared/LinearUtils.ts | 28 +++++ 5 files changed, 229 insertions(+), 37 deletions(-) create mode 100644 plugins/linear/server/linear.test.ts diff --git a/app/utils/mention.ts b/app/utils/mention.ts index 8e6c4e2b1f..938cc4ca7a 100644 --- a/app/utils/mention.ts +++ b/app/utils/mention.ts @@ -30,6 +30,12 @@ export const isURLMentionable = ({ } case IntegrationService.Linear: { + // Review (Diffs) urls mirror GitHub pull request urls and don't include + // the workspace key, so any installed workspace can resolve them. + if (hostname === "linear.review") { + return true; + } + const pathParts = pathname.split("/"); const settings = integration.settings as IntegrationSettings; @@ -84,6 +90,12 @@ export const determineMentionType = ({ } case IntegrationService.Linear: { + if (url.hostname === "linear.review") { + return pathParts[3] === "pull" && pathParts[4] + ? MentionType.PullRequest + : undefined; + } + const type = pathParts[2]; return type === "issue" ? MentionType.Issue diff --git a/plugins/linear/client/index.tsx b/plugins/linear/client/index.tsx index bf1bb39e14..235fc999c5 100644 --- a/plugins/linear/client/index.tsx +++ b/plugins/linear/client/index.tsx @@ -11,7 +11,7 @@ PluginManager.add([ group: "Integrations", icon: Icon, description: - "Connect your Linear account to Outline to enable rich, realtime, issue previews inside documents.", + "Connect your Linear account to Outline to enable rich, realtime, issue and pull request review previews inside documents.", component: createLazyComponent(() => import("./Settings")), }, }, diff --git a/plugins/linear/server/linear.test.ts b/plugins/linear/server/linear.test.ts new file mode 100644 index 0000000000..e7633369e3 --- /dev/null +++ b/plugins/linear/server/linear.test.ts @@ -0,0 +1,66 @@ +import { UnfurlResourceType } from "@shared/types"; +import { Linear } from "./linear"; + +describe("Linear.parseUrl", () => { + it("should parse an issue url", () => { + expect( + Linear.parseUrl("https://linear.app/acme/issue/ACM-123/fix-the-thing") + ).toEqual({ + workspaceKey: "acme", + type: UnfurlResourceType.Issue, + id: "ACM-123", + name: "fix-the-thing", + }); + }); + + it("should parse a project url", () => { + expect( + Linear.parseUrl("https://linear.app/acme/project/my-project-abc123") + ).toEqual({ + workspaceKey: "acme", + type: UnfurlResourceType.Project, + id: "my-project-abc123", + name: undefined, + }); + }); + + it("should parse a review url", () => { + expect( + Linear.parseUrl("https://linear.review/outline/outline/pull/1234") + ).toEqual({ + type: UnfurlResourceType.PR, + owner: "outline", + repo: "outline", + number: 1234, + }); + }); + + it("should not parse a review url with an invalid pull request number", () => { + expect( + Linear.parseUrl("https://linear.review/outline/outline/pull/abc") + ).toBeUndefined(); + }); + + it("should not parse a review url missing path segments", () => { + expect(Linear.parseUrl("https://linear.review/outline")).toBeUndefined(); + expect( + Linear.parseUrl("https://linear.review/outline/outline/issues/123") + ).toBeUndefined(); + }); + + it("should not parse an in-app review url", () => { + expect( + Linear.parseUrl("https://linear.app/acme/review/fix-the-thing-abc123") + ).toBeUndefined(); + }); + + it("should not parse urls from other hosts", () => { + expect( + Linear.parseUrl("https://github.com/outline/outline/pull/1234") + ).toBeUndefined(); + }); + + it("should not parse invalid urls", () => { + expect(Linear.parseUrl("not a url")).toBeUndefined(); + }); +}); diff --git a/plugins/linear/server/linear.ts b/plugins/linear/server/linear.ts index 85a8376430..7bf22da57a 100644 --- a/plugins/linear/server/linear.ts +++ b/plugins/linear/server/linear.ts @@ -29,12 +29,31 @@ const AccessTokenResponseSchema = z.object({ scope: z.string(), }); -export class Linear { - private static supportedUnfurls = [ - UnfurlResourceType.Issue, - UnfurlResourceType.Project, - ]; +// Pull request data Linear syncs onto issue attachments through its GitHub integration. +const PullRequestAttachmentMetadataSchema = z.object({ + status: z.string().optional(), + draft: z.boolean().optional(), + title: z.string().optional(), + userLogin: z.string().optional(), +}); +type ParsedIssueOrProject = { + type: UnfurlResourceType.Issue | UnfurlResourceType.Project; + workspaceKey: string; + id: string; + name?: string; +}; + +type ParsedPullRequest = { + type: UnfurlResourceType.PR; + owner: string; + repo: string; + number: number; +}; + +type LinearResource = ParsedIssueOrProject | ParsedPullRequest; + +export class Linear { static async oauthAccess(code: string) { const headers = { "Content-Type": "application/x-www-form-urlencoded", @@ -106,6 +125,53 @@ export class Linear { return client.organization; } + /** + * Parses a given URL and returns resource identifiers for Linear specific URLs. + * + * @param url URL to parse + * @returns An object identifying a Linear issue, project or pull request review, or undefined. + */ + public static parseUrl(url: string): LinearResource | undefined { + try { + const { hostname, pathname } = new URL(url); + const parts = pathname.split("/"); + + // Review (Diffs) urls mirror GitHub pull request urls, e.g. + // https://linear.review/{owner}/{repo}/pull/{number} + if (hostname === LinearUtils.reviewHost) { + const [, owner, repo, type, id] = parts; + const number = Number(id); + + if (!owner || !repo || type !== "pull" || isNaN(number)) { + return; + } + + return { type: UnfurlResourceType.PR, owner, repo, number }; + } + + if (hostname !== "linear.app") { + return; + } + + const workspaceKey = parts[1]; + const type = parts[2] ? (parts[2] as UnfurlResourceType) : undefined; + const id = parts[3]; + const name = parts[4]; + + if ( + type !== UnfurlResourceType.Issue && + type !== UnfurlResourceType.Project + ) { + return; + } + + return { workspaceKey, type, id, name }; + } catch (_err) { + // Invalid URL format + return; + } + } + /** * * @param url Linear resource url @@ -135,7 +201,9 @@ export class Linear { // Prefer integration with matching workspaceKey, otherwise pick the first one const integration = integrations.find( - (int) => int.settings.linear?.workspace.key === resource.workspaceKey + (int) => + "workspaceKey" in resource && + int.settings.linear?.workspace.key === resource.workspaceKey ) ?? integrations[0]; try { @@ -151,6 +219,8 @@ export class Linear { return await Linear.unfurlIssue(client, resource.id, actor); case UnfurlResourceType.Project: return await Linear.unfurlProject(client, resource.id, actor); + case UnfurlResourceType.PR: + return await Linear.unfurlPullRequest(client, resource, url); default: return; } @@ -264,6 +334,52 @@ export class Linear { } satisfies UnfurlProject; } + /** + * Unfurls a Linear review (Diffs) url. Linear's public API does not expose pull + * requests directly, so this reads the pull request data Linear syncs onto the + * attachments of linked issues, keyed by the GitHub url the review mirrors. + */ + private static async unfurlPullRequest( + client: LinearClient, + resource: ParsedPullRequest, + url: string + ) { + const githubUrl = `https://github.com/${resource.owner}/${resource.repo}/pull/${resource.number}`; + const attachments = await client.attachmentsForURL(githubUrl); + const attachment = attachments.nodes[0]; + + if (!attachment) { + return { error: "Resource not found" }; + } + + const parsed = PullRequestAttachmentMetadataSchema.safeParse( + attachment.metadata + ); + const metadata: z.infer = + parsed.success ? parsed.data : {}; + const status = metadata.status ?? "open"; + + return { + type: UnfurlResourceType.PR, + url, + id: `#${resource.number}`, + title: metadata.title ?? attachment.title, + description: null, + author: { + name: metadata.userLogin ?? "", + avatarUrl: metadata.userLogin + ? `https://github.com/${metadata.userLogin}.png` + : "", + }, + state: { + name: status === "merged" || status === "closed" ? status : "open", + color: LinearUtils.getColorForPullRequestStatus(status), + draft: metadata.draft ?? status === "draft", + }, + createdAt: attachment.createdAt.toISOString(), + } satisfies UnfurlIssueOrPR; + } + private static async completionPercentage( client: LinearClient, issue: Issue, @@ -309,34 +425,4 @@ export class Linear { return (idx + 1) / (states.length + 1); // add 1 to states for the "done" state. } } - - /** - * Parses a given URL and returns resource identifiers for Linear specific URLs - * - * @param url URL to parse - * @returns An object containing resource identifiers - `workspaceKey`, `type`, `id` and `name`. - */ - private static parseUrl(url: string) { - try { - const { hostname, pathname } = new URL(url); - if (hostname !== "linear.app") { - return; - } - - const parts = pathname.split("/"); - const workspaceKey = parts[1]; - const type = parts[2] ? (parts[2] as UnfurlResourceType) : undefined; - const id = parts[3]; - const name = parts[4]; - - if (!type || !Linear.supportedUnfurls.includes(type)) { - return; - } - - return { workspaceKey, type, id, name }; - } catch (_err) { - // Invalid URL format - return; - } - } } diff --git a/plugins/linear/shared/LinearUtils.ts b/plugins/linear/shared/LinearUtils.ts index 6c2e3f1b13..6af4683173 100644 --- a/plugins/linear/shared/LinearUtils.ts +++ b/plugins/linear/shared/LinearUtils.ts @@ -14,6 +14,10 @@ export class LinearUtils { public static tokenUrl = "https://api.linear.app/oauth/token"; public static revokeUrl = "https://api.linear.app/oauth/revoke"; + + /** Hostname Linear uses for review (Diffs) urls, mirroring GitHub pull request urls. */ + public static reviewHost = "linear.review"; + private static authBaseUrl = "https://linear.app/oauth/authorize"; private static settingsUrl = integrationSettingsPath("linear"); @@ -45,6 +49,30 @@ export class LinearUtils { : `${baseUrl}/api/linear.callback`; } + /** + * Returns a color representing the given pull request status. + * + * @param status Pull request status synced from Linear, e.g. "open" or "merged". + * @returns a hex color string. + */ + public static getColorForPullRequestStatus(status: string) { + switch (status) { + case "open": + case "reopened": + case "approved": + return "#238636"; + case "inReview": + return "#d29922"; + case "merged": + return "#8250df"; + case "closed": + return "#f85149"; + case "draft": + default: + return "#848d97"; + } + } + static authUrl({ state }: { state: OAuthState }) { const params = { client_id: env.LINEAR_CLIENT_ID,