From 40a0ea68f005bc49bee21a8f86bf0f8a316c0e84 Mon Sep 17 00:00:00 2001 From: Sam Whitmore Date: Thu, 20 Aug 2026 04:08:42 +0530 Subject: [PATCH 01/12] feat: assignee and label changes in issue update MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add --assignee , --label (repeatable), and --remove-label (repeatable) to `issue update` (#24). - --assignee: "me" uses the viewer id from fetchViewer directly (no users() round trip for a write); any other name resolves via users(filter: { name: { eq } }) in the new resolveUserId, which fails loud on no match and on ambiguous display names (candidates listed). Re-assigning the current assignee is a field-level no-op. - --label/--remove-label: Linear's IssueUpdateInput.labelIds REPLACES the whole set, so the command computes the final set — current label ids (ISSUE_DETAIL_FIELDS now selects labels { nodes { id name } }) minus names matched by --remove-label (case-insensitive against the issue's own labels), unioned with --label names resolved via the exported resolveLabelIds. An empty computed set sends labelIds: [] explicitly (removing the last label works); a set identical to the current one is skipped, and an update where every field is a no-op reports "(no-op)" without mutating. - updateIssue (src/linear.ts) extends the omit-null input builder with assigneeId/labelIds; labelIds === [] is deliberately included. - Schema verified against @linear/sdk v90 generated documents: Query.users takes filter: UserFilter (UserFilter.name is a StringComparator with eq), User exposes id/name/email, and IssueUpdateInput has assigneeId?: String ("The identifier of the user to assign the issue to") and labelIds?: [String] ("The identifiers of the issue labels associated with this ticket"). - ISSUE_HELP documents the flags; SKILL.md regenerated (build:skill). Tests: test/issue-update-assignee-labels.test.ts (15 cases, stubbed fetch, no network) — "me" and named resolution, not-found/ambiguous users, label union, one-of-two and last-label removal (labelIds: []), the combined acceptance case, no-op paths, the nothing-to-update guard, and omit-null mutation documents. --- skills/linear-axi/SKILL.md | 7 +- src/commands/issue.ts | 124 ++++++- src/linear.ts | 91 ++++- src/skill.ts | 12 +- test/issue-update-assignee-labels.test.ts | 395 ++++++++++++++++++++++ 5 files changed, 615 insertions(+), 14 deletions(-) create mode 100644 test/issue-update-assignee-labels.test.ts diff --git a/skills/linear-axi/SKILL.md b/skills/linear-axi/SKILL.md index 1f5f0b3..97f223d 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, 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, commenting 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`, or finding label names via `labels` for `issue create --label` and `issues --label`." +description: "Operate Linear (linear.app) through the linear-axi CLI - list and view issues, create, update, and delete 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, 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`, 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 or priority; 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 `; or listing the workspace's labels and using their names with `issues --label ` or `issue create --label `. +Use linear-axi whenever a task touches Linear: listing, viewing, filing, editing, or deleting issues; changing issue state, priority, assignee, or labels; 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 `; or listing the workspace's labels and using their names with `issues --label `, `issue create --label `, or `issue update --label `. ## Workflow @@ -28,7 +28,7 @@ Use linear-axi whenever a task touches Linear: listing, viewing, filing, editing 2. Drill in command-first: `issues`, `issue view `, `teams`, `projects`, `cycles`, `labels`. 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`. -5. Change state with `issue update --state ""`; pass the workflow state name exactly (e.g. `"In Progress"`). +5. Change state with `issue update --state ""`; pass the workflow state name exactly (e.g. `"In Progress"`). Assign with `--assignee ` and manage labels with `--label ` / `--remove-label ` (repeatable) 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). 7. Every response ends with contextual next-step hints under `help:` - follow them. @@ -50,6 +50,7 @@ Run `npx -y @apexcloudwise/linear-axi --help` for global flags, or `npx -y @apex - 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). - 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. - `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. `comment list` likewise previews each comment body; `--full` shows them whole. diff --git a/src/commands/issue.ts b/src/commands/issue.ts index 46f6dae..3b7b543 100644 --- a/src/commands/issue.ts +++ b/src/commands/issue.ts @@ -2,9 +2,12 @@ import type { LinearContext } from '../context.js'; import { requireKey } from '../config.js'; import { fetchIssue, + fetchViewer, createIssue, updateIssue, deleteIssue, + resolveUserId, + resolveLabelIds, } from '../linear.js'; import { assertKnownFlags, @@ -35,23 +38,39 @@ subcommands: view show issue details (--full disables description truncation; --fields adds extra fields) create --title "..." --team [--description "..."] [--label ...] update [--state ] [--title "..."] [--priority <0-4>] [--description "..."] + [--assignee ] [--label ] [--remove-label ] delete view --fields names (opt-in, comma-separated): dueDate, estimate, archivedAt, branchName default detail fields are untouched without the flag +update flags: + --assignee assign the issue; "me" is the authenticated viewer + --label add a label (repeatable; names from \`linear-axi labels\`) + --remove-label remove a label (repeatable); removing the last one clears labels + examples: linear-axi issue view LIN-123 linear-axi issue view LIN-123 --full linear-axi issue view LIN-123 --fields dueDate,estimate linear-axi issue create --title "Fix login" --team ENG --label bug linear-axi issue update LIN-123 --state "In Progress" + linear-axi issue update LIN-123 --assignee me --label bug + linear-axi issue update LIN-123 --remove-label bug linear-axi issue delete LIN-123 `; const VIEW_FLAGS = ['--full', '--fields']; const CREATE_FLAGS = ['--title', '--team', '--description', '--label']; -const UPDATE_FLAGS = ['--state', '--title', '--priority', '--description']; +const UPDATE_FLAGS = [ + '--state', + '--title', + '--priority', + '--description', + '--assignee', + '--label', + '--remove-label', +]; const detailSchema: FieldDef[] = [ field('identifier'), @@ -236,10 +255,21 @@ async function updateIssueCmd( const title = takeFlag(args, '--title'); const priorityRaw = takeFlag(args, '--priority'); const description = takeFlag(args, '--description'); + const assigneeRaw = takeFlag(args, '--assignee'); + const addLabelNames = takeAllFlags(args, '--label'); + const removeLabelNames = takeAllFlags(args, '--remove-label'); - if (!stateName && !title && priorityRaw === undefined && description === undefined) { + if ( + !stateName && + !title && + priorityRaw === undefined && + description === undefined && + assigneeRaw === undefined && + addLabelNames.length === 0 && + removeLabelNames.length === 0 + ) { throw new AxiError( - 'Nothing to update — pass at least one of --state, --title, --priority, --description', + 'Nothing to update — pass at least one of --state, --title, --priority, --description, --assignee, --label, --remove-label', 'VALIDATION_ERROR', ); } @@ -249,6 +279,13 @@ async function updateIssueCmd( priority = parsePriority(priorityRaw); } + // Blank flag values fail loud before any network request (takeAllFlags + // already rejects blank --label/--remove-label the same way). + const assigneeName = assigneeRaw?.trim(); + if (assigneeRaw !== undefined && !assigneeName) { + throw new AxiError('--assignee requires a value', 'VALIDATION_ERROR'); + } + const existing = await fetchIssue(apiKey, refRaw); if (!existing) { throw new AxiError(`Issue "${refRaw}" not found`, 'NOT_FOUND'); @@ -264,11 +301,92 @@ async function updateIssueCmd( ]); } + // Assignee (#24): "me" resolves to the viewer — fetchViewer already + // returns the id the mutation needs, so no users() round trip. Any other + // value resolves through users(filter: { name: { eq } }) and fails loud on + // no match or an ambiguous display name. Requesting the current assignee + // is a field-level no-op (skipped, like a matching state above). + let assigneeId: string | undefined; + if (assigneeName) { + let resolvedId: string; + let resolvedName: string; + if (assigneeName.toLowerCase() === 'me') { + const viewer = await fetchViewer(apiKey).catch(() => null); + if (!viewer?.id) { + throw new AxiError( + 'Could not resolve your Linear user for --assignee me', + 'UNKNOWN', + ); + } + resolvedId = viewer.id; + resolvedName = viewer.name; + } else { + resolvedId = await resolveUserId(apiKey, assigneeName); + resolvedName = assigneeName; + } + const currentAssignee = existing.assignee?.name; + if ( + !currentAssignee || + resolvedName.toLowerCase() !== currentAssignee.toLowerCase() + ) { + assigneeId = resolvedId; + } + } + + // Labels (#24): Linear's labelIds REPLACES the whole set, so compute the + // final set here — the issue's current label ids (ISSUE_DETAIL_FIELDS + // selects labels { nodes { id name } }) minus names matched by + // --remove-label (case-insensitive, against the issue's own labels so an + // absent name is a harmless no-op), unioned with --label names resolved + // workspace-wide via resolveLabelIds. When the computed set equals the + // current one the field is skipped; when removal empties it, [] is sent + // explicitly (updateIssue) — removing the last label must work. + let labelIds: string[] | undefined; + if (addLabelNames.length > 0 || removeLabelNames.length > 0) { + const currentNodes = existing.labels?.nodes ?? []; + const currentIds = currentNodes + .map((n) => n.id) + .filter((id): id is string => id !== undefined); + const removeLower = removeLabelNames.map((n) => n.trim().toLowerCase()); + const keptIds = currentNodes + .filter((n) => !removeLower.includes(n.name.toLowerCase())) + .map((n) => n.id) + .filter((id): id is string => id !== undefined); + const addIds = addLabelNames.length + ? await resolveLabelIds(apiKey, addLabelNames) + : []; + const nextIds = [...new Set([...keptIds, ...addIds])]; + const unchanged = + nextIds.length === currentIds.length && + nextIds.every((id) => currentIds.includes(id)); + if (!unchanged) { + labelIds = nextIds; + } + } + + // Each field can individually be a no-op (a matching state short-circuits + // above; a matching assignee/label set is skipped). When NOTHING would be + // sent, report the no-op instead of mutating with an empty input. + const hasPatch = + title !== undefined || + description !== undefined || + stateName !== undefined || + priority !== undefined || + assigneeId !== undefined || + labelIds !== undefined; + if (!hasPatch) { + return renderOutput([ + `issue: ${existing.identifier} already up to date (no-op)`, + ]); + } + const updated = await updateIssue(apiKey, existing.id, { title, description, stateName, priority, + assigneeId, + labelIds, }); return renderOutput([ diff --git a/src/linear.ts b/src/linear.ts index 1e15e82..869d82f 100644 --- a/src/linear.ts +++ b/src/linear.ts @@ -66,6 +66,13 @@ export const ISSUE_LIST_FIELDS = ` updatedAt `; +/** + * Detail selection for a single issue. Label nodes include `id` because + * `issue update --label/--remove-label` (#24) needs the issue's CURRENT label + * ids to compute the union/difference replacement set — names alone cannot be + * sent back to IssueUpdateInput.labelIds. Renderers read only `name`, so the + * extra key is invisible in output. + */ export const ISSUE_DETAIL_FIELDS = ` id identifier @@ -75,7 +82,7 @@ export const ISSUE_DETAIL_FIELDS = ` priority assignee { name } team { key name id } - labels { nodes { name } } + labels { nodes { id name } } url createdAt updatedAt @@ -129,7 +136,9 @@ export interface LinearIssue { priority?: number; assignee?: { name: string } | null; team?: { id?: string; key: string; name?: string } | null; - labels?: { nodes: Array<{ name: string }> } | null; + // `id` is present when the selection includes it (ISSUE_DETAIL_FIELDS does; + // ISSUE_LIST_FIELDS selects no labels at all), so it is optional here. + labels?: { nodes: Array<{ id?: string; name: string }> } | null; url?: string; createdAt?: string; updatedAt?: string; @@ -808,6 +817,21 @@ export interface IssueUpdate { description?: string; stateName?: string; priority?: number; + /** + * Assign the issue to this user id. Undefined leaves the assignee + * untouched; Linear's IssueUpdateInput.assigneeId is a nullable String + * ("The identifier of the user to assign the issue to", @linear/sdk v90). + */ + assigneeId?: string; + /** + * FULL REPLACEMENT label-id set — Linear's IssueUpdateInput.labelIds + * replaces the issue's labels wholesale ("The identifiers of the issue + * labels associated with this ticket", @linear/sdk v90). Callers compute + * union/difference against the issue's current ids. An EMPTY ARRAY IS + * SENT EXPLICITLY (removing the last label must work); only undefined + * means "leave labels alone". + */ + labelIds?: string[]; } /** @@ -852,6 +876,20 @@ export async function updateIssue( varDecls.push('$stateId: String'); variables['stateId'] = stateId; } + // assigneeId/labelIds follow the same omit-null convention as above, with + // one deliberate exception: labelIds === [] IS included — an explicit + // removal of the last label must reach Linear as an empty replacement set, + // not be skipped like a null optional. + if (update.assigneeId !== undefined) { + inputFields.push('assigneeId: $assigneeId'); + varDecls.push('$assigneeId: String'); + variables['assigneeId'] = update.assigneeId; + } + if (update.labelIds !== undefined) { + inputFields.push('labelIds: $labelIds'); + varDecls.push('$labelIds: [String!]'); + variables['labelIds'] = update.labelIds; + } const data = await linearRequest<{ issueUpdate: { issue: LinearIssue; success: boolean }; @@ -955,8 +993,9 @@ export async function resolveProjectId( * Reuses fetchLabels (auto-paginated) so label resolution keeps working in * workspaces with more than one page (50) of labels — the old inline query * here fetched a single unpaginated page and silently missed the rest. + * Unknown names are silently skipped (same semantics as issue create). */ -async function resolveLabelIds( +export async function resolveLabelIds( apiKey: string, names: string[], ): Promise { @@ -967,6 +1006,52 @@ async function resolveLabelIds( .map((l) => l.id); } +/** + * Resolve a user's display name (exact match) to their id for + * `issue update --assignee ` (#24). Query shape verified against the + * generated documents in @linear/sdk v90: Query.users takes + * `filter: UserFilter` where UserFilter.name is a StringComparator with `eq`, + * and the User type exposes id (ID), name, email — all selected here. + * + * Fails loud on both no-match and multi-match (display names are not unique + * in Linear), listing the matched candidates like resolveStateIdByName lists + * available states. + */ +export async function resolveUserId( + apiKey: string, + name: string, +): Promise { + const data = await linearRequest<{ + users: { nodes: Array<{ id: string; name: string; email: string }> }; + }>( + apiKey, + `query UserByName($name: String!) { + users(filter: { name: { eq: $name } }) { nodes { id name email } } + }`, + { name }, + ); + + const nodes = data.users.nodes; + if (nodes.length === 0) { + throw new AxiError( + `User "${name}" not found in your workspace`, + 'VALIDATION_ERROR', + ['Use the exact display name, or "me" for yourself'], + ); + } + if (nodes.length > 1) { + const candidates = nodes + .map((u) => `${u.name} <${u.email}>`) + .join(', '); + throw new AxiError( + `User "${name}" matches ${nodes.length} users`, + 'VALIDATION_ERROR', + [`Candidates: ${candidates}`], + ); + } + return nodes[0]!.id; +} + async function resolveStateIdByName( apiKey: string, issueRef: string, diff --git a/src/skill.ts b/src/skill.ts index a8b3434..0f8005f 100644 --- a/src/skill.ts +++ b/src/skill.ts @@ -5,9 +5,10 @@ import { DESCRIPTION, TOP_HELP } from './cli.js'; export const SKILL_DESCRIPTION = 'Operate Linear (linear.app) through the linear-axi CLI - list and view issues, ' + 'create, update, and delete 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, ' + - 'commenting 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`, or finding label names via `labels` for `issue create --label` and `issues --label`.'; + 'Use whenever a task touches Linear: triaging or filing issues, changing issue state, setting priority, assigning issues or managing their labels, ' + + '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`, or finding label names via `labels` for `issue create --label`, ' + + '`issue update --label`, and `issues --label`.'; export const SKILL_AUTHOR = 'ApexCloudWise'; @@ -51,7 +52,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 or priority; 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 \`; or listing the workspace's labels and using their names with \`issues --label \` or \`issue create --label \`. +Use linear-axi whenever a task touches Linear: listing, viewing, filing, editing, or deleting issues; changing issue state, priority, assignee, or labels; 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 \`; or listing the workspace's labels and using their names with \`issues --label \`, \`issue create --label \`, or \`issue update --label \`. ## Workflow @@ -59,7 +60,7 @@ Use linear-axi whenever a task touches Linear: listing, viewing, filing, editing 2. Drill in command-first: \`issues\`, \`issue view \`, \`teams\`, \`projects\`, \`cycles\`, \`labels\`. 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\`. -5. Change state with \`issue update --state ""\`; pass the workflow state name exactly (e.g. \`"In Progress"\`). +5. Change state with \`issue update --state ""\`; pass the workflow state name exactly (e.g. \`"In Progress"\`). Assign with \`--assignee \` and manage labels with \`--label \` / \`--remove-label \` (repeatable) 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). 7. Every response ends with contextual next-step hints under \`help:\` - follow them. @@ -80,6 +81,7 @@ Run \`npx -y @apexcloudwise/linear-axi --help\` for global flags, or \`npx -y @a - 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). - 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. - \`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. \`comment list\` likewise previews each comment body; \`--full\` shows them whole. diff --git a/test/issue-update-assignee-labels.test.ts b/test/issue-update-assignee-labels.test.ts new file mode 100644 index 0000000..e6ae0cc --- /dev/null +++ b/test/issue-update-assignee-labels.test.ts @@ -0,0 +1,395 @@ +// Tests for `issue update --assignee / --label / --remove-label` (#24). +// Follows the network-free GraphQL stub pattern from labels.test.ts / +// comment-list.test.ts: stub global fetch, then assert on the captured query +// documents, mutation variables, and rendered TOON output. Update flow order: +// resolve the ref (query Issue), resolve the assignee (viewer for "me", +// UserByName otherwise), resolve added label names (query Labels), mutate. +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { issueCommand, ISSUE_HELP } from '../src/commands/issue.js'; +import type { AxiError } from '../errors.js'; +import type { LinearIssue, LinearLabel } from '../src/linear.js'; + +const FAKE_KEY = 'lin_api_fake'; + +interface CapturedRequest { + url: string; + body: { query: string; variables: Record }; +} + +interface StubUser { + id: string; + name: string; + email: string; +} + +interface StubOptions { + /** The issue served for `query Issue(` (default: LIN-1, unassigned). */ + issue?: LinearIssue | null; + /** Users served for `query UserByName(` (default: one Ada Lovelace). */ + users?: StubUser[]; + /** Viewer served for the `viewer { ... }` query (default: Sam the viewer). */ + viewer?: StubUser | null; + /** Labels served for `query Labels(` (default: bug + feature). */ + labels?: LinearLabel[]; +} + +function issueNode(overrides: Partial = {}): LinearIssue { + return { + id: 'ir-1', + identifier: 'LIN-1', + title: 'Ship the thing', + description: 'Body text', + state: { name: 'Todo', type: 'unstarted' }, + priority: 2, + assignee: null, + team: { key: 'LIN', name: 'Linear', id: 'team-1' }, + labels: { nodes: [] }, + url: 'https://linear.app/lini/issue/LIN-1/ship-the-thing', + createdAt: '2026-07-01T00:00:00.000Z', + updatedAt: '2026-08-01T00:00:00.000Z', + ...overrides, + }; +} + +const DEFAULT_VIEWER: StubUser = { + id: 'user-viewer', + name: 'Sam Viewer', + email: 'sam@example.com', +}; + +const DEFAULT_USERS: StubUser[] = [ + { id: 'user-ada', name: 'Ada Lovelace', email: 'ada@example.com' }, +]; + +const DEFAULT_LABELS: LinearLabel[] = [ + { id: 'lb-bug', name: 'bug', color: '#EB5757' }, + { id: 'lb-feat', name: 'feature', color: '#4CB782' }, +]; + +function graphqlOk(data: unknown): Response { + return { ok: true, status: 200, json: async () => ({ data }) } as Response; +} + +/** + * Stub global fetch as a Linear GraphQL endpoint serving every document the + * update flow can issue. Returns the captured requests for assertions. + */ +function stubUpdateFetch(opts: StubOptions = {}): { + requests: CapturedRequest[]; +} { + const issue = opts.issue === undefined ? issueNode() : opts.issue; + const users = opts.users ?? DEFAULT_USERS; + const viewer = opts.viewer === undefined ? DEFAULT_VIEWER : opts.viewer; + const labels = opts.labels ?? DEFAULT_LABELS; + + 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 }); + if (body.query.includes('query Issue(')) { + return graphqlOk({ issue }); + } + if (body.query.includes('viewer {')) { + return graphqlOk({ viewer }); + } + if (body.query.includes('query UserByName(')) { + return graphqlOk({ users: { nodes: users } }); + } + if (body.query.includes('query Labels(')) { + return graphqlOk({ + issueLabels: { + nodes: labels, + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }); + } + return graphqlOk({ + issueUpdate: { + success: true, + issue: { + id: 'ir-1', + identifier: 'LIN-1', + title: 'Ship the thing', + url: 'https://linear.app/lini/issue/LIN-1/ship-the-thing', + }, + }, + }); + }); + vi.stubGlobal('fetch', fetchMock); + return { requests }; +} + +const mutationRequests = (requests: CapturedRequest[]) => + requests.filter((r) => r.body.query.includes('mutation UpdateIssue(')); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('issue update --assignee', () => { + it('resolves "me" to the viewer id and sends only assigneeId', async () => { + const { requests } = stubUpdateFetch(); + + const out = await issueCommand(['update', 'LIN-1', '--assignee', 'me'], { + apiKey: FAKE_KEY, + }); + + // Issue resolution, then viewer, then the mutation. + expect(requests).toHaveLength(3); + expect(requests[1].body.query).toContain('viewer { id name email }'); + const mutation = requests[2]; + expect(mutation.body.query).toContain('assigneeId: $assigneeId'); + // Omit-null builder: only the fields that changed are declared/sent. + expect(mutation.body.query).toContain( + 'mutation UpdateIssue($id: String!, $assigneeId: String)', + ); + expect(mutation.body.query).not.toContain('labelIds'); + expect(mutation.body.query).not.toContain('$title'); + expect(mutation.body.variables).toEqual({ + id: 'ir-1', + assigneeId: 'user-viewer', + }); + expect(out).toContain('updated: LIN-1'); + }); + + it('resolves a named assignee through users(filter: { name: { eq } })', async () => { + const { requests } = stubUpdateFetch(); + + await issueCommand(['update', 'LIN-1', '--assignee', 'Ada Lovelace'], { + apiKey: FAKE_KEY, + }); + + expect(requests).toHaveLength(3); + expect(requests[1].body.query).toContain( + 'users(filter: { name: { eq: $name } })', + ); + expect(requests[1].body.query).toContain('nodes { id name email }'); + expect(requests[1].body.variables).toEqual({ name: 'Ada Lovelace' }); + expect(requests[2].body.variables).toEqual({ + id: 'ir-1', + assigneeId: 'user-ada', + }); + }); + + it('fails loud when the assignee name matches no user', async () => { + const { requests } = stubUpdateFetch({ users: [] }); + + await expect( + issueCommand(['update', 'LIN-1', '--assignee', 'Nobody'], { + apiKey: FAKE_KEY, + }), + ).rejects.toThrow(/User "Nobody" not found/); + + expect(mutationRequests(requests)).toHaveLength(0); + }); + + it('fails loud listing candidates when the name is ambiguous', async () => { + const { requests } = stubUpdateFetch({ + users: [ + { id: 'user-ada-1', name: 'Ada Lovelace', email: 'ada@example.com' }, + { id: 'user-ada-2', name: 'Ada Lovelace', email: 'ada2@example.com' }, + ], + }); + + await expect( + issueCommand(['update', 'LIN-1', '--assignee', 'Ada Lovelace'], { + apiKey: FAKE_KEY, + }), + ).rejects.toThrow(/matches 2 users/); + + const err = await issueCommand( + ['update', 'LIN-1', '--assignee', 'Ada Lovelace'], + { apiKey: FAKE_KEY }, + ).catch((e: AxiError) => e); + expect(err.suggestions.join(' ')).toContain('ada@example.com'); + expect(err.suggestions.join(' ')).toContain('ada2@example.com'); + expect(mutationRequests(requests)).toHaveLength(0); + }); + + it('reports re-assigning the current assignee as a no-op (no mutation)', async () => { + const { requests } = stubUpdateFetch({ + issue: issueNode({ assignee: { name: 'Ada Lovelace' } }), + }); + + const out = await issueCommand( + ['update', 'LIN-1', '--assignee', 'ada lovelace'], + { apiKey: FAKE_KEY }, + ); + + expect(out).toContain('(no-op)'); + expect(mutationRequests(requests)).toHaveLength(0); + }); + + it('reports --assignee me as a no-op when the viewer is assigned', async () => { + const { requests } = stubUpdateFetch({ + issue: issueNode({ assignee: { name: 'Sam Viewer' } }), + }); + + const out = await issueCommand(['update', 'LIN-1', '--assignee', 'me'], { + apiKey: FAKE_KEY, + }); + + expect(out).toContain('(no-op)'); + expect(mutationRequests(requests)).toHaveLength(0); + }); +}); + +describe('issue update --label / --remove-label', () => { + it('unions resolved --label ids with the current label ids', async () => { + const { requests } = stubUpdateFetch({ + issue: issueNode({ + labels: { nodes: [{ id: 'lb-feat', name: 'feature' }] }, + }), + }); + + const out = await issueCommand(['update', 'LIN-1', '--label', 'bug'], { + apiKey: FAKE_KEY, + }); + + // Issue resolution, label-name resolution (reuse of fetchLabels), mutate. + expect(requests).toHaveLength(3); + expect(requests[1].body.query).toContain( + 'issueLabels(first: $first, after: $after, orderBy: updatedAt)', + ); + const mutation = requests[2]; + expect(mutation.body.query).toContain('labelIds: $labelIds'); + expect(mutation.body.query).toContain('$labelIds: [String!]'); + // Current ids first, resolved additions after — the full replacement set. + expect(mutation.body.variables).toEqual({ + id: 'ir-1', + labelIds: ['lb-feat', 'lb-bug'], + }); + expect(out).toContain('updated: LIN-1'); + }); + + it("removes one of two labels by matching the issue's own label names", async () => { + const { requests } = stubUpdateFetch({ + issue: issueNode({ + labels: { + nodes: [ + { id: 'lb-bug', name: 'bug' }, + { id: 'lb-feat', name: 'feature' }, + ], + }, + }), + }); + + await issueCommand(['update', 'LIN-1', '--remove-label', 'BUG'], { + apiKey: FAKE_KEY, + }); + + // Removal matches against the issue's own labels — no workspace labels + // round trip, just resolve + mutate. Case-insensitive name match. + expect(requests).toHaveLength(2); + expect(requests[1].body.variables).toEqual({ + id: 'ir-1', + labelIds: ['lb-feat'], + }); + }); + + it('sends labelIds: [] explicitly when the LAST label is removed', async () => { + const { requests } = stubUpdateFetch({ + issue: issueNode({ + labels: { nodes: [{ id: 'lb-bug', name: 'bug' }] }, + }), + }); + + const out = await issueCommand( + ['update', 'LIN-1', '--remove-label', 'bug'], + { apiKey: FAKE_KEY }, + ); + + const mutation = requests[1]; + // The field must be INCLUDED with an empty array — an omitted labelIds + // would leave the label in place (Linear replaces the whole set). + expect(mutation.body.query).toContain('labelIds: $labelIds'); + expect(mutation.body.variables).toEqual({ id: 'ir-1', labelIds: [] }); + expect(out).toContain('updated: LIN-1'); + }); + + it('reports a label set that already matches as a no-op (no mutation)', async () => { + const { requests } = stubUpdateFetch({ + issue: issueNode({ + labels: { nodes: [{ id: 'lb-bug', name: 'bug' }] }, + }), + }); + + const out = await issueCommand(['update', 'LIN-1', '--label', 'bug'], { + apiKey: FAKE_KEY, + }); + + expect(out).toContain('(no-op)'); + expect(mutationRequests(requests)).toHaveLength(0); + }); + + it('treats removing a label the issue does not carry as a no-op', async () => { + const { requests } = stubUpdateFetch({ + issue: issueNode({ + labels: { nodes: [{ id: 'lb-bug', name: 'bug' }] }, + }), + }); + + const out = await issueCommand( + ['update', 'LIN-1', '--remove-label', 'feature'], + { apiKey: FAKE_KEY }, + ); + + expect(out).toContain('(no-op)'); + expect(mutationRequests(requests)).toHaveLength(0); + }); +}); + +describe('issue update acceptance and guards', () => { + it('applies --assignee me --label bug together (acceptance case)', async () => { + const { requests } = stubUpdateFetch(); + + const out = await issueCommand( + ['update', 'LIN-1', '--assignee', 'me', '--label', 'bug'], + { apiKey: FAKE_KEY }, + ); + + // Resolve ref, resolve viewer, resolve label names, mutate. + expect(requests).toHaveLength(4); + expect(requests[3].body.variables).toEqual({ + id: 'ir-1', + assigneeId: 'user-viewer', + labelIds: ['lb-bug'], + }); + expect(out).toContain('updated: LIN-1'); + }); + + it('still rejects an update with nothing to do, listing every flag', async () => { + const { requests } = stubUpdateFetch(); + + await expect( + issueCommand(['update', 'LIN-1'], { apiKey: FAKE_KEY }), + ).rejects.toThrow(/Nothing to update/); + + // The guard fires before any network request. + expect(requests).toHaveLength(0); + }); + + it('rejects blank --label and --assignee values loudly', async () => { + stubUpdateFetch(); + await expect( + issueCommand(['update', 'LIN-1', '--label', ''], { apiKey: FAKE_KEY }), + ).rejects.toThrow(/--label requires a value/); + await expect( + issueCommand(['update', 'LIN-1', '--assignee', ''], { + apiKey: FAKE_KEY, + }), + ).rejects.toThrow(/--assignee requires a value/); + }); + + it('documents the new flags in help', () => { + expect(ISSUE_HELP).toContain('[--assignee ]'); + expect(ISSUE_HELP).toContain('[--label ]'); + expect(ISSUE_HELP).toContain('[--remove-label ]'); + expect(ISSUE_HELP).toContain( + 'linear-axi issue update LIN-123 --assignee me --label bug', + ); + expect(ISSUE_HELP).toContain( + 'linear-axi issue update LIN-123 --remove-label bug', + ); + }); +}); From 093c37f32a6b878f39c8b71c8d5daec0349e3189 Mon Sep 17 00:00:00 2001 From: Sam Whitmore Date: Thu, 20 Aug 2026 04:21:14 +0530 Subject: [PATCH 02/12] feat: project and cycle assignment on issue create/update --- skills/linear-axi/SKILL.md | 9 +- src/commands/issue.ts | 273 +++++++++++- src/linear.ts | 163 +++++++- src/skill.ts | 10 +- test/project-cycle-assignment.test.ts | 575 ++++++++++++++++++++++++++ 5 files changed, 1012 insertions(+), 18 deletions(-) create mode 100644 test/project-cycle-assignment.test.ts diff --git a/skills/linear-axi/SKILL.md b/skills/linear-axi/SKILL.md index 97f223d..4ba2b8d 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, 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, 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`, 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, 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, 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`." user-invocable: false author: ApexCloudWise metadata: @@ -20,15 +20,15 @@ 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; 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 `; 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; 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 `. ## Workflow 1. Run `npx -y @apexcloudwise/linear-axi` with no arguments for a dashboard - the current viewer and your started, assigned issues. 2. Drill in command-first: `issues`, `issue view `, `teams`, `projects`, `cycles`, `labels`. 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`. -5. Change state with `issue update --state ""`; pass the workflow state name exactly (e.g. `"In Progress"`). Assign with `--assignee ` and manage labels with `--label ` / `--remove-label ` (repeatable) in the same command. +4. Create an issue with `issue create --title "..." --team `; team keys come from `teams`, and `--project ""` / `--cycle current|` land it in a project or cycle straight away. +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). 7. Every response ends with contextual next-step hints under `help:` - follow them. @@ -51,6 +51,7 @@ Run `npx -y @apexcloudwise/linear-axi --help` for global flags, or `npx -y @apex - Mutations are idempotent: updating an issue to its current state, or deleting an already-deleted issue, is a reported no-op (exit 0). - 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. `comment list` likewise previews each comment body; `--full` shows them whole. diff --git a/src/commands/issue.ts b/src/commands/issue.ts index 3b7b543..5a31b36 100644 --- a/src/commands/issue.ts +++ b/src/commands/issue.ts @@ -8,6 +8,9 @@ import { deleteIssue, resolveUserId, resolveLabelIds, + requireProjectId, + fetchActiveCycles, + resolveCycleIdByNumber, } from '../linear.js'; import { assertKnownFlags, @@ -36,9 +39,9 @@ Manage Linear issues. subcommands: view show issue details (--full disables description truncation; --fields adds extra fields) - create --title "..." --team [--description "..."] [--label ...] + create --title "..." --team [--description "..."] [--label ...] [--project ] [--cycle ] update [--state ] [--title "..."] [--priority <0-4>] [--description "..."] - [--assignee ] [--label ] [--remove-label ] + [--assignee ] [--label ] [--remove-label ] [--project ] [--cycle ] delete view --fields names (opt-in, comma-separated): dueDate, estimate, archivedAt, branchName @@ -49,19 +52,37 @@ update flags: --label add a label (repeatable; names from \`linear-axi labels\`) --remove-label remove a label (repeatable); removing the last one clears labels +project/cycle flags (create and update): + --project put the issue in a project (exact name; run \`linear-axi projects\`) + --cycle put the issue in a cycle: "current" = the active cycle — with --team, that team's; + without --team (update only), the workspace's single active one (fails if several teams have one); + a cycle number always requires --team (numbers restart per team; run \`linear-axi cycles\`) + --team scopes --cycle; on create it is the issue's team, on update it must be the issue's own team + examples: linear-axi issue view LIN-123 linear-axi issue view LIN-123 --full linear-axi issue view LIN-123 --fields dueDate,estimate linear-axi issue create --title "Fix login" --team ENG --label bug + linear-axi issue create --title "Ship v2" --team LIN --project "Q3 launch" --cycle current linear-axi issue update LIN-123 --state "In Progress" linear-axi issue update LIN-123 --assignee me --label bug linear-axi issue update LIN-123 --remove-label bug + linear-axi issue update LIN-123 --project "Mobile app" + linear-axi issue update LIN-123 --cycle current + linear-axi issue update LIN-123 --cycle 3 --team LIN linear-axi issue delete LIN-123 `; const VIEW_FLAGS = ['--full', '--fields']; -const CREATE_FLAGS = ['--title', '--team', '--description', '--label']; +const CREATE_FLAGS = [ + '--title', + '--team', + '--description', + '--label', + '--project', + '--cycle', +]; const UPDATE_FLAGS = [ '--state', '--title', @@ -70,6 +91,9 @@ const UPDATE_FLAGS = [ '--assignee', '--label', '--remove-label', + '--project', + '--cycle', + '--team', ]; const detailSchema: FieldDef[] = [ @@ -199,6 +223,15 @@ async function createIssueCmd( const team = takeFlag(args, '--team'); const description = takeFlag(args, '--description'); const labels = takeAllFlags(args, '--label'); + // has* detection must precede takeFlag (which removes the flag pair). + const hasProject = args.some( + (a) => a === '--project' || a.startsWith('--project='), + ); + const projectName = takeFlag(args, '--project'); + const hasCycle = args.some( + (a) => a === '--cycle' || a.startsWith('--cycle='), + ); + const cycleRaw = takeFlag(args, '--cycle'); if (!title || !title.trim()) { throw new AxiError('--title is required', 'VALIDATION_ERROR', [ @@ -211,11 +244,43 @@ async function createIssueCmd( ]); } + // Blank/missing --project/--cycle values fail loud before any network + // request (same guard shape as the `issues` read path). + if (hasProject && (projectName === undefined || projectName.trim() === '')) { + throw new AxiError('--project requires a value', 'VALIDATION_ERROR', [ + 'e.g. --project "Q3 launch"', + ]); + } + if (hasCycle && (cycleRaw === undefined || cycleRaw.trim() === '')) { + throw new AxiError('--cycle requires a value', 'VALIDATION_ERROR', [ + 'Use --cycle current for the active cycle, or --cycle (per --team)', + ]); + } + + // Resolve --project/--cycle to ids BEFORE the mutation (#25): a loud + // failure here beats an issue created without its requested project/cycle. + // On create --team is required, so every cycle lookup is team-scoped — no + // cross-team ambiguity exists on this path. + let projectId: string | undefined; + if (projectName) { + projectId = await requireProjectId(apiKey, projectName.trim()); + } + let cycleId: string | undefined; + if (cycleRaw !== undefined) { + const spec = parseCycleValue(cycleRaw); + cycleId = + spec === 'current' + ? await requireActiveCycleId(apiKey, team.trim()) + : await requireCycleIdByNumber(apiKey, team.trim(), spec); + } + const issue = await createIssue(apiKey, { title: title.trim(), description, teamKey: team.trim(), labelNames: labels, + projectId, + cycleId, }); const blocks: string[] = [ @@ -258,6 +323,19 @@ async function updateIssueCmd( const assigneeRaw = takeFlag(args, '--assignee'); const addLabelNames = takeAllFlags(args, '--label'); const removeLabelNames = takeAllFlags(args, '--remove-label'); + // has* detection must precede takeFlag (which removes the flag pair). + const hasProject = args.some( + (a) => a === '--project' || a.startsWith('--project='), + ); + const projectRaw = takeFlag(args, '--project'); + const hasCycle = args.some( + (a) => a === '--cycle' || a.startsWith('--cycle='), + ); + const cycleRaw = takeFlag(args, '--cycle'); + const hasTeam = args.some( + (a) => a === '--team' || a.startsWith('--team='), + ); + const teamRaw = takeFlag(args, '--team'); if ( !stateName && @@ -266,19 +344,66 @@ async function updateIssueCmd( description === undefined && assigneeRaw === undefined && addLabelNames.length === 0 && - removeLabelNames.length === 0 + removeLabelNames.length === 0 && + projectRaw === undefined && + cycleRaw === undefined ) { throw new AxiError( - 'Nothing to update — pass at least one of --state, --title, --priority, --description, --assignee, --label, --remove-label', + 'Nothing to update — pass at least one of --state, --title, --priority, --description, --assignee, --label, --remove-label, --project, --cycle', 'VALIDATION_ERROR', ); } + // Blank/missing values fail loud before any network request (same guard + // shape as the `issues` read path). + if (hasProject && (projectRaw === undefined || projectRaw.trim() === '')) { + throw new AxiError('--project requires a value', 'VALIDATION_ERROR', [ + 'e.g. --project "Q3 launch"', + ]); + } + if (hasCycle && (cycleRaw === undefined || cycleRaw.trim() === '')) { + throw new AxiError('--cycle requires a value', 'VALIDATION_ERROR', [ + 'Use --cycle current for the active cycle, or --cycle --team ', + ]); + } + const teamKey = teamRaw?.trim(); + if (hasTeam && !teamKey) { + throw new AxiError('--team requires a value', 'VALIDATION_ERROR'); + } + // --team on update exists ONLY to scope --cycle resolution (moving an issue + // between teams is not supported) — reject it alone rather than silently + // dropping it (AXI principle 6). + if (teamKey && cycleRaw === undefined) { + throw new AxiError( + '--team on update only applies to --cycle resolution', + 'VALIDATION_ERROR', + ['e.g. --cycle current --team LIN, or --cycle --team '], + ); + } + let priority: number | undefined; if (priorityRaw !== undefined) { priority = parsePriority(priorityRaw); } + // Cycle spec parsing also happens before any network request so the + // numeric-without-team case fails loud exactly like the #21 read path + // (cycle numbers restart per team, so a bare number is ambiguous). + let cycleSpec: 'current' | number | undefined; + if (cycleRaw !== undefined) { + cycleSpec = parseCycleValue(cycleRaw); + if (cycleSpec !== 'current' && !teamKey) { + throw new AxiError( + '--cycle requires --team (cycle numbers restart per team)', + 'VALIDATION_ERROR', + [ + `e.g. --cycle ${cycleSpec} --team LIN`, + 'Or use --cycle current without --team', + ], + ); + } + } + // Blank flag values fail loud before any network request (takeAllFlags // already rejects blank --label/--remove-label the same way). const assigneeName = assigneeRaw?.trim(); @@ -364,16 +489,56 @@ async function updateIssueCmd( } } + // Project (#25): resolve loud (requireProjectId — a silently missing project + // would surprise), then field-level no-op — requesting the project the issue + // already carries skips the field, exactly like a matching assignee above. + let projectId: string | undefined; + if (projectRaw !== undefined) { + const resolved = await requireProjectId(apiKey, projectRaw.trim()); + if (existing.project?.id !== resolved) { + projectId = resolved; + } + } + + // Cycle (#25): same field-level no-op against the issue's current cycle id. + // --team, when given, must be the issue's OWN team — resolving against + // another team would attach the issue to a foreign team's cycle. + let cycleId: string | undefined; + if (cycleSpec !== undefined) { + const issueTeamKey = existing.team?.key; + if ( + teamKey && + issueTeamKey && + teamKey.toUpperCase() !== issueTeamKey.toUpperCase() + ) { + throw new AxiError( + `Issue ${existing.identifier} belongs to team ${issueTeamKey} — --team must match it for --cycle resolution`, + 'VALIDATION_ERROR', + [`Drop --team, or use --team ${issueTeamKey}`], + ); + } + const resolved = + cycleSpec === 'current' + ? await requireActiveCycleId(apiKey, teamKey, issueTeamKey) + : await requireCycleIdByNumber(apiKey, teamKey!, cycleSpec); + if (existing.cycle?.id !== resolved) { + cycleId = resolved; + } + } + // Each field can individually be a no-op (a matching state short-circuits - // above; a matching assignee/label set is skipped). When NOTHING would be - // sent, report the no-op instead of mutating with an empty input. + // above; a matching assignee/label set/project/cycle is skipped). When + // NOTHING would be sent, report the no-op instead of mutating with an empty + // input. const hasPatch = title !== undefined || description !== undefined || stateName !== undefined || priority !== undefined || assigneeId !== undefined || - labelIds !== undefined; + labelIds !== undefined || + projectId !== undefined || + cycleId !== undefined; if (!hasPatch) { return renderOutput([ `issue: ${existing.identifier} already up to date (no-op)`, @@ -387,6 +552,8 @@ async function updateIssueCmd( priority, assigneeId, labelIds, + projectId, + cycleId, }); return renderOutput([ @@ -438,3 +605,93 @@ function parsePriority(raw: string): number { } return n; } + +/** + * Parse a --cycle value: "current" (case-insensitive) or a positive integer + * cycle number. Same grammar as the `issues` read path (#21); anything else + * fails loud before any network request. + */ +function parseCycleValue(raw: string): 'current' | number { + const value = raw.trim().toLowerCase(); + if (value === 'current') return 'current'; + if (/^\d+$/.test(value) && Number(value) >= 1) return Number(value); + throw new AxiError(`Invalid --cycle: ${raw}`, 'VALIDATION_ERROR', [ + 'Use "current" for the active cycle, or a positive cycle number', + ]); +} + +/** + * Resolve --cycle current to a cycle id (#25 write path). With teamKey (always + * on create, where --team is required; on update when passed): that team's + * active cycle. Without (update only): the workspace's active cycles — #21's + * read path lets "current" match every team's active cycle, but a WRITE must + * pick exactly one, so more than one active cycle fails loud listing the + * candidates instead of silently assigning a wrong team's cycle. When the + * target issue's team is known (update), a resolved cycle belonging to a + * DIFFERENT team is rejected for the same reason — an issue can only sit in + * one of its own team's cycles. + */ +async function requireActiveCycleId( + apiKey: string, + teamKey?: string, + issueTeamKey?: string, +): Promise { + const active = await fetchActiveCycles(apiKey, teamKey); + const scope = teamKey?.toUpperCase(); + if (active.length === 0) { + throw new AxiError( + scope + ? `Team ${scope} has no active cycle` + : 'No active cycles in your workspace', + 'VALIDATION_ERROR', + ['Run `linear-axi cycles` to see cycles'], + ); + } + if (active.length > 1) { + const list = active + .map((c) => `cycle ${c.number} (${c.teamKey})`) + .join(', '); + throw new AxiError( + scope + ? `Team ${scope} has ${active.length} active cycles — pick one by number instead` + : `${active.length} active cycles in your workspace — pass --team to pick one`, + 'VALIDATION_ERROR', + [`Active cycles: ${list}`], + ); + } + const match = active[0]!; + if ( + issueTeamKey && + match.teamKey.toUpperCase() !== issueTeamKey.toUpperCase() + ) { + throw new AxiError( + `The only active cycle is cycle ${match.number} of team ${match.teamKey}, but this issue belongs to team ${issueTeamKey}`, + 'VALIDATION_ERROR', + [ + `Use --cycle --team ${issueTeamKey} to pick one of its own team's cycles`, + ], + ); + } + return match.id; +} + +/** + * Resolve a numbered --cycle to a cycle id, loud on no match (#25 write + * path). The team key is mandatory — cycle numbers restart per team (same + * disambiguation as the #21 read path). + */ +async function requireCycleIdByNumber( + apiKey: string, + teamKey: string, + number: number, +): Promise { + const id = await resolveCycleIdByNumber(apiKey, teamKey, number); + if (!id) { + throw new AxiError( + `Cycle ${number} not found for team ${teamKey.toUpperCase()}`, + 'VALIDATION_ERROR', + [`Run \`linear-axi cycles --team ${teamKey.toUpperCase()}\` to see cycle numbers`], + ); + } + return id; +} diff --git a/src/linear.ts b/src/linear.ts index 869d82f..c48310b 100644 --- a/src/linear.ts +++ b/src/linear.ts @@ -70,8 +70,11 @@ export const ISSUE_LIST_FIELDS = ` * Detail selection for a single issue. Label nodes include `id` because * `issue update --label/--remove-label` (#24) needs the issue's CURRENT label * ids to compute the union/difference replacement set — names alone cannot be - * sent back to IssueUpdateInput.labelIds. Renderers read only `name`, so the - * extra key is invisible in output. + * sent back to IssueUpdateInput.labelIds. `project { id name }` and + * `cycle { id number }` serve the same purpose for #25: update-side no-op + * detection compares the issue's CURRENT project/cycle ids against the + * resolved --project/--cycle ids before sending IssueUpdateInput.projectId/ + * cycleId. Renderers read none of the extra keys, so they stay invisible. */ export const ISSUE_DETAIL_FIELDS = ` id @@ -83,6 +86,8 @@ export const ISSUE_DETAIL_FIELDS = ` assignee { name } team { key name id } labels { nodes { id name } } + project { id name } + cycle { id number } url createdAt updatedAt @@ -139,6 +144,13 @@ export interface LinearIssue { // `id` is present when the selection includes it (ISSUE_DETAIL_FIELDS does; // ISSUE_LIST_FIELDS selects no labels at all), so it is optional here. labels?: { nodes: Array<{ id?: string; name: string }> } | null; + // Present when selected (ISSUE_DETAIL_FIELDS does, list selections do not): + // the issue's current project/cycle for #25 update no-op detection. Issue + // .project and .cycle are nullable object types per @linear/sdk (an issue + // can be in neither); `cycle.number` mirrors Cycle.number (Float, unique + // within its team). + project?: { id: string; name: string } | null; + cycle?: { id: string; number: number } | null; url?: string; createdAt?: string; updatedAt?: string; @@ -756,6 +768,18 @@ export interface IssueCreateInput { description?: string; teamKey: string; labelNames?: string[]; + /** + * Put the issue in this project (resolved by the caller from --project via + * requireProjectId). IssueCreateInput.projectId is a nullable String + * ("The project associated with the issue", @linear/sdk v90). + */ + projectId?: string; + /** + * Put the issue in this cycle (resolved by the caller from --cycle). + * IssueCreateInput.cycleId is a nullable String ("The cycle associated with + * the issue", @linear/sdk v90). + */ + cycleId?: string; } export async function createIssue( @@ -792,6 +816,18 @@ export async function createIssue( varDecls.push('$labelIds: [String!]'); variables['labelIds'] = labelIds; } + // projectId/cycleId (#25) follow the same omit-null convention: declared + // and sent only when the caller resolved an id. + if (input.projectId !== undefined) { + inputFields.push('projectId: $projectId'); + varDecls.push('$projectId: String'); + variables['projectId'] = input.projectId; + } + if (input.cycleId !== undefined) { + inputFields.push('cycleId: $cycleId'); + varDecls.push('$cycleId: String'); + variables['cycleId'] = input.cycleId; + } const data = await linearRequest<{ issueCreate: { issue: LinearIssue; success: boolean }; @@ -832,6 +868,19 @@ export interface IssueUpdate { * means "leave labels alone". */ labelIds?: string[]; + /** + * Move the issue into this project (resolved by the caller from --project + * via requireProjectId). IssueUpdateInput.projectId is a nullable String + * ("The project associated with the issue", @linear/sdk v90); undefined + * leaves the project untouched. + */ + projectId?: string; + /** + * Move the issue into this cycle (resolved by the caller from --cycle). + * IssueUpdateInput.cycleId is a nullable String ("The cycle associated + * with the issue", @linear/sdk v90); undefined leaves the cycle untouched. + */ + cycleId?: string; } /** @@ -890,6 +939,17 @@ export async function updateIssue( varDecls.push('$labelIds: [String!]'); variables['labelIds'] = update.labelIds; } + // projectId/cycleId (#25) follow the same omit-null convention as above. + if (update.projectId !== undefined) { + inputFields.push('projectId: $projectId'); + varDecls.push('$projectId: String'); + variables['projectId'] = update.projectId; + } + if (update.cycleId !== undefined) { + inputFields.push('cycleId: $cycleId'); + varDecls.push('$cycleId: String'); + variables['cycleId'] = update.cycleId; + } const data = await linearRequest<{ issueUpdate: { issue: LinearIssue; success: boolean }; @@ -988,6 +1048,105 @@ export async function resolveProjectId( return data.projects.nodes[0]?.id; } +/** + * Write-path project resolution (#25): resolveProjectId, but LOUD on no match. + * Silently creating/updating an issue without its requested project would + * surprise far more than a failed command (AXI principle 6), so mutations + * follow resolveStateIdByName/resolveUserId's loud convention while the read + * path keeps resolveProjectId's silent-undefined semantics. + */ +export async function requireProjectId( + apiKey: string, + name: string, +): Promise { + const id = await resolveProjectId(apiKey, name); + if (!id) { + throw new AxiError( + `Project "${name}" not found in your workspace`, + 'VALIDATION_ERROR', + ['Run `linear-axi projects` to see project names (exact match required)'], + ); + } + return id; +} + +/** An active cycle as returned by fetchActiveCycles. */ +export interface ActiveCycle { + id: string; + number: number; + /** + * Key of the team the cycle belongs to — Cycle.team is non-nullable + * ("The team that the cycle belongs to. Each cycle is scoped to exactly one + * team", @linear/sdk), so the workspace-wide query can attribute every + * active cycle to its team. + */ + teamKey: string; +} + +/** + * The active cycles of the workspace (or of one team) for write-side + * `--cycle current` resolution (#25). Uses the root `cycles` connection with + * `filter: CycleFilter` (QueryCyclesArgs in @linear/sdk v90): isActive is a + * BooleanComparator with eq (the same comparator the #21 read path puts in + * IssueFilter.cycle), and team is a TeamFilter, so `team: { key: { eq } }` + * scopes to one team — the same key comparator resolveTeamId relies on. One + * round trip either way; a team has at most one active cycle in practice, but + * callers decide what multiple matches mean (the workspace-wide form returns + * one per team with cycling enabled). + */ +export async function fetchActiveCycles( + apiKey: string, + teamKey?: string, +): Promise { + const perTeam = teamKey !== undefined; + const query = perTeam + ? `query TeamActiveCycles($teamKey: String!) { + cycles(filter: { isActive: { eq: true }, team: { key: { eq: $teamKey } } }) { + nodes { id number team { key } } + } + }` + : `query ActiveCycles { + cycles(filter: { isActive: { eq: true } }) { nodes { id number team { key } } } + }`; + const data = await linearRequest<{ + cycles: { + nodes: Array<{ id: string; number: number; team: { key: string } }>; + }; + }>(apiKey, query, perTeam ? { teamKey: teamKey.toUpperCase() } : {}); + return data.cycles.nodes.map((n) => ({ + id: n.id, + number: n.number, + teamKey: n.team.key, + })); +} + +/** + * Resolve a team's cycle NUMBER to its id (#25 write path). Cycle numbers are + * "unique within its team" and restart per team (Cycle.number, + * @linear/sdk v90), so the team key is mandatory — the same disambiguation + * the #21 read path enforces. CycleFilter.number is a NumberComparator whose + * eq takes a Float. Returns undefined when the team has no cycle with that + * number (callers fail loud — a mutation must not silently skip the cycle). + */ +export async function resolveCycleIdByNumber( + apiKey: string, + teamKey: string, + number: number, +): Promise { + const data = await linearRequest<{ + cycles: { nodes: Array<{ id: string }> }; + }>( + apiKey, + `query CycleByNumber($number: Float!, $teamKey: String!) { + cycles(filter: { number: { eq: $number }, team: { key: { eq: $teamKey } } }) { + nodes { id } + } + }`, + { number, teamKey: teamKey.toUpperCase() }, + ); + return data.cycles.nodes[0]?.id; +} + /** * Resolve label names to their ids, case-insensitively, in server order. * Reuses fetchLabels (auto-paginated) so label resolution keeps working in diff --git a/src/skill.ts b/src/skill.ts index 0f8005f..8fc0f26 100644 --- a/src/skill.ts +++ b/src/skill.ts @@ -7,7 +7,8 @@ export const SKILL_DESCRIPTION = 'create, update, and delete 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, ' + '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`, or finding label names via `labels` for `issue create --label`, ' + + '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`.'; export const SKILL_AUTHOR = 'ApexCloudWise'; @@ -52,15 +53,15 @@ 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; 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 \`; 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; 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 \`. ## Workflow 1. Run \`npx -y @apexcloudwise/linear-axi\` with no arguments for a dashboard - the current viewer and your started, assigned issues. 2. Drill in command-first: \`issues\`, \`issue view \`, \`teams\`, \`projects\`, \`cycles\`, \`labels\`. 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\`. -5. Change state with \`issue update --state ""\`; pass the workflow state name exactly (e.g. \`"In Progress"\`). Assign with \`--assignee \` and manage labels with \`--label \` / \`--remove-label \` (repeatable) in the same command. +4. Create an issue with \`issue create --title "..." --team \`; team keys come from \`teams\`, and \`--project ""\` / \`--cycle current|\` land it in a project or cycle straight away. +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). 7. Every response ends with contextual next-step hints under \`help:\` - follow them. @@ -82,6 +83,7 @@ Run \`npx -y @apexcloudwise/linear-axi --help\` for global flags, or \`npx -y @a - Mutations are idempotent: updating an issue to its current state, or deleting an already-deleted issue, is a reported no-op (exit 0). - 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. \`comment list\` likewise previews each comment body; \`--full\` shows them whole. diff --git a/test/project-cycle-assignment.test.ts b/test/project-cycle-assignment.test.ts new file mode 100644 index 0000000..6358258 --- /dev/null +++ b/test/project-cycle-assignment.test.ts @@ -0,0 +1,575 @@ +// Tests for `issue create/update --project / --cycle` (#25). Follows the +// network-free GraphQL stub pattern from issue-update-assignee-labels.test.ts: +// stub global fetch, then assert on the captured query documents, mutation +// variables, and rendered TOON output. +// +// Flow order under test: +// - create: resolve --project (query ProjectByName) and/or --cycle (query +// TeamActiveCycles / CycleByNumber) BEFORE the mutation, then createIssue +// resolves the team (query TeamByKey) and mutates. +// - update: resolve the ref (query Issue, now selecting project/cycle for +// no-op detection), resolve ids, mutate. +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { issueCommand, ISSUE_HELP } from '../src/commands/issue.js'; +import type { LinearIssue } from '../src/linear.js'; + +const FAKE_KEY = 'lin_api_fake'; + +interface CapturedRequest { + url: string; + body: { query: string; variables: Record }; +} + +/** A stub active-cycle node as returned by the cycles connection. */ +interface StubCycle { + id: string; + number: number; + teamKey: string; +} + +interface StubOptions { + /** The issue served for `query Issue(` (default: LIN-1, no project/cycle). */ + issue?: LinearIssue | null; + /** Projects served for `query ProjectByName(` (default: one "Q3 launch"). */ + projects?: Array<{ id: string; name: string }>; + /** Cycles served for the active-cycle queries (default: LIN cycle 12). */ + activeCycles?: StubCycle[]; + /** Cycles served for `query CycleByNumber(` (default: cycle 3, id cyc-3). */ + numberedCycles?: Array<{ id: string }>; +} + +function issueNode(overrides: Partial = {}): LinearIssue { + return { + id: 'ir-1', + identifier: 'LIN-1', + title: 'Ship the thing', + description: 'Body text', + state: { name: 'Todo', type: 'unstarted' }, + priority: 2, + assignee: null, + team: { key: 'LIN', name: 'Linear', id: 'team-1' }, + labels: { nodes: [] }, + project: null, + cycle: null, + url: 'https://linear.app/lini/issue/LIN-1/ship-the-thing', + createdAt: '2026-07-01T00:00:00.000Z', + updatedAt: '2026-08-01T00:00:00.000Z', + ...overrides, + }; +} + +const DEFAULT_PROJECTS = [{ id: 'proj-q3', name: 'Q3 launch' }]; + +const DEFAULT_ACTIVE_CYCLES: StubCycle[] = [ + { id: 'cyc-active-lin', number: 12, teamKey: 'LIN' }, +]; + +const DEFAULT_NUMBERED_CYCLES = [{ id: 'cyc-3' }]; + +function graphqlOk(data: unknown): Response { + return { ok: true, status: 200, json: async () => ({ data }) } as Response; +} + +function cycleNode(c: StubCycle): { + id: string; + number: number; + team: { key: string }; +} { + return { id: c.id, number: c.number, team: { key: c.teamKey } }; +} + +/** + * Stub global fetch as a Linear GraphQL endpoint serving every document the + * create/update flows can issue for #25. Returns captured requests for + * assertions. Any unexpected document fails the stub loudly. + */ +function stubFetch(opts: StubOptions = {}): { requests: CapturedRequest[] } { + const issue = opts.issue === undefined ? issueNode() : opts.issue; + const projects = opts.projects ?? DEFAULT_PROJECTS; + const activeCycles = opts.activeCycles ?? DEFAULT_ACTIVE_CYCLES; + const numberedCycles = opts.numberedCycles ?? DEFAULT_NUMBERED_CYCLES; + + 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 }); + if (body.query.includes('query Issue(')) { + return graphqlOk({ issue }); + } + if (body.query.includes('query ProjectByName(')) { + return graphqlOk({ projects: { nodes: projects } }); + } + if (body.query.includes('query TeamActiveCycles(')) { + return graphqlOk({ + cycles: { nodes: activeCycles.map(cycleNode) }, + }); + } + // The workspace-wide document has no variables, so no parens after the name. + if (body.query.includes('query ActiveCycles')) { + return graphqlOk({ + cycles: { nodes: activeCycles.map(cycleNode) }, + }); + } + if (body.query.includes('query CycleByNumber(')) { + return graphqlOk({ cycles: { nodes: numberedCycles } }); + } + if (body.query.includes('query TeamByKey(')) { + return graphqlOk({ + teams: { nodes: [{ id: 'team-1', key: 'LIN' }] }, + }); + } + if (body.query.includes('mutation CreateIssue(')) { + return graphqlOk({ + issueCreate: { + success: true, + issue: { + id: 'ir-9', + identifier: 'LIN-9', + title: 'Ship v2', + url: 'https://linear.app/lini/issue/LIN-9/ship-v2', + }, + }, + }); + } + if (body.query.includes('mutation UpdateIssue(')) { + return graphqlOk({ + issueUpdate: { + success: true, + issue: { + id: 'ir-1', + identifier: 'LIN-1', + title: 'Ship the thing', + url: 'https://linear.app/lini/issue/LIN-1/ship-the-thing', + }, + }, + }); + } + throw new Error(`Unexpected GraphQL document in stub: ${body.query}`); + }); + vi.stubGlobal('fetch', fetchMock); + return { requests }; +} + +const createRequests = (requests: CapturedRequest[]) => + requests.filter((r) => r.body.query.includes('mutation CreateIssue(')); +const updateRequests = (requests: CapturedRequest[]) => + requests.filter((r) => r.body.query.includes('mutation UpdateIssue(')); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('issue create --project / --cycle', () => { + it('resolves --project by exact name and sends projectId', async () => { + const { requests } = stubFetch(); + + const out = await issueCommand( + [ + 'create', + '--title', + 'Ship v2', + '--team', + 'LIN', + '--project', + 'Q3 launch', + ], + { apiKey: FAKE_KEY }, + ); + + // Project resolution first, then team resolution, then the mutation. + expect(requests).toHaveLength(3); + expect(requests[0].body.query).toContain( + 'projects(filter: { name: { eq: $name } })', + ); + expect(requests[0].body.variables).toEqual({ name: 'Q3 launch' }); + const mutation = requests[2]; + expect(mutation.body.query).toContain('projectId: $projectId'); + expect(mutation.body.query).toContain('$projectId: String'); + expect(mutation.body.variables).toEqual({ + teamId: 'team-1', + title: 'Ship v2', + projectId: 'proj-q3', + }); + expect(out).toContain('created: LIN-9'); + }); + + it("resolves --cycle current to the team's active cycle and sends cycleId", async () => { + const { requests } = stubFetch(); + + await issueCommand( + ['create', '--title', 'Ship v2', '--team', 'LIN', '--cycle', 'current'], + { apiKey: FAKE_KEY }, + ); + + expect(requests).toHaveLength(3); + // --team scopes the active-cycle lookup on create. + expect(requests[0].body.query).toContain('query TeamActiveCycles('); + expect(requests[0].body.query).toContain( + 'isActive: { eq: true }, team: { key: { eq: $teamKey } }', + ); + expect(requests[0].body.variables).toEqual({ teamKey: 'LIN' }); + const mutation = requests[2]; + expect(mutation.body.query).toContain('cycleId: $cycleId'); + expect(mutation.body.variables).toEqual({ + teamId: 'team-1', + title: 'Ship v2', + cycleId: 'cyc-active-lin', + }); + }); + + it('resolves a numbered --cycle against --team and sends cycleId', async () => { + const { requests } = stubFetch(); + + await issueCommand( + ['create', '--title', 'Ship v2', '--team', 'LIN', '--cycle', '3'], + { apiKey: FAKE_KEY }, + ); + + expect(requests).toHaveLength(3); + expect(requests[0].body.query).toContain( + 'number: { eq: $number }, team: { key: { eq: $teamKey } }', + ); + expect(requests[0].body.variables).toEqual({ number: 3, teamKey: 'LIN' }); + expect(requests[2].body.variables).toEqual({ + teamId: 'team-1', + title: 'Ship v2', + cycleId: 'cyc-3', + }); + }); + + it('sends projectId AND cycleId together when both flags are given', async () => { + const { requests } = stubFetch(); + + await issueCommand( + [ + 'create', + '--title', + 'Ship v2', + '--team', + 'LIN', + '--project', + 'Q3 launch', + '--cycle', + 'current', + ], + { apiKey: FAKE_KEY }, + ); + + const mutation = createRequests(requests)[0]!; + expect(mutation.body.query).toContain('projectId: $projectId'); + expect(mutation.body.query).toContain('cycleId: $cycleId'); + expect(mutation.body.variables).toEqual({ + teamId: 'team-1', + title: 'Ship v2', + projectId: 'proj-q3', + cycleId: 'cyc-active-lin', + }); + }); + + it('fails loud on an unknown project name (no mutation)', async () => { + const { requests } = stubFetch({ projects: [] }); + + await expect( + issueCommand( + ['create', '--title', 'Ship v2', '--team', 'LIN', '--project', 'Nope'], + { apiKey: FAKE_KEY }, + ), + ).rejects.toThrow(/Project "Nope" not found/); + + expect(createRequests(requests)).toHaveLength(0); + }); + + it('fails loud when the team has no active cycle (no mutation)', async () => { + const { requests } = stubFetch({ activeCycles: [] }); + + await expect( + issueCommand( + ['create', '--title', 'Ship v2', '--team', 'LIN', '--cycle', 'current'], + { apiKey: FAKE_KEY }, + ), + ).rejects.toThrow(/Team LIN has no active cycle/); + + expect(createRequests(requests)).toHaveLength(0); + }); + + it('requires --team (cycle numbers restart per team) — create always does', async () => { + const { requests } = stubFetch(); + + await expect( + issueCommand(['create', '--title', 'Ship v2', '--cycle', '3'], { + apiKey: FAKE_KEY, + }), + ).rejects.toThrow(/--team is required/); + + expect(requests).toHaveLength(0); + }); +}); + +describe('issue update --project', () => { + it('sends projectId when the issue is in a different project', async () => { + const { requests } = stubFetch({ + issue: issueNode({ + project: { id: 'proj-old', name: 'Old thing' }, + }), + }); + + const out = await issueCommand( + ['update', 'LIN-1', '--project', 'Q3 launch'], + { apiKey: FAKE_KEY }, + ); + + // Ref resolution now selects the issue's current project/cycle ids. + expect(requests[0].body.query).toContain('project { id name }'); + expect(requests[0].body.query).toContain('cycle { id number }'); + expect(requests[1].body.variables).toEqual({ name: 'Q3 launch' }); + const mutation = updateRequests(requests)[0]!; + expect(mutation.body.query).toContain('projectId: $projectId'); + // Omit-null builder: only the changed field is declared/sent. + expect(mutation.body.query).toContain( + 'mutation UpdateIssue($id: String!, $projectId: String)', + ); + expect(mutation.body.variables).toEqual({ + id: 'ir-1', + projectId: 'proj-q3', + }); + expect(out).toContain('updated: LIN-1'); + }); + + it('reports re-requesting the current project as a no-op (no mutation)', async () => { + const { requests } = stubFetch({ + issue: issueNode({ project: { id: 'proj-q3', name: 'Q3 launch' } }), + }); + + const out = await issueCommand( + ['update', 'LIN-1', '--project', 'Q3 launch'], + { apiKey: FAKE_KEY }, + ); + + expect(out).toContain('(no-op)'); + expect(updateRequests(requests)).toHaveLength(0); + }); + + it('sends projectId when the issue carries no project yet', async () => { + const { requests } = stubFetch({ issue: issueNode() }); + + await issueCommand(['update', 'LIN-1', '--project', 'Q3 launch'], { + apiKey: FAKE_KEY, + }); + + expect(updateRequests(requests)[0]!.body.variables).toEqual({ + id: 'ir-1', + projectId: 'proj-q3', + }); + }); + + it('fails loud on an unknown project name (no mutation)', async () => { + const { requests } = stubFetch({ projects: [] }); + + await expect( + issueCommand(['update', 'LIN-1', '--project', 'Nope'], { + apiKey: FAKE_KEY, + }), + ).rejects.toThrow(/Project "Nope" not found/); + + expect(updateRequests(requests)).toHaveLength(0); + }); +}); + +describe('issue update --cycle', () => { + it("resolves --cycle current to the workspace's single active cycle", async () => { + const { requests } = stubFetch({ issue: issueNode() }); + + const out = await issueCommand(['update', 'LIN-1', '--cycle', 'current'], { + apiKey: FAKE_KEY, + }); + + // Ref resolution, workspace-wide active-cycle lookup, mutate. + expect(requests).toHaveLength(3); + // No variables in the workspace-wide document, hence no parens after the name. + expect(requests[1].body.query).toContain('query ActiveCycles {'); + expect(requests[1].body.query).toContain('isActive: { eq: true }'); + expect(requests[1].body.variables).toEqual({}); + const mutation = updateRequests(requests)[0]!; + expect(mutation.body.query).toContain( + 'mutation UpdateIssue($id: String!, $cycleId: String)', + ); + expect(mutation.body.variables).toEqual({ + id: 'ir-1', + cycleId: 'cyc-active-lin', + }); + expect(out).toContain('updated: LIN-1'); + }); + + it('fails loud listing candidates when several teams have active cycles', async () => { + const { requests } = stubFetch({ + activeCycles: [ + { id: 'cyc-lin-12', number: 12, teamKey: 'LIN' }, + { id: 'cyc-eng-7', number: 7, teamKey: 'ENG' }, + ], + }); + + const err = await issueCommand(['update', 'LIN-1', '--cycle', 'current'], { + apiKey: FAKE_KEY, + }).catch((e: { message: string; suggestions?: string[] }) => e); + + expect(err.message).toMatch(/2 active cycles/); + expect(err.suggestions?.join(' ')).toContain('cycle 12 (LIN)'); + expect(err.suggestions?.join(' ')).toContain('cycle 7 (ENG)'); + expect(updateRequests(requests)).toHaveLength(0); + }); + + it('rejects the only active cycle when it belongs to another team', async () => { + const { requests } = stubFetch({ + activeCycles: [{ id: 'cyc-eng-7', number: 7, teamKey: 'ENG' }], + }); + + await expect( + issueCommand(['update', 'LIN-1', '--cycle', 'current'], { + apiKey: FAKE_KEY, + }), + ).rejects.toThrow(/belongs to team LIN/); + + expect(updateRequests(requests)).toHaveLength(0); + }); + + it('scopes --cycle current to --team when given', async () => { + const { requests } = stubFetch(); + + await issueCommand( + ['update', 'LIN-1', '--cycle', 'current', '--team', 'LIN'], + { apiKey: FAKE_KEY }, + ); + + expect(requests[1].body.query).toContain('query TeamActiveCycles('); + expect(requests[1].body.variables).toEqual({ teamKey: 'LIN' }); + expect(updateRequests(requests)[0]!.body.variables).toEqual({ + id: 'ir-1', + cycleId: 'cyc-active-lin', + }); + }); + + it("rejects a --team that does not match the issue's own team", async () => { + const { requests } = stubFetch(); + + await expect( + issueCommand(['update', 'LIN-1', '--cycle', 'current', '--team', 'ENG'], { + apiKey: FAKE_KEY, + }), + ).rejects.toThrow(/belongs to team LIN/); + + expect(updateRequests(requests)).toHaveLength(0); + }); + + it('resolves a numbered --cycle against --team and sends cycleId', async () => { + const { requests } = stubFetch({ + issue: issueNode({ cycle: { id: 'cyc-active-lin', number: 12 } }), + }); + + await issueCommand(['update', 'LIN-1', '--cycle', '3', '--team', 'LIN'], { + apiKey: FAKE_KEY, + }); + + expect(requests[1].body.query).toContain('query CycleByNumber('); + expect(requests[1].body.variables).toEqual({ number: 3, teamKey: 'LIN' }); + expect(updateRequests(requests)[0]!.body.variables).toEqual({ + id: 'ir-1', + cycleId: 'cyc-3', + }); + }); + + it('requires --team for a numbered cycle, before any network request', async () => { + const { requests } = stubFetch(); + + await expect( + issueCommand(['update', 'LIN-1', '--cycle', '3'], { apiKey: FAKE_KEY }), + ).rejects.toThrow(/--cycle requires --team/); + + expect(requests).toHaveLength(0); + }); + + it('fails loud when the team has no such cycle number', async () => { + const { requests } = stubFetch({ numberedCycles: [] }); + + await expect( + issueCommand(['update', 'LIN-1', '--cycle', '9', '--team', 'LIN'], { + apiKey: FAKE_KEY, + }), + ).rejects.toThrow(/Cycle 9 not found for team LIN/); + + expect(updateRequests(requests)).toHaveLength(0); + }); + + it('reports re-requesting the current cycle as a no-op (no mutation)', async () => { + const { requests } = stubFetch({ + issue: issueNode({ cycle: { id: 'cyc-active-lin', number: 12 } }), + }); + + const out = await issueCommand(['update', 'LIN-1', '--cycle', 'current'], { + apiKey: FAKE_KEY, + }); + + expect(out).toContain('(no-op)'); + expect(updateRequests(requests)).toHaveLength(0); + }); +}); + +describe('parity and guards (create and update)', () => { + it('rejects --team on update without --cycle', async () => { + const { requests } = stubFetch(); + + await expect( + issueCommand(['update', 'LIN-1', '--title', 'x', '--team', 'LIN'], { + apiKey: FAKE_KEY, + }), + ).rejects.toThrow(/--team on update only applies to --cycle/); + + expect(requests).toHaveLength(0); + }); + + it('rejects blank --project / --cycle values loudly', async () => { + stubFetch(); + await expect( + issueCommand(['update', 'LIN-1', '--project', ''], { apiKey: FAKE_KEY }), + ).rejects.toThrow(/--project requires a value/); + await expect( + issueCommand(['update', 'LIN-1', '--cycle', ''], { apiKey: FAKE_KEY }), + ).rejects.toThrow(/--cycle requires a value/); + await expect( + issueCommand( + ['create', '--title', 'x', '--team', 'LIN', '--project', ''], + { apiKey: FAKE_KEY }, + ), + ).rejects.toThrow(/--project requires a value/); + await expect( + issueCommand( + ['create', '--title', 'x', '--team', 'LIN', '--cycle', 'soon'], + { apiKey: FAKE_KEY }, + ), + ).rejects.toThrow(/Invalid --cycle: soon/); + }); + + it('lists the new flags in the nothing-to-update guard', async () => { + stubFetch(); + await expect( + issueCommand(['update', 'LIN-1'], { apiKey: FAKE_KEY }), + ).rejects.toThrow(/Nothing to update.*--project, --cycle/); + }); + + it('documents the flags and examples in help (both subcommands)', () => { + expect(ISSUE_HELP).toContain( + 'create --title "..." --team [--description "..."] [--label ...] [--project ] [--cycle ]', + ); + expect(ISSUE_HELP).toContain( + '[--assignee ] [--label ] [--remove-label ] [--project ] [--cycle ]', + ); + expect(ISSUE_HELP).toContain( + 'linear-axi issue create --title "Ship v2" --team LIN --project "Q3 launch" --cycle current', + ); + expect(ISSUE_HELP).toContain( + 'linear-axi issue update LIN-123 --cycle current', + ); + expect(ISSUE_HELP).toContain( + 'linear-axi issue update LIN-123 --cycle 3 --team LIN', + ); + }); +}); From 668d5d128783dee4563eafba3b7b4b0bbc233609 Mon Sep 17 00:00:00 2001 From: Sam Whitmore Date: Thu, 20 Aug 2026 04:31:38 +0530 Subject: [PATCH 03/12] feat: add sub-issue support (--parent, children in issue view) --- skills/linear-axi/SKILL.md | 9 +- src/commands/issue.ts | 75 ++++++++- src/linear.ts | 58 ++++++- src/skill.ts | 10 +- test/sub-issues.test.ts | 313 +++++++++++++++++++++++++++++++++++++ 5 files changed, 453 insertions(+), 12 deletions(-) create mode 100644 test/sub-issues.test.ts diff --git a/skills/linear-axi/SKILL.md b/skills/linear-axi/SKILL.md index 4ba2b8d..1aac2d0 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, 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, 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 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`." user-invocable: false author: ApexCloudWise metadata: @@ -20,14 +20,14 @@ 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; 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 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 1. Run `npx -y @apexcloudwise/linear-axi` with no arguments for a dashboard - the current viewer and your started, assigned issues. 2. Drill in command-first: `issues`, `issue view `, `teams`, `projects`, `cycles`, `labels`. 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`, and `--project ""` / `--cycle current|` land it in a project or cycle straight away. +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). 7. Every response ends with contextual next-step hints under `help:` - follow them. @@ -54,5 +54,6 @@ Run `npx -y @apexcloudwise/linear-axi --help` for global flags, or `npx -y @apex - `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. `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. +- `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/issue.ts b/src/commands/issue.ts index 5a31b36..742111f 100644 --- a/src/commands/issue.ts +++ b/src/commands/issue.ts @@ -1,5 +1,6 @@ import type { LinearContext } from '../context.js'; import { requireKey } from '../config.js'; +import type { LinearIssueChild } from '../linear.js'; import { fetchIssue, fetchViewer, @@ -38,8 +39,8 @@ export const ISSUE_HELP = `usage: linear-axi issue [args] Manage Linear issues. subcommands: - view show issue details (--full disables description truncation; --fields adds extra fields) - create --title "..." --team [--description "..."] [--label ...] [--project ] [--cycle ] + view show issue details (--full shows the full description and sub-issues; --fields adds extra fields) + create --title "..." --team [--description "..."] [--label ...] [--project ] [--cycle ] [--parent ] update [--state ] [--title "..."] [--priority <0-4>] [--description "..."] [--assignee ] [--label ] [--remove-label ] [--project ] [--cycle ] delete @@ -52,6 +53,11 @@ update flags: --label add a label (repeatable; names from \`linear-axi labels\`) --remove-label remove a label (repeatable); removing the last one clears labels +sub-issue flags: + --parent create: nest the new issue under this parent (fails loud if not found; + cross-team parents are allowed — the hierarchy spans teams) + --full (view) also lists the issue's children, one "identifier | title | state" line each + project/cycle flags (create and update): --project put the issue in a project (exact name; run \`linear-axi projects\`) --cycle put the issue in a cycle: "current" = the active cycle — with --team, that team's; @@ -65,6 +71,7 @@ examples: linear-axi issue view LIN-123 --fields dueDate,estimate linear-axi issue create --title "Fix login" --team ENG --label bug linear-axi issue create --title "Ship v2" --team LIN --project "Q3 launch" --cycle current + linear-axi issue create --title "Wire the toggle" --team ENG --parent LIN-42 linear-axi issue update LIN-123 --state "In Progress" linear-axi issue update LIN-123 --assignee me --label bug linear-axi issue update LIN-123 --remove-label bug @@ -82,6 +89,7 @@ const CREATE_FLAGS = [ '--label', '--project', '--cycle', + '--parent', ]; const UPDATE_FLAGS = [ '--state', @@ -174,7 +182,7 @@ async function viewIssue( ]); } - const issue = await fetchIssue(apiKey, refRaw, extraLinearKeys); + const issue = await fetchIssue(apiKey, refRaw, extraLinearKeys, full); if (!issue) { throw new AxiError( `Issue "${refRaw}" not found`, @@ -202,6 +210,15 @@ async function viewIssue( ); } + // Sub-issues (#26): the children connection was selected (under --full) — + // render it after the description. Manual block rather than renderList: one + // `identifier | title | state` line per child is a third of the tokens of a + // TOON array-of-objects while carrying exactly the three fields issue #26's + // acceptance names. + if (full) { + blocks.push(renderSubIssues(issue.children)); + } + blocks.push( renderHelp([ `Run \`linear-axi issue update ${issue.identifier} --state ""\` to change state`, @@ -232,6 +249,10 @@ async function createIssueCmd( (a) => a === '--cycle' || a.startsWith('--cycle='), ); const cycleRaw = takeFlag(args, '--cycle'); + const hasParent = args.some( + (a) => a === '--parent' || a.startsWith('--parent='), + ); + const parentRaw = takeFlag(args, '--parent'); if (!title || !title.trim()) { throw new AxiError('--title is required', 'VALIDATION_ERROR', [ @@ -256,6 +277,11 @@ async function createIssueCmd( 'Use --cycle current for the active cycle, or --cycle (per --team)', ]); } + if (hasParent && (parentRaw === undefined || parentRaw.trim() === '')) { + throw new AxiError('--parent requires a value', 'VALIDATION_ERROR', [ + 'e.g. --parent LIN-42 (identifier or UUID of the parent issue)', + ]); + } // Resolve --project/--cycle to ids BEFORE the mutation (#25): a loud // failure here beats an issue created without its requested project/cycle. @@ -274,6 +300,29 @@ async function createIssueCmd( : await requireCycleIdByNumber(apiKey, team.trim(), spec); } + // Sub-issue parent (#26): resolve --parent to the parent issue's ID BEFORE + // the mutation, the same resolve-loud-before-write convention as + // --project/--cycle. Linear's IssueCreateInput.parentId would also accept + // the raw identifier ("Can be a UUID or issue identifier (e.g., 'LIN-123')", + // @linear/sdk v90), but pre-resolving turns an unknown parent into a clean + // NOT_FOUND instead of an opaque mutation error. Cross-team parents are + // deliberately NOT rejected client-side — Linear's sub-issue hierarchy + // spans teams (a child may live in a different team than its parent), so + // any genuine workspace-level rejection surfaces through the normal API + // error mapping. + let parentId: string | undefined; + if (parentRaw !== undefined) { + const parent = await fetchIssue(apiKey, parentRaw.trim()); + if (!parent) { + throw new AxiError( + `Parent issue "${parentRaw.trim()}" not found`, + 'NOT_FOUND', + ['Run `linear-axi issues` to list issues'], + ); + } + parentId = parent.id; + } + const issue = await createIssue(apiKey, { title: title.trim(), description, @@ -281,6 +330,7 @@ async function createIssueCmd( labelNames: labels, projectId, cycleId, + parentId, }); const blocks: string[] = [ @@ -606,6 +656,25 @@ function parsePriority(raw: string): number { return n; } +/** + * Render the sub-issues block for `issue view --full` (#26): a count header + * (the help[N]:-style bracket convention) plus one + * `identifier | title | state` line per child, in the server's order for the + * children connection. An empty list still renders the bare header — under + * --full the user asked for everything, so "no sub-issues" is information, + * not noise. + */ +function renderSubIssues( + children: { nodes: LinearIssueChild[] } | null | undefined, +): string { + const nodes = children?.nodes ?? []; + const lines = nodes.map( + (child) => + ` ${child.identifier} | ${child.title} | ${child.state?.name ?? 'unknown'}`, + ); + return `sub-issues[${nodes.length}]:${lines.length ? `\n${lines.join('\n')}` : ''}`; +} + /** * Parse a --cycle value: "current" (case-insensitive) or a positive integer * cycle number. Same grammar as the `issues` read path (#21); anything else diff --git a/src/linear.ts b/src/linear.ts index c48310b..882c724 100644 --- a/src/linear.ts +++ b/src/linear.ts @@ -75,6 +75,8 @@ export const ISSUE_LIST_FIELDS = ` * detection compares the issue's CURRENT project/cycle ids against the * resolved --project/--cycle ids before sending IssueUpdateInput.projectId/ * cycleId. Renderers read none of the extra keys, so they stay invisible. + * The sub-issue children connection is NOT part of this base selection — it + * rides along via ISSUE_CHILDREN_FIELDS only for `issue view --full` (#26). */ export const ISSUE_DETAIL_FIELDS = ` id @@ -103,6 +105,20 @@ export function withExtraFields(base: string, extraKeys: string[]): string { return `${base}${extraKeys.map((k) => `\n ${k}`).join('')}`; } +/** + * Sub-issue (children) selection for `issue view --full` (#26). Linear's + * Issue type exposes its sub-issues as `children: IssueConnection` + * ("Children of the issue", @linear/sdk v90 — the field is named `children`, + * not `subIssues`); IssueConnection carries `nodes: Array`. One detail + * row per child needs identifier, title, and the workflow state's display + * name. Appended to ISSUE_DETAIL_FIELDS only when --full requests it, so the + * default detail document stays byte-identical (same opt-in principle as + * withExtraFields). + */ +export const ISSUE_CHILDREN_FIELDS = ` + children { nodes { identifier title state { name } } } +`; + /** * Selection for the `projects` connection. We select `status { type }` rather * than the deprecated `state` field (Project.state: "[DEPRECATED] Use @@ -132,6 +148,18 @@ export const CYCLE_LIST_FIELDS = ` progress `; +/** + * A sub-issue child row as returned by ISSUE_CHILDREN_FIELDS (#26): the + * subset of Issue fields one `issue view --full` child line renders. Shapes + * match the generated Issue type in @linear/sdk v90 (identifier/title are + * non-nullable Strings; state is the issue's WorkflowState). + */ +export interface LinearIssueChild { + identifier: string; + title: string; + state?: { name: string } | null; +} + export interface LinearIssue { id: string; identifier: string; @@ -151,6 +179,9 @@ export interface LinearIssue { // within its team). project?: { id: string; name: string } | null; cycle?: { id: string; number: number } | null; + // Present when selected via ISSUE_CHILDREN_FIELDS (issue view --full only, + // #26): the issue's sub-issues ("children" connection, server order). + children?: { nodes: LinearIssueChild[] } | null; url?: string; createdAt?: string; updatedAt?: string; @@ -555,15 +586,25 @@ export async function fetchIssues( * * `extraFields` is a list of opt-in extra scalar Issue keys (from `--fields`) * appended to the selection; the default document is used when empty. + * + * `includeChildren` (#26) additionally appends ISSUE_CHILDREN_FIELDS so + * `issue view --full` can list the issue's sub-issues. Opt-in for the same + * reason as the scalar extras: every update/delete/state-resolution caller + * shares this query and would otherwise carry (and pay for) a children page + * it never reads. */ export async function fetchIssue( apiKey: string, ref: string, extraFields: string[] = [], + includeChildren = false, ): Promise { + const selection = includeChildren + ? `${withExtraFields(ISSUE_DETAIL_FIELDS, extraFields)}${ISSUE_CHILDREN_FIELDS}` + : withExtraFields(ISSUE_DETAIL_FIELDS, extraFields); const data = await linearRequest<{ issue: LinearIssue | null }>( apiKey, - `query Issue($id: String!) { issue(id: $id) { ${withExtraFields(ISSUE_DETAIL_FIELDS, extraFields)} } }`, + `query Issue($id: String!) { issue(id: $id) { ${selection} } }`, { id: ref }, ); return data.issue ?? undefined; @@ -780,6 +821,15 @@ export interface IssueCreateInput { * the issue", @linear/sdk v90). */ cycleId?: string; + /** + * Create the issue as a sub-issue of this parent (resolved by the caller + * from --parent via fetchIssue). IssueCreateInput.parentId is a nullable + * String ("The identifier of the parent issue. Can be a UUID or issue + * identifier (e.g., 'LIN-123')", @linear/sdk v90) — verified as the + * mechanism for #26: the generated SDK types expose NO subIssueCreate + * mutation, sub-issues are created through issueCreate + parentId. + */ + parentId?: string; } export async function createIssue( @@ -828,6 +878,12 @@ export async function createIssue( varDecls.push('$cycleId: String'); variables['cycleId'] = input.cycleId; } + // parentId (#26) follows the same omit-null convention as above. + if (input.parentId !== undefined) { + inputFields.push('parentId: $parentId'); + varDecls.push('$parentId: String'); + variables['parentId'] = input.parentId; + } const data = await linearRequest<{ issueCreate: { issue: LinearIssue; success: boolean }; diff --git a/src/skill.ts b/src/skill.ts index 8fc0f26..76c0c19 100644 --- a/src/skill.ts +++ b/src/skill.ts @@ -4,8 +4,9 @@ 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, 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 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`, ' + @@ -53,14 +54,14 @@ 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; 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 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 1. Run \`npx -y @apexcloudwise/linear-axi\` with no arguments for a dashboard - the current viewer and your started, assigned issues. 2. Drill in command-first: \`issues\`, \`issue view \`, \`teams\`, \`projects\`, \`cycles\`, \`labels\`. 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\`, and \`--project ""\` / \`--cycle current|\` land it in a project or cycle straight away. +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). 7. Every response ends with contextual next-step hints under \`help:\` - follow them. @@ -86,7 +87,8 @@ Run \`npx -y @apexcloudwise/linear-axi --help\` for global flags, or \`npx -y @a - \`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. \`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. +- \`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/sub-issues.test.ts b/test/sub-issues.test.ts new file mode 100644 index 0000000..1d91731 --- /dev/null +++ b/test/sub-issues.test.ts @@ -0,0 +1,313 @@ +// Tests for sub-issue support (#26): `issue create --parent ` and the +// children block in `issue view --full`. Follows the network-free GraphQL +// stub pattern from project-cycle-assignment.test.ts: stub global fetch, then +// assert on the captured query documents, mutation variables, and rendered +// TOON output. +// +// Flow order under test: +// - create --parent: resolve the parent ref (query Issue, identifier or UUID) +// BEFORE the mutation, then createIssue resolves the team (query TeamByKey) +// and mutates with parentId. +// - view --full: the detail document gains the children connection and the +// output gains a sub-issues block; without --full neither appears. +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { issueCommand, ISSUE_HELP } from '../src/commands/issue.js'; +import type { LinearIssue } from '../src/linear.js'; + +const FAKE_KEY = 'lin_api_fake'; +const PARENT_UUID = '9b41d3e2-1111-4222-8333-444455556666'; + +interface CapturedRequest { + url: string; + body: { query: string; variables: Record }; +} + +interface StubOptions { + /** The issue served for `query Issue(` (default: LIN-42 with 2 children). */ + issue?: LinearIssue | null; +} + +/** + * A parent issue node as returned by ISSUE_DETAIL_FIELDS plus (when the + * document asks for it) ISSUE_CHILDREN_FIELDS — the same fixture serves the + * --parent resolution round trip and the viewed parent. + */ +function parentNode(overrides: Partial = {}): LinearIssue { + return { + id: 'ir-42', + identifier: 'LIN-42', + title: 'Ship the settings page', + description: 'Parent body text', + state: { name: 'In Progress', type: 'started' }, + priority: 2, + assignee: null, + team: { key: 'LIN', name: 'Linear', id: 'team-1' }, + labels: { nodes: [] }, + project: null, + cycle: null, + children: { + nodes: [ + { + identifier: 'LIN-43', + title: 'Wire the toggle', + state: { name: 'In Progress' }, + }, + { + identifier: 'LIN-44', + title: 'Write tests', + state: { name: 'Todo' }, + }, + ], + }, + url: 'https://linear.app/lini/issue/LIN-42/ship-the-settings-page', + createdAt: '2026-07-01T00:00:00.000Z', + updatedAt: '2026-08-01T00:00:00.000Z', + ...overrides, + }; +} + +function graphqlOk(data: unknown): Response { + return { ok: true, status: 200, json: async () => ({ data }) } as Response; +} + +/** + * Stub global fetch as a Linear GraphQL endpoint serving every document the + * #26 flows can issue. Returns captured requests for assertions. Any + * unexpected document fails the stub loudly. + */ +function stubFetch(opts: StubOptions = {}): { requests: CapturedRequest[] } { + const issue = opts.issue === undefined ? parentNode() : opts.issue; + + 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 }); + if (body.query.includes('query Issue(')) { + return graphqlOk({ issue }); + } + if (body.query.includes('query TeamByKey(')) { + return graphqlOk({ + teams: { nodes: [{ id: 'team-1', key: 'LIN' }] }, + }); + } + if (body.query.includes('mutation CreateIssue(')) { + return graphqlOk({ + issueCreate: { + success: true, + issue: { + id: 'ir-9', + identifier: 'LIN-9', + title: 'Wire the toggle', + url: 'https://linear.app/lini/issue/LIN-9/wire-the-toggle', + }, + }, + }); + } + throw new Error(`Unexpected GraphQL document in stub: ${body.query}`); + }); + vi.stubGlobal('fetch', fetchMock); + return { requests }; +} + +const createRequests = (requests: CapturedRequest[]) => + requests.filter((r) => r.body.query.includes('mutation CreateIssue(')); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('issue create --parent', () => { + it('resolves the parent ref to its id and sends parentId', async () => { + const { requests } = stubFetch(); + + const out = await issueCommand( + [ + 'create', + '--title', + 'Wire the toggle', + '--team', + 'LIN', + '--parent', + 'LIN-42', + ], + { apiKey: FAKE_KEY }, + ); + + // Parent resolution first, then team resolution, then the mutation. + expect(requests).toHaveLength(3); + expect(requests[0].body.query).toContain('query Issue('); + expect(requests[0].body.variables).toEqual({ id: 'LIN-42' }); + const mutation = requests[2]; + expect(mutation.body.query).toContain('parentId: $parentId'); + expect(mutation.body.query).toContain('$parentId: String'); + expect(mutation.body.variables).toEqual({ + teamId: 'team-1', + title: 'Wire the toggle', + parentId: 'ir-42', + }); + expect(out).toContain('created: LIN-9'); + }); + + it('passes a UUID parent ref through the same resolution', async () => { + const { requests } = stubFetch(); + + await issueCommand( + [ + 'create', + '--title', + 'Wire the toggle', + '--team', + 'LIN', + '--parent', + PARENT_UUID, + ], + { apiKey: FAKE_KEY }, + ); + + expect(requests[0].body.variables).toEqual({ id: PARENT_UUID }); + expect(createRequests(requests)[0]!.body.variables).toEqual({ + teamId: 'team-1', + title: 'Wire the toggle', + parentId: 'ir-42', + }); + }); + + it('fails loud when the parent is not found (no mutation)', async () => { + const { requests } = stubFetch({ issue: null }); + + await expect( + issueCommand( + [ + 'create', + '--title', + 'Wire the toggle', + '--team', + 'LIN', + '--parent', + 'LIN-404', + ], + { apiKey: FAKE_KEY }, + ), + ).rejects.toThrow(/Parent issue "LIN-404" not found/); + + expect(createRequests(requests)).toHaveLength(0); + }); + + it('rejects a blank --parent before any network request', async () => { + const { requests } = stubFetch(); + + await expect( + issueCommand( + ['create', '--title', 'x', '--team', 'LIN', '--parent', ''], + { apiKey: FAKE_KEY }, + ), + ).rejects.toThrow(/--parent requires a value/); + + expect(requests).toHaveLength(0); + }); + + it('does not pre-reject a cross-team parent (Linear decides)', async () => { + // Linear's sub-issue hierarchy spans teams, so the client sends the + // parentId and lets the API answer — any rejection would surface through + // the normal error mapping. + const { requests } = stubFetch({ + issue: parentNode({ + team: { key: 'ENG', name: 'Engineering', id: 'team-eng' }, + }), + }); + + await issueCommand( + [ + 'create', + '--title', + 'Wire the toggle', + '--team', + 'LIN', + '--parent', + 'ENG-7', + ], + { apiKey: FAKE_KEY }, + ); + + const mutation = createRequests(requests)[0]!; + expect(mutation.body.variables).toEqual({ + teamId: 'team-1', + title: 'Wire the toggle', + parentId: 'ir-42', + }); + }); + + it('sends no parentId without --parent (default document unchanged)', async () => { + const { requests } = stubFetch(); + + await issueCommand(['create', '--title', 'Ship v2', '--team', 'LIN'], { + apiKey: FAKE_KEY, + }); + + // Only team resolution + mutation; no parent round trip. + expect(requests).toHaveLength(2); + const mutation = createRequests(requests)[0]!; + expect(mutation.body.query).not.toContain('parentId'); + expect(mutation.body.variables).toEqual({ + teamId: 'team-1', + title: 'Ship v2', + }); + }); +}); + +describe('issue view --full children', () => { + it('selects the children connection and renders one line per child', async () => { + const { requests } = stubFetch(); + + const out = await issueCommand(['view', 'LIN-42', '--full'], { + apiKey: FAKE_KEY, + }); + + expect(requests).toHaveLength(1); + expect(requests[0].body.query).toContain( + 'children { nodes { identifier title state { name } } }', + ); + expect(requests[0].body.variables).toEqual({ id: 'LIN-42' }); + // Count header plus one `identifier | title | state` line per child, in + // server order. + expect(out).toContain('sub-issues[2]:'); + expect(out).toContain('LIN-43 | Wire the toggle | In Progress'); + expect(out).toContain('LIN-44 | Write tests | Todo'); + expect(out.indexOf('LIN-43')).toBeLessThan(out.indexOf('LIN-44')); + }); + + it('omits the children selection and the block without --full', async () => { + const { requests } = stubFetch(); + + const out = await issueCommand(['view', 'LIN-42'], { apiKey: FAKE_KEY }); + + // Default detail document stays byte-identical (opt-in only). + expect(requests[0].body.query).not.toContain('children'); + expect(out).not.toContain('sub-issues'); + }); + + it('renders an empty sub-issues header under --full', async () => { + stubFetch({ issue: parentNode({ children: { nodes: [] } }) }); + + const out = await issueCommand(['view', 'LIN-42', '--full'], { + apiKey: FAKE_KEY, + }); + + expect(out).toContain('sub-issues[0]:'); + }); +}); + +describe('help documents the sub-issue flags (#26)', () => { + it('lists --parent on create and the --full children listing', () => { + expect(ISSUE_HELP).toContain( + '[--description "..."] [--label ...] [--project ] [--cycle ] [--parent ]', + ); + expect(ISSUE_HELP).toContain( + '--parent create: nest the new issue under this parent', + ); + expect(ISSUE_HELP).toContain('--full (view)'); + expect(ISSUE_HELP).toContain( + 'linear-axi issue create --title "Wire the toggle" --team ENG --parent LIN-42', + ); + }); +}); From 727d0aa866e83c2d9313fbdee69ebb1b0fcebb88 Mon Sep 17 00:00:00 2001 From: Sam Whitmore Date: Thu, 20 Aug 2026 04:41:31 +0530 Subject: [PATCH 04/12] feat: add --description-file and stdin input for long bodies --- src/commands/issue.ts | 121 ++++++- test/description-file.test.ts | 453 ++++++++++++++++++++++++++ test/project-cycle-assignment.test.ts | 2 +- test/sub-issues.test.ts | 2 +- 4 files changed, 569 insertions(+), 9 deletions(-) create mode 100644 test/description-file.test.ts diff --git a/src/commands/issue.ts b/src/commands/issue.ts index 742111f..9cc4ebe 100644 --- a/src/commands/issue.ts +++ b/src/commands/issue.ts @@ -34,14 +34,15 @@ import { truncateForDisplay, type FieldDef, } from '../toon.js'; +import { readFileSync } from 'node:fs'; export const ISSUE_HELP = `usage: linear-axi issue [args] Manage Linear issues. subcommands: view show issue details (--full shows the full description and sub-issues; --fields adds extra fields) - create --title "..." --team [--description "..."] [--label ...] [--project ] [--cycle ] [--parent ] - update [--state ] [--title "..."] [--priority <0-4>] [--description "..."] + create --title "..." --team [--description "..."] [--description-file ] [--label ...] [--project ] [--cycle ] [--parent ] + update [--state ] [--title "..."] [--priority <0-4>] [--description "..."] [--description-file ] [--assignee ] [--label ] [--remove-label ] [--project ] [--cycle ] delete @@ -53,6 +54,11 @@ update flags: --label add a label (repeatable; names from \`linear-axi labels\`) --remove-label remove a label (repeatable); removing the last one clears labels +description input (create and update): + --description "..." inline description — fine for short, single-line text + --description-file read the description from a UTF-8 file instead (multi-line markdown without + shell quoting); pass "-" to read stdin to EOF; pass only one of the two flags + sub-issue flags: --parent create: nest the new issue under this parent (fails loud if not found; cross-team parents are allowed — the hierarchy spans teams) @@ -72,6 +78,8 @@ examples: linear-axi issue create --title "Fix login" --team ENG --label bug linear-axi issue create --title "Ship v2" --team LIN --project "Q3 launch" --cycle current linear-axi issue create --title "Wire the toggle" --team ENG --parent LIN-42 + linear-axi issue create --title "Fix login" --team ENG --description-file ./issue.md + cat notes.md | linear-axi issue update LIN-123 --description-file - linear-axi issue update LIN-123 --state "In Progress" linear-axi issue update LIN-123 --assignee me --label bug linear-axi issue update LIN-123 --remove-label bug @@ -86,6 +94,7 @@ const CREATE_FLAGS = [ '--title', '--team', '--description', + '--description-file', '--label', '--project', '--cycle', @@ -96,6 +105,7 @@ const UPDATE_FLAGS = [ '--title', '--priority', '--description', + '--description-file', '--assignee', '--label', '--remove-label', @@ -239,6 +249,11 @@ async function createIssueCmd( const title = takeFlag(args, '--title'); const team = takeFlag(args, '--team'); const description = takeFlag(args, '--description'); + // has* detection must precede takeFlag (which removes the flag pair). + const hasDescriptionFile = args.some( + (a) => a === '--description-file' || a.startsWith('--description-file='), + ); + const descriptionFile = takeFlag(args, '--description-file'); const labels = takeAllFlags(args, '--label'); // has* detection must precede takeFlag (which removes the flag pair). const hasProject = args.some( @@ -265,6 +280,16 @@ async function createIssueCmd( ]); } + // --description-file (#28): mutual exclusion with --description (same + // convention as comment's --body/--body-file), then the read itself — a + // blank value, unreadable path, or empty input fails loud BEFORE any + // network request. + const descriptionInput = readDescriptionInput( + description, + hasDescriptionFile, + descriptionFile, + ); + // Blank/missing --project/--cycle values fail loud before any network // request (same guard shape as the `issues` read path). if (hasProject && (projectName === undefined || projectName.trim() === '')) { @@ -325,7 +350,7 @@ async function createIssueCmd( const issue = await createIssue(apiKey, { title: title.trim(), - description, + description: descriptionInput, teamKey: team.trim(), labelNames: labels, projectId, @@ -370,6 +395,10 @@ async function updateIssueCmd( const title = takeFlag(args, '--title'); const priorityRaw = takeFlag(args, '--priority'); const description = takeFlag(args, '--description'); + const hasDescriptionFile = args.some( + (a) => a === '--description-file' || a.startsWith('--description-file='), + ); + const descriptionFile = takeFlag(args, '--description-file'); const assigneeRaw = takeFlag(args, '--assignee'); const addLabelNames = takeAllFlags(args, '--label'); const removeLabelNames = takeAllFlags(args, '--remove-label'); @@ -387,11 +416,22 @@ async function updateIssueCmd( ); const teamRaw = takeFlag(args, '--team'); + // --description-file (#28): resolve the guards + read BEFORE the + // nothing-to-update check below, so a lone blank `--description-file` is a + // flag error rather than a confusing "Nothing to update". Same shared + // helper as create — both flags passed, blank values, unreadable paths, + // and empty input all fail loud before any network request. + const descriptionInput = readDescriptionInput( + description, + hasDescriptionFile, + descriptionFile, + ); + if ( !stateName && !title && priorityRaw === undefined && - description === undefined && + descriptionInput === undefined && assigneeRaw === undefined && addLabelNames.length === 0 && removeLabelNames.length === 0 && @@ -399,7 +439,7 @@ async function updateIssueCmd( cycleRaw === undefined ) { throw new AxiError( - 'Nothing to update — pass at least one of --state, --title, --priority, --description, --assignee, --label, --remove-label, --project, --cycle', + 'Nothing to update — pass at least one of --state, --title, --priority, --description, --description-file, --assignee, --label, --remove-label, --project, --cycle', 'VALIDATION_ERROR', ); } @@ -582,7 +622,7 @@ async function updateIssueCmd( // input. const hasPatch = title !== undefined || - description !== undefined || + descriptionInput !== undefined || stateName !== undefined || priority !== undefined || assigneeId !== undefined || @@ -597,7 +637,7 @@ async function updateIssueCmd( const updated = await updateIssue(apiKey, existing.id, { title, - description, + description: descriptionInput, stateName, priority, assigneeId, @@ -645,6 +685,73 @@ async function deleteIssueCmd( ]); } +/** + * Resolve the description input for create/update (#28): --description + * (inline) XOR --description-file (file path or "-" for stdin). Shared by + * both write paths so the guards stay identical, mirroring comment's + * --body/--body-file convention: + * - both flags passed → loud mutual-exclusion error + * - --description-file present but blank → loud flag-value error + * - the file (or stdin) is read up front (readDescriptionFile) so an + * unreadable path or empty input fails loud before any network request + * Returns undefined when neither flag was passed (field omitted entirely). + */ +function readDescriptionInput( + description: string | undefined, + hasFile: boolean, + file: string | undefined, +): string | undefined { + if (description !== undefined && hasFile) { + throw new AxiError( + 'Pass only one of --description or --description-file', + 'VALIDATION_ERROR', + ); + } + if (hasFile && (file === undefined || file.trim() === '')) { + throw new AxiError( + '--description-file requires a value', + 'VALIDATION_ERROR', + ['e.g. --description-file ./issue.md (or - to read stdin)'], + ); + } + return hasFile ? readDescriptionFile(file!) : description; +} + +/** + * Read description content for --description-file (#28): a UTF-8 file path, + * or "-" to read stdin to EOF (fd 0) — long markdown bodies without + * shell-quoting hazards. Mirrors comment's readBodyFile for the path case; + * stdin is the new capability here. `read` is injectable for tests; the + * default is readFileSync(utf-8), which accepts both a path and a numeric fd. + * + * Empty content is rejected loud: on update a silently-empty description + * would wipe the field (a destructive no-op, not silence). + */ +export function readDescriptionFile( + path: string, + read: (source: string | number) => string = (source) => + readFileSync(source, 'utf-8'), +): string { + let content: string; + try { + // "-" maps to fd 0; the same readFileSync call serves both cases. + content = read(path === '-' ? 0 : path); + } catch { + throw new AxiError( + `Could not read --description-file: ${path}`, + 'VALIDATION_ERROR', + ); + } + if (content.trim() === '') { + throw new AxiError( + `--description-file is empty: ${path}`, + 'VALIDATION_ERROR', + ['Pass a non-empty UTF-8 file or pipe stdin ("-")'], + ); + } + return content; +} + function parsePriority(raw: string): number { const n = parseInt(raw, 10); if (isNaN(n) || n < 0 || n > 4) { diff --git a/test/description-file.test.ts b/test/description-file.test.ts new file mode 100644 index 0000000..2c8151e --- /dev/null +++ b/test/description-file.test.ts @@ -0,0 +1,453 @@ +// Tests for `--description-file` (#28): read the issue description from a +// UTF-8 file — or "-" for stdin to EOF — on `issue create` and `issue +// update`. Follows the network-free GraphQL stub pattern from +// project-cycle-assignment.test.ts / sub-issues.test.ts: stub global fetch, +// then assert on the captured mutation variables and rendered TOON output. +// +// Flow order under test: +// - create --description-file: the file is read BEFORE any network request, +// then team resolution (query TeamByKey) and the CreateIssue mutation +// carry the file content as `description`. +// - update --description-file: same pre-network read, then the Issue fetch +// and the UpdateIssue mutation carry the content. +// - "-" (stdin): reader-level fd-0 semantics via the injectable `read` +// function, plus command-level runs where node:fs serves fixed content +// for fd 0. +// - Guards: mutual exclusion with --description, blank flag values, +// unreadable paths, and empty input all fail loud with zero network +// traffic (a silently-empty description would wipe the field on update). +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import type { MockInstance } from 'vitest'; +import { mkdtempSync, writeFileSync, rmSync, readFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { + issueCommand, + ISSUE_HELP, + readDescriptionFile, +} from '../src/commands/issue.js'; +import { AxiError } from '../src/errors.js'; +import type { LinearIssue } from '../src/linear.js'; + +const FAKE_KEY = 'lin_api_fake'; + +// node:fs is mocked file-wide with a readFileSync spy that delegates to the +// real implementation by default (so tmp fixtures and any import-time reads +// keep working); the stdin tests override the next call only. The factory +// spread keeps every other export real. +vi.mock('node:fs', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, readFileSync: vi.fn(actual.readFileSync) }; +}); + +// Loose mock handle: node's readFileSync overloads make the precise +// MockInstance type unwieldy, and these tests only need "serve fixed +// content on the next call". +const readFileMock = vi.mocked(readFileSync) as unknown as MockInstance< + (...args: unknown[]) => unknown +>; + +/** Multi-line body with the shell-quoting hazards the flag exists to avoid. */ +const DESCRIPTION = `## Steps to reproduce + +1. Run the command with a "quoted" arg +2. Observe the **failure** + +It's got 'apostrophes', $hell expansions, and +line breaks — no shell can mangle it through a file.`; + +const STDIN_BODY = 'piped straight through stdin\nwith newlines\n'; + +interface CapturedRequest { + url: string; + body: { query: string; variables: Record }; +} + +function issueNode(overrides: Partial = {}): LinearIssue { + return { + id: 'ir-7', + identifier: 'LIN-7', + title: 'Wire the toggle', + description: 'Old body', + state: { name: 'Todo', type: 'unstarted' }, + priority: 0, + assignee: null, + team: { key: 'LIN', name: 'Linear', id: 'team-1' }, + labels: { nodes: [] }, + project: null, + cycle: null, + url: 'https://linear.app/lini/issue/LIN-7/wire-the-toggle', + createdAt: '2026-07-01T00:00:00.000Z', + updatedAt: '2026-08-01T00:00:00.000Z', + ...overrides, + }; +} + +function graphqlOk(data: unknown): Response { + return { ok: true, status: 200, json: async () => ({ data }) } as Response; +} + +/** + * Stub global fetch as a Linear GraphQL endpoint serving every document the + * #28 flows can issue. Returns captured requests for assertions; any + * unexpected document fails the stub loudly. + */ +function stubFetch(): { requests: CapturedRequest[] } { + const issue = issueNode(); + 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 }); + if (body.query.includes('query Issue(')) { + return graphqlOk({ issue }); + } + if (body.query.includes('query TeamByKey(')) { + return graphqlOk({ + teams: { nodes: [{ id: 'team-1', key: 'LIN' }] }, + }); + } + if (body.query.includes('mutation CreateIssue(')) { + return graphqlOk({ + issueCreate: { + success: true, + issue: { + id: 'ir-9', + identifier: 'LIN-9', + title: 'Wire the toggle', + url: 'https://linear.app/lini/issue/LIN-9/wire-the-toggle', + }, + }, + }); + } + if (body.query.includes('mutation UpdateIssue(')) { + return graphqlOk({ + issueUpdate: { + success: true, + issue: { + id: 'ir-7', + identifier: 'LIN-7', + title: 'Wire the toggle', + url: 'https://linear.app/lini/issue/LIN-7/wire-the-toggle', + }, + }, + }); + } + throw new Error(`Unexpected GraphQL document in stub: ${body.query}`); + }); + vi.stubGlobal('fetch', fetchMock); + return { requests }; +} + +const createRequests = (requests: CapturedRequest[]) => + requests.filter((r) => r.body.query.includes('mutation CreateIssue(')); + +const updateRequests = (requests: CapturedRequest[]) => + requests.filter((r) => r.body.query.includes('mutation UpdateIssue(')); + +let tmpDir: string; + +function writeTmp(name: string, content: string): string { + const path = join(tmpDir, name); + writeFileSync(path, content, 'utf-8'); + return path; +} + +/** Assert the thunk throws a VALIDATION_ERROR matching message. */ +function expectLoud(fn: () => unknown, message: RegExp): void { + try { + fn(); + } catch (err) { + const axi = err as AxiError; + expect(axi.message).toMatch(message); + expect(axi.code).toBe('VALIDATION_ERROR'); + return; + } + throw new Error(`expected a loud VALIDATION_ERROR matching ${message}`); +} + +beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'axi-description-file-')); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + // mockReset restores the real-implementation default from vi.fn(impl). + readFileMock.mockReset(); + rmSync(tmpDir, { recursive: true, force: true }); +}); + +describe('readDescriptionFile reader semantics', () => { + it('reads a path through the underlying read function', () => { + const seen: Array = []; + const content = readDescriptionFile('issue.md', (source) => { + seen.push(source); + return 'file body'; + }); + expect(content).toBe('file body'); + expect(seen).toEqual(['issue.md']); + }); + + it('maps "-" to fd 0 — stdin, read to EOF', () => { + const seen: Array = []; + const content = readDescriptionFile('-', (source) => { + seen.push(source); + return STDIN_BODY; + }); + expect(content).toBe(STDIN_BODY); + expect(seen).toEqual([0]); + }); + + it('fails loud when the file read throws', () => { + expectLoud( + () => + readDescriptionFile('missing.md', () => { + throw new Error('ENOENT'); + }), + /Could not read --description-file: missing\.md/, + ); + }); + + it('fails loud when the stdin read throws', () => { + expectLoud( + () => + readDescriptionFile('-', () => { + throw new Error('EAGAIN'); + }), + /Could not read --description-file: -/, + ); + }); + + it('fails loud on empty and whitespace-only content', () => { + for (const empty of ['', ' \n\t\n']) { + expectLoud( + () => readDescriptionFile('issue.md', () => empty), + /--description-file is empty: issue\.md/, + ); + } + }); +}); + +describe('issue create --description-file', () => { + it('sends the file content as the description variable', async () => { + const file = writeTmp('issue.md', DESCRIPTION); + const { requests } = stubFetch(); + + const out = await issueCommand( + [ + 'create', + '--title', + 'Wire the toggle', + '--team', + 'LIN', + '--description-file', + file, + ], + { apiKey: FAKE_KEY }, + ); + + expect(requests[0]!.body.query).toContain('query TeamByKey('); + const mutation = createRequests(requests)[0]!; + expect(mutation.body.query).toContain('description: $description'); + expect(mutation.body.variables).toEqual({ + teamId: 'team-1', + title: 'Wire the toggle', + description: DESCRIPTION, + }); + expect(out).toContain('created: LIN-9'); + }); + + it('reads stdin for "-" end to end', async () => { + // The only readFileSync call on this path is the reader's fd-0 read. + readFileMock.mockImplementationOnce(() => STDIN_BODY); + const { requests } = stubFetch(); + + await issueCommand( + [ + 'create', + '--title', + 'From pipe', + '--team', + 'LIN', + '--description-file', + '-', + ], + { apiKey: FAKE_KEY }, + ); + + expect(createRequests(requests)[0]!.body.variables).toEqual({ + teamId: 'team-1', + title: 'From pipe', + description: STDIN_BODY, + }); + }); + + it('rejects --description together with --description-file before any network request', async () => { + const { requests } = stubFetch(); + + await expect( + issueCommand( + [ + 'create', + '--title', + 't', + '--team', + 'LIN', + '--description', + 'inline', + '--description-file', + 'issue.md', + ], + { apiKey: FAKE_KEY }, + ), + ).rejects.toThrow(/Pass only one of --description or --description-file/); + + expect(requests).toHaveLength(0); + }); + + it('rejects an unreadable path before any network request', async () => { + const { requests } = stubFetch(); + + await expect( + issueCommand( + [ + 'create', + '--title', + 't', + '--team', + 'LIN', + '--description-file', + join(tmpDir, 'nope.md'), + ], + { apiKey: FAKE_KEY }, + ), + ).rejects.toThrow(/Could not read --description-file: .*nope\.md/); + + expect(requests).toHaveLength(0); + }); + + it('rejects an empty file before any network request', async () => { + const file = writeTmp('empty.md', '\n \n'); + const { requests } = stubFetch(); + + await expect( + issueCommand( + ['create', '--title', 't', '--team', 'LIN', '--description-file', file], + { apiKey: FAKE_KEY }, + ), + ).rejects.toThrow(/--description-file is empty/); + + expect(requests).toHaveLength(0); + }); + + it('rejects a blank flag value', async () => { + await expect( + issueCommand( + ['create', '--title', 't', '--team', 'LIN', '--description-file', ''], + { apiKey: FAKE_KEY }, + ), + ).rejects.toThrow(/--description-file requires a value/); + }); + + it('sends no description without the flags (default unchanged)', async () => { + const { requests } = stubFetch(); + + await issueCommand(['create', '--title', 'Ship v2', '--team', 'LIN'], { + apiKey: FAKE_KEY, + }); + + const mutation = createRequests(requests)[0]!; + expect(mutation.body.query).not.toContain('description'); + expect(mutation.body.variables).toEqual({ + teamId: 'team-1', + title: 'Ship v2', + }); + }); +}); + +describe('issue update --description-file', () => { + it('sends the file content as the description variable', async () => { + const file = writeTmp('issue.md', DESCRIPTION); + const { requests } = stubFetch(); + + const out = await issueCommand( + ['update', 'LIN-7', '--description-file', file], + { + apiKey: FAKE_KEY, + }, + ); + + expect(requests[0]!.body.query).toContain('query Issue('); + const mutation = updateRequests(requests)[0]!; + expect(mutation.body.query).toContain('description: $description'); + expect(mutation.body.variables).toEqual({ + id: 'ir-7', + description: DESCRIPTION, + }); + expect(out).toContain('updated: LIN-7'); + }); + + it('reads stdin for "-" end to end', async () => { + readFileMock.mockImplementationOnce(() => STDIN_BODY); + const { requests } = stubFetch(); + + await issueCommand(['update', 'LIN-7', '--description-file', '-'], { + apiKey: FAKE_KEY, + }); + + expect(updateRequests(requests)[0]!.body.variables).toEqual({ + id: 'ir-7', + description: STDIN_BODY, + }); + }); + + it('rejects --description together with --description-file before any network request', async () => { + const { requests } = stubFetch(); + + await expect( + issueCommand( + [ + 'update', + 'LIN-7', + '--description', + 'inline', + '--description-file', + 'issue.md', + ], + { apiKey: FAKE_KEY }, + ), + ).rejects.toThrow(/Pass only one of --description or --description-file/); + + expect(requests).toHaveLength(0); + }); + + it('rejects an unreadable path before any network request', async () => { + const { requests } = stubFetch(); + + await expect( + issueCommand( + ['update', 'LIN-7', '--description-file', join(tmpDir, 'nope.md')], + { apiKey: FAKE_KEY }, + ), + ).rejects.toThrow(/Could not read --description-file: .*nope\.md/); + + expect(requests).toHaveLength(0); + }); +}); + +describe('help documents --description-file (#28)', () => { + it('lists the flag on both write subcommands plus the stdin form', () => { + expect(ISSUE_HELP).toContain( + 'create --title "..." --team [--description "..."] [--description-file ]', + ); + expect(ISSUE_HELP).toContain( + '[--priority <0-4>] [--description "..."] [--description-file ]', + ); + expect(ISSUE_HELP).toContain('pass "-" to read stdin to EOF'); + expect(ISSUE_HELP).toContain( + 'linear-axi issue create --title "Fix login" --team ENG --description-file ./issue.md', + ); + expect(ISSUE_HELP).toContain( + 'cat notes.md | linear-axi issue update LIN-123 --description-file -', + ); + }); +}); diff --git a/test/project-cycle-assignment.test.ts b/test/project-cycle-assignment.test.ts index 6358258..3e361fa 100644 --- a/test/project-cycle-assignment.test.ts +++ b/test/project-cycle-assignment.test.ts @@ -557,7 +557,7 @@ describe('parity and guards (create and update)', () => { it('documents the flags and examples in help (both subcommands)', () => { expect(ISSUE_HELP).toContain( - 'create --title "..." --team [--description "..."] [--label ...] [--project ] [--cycle ]', + 'create --title "..." --team [--description "..."] [--description-file ] [--label ...] [--project ] [--cycle ]', ); expect(ISSUE_HELP).toContain( '[--assignee ] [--label ] [--remove-label ] [--project ] [--cycle ]', diff --git a/test/sub-issues.test.ts b/test/sub-issues.test.ts index 1d91731..07145aa 100644 --- a/test/sub-issues.test.ts +++ b/test/sub-issues.test.ts @@ -300,7 +300,7 @@ describe('issue view --full children', () => { describe('help documents the sub-issue flags (#26)', () => { it('lists --parent on create and the --full children listing', () => { expect(ISSUE_HELP).toContain( - '[--description "..."] [--label ...] [--project ] [--cycle ] [--parent ]', + '[--description "..."] [--description-file ] [--label ...] [--project ] [--cycle ] [--parent ]', ); expect(ISSUE_HELP).toContain( '--parent create: nest the new issue under this parent', From be6711c54be2b07798d565b1688270ada6cd71d4 Mon Sep 17 00:00:00 2001 From: Sam Whitmore Date: Thu, 20 Aug 2026 04:50:48 +0530 Subject: [PATCH 05/12] feat: add comment update and delete - comment update --body|--body-file via commentUpdate (pre-fetch for a loud NOT_FOUND; body guards mirror the create path) - comment delete via commentDelete, idempotent no-op when the comment is already gone (pre-fetch pattern, mirrors deleteIssueCmd) - comment list rows now lead with the full comment id (the handle update/delete target); list hint advertises both subcommands - COMMENT_HELP documents the new subcommands and the reserved words - skill prose regenerated via pnpm run build:skill Closes #27 --- skills/linear-axi/SKILL.md | 10 +- src/commands/comment.ts | 157 +++++++++++- src/linear.ts | 85 +++++++ src/skill.ts | 12 +- test/comment-list.test.ts | 2 +- test/comment-update-delete.test.ts | 375 +++++++++++++++++++++++++++++ 6 files changed, 620 insertions(+), 21 deletions(-) create mode 100644 test/comment-update-delete.test.ts 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 882c724..1372e6f 100644 --- a/src/linear.ts +++ b/src/linear.ts @@ -712,6 +712,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 @@ -1062,6 +1092,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); + }); +}); From a7aee22740dc90c39b14bc5a8df957b6305e6dc3 Mon Sep 17 00:00:00 2001 From: Sam Whitmore Date: Thu, 20 Aug 2026 04:58:19 +0530 Subject: [PATCH 06/12] feat: retry rate-limited requests with bounded backoff MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - linearRequest retries HTTP 429 up to 2 times (3 attempts total) before surfacing mapLinearError's structured RATE_LIMITED error unchanged — transport-level, so every command benefits with no API/flag changes - backoff honors Retry-After delay-seconds, clamped to [1s, 60s]; missing/ invalid/non-positive header falls back to bounded exponential (1s, 2s) - no retry on non-429 outcomes, GraphQL-level errors (200 + errors), or network failures; a non-429 after a 429 fails immediately with the last response's mapping - test/rate-limit-retry.test.ts: 11 tests over the fetch-stub pattern with fake timers (only setTimeout faked, Date stays real to prove no actual sleeping; per-ms advance pins exact honored-delay boundaries) Closes #30 --- src/linear.ts | 74 +++++++-- test/rate-limit-retry.test.ts | 301 ++++++++++++++++++++++++++++++++++ 2 files changed, 364 insertions(+), 11 deletions(-) create mode 100644 test/rate-limit-retry.test.ts diff --git a/src/linear.ts b/src/linear.ts index 1372e6f..ce41691 100644 --- a/src/linear.ts +++ b/src/linear.ts @@ -2,11 +2,59 @@ import { mapLinearError, networkError, AxiError } from './errors.js'; export const LINEAR_API_URL = 'https://api.linear.app/graphql'; +// --------------------------------------------------------------------------- +// Rate-limit retry (#30) +// --------------------------------------------------------------------------- + +/** + * On HTTP 429, linearRequest re-sends the request up to this many times (3 + * attempts total) before surfacing mapLinearError's structured RATE_LIMITED + * error — the retry is transport-level and bounded, so no caller or flag + * knows it exists. + */ +const RATE_LIMIT_MAX_RETRIES = 2; + +/** An honored Retry-After is clamped into [1s, 60s] so a hostile/garbage + * header cannot stall the CLI. */ +const RETRY_DELAY_MIN_MS = 1_000; +const RETRY_DELAY_MAX_MS = 60_000; + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** + * How long to wait before retry attempt `retry` (0-based) after a 429: the + * response's `Retry-After` header when it parses as positive delay-seconds + * (clamped to [1s, 60s]), else a bounded exponential fallback (1s, 2s, ...). + * + * mapLinearError derives RATE_LIMITED from the HTTP status alone, so HTTP 429 + * is the only retry signal here — a GraphQL-level error (HTTP 200 + `errors`) + * is never rate-limit-typed by mapLinearError and is never retried. + */ +function rateLimitRetryDelayMs(response: Response, retry: number): number { + const raw = response.headers?.get?.('retry-after'); + const seconds = Number(raw); + if (Number.isFinite(seconds) && seconds > 0) { + return Math.min( + Math.max(seconds * 1_000, RETRY_DELAY_MIN_MS), + RETRY_DELAY_MAX_MS, + ); + } + // Missing/invalid Retry-After (null, "", "0", HTTP-date form, garbage). + return Math.min(RETRY_DELAY_MIN_MS * 2 ** retry, RETRY_DELAY_MAX_MS); +} + /** * Raw transport: POST a GraphQL document to Linear with the API key as the * Authorization header. Returns the parsed `data` on success; throws an AxiError * (translated from HTTP/GraphQL status) otherwise. * + * HTTP 429 responses are retried with bounded backoff honoring Retry-After + * (see rateLimitRetryDelayMs); once retries are exhausted — or a non-429 + * response arrives, including a non-429 after a 429 — the error path below + * surfaces mapLinearError's structured error unchanged. + * * Set LINEAR_AXI_DEBUG=1 to dump the raw response body to stderr on error, * which makes query-shape mistakes debuggable in one round trip. */ @@ -16,17 +64,21 @@ export async function linearRequest( variables: Record = {}, ): Promise { let response: Response; - try { - response = await fetch(LINEAR_API_URL, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: apiKey, - }, - body: JSON.stringify({ query, variables }), - }); - } catch (cause) { - throw networkError(cause); + for (let retry = 0; ; retry++) { + try { + response = await fetch(LINEAR_API_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: apiKey, + }, + body: JSON.stringify({ query, variables }), + }); + } catch (cause) { + throw networkError(cause); + } + if (response.status !== 429 || retry >= RATE_LIMIT_MAX_RETRIES) break; + await sleep(rateLimitRetryDelayMs(response, retry)); } let body: unknown; diff --git a/test/rate-limit-retry.test.ts b/test/rate-limit-retry.test.ts new file mode 100644 index 0000000..18231bc --- /dev/null +++ b/test/rate-limit-retry.test.ts @@ -0,0 +1,301 @@ +// Tests for rate-limit retry in linearRequest (#30): HTTP 429 responses are +// retried with bounded backoff honoring Retry-After, then the SAME structured +// RATE_LIMITED AxiError mapLinearError already produces is surfaced. +// +// Network-free per the fetch-stub pattern from comment-update-delete.test.ts / +// description-file.test.ts (vi.stubGlobal('fetch', ...) + Response-like +// objects + afterEach unstub), with one addition for the delays: fake timers. +// Only setTimeout is faked (`toFake: ['setTimeout']`) so Date.now() stays REAL +// — every backoff test asserts it finished in real milliseconds, proving no +// actual sleep ran, while vi.advanceTimersByTimeAsync advances the logical +// clock one millisecond at a time to pin the exact honored delay boundaries. +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { linearRequest } from '../src/linear.js'; +import { AxiError, mapLinearError, exitCodeForError } from '../src/errors.js'; + +const FAKE_KEY = 'lin_api_fake'; +const QUERY = 'query { viewer { id name email } }'; +const VIEWER = { viewer: { id: 'u-1', name: 'Ada', email: 'ada@example.com' } }; + +function okResponse(data: unknown): Response { + return { + ok: true, + status: 200, + headers: new Headers(), + json: async () => ({ data }), + } as Response; +} + +/** + * A 429 with a real Headers instance — the retry reads + * `headers.get('retry-after')` off the mocked Response, so the stub must be + * Headers-like. Omit `retryAfterSeconds` for the missing-header fallback. + */ +function rateLimitedResponse(retryAfterSeconds?: number): Response { + const headers = new Headers(); + if (retryAfterSeconds !== undefined) { + headers.set('retry-after', String(retryAfterSeconds)); + } + return { + ok: false, + status: 429, + headers, + json: async () => ({ errors: [{ message: 'Too many requests' }] }), + } as Response; +} + +/** A non-429 HTTP failure (401/500/...) with a Headers-like headers object. */ +function httpErrorResponse(status: number): Response { + return { + ok: false, + status, + headers: new Headers(), + json: async () => ({ errors: [{ message: `HTTP ${status}` }] }), + } as Response; +} + +/** + * Stub global fetch to answer with `responses` in order; the last one repeats + * for any further calls. Returns the mock for exact call-count assertions. + */ +function stubSequencedFetch(...responses: Response[]) { + let call = 0; + const fetchMock = vi.fn( + async (_url: string | URL | Request, _init?: RequestInit) => { + const response = responses[Math.min(call, responses.length - 1)]!; + call += 1; + return response; + }, + ); + vi.stubGlobal('fetch', fetchMock); + return fetchMock; +} + +afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); +}); + +describe('429 then success (Retry-After honored)', () => { + it('retries once after the Retry-After delay and succeeds', async () => { + vi.useFakeTimers({ toFake: ['setTimeout'] }); + const startedAt = Date.now(); // real clock — only setTimeout is faked + const fetchMock = stubSequencedFetch( + rateLimitedResponse(2), + okResponse(VIEWER), + ); + + const pending = linearRequest(FAKE_KEY, QUERY); + // One millisecond before the honored 2s Retry-After: still waiting. + await vi.advanceTimersByTimeAsync(2_000 - 1); + expect(fetchMock).toHaveBeenCalledTimes(1); + // At exactly 2s the retry fires and the 200 resolves the request. + await vi.advanceTimersByTimeAsync(1); + const data = await pending; + + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(data).toEqual(VIEWER); + // 2s of logical backoff, but the test never actually slept. + expect(Date.now() - startedAt).toBeLessThan(500); + }); + + it('sends the same GraphQL document and variables on the retry', async () => { + vi.useFakeTimers({ toFake: ['setTimeout'] }); + const variables = { id: 'LIN-1' }; + const fetchMock = stubSequencedFetch( + rateLimitedResponse(1), + okResponse(VIEWER), + ); + + const pending = linearRequest(FAKE_KEY, QUERY, variables); + await vi.advanceTimersByTimeAsync(1_000); + await pending; + + expect(fetchMock).toHaveBeenCalledTimes(2); + const [firstUrl, firstInit] = fetchMock.mock.calls[0]!; + const [retryUrl, retryInit] = fetchMock.mock.calls[1]!; + expect(retryUrl).toBe(firstUrl); + expect(retryInit?.body).toBe(firstInit?.body); + expect(retryInit?.headers).toEqual(firstInit?.headers); + }); + + it('clamps an oversized Retry-After to 60s', async () => { + vi.useFakeTimers({ toFake: ['setTimeout'] }); + const fetchMock = stubSequencedFetch( + rateLimitedResponse(3_600), + okResponse(VIEWER), + ); + + const pending = linearRequest(FAKE_KEY, QUERY); + // Retry-After: 3600 must not be honored literally — the clamp caps it at + // 60s, so nothing fires before t=60_000. + await vi.advanceTimersByTimeAsync(60_000 - 1); + expect(fetchMock).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(1); + await pending; + expect(fetchMock).toHaveBeenCalledTimes(2); + }); +}); + +describe('429 without a usable Retry-After (exponential fallback)', () => { + it('falls back to 1s then 2s when the header is missing', async () => { + vi.useFakeTimers({ toFake: ['setTimeout'] }); + const fetchMock = stubSequencedFetch( + rateLimitedResponse(), // no Retry-After + rateLimitedResponse(), // no Retry-After + okResponse(VIEWER), + ); + + const pending = linearRequest(FAKE_KEY, QUERY); + // First backoff: fallback 1s (not the 60s a missing header could imply). + await vi.advanceTimersByTimeAsync(999); + expect(fetchMock).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(1); + expect(fetchMock).toHaveBeenCalledTimes(2); + // Second backoff: fallback doubles to 2s. + await vi.advanceTimersByTimeAsync(1_999); + expect(fetchMock).toHaveBeenCalledTimes(2); + await vi.advanceTimersByTimeAsync(1); + const data = await pending; + + expect(fetchMock).toHaveBeenCalledTimes(3); + expect(data).toEqual(VIEWER); + }); + + it('treats a non-numeric or zero Retry-After as missing', async () => { + vi.useFakeTimers({ toFake: ['setTimeout'] }); + const garbage = rateLimitedResponse(); + garbage.headers.set('retry-after', 'soon'); + const zero = rateLimitedResponse(0); + const fetchMock = stubSequencedFetch(garbage, zero, okResponse(VIEWER)); + + const pending = linearRequest(FAKE_KEY, QUERY); + // 'soon' does not parse as delay-seconds -> 1s exponential fallback. + await vi.advanceTimersByTimeAsync(999); + expect(fetchMock).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(1); + // Retry-After: 0 is not a positive delay -> 2s fallback (not "immediately"). + await vi.advanceTimersByTimeAsync(1_999); + expect(fetchMock).toHaveBeenCalledTimes(2); + await vi.advanceTimersByTimeAsync(1); + await pending; + expect(fetchMock).toHaveBeenCalledTimes(3); + }); +}); + +describe('exhausted retries surface RATE_LIMITED', () => { + it('stops after 3 attempts and throws mapLinearError’s 429 mapping', async () => { + vi.useFakeTimers({ toFake: ['setTimeout'] }); + const startedAt = Date.now(); + const fetchMock = stubSequencedFetch( + rateLimitedResponse(1), + rateLimitedResponse(1), + rateLimitedResponse(1), + ); + + // Handler attached up-front: the promise rejects mid-advance, and a later + // .catch would briefly leave it unhandled. + const pending = linearRequest(FAKE_KEY, QUERY).catch((e: unknown) => e); + await vi.advanceTimersByTimeAsync(1_000); + expect(fetchMock).toHaveBeenCalledTimes(2); + await vi.advanceTimersByTimeAsync(1_000); + + const err = (await pending) as AxiError; + + // Exactly 3 attempts (1 initial + 2 retries), no fourth call. + expect(fetchMock).toHaveBeenCalledTimes(3); + // The surfaced error IS mapLinearError's RATE_LIMITED — same code, message, + // hint, and SDK exit code; retry adds nothing and changes nothing. + const expected = mapLinearError({ status: 429, body: null }); + expect(err).toBeInstanceOf(AxiError); + expect(err.code).toBe(expected.code); + expect(err.code).toBe('RATE_LIMITED'); + expect(err.message).toBe(expected.message); + expect(err.suggestions).toEqual(expected.suggestions); + expect(exitCodeForError(err)).toBe(exitCodeForError(expected)); + // 2s of logical backoff, zero real sleeping. + expect(Date.now() - startedAt).toBeLessThan(500); + }); +}); + +describe('non-429 failures are never retried', () => { + it('fails immediately with the 500 mapping when a 429 retry hits a 500', async () => { + vi.useFakeTimers({ toFake: ['setTimeout'] }); + const fetchMock = stubSequencedFetch( + rateLimitedResponse(1), + httpErrorResponse(500), + ); + + const pending = linearRequest(FAKE_KEY, QUERY).catch((e: unknown) => e); + await vi.advanceTimersByTimeAsync(1_000); + const err = (await pending) as AxiError; + + // The 429 was retried; the 500 was NOT — the last response's mapping wins. + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(err.code).toBe('NETWORK_ERROR'); + expect(err.message).toContain('HTTP 500'); + }); + + it('does not retry a first-call 401 and sets up no timers', async () => { + vi.useFakeTimers({ toFake: ['setTimeout'] }); + const fetchMock = stubSequencedFetch(httpErrorResponse(401)); + + const err = (await linearRequest(FAKE_KEY, QUERY).catch( + (e: unknown) => e, + )) as AxiError; + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(err.code).toBe('AUTH_REQUIRED'); + expect(vi.getTimerCount()).toBe(0); + }); + + it('does not retry GraphQL-level errors (200 + errors array)', async () => { + vi.useFakeTimers({ toFake: ['setTimeout'] }); + const gqlError = okResponse(null); + // 200 with a top-level errors array — never rate-limit-typed by + // mapLinearError, so never a retry candidate. + const body = { errors: [{ message: 'GraphQL blew up' }] }; + const fetchMock = stubSequencedFetch({ + ...gqlError, + json: async () => body, + } as Response); + + const err = (await linearRequest(FAKE_KEY, QUERY).catch( + (e: unknown) => e, + )) as AxiError; + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(err.message).toContain('GraphQL blew up'); + expect(vi.getTimerCount()).toBe(0); + }); + + it('does not retry a network-level fetch failure', async () => { + vi.useFakeTimers({ toFake: ['setTimeout'] }); + const fetchMock = vi.fn(async () => { + throw new Error('ECONNRESET'); + }); + vi.stubGlobal('fetch', fetchMock); + + const err = (await linearRequest(FAKE_KEY, QUERY).catch( + (e: unknown) => e, + )) as AxiError; + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(err.code).toBe('NETWORK_ERROR'); + expect(err.message).toContain('ECONNRESET'); + expect(vi.getTimerCount()).toBe(0); + }); +}); + +describe('first-call success', () => { + it('returns immediately without setting up any timer', async () => { + vi.useFakeTimers({ toFake: ['setTimeout'] }); + const fetchMock = stubSequencedFetch(okResponse(VIEWER)); + + const data = await linearRequest(FAKE_KEY, QUERY); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(data).toEqual(VIEWER); + expect(vi.getTimerCount()).toBe(0); + }); +}); From eaf442febef3432d895e312d7982087c4ca24c6c Mon Sep 17 00:00:00 2001 From: Sam Whitmore Date: Thu, 20 Aug 2026 05:06:53 +0530 Subject: [PATCH 07/12] test: cover every GraphQL operation and error path in the transport layer Gap-fill for #29 on top of the command-level suites: direct transport-level tests for fetchViewer, fetchTeams, deleteIssue, and resolveStateIdByName (query TeamStates) which had zero document assertions; createIssue's labelNames->labelIds branch, team-not-found error, and the minimal omit-null document; updateIssue's minimal and full omit-null combinations; all six mutations' success:false payload branches; fetchIssues' anti-spin pagination guards; and the mapLinearError matrix end to end through linearRequest (HTTP 400/403/404/502 + GraphQL not-found/auth/validation, message text formatting, extensions hint, non-JSON body fallback, LINEAR_AXI_DEBUG dump, and the POST request-shape contract). --- test/graphql-layer-errors.test.ts | 276 +++++++++++++ test/graphql-layer.test.ts | 617 ++++++++++++++++++++++++++++++ 2 files changed, 893 insertions(+) create mode 100644 test/graphql-layer-errors.test.ts create mode 100644 test/graphql-layer.test.ts diff --git a/test/graphql-layer-errors.test.ts b/test/graphql-layer-errors.test.ts new file mode 100644 index 0000000..f1f14f1 --- /dev/null +++ b/test/graphql-layer-errors.test.ts @@ -0,0 +1,276 @@ +// mapLinearError END TO END through linearRequest (#29): every error branch +// is driven by a stubbed HTTP response — HTTP status codes AND GraphQL 200 + +// errors[] variants — and asserted as the structured AxiError (code, message, +// hints) the CLI actually surfaces. errors.test.ts unit-tests mapLinearError +// directly and rate-limit-retry.test.ts already covers through linearRequest: +// 401 (AUTH_REQUIRED), 429 (RATE_LIMITED, retried), 500 (NETWORK_ERROR), a +// generic GraphQL message, and a thrown fetch (networkError). This file fills +// the remaining matrix: 400, 403 (both sub-branches), 404, 502, GraphQL +// not-found/auth/validation messages, message formatting with path + +// extensions.code, the extensions hint, the non-JSON-body fallback, and the +// LINEAR_AXI_DEBUG dump. +// +// Fetch-stub pattern per test/rate-limit-retry.test.ts: vi.stubGlobal('fetch', +// ...) with Response-like { ok, status, headers, json } objects and +// afterEach(vi.unstubAllGlobals). No network access. +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { linearRequest, LINEAR_API_URL } from '../src/linear.js'; +import { AxiError } from '../src/errors.js'; + +const FAKE_KEY = 'lin_api_fake'; +const QUERY = 'query { viewer { id name email } }'; + +/** A 200/data envelope (also used for 200 + errors[] payloads). */ +function gqlResponse(payload: unknown): Response { + return { + ok: true, + status: 200, + headers: new Headers(), + json: async () => payload, + } as Response; +} + +/** An HTTP failure with the given JSON body. */ +function httpError(status: number, payload: unknown = {}): Response { + return { + ok: false, + status, + headers: new Headers(), + json: async () => payload, + } as Response; +} + +/** A failure whose body is not JSON — linearRequest's json() catch branch. */ +function nonJsonError(status: number): Response { + return { + ok: false, + status, + headers: new Headers(), + json: async () => { + throw new SyntaxError('Unexpected token < in JSON'); + }, + } as Response; +} + +/** + * Stub global fetch to answer with `responses` in order (last one repeats). + * Returns the mock for call/argument assertions. + */ +function stubSequencedFetch(...responses: Response[]) { + let call = 0; + const fetchMock = vi.fn( + async (_url: string | URL | Request, _init?: RequestInit) => { + const response = responses[Math.min(call, responses.length - 1)]!; + call += 1; + return response; + }, + ); + vi.stubGlobal('fetch', fetchMock); + return fetchMock; +} + +/** Run linearRequest against one error response; return the thrown AxiError. */ +async function requestError(response: Response): Promise { + stubSequencedFetch(response); + return (await linearRequest(FAKE_KEY, QUERY).catch( + (e: unknown) => e, + )) as AxiError; +} + +function expectAxiError(err: AxiError, code: string, message: string) { + expect(err).toBeInstanceOf(AxiError); + expect(err.code).toBe(code); + expect(err.message).toBe(message); +} + +afterEach(() => { + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); +}); + +describe('request shape', () => { + it('POSTs the document to the Linear URL with the API key and variables', async () => { + const fetchMock = stubSequencedFetch( + gqlResponse({ data: { viewer: { id: 'u-1' } } }), + ); + const variables = { id: 'LIN-1', first: 25 }; + + await linearRequest(FAKE_KEY, QUERY, variables); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, init] = fetchMock.mock.calls[0]!; + expect(url).toBe(LINEAR_API_URL); + expect(init?.method).toBe('POST'); + expect(init?.headers).toEqual({ + 'Content-Type': 'application/json', + Authorization: FAKE_KEY, + }); + expect(init?.body).toBe(JSON.stringify({ query: QUERY, variables })); + }); + + it('defaults variables to an empty object when omitted', async () => { + const fetchMock = stubSequencedFetch( + gqlResponse({ data: { viewer: { id: 'u-1' } } }), + ); + + await linearRequest(FAKE_KEY, QUERY); + + expect(fetchMock.mock.calls[0]![1]?.body).toBe( + JSON.stringify({ query: QUERY, variables: {} }), + ); + }); +}); + +describe('HTTP status matrix (no GraphQL errors)', () => { + it('maps 400 to UNKNOWN with the status in the message', async () => { + const err = await requestError(httpError(400, { errors: [] })); + // errors: [] is an empty array — not GraphQL-level errors — so the plain + // 4xx fallback fires. + expectAxiError(err, 'UNKNOWN', 'Linear request failed (HTTP 400)'); + }); + + it('maps 400 with an unparseable body via the null-body fallback', async () => { + // The json() call throws -> body=null -> mapLinearError({status:400, + // body:null}) — same UNKNOWN mapping, proving the catch branch. + const err = await requestError(nonJsonError(400)); + expectAxiError(err, 'UNKNOWN', 'Linear request failed (HTTP 400)'); + }); + + it('maps a message-less 403 to FORBIDDEN', async () => { + const err = await requestError(httpError(403)); + expectAxiError( + err, + 'FORBIDDEN', + 'Insufficient Linear permissions for this action', + ); + }); + + it('maps a 403 whose GraphQL message says forbidden to AUTH_REQUIRED', async () => { + const err = await requestError( + httpError(403, { + errors: [{ message: 'Forbidden resource: scope missing' }], + }), + ); + // The forbidden-message branch overrides the generic 403 FORBIDDEN path. + expectAxiError( + err, + 'AUTH_REQUIRED', + 'Linear auth required — set LINEAR_API_KEY or pass --key ', + ); + expect(err.suggestions?.join(' ')).toContain('linear.app/settings/api'); + }); + + it('maps a plain 404 to NOT_FOUND', async () => { + const err = await requestError(httpError(404)); + expectAxiError(err, 'NOT_FOUND', 'Not found'); + }); + + it('maps 502 to NETWORK_ERROR like every 5xx', async () => { + const err = await requestError(httpError(502)); + expectAxiError( + err, + 'NETWORK_ERROR', + 'Linear returned HTTP 502 — transient server error', + ); + expect(err.suggestions).toEqual(['Retry in a few seconds']); + }); +}); + +describe('GraphQL errors[] matrix (HTTP 200)', () => { + it('maps a not-found message to NOT_FOUND with the message as text', async () => { + const err = await requestError( + gqlResponse({ errors: [{ message: 'Issue not found' }] }), + ); + expectAxiError(err, 'NOT_FOUND', 'Issue not found'); + }); + + it('maps an authorization message to AUTH_REQUIRED', async () => { + const err = await requestError( + gqlResponse({ errors: [{ message: 'User is unauthorized' }] }), + ); + expectAxiError(err, 'AUTH_REQUIRED', 'User is unauthorized'); + }); + + it('maps a validation message to VALIDATION_ERROR', async () => { + const err = await requestError( + gqlResponse({ errors: [{ message: 'Title is required' }] }), + ); + expectAxiError(err, 'VALIDATION_ERROR', 'Title is required'); + }); + + it('appends the path and extensions.code to the message text', async () => { + const err = await requestError( + gqlResponse({ + errors: [ + { + message: 'Boom', + path: ['issueCreate', 'issue'], + extensions: { code: 'BAD_USER_INPUT' }, + }, + ], + }), + ); + // path and extensions.code ride along for debugging query shapes. + expectAxiError( + err, + 'UNKNOWN', + 'Boom (at issueCreate.issue) [BAD_USER_INPUT]', + ); + }); + + it('dumps the raw extensions as a hint for unclassified errors', async () => { + const err = await requestError( + gqlResponse({ + errors: [{ message: 'Something odd', extensions: { userError: true } }], + }), + ); + expect(err.code).toBe('UNKNOWN'); + expect(err.suggestions).toEqual(['extensions: {"userError":true}']); + }); + + it('maps an unclassified message without extensions to UNKNOWN with no hints', async () => { + const err = await requestError( + gqlResponse({ errors: [{ message: 'Something odd' }] }), + ); + expect(err.code).toBe('UNKNOWN'); + expect(err.suggestions ?? []).toEqual([]); + }); + + it('ignores a non-array errors value and maps by status alone', async () => { + // hasGqlErrors requires Array.isArray(errors) — an errors OBJECT on a 200 + // must not be mistaken for GraphQL errors: the request succeeds and the + // data payload is returned untouched. + const viewer = { viewer: { id: 'u-1' } }; + const fetchMock = stubSequencedFetch( + gqlResponse({ data: viewer, errors: { message: 'x' } }), + ); + const data = await linearRequest(FAKE_KEY, QUERY); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(data).toEqual(viewer); + }); +}); + +describe('LINEAR_AXI_DEBUG error dump', () => { + it('prints query, variables, and raw body to stderr when set', async () => { + vi.stubEnv('LINEAR_AXI_DEBUG', '1'); + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + try { + const body = { errors: [{ message: 'Issue not found' }] }; + stubSequencedFetch({ ...httpError(404, body) } as Response); + const err = (await linearRequest(FAKE_KEY, QUERY, { id: 'LIN-1' }).catch( + (e: unknown) => e, + )) as AxiError; + + expect(err.code).toBe('NOT_FOUND'); + // Each dump is console.error(label, value) — join each call's args. + const lines = errorSpy.mock.calls.map((c) => c.join(' ')); + expect(lines).toEqual([ + `[linear-axi] query: ${QUERY}`, + '[linear-axi] variables: {"id":"LIN-1"}', + `[linear-axi] raw body: ${JSON.stringify(body)}`, + ]); + } finally { + errorSpy.mockRestore(); + } + }); +}); diff --git a/test/graphql-layer.test.ts b/test/graphql-layer.test.ts new file mode 100644 index 0000000..c3c555e --- /dev/null +++ b/test/graphql-layer.test.ts @@ -0,0 +1,617 @@ +// Transport-level tests for src/linear.ts (#29): call the GraphQL operation +// functions DIRECTLY against a stubbed global fetch and assert the exact +// document, variables, and resolver wiring — no command layer involved. +// +// This file is the GAP-FILL from the coverage audit, not a re-run of what the +// command-level suites already assert (all citations in the PR body): +// - fetchViewer / fetchTeams / deleteIssue documents had zero direct or +// transitive assertions (fetchTeams and deleteIssue had none at all). +// - resolveStateIdByName (query TeamStates) was never exercised. +// - createIssue's labelNames->labelIds branch (and resolveLabelIds through +// it), its team-not-found error, and ALL mutation success:false branches +// were untested. +// - updateIssue's omit-null builder was only asserted in partial +// combinations; here the minimal (title-only) and maximal +// (title+stateName+assigneeId+labelIds) forms pin what is omitted. +// - fetchIssues' anti-spin pagination guards (missing / non-advancing cursor, +// empty page) were untested (fetchLabels' empty-page guard is covered in +// labels.test.ts). +// +// Fetch-stub pattern per test/comment-update-delete.test.ts and +// test/rate-limit-retry.test.ts: vi.stubGlobal('fetch', ...) serving +// Response-like { ok, status, headers, json } objects, capturing each request +// body, with afterEach(vi.unstubAllGlobals). No network access. +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { + fetchViewer, + fetchTeams, + fetchIssue, + fetchIssues, + createIssue, + updateIssue, + deleteIssue, + createComment, + updateComment, + deleteComment, +} from '../src/linear.js'; +import { AxiError } from '../src/errors.js'; +import type { LinearIssue } from '../src/linear.js'; + +const FAKE_KEY = 'lin_api_fake'; + +function graphqlOk(data: unknown): Response { + return { + ok: true, + status: 200, + headers: new Headers(), + json: async () => ({ data }), + } as Response; +} + +interface CapturedRequest { + url: string; + body: { query: string; variables: Record }; +} + +/** + * Stub global fetch as a Linear GraphQL endpoint that routes each captured + * document through `route` and wraps its return value in a 200/data envelope. + * An unexpected document (route returning undefined) fails the stub. + */ +function stubLinearFetch(route: (body: CapturedRequest['body']) => unknown): { + requests: CapturedRequest[]; +} { + 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 }); + const data = route(body); + if (data === undefined) { + throw new Error(`Unexpected GraphQL document in stub: ${body.query}`); + } + return graphqlOk(data); + }); + vi.stubGlobal('fetch', fetchMock); + return { requests }; +} + +/** A detail-shaped issue node with a team id, as `query Issue(` returns it. */ +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: '2026-07-20T00:00:00.000Z', + updatedAt: '2026-08-19T00:00:00.000Z', + ...overrides, + }; +} + +/** + * Serve the standard resolver chain the write paths use: TeamByKey for team + * resolution, Labels for label resolution, Issue for ref pre-fetches, and + * TeamStates for stateName resolution — each overridable per test. + */ +function stubResolvers(options: { + teams?: Array<{ id: string; key: string }>; + states?: Array<{ id: string; name: string; type: string }>; + labels?: Array<{ id: string; name: string; color: string }>; + issue?: LinearIssue | null; + issueCreate?: { success: boolean }; + issueUpdate?: { success: boolean }; +}): { requests: CapturedRequest[] } { + return stubLinearFetch((body) => { + if (body.query.includes('query TeamByKey(')) { + return { + teams: { nodes: options.teams ?? [{ id: 'team-1', key: 'LIN' }] }, + }; + } + if (body.query.includes('query Labels(')) { + return { + issueLabels: { + nodes: options.labels ?? [], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }; + } + if (body.query.includes('query Issue(')) { + return { + issue: options.issue === undefined ? issueNode() : options.issue, + }; + } + if (body.query.includes('query TeamStates(')) { + return { + team: { + states: { + nodes: options.states ?? [ + { id: 'st-todo', name: 'Todo', type: 'unstarted' }, + { id: 'st-prog', name: 'In Progress', type: 'started' }, + { id: 'st-done', name: 'Done', type: 'completed' }, + ], + }, + }, + }; + } + if (body.query.includes('mutation CreateIssue(')) { + return { + issueCreate: options.issueCreate ?? { + success: true, + issue: issueNode({ id: 'ir-9', identifier: 'LIN-9' }), + }, + }; + } + if (body.query.includes('mutation UpdateIssue(')) { + return { + issueUpdate: options.issueUpdate ?? { + success: true, + issue: issueNode(), + }, + }; + } + return undefined; + }); +} + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +// --------------------------------------------------------------------------- +// Read queries with no direct/transitive document assertions before this file +// --------------------------------------------------------------------------- + +describe('fetchViewer', () => { + it('sends the exact viewer document and unwraps data.viewer', async () => { + const viewer = { id: 'u-1', name: 'Ada', email: 'ada@example.com' }; + const { requests } = stubLinearFetch(() => ({ viewer })); + + const result = await fetchViewer(FAKE_KEY); + + expect(requests).toHaveLength(1); + expect(requests[0]!.body.query).toBe('query { viewer { id name email } }'); + expect(requests[0]!.body.variables).toEqual({}); + expect(result).toEqual(viewer); + }); +}); + +describe('fetchTeams', () => { + it('sends the exact teams document and unwraps the nodes', async () => { + const teams = [ + { id: 'team-1', key: 'LIN', name: 'Linear' }, + { id: 'team-2', key: 'ENG', name: 'Engineering' }, + ]; + const { requests } = stubLinearFetch(() => ({ teams: { nodes: teams } })); + + const result = await fetchTeams(FAKE_KEY); + + expect(requests).toHaveLength(1); + expect(requests[0]!.body.query).toBe( + 'query Teams { teams { nodes { id key name } } }', + ); + expect(requests[0]!.body.variables).toEqual({}); + expect(result).toEqual(teams); + }); + + it('returns an empty list for a workspace with no teams', async () => { + stubLinearFetch(() => ({ teams: { nodes: [] } })); + + expect(await fetchTeams(FAKE_KEY)).toEqual([]); + }); +}); + +describe('fetchIssue (direct)', () => { + it('sends the detail selection with the ref as $id', async () => { + const { requests } = stubLinearFetch(() => ({ issue: issueNode() })); + + const issue = await fetchIssue(FAKE_KEY, 'CTZ-311'); + + expect(requests).toHaveLength(1); + const { query, variables } = requests[0]!.body; + expect(query).toContain('query Issue($id: String!) { issue(id: $id)'); + // ISSUE_DETAIL_FIELDS essentials ride along in the same document. + expect(query).toContain('labels { nodes { id name } }'); + expect(query).toContain('project { id name }'); + expect(query).toContain('cycle { id number }'); + // No children page without includeChildren (opt-in only, #26). + expect(query).not.toContain('children'); + expect(variables).toEqual({ id: 'CTZ-311' }); + expect(issue?.id).toBe('ir-1'); + }); + + it('maps a null issue to undefined', async () => { + stubLinearFetch(() => ({ issue: null })); + + expect(await fetchIssue(FAKE_KEY, 'LIN-404')).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// deleteIssue — zero coverage before this file +// --------------------------------------------------------------------------- + +describe('deleteIssue', () => { + it('sends the DeleteIssue mutation with the id and resolves on success', async () => { + const { requests } = stubLinearFetch(() => ({ + issueDelete: { success: true }, + })); + + await deleteIssue(FAKE_KEY, 'ir-1'); + + expect(requests).toHaveLength(1); + const { query, variables } = requests[0]!.body; + expect(query).toContain('mutation DeleteIssue($id: String!)'); + expect(query).toContain('issueDelete(id: $id) { success }'); + expect(variables).toEqual({ id: 'ir-1' }); + }); +}); + +// --------------------------------------------------------------------------- +// resolveStateIdByName (query TeamStates) — zero coverage before this file; +// reached through updateIssue's stateName path +// --------------------------------------------------------------------------- + +describe('stateName resolution in updateIssue', () => { + it('pre-fetches the issue and its team states, then sends stateId', async () => { + const { requests } = stubResolvers({}); + + await updateIssue(FAKE_KEY, 'LIN-1', { stateName: 'Done' }); + + expect(requests).toHaveLength(3); + expect(requests[0]!.body.query).toContain('query Issue('); + expect(requests[1]!.body.query).toContain('query TeamStates($id: String!)'); + expect(requests[1]!.body.query).toContain( + 'team(id: $id) { states { nodes { id name type } } }', + ); + // The team id from the pre-fetched issue, not the issue ref. + expect(requests[1]!.body.variables).toEqual({ id: 'team-1' }); + const mutation = requests[2]!.body; + expect(mutation.query).toContain('$stateId: String'); + expect(mutation.query).toContain('stateId: $stateId'); + expect(mutation.variables).toEqual({ id: 'LIN-1', stateId: 'st-done' }); + }); + + it('matches state names case-insensitively', async () => { + const { requests } = stubResolvers({}); + + await updateIssue(FAKE_KEY, 'LIN-1', { stateName: 'in progress' }); + + expect(requests[2]!.body.variables).toEqual({ + id: 'LIN-1', + stateId: 'st-prog', + }); + }); + + it('lists the available states when the name matches none', async () => { + const { requests } = stubResolvers({}); + + const err = (await updateIssue(FAKE_KEY, 'LIN-1', { + stateName: 'Bogus', + }).catch((e: unknown) => e)) as AxiError; + + expect(err).toBeInstanceOf(AxiError); + expect(err.code).toBe('VALIDATION_ERROR'); + expect(err.message).toContain('State "Bogus" not found for this team'); + expect(err.suggestions?.join(' ')).toContain( + 'Available states: Todo, In Progress, Done', + ); + // Only the two read documents fired — no mutation for an unmatched state. + expect(requests).toHaveLength(2); + }); + + it('fails when the issue cannot be resolved to a team', async () => { + stubResolvers({ issue: null }); + + const err = (await updateIssue(FAKE_KEY, 'LIN-404', { + stateName: 'Done', + }).catch((e: unknown) => e)) as AxiError; + + expect(err).toBeInstanceOf(AxiError); + expect(err.code).toBe('UNKNOWN'); + expect(err.message).toBe('Could not resolve team for issue'); + }); +}); + +// --------------------------------------------------------------------------- +// createIssue omit-null builder — the labelNames branch, team-not-found, and +// the minimal document were untested (description/projectId/cycleId/parentId +// variations are covered in description-file/project-cycle-assignment/ +// sub-issues tests) +// --------------------------------------------------------------------------- + +describe('createIssue omit-null input builder', () => { + it('sends only teamId and title for a minimal create', async () => { + const { requests } = stubResolvers({}); + + await createIssue(FAKE_KEY, { title: 'New bug', teamKey: 'LIN' }); + + const mutation = requests[1]!.body; + expect(mutation.query).toContain( + 'mutation CreateIssue($teamId: String!, $title: String!)', + ); + expect(mutation.query).toContain( + 'issueCreate(input: { teamId: $teamId, title: $title })', + ); + // Nothing else is declared or sent when unset. + expect(mutation.query).not.toContain('description'); + expect(mutation.query).not.toContain('labelIds'); + expect(mutation.query).not.toContain('projectId'); + expect(mutation.query).not.toContain('cycleId'); + expect(mutation.query).not.toContain('parentId'); + expect(mutation.variables).toEqual({ teamId: 'team-1', title: 'New bug' }); + }); + + it('resolves label names to ids via fetchLabels and sends labelIds', async () => { + const { requests } = stubResolvers({ + labels: [ + { id: 'lb-bug', name: 'Bug', color: '#EB5757' }, + { id: 'lb-feat', name: 'Feature', color: '#4CB782' }, + { id: 'lb-chore', name: 'Chore', color: '#95A2B3' }, + ], + }); + + // Mixed case and an unknown name: case-insensitive match in server order, + // unknowns silently skipped (resolveLabelIds semantics, #29 gap). + await createIssue(FAKE_KEY, { + title: 'New bug', + teamKey: 'LIN', + labelNames: ['BUG', 'Nope', 'feature'], + }); + + expect(requests[1]!.body.query).toContain('query Labels('); + const mutation = requests[2]!.body; + expect(mutation.query).toContain('$labelIds: [String!]'); + expect(mutation.query).toContain('labelIds: $labelIds'); + expect(mutation.variables).toEqual({ + teamId: 'team-1', + title: 'New bug', + labelIds: ['lb-bug', 'lb-feat'], + }); + }); + + it('fails loud when the team key matches no team', async () => { + const { requests } = stubResolvers({ teams: [] }); + + const err = (await createIssue(FAKE_KEY, { + title: 'x', + teamKey: 'NOPE', + }).catch((e: unknown) => e)) as AxiError; + + expect(err).toBeInstanceOf(AxiError); + expect(err.code).toBe('VALIDATION_ERROR'); + expect(err.message).toBe('Team "NOPE" not found in your workspace'); + expect(err.suggestions?.join(' ')).toContain('linear-axi teams'); + // Only the team resolution fired — no mutation without a team id. + expect(requests).toHaveLength(1); + }); +}); + +// --------------------------------------------------------------------------- +// updateIssue omit-null builder — minimal and maximal combinations +// (assigneeId / labelIds / labelIds:[] / projectId / cycleId / description +// variations are covered in issue-update-assignee-labels / +// project-cycle-assignment / description-file tests) +// --------------------------------------------------------------------------- + +describe('updateIssue omit-null input builder', () => { + it('sends only id and title for a title-only update', async () => { + const { requests } = stubResolvers({}); + + await updateIssue(FAKE_KEY, 'LIN-1', { title: 'Renamed' }); + + expect(requests).toHaveLength(1); + const { query, variables } = requests[0]!.body; + expect(query).toContain( + 'mutation UpdateIssue($id: String!, $title: String)', + ); + expect(query).toContain('issueUpdate(id: $id, input: { title: $title })'); + expect(query).not.toContain('description'); + expect(query).not.toContain('priority'); + expect(query).not.toContain('stateId'); + expect(query).not.toContain('assigneeId'); + expect(query).not.toContain('labelIds'); + expect(query).not.toContain('projectId'); + expect(query).not.toContain('cycleId'); + expect(variables).toEqual({ id: 'LIN-1', title: 'Renamed' }); + }); + + it('declares every set field and omits the unset ones in a full update', async () => { + const { requests } = stubResolvers({}); + + await updateIssue(FAKE_KEY, 'LIN-1', { + title: 'Renamed', + stateName: 'Done', + assigneeId: 'user-ada', + labelIds: ['lb-bug', 'lb-feat'], + }); + + const mutation = requests[2]!.body; + // Declaration and input order follow the builder: title, stateId, + // assigneeId, labelIds — and nothing else. + expect(mutation.query).toContain( + 'mutation UpdateIssue($id: String!, $title: String, ' + + '$stateId: String, $assigneeId: String, $labelIds: [String!])', + ); + expect(mutation.query).toContain( + 'issueUpdate(id: $id, input: { title: $title, stateId: $stateId, ' + + 'assigneeId: $assigneeId, labelIds: $labelIds })', + ); + expect(mutation.query).not.toContain('description'); + expect(mutation.query).not.toContain('priority'); + expect(mutation.query).not.toContain('projectId'); + expect(mutation.query).not.toContain('cycleId'); + expect(mutation.variables).toEqual({ + id: 'LIN-1', + title: 'Renamed', + stateId: 'st-done', + assigneeId: 'user-ada', + labelIds: ['lb-bug', 'lb-feat'], + }); + }); +}); + +// --------------------------------------------------------------------------- +// Mutation success:false payload branches — zero coverage before this file +// (every command-level suite stubs success: true only) +// --------------------------------------------------------------------------- + +describe('mutation success:false payloads', () => { + it('createIssue throws UNKNOWN when Linear reports success: false', async () => { + stubResolvers({ issueCreate: { success: false } }); + + const err = (await createIssue(FAKE_KEY, { + title: 'x', + teamKey: 'LIN', + }).catch((e: unknown) => e)) as AxiError; + + expect(err).toBeInstanceOf(AxiError); + expect(err.message).toBe('Linear rejected the issue create'); + expect(err.code).toBe('UNKNOWN'); + }); + + it('updateIssue throws UNKNOWN when Linear reports success: false', async () => { + stubResolvers({ issueUpdate: { success: false } }); + + const err = (await updateIssue(FAKE_KEY, 'LIN-1', { title: 'x' }).catch( + (e: unknown) => e, + )) as AxiError; + + expect(err).toBeInstanceOf(AxiError); + expect(err.message).toBe('Linear rejected the issue update'); + expect(err.code).toBe('UNKNOWN'); + }); + + it('deleteIssue throws UNKNOWN when Linear reports success: false', async () => { + stubLinearFetch(() => ({ issueDelete: { success: false } })); + + const err = (await deleteIssue(FAKE_KEY, 'ir-1').catch( + (e: unknown) => e, + )) as AxiError; + + expect(err).toBeInstanceOf(AxiError); + expect(err.message).toBe('Linear rejected the issue delete'); + expect(err.code).toBe('UNKNOWN'); + }); + + it('createComment throws UNKNOWN when Linear reports success: false', async () => { + stubLinearFetch(() => ({ commentCreate: { success: false } })); + + const err = (await createComment(FAKE_KEY, 'ir-1', 'hi').catch( + (e: unknown) => e, + )) as AxiError; + + expect(err).toBeInstanceOf(AxiError); + expect(err.message).toBe('Linear rejected the comment'); + expect(err.code).toBe('UNKNOWN'); + }); + + it('updateComment throws UNKNOWN when Linear reports success: false', async () => { + stubLinearFetch(() => ({ commentUpdate: { success: false } })); + + const err = (await updateComment(FAKE_KEY, 'cm-1', 'hi').catch( + (e: unknown) => e, + )) as AxiError; + + expect(err).toBeInstanceOf(AxiError); + expect(err.message).toBe('Linear rejected the comment update'); + expect(err.code).toBe('UNKNOWN'); + }); + + it('deleteComment throws UNKNOWN when Linear reports success: false', async () => { + stubLinearFetch(() => ({ commentDelete: { success: false } })); + + const err = (await deleteComment(FAKE_KEY, 'cm-1').catch( + (e: unknown) => e, + )) as AxiError; + + expect(err).toBeInstanceOf(AxiError); + expect(err.message).toBe('Linear rejected the comment delete'); + expect(err.code).toBe('UNKNOWN'); + }); +}); + +// --------------------------------------------------------------------------- +// fetchIssues anti-spin pagination guards — cursor batching itself is covered +// directly in issues-pagination.test.ts (50-size batches, remaining-slot +// `first`); these pin the three guards against a misbehaving server. +// --------------------------------------------------------------------------- + +describe('fetchIssues pagination guards', () => { + function listNode(n: number): LinearIssue { + return { + id: `ir-${n}`, + identifier: `LIN-${n}`, + title: `Issue ${n}`, + state: { name: 'Started', type: 'started' }, + priority: 2, + assignee: { name: 'Ada' }, + team: { key: 'LIN' }, + updatedAt: '2026-08-19T00:00:00.000Z', + }; + } + + it('stops without a follow-up when hasNextPage is true but endCursor is null', async () => { + const { requests } = stubLinearFetch(() => ({ + issues: { + nodes: [listNode(1), listNode(2)], + pageInfo: { hasNextPage: true, endCursor: null }, + }, + })); + + const { issues, hasMore } = await fetchIssues(FAKE_KEY, {}, 100); + + expect(requests).toHaveLength(1); + expect(issues).toHaveLength(2); + // The server claimed more pages, so the truncation flag stays honest. + expect(hasMore).toBe(true); + }); + + it('stops when the server repeats the same endCursor (non-advancing)', async () => { + // Every page — with or without an `after` — answers with the SAME cursor. + const { requests } = stubLinearFetch(() => ({ + issues: { + nodes: [listNode(1)], + pageInfo: { hasNextPage: true, endCursor: 'stuck' }, + }, + })); + + const { issues, hasMore } = await fetchIssues(FAKE_KEY, {}, 100); + + // Page 1 (no after) and page 2 (after: stuck) both return cursor "stuck" + // — the guard breaks the loop instead of paging forever. + expect(requests).toHaveLength(2); + expect(requests[1]!.body.variables).toEqual({ first: 50, after: 'stuck' }); + expect(issues).toHaveLength(2); + expect(hasMore).toBe(true); + }); + + it('stops on an empty page that still claims hasNextPage', async () => { + const { requests } = stubLinearFetch((body) => + body.variables['after'] === undefined + ? { + issues: { + nodes: [listNode(1)], + pageInfo: { hasNextPage: true, endCursor: 'c1' }, + }, + } + : { + issues: { + nodes: [], + pageInfo: { hasNextPage: true, endCursor: 'c2' }, + }, + }, + ); + + const { issues, hasMore } = await fetchIssues(FAKE_KEY, {}, 100); + + expect(requests).toHaveLength(2); + expect(issues).toHaveLength(1); + expect(hasMore).toBe(true); + }); +}); From d4c2c002d3d5f699bd3e9b1059638d61852874be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sigrid=20Nystr=C3=B6m?= Date: Thu, 20 Aug 2026 09:53:57 +0530 Subject: [PATCH 08/12] fix: reject invalid successful Linear responses --- src/linear.ts | 10 ++++++++++ test/graphql-layer-errors.test.ts | 18 ++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/src/linear.ts b/src/linear.ts index ce41691..ec986d3 100644 --- a/src/linear.ts +++ b/src/linear.ts @@ -100,6 +100,16 @@ export async function linearRequest( throw mapLinearError({ status: response.status, body }); } + if ( + !body || + typeof body !== 'object' || + !Object.prototype.hasOwnProperty.call(body, 'data') + ) { + throw new AxiError('Linear returned an invalid response', 'UNKNOWN', [ + 'Retry in a few seconds', + ]); + } + return (body as { data: T }).data; } diff --git a/test/graphql-layer-errors.test.ts b/test/graphql-layer-errors.test.ts index f1f14f1..31eba8d 100644 --- a/test/graphql-layer-errors.test.ts +++ b/test/graphql-layer-errors.test.ts @@ -52,6 +52,18 @@ function nonJsonError(status: number): Response { } as Response; } +/** A successful HTTP response whose body cannot be decoded as JSON. */ +function nonJsonSuccess(): Response { + return { + ok: true, + status: 200, + headers: new Headers(), + json: async () => { + throw new SyntaxError('Unexpected token < in JSON'); + }, + } as Response; +} + /** * Stub global fetch to answer with `responses` in order (last one repeats). * Returns the mock for call/argument assertions. @@ -122,6 +134,12 @@ describe('request shape', () => { }); describe('HTTP status matrix (no GraphQL errors)', () => { + it('maps an invalid 200 response to a structured error', async () => { + const err = await requestError(nonJsonSuccess()); + expectAxiError(err, 'UNKNOWN', 'Linear returned an invalid response'); + expect(err.suggestions).toEqual(['Retry in a few seconds']); + }); + it('maps 400 to UNKNOWN with the status in the message', async () => { const err = await requestError(httpError(400, { errors: [] })); // errors: [] is an empty array — not GraphQL-level errors — so the plain From 0035ff75758be9aba1827776ddb8f5f454c157e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sigrid=20Nystr=C3=B6m?= Date: Thu, 20 Aug 2026 09:56:53 +0530 Subject: [PATCH 09/12] fix: preserve combined issue updates and strict CLI validation --- src/commands/issue.ts | 23 +++++++------- test/issue-update-assignee-labels.test.ts | 38 +++++++++++++++++++++++ 2 files changed, 49 insertions(+), 12 deletions(-) diff --git a/src/commands/issue.ts b/src/commands/issue.ts index 9cc4ebe..0c5d475 100644 --- a/src/commands/issue.ts +++ b/src/commands/issue.ts @@ -506,15 +506,14 @@ async function updateIssueCmd( throw new AxiError(`Issue "${refRaw}" not found`, 'NOT_FOUND'); } - // Idempotency: if a requested state already matches, treat as no-op. - if ( + // Idempotency: if a requested state already matches, treat only that field + // as a no-op. Other requested fields in the same update must still be + // applied. + const stateUpdate = stateName && existing.state?.name?.toLowerCase() === stateName.toLowerCase() - ) { - return renderOutput([ - `issue: ${existing.identifier} already "${stateName}" (no-op)`, - ]); - } + ? undefined + : stateName; // Assignee (#24): "me" resolves to the viewer — fetchViewer already // returns the id the mutation needs, so no users() round trip. Any other @@ -623,7 +622,7 @@ async function updateIssueCmd( const hasPatch = title !== undefined || descriptionInput !== undefined || - stateName !== undefined || + stateUpdate !== undefined || priority !== undefined || assigneeId !== undefined || labelIds !== undefined || @@ -638,7 +637,7 @@ async function updateIssueCmd( const updated = await updateIssue(apiKey, existing.id, { title, description: descriptionInput, - stateName, + stateName: stateUpdate, priority, assigneeId, labelIds, @@ -665,6 +664,7 @@ async function deleteIssueCmd( args: string[], ctx: LinearContext, ): Promise { + assertKnownFlags(args, []); const apiKey = requireKey(ctx.apiKey); const refRaw = getPositional(args); if (!refRaw) { @@ -753,14 +753,13 @@ export function readDescriptionFile( } function parsePriority(raw: string): number { - const n = parseInt(raw, 10); - if (isNaN(n) || n < 0 || n > 4) { + if (!/^[0-4]$/.test(raw)) { throw new AxiError( `Invalid --priority: ${raw} (use 0-4)`, 'VALIDATION_ERROR', ); } - return n; + return Number(raw); } /** diff --git a/test/issue-update-assignee-labels.test.ts b/test/issue-update-assignee-labels.test.ts index e6ae0cc..bc79426 100644 --- a/test/issue-update-assignee-labels.test.ts +++ b/test/issue-update-assignee-labels.test.ts @@ -340,6 +340,44 @@ describe('issue update --label / --remove-label', () => { }); describe('issue update acceptance and guards', () => { + it('applies other changes when the requested state is already current', async () => { + const { requests } = stubUpdateFetch({ + issue: issueNode({ state: { name: 'Todo', type: 'unstarted' } }), + }); + + const out = await issueCommand( + ['update', 'LIN-1', '--state', 'Todo', '--title', 'Renamed'], + { apiKey: FAKE_KEY }, + ); + + expect(mutationRequests(requests)).toHaveLength(1); + expect(mutationRequests(requests)[0]?.body.variables).toEqual({ + id: 'ir-1', + title: 'Renamed', + }); + expect(out).toContain('updated: LIN-1'); + }); + + it('rejects a priority with a non-numeric suffix', async () => { + const { requests } = stubUpdateFetch(); + + await expect( + issueCommand(['update', 'LIN-1', '--priority', '2junk'], { + apiKey: FAKE_KEY, + }), + ).rejects.toThrow(/Invalid --priority/); + expect(requests).toHaveLength(0); + }); + + it('rejects unknown flags on issue delete', async () => { + const { requests } = stubUpdateFetch(); + + await expect( + issueCommand(['delete', 'LIN-1', '--bogus'], { apiKey: FAKE_KEY }), + ).rejects.toThrow(/unknown flag --bogus/); + expect(requests).toHaveLength(0); + }); + it('applies --assignee me --label bug together (acceptance case)', async () => { const { requests } = stubUpdateFetch(); From d33c3c150e20c221159d916947bc7ca6e04b84ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sigrid=20Nystr=C3=B6m?= Date: Thu, 20 Aug 2026 09:57:21 +0530 Subject: [PATCH 10/12] fix: handle documented Linear rate limits --- src/errors.ts | 5 ++- src/linear.ts | 84 ++++++++++++++++++++++++++--------- test/rate-limit-retry.test.ts | 62 ++++++++++++++++++++++++++ 3 files changed, 128 insertions(+), 23 deletions(-) diff --git a/src/errors.ts b/src/errors.ts index e3ff1a5..4a33026 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -55,7 +55,10 @@ export function mapLinearError(result: LinearQueryResult): AxiError { ); } - if (status === 429) { + if ( + status === 429 || + gqlErrors.some((error) => error.extensions?.code === 'RATELIMITED') + ) { return new AxiError('Linear rate limit hit — wait and retry', 'RATE_LIMITED', [ 'Wait ~60s before retrying', ]); diff --git a/src/linear.ts b/src/linear.ts index ec986d3..1335440 100644 --- a/src/linear.ts +++ b/src/linear.ts @@ -7,15 +7,15 @@ export const LINEAR_API_URL = 'https://api.linear.app/graphql'; // --------------------------------------------------------------------------- /** - * On HTTP 429, linearRequest re-sends the request up to this many times (3 - * attempts total) before surfacing mapLinearError's structured RATE_LIMITED - * error — the retry is transport-level and bounded, so no caller or flag - * knows it exists. + * On a Linear rate-limit response, linearRequest re-sends the request up to + * this many times (3 attempts total) before surfacing mapLinearError's + * structured RATE_LIMITED error — the retry is transport-level and bounded, + * so no caller or flag knows it exists. */ const RATE_LIMIT_MAX_RETRIES = 2; -/** An honored Retry-After is clamped into [1s, 60s] so a hostile/garbage - * header cannot stall the CLI. */ +/** A retry delay is clamped into [1s, 60s] so a hostile/garbage header cannot + * stall the CLI. */ const RETRY_DELAY_MIN_MS = 1_000; const RETRY_DELAY_MAX_MS = 60_000; @@ -24,15 +24,32 @@ function sleep(ms: number): Promise { } /** - * How long to wait before retry attempt `retry` (0-based) after a 429: the - * response's `Retry-After` header when it parses as positive delay-seconds - * (clamped to [1s, 60s]), else a bounded exponential fallback (1s, 2s, ...). + * How long to wait before retry attempt `retry` (0-based) after a rate limit. + * Linear documents its `X-RateLimit-*-Reset` headers as UTC epoch milliseconds, + * so use the most specific available reset time first. `Retry-After` remains a + * defensive fallback for intermediaries that return HTTP 429. All values are + * clamped to [1s, 60s], otherwise a bounded exponential fallback is used. * * mapLinearError derives RATE_LIMITED from the HTTP status alone, so HTTP 429 * is the only retry signal here — a GraphQL-level error (HTTP 200 + `errors`) * is never rate-limit-typed by mapLinearError and is never retried. */ function rateLimitRetryDelayMs(response: Response, retry: number): number { + const resets = [ + 'x-ratelimit-endpoint-requests-reset', + 'x-ratelimit-requests-reset', + 'x-ratelimit-complexity-reset', + ]; + for (const name of resets) { + const resetAt = Number(response.headers?.get?.(name)); + if (Number.isFinite(resetAt) && resetAt > Date.now()) { + return Math.min( + Math.max(resetAt - Date.now(), RETRY_DELAY_MIN_MS), + RETRY_DELAY_MAX_MS, + ); + } + } + const raw = response.headers?.get?.('retry-after'); const seconds = Number(raw); if (Number.isFinite(seconds) && seconds > 0) { @@ -41,19 +58,37 @@ function rateLimitRetryDelayMs(response: Response, retry: number): number { RETRY_DELAY_MAX_MS, ); } - // Missing/invalid Retry-After (null, "", "0", HTTP-date form, garbage). + // Missing/invalid reset and Retry-After values (null, "", "0", HTTP-date + // form, garbage). return Math.min(RETRY_DELAY_MIN_MS * 2 ** retry, RETRY_DELAY_MAX_MS); } +function isRateLimitedResponse(status: number, body: unknown): boolean { + if (status === 429) return true; + if (!body || typeof body !== 'object') return false; + const errors = (body as { errors?: unknown }).errors; + return ( + Array.isArray(errors) && + errors.some( + (error) => + typeof error === 'object' && + error !== null && + (error as { extensions?: { code?: unknown } }).extensions?.code === + 'RATELIMITED', + ) + ); +} + /** * Raw transport: POST a GraphQL document to Linear with the API key as the * Authorization header. Returns the parsed `data` on success; throws an AxiError * (translated from HTTP/GraphQL status) otherwise. * - * HTTP 429 responses are retried with bounded backoff honoring Retry-After - * (see rateLimitRetryDelayMs); once retries are exhausted — or a non-429 - * response arrives, including a non-429 after a 429 — the error path below - * surfaces mapLinearError's structured error unchanged. + * Linear documents rate-limit errors as HTTP 400 plus a GraphQL + * `extensions.code` of `RATELIMITED`; HTTP 429 is also handled defensively. + * Those responses are retried with bounded backoff (see + * rateLimitRetryDelayMs). Once retries are exhausted — or another response + * arrives — the error path below surfaces mapLinearError's structured error. * * Set LINEAR_AXI_DEBUG=1 to dump the raw response body to stderr on error, * which makes query-shape mistakes debuggable in one round trip. @@ -64,6 +99,7 @@ export async function linearRequest( variables: Record = {}, ): Promise { let response: Response; + let body: unknown; for (let retry = 0; ; retry++) { try { response = await fetch(LINEAR_API_URL, { @@ -77,15 +113,19 @@ export async function linearRequest( } catch (cause) { throw networkError(cause); } - if (response.status !== 429 || retry >= RATE_LIMIT_MAX_RETRIES) break; - await sleep(rateLimitRetryDelayMs(response, retry)); - } + try { + body = await response.json(); + } catch { + body = null; + } - let body: unknown; - try { - body = await response.json(); - } catch { - body = null; + if ( + !isRateLimitedResponse(response.status, body) || + retry >= RATE_LIMIT_MAX_RETRIES + ) { + break; + } + await sleep(rateLimitRetryDelayMs(response, retry)); } const withErrors = body as { errors?: unknown } | null; diff --git a/test/rate-limit-retry.test.ts b/test/rate-limit-retry.test.ts index 18231bc..f9c7d21 100644 --- a/test/rate-limit-retry.test.ts +++ b/test/rate-limit-retry.test.ts @@ -44,6 +44,22 @@ function rateLimitedResponse(retryAfterSeconds?: number): Response { } as Response; } +/** Linear documents rate limits as HTTP 400 plus this GraphQL error code. */ +function linearRateLimitedResponse(resetAt?: number): Response { + const headers = new Headers(); + if (resetAt !== undefined) { + headers.set('x-ratelimit-requests-reset', String(resetAt)); + } + return { + ok: false, + status: 400, + headers, + json: async () => ({ + errors: [{ message: 'Rate limit exceeded', extensions: { code: 'RATELIMITED' } }], + }), + } as Response; +} + /** A non-429 HTTP failure (401/500/...) with a Headers-like headers object. */ function httpErrorResponse(status: number): Response { return { @@ -137,6 +153,52 @@ describe('429 then success (Retry-After honored)', () => { }); }); +describe('documented Linear GraphQL rate limits', () => { + it('retries HTTP 400 with the RATELIMITED GraphQL code', async () => { + vi.useFakeTimers({ toFake: ['setTimeout'] }); + const fetchMock = stubSequencedFetch( + linearRateLimitedResponse(), + okResponse(VIEWER), + ); + + const pending = linearRequest(FAKE_KEY, QUERY); + await vi.advanceTimersByTimeAsync(1_000); + await expect(pending).resolves.toEqual(VIEWER); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it('honors Linear’s documented request-reset header', async () => { + vi.useFakeTimers({ toFake: ['setTimeout'] }); + const fetchMock = stubSequencedFetch( + linearRateLimitedResponse(Date.now() + 2_000), + okResponse(VIEWER), + ); + + const pending = linearRequest(FAKE_KEY, QUERY); + await vi.advanceTimersByTimeAsync(1_999); + expect(fetchMock).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(1); + await expect(pending).resolves.toEqual(VIEWER); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it('maps exhausted documented rate limits to RATE_LIMITED', async () => { + vi.useFakeTimers({ toFake: ['setTimeout'] }); + const fetchMock = stubSequencedFetch( + linearRateLimitedResponse(), + linearRateLimitedResponse(), + linearRateLimitedResponse(), + ); + + const pending = linearRequest(FAKE_KEY, QUERY).catch((e: unknown) => e); + await vi.advanceTimersByTimeAsync(3_000); + const err = (await pending) as AxiError; + + expect(fetchMock).toHaveBeenCalledTimes(3); + expect(err.code).toBe('RATE_LIMITED'); + }); +}); + describe('429 without a usable Retry-After (exponential fallback)', () => { it('falls back to 1s then 2s when the header is missing', async () => { vi.useFakeTimers({ toFake: ['setTimeout'] }); From 6c8ea4aaa55d3c014562a7b1c5bad4c04bcacebe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sigrid=20Nystr=C3=B6m?= Date: Thu, 20 Aug 2026 10:35:01 +0530 Subject: [PATCH 11/12] fix: fail loud for comment dispatch and list commands --- src/commands/comment.ts | 20 +++++++++++++++++++- src/commands/issues.ts | 10 ++++++++-- src/commands/labels.ts | 5 +++++ src/commands/projects.ts | 5 +++++ src/linear.ts | 3 --- test/comment-list.test.ts | 16 ++++++++++++++++ test/comment-update-delete.test.ts | 26 ++++++++++++++++++++++++++ test/cycles.test.ts | 3 +++ test/labels.test.ts | 6 ++++++ test/projects.test.ts | 9 +++++++++ 10 files changed, 97 insertions(+), 6 deletions(-) diff --git a/src/commands/comment.ts b/src/commands/comment.ts index c4e3027..70c9ed8 100644 --- a/src/commands/comment.ts +++ b/src/commands/comment.ts @@ -79,7 +79,10 @@ export async function commentCommand( // 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); + // Values for create/update body flags are positional-looking strings too. + // Skip them while dispatching so `comment --body list LIN-1` stays on the + // create path instead of being mistaken for `comment list ...`. + const sub = getDispatchPositional(args); if (sub === 'list') { return listComments(args.slice(1), ctx); } @@ -92,6 +95,21 @@ export async function commentCommand( return createCommentCmd(args, ctx); } +function getDispatchPositional(args: string[]): string | undefined { + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + if (arg === '--body' || arg === '--body-file') { + i++; + continue; + } + if (arg.startsWith('--body=') || arg.startsWith('--body-file=')) { + continue; + } + if (!arg.startsWith('--')) return arg; + } + return undefined; +} + async function listComments( args: string[], ctx: LinearContext, diff --git a/src/commands/issues.ts b/src/commands/issues.ts index 7492251..0c6eec1 100644 --- a/src/commands/issues.ts +++ b/src/commands/issues.ts @@ -108,7 +108,10 @@ export async function issuesCommand( } const hasProject = args.some((a) => a === '--project' || a.startsWith('--project=')); const project = takeFlag(args, '--project'); - if (hasProject && (project === undefined || project.trim() === '')) { + if ( + hasProject && + (project === undefined || project.trim() === '' || project.startsWith('--')) + ) { // Same guard as --search: a missing value must fail loud, not drop the filter. throw new AxiError('--project requires a value', 'VALIDATION_ERROR', [ 'e.g. --project "Mobile app"', @@ -116,7 +119,10 @@ export async function issuesCommand( } const hasCycle = args.some((a) => a === '--cycle' || a.startsWith('--cycle=')); const cycle = takeFlag(args, '--cycle'); - if (hasCycle && (cycle === undefined || cycle.trim() === '')) { + if ( + hasCycle && + (cycle === undefined || cycle.trim() === '' || cycle.startsWith('--')) + ) { throw new AxiError('--cycle requires a value', 'VALIDATION_ERROR', [ 'Use --cycle current for the active cycle, or --cycle --team ', ]); diff --git a/src/commands/labels.ts b/src/commands/labels.ts index ad7e5d9..1712b63 100644 --- a/src/commands/labels.ts +++ b/src/commands/labels.ts @@ -1,6 +1,7 @@ import type { LinearContext } from '../context.js'; import { requireKey } from '../config.js'; import { assertKnownFlags } from '../args.js'; +import { AxiError } from '../errors.js'; import { fetchLabels } from '../linear.js'; import { field, renderList, renderHelp, renderOutput } from '../toon.js'; import { formatCountLine } from '../format.js'; @@ -26,6 +27,10 @@ export async function labelsCommand( ): Promise { // `labels` takes no flags — reject any rather than silently dropping it. assertKnownFlags(args, []); + const positional = args.find((arg) => !arg.startsWith('--')); + if (positional !== undefined) { + throw new AxiError(`Unexpected argument: ${positional}`, 'VALIDATION_ERROR'); + } const apiKey = requireKey(ctx.apiKey); const { labels, hasMore } = await fetchLabels(apiKey); diff --git a/src/commands/projects.ts b/src/commands/projects.ts index 7b6e4d0..317b9a4 100644 --- a/src/commands/projects.ts +++ b/src/commands/projects.ts @@ -1,6 +1,7 @@ import type { LinearContext } from '../context.js'; import { requireKey } from '../config.js'; import { assertKnownFlags } from '../args.js'; +import { AxiError } from '../errors.js'; import { fetchProjects } from '../linear.js'; import { field, @@ -43,6 +44,10 @@ export async function projectsCommand( ): Promise { // `projects` takes no flags — reject any rather than silently dropping it. assertKnownFlags(args, []); + const positional = args.find((arg) => !arg.startsWith('--')); + if (positional !== undefined) { + throw new AxiError(`Unexpected argument: ${positional}`, 'VALIDATION_ERROR'); + } const apiKey = requireKey(ctx.apiKey); const { projects, hasMore } = await fetchProjects(apiKey); diff --git a/src/linear.ts b/src/linear.ts index 1335440..010d818 100644 --- a/src/linear.ts +++ b/src/linear.ts @@ -30,9 +30,6 @@ function sleep(ms: number): Promise { * defensive fallback for intermediaries that return HTTP 429. All values are * clamped to [1s, 60s], otherwise a bounded exponential fallback is used. * - * mapLinearError derives RATE_LIMITED from the HTTP status alone, so HTTP 429 - * is the only retry signal here — a GraphQL-level error (HTTP 200 + `errors`) - * is never rate-limit-typed by mapLinearError and is never retried. */ function rateLimitRetryDelayMs(response: Response, retry: number): number { const resets = [ diff --git a/test/comment-list.test.ts b/test/comment-list.test.ts index 34ae1af..2aba627 100644 --- a/test/comment-list.test.ts +++ b/test/comment-list.test.ts @@ -134,6 +134,22 @@ describe('comment dispatch', () => { // The create path must not leak list output. expect(out).not.toContain('comments[0]'); }); + + it('does not treat a --body value as a subcommand', async () => { + const { requests } = stubLinearFetch({ nodes: [] }); + + const out = await commentCommand(['--body', 'list', 'LIN-1'], { + apiKey: FAKE_KEY, + }); + + expect(requests[0].body.query).toContain('query Issue('); + expect(requests[1].body.query).toContain('mutation Comment('); + expect(requests[1].body.variables).toEqual({ + issueId: 'ir-1', + body: 'list', + }); + expect(out).toContain('comment: added to LIN-1'); + }); }); describe('comment list document', () => { diff --git a/test/comment-update-delete.test.ts b/test/comment-update-delete.test.ts index 10bd2f0..b3dc7ec 100644 --- a/test/comment-update-delete.test.ts +++ b/test/comment-update-delete.test.ts @@ -328,6 +328,32 @@ describe('comment dispatch (backward compat + reserved words)', () => { expect(out).toContain('comment: added to LIN-1'); }); + it('does not treat a --body-file value as a subcommand', async () => { + const tmpDir = mkdtempSync(join(tmpdir(), 'axi-comment-dispatch-')); + const previousCwd = process.cwd(); + try { + process.chdir(tmpDir); + writeFileSync('update', 'Edited via file', 'utf-8'); + const { requests } = stubLinearFetch({}); + + const out = await commentCommand( + ['--body-file', 'update', 'LIN-1'], + { apiKey: FAKE_KEY }, + ); + + expect(requests[0].body.query).toContain('query Issue('); + expect(requests[1].body.query).toContain('mutation Comment('); + expect(requests[1].body.variables).toEqual({ + issueId: 'ir-1', + body: 'Edited via file', + }); + expect(out).toContain('comment: added to LIN-1'); + } finally { + process.chdir(previousCwd); + rmSync(tmpDir, { recursive: true, force: true }); + } + }); + it('documents the new subcommands and the reserved-words note', () => { expect(COMMENT_HELP).toContain('comment update --body'); expect(COMMENT_HELP).toContain('comment delete '); diff --git a/test/cycles.test.ts b/test/cycles.test.ts index 339ec5a..0ec03eb 100644 --- a/test/cycles.test.ts +++ b/test/cycles.test.ts @@ -354,6 +354,9 @@ describe('issues --cycle filter', () => { await expect( issuesCommand(['--cycle='], { apiKey: FAKE_KEY }), ).rejects.toThrow(/--cycle requires a value/); + await expect( + issuesCommand(['--cycle', '--limit', '25'], { apiKey: FAKE_KEY }), + ).rejects.toThrow(/--cycle requires a value/); }); it('composes --cycle with --search in the searchIssues query', async () => { diff --git a/test/labels.test.ts b/test/labels.test.ts index 2b24c1a..bb3172f 100644 --- a/test/labels.test.ts +++ b/test/labels.test.ts @@ -220,6 +220,12 @@ describe('labels failures', () => { labelsCommand(['--bogus'], { apiKey: FAKE_KEY }), ).rejects.toThrow(/unknown flag --bogus/); }); + + it('rejects positional arguments loudly', async () => { + await expect(labelsCommand(['extra'], { apiKey: FAKE_KEY })).rejects.toThrow( + /Unexpected argument: extra/, + ); + }); }); describe('label resolution reuse', () => { diff --git a/test/projects.test.ts b/test/projects.test.ts index 73a8de0..3554a2c 100644 --- a/test/projects.test.ts +++ b/test/projects.test.ts @@ -194,6 +194,12 @@ describe('projects command', () => { projectsCommand(['--bogus'], { apiKey: FAKE_KEY }), ).rejects.toThrow(/unknown flag --bogus/); }); + + it('rejects positional arguments loudly', async () => { + await expect(projectsCommand(['extra'], { apiKey: FAKE_KEY })).rejects.toThrow( + /Unexpected argument: extra/, + ); + }); }); describe('issues --project filter', () => { @@ -258,6 +264,9 @@ describe('issues --project filter', () => { await expect( issuesCommand(['--project='], { apiKey: FAKE_KEY }), ).rejects.toThrow(/--project requires a value/); + await expect( + issuesCommand(['--project', '--limit', '25'], { apiKey: FAKE_KEY }), + ).rejects.toThrow(/--project requires a value/); }); it('rejects unknown flags alongside --project', async () => { From 6ff0b6ef3f8d43facb39256309def3ff21b031a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sigrid=20Nystr=C3=B6m?= Date: Thu, 20 Aug 2026 14:35:58 +0530 Subject: [PATCH 12/12] test: strengthen cycle project and rate-limit coverage --- test/cycles.test.ts | 4 ++-- test/projects.test.ts | 20 ++++++++++++++++++++ test/rate-limit-retry.test.ts | 3 ++- 3 files changed, 24 insertions(+), 3 deletions(-) diff --git a/test/cycles.test.ts b/test/cycles.test.ts index 0ec03eb..a7397f8 100644 --- a/test/cycles.test.ts +++ b/test/cycles.test.ts @@ -104,7 +104,7 @@ describe('cycles command', () => { expect(requests[0].body.variables).toEqual({ first: 10 }); expect(out).toContain('42'); // cycle number expect(out).toContain('LIN'); // team attribution - expect(out).toContain('active'); // now is between start and end + expect(out).toContain('42,LIN,active,'); // status is rendered in the row expect(out).toContain(`${start.slice(0, 10)}..${end.slice(0, 10)}`); expect(out).toContain('25'); // progress 0.25 rendered as integer percent expect(out).toContain('count: 1'); @@ -142,7 +142,7 @@ describe('cycles command', () => { expect(query).not.toContain('team { key }'); expect(requests[1].body.variables).toEqual({ id: 'team-1', first: 10 }); expect(out).toContain('42'); - expect(out).toContain('active'); + expect(out).toContain('42,active,'); expect(out).toContain('count: 1'); }); diff --git a/test/projects.test.ts b/test/projects.test.ts index 3554a2c..c188b41 100644 --- a/test/projects.test.ts +++ b/test/projects.test.ts @@ -180,6 +180,26 @@ describe('projects command', () => { expect(hasMore).toBe(false); }); + it('renders the truncation count when the project limit is reached', async () => { + const projects = Array.from({ length: 50 }, (_, i) => + projectNode({ id: `pr-${i}`, name: `Project ${i}` }), + ); + let page = 0; + stubLinearFetch(() => { + page += 1; + return { + projects: { + nodes: projects, + pageInfo: { hasNextPage: true, endCursor: `cursor-${page}` }, + }, + }; + }); + + const out = await projectsCommand([], { apiKey: FAKE_KEY }); + + expect(out).toContain('count: 100 (showing first 100)'); + }); + it('documents state values and percent progress in --help', () => { expect(PROJECTS_HELP).toContain( 'backlog, planned, started, paused, completed, canceled', diff --git a/test/rate-limit-retry.test.ts b/test/rate-limit-retry.test.ts index f9c7d21..e577e30 100644 --- a/test/rate-limit-retry.test.ts +++ b/test/rate-limit-retry.test.ts @@ -168,7 +168,8 @@ describe('documented Linear GraphQL rate limits', () => { }); it('honors Linear’s documented request-reset header', async () => { - vi.useFakeTimers({ toFake: ['setTimeout'] }); + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-08-20T00:00:00.000Z')); const fetchMock = stubSequencedFetch( linearRateLimitedResponse(Date.now() + 2_000), okResponse(VIEWER),