From e1ef29931622cc1156ccd461228ebaa9b3b2bea8 Mon Sep 17 00:00:00 2001 From: Sam Whitmore Date: Thu, 20 Aug 2026 02:53:45 +0530 Subject: [PATCH 1/9] feat: add issues --search full-text search Implement --search on the issues command via Linear's ranked searchIssues query (the current form of the deprecated issueSearch root field), composing the existing IssueFilter from fetchIssues and reusing ISSUE_LIST_FIELDS. Missing/blank --search values fail loud rather than being silently dropped, and an empty search result gains a search-specific hint. Establishes the stubbed-global-fetch pattern in test/issues-search.test.ts for testing command/transport code without network. --- src/commands/issues.ts | 19 +++- src/linear.ts | 27 ++++- test/issues-search.test.ts | 199 +++++++++++++++++++++++++++++++++++++ 3 files changed, 242 insertions(+), 3 deletions(-) create mode 100644 test/issues-search.test.ts diff --git a/src/commands/issues.ts b/src/commands/issues.ts index 8ada98f..3999061 100644 --- a/src/commands/issues.ts +++ b/src/commands/issues.ts @@ -15,20 +15,22 @@ import { } from '../toon.js'; import { formatCountLine } from '../format.js'; -export const ISSUES_HELP = `usage: linear-axi issues [--team ] [--state ] [--assignee ] [--label ] [--limit ] -List Linear issues, most recently updated first. +export const ISSUES_HELP = `usage: linear-axi issues [--team ] [--state ] [--assignee ] [--label ] [--search ] [--limit ] +List Linear issues, most recently updated first (relevance-ranked when --search is given). flags: --team filter by team key (e.g. LIN) --state workflow state type: backlog, unstarted, started, completed, canceled, triage --assignee "me" for yourself, or a user name --label filter by label name (repeatable) + --search full-text search (same ranking as Linear's app search); composable with the filters above --limit max issues to return (default 25, capped at 50) examples: linear-axi issues linear-axi issues --assignee me --state started linear-axi issues --team LIN --label bug + linear-axi issues --search "onboarding" --team LIN --state started `; const KNOWN_FLAGS = [ @@ -36,6 +38,7 @@ const KNOWN_FLAGS = [ '--state', '--assignee', '--label', + '--search', '--limit', ]; @@ -67,6 +70,14 @@ export async function issuesCommand( const stateType = takeFlag(args, '--state'); const assignee = takeFlag(args, '--assignee'); const labels = takeAllFlags(args, '--label'); + const hasSearch = args.some((a) => a === '--search' || a.startsWith('--search=')); + const search = takeFlag(args, '--search'); + if (hasSearch && (search === undefined || search.trim() === '')) { + // takeFlag returns undefined for a missing value; never silently drop a search. + throw new AxiError('--search requires a value', 'VALIDATION_ERROR', [ + 'e.g. --search "onboarding"', + ]); + } const limitRaw = takeFlag(args, '--limit'); const limit = limitRaw ? parseLimit(limitRaw) : 25; @@ -112,6 +123,7 @@ export async function issuesCommand( // The API matches "at least one label"; pass the first label only for v1. if (labels.length) filter.label = labels[0]; + if (search !== undefined) filter.search = search; const { issues } = await fetchIssues(apiKey, filter, limit); @@ -140,6 +152,9 @@ export async function issuesCommand( `Only the first label ("${labels[0]}") was applied — multi-label filter lands post-v1`, ); } + if (search !== undefined && issues.length === 0) { + hints.push(`No matches for "${search}" — try a shorter term or drop a filter`); + } blocks.push(renderHelp(hints)); return renderOutput(blocks); diff --git a/src/linear.ts b/src/linear.ts index 977b87a..1601a85 100644 --- a/src/linear.ts +++ b/src/linear.ts @@ -134,6 +134,7 @@ export interface IssueListFilter { assigneeEmail?: string; // exact email (use viewer email for "me") assigneeName?: string; // exact display name label?: string; // label name (at least one match) + search?: string; // ranked full-text search term (Linear app search ranking) } export interface IssueListResult { @@ -149,6 +150,13 @@ export interface IssueListResult { * state: { type: { eq: "started" } } * team: { key: { eq: "LIN" } } * + * When `filter.search` is set, Linear's ranked full-text search is used + * instead (same ranking as the Linear app's search). We query `searchIssues` + * rather than the `issueSearch` root field: both accept the same + * `filter: IssueFilter` + `first: Int`, but Linear marks `issueSearch` as + * deprecated ("will be removed in the future — use searchIssues instead"), + * and its nodes carry the same fields as `Issue` for our selection set. + * * There is no `totalCount` on Linear connections, so callers compute the count * line from the returned slice (with a truncation hint when `limit` is hit). */ @@ -176,6 +184,23 @@ export async function fetchIssues( } const filterPart = where.length ? `filter: { ${where.join(', ')} }, ` : ''; + const first = Math.min(limit, 50); + + if (filter.search !== undefined) { + const data = await linearRequest<{ + searchIssues: { nodes: LinearIssue[] }; + }>( + apiKey, + `query SearchIssues($term: String!, $first: Int!) { + searchIssues(term: $term, ${filterPart}first: $first) { + nodes { ${ISSUE_LIST_FIELDS} } + } + }`, + { term: filter.search, first }, + ); + return { issues: data.searchIssues.nodes }; + } + const query = `query Issues($first: Int!) { issues(${filterPart}first: $first, orderBy: updatedAt) { nodes { ${ISSUE_LIST_FIELDS} } @@ -185,7 +210,7 @@ export async function fetchIssues( const data = await linearRequest<{ issues: { nodes: LinearIssue[] } }>( apiKey, query, - { first: Math.min(limit, 50) }, + { first }, ); return { issues: data.issues.nodes }; diff --git a/test/issues-search.test.ts b/test/issues-search.test.ts new file mode 100644 index 0000000..c36bf09 --- /dev/null +++ b/test/issues-search.test.ts @@ -0,0 +1,199 @@ +// Tests for `issues --search` — the first suite here to exercise the +// command/transport path against a stubbed global fetch (no network, ever). +// The pattern: vi.stubGlobal('fetch', ...) a fake GraphQL endpoint, then +// assert on the captured query document and variables. +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { issuesCommand } from '../src/commands/issues.js'; +import { fetchIssues } from '../src/linear.js'; +import { AxiError } from '../src/errors.js'; +import type { LinearIssue } from '../src/linear.js'; + +const FAKE_KEY = 'lin_api_fake'; + +interface CapturedRequest { + url: string; + body: { query: string; variables: Record }; +} + +/** A minimal Linear issue node matching ISSUE_LIST_FIELDS. */ +function issueNode(overrides: Partial = {}): LinearIssue { + return { + id: 'ir-1', + identifier: 'LIN-1', + title: 'Onboarding checklist', + state: { name: 'Started', type: 'started' }, + priority: 2, + assignee: { name: 'Ada' }, + team: { key: 'LIN' }, + updatedAt: '2026-08-19T00: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. 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('fetchIssues search dispatch', () => { + it('uses the searchIssues query with term + first when search is set', async () => { + const { requests } = stubLinearFetch(() => ({ + searchIssues: { nodes: [issueNode()] }, + })); + + const { issues } = await fetchIssues( + FAKE_KEY, + { search: 'onboarding' }, + 25, + ); + + expect(issues).toHaveLength(1); + expect(requests).toHaveLength(1); + expect(requests[0].url).toBe('https://api.linear.app/graphql'); + expect(requests[0].body.query).toContain('searchIssues('); + expect(requests[0].body.query).toContain('term: $term'); + expect(requests[0].body.query).toContain('first: $first'); + // No composed filter → no filter: argument at all. + expect(requests[0].body.query).not.toContain('filter:'); + expect(requests[0].body.variables).toEqual({ + term: 'onboarding', + first: 25, + }); + }); + + it('composes search with team + state + label into the IssueFilter', async () => { + const { requests } = stubLinearFetch(() => ({ + searchIssues: { nodes: [] }, + })); + + await fetchIssues( + FAKE_KEY, + { search: 'x', team: 'lin', stateType: 'started', label: 'bug' }, + 10, + ); + + const query = requests[0].body.query; + expect(query).toContain('searchIssues('); + expect(query).toContain( + 'filter: { team: { key: { eq: "LIN" } }, state: { type: { eq: "started" } }, labels: { name: { eq: "bug" } } }', + ); + expect(requests[0].body.variables).toEqual({ term: 'x', first: 10 }); + }); + + it('still uses the plain issues query (updatedAt order) without search', async () => { + const { requests } = stubLinearFetch(() => ({ + issues: { nodes: [issueNode()] }, + })); + + await fetchIssues(FAKE_KEY, { team: 'LIN' }, 25); + + const query = requests[0].body.query; + expect(query).toContain('issues('); + expect(query).toContain('orderBy: updatedAt'); + expect(query).not.toContain('searchIssues'); + expect(requests[0].body.variables).toEqual({ first: 25 }); + }); +}); + +describe('issues command --search', () => { + it('is accepted by KNOWN_FLAGS and renders searchIssues results', async () => { + const { requests } = stubLinearFetch(() => ({ + searchIssues: { + nodes: [issueNode({ identifier: 'LIN-7', title: 'Fix onboarding' })], + }, + })); + + const out = await issuesCommand(['--search', 'onboarding'], { + apiKey: FAKE_KEY, + }); + + expect(requests[0].body.query).toContain('searchIssues('); + expect(out).toContain('LIN-7'); + expect(out).toContain('Fix onboarding'); + }); + + it('composes --search with --team and --state', async () => { + const { requests } = stubLinearFetch(() => ({ + searchIssues: { nodes: [] }, + })); + + const out = await issuesCommand( + ['--search', 'x', '--team', 'LIN', '--state', 'started'], + { apiKey: FAKE_KEY }, + ); + + const query = requests[0].body.query; + expect(query).toContain('searchIssues('); + expect(query).toContain('team: { key: { eq: "LIN" } }'); + expect(query).toContain('state: { type: { eq: "started" } }'); + expect(requests[0].body.variables).toEqual({ term: 'x', first: 25 }); + // Definitive empty state + search-specific hint. + expect(out).toContain('issues: 0 found'); + expect(out).toContain('No matches for "x"'); + }); + + it('resolves --assignee me (viewer round trip) then searches with the email filter', async () => { + const { requests } = stubLinearFetch((req) => { + if (req.body.query.includes('viewer')) { + return { + viewer: { id: 'u1', name: 'Ada', email: 'ada@example.com' }, + }; + } + return { searchIssues: { nodes: [] } }; + }); + + await issuesCommand(['--search', 'onboarding', '--assignee', 'me'], { + apiKey: FAKE_KEY, + }); + + expect(requests).toHaveLength(2); + expect(requests[0].body.query).toContain('viewer'); + const search = requests[1]; + expect(search.body.query).toContain('searchIssues('); + expect(search.body.query).toContain( + 'assignee: { email: { eq: "ada@example.com" } }', + ); + expect(search.body.variables).toEqual({ term: 'onboarding', first: 25 }); + }); + + it('rejects --search with a missing or blank value instead of dropping it', async () => { + stubLinearFetch(() => ({})); + + await expect( + issuesCommand(['--search'], { apiKey: FAKE_KEY }), + ).rejects.toThrow(AxiError); + + await expect( + issuesCommand(['--search='], { apiKey: FAKE_KEY }), + ).rejects.toThrow(/--search requires a value/); + }); + + it('still rejects unknown flags alongside --search', async () => { + stubLinearFetch(() => ({})); + + await expect( + issuesCommand(['--search', 'x', '--bogus'], { apiKey: FAKE_KEY }), + ).rejects.toThrow(/unknown flag --bogus/); + }); +}); From 230c540d0598a9c5c4417c5892e8ebe26d57ddfa Mon Sep 17 00:00:00 2001 From: Sam Whitmore Date: Thu, 20 Aug 2026 03:06:23 +0530 Subject: [PATCH 2/9] feat: multi-label filter and auto-pagination for issues Honor every repeated --label via labels: { some: { name: { in: [...] } } } (any-of semantics, documented in --help) and auto-paginate fetchIssues in 50-size batches using pageInfo { hasNextPage endCursor } until --limit is satisfied. Raise the --limit cap to 500 (rejected loudly above it), keep the truncation hint accurate via pageInfo.hasNextPage, and drop the old first-label-only filter + hint. Closes #18 --- skills/linear-axi/SKILL.md | 2 +- src/commands/issues.ts | 34 +++-- src/format.ts | 16 ++- src/linear.ts | 114 +++++++++++----- src/skill.ts | 2 +- test/format.test.ts | 14 ++ test/issues-pagination.test.ts | 242 +++++++++++++++++++++++++++++++++ test/issues-search.test.ts | 4 +- 8 files changed, 377 insertions(+), 51 deletions(-) create mode 100644 test/issues-pagination.test.ts diff --git a/skills/linear-axi/SKILL.md b/skills/linear-axi/SKILL.md index 6ea1376..069f9c4 100644 --- a/skills/linear-axi/SKILL.md +++ b/skills/linear-axi/SKILL.md @@ -49,6 +49,6 @@ 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 flags (`--label`) apply once per value; a missing or blank value is rejected, never dropped. +- Repeated `--label` flags match issues carrying ANY of the given labels; a missing or blank value is rejected, never dropped. - `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/issues.ts b/src/commands/issues.ts index 3999061..d01d059 100644 --- a/src/commands/issues.ts +++ b/src/commands/issues.ts @@ -16,20 +16,21 @@ import { import { formatCountLine } from '../format.js'; export const ISSUES_HELP = `usage: linear-axi issues [--team ] [--state ] [--assignee ] [--label ] [--search ] [--limit ] -List Linear issues, most recently updated first (relevance-ranked when --search is given). + List Linear issues, most recently updated first (relevance-ranked when --search is given). flags: --team filter by team key (e.g. LIN) --state workflow state type: backlog, unstarted, started, completed, canceled, triage --assignee "me" for yourself, or a user name - --label filter by label name (repeatable) + --label filter by label name; repeat to match ANY of the given labels (issues carrying at least one) --search full-text search (same ranking as Linear's app search); composable with the filters above - --limit max issues to return (default 25, capped at 50) + --limit max issues to return (default 25, max 500; fetched in pages of 50) examples: linear-axi issues linear-axi issues --assignee me --state started linear-axi issues --team LIN --label bug + linear-axi issues --label bug --label regression linear-axi issues --search "onboarding" --team LIN --state started `; @@ -121,14 +122,15 @@ export async function issuesCommand( } } - // The API matches "at least one label"; pass the first label only for v1. - if (labels.length) filter.label = labels[0]; + // Every repeated --label is applied: Linear matches issues carrying at + // least one of the names ("any of" semantics, documented in --help). + if (labels.length) filter.labels = labels; if (search !== undefined) filter.search = search; - const { issues } = await fetchIssues(apiKey, filter, limit); + const { issues, hasMore } = await fetchIssues(apiKey, filter, limit); const blocks: string[] = []; - blocks.push(formatCountLine({ count: issues.length, limit })); + blocks.push(formatCountLine({ count: issues.length, limit, hasMore })); if (issues.length) { blocks.push(renderList('issues', issues, listSchema)); @@ -147,9 +149,9 @@ export async function issuesCommand( 'Run `linear-axi issue create --title "..." --team ` to create one', ); } - if (labels.length > 1) { + if (labels.length > 1 && issues.length === 0) { hints.push( - `Only the first label ("${labels[0]}") was applied — multi-label filter lands post-v1`, + `No issues carry any of: ${labels.join(', ')} — try fewer labels`, ); } if (search !== undefined && issues.length === 0) { @@ -160,10 +162,22 @@ export async function issuesCommand( return renderOutput(blocks); } +/** Hard ceiling for --limit. Issues are auto-paginated in 50-size batches up to this. */ +const MAX_LIMIT = 500; + function parseLimit(raw: string): number { const n = parseInt(raw, 10); if (isNaN(n) || n < 1) { throw new AxiError(`Invalid --limit: ${raw}`, 'VALIDATION_ERROR'); } - return Math.min(n, 50); + // Loud failure over silent clamping (AXI principle 6): a caller asking for + // 501 should learn the real ceiling, not get 500 back unawares. + if (n > MAX_LIMIT) { + throw new AxiError( + `Invalid --limit: ${raw} (max ${MAX_LIMIT})`, + 'VALIDATION_ERROR', + ['Results are fetched in pages of 50; the total is capped for token safety.'], + ); + } + return n; } diff --git a/src/format.ts b/src/format.ts index d8c9bf2..ecc8877 100644 --- a/src/format.ts +++ b/src/format.ts @@ -12,17 +12,27 @@ export interface CountLineOptions { limit?: number; /** True total count from an API connection. */ totalCount?: number; + /** + * Whether more results exist beyond the returned slice, when the caller + * knows it from the connection's pageInfo.hasNextPage. Takes precedence + * over the count === limit heuristic (which guesses). + */ + hasMore?: boolean; } export function formatCountLine(opts: CountLineOptions): string { - const { count, limit, totalCount } = opts; + const { count, limit, totalCount, hasMore } = opts; if (totalCount !== undefined && totalCount !== null && totalCount >= count) { return `count: ${count} of ${totalCount} total`; } - // Hit the request limit — results may be truncated - if (limit !== undefined && count === limit && count > 0) { + // More results existed — results are truncated. Prefer the explicit + // pageInfo signal when present; fall back to the limit heuristic. + const truncated = + hasMore ?? (limit !== undefined && count === limit && count > 0); + + if (truncated && count > 0) { return `count: ${count} (showing first ${count})`; } diff --git a/src/linear.ts b/src/linear.ts index 1601a85..dbc35ad 100644 --- a/src/linear.ts +++ b/src/linear.ts @@ -133,12 +133,25 @@ export interface IssueListFilter { stateType?: string; // backlog | unstarted | started | completed | canceled | triage assigneeEmail?: string; // exact email (use viewer email for "me") assigneeName?: string; // exact display name - label?: string; // label name (at least one match) + labels?: string[]; // label names — matches issues carrying ANY of them search?: string; // ranked full-text search term (Linear app search ranking) } export interface IssueListResult { issues: LinearIssue[]; + /** + * True when the last fetched page reported `hasNextPage` — more results + * exist beyond the returned slice (e.g. the `--limit` was hit mid-cursor). + */ + hasMore: boolean; +} + +/** Linear caps connection pages at 50; fetchIssues auto-paginates in this size. */ +const PAGE_SIZE = 50; + +interface IssueListPage { + nodes: LinearIssue[]; + pageInfo?: { hasNextPage?: boolean; endCursor?: string | null } | null; } /** @@ -146,19 +159,31 @@ export interface IssueListResult { * * Filter syntax follows Linear's documented comparators, e.g. * assignee: { email: { eq: "x" } } - * labels: { name: { eq: "Bug" } } + * labels: { some: { name: { in: ["Bug", "Regression"] } } } * state: { type: { eq: "started" } } * team: { key: { eq: "LIN" } } * + * `labels` uses `some: { name: { in: [...] } }` — an issue matches when at + * least one of its labels is in the list ("any of" semantics). Shape verified + * against the generated schema types in @linear/sdk: IssueFilter.labels is an + * IssueLabelCollectionFilter whose `some` takes an IssueLabelFilter with a + * `name` StringComparator exposing `in`. + * + * Results are auto-paginated: each request fetches up to PAGE_SIZE (50) nodes + * via `pageInfo { hasNextPage endCursor }` and follows the cursor until + * `limit` is satisfied or the server reports no more pages. + * * When `filter.search` is set, Linear's ranked full-text search is used * instead (same ranking as the Linear app's search). We query `searchIssues` * rather than the `issueSearch` root field: both accept the same - * `filter: IssueFilter` + `first: Int`, but Linear marks `issueSearch` as - * deprecated ("will be removed in the future — use searchIssues instead"), - * and its nodes carry the same fields as `Issue` for our selection set. + * `filter: IssueFilter` + `first`/`after` paging, but Linear marks + * `issueSearch` as deprecated ("will be removed in the future — use + * searchIssues instead"), and its nodes carry the same fields as `Issue` for + * our selection set. * - * There is no `totalCount` on Linear connections, so callers compute the count - * line from the returned slice (with a truncation hint when `limit` is hit). + * 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). */ export async function fetchIssues( apiKey: string, @@ -179,41 +204,62 @@ export async function fetchIssues( if (filter.stateType) { where.push(`state: { type: { eq: ${jsonStr(filter.stateType)} } }`); } - if (filter.label) { - where.push(`labels: { name: { eq: ${jsonStr(filter.label)} } }`); + if (filter.labels?.length) { + where.push(`labels: { some: { name: { in: ${jsonStr(filter.labels)} } } }`); } const filterPart = where.length ? `filter: { ${where.join(', ')} }, ` : ''; - const first = Math.min(limit, 50); + const isSearch = filter.search !== undefined; - if (filter.search !== undefined) { - const data = await linearRequest<{ - searchIssues: { nodes: LinearIssue[] }; - }>( - apiKey, - `query SearchIssues($term: String!, $first: Int!) { - searchIssues(term: $term, ${filterPart}first: $first) { + const query = isSearch + ? `query SearchIssues($term: String!, $first: Int!, $after: String) { + searchIssues(term: $term, ${filterPart}first: $first, after: $after) { nodes { ${ISSUE_LIST_FIELDS} } + pageInfo { hasNextPage endCursor } } - }`, - { term: filter.search, first }, - ); - return { issues: data.searchIssues.nodes }; - } - - const query = `query Issues($first: Int!) { - issues(${filterPart}first: $first, orderBy: updatedAt) { - nodes { ${ISSUE_LIST_FIELDS} } + }` + : `query Issues($first: Int!, $after: String) { + issues(${filterPart}first: $first, after: $after, orderBy: updatedAt) { + nodes { ${ISSUE_LIST_FIELDS} } + pageInfo { hasNextPage endCursor } + } + }`; + + const issues: LinearIssue[] = []; + let cursor: string | undefined; + let hasMore = false; + + // Auto-paginate until `limit` is satisfied. Each page asks for only the + // remaining slots (capped at PAGE_SIZE) so we never over-fetch. + while (issues.length < limit) { + const first = Math.min(limit - issues.length, PAGE_SIZE); + const variables: Record = isSearch + ? { term: filter.search, first } + : { first }; + if (cursor !== undefined) { + variables['after'] = cursor; } - }`; - const data = await linearRequest<{ issues: { nodes: LinearIssue[] } }>( - apiKey, - query, - { first }, - ); + const data = await linearRequest<{ + issues?: IssueListPage; + searchIssues?: IssueListPage; + }>(apiKey, query, variables); + + const conn = (isSearch ? data.searchIssues : data.issues) as IssueListPage; + issues.push(...conn.nodes); + + hasMore = conn.pageInfo?.hasNextPage ?? false; + const nextCursor = conn.pageInfo?.endCursor ?? undefined; + + // Stop when the server says we are done, and guard against a server that + // keeps hasNextPage true without a usable (present, advancing) cursor or + // without nodes — both would loop forever. + if (!hasMore || nextCursor === undefined) break; + if (conn.nodes.length === 0 || nextCursor === cursor) break; + cursor = nextCursor; + } - return { issues: data.issues.nodes }; + return { issues: issues.slice(0, limit), hasMore }; } /** @@ -471,6 +517,6 @@ async function resolveStateIdByName( return match.id; } -function jsonStr(value: string): string { +function jsonStr(value: string | string[]): string { return JSON.stringify(value); } diff --git a/src/skill.ts b/src/skill.ts index e74693c..b54ef79 100644 --- a/src/skill.ts +++ b/src/skill.ts @@ -78,7 +78,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 flags (\`--label\`) apply once per value; a missing or blank value is rejected, never dropped. +- Repeated \`--label\` flags match issues carrying ANY of the given labels; a missing or blank value is rejected, never dropped. - \`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/format.test.ts b/test/format.test.ts index d50439a..0632e8e 100644 --- a/test/format.test.ts +++ b/test/format.test.ts @@ -18,6 +18,20 @@ describe('formatCountLine', () => { ); }); + it('hasMore=true marks truncation even below the limit', () => { + expect(formatCountLine({ count: 3, limit: 25, hasMore: true })).toBe( + 'count: 3 (showing first 3)', + ); + }); + + it('hasMore=false beats the count === limit heuristic', () => { + // Exactly `limit` results and the connection said no more pages exist: + // nothing was truncated. + expect(formatCountLine({ count: 25, limit: 25, hasMore: false })).toBe( + 'count: 25', + ); + }); + it('zero is a simple count', () => { expect(formatCountLine({ count: 0 })).toBe('count: 0'); }); diff --git a/test/issues-pagination.test.ts b/test/issues-pagination.test.ts new file mode 100644 index 0000000..c685c4d --- /dev/null +++ b/test/issues-pagination.test.ts @@ -0,0 +1,242 @@ +// Tests for multi-label filtering and auto-pagination of `issues` (#18), +// extending the network-free GraphQL stub pattern from issues-search.test.ts: +// stub global fetch, then assert on the captured query document, variables, +// and how many requests the pagination loop issued. +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { issuesCommand, ISSUES_HELP } from '../src/commands/issues.js'; +import { fetchIssues } from '../src/linear.js'; +import { AxiError } from '../src/errors.js'; +import type { LinearIssue } from '../src/linear.js'; + +const FAKE_KEY = 'lin_api_fake'; + +interface CapturedRequest { + url: string; + body: { query: string; variables: Record }; +} + +/** A minimal Linear issue node matching ISSUE_LIST_FIELDS. */ +function issueNode(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', + }; +} + +/** `count` issue nodes numbered from `start` (inclusive). */ +function nodes(start: number, count: number): LinearIssue[] { + return Array.from({ length: count }, (_, i) => issueNode(start + i)); +} + +/** A Linear connection page: nodes plus the pageInfo the pager follows. */ +function page(nodes: LinearIssue[], hasNextPage: boolean, endCursor?: string) { + return { + nodes, + pageInfo: { hasNextPage, endCursor: endCursor ?? null }, + }; +} + +function graphqlOk(data: unknown): Response { + return { ok: true, status: 200, json: async () => ({ data }) } as Response; +} + +/** + * 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('multi-label filter', () => { + it('sends every repeated --label as an "any of" in-comparator', async () => { + const { requests } = stubLinearFetch(() => ({ + issues: page(nodes(1, 2), false), + })); + + const out = await issuesCommand( + ['--label', 'bug', '--label', 'regression'], + { apiKey: FAKE_KEY }, + ); + + expect(requests[0].body.query).toContain( + 'labels: { some: { name: { in: ["bug","regression"] } } }', + ); + expect(out).toContain('LIN-1'); + }); + + it('uses the same shape for a single label', async () => { + const { requests } = stubLinearFetch(() => ({ + issues: page([], false), + })); + + await fetchIssues(FAKE_KEY, { labels: ['bug'] }, 25); + + expect(requests[0].body.query).toContain( + 'labels: { some: { name: { in: ["bug"] } } }', + ); + }); + + it('composes multiple labels with --search in the searchIssues query', async () => { + const { requests } = stubLinearFetch(() => ({ + searchIssues: page([], false), + })); + + await issuesCommand(['--search', 'crash', '--label', 'a', '--label', 'b'], { + apiKey: FAKE_KEY, + }); + + const query = requests[0].body.query; + expect(query).toContain('searchIssues('); + expect(query).toContain('labels: { some: { name: { in: ["a","b"] } } }'); + expect(requests[0].body.variables).toEqual({ term: 'crash', first: 25 }); + }); + + it('no longer claims only the first label was applied', async () => { + stubLinearFetch(() => ({ issues: page(nodes(1, 1), false) })); + + const out = await issuesCommand( + ['--label', 'bug', '--label', 'regression'], + { apiKey: FAKE_KEY }, + ); + + expect(out).not.toContain('Only the first label'); + expect(out).not.toContain('post-v1'); + }); + + it('hints the any-of semantics when repeated labels match nothing', async () => { + stubLinearFetch(() => ({ issues: page([], false) })); + + const out = await issuesCommand( + ['--label', 'bug', '--label', 'regression'], + { apiKey: FAKE_KEY }, + ); + + expect(out).toContain('No issues carry any of: bug, regression'); + }); + + it('documents any-of semantics and the 500 cap in --help', () => { + expect(ISSUES_HELP).toContain('repeat to match ANY of the given labels'); + expect(ISSUES_HELP).toContain('max 500'); + expect(ISSUES_HELP).not.toContain('capped at 50'); + }); +}); + +describe('auto-pagination', () => { + it('follows cursors in 50-size batches until --limit is satisfied', async () => { + const { requests } = stubLinearFetch((req) => { + const after = req.body.variables['after']; + if (after === undefined) + return { issues: page(nodes(1, 50), true, 'c1') }; + if (after === 'c1') return { issues: page(nodes(51, 50), true, 'c2') }; + return { issues: page(nodes(101, 20), true, 'c3') }; + }); + + const { issues, hasMore } = await fetchIssues(FAKE_KEY, {}, 120); + + expect(requests).toHaveLength(3); + expect(requests[0].body.query).toContain( + 'pageInfo { hasNextPage endCursor }', + ); + expect(requests[0].body.variables).toEqual({ first: 50 }); + expect(requests[1].body.variables).toEqual({ first: 50, after: 'c1' }); + // Only the remaining slots are requested on the last page. + expect(requests[2].body.variables).toEqual({ first: 20, after: 'c2' }); + expect(issues).toHaveLength(120); + expect(issues[0]!.identifier).toBe('LIN-1'); + expect(issues[119]!.identifier).toBe('LIN-120'); + expect(hasMore).toBe(true); + }); + + it('stops when the server exhausts results before --limit', async () => { + const { requests } = stubLinearFetch((req) => { + const after = req.body.variables['after']; + if (after === undefined) + return { issues: page(nodes(1, 50), true, 'c1') }; + return { issues: page(nodes(51, 25), false) }; + }); + + const out = await issuesCommand(['--limit', '200'], { apiKey: FAKE_KEY }); + + expect(requests).toHaveLength(2); + expect(requests[1].body.variables).toEqual({ first: 50, after: 'c1' }); + expect(out).toContain('LIN-75'); + expect(out).not.toContain('LIN-76'); + // hasMore came back false → no truncation hint on a count below the limit. + expect(out).toContain('count: 75'); + expect(out).not.toContain('showing first'); + }); + + it('--limit 25 fetches a single page of 25 and stops when satisfied', async () => { + const { requests } = stubLinearFetch(() => ({ + issues: page(nodes(1, 25), true, 'c1'), + })); + + const out = await issuesCommand(['--limit', '25'], { apiKey: FAKE_KEY }); + + expect(requests).toHaveLength(1); + expect(requests[0].body.variables).toEqual({ first: 25 }); + expect(out).toContain('count: 25 (showing first 25)'); + }); + + it('keeps paging when a page comes back short but hasNextPage is true', async () => { + const { requests } = stubLinearFetch((req) => { + const after = req.body.variables['after']; + if (after === undefined) + return { issues: page(nodes(1, 10), true, 'c1') }; + return { issues: page(nodes(11, 15), false) }; + }); + + const out = await issuesCommand(['--limit', '25'], { apiKey: FAKE_KEY }); + + expect(requests).toHaveLength(2); + expect(requests[1].body.variables).toEqual({ first: 15, after: 'c1' }); + expect(out).toContain('LIN-25'); + // The server said it was done on page 2 → plain count, no hint. + expect(out).toContain('count: 25'); + expect(out).not.toContain('showing first'); + }); +}); + +describe('--limit cap', () => { + it('accepts --limit 500', async () => { + const { requests } = stubLinearFetch(() => ({ + issues: page(nodes(1, 1), false), + })); + + const out = await issuesCommand(['--limit', '500'], { apiKey: FAKE_KEY }); + + expect(requests[0].body.variables).toEqual({ first: 50 }); + expect(out).toContain('count: 1'); + }); + + it('rejects --limit 501 loudly instead of clamping to 500', async () => { + stubLinearFetch(() => ({})); + + const rejected = issuesCommand(['--limit', '501'], { + apiKey: FAKE_KEY, + }); + await expect(rejected).rejects.toBeInstanceOf(AxiError); + await expect(rejected).rejects.toThrow(/max 500/); + }); +}); diff --git a/test/issues-search.test.ts b/test/issues-search.test.ts index c36bf09..7f56eb8 100644 --- a/test/issues-search.test.ts +++ b/test/issues-search.test.ts @@ -89,14 +89,14 @@ describe('fetchIssues search dispatch', () => { await fetchIssues( FAKE_KEY, - { search: 'x', team: 'lin', stateType: 'started', label: 'bug' }, + { search: 'x', team: 'lin', stateType: 'started', labels: ['bug'] }, 10, ); const query = requests[0].body.query; expect(query).toContain('searchIssues('); expect(query).toContain( - 'filter: { team: { key: { eq: "LIN" } }, state: { type: { eq: "started" } }, labels: { name: { eq: "bug" } } }', + 'filter: { team: { key: { eq: "LIN" } }, state: { type: { eq: "started" } }, labels: { some: { name: { in: ["bug"] } } } }', ); expect(requests[0].body.variables).toEqual({ term: 'x', first: 10 }); }); From c8a0fbf410e7b17ae895e4af0a2e0f2fb3f034bb Mon Sep 17 00:00:00 2001 From: Sam Whitmore Date: Thu, 20 Aug 2026 03:17:24 +0530 Subject: [PATCH 3/9] feat: add projects command and issues --project filter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit List workspace projects via the root `projects` connection (name, lifecycle state, progress, lead, target date), auto-paginated in 50-size batches like fetchIssues. State is selected as `status { type }` — Project.state is deprecated in favor of Project.status per @linear/sdk, and status.type yields the same raw lowercase lifecycle values (backlog, planned, started, paused, completed, canceled). Progress is rendered as an integer percent (0-100). Add `issues --project `, composing project: { name: { eq: ... } } into the shared IssueFilter builder so it works on both the issues and searchIssues query paths; a missing/blank value is rejected loudly like --search. Add resolveProjectId mirroring resolveTeamId (reserved for v0.3 write-side project assignment). Closes #20 --- skills/linear-axi/SKILL.md | 10 +- src/cli.ts | 8 +- src/commands/issues.ts | 19 ++- src/commands/projects.ts | 71 +++++++++ src/linear.ts | 120 +++++++++++++++ src/skill.ts | 11 +- test/projects.test.ts | 301 +++++++++++++++++++++++++++++++++++++ 7 files changed, 527 insertions(+), 13 deletions(-) create mode 100644 src/commands/projects.ts create mode 100644 test/projects.test.ts diff --git a/skills/linear-axi/SKILL.md b/skills/linear-axi/SKILL.md index 069f9c4..86f3226 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 list teams. Use whenever a task touches Linear: triaging or filing issues, changing issue state, setting priority, commenting, or discovering team keys via `teams`." +description: "Operate Linear (linear.app) through the linear-axi CLI - list and view issues, create, update, and delete issues, post comments, list teams, and list/filter projects. Use whenever a task touches Linear: triaging or filing issues, changing issue state, setting priority, commenting, discovering team keys via `teams`, or scoping work to a project via `projects` and `issues --project`." user-invocable: false author: ApexCloudWise metadata: @@ -20,12 +20,12 @@ 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 discovering team keys. +Use linear-axi whenever a task touches Linear: listing, viewing, filing, editing, or deleting issues; changing issue state or priority; commenting on issues; discovering team keys; or listing projects and scoping issues to one with `issues --project `. ## 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`. +2. Drill in command-first: `issues`, `issue view `, `teams`, `projects`. 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"`). @@ -35,8 +35,8 @@ Use linear-axi whenever a task touches Linear: listing, viewing, filing, editing ## Commands ``` -commands[6]: - (none)=dashboard, issues, issue, comment, teams, login, setup +commands[7]: + (none)=dashboard, issues, issue, comment, teams, projects, login, setup ``` Installed copies also inherit the SDK built-in `update` command. diff --git a/src/cli.ts b/src/cli.ts index 6308a4c..e5f3a7a 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -7,6 +7,7 @@ import { issuesCommand, ISSUES_HELP } from './commands/issues.js'; import { issueCommand, ISSUE_HELP } from './commands/issue.js'; import { commentCommand, COMMENT_HELP } from './commands/comment.js'; import { teamsCommand, TEAMS_HELP } from './commands/teams.js'; +import { projectsCommand, PROJECTS_HELP } from './commands/projects.js'; import { loginCommand, LOGIN_HELP } from './commands/login.js'; import { setupCommand, SETUP_HELP } from './commands/setup.js'; @@ -28,8 +29,8 @@ type LinearCommand = ( ) => Promise; export const TOP_HELP = `usage: linear-axi [command] [args] [flags] -commands[6]: - (none)=dashboard, issues, issue, comment, teams, login, setup +commands[7]: + (none)=dashboard, issues, issue, comment, teams, projects, login, setup globals[1]: --key (after command) or LINEAR_API_KEY env, accepted in space or = form; --help always allowed examples: @@ -41,6 +42,7 @@ examples: linear-axi issue update LIN-123 --state "In Progress" linear-axi comment LIN-123 --body "Looks good" linear-axi teams + linear-axi projects linear-axi login lin_api_xxx linear-axi setup hooks `; @@ -50,6 +52,7 @@ const COMMAND_HELP: Record = { issue: ISSUE_HELP, comment: COMMENT_HELP, teams: TEAMS_HELP, + projects: PROJECTS_HELP, login: LOGIN_HELP, setup: SETUP_HELP, }; @@ -66,6 +69,7 @@ const COMMANDS: Record> = { issue: wrap(issueCommand), comment: wrap(commentCommand), teams: wrap(teamsCommand), + projects: wrap(projectsCommand), login: wrap(loginCommand), setup: wrap(setupCommand), }; diff --git a/src/commands/issues.ts b/src/commands/issues.ts index d01d059..24503fc 100644 --- a/src/commands/issues.ts +++ b/src/commands/issues.ts @@ -15,7 +15,7 @@ import { } from '../toon.js'; import { formatCountLine } from '../format.js'; -export const ISSUES_HELP = `usage: linear-axi issues [--team ] [--state ] [--assignee ] [--label ] [--search ] [--limit ] +export const ISSUES_HELP = `usage: linear-axi issues [--team ] [--state ] [--assignee ] [--label ] [--project ] [--search ] [--limit ] List Linear issues, most recently updated first (relevance-ranked when --search is given). flags: @@ -23,6 +23,7 @@ flags: --state workflow state type: backlog, unstarted, started, completed, canceled, triage --assignee "me" for yourself, or a user name --label filter by label name; repeat to match ANY of the given labels (issues carrying at least one) + --project filter by project name (exact match; run \`linear-axi projects\` for names) --search full-text search (same ranking as Linear's app search); composable with the filters above --limit max issues to return (default 25, max 500; fetched in pages of 50) @@ -31,6 +32,7 @@ examples: linear-axi issues --assignee me --state started linear-axi issues --team LIN --label bug linear-axi issues --label bug --label regression + linear-axi issues --project "Mobile app" --state started linear-axi issues --search "onboarding" --team LIN --state started `; @@ -39,6 +41,7 @@ const KNOWN_FLAGS = [ '--state', '--assignee', '--label', + '--project', '--search', '--limit', ]; @@ -79,6 +82,14 @@ export async function issuesCommand( 'e.g. --search "onboarding"', ]); } + const hasProject = args.some((a) => a === '--project' || a.startsWith('--project=')); + const project = takeFlag(args, '--project'); + if (hasProject && (project === undefined || project.trim() === '')) { + // 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"', + ]); + } const limitRaw = takeFlag(args, '--limit'); const limit = limitRaw ? parseLimit(limitRaw) : 25; @@ -125,6 +136,7 @@ export async function issuesCommand( // Every repeated --label is applied: Linear matches issues carrying at // least one of the names ("any of" semantics, documented in --help). if (labels.length) filter.labels = labels; + if (project) filter.project = project; if (search !== undefined) filter.search = search; const { issues, hasMore } = await fetchIssues(apiKey, filter, limit); @@ -157,6 +169,11 @@ export async function issuesCommand( if (search !== undefined && issues.length === 0) { hints.push(`No matches for "${search}" — try a shorter term or drop a filter`); } + if (project !== undefined && issues.length === 0) { + hints.push( + `No issues in project "${project}" — check the exact name with \`linear-axi projects\``, + ); + } blocks.push(renderHelp(hints)); return renderOutput(blocks); diff --git a/src/commands/projects.ts b/src/commands/projects.ts new file mode 100644 index 0000000..7b6e4d0 --- /dev/null +++ b/src/commands/projects.ts @@ -0,0 +1,71 @@ +import type { LinearContext } from '../context.js'; +import { requireKey } from '../config.js'; +import { assertKnownFlags } from '../args.js'; +import { fetchProjects } from '../linear.js'; +import { + field, + custom, + renderList, + renderHelp, + renderOutput, + type FieldDef, +} from '../toon.js'; +import { formatCountLine } from '../format.js'; + +export const PROJECTS_HELP = `usage: linear-axi projects + List the projects in your Linear workspace, most recently updated first. + +columns: + name project name + state lifecycle state: backlog, planned, started, paused, completed, canceled + progress percent complete, 0-100 + lead project lead (none if unassigned) + target target date as YYYY-MM-DD (none if unset) + +examples: + linear-axi projects + linear-axi issues --project "Mobile app" --state started +`; + +const listSchema: FieldDef[] = [ + field('name'), + // status.type is the raw lowercase lifecycle value (planned, started, ...); + // Project.state is deprecated in favor of Project.status (see linear.ts). + custom('state', (item: any) => item.status?.type ?? ''), + custom('progress', (item: any) => Math.round((item.progress ?? 0) * 100)), + custom('lead', (item: any) => item.lead?.name ?? 'none'), + custom('target', (item: any) => item.targetDate ?? 'none'), +]; + +export async function projectsCommand( + args: string[], + ctx: LinearContext, +): Promise { + // `projects` takes no flags — reject any rather than silently dropping it. + assertKnownFlags(args, []); + const apiKey = requireKey(ctx.apiKey); + const { projects, hasMore } = await fetchProjects(apiKey); + + const blocks: string[] = []; + blocks.push(formatCountLine({ count: projects.length, hasMore })); + + if (projects.length) { + blocks.push(renderList('projects', projects, listSchema)); + } else { + blocks.push('projects: 0 found'); + } + + const hints: string[] = []; + if (projects.length) { + hints.push( + 'Run `linear-axi issues --project ""` to list a project\'s issues', + ); + } else { + hints.push( + 'Create a project in the Linear app — none exist in this workspace yet', + ); + } + blocks.push(renderHelp(hints)); + + return renderOutput(blocks); +} diff --git a/src/linear.ts b/src/linear.ts index dbc35ad..664cce4 100644 --- a/src/linear.ts +++ b/src/linear.ts @@ -81,6 +81,21 @@ export const ISSUE_DETAIL_FIELDS = ` updatedAt `; +/** + * Selection for the `projects` connection. We select `status { type }` rather + * than the deprecated `state` field (Project.state: "[DEPRECATED] Use + * project.status instead" per @linear/sdk): status.type is a ProjectStatusType + * enum yielding the same raw lowercase lifecycle values. + */ +export const PROJECT_LIST_FIELDS = ` + id + name + status { type } + progress + lead { name } + targetDate +`; + export interface LinearIssue { id: string; identifier: string; @@ -102,6 +117,22 @@ export interface LinearTeam { name: string; } +/** + * A Linear project as returned by PROJECT_LIST_FIELDS. `status.type` carries + * the lifecycle state (backlog, planned, started, paused, completed, + * canceled); `progress` is a float 0-1; `targetDate` is a TimelessDate + * serialized as YYYY-MM-DD (null when unset). Field shapes verified against + * the generated types in @linear/sdk (Project, ProjectStatus). + */ +export interface LinearProject { + id: string; + name: string; + status?: { type: string } | null; + progress?: number; + lead?: { name: string } | null; + targetDate?: string | null; +} + export interface LinearViewer { id: string; name: string; @@ -128,12 +159,75 @@ export async function fetchTeams(apiKey: string): Promise { return data.teams.nodes; } +export interface ProjectListResult { + projects: LinearProject[]; + /** + * True when the last fetched page reported `hasNextPage` — more projects + * exist beyond the returned slice (the fetch limit was hit mid-cursor). + */ + hasMore: boolean; +} + +interface ProjectListPage { + nodes: LinearProject[]; + pageInfo?: { hasNextPage?: boolean; endCursor?: string | null } | null; +} + +/** + * List workspace projects, most recently updated first, auto-paginated in + * PAGE_SIZE (50) batches like fetchIssues until `limit` is satisfied or the + * server reports no more pages. + */ +export async function fetchProjects( + apiKey: string, + limit = 100, +): Promise { + const query = `query Projects($first: Int!, $after: String) { + projects(first: $first, after: $after, orderBy: updatedAt) { + nodes { ${PROJECT_LIST_FIELDS} } + pageInfo { hasNextPage endCursor } + } + }`; + + const projects: LinearProject[] = []; + let cursor: string | undefined; + let hasMore = false; + + while (projects.length < limit) { + const first = Math.min(limit - projects.length, PAGE_SIZE); + const variables: Record = { first }; + if (cursor !== undefined) { + variables['after'] = cursor; + } + + const data = await linearRequest<{ projects: ProjectListPage }>( + apiKey, + query, + variables, + ); + + projects.push(...data.projects.nodes); + + hasMore = data.projects.pageInfo?.hasNextPage ?? false; + const nextCursor = data.projects.pageInfo?.endCursor ?? undefined; + + // Same loop guards as fetchIssues: stop on server exhaustion and on a + // non-advancing cursor or empty page that would otherwise spin forever. + if (!hasMore || nextCursor === undefined) break; + if (data.projects.nodes.length === 0 || nextCursor === cursor) break; + cursor = nextCursor; + } + + return { projects: projects.slice(0, limit), hasMore }; +} + export interface IssueListFilter { team?: string; // team key, e.g. "LIN" stateType?: string; // backlog | unstarted | started | completed | canceled | triage assigneeEmail?: string; // exact email (use viewer email for "me") assigneeName?: string; // exact display name labels?: string[]; // label names — matches issues carrying ANY of them + project?: string; // project name — exact match search?: string; // ranked full-text search term (Linear app search ranking) } @@ -162,6 +256,7 @@ interface IssueListPage { * labels: { some: { name: { in: ["Bug", "Regression"] } } } * state: { type: { eq: "started" } } * team: { key: { eq: "LIN" } } + * project: { name: { eq: "Mobile app" } } * * `labels` uses `some: { name: { in: [...] } }` — an issue matches when at * least one of its labels is in the list ("any of" semantics). Shape verified @@ -204,6 +299,9 @@ export async function fetchIssues( if (filter.stateType) { where.push(`state: { type: { eq: ${jsonStr(filter.stateType)} } }`); } + if (filter.project) { + where.push(`project: { name: { eq: ${jsonStr(filter.project)} } }`); + } if (filter.labels?.length) { where.push(`labels: { some: { name: { in: ${jsonStr(filter.labels)} } } }`); } @@ -468,6 +566,28 @@ export async function resolveTeamId( return data.teams.nodes[0]?.id; } +/** + * Resolve a project name (exact match) to its id. Mirrors resolveTeamId; + * currently exercised by tests and reserved for v0.3 write-side project + * assignment (e.g. issue create --project). ProjectFilter.name is a + * StringComparator with `eq` (verified against @linear/sdk). + */ +export async function resolveProjectId( + apiKey: string, + name: string, +): Promise { + const data = await linearRequest<{ + projects: { nodes: Array<{ id: string; name: string }> }; + }>( + apiKey, + `query ProjectByName($name: String!) { + projects(filter: { name: { eq: $name } }) { nodes { id name } } + }`, + { name }, + ); + return data.projects.nodes[0]?.id; +} + async function resolveLabelIds( apiKey: string, names: string[], diff --git a/src/skill.ts b/src/skill.ts index b54ef79..33a64b0 100644 --- a/src/skill.ts +++ b/src/skill.ts @@ -4,9 +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, post comments, and list teams. Use whenever a task ' + - 'touches Linear: triaging or filing issues, changing issue state, setting priority, ' + - 'commenting, or discovering team keys via `teams`.'; + 'create, update, and delete issues, post comments, list teams, and list/filter projects. ' + + 'Use whenever a task touches Linear: triaging or filing issues, changing issue state, setting priority, ' + + 'commenting, discovering team keys via `teams`, or scoping work to a project via `projects` ' + + 'and `issues --project`.'; export const SKILL_AUTHOR = 'ApexCloudWise'; @@ -50,12 +51,12 @@ 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 discovering team keys. +Use linear-axi whenever a task touches Linear: listing, viewing, filing, editing, or deleting issues; changing issue state or priority; commenting on issues; discovering team keys; or listing projects and scoping issues to one with \`issues --project \`. ## 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\`. +2. Drill in command-first: \`issues\`, \`issue view \`, \`teams\`, \`projects\`. 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"\`). diff --git a/test/projects.test.ts b/test/projects.test.ts new file mode 100644 index 0000000..73a8de0 --- /dev/null +++ b/test/projects.test.ts @@ -0,0 +1,301 @@ +// Tests for the `projects` command and `issues --project` filter (#20), +// following the network-free GraphQL stub pattern from issues-search.test.ts +// and issues-pagination.test.ts: stub global fetch, then assert on the +// captured query document, variables, and rendered TOON output. +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { projectsCommand, PROJECTS_HELP } from '../src/commands/projects.js'; +import { issuesCommand, ISSUES_HELP } from '../src/commands/issues.js'; +import { fetchProjects, resolveProjectId } from '../src/linear.js'; +import { AxiError } from '../src/errors.js'; +import type { LinearIssue, LinearProject } from '../src/linear.js'; + +const FAKE_KEY = 'lin_api_fake'; + +interface CapturedRequest { + url: string; + body: { query: string; variables: Record }; +} + +/** A minimal Linear project node matching PROJECT_LIST_FIELDS. */ +function projectNode(overrides: Partial = {}): LinearProject { + return { + id: 'pr-1', + name: 'Mobile app', + status: { type: 'started' }, + progress: 0.42, + lead: { name: 'Ada' }, + targetDate: '2026-09-30', + ...overrides, + }; +} + +/** A minimal Linear issue node matching ISSUE_LIST_FIELDS. */ +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, + }; +} + +function graphqlOk(data: unknown): Response { + return { ok: true, status: 200, json: async () => ({ data }) } as Response; +} + +/** + * 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('projects command', () => { + it('requests name, status type, progress, lead, and targetDate, and renders them', async () => { + const { requests } = stubLinearFetch(() => ({ + projects: { + nodes: [projectNode()], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + })); + + const out = await projectsCommand([], { apiKey: FAKE_KEY }); + + const query = requests[0].body.query; + expect(requests[0].url).toBe('https://api.linear.app/graphql'); + expect(query).toContain('projects('); + expect(query).toContain('name'); + expect(query).toContain('status { type }'); + expect(query).toContain('progress'); + expect(query).toContain('lead { name }'); + expect(query).toContain('targetDate'); + expect(query).toContain('orderBy: updatedAt'); + expect(requests[0].body.variables).toEqual({ first: 50 }); + expect(out).toContain('Mobile app'); + expect(out).toContain('started'); + expect(out).toContain('42'); // 0.42 rendered as integer percent + expect(out).toContain('Ada'); + expect(out).toContain('2026-09-30'); + expect(out).toContain('count: 1'); + }); + + it('renders none for a missing lead or target date', async () => { + stubLinearFetch(() => ({ + projects: { + nodes: [projectNode({ lead: null, targetDate: null })], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + })); + + const out = await projectsCommand([], { apiKey: FAKE_KEY }); + + expect(out).toContain('none'); + }); + + it('renders a definitive empty state when the workspace has no projects', async () => { + stubLinearFetch(() => ({ + projects: { + nodes: [], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + })); + + const out = await projectsCommand([], { apiKey: FAKE_KEY }); + + expect(out).toContain('projects: 0 found'); + expect(out).toContain('Create a project in the Linear app'); + }); + + it('hints at issues --project when projects are listed', async () => { + stubLinearFetch(() => ({ + projects: { + nodes: [projectNode()], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + })); + + const out = await projectsCommand([], { apiKey: FAKE_KEY }); + + expect(out).toContain('issues --project'); + }); + + it('auto-paginates in 50-size batches until the server is exhausted', async () => { + const many = (start: number, count: number): LinearProject[] => + Array.from({ length: count }, (_, i) => + projectNode({ id: `pr-${start + i}`, name: `Project ${start + i}` }), + ); + + const { requests } = stubLinearFetch((req) => { + const after = req.body.variables['after']; + if (after === undefined) + return { + projects: { + nodes: many(1, 50), + pageInfo: { hasNextPage: true, endCursor: 'c1' }, + }, + }; + if (after === 'c1') + return { + projects: { + nodes: many(51, 10), + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }; + return { + projects: { + nodes: [], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }; + }); + + const { projects, hasMore } = await fetchProjects(FAKE_KEY); + + expect(requests).toHaveLength(2); + expect(requests[0].body.variables).toEqual({ first: 50 }); + expect(requests[1].body.variables).toEqual({ first: 50, after: 'c1' }); + expect(projects).toHaveLength(60); + expect(projects[0]!.name).toBe('Project 1'); + expect(projects[59]!.name).toBe('Project 60'); + expect(hasMore).toBe(false); + }); + + it('documents state values and percent progress in --help', () => { + expect(PROJECTS_HELP).toContain( + 'backlog, planned, started, paused, completed, canceled', + ); + expect(PROJECTS_HELP).toContain('percent complete, 0-100'); + }); + + it('rejects unknown flags loudly (no flags are valid)', async () => { + stubLinearFetch(() => ({})); + + await expect( + projectsCommand(['--bogus'], { apiKey: FAKE_KEY }), + ).rejects.toThrow(/unknown flag --bogus/); + }); +}); + +describe('issues --project filter', () => { + it('composes project with team and state into the IssueFilter', async () => { + const { requests } = stubLinearFetch(() => ({ + issues: { nodes: [issueNode()] }, + })); + + const out = await issuesCommand( + ['--project', 'Mobile app', '--team', 'LIN', '--state', 'started'], + { apiKey: FAKE_KEY }, + ); + + const query = requests[0].body.query; + expect(query).toContain('issues('); + expect(query).toContain('project: { name: { eq: "Mobile app" } }'); + expect(query).toContain('team: { key: { eq: "LIN" } }'); + expect(query).toContain('state: { type: { eq: "started" } }'); + expect(out).toContain('LIN-1'); + }); + + it('composes project with --search in the searchIssues query', async () => { + const { requests } = stubLinearFetch(() => ({ + searchIssues: { nodes: [] }, + })); + + const out = await issuesCommand( + ['--search', 'crash', '--project', 'Mobile app'], + { apiKey: FAKE_KEY }, + ); + + const query = requests[0].body.query; + expect(query).toContain('searchIssues('); + expect(query).toContain('project: { name: { eq: "Mobile app" } }'); + expect(requests[0].body.variables).toEqual({ term: 'crash', first: 25 }); + // Empty search result keeps the search hint and gains no project noise. + expect(out).toContain('issues: 0 found'); + expect(out).toContain('No matches for "crash"'); + }); + + it('hints at the exact-name check when a project filter matches nothing', async () => { + stubLinearFetch(() => ({ + issues: { nodes: [] }, + })); + + const out = await issuesCommand(['--project', 'Mobile app'], { + apiKey: FAKE_KEY, + }); + + expect(out).toContain( + 'No issues in project "Mobile app" — check the exact name with `linear-axi projects`', + ); + }); + + it('rejects a missing or blank --project value instead of dropping it', async () => { + stubLinearFetch(() => ({})); + + await expect( + issuesCommand(['--project'], { apiKey: FAKE_KEY }), + ).rejects.toThrow(AxiError); + + await expect( + issuesCommand(['--project='], { apiKey: FAKE_KEY }), + ).rejects.toThrow(/--project requires a value/); + }); + + it('rejects unknown flags alongside --project', async () => { + stubLinearFetch(() => ({})); + + await expect( + issuesCommand(['--project', 'X', '--bogus'], { apiKey: FAKE_KEY }), + ).rejects.toThrow(/unknown flag --bogus/); + }); + + it('documents --project in --help', () => { + expect(ISSUES_HELP).toContain('--project '); + expect(ISSUES_HELP).toContain('linear-axi projects'); + }); +}); + +describe('resolveProjectId', () => { + it('resolves an exact project name to its id', async () => { + const { requests } = stubLinearFetch(() => ({ + projects: { nodes: [{ id: 'pr-9', name: 'Mobile app' }] }, + })); + + const id = await resolveProjectId(FAKE_KEY, 'Mobile app'); + + expect(id).toBe('pr-9'); + expect(requests[0].body.query).toContain( + 'projects(filter: { name: { eq: $name } })', + ); + expect(requests[0].body.variables).toEqual({ name: 'Mobile app' }); + }); + + it('returns undefined when no project matches', async () => { + stubLinearFetch(() => ({ + projects: { nodes: [] }, + })); + + const id = await resolveProjectId(FAKE_KEY, 'No such project'); + + expect(id).toBeUndefined(); + }); +}); From 672c36e6328050c2f0d002aa9532efcdb6e65470 Mon Sep 17 00:00:00 2001 From: Sam Whitmore Date: Thu, 20 Aug 2026 03:29:34 +0530 Subject: [PATCH 4/9] feat: add cycle support with cycles command --- skills/linear-axi/SKILL.md | 11 +- src/cli.ts | 8 +- src/commands/cycles.ts | 121 +++++++++++ src/commands/issues.ts | 47 ++++- src/linear.ts | 145 ++++++++++++++ src/skill.ts | 11 +- test/cycles.test.ts | 401 +++++++++++++++++++++++++++++++++++++ 7 files changed, 731 insertions(+), 13 deletions(-) create mode 100644 src/commands/cycles.ts create mode 100644 test/cycles.test.ts diff --git a/skills/linear-axi/SKILL.md b/skills/linear-axi/SKILL.md index 86f3226..4b19874 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, list teams, and list/filter projects. Use whenever a task touches Linear: triaging or filing issues, changing issue state, setting priority, commenting, discovering team keys via `teams`, or scoping work to a project via `projects` and `issues --project`." +description: "Operate Linear (linear.app) through the linear-axi CLI - list and view issues, create, update, and delete issues, post comments, list teams, list/filter projects, and list cycles. Use whenever a task touches Linear: triaging or filing issues, changing issue state, setting priority, commenting, discovering team keys via `teams`, scoping work to a project via `projects` and `issues --project`, or to a cycle via `cycles` and `issues --cycle`." user-invocable: false author: ApexCloudWise metadata: @@ -20,12 +20,12 @@ 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; discovering team keys; or listing projects and scoping issues to one with `issues --project `. +Use linear-axi whenever a task touches Linear: listing, viewing, filing, editing, or deleting issues; changing issue state or priority; commenting on issues; discovering team keys; listing projects and scoping issues to one with `issues --project `; or listing a team's cycles and scoping issues to one with `issues --cycle current| --team `. ## 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`. +2. Drill in command-first: `issues`, `issue view `, `teams`, `projects`, `cycles`. 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"`). @@ -35,8 +35,8 @@ Use linear-axi whenever a task touches Linear: listing, viewing, filing, editing ## Commands ``` -commands[7]: - (none)=dashboard, issues, issue, comment, teams, projects, login, setup +commands[8]: + (none)=dashboard, issues, issue, comment, teams, projects, cycles, login, setup ``` Installed copies also inherit the SDK built-in `update` command. @@ -50,5 +50,6 @@ 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. +- `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 ]`. - `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/cli.ts b/src/cli.ts index e5f3a7a..a76cb99 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -8,6 +8,7 @@ import { issueCommand, ISSUE_HELP } from './commands/issue.js'; import { commentCommand, COMMENT_HELP } from './commands/comment.js'; import { teamsCommand, TEAMS_HELP } from './commands/teams.js'; import { projectsCommand, PROJECTS_HELP } from './commands/projects.js'; +import { cyclesCommand, CYCLES_HELP } from './commands/cycles.js'; import { loginCommand, LOGIN_HELP } from './commands/login.js'; import { setupCommand, SETUP_HELP } from './commands/setup.js'; @@ -29,8 +30,8 @@ type LinearCommand = ( ) => Promise; export const TOP_HELP = `usage: linear-axi [command] [args] [flags] -commands[7]: - (none)=dashboard, issues, issue, comment, teams, projects, login, setup +commands[8]: + (none)=dashboard, issues, issue, comment, teams, projects, cycles, login, setup globals[1]: --key (after command) or LINEAR_API_KEY env, accepted in space or = form; --help always allowed examples: @@ -43,6 +44,7 @@ examples: linear-axi comment LIN-123 --body "Looks good" linear-axi teams linear-axi projects + linear-axi cycles --team LIN linear-axi login lin_api_xxx linear-axi setup hooks `; @@ -53,6 +55,7 @@ const COMMAND_HELP: Record = { comment: COMMENT_HELP, teams: TEAMS_HELP, projects: PROJECTS_HELP, + cycles: CYCLES_HELP, login: LOGIN_HELP, setup: SETUP_HELP, }; @@ -70,6 +73,7 @@ const COMMANDS: Record> = { comment: wrap(commentCommand), teams: wrap(teamsCommand), projects: wrap(projectsCommand), + cycles: wrap(cyclesCommand), login: wrap(loginCommand), setup: wrap(setupCommand), }; diff --git a/src/commands/cycles.ts b/src/commands/cycles.ts new file mode 100644 index 0000000..291b9d5 --- /dev/null +++ b/src/commands/cycles.ts @@ -0,0 +1,121 @@ +import type { LinearContext } from '../context.js'; +import { requireKey } from '../config.js'; +import { assertKnownFlags, takeFlag } from '../args.js'; +import { AxiError } from '../errors.js'; +import { fetchCycles } from '../linear.js'; +import { + field, + pluck, + custom, + renderList, + renderHelp, + renderOutput, + type FieldDef, +} from '../toon.js'; +import { formatCountLine } from '../format.js'; + +export const CYCLES_HELP = `usage: linear-axi cycles [--team ] + List recent cycles with their date range and progress, most recently updated first. + +flags: + --team restrict to one team (e.g. LIN); without it, cycles across all teams are listed, each tagged with its team key + +columns: + number cycle number (unique within its team) + team team key (only shown without --team) + status past, active, or upcoming — derived from the date range + range cycle start..end as YYYY-MM-DD..YYYY-MM-DD + progress percent complete, 0-100 + +examples: + linear-axi cycles + linear-axi cycles --team LIN + linear-axi issues --cycle current --team LIN +`; + +/** Field order: number, [team], status, range, progress. */ +function listSchema(withTeam: boolean): FieldDef[] { + return [ + field('number'), + ...(withTeam ? [pluck('team', 'key', 'team')] : []), + custom('status', (item: any) => cycleStatus(item)), + custom('range', (item: any) => cycleRange(item)), + custom('progress', (item: any) => Math.round((item.progress ?? 0) * 100)), + ]; +} + +/** + * Cycle status from its date range. upcoming = starts in the future; + * past = ended; active = in between. (Linear's Cycle.isActive also excludes + * manually early-completed cycles, which the dates alone cannot see — close + * enough for a listing, and no extra field is fetched.) + */ +function cycleStatus(item: any): string { + const now = Date.now(); + const start = new Date(item.startsAt).getTime(); + const end = new Date(item.endsAt).getTime(); + if (!isNaN(start) && now < start) return 'upcoming'; + if (!isNaN(end) && now >= end) return 'past'; + return 'active'; +} + +/** Date range as YYYY-MM-DD..YYYY-MM-DD (DateTime date part). */ +function cycleRange(item: any): string { + const start = String(item.startsAt ?? '').slice(0, 10) || 'unknown'; + const end = String(item.endsAt ?? '').slice(0, 10) || 'unknown'; + return `${start}..${end}`; +} + +export async function cyclesCommand( + args: string[], + ctx: LinearContext, +): Promise { + assertKnownFlags(args, ['--team']); + const apiKey = requireKey(ctx.apiKey); + + // A missing/blank --team must fail loud, not fall back to all-teams. + const hasTeam = args.some((a) => a === '--team' || a.startsWith('--team=')); + const team = takeFlag(args, '--team'); + if (hasTeam && (team === undefined || team.trim() === '')) { + throw new AxiError('--team requires a value', 'VALIDATION_ERROR', [ + 'e.g. --team LIN', + ]); + } + + if (args.some((a) => !a.startsWith('--') && a !== '')) { + const stray = args.filter((a) => !a.startsWith('--')); + throw new AxiError(`Unexpected argument: ${stray[0]}`, 'VALIDATION_ERROR', [ + '`cycles` takes only --team — run `linear-axi cycles --help`', + ]); + } + + const { cycles, hasMore } = await fetchCycles(apiKey, team); + + const blocks: string[] = []; + blocks.push(formatCountLine({ count: cycles.length, hasMore })); + + if (cycles.length) { + blocks.push(renderList('cycles', cycles, listSchema(team === undefined))); + } else { + blocks.push('cycles: 0 found'); + } + + const hints: string[] = []; + if (cycles.length) { + const key = team ?? cycles[0]?.team?.key ?? ''; + hints.push( + `Run \`linear-axi issues --cycle current --team ${key}\` to list the active cycle's issues`, + ); + } else if (team) { + hints.push( + `Team ${team} has no cycles — cycles may be disabled for it in Linear's team settings`, + ); + } else { + hints.push( + "No cycles in this workspace yet — enable cycles for a team in Linear's team settings", + ); + } + blocks.push(renderHelp(hints)); + + return renderOutput(blocks); +} diff --git a/src/commands/issues.ts b/src/commands/issues.ts index 24503fc..92a4c1c 100644 --- a/src/commands/issues.ts +++ b/src/commands/issues.ts @@ -15,7 +15,7 @@ import { } from '../toon.js'; import { formatCountLine } from '../format.js'; -export const ISSUES_HELP = `usage: linear-axi issues [--team ] [--state ] [--assignee ] [--label ] [--project ] [--search ] [--limit ] +export const ISSUES_HELP = `usage: linear-axi issues [--team ] [--state ] [--assignee ] [--label ] [--project ] [--cycle ] [--search ] [--limit ] List Linear issues, most recently updated first (relevance-ranked when --search is given). flags: @@ -24,6 +24,7 @@ flags: --assignee "me" for yourself, or a user name --label filter by label name; repeat to match ANY of the given labels (issues carrying at least one) --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 --limit max issues to return (default 25, max 500; fetched in pages of 50) @@ -33,6 +34,7 @@ examples: linear-axi issues --team LIN --label bug linear-axi issues --label bug --label regression linear-axi issues --project "Mobile app" --state started + linear-axi issues --cycle current --team LIN linear-axi issues --search "onboarding" --team LIN --state started `; @@ -42,6 +44,7 @@ const KNOWN_FLAGS = [ '--assignee', '--label', '--project', + '--cycle', '--search', '--limit', ]; @@ -90,6 +93,13 @@ export async function issuesCommand( 'e.g. --project "Mobile app"', ]); } + const hasCycle = args.some((a) => a === '--cycle' || a.startsWith('--cycle=')); + const cycle = takeFlag(args, '--cycle'); + if (hasCycle && (cycle === undefined || cycle.trim() === '')) { + throw new AxiError('--cycle requires a value', 'VALIDATION_ERROR', [ + 'Use --cycle current for the active cycle, or --cycle --team ', + ]); + } const limitRaw = takeFlag(args, '--limit'); const limit = limitRaw ? parseLimit(limitRaw) : 25; @@ -103,6 +113,33 @@ export async function issuesCommand( ); } + // "current" filters to the active cycle(s); a number is a per-team cycle + // number and is ambiguous without --team (cycle 42 exists once per team), + // so the bare-number form fails loud instead of guessing a team. + let cycleFilter: 'current' | number | undefined; + if (cycle !== undefined) { + const value = cycle.trim().toLowerCase(); + if (value === 'current') { + cycleFilter = 'current'; + } else if (/^\d+$/.test(value) && Number(value) >= 1) { + if (team === undefined) { + throw new AxiError( + `--cycle requires --team (cycle numbers restart per team)`, + 'VALIDATION_ERROR', + [ + `e.g. --cycle ${value} --team LIN`, + 'Or use --cycle current without --team for every team\'s active cycle', + ], + ); + } + cycleFilter = Number(value); + } else { + throw new AxiError(`Invalid --cycle: ${cycle}`, 'VALIDATION_ERROR', [ + 'Use "current" for the active cycle, or a positive cycle number with --team', + ]); + } + } + if (args.some((a) => !a.startsWith('--') && a !== '')) { const stray = args.filter((a) => !a.startsWith('--')); throw new AxiError( @@ -137,6 +174,7 @@ export async function issuesCommand( // least one of the names ("any of" semantics, documented in --help). if (labels.length) filter.labels = labels; if (project) filter.project = project; + if (cycleFilter !== undefined) filter.cycle = cycleFilter; if (search !== undefined) filter.search = search; const { issues, hasMore } = await fetchIssues(apiKey, filter, limit); @@ -174,6 +212,13 @@ export async function issuesCommand( `No issues in project "${project}" — check the exact name with \`linear-axi projects\``, ); } + if (cycleFilter !== undefined && issues.length === 0) { + hints.push( + cycleFilter === 'current' + ? 'No issues in an active cycle — run `linear-axi cycles` to see cycles' + : `No issues in cycle ${cycleFilter} — run \`linear-axi cycles --team ${team}\` to see the team's cycles`, + ); + } blocks.push(renderHelp(hints)); return renderOutput(blocks); diff --git a/src/linear.ts b/src/linear.ts index 664cce4..d936759 100644 --- a/src/linear.ts +++ b/src/linear.ts @@ -96,6 +96,20 @@ export const PROJECT_LIST_FIELDS = ` targetDate `; +/** + * Selection for cycle nodes. Only the fields the `cycles` command renders: + * number (per-team identifier), the date range, and progress. The team key is + * added by the caller when fetching the root `cycles` connection (cycles from + * all teams are mixed there and need attribution). + */ +export const CYCLE_LIST_FIELDS = ` + id + number + startsAt + endsAt + progress +`; + export interface LinearIssue { id: string; identifier: string; @@ -133,6 +147,25 @@ export interface LinearProject { targetDate?: string | null; } +/** + * A Linear cycle as returned by CYCLE_LIST_FIELDS. Cycles are a team's + * time-boxed iterations: `number` is auto-incrementing and unique within its + * team (it restarts per team — cycle 42 of LIN and cycle 42 of ENG are + * different cycles); `progress` is a float 0-1 ("(completed estimate points + + * 0.25 * in-progress estimate points) / total estimate points", 0 when nothing + * is estimated); `startsAt`/`endsAt` are ISO DateTimes. Field shapes verified + * against the generated types in @linear/sdk (Cycle). + */ +export interface LinearCycle { + id: string; + number: number; + startsAt: string; + endsAt: string; + progress?: number; + /** Present when fetched via the root `cycles` connection (absent per-team). */ + team?: { key: string } | null; +} + export interface LinearViewer { id: string; name: string; @@ -221,6 +254,104 @@ export async function fetchProjects( return { projects: projects.slice(0, limit), hasMore }; } +export interface CycleListResult { + cycles: LinearCycle[]; + /** + * True when the last fetched page reported `hasNextPage` — more cycles + * exist beyond the returned slice (the fetch limit was hit mid-cursor). + */ + hasMore: boolean; +} + +interface CycleListPage { + nodes: LinearCycle[]; + pageInfo?: { hasNextPage?: boolean; endCursor?: string | null } | null; +} + +/** + * List recent cycles, most recently updated first, auto-paginated in PAGE_SIZE + * (50) batches like fetchProjects until `limit` is satisfied or the server + * reports no more pages. + * + * With `teamKey`, the team is resolved to its id (resolveTeamId) and its + * `team(id:) { cycles }` connection is queried. Without, the root `cycles` + * connection ("all cycles accessible to the user") is queried and each node + * also selects `team { key }` so mixed-team rows can be attributed — one + * request instead of one per team. Both connections take the same args + * (filter/first/after/orderBy: PaginationOrderBy) per @linear/sdk + * (TeamCyclesArgs, QueryCyclesArgs); PaginationOrderBy only offers + * createdAt/updatedAt, so updatedAt is the recent-first ordering, matching + * issues and projects. + * + * An unknown team key fails loud rather than silently returning nothing. + */ +export async function fetchCycles( + apiKey: string, + teamKey?: string, + limit = 10, +): Promise { + let teamId: string | undefined; + if (teamKey !== undefined) { + teamId = await resolveTeamId(apiKey, teamKey); + if (!teamId) { + throw new AxiError( + `Team "${teamKey}" not found in your workspace`, + 'VALIDATION_ERROR', + ['Run `linear-axi teams` to see available team keys'], + ); + } + } + + const perTeam = teamId !== undefined; + const query = perTeam + ? `query TeamCycles($id: String!, $first: Int!, $after: String) { + team(id: $id) { + cycles(first: $first, after: $after, orderBy: updatedAt) { + nodes { ${CYCLE_LIST_FIELDS} } + pageInfo { hasNextPage endCursor } + } + } + }` + : `query Cycles($first: Int!, $after: String) { + cycles(first: $first, after: $after, orderBy: updatedAt) { + nodes { ${CYCLE_LIST_FIELDS} team { key } } + pageInfo { hasNextPage endCursor } + } + }`; + + const cycles: LinearCycle[] = []; + let cursor: string | undefined; + let hasMore = false; + + while (cycles.length < limit) { + const first = Math.min(limit - cycles.length, PAGE_SIZE); + const variables: Record = perTeam + ? { id: teamId, first } + : { first }; + if (cursor !== undefined) { + variables['after'] = cursor; + } + + const data = await linearRequest<{ + team?: { cycles?: CycleListPage } | null; + cycles?: CycleListPage; + }>(apiKey, query, variables); + + const conn = (perTeam ? data.team?.cycles : data.cycles) as CycleListPage; + cycles.push(...conn.nodes); + + hasMore = conn.pageInfo?.hasNextPage ?? false; + const nextCursor = conn.pageInfo?.endCursor ?? undefined; + + // Same loop guards as fetchIssues/fetchProjects. + if (!hasMore || nextCursor === undefined) break; + if (conn.nodes.length === 0 || nextCursor === cursor) break; + cursor = nextCursor; + } + + return { cycles: cycles.slice(0, limit), hasMore }; +} + export interface IssueListFilter { team?: string; // team key, e.g. "LIN" stateType?: string; // backlog | unstarted | started | completed | canceled | triage @@ -228,6 +359,7 @@ export interface IssueListFilter { assigneeName?: string; // exact display name labels?: string[]; // label names — matches issues carrying ANY of them project?: string; // project name — exact match + cycle?: 'current' | number; // 'current' = any team's active cycle; a number is per-team (compose with team) search?: string; // ranked full-text search term (Linear app search ranking) } @@ -257,6 +389,8 @@ interface IssueListPage { * state: { type: { eq: "started" } } * team: { key: { eq: "LIN" } } * project: { name: { eq: "Mobile app" } } + * cycle: { isActive: { eq: true } } // the active cycle(s) + * cycle: { number: { eq: 42 } } // a per-team cycle number * * `labels` uses `some: { name: { in: [...] } }` — an issue matches when at * least one of its labels is in the list ("any of" semantics). Shape verified @@ -305,6 +439,17 @@ export async function fetchIssues( if (filter.labels?.length) { where.push(`labels: { some: { name: { in: ${jsonStr(filter.labels)} } } }`); } + if (filter.cycle !== undefined) { + // IssueFilter.cycle is a NullableCycleFilter (verified against the + // generated types in @linear/sdk): isActive is a BooleanComparator + // (eq/neq) and number is a NumberComparator with eq, so both shapes below + // are schema-valid — no cycle-id resolution round trip is needed. + where.push( + filter.cycle === 'current' + ? `cycle: { isActive: { eq: true } }` + : `cycle: { number: { eq: ${filter.cycle} } }`, + ); + } const filterPart = where.length ? `filter: { ${where.join(', ')} }, ` : ''; const isSearch = filter.search !== undefined; diff --git a/src/skill.ts b/src/skill.ts index 33a64b0..f3d2bb4 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, post comments, list teams, and list/filter projects. ' + + 'create, update, and delete issues, post comments, list teams, list/filter projects, and list cycles. ' + 'Use whenever a task touches Linear: triaging or filing issues, changing issue state, setting priority, ' + - 'commenting, discovering team keys via `teams`, or scoping work to a project via `projects` ' + - 'and `issues --project`.'; + 'commenting, discovering team keys via `teams`, scoping work to a project via `projects` ' + + 'and `issues --project`, or to a cycle via `cycles` and `issues --cycle`.'; export const SKILL_AUTHOR = 'ApexCloudWise'; @@ -51,12 +51,12 @@ 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; discovering team keys; or listing projects and scoping issues to one with \`issues --project \`. +Use linear-axi whenever a task touches Linear: listing, viewing, filing, editing, or deleting issues; changing issue state or priority; commenting on issues; discovering team keys; listing projects and scoping issues to one with \`issues --project \`; or listing a team's cycles and scoping issues to one with \`issues --cycle current| --team \`. ## 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\`. +2. Drill in command-first: \`issues\`, \`issue view \`, \`teams\`, \`projects\`, \`cycles\`. 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"\`). @@ -80,6 +80,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. +- \`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 ]\`. - \`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/cycles.test.ts b/test/cycles.test.ts new file mode 100644 index 0000000..339ec5a --- /dev/null +++ b/test/cycles.test.ts @@ -0,0 +1,401 @@ +// Tests for the `cycles` command and `issues --cycle` filter (#21), following +// the network-free GraphQL stub pattern from projects.test.ts: stub global +// fetch, then assert on the captured query document, variables, and rendered +// TOON output. +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { cyclesCommand, CYCLES_HELP } from '../src/commands/cycles.js'; +import { issuesCommand, ISSUES_HELP } from '../src/commands/issues.js'; +import { fetchCycles } from '../src/linear.js'; +import { AxiError } from '../src/errors.js'; +import type { LinearCycle, LinearIssue } from '../src/linear.js'; + +const FAKE_KEY = 'lin_api_fake'; + +const DAY_MS = 24 * 60 * 60 * 1000; + +/** ISO timestamp n days from now (negative = past) — keeps status derivation stable forever. */ +function daysFromNow(n: number): string { + return new Date(Date.now() + n * DAY_MS).toISOString(); +} + +/** A minimal Linear cycle node matching CYCLE_LIST_FIELDS (+ team via the root connection). */ +function cycleNode(overrides: Partial = {}): LinearCycle { + return { + id: 'cy-1', + number: 42, + startsAt: daysFromNow(-10), + endsAt: daysFromNow(4), + progress: 0.25, + team: { key: 'LIN' }, + ...overrides, + }; +} + +/** A minimal Linear issue node matching ISSUE_LIST_FIELDS. */ +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, + }; +} + +function graphqlOk(data: unknown): Response { + return { ok: true, status: 200, json: async () => ({ data }) } as Response; +} + +/** + * 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 }; +} + +interface CapturedRequest { + url: string; + body: { query: string; variables: Record }; +} + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('cycles command', () => { + it('queries the root cycles connection recent-first and renders number, team, status, range, and progress', async () => { + const start = daysFromNow(-10); + const end = daysFromNow(4); + const { requests } = stubLinearFetch(() => ({ + cycles: { + nodes: [cycleNode({ startsAt: start, endsAt: end })], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + })); + + const out = await cyclesCommand([], { apiKey: FAKE_KEY }); + + const query = requests[0].body.query; + expect(requests[0].url).toBe('https://api.linear.app/graphql'); + expect(query).toContain('query Cycles('); + expect(query).toContain( + 'cycles(first: $first, after: $after, orderBy: updatedAt)', + ); + expect(query).toContain('number'); + expect(query).toContain('startsAt'); + expect(query).toContain('endsAt'); + expect(query).toContain('progress'); + expect(query).toContain('team { key }'); // mixed-team rows are attributed + 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(`${start.slice(0, 10)}..${end.slice(0, 10)}`); + expect(out).toContain('25'); // progress 0.25 rendered as integer percent + expect(out).toContain('count: 1'); + }); + + it('resolves --team and queries the team { cycles } connection without the team column', async () => { + const { requests } = stubLinearFetch((req) => { + if (req.body.query.includes('TeamByKey')) { + return { teams: { nodes: [{ id: 'team-1', key: 'LIN' }] } }; + } + return { + team: { + cycles: { + nodes: [cycleNode()], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }, + }; + }); + + const out = await cyclesCommand(['--team', 'LIN'], { apiKey: FAKE_KEY }); + + expect(requests[0].body.query).toContain( + 'teams(filter: { key: { eq: $key } })', + ); + expect(requests[0].body.variables).toEqual({ key: 'LIN' }); + + const query = requests[1].body.query; + expect(query).toContain('query TeamCycles('); + expect(query).toContain('team(id: $id)'); + expect(query).toContain( + 'cycles(first: $first, after: $after, orderBy: updatedAt)', + ); + // Per-team rows don't re-select team { key } — the team is already known. + 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('count: 1'); + }); + + it('rejects an unknown --team key loudly', async () => { + stubLinearFetch(() => ({ teams: { nodes: [] } })); + + await expect( + cyclesCommand(['--team', 'NOPE'], { apiKey: FAKE_KEY }), + ).rejects.toThrow(/Team "NOPE" not found/); + }); + + it('derives past and upcoming statuses from the date range', async () => { + const past = cycleNode({ + id: 'cy-p', + number: 40, + startsAt: daysFromNow(-20), + endsAt: daysFromNow(-10), + }); + const upcoming = cycleNode({ + id: 'cy-u', + number: 43, + startsAt: daysFromNow(2), + endsAt: daysFromNow(12), + }); + const active = cycleNode({ id: 'cy-a', number: 42 }); + stubLinearFetch(() => ({ cycles: { nodes: [past, upcoming, active] } })); + + const out = await cyclesCommand([], { apiKey: FAKE_KEY }); + + expect(out).toContain('past'); + expect(out).toContain('upcoming'); + expect(out).toContain('active'); + }); + + it('renders a definitive empty state when the workspace has no cycles', async () => { + stubLinearFetch(() => ({ + cycles: { nodes: [], pageInfo: { hasNextPage: false, endCursor: null } }, + })); + + const out = await cyclesCommand([], { apiKey: FAKE_KEY }); + + expect(out).toContain('cycles: 0 found'); + expect(out).toContain('No cycles in this workspace yet'); + }); + + it('hints at issues --cycle when cycles are listed', async () => { + stubLinearFetch(() => ({ + cycles: { + nodes: [cycleNode()], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + })); + + const out = await cyclesCommand([], { apiKey: FAKE_KEY }); + + expect(out).toContain('issues --cycle current --team LIN'); + }); + + it('rejects unknown flags loudly (only --team is valid)', async () => { + stubLinearFetch(() => ({})); + + await expect( + cyclesCommand(['--limit', '5'], { apiKey: FAKE_KEY }), + ).rejects.toThrow(/unknown flag --limit/); + }); + + it('rejects a missing or blank --team value instead of listing all teams', async () => { + stubLinearFetch(() => ({})); + + await expect( + cyclesCommand(['--team'], { apiKey: FAKE_KEY }), + ).rejects.toThrow(/--team requires a value/); + await expect( + cyclesCommand(['--team='], { apiKey: FAKE_KEY }), + ).rejects.toThrow(/--team requires a value/); + }); + + it('auto-paginates in 50-size batches until the limit is satisfied', async () => { + const many = (start: number, count: number): LinearCycle[] => + Array.from({ length: count }, (_, i) => + cycleNode({ id: `cy-${start + i}`, number: start + i }), + ); + + const { requests } = stubLinearFetch((req) => { + const after = req.body.variables['after']; + if (after === undefined) + return { + cycles: { + nodes: many(1, 50), + pageInfo: { hasNextPage: true, endCursor: 'c1' }, + }, + }; + if (after === 'c1') + return { + cycles: { + nodes: many(51, 50), + pageInfo: { hasNextPage: true, endCursor: 'c2' }, + }, + }; + if (after === 'c2') + return { + cycles: { + nodes: many(101, 20), + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }; + return { + cycles: { + nodes: [], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }; + }); + + const { cycles, hasMore } = await fetchCycles(FAKE_KEY, undefined, 120); + + expect(requests).toHaveLength(3); + expect(requests[0].body.variables).toEqual({ first: 50 }); + expect(requests[1].body.variables).toEqual({ first: 50, after: 'c1' }); + expect(requests[2].body.variables).toEqual({ first: 20, after: 'c2' }); + expect(cycles).toHaveLength(120); + expect(cycles[0]!.number).toBe(1); + expect(cycles[119]!.number).toBe(120); + expect(hasMore).toBe(false); + }); + + it('documents columns and the --team flag in --help', () => { + expect(CYCLES_HELP).toContain('--team '); + expect(CYCLES_HELP).toContain('past, active, or upcoming'); + expect(CYCLES_HELP).toContain('percent complete, 0-100'); + expect(CYCLES_HELP).toContain('issues --cycle current --team LIN'); + }); +}); + +describe('issues --cycle filter', () => { + it('composes --cycle current with --team into the IssueFilter', async () => { + const { requests } = stubLinearFetch(() => ({ + issues: { nodes: [issueNode()] }, + })); + + const out = await issuesCommand(['--cycle', 'current', '--team', 'LIN'], { + apiKey: FAKE_KEY, + }); + + const query = requests[0].body.query; + expect(query).toContain('issues('); + expect(query).toContain('cycle: { isActive: { eq: true } }'); + expect(query).toContain('team: { key: { eq: "LIN" } }'); + expect(out).toContain('LIN-1'); + }); + + it('applies --cycle current without --team across every active cycle', async () => { + const { requests } = stubLinearFetch(() => ({ + issues: { nodes: [issueNode()] }, + })); + + await issuesCommand(['--cycle', 'current'], { apiKey: FAKE_KEY }); + + const query = requests[0].body.query; + expect(query).toContain('cycle: { isActive: { eq: true } }'); + expect(query).not.toContain('team: {'); + }); + + it('filters a numeric --cycle to that team cycle number', async () => { + const { requests } = stubLinearFetch(() => ({ + issues: { nodes: [issueNode()] }, + })); + + await issuesCommand(['--cycle', '42', '--team', 'LIN'], { + apiKey: FAKE_KEY, + }); + + const query = requests[0].body.query; + expect(query).toContain('cycle: { number: { eq: 42 } }'); + expect(query).toContain('team: { key: { eq: "LIN" } }'); + }); + + it('rejects a numeric --cycle without --team loudly', async () => { + stubLinearFetch(() => ({})); + + await expect( + issuesCommand(['--cycle', '42'], { apiKey: FAKE_KEY }), + ).rejects.toThrow(AxiError); + await expect( + issuesCommand(['--cycle', '7'], { apiKey: FAKE_KEY }), + ).rejects.toThrow(/requires --team/); + }); + + it('rejects values that are neither "current" nor a positive number', async () => { + stubLinearFetch(() => ({})); + + await expect( + issuesCommand(['--cycle', 'next'], { apiKey: FAKE_KEY }), + ).rejects.toThrow(/Invalid --cycle: next/); + await expect( + issuesCommand(['--cycle', '0'], { apiKey: FAKE_KEY }), + ).rejects.toThrow(/Invalid --cycle: 0/); + await expect( + issuesCommand(['--cycle', '-3'], { apiKey: FAKE_KEY }), + ).rejects.toThrow(/Invalid --cycle: -3/); + }); + + it('rejects a missing or blank --cycle value instead of dropping it', async () => { + stubLinearFetch(() => ({})); + + await expect( + issuesCommand(['--cycle'], { apiKey: FAKE_KEY }), + ).rejects.toThrow(/--cycle requires a value/); + await expect( + issuesCommand(['--cycle='], { apiKey: FAKE_KEY }), + ).rejects.toThrow(/--cycle requires a value/); + }); + + it('composes --cycle with --search in the searchIssues query', async () => { + const { requests } = stubLinearFetch(() => ({ + searchIssues: { nodes: [] }, + })); + + const out = await issuesCommand( + ['--search', 'crash', '--cycle', 'current', '--team', 'LIN'], + { apiKey: FAKE_KEY }, + ); + + const query = requests[0].body.query; + expect(query).toContain('searchIssues('); + expect(query).toContain('cycle: { isActive: { eq: true } }'); + expect(query).toContain('team: { key: { eq: "LIN" } }'); + expect(requests[0].body.variables).toEqual({ term: 'crash', first: 25 }); + expect(out).toContain('issues: 0 found'); + }); + + it('hints at cycles when a cycle filter matches nothing', async () => { + stubLinearFetch(() => ({ issues: { nodes: [] } })); + + const out = await issuesCommand(['--cycle', '42', '--team', 'LIN'], { + apiKey: FAKE_KEY, + }); + + expect(out).toContain('No issues in cycle 42'); + expect(out).toContain('`linear-axi cycles --team LIN`'); + + stubLinearFetch(() => ({ issues: { nodes: [] } })); + + const out2 = await issuesCommand(['--cycle', 'current'], { + apiKey: FAKE_KEY, + }); + + expect(out2).toContain('No issues in an active cycle'); + }); + + it('documents --cycle in --help', () => { + expect(ISSUES_HELP).toContain('--cycle '); + expect(ISSUES_HELP).toContain('requires --team'); + expect(ISSUES_HELP).toContain('linear-axi cycles'); + }); +}); From 65c7891483dbde153fc8ec0fff3f324c86ce4442 Mon Sep 17 00:00:00 2001 From: Sam Whitmore Date: Thu, 20 Aug 2026 03:37:32 +0530 Subject: [PATCH 5/9] feat: wire --fields opt-in extras into issues and issue view parseFields (src/fields.ts) was built and unit-tested but wired into no command. Add the opt-in --fields flag to `issues` and `issue view` (#23): per-command available field maps select extra GraphQL scalar keys that are appended to the selection set and the rendered TOON schema. - issues: url, estimate, dueDate, createdAt (extra columns) - issue view: dueDate, estimate, archivedAt, branchName (extra rows) - Field shapes verified against the generated Issue type in @linear/sdk v90.0.0; only fields absent from each command's default output are offered, so defaults stay byte-identical without the flag - fetchIssues/fetchIssue gain an optional extraFields param; a new withExtraFields helper appends keys, returning the base string unchanged when no extras are requested. Extras apply to the searchIssues document too - Unknown names fail loud (AxiError VALIDATION_ERROR) with the valid set listed, before any network request; blank values select nothing - Help (ISSUES_HELP/ISSUE_HELP), KNOWN_FLAGS/VIEW_FLAGS, and SKILL.md updated; new test/fields-wiring.test.ts (13 tests) covers document + output wiring, filter/search composition, unknown-field failure, and default-schema invariance --- skills/linear-axi/SKILL.md | 1 + src/commands/issue.ts | 36 ++++- src/commands/issues.ts | 36 ++++- src/linear.ts | 35 ++++- src/skill.ts | 1 + test/fields-wiring.test.ts | 300 +++++++++++++++++++++++++++++++++++++ 6 files changed, 399 insertions(+), 10 deletions(-) create mode 100644 test/fields-wiring.test.ts 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', + ); + }); +}); From c6221ac8f8b1016ae5dfb837a4618272d4b05ad0 Mon Sep 17 00:00:00 2001 From: seraph-pixelperfect Date: Thu, 20 Aug 2026 03:50:06 +0530 Subject: [PATCH 6/9] feat: add comment list to read issue threads `comment list ` reads an issue's comment thread through the root `comments` connection, scoped via `filter: { issue: { id: { eq: } } }` (the connection has no issueId argument in current @linear/sdk), ordered `orderBy: createdAt` (Linear connections sort descending, so newest-first with no client-side reversing). TOON list rows render author (user, falling back to externalUser for integration comments), body truncated to a 200-char preview (`--full` disables), and relative creation time, with a count line, definitive empty state, and reply hints. Bare `comment ` --body` keeps dispatching to create exactly as before. Closes #19 --- skills/linear-axi/SKILL.md | 8 +- src/cli.ts | 1 + src/commands/comment.ts | 149 ++++++++++++++- src/linear.ts | 102 ++++++++++ src/skill.ts | 10 +- test/comment-list.test.ts | 376 +++++++++++++++++++++++++++++++++++++ 6 files changed, 629 insertions(+), 17 deletions(-) create mode 100644 test/comment-list.test.ts diff --git a/skills/linear-axi/SKILL.md b/skills/linear-axi/SKILL.md index 5d1a206..b5e0eac 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, list teams, list/filter projects, and list cycles. Use whenever a task touches Linear: triaging or filing issues, changing issue state, setting priority, commenting, discovering team keys via `teams`, scoping work to a project via `projects` and `issues --project`, or to a cycle via `cycles` and `issues --cycle`." +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, and list cycles. 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`, or to a cycle via `cycles` and `issues --cycle`." 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; discovering team keys; listing projects and scoping issues to one with `issues --project `; or listing a team's cycles and scoping issues to one with `issues --cycle current| --team `. +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 `; or listing a team's cycles and scoping issues to one with `issues --cycle current| --team `. ## 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`. 5. Change state with `issue update --state ""`; pass the workflow state name exactly (e.g. `"In Progress"`). -6. Comment with `comment --body "..."` or `--body-file ` for multi-line 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). 7. Every response ends with contextual next-step hints under `help:` - follow them. ## Commands @@ -52,5 +52,5 @@ Run `npx -y @apexcloudwise/linear-axi --help` for global flags, or `npx -y @apex - 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 view` truncates the description by default; pass `--full` to see it entirely. `comment list` likewise previews each comment body; `--full` shows them whole. - Issue references accept either a UUID or a `TEAM-NUMBER` identifier like `LIN-123`. diff --git a/src/cli.ts b/src/cli.ts index a76cb99..a13cb58 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -42,6 +42,7 @@ examples: linear-axi issue create --title "Fix login" --team ENG --label bug linear-axi issue update LIN-123 --state "In Progress" linear-axi comment LIN-123 --body "Looks good" + linear-axi comment list LIN-123 linear-axi teams linear-axi projects linear-axi cycles --team LIN diff --git a/src/commands/comment.ts b/src/commands/comment.ts index fffb16b..4910e1d 100644 --- a/src/commands/comment.ts +++ b/src/commands/comment.ts @@ -1,30 +1,162 @@ import type { LinearContext } from '../context.js'; import { requireKey } from '../config.js'; -import { fetchIssue, createComment } from '../linear.js'; -import { assertKnownFlags, takeFlag, getPositional } from '../args.js'; +import { fetchIssue, createComment, fetchComments } from '../linear.js'; +import { + assertKnownFlags, + takeFlag, + takeBoolFlag, + getPositional, +} from '../args.js'; import { AxiError } from '../errors.js'; -import { renderHelp, renderOutput } from '../toon.js'; +import { + custom, + relativeTime, + renderHelp, + renderList, + renderOutput, + truncateForDisplay, + type FieldDef, +} from '../toon.js'; +import { formatCountLine } from '../format.js'; import { readFileSync } from 'node:fs'; +/** + * Body preview length for `comment list` rows. Shorter than issue view's + * 1000-char description preview because a list renders one row per comment + * (author + body + time); a 200-char preview keeps a long thread scannable + * while still showing real content. `--full` disables truncation entirely + * (AXI principle 3: preview + total size, never silent omission). + */ +const COMMENT_BODY_PREVIEW = 200; + export const COMMENT_HELP = `usage: linear-axi comment --body "..." [--body-file ] -Add a comment to a Linear issue. + linear-axi comment list [--full] +Add a comment to a Linear issue, or read its comment thread. + +subcommands: + list read the issue's comment thread, newest first (--full disables body truncation) flags: - --body "text" comment body (inline) - --body-file read body from a UTF-8 file (use for multi-line) + --body "text" comment body (inline; create) + --body-file read body from a UTF-8 file (use for multi-line; create) + --full list: show full comment bodies instead of ${COMMENT_BODY_PREVIEW}-char previews examples: linear-axi comment LIN-123 --body "Looks good to me" linear-axi comment LIN-123 --body-file ./notes.md + linear-axi comment list LIN-123 + linear-axi comment list LIN-123 --full `; -const KNOWN_FLAGS = ['--body', '--body-file']; +const CREATE_FLAGS = ['--body', '--body-file']; +const LIST_FLAGS = ['--full']; export async function commentCommand( args: string[], ctx: LinearContext, ): Promise { - assertKnownFlags(args, KNOWN_FLAGS); + // 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. + const sub = getPositional(args, 0); + if (sub === 'list') { + return listComments(args.slice(1), ctx); + } + return createCommentCmd(args, ctx); +} + +async function listComments( + args: string[], + ctx: LinearContext, +): Promise { + assertKnownFlags(args, LIST_FLAGS); + const apiKey = requireKey(ctx.apiKey); + const full = takeBoolFlag(args, '--full'); + + const refRaw = getPositional(args); + if (!refRaw) { + throw new AxiError('Missing issue reference', 'VALIDATION_ERROR', [ + 'Run `linear-axi comment list ` (e.g. LIN-123)', + ]); + } + + // Resolve the ref first: the issue's id scopes the comments filter, its + // identifier seeds the hints, and an unknown ref fails loud here. + const issue = await fetchIssue(apiKey, refRaw); + if (!issue) { + throw new AxiError(`Issue "${refRaw}" not found`, 'NOT_FOUND', [ + 'Run `linear-axi issues` to list issues', + ]); + } + + const { comments, hasMore } = await fetchComments(apiKey, issue.id); + + const blocks: string[] = []; + blocks.push(formatCountLine({ count: comments.length, hasMore })); + + if (comments.length) { + blocks.push(renderList('comments', comments, listSchema(full))); + } else { + blocks.push('comments: 0 found'); + } + + const hints: string[] = []; + const anyTruncated = comments.some( + (c) => c.body.length > COMMENT_BODY_PREVIEW, + ); + if (anyTruncated && !full) { + hints.push( + `Run \`linear-axi comment list ${issue.identifier} --full\` for untruncated bodies`, + ); + } + hints.push( + `Run \`linear-axi comment ${issue.identifier} --body "..."\` to ${comments.length ? 'reply' : 'start the thread'}`, + ); + blocks.push(renderHelp(hints)); + + return renderOutput(blocks); +} + +/** Field order: author, body, created. */ +function listSchema(full: boolean): FieldDef[] { + return [ + custom('author', (item: any) => authorName(item)), + custom('body', (item: any) => commentBodyForDisplay(item.body, full)), + relativeTime('createdAt', 'created'), + ]; +} + +/** + * Author display name. Workspace comments carry `user`; comments created + * through integrations (Slack, Intercom) carry `externalUser` instead + * (Comment.user is nullable per @linear/sdk). Neither present (bots without + * a user association) renders as "unknown". + */ +function authorName(item: { + user?: { name: string } | null; + externalUser?: { displayName: string } | null; +}): string { + return item.user?.name ?? item.externalUser?.displayName ?? 'unknown'; +} + +function commentBodyForDisplay( + body: string | null | undefined, + full: boolean, +): string { + const { preview, total, truncated } = truncateForDisplay( + body, + full ? Infinity : COMMENT_BODY_PREVIEW, + ); + return truncated ? `${preview} (${total} chars total)` : preview; +} + +async function createCommentCmd( + args: string[], + ctx: LinearContext, +): Promise { + assertKnownFlags(args, CREATE_FLAGS); const apiKey = requireKey(ctx.apiKey); const refRaw = getPositional(args); @@ -62,6 +194,7 @@ export async function commentCommand( `comment: added to ${issue.identifier}`, renderHelp([ `Run \`linear-axi issue view ${issue.identifier}\` to see it in context`, + `Run \`linear-axi comment list ${issue.identifier}\` to read the thread`, ]), ]); } diff --git a/src/linear.ts b/src/linear.ts index 835cb02..ccf2871 100644 --- a/src/linear.ts +++ b/src/linear.ts @@ -550,6 +550,108 @@ export async function fetchIssue( return data.issue ?? undefined; } +/** Selection for comment nodes. The author is `user` for workspace comments, + * `externalUser` for comments created through integrations (Slack, Intercom). */ +export const COMMENT_LIST_FIELDS = ` + id + body + user { name } + externalUser { displayName } + createdAt +`; + +/** + * A Linear comment as returned by COMMENT_LIST_FIELDS. Exactly one of + * user/externalUser is set for real comments (Comment.user is nullable: + * "null for comments created by integrations or bots without a user + * association" per @linear/sdk); both are absent in fixtures only. + */ +export interface LinearComment { + id: string; + body: string; + user?: { name: string } | null; + externalUser?: { displayName: string } | null; + createdAt: string; +} + +export interface CommentListResult { + comments: LinearComment[]; + /** + * True when the last fetched page reported `hasNextPage` — more comments + * exist beyond the returned slice (the fetch limit was hit mid-cursor). + */ + hasMore: boolean; +} + +interface CommentListPage { + nodes: LinearComment[]; + pageInfo?: { hasNextPage?: boolean; endCursor?: string | null } | null; +} + +/** + * List one issue's comments, newest first. + * + * The root `comments` connection has no `issueId` argument in current + * @linear/sdk (v90.0.0 QueryCommentsArgs: after/before/filter/first/ + * includeArchived/last/orderBy) — the thread is scoped through + * `filter: { issue: { id: { eq: ... } } }` (CommentFilter.issue is a + * NullableIssueFilter; IssueFilter.id is an IssueIdComparator with eq: ID). + * + * Ordering: PaginationOrderBy only offers createdAt/updatedAt and Linear's + * connections sort descending (newest first — Linear's pagination docs and + * the repo's fetchIssues/fetchProjects/fetchCycles precedent; the + * Ascending/Descending PaginationSortOrder enum in the SDK is not an arg on + * this connection). `orderBy: createdAt` therefore returns the newest + * comment first without any client-side reversing, and stays the stable + * thread order (an edited old comment does not jump to the top, which + * `updatedAt` ordering would cause). + * + * Auto-paginates in PAGE_SIZE (50) batches like fetchIssues until `limit` is + * satisfied or the server reports no more pages. + */ +export async function fetchComments( + apiKey: string, + issueId: string, + limit = 100, +): Promise { + const query = `query Comments($issueId: ID!, $first: Int!, $after: String) { + comments(filter: { issue: { id: { eq: $issueId } } }, first: $first, after: $after, orderBy: createdAt) { + nodes { ${COMMENT_LIST_FIELDS} } + pageInfo { hasNextPage endCursor } + } + }`; + + const comments: LinearComment[] = []; + let cursor: string | undefined; + let hasMore = false; + + while (comments.length < limit) { + const first = Math.min(limit - comments.length, PAGE_SIZE); + const variables: Record = { issueId, first }; + if (cursor !== undefined) { + variables['after'] = cursor; + } + + const data = await linearRequest<{ comments: CommentListPage }>( + apiKey, + query, + variables, + ); + + comments.push(...data.comments.nodes); + + hasMore = data.comments.pageInfo?.hasNextPage ?? false; + const nextCursor = data.comments.pageInfo?.endCursor ?? undefined; + + // Same loop guards as fetchIssues/fetchProjects/fetchCycles. + if (!hasMore || nextCursor === undefined) break; + if (data.comments.nodes.length === 0 || nextCursor === cursor) break; + cursor = nextCursor; + } + + return { comments: comments.slice(0, limit), hasMore }; +} + // --------------------------------------------------------------------------- // Mutations // --------------------------------------------------------------------------- diff --git a/src/skill.ts b/src/skill.ts index b29900f..3746b78 100644 --- a/src/skill.ts +++ b/src/skill.ts @@ -4,9 +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, list teams, list/filter projects, and list cycles. ' + + 'create, update, and delete issues, post comments and read comment threads, list teams, list/filter projects, and list cycles. ' + 'Use whenever a task touches Linear: triaging or filing issues, changing issue state, setting priority, ' + - 'commenting, discovering team keys via `teams`, scoping work to a project via `projects` ' + + 'commenting or reading an issue discussion via `comment list`, discovering team keys via `teams`, scoping work to a project via `projects` ' + 'and `issues --project`, or to a cycle via `cycles` and `issues --cycle`.'; export const SKILL_AUTHOR = 'ApexCloudWise'; @@ -51,7 +51,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; discovering team keys; listing projects and scoping issues to one with \`issues --project \`; or listing a team's cycles and scoping issues to one with \`issues --cycle current| --team \`. +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 \`; or listing a team's cycles and scoping issues to one with \`issues --cycle current| --team \`. ## Workflow @@ -60,7 +60,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\`. 5. Change state with \`issue update --state ""\`; pass the workflow state name exactly (e.g. \`"In Progress"\`). -6. Comment with \`comment --body "..."\` or \`--body-file \` for multi-line 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). 7. Every response ends with contextual next-step hints under \`help:\` - follow them. ## Commands @@ -82,7 +82,7 @@ Run \`npx -y @apexcloudwise/linear-axi --help\` for global flags, or \`npx -y @a - 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 view\` truncates the description by default; pass \`--full\` to see it entirely. \`comment list\` likewise previews each comment body; \`--full\` shows them whole. - 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 new file mode 100644 index 0000000..93e22ce --- /dev/null +++ b/test/comment-list.test.ts @@ -0,0 +1,376 @@ +// Tests for `comment list` (#19): reading an issue's comment thread through +// the root `comments` connection. Follows the network-free GraphQL stub +// pattern from cycles.test.ts / fields-wiring.test.ts: stub global fetch, +// then assert on the captured query documents (issue resolution first, then +// comments), variables, and rendered TOON output. +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { commentCommand, COMMENT_HELP } from '../src/commands/comment.js'; +import { fetchComments } from '../src/linear.js'; +import { AxiError } from '../src/errors.js'; +import type { LinearComment, LinearIssue } from '../src/linear.js'; + +const FAKE_KEY = 'lin_api_fake'; + +const DAY_MS = 24 * 60 * 60 * 1000; + +/** ISO timestamp n days from now (negative = past) — keeps relative times stable. */ +function daysFromNow(n: number): string { + return new Date(Date.now() + n * DAY_MS).toISOString(); +} + +/** A minimal Linear issue node matching ISSUE_DETAIL_FIELDS. */ +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, + }; +} + +/** A minimal Linear comment node matching COMMENT_LIST_FIELDS. */ +function commentNode(overrides: Partial = {}): LinearComment { + return { + id: 'cm-1', + body: 'Looks good to me', + user: { name: 'Ada' }, + externalUser: null, + createdAt: daysFromNow(-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. `thread` is served for the + * `comments` query; `issue` (default: the LIN-1 node) for the `issue(...)` + * resolution query; any mutation falls through to a commentCreate success. + * Returns the captured requests for assertions. + */ +function stubLinearFetch( + thread: { nodes: LinearComment[]; hasNextPage?: boolean }, + issue: LinearIssue | null = issueNode(), +): { 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); + if (body.query.includes('query Comments(')) { + return graphqlOk({ + comments: { + nodes: thread.nodes, + pageInfo: { + hasNextPage: thread.hasNextPage ?? false, + endCursor: null, + }, + }, + }); + } + if (body.query.includes('query Issue(')) { + return graphqlOk({ issue }); + } + return graphqlOk({ commentCreate: { success: true } }); + }); + vi.stubGlobal('fetch', fetchMock); + return { requests }; +} + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('comment dispatch', () => { + it('routes `comment list ` to the comments query', async () => { + const { requests } = stubLinearFetch({ + nodes: [commentNode()], + }); + + const out = await commentCommand(['list', 'LIN-1'], { + apiKey: FAKE_KEY, + }); + + // Two round trips: resolve the ref to the issue, then its comments. + expect(requests).toHaveLength(2); + expect(requests[0].body.query).toContain('query Issue('); + expect(requests[0].body.variables).toEqual({ id: 'LIN-1' }); + expect(requests[1].body.query).toContain('query Comments('); + expect(out).toContain('count: 1'); + expect(out).toContain('Ada'); + }); + + it('keeps bare `comment --body` on the create path (backward compat)', async () => { + const { requests } = stubLinearFetch({ nodes: [] }); + + const out = await commentCommand(['LIN-1', '--body', 'hello'], { + apiKey: FAKE_KEY, + }); + + expect(requests).toHaveLength(2); + 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: 'hello', + }); + expect(out).toContain('comment: added to LIN-1'); + // The create path must not leak list output. + expect(out).not.toContain('comments[0]'); + }); +}); + +describe('comment list document', () => { + it('scopes the root comments connection by issue id, newest first', async () => { + const { requests } = stubLinearFetch({ nodes: [commentNode()] }); + + await commentCommand(['list', 'LIN-1'], { apiKey: FAKE_KEY }); + + const query = requests[1].body.query; + expect(requests[1].url).toBe('https://api.linear.app/graphql'); + expect(query).toContain( + 'comments(filter: { issue: { id: { eq: $issueId } } }, first: $first, after: $after, orderBy: createdAt)', + ); + // Requested node fields: body, author, creation time. + expect(query).toContain('body'); + expect(query).toContain('user { name }'); + expect(query).toContain('createdAt'); + expect(requests[1].body.variables).toEqual({ issueId: 'ir-1', first: 50 }); + }); +}); + +describe('comment list rendering', () => { + it('renders the thread newest-first in the server order', async () => { + const oldest = commentNode({ + id: 'cm-1', + body: 'oldest comment', + user: { name: 'Older' }, + createdAt: daysFromNow(-10), + }); + const middle = commentNode({ + id: 'cm-2', + body: 'middle comment', + user: { name: 'Middler' }, + createdAt: daysFromNow(-3), + }); + const newest = commentNode({ + id: 'cm-3', + body: 'newest comment', + user: { name: 'Newer' }, + createdAt: daysFromNow(-1), + }); + // The connection returns newest-first (orderBy: createdAt, descending) — + // the CLI renders nodes as-is with no client-side reordering. + stubLinearFetch({ nodes: [newest, middle, oldest] }); + + const out = await commentCommand(['list', 'LIN-1'], { + apiKey: FAKE_KEY, + }); + + expect(out).toContain('comments[3]{author,body,created}'); + expect(out).toContain('newest comment'); + expect(out).toContain('middle comment'); + expect(out).toContain('oldest comment'); + // Newest row before middle before oldest. + expect(out.indexOf('newest comment')).toBeLessThan( + out.indexOf('middle comment'), + ); + expect(out.indexOf('middle comment')).toBeLessThan( + out.indexOf('oldest comment'), + ); + expect(out).toContain('count: 3'); + // Relative creation times per row. + expect(out).toContain('1d ago'); + expect(out).toContain('3d ago'); + expect(out).toContain('10d ago'); + // Reply hint points at the create syntax on the resolved identifier. + expect(out).toContain('comment LIN-1 --body "..."'); + }); + + it('falls back to externalUser displayName for integration comments', async () => { + stubLinearFetch({ + nodes: [ + commentNode({ + body: 'from slack', + user: null, + externalUser: { displayName: 'Slack Guest' }, + }), + commentNode({ id: 'cm-2', body: 'from a bot', user: null }), + ], + }); + + const out = await commentCommand(['list', 'LIN-1'], { + apiKey: FAKE_KEY, + }); + + expect(out).toContain('Slack Guest'); + expect(out).toContain('unknown'); // bot comment without a user association + }); + + it('truncates long bodies to a 200-char preview with a total-size note', async () => { + const long = `${'x'.repeat(250)}TAILMARKER`; + stubLinearFetch({ nodes: [commentNode({ body: long })] }); + + const out = await commentCommand(['list', 'LIN-1'], { + apiKey: FAKE_KEY, + }); + + expect(out).toContain('...'); // truncateForDisplay ellipsis + expect(out).toContain(`(${long.length} chars total)`); + expect(out).not.toContain('TAILMARKER'); // tail is cut + // Escape hatch is advertised. + expect(out).toContain('comment list LIN-1 --full'); + }); + + it('shows full bodies with --full (no truncation)', async () => { + const long = `${'x'.repeat(250)}TAILMARKER`; + stubLinearFetch({ nodes: [commentNode({ body: long })] }); + + const out = await commentCommand(['list', 'LIN-1', '--full'], { + apiKey: FAKE_KEY, + }); + + expect(out).toContain('TAILMARKER'); + expect(out).not.toContain('chars total)'); + expect(out).not.toContain('--full'); // no truncation hint when untruncated + }); + + it('renders a definitive empty state with count line and a start-thread hint', async () => { + stubLinearFetch({ nodes: [] }); + + const out = await commentCommand(['list', 'LIN-1'], { + apiKey: FAKE_KEY, + }); + + expect(out).toContain('count: 0'); + expect(out).toContain('comments: 0 found'); + expect(out).toContain('start the thread'); + expect(out).not.toContain('comments[0]'); + }); +}); + +describe('comment list failures', () => { + it('fails loud when the issue ref does not resolve', async () => { + const { requests } = stubLinearFetch({ nodes: [] }, null); + + const err = await commentCommand(['list', 'LIN-404'], { + 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('LIN-404'); + // Only the resolution query fired — no comments query for a dead ref. + expect(requests).toHaveLength(1); + }); + + it('requires a ref after the subcommand', async () => { + const err = await commentCommand(['list'], { 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('Missing issue reference'); + }); + + it('rejects unknown flags on the list path', async () => { + const err = await commentCommand(['list', 'LIN-1', '--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 flag --bogus'); + }); + + it('rejects create-only flags on the list path and list-only flags on the create path', async () => { + const listWithBody = await commentCommand( + ['list', 'LIN-1', '--body', 'x'], + { apiKey: FAKE_KEY }, + ).catch((e: unknown) => e); + expect(listWithBody).toBeInstanceOf(AxiError); + expect((listWithBody as AxiError).message).toContain('unknown flag --body'); + + const createWithFull = await commentCommand( + ['LIN-1', '--body', 'x', '--full'], + { apiKey: FAKE_KEY }, + ).catch((e: unknown) => e); + expect(createWithFull).toBeInstanceOf(AxiError); + expect((createWithFull as AxiError).message).toContain( + 'unknown flag --full', + ); + }); +}); + +describe('fetchComments pagination', () => { + it('follows the cursor until the limit is met and reports hasMore', async () => { + const requests: CapturedRequest[] = []; + const page = (id: string) => + commentNode({ id, body: `comment ${id}`, createdAt: daysFromNow(-1) }); + 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.variables['after'] === undefined) { + return graphqlOk({ + comments: { + nodes: Array.from({ length: 50 }, (_, i) => page(`a${i}`)), + pageInfo: { hasNextPage: true, endCursor: 'cursor-1' }, + }, + }); + } + return graphqlOk({ + comments: { + nodes: Array.from({ length: 50 }, (_, i) => page(`b${i}`)), + pageInfo: { hasNextPage: true, endCursor: 'cursor-2' }, + }, + }); + }); + vi.stubGlobal('fetch', fetchMock); + + const { comments, hasMore } = await fetchComments( + FAKE_KEY, + 'ir-1', + 100, // limit satisfied after two 50-node pages + ); + + expect(comments).toHaveLength(100); + expect(hasMore).toBe(true); + expect(requests).toHaveLength(2); + expect(requests[0].body.variables).toEqual({ issueId: 'ir-1', first: 50 }); + expect(requests[1].body.variables).toEqual({ + issueId: 'ir-1', + first: 50, + after: 'cursor-1', + }); + }); +}); + +describe('comment help', () => { + it('documents the list subcommand and --full', () => { + expect(COMMENT_HELP).toContain('comment list [--full]'); + expect(COMMENT_HELP).toContain('newest first'); + expect(COMMENT_HELP).toContain('200-char previews'); + expect(COMMENT_HELP).toContain('linear-axi comment list LIN-123 --full'); + // The create usage line stays first (backward compat). + expect(COMMENT_HELP.indexOf('--body "..."')).toBeLessThan( + COMMENT_HELP.indexOf('comment list'), + ); + }); +}); From 8cca1bce6c8de1338dc1c63746d662f54f827263 Mon Sep 17 00:00:00 2001 From: Sam Whitmore Date: Thu, 20 Aug 2026 03:58:25 +0530 Subject: [PATCH 7/9] feat: add labels command to list workspace labels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `linear-axi labels` lists the workspace's issue labels through the root `issueLabels` connection (the same connection resolveLabelIds already queried workspace-wide), auto-paginated in 50-node batches with orderBy: updatedAt (recent-first, matching issues/projects/cycles). TOON rows render the label name and its color as the raw HEX string ("#EB5757"-style per the generated IssueLabel type in @linear/sdk — rendered raw since terminal color support varies, no ANSI swatch), with a count line, definitive empty state, and hints pointing at `issue create --label` and `issues --label`. resolveLabelIds now reuses fetchLabels, fixing a latent gap where label resolution fetched a single unpaginated page and silently missed labels beyond the first 50; matching stays case-insensitive in server order. Closes #22 --- skills/linear-axi/SKILL.md | 12 +- src/cli.ts | 8 +- src/commands/labels.ts | 57 +++++++ src/linear.ts | 100 +++++++++++- src/skill.ts | 10 +- test/labels.test.ts | 305 +++++++++++++++++++++++++++++++++++++ 6 files changed, 475 insertions(+), 17 deletions(-) create mode 100644 src/commands/labels.ts create mode 100644 test/labels.test.ts diff --git a/skills/linear-axi/SKILL.md b/skills/linear-axi/SKILL.md index b5e0eac..1f5f0b3 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, and list cycles. 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`, or to a cycle via `cycles` and `issues --cycle`." +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`." user-invocable: false author: ApexCloudWise metadata: @@ -20,12 +20,12 @@ 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 `; or listing a team's cycles and scoping issues to one with `issues --cycle current| --team `. +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 `. ## 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`. +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"`). @@ -35,8 +35,8 @@ Use linear-axi whenever a task touches Linear: listing, viewing, filing, editing ## Commands ``` -commands[8]: - (none)=dashboard, issues, issue, comment, teams, projects, cycles, login, setup +commands[9]: + (none)=dashboard, issues, issue, comment, teams, projects, cycles, labels, login, setup ``` Installed copies also inherit the SDK built-in `update` command. @@ -49,7 +49,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. +- 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`. - `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/cli.ts b/src/cli.ts index a13cb58..53ebbf2 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -9,6 +9,7 @@ import { commentCommand, COMMENT_HELP } from './commands/comment.js'; import { teamsCommand, TEAMS_HELP } from './commands/teams.js'; import { projectsCommand, PROJECTS_HELP } from './commands/projects.js'; import { cyclesCommand, CYCLES_HELP } from './commands/cycles.js'; +import { labelsCommand, LABELS_HELP } from './commands/labels.js'; import { loginCommand, LOGIN_HELP } from './commands/login.js'; import { setupCommand, SETUP_HELP } from './commands/setup.js'; @@ -30,8 +31,8 @@ type LinearCommand = ( ) => Promise; export const TOP_HELP = `usage: linear-axi [command] [args] [flags] -commands[8]: - (none)=dashboard, issues, issue, comment, teams, projects, cycles, login, setup +commands[9]: + (none)=dashboard, issues, issue, comment, teams, projects, cycles, labels, login, setup globals[1]: --key (after command) or LINEAR_API_KEY env, accepted in space or = form; --help always allowed examples: @@ -46,6 +47,7 @@ examples: linear-axi teams linear-axi projects linear-axi cycles --team LIN + linear-axi labels linear-axi login lin_api_xxx linear-axi setup hooks `; @@ -57,6 +59,7 @@ const COMMAND_HELP: Record = { teams: TEAMS_HELP, projects: PROJECTS_HELP, cycles: CYCLES_HELP, + labels: LABELS_HELP, login: LOGIN_HELP, setup: SETUP_HELP, }; @@ -75,6 +78,7 @@ const COMMANDS: Record> = { teams: wrap(teamsCommand), projects: wrap(projectsCommand), cycles: wrap(cyclesCommand), + labels: wrap(labelsCommand), login: wrap(loginCommand), setup: wrap(setupCommand), }; diff --git a/src/commands/labels.ts b/src/commands/labels.ts new file mode 100644 index 0000000..ad7e5d9 --- /dev/null +++ b/src/commands/labels.ts @@ -0,0 +1,57 @@ +import type { LinearContext } from '../context.js'; +import { requireKey } from '../config.js'; +import { assertKnownFlags } from '../args.js'; +import { fetchLabels } from '../linear.js'; +import { field, renderList, renderHelp, renderOutput } from '../toon.js'; +import { formatCountLine } from '../format.js'; + +export const LABELS_HELP = `usage: linear-axi labels + List the issue labels in your Linear workspace, most recently updated first. + +columns: + name label name (usable with issue create --label and issues --label) + color label color as a raw HEX string (e.g. #EB5757) + +examples: + linear-axi labels + linear-axi issues --label bug + linear-axi issue create --title "Fix login" --team ENG --label bug +`; + +const listSchema = [field('name'), field('color')]; + +export async function labelsCommand( + args: string[], + ctx: LinearContext, +): Promise { + // `labels` takes no flags — reject any rather than silently dropping it. + assertKnownFlags(args, []); + const apiKey = requireKey(ctx.apiKey); + const { labels, hasMore } = await fetchLabels(apiKey); + + const blocks: string[] = []; + blocks.push(formatCountLine({ count: labels.length, hasMore })); + + if (labels.length) { + blocks.push(renderList('labels', labels, listSchema)); + } else { + blocks.push('labels: 0 found'); + } + + const hints: string[] = []; + if (labels.length) { + hints.push( + 'Run `linear-axi issues --label ""` to list issues carrying a label', + ); + hints.push( + 'Run `linear-axi issue create --label ` to add a label to a new issue', + ); + } else { + hints.push( + 'Create a label in the Linear app (workspace or team settings) — none exist in this workspace yet', + ); + } + blocks.push(renderHelp(hints)); + + return renderOutput(blocks); +} diff --git a/src/linear.ts b/src/linear.ts index ccf2871..cc66e9f 100644 --- a/src/linear.ts +++ b/src/linear.ts @@ -652,6 +652,94 @@ export async function fetchComments( return { comments: comments.slice(0, limit), hasMore }; } +/** Selection for label nodes. `color` is a raw HEX string (see LinearLabel). */ +export const LABEL_LIST_FIELDS = ` + id + name + color +`; + +/** + * A Linear issue label as returned by LABEL_LIST_FIELDS. Labels can be + * workspace-level or team-scoped; `color` is a HEX string like "#EB5757" + * ("The label's color as a HEX string (e.g., '#EB5757')" per the generated + * IssueLabel type in @linear/sdk, where color is a non-nullable String). + * Rendered raw — terminal color support varies, so no ANSI swatch is + * attempted. + */ +export interface LinearLabel { + id: string; + name: string; + color: string; +} + +export interface LabelListResult { + labels: LinearLabel[]; + /** + * True when the last fetched page reported `hasNextPage` — more labels + * exist beyond the returned slice (the fetch limit was hit mid-cursor). + */ + hasMore: boolean; +} + +interface LabelListPage { + nodes: LinearLabel[]; + pageInfo?: { hasNextPage?: boolean; endCursor?: string | null } | null; +} + +/** + * List the workspace's issue labels, most recently updated first, + * auto-paginated in PAGE_SIZE (50) batches like fetchProjects until `limit` + * is satisfied or the server reports no more pages. The root `issueLabels` + * connection takes the same after/filter/first/orderBy args as the other + * list connections (QueryIssueLabelsArgs in @linear/sdk); PaginationOrderBy + * only offers createdAt/updatedAt, so updatedAt is the recent-first + * ordering, matching issues, projects, and cycles. The default limit (500) + * is generous for real workspaces while still guarding a runaway cursor; + * `hasMore` in the result says whether truncation occurred. + */ +export async function fetchLabels( + apiKey: string, + limit = 500, +): Promise { + const query = `query Labels($first: Int!, $after: String) { + issueLabels(first: $first, after: $after, orderBy: updatedAt) { + nodes { ${LABEL_LIST_FIELDS} } + pageInfo { hasNextPage endCursor } + } + }`; + + const labels: LinearLabel[] = []; + let cursor: string | undefined; + let hasMore = false; + + while (labels.length < limit) { + const first = Math.min(limit - labels.length, PAGE_SIZE); + const variables: Record = { first }; + if (cursor !== undefined) { + variables['after'] = cursor; + } + + const data = await linearRequest<{ issueLabels: LabelListPage }>( + apiKey, + query, + variables, + ); + + labels.push(...data.issueLabels.nodes); + + hasMore = data.issueLabels.pageInfo?.hasNextPage ?? false; + const nextCursor = data.issueLabels.pageInfo?.endCursor ?? undefined; + + // Same loop guards as fetchIssues/fetchProjects/fetchCycles/fetchComments. + if (!hasMore || nextCursor === undefined) break; + if (data.issueLabels.nodes.length === 0 || nextCursor === cursor) break; + cursor = nextCursor; + } + + return { labels: labels.slice(0, limit), hasMore }; +} + // --------------------------------------------------------------------------- // Mutations // --------------------------------------------------------------------------- @@ -864,15 +952,19 @@ export async function resolveProjectId( return data.projects.nodes[0]?.id; } +/** + * Resolve label names to their ids, case-insensitively, in server order. + * 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. + */ async function resolveLabelIds( apiKey: string, names: string[], ): Promise { + const { labels } = await fetchLabels(apiKey); const wanted = names.map((n) => n.trim().toLowerCase()); - const data = await linearRequest<{ - issueLabels: { nodes: Array<{ id: string; name: string }> }; - }>(apiKey, `query Labels { issueLabels { nodes { id name } } }`); - return data.issueLabels.nodes + return labels .filter((l) => wanted.includes(l.name.toLowerCase())) .map((l) => l.id); } diff --git a/src/skill.ts b/src/skill.ts index 3746b78..a8b3434 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, post comments and read comment threads, list teams, list/filter projects, and list cycles. ' + + '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`, or to a cycle via `cycles` and `issues --cycle`.'; + 'and `issues --project`, to a cycle via `cycles` and `issues --cycle`, or finding label names via `labels` for `issue create --label` and `issues --label`.'; export const SKILL_AUTHOR = 'ApexCloudWise'; @@ -51,12 +51,12 @@ 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 \`; or listing a team's cycles and scoping issues to one with \`issues --cycle current| --team \`. +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 \`. ## 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\`. +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"\`). @@ -79,7 +79,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. +- 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\`. - \`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/labels.test.ts b/test/labels.test.ts new file mode 100644 index 0000000..2b24c1a --- /dev/null +++ b/test/labels.test.ts @@ -0,0 +1,305 @@ +// Tests for the `labels` command (#22): listing workspace issue labels +// through the root `issueLabels` connection. Follows the network-free GraphQL +// stub pattern from projects.test.ts / comment-list.test.ts: stub global +// fetch, then assert on the captured query document, variables, and rendered +// TOON output. +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { labelsCommand, LABELS_HELP } from '../src/commands/labels.js'; +import { issueCommand } from '../src/commands/issue.js'; +import { fetchLabels } from '../src/linear.js'; +import type { LinearLabel } from '../src/linear.js'; + +const FAKE_KEY = 'lin_api_fake'; + +interface CapturedRequest { + url: string; + body: { query: string; variables: Record }; +} + +interface LabelPage { + nodes: LinearLabel[]; + hasNextPage?: boolean; + endCursor?: string | null; +} + +/** A minimal Linear label node matching LABEL_LIST_FIELDS. */ +function labelNode(overrides: Partial = {}): LinearLabel { + return { + id: 'lb-1', + name: 'bug', + color: '#EB5757', + ...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 `pages` of labels + * sequentially (the i-th `query Labels(` request gets pages[i]; requests + * beyond the list get the last page). Returns the captured requests for + * assertions. + */ +function stubLabelFetch(pages: LabelPage[]): { + requests: CapturedRequest[]; +} { + const requests: CapturedRequest[] = []; + let labelsCall = 0; + 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 Labels(')) { + const page = pages[Math.min(labelsCall, pages.length - 1)]!; + labelsCall += 1; + return graphqlOk({ + issueLabels: { + nodes: page.nodes, + pageInfo: { + hasNextPage: page.hasNextPage ?? false, + endCursor: page.endCursor ?? null, + }, + }, + }); + } + return graphqlOk({}); + }); + vi.stubGlobal('fetch', fetchMock); + return { requests }; +} + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('labels command document', () => { + it('requests id, name, and color workspace-wide, newest-updated first', async () => { + const { requests } = stubLabelFetch([{ nodes: [labelNode()] }]); + + await labelsCommand([], { apiKey: FAKE_KEY }); + + const query = requests[0].body.query; + expect(requests[0].url).toBe('https://api.linear.app/graphql'); + expect(query).toContain( + 'issueLabels(first: $first, after: $after, orderBy: updatedAt)', + ); + // Requested node fields: id, name, and the raw HEX color. + expect(query).toContain('id'); + expect(query).toContain('name'); + expect(query).toContain('color'); + expect(query).toContain('pageInfo { hasNextPage endCursor }'); + expect(requests[0].body.variables).toEqual({ first: 50 }); + }); +}); + +describe('labels rendering', () => { + it('renders name and raw HEX color rows with a count line', async () => { + stubLabelFetch([ + { + nodes: [ + labelNode({ id: 'lb-bug', name: 'bug', color: '#EB5757' }), + labelNode({ id: 'lb-feat', name: 'feature', color: '#4CB782' }), + labelNode({ id: 'lb-chore', name: 'chore', color: '#95A2B3' }), + ], + }, + ]); + + const out = await labelsCommand([], { apiKey: FAKE_KEY }); + + expect(out).toContain('labels[3]{name,color}'); + expect(out).toContain('bug'); + expect(out).toContain('#EB5757'); // raw hex, no ANSI swatch + expect(out).toContain('feature'); + expect(out).toContain('#4CB782'); + expect(out).toContain('chore'); + expect(out).toContain('#95A2B3'); + expect(out).toContain('count: 3'); + }); + + it('flags a truncated slice when more labels exist past the cursor', async () => { + // Page 1 reports hasNextPage and the follow-up page comes back empty — + // the empty-page loop guard stops the fetch with hasMore still true. + stubLabelFetch([ + { + nodes: [labelNode(), labelNode({ id: 'lb-2', name: 'chore' })], + hasNextPage: true, + endCursor: 'c1', + }, + { nodes: [], hasNextPage: true, endCursor: 'c2' }, + ]); + + const out = await labelsCommand([], { apiKey: FAKE_KEY }); + + expect(out).toContain('count: 2 (showing first 2)'); + }); + + it('renders a definitive empty state when the workspace has no labels', async () => { + stubLabelFetch([{ nodes: [] }]); + + const out = await labelsCommand([], { apiKey: FAKE_KEY }); + + expect(out).toContain('count: 0'); + expect(out).toContain('labels: 0 found'); + expect(out).toContain('Create a label in the Linear app'); + expect(out).not.toContain('labels[0]'); + }); + + it('hints at issues --label and issue create --label', async () => { + stubLabelFetch([{ nodes: [labelNode()] }]); + + const out = await labelsCommand([], { apiKey: FAKE_KEY }); + + expect(out).toContain('issues --label'); + expect(out).toContain('issue create --label'); + }); +}); + +describe('labels pagination', () => { + it('follows the cursor until the limit is met and reports hasMore', async () => { + const page = (prefix: string) => + Array.from({ length: 50 }, (_, i) => + labelNode({ id: `${prefix}${i}`, name: `label ${prefix}${i}` }), + ); + const { requests } = stubLabelFetch([ + { nodes: page('a'), hasNextPage: true, endCursor: 'cursor-1' }, + { nodes: page('b'), hasNextPage: true, endCursor: 'cursor-2' }, + ]); + + const { labels, hasMore } = await fetchLabels( + FAKE_KEY, + 100, // limit satisfied after two 50-node pages + ); + + expect(labels).toHaveLength(100); + expect(labels[0]!.name).toBe('label a0'); + expect(labels[99]!.name).toBe('label b49'); + expect(hasMore).toBe(true); + expect(requests).toHaveLength(2); + expect(requests[0].body.variables).toEqual({ first: 50 }); + expect(requests[1].body.variables).toEqual({ + first: 50, + after: 'cursor-1', + }); + }); + + it('auto-paginates in 50-size batches until the server is exhausted', async () => { + const { requests } = stubLabelFetch([ + { + nodes: Array.from({ length: 50 }, (_, i) => + labelNode({ id: `lb-${i}`, name: `label ${i}` }), + ), + hasNextPage: true, + endCursor: 'c1', + }, + { + nodes: [ + labelNode({ id: 'lb-50', name: 'label 50' }), + labelNode({ id: 'lb-51', name: 'label 51' }), + ], + hasNextPage: false, + endCursor: null, + }, + ]); + + const { labels, hasMore } = await fetchLabels(FAKE_KEY); // default limit + + expect(requests).toHaveLength(2); + expect(requests[1].body.variables).toEqual({ first: 50, after: 'c1' }); + expect(labels).toHaveLength(52); + expect(labels[51]!.name).toBe('label 51'); + expect(hasMore).toBe(false); + }); +}); + +describe('labels failures', () => { + it('rejects unknown flags loudly (no flags are valid)', async () => { + stubLabelFetch([{ nodes: [] }]); + + await expect( + labelsCommand(['--bogus'], { apiKey: FAKE_KEY }), + ).rejects.toThrow(/unknown flag --bogus/); + }); +}); + +describe('label resolution reuse', () => { + it('resolves issue create --label names through the paginated labels query', async () => { + // The create path (createIssue -> resolveLabelIds) reuses fetchLabels: + // team resolution, then the Labels query, then the CreateIssue mutation. + 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 TeamByKey(')) { + return graphqlOk({ + teams: { nodes: [{ id: 'team-1', key: 'LIN' }] }, + }); + } + if (body.query.includes('query Labels(')) { + return graphqlOk({ + issueLabels: { + nodes: [ + labelNode({ id: 'lb-bug', name: 'Bug' }), + labelNode({ id: 'lb-feat', name: 'feature' }), + labelNode({ id: 'lb-chore', name: 'chore' }), + ], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }); + } + return graphqlOk({ + issueCreate: { + success: true, + issue: { + id: 'ir-9', + identifier: 'LIN-9', + title: 'T', + url: 'https://linear.app/lini/issue/LIN-9/t', + }, + }, + }); + }); + vi.stubGlobal('fetch', fetchMock); + + const out = await issueCommand( + [ + 'create', + '--title', + 'T', + '--team', + 'LIN', + '--label', + 'BUG', + '--label', + 'feature', + ], + { apiKey: FAKE_KEY }, + ); + + // Three round trips: team key, labels, mutation. + expect(requests).toHaveLength(3); + expect(requests[1].body.query).toContain( + 'issueLabels(first: $first, after: $after, orderBy: updatedAt)', + ); + // Matching stays case-insensitive and yields ids in server order. + expect(requests[2].body.variables).toEqual({ + teamId: 'team-1', + title: 'T', + labelIds: ['lb-bug', 'lb-feat'], + }); + expect(out).toContain('created: LIN-9'); + }); +}); + +describe('labels help', () => { + it('documents the columns and both label flags', () => { + expect(LABELS_HELP).toContain('usage: linear-axi labels'); + expect(LABELS_HELP).toContain( + 'color label color as a raw HEX string (e.g. #EB5757)', + ); + expect(LABELS_HELP).toContain('linear-axi issues --label bug'); + expect(LABELS_HELP).toContain( + 'issue create --title "Fix login" --team ENG --label bug', + ); + }); +}); From be79a0bdde1b8c05cd15fa58fd5a7241673a21f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sigrid=20Nystr=C3=B6m?= Date: Thu, 20 Aug 2026 09:40:39 +0530 Subject: [PATCH 8/9] fix: reject a flag as the issues search value --- src/commands/issues.ts | 5 ++++- test/issues-search.test.ts | 4 ++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/commands/issues.ts b/src/commands/issues.ts index 142f3f8..d20ec27 100644 --- a/src/commands/issues.ts +++ b/src/commands/issues.ts @@ -97,7 +97,10 @@ export async function issuesCommand( const labels = takeAllFlags(args, '--label'); const hasSearch = args.some((a) => a === '--search' || a.startsWith('--search=')); const search = takeFlag(args, '--search'); - if (hasSearch && (search === undefined || search.trim() === '')) { + if ( + hasSearch && + (search === undefined || search.trim() === '' || search.startsWith('--')) + ) { // takeFlag returns undefined for a missing value; never silently drop a search. throw new AxiError('--search requires a value', 'VALIDATION_ERROR', [ 'e.g. --search "onboarding"', diff --git a/test/issues-search.test.ts b/test/issues-search.test.ts index 7f56eb8..bcf8689 100644 --- a/test/issues-search.test.ts +++ b/test/issues-search.test.ts @@ -187,6 +187,10 @@ describe('issues command --search', () => { await expect( issuesCommand(['--search='], { apiKey: FAKE_KEY }), ).rejects.toThrow(/--search requires a value/); + + await expect( + issuesCommand(['--search', '--team', 'LIN'], { apiKey: FAKE_KEY }), + ).rejects.toThrow(/--search requires a value/); }); it('still rejects unknown flags alongside --search', async () => { From 534b3ccbc8caefb786a9bd4368496d4c62e426cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sigrid=20Nystr=C3=B6m?= Date: Thu, 20 Aug 2026 09:51:08 +0530 Subject: [PATCH 9/9] docs: correct Linear search API compatibility notes --- src/commands/issues.ts | 4 ++-- src/linear.ts | 14 ++++++-------- 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/src/commands/issues.ts b/src/commands/issues.ts index d20ec27..7492251 100644 --- a/src/commands/issues.ts +++ b/src/commands/issues.ts @@ -17,7 +17,7 @@ import { import { formatCountLine } from '../format.js'; 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). +List Linear issues, most recently updated first (full-text search when --search is given). flags: --team filter by team key (e.g. LIN) @@ -26,7 +26,7 @@ flags: --label filter by label name; repeat to match ANY of the given labels (issues carrying at least one) --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 + --search full-text 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) diff --git a/src/linear.ts b/src/linear.ts index cc66e9f..1e15e82 100644 --- a/src/linear.ts +++ b/src/linear.ts @@ -378,7 +378,7 @@ export interface IssueListFilter { labels?: string[]; // label names — matches issues carrying ANY of them project?: string; // project name — exact match cycle?: 'current' | number; // 'current' = any team's active cycle; a number is per-team (compose with team) - search?: string; // ranked full-text search term (Linear app search ranking) + search?: string; // full-text search term } export interface IssueListResult { @@ -420,13 +420,11 @@ interface IssueListPage { * via `pageInfo { hasNextPage endCursor }` and follows the cursor until * `limit` is satisfied or the server reports no more pages. * - * When `filter.search` is set, Linear's ranked full-text search is used - * instead (same ranking as the Linear app's search). We query `searchIssues` - * rather than the `issueSearch` root field: both accept the same - * `filter: IssueFilter` + `first`/`after` paging, but Linear marks - * `issueSearch` as deprecated ("will be removed in the future — use - * searchIssues instead"), and its nodes carry the same fields as `Issue` for - * our selection set. + * When `filter.search` is set, the public `searchIssues` root field is used + * instead. Its current schema accepts `term`, `filter: IssueFilter`, and + * cursor pagination; its nodes support the same selection as `Issue`. We do + * not claim a particular ranking implementation or a deprecation status for + * the distinct `issueSearch` root field, which may evolve independently. * * There is no usable `totalCount` on the `issues` connection, so callers * compute the count line from the returned slice (`hasMore` in the result