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 }); });