Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 17 additions & 2 deletions src/commands/issues.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,27 +15,30 @@ import {
} from '../toon.js';
import { formatCountLine } from '../format.js';

export const ISSUES_HELP = `usage: linear-axi issues [--team <KEY>] [--state <type>] [--assignee <name|me>] [--label <name>] [--limit <n>]
List Linear issues, most recently updated first.
export const ISSUES_HELP = `usage: linear-axi issues [--team <KEY>] [--state <type>] [--assignee <name|me>] [--label <name>] [--search <text>] [--limit <n>]
List Linear issues, most recently updated first (relevance-ranked when --search is given).

flags:
--team <KEY> filter by team key (e.g. LIN)
--state <type> workflow state type: backlog, unstarted, started, completed, canceled, triage
--assignee <name> "me" for yourself, or a user name
--label <name> filter by label name (repeatable)
--search <text> full-text search (same ranking as Linear's app search); composable with the filters above
--limit <n> 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 = [
'--team',
'--state',
'--assignee',
'--label',
'--search',
'--limit',
];

Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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);
Expand Down
27 changes: 26 additions & 1 deletion src/linear.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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).
*/
Expand Down Expand Up @@ -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} }
Expand All @@ -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 };
Expand Down
199 changes: 199 additions & 0 deletions test/issues-search.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> };
}

/** A minimal Linear issue node matching ISSUE_LIST_FIELDS. */
function issueNode(overrides: Partial<LinearIssue> = {}): 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/);
});
});
Loading