mirror of
https://github.com/outline/outline.git
synced 2026-06-13 03:14:59 +03:00
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
This commit is contained in:
@@ -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<IntegrationType.Embed>;
|
||||
@@ -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
|
||||
|
||||
@@ -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")),
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
+122
-36
@@ -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<typeof PullRequestAttachmentMetadataSchema> =
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user