From 232809087720ffd05afc39a39a43bba672957b3c Mon Sep 17 00:00:00 2001 From: Daniel Wise Date: Wed, 27 May 2026 23:14:34 -0700 Subject: [PATCH 1/3] feat(cli): resolve issue workflow state by name on issues update Add a reusable workflow-state resolver in linear-core that maps a state reference (UUID or human name) to a stateId for a team, and wire it into `issues update` so agents can set state with `--state "In Progress"` or `--input '{"state":"..."}'`/`'{"stateName":"..."}'`. Resolution is scoped to the target issue's team; an explicit stateId UUID still passes through unchanged; state/stateName keys are stripped before reaching GraphQL. Unknown or ambiguous names error with the team's valid states listed. Refs: #22 --- .reports/issue-22-qa.md | 116 +++++++++++++ packages/cli/src/commands/input.ts | 8 + packages/cli/src/commands/issue-state.ts | 76 +++++++++ packages/cli/src/commands/resource.ts | 11 +- packages/cli/src/help/resource-help.ts | 2 + packages/cli/src/index.ts | 19 ++- packages/cli/tests/issue-state.test.ts | 155 ++++++++++++++++++ .../src/entities/linear-gateway.ts | 11 ++ packages/linear-core/src/entities/models.ts | 1 + .../src/entities/state-resolver.ts | 94 +++++++++++ packages/linear-core/src/index.ts | 1 + .../linear-core/tests/state-resolver.test.ts | 85 ++++++++++ 12 files changed, 573 insertions(+), 6 deletions(-) create mode 100644 .reports/issue-22-qa.md create mode 100644 packages/cli/src/commands/issue-state.ts create mode 100644 packages/cli/tests/issue-state.test.ts create mode 100644 packages/linear-core/src/entities/state-resolver.ts create mode 100644 packages/linear-core/tests/state-resolver.test.ts diff --git a/.reports/issue-22-qa.md b/.reports/issue-22-qa.md new file mode 100644 index 0000000..0413760 --- /dev/null +++ b/.reports/issue-22-qa.md @@ -0,0 +1,116 @@ +# Self-QA fallback — issue #22 + +> This work item has no demoable browser surface, so a Playwright video walkthrough is not possible. +> This document replaces the recording and describes what was verified instead. + +## Why no video + +`linear-cli` is a terminal CLI / SDK monorepo with no web UI. The change is a deep +workflow-state resolver in `@wiseiodev/linear-core` plus input normalization wired into +the `linear issues update` command. The acceptance criteria are pure input/output +behavior, verified deterministically through unit tests (fake gateways), a runtime +demonstration against the built module, and a CLI help smoke test. + +## What was verified + +Acceptance criteria from issue #22: + +- [x] `issues update --state ""` sets the issue to the matching state + - The generic resource registrar now allows the issues `update` command to run with no + `--input` (opt-in `allowEmptyInput`), and the handler folds `--state` into a resolved + `stateId`. + - Verified by: `tests/issue-state.test.ts` → "resolves the --state flag to a stateId + scoped to the issue's team"; CLI help smoke test shows `--state ` available on + `linear issues update`. + +- [x] `--input '{"stateName":""}'` and `--input '{"state":""}'` resolve to a `stateId` + - Verified by: `tests/issue-state.test.ts` → "resolves a `state` key…" and "resolves a + `stateName` key…" (both assert the resolved `stateId` and that the name key is stripped). + +- [x] `--input '{"stateId":""}'` continues to work unchanged + - Verified by: `tests/issue-state.test.ts` → "leaves an explicit stateId untouched, skips + resolution, strips name keys" (no `getIssue` call; `stateId` passed through verbatim). + +- [x] Resolution is scoped to the target issue's team + - The handler fetches the issue, reads `teamId`, and lists states via + `listWorkflowStatesForTeam(teamId)` (server-side `filter: { team: { id: { eq } } }`). + - Verified by: `tests/issue-state.test.ts` asserts `getIssue("ANN-1")` and + `listWorkflowStatesForTeam("team-1")`; `tests/state-resolver.test.ts` → "scopes the + lookup to the provided team id". + +- [x] Unknown/ambiguous name errors with the team's valid state names listed + - Verified by: `tests/state-resolver.test.ts` → "throws a LinearCoreError listing valid + states when the name is unknown" and "throws an ambiguity error…"; runtime demo below + shows the exact messages. + +- [x] `state`/`stateName` keys never reach GraphQL + - The normalizer destructures `state`/`stateName` out of the payload before returning. + - Verified by: every resolving test asserts the output contains only `stateId` (+ other + real fields), never `state`/`stateName`. + +- [x] Resolver unit tests cover UUID passthrough, name match, type fallback, not-found, ambiguous + - `tests/state-resolver.test.ts` — 7 tests, all passing (see Evidence). + +- [x] `issues update` normalization is tested (flag + JSON-key paths, stateId untouched) + - `tests/issue-state.test.ts` — 8 tests, all passing (see Evidence). + +## Evidence + +Quality gates (all green): + +- `pnpm verify` → biome check (0 errors), `turbo run typecheck` (4 pkgs), `turbo run test` + (linear-core 58 tests, cli 60 tests — totals include the 8 + 10 new tests). +- `pnpm build` → 4 packages built successfully. + +Focused test runs: + +``` +tests/state-resolver.test.ts (8 passed) + ✓ returns a UUID reference unchanged without listing states + ✓ matches an exact state name case-insensitively + ✓ scopes the lookup to the provided team id + ✓ falls back to the lowest-position state of the preferred type + ✓ preferred-type fallback ignores states without a position + ✓ throws a LinearCoreError listing valid states when the name is unknown + ✓ throws when no name match and the preferred type is absent + ✓ throws an ambiguity error when multiple states share the name + +tests/issue-state.test.ts (10 passed) + ✓ resolves the --state flag to a stateId scoped to the issue's team + ✓ resolves a `state` key in the payload and strips it + ✓ resolves a `stateName` key in the payload and strips it + ✓ leaves an explicit stateId untouched, skips resolution, strips name keys + ✓ resolves a state ref even when stateId is present but empty + ✓ trims a whitespace-padded UUID --state and skips the fetch + ✓ prefers the --state flag over `state`/`stateName` keys + ✓ returns the payload unchanged and skips the fetch when no state is provided + ✓ passes a UUID --state through without fetching the issue + ✓ surfaces a listing error when the state name is unknown +``` + +Runtime demonstration against the built `@wiseiodev/linear-core` module: + +``` +1. UUID passthrough (no team lookup): + resolve(11111111-2222-3333-4444-555555555555) => 11111111-2222-3333-4444-555555555555 | list calls: 0 +2. Case-insensitive name match: + resolve('in progress') => s-progress +3. Preferred-type fallback (lowest position 'started'): + resolve('Doing', {preferredType:'started'}) => s-progress +4. Unknown name -> typed error listing valid states: + code: InvalidInput | message: Workflow state "Nope" not found for this team. Valid states: Backlog (backlog), Todo (unstarted), In Progress (started), In Review (started), Done (completed). +5. Ambiguous name -> typed error: + code: InvalidInput | message: Workflow state "In Progress" is ambiguous for this team. Valid states: In Progress (started), in progress (started). +6. isWorkflowStateId guard: true / false +``` + +CLI help smoke test (`node packages/cli/dist/bin/linear.js issues update --help`) confirms the +`update` command exists and exposes the `--state ` global option. + +## Follow-up flag + +End-to-end execution against the live Linear API (real auth + a real issue) was not run because +it requires network credentials and is non-deterministic. The deterministic unit/runtime +evidence above covers every acceptance criterion. Create/bulk-update state-by-name and the +`prep`/`pr-ready` consumers of the preferred-`type` fallback are intentionally out of scope +(tracked by issues #27, #28, #29). diff --git a/packages/cli/src/commands/input.ts b/packages/cli/src/commands/input.ts index 6535ff5..8f56a62 100644 --- a/packages/cli/src/commands/input.ts +++ b/packages/cli/src/commands/input.ts @@ -21,3 +21,11 @@ export async function parseJsonInput(options: InputOptions): Promise { return JSON.parse(text); } + +export async function parseOptionalJsonInput(options: InputOptions): Promise { + if (!options.input && !options.inputFile) { + return {}; + } + + return parseJsonInput(options); +} diff --git a/packages/cli/src/commands/issue-state.ts b/packages/cli/src/commands/issue-state.ts new file mode 100644 index 0000000..73bb068 --- /dev/null +++ b/packages/cli/src/commands/issue-state.ts @@ -0,0 +1,76 @@ +import type { IssueRecord, ResolvableWorkflowState } from "@wiseiodev/linear-core"; +import { isWorkflowStateId, LinearCoreError, resolveStateId } from "@wiseiodev/linear-core"; + +export interface IssueStateGateway { + getIssue(id: string): Promise; + listWorkflowStatesForTeam(teamId: string): Promise; +} + +function asString(value: unknown): string | undefined { + if (typeof value !== "string") { + return undefined; + } + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +function pickStateRef( + stateFlag: string | undefined, + state: unknown, + stateName: unknown, +): string | undefined { + return asString(stateFlag) ?? asString(state) ?? asString(stateName); +} + +async function resolveStateRef( + gateway: IssueStateGateway, + issueId: string, + stateRef: string, +): Promise { + // Short-circuit a UUID here (not just inside resolveStateId) so a UUID + // reference never triggers the getIssue() round-trip needed for name lookup. + if (isWorkflowStateId(stateRef)) { + return stateRef; + } + + const issue = await gateway.getIssue(issueId); + if (!issue.teamId) { + throw new LinearCoreError( + "InvalidInput", + `Cannot resolve state "${stateRef}" because issue ${issue.identifier} has no team.`, + ); + } + + return resolveStateId(issue.teamId, stateRef, (teamId) => + gateway.listWorkflowStatesForTeam(teamId), + ); +} + +/** + * Folds a state reference (from --state, or a `state`/`stateName` key) into a + * resolved `stateId`, scoped to the target issue's team. An explicit `stateId` + * in the payload always wins and skips resolution; `state`/`stateName` keys are + * stripped so they never reach GraphQL. + */ +export async function normalizeIssueUpdateStatePayload( + gateway: IssueStateGateway, + issueId: string, + payload: Record, + stateFlag: string | undefined, +): Promise> { + const { state, stateName, ...rest } = payload; + + // An explicit, non-empty stateId always wins and skips resolution. A blank or + // non-string stateId is not treated as explicit, so a state ref can still resolve. + if (typeof rest.stateId === "string" && rest.stateId.trim().length > 0) { + return rest; + } + + const stateRef = pickStateRef(stateFlag, state, stateName); + if (stateRef === undefined) { + return rest; + } + + const stateId = await resolveStateRef(gateway, issueId, stateRef); + return { ...rest, stateId }; +} diff --git a/packages/cli/src/commands/resource.ts b/packages/cli/src/commands/resource.ts index 1bd04cf..343075e 100644 --- a/packages/cli/src/commands/resource.ts +++ b/packages/cli/src/commands/resource.ts @@ -9,7 +9,7 @@ import type { Command } from "commander"; import { renderEnvelope } from "../formatters/output.js"; import { getResourceHelpTexts } from "../help/resource-help.js"; import { getGlobalOptions } from "../runtime/options.js"; -import { parseJsonInput } from "./input.js"; +import { parseJsonInput, parseOptionalJsonInput } from "./input.js"; interface ResourceHandlers { readonly list?: (manager: AuthManager, command: Command) => Promise; @@ -56,12 +56,17 @@ async function executeAction( } } +export interface ResourceCommandOptions { + readonly update?: { readonly allowEmptyInput?: boolean }; +} + export function registerResourceCommand( program: Command, entity: LinearEntity, description: string, handlers: ResourceHandlers, authManager: AuthManager, + options: ResourceCommandOptions = {}, ): Command { const command = program.command(entity).description(description); const helpTexts = getResourceHelpTexts(entity); @@ -122,7 +127,9 @@ export function registerResourceCommand( .option("--input-file ", "JSON payload file") .action(async (id, opts, cmd) => executeAction(entity, "update", cmd, async () => { - const payload = await parseJsonInput(opts); + const payload = options.update?.allowEmptyInput + ? await parseOptionalJsonInput(opts) + : await parseJsonInput(opts); return updateHandler(authManager, id, payload, cmd); }), ); diff --git a/packages/cli/src/help/resource-help.ts b/packages/cli/src/help/resource-help.ts index a636452..921b90b 100644 --- a/packages/cli/src/help/resource-help.ts +++ b/packages/cli/src/help/resource-help.ts @@ -134,6 +134,8 @@ const resourceExamples: Record = { update: { examples: [ "linear issues update --input '{\"priority\":2}'", + 'linear issues update --state "In Progress"', + 'linear issues update --input \'{"state":"In Progress"}\'', "linear issues update --input-file payload.json --json", ], }, diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 5df6ae7..51255e2 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -50,6 +50,7 @@ import open from "open"; import { runInteractiveOAuthLogin } from "./auth/login.js"; import { buildAuthStatusReport } from "./auth/status-report.js"; import { isIssueUpdateInput } from "./commands/issue-guards.js"; +import { normalizeIssueUpdateStatePayload } from "./commands/issue-state.js"; import { registerIssuesBulkUpdate } from "./commands/issues-bulk-update.js"; import { registerResourceCommand } from "./commands/resource.js"; import { renderEnvelope } from "./formatters/output.js"; @@ -571,18 +572,28 @@ export function createProgram(authManager = new AuthManager()): Command { templateId, }); }, - update: async (_manager, id, payload, cmd) => - (await sessionGateway(cmd)).updateIssue( + update: async (_manager, id, payload, cmd) => { + const globals = getGlobalOptions(cmd); + const gateway = await sessionGateway(cmd); + const normalized = await normalizeIssueUpdateStatePayload( + gateway, + id, + isRecord(payload) ? payload : {}, + globals.state, + ); + return gateway.updateIssue( id, ensurePayload( - payload, + normalized, isIssueUpdateInput, "Issue update payload must be a non-empty object.", ), - ), + ); + }, delete: async (_manager, id, cmd) => (await sessionGateway(cmd)).deleteIssue(id), }, authManager, + { update: { allowEmptyInput: true } }, ); const issuesCommand = program.commands.find((command) => command.name() === "issues"); diff --git a/packages/cli/tests/issue-state.test.ts b/packages/cli/tests/issue-state.test.ts new file mode 100644 index 0000000..f10578b --- /dev/null +++ b/packages/cli/tests/issue-state.test.ts @@ -0,0 +1,155 @@ +import type { IssueRecord, ResolvableWorkflowState } from "@wiseiodev/linear-core"; +import { describe, expect, test, vi } from "vitest"; +import { + type IssueStateGateway, + normalizeIssueUpdateStatePayload, +} from "../src/commands/issue-state.js"; + +const STATES: readonly ResolvableWorkflowState[] = [ + { id: "s-todo", name: "Todo", type: "unstarted", position: 1 }, + { id: "s-progress", name: "In Progress", type: "started", position: 2 }, +]; + +function makeIssue(overrides: Partial = {}): IssueRecord { + return { + id: "issue-uuid", + number: 1, + identifier: "ANN-1", + title: "Demo", + priority: 0, + url: "https://linear.app/x/issue/ANN-1", + teamId: "team-1", + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + ...overrides, + }; +} + +function makeGateway( + options: { issue?: IssueRecord; states?: readonly ResolvableWorkflowState[] } = {}, +) { + const getIssue = vi.fn(async (_id: string) => options.issue ?? makeIssue()); + const listWorkflowStatesForTeam = vi.fn(async (_teamId: string) => options.states ?? STATES); + const gateway: IssueStateGateway = { getIssue, listWorkflowStatesForTeam }; + return { gateway, getIssue, listWorkflowStatesForTeam }; +} + +describe("normalizeIssueUpdateStatePayload", () => { + test("resolves the --state flag to a stateId scoped to the issue's team", async () => { + const { gateway, getIssue, listWorkflowStatesForTeam } = makeGateway(); + + const result = await normalizeIssueUpdateStatePayload(gateway, "ANN-1", {}, "In Progress"); + + expect(result).toEqual({ stateId: "s-progress" }); + expect(getIssue).toHaveBeenCalledWith("ANN-1"); + expect(listWorkflowStatesForTeam).toHaveBeenCalledWith("team-1"); + }); + + test("resolves a `state` key in the payload and strips it", async () => { + const { gateway } = makeGateway(); + + const result = await normalizeIssueUpdateStatePayload( + gateway, + "ANN-1", + { state: "Todo", title: "x" }, + undefined, + ); + + expect(result).toEqual({ stateId: "s-todo", title: "x" }); + }); + + test("resolves a `stateName` key in the payload and strips it", async () => { + const { gateway } = makeGateway(); + + const result = await normalizeIssueUpdateStatePayload( + gateway, + "ANN-1", + { stateName: "In Progress" }, + undefined, + ); + + expect(result).toEqual({ stateId: "s-progress" }); + }); + + test("leaves an explicit stateId untouched, skips resolution, strips name keys", async () => { + const { gateway, getIssue } = makeGateway(); + + const result = await normalizeIssueUpdateStatePayload( + gateway, + "ANN-1", + { stateId: "explicit-uuid", state: "Todo", stateName: "Todo", title: "x" }, + "In Progress", + ); + + expect(result).toEqual({ stateId: "explicit-uuid", title: "x" }); + expect(getIssue).not.toHaveBeenCalled(); + }); + + test("resolves a state ref even when stateId is present but empty", async () => { + const { gateway } = makeGateway(); + + const result = await normalizeIssueUpdateStatePayload( + gateway, + "ANN-1", + { stateId: "" }, + "In Progress", + ); + + expect(result).toEqual({ stateId: "s-progress" }); + }); + + test("trims a whitespace-padded UUID --state and skips the fetch", async () => { + const uuid = "11111111-2222-3333-4444-555555555555"; + const { gateway, getIssue } = makeGateway(); + + const result = await normalizeIssueUpdateStatePayload(gateway, "ANN-1", {}, ` ${uuid} `); + + expect(result).toEqual({ stateId: uuid }); + expect(getIssue).not.toHaveBeenCalled(); + }); + + test("prefers the --state flag over `state`/`stateName` keys", async () => { + const { gateway } = makeGateway(); + + const result = await normalizeIssueUpdateStatePayload( + gateway, + "ANN-1", + { state: "Todo", stateName: "Todo" }, + "In Progress", + ); + + expect(result).toEqual({ stateId: "s-progress" }); + }); + + test("returns the payload unchanged and skips the fetch when no state is provided", async () => { + const { gateway, getIssue } = makeGateway(); + + const result = await normalizeIssueUpdateStatePayload( + gateway, + "ANN-1", + { title: "x" }, + undefined, + ); + + expect(result).toEqual({ title: "x" }); + expect(getIssue).not.toHaveBeenCalled(); + }); + + test("passes a UUID --state through without fetching the issue", async () => { + const uuid = "11111111-2222-3333-4444-555555555555"; + const { gateway, getIssue } = makeGateway(); + + const result = await normalizeIssueUpdateStatePayload(gateway, "ANN-1", {}, uuid); + + expect(result).toEqual({ stateId: uuid }); + expect(getIssue).not.toHaveBeenCalled(); + }); + + test("surfaces a listing error when the state name is unknown", async () => { + const { gateway } = makeGateway(); + + await expect(normalizeIssueUpdateStatePayload(gateway, "ANN-1", {}, "Bogus")).rejects.toThrow( + /In Progress \(started\)/, + ); + }); +}); diff --git a/packages/linear-core/src/entities/linear-gateway.ts b/packages/linear-core/src/entities/linear-gateway.ts index 5067ab0..e96e7b2 100644 --- a/packages/linear-core/src/entities/linear-gateway.ts +++ b/packages/linear-core/src/entities/linear-gateway.ts @@ -449,6 +449,7 @@ function toWorkflowState(record: SdkWorkflowStateLike): WorkflowStateRecord { description: record.description ?? undefined, type: record.type, color: record.color ?? undefined, + position: record.position, teamId: record.teamId, createdAt: toDateString(record.createdAt), updatedAt: toDateString(record.updatedAt), @@ -1157,6 +1158,16 @@ export class LinearGateway { }; } + public async listWorkflowStatesForTeam(teamId: string): Promise { + // A single page of 250 covers every realistic team (states number in the + // dozens), so name resolution reads them in one request without paging. + const connection = await this.client.workflowStates({ + filter: { team: { id: { eq: teamId } } }, + first: 250, + }); + return connection.nodes.map(toWorkflowState); + } + public async getWorkflowState(id: string): Promise { return toWorkflowState(await this.client.workflowState(id)); } diff --git a/packages/linear-core/src/entities/models.ts b/packages/linear-core/src/entities/models.ts index 06de308..7a0d72b 100644 --- a/packages/linear-core/src/entities/models.ts +++ b/packages/linear-core/src/entities/models.ts @@ -247,6 +247,7 @@ export interface WorkflowStateRecord { readonly description?: string; readonly type: string; readonly color?: string; + readonly position?: number; readonly teamId?: string; readonly createdAt: string; readonly updatedAt: string; diff --git a/packages/linear-core/src/entities/state-resolver.ts b/packages/linear-core/src/entities/state-resolver.ts new file mode 100644 index 0000000..bb432a8 --- /dev/null +++ b/packages/linear-core/src/entities/state-resolver.ts @@ -0,0 +1,94 @@ +import { LinearCoreError } from "../errors/core-error.js"; + +const WORKFLOW_STATE_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +export interface ResolvableWorkflowState { + readonly id: string; + readonly name: string; + readonly type: string; + readonly position?: number; +} + +export type ListTeamWorkflowStates = ( + teamId: string, +) => Promise; + +export interface ResolveStateIdOptions { + readonly preferredType?: string; +} + +export function isWorkflowStateId(reference: string): boolean { + return WORKFLOW_STATE_ID_PATTERN.test(reference); +} + +export async function resolveStateId( + teamId: string, + stateRef: string, + listStates: ListTeamWorkflowStates, + options: ResolveStateIdOptions = {}, +): Promise { + if (isWorkflowStateId(stateRef)) { + return stateRef; + } + + const states = await listStates(teamId); + const target = normalizeName(stateRef); + const nameMatches = states.filter((state) => normalizeName(state.name) === target); + + if (nameMatches.length > 1) { + throw new LinearCoreError( + "InvalidInput", + `Workflow state "${stateRef}" is ambiguous for this team. ${describeStates(states)}`, + { stateRef }, + ); + } + + const [exactMatch] = nameMatches; + if (exactMatch) { + return exactMatch.id; + } + + if (options.preferredType) { + const fallback = lowestPositionOfType(states, options.preferredType); + if (fallback) { + return fallback.id; + } + } + + throw new LinearCoreError( + "InvalidInput", + `Workflow state "${stateRef}" not found for this team. ${describeStates(states)}`, + { stateRef }, + ); +} + +function normalizeName(value: string): string { + return value.trim().toLowerCase(); +} + +function lowestPositionOfType( + states: readonly ResolvableWorkflowState[], + type: string, +): ResolvableWorkflowState | undefined { + let best: ResolvableWorkflowState | undefined; + for (const state of states) { + if (state.type !== type) { + continue; + } + // States without a position sort last so explicitly-positioned states always win. + const position = state.position ?? Number.POSITIVE_INFINITY; + const bestPosition = best?.position ?? Number.POSITIVE_INFINITY; + if (best === undefined || position < bestPosition) { + best = state; + } + } + return best; +} + +function describeStates(states: readonly ResolvableWorkflowState[]): string { + if (states.length === 0) { + return "This team has no workflow states."; + } + const list = states.map((state) => `${state.name} (${state.type})`).join(", "); + return `Valid states: ${list}.`; +} diff --git a/packages/linear-core/src/index.ts b/packages/linear-core/src/index.ts index a06e676..37ab4c2 100644 --- a/packages/linear-core/src/index.ts +++ b/packages/linear-core/src/index.ts @@ -6,6 +6,7 @@ export * from "./config/schema.js"; export * from "./entities/linear-gateway.js"; export * from "./entities/models.js"; export * from "./entities/sdk-types.js"; +export * from "./entities/state-resolver.js"; export * from "./errors/core-error.js"; export * from "./output/envelope.js"; export * from "./token-store/composite-store.js"; diff --git a/packages/linear-core/tests/state-resolver.test.ts b/packages/linear-core/tests/state-resolver.test.ts new file mode 100644 index 0000000..2df659a --- /dev/null +++ b/packages/linear-core/tests/state-resolver.test.ts @@ -0,0 +1,85 @@ +import type { ResolvableWorkflowState } from "@wiseiodev/linear-core"; +import { LinearCoreError, resolveStateId } from "@wiseiodev/linear-core"; +import { describe, expect, test, vi } from "vitest"; + +const STATES: readonly ResolvableWorkflowState[] = [ + { id: "s-backlog", name: "Backlog", type: "backlog", position: 0 }, + { id: "s-todo", name: "Todo", type: "unstarted", position: 1 }, + { id: "s-progress", name: "In Progress", type: "started", position: 2 }, + { id: "s-review", name: "In Review", type: "started", position: 3 }, + { id: "s-done", name: "Done", type: "completed", position: 4 }, +]; + +const listStates = async (): Promise => STATES; + +describe("resolveStateId", () => { + test("returns a UUID reference unchanged without listing states", async () => { + const uuid = "11111111-2222-3333-4444-555555555555"; + const list = vi.fn(async () => STATES); + + const result = await resolveStateId("team-1", uuid, list); + + expect(result).toBe(uuid); + expect(list).not.toHaveBeenCalled(); + }); + + test("matches an exact state name case-insensitively", async () => { + const result = await resolveStateId("team-1", "in progress", listStates); + expect(result).toBe("s-progress"); + }); + + test("scopes the lookup to the provided team id", async () => { + const list = vi.fn(async () => STATES); + + await resolveStateId("team-xyz", "Done", list); + + expect(list).toHaveBeenCalledWith("team-xyz"); + }); + + test("falls back to the lowest-position state of the preferred type", async () => { + const result = await resolveStateId("team-1", "Doing", listStates, { + preferredType: "started", + }); + + expect(result).toBe("s-progress"); + }); + + test("throws a LinearCoreError listing valid states when the name is unknown", async () => { + const error = await resolveStateId("team-1", "Nope", listStates).catch((thrown) => thrown); + + expect(error).toBeInstanceOf(LinearCoreError); + expect((error as LinearCoreError).code).toBe("InvalidInput"); + expect((error as LinearCoreError).message).toContain("In Progress (started)"); + expect((error as LinearCoreError).message).toContain("not found"); + }); + + test("preferred-type fallback ignores states without a position", async () => { + const mixed: readonly ResolvableWorkflowState[] = [ + { id: "no-pos", name: "Started A", type: "started" }, + { id: "pos-5", name: "Started B", type: "started", position: 5 }, + ]; + + const result = await resolveStateId("team-1", "Doing", async () => mixed, { + preferredType: "started", + }); + + expect(result).toBe("pos-5"); + }); + + test("throws when no name match and the preferred type is absent", async () => { + await expect( + resolveStateId("team-1", "Doing", listStates, { preferredType: "triage" }), + ).rejects.toThrow(/not found/); + }); + + test("throws an ambiguity error when multiple states share the name", async () => { + const ambiguous: readonly ResolvableWorkflowState[] = [ + { id: "a", name: "In Progress", type: "started", position: 1 }, + { id: "b", name: "in progress", type: "started", position: 2 }, + ]; + + await expect(resolveStateId("team-1", "In Progress", async () => ambiguous)).rejects.toThrow( + /ambiguous/, + ); + }); +}); From 7d9c140ffe5a6df11e34ffea06633553bf02b908 Mon Sep 17 00:00:00 2001 From: Daniel Wise Date: Wed, 27 May 2026 23:16:33 -0700 Subject: [PATCH 2/3] chore(report): add work report for issue #22 Refs: #22 --- .reports/issue-22.html | 443 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 443 insertions(+) create mode 100644 .reports/issue-22.html diff --git a/.reports/issue-22.html b/.reports/issue-22.html new file mode 100644 index 0000000..43abd83 --- /dev/null +++ b/.reports/issue-22.html @@ -0,0 +1,443 @@ + + + + + + issue #22 — State-by-name resolver + issues update + + + + + +
+
+
+
GitHub issue · #22 (parent #21)
+

