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