diff --git a/skills/linear-axi/SKILL.md b/skills/linear-axi/SKILL.md index 1aac2d0..490c59e 100644 --- a/skills/linear-axi/SKILL.md +++ b/skills/linear-axi/SKILL.md @@ -1,6 +1,6 @@ --- name: linear-axi -description: "Operate Linear (linear.app) through the linear-axi CLI - list and view issues, create, update, and delete issues, nest work as sub-issues, post comments and read comment threads, list teams, list/filter projects, list cycles, and list labels. Use whenever a task touches Linear: triaging or filing issues, changing issue state, setting priority, assigning issues or managing their labels, breaking an issue into sub-issues with `issue create --parent` and reading them back with `issue view --full`, commenting on or reading an issue discussion via `comment list`, discovering team keys via `teams`, scoping work to a project via `projects` and `issues --project`, to a cycle via `cycles` and `issues --cycle`, assigning issues to a project or cycle with `issue create/update --project ` and `--cycle current|`, or finding label names via `labels` for `issue create --label`, `issue update --label`, and `issues --label`." +description: "Operate Linear (linear.app) through the linear-axi CLI - list and view issues, create, update, and delete issues, nest work as sub-issues, post, edit, and delete comments and read comment threads, list teams, list/filter projects, list cycles, and list labels. Use whenever a task touches Linear: triaging or filing issues, changing issue state, setting priority, assigning issues or managing their labels, breaking an issue into sub-issues with `issue create --parent` and reading them back with `issue view --full`, commenting on or reading an issue discussion via `comment list`, editing or removing a comment via `comment update/delete ` (ids come from `comment list`), discovering team keys via `teams`, scoping work to a project via `projects` and `issues --project`, to a cycle via `cycles` and `issues --cycle`, assigning issues to a project or cycle with `issue create/update --project ` and `--cycle current|`, or finding label names via `labels` for `issue create --label`, `issue update --label`, and `issues --label`." user-invocable: false author: ApexCloudWise metadata: @@ -20,7 +20,7 @@ linear-axi authenticates with a Linear personal API key (https://linear.app/sett ## When to use -Use linear-axi whenever a task touches Linear: listing, viewing, filing, editing, or deleting issues; changing issue state, priority, assignee, or labels; nesting issues as sub-issues with `issue create --parent `; commenting on issues or reading their comment threads; discovering team keys; listing projects and scoping issues to one with `issues --project `; listing a team's cycles and scoping issues to one with `issues --cycle current| --team `; putting issues in a project or cycle with `issue create/update --project ` or `--cycle current|`; or listing the workspace's labels and using their names with `issues --label `, `issue create --label `, or `issue update --label `. +Use linear-axi whenever a task touches Linear: listing, viewing, filing, editing, or deleting issues; changing issue state, priority, assignee, or labels; nesting issues as sub-issues with `issue create --parent `; commenting on issues, editing or deleting comments by id, or reading their comment threads; discovering team keys; listing projects and scoping issues to one with `issues --project `; listing a team's cycles and scoping issues to one with `issues --cycle current| --team `; putting issues in a project or cycle with `issue create/update --project ` or `--cycle current|`; or listing the workspace's labels and using their names with `issues --label `, `issue create --label `, or `issue update --label `. ## Workflow @@ -29,7 +29,7 @@ Use linear-axi whenever a task touches Linear: listing, viewing, filing, editing 3. Identify issues by Linear identifier (e.g. `LIN-123`) or UUID. 4. Create an issue with `issue create --title "..." --team `; team keys come from `teams`, `--project ""` / `--cycle current|` land it in a project or cycle straight away, and `--parent LIN-123` nests it as a sub-issue. 5. Change state with `issue update --state ""`; pass the workflow state name exactly (e.g. `"In Progress"`). Assign with `--assignee `, manage labels with `--label ` / `--remove-label ` (repeatable), and move it into a project or cycle with `--project ""` / `--cycle current|` in the same command. -6. Comment with `comment --body "..."` or `--body-file ` for multi-line bodies; read an issue's thread with `comment list ` (newest first, `--full` for untruncated bodies). +6. Comment with `comment --body "..."` or `--body-file ` for multi-line bodies; read an issue's thread with `comment list ` (newest first, `--full` for untruncated bodies). Each row carries the comment's id - edit with `comment update --body "..."`, remove with `comment delete ` (deleting a missing comment is a reported no-op). 7. Every response ends with contextual next-step hints under `help:` - follow them. ## Commands @@ -48,12 +48,12 @@ Run `npx -y @apexcloudwise/linear-axi --help` for global flags, or `npx -y @apex ## Tips - Output is TOON-encoded and token-efficient; pipe through grep/head only when a list is very long. -- Mutations are idempotent: updating an issue to its current state, or deleting an already-deleted issue, is a reported no-op (exit 0). +- Mutations are idempotent: updating an issue to its current state, or deleting an already-deleted issue or comment, is a reported no-op (exit 0). Updating a missing comment instead fails loud - only delete is idempotent. - Repeated `--label` flags match issues carrying ANY of the given labels; a missing or blank value is rejected, never dropped. Label names come from `labels`. - `issue update --label ` adds to an issue's existing labels and `--remove-label ` removes one; the last removal sends an explicit empty set, so it works too. Re-assigning the current assignee (or a label set that already matches) is a reported no-op. - `issue create/update --project ` matches the project name exactly (`projects` lists them) and fails loud on a miss; re-requesting the issue's current project or cycle is a reported no-op. On `--cycle`, "current" resolves the active cycle (scoped to `--team`'s when given; on update without `--team`, the workspace's single active one - ambiguous if several teams have one) while a cycle number always requires `--team` because numbers restart per team (`cycles` lists them). - `issues --cycle current` lists every team's active-cycle issues (add `--team ` to narrow); `--cycle ` also requires `--team` because cycle numbers restart per team - find them with `cycles [--team ]`. - `issues --fields ` and `issue view --fields ` add opt-in extra fields (per-command sets - see each command's --help) when the default columns lack something you need. -- `issue view` truncates the description by default; pass `--full` to see it entirely and list the issue's sub-issues (one `identifier | title | state` line each). `comment list` likewise previews each comment body; `--full` shows them whole. +- `issue view` truncates the description by default; pass `--full` to see it entirely and list the issue's sub-issues (one `identifier | title | state` line each). `comment list` likewise previews each comment body; `--full` shows them whole. Every `comment list` row starts with the comment's full id - the handle `comment update`/`comment delete` take. - `issue create --parent ` creates a sub-issue under the referenced parent (fails loud when the parent is not found; the parent may belong to another team - Linear's hierarchy spans teams). - Issue references accept either a UUID or a `TEAM-NUMBER` identifier like `LIN-123`. diff --git a/src/commands/comment.ts b/src/commands/comment.ts index 4910e1d..c4e3027 100644 --- a/src/commands/comment.ts +++ b/src/commands/comment.ts @@ -1,6 +1,13 @@ import type { LinearContext } from '../context.js'; import { requireKey } from '../config.js'; -import { fetchIssue, createComment, fetchComments } from '../linear.js'; +import { + fetchIssue, + createComment, + fetchComments, + fetchComment, + updateComment, + deleteComment, +} from '../linear.js'; import { assertKnownFlags, takeFlag, @@ -10,6 +17,7 @@ import { import { AxiError } from '../errors.js'; import { custom, + field, relativeTime, renderHelp, renderList, @@ -31,14 +39,21 @@ const COMMENT_BODY_PREVIEW = 200; export const COMMENT_HELP = `usage: linear-axi comment --body "..." [--body-file ] linear-axi comment list [--full] -Add a comment to a Linear issue, or read its comment thread. + linear-axi comment update --body "..." [--body-file ] + linear-axi comment delete + Add, read, edit, or remove comments on a Linear issue. subcommands: list read the issue's comment thread, newest first (--full disables body truncation) + update edit a comment's body (get ids from \`comment list\`) + delete remove a comment (idempotent: a missing comment is a no-op) + + "list", "update", and "delete" are reserved words: a bare reference that + matches one of them cannot route to the create path (no real ref can). flags: - --body "text" comment body (inline; create) - --body-file read body from a UTF-8 file (use for multi-line; create) + --body "text" comment body (inline; create and update) + --body-file read body from a UTF-8 file (use for multi-line; create and update) --full list: show full comment bodies instead of ${COMMENT_BODY_PREVIEW}-char previews examples: @@ -46,24 +61,34 @@ examples: linear-axi comment LIN-123 --body-file ./notes.md linear-axi comment list LIN-123 linear-axi comment list LIN-123 --full + linear-axi comment update 3ba4c5d6-... --body "Edited: see the notes" + linear-axi comment delete 3ba4c5d6-... `; const CREATE_FLAGS = ['--body', '--body-file']; const LIST_FLAGS = ['--full']; +const UPDATE_FLAGS = ['--body', '--body-file']; +const DELETE_FLAGS: string[] = []; export async function commentCommand( args: string[], ctx: LinearContext, ): Promise { // Backward compatible dispatch: a bare `comment --body "..."` (no - // subcommand) keeps working — the create path stays the default. Only the - // literal first positional "list" selects the read subcommand, and no - // Linear issue reference (TEAM-NUMBER like LIN-123, or a UUID) can collide - // with that word, so existing invocations are unaffected. + // subcommand) keeps working — the create path stays the default. Only a + // literal first positional from the reserved set selects a subcommand, and + // no Linear issue reference (TEAM-NUMBER like LIN-123, or a UUID) can + // collide with those words, so existing invocations are unaffected. const sub = getPositional(args, 0); if (sub === 'list') { return listComments(args.slice(1), ctx); } + if (sub === 'update') { + return updateCommentCmd(args.slice(1), ctx); + } + if (sub === 'delete') { + return deleteCommentCmd(args.slice(1), ctx); + } return createCommentCmd(args, ctx); } @@ -114,14 +139,26 @@ async function listComments( hints.push( `Run \`linear-axi comment ${issue.identifier} --body "..."\` to ${comments.length ? 'reply' : 'start the thread'}`, ); + if (comments.length) { + hints.push( + 'Run `linear-axi comment update --body "..."` to edit, or `comment delete ` to remove', + ); + } blocks.push(renderHelp(hints)); return renderOutput(blocks); } -/** Field order: author, body, created. */ +/** + * Field order: id, author, body, created. The id is the comment's UUID — + * rendered in full (never truncated) because it is the handle `comment + * update`/`comment delete` target (#27); a truncated id could not be + * copy-pasted back into a command. It is also short in practice relative to + * the body preview beside it. + */ function listSchema(full: boolean): FieldDef[] { return [ + field('id'), custom('author', (item: any) => authorName(item)), custom('body', (item: any) => commentBodyForDisplay(item.body, full)), relativeTime('createdAt', 'created'), @@ -199,6 +236,108 @@ async function createCommentCmd( ]); } +/** + * `comment update --body|--body-file` (#27). The body guards + * mirror the create path exactly (mutual exclusion + readBodyFile) so both + * write paths behave identically. + * + * Pre-fetches the comment (mirrors updateIssueCmd): update is NOT + * idempotent-by-convention — only delete is — so a missing comment fails + * LOUD with NOT_FOUND rather than silently no-oping. The pre-fetch also + * carries the parent issue's identifier (Comment.issue, nullable in the + * schema for non-issue comments), which the confirmation mentions when + * available. + */ +async function updateCommentCmd( + args: string[], + ctx: LinearContext, +): Promise { + assertKnownFlags(args, UPDATE_FLAGS); + const apiKey = requireKey(ctx.apiKey); + + const idRaw = getPositional(args); + if (!idRaw) { + throw new AxiError('Missing comment id', 'VALIDATION_ERROR', [ + 'Run `linear-axi comment update --body "..."` (ids come from `comment list`)', + ]); + } + + const body = takeFlag(args, '--body'); + const bodyFile = takeFlag(args, '--body-file'); + if (!body && !bodyFile) { + throw new AxiError('--body or --body-file is required', 'VALIDATION_ERROR', [ + 'linear-axi comment update --body "..."', + 'linear-axi comment update --body-file ', + ]); + } + if (body && bodyFile) { + throw new AxiError( + 'Pass only one of --body or --body-file', + 'VALIDATION_ERROR', + ); + } + + const text = body ?? readBodyFile(bodyFile!); + + const comment = await fetchComment(apiKey, idRaw); + if (!comment) { + throw new AxiError(`Comment "${idRaw}" not found`, 'NOT_FOUND', [ + 'Run `linear-axi comment list ` to see comment ids', + ]); + } + + await updateComment(apiKey, comment.id, text); + + // Confirmation mentions the issue identifier when the pre-fetch carried + // one (the usual case); a non-issue comment (Comment.issue is nullable) + // falls back to the comment id so the line stays unambiguous. + const identifier = comment.issue?.identifier; + return renderOutput([ + `comment: updated${identifier ? ` on ${identifier}` : ` ${comment.id}`}`, + renderHelp([ + ...(identifier + ? [ + `Run \`linear-axi comment list ${identifier}\` to read the thread`, + ] + : []), + `Run \`linear-axi comment delete ${comment.id}\` to remove it`, + ]), + ]); +} + +/** + * `comment delete ` (#27). Idempotent no-op when the comment is + * already gone — mirroring deleteIssueCmd's pre-fetch pattern (chosen over + * mapping Linear's delete-not-found error so the convention lives in one + * place in the command layer). Like issue delete, ANY missing id no-ops: + * delete is safe to re-run after a prior delete or with a stale id. + */ +async function deleteCommentCmd( + args: string[], + ctx: LinearContext, +): Promise { + assertKnownFlags(args, DELETE_FLAGS); + const apiKey = requireKey(ctx.apiKey); + + const idRaw = getPositional(args); + if (!idRaw) { + throw new AxiError('Missing comment id', 'VALIDATION_ERROR', [ + 'Run `linear-axi comment delete ` (ids come from `comment list`)', + ]); + } + + const comment = await fetchComment(apiKey, idRaw); + if (!comment) { + // Idempotent: already gone. + return renderOutput([`comment: "${idRaw}" not found (no-op)`]); + } + + await deleteComment(apiKey, comment.id); + return renderOutput([ + `deleted: comment${comment.issue ? ` on ${comment.issue.identifier}` : ` ${comment.id}`}`, + ]); +} + function readBodyFile(path: string): string { try { return readFileSync(path, 'utf-8'); diff --git a/src/linear.ts b/src/linear.ts index d72df0f..8e1ef6d 100644 --- a/src/linear.ts +++ b/src/linear.ts @@ -714,6 +714,36 @@ export async function fetchComments( return { comments: comments.slice(0, limit), hasMore }; } +/** + * A single comment fetched by id via fetchComment: the id plus just enough + * context for the write paths' confirmations — the parent issue's identifier + * (Comment.issue is nullable per @linear/sdk: "Null if the comment belongs to + * a different parent entity type", e.g. project comments). + */ +export interface LinearCommentRef { + id: string; + issue?: { identifier: string } | null; +} + +/** + * Fetch one comment by id (#27). The root `comment(id:)` query exists in + * @linear/sdk v90 (QueryCommentArgs: { hash?: String, id?: String }, returns + * Maybe) — like `issue(id:)`, a missing id resolves to null rather + * than an error, which is exactly what the delete path's idempotent no-op and + * the update path's loud NOT_FOUND branch on. + */ +export async function fetchComment( + apiKey: string, + id: string, +): Promise { + const data = await linearRequest<{ comment: LinearCommentRef | null }>( + apiKey, + `query Comment($id: String!) { comment(id: $id) { id issue { identifier } } }`, + { id }, + ); + return data.comment ?? undefined; +} + /** Selection for label nodes. `color` is a raw HEX string (see LinearLabel). */ export const LABEL_LIST_FIELDS = ` id @@ -1064,6 +1094,61 @@ export async function createComment( } } +/** + * Update a comment's body (#27). Mutation shape verified against the generated + * documents in @linear/sdk v90 (UpdateCommentMutationVariables): the root + * mutation is `commentUpdate(id: String!, input: CommentUpdateInput, ...)` + * returning CommentPayload { comment, lastSyncId, success }. CommentUpdateInput + * exposes exactly one non-internal field — `body: String` ("The comment + * content") — so no omit-null input builder is needed: body is the single + * required value and is always sent. + */ +export async function updateComment( + apiKey: string, + id: string, + body: string, +): Promise { + const data = await linearRequest<{ + commentUpdate: { success: boolean }; + }>( + apiKey, + `mutation UpdateComment($id: String!, $body: String!) { + commentUpdate(id: $id, input: { body: $body }) { + success + } + }`, + { id, body }, + ); + if (!data.commentUpdate.success) { + throw new AxiError('Linear rejected the comment update', 'UNKNOWN'); + } +} + +/** + * Delete a comment (#27). Mutation shape verified against the generated + * documents in @linear/sdk v90 (DeleteCommentMutationVariables): the root + * mutation is `commentDelete(id: String!)` returning DeletePayload + * { entityId, lastSyncId, success } — success is the only field the CLI + * needs, mirroring deleteIssue. + */ +export async function deleteComment( + apiKey: string, + id: string, +): Promise { + const data = await linearRequest<{ + commentDelete: { success: boolean }; + }>( + apiKey, + `mutation DeleteComment($id: String!) { + commentDelete(id: $id) { success } + }`, + { id }, + ); + if (!data.commentDelete.success) { + throw new AxiError('Linear rejected the comment delete', 'UNKNOWN'); + } +} + // --------------------------------------------------------------------------- // Resolution helpers // --------------------------------------------------------------------------- diff --git a/src/skill.ts b/src/skill.ts index 76c0c19..3e47cd2 100644 --- a/src/skill.ts +++ b/src/skill.ts @@ -4,10 +4,10 @@ import { DESCRIPTION, TOP_HELP } from './cli.js'; // outcome-focused so it fires on "needs Linear" intents. export const SKILL_DESCRIPTION = 'Operate Linear (linear.app) through the linear-axi CLI - list and view issues, ' + - 'create, update, and delete issues, nest work as sub-issues, post comments and read comment threads, list teams, list/filter projects, list cycles, and list labels. ' + + 'create, update, and delete issues, nest work as sub-issues, post, edit, and delete comments and read comment threads, list teams, list/filter projects, list cycles, and list labels. ' + 'Use whenever a task touches Linear: triaging or filing issues, changing issue state, setting priority, assigning issues or managing their labels, ' + 'breaking an issue into sub-issues with `issue create --parent` and reading them back with `issue view --full`, ' + - 'commenting on or reading an issue discussion via `comment list`, discovering team keys via `teams`, scoping work to a project via `projects` ' + + 'commenting on or reading an issue discussion via `comment list`, editing or removing a comment via `comment update/delete ` (ids come from `comment list`), discovering team keys via `teams`, scoping work to a project via `projects` ' + 'and `issues --project`, to a cycle via `cycles` and `issues --cycle`, assigning issues to a project or cycle with `issue create/update ' + '--project ` and `--cycle current|`, or finding label names via `labels` for `issue create --label`, ' + '`issue update --label`, and `issues --label`.'; @@ -54,7 +54,7 @@ linear-axi authenticates with a Linear personal API key (https://linear.app/sett ## When to use -Use linear-axi whenever a task touches Linear: listing, viewing, filing, editing, or deleting issues; changing issue state, priority, assignee, or labels; nesting issues as sub-issues with \`issue create --parent \`; commenting on issues or reading their comment threads; discovering team keys; listing projects and scoping issues to one with \`issues --project \`; listing a team's cycles and scoping issues to one with \`issues --cycle current| --team \`; putting issues in a project or cycle with \`issue create/update --project \` or \`--cycle current|\`; or listing the workspace's labels and using their names with \`issues --label \`, \`issue create --label \`, or \`issue update --label \`. +Use linear-axi whenever a task touches Linear: listing, viewing, filing, editing, or deleting issues; changing issue state, priority, assignee, or labels; nesting issues as sub-issues with \`issue create --parent \`; commenting on issues, editing or deleting comments by id, or reading their comment threads; discovering team keys; listing projects and scoping issues to one with \`issues --project \`; listing a team's cycles and scoping issues to one with \`issues --cycle current| --team \`; putting issues in a project or cycle with \`issue create/update --project \` or \`--cycle current|\`; or listing the workspace's labels and using their names with \`issues --label \`, \`issue create --label \`, or \`issue update --label \`. ## Workflow @@ -63,7 +63,7 @@ Use linear-axi whenever a task touches Linear: listing, viewing, filing, editing 3. Identify issues by Linear identifier (e.g. \`LIN-123\`) or UUID. 4. Create an issue with \`issue create --title "..." --team \`; team keys come from \`teams\`, \`--project ""\` / \`--cycle current|\` land it in a project or cycle straight away, and \`--parent LIN-123\` nests it as a sub-issue. 5. Change state with \`issue update --state ""\`; pass the workflow state name exactly (e.g. \`"In Progress"\`). Assign with \`--assignee \`, manage labels with \`--label \` / \`--remove-label \` (repeatable), and move it into a project or cycle with \`--project ""\` / \`--cycle current|\` in the same command. -6. Comment with \`comment --body "..."\` or \`--body-file \` for multi-line bodies; read an issue's thread with \`comment list \` (newest first, \`--full\` for untruncated bodies). +6. Comment with \`comment --body "..."\` or \`--body-file \` for multi-line bodies; read an issue's thread with \`comment list \` (newest first, \`--full\` for untruncated bodies). Each row carries the comment's id - edit with \`comment update --body "..."\`, remove with \`comment delete \` (deleting a missing comment is a reported no-op). 7. Every response ends with contextual next-step hints under \`help:\` - follow them. ## Commands @@ -81,13 +81,13 @@ Run \`npx -y @apexcloudwise/linear-axi --help\` for global flags, or \`npx -y @a ## Tips - Output is TOON-encoded and token-efficient; pipe through grep/head only when a list is very long. -- Mutations are idempotent: updating an issue to its current state, or deleting an already-deleted issue, is a reported no-op (exit 0). +- Mutations are idempotent: updating an issue to its current state, or deleting an already-deleted issue or comment, is a reported no-op (exit 0). Updating a missing comment instead fails loud - only delete is idempotent. - Repeated \`--label\` flags match issues carrying ANY of the given labels; a missing or blank value is rejected, never dropped. Label names come from \`labels\`. - \`issue update --label \` adds to an issue's existing labels and \`--remove-label \` removes one; the last removal sends an explicit empty set, so it works too. Re-assigning the current assignee (or a label set that already matches) is a reported no-op. - \`issue create/update --project \` matches the project name exactly (\`projects\` lists them) and fails loud on a miss; re-requesting the issue's current project or cycle is a reported no-op. On \`--cycle\`, "current" resolves the active cycle (scoped to \`--team\`'s when given; on update without \`--team\`, the workspace's single active one - ambiguous if several teams have one) while a cycle number always requires \`--team\` because numbers restart per team (\`cycles\` lists them). - \`issues --cycle current\` lists every team's active-cycle issues (add \`--team \` to narrow); \`--cycle \` also requires \`--team\` because cycle numbers restart per team - find them with \`cycles [--team ]\`. - \`issues --fields \` and \`issue view --fields \` add opt-in extra fields (per-command sets - see each command's --help) when the default columns lack something you need. -- \`issue view\` truncates the description by default; pass \`--full\` to see it entirely and list the issue's sub-issues (one \`identifier | title | state\` line each). \`comment list\` likewise previews each comment body; \`--full\` shows them whole. +- \`issue view\` truncates the description by default; pass \`--full\` to see it entirely and list the issue's sub-issues (one \`identifier | title | state\` line each). \`comment list\` likewise previews each comment body; \`--full\` shows them whole. Every \`comment list\` row starts with the comment's full id - the handle \`comment update\`/\`comment delete\` take. - \`issue create --parent \` creates a sub-issue under the referenced parent (fails loud when the parent is not found; the parent may belong to another team - Linear's hierarchy spans teams). - Issue references accept either a UUID or a \`TEAM-NUMBER\` identifier like \`LIN-123\`. `; diff --git a/test/comment-list.test.ts b/test/comment-list.test.ts index 93e22ce..34ae1af 100644 --- a/test/comment-list.test.ts +++ b/test/comment-list.test.ts @@ -183,7 +183,7 @@ describe('comment list rendering', () => { apiKey: FAKE_KEY, }); - expect(out).toContain('comments[3]{author,body,created}'); + expect(out).toContain('comments[3]{id,author,body,created}'); expect(out).toContain('newest comment'); expect(out).toContain('middle comment'); expect(out).toContain('oldest comment'); diff --git a/test/comment-update-delete.test.ts b/test/comment-update-delete.test.ts new file mode 100644 index 0000000..10bd2f0 --- /dev/null +++ b/test/comment-update-delete.test.ts @@ -0,0 +1,375 @@ +// Tests for `comment update` / `comment delete` (#27): editing and removing +// comments by id through the `commentUpdate`/`commentDelete` mutations, plus +// `comment list` surfacing the ids that target them. Follows the network-free +// GraphQL stub pattern from comment-list.test.ts / description-file.test.ts: +// stub global fetch, then assert on the captured documents, variables, and +// rendered TOON output. +// +// Flow order under test: +// - update: pre-fetch the comment (query Comment) for the loud NOT_FOUND +// branch and the parent issue identifier, then mutation UpdateComment. +// - delete: same pre-fetch, but a missing comment is the idempotent no-op +// (mirrors deleteIssueCmd) — no mutation is issued for a gone comment. +// - list: the node selection and the rendered rows both carry comment ids. +// - round trip: list → update → delete against one stub, asserting each +// issued document in sequence. +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { commentCommand, COMMENT_HELP } from '../src/commands/comment.js'; +import { AxiError } from '../src/errors.js'; +import type { + LinearComment, + LinearCommentRef, + LinearIssue, +} from '../src/linear.js'; + +const FAKE_KEY = 'lin_api_fake'; + +const DAY_MS = 24 * 60 * 60 * 1000; + +function daysFromNow(n: number): string { + return new Date(Date.now() + n * DAY_MS).toISOString(); +} + +function issueNode(overrides: Partial = {}): LinearIssue { + return { + id: 'ir-1', + identifier: 'LIN-1', + title: 'Ship the thing', + description: 'Body text', + state: { name: 'Started', type: 'started' }, + priority: 2, + assignee: { name: 'Ada' }, + team: { key: 'LIN', name: 'Linear', id: 'team-1' }, + labels: { nodes: [] }, + url: 'https://linear.app/lini/issue/LIN-1/ship-the-thing', + createdAt: daysFromNow(-30), + updatedAt: daysFromNow(-1), + ...overrides, + }; +} + +function commentNode(overrides: Partial = {}): LinearComment { + return { + id: 'cm-9d2c', + body: 'Looks good to me', + user: { name: 'Ada' }, + externalUser: null, + createdAt: daysFromNow(-1), + ...overrides, + }; +} + +/** A comment ref as returned by fetchComment's minimal selection. */ +function commentRef( + overrides: Partial = {}, +): LinearCommentRef { + return { id: 'cm-9d2c', issue: { identifier: 'LIN-1' }, ...overrides }; +} + +function graphqlOk(data: unknown): Response { + return { ok: true, status: 200, json: async () => ({ data }) } as Response; +} + +interface CapturedRequest { + url: string; + body: { query: string; variables: Record }; +} + +/** + * Stub global fetch as a Linear GraphQL endpoint serving every document the + * #27 flows can issue. `commentRef` is served for the `comment(id:)` + * pre-fetch (null models an already-gone comment), `thread` for the comments + * query. Returns captured requests; an unexpected document fails the stub. + */ +function stubLinearFetch(options: { + commentRef?: LinearCommentRef | null; + thread?: LinearComment[]; +}): { requests: CapturedRequest[] } { + const ref = + options.commentRef === undefined ? commentRef() : options.commentRef; + const thread = options.thread ?? [commentNode()]; + const requests: CapturedRequest[] = []; + const fetchMock = vi.fn(async (url: string, init?: RequestInit) => { + const body = JSON.parse(String(init?.body)) as CapturedRequest['body']; + requests.push({ url: String(url), body }); + // NOTE: 'query Comment(' cannot match 'query Comments(' — the required + // "(" sits right after "Comment". + if (body.query.includes('query Comment(')) { + return graphqlOk({ comment: ref }); + } + if (body.query.includes('query Comments(')) { + return graphqlOk({ + comments: { + nodes: thread, + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }); + } + if (body.query.includes('query Issue(')) { + return graphqlOk({ issue: issueNode() }); + } + if (body.query.includes('mutation Comment(')) { + return graphqlOk({ commentCreate: { success: true } }); + } + if (body.query.includes('mutation UpdateComment(')) { + return graphqlOk({ commentUpdate: { success: true } }); + } + if (body.query.includes('mutation DeleteComment(')) { + return graphqlOk({ commentDelete: { success: true } }); + } + throw new Error(`Unexpected GraphQL document in stub: ${body.query}`); + }); + vi.stubGlobal('fetch', fetchMock); + return { requests }; +} + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('comment update document', () => { + it('pre-fetches the comment, then sends commentUpdate with id and body', async () => { + const { requests } = stubLinearFetch({}); + + const out = await commentCommand( + ['update', 'cm-9d2c', '--body', 'Edited body'], + { apiKey: FAKE_KEY }, + ); + + expect(requests).toHaveLength(2); + expect(requests[0].body.query).toContain('query Comment('); + expect(requests[0].body.variables).toEqual({ id: 'cm-9d2c' }); + const mutation = requests[1].body; + expect(mutation.query).toContain('mutation UpdateComment('); + expect(mutation.query).toContain( + 'commentUpdate(id: $id, input: { body: $body })', + ); + expect(mutation.query).toContain('success'); + expect(mutation.variables).toEqual({ id: 'cm-9d2c', body: 'Edited body' }); + // Confirmation mentions the parent issue identifier from the pre-fetch. + expect(out).toContain('comment: updated on LIN-1'); + }); + + it('sends the file content as the body with --body-file', async () => { + const tmpDir = mkdtempSync(join(tmpdir(), 'axi-comment-update-')); + try { + const file = join(tmpDir, 'comment.md'); + writeFileSync(file, 'Edited via file', 'utf-8'); + const { requests } = stubLinearFetch({}); + + await commentCommand(['update', 'cm-9d2c', '--body-file', file], { + apiKey: FAKE_KEY, + }); + + expect(requests[1].body.query).toContain('mutation UpdateComment('); + expect(requests[1].body.variables).toEqual({ + id: 'cm-9d2c', + body: 'Edited via file', + }); + } finally { + rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it('falls back to the comment id in the confirmation when the comment has no issue', async () => { + // Comment.issue is nullable (non-issue comments) — the confirmation must + // not crash or render an empty identifier. + const { requests } = stubLinearFetch({ + commentRef: { id: 'cm-9d2c', issue: null }, + }); + + const out = await commentCommand(['update', 'cm-9d2c', '--body', 'x'], { + apiKey: FAKE_KEY, + }); + + expect(requests[1].body.query).toContain('mutation UpdateComment('); + expect(out).toContain('comment: updated cm-9d2c'); + }); +}); + +describe('comment update guards', () => { + it('fails loud NOT_FOUND when the comment is gone (update is not idempotent)', async () => { + const { requests } = stubLinearFetch({ commentRef: null }); + + const err = await commentCommand(['update', 'cm-404', '--body', 'x'], { + apiKey: FAKE_KEY, + }).catch((e: unknown) => e); + + expect(err).toBeInstanceOf(AxiError); + expect((err as AxiError).code).toBe('NOT_FOUND'); + expect((err as AxiError).message).toContain('cm-404'); + // Only the pre-fetch fired — no mutation for a dead comment. + expect(requests).toHaveLength(1); + }); + + it('rejects --body together with --body-file before any network request', async () => { + const { requests } = stubLinearFetch({}); + + await expect( + commentCommand( + ['update', 'cm-9d2c', '--body', 'inline', '--body-file', 'c.md'], + { apiKey: FAKE_KEY }, + ), + ).rejects.toThrow(/Pass only one of --body or --body-file/); + + expect(requests).toHaveLength(0); + }); + + it('requires a body and a comment id', async () => { + const { requests } = stubLinearFetch({}); + + await expect( + commentCommand(['update', 'cm-9d2c'], { apiKey: FAKE_KEY }), + ).rejects.toThrow(/--body or --body-file is required/); + + await expect( + commentCommand(['update'], { apiKey: FAKE_KEY }), + ).rejects.toThrow(/Missing comment id/); + + expect(requests).toHaveLength(0); + }); + + it('rejects unknown flags on the update path', async () => { + await expect( + commentCommand(['update', 'cm-9d2c', '--body', 'x', '--full'], { + apiKey: FAKE_KEY, + }), + ).rejects.toThrow(/unknown flag --full/); + }); +}); + +describe('comment delete document', () => { + it('pre-fetches the comment, then sends commentDelete with the id', async () => { + const { requests } = stubLinearFetch({}); + + const out = await commentCommand(['delete', 'cm-9d2c'], { + apiKey: FAKE_KEY, + }); + + expect(requests).toHaveLength(2); + expect(requests[0].body.query).toContain('query Comment('); + const mutation = requests[1].body; + expect(mutation.query).toContain('mutation DeleteComment('); + expect(mutation.query).toContain('commentDelete(id: $id) { success }'); + expect(mutation.variables).toEqual({ id: 'cm-9d2c' }); + expect(out).toContain('deleted: comment on LIN-1'); + }); +}); + +describe('comment delete idempotence', () => { + it('treats an already-gone comment as a no-op and issues no mutation', async () => { + const { requests } = stubLinearFetch({ commentRef: null }); + + const out = await commentCommand(['delete', 'cm-gone'], { + apiKey: FAKE_KEY, + }); + + expect(out).toContain('comment: "cm-gone" not found (no-op)'); + // Only the pre-fetch fired — deleting a missing comment mutates nothing. + expect(requests).toHaveLength(1); + expect(requests[0].body.query).toContain('query Comment('); + }); + + it('requires a comment id and rejects flags on the delete path', async () => { + await expect( + commentCommand(['delete'], { apiKey: FAKE_KEY }), + ).rejects.toThrow(/Missing comment id/); + + await expect( + commentCommand(['delete', 'cm-9d2c', '--body', 'x'], { + apiKey: FAKE_KEY, + }), + ).rejects.toThrow(/unknown flag --body/); + }); +}); + +describe('comment list surfaces ids (#27)', () => { + it('selects and renders the comment id in every row', async () => { + const { requests } = stubLinearFetch({ + thread: [ + commentNode({ id: 'cm-aaaa', body: 'first' }), + commentNode({ + id: 'cm-bbbb', + body: 'second', + createdAt: daysFromNow(-2), + }), + ], + }); + + const out = await commentCommand(['list', 'LIN-1'], { + apiKey: FAKE_KEY, + }); + + // Document: the node selection carries the id alongside body/author/time. + const listQuery = requests[1].body.query; + expect(listQuery).toContain('query Comments('); + expect(listQuery).toMatch(/nodes \{\s*\n\s*id\s*\n\s*body/); + // Render: id is the first column of every row, complete (no truncation — + // it is the handle update/delete target). + expect(out).toContain('comments[2]{id,author,body,created}'); + expect(out).toContain('cm-aaaa'); + expect(out).toContain('cm-bbbb'); + }); +}); + +describe('comment dispatch (backward compat + reserved words)', () => { + it('keeps a bare ref on the create path', async () => { + const { requests } = stubLinearFetch({}); + + const out = await commentCommand(['LIN-1', '--body', 'hello'], { + apiKey: FAKE_KEY, + }); + + expect(requests[0].body.query).toContain('query Issue('); + expect(requests[1].body.query).toContain('mutation Comment('); + expect(out).toContain('comment: added to LIN-1'); + }); + + it('documents the new subcommands and the reserved-words note', () => { + expect(COMMENT_HELP).toContain('comment update --body'); + expect(COMMENT_HELP).toContain('comment delete '); + expect(COMMENT_HELP).toContain('idempotent: a missing comment is a no-op'); + expect(COMMENT_HELP).toContain('reserved words'); + expect(COMMENT_HELP).toContain('linear-axi comment delete 3ba4c5d6-...'); + }); +}); + +describe('round trip: list → update → delete', () => { + it('issues each expected document in sequence against one stub', async () => { + const target = commentNode({ id: 'cm-9d2c', body: 'Stale body' }); + const { requests } = stubLinearFetch({ thread: [target] }); + const ctx = { apiKey: FAKE_KEY }; + + // 1. list — resolve the issue, read the thread, see the id. + const listed = await commentCommand(['list', 'LIN-1'], ctx); + expect(listed).toContain('cm-9d2c'); + expect(requests[0].body.query).toContain('query Issue('); + expect(requests[1].body.query).toContain('query Comments('); + + // 2. update — pre-fetch the comment by id, mutate its body. + const updated = await commentCommand( + ['update', 'cm-9d2c', '--body', 'Fresh body'], + ctx, + ); + expect(requests[2].body.query).toContain('query Comment('); + expect(requests[3].body.query).toContain('mutation UpdateComment('); + expect(requests[3].body.variables).toEqual({ + id: 'cm-9d2c', + body: 'Fresh body', + }); + expect(updated).toContain('comment: updated on LIN-1'); + + // 3. delete — pre-fetch again, then remove it. + const deleted = await commentCommand(['delete', 'cm-9d2c'], ctx); + expect(requests[4].body.query).toContain('query Comment('); + expect(requests[5].body.query).toContain('mutation DeleteComment('); + expect(requests[5].body.variables).toEqual({ id: 'cm-9d2c' }); + expect(deleted).toContain('deleted: comment on LIN-1'); + + // Exactly the six expected round trips — nothing else fired. + expect(requests).toHaveLength(6); + }); +});