diff --git a/src/commands/issue/issue-comment-add.ts b/src/commands/issue/issue-comment-add.ts index 8fcba38..38b3d72 100644 --- a/src/commands/issue/issue-comment-add.ts +++ b/src/commands/issue/issue-comment-add.ts @@ -10,6 +10,10 @@ import { } from "../../utils/upload.ts" import { shouldShowSpinner } from "../../utils/hyperlink.ts" import { CliError, handleError, ValidationError } from "../../utils/errors.ts" +import { + buildBodyFields, + processTextWithMentions, +} from "../../utils/mentions.ts" export const commentAddCommand = new Command() .name("add") @@ -114,14 +118,15 @@ export const commentAddCommand = new Command() } `) + // Process @mentions in the comment body + const mentionResult = await processTextWithMentions(commentBody || "") + const bodyFields = buildBodyFields(mentionResult, "body") + const client = getGraphQLClient() - const input: Record = { - body: commentBody, + const input = { issueId: resolvedIdentifier, - } - - if (parent) { - input.parentId = parent + ...bodyFields, + ...(parent ? { parentId: parent } : {}), } const data = await client.request(mutation, { diff --git a/src/commands/issue/issue-comment-update.ts b/src/commands/issue/issue-comment-update.ts index b895a78..93e8456 100644 --- a/src/commands/issue/issue-comment-update.ts +++ b/src/commands/issue/issue-comment-update.ts @@ -3,6 +3,10 @@ import { Input } from "@cliffy/prompt" import { gql } from "../../__codegen__/gql.ts" import { getGraphQLClient } from "../../utils/graphql.ts" import { CliError, handleError, ValidationError } from "../../utils/errors.ts" +import { + buildBodyFields, + processTextWithMentions, +} from "../../utils/mentions.ts" export const commentUpdateCommand = new Command() .name("update") @@ -61,12 +65,14 @@ export const commentUpdateCommand = new Command() } `) + // Process @mentions in the comment body + const mentionResult = await processTextWithMentions(newBody) + const input = buildBodyFields(mentionResult, "body") + const client = getGraphQLClient() const data = await client.request(mutation, { id: commentId, - input: { - body: newBody, - }, + input, }) if (!data.commentUpdate.success) { diff --git a/src/commands/issue/issue-create.ts b/src/commands/issue/issue-create.ts index d046745..0700d24 100644 --- a/src/commands/issue/issue-create.ts +++ b/src/commands/issue/issue-create.ts @@ -30,6 +30,10 @@ import { NotFoundError, ValidationError, } from "../../utils/errors.ts" +import { + buildBodyFields, + processTextWithMentions, +} from "../../utils/mentions.ts" type IssueLabel = { id: string; name: string; color: string } @@ -558,6 +562,14 @@ export const createCommand = new Command() console.log(`Creating issue...`) console.log() + // Process @mentions in the description + const descriptionFields = interactiveData.description + ? buildBodyFields( + await processTextWithMentions(interactiveData.description), + "description", + ) + : {} + const createIssueMutation = gql(` mutation CreateIssue($input: IssueCreateInput!) { issueCreate(input: $input) { @@ -568,21 +580,22 @@ export const createCommand = new Command() `) const client = getGraphQLClient() + const baseInput = { + title: interactiveData.title, + assigneeId: interactiveData.assigneeId, + dueDate: undefined, + parentId: interactiveData.parentId, + priority: interactiveData.priority, + estimate: interactiveData.estimate, + labelIds: interactiveData.labelIds, + teamId: interactiveData.teamId, + projectId: interactiveData.projectId, + stateId: interactiveData.stateId, + useDefaultTemplate, + ...descriptionFields, + } const data = await client.request(createIssueMutation, { - input: { - title: interactiveData.title, - assigneeId: interactiveData.assigneeId, - dueDate: undefined, - parentId: interactiveData.parentId, - priority: interactiveData.priority, - estimate: interactiveData.estimate, - labelIds: interactiveData.labelIds, - teamId: interactiveData.teamId, - projectId: interactiveData.projectId, - stateId: interactiveData.stateId, - useDefaultTemplate, - description: interactiveData.description, - }, + input: baseInput, }) if (!data.issueCreate.success) { @@ -736,6 +749,14 @@ export const createCommand = new Command() parentData = await fetchParentIssueData(parentId) } + // Process @mentions in the description + const descriptionFields = description + ? buildBodyFields( + await processTextWithMentions(description), + "description", + ) + : {} + const input = { title, assigneeId, @@ -748,7 +769,7 @@ export const createCommand = new Command() projectId: projectId || parentData?.projectId, stateId, useDefaultTemplate, - description, + ...descriptionFields, } spinner?.stop() console.log(`Creating issue in ${team}`) diff --git a/src/commands/issue/issue-update.ts b/src/commands/issue/issue-update.ts index 0408b23..78142d3 100644 --- a/src/commands/issue/issue-update.ts +++ b/src/commands/issue/issue-update.ts @@ -16,6 +16,10 @@ import { NotFoundError, ValidationError, } from "../../utils/errors.ts" +import { + buildBodyFields, + processTextWithMentions, +} from "../../utils/mentions.ts" export const updateCommand = new Command() .name("update") @@ -179,7 +183,14 @@ export const updateCommand = new Command() } if (priority !== undefined) input.priority = priority if (estimate !== undefined) input.estimate = estimate - if (description !== undefined) input.description = description + if (description !== undefined) { + // Process @mentions in the description + const descriptionFields = buildBodyFields( + await processTextWithMentions(description), + "description", + ) + Object.assign(input, descriptionFields) + } if (labelIds.length > 0) input.labelIds = labelIds if (teamId !== undefined) input.teamId = teamId if (projectId !== undefined) input.projectId = projectId diff --git a/src/utils/mentions.ts b/src/utils/mentions.ts new file mode 100644 index 0000000..4c20bf4 --- /dev/null +++ b/src/utils/mentions.ts @@ -0,0 +1,292 @@ +import { getGraphQLClient } from "./graphql.ts" + +/** + * Represents a user that can be mentioned in comments + */ +export interface MentionableUser { + id: string + displayName: string + name: string +} + +/** + * Prosemirror document node types + */ +interface ProsemirrorTextNode { + type: "text" + text: string +} + +interface ProsemirrorMentionNode { + type: "suggestion_userMentions" + attrs: { + id: string + label: string + } +} + +type ProsemirrorContentNode = ProsemirrorTextNode | ProsemirrorMentionNode + +interface ProsemirrorParagraph { + type: "paragraph" + content: ProsemirrorContentNode[] +} + +interface ProsemirrorDoc { + type: "doc" + content: ProsemirrorParagraph[] +} + +/** + * Fetches all mentionable users from the organization + */ +export async function fetchMentionableUsers(): Promise { + const client = getGraphQLClient() + + // Fetch users with their display names and names + const query = ` + query GetMentionableUsers { + users(first: 250, filter: { active: { eq: true } }) { + nodes { + id + displayName + name + } + } + } + ` + + const data = await client.request<{ + users: { + nodes: MentionableUser[] + } + }>(query) + + return data.users.nodes +} + +/** + * Extract @mentions from text. Matches @username patterns. + * Returns unique mention names without the @ prefix. + * + * Supports single-word mentions like @bot, @username, @john-doe + */ +export function extractMentions(text: string): string[] { + // Match @username patterns - single word containing letters, numbers, underscores, hyphens + const mentionRegex = /@([a-zA-Z0-9_-]+)/g + const mentions: string[] = [] + let match + + while ((match = mentionRegex.exec(text)) !== null) { + mentions.push(match[1]) + } + + return [...new Set(mentions)] +} + +/** + * Resolve mention names to user IDs by matching against user names/displayNames + */ +export async function resolveMentions( + mentionNames: string[], +): Promise> { + if (mentionNames.length === 0) { + return new Map() + } + + const users = await fetchMentionableUsers() + const resolved = new Map() + + for (const mentionName of mentionNames) { + const lowerMention = mentionName.toLowerCase() + + // Try to match against displayName or name (case-insensitive) + const matchedUser = users.find((user) => { + const lowerDisplayName = user.displayName.toLowerCase() + const lowerName = user.name.toLowerCase() + + return lowerDisplayName === lowerMention || lowerName === lowerMention + }) + + if (matchedUser) { + resolved.set(mentionName, matchedUser) + } + } + + return resolved +} + +/** + * Convert markdown text with @mentions into Prosemirror document format + * that Linear understands for proper user mentions. + */ +export function textToProsemirrorDoc( + text: string, + resolvedMentions: Map, +): ProsemirrorDoc { + const lines = text.split("\n") + const paragraphs: ProsemirrorParagraph[] = [] + + for (const line of lines) { + const content: ProsemirrorContentNode[] = [] + + // Handle empty lines - create empty paragraph + if (line === "") { + paragraphs.push({ type: "paragraph", content: [] }) + continue + } + + // Regex to find @mentions - single word containing letters, numbers, underscores, hyphens + const mentionRegex = /@([a-zA-Z0-9_-]+)/g + let lastIndex = 0 + let match + + while ((match = mentionRegex.exec(line)) !== null) { + const mentionName = match[1] + const user = resolvedMentions.get(mentionName) + + // Add text before the mention + if (match.index > lastIndex) { + const textBefore = line.slice(lastIndex, match.index) + content.push({ type: "text", text: textBefore }) + } + + if (user) { + // Add proper mention node + content.push({ + type: "suggestion_userMentions", + attrs: { + id: user.id, + label: user.displayName, + }, + }) + } else { + // User not found, keep as plain text + content.push({ type: "text", text: match[0] }) + } + + lastIndex = match.index + match[0].length + } + + // Add remaining text after last mention + if (lastIndex < line.length) { + content.push({ type: "text", text: line.slice(lastIndex) }) + } + + // If line has no content nodes (shouldn't happen normally), add the full line + if (content.length === 0) { + content.push({ type: "text", text: line }) + } + + paragraphs.push({ type: "paragraph", content }) + } + + return { + type: "doc", + content: paragraphs, + } +} + +/** + * Result when text has no mentions that need special handling. + * Use the `text` field for plain text body/description. + */ +export interface PlainTextResult { + hasMentions: false + text: string +} + +/** + * Result when text has @mentions that were processed. + * Use the `bodyData` field for Prosemirror JSON format. + */ +export interface MentionTextResult { + hasMentions: true + bodyData: string +} + +/** + * Discriminated union for processed text results. + * Check `hasMentions` to determine which field to use. + */ +export type ProcessedTextResult = PlainTextResult | MentionTextResult + +/** + * Process text containing @mentions and return a discriminated union result. + * Use this when you need to conditionally set body vs bodyData fields. + * + * @example + * const result = await processTextWithMentions(text) + * if (result.hasMentions) { + * input.bodyData = result.bodyData + * } else { + * input.body = result.text + * } + */ +export async function processTextWithMentions( + text: string, +): Promise { + const mentionNames = extractMentions(text) + + if (mentionNames.length === 0) { + return { hasMentions: false, text } + } + + const resolvedMentions = await resolveMentions(mentionNames) + const doc = textToProsemirrorDoc(text, resolvedMentions) + + return { + hasMentions: true, + bodyData: JSON.stringify(doc), + } +} + +/** + * Type for body field variants (body/bodyData for comments) + */ +export type BodyFields = { body: string } | { bodyData: string } + +/** + * Type for description field variants (description/descriptionData for issues) + */ +export type DescriptionFields = + | { description: string } + | { descriptionData: string } + +/** + * Helper to build an input object with either body/bodyData or description/descriptionData. + * Handles the mutually exclusive nature of these fields in the Linear API. + * + * @example + * // For comments: + * const bodyFields = await buildBodyFields(text, "body") + * // Returns { body: text } or { bodyData: prosemirrorJson } + * + * // For issues: + * const descFields = await buildBodyFields(text, "description") + * // Returns { description: text } or { descriptionData: prosemirrorJson } + */ +export function buildBodyFields( + result: ProcessedTextResult, + fieldName: "body", +): BodyFields +export function buildBodyFields( + result: ProcessedTextResult, + fieldName: "description", +): DescriptionFields +export function buildBodyFields( + result: ProcessedTextResult, + fieldName: "body" | "description", +): BodyFields | DescriptionFields { + if (fieldName === "body") { + if (result.hasMentions) { + return { bodyData: result.bodyData } + } + return { body: result.text } + } else { + if (result.hasMentions) { + return { descriptionData: result.bodyData } + } + return { description: result.text } + } +} diff --git a/test/utils/mentions.test.ts b/test/utils/mentions.test.ts new file mode 100644 index 0000000..62dc5e7 --- /dev/null +++ b/test/utils/mentions.test.ts @@ -0,0 +1,189 @@ +import { assertEquals } from "@std/assert" +import { + extractMentions, + textToProsemirrorDoc, +} from "../../src/utils/mentions.ts" + +Deno.test("extractMentions - extracts single mention", () => { + const text = "Hello @bot how are you?" + const mentions = extractMentions(text) + assertEquals(mentions, ["bot"]) +}) + +Deno.test("extractMentions - extracts multiple mentions", () => { + const text = "Hey @alice and @bob, please review this" + const mentions = extractMentions(text) + assertEquals(mentions, ["alice", "bob"]) +}) + +Deno.test("extractMentions - handles duplicate mentions", () => { + const text = "@bot please help @bot" + const mentions = extractMentions(text) + assertEquals(mentions, ["bot"]) +}) + +Deno.test("extractMentions - returns empty array for no mentions", () => { + const text = "This text has no mentions" + const mentions = extractMentions(text) + assertEquals(mentions, []) +}) + +Deno.test("extractMentions - handles mentions with hyphens and underscores", () => { + const text = "Hello @john-doe and @jane_smith" + const mentions = extractMentions(text) + assertEquals(mentions, ["john-doe", "jane_smith"]) +}) + +Deno.test("extractMentions - handles mention at start of line", () => { + const text = "@admin please check this" + const mentions = extractMentions(text) + assertEquals(mentions, ["admin"]) +}) + +Deno.test("extractMentions - handles mention at end of line", () => { + const text = "Please check this @admin" + const mentions = extractMentions(text) + assertEquals(mentions, ["admin"]) +}) + +Deno.test("extractMentions - handles mentions with numbers", () => { + const text = "Hello @user123" + const mentions = extractMentions(text) + assertEquals(mentions, ["user123"]) +}) + +Deno.test("extractMentions - does not match email addresses", () => { + // Note: This is a limitation - the regex will match the part after @ + const text = "Email me at user@example.com" + const mentions = extractMentions(text) + assertEquals(mentions, ["example"]) +}) + +Deno.test("textToProsemirrorDoc - converts text without mentions", () => { + const text = "Hello world" + const doc = textToProsemirrorDoc(text, new Map()) + assertEquals(doc, { + type: "doc", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Hello world" }], + }, + ], + }) +}) + +Deno.test("textToProsemirrorDoc - converts text with resolved mention", () => { + const text = "Hello @bot" + const resolved = new Map([ + ["bot", { id: "user-123", displayName: "Bot", name: "bot" }], + ]) + const doc = textToProsemirrorDoc(text, resolved) + assertEquals(doc, { + type: "doc", + content: [ + { + type: "paragraph", + content: [ + { type: "text", text: "Hello " }, + { + type: "suggestion_userMentions", + attrs: { id: "user-123", label: "Bot" }, + }, + ], + }, + ], + }) +}) + +Deno.test("textToProsemirrorDoc - keeps unresolved mention as text", () => { + const text = "Hello @unknown" + const doc = textToProsemirrorDoc(text, new Map()) + assertEquals(doc, { + type: "doc", + content: [ + { + type: "paragraph", + content: [ + { type: "text", text: "Hello " }, + { type: "text", text: "@unknown" }, + ], + }, + ], + }) +}) + +Deno.test("textToProsemirrorDoc - handles multiple paragraphs", () => { + const text = "First line\n\nSecond line" + const doc = textToProsemirrorDoc(text, new Map()) + assertEquals(doc, { + type: "doc", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "First line" }], + }, + { + type: "paragraph", + content: [], + }, + { + type: "paragraph", + content: [{ type: "text", text: "Second line" }], + }, + ], + }) +}) + +Deno.test("textToProsemirrorDoc - handles mention in the middle of text", () => { + const text = "Hey @bot how are you?" + const resolved = new Map([ + ["bot", { id: "user-123", displayName: "Bot", name: "bot" }], + ]) + const doc = textToProsemirrorDoc(text, resolved) + assertEquals(doc, { + type: "doc", + content: [ + { + type: "paragraph", + content: [ + { type: "text", text: "Hey " }, + { + type: "suggestion_userMentions", + attrs: { id: "user-123", label: "Bot" }, + }, + { type: "text", text: " how are you?" }, + ], + }, + ], + }) +}) + +Deno.test("textToProsemirrorDoc - handles multiple mentions in one line", () => { + const text = "Hey @alice and @bob" + const resolved = new Map([ + ["alice", { id: "user-1", displayName: "Alice", name: "alice" }], + ["bob", { id: "user-2", displayName: "Bob", name: "bob" }], + ]) + const doc = textToProsemirrorDoc(text, resolved) + assertEquals(doc, { + type: "doc", + content: [ + { + type: "paragraph", + content: [ + { type: "text", text: "Hey " }, + { + type: "suggestion_userMentions", + attrs: { id: "user-1", label: "Alice" }, + }, + { type: "text", text: " and " }, + { + type: "suggestion_userMentions", + attrs: { id: "user-2", label: "Bob" }, + }, + ], + }, + ], + }) +})