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/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,7 @@ linear team delete
linear team id
linear team list
linear team members
linear team states
```

## Reference Documentation
Expand Down
21 changes: 20 additions & 1 deletion skills/linear-cli/references/team.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@ Commands:
list - List teams
id - Print the configured team id
autolinks - Configure GitHub repository autolinks for Linear issues with this team prefix
members [teamKey] - List team members
members [teamKey] - List team members
states [teamKey] - List workflow states for a team
```

## Subcommands
Expand Down Expand Up @@ -139,3 +140,21 @@ Options:
--workspace <slug> - Target workspace (uses credentials)
-a, --all - Include inactive members
```

### states

> List workflow states for a team

```
Usage: linear team states [teamKey]

Description:

List workflow states for a team

Options:

-h, --help - Show this help.
--workspace <slug> - Target workspace (uses credentials)
-j, --json - Output as JSON
```
17 changes: 7 additions & 10 deletions src/commands/issue/issue-create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,15 @@ import {
getProjectsForTeam,
getTeamIdByKey,
getTeamKey,
getWorkflowStateByNameOrType,
getWorkflowStates,
isLinearUuid,
lookupUserId,
resolveMilestoneId,
resolveWorkflowState,
searchTeamsByKeySubstring,
selectOption,
type WorkflowState,
workflowStateNotFoundError,
} from "../../utils/linear.ts"
import { startWorkOnIssue } from "../../utils/actions.ts"
import {
Expand Down Expand Up @@ -822,16 +823,12 @@ export const createCommand = new Command()
)
}
let stateId: string | undefined
if (state) {
const workflowState = await getWorkflowStateByNameOrType(
team,
state,
)
if (state != null) {
const states = await getWorkflowStates(team)
const workflowState = resolveWorkflowState(states, state)
if (!workflowState) {
throw new NotFoundError(
"Workflow state",
`'${state}' for team ${team}`,
)
spinner?.stop()
throw workflowStateNotFoundError(team, state, states)
}
stateId = workflowState.id
}
Expand Down
18 changes: 8 additions & 10 deletions src/commands/issue/issue-update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,12 @@ import {
getIssueProjectId,
getProjectIdByName,
getTeamIdByKey,
getWorkflowStateByNameOrType,
getWorkflowStates,
isLinearUuid,
lookupUserId,
resolveMilestoneId,
resolveWorkflowState,
workflowStateNotFoundError,
} from "../../utils/linear.ts"
import {
CliError,
Expand Down Expand Up @@ -160,16 +162,12 @@ export const updateCommand = new Command()
}

let stateId: string | undefined
if (state) {
const workflowState = await getWorkflowStateByNameOrType(
teamKey,
state,
)
if (state != null) {
const states = await getWorkflowStates(teamKey)
const workflowState = resolveWorkflowState(states, state)
if (!workflowState) {
throw new NotFoundError(
"Workflow state",
`'${state}' for team ${teamKey}`,
)
spinner?.stop()
throw workflowStateNotFoundError(teamKey, state, states)
}
stateId = workflowState.id
}
Expand Down
77 changes: 77 additions & 0 deletions src/commands/team/team-states.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { Command } from "@cliffy/command"
import { unicodeWidth } from "@std/cli"
import { getTeamKey, getWorkflowStates } from "../../utils/linear.ts"
import { padDisplay } from "../../utils/display.ts"
import { shouldShowSpinner } from "../../utils/hyperlink.ts"
import { handleError, ValidationError } from "../../utils/errors.ts"

export const statesCommand = new Command()
.name("states")
.description("List workflow states for a team")
.arguments("[teamKey:string]")
.option("-j, --json", "Output as JSON")
.action(async ({ json }, teamKey?: string) => {
const showSpinner = !json && shouldShowSpinner()
let spinner: { start: () => void; stop: () => void } | null = null

try {
const resolvedTeamKey = teamKey || getTeamKey()
if (!resolvedTeamKey) {
throw new ValidationError(
"Could not determine team key from directory name",
{ suggestion: "Please specify a team key as an argument." },
)
}

if (showSpinner) {
const { Spinner } = await import("@std/cli/unstable-spinner")
spinner = new Spinner()
spinner.start()
}

const states = await getWorkflowStates(resolvedTeamKey)

spinner?.stop()

if (json) {
console.log(JSON.stringify({ nodes: states }, null, 2))
return
}

if (states.length === 0) {
console.log("No workflow states found for this team.")
return
}

// States arrive sorted by position; keep that order (it is meaningful).
const NAME_WIDTH = Math.max(
unicodeWidth("NAME"),
...states.map((s) => unicodeWidth(s.name)),
)
const TYPE_WIDTH = Math.max(
unicodeWidth("TYPE"),
...states.map((s) => unicodeWidth(s.type)),
)

console.log(
`%c${padDisplay("NAME", NAME_WIDTH)}%c %c${
padDisplay("TYPE", TYPE_WIDTH)
}%c`,
"text-decoration: underline",
"text-decoration: none",
"text-decoration: underline",
"text-decoration: none",
)

for (const state of states) {
console.log(
`${padDisplay(state.name, NAME_WIDTH)} ${
padDisplay(state.type, TYPE_WIDTH)
}`,
)
}
} catch (error) {
spinner?.stop()
handleError(error, "Failed to fetch workflow states")
}
})
2 changes: 2 additions & 0 deletions src/commands/team/team.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { idCommand } from "./team-id.ts"
import { autolinksCommand } from "./team-autolinks.ts"
import { membersCommand } from "./team-members.ts"
import { listCommand } from "./team-list.ts"
import { statesCommand } from "./team-states.ts"
import { createCommand } from "./team-create.ts"
import { deleteCommand } from "./team-delete.ts"

Expand All @@ -18,3 +19,4 @@ export const teamCommand = new Command()
.command("id", idCommand)
.command("autolinks", autolinksCommand)
.command("members", membersCommand)
.command("states", statesCommand)
44 changes: 33 additions & 11 deletions src/utils/linear.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,25 +171,47 @@ export async function getStartedState(
return { id: startedStates[0].id, name: startedStates[0].name }
}

export async function getWorkflowStateByNameOrType(
teamKey: string,
/**
* Resolve a workflow state from an already-fetched list by name
* (case-insensitive) or by type. Duplicate types resolve to the first matching
* state in the input order — callers pass the position-sorted list from
* `getWorkflowStates`, so that is the lowest-position state of that type.
*/
export function resolveWorkflowState(
states: readonly WorkflowState[],
nameOrType: string,
): Promise<{ id: string; name: string } | undefined> {
const states = await getWorkflowStates(teamKey)

): WorkflowState | undefined {
const nameMatch = states.find(
(s) => s.name.toLowerCase() === nameOrType.toLowerCase(),
)
if (nameMatch) {
return { id: nameMatch.id, name: nameMatch.name }
return nameMatch
}

const typeMatch = states.find((s) => s.type === nameOrType.toLowerCase())
if (typeMatch) {
return { id: typeMatch.id, name: typeMatch.name }
}
return states.find((s) => s.type === nameOrType.toLowerCase())
}

return undefined
/**
* Build the error thrown when a requested workflow state can't be resolved for
* a team. Shared by `issue create` and `issue update` so both surface the same
* message and the same list of valid states.
*/
export function workflowStateNotFoundError(
teamKey: string,
requested: string,
states: readonly WorkflowState[],
): NotFoundError {
const suggestion = states.length > 0
? `Valid states: ${
states.map((s) => `${JSON.stringify(s.name)} (${s.type})`).join(", ")
}. Run \`linear team states ${teamKey}\` to list them.`
: `Team ${teamKey} has no workflow states. Run \`linear team states ${teamKey}\`.`

