Files
schpet__linear-cli/test/config.test.ts
T
Magnus Buvarp 9e0874847e Improve issue create project selection (#208)
## Summary

This improves `linear issue create` in three related areas:

- allow `--project` to keep issue creation interactive, the same way
`--parent` already does
- add optional project selection in interactive issue creation
- add configurable default self-assignment behavior for created issues

Before this change, `linear issue create --project "Dashboard"` skipped
interactive mode and failed with "Title is required when not using
interactive mode". With this PR, `--project` is treated as an
interactive-safe input, so users can preselect a project without also
needing `--title`.

Project prompting in interactive mode remains opt-in.
`issue_create_ask_project` defaults to `false`, so existing interactive
behavior is unchanged unless users explicitly enable the new prompt.

## Changes

- allow interactive issue creation when the only flags are `--project`,
`--parent`, or both
- add a team-scoped project picker during interactive issue creation
- gate the direct project prompt behind a new config option:
`issue_create_ask_project`
- keep `issue_create_ask_project = false` as the default, so project
selection stays out of the main interactive flow unless enabled
- when `issue_create_ask_project = false`, expose `Project` through the
existing "Add more fields" flow instead
- continue inheriting the parent issue's project when `--parent` is used
without an explicit `--project`
- allow explicit `--project` together with `--parent` and defer any
invalid combination checks to the Linear API
- add a new config option, `issue_create_assign_self`, with these modes:
  - `auto` (default): respect Linear's `autoAssignToSelf` setting
  - `always`: default-assign created issues to self
  - `never`: never default-assign created issues
- update docs for the new interactive behavior and config options

## Notes

- `issue_create_ask_project` defaults to `false`, so users who do not
set it will keep the previous interactive flow
- project selection only shows projects available to the selected team
- the interactive project picker is not shown when a parent issue is
present unless the project was explicitly provided
- explicit `--assignee`, the interactive assignee flow, and `--start`
still override the default assignment behavior

## Testing

- `deno task codegen`
- `deno task check`
- `deno lint`
- `deno fmt`
- `deno task generate-skill-docs`
- `deno test --allow-all --quiet test/config.test.ts`
- `deno test --allow-all --quiet
test/commands/issue/issue-create.test.ts`
2026-07-11 14:11:26 -07:00

625 lines
19 KiB
TypeScript

import { assertEquals } from "@std/assert"
import { fromFileUrl } from "@std/path"
import { getOption } from "../src/config.ts"
// Note: These tests use the cliValue parameter (highest precedence)
// to avoid interference from config files that may exist in the repo
Deno.test("getOption - download_images returns boolean for truthy strings", () => {
const truthyValues = [
"true",
"TRUE",
"True",
"yes",
"YES",
"y",
"Y",
"on",
"ON",
"1",
"t",
"T",
]
for (const value of truthyValues) {
const result = getOption("download_images", value)
assertEquals(result, true, `Expected "${value}" to coerce to true`)
}
})
Deno.test("getOption - download_images returns boolean for falsy strings", () => {
const falsyValues = [
"false",
"FALSE",
"False",
"no",
"NO",
"n",
"N",
"off",
"OFF",
"0",
"f",
"F",
]
for (const value of falsyValues) {
const result = getOption("download_images", value)
assertEquals(result, false, `Expected "${value}" to coerce to false`)
}
})
Deno.test("getOption - download_images returns undefined for unrecognized strings", () => {
const result = getOption("download_images", "maybe")
assertEquals(result, undefined)
})
Deno.test("getOption - issue_create_ask_project returns boolean for truthy strings", () => {
const truthyValues = ["true", "yes", "1", "on", "t"]
for (const value of truthyValues) {
const result = getOption("issue_create_ask_project", value)
assertEquals(result, true, `Expected "${value}" to coerce to true`)
}
})
Deno.test("getOption - issue_create_ask_project returns boolean for falsy strings", () => {
const falsyValues = ["false", "no", "0", "off", "f"]
for (const value of falsyValues) {
const result = getOption("issue_create_ask_project", value)
assertEquals(result, false, `Expected "${value}" to coerce to false`)
}
})
Deno.test("getOption - issue_create_assign_self accepts valid mode values", () => {
const validValues = ["always", "auto", "never"] as const
for (const value of validValues) {
const result = getOption("issue_create_assign_self", value)
assertEquals(result, value)
}
})
Deno.test("getOption - issue_create_assign_self rejects invalid mode values", () => {
const result = getOption("issue_create_assign_self", "true")
assertEquals(result, undefined)
})
Deno.test("getOption - environment variables take precedence over config file", async () => {
// Create a temp directory with a config file
const tempDir = await Deno.makeTempDir()
const configValue = "from-config-file"
const envValue = "from-env-var"
try {
// Write a .linear.toml with a workspace value
await Deno.writeTextFile(
`${tempDir}/.linear.toml`,
`workspace = "${configValue}"\n`,
)
// Get absolute paths to the config module and deno.json
const configUrl = new URL("../src/config.ts", import.meta.url)
const denoJsonPath = fromFileUrl(new URL("../deno.json", import.meta.url))
// Run a subprocess that imports config and prints the workspace value
// The subprocess runs from the temp directory so it loads our test config
const command = new Deno.Command("deno", {
args: [
"eval",
`--config=${denoJsonPath}`,
`import { getOption } from "${configUrl}"; console.log(getOption("workspace") ?? "undefined");`,
],
cwd: tempDir,
env: {
LINEAR_WORKSPACE: envValue,
},
stdout: "piped",
stderr: "piped",
})
const { stdout, stderr } = await command.output()
const output = new TextDecoder().decode(stdout).trim()
const errorOutput = new TextDecoder().decode(stderr)
if (errorOutput) {
console.error("Subprocess stderr:", errorOutput)
}
// The env var should win over the config file
assertEquals(
output,
envValue,
"Environment variable should take precedence over config file",
)
} finally {
// Clean up temp directory
await Deno.remove(tempDir, { recursive: true })
}
})
Deno.test("getOption - config file is used when no env var is set", async () => {
// Create a temp directory with a config file
const tempDir = await Deno.makeTempDir()
const configValue = "from-config-file"
try {
// Write a .linear.toml with a workspace value
await Deno.writeTextFile(
`${tempDir}/.linear.toml`,
`workspace = "${configValue}"\n`,
)
// Get absolute paths to the config module and deno.json
const configUrl = new URL("../src/config.ts", import.meta.url)
const denoJsonPath = fromFileUrl(new URL("../deno.json", import.meta.url))
// Run a subprocess without LINEAR_WORKSPACE env var
// Include essential env vars for subprocess to work correctly
const command = new Deno.Command("deno", {
args: [
"eval",
`--config=${denoJsonPath}`,
`import { getOption } from "${configUrl}"; console.log(getOption("workspace") ?? "undefined");`,
],
cwd: tempDir,
env: {
PATH: Deno.env.get("PATH") ?? "",
...(Deno.build.os === "windows"
? { SystemRoot: Deno.env.get("SystemRoot") ?? "" }
: {}),
},
stdout: "piped",
stderr: "piped",
})
const { stdout, stderr } = await command.output()
const output = new TextDecoder().decode(stdout).trim()
const errorOutput = new TextDecoder().decode(stderr)
if (errorOutput) {
console.error("Subprocess stderr:", errorOutput)
}
// The config file value should be used as fallback
assertEquals(
output,
configValue,
"Config file should be used when no env var is set",
)
} finally {
// Clean up temp directory
await Deno.remove(tempDir, { recursive: true })
}
})
Deno.test("getOption - home folder config is used as fallback", async () => {
// Create a temp directory structure simulating a home folder
const tempHome = await Deno.makeTempDir()
const homeConfigValue = "from-home-config"
try {
// Create config file in platform-appropriate location
const isWindows = Deno.build.os === "windows"
if (isWindows) {
// Windows: %APPDATA%\linear\linear.toml
await Deno.mkdir(`${tempHome}/linear`, { recursive: true })
await Deno.writeTextFile(
`${tempHome}/linear/linear.toml`,
`workspace = "${homeConfigValue}"\n`,
)
} else {
// Unix: ~/.config/linear/linear.toml
await Deno.mkdir(`${tempHome}/.config/linear`, { recursive: true })
await Deno.writeTextFile(
`${tempHome}/.config/linear/linear.toml`,
`workspace = "${homeConfigValue}"\n`,
)
}
// Create a separate temp directory to run from (no project config)
const workDir = await Deno.makeTempDir()
try {
const configUrl = new URL("../src/config.ts", import.meta.url)
const denoJsonPath = fromFileUrl(
new URL("../deno.json", import.meta.url),
)
// Run subprocess with appropriate env var for platform
// Note: Must NOT include XDG_CONFIG_HOME so HOME/.config is used
const env: Record<string, string> = isWindows
? { APPDATA: tempHome }
: { HOME: tempHome, PATH: Deno.env.get("PATH") ?? "" }
const command = new Deno.Command("deno", {
args: [
"eval",
`--config=${denoJsonPath}`,
`import { getOption } from "${configUrl}"; console.log(getOption("workspace") ?? "undefined");`,
],
cwd: workDir,
env,
clearEnv: true,
stdout: "piped",
stderr: "piped",
})
const { stdout, stderr } = await command.output()
const output = new TextDecoder().decode(stdout).trim()
const errorOutput = new TextDecoder().decode(stderr)
if (errorOutput) {
console.error("Subprocess stderr:", errorOutput)
}
assertEquals(
output,
homeConfigValue,
"Home folder config should be used when no project config exists",
)
} finally {
await Deno.remove(workDir, { recursive: true })
}
} finally {
await Deno.remove(tempHome, { recursive: true })
}
})
Deno.test("getOption - project config takes precedence over home config", async () => {
// Create temp directories for home and project
const tempHome = await Deno.makeTempDir()
const projectDir = await Deno.makeTempDir()
const homeConfigValue = "from-home-config"
const projectConfigValue = "from-project-config"
try {
// Create home config in platform-appropriate location
const isWindows = Deno.build.os === "windows"
if (isWindows) {
await Deno.mkdir(`${tempHome}/linear`, { recursive: true })
await Deno.writeTextFile(
`${tempHome}/linear/linear.toml`,
`workspace = "${homeConfigValue}"\n`,
)
} else {
// Unix: ~/.config/linear/linear.toml
await Deno.mkdir(`${tempHome}/.config/linear`, { recursive: true })
await Deno.writeTextFile(
`${tempHome}/.config/linear/linear.toml`,
`workspace = "${homeConfigValue}"\n`,
)
}
// Create project .linear.toml
await Deno.writeTextFile(
`${projectDir}/.linear.toml`,
`workspace = "${projectConfigValue}"\n`,
)
const configUrl = new URL("../src/config.ts", import.meta.url)
const denoJsonPath = fromFileUrl(new URL("../deno.json", import.meta.url))
const env: Record<string, string> = isWindows
? { APPDATA: tempHome, SystemRoot: Deno.env.get("SystemRoot") ?? "" }
: { HOME: tempHome, PATH: Deno.env.get("PATH") ?? "" }
const command = new Deno.Command("deno", {
args: [
"eval",
`--config=${denoJsonPath}`,
`import { getOption } from "${configUrl}"; console.log(getOption("workspace") ?? "undefined");`,
],
cwd: projectDir,
env,
stdout: "piped",
stderr: "piped",
})
const { stdout, stderr } = await command.output()
const output = new TextDecoder().decode(stdout).trim()
const errorOutput = new TextDecoder().decode(stderr)
if (errorOutput) {
console.error("Subprocess stderr:", errorOutput)
}
assertEquals(
output,
projectConfigValue,
"Project config should take precedence over home config",
)
} finally {
await Deno.remove(tempHome, { recursive: true })
await Deno.remove(projectDir, { recursive: true })
}
})
Deno.test({
name: "getOption - XDG_CONFIG_HOME takes precedence over HOME/.config",
ignore: Deno.build.os === "windows", // XDG is Unix-specific
fn: async () => {
// Create temp directories for XDG and regular home
const tempHome = await Deno.makeTempDir()
const xdgConfigDir = await Deno.makeTempDir()
const homeConfigValue = "from-home-config"
const xdgConfigValue = "from-xdg-config"
try {
// Create ~/.config/linear/linear.toml (should NOT be used)
await Deno.mkdir(`${tempHome}/.config/linear`, { recursive: true })
await Deno.writeTextFile(
`${tempHome}/.config/linear/linear.toml`,
`workspace = "${homeConfigValue}"\n`,
)
// Create $XDG_CONFIG_HOME/linear/linear.toml (should be used)
await Deno.mkdir(`${xdgConfigDir}/linear`, { recursive: true })
await Deno.writeTextFile(
`${xdgConfigDir}/linear/linear.toml`,
`workspace = "${xdgConfigValue}"\n`,
)
// Create a work directory (no project config)
const workDir = await Deno.makeTempDir()
try {
const configUrl = new URL("../src/config.ts", import.meta.url)
const denoJsonPath = fromFileUrl(
new URL("../deno.json", import.meta.url),
)
const command = new Deno.Command("deno", {
args: [
"eval",
`--config=${denoJsonPath}`,
`import { getOption } from "${configUrl}"; console.log(getOption("workspace") ?? "undefined");`,
],
cwd: workDir,
env: {
HOME: tempHome,
XDG_CONFIG_HOME: xdgConfigDir,
PATH: Deno.env.get("PATH") ?? "",
},
stdout: "piped",
stderr: "piped",
})
const { stdout, stderr } = await command.output()
const output = new TextDecoder().decode(stdout).trim()
const errorOutput = new TextDecoder().decode(stderr)
if (errorOutput) {
console.error("Subprocess stderr:", errorOutput)
}
assertEquals(
output,
xdgConfigValue,
"XDG_CONFIG_HOME should take precedence over HOME/.config",
)
} finally {
await Deno.remove(workDir, { recursive: true })
}
} finally {
await Deno.remove(tempHome, { recursive: true })
await Deno.remove(xdgConfigDir, { recursive: true })
}
},
})
Deno.test({
name: "getOption - APPDATA config is used on Windows",
ignore: Deno.build.os !== "windows", // Windows-specific test
fn: async () => {
// Create temp directory simulating APPDATA
const tempAppData = await Deno.makeTempDir()
const configValue = "from-appdata-config"
try {
// Create %APPDATA%\linear\linear.toml
await Deno.mkdir(`${tempAppData}/linear`, { recursive: true })
await Deno.writeTextFile(
`${tempAppData}/linear/linear.toml`,
`workspace = "${configValue}"\n`,
)
// Create a work directory (no project config)
const workDir = await Deno.makeTempDir()
try {
const configUrl = new URL("../src/config.ts", import.meta.url)
const denoJsonPath = fromFileUrl(
new URL("../deno.json", import.meta.url),
)
const command = new Deno.Command("deno", {
args: [
"eval",
`--config=${denoJsonPath}`,
`import { getOption } from "${configUrl}"; console.log(getOption("workspace") ?? "undefined");`,
],
cwd: workDir,
env: {
APPDATA: tempAppData,
SystemRoot: Deno.env.get("SystemRoot") ?? "",
},
stdout: "piped",
stderr: "piped",
})
const { stdout, stderr } = await command.output()
const output = new TextDecoder().decode(stdout).trim()
const errorOutput = new TextDecoder().decode(stderr)
if (errorOutput) {
console.error("Subprocess stderr:", errorOutput)
}
assertEquals(
output,
configValue,
"APPDATA config should be used on Windows",
)
} finally {
await Deno.remove(workDir, { recursive: true })
}
} finally {
await Deno.remove(tempAppData, { recursive: true })
}
},
})
Deno.test("getOption - global and project configs are merged", async () => {
// Create temp directories for home and project
const tempHome = await Deno.makeTempDir()
const projectDir = await Deno.makeTempDir()
const globalIssueSort = "priority"
const projectWorkspace = "my-workspace"
try {
// Create home config with issue_sort
const isWindows = Deno.build.os === "windows"
if (isWindows) {
await Deno.mkdir(`${tempHome}/linear`, { recursive: true })
await Deno.writeTextFile(
`${tempHome}/linear/linear.toml`,
`issue_sort = "${globalIssueSort}"\n`,
)
} else {
await Deno.mkdir(`${tempHome}/.config/linear`, { recursive: true })
await Deno.writeTextFile(
`${tempHome}/.config/linear/linear.toml`,
`issue_sort = "${globalIssueSort}"\n`,
)
}
// Create project config with workspace (different key)
await Deno.writeTextFile(
`${projectDir}/.linear.toml`,
`workspace = "${projectWorkspace}"\n`,
)
const configUrl = new URL("../src/config.ts", import.meta.url)
const denoJsonPath = fromFileUrl(new URL("../deno.json", import.meta.url))
// Note: clearEnv ensures XDG_CONFIG_HOME doesn't interfere with HOME/.config
const env: Record<string, string> = isWindows
? { APPDATA: tempHome, SystemRoot: Deno.env.get("SystemRoot") ?? "" }
: { HOME: tempHome, PATH: Deno.env.get("PATH") ?? "" }
// Test that both values are accessible
const command = new Deno.Command("deno", {
args: [
"eval",
`--config=${denoJsonPath}`,
`import { getOption } from "${configUrl}"; console.log(JSON.stringify({ issue_sort: getOption("issue_sort"), workspace: getOption("workspace") }));`,
],
cwd: projectDir,
env,
clearEnv: true,
stdout: "piped",
stderr: "piped",
})
const { stdout, stderr } = await command.output()
const output = new TextDecoder().decode(stdout).trim()
const errorOutput = new TextDecoder().decode(stderr)
if (errorOutput) {
console.error("Subprocess stderr:", errorOutput)
}
const result = JSON.parse(output)
assertEquals(
result.issue_sort,
globalIssueSort,
"Global config value (issue_sort) should be accessible",
)
assertEquals(
result.workspace,
projectWorkspace,
"Project config value (workspace) should be accessible",
)
} finally {
await Deno.remove(tempHome, { recursive: true })
await Deno.remove(projectDir, { recursive: true })
}
})
Deno.test("getOption - env var takes precedence over home config", async () => {
// Create temp home directory
const tempHome = await Deno.makeTempDir()
const homeConfigValue = "from-home-config"
const envValue = "from-env-var"
try {
// Create home config in platform-appropriate location
const isWindows = Deno.build.os === "windows"
if (isWindows) {
await Deno.mkdir(`${tempHome}/linear`, { recursive: true })
await Deno.writeTextFile(
`${tempHome}/linear/linear.toml`,
`workspace = "${homeConfigValue}"\n`,
)
} else {
// Unix: ~/.config/linear/linear.toml
await Deno.mkdir(`${tempHome}/.config/linear`, { recursive: true })
await Deno.writeTextFile(
`${tempHome}/.config/linear/linear.toml`,
`workspace = "${homeConfigValue}"\n`,
)
}
// Create a work directory (no project config)
const workDir = await Deno.makeTempDir()
try {
const configUrl = new URL("../src/config.ts", import.meta.url)
const denoJsonPath = fromFileUrl(
new URL("../deno.json", import.meta.url),
)
const env: Record<string, string> = isWindows
? {
APPDATA: tempHome,
LINEAR_WORKSPACE: envValue,
SystemRoot: Deno.env.get("SystemRoot") ?? "",
}
: {
HOME: tempHome,
LINEAR_WORKSPACE: envValue,
PATH: Deno.env.get("PATH") ?? "",
}
const command = new Deno.Command("deno", {
args: [
"eval",
`--config=${denoJsonPath}`,
`import { getOption } from "${configUrl}"; console.log(getOption("workspace") ?? "undefined");`,
],
cwd: workDir,
env,
stdout: "piped",
stderr: "piped",
})
const { stdout, stderr } = await command.output()
const output = new TextDecoder().decode(stdout).trim()
const errorOutput = new TextDecoder().decode(stderr)
if (errorOutput) {
console.error("Subprocess stderr:", errorOutput)
}
assertEquals(
output,
envValue,
"Environment variable should take precedence over home config",
)
} finally {
await Deno.remove(workDir, { recursive: true })
}
} finally {
await Deno.remove(tempHome, { recursive: true })
}
})