From c04834cdb44ef8693d377b0eb05819cb0090b741 Mon Sep 17 00:00:00 2001 From: Peter Schilling Date: Fri, 4 Sep 2026 13:04:15 -0700 Subject: [PATCH] Add document, project, and initiative comments with threaded replies The CLI could only comment on issues. Documents, projects, and initiatives all take comments in Linear (GitHub issue #230 asked for document comments), so this adds `document comment list|add`, `project comment list|add`, and `initiative comment list|add`, mirroring `issue comment` with the same --body / --body-file conventions and the shared Markdown hint. Every comment `add`, including the issue one, now takes `--reply-to `; -p and --parent stay as aliases so existing scripts keep working. The entity-agnostic parts live in src/utils/comments.ts: a typed comment target union feeding one AddComment mutation, strict body handling (an explicitly blank --body or body file is an error, not a fall-through to the prompt), a CommentListFields fragment so the four --json shapes cannot drift, a page collector, and the threaded renderer. Comment lists now fetch every page instead of stopping silently at 50, and their JSON nodes, plus the comments in `issue view --json`, carry quotedText (the passage an inline comment quotes) alongside parent.id. Replies whose root is missing from the result are rendered as replies naming their parent instead of being dropped. API findings, verified live against scratch objects on 2026-09-04: - A reply must carry its entity id as well as parentId; parentId alone is rejected, so every add sends both. - Project comments attach via projectId, but the schema's Project.comments connection does not return them; only the root `comments` query filtered by project does. Initiative has no comments connection at all. Both list commands therefore use the root query and select the entity in the same operation so an unknown UUID is reported as not found rather than as an empty list. - Document comments attach via the document's documentContentId, which is looked up first; `document(id:)` accepts a UUID or slug directly. - Linear rejects a reply to a reply and a cross-entity parent with a user-presentable message, which is surfaced verbatim. Linear's not-found error carries the user-presentable message "Could not find referenced .", which isNotFoundError never matched, so the existing not-found branches were dead. Matching that wording exposed a `document view` catch block that re-threw instead of reporting; it now goes through handleError like everything else. Claude-Session: https://claude.ai/code/session_01A9qEGri4p2HZMQSuYsBmub --- CHANGELOG.md | 2 + README.md | 22 +- docs/usage.md | 40 ++ skills/linear-cli/SKILL.md | 9 + skills/linear-cli/references/document.md | 64 +++ skills/linear-cli/references/initiative.md | 66 ++- skills/linear-cli/references/issue.md | 18 +- skills/linear-cli/references/project.md | 64 +++ src/commands/document/document-comment-add.ts | 80 ++++ .../document/document-comment-list.ts | 65 +++ src/commands/document/document-comment.ts | 11 + src/commands/document/document-view.ts | 10 +- src/commands/document/document.ts | 2 + .../initiative/initiative-comment-add.ts | 43 ++ .../initiative/initiative-comment-list.ts | 83 ++++ src/commands/initiative/initiative-comment.ts | 11 + src/commands/initiative/initiative.ts | 2 + src/commands/issue/issue-comment-add.ts | 138 ++----- src/commands/issue/issue-comment-list.ts | 193 ++------- src/commands/project/project-comment-add.ts | 43 ++ src/commands/project/project-comment-list.ts | 84 ++++ src/commands/project/project-comment.ts | 11 + src/commands/project/project.ts | 2 + src/utils/comments.ts | 384 ++++++++++++++++++ src/utils/errors.ts | 26 +- src/utils/linear.ts | 1 + src/utils/markdown-help.ts | 2 +- .../document-comment-add.test.ts.snap | 54 +++ .../document-comment-list.test.ts.snap | 162 ++++++++ .../document-comment.test.ts.snap | 24 ++ .../document/document-comment-add.test.ts | 300 ++++++++++++++ .../document/document-comment-list.test.ts | 237 +++++++++++ .../document/document-comment.test.ts | 16 + test/commands/document/document-view.test.ts | 58 +++ .../initiative-comment-add.test.ts.snap | 45 ++ .../initiative-comment-list.test.ts.snap | 74 ++++ .../initiative-comment.test.ts.snap | 24 ++ .../initiative/initiative-comment-add.test.ts | 121 ++++++ .../initiative-comment-list.test.ts | 146 +++++++ .../initiative/initiative-comment.test.ts | 16 + .../issue-comment-add.test.ts.snap | 25 +- .../issue-comment-list.test.ts.snap | 52 +++ .../__snapshots__/issue-view.test.ts.snap | 2 + test/commands/issue/issue-comment-add.test.ts | 53 +++ .../commands/issue/issue-comment-list.test.ts | 152 ++++++- test/commands/issue/issue-view.test.ts | 4 + .../project-comment-add.test.ts.snap | 45 ++ .../project-comment-list.test.ts.snap | 124 ++++++ .../project-comment.test.ts.snap | 24 ++ .../project/project-comment-add.test.ts | 113 ++++++ .../project/project-comment-list.test.ts | 238 +++++++++++ test/commands/project/project-comment.test.ts | 16 + test/utils/comments.test.ts | 165 ++++++++ test/utils/markdown-help.test.ts | 3 + 54 files changed, 3485 insertions(+), 284 deletions(-) create mode 100644 src/commands/document/document-comment-add.ts create mode 100644 src/commands/document/document-comment-list.ts create mode 100644 src/commands/document/document-comment.ts create mode 100644 src/commands/initiative/initiative-comment-add.ts create mode 100644 src/commands/initiative/initiative-comment-list.ts create mode 100644 src/commands/initiative/initiative-comment.ts create mode 100644 src/commands/project/project-comment-add.ts create mode 100644 src/commands/project/project-comment-list.ts create mode 100644 src/commands/project/project-comment.ts create mode 100644 src/utils/comments.ts create mode 100644 test/commands/document/__snapshots__/document-comment-add.test.ts.snap create mode 100644 test/commands/document/__snapshots__/document-comment-list.test.ts.snap create mode 100644 test/commands/document/__snapshots__/document-comment.test.ts.snap create mode 100644 test/commands/document/document-comment-add.test.ts create mode 100644 test/commands/document/document-comment-list.test.ts create mode 100644 test/commands/document/document-comment.test.ts create mode 100644 test/commands/initiative/__snapshots__/initiative-comment-add.test.ts.snap create mode 100644 test/commands/initiative/__snapshots__/initiative-comment-list.test.ts.snap create mode 100644 test/commands/initiative/__snapshots__/initiative-comment.test.ts.snap create mode 100644 test/commands/initiative/initiative-comment-add.test.ts create mode 100644 test/commands/initiative/initiative-comment-list.test.ts create mode 100644 test/commands/initiative/initiative-comment.test.ts create mode 100644 test/commands/project/__snapshots__/project-comment-add.test.ts.snap create mode 100644 test/commands/project/__snapshots__/project-comment-list.test.ts.snap create mode 100644 test/commands/project/__snapshots__/project-comment.test.ts.snap create mode 100644 test/commands/project/project-comment-add.test.ts create mode 100644 test/commands/project/project-comment-list.test.ts create mode 100644 test/commands/project/project-comment.test.ts create mode 100644 test/utils/comments.test.ts 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",