issue #22 · State-by-name resolver + issues update

+
+
+
2026-05-27
+
wiseiodev/linear-cli
+
+
+ +
+

What shipped

+
Plain-language summary of what landed in this slice.
+
    +
  • A reusable resolveStateId module in @wiseiodev/linear-core that maps a state reference (a UUID or a human name) to a stateId for a team, with an optional preferred-type fallback for later slices.
  • +
  • linear issues update ANN-1 --state "In Progress" now works — no --input required.
  • +
  • --input '{"state":"In Progress"}' and --input '{"stateName":"In Progress"}' resolve to the right stateId too.
  • +
  • Resolution is scoped to the target issue's team (the issue is fetched to read its teamId); a raw stateId UUID still passes through unchanged.
  • +
  • Unknown or ambiguous state names fail with a typed error that lists the team's valid states as name (type); state/stateName keys are stripped before the payload reaches GraphQL.
  • +
  • New listWorkflowStatesForTeam gateway method (server-side team filter) and a position field on WorkflowStateRecord.
  • +
+
+ +
+

Dependencies collected

+
External values, credentials, or assets provided before implementation started.
+ + + + + +
DependencyValue / status
External dependenciesNone detected — pure code change in an existing CLI/SDK monorepo.
+
+ +
+

Files changed

