diff --git a/skills/linear-axi/SKILL.md b/skills/linear-axi/SKILL.md index 1f5f0b3..97f223d 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, commenting 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 --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`, or finding label names via `labels` for `issue create --label`, `issue update --label`, and `issues --label`." user-invocable: false author: ApexCloudWise metadata: @@ -20,7 +20,7 @@ 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 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 ` or `issue create --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 `; or listing the workspace's labels and using their names with `issues --label `, `issue create --label `, or `issue update --label `. ## Workflow @@ -28,7 +28,7 @@ Use linear-axi whenever a task touches Linear: listing, viewing, filing, editing 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"`). +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. 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. @@ -50,6 +50,7 @@ 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. 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. - `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 46f6dae..3b7b543 100644 --- a/src/commands/issue.ts +++ b/src/commands/issue.ts @@ -2,9 +2,12 @@ import type { LinearContext } from '../context.js'; import { requireKey } from '../config.js'; import { fetchIssue, + fetchViewer, createIssue, updateIssue, deleteIssue, + resolveUserId, + resolveLabelIds, } from '../linear.js'; import { assertKnownFlags, @@ -35,23 +38,39 @@ subcommands: view show issue details (--full disables description truncation; --fields adds extra fields) create --title "..." --team [--description "..."] [--label ...] update [--state ] [--title "..."] [--priority <0-4>] [--description "..."] + [--assignee ] [--label ] [--remove-label ] delete view --fields names (opt-in, comma-separated): dueDate, estimate, archivedAt, branchName default detail fields are untouched without the flag +update flags: + --assignee assign the issue; "me" is the authenticated viewer + --label add a label (repeatable; names from \`linear-axi labels\`) + --remove-label remove a label (repeatable); removing the last one clears labels + 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 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 delete LIN-123 `; const VIEW_FLAGS = ['--full', '--fields']; const CREATE_FLAGS = ['--title', '--team', '--description', '--label']; -const UPDATE_FLAGS = ['--state', '--title', '--priority', '--description']; +const UPDATE_FLAGS = [ + '--state', + '--title', + '--priority', + '--description', + '--assignee', + '--label', + '--remove-label', +]; const detailSchema: FieldDef[] = [ field('identifier'), @@ -236,10 +255,21 @@ async function updateIssueCmd( const title = takeFlag(args, '--title'); const priorityRaw = takeFlag(args, '--priority'); const description = takeFlag(args, '--description'); + const assigneeRaw = takeFlag(args, '--assignee'); + const addLabelNames = takeAllFlags(args, '--label'); + const removeLabelNames = takeAllFlags(args, '--remove-label'); - if (!stateName && !title && priorityRaw === undefined && description === undefined) { + if ( + !stateName && + !title && + priorityRaw === undefined && + description === undefined && + assigneeRaw === undefined && + addLabelNames.length === 0 && + removeLabelNames.length === 0 + ) { throw new AxiError( - 'Nothing to update — pass at least one of --state, --title, --priority, --description', + 'Nothing to update — pass at least one of --state, --title, --priority, --description, --assignee, --label, --remove-label', 'VALIDATION_ERROR', ); } @@ -249,6 +279,13 @@ async function updateIssueCmd( priority = parsePriority(priorityRaw); } + // Blank flag values fail loud before any network request (takeAllFlags + // already rejects blank --label/--remove-label the same way). + const assigneeName = assigneeRaw?.trim(); + if (assigneeRaw !== undefined && !assigneeName) { + throw new AxiError('--assignee requires a value', 'VALIDATION_ERROR'); + } + const existing = await fetchIssue(apiKey, refRaw); if (!existing) { throw new AxiError(`Issue "${refRaw}" not found`, 'NOT_FOUND'); @@ -264,11 +301,92 @@ async function updateIssueCmd( ]); } + // Assignee (#24): "me" resolves to the viewer — fetchViewer already + // returns the id the mutation needs, so no users() round trip. Any other + // value resolves through users(filter: { name: { eq } }) and fails loud on + // no match or an ambiguous display name. Requesting the current assignee + // is a field-level no-op (skipped, like a matching state above). + let assigneeId: string | undefined; + if (assigneeName) { + let resolvedId: string; + let resolvedName: string; + if (assigneeName.toLowerCase() === 'me') { + const viewer = await fetchViewer(apiKey).catch(() => null); + if (!viewer?.id) { + throw new AxiError( + 'Could not resolve your Linear user for --assignee me', + 'UNKNOWN', + ); + } + resolvedId = viewer.id; + resolvedName = viewer.name; + } else { + resolvedId = await resolveUserId(apiKey, assigneeName); + resolvedName = assigneeName; + } + const currentAssignee = existing.assignee?.name; + if ( + !currentAssignee || + resolvedName.toLowerCase() !== currentAssignee.toLowerCase() + ) { + assigneeId = resolvedId; + } + } + + // Labels (#24): Linear's labelIds REPLACES the whole set, so compute the + // final set here — the issue's current label ids (ISSUE_DETAIL_FIELDS + // selects labels { nodes { id name } }) minus names matched by + // --remove-label (case-insensitive, against the issue's own labels so an + // absent name is a harmless no-op), unioned with --label names resolved + // workspace-wide via resolveLabelIds. When the computed set equals the + // current one the field is skipped; when removal empties it, [] is sent + // explicitly (updateIssue) — removing the last label must work. + let labelIds: string[] | undefined; + if (addLabelNames.length > 0 || removeLabelNames.length > 0) { + const currentNodes = existing.labels?.nodes ?? []; + const currentIds = currentNodes + .map((n) => n.id) + .filter((id): id is string => id !== undefined); + const removeLower = removeLabelNames.map((n) => n.trim().toLowerCase()); + const keptIds = currentNodes + .filter((n) => !removeLower.includes(n.name.toLowerCase())) + .map((n) => n.id) + .filter((id): id is string => id !== undefined); + const addIds = addLabelNames.length + ? await resolveLabelIds(apiKey, addLabelNames) + : []; + const nextIds = [...new Set([...keptIds, ...addIds])]; + const unchanged = + nextIds.length === currentIds.length && + nextIds.every((id) => currentIds.includes(id)); + if (!unchanged) { + labelIds = nextIds; + } + } + + // 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. + const hasPatch = + title !== undefined || + description !== undefined || + stateName !== undefined || + priority !== undefined || + assigneeId !== undefined || + labelIds !== undefined; + if (!hasPatch) { + return renderOutput([ + `issue: ${existing.identifier} already up to date (no-op)`, + ]); + } + const updated = await updateIssue(apiKey, existing.id, { title, description, stateName, priority, + assigneeId, + labelIds, }); return renderOutput([ diff --git a/src/linear.ts b/src/linear.ts index cc66e9f..11ea043 100644 --- a/src/linear.ts +++ b/src/linear.ts @@ -66,6 +66,13 @@ export const ISSUE_LIST_FIELDS = ` updatedAt `; +/** + * 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. + */ export const ISSUE_DETAIL_FIELDS = ` id identifier @@ -75,7 +82,7 @@ export const ISSUE_DETAIL_FIELDS = ` priority assignee { name } team { key name id } - labels { nodes { name } } + labels { nodes { id name } } url createdAt updatedAt @@ -129,7 +136,9 @@ export interface LinearIssue { priority?: number; assignee?: { name: string } | null; team?: { id?: string; key: string; name?: string } | null; - labels?: { nodes: Array<{ name: string }> } | null; + // `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; url?: string; createdAt?: string; updatedAt?: string; @@ -810,6 +819,21 @@ export interface IssueUpdate { description?: string; stateName?: string; priority?: number; + /** + * Assign the issue to this user id. Undefined leaves the assignee + * untouched; Linear's IssueUpdateInput.assigneeId is a nullable String + * ("The identifier of the user to assign the issue to", @linear/sdk v90). + */ + assigneeId?: string; + /** + * FULL REPLACEMENT label-id set — Linear's IssueUpdateInput.labelIds + * replaces the issue's labels wholesale ("The identifiers of the issue + * labels associated with this ticket", @linear/sdk v90). Callers compute + * union/difference against the issue's current ids. An EMPTY ARRAY IS + * SENT EXPLICITLY (removing the last label must work); only undefined + * means "leave labels alone". + */ + labelIds?: string[]; } /** @@ -854,6 +878,20 @@ export async function updateIssue( varDecls.push('$stateId: String'); variables['stateId'] = stateId; } + // assigneeId/labelIds follow the same omit-null convention as above, with + // one deliberate exception: labelIds === [] IS included — an explicit + // removal of the last label must reach Linear as an empty replacement set, + // not be skipped like a null optional. + if (update.assigneeId !== undefined) { + inputFields.push('assigneeId: $assigneeId'); + varDecls.push('$assigneeId: String'); + variables['assigneeId'] = update.assigneeId; + } + if (update.labelIds !== undefined) { + inputFields.push('labelIds: $labelIds'); + varDecls.push('$labelIds: [String!]'); + variables['labelIds'] = update.labelIds; + } const data = await linearRequest<{ issueUpdate: { issue: LinearIssue; success: boolean }; @@ -957,8 +995,9 @@ export async function resolveProjectId( * Reuses fetchLabels (auto-paginated) so label resolution keeps working in * workspaces with more than one page (50) of labels — the old inline query * here fetched a single unpaginated page and silently missed the rest. + * Unknown names are silently skipped (same semantics as issue create). */ -async function resolveLabelIds( +export async function resolveLabelIds( apiKey: string, names: string[], ): Promise { @@ -969,6 +1008,52 @@ async function resolveLabelIds( .map((l) => l.id); } +/** + * Resolve a user's display name (exact match) to their id for + * `issue update --assignee ` (#24). Query shape verified against the + * generated documents in @linear/sdk v90: Query.users takes + * `filter: UserFilter` where UserFilter.name is a StringComparator with `eq`, + * and the User type exposes id (ID), name, email — all selected here. + * + * Fails loud on both no-match and multi-match (display names are not unique + * in Linear), listing the matched candidates like resolveStateIdByName lists + * available states. + */ +export async function resolveUserId( + apiKey: string, + name: string, +): Promise { + const data = await linearRequest<{ + users: { nodes: Array<{ id: string; name: string; email: string }> }; + }>( + apiKey, + `query UserByName($name: String!) { + users(filter: { name: { eq: $name } }) { nodes { id name email } } + }`, + { name }, + ); + + const nodes = data.users.nodes; + if (nodes.length === 0) { + throw new AxiError( + `User "${name}" not found in your workspace`, + 'VALIDATION_ERROR', + ['Use the exact display name, or "me" for yourself'], + ); + } + if (nodes.length > 1) { + const candidates = nodes + .map((u) => `${u.name} <${u.email}>`) + .join(', '); + throw new AxiError( + `User "${name}" matches ${nodes.length} users`, + 'VALIDATION_ERROR', + [`Candidates: ${candidates}`], + ); + } + return nodes[0]!.id; +} + async function resolveStateIdByName( apiKey: string, issueRef: string, diff --git a/src/skill.ts b/src/skill.ts index a8b3434..0f8005f 100644 --- a/src/skill.ts +++ b/src/skill.ts @@ -5,9 +5,10 @@ import { DESCRIPTION, TOP_HELP } from './cli.js'; 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 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, ' + - 'commenting 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 --label`.'; + '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`.'; export const SKILL_AUTHOR = 'ApexCloudWise'; @@ -51,7 +52,7 @@ 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 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 \` or \`issue create --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 \`; or listing the workspace's labels and using their names with \`issues --label \`, \`issue create --label \`, or \`issue update --label \`. ## Workflow @@ -59,7 +60,7 @@ Use linear-axi whenever a task touches Linear: listing, viewing, filing, editing 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"\`). +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. 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. @@ -80,6 +81,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. 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. - \`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/issue-update-assignee-labels.test.ts b/test/issue-update-assignee-labels.test.ts new file mode 100644 index 0000000..e6ae0cc --- /dev/null +++ b/test/issue-update-assignee-labels.test.ts @@ -0,0 +1,395 @@ +// Tests for `issue update --assignee / --label / --remove-label` (#24). +// Follows the network-free GraphQL stub pattern from labels.test.ts / +// comment-list.test.ts: stub global fetch, then assert on the captured query +// documents, mutation variables, and rendered TOON output. Update flow order: +// resolve the ref (query Issue), resolve the assignee (viewer for "me", +// UserByName otherwise), resolve added label names (query Labels), mutate. +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { issueCommand, ISSUE_HELP } from '../src/commands/issue.js'; +import type { AxiError } from '../errors.js'; +import type { LinearIssue, LinearLabel } from '../src/linear.js'; + +const FAKE_KEY = 'lin_api_fake'; + +interface CapturedRequest { + url: string; + body: { query: string; variables: Record }; +} + +interface StubUser { + id: string; + name: string; + email: string; +} + +interface StubOptions { + /** The issue served for `query Issue(` (default: LIN-1, unassigned). */ + issue?: LinearIssue | null; + /** Users served for `query UserByName(` (default: one Ada Lovelace). */ + users?: StubUser[]; + /** Viewer served for the `viewer { ... }` query (default: Sam the viewer). */ + viewer?: StubUser | null; + /** Labels served for `query Labels(` (default: bug + feature). */ + labels?: LinearLabel[]; +} + +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: [] }, + 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_VIEWER: StubUser = { + id: 'user-viewer', + name: 'Sam Viewer', + email: 'sam@example.com', +}; + +const DEFAULT_USERS: StubUser[] = [ + { id: 'user-ada', name: 'Ada Lovelace', email: 'ada@example.com' }, +]; + +const DEFAULT_LABELS: LinearLabel[] = [ + { id: 'lb-bug', name: 'bug', color: '#EB5757' }, + { id: 'lb-feat', name: 'feature', color: '#4CB782' }, +]; + +function graphqlOk(data: unknown): Response { + return { ok: true, status: 200, json: async () => ({ data }) } as Response; +} + +/** + * Stub global fetch as a Linear GraphQL endpoint serving every document the + * update flow can issue. Returns the captured requests for assertions. + */ +function stubUpdateFetch(opts: StubOptions = {}): { + requests: CapturedRequest[]; +} { + const issue = opts.issue === undefined ? issueNode() : opts.issue; + const users = opts.users ?? DEFAULT_USERS; + const viewer = opts.viewer === undefined ? DEFAULT_VIEWER : opts.viewer; + const labels = opts.labels ?? DEFAULT_LABELS; + + 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('viewer {')) { + return graphqlOk({ viewer }); + } + if (body.query.includes('query UserByName(')) { + return graphqlOk({ users: { nodes: users } }); + } + if (body.query.includes('query Labels(')) { + return graphqlOk({ + issueLabels: { + nodes: labels, + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }); + } + 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', + }, + }, + }); + }); + vi.stubGlobal('fetch', fetchMock); + return { requests }; +} + +const mutationRequests = (requests: CapturedRequest[]) => + requests.filter((r) => r.body.query.includes('mutation UpdateIssue(')); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('issue update --assignee', () => { + it('resolves "me" to the viewer id and sends only assigneeId', async () => { + const { requests } = stubUpdateFetch(); + + const out = await issueCommand(['update', 'LIN-1', '--assignee', 'me'], { + apiKey: FAKE_KEY, + }); + + // Issue resolution, then viewer, then the mutation. + expect(requests).toHaveLength(3); + expect(requests[1].body.query).toContain('viewer { id name email }'); + const mutation = requests[2]; + expect(mutation.body.query).toContain('assigneeId: $assigneeId'); + // Omit-null builder: only the fields that changed are declared/sent. + expect(mutation.body.query).toContain( + 'mutation UpdateIssue($id: String!, $assigneeId: String)', + ); + expect(mutation.body.query).not.toContain('labelIds'); + expect(mutation.body.query).not.toContain('$title'); + expect(mutation.body.variables).toEqual({ + id: 'ir-1', + assigneeId: 'user-viewer', + }); + expect(out).toContain('updated: LIN-1'); + }); + + it('resolves a named assignee through users(filter: { name: { eq } })', async () => { + const { requests } = stubUpdateFetch(); + + await issueCommand(['update', 'LIN-1', '--assignee', 'Ada Lovelace'], { + apiKey: FAKE_KEY, + }); + + expect(requests).toHaveLength(3); + expect(requests[1].body.query).toContain( + 'users(filter: { name: { eq: $name } })', + ); + expect(requests[1].body.query).toContain('nodes { id name email }'); + expect(requests[1].body.variables).toEqual({ name: 'Ada Lovelace' }); + expect(requests[2].body.variables).toEqual({ + id: 'ir-1', + assigneeId: 'user-ada', + }); + }); + + it('fails loud when the assignee name matches no user', async () => { + const { requests } = stubUpdateFetch({ users: [] }); + + await expect( + issueCommand(['update', 'LIN-1', '--assignee', 'Nobody'], { + apiKey: FAKE_KEY, + }), + ).rejects.toThrow(/User "Nobody" not found/); + + expect(mutationRequests(requests)).toHaveLength(0); + }); + + it('fails loud listing candidates when the name is ambiguous', async () => { + const { requests } = stubUpdateFetch({ + users: [ + { id: 'user-ada-1', name: 'Ada Lovelace', email: 'ada@example.com' }, + { id: 'user-ada-2', name: 'Ada Lovelace', email: 'ada2@example.com' }, + ], + }); + + await expect( + issueCommand(['update', 'LIN-1', '--assignee', 'Ada Lovelace'], { + apiKey: FAKE_KEY, + }), + ).rejects.toThrow(/matches 2 users/); + + const err = await issueCommand( + ['update', 'LIN-1', '--assignee', 'Ada Lovelace'], + { apiKey: FAKE_KEY }, + ).catch((e: AxiError) => e); + expect(err.suggestions.join(' ')).toContain('ada@example.com'); + expect(err.suggestions.join(' ')).toContain('ada2@example.com'); + expect(mutationRequests(requests)).toHaveLength(0); + }); + + it('reports re-assigning the current assignee as a no-op (no mutation)', async () => { + const { requests } = stubUpdateFetch({ + issue: issueNode({ assignee: { name: 'Ada Lovelace' } }), + }); + + const out = await issueCommand( + ['update', 'LIN-1', '--assignee', 'ada lovelace'], + { apiKey: FAKE_KEY }, + ); + + expect(out).toContain('(no-op)'); + expect(mutationRequests(requests)).toHaveLength(0); + }); + + it('reports --assignee me as a no-op when the viewer is assigned', async () => { + const { requests } = stubUpdateFetch({ + issue: issueNode({ assignee: { name: 'Sam Viewer' } }), + }); + + const out = await issueCommand(['update', 'LIN-1', '--assignee', 'me'], { + apiKey: FAKE_KEY, + }); + + expect(out).toContain('(no-op)'); + expect(mutationRequests(requests)).toHaveLength(0); + }); +}); + +describe('issue update --label / --remove-label', () => { + it('unions resolved --label ids with the current label ids', async () => { + const { requests } = stubUpdateFetch({ + issue: issueNode({ + labels: { nodes: [{ id: 'lb-feat', name: 'feature' }] }, + }), + }); + + const out = await issueCommand(['update', 'LIN-1', '--label', 'bug'], { + apiKey: FAKE_KEY, + }); + + // Issue resolution, label-name resolution (reuse of fetchLabels), mutate. + expect(requests).toHaveLength(3); + expect(requests[1].body.query).toContain( + 'issueLabels(first: $first, after: $after, orderBy: updatedAt)', + ); + const mutation = requests[2]; + expect(mutation.body.query).toContain('labelIds: $labelIds'); + expect(mutation.body.query).toContain('$labelIds: [String!]'); + // Current ids first, resolved additions after — the full replacement set. + expect(mutation.body.variables).toEqual({ + id: 'ir-1', + labelIds: ['lb-feat', 'lb-bug'], + }); + expect(out).toContain('updated: LIN-1'); + }); + + it("removes one of two labels by matching the issue's own label names", async () => { + const { requests } = stubUpdateFetch({ + issue: issueNode({ + labels: { + nodes: [ + { id: 'lb-bug', name: 'bug' }, + { id: 'lb-feat', name: 'feature' }, + ], + }, + }), + }); + + await issueCommand(['update', 'LIN-1', '--remove-label', 'BUG'], { + apiKey: FAKE_KEY, + }); + + // Removal matches against the issue's own labels — no workspace labels + // round trip, just resolve + mutate. Case-insensitive name match. + expect(requests).toHaveLength(2); + expect(requests[1].body.variables).toEqual({ + id: 'ir-1', + labelIds: ['lb-feat'], + }); + }); + + it('sends labelIds: [] explicitly when the LAST label is removed', async () => { + const { requests } = stubUpdateFetch({ + issue: issueNode({ + labels: { nodes: [{ id: 'lb-bug', name: 'bug' }] }, + }), + }); + + const out = await issueCommand( + ['update', 'LIN-1', '--remove-label', 'bug'], + { apiKey: FAKE_KEY }, + ); + + const mutation = requests[1]; + // The field must be INCLUDED with an empty array — an omitted labelIds + // would leave the label in place (Linear replaces the whole set). + expect(mutation.body.query).toContain('labelIds: $labelIds'); + expect(mutation.body.variables).toEqual({ id: 'ir-1', labelIds: [] }); + expect(out).toContain('updated: LIN-1'); + }); + + it('reports a label set that already matches as a no-op (no mutation)', async () => { + const { requests } = stubUpdateFetch({ + issue: issueNode({ + labels: { nodes: [{ id: 'lb-bug', name: 'bug' }] }, + }), + }); + + const out = await issueCommand(['update', 'LIN-1', '--label', 'bug'], { + apiKey: FAKE_KEY, + }); + + expect(out).toContain('(no-op)'); + expect(mutationRequests(requests)).toHaveLength(0); + }); + + it('treats removing a label the issue does not carry as a no-op', async () => { + const { requests } = stubUpdateFetch({ + issue: issueNode({ + labels: { nodes: [{ id: 'lb-bug', name: 'bug' }] }, + }), + }); + + const out = await issueCommand( + ['update', 'LIN-1', '--remove-label', 'feature'], + { apiKey: FAKE_KEY }, + ); + + expect(out).toContain('(no-op)'); + expect(mutationRequests(requests)).toHaveLength(0); + }); +}); + +describe('issue update acceptance and guards', () => { + it('applies --assignee me --label bug together (acceptance case)', async () => { + const { requests } = stubUpdateFetch(); + + const out = await issueCommand( + ['update', 'LIN-1', '--assignee', 'me', '--label', 'bug'], + { apiKey: FAKE_KEY }, + ); + + // Resolve ref, resolve viewer, resolve label names, mutate. + expect(requests).toHaveLength(4); + expect(requests[3].body.variables).toEqual({ + id: 'ir-1', + assigneeId: 'user-viewer', + labelIds: ['lb-bug'], + }); + expect(out).toContain('updated: LIN-1'); + }); + + it('still rejects an update with nothing to do, listing every flag', async () => { + const { requests } = stubUpdateFetch(); + + await expect( + issueCommand(['update', 'LIN-1'], { apiKey: FAKE_KEY }), + ).rejects.toThrow(/Nothing to update/); + + // The guard fires before any network request. + expect(requests).toHaveLength(0); + }); + + it('rejects blank --label and --assignee values loudly', async () => { + stubUpdateFetch(); + await expect( + issueCommand(['update', 'LIN-1', '--label', ''], { apiKey: FAKE_KEY }), + ).rejects.toThrow(/--label requires a value/); + await expect( + issueCommand(['update', 'LIN-1', '--assignee', ''], { + apiKey: FAKE_KEY, + }), + ).rejects.toThrow(/--assignee requires a value/); + }); + + it('documents the new flags in help', () => { + expect(ISSUE_HELP).toContain('[--assignee ]'); + expect(ISSUE_HELP).toContain('[--label ]'); + expect(ISSUE_HELP).toContain('[--remove-label ]'); + expect(ISSUE_HELP).toContain( + 'linear-axi issue update LIN-123 --assignee me --label bug', + ); + expect(ISSUE_HELP).toContain( + 'linear-axi issue update LIN-123 --remove-label bug', + ); + }); +});