From 40d3093b53f1e9ddfe4ca9e09aaf917ffc4de095 Mon Sep 17 00:00:00 2001 From: Daniel Wise Date: Sat, 30 May 2026 11:54:48 -0700 Subject: [PATCH] feat: complete agent issue UX --- README.md | 7 +- assets/skills/cycle-planning/SKILL.md | 3 +- assets/skills/issue-triage/SKILL.md | 8 +- assets/skills/linear-cli/SKILL.md | 39 ++- packages/cli/src/commands/issue-state.ts | 86 +++++++ packages/cli/src/commands/issue-workflow.ts | 145 +++++++++++ .../cli/src/commands/issues-bulk-update.ts | 132 ++++++---- packages/cli/src/commands/resource.ts | 31 ++- packages/cli/src/help/resource-help.ts | 8 +- packages/cli/src/help/root-help.ts | 10 +- packages/cli/src/index.ts | 172 ++++++++++++- packages/cli/tests/agent-ux-commands.test.ts | 227 ++++++++++++++++++ packages/cli/tests/help.test.ts | 26 +- packages/cli/tests/issue-state.test.ts | 97 ++++++++ packages/cli/tests/issue-workflow.test.ts | 141 +++++++++++ packages/cli/tests/issues-bulk-update.test.ts | 43 ++++ .../src/entities/linear-gateway.ts | 9 +- packages/linear-core/src/types/public.ts | 1 + .../linear-core/tests/linear-gateway.test.ts | 20 ++ .../linear-core/tests/state-resolver.test.ts | 5 +- .../tests/skills-catalog.test.ts | 12 +- 21 files changed, 1145 insertions(+), 77 deletions(-) create mode 100644 packages/cli/src/commands/issue-workflow.ts create mode 100644 packages/cli/tests/agent-ux-commands.test.ts create mode 100644 packages/cli/tests/issue-workflow.test.ts diff --git a/README.md b/README.md index 1602d85..934f032 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ pnpm verify ```bash pnpm --filter @wiseiodev/linear-cli dev --help -pnpm --filter @wiseiodev/linear-cli dev issues list --json +pnpm --filter @wiseiodev/linear-cli dev issues list --limit 10 --json ``` ## Help Discovery @@ -67,7 +67,12 @@ linear issues list --limit 10 linear issues branch ANN-123 --json linear issues browse linear issues create --input '{"title":"Investigate bug","teamId":""}' +linear issues create --state "Todo" --input '{"title":"Investigate bug","teamId":""}' linear issues update ANN-123 --state "In Progress" # set state by name, no stateId needed +linear issues bulk-update --ids ANN-123,ANN-124 --state "In Progress" --dry-run --json +linear comments list --issue ANN-123 --json +linear prep ANN-123 --json +linear pr-ready ANN-123 --pr https://github.com/org/repo/pull/123 --json linear projects list linear documents list diff --git a/assets/skills/cycle-planning/SKILL.md b/assets/skills/cycle-planning/SKILL.md index 498f392..cbc34fa 100644 --- a/assets/skills/cycle-planning/SKILL.md +++ b/assets/skills/cycle-planning/SKILL.md @@ -10,10 +10,11 @@ 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: +5. When committing the plan, move issues by state name (no stateId lookup needed); the name resolves against each issue's team. Use bulk-update when changing more than one issue: ```bash linear issues update --state "Todo" --json + linear issues bulk-update --ids ENG-123,ENG-124 --state "Todo" --dry-run --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 3a78bb0..58a8714 100644 --- a/assets/skills/issue-triage/SKILL.md +++ b/assets/skills/issue-triage/SKILL.md @@ -6,7 +6,13 @@ description: Agent playbook for Linear issue triage and prioritization When invoked, review issue details, assign priority, suggest owner, and propose next action. Checklist: -1. Confirm issue scope and acceptance criteria. +1. Confirm issue scope, acceptance criteria, and discussion: + + ```bash + linear issues get --json + linear comments list --issue --json + ``` + 2. Validate priority against impact and urgency. 3. Suggest assignee and timeline. 4. Propose labels, cycle placement, and dependencies. diff --git a/assets/skills/linear-cli/SKILL.md b/assets/skills/linear-cli/SKILL.md index bba4a6f..22267fc 100644 --- a/assets/skills/linear-cli/SKILL.md +++ b/assets/skills/linear-cli/SKILL.md @@ -28,11 +28,14 @@ Read the branch from `.data.branchName`. ## Common Issue Commands ```bash -linear issues list --json +linear issues list --limit 25 --json linear issues get --json linear issues create --input '{"teamId":"","title":"Investigate issue"}' --json linear issues update --input '{"priority":2}' --json linear issues branch --json +linear comments list --issue --json +linear prep --json +linear pr-ready --pr --json ``` For list workflows, use filters instead of broad scans when possible: @@ -45,7 +48,7 @@ linear issues list --query "search text" --json linear issues list --updated-after 2026-05-01 --json linear issues list --created-after -P7D --json linear issues list --parent ENG-123 --json -linear issues list --no-parent --json +linear issues list --team ENG --no-parent --json ``` Use pagination flags for bounded queries: @@ -62,6 +65,8 @@ Issue creation requires `teamId` plus either `title` or a template: ```bash linear issues create --input '{"teamId":"","title":"New issue title"}' --json +linear issues create --state "Todo" --input '{"teamId":"","title":"New issue title"}' --json +linear issues create --input '{"teamId":"","title":"New issue title","state":"Todo"}' --json linear issues create --template "Bug Report" --input '{"teamId":""}' --json ``` @@ -88,6 +93,8 @@ You do not need a `stateId` UUID to change an issue's state. Pass the state name 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 +linear issues create --state "Todo" --input '{"teamId":"","title":"New issue"}' --json +linear issues bulk-update --ids ENG-123,ENG-124 --state "In Progress" --dry-run --json ``` All three are equivalent. A raw `stateId` UUID still works and is used as-is: @@ -114,8 +121,12 @@ These are the patterns agents reach for that do not work, with the command that | 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` | +| Discover workflow states | Guessing `list-states` or parsing team payloads | `linear states list --json` (`statuses` and `workflow-states` are accepted aliases) | +| Read one issue | Treating an alias as the canonical docs target | `linear issues get --json` (`view` and `show` also work) | +| Read issue discussion | `linear comments list` with a broad scan | `linear comments list --issue --json` | +| List issues | `linear issues list --all --json` with no filters | Add `--mine`, `--team`, `--state`, `--query`, or `--limit` | +| Start work | Multiple commands to fetch context, branch, and set state | `linear prep --json` | +| Mark PR ready | Separate update plus hand-written PR comment | `linear pr-ready --pr --json` | ## Batch Workflows @@ -124,10 +135,28 @@ Use `bulk-update` for multi-issue updates. Start with `--dry-run` and inspect th ```bash linear issues bulk-update --ids ENG-123,ENG-124 --input '{"priority":2}' --dry-run --json linear issues bulk-update --ids ENG-123,ENG-124 --input '{"priority":2}' --json +linear issues bulk-update --ids ENG-123,ENG-124 --state "In Progress" --dry-run --json linear issues bulk-update --input-file updates.json --dry-run --json ``` -For per-issue input files, use an array where each object includes the target issue id or identifier plus update fields. +For per-issue input files, use an array where each object includes the target issue id or identifier plus update fields. A shared `--state` applies to every item and resolves per issue team; per-item `state` or `stateName` also works. + +## Prep And PR Ready + +Use `prep` when starting work. It fetches the issue, parent or project context, branch name, and moves the issue to the team's in-progress state: + +```bash +linear prep ENG-123 --json +linear prep ENG-123 --state "Doing" --json +``` + +Use `pr-ready` when the branch is ready for review. It moves the issue to `In Review` unless you override the state, and comments only when you ask for one: + +```bash +linear pr-ready ENG-123 --json +linear pr-ready ENG-123 --pr https://github.com/org/repo/pull/123 --json +linear pr-ready ENG-123 --comment "Ready for review" --json +``` ## Linear Linking In GitHub diff --git a/packages/cli/src/commands/issue-state.ts b/packages/cli/src/commands/issue-state.ts index 73bb068..e1e61e2 100644 --- a/packages/cli/src/commands/issue-state.ts +++ b/packages/cli/src/commands/issue-state.ts @@ -46,6 +46,18 @@ async function resolveStateRef( ); } +async function resolveStateRefForTeam( + gateway: IssueStateGateway, + teamId: string, + stateRef: string, +): Promise { + if (isWorkflowStateId(stateRef)) { + return stateRef; + } + + return resolveStateId(teamId, stateRef, (id) => gateway.listWorkflowStatesForTeam(id)); +} + /** * 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` @@ -74,3 +86,77 @@ export async function normalizeIssueUpdateStatePayload( const stateId = await resolveStateRef(gateway, issueId, stateRef); return { ...rest, stateId }; } + +export async function normalizeIssueCreateStatePayload( + gateway: IssueStateGateway, + payload: Record, + stateFlag: string | undefined, +): Promise> { + const { state, stateName, ...rest } = payload; + + if (typeof rest.stateId === "string" && rest.stateId.trim().length > 0) { + return rest; + } + + const stateRef = pickStateRef(stateFlag, state, stateName); + if (stateRef === undefined) { + return rest; + } + + if (typeof rest.teamId !== "string" || rest.teamId.trim().length === 0) { + throw new LinearCoreError( + "InvalidInput", + `Cannot resolve state "${stateRef}" for issue create because teamId is missing.`, + ); + } + + const stateId = await resolveStateRefForTeam(gateway, rest.teamId, stateRef); + return { ...rest, stateId }; +} + +export async function normalizeIssueBulkUpdateStatePayloads( + gateway: IssueStateGateway, + items: readonly (T & { payload: Record })[], + stateFlag: string | undefined, +): Promise }>> { + const stateCache = new Map>(); + + const normalizeItem = async ( + item: T & { payload: Record }, + ): Promise }> => { + const { state, stateName, ...rest } = item.payload; + + if (typeof rest.stateId === "string" && rest.stateId.trim().length > 0) { + return { ...item, payload: rest }; + } + + const stateRef = pickStateRef(stateFlag, state, stateName); + if (stateRef === undefined) { + return { ...item, payload: rest }; + } + + if (isWorkflowStateId(stateRef)) { + return { ...item, payload: { ...rest, stateId: stateRef } }; + } + + const issue = await gateway.getIssue(item.id); + if (!issue.teamId) { + throw new LinearCoreError( + "InvalidInput", + `Cannot resolve state "${stateRef}" because issue ${issue.identifier} has no team.`, + ); + } + + const cacheKey = `${issue.teamId}\n${stateRef.trim().toLowerCase()}`; + let stateIdPromise = stateCache.get(cacheKey); + if (!stateIdPromise) { + stateIdPromise = resolveStateRefForTeam(gateway, issue.teamId, stateRef); + stateCache.set(cacheKey, stateIdPromise); + } + const stateId = await stateIdPromise; + + return { ...item, payload: { ...rest, stateId } }; + }; + + return Promise.all(items.map((item) => normalizeItem(item))); +} diff --git a/packages/cli/src/commands/issue-workflow.ts b/packages/cli/src/commands/issue-workflow.ts new file mode 100644 index 0000000..1776d15 --- /dev/null +++ b/packages/cli/src/commands/issue-workflow.ts @@ -0,0 +1,145 @@ +import type { + CommentRecord, + IssueRecord, + ProjectRecord, + ResolvableWorkflowState, + SdkCommentInput, + SdkIssueUpdateInput, +} from "@wiseiodev/linear-core"; +import { LinearCoreError, resolveStateId } from "@wiseiodev/linear-core"; + +export interface IssueWorkflowGateway { + getIssue(id: string): Promise; + getProject(id: string): Promise; + getIssueBranchName(id: string): Promise<{ + readonly id: string; + readonly identifier: string; + readonly branchName: string; + readonly url: string; + }>; + listWorkflowStatesForTeam(teamId: string): Promise; + updateIssue(id: string, input: SdkIssueUpdateInput): Promise; + createComment(input: SdkCommentInput): Promise; +} + +export interface WorkflowStateSummary { + readonly id: string; + readonly name: string; + readonly type: string; +} + +export type IssueContext = + | { readonly type: "parent"; readonly issue: IssueRecord } + | { readonly type: "project"; readonly project: ProjectRecord } + | null; + +export interface PrepResult { + readonly issue: IssueRecord; + readonly context: IssueContext; + readonly branchName: string; + readonly state: WorkflowStateSummary; +} + +export interface PrReadyResult { + readonly issue: IssueRecord; + readonly state: WorkflowStateSummary; + readonly comment?: CommentRecord; +} + +function requireTeam(issue: IssueRecord): string { + if (!issue.teamId) { + throw new LinearCoreError( + "InvalidInput", + `Cannot change state for ${issue.identifier} because it has no team.`, + ); + } + return issue.teamId; +} + +function findState( + states: readonly ResolvableWorkflowState[], + stateId: string, +): WorkflowStateSummary { + const state = states.find((candidate) => candidate.id === stateId); + if (!state) { + return { id: stateId, name: stateId, type: "unknown" }; + } + return { id: state.id, name: state.name, type: state.type }; +} + +async function resolveStateSummary( + gateway: IssueWorkflowGateway, + teamId: string, + stateRef: string, + options: { readonly preferredType?: string } = {}, +): Promise { + const states = await gateway.listWorkflowStatesForTeam(teamId); + const stateId = await resolveStateId(teamId, stateRef, async () => states, options); + return findState(states, stateId); +} + +async function loadContext( + gateway: IssueWorkflowGateway, + issue: IssueRecord, +): Promise { + if (issue.parentId) { + return { type: "parent", issue: await gateway.getIssue(issue.parentId) }; + } + if (issue.projectId) { + return { type: "project", project: await gateway.getProject(issue.projectId) }; + } + return null; +} + +export async function runPrep( + gateway: IssueWorkflowGateway, + issueRef: string, + stateOverride?: string, +): Promise { + const issue = await gateway.getIssue(issueRef); + const teamId = requireTeam(issue); + const state = await resolveStateSummary(gateway, teamId, stateOverride ?? "In Progress", { + ...(stateOverride ? {} : { preferredType: "started" }), + }); + const [context, branch] = await Promise.all([ + loadContext(gateway, issue), + gateway.getIssueBranchName(issueRef), + ]); + const updated = await gateway.updateIssue(issueRef, { stateId: state.id }); + + return { + issue: updated, + context, + branchName: branch.branchName, + state, + }; +} + +function buildPrComment(comment: string | undefined, pr: string | undefined): string | undefined { + if (!comment && !pr) { + return undefined; + } + if (comment && pr) { + return `${comment}\n\nPR: ${pr}`; + } + return comment ?? `PR: ${pr}`; +} + +export async function runPrReady( + gateway: IssueWorkflowGateway, + issueRef: string, + options: { readonly state?: string; readonly comment?: string; readonly pr?: string }, +): Promise { + const issue = await gateway.getIssue(issueRef); + const teamId = requireTeam(issue); + const state = await resolveStateSummary(gateway, teamId, options.state ?? "In Review"); + const updated = await gateway.updateIssue(issueRef, { stateId: state.id }); + const body = buildPrComment(options.comment, options.pr); + const comment = body ? await gateway.createComment({ issueId: issue.id, body }) : undefined; + + return { + issue: updated, + state, + ...(comment ? { comment } : {}), + }; +} diff --git a/packages/cli/src/commands/issues-bulk-update.ts b/packages/cli/src/commands/issues-bulk-update.ts index 474e0a6..cbbe598 100644 --- a/packages/cli/src/commands/issues-bulk-update.ts +++ b/packages/cli/src/commands/issues-bulk-update.ts @@ -10,6 +10,7 @@ import { renderEnvelope } from "../formatters/output.js"; import { getGlobalOptions } from "../runtime/options.js"; import { parseJsonInput } from "./input.js"; import { isIssueUpdateInput } from "./issue-guards.js"; +import { normalizeIssueBulkUpdateStatePayloads } from "./issue-state.js"; export interface BulkUpdateRawOptions { readonly ids?: string; @@ -114,6 +115,7 @@ async function readStdin(stdin: StdinLike): Promise { export async function readBulkInput( options: BulkUpdateRawOptions, stdin: StdinLike = process.stdin, + stateFlag?: string, ): Promise { if (options.input === "-") { if (options.inputFile) { @@ -127,6 +129,9 @@ export async function readBulkInput( } if (!options.input && !options.inputFile) { + if (stateFlag) { + return {}; + } throw new Error("Missing input. Provide --input, --input-file, or --input -."); } @@ -138,10 +143,11 @@ export async function readBulkInput( export async function parseBulkUpdateInput( options: BulkUpdateRawOptions, + stateFlag?: string, ): Promise { const dryRun = options.dryRun === true; const concurrency = parseConcurrency(options.concurrency); - const parsed = await readBulkInput(options); + const parsed = await readBulkInput(options, process.stdin, stateFlag); if (Array.isArray(parsed)) { if (options.ids) { @@ -175,10 +181,10 @@ export async function parseBulkUpdateInput( if (ids.length === 0) { throw new Error("--ids was empty."); } - if (!isIssueUpdateInput(parsed)) { + if (!isIssueUpdateInput(parsed) && !stateFlag) { throw new Error("Issue update payload must be a non-empty object."); } - const sharedPayload = parsed; + const sharedPayload = isRecord(parsed) ? parsed : {}; return { dryRun, concurrency, @@ -188,6 +194,7 @@ export async function parseBulkUpdateInput( interface BulkGateway { getIssue(id: string): Promise; + listWorkflowStatesForTeam: LinearGateway["listWorkflowStatesForTeam"]; updateIssue(id: string, input: SdkIssueUpdateInput): Promise; } @@ -262,6 +269,28 @@ export async function runBulkUpdate( }; } +export async function normalizeBulkUpdateInput( + gateway: BulkGateway, + parsed: ParsedBulkUpdate, + stateFlag: string | undefined, +): Promise { + const normalizedItems = await normalizeIssueBulkUpdateStatePayloads( + gateway, + parsed.items.map((item) => ({ ...item, payload: item.payload as Record })), + stateFlag, + ); + + return { + ...parsed, + items: normalizedItems.map((item) => { + if (!isIssueUpdateInput(item.payload)) { + throw new Error(`Issue update payload for ${item.id} must be a non-empty object.`); + } + return { id: item.id, payload: item.payload }; + }), + }; +} + export function exitCodeForBulk(data: BulkUpdateData): number { if (data.failed === 0) { return 0; @@ -273,7 +302,7 @@ export function exitCodeForBulk(data: BulkUpdateData): number { } export function registerIssuesBulkUpdate(issuesCommand: Command, authManager: AuthManager): void { - issuesCommand + const command = issuesCommand .command("bulk-update") .description( "Apply updates to many issues in one pass. Linear only updates fields present in the payload; relations, labels, and comments stay intact unless removal fields are supplied.", @@ -288,46 +317,61 @@ export function registerIssuesBulkUpdate(issuesCommand: Command, authManager: Au .option( "--concurrency ", `Max parallel updates, 1-${MAX_CONCURRENCY} (default ${DEFAULT_CONCURRENCY})`, - ) - .action(async (opts: BulkUpdateRawOptions, cmd: Command) => { - const globals = getGlobalOptions(cmd); - let parsed: ParsedBulkUpdate; - try { - parsed = await parseBulkUpdateInput(opts); - } catch (error) { - const normalized = normalizeError(error); - renderEnvelope( - errorEnvelope("issues", "update", { - code: normalized.code, - message: normalized.message, - details: normalized.details, - }), - globals, - ); - process.exitCode = 1; - return; - } + ); + + command.addHelpText( + "after", + ` +Examples: + linear issues bulk-update --ids ANN-1,ANN-2 --state "In Progress" --dry-run --json + linear issues bulk-update --ids ANN-1,ANN-2 --input '{"priority":2}' --json + linear issues bulk-update --input-file updates.json --dry-run --json + +State by name: + Pass --state once, or include "state"/"stateName" in the input payload. Names are resolved per issue team; stateId still works as-is. +`, + ); - try { - const session = await authManager.openSession({ profile: globals.profile }); - const gateway: BulkGateway = session.gateway as LinearGateway; - const data = await runBulkUpdate(gateway, parsed); - renderEnvelope(successEnvelope("issues", "update", data), globals); - const code = exitCodeForBulk(data); - if (code !== 0) { - process.exitCode = code; - } - } catch (error) { - const normalized = normalizeError(error); - renderEnvelope( - errorEnvelope("issues", "update", { - code: normalized.code, - message: normalized.message, - details: normalized.details, - }), - globals, - ); - process.exitCode = 1; + command.action(async (opts: BulkUpdateRawOptions, cmd: Command) => { + const globals = getGlobalOptions(cmd); + let parsed: ParsedBulkUpdate; + try { + parsed = await parseBulkUpdateInput(opts, globals.state); + } catch (error) { + const normalized = normalizeError(error); + renderEnvelope( + errorEnvelope("issues", "update", { + code: normalized.code, + message: normalized.message, + details: normalized.details, + }), + globals, + ); + process.exitCode = 1; + return; + } + + try { + const session = await authManager.openSession({ profile: globals.profile }); + const gateway: BulkGateway = session.gateway as LinearGateway; + const normalized = await normalizeBulkUpdateInput(gateway, parsed, globals.state); + const data = await runBulkUpdate(gateway, normalized); + renderEnvelope(successEnvelope("issues", "update", data), globals); + const code = exitCodeForBulk(data); + if (code !== 0) { + process.exitCode = code; } - }); + } catch (error) { + const normalized = normalizeError(error); + renderEnvelope( + errorEnvelope("issues", "update", { + code: normalized.code, + message: normalized.message, + details: normalized.details, + }), + globals, + ); + process.exitCode = 1; + } + }); } diff --git a/packages/cli/src/commands/resource.ts b/packages/cli/src/commands/resource.ts index 343075e..63edcd3 100644 --- a/packages/cli/src/commands/resource.ts +++ b/packages/cli/src/commands/resource.ts @@ -4,7 +4,12 @@ import type { LinearEntity, OutputEnvelope, } from "@wiseiodev/linear-core"; -import { errorEnvelope, normalizeError, successEnvelope } from "@wiseiodev/linear-core"; +import { + errorEnvelope, + LinearCoreError, + normalizeError, + successEnvelope, +} from "@wiseiodev/linear-core"; import type { Command } from "commander"; import { renderEnvelope } from "../formatters/output.js"; import { getResourceHelpTexts } from "../help/resource-help.js"; @@ -57,6 +62,7 @@ async function executeAction( } export interface ResourceCommandOptions { + readonly list?: { readonly rejectPositionals?: boolean }; readonly update?: { readonly allowEmptyInput?: boolean }; } @@ -77,12 +83,26 @@ export function registerResourceCommand( if (handlers.list) { const listHandler = handlers.list; - const listCommand = command - .command("list") - .description(`List ${entity}`) - .action(async (_, cmd) => + const listCommand = command.command("list").description(`List ${entity}`); + if (options.list?.rejectPositionals) { + listCommand + .argument("[unexpected...]", "Use --team, --query, --state, or another filter instead") + .action(async (unexpected: readonly string[] | undefined, _opts, cmd) => { + await executeAction(entity, "list", cmd, () => { + if (unexpected && unexpected.length > 0) { + throw new LinearCoreError( + "InvalidInput", + `Unexpected positional argument "${unexpected.join(" ")}". Use --team , --query , --state , or another filter with ${entity} list.`, + ); + } + return listHandler(authManager, cmd); + }); + }); + } else { + listCommand.action(async (_, cmd) => executeAction(entity, "list", cmd, () => listHandler(authManager, cmd)), ); + } if (helpTexts.list) { listCommand.addHelpText("after", helpTexts.list); } @@ -92,6 +112,7 @@ export function registerResourceCommand( const getHandler = handlers.get; command .command("get") + .aliases(["view", "show"]) .description(`Get ${entity} by id`) .argument("", "Entity id") .action(async (id, _, cmd) => diff --git a/packages/cli/src/help/resource-help.ts b/packages/cli/src/help/resource-help.ts index e24eea8..d965fa4 100644 --- a/packages/cli/src/help/resource-help.ts +++ b/packages/cli/src/help/resource-help.ts @@ -122,13 +122,14 @@ const resourceExamples: Record = { examples: [ 'linear issues list --limit 10 --state "In Progress" --assignee me', "linear issues list --mine --view detail --fields identifier,title,assigneeName", - "linear issues list --all --json", + "linear issues list --team ENG --all --json", ], }, create: { required: "teamId plus (title or templateId)", examples: [ 'linear issues create --input \'{"teamId":"","title":"My issue"}\'', + 'linear issues create --state "Todo" --input \'{"teamId":"","title":"My issue"}\'', 'linear issues create --template "Bug Report" --input \'{"teamId":""}\' --json', ], }, @@ -328,7 +329,10 @@ const resourceExamples: Record = { list: { filters: [], pagination: "basic", - examples: ["linear comments list --limit 25 --json"], + examples: [ + "linear comments list --limit 25 --json", + "linear comments list --issue ANN-123 --json", + ], }, create: { required: "body", diff --git a/packages/cli/src/help/root-help.ts b/packages/cli/src/help/root-help.ts index 0ce9970..877c061 100644 --- a/packages/cli/src/help/root-help.ts +++ b/packages/cli/src/help/root-help.ts @@ -5,6 +5,8 @@ Task-first workflows: linear doctor linear my-work --mine linear triage --team ENG + linear prep ANN-123 --json + linear pr-ready ANN-123 --pr https://github.com/org/repo/pull/123 --json linear project status linear updates --limit 20 @@ -20,6 +22,8 @@ Examples: linear issues branch --help linear issues list --limit 10 --state "In Progress" --assignee me linear issues branch ANN-123 --json + linear prep ANN-123 --json + linear pr-ready ANN-123 --comment "Ready for review" --json linear issues browse linear issues create --template "Bug Report" --input '{"teamId":""}' linear customers list @@ -52,14 +56,16 @@ Examples: linear issues list --limit 10 --json linear issues list --mine --state "Todo" --view detail linear issues list --parent ANN-123 --json - linear issues list --fields identifier,title,assigneeName,projectName + linear issues list --limit 10 --fields identifier,title,assigneeName,projectName linear issues list --project "Evalite setup" --label eng --priority 2 --json linear issues list --query "evalite" --json linear issues list --updated-after 2026-05-01 --json linear issues list --created-after -P7D --json - linear issues list --no-parent --json + linear issues list --team ENG --no-parent --json linear issues create --template "Bug Report" --input '{"teamId":""}' --json + linear issues create --state "Todo" --input '{"teamId":"","title":"My issue"}' --json linear issues bulk-update --ids ANN-1,ANN-2 --input '{"priority":2}' --dry-run --json + linear issues bulk-update --ids ANN-1,ANN-2 --state "In Progress" --dry-run --json linear issues bulk-update --input-file updates.json --json `; diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 51255e2..0ed988e 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -1,4 +1,4 @@ -import { readFileSync } from "node:fs"; +import { existsSync, readFileSync, realpathSync } from "node:fs"; import path from "node:path"; import { createInterface } from "node:readline/promises"; import { fileURLToPath } from "node:url"; @@ -50,7 +50,11 @@ 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 { + normalizeIssueCreateStatePayload, + normalizeIssueUpdateStatePayload, +} from "./commands/issue-state.js"; +import { runPrep, runPrReady } from "./commands/issue-workflow.js"; import { registerIssuesBulkUpdate } from "./commands/issues-bulk-update.js"; import { registerResourceCommand } from "./commands/resource.js"; import { renderEnvelope } from "./formatters/output.js"; @@ -118,6 +122,76 @@ function isIssueCreateInput(value: unknown): value is SdkIssueInput { ); } +function assertBoundedIssuesList(globals: ReturnType): void { + const hasNarrowing = Boolean( + globals.team || + globals.mine || + globals.state || + globals.status || + globals.assignee || + globals.label || + globals.priority || + globals.query || + globals.project || + globals.cycle || + globals.parent || + globals.filter || + globals.updatedAfter || + globals.createdAfter || + globals.limit !== undefined, + ); + + if (hasNarrowing) { + return; + } + + throw new Error( + 'Refusing to run an unbounded issues list. Narrow it with --mine, --state "In Progress", --query , --limit N, --team , or another filter.', + ); +} + +function buildBinaryReport() { + const executable = process.argv[1] ? path.resolve(process.argv[1]) : undefined; + const pathEntries = (process.env.PATH ?? "").split(path.delimiter).filter(Boolean); + const seen = new Set(); + const candidates: Array<{ command: "linear" | "li"; path: string; current: boolean }> = []; + + const normalize = (value: string) => { + try { + return realpathSync(value); + } catch { + return path.resolve(value); + } + }; + + const current = executable ? normalize(executable) : undefined; + for (const entry of pathEntries) { + for (const command of ["linear", "li"] as const) { + const candidate = path.join(entry, command); + if (!existsSync(candidate)) { + continue; + } + const resolved = normalize(candidate); + const key = `${command}\n${resolved}`; + if (seen.has(key)) { + continue; + } + seen.add(key); + candidates.push({ + command, + path: resolved, + current: current !== undefined && resolved === current, + }); + } + } + + return { + executable, + onPath: candidates.some((candidate) => candidate.current), + candidates, + }; +} + function isInitiativeCreateInput(value: unknown): value is SdkInitiativeInput { return isRecord(value) && hasString(value, "name"); } @@ -294,6 +368,8 @@ async function resolveIssueTemplateId( export function createProgram(authManager = new AuthManager()): Command { const program = new Command(); program.configureHelp({ showGlobalOptions: true }); + program.showSuggestionAfterError(); + program.showHelpAfterError(); program .name("linear") @@ -535,6 +611,7 @@ export function createProgram(authManager = new AuthManager()): Command { { list: async (_manager, cmd) => { const globals = getGlobalOptions(cmd); + assertBoundedIssuesList(globals); const viewerName = await resolveViewerName(cmd); const gateway = await sessionGateway(cmd); const parentId = globals.parent ? await gateway.resolveIssueId(globals.parent) : undefined; @@ -547,6 +624,8 @@ export function createProgram(authManager = new AuthManager()): Command { }, get: async (_manager, id, cmd) => (await sessionGateway(cmd)).getIssue(id), create: async (_manager, payload, cmd) => { + const globals = getGlobalOptions(cmd); + const gateway = await sessionGateway(cmd); const templateReference = cmd.opts<{ template?: string }>().template; const payloadWithTemplateReference = templateReference && isRecord(payload) @@ -555,17 +634,21 @@ export function createProgram(authManager = new AuthManager()): Command { templateId: templateReference, } : payload; + const normalized = await normalizeIssueCreateStatePayload( + gateway, + isRecord(payloadWithTemplateReference) ? payloadWithTemplateReference : {}, + globals.state, + ); const issueInput = ensurePayload( - payloadWithTemplateReference, + normalized, isIssueCreateInput, "Issue create payload requires teamId plus title or templateId.", ); if (!templateReference) { - return (await sessionGateway(cmd)).createIssue(issueInput); + return gateway.createIssue(issueInput); } - const gateway = await sessionGateway(cmd); const templateId = await resolveIssueTemplateId(gateway, templateReference); return gateway.createIssue({ ...issueInput, @@ -593,7 +676,7 @@ export function createProgram(authManager = new AuthManager()): Command { delete: async (_manager, id, cmd) => (await sessionGateway(cmd)).deleteIssue(id), }, authManager, - { update: { allowEmptyInput: true } }, + { list: { rejectPositionals: true }, update: { allowEmptyInput: true } }, ); const issuesCommand = program.commands.find((command) => command.name() === "issues"); @@ -1067,16 +1150,20 @@ export function createProgram(authManager = new AuthManager()): Command { authManager, ); - registerResourceCommand( + const commentsCommand = registerResourceCommand( program, "comments", "Comment commands", { list: async (_manager, cmd) => { const globals = getGlobalOptions(cmd); - return (await sessionGateway(cmd)).listComments({ + const issueRef = cmd.opts<{ issue?: string }>().issue; + const gateway = await sessionGateway(cmd); + const issueId = issueRef ? await gateway.resolveIssueId(issueRef) : undefined; + return gateway.listComments({ limit: globals.limit, cursor: globals.cursor, + issueId, }); }, get: async (_manager, id, cmd) => (await sessionGateway(cmd)).getComment(id), @@ -1097,6 +1184,9 @@ export function createProgram(authManager = new AuthManager()): Command { }, authManager, ); + commentsCommand.commands + .find((command) => command.name() === "list") + ?.option("--issue ", "Only list comments for this issue"); registerResourceCommand( program, @@ -1191,7 +1281,7 @@ export function createProgram(authManager = new AuthManager()): Command { authManager, ); - registerResourceCommand( + const statesCommand = registerResourceCommand( program, "states", "Workflow state commands", @@ -1225,10 +1315,71 @@ export function createProgram(authManager = new AuthManager()): Command { }, authManager, ); + statesCommand.aliases(["statuses", "workflow-states"]); + + program + .command("prep") + .description("Prepare to work an issue: fetch context, branch name, and move it in progress") + .argument("", "Issue id (UUID) or identifier (e.g. ANN-123)") + .option("--state ", "State to move to (default: In Progress, then first started state)") + .action(async (idOrIdentifier, opts: { state?: string }, cmd) => { + const globals = getGlobalOptions(cmd); + try { + const data = await runPrep( + await sessionGateway(cmd), + idOrIdentifier, + opts.state ?? globals.state, + ); + renderEnvelope(successEnvelope("issues", "update", data), globals); + } catch (error) { + const normalized = normalizeError(error); + renderEnvelope( + errorEnvelope("issues", "update", { + code: normalized.code, + message: normalized.message, + details: normalized.details, + }), + globals, + ); + process.exitCode = 1; + } + }); + + program + .command("pr-ready") + .description("Move an issue to review and optionally post a PR or review comment") + .argument("", "Issue id (UUID) or identifier (e.g. ANN-123)") + .option("--state ", "Review state name (default: In Review)") + .option("--comment ", "Comment body to post after moving the issue") + .option("--pr ", "Pull request URL to link in a comment") + .action( + async (idOrIdentifier, opts: { state?: string; comment?: string; pr?: string }, cmd) => { + const globals = getGlobalOptions(cmd); + try { + const data = await runPrReady(await sessionGateway(cmd), idOrIdentifier, { + state: opts.state ?? globals.state, + comment: opts.comment, + pr: opts.pr, + }); + renderEnvelope(successEnvelope("issues", "update", data), globals); + } catch (error) { + const normalized = normalizeError(error); + renderEnvelope( + errorEnvelope("issues", "update", { + code: normalized.code, + message: normalized.message, + details: normalized.details, + }), + globals, + ); + process.exitCode = 1; + } + }, + ); program .command("doctor") - .description("Validate auth, profile, API connectivity, and rate limits") + .description("Validate auth, profile, API connectivity, rate limits, and binary install") .action(async (_, cmd) => { const globals = getGlobalOptions(cmd); @@ -1247,6 +1398,7 @@ export function createProgram(authManager = new AuthManager()): Command { email: viewer.email, }, rateLimit, + binary: buildBinaryReport(), }), globals, ); diff --git a/packages/cli/tests/agent-ux-commands.test.ts b/packages/cli/tests/agent-ux-commands.test.ts new file mode 100644 index 0000000..6dc67e4 --- /dev/null +++ b/packages/cli/tests/agent-ux-commands.test.ts @@ -0,0 +1,227 @@ +import type { CommentRecord, IssueRecord, PageResult } from "@wiseiodev/linear-core"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { createProgram } from "../src/index.js"; + +const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + +afterEach(() => { + logSpy.mockClear(); + process.exitCode = undefined; +}); + +function issue(): IssueRecord { + return { + id: "issue-1", + number: 1, + identifier: "ANN-1", + title: "Demo", + priority: 0, + teamId: "team-1", + url: "https://linear.app/x/issue/ANN-1", + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }; +} + +function comment(): CommentRecord { + return { + id: "comment-1", + body: "Ready", + issueId: "issue-1", + url: "https://linear.app/comment/comment-1", + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }; +} + +function workflowGateway(overrides: Partial> = {}) { + return { + getIssue: vi.fn(async () => issue({ projectId: "project-1" })), + getProject: vi.fn(async () => ({ + id: "project-1", + name: "Project", + state: "active", + priority: 0, + progress: 0, + url: "https://linear.app/project/project", + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + })), + getIssueBranchName: vi.fn(async () => ({ + id: "issue-1", + identifier: "ANN-1", + branchName: "ann-1-demo", + url: "https://linear.app/x/issue/ANN-1", + })), + listWorkflowStatesForTeam: vi.fn(async () => [ + { id: "state-started", name: "In Progress", type: "started", position: 1 }, + { id: "state-review", name: "In Review", type: "started", position: 2 }, + ]), + updateIssue: vi.fn(async (_id: string, input: { stateId?: string }) => + issue({ stateId: input.stateId }), + ), + createComment: vi.fn(async (input: { issueId?: string; body?: string }) => ({ + ...comment(), + issueId: input.issueId, + body: input.body ?? "", + })), + ...overrides, + }; +} + +describe("agent UX commands", () => { + test("blocks an unbounded issues list before opening a Linear session", async () => { + const openSession = vi.fn(); + const program = createProgram({ openSession } as never); + + await program.parseAsync(["node", "linear", "--json", "issues", "list"]); + + expect(openSession).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); + expect(JSON.parse(String(logSpy.mock.calls[0]?.[0]))).toMatchObject({ + ok: false, + error: { message: expect.stringContaining("unbounded issues list") }, + }); + }); + + test("renders unexpected issues list positionals as a JSON error envelope", async () => { + const openSession = vi.fn(); + const program = createProgram({ openSession } as never); + + await program.parseAsync(["node", "linear", "--json", "issues", "list", "ANN-1"]); + + expect(openSession).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); + expect(JSON.parse(String(logSpy.mock.calls[0]?.[0]))).toMatchObject({ + ok: false, + entity: "issues", + action: "list", + error: { + code: "InvalidInput", + message: expect.stringContaining('Unexpected positional argument "ANN-1"'), + }, + }); + }); + + test("allows bounded issues list calls", async () => { + const listIssues = vi.fn( + async (): Promise> => ({ + items: [issue()], + nextCursor: null, + }), + ); + const openSession = vi.fn().mockResolvedValue({ gateway: { listIssues } }); + const program = createProgram({ openSession } as never); + + await program.parseAsync(["node", "linear", "--json", "issues", "list", "--limit", "1"]); + + expect(listIssues).toHaveBeenCalledWith({ limit: 1, cursor: undefined }); + expect(JSON.parse(String(logSpy.mock.calls[0]?.[0]))).toMatchObject({ + ok: true, + entity: "issues", + action: "list", + }); + }); + + test("comments list --issue resolves the issue and scopes the list", async () => { + const resolveIssueId = vi.fn(async () => "issue-1"); + const listComments = vi.fn( + async (): Promise> => ({ + items: [comment()], + nextCursor: null, + }), + ); + const openSession = vi.fn().mockResolvedValue({ + gateway: { resolveIssueId, listComments }, + }); + const program = createProgram({ openSession } as never); + + await program.parseAsync(["node", "linear", "--json", "comments", "list", "--issue", "ANN-1"]); + + expect(resolveIssueId).toHaveBeenCalledWith("ANN-1"); + expect(listComments).toHaveBeenCalledWith({ + limit: undefined, + cursor: undefined, + issueId: "issue-1", + }); + }); + + test("doctor includes binary install information", async () => { + const status = vi.fn(async () => ({ + profile: "default", + hasApiKey: true, + hasAccessToken: false, + oauthConfigured: false, + hasRefreshToken: false, + expired: false, + })); + const openSession = vi.fn().mockResolvedValue({ + client: { + viewer: Promise.resolve({ + id: "user-1", + displayName: "Wise", + name: "wise", + email: "w@example.com", + }), + rateLimitStatus: Promise.resolve({ requests: { remaining: 100 } }), + }, + }); + const program = createProgram({ status, openSession } as never); + + await program.parseAsync(["node", "linear", "--json", "doctor"]); + + expect(JSON.parse(String(logSpy.mock.calls[0]?.[0]))).toMatchObject({ + ok: true, + entity: "doctor", + action: "show", + data: { + binary: expect.objectContaining({ + executable: expect.any(String), + onPath: expect.any(Boolean), + candidates: expect.any(Array), + }), + }, + }); + }); + + test("prep command returns branch and state context", async () => { + const gateway = workflowGateway(); + const openSession = vi.fn().mockResolvedValue({ gateway }); + const program = createProgram({ openSession } as never); + + await program.parseAsync(["node", "linear", "--json", "prep", "ANN-1"]); + + expect(gateway.updateIssue).toHaveBeenCalledWith("ANN-1", { stateId: "state-started" }); + expect(JSON.parse(String(logSpy.mock.calls[0]?.[0]))).toMatchObject({ + ok: true, + entity: "issues", + action: "update", + data: { + branchName: "ann-1-demo", + state: { name: "In Progress" }, + }, + }); + }); + + test("pr-ready command posts a PR comment when requested", async () => { + const gateway = workflowGateway(); + const openSession = vi.fn().mockResolvedValue({ gateway }); + const program = createProgram({ openSession } as never); + + await program.parseAsync([ + "node", + "linear", + "--json", + "pr-ready", + "ANN-1", + "--pr", + "https://github.com/wiseiodev/linear-cli/pull/21", + ]); + + expect(gateway.updateIssue).toHaveBeenCalledWith("ANN-1", { stateId: "state-review" }); + expect(gateway.createComment).toHaveBeenCalledWith({ + issueId: "issue-1", + body: "PR: https://github.com/wiseiodev/linear-cli/pull/21", + }); + }); +}); diff --git a/packages/cli/tests/help.test.ts b/packages/cli/tests/help.test.ts index b7c2147..0db3383 100644 --- a/packages/cli/tests/help.test.ts +++ b/packages/cli/tests/help.test.ts @@ -65,6 +65,10 @@ describe("help output", () => { program.commands.find((command) => command.name() === "my-work")?.helpInformation() ?? ""; const triageHelp = program.commands.find((command) => command.name() === "triage")?.helpInformation() ?? ""; + const prepHelp = + program.commands.find((command) => command.name() === "prep")?.helpInformation() ?? ""; + const prReadyHelp = + program.commands.find((command) => command.name() === "pr-ready")?.helpInformation() ?? ""; expect(help).toContain("--version"); expect(help).toContain("docs"); @@ -72,6 +76,8 @@ describe("help output", () => { expect(help).toContain("doctor"); expect(help).toContain("my-work"); expect(help).toContain("triage"); + expect(help).toContain("prep"); + expect(help).toContain("pr-ready"); expect(help).toContain("issues"); expect(help).toContain("initiatives"); expect(help).toContain("documents"); @@ -107,9 +113,11 @@ describe("help output", () => { expect(projectUpdatesHelp).toContain("list"); expect(initiativeUpdatesHelp).toContain("update"); expect(notificationsHelp).toContain("list"); - expect(doctorHelp).toContain("Validate auth"); + expect(doctorHelp).toContain("binary install"); expect(myWorkHelp).toContain("assigned"); expect(triageHelp).toContain("triage"); + expect(prepHelp).toContain("Prepare to work"); + expect(prReadyHelp).toContain("Move an issue to review"); }); test("subcommands surface filters, examples, and input field hints", () => { @@ -130,12 +138,17 @@ describe("help output", () => { const customersListCommand = customersCommand?.commands.find( (command) => command.name() === "list", ); + const commentsCommand = program.commands.find((command) => command.name() === "comments"); + const commentsListCommand = commentsCommand?.commands.find( + (command) => command.name() === "list", + ); const issuesListHelp = captureRenderedHelp(issuesListCommand); const issuesCreateHelp = captureRenderedHelp(issuesCreateCommand); const issuesUpdateHelp = captureRenderedHelp(issuesUpdateCommand); const projectsCreateHelp = captureRenderedHelp(projectsCreateCommand); const customersListHelp = captureRenderedHelp(customersListCommand); + const commentsListHelp = captureRenderedHelp(commentsListCommand); expect(issuesListHelp).toContain("Filters, pagination, and output"); expect(issuesListHelp).toContain("--team"); @@ -158,6 +171,17 @@ describe("help output", () => { expect(customersListHelp).toContain("Filters, pagination, and output"); expect(customersListHelp).toContain("linear customers list"); + expect(commentsListHelp).toContain("--issue"); + }); + + test("registers agent-friendly command aliases", () => { + const program = createProgram(); + const issuesCommand = program.commands.find((command) => command.name() === "issues"); + const statesCommand = program.commands.find((command) => command.name() === "states"); + const issueGet = issuesCommand?.commands.find((command) => command.name() === "get"); + + expect(issueGet?.aliases()).toEqual(["view", "show"]); + expect(statesCommand?.aliases()).toEqual(["statuses", "workflow-states"]); }); test("list help only advertises options that the handler honors", () => { diff --git a/packages/cli/tests/issue-state.test.ts b/packages/cli/tests/issue-state.test.ts index f10578b..cc3827f 100644 --- a/packages/cli/tests/issue-state.test.ts +++ b/packages/cli/tests/issue-state.test.ts @@ -2,6 +2,8 @@ import type { IssueRecord, ResolvableWorkflowState } from "@wiseiodev/linear-cor import { describe, expect, test, vi } from "vitest"; import { type IssueStateGateway, + normalizeIssueBulkUpdateStatePayloads, + normalizeIssueCreateStatePayload, normalizeIssueUpdateStatePayload, } from "../src/commands/issue-state.js"; @@ -153,3 +155,98 @@ describe("normalizeIssueUpdateStatePayload", () => { ); }); }); + +describe("normalizeIssueCreateStatePayload", () => { + test("resolves the --state flag against the create payload team", async () => { + const { gateway, listWorkflowStatesForTeam } = makeGateway(); + + const result = await normalizeIssueCreateStatePayload( + gateway, + { teamId: "team-1", title: "Demo" }, + "Todo", + ); + + expect(result).toEqual({ teamId: "team-1", title: "Demo", stateId: "s-todo" }); + expect(listWorkflowStatesForTeam).toHaveBeenCalledWith("team-1"); + }); + + test("resolves state/stateName payload keys and strips them", async () => { + const { gateway } = makeGateway(); + + await expect( + normalizeIssueCreateStatePayload( + gateway, + { teamId: "team-1", title: "Demo", state: "In Progress" }, + undefined, + ), + ).resolves.toEqual({ teamId: "team-1", title: "Demo", stateId: "s-progress" }); + + await expect( + normalizeIssueCreateStatePayload( + gateway, + { teamId: "team-1", title: "Demo", stateName: "Todo" }, + undefined, + ), + ).resolves.toEqual({ teamId: "team-1", title: "Demo", stateId: "s-todo" }); + }); + + test("keeps explicit stateId and strips state names without listing states", async () => { + const { gateway, listWorkflowStatesForTeam } = makeGateway(); + + const result = await normalizeIssueCreateStatePayload( + gateway, + { teamId: "team-1", title: "Demo", stateId: "state-explicit", state: "Todo" }, + "In Progress", + ); + + expect(result).toEqual({ teamId: "team-1", title: "Demo", stateId: "state-explicit" }); + expect(listWorkflowStatesForTeam).not.toHaveBeenCalled(); + }); + + test("fails clearly when a create state name has no teamId", async () => { + const { gateway } = makeGateway(); + + await expect( + normalizeIssueCreateStatePayload(gateway, { title: "Demo" }, "Todo"), + ).rejects.toThrow(/teamId is missing/); + }); +}); + +describe("normalizeIssueBulkUpdateStatePayloads", () => { + test("resolves one state flag per distinct team and strips name keys", async () => { + const issueA = makeIssue({ id: "issue-a", identifier: "ANN-1", teamId: "team-1" }); + const issueB = makeIssue({ id: "issue-b", identifier: "ANN-2", teamId: "team-1" }); + const getIssue = vi.fn(async (id: string) => (id === "ANN-1" ? issueA : issueB)); + const listWorkflowStatesForTeam = vi.fn(async () => STATES); + const gateway: IssueStateGateway = { getIssue, listWorkflowStatesForTeam }; + + const result = await normalizeIssueBulkUpdateStatePayloads( + gateway, + [ + { id: "ANN-1", payload: { title: "A", stateName: "Todo" } }, + { id: "ANN-2", payload: { title: "B" } }, + ], + "In Progress", + ); + + expect(result).toEqual([ + { id: "ANN-1", payload: { title: "A", stateId: "s-progress" } }, + { id: "ANN-2", payload: { title: "B", stateId: "s-progress" } }, + ]); + expect(getIssue).toHaveBeenCalledTimes(2); + expect(listWorkflowStatesForTeam).toHaveBeenCalledTimes(1); + }); + + test("explicit stateId wins per bulk item", async () => { + const { gateway, getIssue } = makeGateway(); + + const result = await normalizeIssueBulkUpdateStatePayloads( + gateway, + [{ id: "ANN-1", payload: { stateId: "explicit", state: "Todo" } }], + "In Progress", + ); + + expect(result).toEqual([{ id: "ANN-1", payload: { stateId: "explicit" } }]); + expect(getIssue).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/cli/tests/issue-workflow.test.ts b/packages/cli/tests/issue-workflow.test.ts new file mode 100644 index 0000000..a2e5254 --- /dev/null +++ b/packages/cli/tests/issue-workflow.test.ts @@ -0,0 +1,141 @@ +import type { + CommentRecord, + IssueRecord, + ProjectRecord, + ResolvableWorkflowState, + SdkCommentInput, + SdkIssueUpdateInput, +} from "@wiseiodev/linear-core"; +import { describe, expect, test, vi } from "vitest"; +import { type IssueWorkflowGateway, runPrep, runPrReady } from "../src/commands/issue-workflow.js"; + +const STATES: readonly ResolvableWorkflowState[] = [ + { id: "state-todo", name: "Todo", type: "unstarted", position: 1 }, + { id: "state-started", name: "Doing", type: "started", position: 1 }, + { id: "state-review", name: "In Review", type: "started", position: 2 }, +]; + +function issue(overrides: Partial = {}): IssueRecord { + return { + id: "issue-1", + number: 1, + identifier: "ANN-1", + title: "Demo", + priority: 0, + teamId: "team-1", + url: "https://linear.app/x/issue/ANN-1", + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + ...overrides, + }; +} + +function project(): ProjectRecord { + return { + id: "project-1", + name: "Demo project", + state: "active", + priority: 0, + progress: 0, + url: "https://linear.app/project/demo", + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }; +} + +function comment(input: SdkCommentInput): CommentRecord { + return { + id: "comment-1", + body: input.body ?? "", + issueId: input.issueId, + url: "https://linear.app/comment/comment-1", + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }; +} + +function makeGateway( + options: { baseIssue?: IssueRecord; states?: readonly ResolvableWorkflowState[] } = {}, +) { + const baseIssue = options.baseIssue ?? issue(); + const getIssue = vi.fn(async (id: string) => + id === "parent-1" ? issue({ id: "parent-1", identifier: "ANN-0", title: "Parent" }) : baseIssue, + ); + const getProject = vi.fn(async () => project()); + const getIssueBranchName = vi.fn(async () => ({ + id: baseIssue.id, + identifier: baseIssue.identifier, + branchName: "ann-1-demo", + url: baseIssue.url, + })); + const listWorkflowStatesForTeam = vi.fn(async () => options.states ?? STATES); + const updateIssue = vi.fn(async (_id: string, input: SdkIssueUpdateInput) => + issue({ ...baseIssue, stateId: input.stateId }), + ); + const createComment = vi.fn(async (input: SdkCommentInput) => comment(input)); + const gateway: IssueWorkflowGateway = { + getIssue, + getProject, + getIssueBranchName, + listWorkflowStatesForTeam, + updateIssue, + createComment, + }; + return { gateway, getIssue, getProject, getIssueBranchName, updateIssue, createComment }; +} + +describe("runPrep", () => { + test("moves to the first started state, includes parent context, and returns branch name", async () => { + const { gateway, getIssue, getIssueBranchName, updateIssue } = makeGateway({ + baseIssue: issue({ parentId: "parent-1" }), + }); + + const result = await runPrep(gateway, "ANN-1"); + + expect(result.branchName).toBe("ann-1-demo"); + expect(result.state).toEqual({ id: "state-started", name: "Doing", type: "started" }); + expect(result.context?.type).toBe("parent"); + expect(getIssue).toHaveBeenCalledWith("parent-1"); + expect(getIssueBranchName).toHaveBeenCalledWith("ANN-1"); + expect(updateIssue).toHaveBeenCalledWith("ANN-1", { stateId: "state-started" }); + }); + + test("honors a state override and uses project context when there is no parent", async () => { + const { gateway, getProject, updateIssue } = makeGateway({ + baseIssue: issue({ projectId: "project-1" }), + }); + + const result = await runPrep(gateway, "ANN-1", "In Review"); + + expect(result.context?.type).toBe("project"); + expect(getProject).toHaveBeenCalledWith("project-1"); + expect(updateIssue).toHaveBeenCalledWith("ANN-1", { stateId: "state-review" }); + }); +}); + +describe("runPrReady", () => { + test("moves to In Review without posting a comment by default", async () => { + const { gateway, updateIssue, createComment } = makeGateway(); + + const result = await runPrReady(gateway, "ANN-1", {}); + + expect(result.state.id).toBe("state-review"); + expect(updateIssue).toHaveBeenCalledWith("ANN-1", { stateId: "state-review" }); + expect(createComment).not.toHaveBeenCalled(); + expect(result.comment).toBeUndefined(); + }); + + test("posts a generated PR comment when --pr is supplied", async () => { + const { gateway, createComment } = makeGateway(); + + const result = await runPrReady(gateway, "ANN-1", { + pr: "https://github.com/wiseiodev/linear-cli/pull/21", + }); + + expect(createComment).toHaveBeenCalledWith({ + issueId: "issue-1", + body: "PR: https://github.com/wiseiodev/linear-cli/pull/21", + }); + expect(result.comment?.body).toContain("/pull/21"); + }); +}); diff --git a/packages/cli/tests/issues-bulk-update.test.ts b/packages/cli/tests/issues-bulk-update.test.ts index cdaa716..6e1fe9f 100644 --- a/packages/cli/tests/issues-bulk-update.test.ts +++ b/packages/cli/tests/issues-bulk-update.test.ts @@ -7,6 +7,7 @@ import { LinearCoreError } from "@wiseiodev/linear-core"; import { describe, expect, test } from "vitest"; import { exitCodeForBulk, + normalizeBulkUpdateInput, parseBulkUpdateInput, readBulkInput, runBulkUpdate, @@ -38,6 +39,9 @@ function makeGateway(handlers: { }): { gateway: { getIssue: (id: string) => Promise; + listWorkflowStatesForTeam: ( + teamId: string, + ) => Promise>; updateIssue: (id: string, input: SdkIssueUpdateInput) => Promise; }; calls: RecordedCall[]; @@ -53,6 +57,10 @@ function makeGateway(handlers: { } return handlers.getIssue(id); }, + listWorkflowStatesForTeam: async (_teamId) => [ + { id: "state-todo", name: "Todo", type: "unstarted", position: 1 }, + { id: "state-progress", name: "In Progress", type: "started", position: 2 }, + ], updateIssue: async (id, input) => { calls.push({ type: "update", id, input }); if (!handlers.updateIssue) { @@ -159,6 +167,12 @@ describe("parseBulkUpdateInput", () => { }), ).rejects.toThrow(/10 or less/); }); + + test("allows --state-only shared-payload mode", async () => { + const parsed = await parseBulkUpdateInput({ ids: "ANN-1" }, "In Progress"); + + expect(parsed.items).toEqual([{ id: "ANN-1", payload: {} }]); + }); }); describe("readBulkInput", () => { @@ -179,6 +193,35 @@ describe("readBulkInput", () => { }); describe("runBulkUpdate", () => { + test("normalizes state names before running shared updates", async () => { + const { gateway, calls } = makeGateway({ + getIssue: async (id) => makeIssue({ id: `uuid-${id}`, identifier: id, teamId: "team-1" }), + updateIssue: async (id, input) => + makeIssue({ id: `uuid-${id}`, identifier: id, stateId: input.stateId }), + }); + + const normalized = await normalizeBulkUpdateInput( + gateway, + { + dryRun: false, + concurrency: 1, + items: [ + { id: "ANN-1", payload: { state: "Todo" } }, + { id: "ANN-2", payload: { state: "Todo" } }, + ], + }, + undefined, + ); + const data = await runBulkUpdate(gateway, normalized); + + expect(normalized.items).toEqual([ + { id: "ANN-1", payload: { stateId: "state-todo" } }, + { id: "ANN-2", payload: { stateId: "state-todo" } }, + ]); + expect(calls.filter((call) => call.type === "get")).toHaveLength(2); + expect(data.failed).toBe(0); + }); + test("dry-run returns planned entries and never calls updateIssue", async () => { const { gateway, calls } = makeGateway({ getIssue: async (id) => makeIssue({ id: `uuid-${id}`, identifier: id }), diff --git a/packages/linear-core/src/entities/linear-gateway.ts b/packages/linear-core/src/entities/linear-gateway.ts index e96e7b2..0748680 100644 --- a/packages/linear-core/src/entities/linear-gateway.ts +++ b/packages/linear-core/src/entities/linear-gateway.ts @@ -470,7 +470,7 @@ function toTemplate(record: SdkTemplateLike): TemplateRecord { }; } -function toListVariables(options: ListOptions): { first: number; after?: string | null } { +function toListVariables(options: ListOptions): Record { const base = { first: options.limit ?? 50, }; @@ -1055,7 +1055,12 @@ export class LinearGateway { } public async listComments(options: ListOptions): Promise> { - const connection = await this.client.comments(toListVariables(options)); + const variables = toListVariables(options); + if (options.issueId) { + variables.filter = { issue: { id: { eq: options.issueId } } }; + } + + const connection = await this.client.comments(variables); return { items: connection.nodes.map(toComment), nextCursor: connection.pageInfo.endCursor ?? null, diff --git a/packages/linear-core/src/types/public.ts b/packages/linear-core/src/types/public.ts index 787767e..9a59582 100644 --- a/packages/linear-core/src/types/public.ts +++ b/packages/linear-core/src/types/public.ts @@ -80,6 +80,7 @@ export interface ListOptions { readonly limit?: number; readonly cursor?: string | null; readonly parent?: string; + readonly issueId?: string; } export type ViewPreset = "table" | "detail" | "dense"; diff --git a/packages/linear-core/tests/linear-gateway.test.ts b/packages/linear-core/tests/linear-gateway.test.ts index 06e4120..4f91a0c 100644 --- a/packages/linear-core/tests/linear-gateway.test.ts +++ b/packages/linear-core/tests/linear-gateway.test.ts @@ -737,6 +737,26 @@ describe("LinearGateway", () => { expect(result.url).toBe("https://linear.app/comment/comment_1"); }); + test("lists comments scoped to an issue id", async () => { + const baseClient = createTestClient(); + const calls: Record[] = []; + const client: SdkLinearClient = { + ...baseClient, + async comments(variables: unknown) { + calls.push(variables as Record); + return baseClient.comments(variables as never); + }, + }; + const gateway = new LinearGateway(client); + + await gateway.listComments({ limit: 10, issueId: "issue-uuid" }); + + expect(calls[0]).toEqual({ + first: 10, + filter: { issue: { id: { eq: "issue-uuid" } } }, + }); + }); + test("updates attachment and maps subtitle and updatedAt", async () => { const gateway = new LinearGateway(createTestClient()); const result = await gateway.updateAttachment("att_1", { title: "Updated attachment" }); diff --git a/packages/linear-core/tests/state-resolver.test.ts b/packages/linear-core/tests/state-resolver.test.ts index 2df659a..7d7624c 100644 --- a/packages/linear-core/tests/state-resolver.test.ts +++ b/packages/linear-core/tests/state-resolver.test.ts @@ -1,6 +1,7 @@ -import type { ResolvableWorkflowState } from "@wiseiodev/linear-core"; -import { LinearCoreError, resolveStateId } from "@wiseiodev/linear-core"; import { describe, expect, test, vi } from "vitest"; +import type { ResolvableWorkflowState } from "../src/entities/state-resolver.js"; +import { resolveStateId } from "../src/entities/state-resolver.js"; +import { LinearCoreError } from "../src/errors/core-error.js"; const STATES: readonly ResolvableWorkflowState[] = [ { id: "s-backlog", name: "Backlog", type: "backlog", position: 0 }, diff --git a/packages/skills-catalog/tests/skills-catalog.test.ts b/packages/skills-catalog/tests/skills-catalog.test.ts index 13148bd..a494a4b 100644 --- a/packages/skills-catalog/tests/skills-catalog.test.ts +++ b/packages/skills-catalog/tests/skills-catalog.test.ts @@ -69,11 +69,14 @@ describe("skills catalog", () => { ); expect(content).toContain("name: linear-cli"); - expect(content).toContain("linear issues list --json"); + expect(content).toContain("linear issues list --limit 25 --json"); expect(content).toContain("linear issues get --json"); expect(content).toContain("linear issues create --input"); expect(content).toContain("linear issues update "); expect(content).toContain("linear issues branch --json"); + expect(content).toContain("linear comments list --issue --json"); + expect(content).toContain("linear prep --json"); + expect(content).toContain("linear pr-ready --pr --json"); expect(content).toContain("linear auth status --json"); expect(content).toContain("linear issues bulk-update"); expect(content).toContain("Fixes ENG-123"); @@ -98,9 +101,16 @@ describe("skills catalog", () => { 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 create --state "Todo"'); + expect(content).toContain( + 'linear issues bulk-update --ids ENG-123,ENG-124 --state "In Progress"', + ); expect(content).toContain('linear issues update ENG-123 --input \'{"state":"In Progress"}\''); expect(content).toContain("linear states list --json"); expect(content).toContain("## Common Mistakes"); + expect(content).toContain("linear comments list --issue --json"); + expect(content).toContain("linear prep --json"); + expect(content).toContain("linear pr-ready --pr --json"); }); test("fails gracefully for unknown skill", async () => {