+
From git show --stat.
+
 .reports/issue-22-qa.md                            | 116 +++++++++++++++
+ packages/cli/src/commands/input.ts                 |   8 ++
+ packages/cli/src/commands/issue-state.ts           |  76 ++++++++++
+ packages/cli/src/commands/resource.ts              |  11 +-
+ packages/cli/src/help/resource-help.ts             |   2 +
+ packages/cli/src/index.ts                          |  19 ++-
+ packages/cli/tests/issue-state.test.ts             | 155 +++++++++++++++++++++
+ packages/linear-core/src/entities/linear-gateway.ts |  11 ++
+ packages/linear-core/src/entities/models.ts        |   1 +
+ packages/linear-core/src/entities/state-resolver.ts |  94 +++++++++++++
+ packages/linear-core/src/index.ts                  |   1 +
+ packages/linear-core/tests/state-resolver.test.ts  |  85 +++++++++++
+ 12 files changed, 573 insertions(+), 6 deletions(-)
+
+ +
+

Tests added

+
New automated cases landing with this work item.
+ + + + + + +
FileCasesFramework
packages/linear-core/tests/state-resolver.test.ts8Vitest
packages/cli/tests/issue-state.test.ts10Vitest
+
+ +
+

Quality gates

+
All must pass with zero errors and zero warnings before commit.
+ + + + + + + + + +
GateCommandStatus
Lint / formatpnpm check:write (biome)Pass
Typecheckpnpm typecheck (turbo, 4 pkgs)Pass
Unit testspnpm test — core 58, cli 60Pass
Buildpnpm build (turbo, 4 pkgs)Pass
E2ENo E2E suite in this repoN/A
+
+ +
+

