From 2ff7516b3ebe301115445e7b8532b93e3373f09c Mon Sep 17 00:00:00 2001 From: Peter Schilling Date: Wed, 1 Apr 2026 08:03:30 -0700 Subject: [PATCH] feat: show resolved thread metadata in issue view --- skills/linear-cli/references/issue.md | 17 +- src/commands/issue/issue-view.ts | 385 ++++++++++-------- src/utils/linear.ts | 365 +++++++++-------- .../__snapshots__/issue-view.test.ts.snap | 208 ++++++++-- test/commands/issue/issue-view.test.ts | 345 +++++++++++++++- 5 files changed, 939 insertions(+), 381 deletions(-) diff --git a/skills/linear-cli/references/issue.md b/skills/linear-cli/references/issue.md index fde6c2f3..4915540a 100644 --- a/skills/linear-cli/references/issue.md +++ b/skills/linear-cli/references/issue.md @@ -143,14 +143,15 @@ Description: Options: - -h, --help - Show this help. - -w, --workspace - Target workspace (uses credentials) - -w, --web - Open in web browser - -a, --app - Open in Linear.app - --no-comments - Exclude comments from the output - --no-pager - Disable automatic paging for long output - -j, --json - Output issue data as JSON - --no-download - Keep remote URLs instead of downloading files + -h, --help - Show this help. + -w, --workspace - Target workspace (uses credentials) + -w, --web - Open in web browser + -a, --app - Open in Linear.app + --no-comments - Exclude comments from the output + --show-resolved-threads - Include resolved comment threads in the output + --no-pager - Disable automatic paging for long output + -j, --json - Output issue data as JSON + --no-download - Keep remote URLs instead of downloading files ``` ### url diff --git a/src/commands/issue/issue-view.ts b/src/commands/issue/issue-view.ts index 1cbfece9..f90c639a 100644 --- a/src/commands/issue/issue-view.ts +++ b/src/commands/issue/issue-view.ts @@ -1,7 +1,15 @@ import { Command } from "@cliffy/command" import { renderMarkdown } from "@littletof/charmd" import type { Extension } from "@littletof/charmd" -import { fetchIssueDetails, getIssueIdentifier } from "../../utils/linear.ts" +import { + fetchIssueDetails, + fetchIssueDetailsRaw, + getIssueIdentifier, +} from "../../utils/linear.ts" +import type { + FetchedIssueComment, + FetchedIssueDetails, +} from "../../utils/linear.ts" import { openIssuePage } from "../../utils/actions.ts" import { formatRelativeTime, getPriorityDisplay } from "../../utils/display.ts" import { pipeToUserPager, shouldUsePager } from "../../utils/pager.ts" @@ -18,6 +26,7 @@ import remarkStringify from "remark-stringify" import { visit } from "unist-util-visit" import type { Image, Link, Root } from "mdast" import { + hyperlink, shouldEnableHyperlinks, shouldShowSpinner, } from "../../utils/hyperlink.ts" @@ -36,11 +45,16 @@ export const viewCommand = new Command() .option("-w, --web", "Open in web browser") .option("-a, --app", "Open in Linear.app") .option("--no-comments", "Exclude comments from the output") + .option( + "--show-resolved-threads", + "Include resolved comment threads in the output", + ) .option("--no-pager", "Disable automatic paging for long output") .option("-j, --json", "Output issue data as JSON") .option("--no-download", "Keep remote URLs instead of downloading files") .action(async (options, issueId) => { - const { web, app, comments, pager, json, download } = options + const { web, app, comments, showResolvedThreads, pager, json, download } = + options const showComments = comments !== false const usePager = pager !== false @@ -58,22 +72,31 @@ export const viewCommand = new Command() ) } + if (json) { + const issueData = await fetchIssueDetailsRaw(resolvedId, showComments) + console.log(JSON.stringify(issueData, null, 2)) + return + } + const issueData = await fetchIssueDetails( resolvedId, - shouldShowSpinner() && !json, + shouldShowSpinner(), showComments, ) + let issueComments = "comments" in issueData + ? issueData.comments + : undefined + let urlToPath: Map | undefined const shouldDownload = download && getOption("download_images") !== false if (shouldDownload) { urlToPath = await downloadIssueImages( issueData.description, - issueData.comments, + issueComments, ) } - // Download attachments if enabled let attachmentPaths: Map | undefined const shouldDownloadAttachments = shouldDownload && getOption("auto_download_attachments") !== false @@ -87,25 +110,9 @@ export const viewCommand = new Command() ) } - // Handle JSON output - if (json) { - console.log(JSON.stringify(issueData, null, 2)) - return - } - - // Determine hyperlink format (only if enabled and environment supports it) - const configuredHyperlinkFormat = getOption("hyperlink_format") - const hyperlinkFormat = - configuredHyperlinkFormat && shouldEnableHyperlinks() - ? configuredHyperlinkFormat - : undefined - let { description } = issueData - let { comments: issueComments } = issueData - const { title } = issueData if (urlToPath && urlToPath.size > 0) { - // Replace URLs with local paths in markdown if (description) { description = await replaceImageUrls(description, urlToPath) } @@ -120,16 +127,24 @@ export const viewCommand = new Command() } } + const derivedComments = issueComments + ? deriveCommentView(issueComments, showResolvedThreads === true) + : undefined + + const configuredHyperlinkFormat = getOption("hyperlink_format") + const hyperlinkFormat = + configuredHyperlinkFormat && shouldEnableHyperlinks() + ? configuredHyperlinkFormat + : undefined + + const { title } = issueData const { identifier } = issueData - // Build metadata line with state, priority, assignee, project and milestone const metaParts: string[] = [] if (issueData.state) { metaParts.push(`**State:** ${issueData.state.name}`) } - metaParts.push( - `**Priority:** ${getPriorityDisplay(issueData.priority)}`, - ) + metaParts.push(`**Priority:** ${getPriorityDisplay(issueData.priority)}`) const assigneeDisplay = issueData.assignee != null ? `@${issueData.assignee.displayName}` : "Unassigned" @@ -155,8 +170,6 @@ export const viewCommand = new Command() if (Deno.stdout.isTerminal()) { const { columns: terminalWidth } = Deno.consoleSize() - - // Build charmd extensions array const extensions = hyperlinkFormat ? [createHyperlinkExtension(hyperlinkFormat)] : [] @@ -166,13 +179,9 @@ export const viewCommand = new Command() extensions, }) - // Capture all output in an array to count lines const outputLines: string[] = [] - - // Add the rendered markdown lines outputLines.push(...renderedMarkdown.split("\n")) - // Add parent/children hierarchy (rendered as markdown for consistency) const hierarchyMarkdown = formatIssueHierarchyAsMarkdown( issueData.parent, issueData.children, @@ -185,7 +194,6 @@ export const viewCommand = new Command() outputLines.push(...renderedHierarchy.split("\n")) } - // Add attachments section if (issueData.attachments && issueData.attachments.length > 0) { const attachmentsMarkdown = formatAttachmentsAsMarkdown( issueData.attachments, @@ -198,34 +206,48 @@ export const viewCommand = new Command() outputLines.push(...renderedAttachments.split("\n")) } - // Add comments if enabled - if (showComments && issueComments && issueComments.length > 0) { - outputLines.push("") // Empty line before comments - const commentsOutput = captureCommentsForTerminal( - issueComments, - terminalWidth, - extensions, + if ( + showComments && derivedComments && + derivedComments.visibleRootComments.length > 0 + ) { + outputLines.push("") + outputLines.push("## Comments") + outputLines.push("") + outputLines.push( + ...captureCommentsForTerminal( + derivedComments.visibleRootComments, + derivedComments.repliesByRootId, + terminalWidth, + extensions, + ), + ) + } + + if ( + showComments && derivedComments && + derivedComments.hiddenResolvedThreadCount > 0 + ) { + outputLines.push("") + outputLines.push( + formatResolvedThreadsSummary( + derivedComments.hiddenResolvedThreadCount, + ), ) - outputLines.push(...commentsOutput) } const finalOutput = outputLines.join("\n") - // Check if output exceeds terminal height and use pager if necessary if (shouldUsePager(outputLines, usePager)) { await pipeToUserPager(finalOutput) } else { - // Print directly for shorter output console.log(finalOutput) } } else { - // Add parent/children hierarchy markdown += formatIssueHierarchyAsMarkdown( issueData.parent, issueData.children, ) - // Add attachments if (issueData.attachments && issueData.attachments.length > 0) { markdown += formatAttachmentsAsMarkdown( issueData.attachments, @@ -233,9 +255,25 @@ export const viewCommand = new Command() ) } - if (showComments && issueComments && issueComments.length > 0) { + if ( + showComments && derivedComments && + derivedComments.visibleRootComments.length > 0 + ) { markdown += "\n\n## Comments\n\n" - markdown += formatCommentsAsMarkdown(issueComments) + markdown += formatCommentsAsMarkdown( + derivedComments.visibleRootComments, + derivedComments.repliesByRootId, + ) + } + + if ( + showComments && derivedComments && + derivedComments.hiddenResolvedThreadCount > 0 + ) { + markdown += "\n\n" + + formatResolvedThreadsSummary( + derivedComments.hiddenResolvedThreadCount, + ) } console.log(markdown) @@ -245,14 +283,8 @@ export const viewCommand = new Command() } }) -// Helper type for issue hierarchy display -type IssueRef = { - identifier: string - title: string - state: { name: string; color: string } -} +type IssueRef = NonNullable -// Helper function to format parent/children as markdown function formatIssueHierarchyAsMarkdown( parent: IssueRef | null | undefined, children: IssueRef[] | undefined, @@ -276,147 +308,183 @@ function formatIssueHierarchyAsMarkdown( return markdown } -// Helper function to format a single comment line with consistent styling +function deriveCommentView( + comments: FetchedIssueComment[], + showResolvedThreads: boolean, +) { + const rootComments = comments + .filter((comment) => comment.parent == null) + .slice() + .sort((a, b) => + new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() + ) + + const commentsById = new Map(comments.map((comment) => [comment.id, comment])) + const rootIdByCommentId = new Map() + const repliesByRootId = new Map() + + function getRootId(commentId: string): string { + const cached = rootIdByCommentId.get(commentId) + if (cached != null) { + return cached + } + + const comment = commentsById.get(commentId) + if (comment?.parent == null) { + rootIdByCommentId.set(commentId, commentId) + return commentId + } + + const rootId = getRootId(comment.parent.id) + rootIdByCommentId.set(commentId, rootId) + return rootId + } + + for (const comment of comments) { + if (comment.parent == null) { + continue + } + + const rootId = getRootId(comment.id) + const replies = repliesByRootId.get(rootId) + if (replies) { + replies.push(comment) + } else { + repliesByRootId.set(rootId, [comment]) + } + } + + for (const replies of repliesByRootId.values()) { + replies.sort((a, b) => + new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime() + ) + } + + const visibleRootComments = showResolvedThreads + ? rootComments + : rootComments.filter((comment) => comment.resolvedAt == null) + + return { + visibleRootComments, + repliesByRootId, + hiddenResolvedThreadCount: rootComments.length - visibleRootComments.length, + } +} + function formatCommentHeader( author: string, date: string, + suffix = "", indent = "", ): string { + const suffixText = suffix ? ` ${suffix}` : "" return `${indent}${underline(bold(`@${author}`))} ${ underline(`commented ${date}`) - }` + }${suffixText}` } -// Helper function to format comments as markdown (for non-terminal output) -function formatCommentsAsMarkdown( - comments: Array<{ - id: string - body: string - createdAt: string - user?: { name: string; displayName: string } | null - externalUser?: { name: string; displayName: string } | null - parent?: { id: string } | null - }>, +function getCommentAuthor(comment: FetchedIssueComment): string { + return comment.user?.displayName || + comment.user?.name || + comment.externalUser?.displayName || + comment.externalUser?.name || + "Unknown" +} + +export function formatThreadIdLabel( + threadId: string, + url: string, + enableHyperlinks: boolean, ): string { - // Separate root comments from replies - const rootComments = comments.filter((comment) => !comment.parent) - const replies = comments.filter((comment) => comment.parent) - - // Create a map of parent ID to replies - const repliesMap = new Map() - replies.forEach((reply) => { - const parentId = reply.parent!.id - if (!repliesMap.has(parentId)) { - repliesMap.set(parentId, []) - } - repliesMap.get(parentId)!.push(reply) - }) + const displayText = `[thread: ${threadId}]` + return enableHyperlinks ? hyperlink(displayText, url) : displayText +} - // Sort root comments by creation date (newest first) - const sortedRootComments = rootComments.slice().reverse() +function getThreadHeaderSuffix( + rootComment: FetchedIssueComment, + enableHyperlinks: boolean, +): string { + const parts = [ + formatThreadIdLabel( + rootComment.id, + rootComment.url, + enableHyperlinks, + ), + ] + if (rootComment.resolvedAt != null) { + parts.push("[resolved]") + } + return parts.join(" ") +} +function formatCommentsAsMarkdown( + rootComments: FetchedIssueComment[], + repliesByRootId: Map, +): string { let markdown = "" - for (const rootComment of sortedRootComments) { - const threadReplies = repliesMap.get(rootComment.id) || [] + for (const rootComment of rootComments) { + const replies = repliesByRootId.get(rootComment.id) ?? [] + const rootAuthor = getCommentAuthor(rootComment) + const rootDate = formatRelativeTime(rootComment.createdAt) + const suffix = getThreadHeaderSuffix(rootComment, false) - // Sort replies by creation date (oldest first within thread) - threadReplies.sort((a, b) => - new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime() - ) + markdown += `- **@${rootAuthor}** - *${rootDate}* ${suffix} - const rootAuthor = rootComment.user?.displayName || - rootComment.user?.name || - rootComment.externalUser?.displayName || rootComment.externalUser?.name || - "Unknown" - const rootDate = formatRelativeTime(rootComment.createdAt) +` + markdown += ` ${rootComment.body.split("\n").join("\n ")} - // Format root comment - markdown += `- **@${rootAuthor}** - *${rootDate}*\n\n` - markdown += ` ${rootComment.body.split("\n").join("\n ")}\n\n` +` - // Format replies - for (const reply of threadReplies) { - const replyAuthor = reply.user?.displayName || reply.user?.name || - reply.externalUser?.displayName || reply.externalUser?.name || - "Unknown" + for (const reply of replies) { + const replyAuthor = getCommentAuthor(reply) const replyDate = formatRelativeTime(reply.createdAt) - markdown += ` - **@${replyAuthor}** - *${replyDate}*\n\n` - markdown += ` ${reply.body.split("\n").join("\n ")}\n\n` + markdown += ` - **@${replyAuthor}** - *${replyDate}* + +` + markdown += ` ${reply.body.split("\n").join("\n ")} + +` } } return markdown } -// Helper function to capture comments output as string array for consistent formatting + function captureCommentsForTerminal( - comments: Array<{ - id: string - body: string - createdAt: string - user?: { name: string; displayName: string } | null - externalUser?: { name: string; displayName: string } | null - parent?: { id: string } | null - }>, + rootComments: FetchedIssueComment[], + repliesByRootId: Map, width: number, extensions: Extension[] = [], ): string[] { const outputLines: string[] = [] + const enableHyperlinks = shouldEnableHyperlinks() - // Separate root comments from replies - const rootComments = comments.filter((comment) => !comment.parent) - const replies = comments.filter((comment) => comment.parent) - - // Create a map of parent ID to replies - const repliesMap = new Map() - replies.forEach((reply) => { - const parentId = reply.parent!.id - if (!repliesMap.has(parentId)) { - repliesMap.set(parentId, []) - } - repliesMap.get(parentId)!.push(reply) - }) - - // Sort root comments by creation date (newest first) - const sortedRootComments = rootComments.slice().reverse() - - for (const rootComment of sortedRootComments) { - const threadReplies = repliesMap.get(rootComment.id) || [] - - // Sort replies by creation date (oldest first within thread) - threadReplies.sort((a, b) => - new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime() - ) - - const rootAuthor = rootComment.user?.displayName || - rootComment.user?.name || - rootComment.externalUser?.displayName || rootComment.externalUser?.name || - "Unknown" + for (const [index, rootComment] of rootComments.entries()) { + const replies = repliesByRootId.get(rootComment.id) ?? [] + const rootAuthor = getCommentAuthor(rootComment) const rootDate = formatRelativeTime(rootComment.createdAt) + const suffix = getThreadHeaderSuffix(rootComment, enableHyperlinks) - // Format root comment using consistent styling - outputLines.push(formatCommentHeader(rootAuthor, rootDate)) + outputLines.push(formatCommentHeader(rootAuthor, rootDate, suffix)) const renderedRootBody = renderMarkdown(rootComment.body, { lineWidth: width, extensions, }) outputLines.push(...renderedRootBody.split("\n")) - if (threadReplies.length > 0) { + if (replies.length > 0) { outputLines.push("") } - // Format replies - for (const reply of threadReplies) { - const replyAuthor = reply.user?.displayName || reply.user?.name || - reply.externalUser?.displayName || reply.externalUser?.name || - "Unknown" + for (const reply of replies) { + const replyAuthor = getCommentAuthor(reply) const replyDate = formatRelativeTime(reply.createdAt) - outputLines.push(formatCommentHeader(replyAuthor, replyDate, " ")) + outputLines.push(formatCommentHeader(replyAuthor, replyDate, "", " ")) const renderedReplyBody = renderMarkdown(reply.body, { - lineWidth: width - 2, // Account for indentation + lineWidth: width - 2, extensions, }) outputLines.push( @@ -424,8 +492,7 @@ function captureCommentsForTerminal( ) } - // Add spacing between comment threads, but not after the last one - if (rootComment !== sortedRootComments[sortedRootComments.length - 1]) { + if (index < rootComments.length - 1) { outputLines.push("") } } @@ -433,6 +500,12 @@ function captureCommentsForTerminal( return outputLines } +function formatResolvedThreadsSummary(hiddenCount: number): string { + const noun = hiddenCount == 1 ? "thread" : "threads" + return "Resolved " + noun + " hidden: " + hiddenCount + + ". Use --show-resolved-threads to show them." +} + const IMAGE_CACHE_DIR = join( Deno.env.get("TMPDIR") || Deno.env.get("TMP") || Deno.env.get("TEMP") || "/tmp", @@ -655,15 +728,7 @@ async function downloadIssueImages( } // Type for attachments -type AttachmentInfo = { - id: string - title: string - url: string - subtitle?: string | null - sourceType?: string | null - metadata: Record - createdAt: string -} +type AttachmentInfo = FetchedIssueDetails["attachments"][number] function getAttachmentCacheDir(): string { const configuredDir = getOption("attachment_dir") diff --git a/src/utils/linear.ts b/src/utils/linear.ts index 2b25ea7f..ea1cfdf7 100644 --- a/src/utils/linear.ts +++ b/src/utils/linear.ts @@ -2,6 +2,8 @@ import { gql } from "../__codegen__/gql.ts" import type { GetAllTeamsQuery, GetAllTeamsQueryVariables as _GetAllTeamsQueryVariables, + GetIssueDetailsQuery, + GetIssueDetailsWithCommentsQuery, GetIssuesForStateQuery, GetTeamMembersQuery, IssueFilter, @@ -184,216 +186,229 @@ export async function updateIssueState( await client.request(mutation, { issueId, stateId }) } -export async function fetchIssueDetails( - issueId: string, - _showSpinner = false, - includeComments = false, -): Promise<{ - identifier: string - title: string - description?: string | null | undefined - url: string - branchName: string - state: { name: string; color: string } - project?: { name: string } | null - projectMilestone?: { name: string } | null - assignee?: { name: string; displayName: string } | null - priority: number - cycle?: { name?: string | null; number: number } | null - parent?: { - identifier: string - title: string - state: { name: string; color: string } - } | null - children?: Array<{ - identifier: string - title: string - state: { name: string; color: string } - }> - comments?: Array<{ - id: string - body: string - createdAt: string - user?: { name: string; displayName: string } | null - externalUser?: { name: string; displayName: string } | null - parent?: { id: string } | null - }> - attachments?: Array<{ - id: string - title: string - url: string - subtitle?: string | null - sourceType?: string | null - metadata: Record - createdAt: string - }> -}> { - const { Spinner } = await import("@std/cli/unstable-spinner") - const { shouldShowSpinner } = await import("./hyperlink.ts") - const spinner = shouldShowSpinner() ? new Spinner() : null - spinner?.start() - try { - const queryWithComments = gql(/* GraphQL */ ` - query GetIssueDetailsWithComments($id: String!) { - issue(id: $id) { +const issueDetailsWithCommentsQuery = gql(/* GraphQL */ ` + query GetIssueDetailsWithComments($id: String!) { + issue(id: $id) { + identifier + title + description + url + branchName + state { + name + color + } + assignee { + name + displayName + } + priority + project { + name + } + projectMilestone { + name + } + cycle { + name + number + } + parent { + identifier + title + state { + name + color + } + } + children(first: 250) { + nodes { identifier title - description - url - branchName state { name color } - assignee { + } + } + comments(first: 50, orderBy: createdAt) { + nodes { + id + body + createdAt + url + resolvedAt + resolvingCommentId + resolvingUser { name displayName } - priority - project { - name - } - projectMilestone { + user { name + displayName } - cycle { + externalUser { name - number + displayName } parent { - identifier - title - state { - name - color - } - } - children(first: 250) { - nodes { - identifier - title - state { - name - color - } - } - } - comments(first: 50, orderBy: createdAt) { - nodes { - id - body - createdAt - user { - name - displayName - } - externalUser { - name - displayName - } - parent { - id - } - } - } - attachments(first: 50) { - nodes { - id - title - url - subtitle - sourceType - metadata - createdAt - } + id } } } - `) - - const queryWithoutComments = gql(/* GraphQL */ ` - query GetIssueDetails($id: String!) { - issue(id: $id) { - identifier + attachments(first: 50) { + nodes { + id title - description url - branchName + subtitle + sourceType + metadata + createdAt + } + } + } + } +`) + +const issueDetailsQuery = gql(/* GraphQL */ ` + query GetIssueDetails($id: String!) { + issue(id: $id) { + identifier + title + description + url + branchName + state { + name + color + } + assignee { + name + displayName + } + priority + project { + name + } + projectMilestone { + name + } + cycle { + name + number + } + parent { + identifier + title + state { + name + color + } + } + children(first: 250) { + nodes { + identifier + title state { name color } - assignee { - name - displayName - } - priority - project { - name - } - projectMilestone { - name - } - cycle { - name - number - } - parent { - identifier - title - state { - name - color - } - } - children(first: 250) { - nodes { - identifier - title - state { - name - color - } - } - } - attachments(first: 50) { - nodes { - id - title - url - subtitle - sourceType - metadata - createdAt - } - } } } - `) + attachments(first: 50) { + nodes { + id + title + url + subtitle + sourceType + metadata + createdAt + } + } + } + } +`) + +export async function fetchIssueDetailsRaw( + issueId: string, + includeComments = false, +) { + const client = getGraphQLClient() + if (includeComments) { + const data = await client.request(issueDetailsWithCommentsQuery, { + id: issueId, + }) + return data.issue + } + + const data = await client.request(issueDetailsQuery, { id: issueId }) + return data.issue +} + +type IssueDetailsWithComments = GetIssueDetailsWithCommentsQuery["issue"] +type IssueDetailsWithoutComments = GetIssueDetailsQuery["issue"] + +export type FetchedIssueComment = IssueDetailsWithComments["comments"]["nodes"][ + number +] + +export type FetchedIssueDetailsWithComments = + & Omit + & { + children: IssueDetailsWithComments["children"]["nodes"] + comments: IssueDetailsWithComments["comments"]["nodes"] + attachments: IssueDetailsWithComments["attachments"]["nodes"] + } +export type FetchedIssueDetailsWithoutComments = + & Omit + & { + children: IssueDetailsWithoutComments["children"]["nodes"] + attachments: IssueDetailsWithoutComments["attachments"]["nodes"] + } + +export type FetchedIssueDetails = + | FetchedIssueDetailsWithComments + | FetchedIssueDetailsWithoutComments + +export async function fetchIssueDetails( + issueId: string, + _showSpinner = false, + includeComments = false, +): Promise { + const { Spinner } = await import("@std/cli/unstable-spinner") + const { shouldShowSpinner } = await import("./hyperlink.ts") + const spinner = shouldShowSpinner() ? new Spinner() : null + spinner?.start() + try { const client = getGraphQLClient() if (includeComments) { - const data = await client.request(queryWithComments, { id: issueId }) - spinner?.stop() - return { - ...data.issue, - children: data.issue.children?.nodes || [], - comments: data.issue.comments?.nodes || [], - attachments: data.issue.attachments?.nodes || [], - } - } else { - const data = await client.request(queryWithoutComments, { id: issueId }) + const response = await client.request(issueDetailsWithCommentsQuery, { + id: issueId, + }) + const data = response.issue spinner?.stop() return { - ...data.issue, - children: data.issue.children?.nodes || [], - attachments: data.issue.attachments?.nodes || [], + ...data, + children: data.children?.nodes || [], + comments: data.comments?.nodes || [], + attachments: data.attachments?.nodes || [], } } + + const response = await client.request(issueDetailsQuery, { id: issueId }) + const data = response.issue + spinner?.stop() + return { + ...data, + children: data.children?.nodes || [], + attachments: data.attachments?.nodes || [], + } } catch (error) { spinner?.stop() - // Re-throw to let caller handle with proper context throw error } } diff --git a/test/commands/issue/__snapshots__/issue-view.test.ts.snap b/test/commands/issue/__snapshots__/issue-view.test.ts.snap index 17ee2c94..04836686 100644 --- a/test/commands/issue/__snapshots__/issue-view.test.ts.snap +++ b/test/commands/issue/__snapshots__/issue-view.test.ts.snap @@ -11,13 +11,14 @@ Description: Options: - -h, --help - Show this help. - -w, --web - Open in web browser - -a, --app - Open in Linear.app - --no-comments - Exclude comments from the output - --no-pager - Disable automatic paging for long output - -j, --json - Output issue data as JSON - --no-download - Keep remote URLs instead of downloading files + -h, --help - Show this help. + -w, --web - Open in web browser + -a, --app - Open in Linear.app + --no-comments - Exclude comments from the output + --show-resolved-threads - Include resolved comment threads in the output + --no-pager - Disable automatic paging for long output + -j, --json - Output issue data as JSON + --no-download - Keep remote URLs instead of downloading files " stderr: @@ -70,11 +71,11 @@ Users are experiencing issues logging in when their session expires. ## Comments -- **@Bob Senior** - *1/15/2024* +- **@Bob Senior** - *1/15/2024* [thread: comment-4] Should we also consider implementing automatic session refresh? -- **@John Doe** - *1/15/2024* +- **@John Doe** - *1/15/2024* [thread: comment-1] I've reproduced this issue on staging. The session timeout seems to be too aggressive. @@ -115,8 +116,12 @@ stdout: "assignee": null, "priority": 3, "parent": null, - "children": [], - "attachments": [] + "children": { + "nodes": [] + }, + "attachments": { + "nodes": [] + } } ' stderr: @@ -143,34 +148,40 @@ stdout: "project": null, "projectMilestone": null, "parent": null, - "children": [], - "comments": [ - { - "id": "comment-1", - "body": "I've reproduced this issue on staging. The session timeout seems to be too aggressive.", - "createdAt": "2024-01-15T10:30:00Z", - "user": { - "name": "john.doe", - "displayName": "John Doe" - }, - "externalUser": null, - "parent": null - }, - { - "id": "comment-2", - "body": "Working on a fix. Will increase the session timeout and add proper error handling.", - "createdAt": "2024-01-15T14:22:00Z", - "user": { - "name": "jane.smith", - "displayName": "Jane Smith" + "children": { + "nodes": [] + }, + "comments": { + "nodes": [ + { + "id": "comment-1", + "body": "I've reproduced this issue on staging. The session timeout seems to be too aggressive.", + "createdAt": "2024-01-15T10:30:00Z", + "user": { + "name": "john.doe", + "displayName": "John Doe" + }, + "externalUser": null, + "parent": null }, - "externalUser": null, - "parent": { - "id": "comment-1" + { + "id": "comment-2", + "body": "Working on a fix. Will increase the session timeout and add proper error handling.", + "createdAt": "2024-01-15T14:22:00Z", + "user": { + "name": "jane.smith", + "displayName": "Jane Smith" + }, + "externalUser": null, + "parent": { + "id": "comment-1" + } } - } - ], - "attachments": [] + ] + }, + "attachments": { + "nodes": [] + } } \` stderr: @@ -224,3 +235,126 @@ Add rate limiting to the API gateway. stderr: "" `; + +snapshot[`Issue View Command - Hides Resolved Threads By Default 1`] = ` +stdout: +"# TEST-321: Audit resolved comment thread output + +**State:** In Progress | **Priority:** ▄▆█ | **Assignee:** Unassigned + +Check how issue view handles resolved comment threads. + +## Comments + +- **@John Doe** - *1/15/2024* [thread: comment-root-open] + + Open thread root comment. + + - **@Jane Smith** - *1/15/2024* + + Reply on the open thread. + + + +Resolved thread hidden: 1. Use --show-resolved-threads to show them. +" +stderr: +"" +`; + +snapshot[`Issue View Command - Show Resolved Threads 1`] = ` +stdout: +"# TEST-321: Audit resolved comment thread output + +**State:** In Progress | **Priority:** ▄▆█ | **Assignee:** Unassigned + +Check how issue view handles resolved comment threads. + +## Comments + +- **@Alice Developer** - *1/15/2024* [thread: comment-root-resolved] [resolved] + + Resolved thread root comment. + +- **@John Doe** - *1/15/2024* [thread: comment-root-open] + + Open thread root comment. + + - **@Jane Smith** - *1/15/2024* + + Reply on the open thread. + + +" +stderr: +"" +`; + +snapshot[`Issue View Command - JSON Output With Resolved Thread Metadata 1`] = ` +stdout: +'{ + "identifier": "TEST-654", + "title": "Expose resolved thread metadata", + "description": "Test JSON output for resolved thread data.", + "url": "https://linear.app/test-team/issue/TEST-654/expose-resolved-thread-metadata", + "branchName": "test-654-resolved-thread-json", + "state": { + "name": "Backlog", + "color": "#bec2c8" + }, + "assignee": null, + "priority": 0, + "project": null, + "projectMilestone": null, + "cycle": null, + "parent": null, + "children": { + "nodes": [] + }, + "comments": { + "nodes": [ + { + "id": "comment-root-json", + "body": "Resolved root comment.", + "createdAt": "2024-01-15T10:30:00Z", + "url": "https://linear.app/issue/TEST-654#comment-root-json", + "resolvedAt": "2024-01-15T11:00:00Z", + "resolvingCommentId": null, + "resolvingUser": { + "name": "john.doe", + "displayName": "John Doe" + }, + "user": { + "name": "john.doe", + "displayName": "John Doe" + }, + "externalUser": null, + "parent": null + }, + { + "id": "comment-reply-json", + "body": "Reply under the resolved thread.", + "createdAt": "2024-01-15T10:45:00Z", + "url": "https://linear.app/issue/TEST-654#comment-reply-json", + "resolvedAt": null, + "resolvingCommentId": null, + "resolvingUser": null, + "user": { + "name": "jane.smith", + "displayName": "Jane Smith" + }, + "externalUser": null, + "parent": { + "id": "comment-root-json" + } + } + ] + }, + "attachments": { + "nodes": [] + } +} +' +stderr: +"" +`; diff --git a/test/commands/issue/issue-view.test.ts b/test/commands/issue/issue-view.test.ts index 9b51101d..a166f7bf 100644 --- a/test/commands/issue/issue-view.test.ts +++ b/test/commands/issue/issue-view.test.ts @@ -1,5 +1,9 @@ import { snapshotTest } from "@cliffy/testing" -import { viewCommand } from "../../../src/commands/issue/issue-view.ts" +import { assertEquals } from "@std/assert" +import { + formatThreadIdLabel, + viewCommand, +} from "../../../src/commands/issue/issue-view.ts" import { MockLinearServer } from "../../utils/mock_linear_server.ts" // Common Deno args for permissions @@ -654,3 +658,342 @@ await snapshotTest({ } }, }) + +await snapshotTest({ + name: "Issue View Command - Hides Resolved Threads By Default", + meta: import.meta, + colors: false, + args: ["TEST-321"], + denoArgs, + async fn() { + const server = new MockLinearServer([ + { + queryName: "GetIssueDetailsWithComments", + variables: { id: "TEST-321" }, + response: { + data: { + issue: { + identifier: "TEST-321", + title: "Audit resolved comment thread output", + description: + "Check how issue view handles resolved comment threads.", + url: + "https://linear.app/test-team/issue/TEST-321/audit-resolved-comment-thread-output", + branchName: "test-321-resolved-thread-output", + state: { + name: "In Progress", + color: "#f87462", + }, + assignee: null, + priority: 2, + project: null, + projectMilestone: null, + parent: null, + children: { + nodes: [], + }, + comments: { + nodes: [ + { + id: "comment-root-open", + body: "Open thread root comment.", + createdAt: "2024-01-15T10:30:00Z", + url: "https://linear.app/issue/TEST-321#comment-root-open", + resolvedAt: null, + resolvingCommentId: null, + resolvingUser: null, + user: { + name: "john.doe", + displayName: "John Doe", + }, + externalUser: null, + parent: null, + }, + { + id: "comment-reply-open", + body: "Reply on the open thread.", + createdAt: "2024-01-15T11:00:00Z", + url: "https://linear.app/issue/TEST-321#comment-reply-open", + resolvedAt: null, + resolvingCommentId: null, + resolvingUser: null, + user: { + name: "jane.smith", + displayName: "Jane Smith", + }, + externalUser: null, + parent: { + id: "comment-root-open", + }, + }, + { + id: "comment-root-resolved", + body: "Resolved thread root comment.", + createdAt: "2024-01-15T12:00:00Z", + url: + "https://linear.app/issue/TEST-321#comment-root-resolved", + resolvedAt: "2024-01-15T12:30:00Z", + resolvingCommentId: null, + resolvingUser: { + name: "alice.dev", + displayName: "Alice Developer", + }, + user: { + name: "alice.dev", + displayName: "Alice Developer", + }, + externalUser: null, + parent: null, + }, + ], + }, + attachments: { + nodes: [], + }, + }, + }, + }, + }, + ]) + + try { + await server.start() + Deno.env.set("LINEAR_GRAPHQL_ENDPOINT", server.getEndpoint()) + Deno.env.set("LINEAR_API_KEY", "Bearer test-token") + + await viewCommand.parse() + } finally { + await server.stop() + Deno.env.delete("LINEAR_GRAPHQL_ENDPOINT") + Deno.env.delete("LINEAR_API_KEY") + } + }, +}) + +await snapshotTest({ + name: "Issue View Command - Show Resolved Threads", + meta: import.meta, + colors: false, + args: ["TEST-321", "--show-resolved-threads"], + denoArgs, + async fn() { + const server = new MockLinearServer([ + { + queryName: "GetIssueDetailsWithComments", + variables: { id: "TEST-321" }, + response: { + data: { + issue: { + identifier: "TEST-321", + title: "Audit resolved comment thread output", + description: + "Check how issue view handles resolved comment threads.", + url: + "https://linear.app/test-team/issue/TEST-321/audit-resolved-comment-thread-output", + branchName: "test-321-resolved-thread-output", + state: { + name: "In Progress", + color: "#f87462", + }, + assignee: null, + priority: 2, + project: null, + projectMilestone: null, + parent: null, + children: { + nodes: [], + }, + comments: { + nodes: [ + { + id: "comment-root-open", + body: "Open thread root comment.", + createdAt: "2024-01-15T10:30:00Z", + url: "https://linear.app/issue/TEST-321#comment-root-open", + resolvedAt: null, + resolvingCommentId: null, + resolvingUser: null, + user: { + name: "john.doe", + displayName: "John Doe", + }, + externalUser: null, + parent: null, + }, + { + id: "comment-reply-open", + body: "Reply on the open thread.", + createdAt: "2024-01-15T11:00:00Z", + url: "https://linear.app/issue/TEST-321#comment-reply-open", + resolvedAt: null, + resolvingCommentId: null, + resolvingUser: null, + user: { + name: "jane.smith", + displayName: "Jane Smith", + }, + externalUser: null, + parent: { + id: "comment-root-open", + }, + }, + { + id: "comment-root-resolved", + body: "Resolved thread root comment.", + createdAt: "2024-01-15T12:00:00Z", + url: + "https://linear.app/issue/TEST-321#comment-root-resolved", + resolvedAt: "2024-01-15T12:30:00Z", + resolvingCommentId: null, + resolvingUser: { + name: "alice.dev", + displayName: "Alice Developer", + }, + user: { + name: "alice.dev", + displayName: "Alice Developer", + }, + externalUser: null, + parent: null, + }, + ], + }, + attachments: { + nodes: [], + }, + }, + }, + }, + }, + ]) + + try { + await server.start() + Deno.env.set("LINEAR_GRAPHQL_ENDPOINT", server.getEndpoint()) + Deno.env.set("LINEAR_API_KEY", "Bearer test-token") + + await viewCommand.parse() + } finally { + await server.stop() + Deno.env.delete("LINEAR_GRAPHQL_ENDPOINT") + Deno.env.delete("LINEAR_API_KEY") + } + }, +}) + +Deno.test("formatThreadIdLabel - keeps thread id visible without hyperlinks", () => { + assertEquals( + formatThreadIdLabel( + "comment-root-open", + "https://linear.app/issue/TEST-321#comment-root-open", + false, + ), + "[thread: comment-root-open]", + ) +}) + +Deno.test("formatThreadIdLabel - wraps thread id in OSC-8 hyperlink", () => { + assertEquals( + formatThreadIdLabel( + "comment-root-open", + "https://linear.app/issue/TEST-321#comment-root-open", + true, + ), + "\x1b]8;;https://linear.app/issue/TEST-321#comment-root-open\x1b\\[thread: comment-root-open]\x1b]8;;\x1b\\", + ) +}) + +await snapshotTest({ + name: "Issue View Command - JSON Output With Resolved Thread Metadata", + meta: import.meta, + colors: false, + args: ["TEST-654", "--json"], + denoArgs, + async fn() { + const server = new MockLinearServer([ + { + queryName: "GetIssueDetailsWithComments", + variables: { id: "TEST-654" }, + response: { + data: { + issue: { + identifier: "TEST-654", + title: "Expose resolved thread metadata", + description: "Test JSON output for resolved thread data.", + url: + "https://linear.app/test-team/issue/TEST-654/expose-resolved-thread-metadata", + branchName: "test-654-resolved-thread-json", + state: { + name: "Backlog", + color: "#bec2c8", + }, + assignee: null, + priority: 0, + project: null, + projectMilestone: null, + cycle: null, + parent: null, + children: { + nodes: [], + }, + comments: { + nodes: [ + { + id: "comment-root-json", + body: "Resolved root comment.", + createdAt: "2024-01-15T10:30:00Z", + url: "https://linear.app/issue/TEST-654#comment-root-json", + resolvedAt: "2024-01-15T11:00:00Z", + resolvingCommentId: null, + resolvingUser: { + name: "john.doe", + displayName: "John Doe", + }, + user: { + name: "john.doe", + displayName: "John Doe", + }, + externalUser: null, + parent: null, + }, + { + id: "comment-reply-json", + body: "Reply under the resolved thread.", + createdAt: "2024-01-15T10:45:00Z", + url: "https://linear.app/issue/TEST-654#comment-reply-json", + resolvedAt: null, + resolvingCommentId: null, + resolvingUser: null, + user: { + name: "jane.smith", + displayName: "Jane Smith", + }, + externalUser: null, + parent: { + id: "comment-root-json", + }, + }, + ], + }, + attachments: { + nodes: [], + }, + }, + }, + }, + }, + ]) + + try { + await server.start() + Deno.env.set("LINEAR_GRAPHQL_ENDPOINT", server.getEndpoint()) + Deno.env.set("LINEAR_API_KEY", "Bearer test-token") + + await viewCommand.parse() + } finally { + await server.stop() + Deno.env.delete("LINEAR_GRAPHQL_ENDPOINT") + Deno.env.delete("LINEAR_API_KEY") + } + }, +})