From 6aca4def938bb7644f10d3ef61dedc8eb0bf026b Mon Sep 17 00:00:00 2001 From: Peter Schilling Date: Sat, 18 Jul 2026 13:50:54 -0700 Subject: [PATCH] Add `issue update --unassign` to clear an issue's assignee MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit There was no way to unassign an issue. The mutation input was built as a `Record`, a type that structurally cannot hold null, so `IssueUpdateInput.assigneeId` could never be set to null no matter what flags were added. Swap that hand-rolled Record for the codegen'd `IssueUpdateInput` — matching what `project update` already does — and add an explicit `--unassign` flag. Passing both --assignee and --unassign is a ValidationError rather than one silently winning. No short alias: `-U, --unassigned` is already bound in three sibling commands as a read-side filter, and putting a data-clearing mutation one shift-key away from a harmless filter invites accidents. Verified against the real API that Linear honors `assigneeId: null` — worth checking explicitly, since it silently ignores `projectId: null` elsewhere. --- skills/linear-cli/references/issue.md | 1 + src/commands/issue/issue-update.ts | 30 +++- .../__snapshots__/issue-update.test.ts.snap | 44 +++++ test/commands/issue/issue-update.test.ts | 169 ++++++++++++++++++ 4 files changed, 240 insertions(+), 4 deletions(-) diff --git a/skills/linear-cli/references/issue.md b/skills/linear-cli/references/issue.md index 57933b79..0da0f7a5 100644 --- a/skills/linear-cli/references/issue.md +++ b/skills/linear-cli/references/issue.md @@ -583,6 +583,7 @@ Options: -h, --help - Show this help. --workspace - Target workspace (uses credentials) -a, --assignee - Assign the issue to 'self' or someone (by username or name) + --unassign - Clear the issue's assignee (cannot be combined with --assignee) --due-date - Due date of the issue --parent - Parent issue (if any) as a team_number code -p, --priority - Priority of the issue (1-4, descending priority) diff --git a/src/commands/issue/issue-update.ts b/src/commands/issue/issue-update.ts index 7c4120cf..5d543657 100644 --- a/src/commands/issue/issue-update.ts +++ b/src/commands/issue/issue-update.ts @@ -1,5 +1,6 @@ import { Command } from "@cliffy/command" import { gql } from "../../__codegen__/gql.ts" +import type { IssueUpdateInput } from "../../__codegen__/graphql.ts" import { getGraphQLClient } from "../../utils/graphql.ts" import { getTeamKeyFromIssueIdentifier } from "../../utils/issue-identifier.ts" import { @@ -32,6 +33,10 @@ export const updateCommand = new Command() "-a, --assignee ", "Assign the issue to 'self' or someone (by username or name)", ) + .option( + "--unassign", + "Clear the issue's assignee (cannot be combined with --assignee)", + ) .option( "--due-date ", "Due date of the issue", @@ -86,6 +91,7 @@ export const updateCommand = new Command() async ( { assignee, + unassign, dueDate, parent, priority, @@ -103,6 +109,16 @@ export const updateCommand = new Command() issueIdArg, ) => { try { + if (unassign && assignee != null) { + throw new ValidationError( + "Cannot specify both --assignee and --unassign", + { + suggestion: + "Use --assignee to set an assignee, or --unassign on its own to clear it.", + }, + ) + } + // Validate that description and descriptionFile are not both provided if (description && descriptionFile) { throw new ValidationError( @@ -180,7 +196,7 @@ export const updateCommand = new Command() } } - const labelIds = [] + const labelIds: string[] = [] if (labels != null && labels.length > 0) { for (const label of labels) { const labelId = await getIssueLabelIdByNameForTeam(label, teamKey) @@ -230,11 +246,17 @@ export const updateCommand = new Command() cycleId = await getCycleIdByNameOrNumber(cycle, teamId) } - // Build the update input object, only including fields that were provided - const input: Record = {} + // Build the update input object, only including fields that were provided. + // Clearing a field requires an explicit flag (see --unassign); never set + // a field to null implicitly. + const input: IssueUpdateInput = {} if (title !== undefined) input.title = title - if (assigneeId !== undefined) input.assigneeId = assigneeId + if (unassign) { + input.assigneeId = null + } else if (assigneeId != null) { + input.assigneeId = assigneeId + } if (dueDate !== undefined) input.dueDate = dueDate if (parent !== undefined) { const parentIdentifier = await getIssueIdentifier(parent) diff --git a/test/commands/issue/__snapshots__/issue-update.test.ts.snap b/test/commands/issue/__snapshots__/issue-update.test.ts.snap index c26f2ef1..aad28b71 100644 --- a/test/commands/issue/__snapshots__/issue-update.test.ts.snap +++ b/test/commands/issue/__snapshots__/issue-update.test.ts.snap @@ -13,6 +13,7 @@ Options: -h, --help - Show this help. -a, --assignee - Assign the issue to 'self' or someone (by username or name) + --unassign - Clear the issue's assignee (cannot be combined with --assignee) --due-date - Due date of the issue --parent - Parent issue (if any) as a team_number code -p, --priority - Priority of the issue (1-4, descending priority) @@ -129,3 +130,46 @@ stderr: Valid states: \\"Todo\\" (unstarted), \\"In Progress\\" (started), \\"Done\\" (completed). Run \`linear team states ENG\` to list them. " `; + +snapshot[`Issue Update Command - Unassign Clears Assignee 1`] = ` +stdout: +"Updating issue ENG-123 + +✓ Updated issue ENG-123: Some issue +https://linear.app/test-team/issue/ENG-123/some-issue +" +stderr: +"" +`; + +snapshot[`Issue Update Command - Unassign With Other Fields 1`] = ` +stdout: +"Updating issue ENG-123 + +✓ Updated issue ENG-123: Renamed +https://linear.app/test-team/issue/ENG-123/renamed +" +stderr: +"" +`; + +snapshot[`Issue Update Command - Assignee Still Sends User Id 1`] = ` +stdout: +"Updating issue ENG-123 + +✓ Updated issue ENG-123: Some issue +https://linear.app/test-team/issue/ENG-123/some-issue +" +stderr: +"" +`; + +snapshot[`Issue Update Command - Assignee And Unassign Conflict 1`] = ` +stdout: +"" +stderr: +"✗ Failed to update issue: Cannot specify both --assignee and --unassign + Use --assignee to set an assignee, or --unassign on its own to clear it. +" +`; + diff --git a/test/commands/issue/issue-update.test.ts b/test/commands/issue/issue-update.test.ts index 5e29fc3f..3198cb3a 100644 --- a/test/commands/issue/issue-update.test.ts +++ b/test/commands/issue/issue-update.test.ts @@ -567,3 +567,172 @@ await snapshotTest({ } }, }) + +// --unassign must send `assigneeId: null` on the wire. The mock's `input` is +// matched with an exact key-count comparison (mock_linear_server.ts deepEqual), +// so this only matches if assigneeId is present AND null: dropping to +// `undefined` erases the key during JSON.stringify, and sending a user id +// fails the value comparison. Do not relax `variables` to an unconstrained +// mock — that would match any payload and prove nothing. +await snapshotTest({ + name: "Issue Update Command - Unassign Clears Assignee", + meta: import.meta, + colors: false, + args: ["ENG-123", "--unassign"], + denoArgs: commonDenoArgs, + async fn() { + const { cleanup } = await setupMockLinearServer([ + { + queryName: "GetTeamIdByKey", + variables: { team: "ENG" }, + response: { data: { teams: { nodes: [{ id: "team-eng-id" }] } } }, + }, + // No GetViewerId mock: --unassign must not perform a user lookup. + { + queryName: "UpdateIssue", + variables: { + id: "ENG-123", + input: { assigneeId: null, teamId: "team-eng-id" }, + }, + response: { + data: { + issueUpdate: { + success: true, + issue: { + id: "issue-existing-123", + identifier: "ENG-123", + url: "https://linear.app/test-team/issue/ENG-123/some-issue", + title: "Some issue", + }, + }, + }, + }, + }, + ], { LINEAR_TEAM_ID: "ENG" }) + + try { + await updateCommand.parse() + } finally { + await cleanup() + } + }, +}) + +// --unassign alongside another field: the null must not clobber, or be +// clobbered by, sibling assignments. +await snapshotTest({ + name: "Issue Update Command - Unassign With Other Fields", + meta: import.meta, + colors: false, + args: ["ENG-123", "--unassign", "--title", "Renamed"], + denoArgs: commonDenoArgs, + async fn() { + const { cleanup } = await setupMockLinearServer([ + { + queryName: "GetTeamIdByKey", + variables: { team: "ENG" }, + response: { data: { teams: { nodes: [{ id: "team-eng-id" }] } } }, + }, + { + queryName: "UpdateIssue", + variables: { + id: "ENG-123", + input: { + title: "Renamed", + assigneeId: null, + teamId: "team-eng-id", + }, + }, + response: { + data: { + issueUpdate: { + success: true, + issue: { + id: "issue-existing-123", + identifier: "ENG-123", + url: "https://linear.app/test-team/issue/ENG-123/renamed", + title: "Renamed", + }, + }, + }, + }, + }, + ], { LINEAR_TEAM_ID: "ENG" }) + + try { + await updateCommand.parse() + } finally { + await cleanup() + } + }, +}) + +// Regression guard for the --assignee path: it must still send a string id. +// The "Happy Path" test above cannot catch a break here because its +// UpdateIssue mock declares no `variables` and matches any payload. +await snapshotTest({ + name: "Issue Update Command - Assignee Still Sends User Id", + meta: import.meta, + colors: false, + args: ["ENG-123", "--assignee", "self"], + denoArgs: commonDenoArgs, + async fn() { + const { cleanup } = await setupMockLinearServer([ + { + queryName: "GetTeamIdByKey", + variables: { team: "ENG" }, + response: { data: { teams: { nodes: [{ id: "team-eng-id" }] } } }, + }, + { + queryName: "GetViewerId", + variables: {}, + response: { data: { viewer: { id: "user-self-123" } } }, + }, + { + queryName: "UpdateIssue", + variables: { + id: "ENG-123", + input: { assigneeId: "user-self-123", teamId: "team-eng-id" }, + }, + response: { + data: { + issueUpdate: { + success: true, + issue: { + id: "issue-existing-123", + identifier: "ENG-123", + url: "https://linear.app/test-team/issue/ENG-123/some-issue", + title: "Some issue", + }, + }, + }, + }, + }, + ], { LINEAR_TEAM_ID: "ENG" }) + + try { + await updateCommand.parse() + } finally { + await cleanup() + } + }, +}) + +// The conflict guard sits at the top of the action, so this must fail before +// any HTTP. No mock server is configured on purpose: if the guard is ever +// moved below the network calls, this fails with a connection error instead of +// the validation message. The endpoint is pinned to a dead port so that +// regression can never reach the real Linear API using inherited credentials. +await snapshotTest({ + name: "Issue Update Command - Assignee And Unassign Conflict", + meta: import.meta, + colors: false, + args: ["ENG-123", "--assignee", "self", "--unassign"], + denoArgs: commonDenoArgs, + canFail: true, + async fn() { + Deno.env.set("LINEAR_GRAPHQL_ENDPOINT", "http://127.0.0.1:1") + Deno.env.set("LINEAR_API_KEY", "Bearer test-token") + await updateCommand.parse() + }, +})