Adversarial review

+
Two-reviewer debate (Architect + Skeptic), iterated until no critical or major findings remain.
+
+
+
Iterations
+
2
+
+
+
Critical / Major
+
0 / 0
+
+
+
Minor
+
0
+
+
+
Nitpick
+
0
+
+
+
+
    +
  • Round 1 raised 2 "major" findings, both resolved by scope/PRD reasoning: (a) reusing the global --state as the setter on update is PRD-mandated ("no new flag required") — the update help now documents the setter usage; (b) issues bulk-update state-by-name is owned by sibling issue #27; this PR does not touch or regress it.
  • +
  • 5 minor/nitpick findings fixed in code: undefined position now sorts last in the type fallback; only a non-empty stateId pre-empts resolution (empty/blank no longer leaks); asString trims so padded UUIDs short-circuit; explanatory comments added for the UUID short-circuit and the 250-state page cap.
  • +
  • Round 2 confirmed all three code fixes correct, no regressions, and zero remaining critical/major findings.
  • +
+
+
+ +
+ work · issue #22 + Page 1 of 2 +
+
+ + +
+
+
+
Proof of work
+

Self-QA · issue #22

+
+
+
2026-05-27
+
2328090
+
+
+ +
+

Self-QA evidence (no video)

+
This is a terminal CLI / SDK change with no browser surface, so a Playwright video is not applicable.
+
+

