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,