diff --git a/skills/linear-axi/SKILL.md b/skills/linear-axi/SKILL.md index 4b19874..5d1a206 100644 --- a/skills/linear-axi/SKILL.md +++ b/skills/linear-axi/SKILL.md @@ -51,5 +51,6 @@ 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. - `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. - 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 7ffb82d..46f6dae 100644 --- a/src/commands/issue.ts +++ b/src/commands/issue.ts @@ -14,6 +14,7 @@ import { getPositional, } from '../args.js'; import { AxiError } from '../errors.js'; +import { parseFields, type ExtraFieldSpec } from '../fields.js'; import { field, custom, @@ -31,20 +32,24 @@ export const ISSUE_HELP = `usage: linear-axi issue [args] Manage Linear issues. subcommands: - view show issue details (--full disables description truncation) + view show issue details (--full disables description truncation; --fields adds extra fields) create --title "..." --team [--description "..."] [--label ...] update [--state ] [--title "..."] [--priority <0-4>] [--description "..."] delete +view --fields names (opt-in, comma-separated): dueDate, estimate, archivedAt, branchName + default detail fields are untouched without the flag + 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 delete LIN-123 `; -const VIEW_FLAGS = ['--full']; +const VIEW_FLAGS = ['--full', '--fields']; const CREATE_FLAGS = ['--title', '--team', '--description', '--label']; const UPDATE_FLAGS = ['--state', '--title', '--priority', '--description']; @@ -62,6 +67,21 @@ const detailSchema: FieldDef[] = [ relativeTime('updatedAt', 'updated'), ]; +/** + * Opt-in extra fields for `issue view --fields` (#23). Only fields NOT already + * in the default detail output are offered; each linearKey is a scalar on + * Linear's Issue type (verified against the generated types in @linear/sdk + * v90.0.0): dueDate (TimelessDate serialized YYYY-MM-DD, null when unset), + * estimate (Float, null when unset), archivedAt (DateTime, null when not + * archived), branchName (String). + */ +const VIEW_EXTRA_FIELDS: Record = { + dueDate: { linearKey: 'dueDate', def: field('dueDate') }, + estimate: { linearKey: 'estimate', def: field('estimate') }, + archivedAt: { linearKey: 'archivedAt', def: relativeTime('archivedAt', 'archived') }, + branchName: { linearKey: 'branchName', def: field('branchName') }, +}; + export async function issueCommand( args: string[], ctx: LinearContext, @@ -98,6 +118,12 @@ async function viewIssue( assertKnownFlags(args, VIEW_FLAGS); const apiKey = requireKey(ctx.apiKey); const full = takeBoolFlag(args, '--full'); + // Opt-in extra fields (--fields): unknown names fail loud (parseFields) + // before any network request; a blank value selects nothing. + const { extraDefs, extraLinearKeys } = parseFields( + takeFlag(args, '--fields'), + VIEW_EXTRA_FIELDS, + ); const refRaw = getPositional(args); if (!refRaw) { throw new AxiError('Missing issue reference', 'VALIDATION_ERROR', [ @@ -105,7 +131,7 @@ async function viewIssue( ]); } - const issue = await fetchIssue(apiKey, refRaw); + const issue = await fetchIssue(apiKey, refRaw, extraLinearKeys); if (!issue) { throw new AxiError( `Issue "${refRaw}" not found`, @@ -114,7 +140,9 @@ async function viewIssue( ); } - const blocks: string[] = [renderDetail('issue', issue, detailSchema)]; + const blocks: string[] = [ + renderDetail('issue', issue, [...detailSchema, ...extraDefs]), + ]; // Description: truncated by default, full on --full (principle 3). const { preview, total, truncated } = truncateForDisplay( diff --git a/src/commands/issues.ts b/src/commands/issues.ts index 92a4c1c..142f3f8 100644 --- a/src/commands/issues.ts +++ b/src/commands/issues.ts @@ -3,6 +3,7 @@ import { requireKey } from '../config.js'; import { fetchIssues, fetchViewer, type IssueListFilter } from '../linear.js'; import { assertKnownFlags, takeFlag, takeAllFlags } from '../args.js'; import { AxiError } from '../errors.js'; +import { parseFields, type ExtraFieldSpec } from '../fields.js'; import { field, custom, @@ -15,7 +16,7 @@ import { } from '../toon.js'; import { formatCountLine } from '../format.js'; -export const ISSUES_HELP = `usage: linear-axi issues [--team ] [--state ] [--assignee ] [--label ] [--project ] [--cycle ] [--search ] [--limit ] +export const ISSUES_HELP = `usage: linear-axi issues [--team ] [--state ] [--assignee ] [--label ] [--project ] [--cycle ] [--search ] [--fields ] [--limit ] List Linear issues, most recently updated first (relevance-ranked when --search is given). flags: @@ -26,6 +27,7 @@ flags: --project filter by project name (exact match; run \`linear-axi projects\` for names) --cycle filter by cycle: "current" matches issues in any team's active cycle (with --team, just that team's); a cycle number requires --team because numbers restart per team (run \`linear-axi cycles\` for numbers) --search full-text search (same ranking as Linear's app search); composable with the filters above + --fields opt-in extra columns (comma-separated): url, estimate, dueDate, createdAt; default columns are untouched without this flag --limit max issues to return (default 25, max 500; fetched in pages of 50) examples: @@ -36,6 +38,7 @@ examples: linear-axi issues --project "Mobile app" --state started linear-axi issues --cycle current --team LIN linear-axi issues --search "onboarding" --team LIN --state started + linear-axi issues --fields url,estimate `; const KNOWN_FLAGS = [ @@ -46,6 +49,7 @@ const KNOWN_FLAGS = [ '--project', '--cycle', '--search', + '--fields', '--limit', ]; @@ -57,6 +61,20 @@ const listSchema: FieldDef[] = [ relativeTime('updatedAt', 'updated'), ]; +/** + * Opt-in extra columns for `issues --fields` (#23). Only fields NOT already in + * the default list output are offered; each linearKey is a scalar on Linear's + * Issue type (verified against the generated types in @linear/sdk v90.0.0): + * url (String), estimate (Float, null when unset), dueDate (TimelessDate + * serialized YYYY-MM-DD, null when unset), createdAt (DateTime). + */ +const ISSUE_LIST_EXTRA_FIELDS: Record = { + url: { linearKey: 'url', def: field('url') }, + estimate: { linearKey: 'estimate', def: field('estimate') }, + dueDate: { linearKey: 'dueDate', def: field('dueDate') }, + createdAt: { linearKey: 'createdAt', def: relativeTime('createdAt', 'created') }, +}; + const STATE_TYPES = new Set([ 'backlog', 'unstarted', @@ -103,6 +121,13 @@ export async function issuesCommand( const limitRaw = takeFlag(args, '--limit'); const limit = limitRaw ? parseLimit(limitRaw) : 25; + // Opt-in extra columns (--fields): unknown names fail loud (parseFields) + // before any network request; a blank value selects nothing. + const { extraDefs, extraLinearKeys } = parseFields( + takeFlag(args, '--fields'), + ISSUE_LIST_EXTRA_FIELDS, + ); + if (stateType && !STATE_TYPES.has(stateType.toLowerCase())) { throw new AxiError( `Unknown state type: "${stateType}"`, @@ -177,13 +202,18 @@ export async function issuesCommand( if (cycleFilter !== undefined) filter.cycle = cycleFilter; if (search !== undefined) filter.search = search; - const { issues, hasMore } = await fetchIssues(apiKey, filter, limit); + const { issues, hasMore } = await fetchIssues( + apiKey, + filter, + limit, + extraLinearKeys, + ); const blocks: string[] = []; blocks.push(formatCountLine({ count: issues.length, limit, hasMore })); if (issues.length) { - blocks.push(renderList('issues', issues, listSchema)); + blocks.push(renderList('issues', issues, [...listSchema, ...extraDefs])); } else { blocks.push('issues: 0 found'); } diff --git a/src/linear.ts b/src/linear.ts index d936759..835cb02 100644 --- a/src/linear.ts +++ b/src/linear.ts @@ -81,6 +81,16 @@ export const ISSUE_DETAIL_FIELDS = ` updatedAt `; +/** + * Append opt-in extra scalar keys (from `--fields`) to a base issue selection + * set. Returns the base string unchanged when no extras are requested, so the + * default GraphQL documents stay byte-identical without the flag (opt-in only). + */ +export function withExtraFields(base: string, extraKeys: string[]): string { + if (extraKeys.length === 0) return base; + return `${base}${extraKeys.map((k) => `\n ${k}`).join('')}`; +} + /** * Selection for the `projects` connection. We select `status { type }` rather * than the deprecated `state` field (Project.state: "[DEPRECATED] Use @@ -123,6 +133,14 @@ export interface LinearIssue { url?: string; createdAt?: string; updatedAt?: string; + // Opt-in extra fields, present only when selected via --fields. Shapes + // verified against the generated Issue type in @linear/sdk v90.0.0: + // estimate is Float|null, dueDate a TimelessDate serialized as YYYY-MM-DD + // (null when unset), archivedAt an ISO DateTime, branchName a String. + estimate?: number | null; + dueDate?: string | null; + archivedAt?: string | null; + branchName?: string; } export interface LinearTeam { @@ -413,11 +431,17 @@ interface IssueListPage { * There is no usable `totalCount` on the `issues` connection, so callers * compute the count line from the returned slice (`hasMore` in the result * says whether a truncation hint is warranted). + * + * `extraFields` is a list of opt-in extra scalar Issue keys (from `--fields`) + * appended to the node selection; the default document is used when empty. + * It applies to both the `issues` and the `searchIssues` (filter.search) + * documents — both return Issue nodes. */ export async function fetchIssues( apiKey: string, filter: IssueListFilter, limit = 50, + extraFields: string[] = [], ): Promise { const where: string[] = []; @@ -453,17 +477,18 @@ export async function fetchIssues( const filterPart = where.length ? `filter: { ${where.join(', ')} }, ` : ''; const isSearch = filter.search !== undefined; + const selection = withExtraFields(ISSUE_LIST_FIELDS, extraFields); const query = isSearch ? `query SearchIssues($term: String!, $first: Int!, $after: String) { searchIssues(term: $term, ${filterPart}first: $first, after: $after) { - nodes { ${ISSUE_LIST_FIELDS} } + nodes { ${selection} } pageInfo { hasNextPage endCursor } } }` : `query Issues($first: Int!, $after: String) { issues(${filterPart}first: $first, after: $after, orderBy: updatedAt) { - nodes { ${ISSUE_LIST_FIELDS} } + nodes { ${selection} } pageInfo { hasNextPage endCursor } } }`; @@ -508,14 +533,18 @@ export async function fetchIssues( /** * Fetch a single issue. Linear's `issue(id:)` accepts EITHER a UUID or the * human-readable identifier (e.g. "CTZ-311"), so no resolution step is needed. + * + * `extraFields` is a list of opt-in extra scalar Issue keys (from `--fields`) + * appended to the selection; the default document is used when empty. */ export async function fetchIssue( apiKey: string, ref: string, + extraFields: string[] = [], ): Promise { const data = await linearRequest<{ issue: LinearIssue | null }>( apiKey, - `query Issue($id: String!) { issue(id: $id) { ${ISSUE_DETAIL_FIELDS} } }`, + `query Issue($id: String!) { issue(id: $id) { ${withExtraFields(ISSUE_DETAIL_FIELDS, extraFields)} } }`, { id: ref }, ); return data.issue ?? undefined; diff --git a/src/skill.ts b/src/skill.ts index f3d2bb4..b29900f 100644 --- a/src/skill.ts +++ b/src/skill.ts @@ -81,6 +81,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. - \`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. - Issue references accept either a UUID or a \`TEAM-NUMBER\` identifier like \`LIN-123\`. `; diff --git a/test/fields-wiring.test.ts b/test/fields-wiring.test.ts new file mode 100644 index 0000000..5a0cbea --- /dev/null +++ b/test/fields-wiring.test.ts @@ -0,0 +1,300 @@ +// Tests for the `--fields` opt-in extra fields wiring (#23): `issues +// --fields ` and `issue view --fields `. Follows the +// network-free GraphQL stub pattern from cycles.test.ts: stub global fetch, +// then assert on the captured query document (extra keys present only when +// requested), variables, and rendered TOON output (extra columns/rows). +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { issuesCommand, ISSUES_HELP } from '../src/commands/issues.js'; +import { issueCommand, ISSUE_HELP } from '../src/commands/issue.js'; +import { AxiError } from '../src/errors.js'; +import type { LinearIssue } from '../src/linear.js'; + +const FAKE_KEY = 'lin_api_fake'; + +/** A minimal Linear issue node matching ISSUE_LIST_FIELDS (+ extras when given). */ +function issueNode(overrides: Partial = {}): LinearIssue { + return { + id: 'ir-1', + identifier: 'LIN-1', + title: 'Ship the thing', + state: { name: 'Started', type: 'started' }, + priority: 2, + assignee: { name: 'Ada' }, + team: { key: 'LIN' }, + updatedAt: '2026-08-19T00:00:00.000Z', + ...overrides, + }; +} + +/** A minimal Linear issue node matching ISSUE_DETAIL_FIELDS (+ extras when given). */ +function detailNode(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: [{ name: 'bug' }] }, + url: 'https://linear.app/lini/issue/LIN-1/ship-the-thing', + createdAt: '2026-08-01T00:00:00.000Z', + updatedAt: '2026-08-19T00:00:00.000Z', + ...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. Returns the captured + * requests so tests can assert on the query/variables that were sent. + */ +function stubLinearFetch(respond: (body: CapturedRequest) => 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']; + const captured: CapturedRequest = { url: String(url), body }; + requests.push(captured); + return graphqlOk(respond(captured)); + }); + vi.stubGlobal('fetch', fetchMock); + return { requests }; +} + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('issues --fields', () => { + it('adds the requested key to the issued document and a url column to the output', async () => { + const { requests } = stubLinearFetch(() => ({ + issues: { + nodes: [issueNode({ url: 'https://linear.app/lini/issue/LIN-1/ship' })], + }, + })); + + const out = await issuesCommand(['--fields', 'url'], { + apiKey: FAKE_KEY, + }); + + const query = requests[0].body.query; + expect(query).toContain('query Issues('); + // The extra key rides along after the base selection fields. + expect(query).toContain('updatedAt'); + expect(query).toMatch(/\n\s+url/); + expect(out).toContain('LIN-1'); + expect(out).toContain('https://linear.app/lini/issue/LIN-1/ship'); + expect(out).toContain('url'); // column key present in the TOON table + }); + + it('accepts multiple comma-separated fields (url,estimate)', async () => { + const { requests } = stubLinearFetch(() => ({ + issues: { + nodes: [ + issueNode({ + url: 'https://linear.app/lini/issue/LIN-1/ship', + estimate: 3, + }), + ], + }, + })); + + const out = await issuesCommand(['--fields', 'url,estimate'], { + apiKey: FAKE_KEY, + }); + + const query = requests[0].body.query; + expect(query).toMatch(/\n\s+url/); + expect(query).toMatch(/\n\s+estimate/); + expect(out).toContain('https://linear.app/lini/issue/LIN-1/ship'); + expect(out).toContain('3'); + expect(out).toContain('estimate'); + }); + + it('composes with an existing filter (--team)', async () => { + const { requests } = stubLinearFetch(() => ({ + issues: { nodes: [issueNode({ dueDate: '2026-09-01' })] }, + })); + + const out = await issuesCommand(['--team', 'LIN', '--fields', 'dueDate'], { + apiKey: FAKE_KEY, + }); + + const query = requests[0].body.query; + expect(query).toContain('team: { key: { eq: "LIN" } }'); + expect(query).toMatch(/\n\s+dueDate/); + expect(out).toContain('2026-09-01'); + expect(out).toContain('dueDate'); + }); + + it('applies extras to the searchIssues document too (--search)', async () => { + const { requests } = stubLinearFetch(() => ({ + searchIssues: { + nodes: [ + issueNode({ + url: 'https://linear.app/lini/issue/LIN-1/ship', + estimate: 5, + }), + ], + }, + })); + + const out = await issuesCommand( + ['--search', 'onboarding', '--fields', 'url,estimate'], + { apiKey: FAKE_KEY }, + ); + + const query = requests[0].body.query; + expect(query).toContain('searchIssues('); + expect(query).toMatch(/\n\s+url/); + expect(query).toMatch(/\n\s+estimate/); + expect(requests[0].body.variables).toEqual({ + term: 'onboarding', + first: 25, + }); + expect(out).toContain('https://linear.app/lini/issue/LIN-1/ship'); + expect(out).toContain('5'); + }); + + it('leaves the default document and output untouched when the flag is absent', async () => { + const { requests } = stubLinearFetch(() => ({ + issues: { nodes: [issueNode()] }, + })); + + const out = await issuesCommand([], { apiKey: FAKE_KEY }); + + const query = requests[0].body.query; + expect(query).toContain('query Issues('); + // None of the opt-in keys may leak into the default selection. + expect(query).not.toMatch(/\n\s+url/); + expect(query).not.toMatch(/\n\s+estimate/); + expect(query).not.toMatch(/\n\s+dueDate/); + expect(query).not.toMatch(/\n\s+createdAt/); + expect(out).toContain('LIN-1'); + expect(out).not.toContain('url:'); + }); + + it('rejects unknown field names loudly with the valid set (no request sent)', async () => { + const { requests } = stubLinearFetch(() => ({})); + + const err = await issuesCommand(['--fields', 'bogus'], { + apiKey: FAKE_KEY, + }).catch((e: unknown) => e); + + expect(err).toBeInstanceOf(AxiError); + expect((err as AxiError).code).toBe('VALIDATION_ERROR'); + expect((err as AxiError).message).toContain('Unknown field(s): bogus'); + expect((err as AxiError).message).toContain('url'); + expect((err as AxiError).message).toContain('estimate'); + expect((err as AxiError).message).toContain('dueDate'); + expect((err as AxiError).message).toContain('createdAt'); + expect(requests).toHaveLength(0); // fails before any network call + }); + + it('tolerates a blank value and blank names as "no extras"', async () => { + const { requests } = stubLinearFetch(() => ({ + issues: { nodes: [issueNode()] }, + })); + + const out = await issuesCommand(['--fields', ''], { apiKey: FAKE_KEY }); + + expect(requests[0].body.query).not.toMatch(/\n\s+url/); + expect(out).toContain('LIN-1'); + }); + + it('documents --fields in --help with the valid names', () => { + expect(ISSUES_HELP).toContain('--fields '); + expect(ISSUES_HELP).toContain('url, estimate, dueDate, createdAt'); + expect(ISSUES_HELP).toContain('linear-axi issues --fields url,estimate'); + }); +}); + +describe('issue view --fields', () => { + it('adds the requested key to the detail document and the output', async () => { + const { requests } = stubLinearFetch(() => ({ + issue: detailNode({ dueDate: '2026-09-01' }), + })); + + const out = await issueCommand(['view', 'LIN-1', '--fields', 'dueDate'], { + apiKey: FAKE_KEY, + }); + + const query = requests[0].body.query; + expect(query).toContain('query Issue('); + expect(query).toMatch(/\n\s+dueDate/); + expect(requests[0].body.variables).toEqual({ id: 'LIN-1' }); + expect(out).toContain('LIN-1'); + expect(out).toContain('2026-09-01'); + expect(out).toContain('dueDate'); + }); + + it('adds multiple fields as extra detail rows (estimate,branchName)', async () => { + const { requests } = stubLinearFetch(() => ({ + issue: detailNode({ estimate: 2, branchName: 'lin-1-ship-the-thing' }), + })); + + const out = await issueCommand( + ['view', 'LIN-1', '--fields', 'estimate,branchName'], + { apiKey: FAKE_KEY }, + ); + + const query = requests[0].body.query; + expect(query).toMatch(/\n\s+estimate/); + expect(query).toMatch(/\n\s+branchName/); + expect(out).toContain('estimate'); + expect(out).toContain('lin-1-ship-the-thing'); + }); + + it('leaves the default detail document untouched when the flag is absent', async () => { + const { requests } = stubLinearFetch(() => ({ + issue: detailNode(), + })); + + const out = await issueCommand(['view', 'LIN-1'], { apiKey: FAKE_KEY }); + + const query = requests[0].body.query; + expect(query).toContain('query Issue('); + expect(query).not.toMatch(/\n\s+dueDate/); + expect(query).not.toMatch(/\n\s+estimate/); + expect(query).not.toMatch(/\n\s+archivedAt/); + expect(query).not.toMatch(/\n\s+branchName/); + expect(out).toContain('LIN-1'); + expect(out).not.toContain('dueDate'); + }); + + it('rejects unknown field names loudly with the view field set', async () => { + const { requests } = stubLinearFetch(() => ({})); + + const err = await issueCommand(['view', 'LIN-1', '--fields', 'bogus'], { + apiKey: FAKE_KEY, + }).catch((e: unknown) => e); + + expect(err).toBeInstanceOf(AxiError); + expect((err as AxiError).code).toBe('VALIDATION_ERROR'); + expect((err as AxiError).message).toContain('Unknown field(s): bogus'); + // The view set differs from the list set: archivedAt/branchName yes, url no. + expect((err as AxiError).message).toContain('archivedAt'); + expect((err as AxiError).message).toContain('branchName'); + expect((err as AxiError).message).not.toContain('url, estimate'); + expect(requests).toHaveLength(0); + }); + + it('documents --fields in --help with the valid names', () => { + expect(ISSUE_HELP).toContain('--fields '); + expect(ISSUE_HELP).toContain('dueDate, estimate, archivedAt, branchName'); + expect(ISSUE_HELP).toContain( + 'issue view LIN-123 --fields dueDate,estimate', + ); + }); +});