return new NotFoundError(
"Workflow state",
`'${requested}' for team ${teamKey}`,
{ suggestion },
)
}

export async function updateIssueState(
Expand Down
9 changes: 9 additions & 0 deletions test/commands/issue/__snapshots__/issue-create.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -84,3 +84,12 @@ https://linear.app/test-team/issue/ENG-890/test-cycle-feature
stderr:
""
`;

snapshot[`Issue Create Command - Unknown State Lists Valid States 1`] = `
stdout:
""
stderr:
"✗ Failed to create issue: Workflow state not found: 'Nope' for team ENG
Valid states: \\"Todo\\" (unstarted), \\"In Progress\\" (started), \\"Done\\" (completed). Run \`linear team states ENG\` to list them.
"
`;
9 changes: 9 additions & 0 deletions test/commands/issue/__snapshots__/issue-update.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -120,3 +120,12 @@ https://linear.app/test-team/issue/ENG-123/test-issue
stderr:
""
`;

snapshot[`Issue Update Command - Unknown State Lists Valid States 1`] = `
stdout:
""
stderr:
"✗ Failed to update issue: Workflow state not found: 'Nope' for team ENG
Valid states: \\"Todo\\" (unstarted), \\"In Progress\\" (started), \\"Done\\" (completed). Run \`linear team states ENG\` to list them.
"
`;
65 changes: 65 additions & 0 deletions test/commands/issue/issue-create.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1521,3 +1521,68 @@ Deno.test("Issue Create Command - Interactive Assignee Can Override Config Self
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: "GetTeamIdByKey",
variables: { team: "ENG" },
response: { data: { teams: { nodes: [{ id: "team-eng-id" }] } } },
},
{
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()
}
},
})
Loading
Loading