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:
Copilot
2026-03-14 11:03:04 -04:00
committed by GitHub
parent 350f69e194
commit 36d555f3fb
12 changed files with 426 additions and 60 deletions
+121 -49
View File
@@ -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,