From 0ce2e071a15da1ddfddbd20433de55f45972555c Mon Sep 17 00:00:00 2001 From: Sam Whitmore Date: Thu, 20 Aug 2026 04:41:31 +0530 Subject: [PATCH] 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',