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
+4 -6
View File
@@ -43,9 +43,9 @@ export const Info = styled(StyledText).attrs(() => ({
white-space: nowrap;
`;
export const Description = styled(StyledText)`
export const Description = styled(StyledText)<{ $margin?: string }>`
${sharedVars}
margin-top: 0.5em;
margin-top: ${(props) => props.$margin ?? "0.5em"};
line-height: var(--line-height);
max-height: calc(var(--line-height) * ${NUMBER_OF_LINES});
overflow: hidden;
@@ -64,8 +64,6 @@ export const Label = styled(Text).attrs({ size: "xsmall", weight: "bold" })<{
width: fit-content;
border-radius: 2em;
padding: 1px 8px 1px 20px;
margin-right: 0.5em;
margin-top: 0.5em;
position: relative;
flex-shrink: 0;
@@ -75,8 +73,8 @@ export const Label = styled(Text).attrs({ size: "xsmall", weight: "bold" })<{
left: 8px;
top: 50%;
transform: translateY(-50%);
width: 6px;
height: 6px;
width: 8px;
height: 8px;
border-radius: 50%;
background-color: ${(props) =>
props.color || props.theme.backgroundSecondary};
@@ -17,6 +17,7 @@ import HoverPreviewGroup from "./HoverPreviewGroup";
import HoverPreviewIssue from "./HoverPreviewIssue";
import HoverPreviewLink from "./HoverPreviewLink";
import HoverPreviewMention from "./HoverPreviewMention";
import HoverPreviewProject from "./HoverPreviewProject";
import HoverPreviewPullRequest from "./HoverPreviewPullRequest";
const DELAY_CLOSE = 500;
@@ -192,6 +193,18 @@ const HoverPreviewDesktop = observer(
createdAt={data.createdAt}
state={data.state}
/>
) : data.type === UnfurlResourceType.Project ? (
<HoverPreviewProject
ref={cardRef}
url={data.url}
name={data.name}
color={data.color}
lead={data.lead}
labels={data.labels}
description={data.description}
state={data.state}
targetDate={data.targetDate}
/>
) : (
<HoverPreviewLink
ref={cardRef}
@@ -75,7 +75,7 @@ const HoverPreviewIssue = React.forwardRef(function HoverPreviewIssue_(
</Description>
)}
<Flex wrap>
<Flex wrap gap={6} style={{ marginTop: 8 }}>
{labels.map((label, index) => (
<Label key={index} color={label.color}>
{label.name}
@@ -0,0 +1,144 @@
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import { s } from "@shared/styles";
import { Backticks } from "@shared/components/Backticks";
import Squircle from "@shared/components/Squircle";
import Editor from "~/components/Editor";
import type { UnfurlResourceType, UnfurlResponse } from "@shared/types";
import { Avatar, AvatarSize } from "~/components/Avatar";
import Flex from "~/components/Flex";
import Text from "../Text";
import Time from "../Time";
import {
Preview,
Title,
Card,
CardContent,
Label,
Description,
} from "./Components";
import { richExtensions } from "@shared/editor/nodes";
type Props = Pick<
UnfurlResponse[UnfurlResourceType.Project],
| "url"
| "name"
| "color"
| "lead"
| "labels"
| "state"
| "targetDate"
| "description"
>;
const HoverPreviewProject = React.forwardRef(function HoverPreviewProject_(
{ url, name, color, lead, labels, state, description, targetDate }: Props,
ref: React.Ref<HTMLDivElement>
) {
const { t } = useTranslation();
return (
<Preview as="a" href={url} target="_blank" rel="noopener noreferrer">
<Flex column ref={ref}>
<Card fadeOut={false}>
<CardContent>
<Flex gap={4} column>
<Title>
<StyledSquircle color={color} size={16} />
<span>
<Backticks content={name} />
</span>
</Title>
{description && (
<Description as="div" $margin="0">
<React.Suspense fallback={<div />}>
<Editor
extensions={richExtensions}
defaultValue={description}
embedsDisabled
readOnly
/>
</React.Suspense>
</Description>
)}
<Text type="tertiary" size="small">
{state.name}
</Text>
{(lead || targetDate) && (
<>
<Divider />
{lead && (
<MetadataRow>
<MetadataLabel>{t("Lead")}</MetadataLabel>
<Flex align="center" gap={6}>
<Avatar src={lead.avatarUrl} size={AvatarSize.Toast} />
<Text size="small">{lead.name}</Text>
</Flex>
</MetadataRow>
)}
{targetDate && (
<MetadataRow>
<MetadataLabel>{t("Target date")}</MetadataLabel>
<Text size="small">
<Time dateTime={targetDate} addSuffix />
</Text>
</MetadataRow>
)}
</>
)}
{labels.length > 0 && (
<>
<Divider />
<MetadataRow>
<MetadataLabel>{t("Labels")}</MetadataLabel>
<Flex wrap gap={6}>
{labels.map((label, index) => (
<Label key={index} color={label.color}>
{label.name}
</Label>
))}
</Flex>
</MetadataRow>
</>
)}
</Flex>
</CardContent>
</Card>
</Flex>
</Preview>
);
});
const StyledSquircle = styled(Squircle)`
flex-shrink: 0;
margin-top: 4px;
`;
const Divider = styled.div`
height: 1px;
background: ${s("divider")};
margin: 4px 0;
`;
const MetadataRow = styled.div`
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
min-height: 28px;
`;
const MetadataLabel = styled(Text).attrs({
type: "tertiary",
size: "small",
})`
flex-shrink: 0;
min-width: 80px;
`;
export default HoverPreviewProject;
+5 -1
View File
@@ -70,7 +70,11 @@ export const determineMentionType = ({
case IntegrationService.Linear: {
const type = pathParts[2];
return type === "issue" ? MentionType.Issue : undefined;
return type === "issue"
? MentionType.Issue
: type === "project"
? MentionType.Project
: undefined;
}
case IntegrationService.GitLab: {
+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,
+7
View File
@@ -22,6 +22,8 @@ async function presentUnfurl(
return presentPR(data);
case UnfurlResourceType.Issue:
return presentIssue(data);
case UnfurlResourceType.Project:
return presentProject(data);
default:
return presentURL(data);
}
@@ -111,6 +113,11 @@ const presentIssue = (
): UnfurlResponse[UnfurlResourceType.Issue] =>
data as UnfurlResponse[UnfurlResourceType.Issue]; // this would have been transformed by the unfurl plugin.
const presentProject = (
data: Record<string, any>
): UnfurlResponse[UnfurlResourceType.Project] =>
data as UnfurlResponse[UnfurlResourceType.Project]; // this would have been transformed by the unfurl plugin.
const presentLastOnlineInfoFor = (user: User) => {
const locale = dateLocale(user.language);
+4
View File
@@ -581,18 +581,22 @@ export type UnfurlIssueOrPR =
| UnfurlResponse[UnfurlResourceType.Issue]
| UnfurlResponse[UnfurlResourceType.PR];
export type UnfurlProject = UnfurlResponse[UnfurlResourceType.Project];
export type UnfurlURL = UnfurlResponse[UnfurlResourceType.URL] & {
transformedUnfurl: true;
};
export type Unfurl =
| UnfurlIssueOrPR
| UnfurlProject
| UnfurlURL
| {
type: Exclude<
UnfurlResourceType,
| UnfurlResourceType.Issue
| UnfurlResourceType.PR
| UnfurlResourceType.Project
| UnfurlResourceType.URL
>;
[x: string]: JSONValue;
+74
View File
@@ -29,6 +29,7 @@ import {
import { cn } from "../styles/utils";
import type { ComponentProps } from "../types";
import { toDisplayUrl, cdnPath } from "../../utils/urls";
import Squircle from "../../components/Squircle";
type Attrs = {
className: string;
@@ -348,6 +349,79 @@ export const MentionIssue = observer((props: IssuePrProps) => {
);
});
type ProjectProps = ComponentProps & {
onChangeUnfurl: (unfurl: UnfurlResponse[UnfurlResourceType.Project]) => void;
};
export const MentionProject = observer((props: ProjectProps) => {
const { unfurls } = useStores();
const isMounted = useIsMounted();
const [loaded, setLoaded] = React.useState(false);
const onChangeUnfurl = React.useRef(props.onChangeUnfurl).current;
const { isSelected, node } = props;
const {
className,
unfurl: unfurlAttr,
...attrs
} = getAttributesFromNode(node);
const unfurl = unfurls.get(attrs.href)?.data ?? unfurlAttr;
React.useEffect(() => {
const fetchProject = async () => {
const unfurlModel = await unfurls.fetchUnfurl({ url: attrs.href });
if (!isMounted()) {
return;
}
if (unfurlModel) {
onChangeUnfurl({
...unfurlModel.data,
description: null,
} satisfies UnfurlResponse[UnfurlResourceType.Project]);
}
setLoaded(true);
};
void fetchProject();
}, [unfurls, attrs.href, isMounted, onChangeUnfurl]);
if (!unfurl) {
return !loaded ? (
<MentionLoading className={className} />
) : (
<MentionError className={className} />
);
}
const project = unfurl as UnfurlResponse[UnfurlResourceType.Project];
return (
<a
{...attrs}
className={cn(className, {
"ProseMirror-selectednode": isSelected,
})}
href={attrs.href as string}
target="_blank"
rel="noopener noreferrer nofollow"
>
<Flex align="center" gap={6}>
<Squircle color={project.color} size={12} />
<Flex align="center" gap={4}>
<Text>
<Backticks content={project.name} />
</Text>
<Text type="tertiary">{Math.round(project.progress * 100)}%</Text>
</Flex>
</Flex>
</a>
);
});
export const MentionPullRequest = observer((props: IssuePrProps) => {
const { unfurls } = useStores();
const isMounted = useIsMounted();
+16 -3
View File
@@ -18,6 +18,7 @@ import {
MentionDocument,
MentionGroup,
MentionIssue,
MentionProject,
MentionPullRequest,
MentionURL,
MentionUser,
@@ -110,7 +111,8 @@ export default class Mention extends Node {
"data-actorid": node.attrs.actorId,
"data-url":
node.attrs.type === MentionType.PullRequest ||
node.attrs.type === MentionType.Issue
node.attrs.type === MentionType.Issue ||
node.attrs.type === MentionType.Project
? node.attrs.href
: `mention://${node.attrs.id}/${node.attrs.type}/${node.attrs.modelId}`,
"data-unfurl": JSON.stringify(node.attrs.unfurl),
@@ -145,6 +147,13 @@ export default class Mention extends Node {
onChangeUnfurl={this.handleChangeUnfurl(props)}
/>
);
case MentionType.Project:
return (
<MentionProject
{...props}
onChangeUnfurl={this.handleChangeUnfurl(props)}
/>
);
case MentionType.URL:
return (
<MentionURL
@@ -199,6 +208,7 @@ export default class Mention extends Node {
MentionType.Document,
MentionType.Issue,
MentionType.PullRequest,
MentionType.Project,
];
return {
@@ -215,7 +225,8 @@ export default class Mention extends Node {
if (
mentionType === MentionType.Issue ||
mentionType === MentionType.PullRequest
mentionType === MentionType.PullRequest ||
mentionType === MentionType.Project
) {
link = selection.node.attrs.href;
} else {
@@ -340,7 +351,9 @@ export default class Mention extends Node {
unfurl.type === UnfurlResourceType.PR ||
unfurl.type === UnfurlResourceType.URL
? unfurl.title
: undefined;
: unfurl.type === UnfurlResourceType.Project
? unfurl.name
: undefined;
const overrides: Record<string, unknown> = label ? { label } : {};
overrides.unfurl = unfurl;
@@ -341,6 +341,9 @@
"{{ count }} members": "{{ count }} member",
"{{ count }} members_plural": "{{ count }} members",
"{{authorName}} created <3></3>": "{{authorName}} created <3></3>",
"Lead": "Lead",
"Target date": "Target date",
"Labels": "Labels",
"{{authorName}} opened <3></3>": "{{authorName}} opened <3></3>",
"Search emoji": "Search emoji",
"Search icons": "Search icons",
+34
View File
@@ -97,6 +97,7 @@ export enum MentionType {
Group = "group",
Issue = "issue",
PullRequest = "pull_request",
Project = "project",
URL = "url",
}
@@ -504,6 +505,7 @@ export enum UnfurlResourceType {
Document = "document",
Issue = "issue",
PR = "pull",
Project = "project",
}
export type UnfurlResponse = {
@@ -514,6 +516,8 @@ export type UnfurlResponse = {
url: string;
/** A text title, describing the resource */
title: string;
/** A color representing the resource */
color?: string;
/** A brief description about the resource */
description: string;
/** A URL to a thumbnail image representing the resource */
@@ -609,6 +613,36 @@ export type UnfurlResponse = {
/** Pull Request creation time */
createdAt: string;
};
[UnfurlResourceType.Project]: {
/** The resource type */
type: UnfurlResourceType.Project;
/** Project link */
url: string;
/** Project identifier */
id: string;
/** Project name */
name: string;
/** Project color */
color: string;
/** Project description */
description: string | null;
/** Project lead */
lead: { name: string; avatarUrl: string } | null;
/** Project state */
state: {
name: string;
color: string;
type: string;
};
/** Project labels */
labels: Array<{ name: string; color: string }>;
/** Project progress (0-1) */
progress: number;
/** Project creation time */
createdAt: string;
/** Project target date */
targetDate: string | null;
};
};
export enum QueryNotices {