From e1ef29931622cc1156ccd461228ebaa9b3b2bea8 Mon Sep 17 00:00:00 2001 From: Sam Whitmore Date: Thu, 20 Aug 2026 02:53:45 +0530 Subject: [PATCH] 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/); + }); +});