diff --git a/skills/linear-axi/SKILL.md b/skills/linear-axi/SKILL.md index 97f223d..4ba2b8d 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 read comment threads, list teams, list/filter projects, list cycles, and list labels. Use whenever a task touches Linear: triaging or filing issues, changing issue state, setting priority, assigning issues or managing their labels, commenting on or reading an issue discussion via `comment list`, discovering team keys via `teams`, scoping work to a project via `projects` and `issues --project`, to a cycle via `cycles` and `issues --cycle`, or finding label names via `labels` for `issue create --label`, `issue update --label`, and `issues --label`." +description: "Operate Linear (linear.app) through the linear-axi CLI - list and view issues, create, update, and delete issues, post comments and read comment threads, list teams, list/filter projects, list cycles, and list labels. Use whenever a task touches Linear: triaging or filing issues, changing issue state, setting priority, assigning issues or managing their labels, commenting on or reading an issue discussion via `comment list`, discovering team keys via `teams`, scoping work to a project via `projects` and `issues --project`, to a cycle via `cycles` and `issues --cycle`, assigning issues to a project or cycle with `issue create/update --project ` and `--cycle current|`, or finding label names via `labels` for `issue create --label`, `issue update --label`, and `issues --label`." user-invocable: false author: ApexCloudWise metadata: @@ -20,15 +20,15 @@ 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, priority, assignee, or labels; commenting on issues or reading their comment threads; discovering team keys; listing projects and scoping issues to one with `issues --project `; listing a team's cycles and scoping issues to one with `issues --cycle current| --team `; or listing the workspace's labels and using their names with `issues --label `, `issue create --label `, or `issue update --label `. +Use linear-axi whenever a task touches Linear: listing, viewing, filing, editing, or deleting issues; changing issue state, priority, assignee, or labels; commenting on issues or reading their comment threads; discovering team keys; listing projects and scoping issues to one with `issues --project `; listing a team's cycles and scoping issues to one with `issues --cycle current| --team `; putting issues in a project or cycle with `issue create/update --project ` or `--cycle current|`; or listing the workspace's labels and using their names with `issues --label `, `issue create --label `, or `issue update --label `. ## 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`, `cycles`, `labels`. 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"`). Assign with `--assignee ` and manage labels with `--label ` / `--remove-label ` (repeatable) in the same command. +4. Create an issue with `issue create --title "..." --team `; team keys come from `teams`, and `--project ""` / `--cycle current|` land it in a project or cycle straight away. +5. Change state with `issue update --state ""`; pass the workflow state name exactly (e.g. `"In Progress"`). Assign with `--assignee `, manage labels with `--label ` / `--remove-label ` (repeatable), and move it into a project or cycle with `--project ""` / `--cycle current|` in the same command. 6. Comment with `comment --body "..."` or `--body-file ` for multi-line bodies; read an issue's thread with `comment list ` (newest first, `--full` for untruncated bodies). 7. Every response ends with contextual next-step hints under `help:` - follow them. @@ -51,6 +51,7 @@ Run `npx -y @apexcloudwise/linear-axi --help` for global flags, or `npx -y @apex - 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. Label names come from `labels`. - `issue update --label ` adds to an issue's existing labels and `--remove-label ` removes one; the last removal sends an explicit empty set, so it works too. Re-assigning the current assignee (or a label set that already matches) is a reported no-op. +- `issue create/update --project ` matches the project name exactly (`projects` lists them) and fails loud on a miss; re-requesting the issue's current project or cycle is a reported no-op. On `--cycle`, "current" resolves the active cycle (scoped to `--team`'s when given; on update without `--team`, the workspace's single active one - ambiguous if several teams have one) while a cycle number always requires `--team` because numbers restart per team (`cycles` lists them). - `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 ]`. - `issues --fields ` and `issue view --fields ` add opt-in extra fields (per-command sets - see each command's --help) when the default columns lack something you need. - `issue view` truncates the description by default; pass `--full` to see it entirely. `comment list` likewise previews each comment body; `--full` shows them whole. diff --git a/src/commands/issue.ts b/src/commands/issue.ts index 3b7b543..5a31b36 100644 --- a/src/commands/issue.ts +++ b/src/commands/issue.ts @@ -8,6 +8,9 @@ import { deleteIssue, resolveUserId, resolveLabelIds, + requireProjectId, + fetchActiveCycles, + resolveCycleIdByNumber, } from '../linear.js'; import { assertKnownFlags, @@ -36,9 +39,9 @@ Manage Linear issues. subcommands: view show issue details (--full disables description truncation; --fields adds extra fields) - create --title "..." --team [--description "..."] [--label ...] + create --title "..." --team [--description "..."] [--label ...] [--project ] [--cycle ] update [--state ] [--title "..."] [--priority <0-4>] [--description "..."] - [--assignee ] [--label ] [--remove-label ] + [--assignee ] [--label ] [--remove-label ] [--project ] [--cycle ] delete view --fields names (opt-in, comma-separated): dueDate, estimate, archivedAt, branchName @@ -49,19 +52,37 @@ update flags: --label add a label (repeatable; names from \`linear-axi labels\`) --remove-label remove a label (repeatable); removing the last one clears labels +project/cycle flags (create and update): + --project put the issue in a project (exact name; run \`linear-axi projects\`) + --cycle put the issue in a cycle: "current" = the active cycle — with --team, that team's; + without --team (update only), the workspace's single active one (fails if several teams have one); + a cycle number always requires --team (numbers restart per team; run \`linear-axi cycles\`) + --team scopes --cycle; on create it is the issue's team, on update it must be the issue's own team + examples: linear-axi issue view LIN-123 linear-axi issue view LIN-123 --full linear-axi issue view LIN-123 --fields dueDate,estimate linear-axi issue create --title "Fix login" --team ENG --label bug + linear-axi issue create --title "Ship v2" --team LIN --project "Q3 launch" --cycle current linear-axi issue update LIN-123 --state "In Progress" linear-axi issue update LIN-123 --assignee me --label bug linear-axi issue update LIN-123 --remove-label bug + linear-axi issue update LIN-123 --project "Mobile app" + linear-axi issue update LIN-123 --cycle current + linear-axi issue update LIN-123 --cycle 3 --team LIN linear-axi issue delete LIN-123 `; const VIEW_FLAGS = ['--full', '--fields']; -const CREATE_FLAGS = ['--title', '--team', '--description', '--label']; +const CREATE_FLAGS = [ + '--title', + '--team', + '--description', + '--label', + '--project', + '--cycle', +]; const UPDATE_FLAGS = [ '--state', '--title', @@ -70,6 +91,9 @@ const UPDATE_FLAGS = [ '--assignee', '--label', '--remove-label', + '--project', + '--cycle', + '--team', ]; const detailSchema: FieldDef[] = [ @@ -199,6 +223,15 @@ async function createIssueCmd( const team = takeFlag(args, '--team'); const description = takeFlag(args, '--description'); const labels = takeAllFlags(args, '--label'); + // has* detection must precede takeFlag (which removes the flag pair). + const hasProject = args.some( + (a) => a === '--project' || a.startsWith('--project='), + ); + const projectName = takeFlag(args, '--project'); + const hasCycle = args.some( + (a) => a === '--cycle' || a.startsWith('--cycle='), + ); + const cycleRaw = takeFlag(args, '--cycle'); if (!title || !title.trim()) { throw new AxiError('--title is required', 'VALIDATION_ERROR', [ @@ -211,11 +244,43 @@ async function createIssueCmd( ]); } + // Blank/missing --project/--cycle values fail loud before any network + // request (same guard shape as the `issues` read path). + if (hasProject && (projectName === undefined || projectName.trim() === '')) { + throw new AxiError('--project requires a value', 'VALIDATION_ERROR', [ + 'e.g. --project "Q3 launch"', + ]); + } + if (hasCycle && (cycleRaw === undefined || cycleRaw.trim() === '')) { + throw new AxiError('--cycle requires a value', 'VALIDATION_ERROR', [ + 'Use --cycle current for the active cycle, or --cycle (per --team)', + ]); + } + + // Resolve --project/--cycle to ids BEFORE the mutation (#25): a loud + // failure here beats an issue created without its requested project/cycle. + // On create --team is required, so every cycle lookup is team-scoped — no + // cross-team ambiguity exists on this path. + let projectId: string | undefined; + if (projectName) { + projectId = await requireProjectId(apiKey, projectName.trim()); + } + let cycleId: string | undefined; + if (cycleRaw !== undefined) { + const spec = parseCycleValue(cycleRaw); + cycleId = + spec === 'current' + ? await requireActiveCycleId(apiKey, team.trim()) + : await requireCycleIdByNumber(apiKey, team.trim(), spec); + } + const issue = await createIssue(apiKey, { title: title.trim(), description, teamKey: team.trim(), labelNames: labels, + projectId, + cycleId, }); const blocks: string[] = [ @@ -258,6 +323,19 @@ async function updateIssueCmd( const assigneeRaw = takeFlag(args, '--assignee'); const addLabelNames = takeAllFlags(args, '--label'); const removeLabelNames = takeAllFlags(args, '--remove-label'); + // has* detection must precede takeFlag (which removes the flag pair). + const hasProject = args.some( + (a) => a === '--project' || a.startsWith('--project='), + ); + const projectRaw = takeFlag(args, '--project'); + const hasCycle = args.some( + (a) => a === '--cycle' || a.startsWith('--cycle='), + ); + const cycleRaw = takeFlag(args, '--cycle'); + const hasTeam = args.some( + (a) => a === '--team' || a.startsWith('--team='), + ); + const teamRaw = takeFlag(args, '--team'); if ( !stateName && @@ -266,19 +344,66 @@ async function updateIssueCmd( description === undefined && assigneeRaw === undefined && addLabelNames.length === 0 && - removeLabelNames.length === 0 + removeLabelNames.length === 0 && + projectRaw === undefined && + cycleRaw === undefined ) { throw new AxiError( - 'Nothing to update — pass at least one of --state, --title, --priority, --description, --assignee, --label, --remove-label', + 'Nothing to update — pass at least one of --state, --title, --priority, --description, --assignee, --label, --remove-label, --project, --cycle', 'VALIDATION_ERROR', ); } + // Blank/missing values fail loud before any network request (same guard + // shape as the `issues` read path). + if (hasProject && (projectRaw === undefined || projectRaw.trim() === '')) { + throw new AxiError('--project requires a value', 'VALIDATION_ERROR', [ + 'e.g. --project "Q3 launch"', + ]); + } + if (hasCycle && (cycleRaw === undefined || cycleRaw.trim() === '')) { + throw new AxiError('--cycle requires a value', 'VALIDATION_ERROR', [ + 'Use --cycle current for the active cycle, or --cycle --team ', + ]); + } + const teamKey = teamRaw?.trim(); + if (hasTeam && !teamKey) { + throw new AxiError('--team requires a value', 'VALIDATION_ERROR'); + } + // --team on update exists ONLY to scope --cycle resolution (moving an issue + // between teams is not supported) — reject it alone rather than silently + // dropping it (AXI principle 6). + if (teamKey && cycleRaw === undefined) { + throw new AxiError( + '--team on update only applies to --cycle resolution', + 'VALIDATION_ERROR', + ['e.g. --cycle current --team LIN, or --cycle --team '], + ); + } + let priority: number | undefined; if (priorityRaw !== undefined) { priority = parsePriority(priorityRaw); } + // Cycle spec parsing also happens before any network request so the + // numeric-without-team case fails loud exactly like the #21 read path + // (cycle numbers restart per team, so a bare number is ambiguous). + let cycleSpec: 'current' | number | undefined; + if (cycleRaw !== undefined) { + cycleSpec = parseCycleValue(cycleRaw); + if (cycleSpec !== 'current' && !teamKey) { + throw new AxiError( + '--cycle requires --team (cycle numbers restart per team)', + 'VALIDATION_ERROR', + [ + `e.g. --cycle ${cycleSpec} --team LIN`, + 'Or use --cycle current without --team', + ], + ); + } + } + // Blank flag values fail loud before any network request (takeAllFlags // already rejects blank --label/--remove-label the same way). const assigneeName = assigneeRaw?.trim(); @@ -364,16 +489,56 @@ async function updateIssueCmd( } } + // Project (#25): resolve loud (requireProjectId — a silently missing project + // would surprise), then field-level no-op — requesting the project the issue + // already carries skips the field, exactly like a matching assignee above. + let projectId: string | undefined; + if (projectRaw !== undefined) { + const resolved = await requireProjectId(apiKey, projectRaw.trim()); + if (existing.project?.id !== resolved) { + projectId = resolved; + } + } + + // Cycle (#25): same field-level no-op against the issue's current cycle id. + // --team, when given, must be the issue's OWN team — resolving against + // another team would attach the issue to a foreign team's cycle. + let cycleId: string | undefined; + if (cycleSpec !== undefined) { + const issueTeamKey = existing.team?.key; + if ( + teamKey && + issueTeamKey && + teamKey.toUpperCase() !== issueTeamKey.toUpperCase() + ) { + throw new AxiError( + `Issue ${existing.identifier} belongs to team ${issueTeamKey} — --team must match it for --cycle resolution`, + 'VALIDATION_ERROR', + [`Drop --team, or use --team ${issueTeamKey}`], + ); + } + const resolved = + cycleSpec === 'current' + ? await requireActiveCycleId(apiKey, teamKey, issueTeamKey) + : await requireCycleIdByNumber(apiKey, teamKey!, cycleSpec); + if (existing.cycle?.id !== resolved) { + cycleId = resolved; + } + } + // Each field can individually be a no-op (a matching state short-circuits - // above; a matching assignee/label set is skipped). When NOTHING would be - // sent, report the no-op instead of mutating with an empty input. + // above; a matching assignee/label set/project/cycle is skipped). When + // NOTHING would be sent, report the no-op instead of mutating with an empty + // input. const hasPatch = title !== undefined || description !== undefined || stateName !== undefined || priority !== undefined || assigneeId !== undefined || - labelIds !== undefined; + labelIds !== undefined || + projectId !== undefined || + cycleId !== undefined; if (!hasPatch) { return renderOutput([ `issue: ${existing.identifier} already up to date (no-op)`, @@ -387,6 +552,8 @@ async function updateIssueCmd( priority, assigneeId, labelIds, + projectId, + cycleId, }); return renderOutput([ @@ -438,3 +605,93 @@ function parsePriority(raw: string): number { } return n; } + +/** + * Parse a --cycle value: "current" (case-insensitive) or a positive integer + * cycle number. Same grammar as the `issues` read path (#21); anything else + * fails loud before any network request. + */ +function parseCycleValue(raw: string): 'current' | number { + const value = raw.trim().toLowerCase(); + if (value === 'current') return 'current'; + if (/^\d+$/.test(value) && Number(value) >= 1) return Number(value); + throw new AxiError(`Invalid --cycle: ${raw}`, 'VALIDATION_ERROR', [ + 'Use "current" for the active cycle, or a positive cycle number', + ]); +} + +/** + * Resolve --cycle current to a cycle id (#25 write path). With teamKey (always + * on create, where --team is required; on update when passed): that team's + * active cycle. Without (update only): the workspace's active cycles — #21's + * read path lets "current" match every team's active cycle, but a WRITE must + * pick exactly one, so more than one active cycle fails loud listing the + * candidates instead of silently assigning a wrong team's cycle. When the + * target issue's team is known (update), a resolved cycle belonging to a + * DIFFERENT team is rejected for the same reason — an issue can only sit in + * one of its own team's cycles. + */ +async function requireActiveCycleId( + apiKey: string, + teamKey?: string, + issueTeamKey?: string, +): Promise { + const active = await fetchActiveCycles(apiKey, teamKey); + const scope = teamKey?.toUpperCase(); + if (active.length === 0) { + throw new AxiError( + scope + ? `Team ${scope} has no active cycle` + : 'No active cycles in your workspace', + 'VALIDATION_ERROR', + ['Run `linear-axi cycles` to see cycles'], + ); + } + if (active.length > 1) { + const list = active + .map((c) => `cycle ${c.number} (${c.teamKey})`) + .join(', '); + throw new AxiError( + scope + ? `Team ${scope} has ${active.length} active cycles — pick one by number instead` + : `${active.length} active cycles in your workspace — pass --team to pick one`, + 'VALIDATION_ERROR', + [`Active cycles: ${list}`], + ); + } + const match = active[0]!; + if ( + issueTeamKey && + match.teamKey.toUpperCase() !== issueTeamKey.toUpperCase() + ) { + throw new AxiError( + `The only active cycle is cycle ${match.number} of team ${match.teamKey}, but this issue belongs to team ${issueTeamKey}`, + 'VALIDATION_ERROR', + [ + `Use --cycle --team ${issueTeamKey} to pick one of its own team's cycles`, + ], + ); + } + return match.id; +} + +/** + * Resolve a numbered --cycle to a cycle id, loud on no match (#25 write + * path). The team key is mandatory — cycle numbers restart per team (same + * disambiguation as the #21 read path). + */ +async function requireCycleIdByNumber( + apiKey: string, + teamKey: string, + number: number, +): Promise { + const id = await resolveCycleIdByNumber(apiKey, teamKey, number); + if (!id) { + throw new AxiError( + `Cycle ${number} not found for team ${teamKey.toUpperCase()}`, + 'VALIDATION_ERROR', + [`Run \`linear-axi cycles --team ${teamKey.toUpperCase()}\` to see cycle numbers`], + ); + } + return id; +} diff --git a/src/linear.ts b/src/linear.ts index 11ea043..af82713 100644 --- a/src/linear.ts +++ b/src/linear.ts @@ -70,8 +70,11 @@ export const ISSUE_LIST_FIELDS = ` * Detail selection for a single issue. Label nodes include `id` because * `issue update --label/--remove-label` (#24) needs the issue's CURRENT label * ids to compute the union/difference replacement set — names alone cannot be - * sent back to IssueUpdateInput.labelIds. Renderers read only `name`, so the - * extra key is invisible in output. + * sent back to IssueUpdateInput.labelIds. `project { id name }` and + * `cycle { id number }` serve the same purpose for #25: update-side no-op + * detection compares the issue's CURRENT project/cycle ids against the + * resolved --project/--cycle ids before sending IssueUpdateInput.projectId/ + * cycleId. Renderers read none of the extra keys, so they stay invisible. */ export const ISSUE_DETAIL_FIELDS = ` id @@ -83,6 +86,8 @@ export const ISSUE_DETAIL_FIELDS = ` assignee { name } team { key name id } labels { nodes { id name } } + project { id name } + cycle { id number } url createdAt updatedAt @@ -139,6 +144,13 @@ export interface LinearIssue { // `id` is present when the selection includes it (ISSUE_DETAIL_FIELDS does; // ISSUE_LIST_FIELDS selects no labels at all), so it is optional here. labels?: { nodes: Array<{ id?: string; name: string }> } | null; + // Present when selected (ISSUE_DETAIL_FIELDS does, list selections do not): + // the issue's current project/cycle for #25 update no-op detection. Issue + // .project and .cycle are nullable object types per @linear/sdk (an issue + // can be in neither); `cycle.number` mirrors Cycle.number (Float, unique + // within its team). + project?: { id: string; name: string } | null; + cycle?: { id: string; number: number } | null; url?: string; createdAt?: string; updatedAt?: string; @@ -758,6 +770,18 @@ export interface IssueCreateInput { description?: string; teamKey: string; labelNames?: string[]; + /** + * Put the issue in this project (resolved by the caller from --project via + * requireProjectId). IssueCreateInput.projectId is a nullable String + * ("The project associated with the issue", @linear/sdk v90). + */ + projectId?: string; + /** + * Put the issue in this cycle (resolved by the caller from --cycle). + * IssueCreateInput.cycleId is a nullable String ("The cycle associated with + * the issue", @linear/sdk v90). + */ + cycleId?: string; } export async function createIssue( @@ -794,6 +818,18 @@ export async function createIssue( varDecls.push('$labelIds: [String!]'); variables['labelIds'] = labelIds; } + // projectId/cycleId (#25) follow the same omit-null convention: declared + // and sent only when the caller resolved an id. + if (input.projectId !== undefined) { + inputFields.push('projectId: $projectId'); + varDecls.push('$projectId: String'); + variables['projectId'] = input.projectId; + } + if (input.cycleId !== undefined) { + inputFields.push('cycleId: $cycleId'); + varDecls.push('$cycleId: String'); + variables['cycleId'] = input.cycleId; + } const data = await linearRequest<{ issueCreate: { issue: LinearIssue; success: boolean }; @@ -834,6 +870,19 @@ export interface IssueUpdate { * means "leave labels alone". */ labelIds?: string[]; + /** + * Move the issue into this project (resolved by the caller from --project + * via requireProjectId). IssueUpdateInput.projectId is a nullable String + * ("The project associated with the issue", @linear/sdk v90); undefined + * leaves the project untouched. + */ + projectId?: string; + /** + * Move the issue into this cycle (resolved by the caller from --cycle). + * IssueUpdateInput.cycleId is a nullable String ("The cycle associated + * with the issue", @linear/sdk v90); undefined leaves the cycle untouched. + */ + cycleId?: string; } /** @@ -892,6 +941,17 @@ export async function updateIssue( varDecls.push('$labelIds: [String!]'); variables['labelIds'] = update.labelIds; } + // projectId/cycleId (#25) follow the same omit-null convention as above. + if (update.projectId !== undefined) { + inputFields.push('projectId: $projectId'); + varDecls.push('$projectId: String'); + variables['projectId'] = update.projectId; + } + if (update.cycleId !== undefined) { + inputFields.push('cycleId: $cycleId'); + varDecls.push('$cycleId: String'); + variables['cycleId'] = update.cycleId; + } const data = await linearRequest<{ issueUpdate: { issue: LinearIssue; success: boolean }; @@ -990,6 +1050,105 @@ export async function resolveProjectId( return data.projects.nodes[0]?.id; } +/** + * Write-path project resolution (#25): resolveProjectId, but LOUD on no match. + * Silently creating/updating an issue without its requested project would + * surprise far more than a failed command (AXI principle 6), so mutations + * follow resolveStateIdByName/resolveUserId's loud convention while the read + * path keeps resolveProjectId's silent-undefined semantics. + */ +export async function requireProjectId( + apiKey: string, + name: string, +): Promise { + const id = await resolveProjectId(apiKey, name); + if (!id) { + throw new AxiError( + `Project "${name}" not found in your workspace`, + 'VALIDATION_ERROR', + ['Run `linear-axi projects` to see project names (exact match required)'], + ); + } + return id; +} + +/** An active cycle as returned by fetchActiveCycles. */ +export interface ActiveCycle { + id: string; + number: number; + /** + * Key of the team the cycle belongs to — Cycle.team is non-nullable + * ("The team that the cycle belongs to. Each cycle is scoped to exactly one + * team", @linear/sdk), so the workspace-wide query can attribute every + * active cycle to its team. + */ + teamKey: string; +} + +/** + * The active cycles of the workspace (or of one team) for write-side + * `--cycle current` resolution (#25). Uses the root `cycles` connection with + * `filter: CycleFilter` (QueryCyclesArgs in @linear/sdk v90): isActive is a + * BooleanComparator with eq (the same comparator the #21 read path puts in + * IssueFilter.cycle), and team is a TeamFilter, so `team: { key: { eq } }` + * scopes to one team — the same key comparator resolveTeamId relies on. One + * round trip either way; a team has at most one active cycle in practice, but + * callers decide what multiple matches mean (the workspace-wide form returns + * one per team with cycling enabled). + */ +export async function fetchActiveCycles( + apiKey: string, + teamKey?: string, +): Promise { + const perTeam = teamKey !== undefined; + const query = perTeam + ? `query TeamActiveCycles($teamKey: String!) { + cycles(filter: { isActive: { eq: true }, team: { key: { eq: $teamKey } } }) { + nodes { id number team { key } } + } + }` + : `query ActiveCycles { + cycles(filter: { isActive: { eq: true } }) { nodes { id number team { key } } } + }`; + const data = await linearRequest<{ + cycles: { + nodes: Array<{ id: string; number: number; team: { key: string } }>; + }; + }>(apiKey, query, perTeam ? { teamKey: teamKey.toUpperCase() } : {}); + return data.cycles.nodes.map((n) => ({ + id: n.id, + number: n.number, + teamKey: n.team.key, + })); +} + +/** + * Resolve a team's cycle NUMBER to its id (#25 write path). Cycle numbers are + * "unique within its team" and restart per team (Cycle.number, + * @linear/sdk v90), so the team key is mandatory — the same disambiguation + * the #21 read path enforces. CycleFilter.number is a NumberComparator whose + * eq takes a Float. Returns undefined when the team has no cycle with that + * number (callers fail loud — a mutation must not silently skip the cycle). + */ +export async function resolveCycleIdByNumber( + apiKey: string, + teamKey: string, + number: number, +): Promise { + const data = await linearRequest<{ + cycles: { nodes: Array<{ id: string }> }; + }>( + apiKey, + `query CycleByNumber($number: Float!, $teamKey: String!) { + cycles(filter: { number: { eq: $number }, team: { key: { eq: $teamKey } } }) { + nodes { id } + } + }`, + { number, teamKey: teamKey.toUpperCase() }, + ); + return data.cycles.nodes[0]?.id; +} + /** * Resolve label names to their ids, case-insensitively, in server order. * Reuses fetchLabels (auto-paginated) so label resolution keeps working in diff --git a/src/skill.ts b/src/skill.ts index 0f8005f..8fc0f26 100644 --- a/src/skill.ts +++ b/src/skill.ts @@ -7,7 +7,8 @@ export const SKILL_DESCRIPTION = 'create, update, and delete issues, post comments and read comment threads, list teams, list/filter projects, list cycles, and list labels. ' + 'Use whenever a task touches Linear: triaging or filing issues, changing issue state, setting priority, assigning issues or managing their labels, ' + 'commenting on or reading an issue discussion via `comment list`, discovering team keys via `teams`, scoping work to a project via `projects` ' + - 'and `issues --project`, to a cycle via `cycles` and `issues --cycle`, or finding label names via `labels` for `issue create --label`, ' + + 'and `issues --project`, to a cycle via `cycles` and `issues --cycle`, assigning issues to a project or cycle with `issue create/update ' + + '--project ` and `--cycle current|`, or finding label names via `labels` for `issue create --label`, ' + '`issue update --label`, and `issues --label`.'; export const SKILL_AUTHOR = 'ApexCloudWise'; @@ -52,15 +53,15 @@ 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, priority, assignee, or labels; commenting on issues or reading their comment threads; discovering team keys; listing projects and scoping issues to one with \`issues --project \`; listing a team's cycles and scoping issues to one with \`issues --cycle current| --team \`; or listing the workspace's labels and using their names with \`issues --label \`, \`issue create --label \`, or \`issue update --label \`. +Use linear-axi whenever a task touches Linear: listing, viewing, filing, editing, or deleting issues; changing issue state, priority, assignee, or labels; commenting on issues or reading their comment threads; discovering team keys; listing projects and scoping issues to one with \`issues --project \`; listing a team's cycles and scoping issues to one with \`issues --cycle current| --team \`; putting issues in a project or cycle with \`issue create/update --project \` or \`--cycle current|\`; or listing the workspace's labels and using their names with \`issues --label \`, \`issue create --label \`, or \`issue update --label \`. ## 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\`, \`cycles\`, \`labels\`. 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"\`). Assign with \`--assignee \` and manage labels with \`--label \` / \`--remove-label \` (repeatable) in the same command. +4. Create an issue with \`issue create --title "..." --team \`; team keys come from \`teams\`, and \`--project ""\` / \`--cycle current|\` land it in a project or cycle straight away. +5. Change state with \`issue update --state ""\`; pass the workflow state name exactly (e.g. \`"In Progress"\`). Assign with \`--assignee \`, manage labels with \`--label \` / \`--remove-label \` (repeatable), and move it into a project or cycle with \`--project ""\` / \`--cycle current|\` in the same command. 6. Comment with \`comment --body "..."\` or \`--body-file \` for multi-line bodies; read an issue's thread with \`comment list \` (newest first, \`--full\` for untruncated bodies). 7. Every response ends with contextual next-step hints under \`help:\` - follow them. @@ -82,6 +83,7 @@ Run \`npx -y @apexcloudwise/linear-axi --help\` for global flags, or \`npx -y @a - 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. Label names come from \`labels\`. - \`issue update --label \` adds to an issue's existing labels and \`--remove-label \` removes one; the last removal sends an explicit empty set, so it works too. Re-assigning the current assignee (or a label set that already matches) is a reported no-op. +- \`issue create/update --project \` matches the project name exactly (\`projects\` lists them) and fails loud on a miss; re-requesting the issue's current project or cycle is a reported no-op. On \`--cycle\`, "current" resolves the active cycle (scoped to \`--team\`'s when given; on update without \`--team\`, the workspace's single active one - ambiguous if several teams have one) while a cycle number always requires \`--team\` because numbers restart per team (\`cycles\` lists them). - \`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 ]\`. - \`issues --fields \` and \`issue view --fields \` add opt-in extra fields (per-command sets - see each command's --help) when the default columns lack something you need. - \`issue view\` truncates the description by default; pass \`--full\` to see it entirely. \`comment list\` likewise previews each comment body; \`--full\` shows them whole. diff --git a/test/project-cycle-assignment.test.ts b/test/project-cycle-assignment.test.ts new file mode 100644 index 0000000..6358258 --- /dev/null +++ b/test/project-cycle-assignment.test.ts @@ -0,0 +1,575 @@ +// Tests for `issue create/update --project / --cycle` (#25). Follows the +// network-free GraphQL stub pattern from issue-update-assignee-labels.test.ts: +// stub global fetch, then assert on the captured query documents, mutation +// variables, and rendered TOON output. +// +// Flow order under test: +// - create: resolve --project (query ProjectByName) and/or --cycle (query +// TeamActiveCycles / CycleByNumber) BEFORE the mutation, then createIssue +// resolves the team (query TeamByKey) and mutates. +// - update: resolve the ref (query Issue, now selecting project/cycle for +// no-op detection), resolve ids, mutate. +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { issueCommand, ISSUE_HELP } from '../src/commands/issue.js'; +import type { LinearIssue } from '../src/linear.js'; + +const FAKE_KEY = 'lin_api_fake'; + +interface CapturedRequest { + url: string; + body: { query: string; variables: Record }; +} + +/** A stub active-cycle node as returned by the cycles connection. */ +interface StubCycle { + id: string; + number: number; + teamKey: string; +} + +interface StubOptions { + /** The issue served for `query Issue(` (default: LIN-1, no project/cycle). */ + issue?: LinearIssue | null; + /** Projects served for `query ProjectByName(` (default: one "Q3 launch"). */ + projects?: Array<{ id: string; name: string }>; + /** Cycles served for the active-cycle queries (default: LIN cycle 12). */ + activeCycles?: StubCycle[]; + /** Cycles served for `query CycleByNumber(` (default: cycle 3, id cyc-3). */ + numberedCycles?: Array<{ id: string }>; +} + +function issueNode(overrides: Partial = {}): LinearIssue { + return { + id: 'ir-1', + identifier: 'LIN-1', + title: 'Ship the thing', + description: 'Body text', + state: { name: 'Todo', type: 'unstarted' }, + priority: 2, + assignee: null, + team: { key: 'LIN', name: 'Linear', id: 'team-1' }, + labels: { nodes: [] }, + project: null, + cycle: null, + url: 'https://linear.app/lini/issue/LIN-1/ship-the-thing', + createdAt: '2026-07-01T00:00:00.000Z', + updatedAt: '2026-08-01T00:00:00.000Z', + ...overrides, + }; +} + +const DEFAULT_PROJECTS = [{ id: 'proj-q3', name: 'Q3 launch' }]; + +const DEFAULT_ACTIVE_CYCLES: StubCycle[] = [ + { id: 'cyc-active-lin', number: 12, teamKey: 'LIN' }, +]; + +const DEFAULT_NUMBERED_CYCLES = [{ id: 'cyc-3' }]; + +function graphqlOk(data: unknown): Response { + return { ok: true, status: 200, json: async () => ({ data }) } as Response; +} + +function cycleNode(c: StubCycle): { + id: string; + number: number; + team: { key: string }; +} { + return { id: c.id, number: c.number, team: { key: c.teamKey } }; +} + +/** + * Stub global fetch as a Linear GraphQL endpoint serving every document the + * create/update flows can issue for #25. Returns captured requests for + * assertions. Any unexpected document fails the stub loudly. + */ +function stubFetch(opts: StubOptions = {}): { requests: CapturedRequest[] } { + const issue = opts.issue === undefined ? issueNode() : opts.issue; + const projects = opts.projects ?? DEFAULT_PROJECTS; + const activeCycles = opts.activeCycles ?? DEFAULT_ACTIVE_CYCLES; + const numberedCycles = opts.numberedCycles ?? DEFAULT_NUMBERED_CYCLES; + + const requests: CapturedRequest[] = []; + const fetchMock = vi.fn(async (url: string, init?: RequestInit) => { + const body = JSON.parse(String(init?.body)) as CapturedRequest['body']; + requests.push({ url: String(url), body }); + if (body.query.includes('query Issue(')) { + return graphqlOk({ issue }); + } + if (body.query.includes('query ProjectByName(')) { + return graphqlOk({ projects: { nodes: projects } }); + } + if (body.query.includes('query TeamActiveCycles(')) { + return graphqlOk({ + cycles: { nodes: activeCycles.map(cycleNode) }, + }); + } + // The workspace-wide document has no variables, so no parens after the name. + if (body.query.includes('query ActiveCycles')) { + return graphqlOk({ + cycles: { nodes: activeCycles.map(cycleNode) }, + }); + } + if (body.query.includes('query CycleByNumber(')) { + return graphqlOk({ cycles: { nodes: numberedCycles } }); + } + if (body.query.includes('query TeamByKey(')) { + return graphqlOk({ + teams: { nodes: [{ id: 'team-1', key: 'LIN' }] }, + }); + } + if (body.query.includes('mutation CreateIssue(')) { + return graphqlOk({ + issueCreate: { + success: true, + issue: { + id: 'ir-9', + identifier: 'LIN-9', + title: 'Ship v2', + url: 'https://linear.app/lini/issue/LIN-9/ship-v2', + }, + }, + }); + } + if (body.query.includes('mutation UpdateIssue(')) { + return graphqlOk({ + issueUpdate: { + success: true, + issue: { + id: 'ir-1', + identifier: 'LIN-1', + title: 'Ship the thing', + url: 'https://linear.app/lini/issue/LIN-1/ship-the-thing', + }, + }, + }); + } + throw new Error(`Unexpected GraphQL document in stub: ${body.query}`); + }); + vi.stubGlobal('fetch', fetchMock); + return { requests }; +} + +const createRequests = (requests: CapturedRequest[]) => + requests.filter((r) => r.body.query.includes('mutation CreateIssue(')); +const updateRequests = (requests: CapturedRequest[]) => + requests.filter((r) => r.body.query.includes('mutation UpdateIssue(')); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('issue create --project / --cycle', () => { + it('resolves --project by exact name and sends projectId', async () => { + const { requests } = stubFetch(); + + const out = await issueCommand( + [ + 'create', + '--title', + 'Ship v2', + '--team', + 'LIN', + '--project', + 'Q3 launch', + ], + { apiKey: FAKE_KEY }, + ); + + // Project resolution first, then team resolution, then the mutation. + expect(requests).toHaveLength(3); + expect(requests[0].body.query).toContain( + 'projects(filter: { name: { eq: $name } })', + ); + expect(requests[0].body.variables).toEqual({ name: 'Q3 launch' }); + const mutation = requests[2]; + expect(mutation.body.query).toContain('projectId: $projectId'); + expect(mutation.body.query).toContain('$projectId: String'); + expect(mutation.body.variables).toEqual({ + teamId: 'team-1', + title: 'Ship v2', + projectId: 'proj-q3', + }); + expect(out).toContain('created: LIN-9'); + }); + + it("resolves --cycle current to the team's active cycle and sends cycleId", async () => { + const { requests } = stubFetch(); + + await issueCommand( + ['create', '--title', 'Ship v2', '--team', 'LIN', '--cycle', 'current'], + { apiKey: FAKE_KEY }, + ); + + expect(requests).toHaveLength(3); + // --team scopes the active-cycle lookup on create. + expect(requests[0].body.query).toContain('query TeamActiveCycles('); + expect(requests[0].body.query).toContain( + 'isActive: { eq: true }, team: { key: { eq: $teamKey } }', + ); + expect(requests[0].body.variables).toEqual({ teamKey: 'LIN' }); + const mutation = requests[2]; + expect(mutation.body.query).toContain('cycleId: $cycleId'); + expect(mutation.body.variables).toEqual({ + teamId: 'team-1', + title: 'Ship v2', + cycleId: 'cyc-active-lin', + }); + }); + + it('resolves a numbered --cycle against --team and sends cycleId', async () => { + const { requests } = stubFetch(); + + await issueCommand( + ['create', '--title', 'Ship v2', '--team', 'LIN', '--cycle', '3'], + { apiKey: FAKE_KEY }, + ); + + expect(requests).toHaveLength(3); + expect(requests[0].body.query).toContain( + 'number: { eq: $number }, team: { key: { eq: $teamKey } }', + ); + expect(requests[0].body.variables).toEqual({ number: 3, teamKey: 'LIN' }); + expect(requests[2].body.variables).toEqual({ + teamId: 'team-1', + title: 'Ship v2', + cycleId: 'cyc-3', + }); + }); + + it('sends projectId AND cycleId together when both flags are given', async () => { + const { requests } = stubFetch(); + + await issueCommand( + [ + 'create', + '--title', + 'Ship v2', + '--team', + 'LIN', + '--project', + 'Q3 launch', + '--cycle', + 'current', + ], + { apiKey: FAKE_KEY }, + ); + + const mutation = createRequests(requests)[0]!; + expect(mutation.body.query).toContain('projectId: $projectId'); + expect(mutation.body.query).toContain('cycleId: $cycleId'); + expect(mutation.body.variables).toEqual({ + teamId: 'team-1', + title: 'Ship v2', + projectId: 'proj-q3', + cycleId: 'cyc-active-lin', + }); + }); + + it('fails loud on an unknown project name (no mutation)', async () => { + const { requests } = stubFetch({ projects: [] }); + + await expect( + issueCommand( + ['create', '--title', 'Ship v2', '--team', 'LIN', '--project', 'Nope'], + { apiKey: FAKE_KEY }, + ), + ).rejects.toThrow(/Project "Nope" not found/); + + expect(createRequests(requests)).toHaveLength(0); + }); + + it('fails loud when the team has no active cycle (no mutation)', async () => { + const { requests } = stubFetch({ activeCycles: [] }); + + await expect( + issueCommand( + ['create', '--title', 'Ship v2', '--team', 'LIN', '--cycle', 'current'], + { apiKey: FAKE_KEY }, + ), + ).rejects.toThrow(/Team LIN has no active cycle/); + + expect(createRequests(requests)).toHaveLength(0); + }); + + it('requires --team (cycle numbers restart per team) — create always does', async () => { + const { requests } = stubFetch(); + + await expect( + issueCommand(['create', '--title', 'Ship v2', '--cycle', '3'], { + apiKey: FAKE_KEY, + }), + ).rejects.toThrow(/--team is required/); + + expect(requests).toHaveLength(0); + }); +}); + +describe('issue update --project', () => { + it('sends projectId when the issue is in a different project', async () => { + const { requests } = stubFetch({ + issue: issueNode({ + project: { id: 'proj-old', name: 'Old thing' }, + }), + }); + + const out = await issueCommand( + ['update', 'LIN-1', '--project', 'Q3 launch'], + { apiKey: FAKE_KEY }, + ); + + // Ref resolution now selects the issue's current project/cycle ids. + expect(requests[0].body.query).toContain('project { id name }'); + expect(requests[0].body.query).toContain('cycle { id number }'); + expect(requests[1].body.variables).toEqual({ name: 'Q3 launch' }); + const mutation = updateRequests(requests)[0]!; + expect(mutation.body.query).toContain('projectId: $projectId'); + // Omit-null builder: only the changed field is declared/sent. + expect(mutation.body.query).toContain( + 'mutation UpdateIssue($id: String!, $projectId: String)', + ); + expect(mutation.body.variables).toEqual({ + id: 'ir-1', + projectId: 'proj-q3', + }); + expect(out).toContain('updated: LIN-1'); + }); + + it('reports re-requesting the current project as a no-op (no mutation)', async () => { + const { requests } = stubFetch({ + issue: issueNode({ project: { id: 'proj-q3', name: 'Q3 launch' } }), + }); + + const out = await issueCommand( + ['update', 'LIN-1', '--project', 'Q3 launch'], + { apiKey: FAKE_KEY }, + ); + + expect(out).toContain('(no-op)'); + expect(updateRequests(requests)).toHaveLength(0); + }); + + it('sends projectId when the issue carries no project yet', async () => { + const { requests } = stubFetch({ issue: issueNode() }); + + await issueCommand(['update', 'LIN-1', '--project', 'Q3 launch'], { + apiKey: FAKE_KEY, + }); + + expect(updateRequests(requests)[0]!.body.variables).toEqual({ + id: 'ir-1', + projectId: 'proj-q3', + }); + }); + + it('fails loud on an unknown project name (no mutation)', async () => { + const { requests } = stubFetch({ projects: [] }); + + await expect( + issueCommand(['update', 'LIN-1', '--project', 'Nope'], { + apiKey: FAKE_KEY, + }), + ).rejects.toThrow(/Project "Nope" not found/); + + expect(updateRequests(requests)).toHaveLength(0); + }); +}); + +describe('issue update --cycle', () => { + it("resolves --cycle current to the workspace's single active cycle", async () => { + const { requests } = stubFetch({ issue: issueNode() }); + + const out = await issueCommand(['update', 'LIN-1', '--cycle', 'current'], { + apiKey: FAKE_KEY, + }); + + // Ref resolution, workspace-wide active-cycle lookup, mutate. + expect(requests).toHaveLength(3); + // No variables in the workspace-wide document, hence no parens after the name. + expect(requests[1].body.query).toContain('query ActiveCycles {'); + expect(requests[1].body.query).toContain('isActive: { eq: true }'); + expect(requests[1].body.variables).toEqual({}); + const mutation = updateRequests(requests)[0]!; + expect(mutation.body.query).toContain( + 'mutation UpdateIssue($id: String!, $cycleId: String)', + ); + expect(mutation.body.variables).toEqual({ + id: 'ir-1', + cycleId: 'cyc-active-lin', + }); + expect(out).toContain('updated: LIN-1'); + }); + + it('fails loud listing candidates when several teams have active cycles', async () => { + const { requests } = stubFetch({ + activeCycles: [ + { id: 'cyc-lin-12', number: 12, teamKey: 'LIN' }, + { id: 'cyc-eng-7', number: 7, teamKey: 'ENG' }, + ], + }); + + const err = await issueCommand(['update', 'LIN-1', '--cycle', 'current'], { + apiKey: FAKE_KEY, + }).catch((e: { message: string; suggestions?: string[] }) => e); + + expect(err.message).toMatch(/2 active cycles/); + expect(err.suggestions?.join(' ')).toContain('cycle 12 (LIN)'); + expect(err.suggestions?.join(' ')).toContain('cycle 7 (ENG)'); + expect(updateRequests(requests)).toHaveLength(0); + }); + + it('rejects the only active cycle when it belongs to another team', async () => { + const { requests } = stubFetch({ + activeCycles: [{ id: 'cyc-eng-7', number: 7, teamKey: 'ENG' }], + }); + + await expect( + issueCommand(['update', 'LIN-1', '--cycle', 'current'], { + apiKey: FAKE_KEY, + }), + ).rejects.toThrow(/belongs to team LIN/); + + expect(updateRequests(requests)).toHaveLength(0); + }); + + it('scopes --cycle current to --team when given', async () => { + const { requests } = stubFetch(); + + await issueCommand( + ['update', 'LIN-1', '--cycle', 'current', '--team', 'LIN'], + { apiKey: FAKE_KEY }, + ); + + expect(requests[1].body.query).toContain('query TeamActiveCycles('); + expect(requests[1].body.variables).toEqual({ teamKey: 'LIN' }); + expect(updateRequests(requests)[0]!.body.variables).toEqual({ + id: 'ir-1', + cycleId: 'cyc-active-lin', + }); + }); + + it("rejects a --team that does not match the issue's own team", async () => { + const { requests } = stubFetch(); + + await expect( + issueCommand(['update', 'LIN-1', '--cycle', 'current', '--team', 'ENG'], { + apiKey: FAKE_KEY, + }), + ).rejects.toThrow(/belongs to team LIN/); + + expect(updateRequests(requests)).toHaveLength(0); + }); + + it('resolves a numbered --cycle against --team and sends cycleId', async () => { + const { requests } = stubFetch({ + issue: issueNode({ cycle: { id: 'cyc-active-lin', number: 12 } }), + }); + + await issueCommand(['update', 'LIN-1', '--cycle', '3', '--team', 'LIN'], { + apiKey: FAKE_KEY, + }); + + expect(requests[1].body.query).toContain('query CycleByNumber('); + expect(requests[1].body.variables).toEqual({ number: 3, teamKey: 'LIN' }); + expect(updateRequests(requests)[0]!.body.variables).toEqual({ + id: 'ir-1', + cycleId: 'cyc-3', + }); + }); + + it('requires --team for a numbered cycle, before any network request', async () => { + const { requests } = stubFetch(); + + await expect( + issueCommand(['update', 'LIN-1', '--cycle', '3'], { apiKey: FAKE_KEY }), + ).rejects.toThrow(/--cycle requires --team/); + + expect(requests).toHaveLength(0); + }); + + it('fails loud when the team has no such cycle number', async () => { + const { requests } = stubFetch({ numberedCycles: [] }); + + await expect( + issueCommand(['update', 'LIN-1', '--cycle', '9', '--team', 'LIN'], { + apiKey: FAKE_KEY, + }), + ).rejects.toThrow(/Cycle 9 not found for team LIN/); + + expect(updateRequests(requests)).toHaveLength(0); + }); + + it('reports re-requesting the current cycle as a no-op (no mutation)', async () => { + const { requests } = stubFetch({ + issue: issueNode({ cycle: { id: 'cyc-active-lin', number: 12 } }), + }); + + const out = await issueCommand(['update', 'LIN-1', '--cycle', 'current'], { + apiKey: FAKE_KEY, + }); + + expect(out).toContain('(no-op)'); + expect(updateRequests(requests)).toHaveLength(0); + }); +}); + +describe('parity and guards (create and update)', () => { + it('rejects --team on update without --cycle', async () => { + const { requests } = stubFetch(); + + await expect( + issueCommand(['update', 'LIN-1', '--title', 'x', '--team', 'LIN'], { + apiKey: FAKE_KEY, + }), + ).rejects.toThrow(/--team on update only applies to --cycle/); + + expect(requests).toHaveLength(0); + }); + + it('rejects blank --project / --cycle values loudly', async () => { + stubFetch(); + await expect( + issueCommand(['update', 'LIN-1', '--project', ''], { apiKey: FAKE_KEY }), + ).rejects.toThrow(/--project requires a value/); + await expect( + issueCommand(['update', 'LIN-1', '--cycle', ''], { apiKey: FAKE_KEY }), + ).rejects.toThrow(/--cycle requires a value/); + await expect( + issueCommand( + ['create', '--title', 'x', '--team', 'LIN', '--project', ''], + { apiKey: FAKE_KEY }, + ), + ).rejects.toThrow(/--project requires a value/); + await expect( + issueCommand( + ['create', '--title', 'x', '--team', 'LIN', '--cycle', 'soon'], + { apiKey: FAKE_KEY }, + ), + ).rejects.toThrow(/Invalid --cycle: soon/); + }); + + it('lists the new flags in the nothing-to-update guard', async () => { + stubFetch(); + await expect( + issueCommand(['update', 'LIN-1'], { apiKey: FAKE_KEY }), + ).rejects.toThrow(/Nothing to update.*--project, --cycle/); + }); + + it('documents the flags and examples in help (both subcommands)', () => { + expect(ISSUE_HELP).toContain( + 'create --title "..." --team [--description "..."] [--label ...] [--project ] [--cycle ]', + ); + expect(ISSUE_HELP).toContain( + '[--assignee ] [--label ] [--remove-label ] [--project ] [--cycle ]', + ); + expect(ISSUE_HELP).toContain( + 'linear-axi issue create --title "Ship v2" --team LIN --project "Q3 launch" --cycle current', + ); + expect(ISSUE_HELP).toContain( + 'linear-axi issue update LIN-123 --cycle current', + ); + expect(ISSUE_HELP).toContain( + 'linear-axi issue update LIN-123 --cycle 3 --team LIN', + ); + }); +});