From c8a0fbf410e7b17ae895e4af0a2e0f2fb3f034bb Mon Sep 17 00:00:00 2001 From: Sam Whitmore Date: Thu, 20 Aug 2026 03:17:24 +0530 Subject: [PATCH] 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(); + }); +});