Verification is documented in issue-22-qa.md: deterministic unit tests (18 new cases), a runtime demonstration against the built @wiseiodev/linear-core module, and a CLI help smoke test.

+

Open issue-22-qa.md

+
+

Fallback QA record — see the linked markdown for command transcripts and asserted outputs.

+
+ +
+

Scenarios covered

+
Golden path and at least one edge case per acceptance criterion.
+
    +
  • UUID reference passes through unchanged without any team lookup (runtime demo + unit test).
  • +
  • Case-insensitive exact name match ("in progress" → the In Progress state).
  • +
  • Preferred-type fallback picks the lowest-position state; position-less states sort last.
  • +
  • Unknown name → typed InvalidInput error listing valid states as name (type).
  • +
  • Ambiguous name (two states share a name) → typed error.
  • +
  • --state flag, state key, and stateName key all fold into stateId scoped to the issue's team.
  • +
  • Explicit stateId left untouched and resolution skipped (no issue fetch); empty stateId no longer pre-empts --state.
  • +
  • Whitespace-padded UUID --state short-circuits without a fetch; no state provided → payload unchanged, no fetch.
  • +
+
+ +
+

Acceptance criteria

+
Every box ticked before the commit was made.
+
    +
  • issues update <id> --state "<name>" sets the issue to the matching state
  • +
  • --input '{"stateName":"<name>"}' and --input '{"state":"<name>"}' resolve to a stateId
  • +
  • --input '{"stateId":"<uuid>"}' continues to work unchanged
  • +
  • Resolution is scoped to the target issue's team
  • +
  • Unknown/ambiguous name errors with the team's valid state names listed
  • +
  • state/stateName keys never reach GraphQL
  • +
  • Resolver unit tests cover UUID passthrough, name match, type fallback, not-found, ambiguous
  • +
  • issues update normalization is tested (flag + JSON-key paths, stateId untouched)
  • +
