Files
Peter Schilling 3a00cc7604 Accept team names and IDs, and state names, wherever the CLI takes them
Every command that takes a team now accepts its key, name, or UUID through
one shared resolver (findTeam / resolveTeam / resolveTeams), replacing the
key-only getTeamIdByKey and the ad-hoc uppercasing spread across commands.
One aliased ResolveTeam operation looks up key, name, and (for UUID-shaped
input) id in a single round trip; precedence is key, then id, then name,
applied client-side so a reference that equals one team's key and another
team's name always means the key. Keys stay the canonical downstream form:
filters that matched on team.key still do, with the server's uppercase key,
and callers that need a UUID take it from the same resolved object. An
unknown team now errors with the list of valid keys instead of an empty
result or a raw "Entity not found" from the API.

Only explicit input goes through the resolver. The configured default team
is already a normalized key, and resolving it would add a round trip to
every default-team invocation of the most-used commands for no gain. In
issue create, the interactive substring picker survives only for that
default; an explicit --team that matches nothing errors like everywhere
else.

issue query --state and issue mine --state take a workflow state name or
ID as well as the six type tokens. Names and IDs are resolved within the
queried scope (the team, the teams, or the whole workspace under
--all-teams, where a name matches every team's same-named state), so a
state from another team errors instead of silently matching nothing, and
the error lists the scope's states. Type-only input still sends the same
{ type: { in } } filter with no extra request; a mix of types and names
becomes an or-filter.

The MCP server already describes these parameters as "key, name, or ID"
and "type, name, or ID"; this brings the CLI to parity so an agent does
not need a preliminary team list to translate a name into a key.

Claude-Session: https://claude.ai/code/session_01A9qEGri4p2HZMQSuYsBmub
2026-09-05 07:19:38 -07:00

1694 lines
40 KiB
TypeScript

import { snapshotTest } from "@cliffy/testing"
import { assertEquals, assertStringIncludes } from "@std/assert"
import { Checkbox, Input, Select } from "@cliffy/prompt"
import { stub } from "@std/testing/mock"
import { createCommand } from "../../../src/commands/issue/issue-create.ts"
import {
commonDenoArgs,
resolveTeamMock,
setupMockLinearServer,
} from "../../utils/test-helpers.ts"
// Test help output
await snapshotTest({
name: "Issue Create Command - Help Text",
meta: import.meta,
colors: false,
args: ["--help"],
denoArgs: commonDenoArgs,
async fn() {
await createCommand.parse()
},
})
// Test creating an issue with flags (happy path)
await snapshotTest({
name: "Issue Create Command - Happy Path",
meta: import.meta,
colors: false,
args: [
"--title",
"Fix authentication bug",
"--description",
"Users are experiencing login issues",
"--assignee",
"self",
"--priority",
"2",
"--estimate",
"3",
"--team",
"ENG",
"--no-interactive",
],
denoArgs: commonDenoArgs,
async fn() {
const { cleanup } = await setupMockLinearServer([
// Mock response for resolveTeam() - converting team key to ID
{
queryName: "ResolveTeam",
variables: { reference: "ENG" },
response: {
data: {
teams: {
nodes: [{ id: "team-eng-id", key: "ENG", name: "Engineering" }],
},
},
},
},
// Mock response for lookupUserId("self") - resolves to viewer
{
queryName: "GetViewerId",
variables: {},
response: {
data: {
viewer: {
id: "user-self-123",
},
},
},
},
// Mock response for the create issue mutation
{
queryName: "CreateIssue",
response: {
data: {
issueCreate: {
success: true,
issue: {
id: "issue-new-456",
identifier: "ENG-123",
url:
"https://linear.app/test-team/issue/ENG-123/fix-authentication-bug",
team: {
key: "ENG",
},
},
},
},
},
},
], { LINEAR_TEAM_ID: "ENG" })
try {
await createCommand.parse()
} finally {
await cleanup()
}
},
})
// Test creating an issue with milestone
await snapshotTest({
name: "Issue Create Command - With Milestone",
meta: import.meta,
colors: false,
args: [
"--title",
"Test milestone feature",
"--team",
"ENG",
"--project",
"My Project",
"--milestone",
"Phase 1",
"--no-interactive",
],
denoArgs: commonDenoArgs,
async fn() {
const { cleanup } = await setupMockLinearServer([
// Mock response for resolveTeam()
{
queryName: "ResolveTeam",
variables: { reference: "ENG" },
response: {
data: {
teams: {
nodes: [{ id: "team-eng-id", key: "ENG", name: "Engineering" }],
},
},
},
},
// Mock response for getProjectIdByName()
{
queryName: "GetProjectIdByName",
variables: { name: "My Project" },
response: {
data: {
projects: {
nodes: [{ id: "project-123" }],
},
},
},
},
// Mock response for getMilestoneIdByName()
{
queryName: "GetProjectMilestonesForLookup",
variables: { projectId: "project-123" },
response: {
data: {
project: {
projectMilestones: {
nodes: [
{ id: "milestone-1", name: "Phase 1" },
{ id: "milestone-2", name: "Phase 2" },
],
},
},
},
},
},
// Mock response for the create issue mutation
{
queryName: "CreateIssue",
response: {
data: {
issueCreate: {
success: true,
issue: {
id: "issue-new-milestone",
identifier: "ENG-789",
url:
"https://linear.app/test-team/issue/ENG-789/test-milestone-feature",
team: {
key: "ENG",
},
},
},
},
},
},
], { LINEAR_TEAM_ID: "ENG" })
try {
await createCommand.parse()
} finally {
await cleanup()
}
},
})
// Test creating an issue with case-insensitive label matching
await snapshotTest({
name: "Issue Create Command - Case Insensitive Label Matching",
meta: import.meta,
colors: false,
args: [
"--title",
"Test case insensitive labels",
"--description",
"Testing label matching",
"--label",
"BUG", // uppercase label that should match "bug" label
"--team",
"ENG",
"--no-interactive",
],
denoArgs: commonDenoArgs,
async fn() {
const { cleanup } = await setupMockLinearServer([
// Mock response for resolveTeam() - converting team key to ID
{
queryName: "ResolveTeam",
variables: { reference: "ENG" },
response: {
data: {
teams: {
nodes: [{ id: "team-eng-id", key: "ENG", name: "Engineering" }],
},
},
},
},
// Mock response for getIssueLabelIdByNameForTeam("BUG", "ENG") - case insensitive
{
queryName: "GetIssueLabelIdByNameForTeam",
variables: { name: "BUG", teamKey: "ENG" },
response: {
data: {
issueLabels: {
nodes: [{
id: "label-bug-123",
name: "bug", // actual label is lowercase
}],
},
},
},
},
// Mock response for the create issue mutation
{
queryName: "CreateIssue",
response: {
data: {
issueCreate: {
success: true,
issue: {
id: "issue-new-789",
identifier: "ENG-456",
url:
"https://linear.app/test-team/issue/ENG-456/test-case-insensitive-labels",
team: {
key: "ENG",
},
},
},
},
},
},
], { LINEAR_TEAM_ID: "ENG" })
try {
await createCommand.parse()
} finally {
await cleanup()
}
},
})
// Test that -p is priority (not parent), resolving the flag conflict
await snapshotTest({
name: "Issue Create Command - Short Flag -p Is Priority",
meta: import.meta,
colors: false,
args: [
"--title",
"Test priority flag",
"--team",
"ENG",
"-p",
"2",
"--parent",
"ENG-220",
"--no-interactive",
],
denoArgs: commonDenoArgs,
async fn() {
const { cleanup } = await setupMockLinearServer([
// Mock response for resolveTeam()
{
queryName: "ResolveTeam",
variables: { reference: "ENG" },
response: {
data: {
teams: {
nodes: [{ id: "team-eng-id", key: "ENG", name: "Engineering" }],
},
},
},
},
// Mock response for getIssueId("ENG-220") - resolves parent identifier to ID
{
queryName: "GetIssueId",
variables: { id: "ENG-220" },
response: {
data: {
issue: {
id: "parent-issue-id",
},
},
},
},
// Mock response for fetchParentIssueData("parent-issue-id")
{
queryName: "GetParentIssueData",
variables: { id: "parent-issue-id" },
response: {
data: {
issue: {
title: "Parent Issue",
identifier: "ENG-220",
project: null,
},
},
},
},
// Mock response for the create issue mutation
{
queryName: "CreateIssue",
response: {
data: {
issueCreate: {
success: true,
issue: {
id: "issue-new-priority",
identifier: "ENG-999",
url:
"https://linear.app/test-team/issue/ENG-999/test-priority-flag",
team: {
key: "ENG",
},
},
},
},
},
},
], { LINEAR_TEAM_ID: "ENG" })
try {
await createCommand.parse()
} finally {
await cleanup()
}
},
})
// Test creating an issue with cycle
await snapshotTest({
name: "Issue Create Command - With Cycle",
meta: import.meta,
colors: false,
args: [
"--title",
"Test cycle feature",
"--team",
"ENG",
"--cycle",
"active",
"--no-interactive",
],
denoArgs: commonDenoArgs,
async fn() {
const { cleanup } = await setupMockLinearServer([
// Mock response for resolveTeam()
{
queryName: "ResolveTeam",
variables: { reference: "ENG" },
response: {
data: {
teams: {
nodes: [{ id: "team-eng-id", key: "ENG", name: "Engineering" }],
},
},
},
},
// Mock response for getCycleIdByNameOrNumber("active")
{
queryName: "GetTeamCyclesForLookup",
variables: { teamId: "team-eng-id" },
response: {
data: {
team: {
key: "ENG",
cyclesEnabled: true,
cycles: {
nodes: [
{
id: "cycle-1",
number: 7,
startsAt: "2026-07-27T07:00:00.000Z",
name: "Sprint 7",
},
{
id: "cycle-2",
number: 8,
startsAt: "2026-07-27T07:00:00.000Z",
name: "Sprint 8",
},
],
},
activeCycle: {
id: "cycle-1",
number: 7,
startsAt: "2026-07-27T07:00:00.000Z",
name: "Sprint 7",
},
},
},
},
},
// Mock response for the create issue mutation
{
queryName: "CreateIssue",
response: {
data: {
issueCreate: {
success: true,
issue: {
id: "issue-new-cycle",
identifier: "ENG-890",
url:
"https://linear.app/test-team/issue/ENG-890/test-cycle-feature",
team: {
key: "ENG",
},
},
},
},
},
},
], { LINEAR_TEAM_ID: "ENG" })
try {
await createCommand.parse()
} finally {
await cleanup()
}
},
})
Deno.test("Issue Create Command - Explicit Project Still Uses Interactive Mode", async () => {
const { cleanup } = await setupMockLinearServer([
{
queryName: "GetUserSettings",
response: {
data: {
userSettings: {
autoAssignToSelf: false,
},
},
},
},
{
queryName: "GetProjectIdByName",
variables: { name: "Dashboard" },
response: {
data: {
projects: {
nodes: [{ id: "project-123" }],
},
},
},
},
{
queryName: "ResolveTeam",
variables: { reference: "ENG" },
response: {
data: {
teams: {
nodes: [{ id: "team-eng-id", key: "ENG", name: "Engineering" }],
},
},
},
},
{
queryName: "GetWorkflowStates",
response: {
data: {
team: {
states: {
nodes: [],
},
},
},
},
},
{
queryName: "GetLabelsForTeam",
response: {
data: {
team: {
labels: {
nodes: [],
},
},
},
},
},
{
queryName: "CreateIssue",
variables: {
input: {
title: "Create dashboard issue",
labelIds: [],
teamId: "team-eng-id",
projectId: "project-123",
useDefaultTemplate: true,
},
},
response: {
data: {
issueCreate: {
success: true,
issue: {
id: "issue-interactive-project",
identifier: "ENG-901",
url:
"https://linear.app/test-team/issue/ENG-901/create-dashboard-issue",
team: {
key: "ENG",
},
},
},
},
},
},
], { LINEAR_TEAM_ID: "ENG" })
const terminalStub = stub(
Object.getPrototypeOf(Deno.stdout),
"isTerminal",
() => true,
)
const inputStub = stub(
Input,
"prompt",
(options: string | { message: string }) => {
const message = typeof options === "string" ? options : options.message
if (message === "What's the title of your issue?") {
return Promise.resolve("Create dashboard issue")
}
if (message.startsWith("Description")) {
return Promise.resolve("")
}
throw new Error(`Unexpected Input.prompt call: ${message}`)
},
)
let selectCallCount = 0
const selectStub = stub(Select, "prompt", (options: { message: string }) => {
selectCallCount += 1
if (options.message === "What's next?") {
return Promise.resolve("submit")
}
if (
options.message ===
"Start working on this issue now? (creates branch and updates status)"
) {
return Promise.resolve(false)
}
throw new Error(`Unexpected Select.prompt call: ${options.message}`)
})
try {
await createCommand.parse(["--project", "Dashboard"])
assertEquals(selectCallCount, 2)
} finally {
selectStub.restore()
inputStub.restore()
terminalStub.restore()
await cleanup()
}
})
Deno.test("Issue Create Command - Interactive Project Prompt Uses Team Projects", async () => {
const { cleanup } = await setupMockLinearServer([
{
queryName: "GetUserSettings",
response: {
data: {
userSettings: {
autoAssignToSelf: false,
},
},
},
},
{
queryName: "ResolveTeam",
variables: { reference: "ENG" },
response: {
data: {
teams: {
nodes: [{ id: "team-eng-id", key: "ENG", name: "Engineering" }],
},
},
},
},
{
queryName: "GetProjectsForTeam",
response: {
data: {
projects: {
nodes: [{ id: "project-456", name: "Dashboard" }],
pageInfo: { hasNextPage: false, endCursor: null },
},
},
},
},
{
queryName: "GetWorkflowStates",
response: {
data: {
team: {
states: {
nodes: [],
},
},
},
},
},
{
queryName: "GetLabelsForTeam",
response: {
data: {
team: {
labels: {
nodes: [],
},
},
},
},
},
{
queryName: "CreateIssue",
variables: {
input: {
title: "Issue with prompted project",
labelIds: [],
teamId: "team-eng-id",
projectId: "project-456",
useDefaultTemplate: true,
},
},
response: {
data: {
issueCreate: {
success: true,
issue: {
id: "issue-project-prompt",
identifier: "ENG-902",
url:
"https://linear.app/test-team/issue/ENG-902/issue-with-prompted-project",
team: {
key: "ENG",
},
},
},
},
},
},
], {
LINEAR_TEAM_ID: "ENG",
LINEAR_ISSUE_CREATE_ASK_PROJECT: "true",
})
const terminalStub = stub(
Object.getPrototypeOf(Deno.stdout),
"isTerminal",
() => true,
)
const inputStub = stub(
Input,
"prompt",
(options: string | { message: string }) => {
const message = typeof options === "string" ? options : options.message
if (message === "What's the title of your issue?") {
return Promise.resolve("Issue with prompted project")
}
if (message.startsWith("Description")) {
return Promise.resolve("")
}
throw new Error(`Unexpected Input.prompt call: ${message}`)
},
)
const selectStub = stub(Select, "prompt", (options: { message: string }) => {
if (options.message === "Which project should this issue belong to?") {
return Promise.resolve("project-456")
}
if (options.message === "What's next?") {
return Promise.resolve("submit")
}
if (
options.message ===
"Start working on this issue now? (creates branch and updates status)"
) {
return Promise.resolve(false)
}
throw new Error(`Unexpected Select.prompt call: ${options.message}`)
})
try {
await createCommand.parse([])
} finally {
selectStub.restore()
inputStub.restore()
terminalStub.restore()
await cleanup()
}
})
Deno.test("Issue Create Command - Additional Fields Can Set Project", async () => {
const { cleanup } = await setupMockLinearServer([
{
queryName: "GetUserSettings",
response: {
data: {
userSettings: {
autoAssignToSelf: false,
},
},
},
},
{
queryName: "ResolveTeam",
variables: { reference: "ENG" },
response: {
data: {
teams: {
nodes: [{ id: "team-eng-id", key: "ENG", name: "Engineering" }],
},
},
},
},
{
queryName: "GetWorkflowStates",
response: {
data: {
team: {
states: {
nodes: [],
},
},
},
},
},
{
queryName: "GetLabelsForTeam",
response: {
data: {
team: {
labels: {
nodes: [],
},
},
},
},
},
{
queryName: "GetProjectsForTeam",
response: {
data: {
projects: {
nodes: [{ id: "project-789", name: "Dashboard" }],
pageInfo: { hasNextPage: false, endCursor: null },
},
},
},
},
{
queryName: "CreateIssue",
variables: {
input: {
title: "Issue from more fields",
labelIds: [],
teamId: "team-eng-id",
projectId: "project-789",
useDefaultTemplate: true,
},
},
response: {
data: {
issueCreate: {
success: true,
issue: {
id: "issue-more-fields",
identifier: "ENG-903",
url:
"https://linear.app/test-team/issue/ENG-903/issue-from-more-fields",
team: {
key: "ENG",
},
},
},
},
},
},
], { LINEAR_TEAM_ID: "ENG" })
const terminalStub = stub(
Object.getPrototypeOf(Deno.stdout),
"isTerminal",
() => true,
)
const inputStub = stub(
Input,
"prompt",
(options: string | { message: string }) => {
const message = typeof options === "string" ? options : options.message
if (message === "What's the title of your issue?") {
return Promise.resolve("Issue from more fields")
}
if (message.startsWith("Description")) {
return Promise.resolve("")
}
throw new Error(`Unexpected Input.prompt call: ${message}`)
},
)
const checkboxStub = stub(
Checkbox,
"prompt",
(options: { message: string }) => {
if (options.message === "Select additional fields to configure") {
return Promise.resolve(["project"])
}
throw new Error(`Unexpected Checkbox.prompt call: ${options.message}`)
},
)
const selectStub = stub(Select, "prompt", (options: { message: string }) => {
if (options.message === "What's next?") {
return Promise.resolve("more_fields")
}
if (options.message === "Which project should this issue belong to?") {
return Promise.resolve("project-789")
}
if (
options.message ===
"Start working on this issue now? (creates branch and updates status)"
) {
return Promise.resolve(false)
}
throw new Error(`Unexpected Select.prompt call: ${options.message}`)
})
try {
await createCommand.parse([])
} finally {
selectStub.restore()
checkboxStub.restore()
inputStub.restore()
terminalStub.restore()
await cleanup()
}
})
Deno.test("Issue Create Command - Inherits Parent Project When Project Not Set", async () => {
const { cleanup } = await setupMockLinearServer([
{
queryName: "ResolveTeam",
variables: { reference: "ENG" },
response: {
data: {
teams: {
nodes: [{ id: "team-eng-id", key: "ENG", name: "Engineering" }],
},
},
},
},
{
queryName: "GetIssueId",
variables: { id: "ENG-123" },
response: {
data: {
issue: {
id: "parent-1",
},
},
},
},
{
queryName: "GetParentIssueData",
variables: { id: "parent-1" },
response: {
data: {
issue: {
title: "Parent issue",
identifier: "ENG-123",
project: {
id: "project-parent",
},
},
},
},
},
{
queryName: "CreateIssue",
variables: {
input: {
title: "Child issue",
parentId: "parent-1",
labelIds: [],
teamId: "team-eng-id",
projectId: "project-parent",
useDefaultTemplate: true,
},
},
response: {
data: {
issueCreate: {
success: true,
issue: {
id: "child-issue",
identifier: "ENG-904",
url: "https://linear.app/test-team/issue/ENG-904/child-issue",
team: {
key: "ENG",
},
},
},
},
},
},
], { LINEAR_TEAM_ID: "ENG" })
try {
await createCommand.parse([
"--title",
"Child issue",
"--team",
"ENG",
"--parent",
"ENG-123",
"--no-interactive",
])
} finally {
await cleanup()
}
})
Deno.test("Issue Create Command - Explicit Project Overrides Parent Project", async () => {
const { cleanup } = await setupMockLinearServer([
{
queryName: "ResolveTeam",
variables: { reference: "ENG" },
response: {
data: {
teams: {
nodes: [{ id: "team-eng-id", key: "ENG", name: "Engineering" }],
},
},
},
},
{
queryName: "GetProjectIdByName",
variables: { name: "Dashboard" },
response: {
data: {
projects: {
nodes: [{ id: "project-dashboard" }],
},
},
},
},
{
queryName: "GetIssueId",
variables: { id: "ENG-123" },
response: {
data: {
issue: {
id: "parent-1",
},
},
},
},
{
queryName: "GetParentIssueData",
variables: { id: "parent-1" },
response: {
data: {
issue: {
title: "Parent issue",
identifier: "ENG-123",
project: {
id: "project-parent",
},
},
},
},
},
{
queryName: "CreateIssue",
variables: {
input: {
title: "Child issue override",
parentId: "parent-1",
labelIds: [],
teamId: "team-eng-id",
projectId: "project-dashboard",
useDefaultTemplate: true,
},
},
response: {
data: {
issueCreate: {
success: true,
issue: {
id: "child-issue-override",
identifier: "ENG-905",
url:
"https://linear.app/test-team/issue/ENG-905/child-issue-override",
team: {
key: "ENG",
},
},
},
},
},
},
], { LINEAR_TEAM_ID: "ENG" })
try {
await createCommand.parse([
"--title",
"Child issue override",
"--team",
"ENG",
"--parent",
"ENG-123",
"--project",
"Dashboard",
"--no-interactive",
])
} finally {
await cleanup()
}
})
Deno.test("Issue Create Command - Invalid Parent Project Combination Surfaces Backend Error", async () => {
const { cleanup } = await setupMockLinearServer([
{
queryName: "ResolveTeam",
variables: { reference: "ENG" },
response: {
data: {
teams: {
nodes: [{ id: "team-eng-id", key: "ENG", name: "Engineering" }],
},
},
},
},
{
queryName: "GetProjectIdByName",
variables: { name: "Dashboard" },
response: {
data: {
projects: {
nodes: [{ id: "project-dashboard" }],
},
},
},
},
{
queryName: "GetIssueId",
variables: { id: "ENG-123" },
response: {
data: {
issue: {
id: "parent-1",
},
},
},
},
{
queryName: "GetParentIssueData",
variables: { id: "parent-1" },
response: {
data: {
issue: {
title: "Parent issue",
identifier: "ENG-123",
project: {
id: "project-parent",
},
},
},
},
},
{
queryName: "CreateIssue",
response: {
errors: [{
message: "Parent issue and project are incompatible",
}],
},
},
], { LINEAR_TEAM_ID: "ENG" })
const errors: string[] = []
const errorStub = stub(console, "error", (...args: unknown[]) => {
errors.push(args.map(String).join(" "))
})
const exitStub = stub(Deno, "exit", (_code?: number) => {
throw new Error("DENO_EXIT")
})
try {
let thrown: Error | undefined
try {
await createCommand.parse([
"--title",
"Child issue override",
"--team",
"ENG",
"--parent",
"ENG-123",
"--project",
"Dashboard",
"--no-interactive",
])
} catch (error) {
thrown = error as Error
}
assertEquals(thrown?.message, "DENO_EXIT")
assertStringIncludes(
errors.join("\n"),
"Parent issue and project are incompatible",
)
} finally {
exitStub.restore()
errorStub.restore()
await cleanup()
}
})
Deno.test("Issue Create Command - Config Can Assign Self By Default", async () => {
const { cleanup } = await setupMockLinearServer([
{
queryName: "ResolveTeam",
variables: { reference: "ENG" },
response: {
data: {
teams: {
nodes: [{ id: "team-eng-id", key: "ENG", name: "Engineering" }],
},
},
},
},
{
queryName: "GetViewerId",
response: {
data: {
viewer: {
id: "user-self-123",
},
},
},
},
{
queryName: "CreateIssue",
variables: {
input: {
title: "Assigned to self",
assigneeId: "user-self-123",
labelIds: [],
teamId: "team-eng-id",
useDefaultTemplate: true,
},
},
response: {
data: {
issueCreate: {
success: true,
issue: {
id: "issue-self-default",
identifier: "ENG-906",
url:
"https://linear.app/test-team/issue/ENG-906/assigned-to-self",
team: {
key: "ENG",
},
},
},
},
},
},
], {
LINEAR_TEAM_ID: "ENG",
LINEAR_ISSUE_CREATE_ASSIGN_SELF: "always",
})
try {
await createCommand.parse([
"--title",
"Assigned to self",
"--team",
"ENG",
"--no-interactive",
])
} finally {
await cleanup()
}
})
Deno.test("Issue Create Command - Auto Assign Mode Respects Linear User Setting In Interactive Create", async () => {
const { cleanup } = await setupMockLinearServer([
{
queryName: "GetUserSettings",
response: {
data: {
userSettings: {
autoAssignToSelf: true,
},
},
},
},
{
queryName: "ResolveTeam",
variables: { reference: "ENG" },
response: {
data: {
teams: {
nodes: [{ id: "team-eng-id", key: "ENG", name: "Engineering" }],
},
},
},
},
{
queryName: "GetWorkflowStates",
response: {
data: {
team: {
states: {
nodes: [],
},
},
},
},
},
{
queryName: "GetLabelsForTeam",
response: {
data: {
team: {
labels: {
nodes: [],
},
},
},
},
},
{
queryName: "GetViewerId",
response: {
data: {
viewer: {
id: "user-self-123",
},
},
},
},
{
queryName: "CreateIssue",
response: {
data: {
issueCreate: {
success: true,
issue: {
id: "issue-auto-assign",
identifier: "ENG-906A",
url: "https://linear.app/test-team/issue/ENG-906A/auto-assign",
team: {
key: "ENG",
},
},
},
},
},
},
], { LINEAR_TEAM_ID: "ENG" })
const terminalStub = stub(
Object.getPrototypeOf(Deno.stdout),
"isTerminal",
() => true,
)
const inputStub = stub(
Input,
"prompt",
(options: string | { message: string }) => {
const message = typeof options === "string" ? options : options.message
if (message === "What's the title of your issue?") {
return Promise.resolve("Auto assign from Linear settings")
}
if (message.startsWith("Description")) {
return Promise.resolve("")
}
throw new Error(`Unexpected Input.prompt call: ${message}`)
},
)
const selectStub = stub(Select, "prompt", (options: { message: string }) => {
if (options.message === "What's next?") {
return Promise.resolve("submit")
}
if (
options.message ===
"Start working on this issue now? (creates branch and updates status)"
) {
return Promise.resolve(false)
}
throw new Error(`Unexpected Select.prompt call: ${options.message}`)
})
try {
await createCommand.parse([])
} finally {
selectStub.restore()
inputStub.restore()
terminalStub.restore()
await cleanup()
}
})
Deno.test("Issue Create Command - Explicit Assignee Overrides Config Self Assignment", async () => {
const { cleanup } = await setupMockLinearServer([
{
queryName: "ResolveTeam",
variables: { reference: "ENG" },
response: {
data: {
teams: {
nodes: [{ id: "team-eng-id", key: "ENG", name: "Engineering" }],
},
},
},
},
{
queryName: "GetViewerId",
response: {
data: {
viewer: {
id: "user-self-123",
},
},
},
},
{
queryName: "LookupUser",
variables: { input: "Jane Developer" },
response: {
data: {
users: {
nodes: [{
id: "user-jane-456",
displayName: "Jane Developer",
email: "jane@example.com",
name: "Jane Developer",
}],
},
},
},
},
{
queryName: "CreateIssue",
variables: {
input: {
title: "Assigned explicitly",
assigneeId: "user-jane-456",
labelIds: [],
teamId: "team-eng-id",
useDefaultTemplate: true,
},
},
response: {
data: {
issueCreate: {
success: true,
issue: {
id: "issue-explicit-assignee",
identifier: "ENG-907",
url:
"https://linear.app/test-team/issue/ENG-907/assigned-explicitly",
team: {
key: "ENG",
},
},
},
},
},
},
], {
LINEAR_TEAM_ID: "ENG",
LINEAR_ISSUE_CREATE_ASSIGN_SELF: "always",
})
try {
await createCommand.parse([
"--title",
"Assigned explicitly",
"--team",
"ENG",
"--assignee",
"Jane Developer",
"--no-interactive",
])
} finally {
await cleanup()
}
})
Deno.test("Issue Create Command - Interactive Assignee Can Override Config Self Assignment", async () => {
const { cleanup } = await setupMockLinearServer([
{
queryName: "ResolveTeam",
variables: { reference: "ENG" },
response: {
data: {
teams: {
nodes: [{ id: "team-eng-id", key: "ENG", name: "Engineering" }],
},
},
},
},
{
queryName: "GetWorkflowStates",
response: {
data: {
team: {
states: {
nodes: [],
},
},
},
},
},
{
queryName: "GetLabelsForTeam",
response: {
data: {
team: {
labels: {
nodes: [],
},
},
},
},
},
{
queryName: "GetViewerId",
response: {
data: {
viewer: {
id: "user-self-123",
},
},
},
},
{
queryName: "CreateIssue",
response: {
data: {
issueCreate: {
success: true,
issue: {
id: "issue-interactive-assignee-override",
identifier: "ENG-908",
url:
"https://linear.app/test-team/issue/ENG-908/interactive-assignee-override",
team: {
key: "ENG",
},
},
},
},
},
},
], {
LINEAR_TEAM_ID: "ENG",
LINEAR_ISSUE_CREATE_ASSIGN_SELF: "always",
})
const terminalStub = stub(
Object.getPrototypeOf(Deno.stdout),
"isTerminal",
() => true,
)
const inputStub = stub(
Input,
"prompt",
(options: string | { message: string }) => {
const message = typeof options === "string" ? options : options.message
if (message === "What's the title of your issue?") {
return Promise.resolve("Interactive assignee override")
}
if (message.startsWith("Description")) {
return Promise.resolve("")
}
throw new Error(`Unexpected Input.prompt call: ${message}`)
},
)
const checkboxStub = stub(
Checkbox,
"prompt",
(options: { message: string }) => {
if (options.message === "Select additional fields to configure") {
return Promise.resolve(["assignee"])
}
throw new Error(`Unexpected Checkbox.prompt call: ${options.message}`)
},
)
const selectStub = stub(Select, "prompt", (options: { message: string }) => {
if (options.message === "What's next?") {
return Promise.resolve("more_fields")
}
if (options.message === "Assign this issue to yourself?") {
return Promise.resolve(false)
}
if (
options.message ===
"Start working on this issue now? (creates branch and updates status)"
) {
return Promise.resolve(false)
}
throw new Error(`Unexpected Select.prompt call: ${options.message}`)
})
try {
await createCommand.parse([])
} finally {
selectStub.restore()
checkboxStub.restore()
inputStub.restore()
terminalStub.restore()
await cleanup()
}
})
// Regression test for #210: an unknown --state must surface the valid options
// and point at `linear team states`, not just "not found".
await snapshotTest({
name: "Issue Create Command - Unknown State Lists Valid States",
meta: import.meta,
colors: false,
args: [
"--title",
"Fix authentication bug",
"--team",
"ENG",
"--state",
"Nope",
"--no-interactive",
],
denoArgs: commonDenoArgs,
canFail: true,
async fn() {
const { cleanup } = await setupMockLinearServer([
{
queryName: "ResolveTeam",
variables: { reference: "ENG" },
response: {
data: {
teams: {
nodes: [{ id: "team-eng-id", key: "ENG", name: "Engineering" }],
},
},
},
},
{
queryName: "GetWorkflowStates",
response: {
data: {
team: {
states: {
nodes: [
{
id: "s-todo",
name: "Todo",
type: "unstarted",
position: 1,
},
{
id: "s-progress",
name: "In Progress",
type: "started",
position: 2,
},
{
id: "s-done",
name: "Done",
type: "completed",
position: 3,
},
],
},
},
},
},
},
], { LINEAR_TEAM_ID: "ENG" })
try {
await createCommand.parse()
} finally {
await cleanup()
}
},
})
// An explicit --team that matches nothing errors with the valid keys, even in
// interactive mode: only the configured default may fall back to a picker.
await snapshotTest({
name: "Issue Create Command - Unknown Explicit Team Lists Keys",
meta: import.meta,
colors: false,
args: ["--title", "Nope", "--team", "Nope", "--no-interactive"],
denoArgs: commonDenoArgs,
canFail: true,
async fn() {
const { cleanup } = await setupMockLinearServer([
{
queryName: "ResolveTeam",
variables: { reference: "Nope" },
response: { data: { teams: { nodes: [] } } },
},
{
queryName: "GetAllTeams",
response: {
data: {
teams: {
nodes: [{ id: "team-eng-id", key: "ENG", name: "Engineering" }],
pageInfo: { hasNextPage: false, endCursor: null },
},
},
},
},
], { LINEAR_TEAM_ID: "ENG" })
try {
await createCommand.parse()
} finally {
await cleanup()
}
},
})
// --team by name: the mutation gets the UUID.
await snapshotTest({
name: "Issue Create Command - Team By Name",
meta: import.meta,
colors: false,
args: ["--title", "By name", "--team", "Engineering", "--no-interactive"],
denoArgs: commonDenoArgs,
async fn() {
const { cleanup } = await setupMockLinearServer([
resolveTeamMock("Engineering"),
{
queryName: "CreateIssue",
variables: {
input: {
title: "By name",
labelIds: [],
teamId: "team-eng-id",
useDefaultTemplate: true,
},
},
response: {
data: {
issueCreate: {
success: true,
issue: {
id: "issue-new-1",
identifier: "ENG-1",
url: "https://linear.app/test-team/issue/ENG-1/by-name",
team: { key: "ENG" },
},
},
},
},
},
], { LINEAR_TEAM_ID: "ENG", LINEAR_ISSUE_CREATE_ASSIGN_SELF: "never" })
try {
await createCommand.parse()
} finally {
await cleanup()
}
},
})