Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions skills/linear-cli/references/issue.md
Original file line number Diff line number Diff line change
Expand Up @@ -583,6 +583,7 @@ Options:
-h, --help - Show this help.
--workspace <slug> - Target workspace (uses credentials)
-a, --assignee <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 <dueDate> - Due date of the issue
--parent <parent> - Parent issue (if any) as a team_number code
-p, --priority <priority> - Priority of the issue (1-4, descending priority)
Expand Down
30 changes: 26 additions & 4 deletions src/commands/issue/issue-update.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -32,6 +33,10 @@ export const updateCommand = new Command()
"-a, --assignee <assignee:string>",
"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 <dueDate:string>",
"Due date of the issue",
Expand Down Expand Up @@ -86,6 +91,7 @@ export const updateCommand = new Command()
async (
{
assignee,
unassign,
dueDate,
parent,
priority,
Expand All @@ -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 <user> 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(
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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<string, string | number | string[] | undefined> = {}
// 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)
Expand Down
44 changes: 44 additions & 0 deletions test/commands/issue/__snapshots__/issue-update.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ Options:

-h, --help - Show this help.
-a, --assignee <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 <dueDate> - Due date of the issue
--parent <parent> - Parent issue (if any) as a team_number code
-p, --priority <priority> - Priority of the issue (1-4, descending priority)
Expand Down Expand Up @@ -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 <user> to set an assignee, or --unassign on its own to clear it.
"
`;

169 changes: 169 additions & 0 deletions test/commands/issue/issue-update.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
},
})
Loading