+
+ +
+

Commit

+
Local commit on the feature branch; pushed when the PR was opened.
+

2328090 232809087720ffd05afc39a39a43bba672957b3c

+
feat(cli): resolve issue workflow state by name on issues update
+
+Add a reusable workflow-state resolver in linear-core that maps a state
+reference (UUID or human name) to a stateId for a team, and wire it into
+`issues update` so agents can set state with `--state "In Progress"` or
+`--input '{"state":"..."}'`/`'{"stateName":"..."}'`. Resolution is scoped
+to the target issue's team; an explicit stateId UUID still passes through
+unchanged; state/stateName keys are stripped before reaching GraphQL.
+Unknown or ambiguous names error with the team's valid states listed.
+
+Refs: #22
+
+ +
+ work · issue #22 + Page 2 of 2 +
+
+ + + From d50dd611cc6b32b9d302b9f14c3ff6702da06dd2 Mon Sep 17 00:00:00 2001 From: Daniel Wise Date: Thu, 28 May 2026 07:49:00 -0700 Subject: [PATCH 3/3] docs(cli): document state-by-name across help, skill, and README Surface the new state-by-name capability where agents will see it: a "Set Issue State By Name" section plus a "common mistakes -> correct command" table in the bundled linear-cli skill, a setter-guidance block in `issues update --help` (new optional notes on update help), a README example, and concrete state-by-name hints in the issue-triage and cycle-planning skills. Locked by a skills-catalog test assertion. Refs: #22 --- .reports/issue-22-qa.md | 14 +++++++ .reports/issue-22.html | 1 + README.md | 1 + assets/skills/cycle-planning/SKILL.md | 7 ++++ assets/skills/issue-triage/SKILL.md | 7 ++++ assets/skills/linear-cli/SKILL.md | 37 +++++++++++++++++++ packages/cli/src/help/resource-help.ts | 10 ++++- .../tests/skills-catalog.test.ts | 19 ++++++++++ 8 files changed, 95 insertions(+), 1 deletion(-) diff --git a/.reports/issue-22-qa.md b/.reports/issue-22-qa.md index 0413760..a188db4 100644 --- a/.reports/issue-22-qa.md +++ b/.reports/issue-22-qa.md @@ -107,6 +107,20 @@ Runtime demonstration against the built `@wiseiodev/linear-core` module: CLI help smoke test (`node packages/cli/dist/bin/linear.js issues update --help`) confirms the `update` command exists and exposes the `--state ` global option. +## Docs & agent hints + +Because the CLI's primary users are coding agents, this slice also ships the guidance for the +feature it adds: + +- `assets/skills/linear-cli/SKILL.md` — new "Set Issue State By Name" section and a "Common + Mistakes → correct command" table; locked by a new skills-catalog test assertion. +- `issues update --help` — a setter-guidance block (verified via `linear issues update --help`): + set state by name, unknown names list valid states, raw stateId used as-is, discover with + `linear states list --json`. +- `README.md` — an `issues update --state "In Progress"` example in the command list. +- `assets/skills/issue-triage/SKILL.md` and `assets/skills/cycle-planning/SKILL.md` — a concrete + state-by-name command hint where each playbook applies its decision. + ## Follow-up flag End-to-end execution against the live Linear API (real auth + a real issue) was not run because diff --git a/.reports/issue-22.html b/.reports/issue-22.html index 43abd83..dca7710 100644 --- a/.reports/issue-22.html +++ b/.reports/issue-22.html @@ -268,6 +268,7 @@

