diff --git a/CHANGELOG.md b/CHANGELOG.md index db0ed4b3..e2d4bd74 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- `document comment list|add`, `project comment list|add`, and `initiative comment list|add`, mirroring `issue comment`. Documents take a UUID or slug, projects and initiatives a UUID, slug, or name; `add` takes `--body` or `--body-file`. Every comment `add`, including `issue comment add`, now takes `--reply-to ` to answer in a thread (`-p`/`--parent` remain aliases). Comment lists now fetch every page instead of stopping at 50, and their `--json` nodes, plus the comments in `issue view --json`, carry `quotedText` (the passage an inline comment is anchored to) alongside `parent.id` ([#230](https://github.com/schpet/linear-cli/issues/230)) - every command that takes a team now accepts its key, name, or UUID, resolved through one shared lookup: `team states`, `team members`, `team delete`, `label list/create/delete --team`, `cycle list/view --team`, `project list/create/update --team`, `document list/create/update --team`, and `issue query/mine/create/update --team`. Keys stay canonical and win over a same-spelled name; an unknown team errors with the list of valid keys instead of an empty result or a raw API error. Previously only keys worked, which is why [#276](https://github.com/schpet/linear-cli/issues/276) asked for `team list --json` as a name-to-key lookup - `issue query --state` and `issue mine --state` accept a workflow state name or ID as well as the six state types, looked up within the queried team scope (all teams under `--all-teams`, where a name matches every team's same-named state). An unknown name errors and lists the scope's states, and types and names can be mixed - `project update --content ` and `--content-file ` replace a project's long-form overview body, matching the flags `project create` already had. Previously the only way to change the body after creation was a hand-written `projectUpdate` mutation through `linear api` @@ -11,6 +12,7 @@ ### Fixed +- an unknown document, project, initiative, or issue passed to `document view` or any `comment` command is reported as ` not found: ` instead of Linear's raw "Could not find referenced …" wording, and `document view` no longer exits with a stack trace for an unknown slug (its not-found branch re-threw instead of reporting, and was unreachable until the not-found detection was fixed) - `cycle list` and `milestone list` now paginate instead of taking Linear's default page, so a team with more than 50 cycles or a project with more than 50 milestones is no longer silently truncated ## [2.6.0] - 2026-09-02 diff --git a/README.md b/README.md index 3d6caa4b..f92884cb 100644 --- a/README.md +++ b/README.md @@ -148,7 +148,8 @@ linear issue update ENG-123 --milestone "Phase 2" # set milestone on existing i linear issue delete # delete an issue linear issue comment list # list comments on current issue linear issue comment add # add a comment to current issue -linear issue comment add -p # reply to a specific comment +linear issue comment add --reply-to # reply to a comment (-p / --parent are aliases) +linear issue comment list --json # comments as JSON, with quotedText and parent for inline comments and replies linear issue comment update # update a comment linear issue commits # show all commits for an issue (jj only) ``` @@ -201,6 +202,19 @@ linear project view --json # project details as JSON linear project create --name "API v2" --team ENG --content-file overview.md linear project create --name "Mobile launch" --team APP --priority high --label Launch --member jane@example.com linear project update --content-file overview.md # replace the project's overview body +linear project comment list # list the project's discussion thread (UUID, slug, or name) +linear project comment add --body "Kickoff Monday" # comment on a project +linear project comment add --body "+1" --reply-to # reply in a thread +``` + +### initiative commands + +```bash +linear initiative list # list initiatives +linear initiative view # view an initiative (UUID, slug, or name) +linear initiative comment list # list the initiative's discussion thread +linear initiative comment add --body-file note.md # comment on an initiative +linear initiative comment add --body "+1" --reply-to # reply in a thread ``` ### cycle commands @@ -251,6 +265,12 @@ linear document view --raw # output raw markdown (for pipin linear document view --web # open in browser linear document view --json # output as JSON, including document comments +# comment on a document +linear document comment list # list comments; inline comments show the text they quote +linear document comment list --json # comments as JSON (quotedText, parent, ...) +linear document comment add --body "Looks good" # add a top-level comment +linear document comment add --body-file note.md --reply-to # reply in a thread + # create a document (exactly one attachment target is required) linear document create --title "Doc" --project # attach to project linear document create --title "Notes" --issue TC-123 # attach to issue diff --git a/docs/usage.md b/docs/usage.md index df690608..7fbf3c64 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -213,6 +213,21 @@ delete an issue: linear issue delete TEAM-123 ``` +#### issue comments + +```bash +# List comments (threads, newest first); --json keeps the GraphQL connection +linear issue comment list TEAM-123 +linear issue comment list TEAM-123 --json + +# Add a comment; --body-file is preferred for markdown +linear issue comment add TEAM-123 --body "Reproduced on staging" +linear issue comment add TEAM-123 --body-file notes.md + +# Reply to a top-level comment (-p / --parent are aliases of --reply-to) +linear issue comment add TEAM-123 --body "Fixed in #42" --reply-to COMMENT-ID +``` + ### teams wherever a command takes a team, pass its key, its name, or its UUID. keys are canonical; an unknown team errors and lists the valid keys. @@ -299,6 +314,31 @@ linear project view PROJECT-ID linear project view PROJECT-ID --json ``` +#### project comments + +```bash +# A project is a UUID, slug ID, or exact name +linear project comment list "Mobile launch" +linear project comment list PROJECT-ID --json + +linear project comment add PROJECT-ID --body "Kickoff is Monday" +linear project comment add PROJECT-ID --body-file update.md --reply-to COMMENT-ID +``` + +### documents and initiatives + +Documents and initiatives take the same `comment list` and `comment add` subcommands as issues and projects. A document is a UUID or slug; an initiative is a UUID, slug, or name. + +```bash +linear document comment list DOC-SLUG # inline comments show the text they quote +linear document comment list DOC-SLUG --json # quotedText and parent are in the JSON +linear document comment add DOC-SLUG --body-file review.md +linear document comment add DOC-SLUG --body "Agreed" --reply-to COMMENT-ID + +linear initiative comment list "Platform" +linear initiative comment add "Platform" --body "Scope locked for Q3" +``` + ### shell completions generate shell completions for better command-line experience: diff --git a/skills/linear-cli/SKILL.md b/skills/linear-cli/SKILL.md index eacbd763..a85f0aa4 100644 --- a/skills/linear-cli/SKILL.md +++ b/skills/linear-cli/SKILL.md @@ -182,6 +182,9 @@ linear cycle list linear cycle view linear document +linear document comment +linear document comment add +linear document comment list linear document create linear document delete linear document list @@ -191,6 +194,9 @@ linear document view linear initiative linear initiative add-project linear initiative archive +linear initiative comment +linear initiative comment add +linear initiative comment list linear initiative create linear initiative delete linear initiative list @@ -247,6 +253,9 @@ linear milestone update linear milestone view linear project +linear project comment +linear project comment add +linear project comment list linear project create linear project delete linear project list diff --git a/skills/linear-cli/references/document.md b/skills/linear-cli/references/document.md index 9fecfb2d..af3b9795 100644 --- a/skills/linear-cli/references/document.md +++ b/skills/linear-cli/references/document.md @@ -23,10 +23,74 @@ Commands: create, c - Create a new document update, u - Update an existing document delete, d [documentId] - Delete a document (moves to trash) + comment - Manage document comments ``` ## Subcommands +### comment + +> Manage document comments + +``` +Usage: linear document comment + +Description: + + Manage document comments + +Options: + + -h, --help - Show this help. + --workspace - Target workspace (uses credentials) + +Commands: + + add - Add a comment or reply to a document (by ID or slug) + list - List comments on a document (by ID or slug) +``` + +#### comment subcommands + +##### add + +``` +Usage: linear document comment add + +Description: + + Add a comment or reply to a document (by ID or slug) + + Linear Markdown: a plain Linear URL creates a mention; `@name`, `@[Name](id)`, + and `[Name](url)` do not. Get a person's URL from the `url` field of + `linear team members --json`, or an issue's from `linear issue url `. + Run `linear markdown` for collapsible sections and the full reference. + +Options: + + -h, --help - Show this help. + --workspace - Target workspace (uses credentials) + -b, --body - Comment body text + --body-file - Read comment body from a file (preferred for markdown content) + -p, --parent, --reply-to - Reply to a top-level comment by ID (the reply joins that thread) +``` + +##### list + +``` +Usage: linear document comment list + +Description: + + List comments on a document (by ID or slug) + +Options: + + -h, --help - Show this help. + --workspace - Target workspace (uses credentials) + -j, --json - Output as JSON +``` + ### create > Create a new document diff --git a/skills/linear-cli/references/initiative.md b/skills/linear-cli/references/initiative.md index 5201943f..dfa40957 100644 --- a/skills/linear-cli/references/initiative.md +++ b/skills/linear-cli/references/initiative.md @@ -26,7 +26,8 @@ Commands: unarchive - Unarchive a Linear initiative delete [initiativeId] - Permanently delete a Linear initiative add-project - Link a project to an initiative - remove-project - Unlink a project from an initiative + remove-project - Unlink a project from an initiative + comment - Manage initiative comments ``` ## Subcommands @@ -70,6 +71,69 @@ Options: --bulk-stdin - Read initiative IDs from stdin ``` +### comment + +> Manage initiative comments + +``` +Usage: linear initiative comment + +Description: + + Manage initiative comments + +Options: + + -h, --help - Show this help. + --workspace - Target workspace (uses credentials) + +Commands: + + add - Add a comment or reply to an initiative's discussion (by ID, slug, or name) + list - List comments on an initiative (by ID, slug, or name) +``` + +#### comment subcommands + +##### add + +``` +Usage: linear initiative comment add + +Description: + + Add a comment or reply to an initiative's discussion (by ID, slug, or name) + + Linear Markdown: a plain Linear URL creates a mention; `@name`, `@[Name](id)`, + and `[Name](url)` do not. Get a person's URL from the `url` field of + `linear team members --json`, or an issue's from `linear issue url `. + Run `linear markdown` for collapsible sections and the full reference. + +Options: + + -h, --help - Show this help. + --workspace - Target workspace (uses credentials) + -b, --body - Comment body text + --body-file - Read comment body from a file (preferred for markdown content) + -p, --parent, --reply-to - Reply to a top-level comment by ID (the reply joins that thread) +``` + +##### list + +``` +Usage: linear initiative comment list + +Description: + + List comments on an initiative (by ID, slug, or name) + +Options: + + -h, --help - Show this help. + --workspace - Target workspace (uses credentials) + -j, --json - Output as JSON +``` + ### create > Create a new Linear initiative diff --git a/skills/linear-cli/references/issue.md b/skills/linear-cli/references/issue.md index 3df9f9d7..0defd226 100644 --- a/skills/linear-cli/references/issue.md +++ b/skills/linear-cli/references/issue.md @@ -161,15 +161,15 @@ Description: Options: - -h, --help - Show this help. - --workspace - Target workspace (uses credentials) - -b, --body - Comment body text - --body-file - Read comment body from a file (preferred for markdown content) - -p, --parent - Parent comment ID for replies - -a, --attach - Upload a file and add its Markdown link to the comment (images render inline; - repeatable) - --public - Upload attached images to a public, unauthenticated URL (default: private, - workspace-members only) + -h, --help - Show this help. + --workspace - Target workspace (uses credentials) + -b, --body - Comment body text + --body-file - Read comment body from a file (preferred for markdown content) + -p, --parent, --reply-to - Reply to a top-level comment by ID (the reply joins that thread) + -a, --attach - Upload a file and add its Markdown link to the comment (images render inline; + repeatable) + --public - Upload attached images to a public, unauthenticated URL (default: private, + workspace-members only) ``` ##### delete diff --git a/skills/linear-cli/references/project.md b/skills/linear-cli/references/project.md index 2f05f728..e75a5fd8 100644 --- a/skills/linear-cli/references/project.md +++ b/skills/linear-cli/references/project.md @@ -23,10 +23,74 @@ Commands: create - Create a new Linear project update - Update a Linear project delete - Delete (trash) a Linear project + comment - Manage project comments ``` ## Subcommands +### comment + +> Manage project comments + +``` +Usage: linear project comment + +Description: + + Manage project comments + +Options: + + -h, --help - Show this help. + --workspace - Target workspace (uses credentials) + +Commands: + + add - Add a comment or reply to a project's discussion (by ID, slug, or name) + list - List comments on a project (by ID, slug, or name) +``` + +#### comment subcommands + +##### add + +``` +Usage: linear project comment add + +Description: + + Add a comment or reply to a project's discussion (by ID, slug, or name) + + Linear Markdown: a plain Linear URL creates a mention; `@name`, `@[Name](id)`, + and `[Name](url)` do not. Get a person's URL from the `url` field of + `linear team members --json`, or an issue's from `linear issue url `. + Run `linear markdown` for collapsible sections and the full reference. + +Options: + + -h, --help - Show this help. + --workspace - Target workspace (uses credentials) + -b, --body - Comment body text + --body-file - Read comment body from a file (preferred for markdown content) + -p, --parent, --reply-to - Reply to a top-level comment by ID (the reply joins that thread) +``` + +##### list + +``` +Usage: linear project comment list + +Description: + + List comments on a project (by ID, slug, or name) + +Options: + + -h, --help - Show this help. + --workspace - Target workspace (uses credentials) + -j, --json - Output as JSON +``` + ### create > Create a new Linear project diff --git a/src/commands/document/document-comment-add.ts b/src/commands/document/document-comment-add.ts new file mode 100644 index 00000000..de1cd8f2 --- /dev/null +++ b/src/commands/document/document-comment-add.ts @@ -0,0 +1,80 @@ +import { Command } from "@cliffy/command" +import { gql } from "../../__codegen__/gql.ts" +import { getGraphQLClient } from "../../utils/graphql.ts" +import { + CliError, + handleError, + NotFoundError, + translateNotFound, +} from "../../utils/errors.ts" +import { withMarkdownHint } from "../../utils/markdown-help.ts" +import { + COMMENT_BODY_DESCRIPTION, + COMMENT_BODY_FILE_DESCRIPTION, + createComment, + promptCommentBody, + REPLY_TO_DESCRIPTION, + resolveCommentBody, +} from "../../utils/comments.ts" + +// A document comment attaches to the document's content record, not to the +// document itself, so look that id up first. `document(id:)` accepts a UUID or +// a slug ID. +const GetDocumentCommentTarget = gql(` + query GetDocumentCommentTarget($id: String!) { + document(id: $id) { + id + title + documentContentId + } + } +`) + +export const commentAddCommand = new Command() + .name("add") + .description( + withMarkdownHint("Add a comment or reply to a document (by ID or slug)"), + ) + .arguments("") + .option("-b, --body ", COMMENT_BODY_DESCRIPTION) + .option("--body-file ", COMMENT_BODY_FILE_DESCRIPTION) + .option("-p, --parent, --reply-to ", REPLY_TO_DESCRIPTION) + .action(async (options, document) => { + const { body, bodyFile, parent } = options + + try { + const textBody = await resolveCommentBody({ body, bodyFile }) + + const client = getGraphQLClient() + const data = await translateNotFound( + "Document", + document, + () => client.request(GetDocumentCommentTarget, { id: document }), + ) + if (!data.document) { + throw new NotFoundError("Document", document) + } + const documentContentId = data.document.documentContentId + if (documentContentId == null) { + throw new CliError( + `Document "${data.document.title}" has no content record to comment on`, + { + suggestion: + "Linear attaches document comments to the document's content; open the document in Linear once so it gets one, then retry.", + }, + ) + } + + const commentBody = textBody ?? await promptCommentBody() + + const comment = await createComment( + { kind: "document", documentContentId }, + { body: commentBody, parentId: parent }, + ) + + console.log(`✓ Comment added to document ${document}`) + console.log(comment.url) + } catch (error) { + handleError(error, "Failed to add comment") + } + }) diff --git a/src/commands/document/document-comment-list.ts b/src/commands/document/document-comment-list.ts new file mode 100644 index 00000000..56928182 --- /dev/null +++ b/src/commands/document/document-comment-list.ts @@ -0,0 +1,65 @@ +import { Command } from "@cliffy/command" +import { gql } from "../../__codegen__/gql.ts" +import { getGraphQLClient } from "../../utils/graphql.ts" +import { + handleError, + NotFoundError, + translateNotFound, +} from "../../utils/errors.ts" +import { + collectCommentPages, + renderCommentThreads, +} from "../../utils/comments.ts" + +// `document(id:)` accepts a UUID or a slug ID, so no resolver is needed. +const GetDocumentComments = gql(` + query GetDocumentComments($id: String!, $after: String) { + document(id: $id) { + id + comments(first: 50, after: $after, orderBy: createdAt) { + nodes { + ...CommentListFields + } + pageInfo { + hasNextPage + endCursor + } + } + } + } +`) + +export const commentListCommand = new Command() + .name("list") + .description("List comments on a document (by ID or slug)") + .arguments("") + .option("-j, --json", "Output as JSON") + .action(async (options, document) => { + const { json } = options + + try { + const client = getGraphQLClient() + const comments = await collectCommentPages(async (after) => { + const data = await translateNotFound( + "Document", + document, + () => client.request(GetDocumentComments, { id: document, after }), + ) + if (!data.document) { + throw new NotFoundError("Document", document) + } + return data.document.comments + }) + + if (json) { + console.log(JSON.stringify(comments, null, 2)) + return + } + + renderCommentThreads(comments.nodes, { + emptyMessage: "No comments found for this document", + }) + } catch (error) { + handleError(error, "Failed to list comments") + } + }) diff --git a/src/commands/document/document-comment.ts b/src/commands/document/document-comment.ts new file mode 100644 index 00000000..cc320617 --- /dev/null +++ b/src/commands/document/document-comment.ts @@ -0,0 +1,11 @@ +import { Command } from "@cliffy/command" +import { commentAddCommand } from "./document-comment-add.ts" +import { commentListCommand } from "./document-comment-list.ts" + +export const commentCommand = new Command() + .description("Manage document comments") + .action(function () { + this.showHelp() + }) + .command("add", commentAddCommand) + .command("list", commentListCommand) diff --git a/src/commands/document/document-view.ts b/src/commands/document/document-view.ts index 3494c630..b5ab4551 100644 --- a/src/commands/document/document-view.ts +++ b/src/commands/document/document-view.ts @@ -290,9 +290,11 @@ export const viewCommand = new Command() console.log(renderMarkdown(markdown, { lineWidth: terminalWidth })) } catch (error) { spinner?.stop() - if (isClientError(error) && isNotFoundError(error)) { - throw new NotFoundError("Document", id) - } - handleError(error, "Failed to view document") + // Report through handleError like every other failure; throwing from + // here would escape the action and print a stack trace instead. + const reported = isClientError(error) && isNotFoundError(error) + ? new NotFoundError("Document", id) + : error + handleError(reported, "Failed to view document") } }) diff --git a/src/commands/document/document.ts b/src/commands/document/document.ts index 2f55ba63..c7bd9859 100644 --- a/src/commands/document/document.ts +++ b/src/commands/document/document.ts @@ -4,6 +4,7 @@ import { viewCommand } from "./document-view.ts" import { createCommand } from "./document-create.ts" import { updateCommand } from "./document-update.ts" import { deleteCommand } from "./document-delete.ts" +import { commentCommand } from "./document-comment.ts" export const documentCommand = new Command() .name("document") @@ -18,3 +19,4 @@ export const documentCommand = new Command() .command("create", createCommand) .command("update", updateCommand) .command("delete", deleteCommand) + .command("comment", commentCommand) diff --git a/src/commands/initiative/initiative-comment-add.ts b/src/commands/initiative/initiative-comment-add.ts new file mode 100644 index 00000000..a802575c --- /dev/null +++ b/src/commands/initiative/initiative-comment-add.ts @@ -0,0 +1,43 @@ +import { Command } from "@cliffy/command" +import { resolveInitiativeId } from "../../utils/linear.ts" +import { handleError } from "../../utils/errors.ts" +import { withMarkdownHint } from "../../utils/markdown-help.ts" +import { + COMMENT_BODY_DESCRIPTION, + COMMENT_BODY_FILE_DESCRIPTION, + createComment, + promptCommentBody, + REPLY_TO_DESCRIPTION, + resolveCommentBody, +} from "../../utils/comments.ts" + +export const commentAddCommand = new Command() + .name("add") + .description( + withMarkdownHint( + "Add a comment or reply to an initiative's discussion (by ID, slug, or name)", + ), + ) + .arguments("") + .option("-b, --body ", COMMENT_BODY_DESCRIPTION) + .option("--body-file ", COMMENT_BODY_FILE_DESCRIPTION) + .option("-p, --parent, --reply-to ", REPLY_TO_DESCRIPTION) + .action(async (options, initiative) => { + const { body, bodyFile, parent } = options + + try { + const textBody = await resolveCommentBody({ body, bodyFile }) + const initiativeId = await resolveInitiativeId(initiative) + const commentBody = textBody ?? await promptCommentBody() + + const comment = await createComment( + { kind: "initiative", initiativeId }, + { body: commentBody, parentId: parent }, + ) + + console.log(`✓ Comment added to initiative ${initiative}`) + console.log(comment.url) + } catch (error) { + handleError(error, "Failed to add comment") + } + }) diff --git a/src/commands/initiative/initiative-comment-list.ts b/src/commands/initiative/initiative-comment-list.ts new file mode 100644 index 00000000..e3d0f97d --- /dev/null +++ b/src/commands/initiative/initiative-comment-list.ts @@ -0,0 +1,83 @@ +import { Command } from "@cliffy/command" +import { gql } from "../../__codegen__/gql.ts" +import { getGraphQLClient } from "../../utils/graphql.ts" +import { resolveInitiativeId } from "../../utils/linear.ts" +import { + handleError, + NotFoundError, + translateNotFound, +} from "../../utils/errors.ts" +import { + collectCommentPages, + renderCommentThreads, +} from "../../utils/comments.ts" + +// `Initiative` has no comments connection in the schema, so list through the +// root `comments` query filtered by initiative. The initiative itself is +// selected in the same operation so an unknown UUID -- which +// resolveInitiativeId passes through unchecked -- is reported as not found +// instead of as an empty list. `initiative(id:)` takes String!, while the +// filter's `eq` takes ID!, hence two variables carrying the same value. +const GetInitiativeComments = gql(` + query GetInitiativeComments($id: String!, $filterId: ID!, $after: String) { + initiative(id: $id) { + id + name + } + comments( + first: 50 + after: $after + orderBy: createdAt + filter: { initiative: { id: { eq: $filterId } } } + ) { + nodes { + ...CommentListFields + } + pageInfo { + hasNextPage + endCursor + } + } + } +`) + +export const commentListCommand = new Command() + .name("list") + .description("List comments on an initiative (by ID, slug, or name)") + .arguments("") + .option("-j, --json", "Output as JSON") + .action(async (options, initiative) => { + const { json } = options + + try { + const initiativeId = await resolveInitiativeId(initiative) + const client = getGraphQLClient() + const comments = await collectCommentPages(async (after) => { + const data = await translateNotFound( + "Initiative", + initiative, + () => + client.request(GetInitiativeComments, { + id: initiativeId, + filterId: initiativeId, + after, + }), + ) + if (!data.initiative) { + throw new NotFoundError("Initiative", initiative) + } + return data.comments + }) + + if (json) { + console.log(JSON.stringify(comments, null, 2)) + return + } + + renderCommentThreads(comments.nodes, { + emptyMessage: "No comments found for this initiative", + }) + } catch (error) { + handleError(error, "Failed to list comments") + } + }) diff --git a/src/commands/initiative/initiative-comment.ts b/src/commands/initiative/initiative-comment.ts new file mode 100644 index 00000000..ce3c1d9b --- /dev/null +++ b/src/commands/initiative/initiative-comment.ts @@ -0,0 +1,11 @@ +import { Command } from "@cliffy/command" +import { commentAddCommand } from "./initiative-comment-add.ts" +import { commentListCommand } from "./initiative-comment-list.ts" + +export const commentCommand = new Command() + .description("Manage initiative comments") + .action(function () { + this.showHelp() + }) + .command("add", commentAddCommand) + .command("list", commentListCommand) diff --git a/src/commands/initiative/initiative.ts b/src/commands/initiative/initiative.ts index d5f88e22..576c26b0 100644 --- a/src/commands/initiative/initiative.ts +++ b/src/commands/initiative/initiative.ts @@ -9,6 +9,7 @@ import { unarchiveCommand } from "./initiative-unarchive.ts" import { deleteCommand } from "./initiative-delete.ts" import { addProjectCommand } from "./initiative-add-project.ts" import { removeProjectCommand } from "./initiative-remove-project.ts" +import { commentCommand } from "./initiative-comment.ts" export const initiativeCommand = new Command() .description("Manage Linear initiatives") @@ -25,3 +26,4 @@ export const initiativeCommand = new Command() .command("delete", deleteCommand) .command("add-project", addProjectCommand) .command("remove-project", removeProjectCommand) + .command("comment", commentCommand) diff --git a/src/commands/issue/issue-comment-add.ts b/src/commands/issue/issue-comment-add.ts index 7e317a06..29b63758 100644 --- a/src/commands/issue/issue-comment-add.ts +++ b/src/commands/issue/issue-comment-add.ts @@ -1,7 +1,4 @@ import { Command } from "@cliffy/command" -import { Input } from "@cliffy/prompt" -import { gql } from "../../__codegen__/gql.ts" -import { getGraphQLClient } from "../../utils/graphql.ts" import { getIssueIdentifier } from "../../utils/linear.ts" import { formatAsMarkdownLink, @@ -11,8 +8,16 @@ import { validateFilePath, } from "../../utils/upload.ts" import { shouldShowSpinner } from "../../utils/hyperlink.ts" -import { CliError, handleError, ValidationError } from "../../utils/errors.ts" +import { handleError, ValidationError } from "../../utils/errors.ts" import { withMarkdownHint } from "../../utils/markdown-help.ts" +import { + COMMENT_BODY_DESCRIPTION, + COMMENT_BODY_FILE_DESCRIPTION, + createComment, + promptCommentBody, + REPLY_TO_DESCRIPTION, + resolveCommentBody, +} from "../../utils/comments.ts" // Linear documents CommentCreateInput.id as "The identifier in UUID v4 format". const UUID_V4_REGEX = @@ -26,12 +31,10 @@ export const commentAddCommand = new Command() ), ) .arguments("[issueId:string]") - .option("-b, --body ", "Comment body text") - .option( - "--body-file ", - "Read comment body from a file (preferred for markdown content)", - ) - .option("-p, --parent ", "Parent comment ID for replies") + .option("-b, --body ", COMMENT_BODY_DESCRIPTION) + .option("--body-file ", COMMENT_BODY_FILE_DESCRIPTION) + // `--parent` and `-p` predate `--reply-to`; all three spellings set `parent`. + .option("-p, --parent, --reply-to ", REPLY_TO_DESCRIPTION) // Hidden: a caller-supplied id makes retries idempotent (re-sending the same // id fails rather than posting a duplicate), which is useful to scripts but // noise in the help output. @@ -51,13 +54,6 @@ export const commentAddCommand = new Command() const { body, bodyFile, parent, id, attach, public: makePublic } = options try { - // Validate that body and bodyFile are not both provided - if (body && bodyFile) { - throw new ValidationError( - "Cannot specify both --body and --body-file", - ) - } - // Reject a malformed --id here rather than letting the API reject it, so // the user gets an actionable message instead of a raw GraphQL error. // CommentCreateInput.id is documented as "The identifier in UUID v4 @@ -74,22 +70,7 @@ export const commentAddCommand = new Command() ) } - // Read body from file if provided - let commentBody = body - if (bodyFile) { - try { - commentBody = await Deno.readTextFile(bodyFile) - } catch (error) { - throw new ValidationError( - `Failed to read body file: ${bodyFile}`, - { - suggestion: `Error: ${ - error instanceof Error ? error.message : String(error) - }`, - }, - ) - } - } + const textBody = await resolveCommentBody({ body, bodyFile }) const resolvedIdentifier = await getIssueIdentifier(issueId) if (!resolvedIdentifier) { @@ -142,81 +123,26 @@ export const commentAddCommand = new Command() } } - // If no body provided and no attachments, prompt for it - if (!commentBody && uploadedFiles.length === 0) { - commentBody = await Input.prompt({ - message: "Comment body", - default: "", - }) - - if (!commentBody.trim()) { - throw new ValidationError("Comment body cannot be empty") - } - } + // Attachment links alone are a valid body; otherwise prompt for text. + const promptedBody = textBody == null && uploadedFiles.length === 0 + ? await promptCommentBody() + : textBody - // Append attachment links to comment body - if (uploadedFiles.length > 0) { - const attachmentLinks = uploadedFiles.map((file) => { - return formatAsMarkdownLink({ - filename: file.filename, - assetUrl: file.assetUrl, - contentType: file.isImage - ? "image/png" - : "application/octet-stream", - }) + const attachmentLinks = uploadedFiles.map((file) => + formatAsMarkdownLink({ + filename: file.filename, + assetUrl: file.assetUrl, + contentType: file.isImage ? "image/png" : "application/octet-stream", }) - - if (commentBody) { - commentBody = `${commentBody}\n\n${attachmentLinks.join("\n")}` - } else { - commentBody = attachmentLinks.join("\n") - } - } - - const mutation = gql(` - mutation AddComment($input: CommentCreateInput!) { - commentCreate(input: $input) { - success - comment { - id - body - createdAt - url - user { - name - displayName - } - } - } - } - `) - - const client = getGraphQLClient() - const input: Record = { - body: commentBody, - issueId: resolvedIdentifier, - } - - if (id != null) { - input.id = id - } - - if (parent) { - input.parentId = parent - } - - const data = await client.request(mutation, { - input, - }) - - if (!data.commentCreate.success) { - throw new CliError("Failed to create comment") - } - - const comment = data.commentCreate.comment - if (!comment) { - throw new CliError("Comment creation failed - no comment returned") - } + ) + const commentBody = [promptedBody, attachmentLinks.join("\n")] + .filter((part) => part != null && part !== "") + .join("\n\n") + + const comment = await createComment( + { kind: "issue", issueId: resolvedIdentifier }, + { body: commentBody, parentId: parent, id }, + ) console.log(`✓ Comment added to ${resolvedIdentifier}`) console.log(comment.url) diff --git a/src/commands/issue/issue-comment-list.ts b/src/commands/issue/issue-comment-list.ts index f56bbd7e..437be4c4 100644 --- a/src/commands/issue/issue-comment-list.ts +++ b/src/commands/issue/issue-comment-list.ts @@ -2,35 +2,32 @@ import { Command } from "@cliffy/command" import { gql } from "../../__codegen__/gql.ts" import { getGraphQLClient } from "../../utils/graphql.ts" import { getIssueIdentifier } from "../../utils/linear.ts" -import { formatRelativeTime } from "../../utils/display.ts" -import { bold } from "@std/fmt/colors" -import { handleError, ValidationError } from "../../utils/errors.ts" - -// Structural shape over the generated comment node. A comment is authored by a -// workspace user, an external user, or an integration; only one is ever set. -interface CommentAuthorFields { - user?: { name: string; displayName: string } | null - externalUser?: { name: string; displayName: string } | null - botActor?: { name?: string | null; type: string } | null -} - -/** - * The name to render for a comment's author. - * - * Integration-authored comments have neither `user` nor `externalUser`, so they - * used to fall all the way through to "Unknown". `botActor` is checked last so - * that a comment carrying both a user and a bot actor still renders the human. - */ -function formatCommentAuthor(comment: CommentAuthorFields): string { - const human = comment.user?.displayName || comment.user?.name || - comment.externalUser?.displayName || comment.externalUser?.name - if (human) return human - - const bot = comment.botActor - if (bot == null) return "Unknown" - // ActorBot.name is nullable; type ("github", "slack", ...) is not. - return bot.name || bot.type -} +import { + handleError, + NotFoundError, + translateNotFound, + ValidationError, +} from "../../utils/errors.ts" +import { + collectCommentPages, + renderCommentThreads, +} from "../../utils/comments.ts" + +const GetIssueComments = gql(` + query GetIssueComments($id: String!, $after: String) { + issue(id: $id) { + comments(first: 50, after: $after, orderBy: createdAt) { + nodes { + ...CommentListFields + } + pageInfo { + hasNextPage + endCursor + } + } + } + } +`) export const commentListCommand = new Command() .name("list") @@ -49,136 +46,28 @@ export const commentListCommand = new Command() ) } - const query = gql(` - query GetIssueComments($id: String!) { - issue(id: $id) { - comments(first: 50, orderBy: createdAt) { - nodes { - id - body - createdAt - updatedAt - editedAt - url - user { - id - name - displayName - } - externalUser { - id - name - displayName - } - botActor { - id - name - type - subType - } - parent { - id - } - } - pageInfo { - hasNextPage - endCursor - } - } - } - } - `) - const client = getGraphQLClient() - const data = await client.request(query, { id: resolvedIdentifier }) - - const commentsConnection = data.issue?.comments ?? { - nodes: [], - pageInfo: { - hasNextPage: false, - endCursor: null, - }, - } - const comments = commentsConnection.nodes + const comments = await collectCommentPages(async (after) => { + const data = await translateNotFound( + "Issue", + resolvedIdentifier, + () => + client.request(GetIssueComments, { id: resolvedIdentifier, after }), + ) + if (!data.issue) { + throw new NotFoundError("Issue", resolvedIdentifier) + } + return data.issue.comments + }) if (json) { - console.log(JSON.stringify(commentsConnection, null, 2)) - return - } - - if (comments.length === 0) { - console.log("No comments found for this issue") + console.log(JSON.stringify(comments, null, 2)) return } - // Separate root comments from replies - const rootComments = comments.filter( - (comment: typeof comments[0]) => !comment.parent, - ) - const replies = comments.filter( - (comment: typeof comments[0]) => comment.parent, - ) - - // Create a map of parent ID to replies - const repliesMap = new Map< - string, - Array<(typeof comments)[0]> - >() - replies.forEach((reply: typeof comments[0]) => { - const parentId = reply.parent!.id - if (!repliesMap.has(parentId)) { - repliesMap.set(parentId, []) - } - repliesMap.get(parentId)!.push(reply) + renderCommentThreads(comments.nodes, { + emptyMessage: "No comments found for this issue", }) - - // Sort root comments by creation date (newest first) - const sortedRootComments = rootComments - .slice() - .sort( - (a: typeof comments[0], b: typeof comments[0]) => - new Date(b.createdAt).getTime() - - new Date(a.createdAt).getTime(), - ) - - for (const rootComment of sortedRootComments) { - const threadReplies = repliesMap.get(rootComment.id) || [] - - // Sort replies by creation date (oldest first within thread) - threadReplies.sort( - (a: typeof comments[0], b: typeof comments[0]) => - new Date(a.createdAt).getTime() - - new Date(b.createdAt).getTime(), - ) - - const author = formatCommentAuthor(rootComment) - const date = formatRelativeTime(rootComment.createdAt) - - console.log( - bold(`@${author}`) + ` commented ${date} [${rootComment.id}]`, - ) - console.log(rootComment.body) - - // Format replies if any - if (threadReplies.length > 0) { - console.log("") - for (const reply of threadReplies) { - const replyAuthor = formatCommentAuthor(reply) - const replyDate = formatRelativeTime(reply.createdAt) - - console.log( - ` ${bold(`@${replyAuthor}`)} replied ${replyDate} [${reply.id}]`, - ) - const indentedBody = reply.body - .split("\n") - .map((line: string) => ` ${line}`) - .join("\n") - console.log(indentedBody) - } - } - - console.log("") - } } catch (error) { handleError(error, "Failed to list comments") } diff --git a/src/commands/project/project-comment-add.ts b/src/commands/project/project-comment-add.ts new file mode 100644 index 00000000..8eb99057 --- /dev/null +++ b/src/commands/project/project-comment-add.ts @@ -0,0 +1,43 @@ +import { Command } from "@cliffy/command" +import { resolveProjectId } from "../../utils/linear.ts" +import { handleError } from "../../utils/errors.ts" +import { withMarkdownHint } from "../../utils/markdown-help.ts" +import { + COMMENT_BODY_DESCRIPTION, + COMMENT_BODY_FILE_DESCRIPTION, + createComment, + promptCommentBody, + REPLY_TO_DESCRIPTION, + resolveCommentBody, +} from "../../utils/comments.ts" + +export const commentAddCommand = new Command() + .name("add") + .description( + withMarkdownHint( + "Add a comment or reply to a project's discussion (by ID, slug, or name)", + ), + ) + .arguments("") + .option("-b, --body ", COMMENT_BODY_DESCRIPTION) + .option("--body-file ", COMMENT_BODY_FILE_DESCRIPTION) + .option("-p, --parent, --reply-to ", REPLY_TO_DESCRIPTION) + .action(async (options, project) => { + const { body, bodyFile, parent } = options + + try { + const textBody = await resolveCommentBody({ body, bodyFile }) + const projectId = await resolveProjectId(project) + const commentBody = textBody ?? await promptCommentBody() + + const comment = await createComment( + { kind: "project", projectId }, + { body: commentBody, parentId: parent }, + ) + + console.log(`✓ Comment added to project ${project}`) + console.log(comment.url) + } catch (error) { + handleError(error, "Failed to add comment") + } + }) diff --git a/src/commands/project/project-comment-list.ts b/src/commands/project/project-comment-list.ts new file mode 100644 index 00000000..6ac2d0ed --- /dev/null +++ b/src/commands/project/project-comment-list.ts @@ -0,0 +1,84 @@ +import { Command } from "@cliffy/command" +import { gql } from "../../__codegen__/gql.ts" +import { getGraphQLClient } from "../../utils/graphql.ts" +import { resolveProjectId } from "../../utils/linear.ts" +import { + handleError, + NotFoundError, + translateNotFound, +} from "../../utils/errors.ts" +import { + collectCommentPages, + renderCommentThreads, +} from "../../utils/comments.ts" + +// Comments created with `projectId` live in the project's discussion thread. +// The schema's `Project.comments` connection does not return them (verified +// against the live API), so list through the root `comments` query filtered by +// project. The project itself is selected in the same operation so an unknown +// UUID -- which resolveProjectId passes through unchecked -- is reported as +// not found instead of as an empty list. `project(id:)` takes String!, while +// the filter's `eq` takes ID!, hence two variables carrying the same value. +const GetProjectComments = gql(` + query GetProjectComments($id: String!, $filterId: ID!, $after: String) { + project(id: $id) { + id + name + } + comments( + first: 50 + after: $after + orderBy: createdAt + filter: { project: { id: { eq: $filterId } } } + ) { + nodes { + ...CommentListFields + } + pageInfo { + hasNextPage + endCursor + } + } + } +`) + +export const commentListCommand = new Command() + .name("list") + .description("List comments on a project (by ID, slug, or name)") + .arguments("") + .option("-j, --json", "Output as JSON") + .action(async (options, project) => { + const { json } = options + + try { + const projectId = await resolveProjectId(project) + const client = getGraphQLClient() + const comments = await collectCommentPages(async (after) => { + const data = await translateNotFound( + "Project", + project, + () => + client.request(GetProjectComments, { + id: projectId, + filterId: projectId, + after, + }), + ) + if (!data.project) { + throw new NotFoundError("Project", project) + } + return data.comments + }) + + if (json) { + console.log(JSON.stringify(comments, null, 2)) + return + } + + renderCommentThreads(comments.nodes, { + emptyMessage: "No comments found for this project", + }) + } catch (error) { + handleError(error, "Failed to list comments") + } + }) diff --git a/src/commands/project/project-comment.ts b/src/commands/project/project-comment.ts new file mode 100644 index 00000000..a2e6bbcc --- /dev/null +++ b/src/commands/project/project-comment.ts @@ -0,0 +1,11 @@ +import { Command } from "@cliffy/command" +import { commentAddCommand } from "./project-comment-add.ts" +import { commentListCommand } from "./project-comment-list.ts" + +export const commentCommand = new Command() + .description("Manage project comments") + .action(function () { + this.showHelp() + }) + .command("add", commentAddCommand) + .command("list", commentListCommand) diff --git a/src/commands/project/project.ts b/src/commands/project/project.ts index 670b8e11..9563d1e5 100644 --- a/src/commands/project/project.ts +++ b/src/commands/project/project.ts @@ -4,6 +4,7 @@ import { viewCommand } from "./project-view.ts" import { createCommand } from "./project-create.ts" import { updateCommand } from "./project-update.ts" import { deleteCommand } from "./project-delete.ts" +import { commentCommand } from "./project-comment.ts" export const projectCommand = new Command() .description("Manage Linear projects") @@ -15,3 +16,4 @@ export const projectCommand = new Command() .command("create", createCommand) .command("update", updateCommand) .command("delete", deleteCommand) + .command("comment", commentCommand) diff --git a/src/utils/comments.ts b/src/utils/comments.ts new file mode 100644 index 00000000..d58da1e8 --- /dev/null +++ b/src/utils/comments.ts @@ -0,0 +1,384 @@ +// Everything about comments that does not depend on which entity they hang +// off. Issues, documents, projects, and initiatives each own their argument +// resolution and their list query; the create mutation, body handling, the +// selection set, pagination, and the threaded rendering live here so the four +// surfaces cannot drift apart. + +import { Input } from "@cliffy/prompt" +import { bold } from "@std/fmt/colors" +import { gql } from "../__codegen__/gql.ts" +import type { + CommentCreateInput, + CommentListFieldsFragment, +} from "../__codegen__/graphql.ts" +import { getGraphQLClient } from "./graphql.ts" +import { formatRelativeTime } from "./display.ts" +import { CliError, ValidationError } from "./errors.ts" + +/** Shared option descriptions so the four `comment add` commands read alike. */ +export const COMMENT_BODY_DESCRIPTION = "Comment body text" +export const COMMENT_BODY_FILE_DESCRIPTION = + "Read comment body from a file (preferred for markdown content)" +export const REPLY_TO_DESCRIPTION = + "Reply to a top-level comment by ID (the reply joins that thread)" + +/** + * The entity a new comment is attached to. Linear's `CommentCreateInput` + * requires exactly one of these even for replies -- a `parentId` on its own is + * rejected -- so every caller names its target explicitly and the input is + * built in one place. + */ +export type CommentTarget = + | { kind: "issue"; issueId: string } + | { kind: "document"; documentContentId: string } + | { kind: "project"; projectId: string } + | { kind: "initiative"; initiativeId: string } + +export interface CreateCommentOptions { + body: string + /** Top-level comment to reply to. Linear rejects a reply to a reply. */ + parentId?: string + /** Caller-supplied UUID v4, for idempotent retries. */ + id?: string +} + +export function buildCommentCreateInput( + target: CommentTarget, + options: CreateCommentOptions, +): CommentCreateInput { + const input: CommentCreateInput = { body: options.body } + if (options.parentId != null) { + input.parentId = options.parentId + } + if (options.id != null) { + input.id = options.id + } + + switch (target.kind) { + case "issue": + input.issueId = target.issueId + break + case "document": + input.documentContentId = target.documentContentId + break + case "project": + input.projectId = target.projectId + break + case "initiative": + input.initiativeId = target.initiativeId + break + default: { + const unreachable: never = target + throw new Error(`Unknown comment target: ${JSON.stringify(unreachable)}`) + } + } + return input +} + +const AddComment = gql(` + mutation AddComment($input: CommentCreateInput!) { + commentCreate(input: $input) { + success + comment { + id + url + } + } + } +`) + +/** Create a comment (or reply) on the given target and return its id and URL. */ +export async function createComment( + target: CommentTarget, + options: CreateCommentOptions, +): Promise<{ id: string; url: string }> { + const client = getGraphQLClient() + const data = await client.request(AddComment, { + input: buildCommentCreateInput(target, options), + }) + + if (!data.commentCreate.success) { + throw new CliError("Failed to create comment") + } + + const comment = data.commentCreate.comment + if (!comment) { + throw new CliError("Comment creation failed - no comment returned") + } + return comment +} + +/** + * Turn the `--body` / `--body-file` flags into a body, or `undefined` when + * neither was given so the caller can prompt. Explicitly supplied input that is + * blank is an error, never a fallback to the prompt. + */ +export async function resolveCommentBody( + options: { body?: string; bodyFile?: string }, +): Promise { + const { body, bodyFile } = options + + if (body != null && bodyFile != null) { + throw new ValidationError("Cannot specify both --body and --body-file") + } + + if (bodyFile != null) { + let content: string + try { + content = await Deno.readTextFile(bodyFile) + } catch (error) { + throw new ValidationError( + `Failed to read body file: ${bodyFile}`, + { + suggestion: `Error: ${ + error instanceof Error ? error.message : String(error) + }`, + }, + ) + } + if (!content.trim()) { + throw new ValidationError( + `Body file is empty: ${bodyFile}`, + { suggestion: "Write the comment into the file, or use --body." }, + ) + } + return content + } + + if (body != null) { + if (!body.trim()) { + throw new ValidationError( + "Comment body cannot be empty", + { suggestion: "Pass text with --body, or omit it to be prompted." }, + ) + } + return body + } + + return undefined +} + +/** Interactive fallback when no body flag was given. */ +export async function promptCommentBody(): Promise { + const body = await Input.prompt({ + message: "Comment body", + default: "", + }) + + if (!body.trim()) { + throw new ValidationError("Comment body cannot be empty") + } + return body +} + +/** + * The fields every `comment list --json` node carries. `quotedText` is set on + * inline comments anchored to text (documents, issue descriptions); + * `parent.id` is set on replies. + */ +export const CommentListFields = gql(` + fragment CommentListFields on Comment { + id + body + quotedText + createdAt + updatedAt + editedAt + url + user { + id + name + displayName + } + externalUser { + id + name + displayName + } + botActor { + id + name + type + subType + } + parent { + id + } + } +`) + +export type CommentListNode = CommentListFieldsFragment + +export interface CommentPage { + nodes: Node[] + pageInfo: Info +} + +export interface CommentPageInfo { + hasNextPage: boolean + endCursor?: string | null +} + +/** + * Fetch every page of a comment connection and return it in the same + * `{ nodes, pageInfo }` shape (all nodes, the last page's pageInfo), so + * `--json` output stays a GraphQL connection. Throws rather than looping or + * returning a partial list if Linear reports another page without a usable + * cursor. + */ +export async function collectCommentPages< + Node, + Info extends CommentPageInfo, +>( + fetchPage: (after: string | null) => Promise>, +): Promise> { + const nodes: Node[] = [] + const seenCursors = new Set() + let after: string | null = null + + while (true) { + const page = await fetchPage(after) + nodes.push(...page.nodes) + + if (!page.pageInfo.hasNextPage) { + return { nodes, pageInfo: page.pageInfo } + } + + const cursor = page.pageInfo.endCursor + if (cursor == null || seenCursors.has(cursor)) { + throw new CliError( + "Linear reported more comments but did not return a usable cursor", + { suggestion: "Rerun the command; if it persists, report it." }, + ) + } + seenCursors.add(cursor) + after = cursor + } +} + +// Structural shape over the generated comment node. A comment is authored by a +// workspace user, an external user, or an integration; only one is ever set. +interface CommentAuthorFields { + user?: { name: string; displayName: string } | null + externalUser?: { name: string; displayName: string } | null + botActor?: { name?: string | null; type: string } | null +} + +/** + * The name to render for a comment's author. + * + * Integration-authored comments have neither `user` nor `externalUser`, so they + * used to fall all the way through to "Unknown". `botActor` is checked last so + * that a comment carrying both a user and a bot actor still renders the human. + */ +export function formatCommentAuthor(comment: CommentAuthorFields): string { + const human = comment.user?.displayName || comment.user?.name || + comment.externalUser?.displayName || comment.externalUser?.name + if (human) return human + + const bot = comment.botActor + if (bot == null) return "Unknown" + // ActorBot.name is nullable; type ("github", "slack", ...) is not. + return bot.name || bot.type +} + +// The subset of the fragment the renderer needs, so callers whose selection +// predates the fragment (or test fixtures) still type-check. +export interface RenderableComment extends CommentAuthorFields { + id: string + body: string + createdAt: string + quotedText?: string | null + parent?: { id: string } | null +} + +function byCreatedAt(direction: "asc" | "desc") { + return (a: RenderableComment, b: RenderableComment) => { + const delta = new Date(a.createdAt).getTime() - + new Date(b.createdAt).getTime() + return direction === "asc" ? delta : -delta + } +} + +function indent(text: string): string { + return text + .split("\n") + .map((line) => ` ${line}`) + .join("\n") +} + +/** + * Print comments as threads: root comments newest first, each followed by its + * replies oldest first. An inline comment shows the text it is anchored to. + * Replies whose parent is not in the list (for example a deleted root) are + * printed last, still labelled as replies, rather than dropped. + */ +export function renderCommentThreads( + comments: readonly RenderableComment[], + options: { emptyMessage: string }, +): void { + if (comments.length === 0) { + console.log(options.emptyMessage) + return + } + + const rootComments = comments.filter((comment) => comment.parent == null) + const rootIds = new Set(rootComments.map((comment) => comment.id)) + + const repliesByParent = new Map() + const orphanReplies: { reply: RenderableComment; parentId: string }[] = [] + for (const comment of comments) { + const parentId = comment.parent?.id + if (parentId == null) continue + if (!rootIds.has(parentId)) { + orphanReplies.push({ reply: comment, parentId }) + continue + } + const siblings = repliesByParent.get(parentId) ?? [] + siblings.push(comment) + repliesByParent.set(parentId, siblings) + } + + for (const rootComment of rootComments.slice().sort(byCreatedAt("desc"))) { + const author = formatCommentAuthor(rootComment) + const date = formatRelativeTime(rootComment.createdAt) + console.log( + bold(`@${author}`) + ` commented ${date} [${rootComment.id}]`, + ) + if (rootComment.quotedText != null) { + console.log(`> ${rootComment.quotedText}`) + } + console.log(rootComment.body) + + const replies = (repliesByParent.get(rootComment.id) ?? []) + .sort(byCreatedAt("asc")) + if (replies.length > 0) { + console.log("") + for (const reply of replies) { + console.log(indent(formatReplyHeader(reply, "replied"))) + if (reply.quotedText != null) { + console.log(indent(`> ${reply.quotedText}`)) + } + console.log(indent(reply.body)) + } + } + + console.log("") + } + + orphanReplies.sort((a, b) => byCreatedAt("asc")(a.reply, b.reply)) + for (const { reply, parentId } of orphanReplies) { + console.log(indent(formatReplyHeader(reply, `replied to [${parentId}]`))) + if (reply.quotedText != null) { + console.log(indent(`> ${reply.quotedText}`)) + } + console.log(indent(reply.body)) + console.log("") + } +} + +function formatReplyHeader(reply: RenderableComment, verb: string): string { + const author = formatCommentAuthor(reply) + const date = formatRelativeTime(reply.createdAt) + return `${bold(`@${author}`)} ${verb} ${date} [${reply.id}]` +} diff --git a/src/utils/errors.ts b/src/utils/errors.ts index 0593beb2..75c58b12 100644 --- a/src/utils/errors.ts +++ b/src/utils/errors.ts @@ -114,8 +114,11 @@ export function extractGraphQLMessage(error: ClientError): string { * Check if a GraphQL error indicates an entity was not found. */ export function isNotFoundError(error: ClientError): boolean { + // Linear's raw message is "Entity not found: ", but its + // userPresentableMessage -- which extractGraphQLMessage prefers -- reads + // "Could not find referenced .", so match both spellings. const message = extractGraphQLMessage(error).toLowerCase() - return message.includes("not found") || message.includes("entity not found") + return message.includes("not found") || message.includes("could not find") } /** @@ -125,6 +128,27 @@ export function isClientError(error: unknown): error is ClientError { return error instanceof ClientError } +/** + * Run a request whose root field is a non-null entity lookup (`issue(id:)`, + * `document(id:)`, `project(id:)`, `initiative(id:)`). Linear answers a missing + * entity with a GraphQL error rather than a null field, so translate that into + * a NotFoundError and let every other error propagate untouched. + */ +export async function translateNotFound( + entityType: string, + identifier: string, + request: () => Promise, +): Promise { + try { + return await request() + } catch (error) { + if (isClientError(error) && isNotFoundError(error)) { + throw new NotFoundError(entityType, identifier) + } + throw error + } +} + /** * Format and display an error to the user. * diff --git a/src/utils/linear.ts b/src/utils/linear.ts index 308d4ea8..2577a4cd 100644 --- a/src/utils/linear.ts +++ b/src/utils/linear.ts @@ -623,6 +623,7 @@ const issueDetailsWithCommentsQuery = gql(/* GraphQL */ ` nodes { id body + quotedText createdAt url resolvedAt diff --git a/src/utils/markdown-help.ts b/src/utils/markdown-help.ts index 4ad3a553..e4be66c1 100644 --- a/src/utils/markdown-help.ts +++ b/src/utils/markdown-help.ts @@ -1,6 +1,6 @@ // Linear-specific Markdown guidance, surfaced through `--help` so an agent // driving this CLI without the bundled skill still learns it. Both strings live -// here so the ten Markdown-writing commands, the `linear markdown` reference, +// here so the thirteen Markdown-writing commands, the `linear markdown` reference, // and the generated skill docs cannot drift apart. // // Cliffy pads description lines but does not re-wrap them, so the line breaks diff --git a/test/commands/document/__snapshots__/document-comment-add.test.ts.snap b/test/commands/document/__snapshots__/document-comment-add.test.ts.snap new file mode 100644 index 00000000..452f615a --- /dev/null +++ b/test/commands/document/__snapshots__/document-comment-add.test.ts.snap @@ -0,0 +1,54 @@ +export const snapshot = {}; + +snapshot[`Document Comment Add Command - With Body Flag 1`] = ` +stdout: +"✓ Comment added to document spec-abc123 +https://linear.app/team/document/spec-abc123#comment-uuid-1 +" +stderr: +"" +`; + +snapshot[`Document Comment Add Command - With Reply To Flag 1`] = ` +stdout: +"✓ Comment added to document spec-abc123 +https://linear.app/team/document/spec-abc123#comment-uuid-2 +" +stderr: +"" +`; + +snapshot[`Document Comment Add Command - With Body File 1`] = ` +stdout: +"✓ Comment added to document spec-abc123 +https://linear.app/team/document/spec-abc123#comment-uuid-3 +" +stderr: +"" +`; + +snapshot[`Document Comment Add Command - Help 1`] = ` +stdout: +" +Usage: add + +Description: + + Add a comment or reply to a document (by ID or slug) + + Linear Markdown: a plain Linear URL creates a mention; \`@name\`, \`@[Name](id)\`, + and \`[Name](url)\` do not. Get a person's URL from the \`url\` field of + \`linear team members --json\`, or an issue's from \`linear issue url \`. + Run \`linear markdown\` for collapsible sections and the full reference. + +Options: + + -h, --help - Show this help. + -b, --body - Comment body text + --body-file - Read comment body from a file (preferred for markdown content) + -p, --parent, --reply-to - Reply to a top-level comment by ID (the reply joins that thread) + +" +stderr: +"" +`; diff --git a/test/commands/document/__snapshots__/document-comment-list.test.ts.snap b/test/commands/document/__snapshots__/document-comment-list.test.ts.snap new file mode 100644 index 00000000..95b6ec1e --- /dev/null +++ b/test/commands/document/__snapshots__/document-comment-list.test.ts.snap @@ -0,0 +1,162 @@ +export const snapshot = {}; + +snapshot[`Document Comment List Command - Threads With Inline Comment 1`] = ` +stdout: +"@Ada Lovelace commented 1/15/2024 [comment-uuid-3] +> handles 500 requests per second +This number is out of date. + +@Ada Lovelace commented 1/15/2024 [comment-uuid-1] +Should this section mention the rollout plan? + + @Grace Hopper replied 1/15/2024 [comment-uuid-2] + Yes, adding it now. + +" +stderr: +"" +`; + +snapshot[`Document Comment List Command - JSON Output 1`] = ` +stdout: +'{ + "nodes": [ + { + "id": "comment-uuid-1", + "body": "Should this section mention the rollout plan?", + "quotedText": null, + "createdAt": "2024-01-15T10:30:00Z", + "updatedAt": "2024-01-15T10:30:00Z", + "editedAt": null, + "url": "https://linear.app/team/document/spec-abc123#comment-uuid-1", + "user": { + "id": "user-uuid-1", + "name": "ada", + "displayName": "Ada Lovelace" + }, + "externalUser": null, + "botActor": null, + "parent": null + }, + { + "id": "comment-uuid-2", + "body": "Yes, adding it now.", + "quotedText": null, + "createdAt": "2024-01-15T11:00:00Z", + "updatedAt": "2024-01-15T11:00:00Z", + "editedAt": null, + "url": "https://linear.app/team/document/spec-abc123#comment-uuid-2", + "user": { + "id": "user-uuid-2", + "name": "grace", + "displayName": "Grace Hopper" + }, + "externalUser": null, + "botActor": null, + "parent": { + "id": "comment-uuid-1" + } + }, + { + "id": "comment-uuid-3", + "body": "This number is out of date.", + "quotedText": "handles 500 requests per second", + "createdAt": "2024-01-15T12:30:00Z", + "updatedAt": "2024-01-15T12:30:00Z", + "editedAt": null, + "url": "https://linear.app/team/document/spec-abc123#comment-uuid-3", + "user": { + "id": "user-uuid-1", + "name": "ada", + "displayName": "Ada Lovelace" + }, + "externalUser": null, + "botActor": null, + "parent": null + } + ], + "pageInfo": { + "hasNextPage": false, + "endCursor": "comment-uuid-3" + } +} +' +stderr: +"" +`; + +snapshot[`Document Comment List Command - No Comments 1`] = ` +stdout: +"No comments found for this document +" +stderr: +"" +`; + +snapshot[`Document Comment List Command - JSON Output Follows Pagination 1`] = ` +stdout: +'{ + "nodes": [ + { + "id": "comment-uuid-1", + "body": "Should this section mention the rollout plan?", + "quotedText": null, + "createdAt": "2024-01-15T10:30:00Z", + "updatedAt": "2024-01-15T10:30:00Z", + "editedAt": null, + "url": "https://linear.app/team/document/spec-abc123#comment-uuid-1", + "user": { + "id": "user-uuid-1", + "name": "ada", + "displayName": "Ada Lovelace" + }, + "externalUser": null, + "botActor": null, + "parent": null + }, + { + "id": "comment-uuid-2", + "body": "Yes, adding it now.", + "quotedText": null, + "createdAt": "2024-01-15T11:00:00Z", + "updatedAt": "2024-01-15T11:00:00Z", + "editedAt": null, + "url": "https://linear.app/team/document/spec-abc123#comment-uuid-2", + "user": { + "id": "user-uuid-2", + "name": "grace", + "displayName": "Grace Hopper" + }, + "externalUser": null, + "botActor": null, + "parent": { + "id": "comment-uuid-1" + } + }, + { + "id": "comment-uuid-3", + "body": "This number is out of date.", + "quotedText": "handles 500 requests per second", + "createdAt": "2024-01-15T12:30:00Z", + "updatedAt": "2024-01-15T12:30:00Z", + "editedAt": null, + "url": "https://linear.app/team/document/spec-abc123#comment-uuid-3", + "user": { + "id": "user-uuid-1", + "name": "ada", + "displayName": "Ada Lovelace" + }, + "externalUser": null, + "botActor": null, + "parent": null + } + ], + "pageInfo": { + "hasNextPage": false, + "endCursor": "cursor-2" + } +} +' +stderr: +"" +`; diff --git a/test/commands/document/__snapshots__/document-comment.test.ts.snap b/test/commands/document/__snapshots__/document-comment.test.ts.snap new file mode 100644 index 00000000..f442e152 --- /dev/null +++ b/test/commands/document/__snapshots__/document-comment.test.ts.snap @@ -0,0 +1,24 @@ +export const snapshot = {}; + +snapshot[`Document Comment Command - Help Through Parent 1`] = ` +stdout: +" +Usage: document comment + +Description: + + Manage document comments + +Options: + + -h, --help - Show this help. + +Commands: + + add - Add a comment or reply to a document (by ID or slug) + list - List comments on a document (by ID or slug) + +" +stderr: +"" +`; diff --git a/test/commands/document/document-comment-add.test.ts b/test/commands/document/document-comment-add.test.ts new file mode 100644 index 00000000..a63499e5 --- /dev/null +++ b/test/commands/document/document-comment-add.test.ts @@ -0,0 +1,300 @@ +import { snapshotTest } from "@cliffy/testing" +import { assertEquals } from "@std/assert" +import { stub } from "@std/testing/mock" +import { commentAddCommand } from "../../../src/commands/document/document-comment-add.ts" +import { + commonDenoArgs, + setupMockLinearServer, +} from "../../utils/test-helpers.ts" + +const documentTarget = { + queryName: "GetDocumentCommentTarget", + variables: { id: "spec-abc123" }, + response: { + data: { + document: { + id: "doc-uuid-1", + title: "API Spec", + documentContentId: "content-uuid-1", + }, + }, + }, +} + +// A document comment hangs off the document's content record, so the mutation +// must carry documentContentId, not the document id the user typed. +await snapshotTest({ + name: "Document Comment Add Command - With Body Flag", + meta: import.meta, + colors: false, + args: ["spec-abc123", "--body", "Looks good to me"], + denoArgs: commonDenoArgs, + async fn() { + const { cleanup } = await setupMockLinearServer([ + documentTarget, + { + queryName: "AddComment", + variables: { + input: { + body: "Looks good to me", + documentContentId: "content-uuid-1", + }, + }, + response: { + data: { + commentCreate: { + success: true, + comment: { + id: "comment-uuid-1", + url: + "https://linear.app/team/document/spec-abc123#comment-uuid-1", + }, + }, + }, + }, + }, + ]) + + try { + await commentAddCommand.parse() + } finally { + await cleanup() + } + }, +}) + +// A reply still names the document content: Linear rejects a bare parentId. +await snapshotTest({ + name: "Document Comment Add Command - With Reply To Flag", + meta: import.meta, + colors: false, + args: [ + "spec-abc123", + "--body", + "Agreed", + "--reply-to", + "comment-uuid-1", + ], + denoArgs: commonDenoArgs, + async fn() { + const { cleanup } = await setupMockLinearServer([ + documentTarget, + { + queryName: "AddComment", + variables: { + input: { + body: "Agreed", + documentContentId: "content-uuid-1", + parentId: "comment-uuid-1", + }, + }, + response: { + data: { + commentCreate: { + success: true, + comment: { + id: "comment-uuid-2", + url: + "https://linear.app/team/document/spec-abc123#comment-uuid-2", + }, + }, + }, + }, + }, + ]) + + try { + await commentAddCommand.parse() + } finally { + await cleanup() + } + }, +}) + +await snapshotTest({ + name: "Document Comment Add Command - With Body File", + meta: import.meta, + colors: false, + args: ["spec-abc123", "--body-file", "__BODY_FILE__"], + denoArgs: commonDenoArgs, + async fn() { + const bodyFile = await Deno.makeTempFile({ suffix: ".md" }) + await Deno.writeTextFile(bodyFile, "## From a file\n\nWith **markdown**.\n") + const { cleanup } = await setupMockLinearServer([ + documentTarget, + { + queryName: "AddComment", + variables: { + input: { + body: "## From a file\n\nWith **markdown**.\n", + documentContentId: "content-uuid-1", + }, + }, + response: { + data: { + commentCreate: { + success: true, + comment: { + id: "comment-uuid-3", + url: + "https://linear.app/team/document/spec-abc123#comment-uuid-3", + }, + }, + }, + }, + }, + ]) + + try { + await commentAddCommand.parse([ + "spec-abc123", + "--body-file", + bodyFile, + ]) + } finally { + await cleanup() + await Deno.remove(bodyFile) + } + }, +}) + +await snapshotTest({ + name: "Document Comment Add Command - Help", + meta: import.meta, + colors: false, + args: ["--help"], + denoArgs: commonDenoArgs, + async fn() { + await commentAddCommand.parse() + }, +}) + +function captureFailure() { + const errorLogs: string[] = [] + const errorStub = stub(console, "error", (...args: unknown[]) => { + errorLogs.push(args.map(String).join(" ")) + }) + const exitStub = stub(Deno, "exit", (_code?: number) => { + throw new Error("EXIT") + }) + return { + errorLogs, + restore() { + errorStub.restore() + exitStub.restore() + }, + } +} + +async function expectExit(run: () => Promise): Promise { + try { + await run() + return false + } catch (e) { + if (!(e instanceof Error) || e.message !== "EXIT") throw e + return true + } +} + +Deno.test("Document Comment Add Command - rejects --body with --body-file before any request", async () => { + // No handlers: any request would fail with a different message. + const { cleanup } = await setupMockLinearServer([]) + const failure = captureFailure() + let exited = false + try { + exited = await expectExit(() => + commentAddCommand.parse([ + "spec-abc123", + "--body", + "x", + "--body-file", + "y.md", + ]) + ) + } finally { + failure.restore() + await cleanup() + } + + assertEquals(exited, true) + assertEquals( + failure.errorLogs.some((l) => + l.includes("Cannot specify both --body and --body-file") + ), + true, + failure.errorLogs.join("\n"), + ) +}) + +Deno.test("Document Comment Add Command - unknown document is reported as not found", async () => { + const { cleanup } = await setupMockLinearServer([ + { + queryName: "GetDocumentCommentTarget", + response: { + errors: [{ + message: "Entity not found: Document", + extensions: { + type: "invalid input", + userError: true, + userPresentableMessage: "Could not find referenced Document.", + }, + }], + }, + }, + ]) + const failure = captureFailure() + let exited = false + try { + exited = await expectExit(() => + commentAddCommand.parse(["doc-missing", "--body", "x"]) + ) + } finally { + failure.restore() + await cleanup() + } + + assertEquals(exited, true) + assertEquals( + failure.errorLogs.some((l) => + l.toLowerCase().includes("not found") && l.includes("doc-missing") + ), + true, + failure.errorLogs.join("\n"), + ) +}) + +Deno.test("Document Comment Add Command - refuses a document without a content record", async () => { + const { cleanup } = await setupMockLinearServer([ + { + queryName: "GetDocumentCommentTarget", + response: { + data: { + document: { + id: "doc-uuid-2", + title: "Empty Doc", + documentContentId: null, + }, + }, + }, + }, + ]) + const failure = captureFailure() + let exited = false + try { + exited = await expectExit(() => + commentAddCommand.parse(["empty-doc", "--body", "x"]) + ) + } finally { + failure.restore() + await cleanup() + } + + assertEquals(exited, true) + assertEquals( + failure.errorLogs.some((l) => + l.includes('Document "Empty Doc" has no content record') + ), + true, + failure.errorLogs.join("\n"), + ) +}) diff --git a/test/commands/document/document-comment-list.test.ts b/test/commands/document/document-comment-list.test.ts new file mode 100644 index 00000000..8c255c82 --- /dev/null +++ b/test/commands/document/document-comment-list.test.ts @@ -0,0 +1,237 @@ +import { snapshotTest } from "@cliffy/testing" +import { assertEquals } from "@std/assert" +import { stub } from "@std/testing/mock" +import { commentListCommand } from "../../../src/commands/document/document-comment-list.ts" +import { + commonDenoArgs, + setupMockLinearServer, +} from "../../utils/test-helpers.ts" + +// A document thread: a top-level comment with a reply, and an inline comment +// anchored to a passage (quotedText), which shows the quoted passage. +const documentComments = { + nodes: [ + { + id: "comment-uuid-1", + body: "Should this section mention the rollout plan?", + quotedText: null, + createdAt: "2024-01-15T10:30:00Z", + updatedAt: "2024-01-15T10:30:00Z", + editedAt: null, + url: "https://linear.app/team/document/spec-abc123#comment-uuid-1", + user: { id: "user-uuid-1", name: "ada", displayName: "Ada Lovelace" }, + externalUser: null, + botActor: null, + parent: null, + }, + { + id: "comment-uuid-2", + body: "Yes, adding it now.", + quotedText: null, + createdAt: "2024-01-15T11:00:00Z", + updatedAt: "2024-01-15T11:00:00Z", + editedAt: null, + url: "https://linear.app/team/document/spec-abc123#comment-uuid-2", + user: { id: "user-uuid-2", name: "grace", displayName: "Grace Hopper" }, + externalUser: null, + botActor: null, + parent: { id: "comment-uuid-1" }, + }, + { + id: "comment-uuid-3", + body: "This number is out of date.", + quotedText: "handles 500 requests per second", + createdAt: "2024-01-15T12:30:00Z", + updatedAt: "2024-01-15T12:30:00Z", + editedAt: null, + url: "https://linear.app/team/document/spec-abc123#comment-uuid-3", + user: { id: "user-uuid-1", name: "ada", displayName: "Ada Lovelace" }, + externalUser: null, + botActor: null, + parent: null, + }, + ], + pageInfo: { hasNextPage: false, endCursor: "comment-uuid-3" }, +} + +await snapshotTest({ + name: "Document Comment List Command - Threads With Inline Comment", + meta: import.meta, + colors: false, + args: ["spec-abc123"], + denoArgs: commonDenoArgs, + async fn() { + const { cleanup } = await setupMockLinearServer([ + { + queryName: "GetDocumentComments", + variables: { id: "spec-abc123", after: null }, + response: { + data: { document: { id: "doc-uuid-1", comments: documentComments } }, + }, + }, + ]) + + try { + await commentListCommand.parse() + } finally { + await cleanup() + } + }, +}) + +await snapshotTest({ + name: "Document Comment List Command - JSON Output", + meta: import.meta, + colors: false, + args: ["spec-abc123", "--json"], + denoArgs: commonDenoArgs, + async fn() { + const { cleanup } = await setupMockLinearServer([ + { + queryName: "GetDocumentComments", + // The selection must carry the inline anchor and the parent link. + queryIncludes: "quotedText", + variables: { id: "spec-abc123", after: null }, + response: { + data: { document: { id: "doc-uuid-1", comments: documentComments } }, + }, + }, + ]) + + try { + await commentListCommand.parse() + } finally { + await cleanup() + } + }, +}) + +await snapshotTest({ + name: "Document Comment List Command - No Comments", + meta: import.meta, + colors: false, + args: ["spec-abc123"], + denoArgs: commonDenoArgs, + async fn() { + const { cleanup } = await setupMockLinearServer([ + { + queryName: "GetDocumentComments", + response: { + data: { + document: { + id: "doc-uuid-1", + comments: { + nodes: [], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }, + }, + }, + }, + ]) + + try { + await commentListCommand.parse() + } finally { + await cleanup() + } + }, +}) + +// The mock server answers the first matching handler and never consumes it, +// so each page is pinned to its cursor. +await snapshotTest({ + name: "Document Comment List Command - JSON Output Follows Pagination", + meta: import.meta, + colors: false, + args: ["spec-abc123", "--json"], + denoArgs: commonDenoArgs, + async fn() { + const [first, second, third] = documentComments.nodes + const { cleanup } = await setupMockLinearServer([ + { + queryName: "GetDocumentComments", + variables: { id: "spec-abc123", after: null }, + response: { + data: { + document: { + id: "doc-uuid-1", + comments: { + nodes: [first, second], + pageInfo: { hasNextPage: true, endCursor: "cursor-1" }, + }, + }, + }, + }, + }, + { + queryName: "GetDocumentComments", + variables: { id: "spec-abc123", after: "cursor-1" }, + response: { + data: { + document: { + id: "doc-uuid-1", + comments: { + nodes: [third], + pageInfo: { hasNextPage: false, endCursor: "cursor-2" }, + }, + }, + }, + }, + }, + ]) + + try { + await commentListCommand.parse() + } finally { + await cleanup() + } + }, +}) + +Deno.test("Document Comment List Command - unknown document is reported as not found", async () => { + const { cleanup } = await setupMockLinearServer([ + { + queryName: "GetDocumentComments", + response: { + errors: [{ + message: "Entity not found: Document", + extensions: { + type: "invalid input", + userError: true, + userPresentableMessage: "Could not find referenced Document.", + }, + }], + }, + }, + ]) + + const errorLogs: string[] = [] + const errorStub = stub(console, "error", (...args: unknown[]) => { + errorLogs.push(args.map(String).join(" ")) + }) + const exitStub = stub(Deno, "exit", (_code?: number) => { + throw new Error("EXIT") + }) + + let exited = false + try { + await commentListCommand.parse(["doc-missing"]) + } catch (e) { + if (!(e instanceof Error) || e.message !== "EXIT") throw e + exited = true + } finally { + errorStub.restore() + exitStub.restore() + await cleanup() + } + + assertEquals(exited, true) + assertEquals( + errorLogs.some((l) => + l.toLowerCase().includes("not found") && l.includes("doc-missing") + ), + true, + errorLogs.join("\n"), + ) +}) diff --git a/test/commands/document/document-comment.test.ts b/test/commands/document/document-comment.test.ts new file mode 100644 index 00000000..99aaa54d --- /dev/null +++ b/test/commands/document/document-comment.test.ts @@ -0,0 +1,16 @@ +import { snapshotTest } from "@cliffy/testing" +import { documentCommand } from "../../../src/commands/document/document.ts" +import { commonDenoArgs } from "../../utils/test-helpers.ts" + +// Goes through the parent command so a missing `.command("comment", ...)` +// registration fails here, not only in a live shell. +await snapshotTest({ + name: "Document Comment Command - Help Through Parent", + meta: import.meta, + colors: false, + args: ["comment", "--help"], + denoArgs: commonDenoArgs, + async fn() { + await documentCommand.parse() + }, +}) diff --git a/test/commands/document/document-view.test.ts b/test/commands/document/document-view.test.ts index 33f08154..751b688d 100644 --- a/test/commands/document/document-view.test.ts +++ b/test/commands/document/document-view.test.ts @@ -1,4 +1,6 @@ import { snapshotTest } from "@cliffy/testing" +import { assertEquals } from "@std/assert" +import { stub } from "@std/testing/mock" import { viewCommand } from "../../../src/commands/document/document-view.ts" import { MockLinearServer } from "../../utils/mock_linear_server.ts" import { commonDenoArgs } from "../../utils/test-helpers.ts" @@ -369,3 +371,59 @@ await snapshotTest({ } }, }) + +// Linear reports an unknown document as a GraphQL error whose user-facing +// message is "Could not find referenced Document." (no "not found" in it). +// That has to become a clean not-found message naming the reference; the +// command used to re-throw from its catch block and print a stack trace. +Deno.test("Document View Command - unknown document is reported as not found", async () => { + const server = new MockLinearServer([ + { + queryName: "GetDocument", + response: { + errors: [{ + message: "Entity not found: Document", + extensions: { + type: "invalid input", + userError: true, + userPresentableMessage: "Could not find referenced Document.", + }, + }], + }, + }, + ]) + await server.start() + Deno.env.set("LINEAR_GRAPHQL_ENDPOINT", server.getEndpoint()) + Deno.env.set("LINEAR_API_KEY", "Bearer test-token") + + const errorLogs: string[] = [] + const errorStub = stub(console, "error", (...args: unknown[]) => { + errorLogs.push(args.map(String).join(" ")) + }) + const exitStub = stub(Deno, "exit", (_code?: number) => { + throw new Error("EXIT") + }) + + let exited = false + try { + await viewCommand.parse(["doc-missing", "--raw"]) + } catch (e) { + if (!(e instanceof Error) || e.message !== "EXIT") throw e + exited = true + } finally { + errorStub.restore() + exitStub.restore() + await server.stop() + Deno.env.delete("LINEAR_GRAPHQL_ENDPOINT") + Deno.env.delete("LINEAR_API_KEY") + } + + assertEquals(exited, true) + assertEquals( + errorLogs.some((l) => + l.toLowerCase().includes("not found") && l.includes("doc-missing") + ), + true, + errorLogs.join("\n"), + ) +}) diff --git a/test/commands/initiative/__snapshots__/initiative-comment-add.test.ts.snap b/test/commands/initiative/__snapshots__/initiative-comment-add.test.ts.snap new file mode 100644 index 00000000..4c46ec56 --- /dev/null +++ b/test/commands/initiative/__snapshots__/initiative-comment-add.test.ts.snap @@ -0,0 +1,45 @@ +export const snapshot = {}; + +snapshot[`Initiative Comment Add Command - By UUID With Body Flag 1`] = ` +stdout: +"✓ Comment added to initiative 0a1b2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d +https://linear.app/team/initiative/platform-abc123/activity#comment-uuid-1 +" +stderr: +"" +`; + +snapshot[`Initiative Comment Add Command - By Name With Reply To Flag 1`] = ` +stdout: +"✓ Comment added to initiative Platform +https://linear.app/team/initiative/platform-abc123/activity#comment-uuid-2 +" +stderr: +"" +`; + +snapshot[`Initiative Comment Add Command - Help 1`] = ` +stdout: +" +Usage: add + +Description: + + Add a comment or reply to an initiative's discussion (by ID, slug, or name) + + Linear Markdown: a plain Linear URL creates a mention; \`@name\`, \`@[Name](id)\`, + and \`[Name](url)\` do not. Get a person's URL from the \`url\` field of + \`linear team members --json\`, or an issue's from \`linear issue url \`. + Run \`linear markdown\` for collapsible sections and the full reference. + +Options: + + -h, --help - Show this help. + -b, --body - Comment body text + --body-file - Read comment body from a file (preferred for markdown content) + -p, --parent, --reply-to - Reply to a top-level comment by ID (the reply joins that thread) + +" +stderr: +"" +`; diff --git a/test/commands/initiative/__snapshots__/initiative-comment-list.test.ts.snap b/test/commands/initiative/__snapshots__/initiative-comment-list.test.ts.snap new file mode 100644 index 00000000..1304e197 --- /dev/null +++ b/test/commands/initiative/__snapshots__/initiative-comment-list.test.ts.snap @@ -0,0 +1,74 @@ +export const snapshot = {}; + +snapshot[`Initiative Comment List Command - By UUID 1`] = ` +stdout: +"@Ada Lovelace commented 1/15/2024 [comment-uuid-1] +Scope is locked for Q3. + + @Slack replied 1/15/2024 [comment-uuid-2] + Noted. + +" +stderr: +"" +`; + +snapshot[`Initiative Comment List Command - By Slug JSON Output 1`] = ` +stdout: +'{ + "nodes": [ + { + "id": "comment-uuid-1", + "body": "Scope is locked for Q3.", + "quotedText": null, + "createdAt": "2024-01-15T10:30:00Z", + "updatedAt": "2024-01-15T10:30:00Z", + "editedAt": null, + "url": "https://linear.app/team/initiative/platform-abc123/activity#comment-uuid-1", + "user": { + "id": "user-uuid-1", + "name": "ada", + "displayName": "Ada Lovelace" + }, + "externalUser": null, + "botActor": null, + "parent": null + }, + { + "id": "comment-uuid-2", + "body": "Noted.", + "quotedText": null, + "createdAt": "2024-01-15T11:00:00Z", + "updatedAt": "2024-01-15T11:00:00Z", + "editedAt": null, + "url": "https://linear.app/team/initiative/platform-abc123/activity#comment-uuid-2", + "user": null, + "externalUser": null, + "botActor": { + "id": "bot-uuid-1", + "name": "Slack", + "type": "slack", + "subType": null + }, + "parent": { + "id": "comment-uuid-1" + } + } + ], + "pageInfo": { + "hasNextPage": false, + "endCursor": "comment-uuid-2" + } +} +' +stderr: +"" +`; + +snapshot[`Initiative Comment List Command - No Comments 1`] = ` +stdout: +"No comments found for this initiative +" +stderr: +"" +`; diff --git a/test/commands/initiative/__snapshots__/initiative-comment.test.ts.snap b/test/commands/initiative/__snapshots__/initiative-comment.test.ts.snap new file mode 100644 index 00000000..88a7c533 --- /dev/null +++ b/test/commands/initiative/__snapshots__/initiative-comment.test.ts.snap @@ -0,0 +1,24 @@ +export const snapshot = {}; + +snapshot[`Initiative Comment Command - Help Through Parent 1`] = ` +stdout: +" +Usage: COMMAND comment + +Description: + + Manage initiative comments + +Options: + + -h, --help - Show this help. + +Commands: + + add - Add a comment or reply to an initiative's discussion (by ID, slug, or name) + list - List comments on an initiative (by ID, slug, or name) + +" +stderr: +"" +`; diff --git a/test/commands/initiative/initiative-comment-add.test.ts b/test/commands/initiative/initiative-comment-add.test.ts new file mode 100644 index 00000000..1fa5e90b --- /dev/null +++ b/test/commands/initiative/initiative-comment-add.test.ts @@ -0,0 +1,121 @@ +import { snapshotTest } from "@cliffy/testing" +import { commentAddCommand } from "../../../src/commands/initiative/initiative-comment-add.ts" +import { + commonDenoArgs, + setupMockLinearServer, +} from "../../utils/test-helpers.ts" + +const INITIATIVE_ID = "0a1b2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d" + +await snapshotTest({ + name: "Initiative Comment Add Command - By UUID With Body Flag", + meta: import.meta, + colors: false, + args: [INITIATIVE_ID, "--body", "Scope is locked for Q3."], + denoArgs: commonDenoArgs, + async fn() { + const { cleanup } = await setupMockLinearServer([ + { + queryName: "AddComment", + variables: { + input: { + body: "Scope is locked for Q3.", + initiativeId: INITIATIVE_ID, + }, + }, + response: { + data: { + commentCreate: { + success: true, + comment: { + id: "comment-uuid-1", + url: + "https://linear.app/team/initiative/platform-abc123/activity#comment-uuid-1", + }, + }, + }, + }, + }, + ]) + + try { + await commentAddCommand.parse() + } finally { + await cleanup() + } + }, +}) + +// A name is resolved through the shared initiative resolver (slug first, then +// case-insensitive name), and a reply still carries initiativeId next to +// parentId. +await snapshotTest({ + name: "Initiative Comment Add Command - By Name With Reply To Flag", + meta: import.meta, + colors: false, + args: ["Platform", "--body", "Noted.", "--reply-to", "comment-uuid-1"], + denoArgs: commonDenoArgs, + async fn() { + const { cleanup } = await setupMockLinearServer([ + { + queryName: "ResolveInitiativeBySlug", + variables: { slugId: "Platform" }, + response: { data: { initiatives: { nodes: [] } } }, + }, + { + queryName: "ResolveInitiativeByName", + variables: { name: "Platform" }, + response: { + data: { + initiatives: { + nodes: [{ + id: INITIATIVE_ID, + name: "Platform", + slugId: "platform-abc123", + }], + }, + }, + }, + }, + { + queryName: "AddComment", + variables: { + input: { + body: "Noted.", + initiativeId: INITIATIVE_ID, + parentId: "comment-uuid-1", + }, + }, + response: { + data: { + commentCreate: { + success: true, + comment: { + id: "comment-uuid-2", + url: + "https://linear.app/team/initiative/platform-abc123/activity#comment-uuid-2", + }, + }, + }, + }, + }, + ]) + + try { + await commentAddCommand.parse() + } finally { + await cleanup() + } + }, +}) + +await snapshotTest({ + name: "Initiative Comment Add Command - Help", + meta: import.meta, + colors: false, + args: ["--help"], + denoArgs: commonDenoArgs, + async fn() { + await commentAddCommand.parse() + }, +}) diff --git a/test/commands/initiative/initiative-comment-list.test.ts b/test/commands/initiative/initiative-comment-list.test.ts new file mode 100644 index 00000000..90fdb8ba --- /dev/null +++ b/test/commands/initiative/initiative-comment-list.test.ts @@ -0,0 +1,146 @@ +import { snapshotTest } from "@cliffy/testing" +import { commentListCommand } from "../../../src/commands/initiative/initiative-comment-list.ts" +import { + commonDenoArgs, + setupMockLinearServer, +} from "../../utils/test-helpers.ts" + +const INITIATIVE_ID = "0a1b2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d" + +const initiativeComments = { + nodes: [ + { + id: "comment-uuid-1", + body: "Scope is locked for Q3.", + quotedText: null, + createdAt: "2024-01-15T10:30:00Z", + updatedAt: "2024-01-15T10:30:00Z", + editedAt: null, + url: + "https://linear.app/team/initiative/platform-abc123/activity#comment-uuid-1", + user: { id: "user-uuid-1", name: "ada", displayName: "Ada Lovelace" }, + externalUser: null, + botActor: null, + parent: null, + }, + { + id: "comment-uuid-2", + body: "Noted.", + quotedText: null, + createdAt: "2024-01-15T11:00:00Z", + updatedAt: "2024-01-15T11:00:00Z", + editedAt: null, + url: + "https://linear.app/team/initiative/platform-abc123/activity#comment-uuid-2", + user: null, + externalUser: null, + botActor: { + id: "bot-uuid-1", + name: "Slack", + type: "slack", + subType: null, + }, + parent: { id: "comment-uuid-1" }, + }, + ], + pageInfo: { hasNextPage: false, endCursor: "comment-uuid-2" }, +} + +// Initiative has no comments connection at all; the command must go through +// the root `comments` query filtered by initiative, sending the UUID both as +// the entity lookup id and as the filter id. +await snapshotTest({ + name: "Initiative Comment List Command - By UUID", + meta: import.meta, + colors: false, + args: [INITIATIVE_ID], + denoArgs: commonDenoArgs, + async fn() { + const { cleanup } = await setupMockLinearServer([ + { + queryName: "GetInitiativeComments", + queryIncludes: "initiative: {id: {eq: $filterId}}", + variables: { id: INITIATIVE_ID, filterId: INITIATIVE_ID, after: null }, + response: { + data: { + initiative: { id: INITIATIVE_ID, name: "Platform" }, + comments: initiativeComments, + }, + }, + }, + ]) + + try { + await commentListCommand.parse() + } finally { + await cleanup() + } + }, +}) + +// A slug goes through the shared initiative resolver first. +await snapshotTest({ + name: "Initiative Comment List Command - By Slug JSON Output", + meta: import.meta, + colors: false, + args: ["platform-abc123", "--json"], + denoArgs: commonDenoArgs, + async fn() { + const { cleanup } = await setupMockLinearServer([ + { + queryName: "ResolveInitiativeBySlug", + variables: { slugId: "platform-abc123" }, + response: { + data: { initiatives: { nodes: [{ id: INITIATIVE_ID }] } }, + }, + }, + { + queryName: "GetInitiativeComments", + queryIncludes: "quotedText", + variables: { id: INITIATIVE_ID, filterId: INITIATIVE_ID, after: null }, + response: { + data: { + initiative: { id: INITIATIVE_ID, name: "Platform" }, + comments: initiativeComments, + }, + }, + }, + ]) + + try { + await commentListCommand.parse() + } finally { + await cleanup() + } + }, +}) + +await snapshotTest({ + name: "Initiative Comment List Command - No Comments", + meta: import.meta, + colors: false, + args: [INITIATIVE_ID], + denoArgs: commonDenoArgs, + async fn() { + const { cleanup } = await setupMockLinearServer([ + { + queryName: "GetInitiativeComments", + response: { + data: { + initiative: { id: INITIATIVE_ID, name: "Platform" }, + comments: { + nodes: [], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }, + }, + }, + ]) + + try { + await commentListCommand.parse() + } finally { + await cleanup() + } + }, +}) diff --git a/test/commands/initiative/initiative-comment.test.ts b/test/commands/initiative/initiative-comment.test.ts new file mode 100644 index 00000000..538c7f77 --- /dev/null +++ b/test/commands/initiative/initiative-comment.test.ts @@ -0,0 +1,16 @@ +import { snapshotTest } from "@cliffy/testing" +import { initiativeCommand } from "../../../src/commands/initiative/initiative.ts" +import { commonDenoArgs } from "../../utils/test-helpers.ts" + +// Goes through the parent command so a missing `.command("comment", ...)` +// registration fails here, not only in a live shell. +await snapshotTest({ + name: "Initiative Comment Command - Help Through Parent", + meta: import.meta, + colors: false, + args: ["comment", "--help"], + denoArgs: commonDenoArgs, + async fn() { + await initiativeCommand.parse() + }, +}) diff --git a/test/commands/issue/__snapshots__/issue-comment-add.test.ts.snap b/test/commands/issue/__snapshots__/issue-comment-add.test.ts.snap index 5c9fed07..8b89bb4e 100644 --- a/test/commands/issue/__snapshots__/issue-comment-add.test.ts.snap +++ b/test/commands/issue/__snapshots__/issue-comment-add.test.ts.snap @@ -27,6 +27,15 @@ stderr: "" `; +snapshot[`Issue Comment Add Command - With Reply To Flag 1`] = ` +stdout: +"✓ Comment added to TEST-123 +https://linear.app/issue/TEST-123#comment-uuid-reply-790 +" +stderr: +"" +`; + snapshot[`Issue Comment Add Command - Help 1`] = ` stdout: " @@ -43,14 +52,14 @@ Description: Options: - -h, --help - Show this help. - -b, --body - Comment body text - --body-file - Read comment body from a file (preferred for markdown content) - -p, --parent - Parent comment ID for replies - -a, --attach - Upload a file and add its Markdown link to the comment (images render inline; - repeatable) - --public - Upload attached images to a public, unauthenticated URL (default: private, - workspace-members only) + -h, --help - Show this help. + -b, --body - Comment body text + --body-file - Read comment body from a file (preferred for markdown content) + -p, --parent, --reply-to - Reply to a top-level comment by ID (the reply joins that thread) + -a, --attach - Upload a file and add its Markdown link to the comment (images render inline; + repeatable) + --public - Upload attached images to a public, unauthenticated URL (default: private, + workspace-members only) " stderr: diff --git a/test/commands/issue/__snapshots__/issue-comment-list.test.ts.snap b/test/commands/issue/__snapshots__/issue-comment-list.test.ts.snap index b840e87a..2f6bfdc1 100644 --- a/test/commands/issue/__snapshots__/issue-comment-list.test.ts.snap +++ b/test/commands/issue/__snapshots__/issue-comment-list.test.ts.snap @@ -23,6 +23,7 @@ stdout: { "id": "comment-uuid-456", "body": "This is a comment", + "quotedText": "the sentence it is anchored to", "createdAt": "2024-01-15T10:30:00Z", "updatedAt": "2024-01-15T10:30:00Z", "editedAt": null, @@ -151,3 +152,54 @@ Merged in abc1234 stderr: "" `; + +snapshot[`Issue Comment List Command - JSON Output Follows Pagination 1`] = ` +stdout: +'{ + "nodes": [ + { + "id": "comment-uuid-1", + "body": "First page", + "quotedText": null, + "createdAt": "2024-01-15T10:30:00Z", + "updatedAt": "2024-01-15T10:30:00Z", + "editedAt": null, + "url": "https://linear.app/issue/TEST-123#comment-uuid-1", + "user": { + "id": "user-uuid-123", + "name": "testuser", + "displayName": "Test User" + }, + "externalUser": null, + "botActor": null, + "parent": null + }, + { + "id": "comment-uuid-2", + "body": "Second page", + "quotedText": null, + "createdAt": "2024-01-15T11:30:00Z", + "updatedAt": "2024-01-15T11:30:00Z", + "editedAt": null, + "url": "https://linear.app/issue/TEST-123#comment-uuid-2", + "user": { + "id": "user-uuid-123", + "name": "testuser", + "displayName": "Test User" + }, + "externalUser": null, + "botActor": null, + "parent": { + "id": "comment-uuid-1" + } + } + ], + "pageInfo": { + "hasNextPage": false, + "endCursor": "cursor-2" + } +} +' +stderr: +"" +`; diff --git a/test/commands/issue/__snapshots__/issue-view.test.ts.snap b/test/commands/issue/__snapshots__/issue-view.test.ts.snap index 1c902505..3565bf2d 100644 --- a/test/commands/issue/__snapshots__/issue-view.test.ts.snap +++ b/test/commands/issue/__snapshots__/issue-view.test.ts.snap @@ -218,6 +218,7 @@ stdout: "nodes": [ { "id": "comment-1", + "quotedText": "session timeout", "body": "I've reproduced this issue on staging. The session timeout seems to be too aggressive.", "createdAt": "2024-01-15T10:30:00Z", "user": { @@ -229,6 +230,7 @@ stdout: }, { "id": "comment-2", + "quotedText": null, "body": "Working on a fix. Will increase the session timeout and add proper error handling.", "createdAt": "2024-01-15T14:22:00Z", "user": { diff --git a/test/commands/issue/issue-comment-add.test.ts b/test/commands/issue/issue-comment-add.test.ts index a408cdc0..eab59305 100644 --- a/test/commands/issue/issue-comment-add.test.ts +++ b/test/commands/issue/issue-comment-add.test.ts @@ -166,6 +166,59 @@ await snapshotTest({ }, }) +// --reply-to is the documented spelling of --parent. The mock requires both +// issueId and parentId in the input: Linear rejects a reply that names only +// its parent, so the reply must stay attached to the issue. +await snapshotTest({ + name: "Issue Comment Add Command - With Reply To Flag", + meta: import.meta, + colors: false, + args: [ + "TEST-123", + "--body", + "Replying via --reply-to", + "--reply-to", + "parent-comment-uuid-123", + ], + denoArgs: commonDenoArgs, + async fn() { + const { cleanup } = await setupMockLinearServer([ + { + queryName: "GetIssueId", + variables: { id: "TEST-123" }, + response: { data: { issue: { id: "issue-uuid-123" } } }, + }, + { + queryName: "AddComment", + variables: { + input: { + body: "Replying via --reply-to", + issueId: "TEST-123", + parentId: "parent-comment-uuid-123", + }, + }, + response: { + data: { + commentCreate: { + success: true, + comment: { + id: "comment-uuid-reply-790", + url: "https://linear.app/issue/TEST-123#comment-uuid-reply-790", + }, + }, + }, + }, + }, + ]) + + try { + await commentAddCommand.parse() + } finally { + await cleanup() + } + }, +}) + // Test validation: --public with no attachments is rejected before any work Deno.test("Issue Comment Add Command - rejects --public without --attach", async () => { const errorLogs: string[] = [] diff --git a/test/commands/issue/issue-comment-list.test.ts b/test/commands/issue/issue-comment-list.test.ts index 28d91a38..b3308866 100644 --- a/test/commands/issue/issue-comment-list.test.ts +++ b/test/commands/issue/issue-comment-list.test.ts @@ -1,4 +1,6 @@ import { snapshotTest } from "@cliffy/testing" +import { assertEquals } from "@std/assert" +import { stub } from "@std/testing/mock" import { commentListCommand } from "../../../src/commands/issue/issue-comment-list.ts" import { commonDenoArgs, @@ -115,7 +117,8 @@ await snapshotTest({ }, { queryName: "GetIssueComments", - queryIncludes: "editedAt", + // The selection must carry both editedAt and the inline anchor. + queryIncludes: "quotedText", response: { data: { issue: { @@ -124,6 +127,7 @@ await snapshotTest({ { id: "comment-uuid-456", body: "This is a comment", + quotedText: "the sentence it is anchored to", createdAt: "2024-01-15T10:30:00Z", updatedAt: "2024-01-15T10:30:00Z", editedAt: null, @@ -397,3 +401,149 @@ await snapshotTest({ } }, }) + +// Issue threads longer than one page used to be silently cut at 50. The mock +// server answers the first matching handler and does not consume it, so the +// first page is pinned to `after: null` and the second to the cursor it +// returned; the JSON output is the concatenated connection with the last +// page's pageInfo. +await snapshotTest({ + name: "Issue Comment List Command - JSON Output Follows Pagination", + meta: import.meta, + colors: false, + args: ["TEST-123", "--json"], + denoArgs: commonDenoArgs, + async fn() { + const { cleanup } = await setupMockLinearServer([ + { + queryName: "GetIssueId", + variables: { id: "TEST-123" }, + response: { data: { issue: { id: "issue-uuid-123" } } }, + }, + { + queryName: "GetIssueComments", + variables: { id: "TEST-123", after: null }, + response: { + data: { + issue: { + comments: { + nodes: [ + { + id: "comment-uuid-1", + body: "First page", + quotedText: null, + createdAt: "2024-01-15T10:30:00Z", + updatedAt: "2024-01-15T10:30:00Z", + editedAt: null, + url: "https://linear.app/issue/TEST-123#comment-uuid-1", + user: { + id: "user-uuid-123", + name: "testuser", + displayName: "Test User", + }, + externalUser: null, + botActor: null, + parent: null, + }, + ], + pageInfo: { hasNextPage: true, endCursor: "cursor-1" }, + }, + }, + }, + }, + }, + { + queryName: "GetIssueComments", + variables: { id: "TEST-123", after: "cursor-1" }, + response: { + data: { + issue: { + comments: { + nodes: [ + { + id: "comment-uuid-2", + body: "Second page", + quotedText: null, + createdAt: "2024-01-15T11:30:00Z", + updatedAt: "2024-01-15T11:30:00Z", + editedAt: null, + url: "https://linear.app/issue/TEST-123#comment-uuid-2", + user: { + id: "user-uuid-123", + name: "testuser", + displayName: "Test User", + }, + externalUser: null, + botActor: null, + parent: { id: "comment-uuid-1" }, + }, + ], + pageInfo: { hasNextPage: false, endCursor: "cursor-2" }, + }, + }, + }, + }, + }, + ]) + + try { + await commentListCommand.parse() + } finally { + await cleanup() + } + }, +}) + +// Linear answers an unknown issue with a GraphQL error rather than a null +// field; it must surface as a not-found message, not a raw API error. +Deno.test("Issue Comment List Command - unknown issue is reported as not found", async () => { + const { cleanup } = await setupMockLinearServer([ + { + queryName: "GetIssueId", + variables: { id: "TEST-404" }, + response: { data: { issue: { id: "issue-uuid-404" } } }, + }, + { + queryName: "GetIssueComments", + response: { + errors: [{ + message: "Entity not found: Issue", + extensions: { + type: "invalid input", + userError: true, + userPresentableMessage: "Could not find referenced Issue.", + }, + }], + }, + }, + ]) + + const errorLogs: string[] = [] + const errorStub = stub(console, "error", (...args: unknown[]) => { + errorLogs.push(args.map(String).join(" ")) + }) + const exitStub = stub(Deno, "exit", (_code?: number) => { + throw new Error("EXIT") + }) + + let exited = false + try { + await commentListCommand.parse(["TEST-404"]) + } catch (e) { + if (!(e instanceof Error) || e.message !== "EXIT") throw e + exited = true + } finally { + errorStub.restore() + exitStub.restore() + await cleanup() + } + + assertEquals(exited, true) + assertEquals( + errorLogs.some((l) => + l.toLowerCase().includes("not found") && l.includes("TEST-404") + ), + true, + errorLogs.join("\n"), + ) +}) diff --git a/test/commands/issue/issue-view.test.ts b/test/commands/issue/issue-view.test.ts index 9c8af24a..c3b05af6 100644 --- a/test/commands/issue/issue-view.test.ts +++ b/test/commands/issue/issue-view.test.ts @@ -531,6 +531,8 @@ await snapshotTest({ const server = new MockLinearServer([ { queryName: "GetIssueDetailsWithComments", + // Fails if the selection ever drops the inline-comment anchor. + queryIncludes: "quotedText", variables: { id: "TEST-123" }, response: { data: { @@ -561,6 +563,7 @@ await snapshotTest({ nodes: [ { id: "comment-1", + quotedText: "session timeout", body: "I've reproduced this issue on staging. The session timeout seems to be too aggressive.", createdAt: "2024-01-15T10:30:00Z", @@ -573,6 +576,7 @@ await snapshotTest({ }, { id: "comment-2", + quotedText: null, body: "Working on a fix. Will increase the session timeout and add proper error handling.", createdAt: "2024-01-15T14:22:00Z", diff --git a/test/commands/project/__snapshots__/project-comment-add.test.ts.snap b/test/commands/project/__snapshots__/project-comment-add.test.ts.snap new file mode 100644 index 00000000..1e7d3754 --- /dev/null +++ b/test/commands/project/__snapshots__/project-comment-add.test.ts.snap @@ -0,0 +1,45 @@ +export const snapshot = {}; + +snapshot[`Project Comment Add Command - By UUID With Body Flag 1`] = ` +stdout: +"✓ Comment added to project 6f1c3b8a-2d4e-4a5b-9c7d-1e2f3a4b5c6d +https://linear.app/team/project/mobile-abc123/activity#comment-uuid-1 +" +stderr: +"" +`; + +snapshot[`Project Comment Add Command - By Slug With Reply To Flag 1`] = ` +stdout: +"✓ Comment added to project mobile-abc123 +https://linear.app/team/project/mobile-abc123/activity#comment-uuid-2 +" +stderr: +"" +`; + +snapshot[`Project Comment Add Command - Help 1`] = ` +stdout: +" +Usage: add + +Description: + + Add a comment or reply to a project's discussion (by ID, slug, or name) + + Linear Markdown: a plain Linear URL creates a mention; \`@name\`, \`@[Name](id)\`, + and \`[Name](url)\` do not. Get a person's URL from the \`url\` field of + \`linear team members --json\`, or an issue's from \`linear issue url \`. + Run \`linear markdown\` for collapsible sections and the full reference. + +Options: + + -h, --help - Show this help. + -b, --body - Comment body text + --body-file - Read comment body from a file (preferred for markdown content) + -p, --parent, --reply-to - Reply to a top-level comment by ID (the reply joins that thread) + +" +stderr: +"" +`; diff --git a/test/commands/project/__snapshots__/project-comment-list.test.ts.snap b/test/commands/project/__snapshots__/project-comment-list.test.ts.snap new file mode 100644 index 00000000..abc61f48 --- /dev/null +++ b/test/commands/project/__snapshots__/project-comment-list.test.ts.snap @@ -0,0 +1,124 @@ +export const snapshot = {}; + +snapshot[`Project Comment List Command - By UUID 1`] = ` +stdout: +"@Ada Lovelace commented 1/15/2024 [comment-uuid-1] +Kickoff is Monday. + + @Grace Hopper replied 1/15/2024 [comment-uuid-2] + I'll be there. + +" +stderr: +"" +`; + +snapshot[`Project Comment List Command - By Name JSON Output 1`] = ` +stdout: +\`{ + "nodes": [ + { + "id": "comment-uuid-1", + "body": "Kickoff is Monday.", + "quotedText": null, + "createdAt": "2024-01-15T10:30:00Z", + "updatedAt": "2024-01-15T10:30:00Z", + "editedAt": null, + "url": "https://linear.app/team/project/mobile-abc123/activity#comment-uuid-1", + "user": { + "id": "user-uuid-1", + "name": "ada", + "displayName": "Ada Lovelace" + }, + "externalUser": null, + "botActor": null, + "parent": null + }, + { + "id": "comment-uuid-2", + "body": "I'll be there.", + "quotedText": null, + "createdAt": "2024-01-15T11:00:00Z", + "updatedAt": "2024-01-15T11:00:00Z", + "editedAt": null, + "url": "https://linear.app/team/project/mobile-abc123/activity#comment-uuid-2", + "user": { + "id": "user-uuid-2", + "name": "grace", + "displayName": "Grace Hopper" + }, + "externalUser": null, + "botActor": null, + "parent": { + "id": "comment-uuid-1" + } + } + ], + "pageInfo": { + "hasNextPage": false, + "endCursor": "comment-uuid-2" + } +} +\` +stderr: +"" +`; + +snapshot[`Project Comment List Command - No Comments 1`] = ` +stdout: +"No comments found for this project +" +stderr: +"" +`; + +snapshot[`Project Comment List Command - JSON Output Follows Pagination 1`] = ` +stdout: +\`{ + "nodes": [ + { + "id": "comment-uuid-1", + "body": "Kickoff is Monday.", + "quotedText": null, + "createdAt": "2024-01-15T10:30:00Z", + "updatedAt": "2024-01-15T10:30:00Z", + "editedAt": null, + "url": "https://linear.app/team/project/mobile-abc123/activity#comment-uuid-1", + "user": { + "id": "user-uuid-1", + "name": "ada", + "displayName": "Ada Lovelace" + }, + "externalUser": null, + "botActor": null, + "parent": null + }, + { + "id": "comment-uuid-2", + "body": "I'll be there.", + "quotedText": null, + "createdAt": "2024-01-15T11:00:00Z", + "updatedAt": "2024-01-15T11:00:00Z", + "editedAt": null, + "url": "https://linear.app/team/project/mobile-abc123/activity#comment-uuid-2", + "user": { + "id": "user-uuid-2", + "name": "grace", + "displayName": "Grace Hopper" + }, + "externalUser": null, + "botActor": null, + "parent": { + "id": "comment-uuid-1" + } + } + ], + "pageInfo": { + "hasNextPage": false, + "endCursor": "cursor-2" + } +} +\` +stderr: +"" +`; diff --git a/test/commands/project/__snapshots__/project-comment.test.ts.snap b/test/commands/project/__snapshots__/project-comment.test.ts.snap new file mode 100644 index 00000000..c8ce7127 --- /dev/null +++ b/test/commands/project/__snapshots__/project-comment.test.ts.snap @@ -0,0 +1,24 @@ +export const snapshot = {}; + +snapshot[`Project Comment Command - Help Through Parent 1`] = ` +stdout: +" +Usage: COMMAND comment + +Description: + + Manage project comments + +Options: + + -h, --help - Show this help. + +Commands: + + add - Add a comment or reply to a project's discussion (by ID, slug, or name) + list - List comments on a project (by ID, slug, or name) + +" +stderr: +"" +`; diff --git a/test/commands/project/project-comment-add.test.ts b/test/commands/project/project-comment-add.test.ts new file mode 100644 index 00000000..dd51ff37 --- /dev/null +++ b/test/commands/project/project-comment-add.test.ts @@ -0,0 +1,113 @@ +import { snapshotTest } from "@cliffy/testing" +import { commentAddCommand } from "../../../src/commands/project/project-comment-add.ts" +import { + commonDenoArgs, + setupMockLinearServer, +} from "../../utils/test-helpers.ts" + +const PROJECT_ID = "6f1c3b8a-2d4e-4a5b-9c7d-1e2f3a4b5c6d" + +await snapshotTest({ + name: "Project Comment Add Command - By UUID With Body Flag", + meta: import.meta, + colors: false, + args: [PROJECT_ID, "--body", "Kickoff is Monday."], + denoArgs: commonDenoArgs, + async fn() { + const { cleanup } = await setupMockLinearServer([ + { + queryName: "AddComment", + variables: { + input: { body: "Kickoff is Monday.", projectId: PROJECT_ID }, + }, + response: { + data: { + commentCreate: { + success: true, + comment: { + id: "comment-uuid-1", + url: + "https://linear.app/team/project/mobile-abc123/activity#comment-uuid-1", + }, + }, + }, + }, + }, + ]) + + try { + await commentAddCommand.parse() + } finally { + await cleanup() + } + }, +}) + +// A slug is resolved through the shared project resolver (name first, then +// slug), and a reply still carries projectId next to parentId. +await snapshotTest({ + name: "Project Comment Add Command - By Slug With Reply To Flag", + meta: import.meta, + colors: false, + args: [ + "mobile-abc123", + "--body", + "I'll be there.", + "--reply-to", + "comment-uuid-1", + ], + denoArgs: commonDenoArgs, + async fn() { + const { cleanup } = await setupMockLinearServer([ + { + queryName: "GetProjectIdByName", + variables: { name: "mobile-abc123" }, + response: { data: { projects: { nodes: [] } } }, + }, + { + queryName: "GetProjectIdBySlugId", + variables: { slugId: "mobile-abc123" }, + response: { data: { projects: { nodes: [{ id: PROJECT_ID }] } } }, + }, + { + queryName: "AddComment", + variables: { + input: { + body: "I'll be there.", + projectId: PROJECT_ID, + parentId: "comment-uuid-1", + }, + }, + response: { + data: { + commentCreate: { + success: true, + comment: { + id: "comment-uuid-2", + url: + "https://linear.app/team/project/mobile-abc123/activity#comment-uuid-2", + }, + }, + }, + }, + }, + ]) + + try { + await commentAddCommand.parse() + } finally { + await cleanup() + } + }, +}) + +await snapshotTest({ + name: "Project Comment Add Command - Help", + meta: import.meta, + colors: false, + args: ["--help"], + denoArgs: commonDenoArgs, + async fn() { + await commentAddCommand.parse() + }, +}) diff --git a/test/commands/project/project-comment-list.test.ts b/test/commands/project/project-comment-list.test.ts new file mode 100644 index 00000000..66e2decf --- /dev/null +++ b/test/commands/project/project-comment-list.test.ts @@ -0,0 +1,238 @@ +import { snapshotTest } from "@cliffy/testing" +import { assertEquals } from "@std/assert" +import { stub } from "@std/testing/mock" +import { commentListCommand } from "../../../src/commands/project/project-comment-list.ts" +import { + commonDenoArgs, + setupMockLinearServer, +} from "../../utils/test-helpers.ts" + +const PROJECT_ID = "6f1c3b8a-2d4e-4a5b-9c7d-1e2f3a4b5c6d" + +const projectComments = { + nodes: [ + { + id: "comment-uuid-1", + body: "Kickoff is Monday.", + quotedText: null, + createdAt: "2024-01-15T10:30:00Z", + updatedAt: "2024-01-15T10:30:00Z", + editedAt: null, + url: + "https://linear.app/team/project/mobile-abc123/activity#comment-uuid-1", + user: { id: "user-uuid-1", name: "ada", displayName: "Ada Lovelace" }, + externalUser: null, + botActor: null, + parent: null, + }, + { + id: "comment-uuid-2", + body: "I'll be there.", + quotedText: null, + createdAt: "2024-01-15T11:00:00Z", + updatedAt: "2024-01-15T11:00:00Z", + editedAt: null, + url: + "https://linear.app/team/project/mobile-abc123/activity#comment-uuid-2", + user: { id: "user-uuid-2", name: "grace", displayName: "Grace Hopper" }, + externalUser: null, + botActor: null, + parent: { id: "comment-uuid-1" }, + }, + ], + pageInfo: { hasNextPage: false, endCursor: "comment-uuid-2" }, +} + +// Project-thread comments are not returned by `Project.comments`; the command +// must go through the root `comments` query filtered by project, and it must +// send the UUID both as the entity lookup id and as the filter id. +await snapshotTest({ + name: "Project Comment List Command - By UUID", + meta: import.meta, + colors: false, + args: [PROJECT_ID], + denoArgs: commonDenoArgs, + async fn() { + const { cleanup } = await setupMockLinearServer([ + { + queryName: "GetProjectComments", + queryIncludes: "project: {id: {eq: $filterId}}", + variables: { id: PROJECT_ID, filterId: PROJECT_ID, after: null }, + response: { + data: { + project: { id: PROJECT_ID, name: "Mobile launch" }, + comments: projectComments, + }, + }, + }, + ]) + + try { + await commentListCommand.parse() + } finally { + await cleanup() + } + }, +}) + +// A name goes through the shared project resolver first. +await snapshotTest({ + name: "Project Comment List Command - By Name JSON Output", + meta: import.meta, + colors: false, + args: ["Mobile launch", "--json"], + denoArgs: commonDenoArgs, + async fn() { + const { cleanup } = await setupMockLinearServer([ + { + queryName: "GetProjectIdByName", + variables: { name: "Mobile launch" }, + response: { data: { projects: { nodes: [{ id: PROJECT_ID }] } } }, + }, + { + queryName: "GetProjectComments", + queryIncludes: "quotedText", + variables: { id: PROJECT_ID, filterId: PROJECT_ID, after: null }, + response: { + data: { + project: { id: PROJECT_ID, name: "Mobile launch" }, + comments: projectComments, + }, + }, + }, + ]) + + try { + await commentListCommand.parse() + } finally { + await cleanup() + } + }, +}) + +await snapshotTest({ + name: "Project Comment List Command - No Comments", + meta: import.meta, + colors: false, + args: [PROJECT_ID], + denoArgs: commonDenoArgs, + async fn() { + const { cleanup } = await setupMockLinearServer([ + { + queryName: "GetProjectComments", + response: { + data: { + project: { id: PROJECT_ID, name: "Mobile launch" }, + comments: { + nodes: [], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }, + }, + }, + ]) + + try { + await commentListCommand.parse() + } finally { + await cleanup() + } + }, +}) + +// Both ids and the cursor must be sent on every page of the combined +// entity-plus-root-comments operation. +await snapshotTest({ + name: "Project Comment List Command - JSON Output Follows Pagination", + meta: import.meta, + colors: false, + args: [PROJECT_ID, "--json"], + denoArgs: commonDenoArgs, + async fn() { + const [first, second] = projectComments.nodes + const { cleanup } = await setupMockLinearServer([ + { + queryName: "GetProjectComments", + variables: { id: PROJECT_ID, filterId: PROJECT_ID, after: null }, + response: { + data: { + project: { id: PROJECT_ID, name: "Mobile launch" }, + comments: { + nodes: [first], + pageInfo: { hasNextPage: true, endCursor: "cursor-1" }, + }, + }, + }, + }, + { + queryName: "GetProjectComments", + variables: { id: PROJECT_ID, filterId: PROJECT_ID, after: "cursor-1" }, + response: { + data: { + project: { id: PROJECT_ID, name: "Mobile launch" }, + comments: { + nodes: [second], + pageInfo: { hasNextPage: false, endCursor: "cursor-2" }, + }, + }, + }, + }, + ]) + + try { + await commentListCommand.parse() + } finally { + await cleanup() + } + }, +}) + +// resolveProjectId passes a UUID through unchecked; the entity lookup in the +// list operation is what turns an unknown UUID into a not-found error instead +// of an empty list. +Deno.test("Project Comment List Command - unknown project UUID is reported as not found", async () => { + const { cleanup } = await setupMockLinearServer([ + { + queryName: "GetProjectComments", + response: { + errors: [{ + message: "Entity not found: Project", + extensions: { + type: "invalid input", + userError: true, + userPresentableMessage: "Could not find referenced Project.", + }, + }], + }, + }, + ]) + + const errorLogs: string[] = [] + const errorStub = stub(console, "error", (...args: unknown[]) => { + errorLogs.push(args.map(String).join(" ")) + }) + const exitStub = stub(Deno, "exit", (_code?: number) => { + throw new Error("EXIT") + }) + + let exited = false + try { + await commentListCommand.parse([PROJECT_ID]) + } catch (e) { + if (!(e instanceof Error) || e.message !== "EXIT") throw e + exited = true + } finally { + errorStub.restore() + exitStub.restore() + await cleanup() + } + + assertEquals(exited, true) + assertEquals( + errorLogs.some((l) => + l.toLowerCase().includes("not found") && l.includes(PROJECT_ID) + ), + true, + errorLogs.join("\n"), + ) +}) diff --git a/test/commands/project/project-comment.test.ts b/test/commands/project/project-comment.test.ts new file mode 100644 index 00000000..9091850b --- /dev/null +++ b/test/commands/project/project-comment.test.ts @@ -0,0 +1,16 @@ +import { snapshotTest } from "@cliffy/testing" +import { projectCommand } from "../../../src/commands/project/project.ts" +import { commonDenoArgs } from "../../utils/test-helpers.ts" + +// Goes through the parent command so a missing `.command("comment", ...)` +// registration fails here, not only in a live shell. +await snapshotTest({ + name: "Project Comment Command - Help Through Parent", + meta: import.meta, + colors: false, + args: ["comment", "--help"], + denoArgs: commonDenoArgs, + async fn() { + await projectCommand.parse() + }, +}) diff --git a/test/utils/comments.test.ts b/test/utils/comments.test.ts new file mode 100644 index 00000000..ce680372 --- /dev/null +++ b/test/utils/comments.test.ts @@ -0,0 +1,165 @@ +import { assertEquals, assertRejects } from "@std/assert" +import { stripAnsiCode } from "@std/fmt/colors" +import { stub } from "@std/testing/mock" +import { + buildCommentCreateInput, + collectCommentPages, + type CommentPageInfo, + renderCommentThreads, + resolveCommentBody, +} from "../../src/utils/comments.ts" +import { CliError, ValidationError } from "../../src/utils/errors.ts" + +// Linear requires exactly one owning entity even on a reply, so the builder +// must always emit the target key next to parentId. +Deno.test("buildCommentCreateInput pairs the target with parentId on a reply", () => { + assertEquals( + buildCommentCreateInput( + { kind: "document", documentContentId: "content-1" }, + { body: "hi", parentId: "root-1" }, + ), + { body: "hi", parentId: "root-1", documentContentId: "content-1" }, + ) + assertEquals( + buildCommentCreateInput( + { kind: "initiative", initiativeId: "init-1" }, + { body: "hi" }, + ), + { body: "hi", initiativeId: "init-1" }, + ) +}) + +Deno.test("resolveCommentBody rejects --body together with --body-file", async () => { + await assertRejects( + () => resolveCommentBody({ body: "a", bodyFile: "b.md" }), + ValidationError, + "Cannot specify both", + ) +}) + +// Explicit input that is blank is an error, never a fall-through to the prompt. +Deno.test("resolveCommentBody rejects a whitespace-only --body", async () => { + await assertRejects( + () => resolveCommentBody({ body: " \n" }), + ValidationError, + "cannot be empty", + ) +}) + +Deno.test("resolveCommentBody rejects an empty body file", async () => { + const file = await Deno.makeTempFile({ suffix: ".md" }) + try { + await Deno.writeTextFile(file, "\n\n") + await assertRejects( + () => resolveCommentBody({ bodyFile: file }), + ValidationError, + "Body file is empty", + ) + } finally { + await Deno.remove(file) + } +}) + +Deno.test("resolveCommentBody wraps an unreadable body file", async () => { + await assertRejects( + () => resolveCommentBody({ bodyFile: "/nonexistent/comment.md" }), + ValidationError, + "Failed to read body file", + ) +}) + +Deno.test("resolveCommentBody returns undefined when neither flag is given", async () => { + assertEquals(await resolveCommentBody({}), undefined) +}) + +Deno.test("collectCommentPages follows cursors and keeps the last pageInfo", async () => { + const requested: (string | null)[] = [] + const result = await collectCommentPages((after) => { + requested.push(after) + if (after == null) { + return Promise.resolve({ + nodes: ["a", "b"], + pageInfo: { hasNextPage: true, endCursor: "cursor-1" }, + }) + } + return Promise.resolve({ + nodes: ["c"], + pageInfo: { hasNextPage: false, endCursor: "cursor-2" }, + }) + }) + + assertEquals(requested, [null, "cursor-1"]) + assertEquals(result, { + nodes: ["a", "b", "c"], + pageInfo: { hasNextPage: false, endCursor: "cursor-2" }, + }) +}) + +Deno.test("collectCommentPages refuses a next page without a cursor", async () => { + await assertRejects( + () => + collectCommentPages(() => + Promise.resolve({ + nodes: ["a"], + pageInfo: { hasNextPage: true, endCursor: null }, + }) + ), + CliError, + "usable cursor", + ) +}) + +// A server that keeps handing back the same cursor must not spin forever. +Deno.test("collectCommentPages refuses a repeated cursor", async () => { + let calls = 0 + await assertRejects( + () => + collectCommentPages(() => { + calls++ + return Promise.resolve({ + nodes: ["a"], + pageInfo: { hasNextPage: true, endCursor: "same" }, + }) + }), + CliError, + "usable cursor", + ) + assertEquals(calls, 2) +}) + +// A reply whose root is missing from the list (deleted, or paged out) used to +// vanish from the rendered output entirely. +Deno.test("renderCommentThreads keeps a reply whose parent is absent", () => { + const lines: string[] = [] + // The renderer bolds the author, so strip ANSI before matching text: CI + // runs without NO_COLOR and the escape codes would split "@Ada replied". + const logStub = stub(console, "log", (...args: unknown[]) => { + lines.push(stripAnsiCode(args.map(String).join(" "))) + }) + try { + renderCommentThreads( + [ + { + id: "reply-1", + body: "still here", + createdAt: "2024-01-15T10:30:00Z", + user: { name: "ada", displayName: "Ada" }, + parent: { id: "gone-root" }, + }, + ], + { emptyMessage: "none" }, + ) + } finally { + logStub.restore() + } + + assertEquals( + lines.some((line) => + line.includes("@Ada replied to [gone-root]") && line.includes("[reply-1]") + ), + true, + ) + assertEquals(lines.some((line) => line.includes("still here")), true) + // It is not misrepresented as a root comment. + assertEquals(lines.some((line) => line.includes("commented")), false) +}) diff --git a/test/utils/markdown-help.test.ts b/test/utils/markdown-help.test.ts index b8edaa35..626675fb 100644 --- a/test/utils/markdown-help.test.ts +++ b/test/utils/markdown-help.test.ts @@ -32,13 +32,16 @@ const MARKDOWN_BODY_OPTIONS = [ // existing one) forces a deliberate decision about the guidance rather than // silently shipping a command an agent will misuse. const EXPECTED_MARKDOWN_COMMANDS = [ + "document comment add", "document create", "document update", + "initiative comment add", "initiative-update create", "issue comment add", "issue comment update", "issue create", "issue update", + "project comment add", "project create", "project update", "project-update create",