From c91f042ebf8848146b0b52b2d16fcab63302ba3e Mon Sep 17 00:00:00 2001 From: Sam Whitmore Date: Thu, 20 Aug 2026 04:31:38 +0530 Subject: [PATCH] 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 af82713..d72df0f 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; @@ -557,15 +588,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; @@ -782,6 +823,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( @@ -830,6 +880,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', + ); + }); +});