What shipped

  • Resolution is scoped to the target issue's team (the issue is fetched to read its teamId); a raw stateId UUID still passes through unchanged.
  • Unknown or ambiguous state names fail with a typed error that lists the team's valid states as name (type); state/stateName keys are stripped before the payload reaches GraphQL.
  • New listWorkflowStatesForTeam gateway method (server-side team filter) and a position field on WorkflowStateRecord.
  • +
  • Agent-facing guidance shipped with the feature: a "Set Issue State By Name" + "Common Mistakes" section in the bundled linear-cli skill, a setter-guidance block in issues update --help, a README example, and state-by-name hints in the issue-triage/cycle-planning skills.
  • diff --git a/README.md b/README.md index 260c5a6..1602d85 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,7 @@ linear issues list --limit 10 linear issues branch ANN-123 --json linear issues browse linear issues create --input '{"title":"Investigate bug","teamId":""}' +linear issues update ANN-123 --state "In Progress" # set state by name, no stateId needed linear projects list linear documents list diff --git a/assets/skills/cycle-planning/SKILL.md b/assets/skills/cycle-planning/SKILL.md index a98cd5c..498f392 100644 --- a/assets/skills/cycle-planning/SKILL.md +++ b/assets/skills/cycle-planning/SKILL.md @@ -10,3 +10,10 @@ Checklist: 2. Group candidate issues by objective. 3. Flag blocked work and missing dependencies. 4. Produce a recommended cycle scope with rationale. +5. When committing the plan, move issues by state name (no stateId lookup needed); the name resolves against each issue's team: + + ```bash + linear issues update --state "Todo" --json + ``` + + An unknown state name lists the team's valid states. Discover names with `linear states list --json`. diff --git a/assets/skills/issue-triage/SKILL.md b/assets/skills/issue-triage/SKILL.md index 19e4c43..3a78bb0 100644 --- a/assets/skills/issue-triage/SKILL.md +++ b/assets/skills/issue-triage/SKILL.md @@ -10,3 +10,10 @@ Checklist: 2. Validate priority against impact and urgency. 3. Suggest assignee and timeline. 4. Propose labels, cycle placement, and dependencies. +5. Apply the triage decision in one call. Set state by name (no stateId lookup needed); the name resolves against the issue's team: + + ```bash + linear issues update --state "Todo" --input '{"priority":2}' --json + ``` + + An unknown state name lists the team's valid states. Discover names with `linear states list --json`. diff --git a/assets/skills/linear-cli/SKILL.md b/assets/skills/linear-cli/SKILL.md index ef50d9c..bba4a6f 100644 --- a/assets/skills/linear-cli/SKILL.md +++ b/assets/skills/linear-cli/SKILL.md @@ -80,6 +80,43 @@ linear issues update --help linear docs ``` +## Set Issue State By Name + +You do not need a `stateId` UUID to change an issue's state. Pass the state name and the CLI resolves it against the target issue's team: + +```bash +linear issues update ENG-123 --state "In Progress" --json +linear issues update ENG-123 --input '{"state":"In Progress"}' --json +linear issues update ENG-123 --input '{"stateName":"In Progress"}' --json +``` + +All three are equivalent. A raw `stateId` UUID still works and is used as-is: + +```bash +linear issues update ENG-123 --input '{"stateId":""}' --json +``` + +Notes: + +- Resolution is scoped to the issue's own team, so the same name (e.g. "In Progress") maps to the right team's state. +- The match is case-insensitive and ignores surrounding whitespace. +- An unknown or ambiguous name fails with an error that lists the team's valid states as `name (type)`, so you can correct it in one step instead of guessing. +- To discover the exact names yourself, list the workflow states: + +```bash +linear states list --json +``` + +## Common Mistakes + +These are the patterns agents reach for that do not work, with the command that does: + +| Goal | Do not use | Use instead | +| --- | --- | --- | +| Set an issue's state | Hand-crafting or guessing a `stateId` UUID | `linear issues update --state "In Progress"` (or `--input '{"state":"In Progress"}'`; the name resolves automatically) | +| Discover workflow states | `linear statuses`, `linear workflow-states`, `linear list-states` | `linear states list --json` | +| Read one issue | `linear issues view ` / `linear issues show ` | `linear issues get --json` | + ## Batch Workflows Use `bulk-update` for multi-issue updates. Start with `--dry-run` and inspect the per-issue result before writing: diff --git a/packages/cli/src/help/resource-help.ts b/packages/cli/src/help/resource-help.ts index 921b90b..e24eea8 100644 --- a/packages/cli/src/help/resource-help.ts +++ b/packages/cli/src/help/resource-help.ts @@ -87,6 +87,7 @@ interface CreateHelp { interface UpdateHelp { readonly examples: readonly string[]; + readonly notes?: readonly string[]; } interface ListHelp { @@ -132,6 +133,12 @@ const resourceExamples: Record = { ], }, update: { + notes: [ + 'Set workflow state by name (no stateId needed): --state "In Progress",', + 'or include "state"/"stateName" in --input. Resolved against the issue\'s team.', + "An unknown or ambiguous name lists the team's valid states; a raw stateId is used as-is.", + "Discover names with: linear states list --json", + ], examples: [ "linear issues update --input '{\"priority\":2}'", 'linear issues update --state "In Progress"', @@ -422,7 +429,8 @@ function createHelp(entity: string, help: CreateHelp): string { } function updateHelp(entity: string, help: UpdateHelp): string { - return `\nUpdate accepts any non-empty JSON payload.\n\nExamples:\n${joinExamples(help.examples)}\n\n${inputDocsHint(entity)}`; + const notes = help.notes && help.notes.length > 0 ? `\n\n${indentLines(help.notes)}` : ""; + return `\nUpdate accepts any non-empty JSON payload.${notes}\n\nExamples:\n${joinExamples(help.examples)}\n\n${inputDocsHint(entity)}`; } export interface ResourceHelpTexts { diff --git a/packages/skills-catalog/tests/skills-catalog.test.ts b/packages/skills-catalog/tests/skills-catalog.test.ts index 275b532..13148bd 100644 --- a/packages/skills-catalog/tests/skills-catalog.test.ts +++ b/packages/skills-catalog/tests/skills-catalog.test.ts @@ -84,6 +84,25 @@ describe("skills catalog", () => { expect(content).toContain("Fixes ENG-123, DES-5 and ENG-256"); }); + test("documents setting issue state by name", async () => { + const skill = getSkill("linear-cli"); + if (!skill) { + throw new Error("Expected linear-cli skill in catalog"); + } + + const repoRelativePath = skill.repoPath.replace("wiseiodev/linear-cli/", ""); + const content = await readFile( + new URL(`../../../${repoRelativePath}/SKILL.md`, import.meta.url), + "utf8", + ); + + expect(content).toContain("## Set Issue State By Name"); + expect(content).toContain('linear issues update ENG-123 --state "In Progress" --json'); + expect(content).toContain('linear issues update ENG-123 --input \'{"state":"In Progress"}\''); + expect(content).toContain("linear states list --json"); + expect(content).toContain("## Common Mistakes"); + }); + test("fails gracefully for unknown skill", async () => { const executor = new StubExecutor(); const result = await installSkill("not-real", executor);