mirror of
https://github.com/outline/outline.git
synced 2026-06-13 11:25:03 +03:00
Add Linear project unfurling support (#11525)
* Initial plan * Add Project type and unfurl implementation for Linear projects Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com> * Fix linter issues - remove unused import and rename unused parameter Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com> * Make actor parameter optional in unfurl helper methods Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com> * fix: Resolve type errors in Linear project unfurl Use project.status (ProjectStatus object) instead of the deprecated project.state (string) field, add satisfies constraint, and fix exhaustive return in unfurl switch. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Determine mention type * styling --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com> Co-authored-by: Tom Moor <tom@getoutline.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
+121
-49
@@ -8,7 +8,11 @@ import { IntegrationService, UnfurlResourceType } from "@shared/types";
|
||||
import Logger from "@server/logging/Logger";
|
||||
import { Integration } from "@server/models";
|
||||
import type User from "@server/models/User";
|
||||
import type { UnfurlIssueOrPR, UnfurlSignature } from "@server/types";
|
||||
import type {
|
||||
UnfurlIssueOrPR,
|
||||
UnfurlProject,
|
||||
UnfurlSignature,
|
||||
} from "@server/types";
|
||||
import { LinearUtils } from "../shared/LinearUtils";
|
||||
import env from "./env";
|
||||
import { Minute } from "@shared/utils/time";
|
||||
@@ -26,7 +30,10 @@ const AccessTokenResponseSchema = z.object({
|
||||
});
|
||||
|
||||
export class Linear {
|
||||
private static supportedUnfurls = [UnfurlResourceType.Issue];
|
||||
private static supportedUnfurls = [
|
||||
UnfurlResourceType.Issue,
|
||||
UnfurlResourceType.Project,
|
||||
];
|
||||
|
||||
static async oauthAccess(code: string) {
|
||||
const headers = {
|
||||
@@ -103,7 +110,7 @@ export class Linear {
|
||||
*
|
||||
* @param url Linear resource url
|
||||
* @param actor User attempting to unfurl resource url
|
||||
* @returns An object containing resource details e.g, a Linear issue details
|
||||
* @returns An object containing resource details e.g, a Linear issue or project details
|
||||
*/
|
||||
static unfurl: UnfurlSignature = async (url: string, actor?: User) => {
|
||||
const resource = Linear.parseUrl(url);
|
||||
@@ -138,60 +145,125 @@ export class Linear {
|
||||
);
|
||||
|
||||
const client = new LinearClient({ accessToken });
|
||||
const issue = await client.issue(resource.id);
|
||||
|
||||
if (!issue) {
|
||||
return { error: "Resource not found" };
|
||||
switch (resource.type) {
|
||||
case UnfurlResourceType.Issue:
|
||||
return Linear.unfurlIssue(client, resource.id, actor);
|
||||
case UnfurlResourceType.Project:
|
||||
return Linear.unfurlProject(client, resource.id, actor);
|
||||
default:
|
||||
return;
|
||||
}
|
||||
|
||||
const [author, state, labels] = await Promise.all([
|
||||
issue.creator,
|
||||
issue.state,
|
||||
issue.paginate(issue.labels, {}),
|
||||
]);
|
||||
|
||||
if (!state || !labels) {
|
||||
return { error: "Failed to fetch auxiliary data from Linear" };
|
||||
}
|
||||
|
||||
const completionPercentage = await Linear.completionPercentage(
|
||||
client,
|
||||
issue,
|
||||
state
|
||||
);
|
||||
|
||||
return {
|
||||
type: UnfurlResourceType.Issue,
|
||||
url: issue.url,
|
||||
id: issue.identifier,
|
||||
title: issue.title,
|
||||
description: issue.description ?? null,
|
||||
author: {
|
||||
name:
|
||||
author?.name ??
|
||||
issue.botActor?.userDisplayName ??
|
||||
issue.botActor?.name ??
|
||||
t("Unknown", opts(actor)),
|
||||
avatarUrl: author?.avatarUrl ?? "",
|
||||
},
|
||||
labels: labels.map((label) => ({
|
||||
name: label.name,
|
||||
color: label.color,
|
||||
})),
|
||||
state: {
|
||||
type: state.type,
|
||||
name: state.name,
|
||||
color: state.color,
|
||||
completionPercentage,
|
||||
},
|
||||
createdAt: issue.createdAt.toISOString(),
|
||||
} satisfies UnfurlIssueOrPR;
|
||||
} catch (err) {
|
||||
Logger.warn("Failed to fetch resource from Linear", err);
|
||||
return { error: err.message || "Unknown error" };
|
||||
}
|
||||
};
|
||||
|
||||
private static async unfurlIssue(
|
||||
client: LinearClient,
|
||||
id: string,
|
||||
actor: User | undefined
|
||||
) {
|
||||
const issue = await client.issue(id);
|
||||
|
||||
if (!issue) {
|
||||
return { error: "Resource not found" };
|
||||
}
|
||||
|
||||
const [author, state, labels] = await Promise.all([
|
||||
issue.creator,
|
||||
issue.state,
|
||||
issue.paginate(issue.labels, {}),
|
||||
]);
|
||||
|
||||
if (!state || !labels) {
|
||||
return { error: "Failed to fetch auxiliary data from Linear" };
|
||||
}
|
||||
|
||||
const completionPercentage = await Linear.completionPercentage(
|
||||
client,
|
||||
issue,
|
||||
state
|
||||
);
|
||||
|
||||
return {
|
||||
type: UnfurlResourceType.Issue,
|
||||
url: issue.url,
|
||||
id: issue.identifier,
|
||||
title: issue.title,
|
||||
description: issue.description ?? null,
|
||||
author: {
|
||||
name:
|
||||
author?.name ??
|
||||
issue.botActor?.userDisplayName ??
|
||||
issue.botActor?.name ??
|
||||
t("Unknown", opts(actor)),
|
||||
avatarUrl: author?.avatarUrl ?? "",
|
||||
},
|
||||
labels: labels.map((label) => ({
|
||||
name: label.name,
|
||||
color: label.color,
|
||||
})),
|
||||
state: {
|
||||
type: state.type,
|
||||
name: state.name,
|
||||
color: state.color,
|
||||
completionPercentage,
|
||||
},
|
||||
createdAt: issue.createdAt.toISOString(),
|
||||
} satisfies UnfurlIssueOrPR;
|
||||
}
|
||||
|
||||
private static async unfurlProject(
|
||||
client: LinearClient,
|
||||
id: string,
|
||||
_actor: User | undefined
|
||||
) {
|
||||
const project = await client.project(id);
|
||||
|
||||
if (!project) {
|
||||
return { error: "Resource not found" };
|
||||
}
|
||||
|
||||
const [lead, status, labels] = await Promise.all([
|
||||
project.lead,
|
||||
project.status,
|
||||
project.paginate(project.labels, {}),
|
||||
]);
|
||||
|
||||
if (!status || !labels) {
|
||||
return { error: "Failed to fetch auxiliary data from Linear" };
|
||||
}
|
||||
|
||||
return {
|
||||
type: UnfurlResourceType.Project,
|
||||
url: project.url,
|
||||
id: project.id,
|
||||
name: project.name,
|
||||
color: project.color ?? status.color,
|
||||
description: project.description ?? null,
|
||||
lead: lead
|
||||
? {
|
||||
name: lead.name,
|
||||
avatarUrl: lead.avatarUrl ?? "",
|
||||
}
|
||||
: null,
|
||||
state: {
|
||||
type: status.type,
|
||||
name: status.name,
|
||||
color: status.color,
|
||||
},
|
||||
labels: labels.map((label) => ({
|
||||
name: label.name,
|
||||
color: label.color,
|
||||
})),
|
||||
progress: project.progress,
|
||||
createdAt: project.createdAt.toISOString(),
|
||||
targetDate: project.targetDate ?? null,
|
||||
} satisfies UnfurlProject;
|
||||
}
|
||||
|
||||
private static async completionPercentage(
|
||||
client: LinearClient,
|
||||
issue: Issue,
|
||||
|
||||
Reference in New Issue
Block a user