diff --git a/docs/command-runtime.md b/docs/command-runtime.md new file mode 100644 index 0000000..006ce93 --- /dev/null +++ b/docs/command-runtime.md @@ -0,0 +1,28 @@ +# Command runtime + +Command handlers extend `CommandOptions` from `src/core/runtime/options.ts` and add only operation-specific flags. Output-only commands can extend `CommandOutputOptions` instead. + +- Use `createCommandContext(options)` for profile resolution, GraphQL, resolver dependencies, retry normalization, and standard success/failure output. +- `ctx.resolveProfile()` caches the selected profile. Selection remains explicit `profile`, then `env.LINEAR_PROFILE`, then the configured default. An explicit API URL overrides the profile's base URL. +- The same `fetchImpl` handles OAuth refresh, GraphQL, and name resolution. Do not resolve a profile through a separate uninjected transport. +- Use `commandIO(options)` or `ctx.stdout`/`ctx.stderr` for human-readable output and JSONL. Pass the options through to validation, dry-run, pagination, and retry helpers so diagnostics use the same streams. +- `stdout` and `stderr` are optional minimal writable objects. They default to the process streams, but no global runtime or stream replacement is installed. `main()` forwards its supplied streams to every registry handler and the advisory schema freshness check. + +Use `tests/helpers/output.ts` to capture injected output in tests. Concurrent command invocations can use independent streams and transports. + +## Issue commands + +`src/commands/issue.ts` retains registry dispatch and the existing public exports. Implementation lives in `src/commands/issue/`: + +| Module | Responsibility | +| --- | --- | +| `options.ts` | Issue-specific flags | +| `documents.ts` | Shared GraphQL fragments and documents | +| `model.ts` | Response types, normalization, human formatting | +| `input.ts` | Input validation, filters, and shared issue lookup | +| `read.ts` | Get, list, and search | +| `write.ts` | Create, update, and delete | +| `workflow.ts` | Close, assign, comment, and Slack attachment | +| `bulk.ts` | Bulk execution and partial-failure contracts | + +Specialized envelopes remain in handlers where their contracts differ from standard context output, such as raw GraphQL partial data, bulk partial failures, and schema metadata. CLI flags, JSON shapes, and exit-code contracts are unchanged. diff --git a/docs/commands.md b/docs/commands.md index 5d93755..d0d3feb 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -233,7 +233,7 @@ If `file upload --issue` uploads successfully but attachment creation fails, its File upload and download stream with backpressure instead of buffering entire files. Upload sizes come from local file metadata; upload sources must be regular files and should not be modified during a transfer. -`--transfer-timeout` sets a total transfer deadline in whole seconds (default **120**, range 1–2147483). The deadline starts with the first PUT/GET and covers all redirects and response-body consumption, including stalled bodies and download writes. It does not change the separate GraphQL request timeout or retry policy; file transfers are not automatically retried. Ctrl-C (SIGINT) or SIGTERM cancels an active transfer and cleans up local resources. Timeout/cancellation returns exit 1. +`--transfer-timeout` sets a total transfer deadline in whole seconds (default **120**, range 1–2147483). The deadline starts with the first PUT/GET and covers all redirects and response-body consumption, including stalled bodies and download writes. It does not change the separate GraphQL request timeout or retry policy; file transfers are not automatically retried. Ctrl-C (SIGINT) or SIGTERM cancels an active transfer and cleans up local resources. Timeout/cancellation returns exit 1 before the download commit boundary. Cancellation is checked immediately before the atomic destination rename; once dispatched, that non-cancellable filesystem operation is awaited and its actual result reported, even if cancellation or the deadline arrives meanwhile. Downloads overwrite existing destinations **only after successful completion**, using a private staging directory beside the output and an atomic rename on the same filesystem. Transfer, write, or rename failures leave the existing destination unchanged and attempt to remove staging files; the parent directory must already exist and be writable. A destination symlink is replaced, not followed. The new file uses private permissions (0600 on POSIX); existing permissions/metadata are not retained. Staging cleanup is best effort: filesystem cleanup failures do not hide the original transfer error or turn a committed download into a failure. Cleanup failures, forced termination (SIGKILL), crashes, or power loss can leave staging directories; atomic replacement is not a crash-durability guarantee. diff --git a/docs/filtering-and-pagination.md b/docs/filtering-and-pagination.md index ff8d805..171e6d7 100644 --- a/docs/filtering-and-pagination.md +++ b/docs/filtering-and-pagination.md @@ -10,6 +10,8 @@ Warning: results truncated at 50 items. Use --all to fetch all results, or --max This prevents silently incomplete data. Always check stderr or use `--all`/`--max` when you need complete results. +`--max` and `--limit` share one bound: the last occurrence wins, including across leading and command-position options. For example, `--max 10 issue list --limit 20` uses a bound of 20. + `project list` requests 50 projects per page by default, including with `--all`, to stay under Linear's query-complexity limit while preserving milestone and team fields in JSON output. An explicit `--page-size ` still takes precedence. ### Flags diff --git a/skills/linearctl/SKILL.md b/skills/linearctl/SKILL.md index 9aea517..7ff82a9 100644 --- a/skills/linearctl/SKILL.md +++ b/skills/linearctl/SKILL.md @@ -110,7 +110,7 @@ Raw GraphQL should not be used merely because it is possible. It is the fallback - `linearctl file upload [--issue ] [--transfer-timeout ] --json` - `linearctl file url [--expires-in ] --json` - `linearctl file download [--output ] [--transfer-timeout ] --json` -- Upload/download stream with backpressure. Upload sources must be regular files; do not modify them during transfer. `--transfer-timeout` is a total PUT/GET deadline in whole seconds (default 120, range 1–2147483), including redirects, response bodies, and download writes; GraphQL requests keep their separate timeout/retry policy. Transfers are not automatically retried. Ctrl-C/SIGINT or SIGTERM cancels an active transfer; timeout/cancellation returns exit 1. +- Upload/download stream with backpressure. Upload sources must be regular files; do not modify them during transfer. `--transfer-timeout` is a total PUT/GET deadline in whole seconds (default 120, range 1–2147483), including redirects, response bodies, and download writes; GraphQL requests keep their separate timeout/retry policy. Transfers are not automatically retried. Ctrl-C/SIGINT or SIGTERM cancels an active transfer; timeout/cancellation returns exit 1 before download commit. The final atomic rename is checked for cancellation before dispatch, but cannot be cancelled once in flight; its actual result is reported even if the deadline or cancellation arrives meanwhile. - Downloads stage beside the destination and atomically overwrite it only after success. Transfer/write/rename failures preserve existing contents and attempt staging cleanup; cleanup failures do not change the primary transfer outcome. The parent must exist and be writable. Destination symlinks are replaced rather than followed; new files have private permissions (0600 on POSIX), not the old metadata. Cleanup failures or SIGKILL/crashes can leave staging directories; this is not a crash-durability guarantee. - Requests and redirects require HTTPS, with at most five redirects; downloads must start at `uploads.linear.app`. Same-host redirects keep signed upload headers or Linear authorization; cross-host redirects drop sensitive headers permanently. Redirected PUTs replay the file from byte zero. @@ -201,7 +201,7 @@ Use `--dry-run` on any mutating command to preview what would happen without exe - Default list behavior returns the first page only (up to 50 items) - **When results are truncated, a warning is emitted to stderr** — check stderr to know if you have incomplete data - Use `--all` to fetch all results (with `--max` or `--limit` to limit) -- Use `--max ` or `--limit ` to cap total results +- Use `--max ` or `--limit ` to cap total results; the last occurrence of either alias wins, including across leading and command-position flags - Use `--quiet` / `-q` to suppress the truncation warning (useful when piping JSON) - Add filters before broad pagination whenever possible - Prefer `--jsonl` for large result sets — it streams one object per line; pass `--all` or `--max ` diff --git a/src/cli/main.ts b/src/cli/main.ts index 3ce98e8..cf061a0 100644 --- a/src/cli/main.ts +++ b/src/cli/main.ts @@ -9,13 +9,14 @@ import { maybeWarnForStaleSchema } from "../core/schema/freshness.js"; import { failureEnvelope } from "../core/output/envelope.js"; import { mapCommandFailure, type CommandFailure } from "../core/errors/command-failure.js"; import type { FetchLike } from "../core/transport/graphql.js"; +import type { OutputStream } from "../core/runtime/options.js"; import packageJson from "../../package.json" with { type: "json" }; -interface MainRuntime { +export interface MainRuntime { env: NodeJS.ProcessEnv; stdin: NodeJS.ReadableStream; - stdout: NodeJS.WriteStream | Pick; - stderr: NodeJS.WriteStream | Pick; + stdout: OutputStream; + stderr: OutputStream; fetchImpl?: FetchLike; schemaFreshnessTimeoutMs?: number; } @@ -73,15 +74,24 @@ function parseCliOptionSet( argv: string[], options: Record ): { values: Record; positionals: string[] } { - const { values, positionals } = parseArgs({ + const { values, positionals, tokens } = parseArgs({ args: argv, options, allowPositionals: true, - strict: true + strict: true, + tokens: true }); + // Normalize aliases before merging leading and command-position options. + // Tokens retain ordering even when both aliases occur in the same segment. + const bound = tokens.slice().reverse().find((token) => token.kind === "option" && (token.name === "max" || token.name === "limit")); + if (bound?.kind === "option") { + values.max = values[bound.name]; + delete values.limit; + } + return { - values: values as Record, + values: { ...values, ...(bound?.kind === "option" ? { maxOptionName: bound.name } : {}) }, positionals }; } @@ -208,7 +218,7 @@ function toParsedCliArguments(values: Record, positionals: stri const jsonl = values.jsonl === true; const jsonEnvelope = values["json-envelope"] === true; const states = stringArrayValue(values.state); - const maxValue = typeof values.max === "string" ? values.max : typeof values.limit === "string" ? values.limit : undefined; + const maxValue = typeof values.max === "string" ? values.max : undefined; if (jsonl && jsonEnvelope) { throw new Error("--jsonl and --json-envelope are mutually exclusive"); @@ -296,7 +306,7 @@ function toParsedCliArguments(values: Record, positionals: stri ...(typeof values["order-by"] === "string" ? { orderBy: values["order-by"] } : {}), ...(typeof values["order-dir"] === "string" ? { orderDir: values["order-dir"] } : {}), all: values.all === true, - ...(maxValue === undefined ? {} : { max: parsePositiveInt(maxValue, typeof values.max === "string" ? "max" : "limit") }), + ...(maxValue === undefined ? {} : { max: parsePositiveInt(maxValue, values.maxOptionName === "limit" ? "limit" : "max") }), ...(typeof values["page-size"] === "string" ? { pageSize: parsePositiveInt(values["page-size"], "page-size") } : {}), ...(typeof values.after === "string" ? { after: values.after } : {}), sync: values.sync === true, @@ -383,8 +393,12 @@ export async function main(argv: string[], runtime: MainRuntime = defaultRuntime if (registration !== undefined) { try { const options = registration.buildOptions(args, runtime.env, runtime.stdin); - if (runtime.fetchImpl !== undefined && options !== null && typeof options === "object") { - (options as Record).fetchImpl = runtime.fetchImpl; + if (options !== null && typeof options === "object") { + Object.assign(options, { + stdout: runtime.stdout, + stderr: runtime.stderr, + ...(runtime.fetchImpl === undefined ? {} : { fetchImpl: runtime.fetchImpl }), + }); } const exitCode = await registration.handler(args.positionals.slice(1), options); if (!args.help && !args.dryRun) { @@ -462,6 +476,7 @@ async function runSchemaFreshnessCheck( credentialsFile: args.credentialsFile, ...(args.apiUrl === undefined ? {} : { apiUrl: args.apiUrl }), env: runtime.env, + stderr: runtime.stderr, fetchImpl }), new Promise((resolve) => { diff --git a/src/commands/api.ts b/src/commands/api.ts index 873c826..8cace0c 100644 --- a/src/commands/api.ts +++ b/src/commands/api.ts @@ -1,9 +1,8 @@ +import { commandIO, type CommandOptions, type CommandIO } from "../core/runtime/options.js"; import { readFile } from "node:fs/promises"; -import { failureEnvelope, successEnvelope } from "../core/output/envelope.js"; +import { successEnvelope } from "../core/output/envelope.js"; import { ExitCode } from "../core/errors/exit-codes.js"; import { emitValidationError } from "../core/output/validation-error.js"; -import type { FetchLike } from "../core/transport/graphql.js"; -import { normalizeRetryOptions } from "../core/transport/retry.js"; import { readAllStdin, isTtyInput } from "../core/io/stdin.js"; import { createCommandContext } from "../core/runtime/command-context.js"; import type { ApiCommandEntry, ApiCommandManifest } from "../generated/generate-manifest.js"; @@ -11,26 +10,15 @@ import bundledApiCommands from "../generated/manifest/api-commands.json" with { export type { ApiCommandEntry, ApiCommandManifest }; -export interface ApiCommandOptions { +export interface ApiCommandOptions extends CommandOptions { help?: boolean; - json: boolean; - jsonEnvelope: boolean; raw: boolean; - profile?: string; - configFile: string; - credentialsFile: string; - apiUrl?: string; id?: string; inputJson?: string; inputFile?: string; inputStdin: boolean; fields?: string; - env: Record; stdinStream?: NodeJS.ReadableStream; - fetchImpl?: FetchLike; - // retry flags - noRetry?: boolean; - maxRetries?: number; /** Override manifest path for testing */ manifestPath?: string; } @@ -84,68 +72,72 @@ export function searchManifest(manifest: ApiCommandManifest, term: string): ApiC // Help text generation // --------------------------------------------------------------------------- -function printApiHelp(manifest: ApiCommandManifest): void { +function printApiHelp(manifest: ApiCommandManifest, options: CommandIO): void { + const { stdout } = commandIO(options); const counts = new Map(); for (const e of manifest) { counts.set(e.resource, (counts.get(e.resource) ?? 0) + 1); } - process.stdout.write("linearctl api \n\n"); - process.stdout.write("Available resources:\n"); + stdout.write("linearctl api \n\n"); + stdout.write("Available resources:\n"); for (const [resource, count] of [...counts.entries()].sort(([a], [b]) => a.localeCompare(b))) { - process.stdout.write(` ${resource} (${count} operation${count !== 1 ? "s" : ""})\n`); + stdout.write(` ${resource} (${count} operation${count !== 1 ? "s" : ""})\n`); } - process.stdout.write("\nUse: linearctl api --help for operations\n"); - process.stdout.write("Use: linearctl api search to search commands\n"); + stdout.write("\nUse: linearctl api --help for operations\n"); + stdout.write("Use: linearctl api search to search commands\n"); } -function printResourceHelp(manifest: ApiCommandManifest, resource: string): void { +function printResourceHelp(manifest: ApiCommandManifest, resource: string, options: CommandIO): void { + const { stdout } = commandIO(options); const entries = manifest.filter((e) => e.resource === resource); - process.stdout.write(`linearctl api ${resource} \n\n`); - process.stdout.write("Operations:\n"); + stdout.write(`linearctl api ${resource} \n\n`); + stdout.write("Operations:\n"); for (const entry of entries) { const desc = entry.description !== "" ? ` ${entry.description}` : ""; const type = entry.graphqlOperationType === "mutation" ? " [mutation]" : ""; - process.stdout.write(` ${entry.operation}${type}${desc}\n`); + stdout.write(` ${entry.operation}${type}${desc}\n`); } } -function printOperationHelp(entry: ApiCommandEntry): void { - process.stdout.write(`${entry.commandPath}\n\n`); +function printOperationHelp(entry: ApiCommandEntry, options: CommandIO): void { + const { stdout } = commandIO(options); + stdout.write(`${entry.commandPath}\n\n`); if (entry.description !== "") { - process.stdout.write(`${entry.description}\n\n`); + stdout.write(`${entry.description}\n\n`); } - process.stdout.write("Usage:\n"); - process.stdout.write(` ${entry.commandPath}`); + stdout.write("Usage:\n"); + stdout.write(` ${entry.commandPath}`); if (entry.inputMode === "id" || entry.inputMode === "id-plus-json") { - process.stdout.write(" --id "); + stdout.write(" --id "); } if (entry.inputMode !== "none" && entry.inputMode !== "id") { - process.stdout.write(" [--input-json |--input-file |--input-stdin]"); + stdout.write(" [--input-json |--input-file |--input-stdin]"); } - process.stdout.write(" [--fields ] [--json]\n"); + stdout.write(" [--fields ] [--json]\n"); const args = [...entry.requiredArgs, ...entry.optionalArgs]; if (args.length > 0) { - process.stdout.write("\nGraphQL arguments:\n"); + stdout.write("\nGraphQL arguments:\n"); for (const arg of args) { const required = entry.requiredArgs.some((requiredArg) => requiredArg.name === arg.name); - process.stdout.write(` ${arg.name}: ${arg.typeName}${required ? " (required)" : ""}\n`); + stdout.write(` ${arg.name}: ${arg.typeName}${required ? " (required)" : ""}\n`); if (arg.description !== null && arg.description !== "") { - process.stdout.write(` ${arg.description}\n`); + stdout.write(` ${arg.description}\n`); } } } } -function printSearchResults(results: ApiCommandEntry[], term: string): void { +function printSearchResults(results: ApiCommandEntry[], term: string, options: CommandIO): void { + const { stderr, stdout } = commandIO(options); if (results.length === 0) { - process.stderr.write(`No commands matching '${term}'.\n`); + stderr.write(`No commands matching '${term}'.\n`); return; } - process.stdout.write(`Commands matching '${term}':\n`); + stdout.write(`Commands matching '${term}':\n`); for (const entry of results) { const desc = entry.description !== "" ? ` ${entry.description}` : ""; - process.stdout.write(` ${entry.commandPath}${desc}\n`); + stdout.write(` ${entry.commandPath}${desc}\n`); } } @@ -280,6 +272,7 @@ export async function handleApiCommand( positionals: string[], options: ApiCommandOptions ): Promise { + const { stdout } = commandIO(options); const manifest = await loadManifest(options.manifestPath); if (manifest === null) { @@ -294,7 +287,7 @@ export async function handleApiCommand( // linearctl api --help (with no positionals) if (resource === undefined) { - printApiHelp(manifest); + printApiHelp(manifest, options); return ExitCode.Success; } @@ -306,12 +299,12 @@ export async function handleApiCommand( } const results = searchManifest(manifest, term); if (options.json) { - process.stdout.write(`${JSON.stringify(results, null, 2)}\n`); + stdout.write(`${JSON.stringify(results, null, 2)}\n`); } else if (options.jsonEnvelope) { const envelope = successEnvelope(results, { sourceLayer: "generated" }); - process.stdout.write(`${JSON.stringify(envelope, null, 2)}\n`); + stdout.write(`${JSON.stringify(envelope, null, 2)}\n`); } else { - printSearchResults(results, term); + printSearchResults(results, term, options); } return ExitCode.Success; } @@ -321,7 +314,7 @@ export async function handleApiCommand( if (!manifest.some((entry) => entry.resource === resource)) { return emitValidationError(`unknown resource '${resource}'. Use 'linearctl api --help' to list resources.`, { ...options, sourceLayer: "generated" }); } - printResourceHelp(manifest, resource); + printResourceHelp(manifest, resource, options); return ExitCode.Success; } @@ -341,7 +334,7 @@ export async function handleApiCommand( } if (options.help === true) { - printOperationHelp(entry); + printOperationHelp(entry, options); return ExitCode.Success; } @@ -376,41 +369,17 @@ export async function handleApiCommand( return emitValidationError("this command requires JSON input. Use --input-json, --input-file, or --input-stdin.", { ...options, sourceLayer: "generated" }); } - const fallbackCtx = createCommandContext({ - json: options.json, - jsonEnvelope: options.jsonEnvelope, - ...(options.profile === undefined ? {} : { profile: options.profile }), - configFile: options.configFile, - credentialsFile: options.credentialsFile, - ...(options.apiUrl === undefined ? {} : { apiUrl: options.apiUrl }), - env: options.env, - ...(options.fetchImpl === undefined ? {} : { fetchImpl: options.fetchImpl }), - sourceLayer: "generated" - }); - - let retry; + let ctx; try { - retry = normalizeRetryOptions(options); + ctx = createCommandContext({ ...options, sourceLayer: "generated" }); } catch (error) { if (error instanceof RangeError) { return emitValidationError(error.message, { ...options, sourceLayer: "generated" }); } - return fallbackCtx.emitCaughtError(error); + throw error; } try { - const ctx = createCommandContext({ - json: options.json, - jsonEnvelope: options.jsonEnvelope, - ...(options.profile === undefined ? {} : { profile: options.profile }), - configFile: options.configFile, - credentialsFile: options.credentialsFile, - ...(options.apiUrl === undefined ? {} : { apiUrl: options.apiUrl }), - env: options.env, - ...(options.fetchImpl === undefined ? {} : { fetchImpl: options.fetchImpl }), - ...(retry === undefined ? {} : { retry }), - sourceLayer: "generated" - }); const variables = buildVariables(entry, options.id, inputJson); const query = buildGraphQLOperation(entry, options.fields); const response = await ctx.graphql>( @@ -428,18 +397,20 @@ export async function handleApiCommand( const data = response.body.data?.[entry.graphqlField] ?? null; if (options.raw) { - process.stdout.write(`${response.text}\n`); + stdout.write(`${response.text}\n`); } else if (options.jsonEnvelope) { return ctx.emitSuccess(data); } else if (options.json) { - process.stdout.write(`${JSON.stringify(data, null, 2)}\n`); + stdout.write(`${JSON.stringify(data, null, 2)}\n`); } else { // Human-readable fallback: just print JSON for generated commands - process.stdout.write(`${JSON.stringify(data, null, 2)}\n`); + stdout.write(`${JSON.stringify(data, null, 2)}\n`); } return ExitCode.Success; } catch (error) { - return fallbackCtx.emitCaughtError(error); + // Historically caught API failures include only an explicitly requested + // profile, unlike response errors emitted by the resolved context above. + return createCommandContext({ ...options, sourceLayer: "generated" }).emitCaughtError(error); } } diff --git a/src/commands/attachment.ts b/src/commands/attachment.ts index 124914e..46c4d0a 100644 --- a/src/commands/attachment.ts +++ b/src/commands/attachment.ts @@ -1,25 +1,18 @@ +import { commandIO, type CommandOptions, type CommandIO } from "../core/runtime/options.js"; import { emitValidationError } from "../core/output/validation-error.js"; import type { PageInfo } from "../core/output/envelope.js"; import { ExitCode } from "../core/errors/exit-codes.js"; -import type { FetchLike, GraphQLErrorPayload } from "../core/transport/graphql.js"; +import type { GraphQLErrorPayload } from "../core/transport/graphql.js"; import { paginateGraphQL, validatePaginationOptions } from "../core/pagination/pagination.js"; import type { PaginationOptions } from "../core/pagination/pagination.js"; import { streamPaginateGraphQL } from "../core/pagination/streaming.js"; import { emitDryRunResult } from "../core/output/dry-run.js"; import { normalizeRetryOptions } from "../core/transport/retry.js"; -import { CommandContext } from "../core/runtime/command-context.js"; +import { createCommandContext } from "../core/runtime/command-context.js"; import { looksLikeId } from "../core/resolution/resolve.js"; -export interface AttachmentCommandOptions { - json: boolean; - jsonEnvelope: boolean; +export interface AttachmentCommandOptions extends CommandOptions { jsonl?: boolean; - profile?: string; - configFile: string; - credentialsFile: string; - apiUrl?: string; - env: Record; - fetchImpl?: FetchLike; dryRun?: boolean; issue?: string; url?: string; @@ -29,9 +22,6 @@ export interface AttachmentCommandOptions { pageSize?: number; after?: string; quiet?: boolean; - // retry flags - noRetry?: boolean; - maxRetries?: number; } interface RawAttachment { @@ -119,46 +109,27 @@ mutation AttachmentDelete($id: String!) { } }`; -function printHumanAttachment(attachment: NormalizedAttachment): void { - process.stdout.write(`${attachment.title}\n`); - process.stdout.write(` Issue: ${attachment.issue.identifier}\n`); +function printHumanAttachment(attachment: NormalizedAttachment, options: CommandIO): void { + const { stdout } = commandIO(options); + stdout.write(`${attachment.title}\n`); + stdout.write(` Issue: ${attachment.issue.identifier}\n`); if (attachment.subtitle !== null) { - process.stdout.write(` Subtitle: ${attachment.subtitle}\n`); + stdout.write(` Subtitle: ${attachment.subtitle}\n`); } if (attachment.creator !== null) { - process.stdout.write(` Creator: ${attachment.creator.name}\n`); + stdout.write(` Creator: ${attachment.creator.name}\n`); } - process.stdout.write(` URL: ${attachment.url}\n`); -} - -/** Build a CommandContext from attachment handler options */ -function buildContext(options: AttachmentCommandOptions): CommandContext { - return new CommandContext({ - json: options.json, - jsonEnvelope: options.jsonEnvelope, - ...(options.profile === undefined ? {} : { profile: options.profile }), - configFile: options.configFile, - credentialsFile: options.credentialsFile, - ...(options.apiUrl === undefined ? {} : { apiUrl: options.apiUrl }), - env: options.env, - ...(options.fetchImpl === undefined ? {} : { fetchImpl: options.fetchImpl }), - ...(options.noRetry === true || options.maxRetries !== undefined - ? { - retry: { - ...(options.noRetry === true ? { noRetry: true } : {}), - ...(options.maxRetries !== undefined ? { maxRetries: options.maxRetries } : {}), - }, - } - : {}), - }); + stdout.write(` URL: ${attachment.url}\n`); } async function handleAttachmentList(options: AttachmentCommandOptions): Promise { + const { stdout } = commandIO(options); if (options.issue === undefined) { return emitValidationError("--issue is required for attachment list.", options); } const paginationOptions: PaginationOptions = { + stderr: commandIO(options).stderr, ...(options.all === true ? { all: true } : {}), ...(options.max === undefined ? {} : { max: options.max }), ...(options.pageSize === undefined ? {} : { pageSize: options.pageSize }), @@ -171,7 +142,7 @@ async function handleAttachmentList(options: AttachmentCommandOptions): Promise< return emitValidationError(validationError, options); } - const ctx = buildContext(options); + const ctx = createCommandContext(options); try { const profile = await ctx.resolveProfile(); @@ -204,7 +175,7 @@ async function handleAttachmentList(options: AttachmentCommandOptions): Promise< ...paginateInput, options: { ...paginationOptions, all: paginationOptions.all ?? true }, onItem: (raw) => { - process.stdout.write(JSON.stringify(normalizeAttachment(raw)) + "\n"); + stdout.write(JSON.stringify(normalizeAttachment(raw)) + "\n"); } }); } else { @@ -218,13 +189,13 @@ async function handleAttachmentList(options: AttachmentCommandOptions): Promise< if (options.jsonEnvelope) { return ctx.emitSuccess(attachments, result.pageInfo); } else if (options.json) { - process.stdout.write(`${JSON.stringify(attachments, null, 2)}\n`); + stdout.write(`${JSON.stringify(attachments, null, 2)}\n`); } else { for (const attachment of attachments) { - printHumanAttachment(attachment); + printHumanAttachment(attachment, options); } if (attachments.length === 0) { - process.stdout.write("No attachments found.\n"); + stdout.write("No attachments found.\n"); } } } @@ -236,6 +207,7 @@ async function handleAttachmentList(options: AttachmentCommandOptions): Promise< } async function handleAttachmentCreate(options: AttachmentCommandOptions): Promise { + const { stdout } = commandIO(options); if (options.issue === undefined || options.issue.trim() === "") { return emitValidationError("--issue is required for attachment create.", options); } @@ -263,7 +235,7 @@ async function handleAttachmentCreate(options: AttachmentCommandOptions): Promis return emitDryRunResult("create", "attachment", { issueId: options.issue, url: trimmedUrl, title: trimmedTitle }, options); } - const ctx = buildContext(options); + const ctx = createCommandContext(options); try { // attachmentCreate requires a UUID — resolve human-readable identifiers first. @@ -303,10 +275,10 @@ async function handleAttachmentCreate(options: AttachmentCommandOptions): Promis if (options.jsonEnvelope) { return ctx.emitSuccess(attachment); } else if (options.json) { - process.stdout.write(`${JSON.stringify(attachment, null, 2)}\n`); + stdout.write(`${JSON.stringify(attachment, null, 2)}\n`); } else { - process.stdout.write(`Created attachment "${attachment.title}" on ${attachment.issue.identifier}\n`); - process.stdout.write(` URL: ${attachment.url}\n`); + stdout.write(`Created attachment "${attachment.title}" on ${attachment.issue.identifier}\n`); + stdout.write(` URL: ${attachment.url}\n`); } return ExitCode.Success; @@ -316,11 +288,12 @@ async function handleAttachmentCreate(options: AttachmentCommandOptions): Promis } async function handleAttachmentDelete(attachmentId: string, options: AttachmentCommandOptions): Promise { + const { stdout } = commandIO(options); if (options.dryRun === true) { return emitDryRunResult("delete", "attachment", { id: attachmentId }, options); } - const ctx = buildContext(options); + const ctx = createCommandContext(options); try { const response = await ctx.graphql<{ @@ -342,9 +315,9 @@ async function handleAttachmentDelete(attachmentId: string, options: AttachmentC if (options.jsonEnvelope) { return ctx.emitSuccess(result); } else if (options.json) { - process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); + stdout.write(`${JSON.stringify(result, null, 2)}\n`); } else { - process.stdout.write(`Deleted attachment ${attachmentId}\n`); + stdout.write(`Deleted attachment ${attachmentId}\n`); } return ExitCode.Success; diff --git a/src/commands/auth.ts b/src/commands/auth.ts index 0337e75..2e8951a 100644 --- a/src/commands/auth.ts +++ b/src/commands/auth.ts @@ -1,3 +1,4 @@ +import { commandIO, type CommandOptions, type CommandIO } from "../core/runtime/options.js"; import { execFile } from "node:child_process"; import { constants } from "node:fs"; import { access } from "node:fs/promises"; @@ -6,8 +7,7 @@ import type { CredentialsStore, ProfileCredentials } from "../core/auth/credenti import { loadCredentialsFile, removeCredentialsProfile, - setCredentialsProfile, - writeCredentialsFile + setCredentialsProfile } from "../core/auth/credentials.js"; import { isNotFoundError, @@ -18,9 +18,7 @@ import { } from "../core/auth/runtime.js"; import type { LinearConfig, ProfileMetadata } from "../core/config/config-file.js"; import { - clearDefaultProfile, - loadLinearConfigFile, - removeProfileMetadata, + clearDefaultProfile, removeProfileMetadata, setDefaultProfile, setProfileMetadata, writeLinearConfigFile @@ -37,14 +35,8 @@ import { import { OAuthCallbackError, startCallbackServer } from "../core/auth/oauth-server.js"; import { successEnvelope } from "../core/output/envelope.js"; import { GraphQLTransportError, requestGraphQL } from "../core/transport/graphql.js"; -import type { FetchLike } from "../core/transport/graphql.js"; -export interface AuthCommandOptions { - json: boolean; - jsonEnvelope: boolean; - configFile: string; - credentialsFile: string; - profile?: string; +export interface AuthCommandOptions extends CommandOptions { apiKeyEnv?: string; apiKeyStdin: boolean; oauth: boolean; @@ -61,10 +53,7 @@ export interface AuthCommandOptions { noBrowser: boolean; setDefault: boolean; removeConfig: boolean; - apiUrl?: string; - env: Record; stdin: NodeJS.ReadableStream; - fetchImpl?: FetchLike; openUrl?: (url: string) => Promise; } @@ -149,10 +138,11 @@ async function fileExists(filePath: string): Promise { } function requireProfile(options: AuthCommandOptions, command: string): string | undefined { + const { stderr } = commandIO(options); const profileName = options.profile?.trim(); if (profileName === undefined || profileName === "") { - process.stderr.write(`Error: --profile is required for ${command}.\n`); + stderr.write(`Error: --profile is required for ${command}.\n`); return undefined; } @@ -160,21 +150,22 @@ function requireProfile(options: AuthCommandOptions, command: string): string | } async function readApiKey(options: AuthCommandOptions): Promise { + const { stderr } = commandIO(options); if (options.apiKeyEnv !== undefined && options.apiKeyStdin) { - process.stderr.write("Error: --api-key-env and --api-key-stdin are mutually exclusive.\n"); + stderr.write("Error: --api-key-env and --api-key-stdin are mutually exclusive.\n"); return undefined; } if (options.apiKeyEnv !== undefined) { const envName = options.apiKeyEnv.trim(); if (envName === "") { - process.stderr.write("Error: --api-key-env requires a non-empty environment variable name.\n"); + stderr.write("Error: --api-key-env requires a non-empty environment variable name.\n"); return undefined; } const apiKey = options.env[envName]; if (apiKey === undefined || apiKey.trim() === "") { - process.stderr.write(`Error: environment variable ${envName} is not set or is empty.\n`); + stderr.write(`Error: environment variable ${envName} is not set or is empty.\n`); return undefined; } @@ -183,26 +174,27 @@ async function readApiKey(options: AuthCommandOptions): Promise or --api-key-stdin.\n"); + stderr.write("Error: API key login requires --api-key-env or --api-key-stdin.\n"); return undefined; } function readOAuthClientId(options: AuthCommandOptions): string | undefined { + const { stderr } = commandIO(options); if (options.oauthClientId !== undefined && options.oauthClientIdEnv !== undefined) { - process.stderr.write("Error: --oauth-client-id and --oauth-client-id-env are mutually exclusive.\n"); + stderr.write("Error: --oauth-client-id and --oauth-client-id-env are mutually exclusive.\n"); return undefined; } @@ -210,13 +202,13 @@ function readOAuthClientId(options: AuthCommandOptions): string | undefined { if (options.oauthClientIdEnv !== undefined) { const envName = options.oauthClientIdEnv.trim(); if (envName === "") { - process.stderr.write("Error: --oauth-client-id-env requires a non-empty environment variable name.\n"); + stderr.write("Error: --oauth-client-id-env requires a non-empty environment variable name.\n"); return undefined; } clientId = options.env[envName]; if (clientId === undefined || clientId.trim() === "") { - process.stderr.write(`Error: environment variable ${envName} is not set or is empty.\n`); + stderr.write(`Error: environment variable ${envName} is not set or is empty.\n`); return undefined; } } else { @@ -224,9 +216,9 @@ function readOAuthClientId(options: AuthCommandOptions): string | undefined { } if (clientId === undefined || clientId.trim() === "") { - process.stderr.write("Error: --oauth-client-id or LINEAR_CLI_CLIENT_ID environment variable is required for OAuth login.\n"); - process.stderr.write(" Create an OAuth application at: https://linear.app/settings/api/applications\n"); - process.stderr.write(" Then pass the client ID via --oauth-client-id or set LINEAR_CLI_CLIENT_ID.\n"); + stderr.write("Error: --oauth-client-id or LINEAR_CLI_CLIENT_ID environment variable is required for OAuth login.\n"); + stderr.write(" Create an OAuth application at: https://linear.app/settings/api/applications\n"); + stderr.write(" Then pass the client ID via --oauth-client-id or set LINEAR_CLI_CLIENT_ID.\n"); return undefined; } @@ -234,22 +226,23 @@ function readOAuthClientId(options: AuthCommandOptions): string | undefined { } async function readOAuthClientSecret(options: AuthCommandOptions): Promise { + const { stderr } = commandIO(options); const readFromStdin = options.oauthClientSecretStdin === true; if (options.oauthClientSecretEnv !== undefined && readFromStdin) { - process.stderr.write("Error: --oauth-client-secret-env and --oauth-client-secret-stdin are mutually exclusive.\n"); + stderr.write("Error: --oauth-client-secret-env and --oauth-client-secret-stdin are mutually exclusive.\n"); return undefined; } if (options.oauthClientSecretEnv !== undefined) { const envName = options.oauthClientSecretEnv.trim(); if (envName === "") { - process.stderr.write("Error: --oauth-client-secret-env requires a non-empty environment variable name.\n"); + stderr.write("Error: --oauth-client-secret-env requires a non-empty environment variable name.\n"); return undefined; } const clientSecret = options.env[envName]; if (clientSecret === undefined || clientSecret.trim() === "") { - process.stderr.write(`Error: environment variable ${envName} is not set or is empty.\n`); + stderr.write(`Error: environment variable ${envName} is not set or is empty.\n`); return undefined; } @@ -258,20 +251,20 @@ async function readOAuthClientSecret(options: AuthCommandOptions): Promise or --oauth-client-secret-stdin.\n"); + stderr.write("Error: OAuth client-credentials login requires --oauth-client-secret-env or --oauth-client-secret-stdin.\n"); return undefined; } @@ -279,6 +272,7 @@ async function validateApiKey( apiKey: string, options: AuthCommandOptions ): Promise { + const { stderr } = commandIO(options); try { const data = await requestGraphQL({ query: "query LinearCliAuthViewer { viewer { id name email organization { id name urlKey } } }", @@ -294,7 +288,7 @@ async function validateApiKey( return data.viewer; } catch (error) { if (error instanceof GraphQLTransportError && (error.status === 401 || error.status === 403)) { - process.stderr.write(`Error: authentication failed: ${error.message}\n`); + stderr.write(`Error: authentication failed: ${error.message}\n`); return undefined; } @@ -358,32 +352,33 @@ function buildAuthStatusProfile( }; } -function printAuthStatus(status: AuthStatus): void { - process.stdout.write(`Default profile: ${status.defaultProfile ?? "(none)"}\n\n`); +function printAuthStatus(status: AuthStatus, options: CommandIO): void { + const { stdout } = commandIO(options); + stdout.write(`Default profile: ${status.defaultProfile ?? "(none)"}\n\n`); if (status.profiles.length === 0) { - process.stdout.write("Profiles: (none)\n"); + stdout.write("Profiles: (none)\n"); return; } - process.stdout.write("Profiles:\n"); + stdout.write("Profiles:\n"); for (const profile of status.profiles) { - process.stdout.write(` ${profile.name}\n`); - process.stdout.write(` Type: ${profile.type ?? "(missing credentials)"}\n`); + stdout.write(` ${profile.name}\n`); + stdout.write(` Type: ${profile.type ?? "(missing credentials)"}\n`); if (profile.workspace !== undefined) { - process.stdout.write(` Workspace: ${profile.workspace}\n`); + stdout.write(` Workspace: ${profile.workspace}\n`); } if (profile.userEmail !== undefined) { - process.stdout.write(` User: ${profile.userEmail}\n`); + stdout.write(` User: ${profile.userEmail}\n`); } if (profile.expiresAt !== undefined) { - process.stdout.write(` Expires: ${profile.expiresAt}\n`); + stdout.write(` Expires: ${profile.expiresAt}\n`); } - process.stdout.write(` Source: ${profile.source === "credentials-file" ? "credentials file" : "missing"}\n\n`); + stdout.write(` Source: ${profile.source === "credentials-file" ? "credentials file" : "missing"}\n\n`); } } @@ -397,12 +392,14 @@ interface WhoamiViewerResponse { } async function handleWhoami(options: AuthCommandOptions, extraPositionals: string[]): Promise { + const { stderr, stdout } = commandIO(options); if (extraPositionals.length > 0) { - process.stderr.write("Error: auth whoami does not accept positional arguments.\n"); + stderr.write("Error: auth whoami does not accept positional arguments.\n"); return ExitCode.ValidationError; } const profile = await resolveStoredProfile({ + ...(options.fetchImpl === undefined ? {} : { fetchImpl: options.fetchImpl }), paths: { configFile: options.configFile, credentialsFile: options.credentialsFile @@ -434,11 +431,11 @@ async function handleWhoami(options: AuthCommandOptions, extraPositionals: strin if (options.jsonEnvelope) { const envelope = successEnvelope(result, { sourceLayer: "curated", profile: profile.name }); - process.stdout.write(`${JSON.stringify(envelope, null, 2)}\n`); + stdout.write(`${JSON.stringify(envelope, null, 2)}\n`); } else if (options.json) { - process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); + stdout.write(`${JSON.stringify(result, null, 2)}\n`); } else { - process.stdout.write(`Logged in as ${result.user.name} (${result.user.email}) in workspace ${result.organization.name} (${result.organization.urlKey})\n`); + stdout.write(`Logged in as ${result.user.name} (${result.user.email}) in workspace ${result.organization.name} (${result.organization.urlKey})\n`); } return ExitCode.Success; @@ -448,6 +445,7 @@ const DEFAULT_CALLBACK_PORT = 8765; const DEFAULT_OAUTH_SCOPE = "read write"; async function handleOAuthLogin(options: AuthCommandOptions): Promise { + const { stderr, stdout } = commandIO(options); const profileName = requireProfile(options, "auth login --oauth"); if (profileName === undefined) { return ExitCode.ValidationError; @@ -459,7 +457,7 @@ async function handleOAuthLogin(options: AuthCommandOptions): Promise { } const port = options.callbackPort !== undefined - ? parseCallbackPort(options.callbackPort) + ? parseCallbackPort(options.callbackPort, options) : DEFAULT_CALLBACK_PORT; if (port === undefined) { return ExitCode.ValidationError; @@ -476,13 +474,13 @@ async function handleOAuthLogin(options: AuthCommandOptions): Promise { codeChallenge }); - process.stderr.write(`OAuth callback URL: ${redirectUri}\n`); - process.stderr.write("Register this exact URL as a redirect URI on your Linear OAuth application.\n"); + stderr.write(`OAuth callback URL: ${redirectUri}\n`); + stderr.write("Register this exact URL as a redirect URI on your Linear OAuth application.\n"); if (options.noBrowser) { - process.stderr.write(`Open this URL in your browser to authorize:\n${authorizeUrl}\n\n`); + stderr.write(`Open this URL in your browser to authorize:\n${authorizeUrl}\n\n`); } else { - process.stderr.write("Opening browser for Linear authorization...\n"); + stderr.write("Opening browser for Linear authorization...\n"); try { if (options.openUrl !== undefined) { await options.openUrl(authorizeUrl); @@ -490,18 +488,18 @@ async function handleOAuthLogin(options: AuthCommandOptions): Promise { await openUrlInBrowser(authorizeUrl); } } catch { - process.stderr.write(`Could not open browser. Open this URL manually:\n${authorizeUrl}\n\n`); + stderr.write(`Could not open browser. Open this URL manually:\n${authorizeUrl}\n\n`); } } - process.stderr.write("Waiting for authorization callback...\n"); + stderr.write("Waiting for authorization callback...\n"); let callbackResult: { code: string }; try { callbackResult = await startCallbackServer({ port, expectedState: state }); } catch (error) { if (error instanceof OAuthCallbackError) { - process.stderr.write(`Error: ${error.message}\n`); + stderr.write(`Error: ${error.message}\n`); return error.reason === "state-mismatch" ? ExitCode.AuthenticationError : ExitCode.GeneralError; } throw error; @@ -518,7 +516,7 @@ async function handleOAuthLogin(options: AuthCommandOptions): Promise { }); } catch (error) { if (error instanceof OAuthTokenError) { - process.stderr.write(`Error: ${error.message}\n`); + stderr.write(`Error: ${error.message}\n`); return ExitCode.AuthenticationError; } throw error; @@ -581,13 +579,13 @@ async function handleOAuthLogin(options: AuthCommandOptions): Promise { if (options.jsonEnvelope) { const envelope = successEnvelope(result, { sourceLayer: "curated", profile: profileName }); - process.stdout.write(`${JSON.stringify(envelope, null, 2)}\n`); + stdout.write(`${JSON.stringify(envelope, null, 2)}\n`); } else if (options.json) { - process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); + stdout.write(`${JSON.stringify(result, null, 2)}\n`); } else { - process.stdout.write(`Logged in to Linear as profile "${profileName}" using OAuth.\n`); + stdout.write(`Logged in to Linear as profile "${profileName}" using OAuth.\n`); if (viewer.email !== undefined) { - process.stdout.write(`User: ${viewer.email}\n`); + stdout.write(`User: ${viewer.email}\n`); } } @@ -595,6 +593,7 @@ async function handleOAuthLogin(options: AuthCommandOptions): Promise { } async function handleOAuthClientCredentialsLogin(options: AuthCommandOptions): Promise { + const { stderr, stdout } = commandIO(options); const profileName = requireProfile(options, "auth login --oauth-client-credentials"); if (profileName === undefined) { return ExitCode.ValidationError; @@ -622,7 +621,7 @@ async function handleOAuthClientCredentialsLogin(options: AuthCommandOptions): P }); } catch (error) { if (error instanceof OAuthTokenError) { - process.stderr.write(`Error: ${redactSecret(error.message, clientSecret)}\n`); + stderr.write(`Error: ${redactSecret(error.message, clientSecret)}\n`); return ExitCode.AuthenticationError; } throw error; @@ -665,11 +664,11 @@ async function handleOAuthClientCredentialsLogin(options: AuthCommandOptions): P if (options.jsonEnvelope) { const envelope = successEnvelope(result, { sourceLayer: "curated", profile: profileName }); - process.stdout.write(`${JSON.stringify(envelope, null, 2)}\n`); + stdout.write(`${JSON.stringify(envelope, null, 2)}\n`); } else if (options.json) { - process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); + stdout.write(`${JSON.stringify(result, null, 2)}\n`); } else { - process.stdout.write(`Logged in to Linear as profile "${profileName}" using OAuth client credentials.\n`); + stdout.write(`Logged in to Linear as profile "${profileName}" using OAuth client credentials.\n`); } return ExitCode.Success; @@ -706,10 +705,11 @@ function openUrlInBrowser(url: string): Promise { }); } -function parseCallbackPort(value: string): number | undefined { +function parseCallbackPort(value: string, options: CommandIO): number | undefined { + const { stderr } = commandIO(options); const port = parseInt(value, 10); if (!Number.isFinite(port) || port < 1 || port > 65535) { - process.stderr.write("Error: --callback-port must be a valid port number (1-65535).\n"); + stderr.write("Error: --callback-port must be a valid port number (1-65535).\n"); return undefined; } return port; @@ -719,6 +719,7 @@ async function validateOAuthToken( accessToken: string, options: AuthCommandOptions ): Promise { + const { stderr } = commandIO(options); try { const data = await requestGraphQL({ query: "query LinearCliAuthViewer { viewer { id name email organization { id name urlKey } } }", @@ -736,7 +737,7 @@ async function validateOAuthToken( return data.viewer; } catch (error) { if (error instanceof GraphQLTransportError && (error.status === 401 || error.status === 403)) { - process.stderr.write(`Error: OAuth token validation failed: ${error.message}\n`); + stderr.write(`Error: OAuth token validation failed: ${error.message}\n`); return undefined; } @@ -745,13 +746,14 @@ async function validateOAuthToken( } async function handleLogin(options: AuthCommandOptions, extraPositionals: string[]): Promise { + const { stderr, stdout } = commandIO(options); if (extraPositionals.length > 0) { - process.stderr.write("Error: auth login does not accept positional arguments.\n"); + stderr.write("Error: auth login does not accept positional arguments.\n"); return ExitCode.ValidationError; } if (options.oauth && options.oauthClientCredentials === true) { - process.stderr.write("Error: --oauth and --oauth-client-credentials are mutually exclusive.\n"); + stderr.write("Error: --oauth and --oauth-client-credentials are mutually exclusive.\n"); return ExitCode.ValidationError; } @@ -825,13 +827,13 @@ async function handleLogin(options: AuthCommandOptions, extraPositionals: string if (options.jsonEnvelope) { const envelope = successEnvelope(result, { sourceLayer: "curated", profile: profileName }); - process.stdout.write(`${JSON.stringify(envelope, null, 2)}\n`); + stdout.write(`${JSON.stringify(envelope, null, 2)}\n`); } else if (options.json) { - process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); + stdout.write(`${JSON.stringify(result, null, 2)}\n`); } else { - process.stdout.write(`Logged in to Linear as profile "${profileName}" using API key authentication.\n`); + stdout.write(`Logged in to Linear as profile "${profileName}" using API key authentication.\n`); if (viewer.email !== undefined) { - process.stdout.write(`User: ${viewer.email}\n`); + stdout.write(`User: ${viewer.email}\n`); } } @@ -839,8 +841,9 @@ async function handleLogin(options: AuthCommandOptions, extraPositionals: string } async function handleLogout(options: AuthCommandOptions, extraPositionals: string[]): Promise { + const { stderr, stdout } = commandIO(options); if (extraPositionals.length > 0) { - process.stderr.write("Error: auth logout does not accept positional arguments.\n"); + stderr.write("Error: auth logout does not accept positional arguments.\n"); return ExitCode.ValidationError; } @@ -878,14 +881,14 @@ async function handleLogout(options: AuthCommandOptions, extraPositionals: strin if (options.jsonEnvelope) { const envelope = successEnvelope(result, { sourceLayer: "curated", profile: profileName }); - process.stdout.write(`${JSON.stringify(envelope, null, 2)}\n`); + stdout.write(`${JSON.stringify(envelope, null, 2)}\n`); } else if (options.json) { - process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); + stdout.write(`${JSON.stringify(result, null, 2)}\n`); } else { - process.stdout.write(`Logged out profile "${profileName}".\n`); - process.stdout.write(credentialsRemoved ? "Credentials removed.\n" : "No credentials were present.\n"); + stdout.write(`Logged out profile "${profileName}".\n`); + stdout.write(credentialsRemoved ? "Credentials removed.\n" : "No credentials were present.\n"); if (defaultProfileCleared) { - process.stdout.write("Default profile cleared.\n"); + stdout.write("Default profile cleared.\n"); } } @@ -896,6 +899,7 @@ export async function handleAuthCommand( positionals: string[], options: AuthCommandOptions ): Promise { + const { stderr, stdout } = commandIO(options); const [subcommand, profileName, ...extraPositionals] = positionals; if (subcommand === "whoami") { @@ -912,7 +916,7 @@ export async function handleAuthCommand( if (subcommand === "status") { if (profileName !== undefined || extraPositionals.length > 0) { - process.stderr.write("Error: auth status does not accept positional arguments.\n"); + stderr.write("Error: auth status does not accept positional arguments.\n"); return ExitCode.ValidationError; } @@ -924,11 +928,11 @@ export async function handleAuthCommand( if (options.jsonEnvelope) { const envelope = successEnvelope(status, { sourceLayer: "curated" }); - process.stdout.write(`${JSON.stringify(envelope, null, 2)}\n`); + stdout.write(`${JSON.stringify(envelope, null, 2)}\n`); } else if (options.json) { - process.stdout.write(`${JSON.stringify(status, null, 2)}\n`); + stdout.write(`${JSON.stringify(status, null, 2)}\n`); } else { - printAuthStatus(status); + printAuthStatus(status, options); } return ExitCode.Success; @@ -936,18 +940,18 @@ export async function handleAuthCommand( if (subcommand === "switch") { if (profileName === undefined || extraPositionals.length > 0) { - process.stderr.write("Error: usage: linearctl auth switch \n"); + stderr.write("Error: usage: linearctl auth switch \n"); return ExitCode.ValidationError; } if (!(await fileExists(options.credentialsFile))) { - process.stderr.write(`Error: Profile "${profileName}" does not exist.\n`); + stderr.write(`Error: Profile "${profileName}" does not exist.\n`); return ExitCode.ValidationError; } const credentials = await loadCredentialsFile(options.credentialsFile); if (!Object.hasOwn(credentials.profiles, profileName)) { - process.stderr.write(`Error: Profile "${profileName}" does not exist.\n`); + stderr.write(`Error: Profile "${profileName}" does not exist.\n`); return ExitCode.ValidationError; } @@ -959,16 +963,16 @@ export async function handleAuthCommand( if (options.jsonEnvelope) { const envelope = successEnvelope(switchResult, { sourceLayer: "curated", profile: profileName }); - process.stdout.write(`${JSON.stringify(envelope, null, 2)}\n`); + stdout.write(`${JSON.stringify(envelope, null, 2)}\n`); } else if (options.json) { - process.stdout.write(`${JSON.stringify(switchResult, null, 2)}\n`); + stdout.write(`${JSON.stringify(switchResult, null, 2)}\n`); } else { - process.stdout.write(`Default Linear profile set to "${profileName}".\n`); + stdout.write(`Default Linear profile set to "${profileName}".\n`); } return ExitCode.Success; } - process.stderr.write("Error: unsupported auth command. Try linearctl auth status or linearctl auth switch .\n"); + stderr.write("Error: unsupported auth command. Try linearctl auth status or linearctl auth switch .\n"); return ExitCode.ValidationError; } diff --git a/src/commands/comment.ts b/src/commands/comment.ts index 61f9750..1b9928d 100644 --- a/src/commands/comment.ts +++ b/src/commands/comment.ts @@ -1,25 +1,17 @@ +import { commandIO, type CommandOptions, type CommandIO } from "../core/runtime/options.js"; import { emitValidationError } from "../core/output/validation-error.js"; import type { PageInfo } from "../core/output/envelope.js"; import { ExitCode } from "../core/errors/exit-codes.js"; -import type { FetchLike } from "../core/transport/graphql.js"; import { paginateGraphQL, validatePaginationOptions } from "../core/pagination/pagination.js"; import type { PaginationOptions } from "../core/pagination/pagination.js"; import { streamPaginateGraphQL } from "../core/pagination/streaming.js"; import { emitDryRunResult } from "../core/output/dry-run.js"; import { normalizeRetryOptions } from "../core/transport/retry.js"; -import { CommandContext } from "../core/runtime/command-context.js"; +import { createCommandContext } from "../core/runtime/command-context.js"; import { resolveBodyInput } from "../core/io/text-input.js"; -export interface CommentCommandOptions { - json: boolean; - jsonEnvelope: boolean; +export interface CommentCommandOptions extends CommandOptions { jsonl?: boolean; - profile?: string; - configFile: string; - credentialsFile: string; - apiUrl?: string; - env: Record; - fetchImpl?: FetchLike; dryRun?: boolean; issue?: string; body?: string; @@ -30,9 +22,6 @@ export interface CommentCommandOptions { pageSize?: number; after?: string; quiet?: boolean; - // retry flags - noRetry?: boolean; - maxRetries?: number; } interface RawComment { @@ -132,45 +121,26 @@ mutation CommentDelete($id: String!) { } }`; -function printHumanComment(comment: NormalizedCommentFull): void { +function printHumanComment(comment: NormalizedCommentFull, options: CommandIO): void { + const { stdout } = commandIO(options); const author = comment.user?.name ?? "Unknown"; const issueRef = comment.issue?.identifier ?? ""; - process.stdout.write(`${issueRef} Comment by ${author}\n`); + stdout.write(`${issueRef} Comment by ${author}\n`); const bodyPreview = comment.body.split("\n")[0] ?? ""; if (bodyPreview.length > 0) { - process.stdout.write(` ${bodyPreview}\n`); + stdout.write(` ${bodyPreview}\n`); } - process.stdout.write(` URL: ${comment.url}\n`); -} - -/** Build a CommandContext from comment handler options */ -function buildContext(options: CommentCommandOptions): CommandContext { - return new CommandContext({ - json: options.json, - jsonEnvelope: options.jsonEnvelope, - ...(options.profile === undefined ? {} : { profile: options.profile }), - configFile: options.configFile, - credentialsFile: options.credentialsFile, - ...(options.apiUrl === undefined ? {} : { apiUrl: options.apiUrl }), - env: options.env, - ...(options.fetchImpl === undefined ? {} : { fetchImpl: options.fetchImpl }), - ...(options.noRetry === true || options.maxRetries !== undefined - ? { - retry: { - ...(options.noRetry === true ? { noRetry: true } : {}), - ...(options.maxRetries !== undefined ? { maxRetries: options.maxRetries } : {}), - }, - } - : {}), - }); + stdout.write(` URL: ${comment.url}\n`); } async function handleCommentList(options: CommentCommandOptions): Promise { + const { stdout } = commandIO(options); if (options.issue === undefined) { return emitValidationError(" or --issue is required for comment list.", options); } const paginationOptions: PaginationOptions = { + stderr: commandIO(options).stderr, ...(options.all === true ? { all: true } : {}), ...(options.max === undefined ? {} : { max: options.max }), ...(options.pageSize === undefined ? {} : { pageSize: options.pageSize }), @@ -183,7 +153,7 @@ async function handleCommentList(options: CommentCommandOptions): Promise { - process.stdout.write(`${JSON.stringify(normalizeCommentFull(raw))}\n`); + stdout.write(`${JSON.stringify(normalizeCommentFull(raw))}\n`); } }); } else { @@ -247,13 +217,13 @@ async function handleCommentList(options: CommentCommandOptions): Promise { + const { stdout } = commandIO(options); if (options.issue === undefined) { return emitValidationError("--issue is required for comment create.", options); } @@ -284,7 +255,7 @@ async function handleCommentCreate(options: CommentCommandOptions): Promise { + const { stdout } = commandIO(options); let body: string | undefined; try { body = await resolveBodyInput(options); @@ -351,7 +323,7 @@ async function handleCommentUpdate(commentId: string, options: CommentCommandOpt return emitDryRunResult("update", "comment", { id: commentId, body }, options); } - const ctx = buildContext(options); + const ctx = createCommandContext(options); try { const response = await ctx.graphql<{ @@ -374,10 +346,10 @@ async function handleCommentUpdate(commentId: string, options: CommentCommandOpt if (options.jsonEnvelope) { return ctx.emitSuccess(comment); } else if (options.json) { - process.stdout.write(`${JSON.stringify(comment, null, 2)}\n`); + stdout.write(`${JSON.stringify(comment, null, 2)}\n`); } else { - process.stdout.write(`Updated comment ${comment.id}\n`); - process.stdout.write(` URL: ${comment.url}\n`); + stdout.write(`Updated comment ${comment.id}\n`); + stdout.write(` URL: ${comment.url}\n`); } return ExitCode.Success; @@ -387,11 +359,12 @@ async function handleCommentUpdate(commentId: string, options: CommentCommandOpt } async function handleCommentDelete(commentId: string, options: CommentCommandOptions): Promise { + const { stdout } = commandIO(options); if (options.dryRun === true) { return emitDryRunResult("delete", "comment", { id: commentId }, options); } - const ctx = buildContext(options); + const ctx = createCommandContext(options); try { const response = await ctx.graphql<{ @@ -413,9 +386,9 @@ async function handleCommentDelete(commentId: string, options: CommentCommandOpt if (options.jsonEnvelope) { return ctx.emitSuccess(result); } else if (options.json) { - process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); + stdout.write(`${JSON.stringify(result, null, 2)}\n`); } else { - process.stdout.write(`Deleted comment ${commentId}\n`); + stdout.write(`Deleted comment ${commentId}\n`); } return ExitCode.Success; diff --git a/src/commands/cycle.ts b/src/commands/cycle.ts index f5788fc..5f071c9 100644 --- a/src/commands/cycle.ts +++ b/src/commands/cycle.ts @@ -1,24 +1,16 @@ +import { commandIO, type CommandOptions, type CommandIO } from "../core/runtime/options.js"; import { emitValidationError } from "../core/output/validation-error.js"; import type { PageInfo } from "../core/output/envelope.js"; import { ExitCode } from "../core/errors/exit-codes.js"; -import type { FetchLike } from "../core/transport/graphql.js"; import { paginateGraphQL, validatePaginationOptions, type PaginationOptions } from "../core/pagination/pagination.js"; import { streamPaginateGraphQL } from "../core/pagination/streaming.js"; import { emitDryRunResult } from "../core/output/dry-run.js"; import { resolveTeamId, looksLikeId } from "../core/resolution/resolve.js"; import { normalizeRetryOptions } from "../core/transport/retry.js"; -import { CommandContext } from "../core/runtime/command-context.js"; +import { createCommandContext } from "../core/runtime/command-context.js"; -export interface CycleCommandOptions { - json: boolean; - jsonEnvelope: boolean; +export interface CycleCommandOptions extends CommandOptions { jsonl?: boolean; - profile?: string; - configFile: string; - credentialsFile: string; - apiUrl?: string; - env: Record; - fetchImpl?: FetchLike; dryRun?: boolean; // cycle flags name?: string; @@ -33,9 +25,6 @@ export interface CycleCommandOptions { pageSize?: number; after?: string; quiet?: boolean; - // retry flags - noRetry?: boolean; - maxRetries?: number; } const CURATED_CYCLE_BASE_FRAGMENT = ` @@ -161,6 +150,7 @@ async function handleCycleArchive( options: CycleCommandOptions, invokedAs: "archive" | "delete" = "archive" ): Promise { + const { stdout } = commandIO(options); if (options.dryRun === true) { const input = invokedAs === "delete" ? { @@ -173,7 +163,7 @@ async function handleCycleArchive( return emitDryRunResult(invokedAs, "cycle", input, options); } - const ctx = buildContext(options); + const ctx = createCommandContext(options); try { const response = await ctx.graphql<{ cycleArchive: { success: boolean } }>( @@ -198,9 +188,9 @@ async function handleCycleArchive( if (options.jsonEnvelope) { return ctx.emitSuccess(result); } else if (options.json) { - process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); + stdout.write(`${JSON.stringify(result, null, 2)}\n`); } else { - process.stdout.write( + stdout.write( invokedAs === "delete" ? `Linear does not support cycle deletion; archived cycle ${id} instead.\n` : `Archived cycle ${id}\n` @@ -285,48 +275,28 @@ function lastNumber(values: number[]): number | null { return values.length > 0 ? values[values.length - 1]! : null; } -function printHumanCycle(cycle: NormalizedCycle): void { +function printHumanCycle(cycle: NormalizedCycle, options: CommandIO): void { + const { stdout } = commandIO(options); const label = cycle.name !== null ? `${cycle.name} (#${cycle.number})` : `Cycle #${cycle.number}`; - process.stdout.write(`${label}\n`); - process.stdout.write(` Team: ${cycle.team.name}\n`); + stdout.write(`${label}\n`); + stdout.write(` Team: ${cycle.team.name}\n`); if (cycle.startsAt !== null) { - process.stdout.write(` Start: ${cycle.startsAt}\n`); + stdout.write(` Start: ${cycle.startsAt}\n`); } if (cycle.endsAt !== null) { - process.stdout.write(` End: ${cycle.endsAt}\n`); + stdout.write(` End: ${cycle.endsAt}\n`); } if (cycle.completedAt !== null) { - process.stdout.write(` Done: ${cycle.completedAt}\n`); + stdout.write(` Done: ${cycle.completedAt}\n`); } } -/** Build a CommandContext from cycle handler options */ -function buildContext(options: CycleCommandOptions): CommandContext { - return new CommandContext({ - json: options.json, - jsonEnvelope: options.jsonEnvelope, - ...(options.profile === undefined ? {} : { profile: options.profile }), - configFile: options.configFile, - credentialsFile: options.credentialsFile, - ...(options.apiUrl === undefined ? {} : { apiUrl: options.apiUrl }), - env: options.env, - ...(options.fetchImpl === undefined ? {} : { fetchImpl: options.fetchImpl }), - ...(options.noRetry === true || options.maxRetries !== undefined - ? { - retry: { - ...(options.noRetry === true ? { noRetry: true } : {}), - ...(options.maxRetries !== undefined ? { maxRetries: options.maxRetries } : {}), - }, - } - : {}), - }); -} - async function handleCycleGet( id: string, options: CycleCommandOptions ): Promise { - const ctx = buildContext(options); + const { stdout } = commandIO(options); + const ctx = createCommandContext(options); try { const response = await ctx.graphql<{ cycle: RawCycle | null }>( @@ -347,9 +317,9 @@ async function handleCycleGet( if (options.jsonEnvelope) { return ctx.emitSuccess(cycle); } else if (options.json) { - process.stdout.write(`${JSON.stringify(cycle, null, 2)}\n`); + stdout.write(`${JSON.stringify(cycle, null, 2)}\n`); } else { - printHumanCycle(cycle); + printHumanCycle(cycle, options); } return ExitCode.Success; @@ -359,7 +329,9 @@ async function handleCycleGet( } async function handleCycleList(options: CycleCommandOptions): Promise { + const { stdout } = commandIO(options); const paginationOptions: PaginationOptions = { + stderr: commandIO(options).stderr, all: options.all, max: options.max, pageSize: options.pageSize, @@ -372,7 +344,7 @@ async function handleCycleList(options: CycleCommandOptions): Promise { return emitValidationError(validationError, options); } - const ctx = buildContext(options); + const ctx = createCommandContext(options); try { const profile = await ctx.resolveProfile(); @@ -407,7 +379,7 @@ async function handleCycleList(options: CycleCommandOptions): Promise { ...commonPaginateInput, options: { ...paginationOptions, all: paginationOptions.all ?? true }, onItem: (raw) => { - process.stdout.write(`${JSON.stringify(normalizeCycle(raw))}\n`); + stdout.write(`${JSON.stringify(normalizeCycle(raw))}\n`); } }); } else { @@ -421,11 +393,11 @@ async function handleCycleList(options: CycleCommandOptions): Promise { if (options.jsonEnvelope) { return ctx.emitSuccess(cycles, pageInfo); } else if (options.json) { - process.stdout.write(`${JSON.stringify(cycles, null, 2)}\n`); + stdout.write(`${JSON.stringify(cycles, null, 2)}\n`); } else { for (const cycle of cycles) { - printHumanCycle(cycle); - process.stdout.write("\n"); + printHumanCycle(cycle, options); + stdout.write("\n"); } } } @@ -437,7 +409,8 @@ async function handleCycleList(options: CycleCommandOptions): Promise { } async function handleCycleCurrent(options: CycleCommandOptions): Promise { - const ctx = buildContext(options); + const { stdout } = commandIO(options); + const ctx = createCommandContext(options); try { const profile = await ctx.resolveProfile(); @@ -470,9 +443,9 @@ async function handleCycleCurrent(options: CycleCommandOptions): Promise if (options.jsonEnvelope) { return ctx.emitSuccess(cycle); } else if (options.json) { - process.stdout.write(`${JSON.stringify(cycle, null, 2)}\n`); + stdout.write(`${JSON.stringify(cycle, null, 2)}\n`); } else { - printHumanCycle(cycle); + printHumanCycle(cycle, options); } return ExitCode.Success; @@ -482,6 +455,7 @@ async function handleCycleCurrent(options: CycleCommandOptions): Promise } async function handleCycleCreate(options: CycleCommandOptions): Promise { + const { stdout } = commandIO(options); if (options.team === undefined) { return emitValidationError("--team is required for cycle create.", options); } @@ -507,7 +481,7 @@ async function handleCycleCreate(options: CycleCommandOptions): Promise return emitDryRunResult("create", "cycle", input, options); } - const ctx = buildContext(options); + const ctx = createCommandContext(options); try { const resolverOpts = await ctx.resolverOptions(); @@ -533,10 +507,10 @@ async function handleCycleCreate(options: CycleCommandOptions): Promise if (options.jsonEnvelope) { return ctx.emitSuccess(cycle); } else if (options.json) { - process.stdout.write(`${JSON.stringify(cycle, null, 2)}\n`); + stdout.write(`${JSON.stringify(cycle, null, 2)}\n`); } else { const label = cycle.name !== null ? cycle.name : `Cycle #${cycle.number}`; - process.stdout.write(`Created cycle: ${label}\n`); + stdout.write(`Created cycle: ${label}\n`); } return ExitCode.Success; @@ -549,6 +523,7 @@ async function handleCycleUpdate( id: string, options: CycleCommandOptions ): Promise { + const { stdout } = commandIO(options); const input: Record = {}; if (options.name !== undefined) { @@ -572,7 +547,7 @@ async function handleCycleUpdate( return emitDryRunResult("update", "cycle", { id, ...input }, options); } - const ctx = buildContext(options); + const ctx = createCommandContext(options); try { const response = await ctx.graphql<{ @@ -595,10 +570,10 @@ async function handleCycleUpdate( if (options.jsonEnvelope) { return ctx.emitSuccess(cycle); } else if (options.json) { - process.stdout.write(`${JSON.stringify(cycle, null, 2)}\n`); + stdout.write(`${JSON.stringify(cycle, null, 2)}\n`); } else { const label = cycle.name !== null ? cycle.name : `Cycle #${cycle.number}`; - process.stdout.write(`Updated cycle: ${label}\n`); + stdout.write(`Updated cycle: ${label}\n`); } return ExitCode.Success; diff --git a/src/commands/file.ts b/src/commands/file.ts index 93b1125..7cec77e 100644 --- a/src/commands/file.ts +++ b/src/commands/file.ts @@ -1,3 +1,4 @@ +import { commandIO, type CommandOptions } from "../core/runtime/options.js"; import { open } from "node:fs/promises"; import type { FileHandle } from "node:fs/promises"; import { downloadFile, uploadFile as streamUploadFile } from "../core/io/file-transfer.js"; @@ -8,18 +9,10 @@ import { emitValidationError } from "../core/output/validation-error.js"; import { authorizationHeader } from "../core/transport/graphql.js"; import type { FetchLike } from "../core/transport/graphql.js"; import { emitDryRunResult } from "../core/output/dry-run.js"; -import { CommandContext } from "../core/runtime/command-context.js"; +import { createCommandContext } from "../core/runtime/command-context.js"; import { runTwoStepWorkflow, WorkflowStepError } from "../core/runtime/workflow.js"; -export interface FileCommandOptions { - json: boolean; - jsonEnvelope: boolean; - profile?: string; - configFile: string; - credentialsFile: string; - apiUrl?: string; - env: Record; - fetchImpl?: FetchLike; +export interface FileCommandOptions extends CommandOptions { dryRun?: boolean; issue?: string; output?: string; @@ -27,9 +20,6 @@ export interface FileCommandOptions { transferTimeout?: string; /** Optional cancellation for embedded callers, in addition to SIGINT/SIGTERM. */ signal?: AbortSignal; - // retry flags - noRetry?: boolean; - maxRetries?: number; } const CONTENT_TYPE_MAP: Record = { @@ -133,33 +123,12 @@ interface AttachmentUrlResponse { } | null; } -/** Build a CommandContext from file handler options */ -function buildContext(options: FileCommandOptions): CommandContext { - return new CommandContext({ - json: options.json, - jsonEnvelope: options.jsonEnvelope, - ...(options.profile === undefined ? {} : { profile: options.profile }), - configFile: options.configFile, - credentialsFile: options.credentialsFile, - ...(options.apiUrl === undefined ? {} : { apiUrl: options.apiUrl }), - env: options.env, - ...(options.fetchImpl === undefined ? {} : { fetchImpl: options.fetchImpl }), - ...(options.noRetry === true || options.maxRetries !== undefined - ? { - retry: { - ...(options.noRetry === true ? { noRetry: true } : {}), - ...(options.maxRetries !== undefined ? { maxRetries: options.maxRetries } : {}), - }, - } - : {}), - }); -} - async function handleFileUpload( filePath: string, options: FileCommandOptions, transferOptions: TransferOptions ): Promise { + const { stdout } = commandIO(options); const resolvedPath = resolve(filePath); const fileName = basename(resolvedPath); const contentType = contentTypeFromExtension(fileName); @@ -184,7 +153,7 @@ async function handleFileUpload( return emitValidationError(`cannot read regular file: ${resolvedPath}`, options); } - const ctx = buildContext(options); + const ctx = createCommandContext(options); try { const fetchImpl = options.fetchImpl ?? fetch; @@ -271,11 +240,11 @@ async function handleFileUpload( return ctx.emitSuccess(result); } - process.stdout.write(`Uploaded ${fileName} (${size} bytes)\n`); - process.stdout.write(` Asset URL: ${result.assetUrl}\n`); + stdout.write(`Uploaded ${fileName} (${size} bytes)\n`); + stdout.write(` Asset URL: ${result.assetUrl}\n`); if (result.attachment !== undefined) { const att = result.attachment as { id: string; title: string; url: string }; - process.stdout.write(` Attachment: ${att.id}\n`); + stdout.write(` Attachment: ${att.id}\n`); } return ExitCode.Success; @@ -293,6 +262,7 @@ async function handleFileUrl( attachmentId: string, options: FileCommandOptions ): Promise { + const { stdout } = commandIO(options); let expiresIn = 60; if (options.expiresIn !== undefined) { const parsed = Number(options.expiresIn); @@ -314,7 +284,7 @@ async function handleFileUrl( headers: { ...existing, "public-file-urls-expire-in": String(expiresIn) }, }); }; - const ctx = buildContext({ ...options, fetchImpl: wrappedFetch }); + const ctx = createCommandContext({ ...options, fetchImpl: wrappedFetch }); try { const response = await ctx.graphql( @@ -336,7 +306,7 @@ async function handleFileUrl( return ctx.emitSuccess(result); } - process.stdout.write(`${result.url}\n`); + stdout.write(`${result.url}\n`); return ExitCode.Success; } catch (error) { return ctx.emitCaughtError(error); @@ -348,6 +318,7 @@ async function handleFileDownload( options: FileCommandOptions, transferOptions: TransferOptions ): Promise { + const { stdout } = commandIO(options); try { const parsed = new URL(downloadUrl); if (parsed.protocol !== "https:") { @@ -360,7 +331,7 @@ async function handleFileDownload( return emitValidationError("invalid URL.", options); } - const ctx = buildContext(options); + const ctx = createCommandContext(options); try { const profile = await ctx.resolveProfile(); @@ -379,7 +350,7 @@ async function handleFileDownload( return ctx.emitSuccess(result); } - process.stdout.write(`Downloaded ${outputPath} (${size} bytes)\n`); + stdout.write(`Downloaded ${outputPath} (${size} bytes)\n`); return ExitCode.Success; } catch (error) { return ctx.emitCaughtError(error); diff --git a/src/commands/gql.ts b/src/commands/gql.ts index c38c1bf..6b1ef50 100644 --- a/src/commands/gql.ts +++ b/src/commands/gql.ts @@ -1,40 +1,29 @@ +import { commandIO, type CommandOptions, type CommandIO } from "../core/runtime/options.js"; import { readFile } from "node:fs/promises"; import { failureEnvelope, successEnvelope, formatCommandErrorHuman } from "../core/output/envelope.js"; import type { JsonEnvelope, CommandError } from "../core/output/envelope.js"; import { emitValidationError } from "../core/output/validation-error.js"; import { mapCommandFailure } from "../core/errors/command-failure.js"; import { ExitCode } from "../core/errors/exit-codes.js"; -import { executeGraphQL } from "../core/transport/graphql.js"; -import type { FetchLike, GraphQLErrorPayload } from "../core/transport/graphql.js"; -import { executeGraphQLWithRetry, normalizeRetryOptions } from "../core/transport/retry.js"; -import { resolveStoredProfile } from "../core/auth/runtime.js"; +import type { GraphQLErrorPayload } from "../core/transport/graphql.js"; +import { createCommandContext } from "../core/runtime/command-context.js"; import { isTtyInput, readAllStdin } from "../core/io/stdin.js"; import { INTROSPECTION_QUERY } from "../core/schema/introspection-query.js"; -export interface GqlCommandOptions { - json: boolean; - jsonEnvelope: boolean; +export interface GqlCommandOptions extends CommandOptions { raw: boolean; stdin: boolean; file?: string; varsFile?: string; vars: string[]; - profile?: string; - configFile: string; - credentialsFile: string; - apiUrl?: string; - env: Record; stdinStream: NodeJS.ReadableStream; - fetchImpl?: FetchLike; - // retry flags - noRetry?: boolean; - maxRetries?: number; } export async function handleGqlCommand( positionals: string[], options: GqlCommandOptions ): Promise { + const { stdout } = commandIO(options); const [subcommand, ...rest] = positionals; if (subcommand === undefined) { @@ -79,35 +68,17 @@ export async function handleGqlCommand( return emitValidationError("gql introspect does not accept --var or --vars-file input.", { ...options, sourceLayer: "raw-graphql" }); } - const profile = await resolveStoredProfile({ - paths: { - configFile: options.configFile, - credentialsFile: options.credentialsFile - }, - ...(options.profile === undefined ? {} : { explicitProfile: options.profile }), - env: options.env - }); - - const graphqlInput = { - query: document, - ...(Object.keys(variables).length === 0 ? {} : { variables }), - credentials: profile.credentials, - ...(options.apiUrl === undefined - ? profile.metadata.baseUrl === undefined - ? {} - : { apiUrl: profile.metadata.baseUrl } - : { apiUrl: options.apiUrl }), - ...(options.fetchImpl === undefined ? {} : { fetchImpl: options.fetchImpl }) - }; - const retry = normalizeRetryOptions(options); - const response = retry === undefined - ? await executeGraphQL(graphqlInput) - : await executeGraphQLWithRetry({ ...graphqlInput, retry }); + const ctx = createCommandContext({ ...options, sourceLayer: "raw-graphql" }); + const profile = await ctx.resolveProfile(); + const response = await ctx.graphql( + document, + Object.keys(variables).length === 0 ? undefined : variables + ); const errors = mapGraphQLErrors(response.body.errors); if (options.raw) { - process.stdout.write(`${response.text}\n`); + stdout.write(`${response.text}\n`); return errors.length > 0 ? 1 : 0; } @@ -117,18 +88,18 @@ export async function handleGqlCommand( errors, profile: profile.name }); - process.stdout.write(`${JSON.stringify(envelope, null, 2)}\n`); + stdout.write(`${JSON.stringify(envelope, null, 2)}\n`); return errors.length > 0 ? 1 : 0; } if (errors.length > 0) { - printCommandError(errors[0] ?? { category: "general", message: "GraphQL request failed" }); + printCommandError(errors[0] ?? { category: "general", message: "GraphQL request failed" }, options); return 1; } if (options.json) { const data = response.body.data ?? null; - process.stdout.write(`${JSON.stringify(data, null, 2)}\n`); + stdout.write(`${JSON.stringify(data, null, 2)}\n`); return 0; } @@ -141,9 +112,9 @@ export async function handleGqlCommand( sourceLayer: "raw-graphql", ...(options.profile === undefined ? {} : { profile: options.profile }) }); - process.stdout.write(`${JSON.stringify(envelope, null, 2)}\n`); + stdout.write(`${JSON.stringify(envelope, null, 2)}\n`); } else { - printCommandError(failure.error); + printCommandError(failure.error, options); } return failure.exitCode; @@ -187,7 +158,7 @@ function findGraphQLOperationStart(document: string): number { async function resolveGraphQLDocument( subcommand: string, positionals: string[], - options: Pick + options: Pick ): Promise { if (subcommand === "introspect") { if (positionals.length > 0 || options.stdin || options.file !== undefined) { @@ -330,6 +301,7 @@ function buildRawGraphQLEnvelope(input: { }; } -function printCommandError(error: CommandError): void { - process.stderr.write(`${formatCommandErrorHuman(error)}\n`); +function printCommandError(error: CommandError, options: CommandIO): void { + const { stderr } = commandIO(options); + stderr.write(`${formatCommandErrorHuman(error)}\n`); } diff --git a/src/commands/issue.ts b/src/commands/issue.ts index b0371ba..271ee33 100644 --- a/src/commands/issue.ts +++ b/src/commands/issue.ts @@ -1,2006 +1,13 @@ import { emitValidationError } from "../core/output/validation-error.js"; -import { failureEnvelope, successEnvelope } from "../core/output/envelope.js"; -import type { CommandError, PageInfo } from "../core/output/envelope.js"; -import { ExitCode } from "../core/errors/exit-codes.js"; -import { mapCommandFailure } from "../core/errors/command-failure.js"; -import type { FetchLike } from "../core/transport/graphql.js"; -import { paginateGraphQL, validatePaginationOptions } from "../core/pagination/pagination.js"; -import type { PaginationOptions } from "../core/pagination/pagination.js"; -import { streamPaginateGraphQL } from "../core/pagination/streaming.js"; -import { normalizeRetryOptions } from "../core/transport/retry.js"; -import { emitDryRunResult } from "../core/output/dry-run.js"; -import { resolveBodyInput, resolveDescriptionInput } from "../core/io/text-input.js"; -import { - resolveTeamId, - resolveUserId, - resolveLabelId, - resolveStateId, - resolveProjectId, - looksLikeId, - ResolutionError, - type ResolverOptions -} from "../core/resolution/resolve.js"; -import { CommandContext } from "../core/runtime/command-context.js"; - -export interface IssueCommandOptions { - json: boolean; - jsonEnvelope: boolean; - jsonl?: boolean; - profile?: string; - configFile: string; - credentialsFile: string; - apiUrl?: string; - env: Record; - fetchImpl?: FetchLike; - dryRun?: boolean; - yes?: boolean; - confirm?: boolean; - // issue create/update flags - title?: string; - team?: string; - allTeams?: boolean; - description?: string; - descriptionFile?: string; - stdinStream?: NodeJS.ReadableStream; - priority?: string; - estimate?: string; - dueDate?: string; - assignee?: string; - label?: string; - state?: string; - states?: string[]; - status?: string; - milestone?: string; - projectMilestone?: string; - inputJson?: string; - // bulk operation flags - ids?: string; - // issue comment flags - body?: string; - bodyFile?: string; - // issue attach-slack flags - url?: string; - sync?: boolean; - // parent issue flag - parent?: string; - // issue list flags - cycle?: string; - project?: string; - filterJson?: string; - createdAfter?: string; - updatedAfter?: string; - completedAfter?: string; - orderBy?: string; - orderDir?: string; - // issue search flags - query?: string; - search?: string; - // pagination flags - all?: boolean; - max?: number; - pageSize?: number; - after?: string; - quiet?: boolean; - // retry flags - noRetry?: boolean; - maxRetries?: number; -} - -/** Build a CommandContext from issue handler options */ -function buildContext(options: IssueCommandOptions): CommandContext { - return new CommandContext({ - json: options.json, - jsonEnvelope: options.jsonEnvelope, - ...(options.profile === undefined ? {} : { profile: options.profile }), - configFile: options.configFile, - credentialsFile: options.credentialsFile, - ...(options.apiUrl === undefined ? {} : { apiUrl: options.apiUrl }), - env: options.env, - ...(options.fetchImpl === undefined ? {} : { fetchImpl: options.fetchImpl }), - ...(options.noRetry === true || options.maxRetries !== undefined - ? { - retry: { - ...(options.noRetry === true ? { noRetry: true } : {}), - ...(options.maxRetries !== undefined ? { maxRetries: options.maxRetries } : {}), - }, - } - : {}), - }); -} - -const CURATED_ISSUE_FRAGMENT = ` -fragment CuratedIssue on Issue { - id - identifier - title - description - priority - estimate - dueDate - state { id name type } - team { id key name } - assignee { id name email } - creator { id name email } - cycle { id number name } - project { id name } - projectMilestone { id name } - parent { id identifier title } - labels { nodes { id name } } - url - trashed - archivedAt - createdAt - updatedAt -}`; - -const CURATED_ISSUE_SEARCH_RESULT_FRAGMENT = ` -fragment CuratedIssueSearchResult on IssueSearchResult { - id - identifier - title - description - priority - estimate - dueDate - state { id name type } - team { id key name } - assignee { id name email } - creator { id name email } - cycle { id number name } - project { id name } - projectMilestone { id name } - parent { id identifier title } - labels { nodes { id name } } - url - trashed - archivedAt - createdAt - updatedAt -}`; - -const ISSUE_GET_QUERY = ` -query IssueGet($id: String!) { - issue(id: $id) { - ...CuratedIssue - } -} -${CURATED_ISSUE_FRAGMENT}`; - -const ISSUE_CREATE_MUTATION = ` -mutation IssueCreate($input: IssueCreateInput!) { - issueCreate(input: $input) { - success - issue { - ...CuratedIssue - } - } -} -${CURATED_ISSUE_FRAGMENT}`; - -const ISSUE_LIST_QUERY = ` -query IssueList($first: Int!, $after: String, $filter: IssueFilter, $orderBy: PaginationOrderBy) { - issues(first: $first, after: $after, filter: $filter, orderBy: $orderBy) { - nodes { - ...CuratedIssue - } - pageInfo { - hasNextPage - endCursor - } - } -} -${CURATED_ISSUE_FRAGMENT}`; - -const ISSUE_SEARCH_QUERY = ` -query IssueSearch($first: Int!, $after: String, $term: String!, $filter: IssueFilter) { - searchIssues(first: $first, after: $after, term: $term, filter: $filter) { - nodes { - ...CuratedIssueSearchResult - } - pageInfo { - hasNextPage - endCursor - } - } -} -${CURATED_ISSUE_SEARCH_RESULT_FRAGMENT}`; - -const ISSUE_UPDATE_MUTATION = ` -mutation IssueUpdate($id: String!, $input: IssueUpdateInput!) { - issueUpdate(id: $id, input: $input) { - success - issue { - ...CuratedIssue - } - } -} -${CURATED_ISSUE_FRAGMENT}`; - -const ISSUE_ARCHIVE_MUTATION = ` -mutation IssueArchive($id: String!) { - issueArchive(id: $id) { - success - } -}`; - -const ISSUE_DELETE_MUTATION = ` -mutation IssueDelete($id: String!) { - issueDelete(id: $id) { - success - } -}`; - -const COMMENT_CREATE_MUTATION = ` -mutation CommentCreate($input: CommentCreateInput!) { - commentCreate(input: $input) { - success - comment { - id - body - createdAt - user { id name email } - } - } -}`; - -const ATTACHMENT_LINK_SLACK_MUTATION = ` -mutation AttachmentLinkSlack($issueId: String!, $url: String!, $syncToCommentThread: Boolean, $title: String) { - attachmentLinkSlack(issueId: $issueId, url: $url, syncToCommentThread: $syncToCommentThread, title: $title) { - success - attachment { - id - title - subtitle - url - issue { id identifier title } - createdAt - } - } -}`; - -interface RawSlackAttachment { - id: string; - title: string | null; - subtitle: string | null; - url: string; - issue: { id: string; identifier: string; title: string }; - createdAt: string; -} - -interface RawIssue { - id: string; - identifier: string; - title: string; - description: string | null; - priority: number; - estimate: number | null; - dueDate: string | null; - state: { id: string; name: string; type: string } | null; - team: { id: string; key: string; name: string }; - assignee: { id: string; name: string; email: string } | null; - creator: { id: string; name: string; email: string } | null; - cycle: { id: string; number: number; name: string | null } | null; - project: { id: string; name: string } | null; - projectMilestone: { id: string; name: string } | null; - parent: { id: string; identifier: string; title: string } | null; - labels: { nodes: Array<{ id: string; name: string }> }; - url: string; - trashed: boolean | null; - archivedAt: string | null; - createdAt: string; - updatedAt: string; -} - -export interface NormalizedIssue { - id: string; - identifier: string; - title: string; - description: string | null; - priority: number; - estimate: number | null; - dueDate: string | null; - state: { id: string; name: string; type: string } | null; - team: { id: string; key: string; name: string }; - assignee: { id: string; name: string; email: string } | null; - creator: { id: string; name: string; email: string } | null; - cycle: { id: string; number: number; name: string | null } | null; - project: { id: string; name: string } | null; - projectMilestone: { id: string; name: string } | null; - parent: { id: string; identifier: string; title: string } | null; - labels: Array<{ id: string; name: string }>; - url: string; - trashed: boolean | null; - archivedAt: string | null; - createdAt: string; - updatedAt: string; -} - -export function normalizeIssue(raw: RawIssue): NormalizedIssue { - return { - id: raw.id, - identifier: raw.identifier, - title: raw.title, - description: raw.description, - priority: raw.priority, - estimate: raw.estimate, - dueDate: raw.dueDate, - state: raw.state, - team: raw.team, - assignee: raw.assignee, - creator: raw.creator, - cycle: raw.cycle, - project: raw.project, - projectMilestone: raw.projectMilestone, - parent: raw.parent, - labels: raw.labels.nodes, - url: raw.url, - trashed: raw.trashed, - archivedAt: raw.archivedAt, - createdAt: raw.createdAt, - updatedAt: raw.updatedAt - }; -} - -function printHumanIssue(issue: NormalizedIssue): void { - process.stdout.write(`${issue.identifier} ${issue.title}\n`); - if (issue.state !== null) { - process.stdout.write(` State: ${issue.state.name}\n`); - } - process.stdout.write(` Team: ${issue.team.name}\n`); - if (issue.assignee !== null) { - process.stdout.write(` Assignee: ${issue.assignee.name}\n`); - } - if (issue.priority !== 0) { - process.stdout.write(` Priority: ${issue.priority}\n`); - } - if (issue.estimate !== null) { - process.stdout.write(` Estimate: ${issue.estimate}\n`); - } - if (issue.dueDate !== null) { - process.stdout.write(` Due date: ${issue.dueDate}\n`); - } - if (issue.project !== null) { - process.stdout.write(` Project: ${issue.project.name}\n`); - } - if (issue.trashed === true) { - process.stdout.write(" Trashed: true\n"); - } - if (issue.archivedAt !== null) { - process.stdout.write(` Archived: ${issue.archivedAt}\n`); - } - if (issue.labels.length > 0) { - process.stdout.write(` Labels: ${issue.labels.map((l) => l.name).join(", ")}\n`); - } - process.stdout.write(` URL: ${issue.url}\n`); -} - -function shouldResolveProjectIdentifier(value: string): boolean { - return !looksLikeId(value); -} - -function isUnsetValue(value: string): boolean { - const normalized = value.trim().toLowerCase(); - return normalized === "" || normalized === "none" || normalized === "null"; -} - -function isUnassignedValue(value: string): boolean { - return isUnsetValue(value) || value.trim().toLowerCase() === "unassigned"; -} - -function isValidDueDate(value: string): boolean { - const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value); - if (match === null) { - return false; - } - - const year = Number(match[1]); - const month = Number(match[2]); - const day = Number(match[3]); - if (year < 1 || month < 1 || month > 12 || day < 1) { - return false; - } - - const daysInMonth = [31, isLeapYear(year) ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; - return day <= daysInMonth[month - 1]!; -} - -function isLeapYear(year: number): boolean { - return year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0); -} - -function dueDateValidationError(allowUnset: boolean): string { - return allowUnset - ? '--due-date must be a valid YYYY-MM-DD date or "none" to clear it.' - : "--due-date must be a valid date in YYYY-MM-DD format."; -} - -async function buildIssueFilter( - options: IssueCommandOptions, - defaultTeam: string | undefined, - resolverOpts: ResolverOptions, - applyDefaultTeam = true -): Promise<{ filter?: Record; validationError?: string }> { - if (options.filterJson !== undefined) { - try { - const parsed = JSON.parse(options.filterJson) as unknown; - if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { - return { validationError: "--filter-json must be a JSON object." }; - } - return { filter: parsed as Record }; - } catch { - return { validationError: "--filter-json contains invalid JSON." }; - } - } - - const filter: Record = {}; - let resolvedTeamId: string | undefined; - const effectiveTeam = options.allTeams ? undefined : (options.team ?? (applyDefaultTeam ? defaultTeam : undefined)); - if (effectiveTeam !== undefined) { - resolvedTeamId = looksLikeId(effectiveTeam) ? effectiveTeam : await resolveTeamId(effectiveTeam, resolverOpts); - filter.team = { id: { eq: resolvedTeamId } }; - } - - const stateValue = options.state ?? options.status; - const rawStateValues = options.states ?? (stateValue === undefined ? [] : [stateValue]); - const stateValues = rawStateValues.flatMap((value) => value.split(",").map((state) => state.trim())); - if (stateValues.some((state) => state === "")) { - return { validationError: "--state values must not be empty." }; - } - - const stateFilterPromise = (async (): Promise | undefined> => { - const buildStateFilter = async (state: string): Promise> => { - if (looksLikeId(state)) { - return { id: { eq: state } }; - } - if (resolvedTeamId === undefined) { - return { name: { eqIgnoreCase: state } }; - } - return { id: { eq: await resolveStateId(state, resolvedTeamId, resolverOpts) } }; - }; - - if (stateValues.length === 1) { - return { state: await buildStateFilter(stateValues[0]!) }; - } - if (stateValues.length > 1) { - const stateFilters = await Promise.all(stateValues.map(buildStateFilter)); - return { or: stateFilters.map((state) => ({ state })) }; - } - return undefined; - })(); - - const assigneeFilterPromise = options.assignee === undefined - ? Promise.resolve(undefined) - : (async (): Promise> => { - if (isUnassignedValue(options.assignee!)) { - return { assignee: { null: true } }; - } - const assigneeId = looksLikeId(options.assignee!) - ? options.assignee! - : await resolveUserId(options.assignee!, resolverOpts); - return { assignee: { id: { eq: assigneeId } } }; - })(); - - const labelFilterPromise = options.label === undefined - ? Promise.resolve(undefined) - : (async (): Promise> => { - const labelId = looksLikeId(options.label!) - ? options.label! - : await resolveLabelId(options.label!, resolvedTeamId, resolverOpts); - return { labels: { some: { id: { eq: labelId } } } }; - })(); - - const projectFilterPromise = options.project === undefined - ? Promise.resolve(undefined) - : (async (): Promise> => { - const projectId = looksLikeId(options.project!) - ? options.project! - : await resolveProjectId(options.project!, resolvedTeamId, resolverOpts); - return { project: { id: { eq: projectId } } }; - })(); - - const resolvedFilters = await Promise.all([ - stateFilterPromise, - assigneeFilterPromise, - labelFilterPromise, - projectFilterPromise - ]); - for (const resolvedFilter of resolvedFilters) { - if (resolvedFilter !== undefined) { - Object.assign(filter, resolvedFilter); - } - } - - if (options.priority !== undefined) { - const parsed = Number(options.priority); - if (!Number.isInteger(parsed)) { - return { validationError: "--priority must be an integer." }; - } - filter.priority = { eq: parsed }; - } - if (options.cycle !== undefined) { - filter.cycle = { id: { eq: options.cycle } }; - } - if (options.createdAfter !== undefined) { - filter.createdAt = { gte: options.createdAfter }; - } - if (options.updatedAfter !== undefined) { - filter.updatedAt = { gte: options.updatedAfter }; - } - if (options.completedAfter !== undefined) { - filter.completedAt = { gte: options.completedAfter }; - } - if (options.dueDate !== undefined) { - if (isUnsetValue(options.dueDate)) { - filter.dueDate = { null: true }; - } else if (!isValidDueDate(options.dueDate)) { - return { validationError: dueDateValidationError(true) }; - } else { - filter.dueDate = { eq: options.dueDate }; - } - } - - return Object.keys(filter).length > 0 ? { filter } : {}; -} - -async function handleIssueGet( - identifier: string, - options: IssueCommandOptions -): Promise { - const ctx = buildContext(options); - - try { - const response = await ctx.graphql<{ issue: RawIssue | null }>( - ISSUE_GET_QUERY, - { id: identifier } - ); - - if (ctx.hasErrors(response.body.errors)) { - return ctx.emitFailure(ctx.mapGraphQLErrors(response.body.errors)); - } - - if (response.body.data?.issue === null || response.body.data?.issue === undefined) { - return ctx.emitNotFound("Issue not found"); - } - - const issue = normalizeIssue(response.body.data.issue); - - if (options.jsonEnvelope) { - return ctx.emitSuccess(issue); - } else if (options.json) { - process.stdout.write(`${JSON.stringify(issue, null, 2)}\n`); - } else { - printHumanIssue(issue); - } - - return ExitCode.Success; - } catch (error) { - return ctx.emitCaughtError(error); - } -} - -async function handleIssueCreate(options: IssueCommandOptions): Promise { - let inputFromJson: Record = {}; - - if (options.inputJson !== undefined) { - try { - const parsed = JSON.parse(options.inputJson) as unknown; - if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { - return emitValidationError("--input-json must be a JSON object.", options); - } - inputFromJson = parsed as Record; - } catch { - return emitValidationError("--input-json contains invalid JSON.", options); - } - } - - const title = options.title ?? (typeof inputFromJson.title === "string" ? inputFromJson.title : undefined); - const teamId = options.team ?? (typeof inputFromJson.teamId === "string" ? inputFromJson.teamId : undefined); - - if (title === undefined) { - return emitValidationError("--title is required for issue create.", options); - } - - if (teamId === undefined) { - return emitValidationError("--team is required for issue create.", options); - } - - const input: Record = { - ...inputFromJson, - title - }; - - let description: string | undefined; - try { - description = await resolveDescriptionInput(options); - } catch (error) { - return emitValidationError(error instanceof Error ? error.message : String(error), options); - } - if (description !== undefined) { - input.description = description; - } - if (options.priority !== undefined) { - const parsed = Number(options.priority); - if (!Number.isInteger(parsed)) { - return emitValidationError("--priority must be an integer.", options); - } - input.priority = parsed; - } - if (options.estimate !== undefined) { - const parsed = Number(options.estimate); - if (!Number.isFinite(parsed) || parsed < 0) { - return emitValidationError("--estimate must be a non-negative number.", options); - } - input.estimate = parsed; - } - if (options.dueDate !== undefined) { - if (isUnsetValue(options.dueDate) || !isValidDueDate(options.dueDate)) { - return emitValidationError(dueDateValidationError(false), options); - } - input.dueDate = options.dueDate; - } - - if (options.assignee !== undefined) { - input.assigneeId = options.assignee; - } - if (options.label !== undefined) { - input.labelIds = [options.label]; - } - if (options.state !== undefined) { - input.stateId = options.state; - } - if (options.cycle !== undefined) { - input.cycleId = options.cycle; - } - if (options.project !== undefined) { - input.projectId = options.project; - } - const projectMilestone = options.projectMilestone ?? options.milestone; - if (projectMilestone !== undefined) { - input.projectMilestoneId = projectMilestone; - } - if (options.parent !== undefined) { - input.parentId = options.parent; - } - input.teamId = teamId; - - const ctx = buildContext(options); - - try { - const resolverOpts = await ctx.resolverOptions(); - - // Resolve friendly names to IDs - const resolvedTeamId = looksLikeId(teamId) ? teamId : await resolveTeamId(teamId, resolverOpts); - input.teamId = resolvedTeamId; - - if (options.assignee !== undefined) { - input.assigneeId = looksLikeId(options.assignee) ? options.assignee : await resolveUserId(options.assignee, resolverOpts); - } - if (options.label !== undefined) { - input.labelIds = [looksLikeId(options.label) ? options.label : await resolveLabelId(options.label, resolvedTeamId, resolverOpts)]; - } - if (options.state !== undefined) { - input.stateId = looksLikeId(options.state) ? options.state : await resolveStateId(options.state, resolvedTeamId, resolverOpts); - } - if (options.project !== undefined) { - input.projectId = looksLikeId(options.project) ? options.project : await resolveProjectId(options.project, resolvedTeamId, resolverOpts); - } - if (options.parent !== undefined) { - if (looksLikeId(options.parent)) { - input.parentId = options.parent; - } else { - const parentData = await ctx.graphql<{ issue: { id: string } | null }>( - `query IssueResolve($id: String!) { issue(id: $id) { id } }`, - { id: options.parent } - ); - if (ctx.hasErrors(parentData.body.errors)) { - return ctx.emitFailure(ctx.mapGraphQLErrors(parentData.body.errors)); - } - if (parentData.body.data?.issue?.id === undefined) { - return emitValidationError(`Could not resolve parent issue "${options.parent}".`, options); - } - input.parentId = parentData.body.data.issue.id; - } - } - - if (options.dryRun === true) { - return emitDryRunResult("create", "issue", input, options); - } - - const response = await ctx.graphql<{ - issueCreate: { success: boolean; issue: RawIssue | null }; - }>(ISSUE_CREATE_MUTATION, { input }); - - if ( - ctx.hasErrors(response.body.errors) || - response.body.data?.issueCreate?.issue === null || - response.body.data?.issueCreate?.issue === undefined - ) { - const errors = ctx.mapGraphQLErrors(response.body.errors); - return ctx.emitFailure( - errors.length > 0 ? errors : [{ category: "general", message: "Issue creation failed" }] - ); - } - - const issue = normalizeIssue(response.body.data.issueCreate.issue); - - if (options.jsonEnvelope) { - return ctx.emitSuccess(issue); - } else if (options.json) { - process.stdout.write(`${JSON.stringify(issue, null, 2)}\n`); - } else { - process.stdout.write(`Created ${issue.identifier}: ${issue.title}\n`); - process.stdout.write(` URL: ${issue.url}\n`); - } - - return ExitCode.Success; - } catch (error) { - return ctx.emitCaughtError(error); - } -} - -async function handleIssueList(options: IssueCommandOptions): Promise { - const paginationOptions: PaginationOptions = { - all: options.all, - max: options.max, - pageSize: options.pageSize, - after: options.after, - quiet: options.quiet - }; - - const validationError = validatePaginationOptions(paginationOptions); - if (validationError !== undefined) { - return emitValidationError(validationError, options); - } - - if (options.orderDir !== undefined) { - return emitValidationError("--order-dir is not supported. Linear's orderBy controls both field and direction.", options); - } - - const ctx = buildContext(options); - - try { - const profile = await ctx.resolveProfile(); - const resolverOpts = await ctx.resolverOptions(); - const filterResult = await buildIssueFilter(options, profile.metadata.defaultTeam, resolverOpts); - if (filterResult.validationError !== undefined) { - return emitValidationError(filterResult.validationError, options); - } - const filter = filterResult.filter; - - const commonPaginateInput = { - query: ISSUE_LIST_QUERY, - variables: { - ...(filter === undefined ? {} : { filter }), - ...(options.orderBy === undefined ? {} : { orderBy: options.orderBy }) - }, - credentials: profile.credentials, - ...(options.apiUrl === undefined - ? profile.metadata.baseUrl === undefined - ? {} - : { apiUrl: profile.metadata.baseUrl } - : { apiUrl: options.apiUrl }), - ...(options.fetchImpl === undefined ? {} : { fetchImpl: options.fetchImpl }), - retry: normalizeRetryOptions(options), - extractConnection: (data: unknown) => { - const d = data as { issues: { nodes: RawIssue[]; pageInfo: PageInfo } }; - return d.issues; - } - }; - - if (options.jsonl === true) { - const streamOptions: PaginationOptions = { - ...paginationOptions, - all: paginationOptions.all ?? true - }; - - await streamPaginateGraphQL({ - ...commonPaginateInput, - options: streamOptions, - onItem: (raw) => { - process.stdout.write(`${JSON.stringify(normalizeIssue(raw))}\n`); - } - }); - } else { - const result = await paginateGraphQL({ - ...commonPaginateInput, - options: paginationOptions - }); - - const issues = result.items.map(normalizeIssue); - - if (options.jsonEnvelope) { - return ctx.emitSuccess(issues, result.pageInfo); - } else if (options.json) { - process.stdout.write(`${JSON.stringify(issues, null, 2)}\n`); - } else { - if (issues.length === 0) { - process.stdout.write("No issues found.\n"); - } else { - for (const issue of issues) { - const state = issue.state !== null ? issue.state.name : ""; - const assignee = issue.assignee !== null ? issue.assignee.name : ""; - process.stdout.write(`${issue.identifier}\t${issue.title}\t${state}\t${assignee}\n`); - } - } - } - } - - return ExitCode.Success; - } catch (error) { - return ctx.emitCaughtError(error); - } -} - -async function handleIssueSearch(options: IssueCommandOptions, applyDefaultTeam = false): Promise { - const trimmedQuery = (options.query ?? options.search)?.trim(); - if (trimmedQuery === undefined || trimmedQuery === "") { - return emitValidationError("usage: linearctl issue search [|--query ]", options); - } - - const paginationOptions: PaginationOptions = { - all: options.all, - max: options.max, - pageSize: options.pageSize, - after: options.after, - quiet: options.quiet - }; - - const validationError = validatePaginationOptions(paginationOptions); - if (validationError !== undefined) { - return emitValidationError(validationError, options); - } - - if (options.orderBy !== undefined || options.orderDir !== undefined) { - return emitValidationError("issue search does not support --order-by or --order-dir.", options); - } - - const ctx = buildContext(options); - - try { - const profile = await ctx.resolveProfile(); - const resolverOpts = await ctx.resolverOptions(); - const filterResult = await buildIssueFilter(options, profile.metadata.defaultTeam, resolverOpts, applyDefaultTeam); - if (filterResult.validationError !== undefined) { - return emitValidationError(filterResult.validationError, options); - } - - const commonPaginateInput = { - query: ISSUE_SEARCH_QUERY, - variables: { - term: trimmedQuery, - ...(filterResult.filter === undefined ? {} : { filter: filterResult.filter }) - }, - credentials: profile.credentials, - ...(options.apiUrl === undefined - ? profile.metadata.baseUrl === undefined - ? {} - : { apiUrl: profile.metadata.baseUrl } - : { apiUrl: options.apiUrl }), - ...(options.fetchImpl === undefined ? {} : { fetchImpl: options.fetchImpl }), - retry: normalizeRetryOptions(options), - extractConnection: (data: unknown) => { - const d = data as { searchIssues: { nodes: RawIssue[]; pageInfo: PageInfo } }; - return d.searchIssues; - } - }; - - if (options.jsonl === true) { - const streamOptions: PaginationOptions = { - ...paginationOptions, - all: paginationOptions.all ?? true - }; - - await streamPaginateGraphQL({ - ...commonPaginateInput, - options: streamOptions, - onItem: (raw) => { - process.stdout.write(`${JSON.stringify(normalizeIssue(raw))}\n`); - } - }); - } else { - const result = await paginateGraphQL({ - ...commonPaginateInput, - options: paginationOptions - }); - - const issues = result.items.map(normalizeIssue); - - if (options.jsonEnvelope) { - return ctx.emitSuccess(issues, result.pageInfo); - } else if (options.json) { - process.stdout.write(`${JSON.stringify(issues, null, 2)}\n`); - } else { - if (issues.length === 0) { - process.stdout.write("No issues found.\n"); - } else { - for (const issue of issues) { - const state = issue.state !== null ? issue.state.name : ""; - const assignee = issue.assignee !== null ? issue.assignee.name : ""; - process.stdout.write(`${issue.identifier}\t${issue.title}\t${state}\t${assignee}\n`); - } - } - } - } - - return ExitCode.Success; - } catch (error) { - return ctx.emitCaughtError(error); - } -} - -async function handleIssueUpdate( - identifier: string, - options: IssueCommandOptions -): Promise { - let inputFromJson: Record = {}; - - if (options.inputJson !== undefined) { - try { - const parsed = JSON.parse(options.inputJson) as unknown; - if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { - return emitValidationError("--input-json must be a JSON object.", options); - } - inputFromJson = parsed as Record; - } catch { - return emitValidationError("--input-json contains invalid JSON.", options); - } - } - - const input: Record = { ...inputFromJson }; - - if (options.title !== undefined) { - input.title = options.title; - } - let description: string | undefined; - try { - description = await resolveDescriptionInput(options); - } catch (error) { - return emitValidationError(error instanceof Error ? error.message : String(error), options); - } - if (description !== undefined) { - input.description = description; - } - if (options.priority !== undefined) { - const parsed = Number(options.priority); - if (!Number.isInteger(parsed)) { - return emitValidationError("--priority must be an integer.", options); - } - input.priority = parsed; - } - if (options.estimate !== undefined) { - const parsed = Number(options.estimate); - if (!Number.isFinite(parsed) || parsed < 0) { - return emitValidationError("--estimate must be a non-negative number.", options); - } - input.estimate = parsed; - } - if (options.dueDate !== undefined) { - if (!isUnsetValue(options.dueDate) && !isValidDueDate(options.dueDate)) { - return emitValidationError(dueDateValidationError(true), options); - } - input.dueDate = isUnsetValue(options.dueDate) ? null : options.dueDate; - } - if (options.assignee !== undefined) { - input.assigneeId = isUnassignedValue(options.assignee) ? null : options.assignee; - } - if (options.label !== undefined) { - // --label is additive. Keep raw labelIds available through --input-json for - // callers that explicitly need replacement semantics. - delete input.labelIds; - } - if (options.state !== undefined) { - input.stateId = options.state; - } - if (options.cycle !== undefined) { - input.cycleId = isUnsetValue(options.cycle) ? null : options.cycle; - } - if (options.project !== undefined) { - input.projectId = isUnsetValue(options.project) ? null : options.project; - } - const projectMilestone = options.projectMilestone ?? options.milestone; - if (projectMilestone !== undefined) { - input.projectMilestoneId = isUnsetValue(projectMilestone) ? null : projectMilestone; - } - if (options.parent !== undefined) { - input.parentId = isUnsetValue(options.parent) ? null : options.parent; - } - - if (Object.keys(input).length === 0 && options.label === undefined) { - return emitValidationError("issue update requires at least one field to update.", options); - } - - const ctx = buildContext(options); - - try { - const resolverOpts = await ctx.resolverOptions(); - - // Resolve friendly names to IDs - if ( - options.assignee !== undefined && - !isUnassignedValue(options.assignee) && - !looksLikeId(options.assignee) - ) { - input.assigneeId = await resolveUserId(options.assignee, resolverOpts); - } - - // Fetch the issue's team when label or state resolution needs it - const needsTeamLookup = - (options.label !== undefined && !looksLikeId(options.label)) || - (options.state !== undefined && !looksLikeId(options.state)) || - (options.project !== undefined && !isUnsetValue(options.project) && shouldResolveProjectIdentifier(options.project)); - let issueTeamId: string | undefined; - if (needsTeamLookup) { - const issueData = await ctx.graphql<{ issue: { team: { id: string } } | null }>( - `query IssueTeam($id: String!) { issue(id: $id) { team { id } } }`, - { id: identifier } - ); - if (ctx.hasErrors(issueData.body.errors)) { - return ctx.emitFailure(ctx.mapGraphQLErrors(issueData.body.errors)); - } - issueTeamId = issueData.body.data?.issue?.team?.id; - if (issueTeamId === undefined) { - return emitValidationError(`Could not find issue "${identifier}" or its team for name resolution.`, options); - } - } - - const labelId = options.label === undefined - ? undefined - : looksLikeId(options.label) - ? options.label - : await resolveLabelId(options.label, issueTeamId, resolverOpts); - if (labelId !== undefined) { - input.addedLabelIds = [labelId]; - } - if (options.state !== undefined && !looksLikeId(options.state)) { - input.stateId = await resolveStateId(options.state, issueTeamId!, resolverOpts); - } - if ( - options.project !== undefined && - !isUnsetValue(options.project) && - shouldResolveProjectIdentifier(options.project) - ) { - input.projectId = await resolveProjectId(options.project, issueTeamId, resolverOpts); - } - if (options.parent !== undefined && !isUnsetValue(options.parent)) { - if (looksLikeId(options.parent)) { - input.parentId = options.parent; - } else { - const parentData = await ctx.graphql<{ issue: { id: string } | null }>( - `query IssueResolve($id: String!) { issue(id: $id) { id } }`, - { id: options.parent } - ); - if (ctx.hasErrors(parentData.body.errors)) { - return ctx.emitFailure(ctx.mapGraphQLErrors(parentData.body.errors)); - } - if (parentData.body.data?.issue?.id === undefined) { - return emitValidationError(`Could not resolve parent issue "${options.parent}".`, options); - } - input.parentId = parentData.body.data.issue.id; - } - } - - if (options.dryRun === true) { - return emitDryRunResult("update", "issue", { - id: identifier, - ...input - }, options); - } - - const response = await ctx.graphql<{ - issueUpdate: { success: boolean; issue: RawIssue | null }; - }>(ISSUE_UPDATE_MUTATION, { id: identifier, input }); - - if ( - ctx.hasErrors(response.body.errors) || - response.body.data?.issueUpdate?.issue === null || - response.body.data?.issueUpdate?.issue === undefined - ) { - const errors = ctx.mapGraphQLErrors(response.body.errors); - return ctx.emitFailure( - errors.length > 0 ? errors : [{ category: "general", message: "Issue update failed" }] - ); - } - - const issue = normalizeIssue(response.body.data.issueUpdate.issue); - - if (options.jsonEnvelope) { - return ctx.emitSuccess(issue); - } else if (options.json) { - process.stdout.write(`${JSON.stringify(issue, null, 2)}\n`); - } else { - process.stdout.write(`Updated ${issue.identifier}: ${issue.title}\n`); - } - - return ExitCode.Success; - } catch (error) { - return ctx.emitCaughtError(error); - } -} - -async function handleIssueClose( - identifier: string, - options: IssueCommandOptions -): Promise { - if (options.dryRun === true) { - return emitDryRunResult("close", "issue", { - id: identifier, - ...(options.state === undefined ? {} : { state: options.state }) - }, options); - } - - const ctx = buildContext(options); - - try { - const resolverOpts = await ctx.resolverOptions(); - - // 1. Fetch the issue's team - const issueData = await ctx.graphql<{ issue: { team: { id: string } } | null }>( - `query IssueTeam($id: String!) { issue(id: $id) { team { id } } }`, - { id: identifier } - ); - - if (ctx.hasErrors(issueData.body.errors)) { - const msg = issueData.body.errors?.[0]?.message ?? "Failed to fetch issue"; - return emitError(msg, options, (await ctx.resolveProfile()).name); - } - - const teamId = issueData.body.data?.issue?.team?.id; - if (teamId === undefined) { - return emitError("Issue not found or has no team.", options, (await ctx.resolveProfile()).name, ExitCode.NotFound); - } - - // 2. Resolve the target state - let targetStateId: string; - let targetStateName: string; - - if (options.state !== undefined) { - // User specified a state — resolve and validate it is terminal. - targetStateId = looksLikeId(options.state) - ? options.state - : await resolveStateId(options.state, teamId, resolverOpts); - targetStateName = options.state; - - // Verify the state is a terminal type. - const stateCheck = await ctx.graphql<{ - workflowState: { id: string; name: string; type: string } | null - }>( - `query StateCheck($id: String!) { workflowState(id: $id) { id name type } }`, - { id: targetStateId } - ); - if (ctx.hasErrors(stateCheck.body.errors)) { - return ctx.emitFailure(ctx.mapGraphQLErrors(stateCheck.body.errors)); - } - const stateType = stateCheck.body.data?.workflowState?.type; - if (stateType !== "completed" && stateType !== "canceled") { - return emitError( - `State "${stateCheck.body.data?.workflowState?.name ?? options.state}" is type "${stateType ?? "unknown"}", not "completed" or "canceled". Use a terminal state for issue close.`, - options, (await ctx.resolveProfile()).name - ); - } - targetStateName = stateCheck.body.data?.workflowState?.name ?? options.state; - } else { - // Default: find a completed-type workflow state for the team - const statesData = await ctx.graphql<{ - workflowStates: { nodes: Array<{ id: string; name: string; type: string; position: number }> } - }>( - `query CompletedStates($filter: WorkflowStateFilter!) { - workflowStates(first: 250, filter: $filter) { - nodes { id name type position } - } - }`, - { filter: { team: { id: { eq: teamId } }, type: { eq: "completed" } } } - ); - - if (ctx.hasErrors(statesData.body.errors)) { - const msg = statesData.body.errors?.[0]?.message ?? "Failed to fetch workflow states"; - return emitError(msg, options, (await ctx.resolveProfile()).name); - } - - const candidates = statesData.body.data?.workflowStates?.nodes ?? []; - // Prefer "Done" by name, then lowest position - const completedState = - candidates.find((s) => s.name === "Done") ?? - candidates.sort((a, b) => a.position - b.position)[0]; - if (completedState === undefined) { - return emitError("No completed workflow state found for this team.", options, (await ctx.resolveProfile()).name); - } - targetStateId = completedState.id; - targetStateName = completedState.name; - } - - // 3. Transition the issue to the target state - const response = await ctx.graphql<{ - issueUpdate: { success: boolean; issue: RawIssue | null }; - }>(ISSUE_UPDATE_MUTATION, { id: identifier, input: { stateId: targetStateId } }); - - if ( - ctx.hasErrors(response.body.errors) || - response.body.data?.issueUpdate?.success !== true - ) { - const errors = ctx.mapGraphQLErrors(response.body.errors); - return ctx.emitFailure( - errors.length > 0 ? errors : [{ category: "general", message: "Issue close failed" }] - ); - } - - const issue = response.body.data.issueUpdate.issue; - const resolvedStateName = issue?.state?.name ?? targetStateName; - const result = { - identifier, - closed: true, - state: resolvedStateName, - ...(issue !== null ? { issue: normalizeIssue(issue) } : {}) - }; - - if (options.jsonEnvelope) { - return ctx.emitSuccess(result); - } else if (options.json) { - process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); - } else { - process.stdout.write(`Closed ${identifier} → ${resolvedStateName}\n`); - } - - return ExitCode.Success; - } catch (error) { - return ctx.emitCaughtError(error); - } -} - -async function handleIssueDelete( - identifier: string, - options: IssueCommandOptions -): Promise { - if (options.dryRun === true) { - return emitDryRunResult("delete", "issue", { id: identifier }, options); - } - - const ctx = buildContext(options); - - try { - const issue = await resolveIssueForDelete(identifier, ctx); - if (issue === undefined) { - return ctx.emitNotFound("Issue not found"); - } - - const response = await ctx.graphql<{ - issueDelete: { success: boolean }; - }>(ISSUE_DELETE_MUTATION, { id: issue.id }); - - if ( - ctx.hasErrors(response.body.errors) || - response.body.data?.issueDelete?.success !== true - ) { - const errors = ctx.mapGraphQLErrors(response.body.errors); - return ctx.emitFailure( - errors.length > 0 ? errors : [{ category: "general", message: "Issue delete failed" }] - ); - } - - const result = { id: issue.id, identifier: issue.identifier, deleted: true }; - - if (options.jsonEnvelope) { - return ctx.emitSuccess(result); - } else if (options.json) { - process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); - } else { - process.stdout.write(`Deleted ${issue.identifier}\n`); - } - - return ExitCode.Success; - } catch (error) { - return ctx.emitCaughtError(error); - } -} - -function emitError(message: string, options: IssueCommandOptions, profileName?: string, exitCode?: number): number { - const resolvedExitCode = exitCode ?? ExitCode.GeneralError; - const category = resolvedExitCode === ExitCode.NotFound ? "not-found" : "general"; - if (options.jsonEnvelope) { - const envelope = failureEnvelope( - [{ category, message }], - { sourceLayer: "curated", ...(profileName === undefined ? {} : { profile: profileName }) } - ); - process.stdout.write(`${JSON.stringify(envelope, null, 2)}\n`); - } else { - process.stderr.write(`Error: ${message}\n`); - } - return resolvedExitCode; -} - -async function handleIssueAssign( - identifier: string, - assigneeValue: string, - options: IssueCommandOptions -): Promise { - if (options.dryRun === true) { - return emitDryRunResult("update", "issue", { id: identifier, assigneeId: assigneeValue }, options); - } - - const ctx = buildContext(options); - - try { - const resolverOpts = await ctx.resolverOptions(); - - const assigneeId = looksLikeId(assigneeValue) ? assigneeValue : await resolveUserId(assigneeValue, resolverOpts); - - const response = await ctx.graphql<{ - issueUpdate: { success: boolean; issue: RawIssue | null }; - }>(ISSUE_UPDATE_MUTATION, { id: identifier, input: { assigneeId } }); - - if ( - ctx.hasErrors(response.body.errors) || - response.body.data?.issueUpdate?.issue === null || - response.body.data?.issueUpdate?.issue === undefined - ) { - const errors = ctx.mapGraphQLErrors(response.body.errors); - return ctx.emitFailure( - errors.length > 0 ? errors : [{ category: "general", message: "Issue assign failed" }] - ); - } - - const issue = normalizeIssue(response.body.data.issueUpdate.issue); - - if (options.jsonEnvelope) { - return ctx.emitSuccess(issue); - } else if (options.json) { - process.stdout.write(`${JSON.stringify(issue, null, 2)}\n`); - } else { - const name = issue.assignee !== null ? issue.assignee.name : assigneeId; - process.stdout.write(`Assigned ${issue.identifier} to ${name}\n`); - } - - return ExitCode.Success; - } catch (error) { - return ctx.emitCaughtError(error); - } -} - -interface RawComment { - id: string; - body: string; - createdAt: string; - user: { id: string; name: string; email: string } | null; -} - -export interface NormalizedComment { - id: string; - body: string; - createdAt: string; - user: { id: string; name: string; email: string } | null; -} - -async function handleIssueComment( - identifier: string, - options: IssueCommandOptions -): Promise { - let body: string | undefined; - try { - body = await resolveBodyInput(options); - } catch (error) { - return emitValidationError(error instanceof Error ? error.message : String(error), options); - } - - if (body === undefined || body.trim() === "") { - return emitValidationError("--body or --body-file is required for issue comment.", options); - } - - if (options.dryRun === true) { - return emitDryRunResult("create", "comment", { issueId: identifier, body }, options); - } - - const ctx = buildContext(options); - - try { - // Resolve identifier to issue ID - const getResponse = await ctx.graphql<{ issue: RawIssue | null }>( - ISSUE_GET_QUERY, - { id: identifier } - ); - - if (ctx.hasErrors(getResponse.body.errors)) { - return ctx.emitFailure(ctx.mapGraphQLErrors(getResponse.body.errors)); - } - - if (getResponse.body.data?.issue === null || getResponse.body.data?.issue === undefined) { - return ctx.emitNotFound("Issue not found"); - } - - const issueId = getResponse.body.data.issue.id; - - const response = await ctx.graphql<{ - commentCreate: { success: boolean; comment: RawComment | null }; - }>(COMMENT_CREATE_MUTATION, { input: { issueId, body } }); - - if ( - ctx.hasErrors(response.body.errors) || - response.body.data?.commentCreate?.comment === null || - response.body.data?.commentCreate?.comment === undefined - ) { - const errors = ctx.mapGraphQLErrors(response.body.errors); - return ctx.emitFailure( - errors.length > 0 ? errors : [{ category: "general", message: "Comment creation failed" }] - ); - } - - const comment: NormalizedComment = response.body.data.commentCreate.comment; - - if (options.jsonEnvelope) { - return ctx.emitSuccess(comment); - } else if (options.json) { - process.stdout.write(`${JSON.stringify(comment, null, 2)}\n`); - } else { - process.stdout.write(`Comment added to ${identifier}\n`); - } - - return ExitCode.Success; - } catch (error) { - return ctx.emitCaughtError(error); - } -} - -interface BulkResult { - succeeded: Array<{ identifier: string; [key: string]: unknown }>; - failed: Array<{ identifier: string; error: string; category?: CommandError["category"] }>; -} - -function bulkExitCode(errors: CommandError[]): number { - if (errors.some((error) => error.category === "authentication")) return ExitCode.AuthenticationError; - if (errors.some((error) => error.category === "rate-limit")) return ExitCode.RateLimitExhausted; - if (errors.some((error) => error.category === "not-found")) return ExitCode.NotFound; - return errors.length > 0 ? ExitCode.GeneralError : ExitCode.Success; -} - -async function resolveIssueForDelete( - identifier: string, - ctx: CommandContext -): Promise<{ id: string; identifier: string } | undefined> { - if (looksLikeId(identifier)) { - return { id: identifier, identifier }; - } - - const response = await ctx.graphql<{ issue: { id: string; identifier: string } | null }>( - `query IssueDeleteResolve($id: String!) { issue(id: $id) { id identifier } }`, - { id: identifier } - ); - - if (ctx.hasErrors(response.body.errors)) { - throw new Error(response.body.errors?.[0]?.message ?? "Issue lookup failed"); - } - - return response.body.data?.issue ?? undefined; -} - -function parseIds(options: IssueCommandOptions): string[] | undefined { - if (options.ids === undefined || options.ids.trim() === "") { - return undefined; - } - return options.ids.split(",").map((id) => id.trim()).filter((id) => id !== ""); -} - -async function executeBulk( - identifiers: string[], - operation: (id: string) => Promise<{ identifier: string; [key: string]: unknown }>, - options: IssueCommandOptions -): Promise { - const result: BulkResult = { succeeded: [], failed: [] }; - - for (const id of identifiers) { - try { - const item = await operation(id); - result.succeeded.push(item); - } catch (error) { - const failure = mapCommandFailure(error); - result.failed.push({ identifier: id, error: failure.error.message, category: failure.error.category }); - } - } - - const errors = result.failed.map((failure) => ({ - category: failure.category ?? "general", - message: `Bulk operation failed for ${failure.identifier}: ${failure.error}` - })) satisfies CommandError[]; - const exitCode = bulkExitCode(errors); - - if (options.jsonEnvelope) { - if (exitCode === ExitCode.Success) { - const envelope = successEnvelope(result, { sourceLayer: "curated" }); - process.stdout.write(`${JSON.stringify(envelope, null, 2)}\n`); - } else { - const envelope = failureEnvelope( - errors.length > 0 ? errors : [{ category: "general", message: "Bulk operation failed" }], - { sourceLayer: "curated", partial: result.succeeded.length > 0 } - ); - process.stdout.write(`${JSON.stringify({ ...envelope, data: result }, null, 2)}\n`); - } - } else if (options.json) { - process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); - } else { - if (result.succeeded.length > 0) { - process.stdout.write(`Succeeded: ${result.succeeded.map((s) => s.identifier).join(", ")}\n`); - } - if (result.failed.length > 0) { - process.stderr.write(`Failed: ${result.failed.map((f) => `${f.identifier} (${f.error})`).join(", ")}\n`); - } - } - - return exitCode; -} - -/** Caches per-team terminal-state resolution across a bulk-close run. */ -interface BulkCloseCache { - /** Resolved target state per team ID (options.state is fixed for the run). */ - stateByTeam: Map; -} - -async function closeIssueForBulk( - identifier: string, - options: IssueCommandOptions, - ctx: CommandContext, - resolverOpts: Awaited>, - cache: BulkCloseCache -): Promise<{ identifier: string; [key: string]: unknown }> { - const issueData = await ctx.graphql<{ issue: { team: { id: string } } | null }>( - `query IssueTeam($id: String!) { issue(id: $id) { team { id } } }`, - { id: identifier } - ); - - if (ctx.hasErrors(issueData.body.errors)) { - throw new Error(issueData.body.errors?.[0]?.message ?? "Failed to fetch issue"); - } - - const teamId = issueData.body.data?.issue?.team?.id; - if (teamId === undefined) { - throw new Error("Issue not found or has no team."); - } - - let targetStateId: string; - let targetStateName: string; - - const cachedState = cache.stateByTeam.get(teamId); - if (cachedState !== undefined) { - targetStateId = cachedState.id; - targetStateName = cachedState.name; - } else if (options.state !== undefined) { - targetStateId = looksLikeId(options.state) - ? options.state - : await resolveStateId(options.state, teamId, resolverOpts); - targetStateName = options.state; - - const stateCheck = await ctx.graphql<{ - workflowState: { id: string; name: string; type: string } | null - }>( - `query StateCheck($id: String!) { workflowState(id: $id) { id name type } }`, - { id: targetStateId } - ); - if (ctx.hasErrors(stateCheck.body.errors)) { - throw new Error(stateCheck.body.errors?.[0]?.message ?? "Failed to verify workflow state"); - } - const stateType = stateCheck.body.data?.workflowState?.type; - if (stateType !== "completed" && stateType !== "canceled") { - throw new Error( - `State "${stateCheck.body.data?.workflowState?.name ?? options.state}" is type "${stateType ?? "unknown"}", not "completed" or "canceled". Use a terminal state for issue close.` - ); - } - targetStateName = stateCheck.body.data?.workflowState?.name ?? options.state; - cache.stateByTeam.set(teamId, { id: targetStateId, name: targetStateName }); - } else { - const statesData = await ctx.graphql<{ - workflowStates: { nodes: Array<{ id: string; name: string; type: string; position: number }> } - }>( - `query CompletedStates($filter: WorkflowStateFilter!) { - workflowStates(first: 250, filter: $filter) { - nodes { id name type position } - } - }`, - { filter: { team: { id: { eq: teamId } }, type: { eq: "completed" } } } - ); - - if (ctx.hasErrors(statesData.body.errors)) { - throw new Error(statesData.body.errors?.[0]?.message ?? "Failed to fetch workflow states"); - } - - const candidates = statesData.body.data?.workflowStates?.nodes ?? []; - const completedState = - candidates.find((state) => state.name === "Done") ?? - candidates.sort((a, b) => a.position - b.position)[0]; - if (completedState === undefined) { - throw new Error("No completed workflow state found for this team."); - } - targetStateId = completedState.id; - targetStateName = completedState.name; - cache.stateByTeam.set(teamId, { id: targetStateId, name: targetStateName }); - } - - const response = await ctx.graphql<{ - issueUpdate: { success: boolean; issue: RawIssue | null }; - }>(ISSUE_UPDATE_MUTATION, { id: identifier, input: { stateId: targetStateId } }); - - if ( - ctx.hasErrors(response.body.errors) || - response.body.data?.issueUpdate?.success !== true - ) { - throw new Error(response.body.errors?.[0]?.message ?? "Issue close failed"); - } - - const issue = response.body.data.issueUpdate.issue; - const resolvedStateName = issue?.state?.name ?? targetStateName; - return { - identifier, - closed: true, - state: resolvedStateName, - ...(issue !== null ? { issue: normalizeIssue(issue) } : {}) - }; -} - -async function handleBulkUpdate(options: IssueCommandOptions): Promise { - const identifiers = parseIds(options); - if (identifiers === undefined || identifiers.length === 0) { - return emitValidationError("--ids is required for issue bulk-update.", options); - } - - const input: Record = {}; - if (options.state !== undefined) { - input.stateId = options.state; - } - if (options.assignee !== undefined) { - input.assigneeId = isUnassignedValue(options.assignee) ? null : options.assignee; - } - if (options.priority !== undefined) { - const parsed = Number(options.priority); - if (!Number.isInteger(parsed)) { - return emitValidationError("--priority must be an integer.", options); - } - input.priority = parsed; - } - if (options.estimate !== undefined) { - const parsed = Number(options.estimate); - if (!Number.isFinite(parsed) || parsed < 0) { - return emitValidationError("--estimate must be a non-negative number.", options); - } - input.estimate = parsed; - } - if (options.dueDate !== undefined) { - if (!isUnsetValue(options.dueDate) && !isValidDueDate(options.dueDate)) { - return emitValidationError(dueDateValidationError(true), options); - } - input.dueDate = isUnsetValue(options.dueDate) ? null : options.dueDate; - } - if (options.cycle !== undefined) { - input.cycleId = isUnsetValue(options.cycle) ? null : options.cycle; - } - const projectMilestone = options.projectMilestone ?? options.milestone; - if (projectMilestone !== undefined) { - input.projectMilestoneId = isUnsetValue(projectMilestone) ? null : projectMilestone; - } - - if (Object.keys(input).length === 0 && options.label === undefined) { - return emitValidationError("bulk-update requires at least one field to update (--state, --assignee, --priority, --label, --estimate, --due-date, --cycle, --project-milestone, --milestone).", options); - } - - if (options.dryRun) { - return emitDryRunResult("bulk-update", "issue", { - ids: identifiers, - update: input, - ...(options.label === undefined ? {} : { addLabel: options.label }) - }, options); - } - - const ctx = buildContext(options); - - try { - const resolverOpts = await ctx.resolverOptions(); - - // Resolve friendly names to IDs once before the bulk loop - if ( - options.assignee !== undefined && - !isUnassignedValue(options.assignee) && - !looksLikeId(options.assignee) - ) { - input.assigneeId = await resolveUserId(options.assignee, resolverOpts); - } - const labelId = options.label === undefined - ? undefined - : looksLikeId(options.label) - ? options.label - : await resolveLabelId(options.label, undefined, resolverOpts); - if (labelId !== undefined) { - input.addedLabelIds = [labelId]; - } - // State names are resolved per team — cache so issues sharing a team - // don't each pay for a resolution query. - const stateIdByTeam = new Map(); - return await executeBulk( - identifiers, - async (id) => { - const issueInput = { ...input }; - if (options.state !== undefined && !looksLikeId(options.state)) { - const issueTeam = await ctx.graphql<{ issue: { team: { id: string } } | null }>( - `query IssueTeam($id: String!) { issue(id: $id) { team { id } } }`, - { id } - ); - if (ctx.hasErrors(issueTeam.body.errors)) { - throw new Error(issueTeam.body.errors?.[0]?.message ?? "Issue team lookup failed"); - } - const teamId = issueTeam.body.data?.issue?.team?.id; - if (teamId === undefined) { - throw new Error(`Could not find issue "${id}" or its team for state resolution.`); - } - const cachedStateId = stateIdByTeam.get(teamId); - if (cachedStateId !== undefined) { - issueInput.stateId = cachedStateId; - } else { - const resolvedStateId = await resolveStateId(options.state, teamId, resolverOpts); - issueInput.stateId = resolvedStateId; - stateIdByTeam.set(teamId, resolvedStateId); - } - } - - const response = await ctx.graphql<{ - issueUpdate: { success: boolean; issue: RawIssue | null }; - }>(ISSUE_UPDATE_MUTATION, { id, input: issueInput }); - - if ( - ctx.hasErrors(response.body.errors) || - response.body.data?.issueUpdate?.issue === null || - response.body.data?.issueUpdate?.issue === undefined - ) { - throw new Error(response.body.errors?.[0]?.message ?? "Issue update failed"); - } - - const issue = normalizeIssue(response.body.data.issueUpdate.issue); - return { ...issue }; - }, - options - ); - } catch (error) { - return ctx.emitCaughtError(error); - } -} - -async function handleBulkClose(options: IssueCommandOptions): Promise { - const identifiers = parseIds(options); - if (identifiers === undefined || identifiers.length === 0) { - return emitValidationError("--ids is required for issue bulk-close.", options); - } - - if (options.dryRun) { - return emitDryRunResult("bulk-close", "issue", { - ids: identifiers, - action: "transition-to-completed", - ...(options.state === undefined ? {} : { state: options.state }) - }, options); - } - - const ctx = buildContext(options); - - try { - const resolverOpts = await ctx.resolverOptions(); - const cache: BulkCloseCache = { stateByTeam: new Map() }; - return await executeBulk( - identifiers, - async (id) => closeIssueForBulk(id, options, ctx, resolverOpts, cache), - options - ); - } catch (error) { - return ctx.emitCaughtError(error); - } -} - -async function handleBulkArchive(options: IssueCommandOptions): Promise { - const identifiers = parseIds(options); - if (identifiers === undefined || identifiers.length === 0) { - return emitValidationError("--ids is required for issue bulk-archive.", options); - } - - if (options.dryRun) { - return emitDryRunResult("bulk-archive", "issue", { ids: identifiers }, options); - } - - const ctx = buildContext(options); - - try { - return await executeBulk( - identifiers, - async (id) => { - const response = await ctx.graphql<{ - issueArchive: { success: boolean }; - }>(ISSUE_ARCHIVE_MUTATION, { id }); - - if ( - ctx.hasErrors(response.body.errors) || - response.body.data?.issueArchive?.success !== true - ) { - throw new Error(response.body.errors?.[0]?.message ?? "Issue archive failed"); - } - - return { identifier: id, archived: true }; - }, - options - ); - } catch (error) { - return ctx.emitCaughtError(error); - } -} - -async function handleBulkDelete(options: IssueCommandOptions): Promise { - const identifiers = parseIds(options); - if (identifiers === undefined || identifiers.length === 0) { - return emitValidationError("--ids is required for issue bulk-delete.", options); - } - - if (options.dryRun) { - return emitDryRunResult("bulk-delete", "issue", { ids: identifiers }, options); - } - - if (options.yes !== true && options.confirm !== true) { - return emitValidationError("issue bulk-delete is destructive; pass --yes or --confirm to proceed.", options); - } - - const ctx = buildContext(options); - - try { - return await executeBulk( - identifiers, - async (id) => { - const issue = await resolveIssueForDelete(id, ctx); - if (issue === undefined) { - throw new Error("Issue not found"); - } - - const response = await ctx.graphql<{ - issueDelete: { success: boolean }; - }>(ISSUE_DELETE_MUTATION, { id: issue.id }); - - if ( - ctx.hasErrors(response.body.errors) || - response.body.data?.issueDelete?.success !== true - ) { - throw new Error(response.body.errors?.[0]?.message ?? "Issue delete failed"); - } - - return { identifier: issue.identifier, id: issue.id, deleted: true }; - }, - options - ); - } catch (error) { - return ctx.emitCaughtError(error); - } -} - -async function handleBulkAssign(options: IssueCommandOptions): Promise { - const identifiers = parseIds(options); - if (identifiers === undefined || identifiers.length === 0) { - return emitValidationError("--ids is required for issue bulk-assign.", options); - } - - if (options.assignee === undefined || options.assignee.trim() === "") { - return emitValidationError("--assignee is required for issue bulk-assign.", options); - } - - if (options.dryRun) { - return emitDryRunResult("bulk-assign", "issue", { ids: identifiers, assignee: options.assignee }, options); - } - - const ctx = buildContext(options); - - try { - const resolverOpts = await ctx.resolverOptions(); - - // Resolve assignee name once before the bulk loop - const assigneeId = looksLikeId(options.assignee) ? options.assignee : await resolveUserId(options.assignee, resolverOpts); - - return await executeBulk( - identifiers, - async (id) => { - const response = await ctx.graphql<{ - issueUpdate: { success: boolean; issue: RawIssue | null }; - }>(ISSUE_UPDATE_MUTATION, { id, input: { assigneeId } }); - - if ( - ctx.hasErrors(response.body.errors) || - response.body.data?.issueUpdate?.issue === null || - response.body.data?.issueUpdate?.issue === undefined - ) { - throw new Error(response.body.errors?.[0]?.message ?? "Issue assign failed"); - } - - const issue = normalizeIssue(response.body.data.issueUpdate.issue); - return { ...issue }; - }, - options - ); - } catch (error) { - return ctx.emitCaughtError(error); - } -} - -async function handleIssueAttachSlack( - identifier: string, - options: IssueCommandOptions -): Promise { - if (options.url === undefined || options.url.trim() === "") { - return emitValidationError("--url is required for issue attach-slack.", options); - } - - const trimmedUrl = options.url.trim(); - let parsedUrl: URL; - try { - parsedUrl = new URL(trimmedUrl); - } catch { - return emitValidationError("--url must be a valid Slack HTTPS URL.", options); - } - const hostname = parsedUrl.hostname.toLowerCase(); - if (parsedUrl.protocol !== "https:" || (hostname !== "slack.com" && !hostname.endsWith(".slack.com"))) { - return emitValidationError("--url must be a valid Slack HTTPS URL.", options); - } - - const variables: Record = { - issueId: identifier, - url: trimmedUrl, - ...(options.sync === true ? { syncToCommentThread: true } : {}), - ...(options.title !== undefined ? { title: options.title } : {}) - }; - - if (options.dryRun === true) { - return emitDryRunResult("attach-slack", "issue", variables, options); - } - - const ctx = buildContext(options); - - try { - // Resolve identifier to UUID if it looks like a human-readable identifier (e.g. INF-2975) - let issueId = identifier; - if (!looksLikeId(identifier)) { - const issueData = await ctx.graphql<{ issue: { id: string } | null }>( - `query IssueResolve($id: String!) { issue(id: $id) { id } }`, - { id: identifier } - ); - - if (ctx.hasErrors(issueData.body.errors)) { - const msg = issueData.body.errors?.[0]?.message ?? "Failed to resolve issue"; - return emitError(msg, options, (await ctx.resolveProfile()).name); - } - - if (issueData.body.data?.issue?.id === undefined) { - return emitError(`Issue "${identifier}" not found.`, options, (await ctx.resolveProfile()).name, ExitCode.NotFound); - } - issueId = issueData.body.data.issue.id; - variables.issueId = issueId; - } - - const response = await ctx.graphql<{ - attachmentLinkSlack: { success: boolean; attachment: RawSlackAttachment | null }; - }>(ATTACHMENT_LINK_SLACK_MUTATION, variables); - - if ( - ctx.hasErrors(response.body.errors) || - response.body.data?.attachmentLinkSlack?.attachment === null || - response.body.data?.attachmentLinkSlack?.attachment === undefined - ) { - const errors = ctx.mapGraphQLErrors(response.body.errors); - return ctx.emitFailure( - errors.length > 0 ? errors : [{ category: "general", message: "Slack attachment failed" }] - ); - } - - const attachment = response.body.data.attachmentLinkSlack.attachment; - - if (options.jsonEnvelope) { - return ctx.emitSuccess(attachment); - } else if (options.json) { - process.stdout.write(`${JSON.stringify(attachment, null, 2)}\n`); - } else { - process.stdout.write(`Linked Slack thread to ${attachment.issue.identifier}\n`); - if (attachment.title !== null) { - process.stdout.write(` Title: ${attachment.title}\n`); - } - process.stdout.write(` URL: ${attachment.url}\n`); - } - - return ExitCode.Success; - } catch (error) { - return ctx.emitCaughtError(error); - } -} +import { type IssueCommandOptions } from "./issue/options.js"; +import { handleIssueGet, handleIssueSearch, handleIssueList } from "./issue/read.js"; +import { handleIssueCreate, handleIssueUpdate, handleIssueDelete } from "./issue/write.js"; +import { handleIssueClose, handleIssueAssign, handleIssueComment, handleIssueAttachSlack } from "./issue/workflow.js"; +import { handleBulkUpdate, handleBulkClose, handleBulkArchive, handleBulkDelete, handleBulkAssign } from "./issue/bulk.js"; +export type { IssueCommandOptions } from "./issue/options.js"; +export type { NormalizedIssue } from "./issue/model.js"; +export { normalizeIssue } from "./issue/model.js"; +export type { NormalizedComment } from "./issue/model.js"; export async function handleIssueCommand( positionals: string[], diff --git a/src/commands/issue/bulk.ts b/src/commands/issue/bulk.ts new file mode 100644 index 0000000..6c4b124 --- /dev/null +++ b/src/commands/issue/bulk.ts @@ -0,0 +1,472 @@ +import { commandIO } from "../../core/runtime/options.js"; +import { emitValidationError } from "../../core/output/validation-error.js"; +import { failureEnvelope, successEnvelope } from "../../core/output/envelope.js"; +import type { CommandError } from "../../core/output/envelope.js"; +import { ExitCode } from "../../core/errors/exit-codes.js"; +import { mapCommandFailure } from "../../core/errors/command-failure.js"; +import { emitDryRunResult } from "../../core/output/dry-run.js"; +import { + resolveUserId, + resolveLabelId, + resolveStateId, looksLikeId +} from "../../core/resolution/resolve.js"; +import { CommandContext, createCommandContext } from "../../core/runtime/command-context.js"; +import { type IssueCommandOptions } from "./options.js"; +import { type RawIssue, normalizeIssue } from "./model.js"; +import { ISSUE_UPDATE_MUTATION, ISSUE_ARCHIVE_MUTATION, ISSUE_DELETE_MUTATION } from "./documents.js"; +import { isUnassignedValue, isUnsetValue, isValidDueDate, dueDateValidationError, resolveIssueForDelete } from "./input.js"; +export interface BulkResult { + succeeded: Array<{ identifier: string; [key: string]: unknown }>; + failed: Array<{ identifier: string; error: string; category?: CommandError["category"] }>; +} + +export function bulkExitCode(errors: CommandError[]): number { + if (errors.some((error) => error.category === "authentication")) return ExitCode.AuthenticationError; + if (errors.some((error) => error.category === "rate-limit")) return ExitCode.RateLimitExhausted; + if (errors.some((error) => error.category === "not-found")) return ExitCode.NotFound; + return errors.length > 0 ? ExitCode.GeneralError : ExitCode.Success; +} + +export function parseIds(options: IssueCommandOptions): string[] | undefined { + if (options.ids === undefined || options.ids.trim() === "") { + return undefined; + } + return options.ids.split(",").map((id) => id.trim()).filter((id) => id !== ""); +} + +export async function executeBulk( + identifiers: string[], + operation: (id: string) => Promise<{ identifier: string; [key: string]: unknown }>, + options: IssueCommandOptions +): Promise { + const { stdout, stderr } = commandIO(options); + const result: BulkResult = { succeeded: [], failed: [] }; + + for (const id of identifiers) { + try { + const item = await operation(id); + result.succeeded.push(item); + } catch (error) { + const failure = mapCommandFailure(error); + result.failed.push({ identifier: id, error: failure.error.message, category: failure.error.category }); + } + } + + const errors = result.failed.map((failure) => ({ + category: failure.category ?? "general", + message: `Bulk operation failed for ${failure.identifier}: ${failure.error}` + })) satisfies CommandError[]; + const exitCode = bulkExitCode(errors); + + if (options.jsonEnvelope) { + if (exitCode === ExitCode.Success) { + const envelope = successEnvelope(result, { sourceLayer: "curated" }); + stdout.write(`${JSON.stringify(envelope, null, 2)}\n`); + } else { + const envelope = failureEnvelope( + errors.length > 0 ? errors : [{ category: "general", message: "Bulk operation failed" }], + { sourceLayer: "curated", partial: result.succeeded.length > 0 } + ); + stdout.write(`${JSON.stringify({ ...envelope, data: result }, null, 2)}\n`); + } + } else if (options.json) { + stdout.write(`${JSON.stringify(result, null, 2)}\n`); + } else { + if (result.succeeded.length > 0) { + stdout.write(`Succeeded: ${result.succeeded.map((s) => s.identifier).join(", ")}\n`); + } + if (result.failed.length > 0) { + stderr.write(`Failed: ${result.failed.map((f) => `${f.identifier} (${f.error})`).join(", ")}\n`); + } + } + + return exitCode; +} + +/** Caches per-team terminal-state resolution across a bulk-close run. */ +export interface BulkCloseCache { + /** Resolved target state per team ID (options.state is fixed for the run). */ + stateByTeam: Map; +} + +export async function closeIssueForBulk( + identifier: string, + options: IssueCommandOptions, + ctx: CommandContext, + resolverOpts: Awaited>, + cache: BulkCloseCache +): Promise<{ identifier: string; [key: string]: unknown }> { + const issueData = await ctx.graphql<{ issue: { team: { id: string } } | null }>( + `query IssueTeam($id: String!) { issue(id: $id) { team { id } } }`, + { id: identifier } + ); + + if (ctx.hasErrors(issueData.body.errors)) { + throw new Error(issueData.body.errors?.[0]?.message ?? "Failed to fetch issue"); + } + + const teamId = issueData.body.data?.issue?.team?.id; + if (teamId === undefined) { + throw new Error("Issue not found or has no team."); + } + + let targetStateId: string; + let targetStateName: string; + + const cachedState = cache.stateByTeam.get(teamId); + if (cachedState !== undefined) { + targetStateId = cachedState.id; + targetStateName = cachedState.name; + } else if (options.state !== undefined) { + targetStateId = looksLikeId(options.state) + ? options.state + : await resolveStateId(options.state, teamId, resolverOpts); + targetStateName = options.state; + + const stateCheck = await ctx.graphql<{ + workflowState: { id: string; name: string; type: string } | null + }>( + `query StateCheck($id: String!) { workflowState(id: $id) { id name type } }`, + { id: targetStateId } + ); + if (ctx.hasErrors(stateCheck.body.errors)) { + throw new Error(stateCheck.body.errors?.[0]?.message ?? "Failed to verify workflow state"); + } + const stateType = stateCheck.body.data?.workflowState?.type; + if (stateType !== "completed" && stateType !== "canceled") { + throw new Error( + `State "${stateCheck.body.data?.workflowState?.name ?? options.state}" is type "${stateType ?? "unknown"}", not "completed" or "canceled". Use a terminal state for issue close.` + ); + } + targetStateName = stateCheck.body.data?.workflowState?.name ?? options.state; + cache.stateByTeam.set(teamId, { id: targetStateId, name: targetStateName }); + } else { + const statesData = await ctx.graphql<{ + workflowStates: { nodes: Array<{ id: string; name: string; type: string; position: number }> } + }>( + `query CompletedStates($filter: WorkflowStateFilter!) { + workflowStates(first: 250, filter: $filter) { + nodes { id name type position } + } + }`, + { filter: { team: { id: { eq: teamId } }, type: { eq: "completed" } } } + ); + + if (ctx.hasErrors(statesData.body.errors)) { + throw new Error(statesData.body.errors?.[0]?.message ?? "Failed to fetch workflow states"); + } + + const candidates = statesData.body.data?.workflowStates?.nodes ?? []; + const completedState = + candidates.find((state) => state.name === "Done") ?? + candidates.sort((a, b) => a.position - b.position)[0]; + if (completedState === undefined) { + throw new Error("No completed workflow state found for this team."); + } + targetStateId = completedState.id; + targetStateName = completedState.name; + cache.stateByTeam.set(teamId, { id: targetStateId, name: targetStateName }); + } + + const response = await ctx.graphql<{ + issueUpdate: { success: boolean; issue: RawIssue | null }; + }>(ISSUE_UPDATE_MUTATION, { id: identifier, input: { stateId: targetStateId } }); + + if ( + ctx.hasErrors(response.body.errors) || + response.body.data?.issueUpdate?.success !== true + ) { + throw new Error(response.body.errors?.[0]?.message ?? "Issue close failed"); + } + + const issue = response.body.data.issueUpdate.issue; + const resolvedStateName = issue?.state?.name ?? targetStateName; + return { + identifier, + closed: true, + state: resolvedStateName, + ...(issue !== null ? { issue: normalizeIssue(issue) } : {}) + }; +} + +export async function handleBulkUpdate(options: IssueCommandOptions): Promise { + const identifiers = parseIds(options); + if (identifiers === undefined || identifiers.length === 0) { + return emitValidationError("--ids is required for issue bulk-update.", options); + } + + const input: Record = {}; + if (options.state !== undefined) { + input.stateId = options.state; + } + if (options.assignee !== undefined) { + input.assigneeId = isUnassignedValue(options.assignee) ? null : options.assignee; + } + if (options.priority !== undefined) { + const parsed = Number(options.priority); + if (!Number.isInteger(parsed)) { + return emitValidationError("--priority must be an integer.", options); + } + input.priority = parsed; + } + if (options.estimate !== undefined) { + const parsed = Number(options.estimate); + if (!Number.isFinite(parsed) || parsed < 0) { + return emitValidationError("--estimate must be a non-negative number.", options); + } + input.estimate = parsed; + } + if (options.dueDate !== undefined) { + if (!isUnsetValue(options.dueDate) && !isValidDueDate(options.dueDate)) { + return emitValidationError(dueDateValidationError(true), options); + } + input.dueDate = isUnsetValue(options.dueDate) ? null : options.dueDate; + } + if (options.cycle !== undefined) { + input.cycleId = isUnsetValue(options.cycle) ? null : options.cycle; + } + const projectMilestone = options.projectMilestone ?? options.milestone; + if (projectMilestone !== undefined) { + input.projectMilestoneId = isUnsetValue(projectMilestone) ? null : projectMilestone; + } + + if (Object.keys(input).length === 0 && options.label === undefined) { + return emitValidationError("bulk-update requires at least one field to update (--state, --assignee, --priority, --label, --estimate, --due-date, --cycle, --project-milestone, --milestone).", options); + } + + if (options.dryRun) { + return emitDryRunResult("bulk-update", "issue", { + ids: identifiers, + update: input, + ...(options.label === undefined ? {} : { addLabel: options.label }) + }, options); + } + + const ctx = createCommandContext(options); + + try { + const resolverOpts = await ctx.resolverOptions(); + + // Resolve friendly names to IDs once before the bulk loop + if ( + options.assignee !== undefined && + !isUnassignedValue(options.assignee) && + !looksLikeId(options.assignee) + ) { + input.assigneeId = await resolveUserId(options.assignee, resolverOpts); + } + const labelId = options.label === undefined + ? undefined + : looksLikeId(options.label) + ? options.label + : await resolveLabelId(options.label, undefined, resolverOpts); + if (labelId !== undefined) { + input.addedLabelIds = [labelId]; + } + // State names are resolved per team — cache so issues sharing a team + // don't each pay for a resolution query. + const stateIdByTeam = new Map(); + return await executeBulk( + identifiers, + async (id) => { + const issueInput = { ...input }; + if (options.state !== undefined && !looksLikeId(options.state)) { + const issueTeam = await ctx.graphql<{ issue: { team: { id: string } } | null }>( + `query IssueTeam($id: String!) { issue(id: $id) { team { id } } }`, + { id } + ); + if (ctx.hasErrors(issueTeam.body.errors)) { + throw new Error(issueTeam.body.errors?.[0]?.message ?? "Issue team lookup failed"); + } + const teamId = issueTeam.body.data?.issue?.team?.id; + if (teamId === undefined) { + throw new Error(`Could not find issue "${id}" or its team for state resolution.`); + } + const cachedStateId = stateIdByTeam.get(teamId); + if (cachedStateId !== undefined) { + issueInput.stateId = cachedStateId; + } else { + const resolvedStateId = await resolveStateId(options.state, teamId, resolverOpts); + issueInput.stateId = resolvedStateId; + stateIdByTeam.set(teamId, resolvedStateId); + } + } + + const response = await ctx.graphql<{ + issueUpdate: { success: boolean; issue: RawIssue | null }; + }>(ISSUE_UPDATE_MUTATION, { id, input: issueInput }); + + if ( + ctx.hasErrors(response.body.errors) || + response.body.data?.issueUpdate?.issue === null || + response.body.data?.issueUpdate?.issue === undefined + ) { + throw new Error(response.body.errors?.[0]?.message ?? "Issue update failed"); + } + + const issue = normalizeIssue(response.body.data.issueUpdate.issue); + return { ...issue }; + }, + options + ); + } catch (error) { + return ctx.emitCaughtError(error); + } +} + +export async function handleBulkClose(options: IssueCommandOptions): Promise { + const identifiers = parseIds(options); + if (identifiers === undefined || identifiers.length === 0) { + return emitValidationError("--ids is required for issue bulk-close.", options); + } + + if (options.dryRun) { + return emitDryRunResult("bulk-close", "issue", { + ids: identifiers, + action: "transition-to-completed", + ...(options.state === undefined ? {} : { state: options.state }) + }, options); + } + + const ctx = createCommandContext(options); + + try { + const resolverOpts = await ctx.resolverOptions(); + const cache: BulkCloseCache = { stateByTeam: new Map() }; + return await executeBulk( + identifiers, + async (id) => closeIssueForBulk(id, options, ctx, resolverOpts, cache), + options + ); + } catch (error) { + return ctx.emitCaughtError(error); + } +} + +export async function handleBulkArchive(options: IssueCommandOptions): Promise { + const identifiers = parseIds(options); + if (identifiers === undefined || identifiers.length === 0) { + return emitValidationError("--ids is required for issue bulk-archive.", options); + } + + if (options.dryRun) { + return emitDryRunResult("bulk-archive", "issue", { ids: identifiers }, options); + } + + const ctx = createCommandContext(options); + + try { + return await executeBulk( + identifiers, + async (id) => { + const response = await ctx.graphql<{ + issueArchive: { success: boolean }; + }>(ISSUE_ARCHIVE_MUTATION, { id }); + + if ( + ctx.hasErrors(response.body.errors) || + response.body.data?.issueArchive?.success !== true + ) { + throw new Error(response.body.errors?.[0]?.message ?? "Issue archive failed"); + } + + return { identifier: id, archived: true }; + }, + options + ); + } catch (error) { + return ctx.emitCaughtError(error); + } +} + +export async function handleBulkDelete(options: IssueCommandOptions): Promise { + const identifiers = parseIds(options); + if (identifiers === undefined || identifiers.length === 0) { + return emitValidationError("--ids is required for issue bulk-delete.", options); + } + + if (options.dryRun) { + return emitDryRunResult("bulk-delete", "issue", { ids: identifiers }, options); + } + + if (options.yes !== true && options.confirm !== true) { + return emitValidationError("issue bulk-delete is destructive; pass --yes or --confirm to proceed.", options); + } + + const ctx = createCommandContext(options); + + try { + return await executeBulk( + identifiers, + async (id) => { + const issue = await resolveIssueForDelete(id, ctx); + if (issue === undefined) { + throw new Error("Issue not found"); + } + + const response = await ctx.graphql<{ + issueDelete: { success: boolean }; + }>(ISSUE_DELETE_MUTATION, { id: issue.id }); + + if ( + ctx.hasErrors(response.body.errors) || + response.body.data?.issueDelete?.success !== true + ) { + throw new Error(response.body.errors?.[0]?.message ?? "Issue delete failed"); + } + + return { identifier: issue.identifier, id: issue.id, deleted: true }; + }, + options + ); + } catch (error) { + return ctx.emitCaughtError(error); + } +} + +export async function handleBulkAssign(options: IssueCommandOptions): Promise { + const identifiers = parseIds(options); + if (identifiers === undefined || identifiers.length === 0) { + return emitValidationError("--ids is required for issue bulk-assign.", options); + } + + if (options.assignee === undefined || options.assignee.trim() === "") { + return emitValidationError("--assignee is required for issue bulk-assign.", options); + } + + if (options.dryRun) { + return emitDryRunResult("bulk-assign", "issue", { ids: identifiers, assignee: options.assignee }, options); + } + + const ctx = createCommandContext(options); + + try { + const resolverOpts = await ctx.resolverOptions(); + + // Resolve assignee name once before the bulk loop + const assigneeId = looksLikeId(options.assignee) ? options.assignee : await resolveUserId(options.assignee, resolverOpts); + + return await executeBulk( + identifiers, + async (id) => { + const response = await ctx.graphql<{ + issueUpdate: { success: boolean; issue: RawIssue | null }; + }>(ISSUE_UPDATE_MUTATION, { id, input: { assigneeId } }); + + if ( + ctx.hasErrors(response.body.errors) || + response.body.data?.issueUpdate?.issue === null || + response.body.data?.issueUpdate?.issue === undefined + ) { + throw new Error(response.body.errors?.[0]?.message ?? "Issue assign failed"); + } + + const issue = normalizeIssue(response.body.data.issueUpdate.issue); + return { ...issue }; + }, + options + ); + } catch (error) { + return ctx.emitCaughtError(error); + } +} diff --git a/src/commands/issue/documents.ts b/src/commands/issue/documents.ts new file mode 100644 index 0000000..6f0fea0 --- /dev/null +++ b/src/commands/issue/documents.ts @@ -0,0 +1,150 @@ + +export const CURATED_ISSUE_FRAGMENT = ` +fragment CuratedIssue on Issue { + id + identifier + title + description + priority + estimate + dueDate + state { id name type } + team { id key name } + assignee { id name email } + creator { id name email } + cycle { id number name } + project { id name } + projectMilestone { id name } + parent { id identifier title } + labels { nodes { id name } } + url + trashed + archivedAt + createdAt + updatedAt +}`; + +export const CURATED_ISSUE_SEARCH_RESULT_FRAGMENT = ` +fragment CuratedIssueSearchResult on IssueSearchResult { + id + identifier + title + description + priority + estimate + dueDate + state { id name type } + team { id key name } + assignee { id name email } + creator { id name email } + cycle { id number name } + project { id name } + projectMilestone { id name } + parent { id identifier title } + labels { nodes { id name } } + url + trashed + archivedAt + createdAt + updatedAt +}`; + +export const ISSUE_GET_QUERY = ` +query IssueGet($id: String!) { + issue(id: $id) { + ...CuratedIssue + } +} +${CURATED_ISSUE_FRAGMENT}`; + +export const ISSUE_CREATE_MUTATION = ` +mutation IssueCreate($input: IssueCreateInput!) { + issueCreate(input: $input) { + success + issue { + ...CuratedIssue + } + } +} +${CURATED_ISSUE_FRAGMENT}`; + +export const ISSUE_LIST_QUERY = ` +query IssueList($first: Int!, $after: String, $filter: IssueFilter, $orderBy: PaginationOrderBy) { + issues(first: $first, after: $after, filter: $filter, orderBy: $orderBy) { + nodes { + ...CuratedIssue + } + pageInfo { + hasNextPage + endCursor + } + } +} +${CURATED_ISSUE_FRAGMENT}`; + +export const ISSUE_SEARCH_QUERY = ` +query IssueSearch($first: Int!, $after: String, $term: String!, $filter: IssueFilter) { + searchIssues(first: $first, after: $after, term: $term, filter: $filter) { + nodes { + ...CuratedIssueSearchResult + } + pageInfo { + hasNextPage + endCursor + } + } +} +${CURATED_ISSUE_SEARCH_RESULT_FRAGMENT}`; + +export const ISSUE_UPDATE_MUTATION = ` +mutation IssueUpdate($id: String!, $input: IssueUpdateInput!) { + issueUpdate(id: $id, input: $input) { + success + issue { + ...CuratedIssue + } + } +} +${CURATED_ISSUE_FRAGMENT}`; + +export const ISSUE_ARCHIVE_MUTATION = ` +mutation IssueArchive($id: String!) { + issueArchive(id: $id) { + success + } +}`; + +export const ISSUE_DELETE_MUTATION = ` +mutation IssueDelete($id: String!) { + issueDelete(id: $id) { + success + } +}`; + +export const COMMENT_CREATE_MUTATION = ` +mutation CommentCreate($input: CommentCreateInput!) { + commentCreate(input: $input) { + success + comment { + id + body + createdAt + user { id name email } + } + } +}`; + +export const ATTACHMENT_LINK_SLACK_MUTATION = ` +mutation AttachmentLinkSlack($issueId: String!, $url: String!, $syncToCommentThread: Boolean, $title: String) { + attachmentLinkSlack(issueId: $issueId, url: $url, syncToCommentThread: $syncToCommentThread, title: $title) { + success + attachment { + id + title + subtitle + url + issue { id identifier title } + createdAt + } + } +}`; diff --git a/src/commands/issue/input.ts b/src/commands/issue/input.ts new file mode 100644 index 0000000..7347230 --- /dev/null +++ b/src/commands/issue/input.ts @@ -0,0 +1,197 @@ +import { + resolveTeamId, + resolveUserId, + resolveLabelId, + resolveStateId, + resolveProjectId, + looksLikeId, type ResolverOptions +} from "../../core/resolution/resolve.js"; +import { CommandContext } from "../../core/runtime/command-context.js"; +import { type IssueCommandOptions } from "./options.js"; +export function shouldResolveProjectIdentifier(value: string): boolean { + return !looksLikeId(value); +} + +export function isUnsetValue(value: string): boolean { + const normalized = value.trim().toLowerCase(); + return normalized === "" || normalized === "none" || normalized === "null"; +} + +export function isUnassignedValue(value: string): boolean { + return isUnsetValue(value) || value.trim().toLowerCase() === "unassigned"; +} + +export function isValidDueDate(value: string): boolean { + const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value); + if (match === null) { + return false; + } + + const year = Number(match[1]); + const month = Number(match[2]); + const day = Number(match[3]); + if (year < 1 || month < 1 || month > 12 || day < 1) { + return false; + } + + const daysInMonth = [31, isLeapYear(year) ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; + return day <= daysInMonth[month - 1]!; +} + +export function isLeapYear(year: number): boolean { + return year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0); +} + +export function dueDateValidationError(allowUnset: boolean): string { + return allowUnset + ? '--due-date must be a valid YYYY-MM-DD date or "none" to clear it.' + : "--due-date must be a valid date in YYYY-MM-DD format."; +} + +export async function buildIssueFilter( + options: IssueCommandOptions, + defaultTeam: string | undefined, + resolverOpts: ResolverOptions, + applyDefaultTeam = true +): Promise<{ filter?: Record; validationError?: string }> { + if (options.filterJson !== undefined) { + try { + const parsed = JSON.parse(options.filterJson) as unknown; + if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { + return { validationError: "--filter-json must be a JSON object." }; + } + return { filter: parsed as Record }; + } catch { + return { validationError: "--filter-json contains invalid JSON." }; + } + } + + const filter: Record = {}; + let resolvedTeamId: string | undefined; + const effectiveTeam = options.allTeams ? undefined : (options.team ?? (applyDefaultTeam ? defaultTeam : undefined)); + if (effectiveTeam !== undefined) { + resolvedTeamId = looksLikeId(effectiveTeam) ? effectiveTeam : await resolveTeamId(effectiveTeam, resolverOpts); + filter.team = { id: { eq: resolvedTeamId } }; + } + + const stateValue = options.state ?? options.status; + const rawStateValues = options.states ?? (stateValue === undefined ? [] : [stateValue]); + const stateValues = rawStateValues.flatMap((value) => value.split(",").map((state) => state.trim())); + if (stateValues.some((state) => state === "")) { + return { validationError: "--state values must not be empty." }; + } + + const stateFilterPromise = (async (): Promise | undefined> => { + const buildStateFilter = async (state: string): Promise> => { + if (looksLikeId(state)) { + return { id: { eq: state } }; + } + if (resolvedTeamId === undefined) { + return { name: { eqIgnoreCase: state } }; + } + return { id: { eq: await resolveStateId(state, resolvedTeamId, resolverOpts) } }; + }; + + if (stateValues.length === 1) { + return { state: await buildStateFilter(stateValues[0]!) }; + } + if (stateValues.length > 1) { + const stateFilters = await Promise.all(stateValues.map(buildStateFilter)); + return { or: stateFilters.map((state) => ({ state })) }; + } + return undefined; + })(); + + const assigneeFilterPromise = options.assignee === undefined + ? Promise.resolve(undefined) + : (async (): Promise> => { + if (isUnassignedValue(options.assignee!)) { + return { assignee: { null: true } }; + } + const assigneeId = looksLikeId(options.assignee!) + ? options.assignee! + : await resolveUserId(options.assignee!, resolverOpts); + return { assignee: { id: { eq: assigneeId } } }; + })(); + + const labelFilterPromise = options.label === undefined + ? Promise.resolve(undefined) + : (async (): Promise> => { + const labelId = looksLikeId(options.label!) + ? options.label! + : await resolveLabelId(options.label!, resolvedTeamId, resolverOpts); + return { labels: { some: { id: { eq: labelId } } } }; + })(); + + const projectFilterPromise = options.project === undefined + ? Promise.resolve(undefined) + : (async (): Promise> => { + const projectId = looksLikeId(options.project!) + ? options.project! + : await resolveProjectId(options.project!, resolvedTeamId, resolverOpts); + return { project: { id: { eq: projectId } } }; + })(); + + const resolvedFilters = await Promise.all([ + stateFilterPromise, + assigneeFilterPromise, + labelFilterPromise, + projectFilterPromise + ]); + for (const resolvedFilter of resolvedFilters) { + if (resolvedFilter !== undefined) { + Object.assign(filter, resolvedFilter); + } + } + + if (options.priority !== undefined) { + const parsed = Number(options.priority); + if (!Number.isInteger(parsed)) { + return { validationError: "--priority must be an integer." }; + } + filter.priority = { eq: parsed }; + } + if (options.cycle !== undefined) { + filter.cycle = { id: { eq: options.cycle } }; + } + if (options.createdAfter !== undefined) { + filter.createdAt = { gte: options.createdAfter }; + } + if (options.updatedAfter !== undefined) { + filter.updatedAt = { gte: options.updatedAfter }; + } + if (options.completedAfter !== undefined) { + filter.completedAt = { gte: options.completedAfter }; + } + if (options.dueDate !== undefined) { + if (isUnsetValue(options.dueDate)) { + filter.dueDate = { null: true }; + } else if (!isValidDueDate(options.dueDate)) { + return { validationError: dueDateValidationError(true) }; + } else { + filter.dueDate = { eq: options.dueDate }; + } + } + + return Object.keys(filter).length > 0 ? { filter } : {}; +} + +export async function resolveIssueForDelete( + identifier: string, + ctx: CommandContext +): Promise<{ id: string; identifier: string } | undefined> { + if (looksLikeId(identifier)) { + return { id: identifier, identifier }; + } + + const response = await ctx.graphql<{ issue: { id: string; identifier: string } | null }>( + `query IssueDeleteResolve($id: String!) { issue(id: $id) { id identifier } }`, + { id: identifier } + ); + + if (ctx.hasErrors(response.body.errors)) { + throw new Error(response.body.errors?.[0]?.message ?? "Issue lookup failed"); + } + + return response.body.data?.issue ?? undefined; +} diff --git a/src/commands/issue/model.ts b/src/commands/issue/model.ts new file mode 100644 index 0000000..8c076f6 --- /dev/null +++ b/src/commands/issue/model.ts @@ -0,0 +1,132 @@ +import { commandIO, type CommandIO } from "../../core/runtime/options.js"; + +export interface RawSlackAttachment { + id: string; + title: string | null; + subtitle: string | null; + url: string; + issue: { id: string; identifier: string; title: string }; + createdAt: string; +} + +export interface RawIssue { + id: string; + identifier: string; + title: string; + description: string | null; + priority: number; + estimate: number | null; + dueDate: string | null; + state: { id: string; name: string; type: string } | null; + team: { id: string; key: string; name: string }; + assignee: { id: string; name: string; email: string } | null; + creator: { id: string; name: string; email: string } | null; + cycle: { id: string; number: number; name: string | null } | null; + project: { id: string; name: string } | null; + projectMilestone: { id: string; name: string } | null; + parent: { id: string; identifier: string; title: string } | null; + labels: { nodes: Array<{ id: string; name: string }> }; + url: string; + trashed: boolean | null; + archivedAt: string | null; + createdAt: string; + updatedAt: string; +} + +export interface NormalizedIssue { + id: string; + identifier: string; + title: string; + description: string | null; + priority: number; + estimate: number | null; + dueDate: string | null; + state: { id: string; name: string; type: string } | null; + team: { id: string; key: string; name: string }; + assignee: { id: string; name: string; email: string } | null; + creator: { id: string; name: string; email: string } | null; + cycle: { id: string; number: number; name: string | null } | null; + project: { id: string; name: string } | null; + projectMilestone: { id: string; name: string } | null; + parent: { id: string; identifier: string; title: string } | null; + labels: Array<{ id: string; name: string }>; + url: string; + trashed: boolean | null; + archivedAt: string | null; + createdAt: string; + updatedAt: string; +} + +export function normalizeIssue(raw: RawIssue): NormalizedIssue { + return { + id: raw.id, + identifier: raw.identifier, + title: raw.title, + description: raw.description, + priority: raw.priority, + estimate: raw.estimate, + dueDate: raw.dueDate, + state: raw.state, + team: raw.team, + assignee: raw.assignee, + creator: raw.creator, + cycle: raw.cycle, + project: raw.project, + projectMilestone: raw.projectMilestone, + parent: raw.parent, + labels: raw.labels.nodes, + url: raw.url, + trashed: raw.trashed, + archivedAt: raw.archivedAt, + createdAt: raw.createdAt, + updatedAt: raw.updatedAt + }; +} + +export function printHumanIssue(issue: NormalizedIssue, options: CommandIO): void { + const { stdout } = commandIO(options); + stdout.write(`${issue.identifier} ${issue.title}\n`); + if (issue.state !== null) { + stdout.write(` State: ${issue.state.name}\n`); + } + stdout.write(` Team: ${issue.team.name}\n`); + if (issue.assignee !== null) { + stdout.write(` Assignee: ${issue.assignee.name}\n`); + } + if (issue.priority !== 0) { + stdout.write(` Priority: ${issue.priority}\n`); + } + if (issue.estimate !== null) { + stdout.write(` Estimate: ${issue.estimate}\n`); + } + if (issue.dueDate !== null) { + stdout.write(` Due date: ${issue.dueDate}\n`); + } + if (issue.project !== null) { + stdout.write(` Project: ${issue.project.name}\n`); + } + if (issue.trashed === true) { + stdout.write(" Trashed: true\n"); + } + if (issue.archivedAt !== null) { + stdout.write(` Archived: ${issue.archivedAt}\n`); + } + if (issue.labels.length > 0) { + stdout.write(` Labels: ${issue.labels.map((l) => l.name).join(", ")}\n`); + } + stdout.write(` URL: ${issue.url}\n`); +} + +export interface RawComment { + id: string; + body: string; + createdAt: string; + user: { id: string; name: string; email: string } | null; +} + +export interface NormalizedComment { + id: string; + body: string; + createdAt: string; + user: { id: string; name: string; email: string } | null; +} diff --git a/src/commands/issue/options.ts b/src/commands/issue/options.ts new file mode 100644 index 0000000..b37cc96 --- /dev/null +++ b/src/commands/issue/options.ts @@ -0,0 +1,54 @@ +import { type CommandOptions } from "../../core/runtime/options.js"; + +export interface IssueCommandOptions extends CommandOptions { + jsonl?: boolean; + dryRun?: boolean; + yes?: boolean; + confirm?: boolean; + // issue create/update flags + title?: string; + team?: string; + allTeams?: boolean; + description?: string; + descriptionFile?: string; + stdinStream?: NodeJS.ReadableStream; + priority?: string; + estimate?: string; + dueDate?: string; + assignee?: string; + label?: string; + state?: string; + states?: string[]; + status?: string; + milestone?: string; + projectMilestone?: string; + inputJson?: string; + // bulk operation flags + ids?: string; + // issue comment flags + body?: string; + bodyFile?: string; + // issue attach-slack flags + url?: string; + sync?: boolean; + // parent issue flag + parent?: string; + // issue list flags + cycle?: string; + project?: string; + filterJson?: string; + createdAfter?: string; + updatedAfter?: string; + completedAfter?: string; + orderBy?: string; + orderDir?: string; + // issue search flags + query?: string; + search?: string; + // pagination flags + all?: boolean; + max?: number; + pageSize?: number; + after?: string; + quiet?: boolean; +} diff --git a/src/commands/issue/read.ts b/src/commands/issue/read.ts new file mode 100644 index 0000000..72ec17e --- /dev/null +++ b/src/commands/issue/read.ts @@ -0,0 +1,243 @@ +import { commandIO } from "../../core/runtime/options.js"; +import { emitValidationError } from "../../core/output/validation-error.js"; +import type { PageInfo } from "../../core/output/envelope.js"; +import { ExitCode } from "../../core/errors/exit-codes.js"; +import { paginateGraphQL, validatePaginationOptions } from "../../core/pagination/pagination.js"; +import type { PaginationOptions } from "../../core/pagination/pagination.js"; +import { streamPaginateGraphQL } from "../../core/pagination/streaming.js"; +import { normalizeRetryOptions } from "../../core/transport/retry.js"; +import { createCommandContext } from "../../core/runtime/command-context.js"; +import { type IssueCommandOptions } from "./options.js"; +import { type RawIssue, normalizeIssue, printHumanIssue } from "./model.js"; +import { ISSUE_GET_QUERY, ISSUE_LIST_QUERY, ISSUE_SEARCH_QUERY } from "./documents.js"; +import { buildIssueFilter } from "./input.js"; +export async function handleIssueGet( + identifier: string, + options: IssueCommandOptions +): Promise { + const { stdout } = commandIO(options); + const ctx = createCommandContext(options); + + try { + const response = await ctx.graphql<{ issue: RawIssue | null }>( + ISSUE_GET_QUERY, + { id: identifier } + ); + + if (ctx.hasErrors(response.body.errors)) { + return ctx.emitFailure(ctx.mapGraphQLErrors(response.body.errors)); + } + + if (response.body.data?.issue === null || response.body.data?.issue === undefined) { + return ctx.emitNotFound("Issue not found"); + } + + const issue = normalizeIssue(response.body.data.issue); + + if (options.jsonEnvelope) { + return ctx.emitSuccess(issue); + } else if (options.json) { + stdout.write(`${JSON.stringify(issue, null, 2)}\n`); + } else { + printHumanIssue(issue, options); + } + + return ExitCode.Success; + } catch (error) { + return ctx.emitCaughtError(error); + } +} + +export async function handleIssueList(options: IssueCommandOptions): Promise { + const { stdout } = commandIO(options); + const paginationOptions: PaginationOptions = { + stderr: commandIO(options).stderr, + all: options.all, + max: options.max, + pageSize: options.pageSize, + after: options.after, + quiet: options.quiet + }; + + const validationError = validatePaginationOptions(paginationOptions); + if (validationError !== undefined) { + return emitValidationError(validationError, options); + } + + if (options.orderDir !== undefined) { + return emitValidationError("--order-dir is not supported. Linear's orderBy controls both field and direction.", options); + } + + const ctx = createCommandContext(options); + + try { + const profile = await ctx.resolveProfile(); + const resolverOpts = await ctx.resolverOptions(); + const filterResult = await buildIssueFilter(options, profile.metadata.defaultTeam, resolverOpts); + if (filterResult.validationError !== undefined) { + return emitValidationError(filterResult.validationError, options); + } + const filter = filterResult.filter; + + const commonPaginateInput = { + query: ISSUE_LIST_QUERY, + variables: { + ...(filter === undefined ? {} : { filter }), + ...(options.orderBy === undefined ? {} : { orderBy: options.orderBy }) + }, + credentials: profile.credentials, + ...(options.apiUrl === undefined + ? profile.metadata.baseUrl === undefined + ? {} + : { apiUrl: profile.metadata.baseUrl } + : { apiUrl: options.apiUrl }), + ...(options.fetchImpl === undefined ? {} : { fetchImpl: options.fetchImpl }), + retry: normalizeRetryOptions(options), + extractConnection: (data: unknown) => { + const d = data as { issues: { nodes: RawIssue[]; pageInfo: PageInfo } }; + return d.issues; + } + }; + + if (options.jsonl === true) { + const streamOptions: PaginationOptions = { + ...paginationOptions, + all: paginationOptions.all ?? true + }; + + await streamPaginateGraphQL({ + ...commonPaginateInput, + options: streamOptions, + onItem: (raw) => { + stdout.write(`${JSON.stringify(normalizeIssue(raw))}\n`); + } + }); + } else { + const result = await paginateGraphQL({ + ...commonPaginateInput, + options: paginationOptions + }); + + const issues = result.items.map(normalizeIssue); + + if (options.jsonEnvelope) { + return ctx.emitSuccess(issues, result.pageInfo); + } else if (options.json) { + stdout.write(`${JSON.stringify(issues, null, 2)}\n`); + } else { + if (issues.length === 0) { + stdout.write("No issues found.\n"); + } else { + for (const issue of issues) { + const state = issue.state !== null ? issue.state.name : ""; + const assignee = issue.assignee !== null ? issue.assignee.name : ""; + stdout.write(`${issue.identifier}\t${issue.title}\t${state}\t${assignee}\n`); + } + } + } + } + + return ExitCode.Success; + } catch (error) { + return ctx.emitCaughtError(error); + } +} + +export async function handleIssueSearch(options: IssueCommandOptions, applyDefaultTeam = false): Promise { + const { stdout } = commandIO(options); + const trimmedQuery = (options.query ?? options.search)?.trim(); + if (trimmedQuery === undefined || trimmedQuery === "") { + return emitValidationError("usage: linearctl issue search [|--query ]", options); + } + + const paginationOptions: PaginationOptions = { + stderr: commandIO(options).stderr, + all: options.all, + max: options.max, + pageSize: options.pageSize, + after: options.after, + quiet: options.quiet + }; + + const validationError = validatePaginationOptions(paginationOptions); + if (validationError !== undefined) { + return emitValidationError(validationError, options); + } + + if (options.orderBy !== undefined || options.orderDir !== undefined) { + return emitValidationError("issue search does not support --order-by or --order-dir.", options); + } + + const ctx = createCommandContext(options); + + try { + const profile = await ctx.resolveProfile(); + const resolverOpts = await ctx.resolverOptions(); + const filterResult = await buildIssueFilter(options, profile.metadata.defaultTeam, resolverOpts, applyDefaultTeam); + if (filterResult.validationError !== undefined) { + return emitValidationError(filterResult.validationError, options); + } + + const commonPaginateInput = { + query: ISSUE_SEARCH_QUERY, + variables: { + term: trimmedQuery, + ...(filterResult.filter === undefined ? {} : { filter: filterResult.filter }) + }, + credentials: profile.credentials, + ...(options.apiUrl === undefined + ? profile.metadata.baseUrl === undefined + ? {} + : { apiUrl: profile.metadata.baseUrl } + : { apiUrl: options.apiUrl }), + ...(options.fetchImpl === undefined ? {} : { fetchImpl: options.fetchImpl }), + retry: normalizeRetryOptions(options), + extractConnection: (data: unknown) => { + const d = data as { searchIssues: { nodes: RawIssue[]; pageInfo: PageInfo } }; + return d.searchIssues; + } + }; + + if (options.jsonl === true) { + const streamOptions: PaginationOptions = { + ...paginationOptions, + all: paginationOptions.all ?? true + }; + + await streamPaginateGraphQL({ + ...commonPaginateInput, + options: streamOptions, + onItem: (raw) => { + stdout.write(`${JSON.stringify(normalizeIssue(raw))}\n`); + } + }); + } else { + const result = await paginateGraphQL({ + ...commonPaginateInput, + options: paginationOptions + }); + + const issues = result.items.map(normalizeIssue); + + if (options.jsonEnvelope) { + return ctx.emitSuccess(issues, result.pageInfo); + } else if (options.json) { + stdout.write(`${JSON.stringify(issues, null, 2)}\n`); + } else { + if (issues.length === 0) { + stdout.write("No issues found.\n"); + } else { + for (const issue of issues) { + const state = issue.state !== null ? issue.state.name : ""; + const assignee = issue.assignee !== null ? issue.assignee.name : ""; + stdout.write(`${issue.identifier}\t${issue.title}\t${state}\t${assignee}\n`); + } + } + } + } + + return ExitCode.Success; + } catch (error) { + return ctx.emitCaughtError(error); + } +} diff --git a/src/commands/issue/workflow.ts b/src/commands/issue/workflow.ts new file mode 100644 index 0000000..da119a8 --- /dev/null +++ b/src/commands/issue/workflow.ts @@ -0,0 +1,347 @@ +import { commandIO } from "../../core/runtime/options.js"; +import { emitValidationError } from "../../core/output/validation-error.js"; +import { ExitCode } from "../../core/errors/exit-codes.js"; +import { emitDryRunResult } from "../../core/output/dry-run.js"; +import { resolveBodyInput } from "../../core/io/text-input.js"; +import { + resolveUserId, resolveStateId, looksLikeId +} from "../../core/resolution/resolve.js"; +import { createCommandContext } from "../../core/runtime/command-context.js"; +import { type IssueCommandOptions } from "./options.js"; +import { type RawIssue, normalizeIssue, type RawComment, type NormalizedComment, type RawSlackAttachment } from "./model.js"; +import { ISSUE_UPDATE_MUTATION, ISSUE_GET_QUERY, COMMENT_CREATE_MUTATION, ATTACHMENT_LINK_SLACK_MUTATION } from "./documents.js"; +export async function handleIssueClose( + identifier: string, + options: IssueCommandOptions +): Promise { + const { stdout } = commandIO(options); + if (options.dryRun === true) { + return emitDryRunResult("close", "issue", { + id: identifier, + ...(options.state === undefined ? {} : { state: options.state }) + }, options); + } + + const ctx = createCommandContext(options); + + try { + const resolverOpts = await ctx.resolverOptions(); + + // 1. Fetch the issue's team + const issueData = await ctx.graphql<{ issue: { team: { id: string } } | null }>( + `query IssueTeam($id: String!) { issue(id: $id) { team { id } } }`, + { id: identifier } + ); + + if (ctx.hasErrors(issueData.body.errors)) { + const msg = issueData.body.errors?.[0]?.message ?? "Failed to fetch issue"; + return ctx.emitFailure([{ category: "general", message: msg }]); + } + + const teamId = issueData.body.data?.issue?.team?.id; + if (teamId === undefined) { + return ctx.emitNotFound("Issue not found or has no team."); + } + + // 2. Resolve the target state + let targetStateId: string; + let targetStateName: string; + + if (options.state !== undefined) { + // User specified a state — resolve and validate it is terminal. + targetStateId = looksLikeId(options.state) + ? options.state + : await resolveStateId(options.state, teamId, resolverOpts); + targetStateName = options.state; + + // Verify the state is a terminal type. + const stateCheck = await ctx.graphql<{ + workflowState: { id: string; name: string; type: string } | null + }>( + `query StateCheck($id: String!) { workflowState(id: $id) { id name type } }`, + { id: targetStateId } + ); + if (ctx.hasErrors(stateCheck.body.errors)) { + return ctx.emitFailure(ctx.mapGraphQLErrors(stateCheck.body.errors)); + } + const stateType = stateCheck.body.data?.workflowState?.type; + if (stateType !== "completed" && stateType !== "canceled") { + return ctx.emitFailure([{ category: "general", message: `State "${stateCheck.body.data?.workflowState?.name ?? options.state}" is type "${stateType ?? "unknown"}", not "completed" or "canceled". Use a terminal state for issue close.` }]); + } + targetStateName = stateCheck.body.data?.workflowState?.name ?? options.state; + } else { + // Default: find a completed-type workflow state for the team + const statesData = await ctx.graphql<{ + workflowStates: { nodes: Array<{ id: string; name: string; type: string; position: number }> } + }>( + `query CompletedStates($filter: WorkflowStateFilter!) { + workflowStates(first: 250, filter: $filter) { + nodes { id name type position } + } + }`, + { filter: { team: { id: { eq: teamId } }, type: { eq: "completed" } } } + ); + + if (ctx.hasErrors(statesData.body.errors)) { + const msg = statesData.body.errors?.[0]?.message ?? "Failed to fetch workflow states"; + return ctx.emitFailure([{ category: "general", message: msg }]); + } + + const candidates = statesData.body.data?.workflowStates?.nodes ?? []; + // Prefer "Done" by name, then lowest position + const completedState = + candidates.find((s) => s.name === "Done") ?? + candidates.sort((a, b) => a.position - b.position)[0]; + if (completedState === undefined) { + return ctx.emitFailure([{ category: "general", message: "No completed workflow state found for this team." }]); + } + targetStateId = completedState.id; + targetStateName = completedState.name; + } + + // 3. Transition the issue to the target state + const response = await ctx.graphql<{ + issueUpdate: { success: boolean; issue: RawIssue | null }; + }>(ISSUE_UPDATE_MUTATION, { id: identifier, input: { stateId: targetStateId } }); + + if ( + ctx.hasErrors(response.body.errors) || + response.body.data?.issueUpdate?.success !== true + ) { + const errors = ctx.mapGraphQLErrors(response.body.errors); + return ctx.emitFailure( + errors.length > 0 ? errors : [{ category: "general", message: "Issue close failed" }] + ); + } + + const issue = response.body.data.issueUpdate.issue; + const resolvedStateName = issue?.state?.name ?? targetStateName; + const result = { + identifier, + closed: true, + state: resolvedStateName, + ...(issue !== null ? { issue: normalizeIssue(issue) } : {}) + }; + + if (options.jsonEnvelope) { + return ctx.emitSuccess(result); + } else if (options.json) { + stdout.write(`${JSON.stringify(result, null, 2)}\n`); + } else { + stdout.write(`Closed ${identifier} → ${resolvedStateName}\n`); + } + + return ExitCode.Success; + } catch (error) { + return ctx.emitCaughtError(error); + } +} + +export async function handleIssueAssign( + identifier: string, + assigneeValue: string, + options: IssueCommandOptions +): Promise { + const { stdout } = commandIO(options); + if (options.dryRun === true) { + return emitDryRunResult("update", "issue", { id: identifier, assigneeId: assigneeValue }, options); + } + + const ctx = createCommandContext(options); + + try { + const resolverOpts = await ctx.resolverOptions(); + + const assigneeId = looksLikeId(assigneeValue) ? assigneeValue : await resolveUserId(assigneeValue, resolverOpts); + + const response = await ctx.graphql<{ + issueUpdate: { success: boolean; issue: RawIssue | null }; + }>(ISSUE_UPDATE_MUTATION, { id: identifier, input: { assigneeId } }); + + if ( + ctx.hasErrors(response.body.errors) || + response.body.data?.issueUpdate?.issue === null || + response.body.data?.issueUpdate?.issue === undefined + ) { + const errors = ctx.mapGraphQLErrors(response.body.errors); + return ctx.emitFailure( + errors.length > 0 ? errors : [{ category: "general", message: "Issue assign failed" }] + ); + } + + const issue = normalizeIssue(response.body.data.issueUpdate.issue); + + if (options.jsonEnvelope) { + return ctx.emitSuccess(issue); + } else if (options.json) { + stdout.write(`${JSON.stringify(issue, null, 2)}\n`); + } else { + const name = issue.assignee !== null ? issue.assignee.name : assigneeId; + stdout.write(`Assigned ${issue.identifier} to ${name}\n`); + } + + return ExitCode.Success; + } catch (error) { + return ctx.emitCaughtError(error); + } +} + +export async function handleIssueComment( + identifier: string, + options: IssueCommandOptions +): Promise { + const { stdout } = commandIO(options); + let body: string | undefined; + try { + body = await resolveBodyInput(options); + } catch (error) { + return emitValidationError(error instanceof Error ? error.message : String(error), options); + } + + if (body === undefined || body.trim() === "") { + return emitValidationError("--body or --body-file is required for issue comment.", options); + } + + if (options.dryRun === true) { + return emitDryRunResult("create", "comment", { issueId: identifier, body }, options); + } + + const ctx = createCommandContext(options); + + try { + // Resolve identifier to issue ID + const getResponse = await ctx.graphql<{ issue: RawIssue | null }>( + ISSUE_GET_QUERY, + { id: identifier } + ); + + if (ctx.hasErrors(getResponse.body.errors)) { + return ctx.emitFailure(ctx.mapGraphQLErrors(getResponse.body.errors)); + } + + if (getResponse.body.data?.issue === null || getResponse.body.data?.issue === undefined) { + return ctx.emitNotFound("Issue not found"); + } + + const issueId = getResponse.body.data.issue.id; + + const response = await ctx.graphql<{ + commentCreate: { success: boolean; comment: RawComment | null }; + }>(COMMENT_CREATE_MUTATION, { input: { issueId, body } }); + + if ( + ctx.hasErrors(response.body.errors) || + response.body.data?.commentCreate?.comment === null || + response.body.data?.commentCreate?.comment === undefined + ) { + const errors = ctx.mapGraphQLErrors(response.body.errors); + return ctx.emitFailure( + errors.length > 0 ? errors : [{ category: "general", message: "Comment creation failed" }] + ); + } + + const comment: NormalizedComment = response.body.data.commentCreate.comment; + + if (options.jsonEnvelope) { + return ctx.emitSuccess(comment); + } else if (options.json) { + stdout.write(`${JSON.stringify(comment, null, 2)}\n`); + } else { + stdout.write(`Comment added to ${identifier}\n`); + } + + return ExitCode.Success; + } catch (error) { + return ctx.emitCaughtError(error); + } +} + +export async function handleIssueAttachSlack( + identifier: string, + options: IssueCommandOptions +): Promise { + const { stdout } = commandIO(options); + if (options.url === undefined || options.url.trim() === "") { + return emitValidationError("--url is required for issue attach-slack.", options); + } + + const trimmedUrl = options.url.trim(); + let parsedUrl: URL; + try { + parsedUrl = new URL(trimmedUrl); + } catch { + return emitValidationError("--url must be a valid Slack HTTPS URL.", options); + } + const hostname = parsedUrl.hostname.toLowerCase(); + if (parsedUrl.protocol !== "https:" || (hostname !== "slack.com" && !hostname.endsWith(".slack.com"))) { + return emitValidationError("--url must be a valid Slack HTTPS URL.", options); + } + + const variables: Record = { + issueId: identifier, + url: trimmedUrl, + ...(options.sync === true ? { syncToCommentThread: true } : {}), + ...(options.title !== undefined ? { title: options.title } : {}) + }; + + if (options.dryRun === true) { + return emitDryRunResult("attach-slack", "issue", variables, options); + } + + const ctx = createCommandContext(options); + + try { + // Resolve identifier to UUID if it looks like a human-readable identifier (e.g. INF-2975) + let issueId = identifier; + if (!looksLikeId(identifier)) { + const issueData = await ctx.graphql<{ issue: { id: string } | null }>( + `query IssueResolve($id: String!) { issue(id: $id) { id } }`, + { id: identifier } + ); + + if (ctx.hasErrors(issueData.body.errors)) { + const msg = issueData.body.errors?.[0]?.message ?? "Failed to resolve issue"; + return ctx.emitFailure([{ category: "general", message: msg }]); + } + + if (issueData.body.data?.issue?.id === undefined) { + return ctx.emitNotFound(`Issue "${identifier}" not found.`); + } + issueId = issueData.body.data.issue.id; + variables.issueId = issueId; + } + + const response = await ctx.graphql<{ + attachmentLinkSlack: { success: boolean; attachment: RawSlackAttachment | null }; + }>(ATTACHMENT_LINK_SLACK_MUTATION, variables); + + if ( + ctx.hasErrors(response.body.errors) || + response.body.data?.attachmentLinkSlack?.attachment === null || + response.body.data?.attachmentLinkSlack?.attachment === undefined + ) { + const errors = ctx.mapGraphQLErrors(response.body.errors); + return ctx.emitFailure( + errors.length > 0 ? errors : [{ category: "general", message: "Slack attachment failed" }] + ); + } + + const attachment = response.body.data.attachmentLinkSlack.attachment; + + if (options.jsonEnvelope) { + return ctx.emitSuccess(attachment); + } else if (options.json) { + stdout.write(`${JSON.stringify(attachment, null, 2)}\n`); + } else { + stdout.write(`Linked Slack thread to ${attachment.issue.identifier}\n`); + if (attachment.title !== null) { + stdout.write(` Title: ${attachment.title}\n`); + } + stdout.write(` URL: ${attachment.url}\n`); + } + + return ExitCode.Success; + } catch (error) { + return ctx.emitCaughtError(error); + } +} diff --git a/src/commands/issue/write.ts b/src/commands/issue/write.ts new file mode 100644 index 0000000..500ca11 --- /dev/null +++ b/src/commands/issue/write.ts @@ -0,0 +1,415 @@ +import { commandIO } from "../../core/runtime/options.js"; +import { emitValidationError } from "../../core/output/validation-error.js"; +import { ExitCode } from "../../core/errors/exit-codes.js"; +import { emitDryRunResult } from "../../core/output/dry-run.js"; +import { resolveDescriptionInput } from "../../core/io/text-input.js"; +import { + resolveTeamId, + resolveUserId, + resolveLabelId, + resolveStateId, + resolveProjectId, + looksLikeId +} from "../../core/resolution/resolve.js"; +import { createCommandContext } from "../../core/runtime/command-context.js"; +import { type IssueCommandOptions } from "./options.js"; +import { isUnsetValue, isValidDueDate, dueDateValidationError, isUnassignedValue, shouldResolveProjectIdentifier, resolveIssueForDelete } from "./input.js"; +import { type RawIssue, normalizeIssue } from "./model.js"; +import { ISSUE_CREATE_MUTATION, ISSUE_UPDATE_MUTATION, ISSUE_DELETE_MUTATION } from "./documents.js"; +export async function handleIssueCreate(options: IssueCommandOptions): Promise { + const { stdout } = commandIO(options); + let inputFromJson: Record = {}; + + if (options.inputJson !== undefined) { + try { + const parsed = JSON.parse(options.inputJson) as unknown; + if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { + return emitValidationError("--input-json must be a JSON object.", options); + } + inputFromJson = parsed as Record; + } catch { + return emitValidationError("--input-json contains invalid JSON.", options); + } + } + + const title = options.title ?? (typeof inputFromJson.title === "string" ? inputFromJson.title : undefined); + const teamId = options.team ?? (typeof inputFromJson.teamId === "string" ? inputFromJson.teamId : undefined); + + if (title === undefined) { + return emitValidationError("--title is required for issue create.", options); + } + + if (teamId === undefined) { + return emitValidationError("--team is required for issue create.", options); + } + + const input: Record = { + ...inputFromJson, + title + }; + + let description: string | undefined; + try { + description = await resolveDescriptionInput(options); + } catch (error) { + return emitValidationError(error instanceof Error ? error.message : String(error), options); + } + if (description !== undefined) { + input.description = description; + } + if (options.priority !== undefined) { + const parsed = Number(options.priority); + if (!Number.isInteger(parsed)) { + return emitValidationError("--priority must be an integer.", options); + } + input.priority = parsed; + } + if (options.estimate !== undefined) { + const parsed = Number(options.estimate); + if (!Number.isFinite(parsed) || parsed < 0) { + return emitValidationError("--estimate must be a non-negative number.", options); + } + input.estimate = parsed; + } + if (options.dueDate !== undefined) { + if (isUnsetValue(options.dueDate) || !isValidDueDate(options.dueDate)) { + return emitValidationError(dueDateValidationError(false), options); + } + input.dueDate = options.dueDate; + } + + if (options.assignee !== undefined) { + input.assigneeId = options.assignee; + } + if (options.label !== undefined) { + input.labelIds = [options.label]; + } + if (options.state !== undefined) { + input.stateId = options.state; + } + if (options.cycle !== undefined) { + input.cycleId = options.cycle; + } + if (options.project !== undefined) { + input.projectId = options.project; + } + const projectMilestone = options.projectMilestone ?? options.milestone; + if (projectMilestone !== undefined) { + input.projectMilestoneId = projectMilestone; + } + if (options.parent !== undefined) { + input.parentId = options.parent; + } + input.teamId = teamId; + + const ctx = createCommandContext(options); + + try { + const resolverOpts = await ctx.resolverOptions(); + + // Resolve friendly names to IDs + const resolvedTeamId = looksLikeId(teamId) ? teamId : await resolveTeamId(teamId, resolverOpts); + input.teamId = resolvedTeamId; + + if (options.assignee !== undefined) { + input.assigneeId = looksLikeId(options.assignee) ? options.assignee : await resolveUserId(options.assignee, resolverOpts); + } + if (options.label !== undefined) { + input.labelIds = [looksLikeId(options.label) ? options.label : await resolveLabelId(options.label, resolvedTeamId, resolverOpts)]; + } + if (options.state !== undefined) { + input.stateId = looksLikeId(options.state) ? options.state : await resolveStateId(options.state, resolvedTeamId, resolverOpts); + } + if (options.project !== undefined) { + input.projectId = looksLikeId(options.project) ? options.project : await resolveProjectId(options.project, resolvedTeamId, resolverOpts); + } + if (options.parent !== undefined) { + if (looksLikeId(options.parent)) { + input.parentId = options.parent; + } else { + const parentData = await ctx.graphql<{ issue: { id: string } | null }>( + `query IssueResolve($id: String!) { issue(id: $id) { id } }`, + { id: options.parent } + ); + if (ctx.hasErrors(parentData.body.errors)) { + return ctx.emitFailure(ctx.mapGraphQLErrors(parentData.body.errors)); + } + if (parentData.body.data?.issue?.id === undefined) { + return emitValidationError(`Could not resolve parent issue "${options.parent}".`, options); + } + input.parentId = parentData.body.data.issue.id; + } + } + + if (options.dryRun === true) { + return emitDryRunResult("create", "issue", input, options); + } + + const response = await ctx.graphql<{ + issueCreate: { success: boolean; issue: RawIssue | null }; + }>(ISSUE_CREATE_MUTATION, { input }); + + if ( + ctx.hasErrors(response.body.errors) || + response.body.data?.issueCreate?.issue === null || + response.body.data?.issueCreate?.issue === undefined + ) { + const errors = ctx.mapGraphQLErrors(response.body.errors); + return ctx.emitFailure( + errors.length > 0 ? errors : [{ category: "general", message: "Issue creation failed" }] + ); + } + + const issue = normalizeIssue(response.body.data.issueCreate.issue); + + if (options.jsonEnvelope) { + return ctx.emitSuccess(issue); + } else if (options.json) { + stdout.write(`${JSON.stringify(issue, null, 2)}\n`); + } else { + stdout.write(`Created ${issue.identifier}: ${issue.title}\n`); + stdout.write(` URL: ${issue.url}\n`); + } + + return ExitCode.Success; + } catch (error) { + return ctx.emitCaughtError(error); + } +} + +export async function handleIssueUpdate( + identifier: string, + options: IssueCommandOptions +): Promise { + const { stdout } = commandIO(options); + let inputFromJson: Record = {}; + + if (options.inputJson !== undefined) { + try { + const parsed = JSON.parse(options.inputJson) as unknown; + if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { + return emitValidationError("--input-json must be a JSON object.", options); + } + inputFromJson = parsed as Record; + } catch { + return emitValidationError("--input-json contains invalid JSON.", options); + } + } + + const input: Record = { ...inputFromJson }; + + if (options.title !== undefined) { + input.title = options.title; + } + let description: string | undefined; + try { + description = await resolveDescriptionInput(options); + } catch (error) { + return emitValidationError(error instanceof Error ? error.message : String(error), options); + } + if (description !== undefined) { + input.description = description; + } + if (options.priority !== undefined) { + const parsed = Number(options.priority); + if (!Number.isInteger(parsed)) { + return emitValidationError("--priority must be an integer.", options); + } + input.priority = parsed; + } + if (options.estimate !== undefined) { + const parsed = Number(options.estimate); + if (!Number.isFinite(parsed) || parsed < 0) { + return emitValidationError("--estimate must be a non-negative number.", options); + } + input.estimate = parsed; + } + if (options.dueDate !== undefined) { + if (!isUnsetValue(options.dueDate) && !isValidDueDate(options.dueDate)) { + return emitValidationError(dueDateValidationError(true), options); + } + input.dueDate = isUnsetValue(options.dueDate) ? null : options.dueDate; + } + if (options.assignee !== undefined) { + input.assigneeId = isUnassignedValue(options.assignee) ? null : options.assignee; + } + if (options.label !== undefined) { + // --label is additive. Keep raw labelIds available through --input-json for + // callers that explicitly need replacement semantics. + delete input.labelIds; + } + if (options.state !== undefined) { + input.stateId = options.state; + } + if (options.cycle !== undefined) { + input.cycleId = isUnsetValue(options.cycle) ? null : options.cycle; + } + if (options.project !== undefined) { + input.projectId = isUnsetValue(options.project) ? null : options.project; + } + const projectMilestone = options.projectMilestone ?? options.milestone; + if (projectMilestone !== undefined) { + input.projectMilestoneId = isUnsetValue(projectMilestone) ? null : projectMilestone; + } + if (options.parent !== undefined) { + input.parentId = isUnsetValue(options.parent) ? null : options.parent; + } + + if (Object.keys(input).length === 0 && options.label === undefined) { + return emitValidationError("issue update requires at least one field to update.", options); + } + + const ctx = createCommandContext(options); + + try { + const resolverOpts = await ctx.resolverOptions(); + + // Resolve friendly names to IDs + if ( + options.assignee !== undefined && + !isUnassignedValue(options.assignee) && + !looksLikeId(options.assignee) + ) { + input.assigneeId = await resolveUserId(options.assignee, resolverOpts); + } + + // Fetch the issue's team when label or state resolution needs it + const needsTeamLookup = + (options.label !== undefined && !looksLikeId(options.label)) || + (options.state !== undefined && !looksLikeId(options.state)) || + (options.project !== undefined && !isUnsetValue(options.project) && shouldResolveProjectIdentifier(options.project)); + let issueTeamId: string | undefined; + if (needsTeamLookup) { + const issueData = await ctx.graphql<{ issue: { team: { id: string } } | null }>( + `query IssueTeam($id: String!) { issue(id: $id) { team { id } } }`, + { id: identifier } + ); + if (ctx.hasErrors(issueData.body.errors)) { + return ctx.emitFailure(ctx.mapGraphQLErrors(issueData.body.errors)); + } + issueTeamId = issueData.body.data?.issue?.team?.id; + if (issueTeamId === undefined) { + return emitValidationError(`Could not find issue "${identifier}" or its team for name resolution.`, options); + } + } + + const labelId = options.label === undefined + ? undefined + : looksLikeId(options.label) + ? options.label + : await resolveLabelId(options.label, issueTeamId, resolverOpts); + if (labelId !== undefined) { + input.addedLabelIds = [labelId]; + } + if (options.state !== undefined && !looksLikeId(options.state)) { + input.stateId = await resolveStateId(options.state, issueTeamId!, resolverOpts); + } + if ( + options.project !== undefined && + !isUnsetValue(options.project) && + shouldResolveProjectIdentifier(options.project) + ) { + input.projectId = await resolveProjectId(options.project, issueTeamId, resolverOpts); + } + if (options.parent !== undefined && !isUnsetValue(options.parent)) { + if (looksLikeId(options.parent)) { + input.parentId = options.parent; + } else { + const parentData = await ctx.graphql<{ issue: { id: string } | null }>( + `query IssueResolve($id: String!) { issue(id: $id) { id } }`, + { id: options.parent } + ); + if (ctx.hasErrors(parentData.body.errors)) { + return ctx.emitFailure(ctx.mapGraphQLErrors(parentData.body.errors)); + } + if (parentData.body.data?.issue?.id === undefined) { + return emitValidationError(`Could not resolve parent issue "${options.parent}".`, options); + } + input.parentId = parentData.body.data.issue.id; + } + } + + if (options.dryRun === true) { + return emitDryRunResult("update", "issue", { + id: identifier, + ...input + }, options); + } + + const response = await ctx.graphql<{ + issueUpdate: { success: boolean; issue: RawIssue | null }; + }>(ISSUE_UPDATE_MUTATION, { id: identifier, input }); + + if ( + ctx.hasErrors(response.body.errors) || + response.body.data?.issueUpdate?.issue === null || + response.body.data?.issueUpdate?.issue === undefined + ) { + const errors = ctx.mapGraphQLErrors(response.body.errors); + return ctx.emitFailure( + errors.length > 0 ? errors : [{ category: "general", message: "Issue update failed" }] + ); + } + + const issue = normalizeIssue(response.body.data.issueUpdate.issue); + + if (options.jsonEnvelope) { + return ctx.emitSuccess(issue); + } else if (options.json) { + stdout.write(`${JSON.stringify(issue, null, 2)}\n`); + } else { + stdout.write(`Updated ${issue.identifier}: ${issue.title}\n`); + } + + return ExitCode.Success; + } catch (error) { + return ctx.emitCaughtError(error); + } +} + +export async function handleIssueDelete( + identifier: string, + options: IssueCommandOptions +): Promise { + const { stdout } = commandIO(options); + if (options.dryRun === true) { + return emitDryRunResult("delete", "issue", { id: identifier }, options); + } + + const ctx = createCommandContext(options); + + try { + const issue = await resolveIssueForDelete(identifier, ctx); + if (issue === undefined) { + return ctx.emitNotFound("Issue not found"); + } + + const response = await ctx.graphql<{ + issueDelete: { success: boolean }; + }>(ISSUE_DELETE_MUTATION, { id: issue.id }); + + if ( + ctx.hasErrors(response.body.errors) || + response.body.data?.issueDelete?.success !== true + ) { + const errors = ctx.mapGraphQLErrors(response.body.errors); + return ctx.emitFailure( + errors.length > 0 ? errors : [{ category: "general", message: "Issue delete failed" }] + ); + } + + const result = { id: issue.id, identifier: issue.identifier, deleted: true }; + + if (options.jsonEnvelope) { + return ctx.emitSuccess(result); + } else if (options.json) { + stdout.write(`${JSON.stringify(result, null, 2)}\n`); + } else { + stdout.write(`Deleted ${issue.identifier}\n`); + } + + return ExitCode.Success; + } catch (error) { + return ctx.emitCaughtError(error); + } +} diff --git a/src/commands/label.ts b/src/commands/label.ts index 2281742..0264db8 100644 --- a/src/commands/label.ts +++ b/src/commands/label.ts @@ -1,25 +1,18 @@ +import { commandIO, type CommandOptions, type CommandIO } from "../core/runtime/options.js"; import { emitValidationError } from "../core/output/validation-error.js"; import type { PageInfo } from "../core/output/envelope.js"; import { ExitCode } from "../core/errors/exit-codes.js"; -import type { FetchLike, GraphQLErrorPayload } from "../core/transport/graphql.js"; +import type { GraphQLErrorPayload } from "../core/transport/graphql.js"; import { paginateGraphQL, validatePaginationOptions } from "../core/pagination/pagination.js"; import type { PaginationOptions } from "../core/pagination/pagination.js"; import { streamPaginateGraphQL } from "../core/pagination/streaming.js"; import { emitDryRunResult } from "../core/output/dry-run.js"; import { resolveTeamId, resolveLabelId, looksLikeId } from "../core/resolution/resolve.js"; import { normalizeRetryOptions } from "../core/transport/retry.js"; -import { CommandContext } from "../core/runtime/command-context.js"; +import { createCommandContext } from "../core/runtime/command-context.js"; -export interface LabelCommandOptions { - json: boolean; - jsonEnvelope: boolean; +export interface LabelCommandOptions extends CommandOptions { jsonl?: boolean; - profile?: string; - configFile: string; - credentialsFile: string; - apiUrl?: string; - env: Record; - fetchImpl?: FetchLike; dryRun?: boolean; // label create flags name?: string; @@ -35,9 +28,6 @@ export interface LabelCommandOptions { pageSize?: number; after?: string; quiet?: boolean; - // retry flags - noRetry?: boolean; - maxRetries?: number; } interface RawLabel { @@ -131,49 +121,29 @@ export function normalizeLabel(raw: RawLabel): NormalizedLabel { }; } -function printHumanLabel(label: NormalizedLabel): void { - process.stdout.write(`${label.name} (${label.color})\n`); +function printHumanLabel(label: NormalizedLabel, options: CommandIO): void { + const { stdout } = commandIO(options); + stdout.write(`${label.name} (${label.color})\n`); if (label.description !== null) { - process.stdout.write(` Description: ${label.description}\n`); + stdout.write(` Description: ${label.description}\n`); } if (label.team !== null) { - process.stdout.write(` Team: ${label.team.name}\n`); + stdout.write(` Team: ${label.team.name}\n`); } if (label.isGroup) { - process.stdout.write(" Group: true\n"); + stdout.write(" Group: true\n"); } if (label.parent !== null) { - process.stdout.write(` Parent: ${label.parent.name}\n`); + stdout.write(` Parent: ${label.parent.name}\n`); } } -/** Build a CommandContext from label handler options */ -function buildContext(options: LabelCommandOptions): CommandContext { - return new CommandContext({ - json: options.json, - jsonEnvelope: options.jsonEnvelope, - ...(options.profile === undefined ? {} : { profile: options.profile }), - configFile: options.configFile, - credentialsFile: options.credentialsFile, - ...(options.apiUrl === undefined ? {} : { apiUrl: options.apiUrl }), - env: options.env, - ...(options.fetchImpl === undefined ? {} : { fetchImpl: options.fetchImpl }), - ...(options.noRetry === true || options.maxRetries !== undefined - ? { - retry: { - ...(options.noRetry === true ? { noRetry: true } : {}), - ...(options.maxRetries !== undefined ? { maxRetries: options.maxRetries } : {}), - }, - } - : {}), - }); -} - async function handleLabelGet( identifier: string, options: LabelCommandOptions ): Promise { - const ctx = buildContext(options); + const { stdout } = commandIO(options); + const ctx = createCommandContext(options); try { let response = await ctx.graphql<{ issueLabel: RawLabel | null }>( @@ -211,9 +181,9 @@ async function handleLabelGet( if (options.jsonEnvelope) { return ctx.emitSuccess(label); } else if (options.json) { - process.stdout.write(`${JSON.stringify(label, null, 2)}\n`); + stdout.write(`${JSON.stringify(label, null, 2)}\n`); } else { - printHumanLabel(label); + printHumanLabel(label, options); } return ExitCode.Success; @@ -223,7 +193,9 @@ async function handleLabelGet( } async function handleLabelList(options: LabelCommandOptions): Promise { + const { stdout } = commandIO(options); const paginationOptions: PaginationOptions = { + stderr: commandIO(options).stderr, all: options.all, max: options.max, pageSize: options.pageSize, @@ -236,7 +208,7 @@ async function handleLabelList(options: LabelCommandOptions): Promise { return emitValidationError(validationError, options); } - const ctx = buildContext(options); + const ctx = createCommandContext(options); try { const profile = await ctx.resolveProfile(); @@ -273,7 +245,7 @@ async function handleLabelList(options: LabelCommandOptions): Promise { ...commonPaginateInput, options: { ...paginationOptions, all: paginationOptions.all ?? true }, onItem: (raw) => { - process.stdout.write(`${JSON.stringify(normalizeLabel(raw))}\n`); + stdout.write(`${JSON.stringify(normalizeLabel(raw))}\n`); } }); } else { @@ -287,11 +259,11 @@ async function handleLabelList(options: LabelCommandOptions): Promise { if (options.jsonEnvelope) { return ctx.emitSuccess(labels, pageInfo); } else if (options.json) { - process.stdout.write(`${JSON.stringify(labels, null, 2)}\n`); + stdout.write(`${JSON.stringify(labels, null, 2)}\n`); } else { for (const label of labels) { - printHumanLabel(label); - process.stdout.write("\n"); + printHumanLabel(label, options); + stdout.write("\n"); } } } @@ -303,6 +275,7 @@ async function handleLabelList(options: LabelCommandOptions): Promise { } async function handleLabelCreate(options: LabelCommandOptions): Promise { + const { stdout } = commandIO(options); if (options.name === undefined) { return emitValidationError("--name is required for label create.", options); } @@ -324,7 +297,7 @@ async function handleLabelCreate(options: LabelCommandOptions): Promise input.isGroup = true; } - const ctx = buildContext(options); + const ctx = createCommandContext(options); try { const resolverOpts = await ctx.resolverOptions(); @@ -366,9 +339,9 @@ async function handleLabelCreate(options: LabelCommandOptions): Promise if (options.jsonEnvelope) { return ctx.emitSuccess(label); } else if (options.json) { - process.stdout.write(`${JSON.stringify(label, null, 2)}\n`); + stdout.write(`${JSON.stringify(label, null, 2)}\n`); } else { - process.stdout.write(`Created label: ${label.name} (${label.color})\n`); + stdout.write(`Created label: ${label.name} (${label.color})\n`); } return ExitCode.Success; @@ -378,11 +351,12 @@ async function handleLabelCreate(options: LabelCommandOptions): Promise } async function handleLabelDelete(labelId: string, options: LabelCommandOptions): Promise { + const { stdout } = commandIO(options); if (options.dryRun === true) { return emitDryRunResult("delete", "label", { id: labelId }, options); } - const ctx = buildContext(options); + const ctx = createCommandContext(options); try { const response = await ctx.graphql<{ @@ -404,9 +378,9 @@ async function handleLabelDelete(labelId: string, options: LabelCommandOptions): if (options.jsonEnvelope) { return ctx.emitSuccess(result); } else if (options.json) { - process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); + stdout.write(`${JSON.stringify(result, null, 2)}\n`); } else { - process.stdout.write(`Deleted label ${labelId}\n`); + stdout.write(`Deleted label ${labelId}\n`); } return ExitCode.Success; diff --git a/src/commands/project-status.ts b/src/commands/project-status.ts index e3d14e1..86fb050 100644 --- a/src/commands/project-status.ts +++ b/src/commands/project-status.ts @@ -1,26 +1,18 @@ +import { commandIO, type CommandOptions, type CommandIO } from "../core/runtime/options.js"; import { emitValidationError } from "../core/output/validation-error.js"; import type { PageInfo } from "../core/output/envelope.js"; import { ExitCode } from "../core/errors/exit-codes.js"; -import type { FetchLike } from "../core/transport/graphql.js"; import { paginateGraphQL, validatePaginationOptions } from "../core/pagination/pagination.js"; import type { PaginationOptions } from "../core/pagination/pagination.js"; import { streamPaginateGraphQL } from "../core/pagination/streaming.js"; import { emitDryRunResult } from "../core/output/dry-run.js"; import { normalizeRetryOptions } from "../core/transport/retry.js"; -import { CommandContext } from "../core/runtime/command-context.js"; +import { createCommandContext } from "../core/runtime/command-context.js"; const VALID_STATUS_TYPES = ["backlog", "planned", "started", "paused", "completed", "canceled"] as const; -export interface ProjectStatusCommandOptions { - json: boolean; - jsonEnvelope: boolean; +export interface ProjectStatusCommandOptions extends CommandOptions { jsonl?: boolean; - profile?: string; - configFile: string; - credentialsFile: string; - apiUrl?: string; - env: Record; - fetchImpl?: FetchLike; dryRun?: boolean; // project-status create flags name?: string; @@ -34,9 +26,6 @@ export interface ProjectStatusCommandOptions { pageSize?: number; after?: string; quiet?: boolean; - // retry flags - noRetry?: boolean; - maxRetries?: number; } interface RawProjectStatus { @@ -117,40 +106,20 @@ export function normalizeProjectStatus(raw: RawProjectStatus): NormalizedProject }; } -function printHumanProjectStatus(status: NormalizedProjectStatus): void { - process.stdout.write(`${status.name} ${status.type} (${status.color})\n`); +function printHumanProjectStatus(status: NormalizedProjectStatus, options: CommandIO): void { + const { stdout } = commandIO(options); + stdout.write(`${status.name} ${status.type} (${status.color})\n`); if (status.description !== null) { - process.stdout.write(` Description: ${status.description}\n`); + stdout.write(` Description: ${status.description}\n`); } } -/** Build a CommandContext from project-status handler options */ -function buildContext(options: ProjectStatusCommandOptions): CommandContext { - return new CommandContext({ - json: options.json, - jsonEnvelope: options.jsonEnvelope, - ...(options.profile === undefined ? {} : { profile: options.profile }), - configFile: options.configFile, - credentialsFile: options.credentialsFile, - ...(options.apiUrl === undefined ? {} : { apiUrl: options.apiUrl }), - env: options.env, - ...(options.fetchImpl === undefined ? {} : { fetchImpl: options.fetchImpl }), - ...(options.noRetry === true || options.maxRetries !== undefined - ? { - retry: { - ...(options.noRetry === true ? { noRetry: true } : {}), - ...(options.maxRetries !== undefined ? { maxRetries: options.maxRetries } : {}), - }, - } - : {}), - }); -} - async function handleProjectStatusGet( identifier: string, options: ProjectStatusCommandOptions ): Promise { - const ctx = buildContext(options); + const { stdout } = commandIO(options); + const ctx = createCommandContext(options); try { const response = await ctx.graphql<{ projectStatus: RawProjectStatus | null }>( @@ -171,9 +140,9 @@ async function handleProjectStatusGet( if (options.jsonEnvelope) { return ctx.emitSuccess(status); } else if (options.json) { - process.stdout.write(`${JSON.stringify(status, null, 2)}\n`); + stdout.write(`${JSON.stringify(status, null, 2)}\n`); } else { - printHumanProjectStatus(status); + printHumanProjectStatus(status, options); } return ExitCode.Success; @@ -183,7 +152,9 @@ async function handleProjectStatusGet( } async function handleProjectStatusList(options: ProjectStatusCommandOptions): Promise { + const { stdout } = commandIO(options); const paginationOptions: PaginationOptions = { + stderr: commandIO(options).stderr, all: options.all, max: options.max, pageSize: options.pageSize, @@ -196,7 +167,7 @@ async function handleProjectStatusList(options: ProjectStatusCommandOptions): Pr return emitValidationError(validationError, options); } - const ctx = buildContext(options); + const ctx = createCommandContext(options); try { const profile = await ctx.resolveProfile(); @@ -227,7 +198,7 @@ async function handleProjectStatusList(options: ProjectStatusCommandOptions): Pr ...commonPaginateInput, options: { ...paginationOptions, all: paginationOptions.all ?? true }, onItem: (raw) => { - process.stdout.write(`${JSON.stringify(normalizeProjectStatus(raw))}\n`); + stdout.write(`${JSON.stringify(normalizeProjectStatus(raw))}\n`); } }); } else { @@ -241,11 +212,11 @@ async function handleProjectStatusList(options: ProjectStatusCommandOptions): Pr if (options.jsonEnvelope) { return ctx.emitSuccess(statuses, pageInfo); } else if (options.json) { - process.stdout.write(`${JSON.stringify(statuses, null, 2)}\n`); + stdout.write(`${JSON.stringify(statuses, null, 2)}\n`); } else { for (const status of statuses) { - printHumanProjectStatus(status); - process.stdout.write("\n"); + printHumanProjectStatus(status, options); + stdout.write("\n"); } } } @@ -257,6 +228,7 @@ async function handleProjectStatusList(options: ProjectStatusCommandOptions): Pr } async function handleProjectStatusCreate(options: ProjectStatusCommandOptions): Promise { + const { stdout } = commandIO(options); if (options.name === undefined) { return emitValidationError("--name is required for project-status create.", options); } @@ -300,7 +272,7 @@ async function handleProjectStatusCreate(options: ProjectStatusCommandOptions): return emitDryRunResult("create", "project-status", input, options); } - const ctx = buildContext(options); + const ctx = createCommandContext(options); try { const response = await ctx.graphql<{ @@ -323,9 +295,9 @@ async function handleProjectStatusCreate(options: ProjectStatusCommandOptions): if (options.jsonEnvelope) { return ctx.emitSuccess(status); } else if (options.json) { - process.stdout.write(`${JSON.stringify(status, null, 2)}\n`); + stdout.write(`${JSON.stringify(status, null, 2)}\n`); } else { - process.stdout.write(`Created project status: ${status.name} (${status.type})\n`); + stdout.write(`Created project status: ${status.name} (${status.type})\n`); } return ExitCode.Success; @@ -335,11 +307,12 @@ async function handleProjectStatusCreate(options: ProjectStatusCommandOptions): } async function handleProjectStatusDelete(statusId: string, options: ProjectStatusCommandOptions): Promise { + const { stdout } = commandIO(options); if (options.dryRun === true) { return emitDryRunResult("delete", "project-status", { id: statusId }, options); } - const ctx = buildContext(options); + const ctx = createCommandContext(options); try { const response = await ctx.graphql<{ @@ -362,9 +335,9 @@ async function handleProjectStatusDelete(statusId: string, options: ProjectStatu if (options.jsonEnvelope) { return ctx.emitSuccess(result); } else if (options.json) { - process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); + stdout.write(`${JSON.stringify(result, null, 2)}\n`); } else { - process.stdout.write(`Archived project status ${statusId}\n`); + stdout.write(`Archived project status ${statusId}\n`); } return ExitCode.Success; diff --git a/src/commands/project.ts b/src/commands/project.ts index 53d3312..53bcd70 100644 --- a/src/commands/project.ts +++ b/src/commands/project.ts @@ -1,35 +1,23 @@ +import { commandIO, type CommandOptions, type CommandIO } from "../core/runtime/options.js"; import { emitValidationError } from "../core/output/validation-error.js"; -import { failureEnvelope, successEnvelope } from "../core/output/envelope.js"; import type { PageInfo } from "../core/output/envelope.js"; -import { mapCommandFailure } from "../core/errors/command-failure.js"; import { ExitCode } from "../core/errors/exit-codes.js"; -import { executeGraphQL } from "../core/transport/graphql.js"; -import type { FetchLike, GraphQLErrorPayload } from "../core/transport/graphql.js"; -import { resolveStoredProfile } from "../core/auth/runtime.js"; +import type { GraphQLErrorPayload } from "../core/transport/graphql.js"; import { paginateGraphQL, validatePaginationOptions, type PaginationOptions } from "../core/pagination/pagination.js"; import { streamPaginateGraphQL } from "../core/pagination/streaming.js"; import { emitDryRunResult } from "../core/output/dry-run.js"; import { resolveContentInput, resolveDescriptionInput } from "../core/io/text-input.js"; import { resolveProjectId, resolveTeamId, resolveUserId, looksLikeId } from "../core/resolution/resolve.js"; -import type { ResolverOptions } from "../core/resolution/resolve.js"; import { normalizeRetryOptions } from "../core/transport/retry.js"; -import { CommandContext } from "../core/runtime/command-context.js"; +import { CommandContext, createCommandContext } from "../core/runtime/command-context.js"; import { runTwoStepWorkflow, WorkflowStepError } from "../core/runtime/workflow.js"; // Linear's query-complexity budget is exceeded by 250-project pages because // each project includes nested milestones and teams in the stable JSON output. const PROJECT_LIST_PAGE_SIZE = 50; -export interface ProjectCommandOptions { - json: boolean; - jsonEnvelope: boolean; +export interface ProjectCommandOptions extends CommandOptions { jsonl?: boolean; - profile?: string; - configFile: string; - credentialsFile: string; - apiUrl?: string; - env: Record; - fetchImpl?: FetchLike; dryRun?: boolean; // project flags name?: string; @@ -55,9 +43,6 @@ export interface ProjectCommandOptions { pageSize?: number; after?: string; quiet?: boolean; - // retry flags - noRetry?: boolean; - maxRetries?: number; } const VALID_PROJECT_STATE_TYPES = ["backlog", "planned", "started", "paused", "completed", "canceled"] as const; @@ -426,33 +411,34 @@ export function normalizeProjectDetail(raw: RawProjectDetail): NormalizedProject }; } -function printHumanProject(project: NormalizedProject): void { - process.stdout.write(`${project.name}\n`); - process.stdout.write(` State: ${project.state}\n`); +function printHumanProject(project: NormalizedProject, options: CommandIO): void { + const { stdout } = commandIO(options); + stdout.write(`${project.name}\n`); + stdout.write(` State: ${project.state}\n`); if (project.progress !== null) { const percent = project.progress <= 1 ? project.progress * 100 : project.progress; - process.stdout.write(` Progress: ${Math.round(percent)}%\n`); + stdout.write(` Progress: ${Math.round(percent)}%\n`); } if (project.health !== null) { - process.stdout.write(` Health: ${project.health}\n`); + stdout.write(` Health: ${project.health}\n`); } if (project.lead !== null) { - process.stdout.write(` Lead: ${project.lead.name}\n`); + stdout.write(` Lead: ${project.lead.name}\n`); } if (project.startDate !== null) { - process.stdout.write(` Start: ${project.startDate}\n`); + stdout.write(` Start: ${project.startDate}\n`); } if (project.targetDate !== null) { - process.stdout.write(` Target: ${project.targetDate}\n`); + stdout.write(` Target: ${project.targetDate}\n`); } if (project.updatedAt !== "") { - process.stdout.write(` Updated: ${project.updatedAt}\n`); + stdout.write(` Updated: ${project.updatedAt}\n`); } if (project.description !== null && project.description !== "") { - process.stdout.write(` Description: ${project.description}\n`); + stdout.write(` Description: ${project.description}\n`); } if (project.milestones.length > 0) { - process.stdout.write(" Milestones:\n"); + stdout.write(" Milestones:\n"); for (const milestone of project.milestones) { const parts = [milestone.name]; if (milestone.targetDate !== null) { @@ -465,13 +451,13 @@ function printHumanProject(project: NormalizedProject): void { if (milestone.status !== null) { parts.push(milestone.status); } - process.stdout.write(` - ${parts.join(" | ")}\n`); + stdout.write(` - ${parts.join(" | ")}\n`); } if (project.milestonesTruncated) { - process.stdout.write(" - ... more milestones available via --json metadata\n"); + stdout.write(" - ... more milestones available via --json metadata\n"); } } - process.stdout.write(` URL: ${project.url}\n`); + stdout.write(` URL: ${project.url}\n`); } function validateIsoDate(value: string, flagName: string, options: ProjectCommandOptions): number | undefined { @@ -486,33 +472,12 @@ function validateIsoDate(value: string, flagName: string, options: ProjectComman return undefined; } -/** Build a CommandContext from project handler options */ -function buildContext(options: ProjectCommandOptions): CommandContext { - return new CommandContext({ - json: options.json, - jsonEnvelope: options.jsonEnvelope, - ...(options.profile === undefined ? {} : { profile: options.profile }), - configFile: options.configFile, - credentialsFile: options.credentialsFile, - ...(options.apiUrl === undefined ? {} : { apiUrl: options.apiUrl }), - env: options.env, - ...(options.fetchImpl === undefined ? {} : { fetchImpl: options.fetchImpl }), - ...(options.noRetry === true || options.maxRetries !== undefined - ? { - retry: { - ...(options.noRetry === true ? { noRetry: true } : {}), - ...(options.maxRetries !== undefined ? { maxRetries: options.maxRetries } : {}), - }, - } - : {}), - }); -} - async function handleProjectGet( id: string, options: ProjectCommandOptions ): Promise { - const ctx = buildContext(options); + const { stdout } = commandIO(options); + const ctx = createCommandContext(options); try { let projectId = id; @@ -547,9 +512,9 @@ async function handleProjectGet( if (options.jsonEnvelope) { return ctx.emitSuccess(project); } else if (options.json) { - process.stdout.write(`${JSON.stringify(project, null, 2)}\n`); + stdout.write(`${JSON.stringify(project, null, 2)}\n`); } else { - printHumanProject(project); + printHumanProject(project, options); } return ExitCode.Success; @@ -559,7 +524,9 @@ async function handleProjectGet( } async function handleProjectList(options: ProjectCommandOptions): Promise { + const { stdout } = commandIO(options); const paginationOptions: PaginationOptions = { + stderr: commandIO(options).stderr, all: options.all, max: options.max, pageSize: options.pageSize ?? PROJECT_LIST_PAGE_SIZE, @@ -572,7 +539,7 @@ async function handleProjectList(options: ProjectCommandOptions): Promise { - process.stdout.write(`${JSON.stringify(normalizeProject(raw))}\n`); + stdout.write(`${JSON.stringify(normalizeProject(raw))}\n`); } }); } else { @@ -647,11 +614,11 @@ async function handleProjectList(options: ProjectCommandOptions): Promise { + const { stdout } = commandIO(options); if (options.name === undefined) { return emitValidationError("--name is required for project create.", options); } @@ -704,7 +672,7 @@ async function handleProjectCreate(options: ProjectCommandOptions): Promise { + const { stdout } = commandIO(options); const input: Record = {}; if (options.name !== undefined) { @@ -820,7 +789,7 @@ async function handleProjectUpdate( return emitValidationError("project update requires at least one of --name, --description, --description-file, --content, --content-file, --status, --state, --lead, --start-date, --target-date.", options); } - const ctx = buildContext(options); + const ctx = createCommandContext(options); try { if (options.lead !== undefined && !looksLikeId(options.lead)) { @@ -860,10 +829,10 @@ async function handleProjectUpdate( if (options.jsonEnvelope) { return ctx.emitSuccess(project); } else if (options.json) { - process.stdout.write(`${JSON.stringify(project, null, 2)}\n`); + stdout.write(`${JSON.stringify(project, null, 2)}\n`); } else { - process.stdout.write(`Updated project: ${project.name}\n`); - process.stdout.write(` URL: ${project.url}\n`); + stdout.write(`Updated project: ${project.name}\n`); + stdout.write(` URL: ${project.url}\n`); } return ExitCode.Success; @@ -877,6 +846,7 @@ async function handleProjectUpdate( * Uses the workflow abstraction for typed partial-success reporting. */ async function handleProjectCreateWithIssues(options: ProjectCommandOptions): Promise { + const { stdout } = commandIO(options); if (options.name === undefined) { return emitValidationError("--name is required for project create-with-issues.", options); } @@ -951,7 +921,7 @@ async function handleProjectCreateWithIssues(options: ProjectCommandOptions): Pr projectInput.targetDate = options.targetDate; } - const ctx = buildContext(options); + const ctx = createCommandContext(options); try { const resolverOpts = await ctx.resolverOptions(); @@ -1056,13 +1026,13 @@ async function handleProjectCreateWithIssues(options: ProjectCommandOptions): Pr if (options.jsonEnvelope) { return ctx.emitSuccess(result); } else if (options.json) { - process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); + stdout.write(`${JSON.stringify(result, null, 2)}\n`); } else { - process.stdout.write(`Created project: ${project.name}\n`); - process.stdout.write(` URL: ${project.url}\n`); - process.stdout.write(` Issues created: ${issues.length}\n`); + stdout.write(`Created project: ${project.name}\n`); + stdout.write(` URL: ${project.url}\n`); + stdout.write(` Issues created: ${issues.length}\n`); for (const issue of issues) { - process.stdout.write(` ${issue.identifier}: ${issue.title}\n`); + stdout.write(` ${issue.identifier}: ${issue.title}\n`); } } @@ -1073,11 +1043,12 @@ async function handleProjectCreateWithIssues(options: ProjectCommandOptions): Pr } async function handleProjectDelete(id: string, options: ProjectCommandOptions): Promise { + const { stdout } = commandIO(options); if (options.dryRun === true) { return emitDryRunResult("delete", "project", { id }, options); } - const ctx = buildContext(options); + const ctx = createCommandContext(options); try { const response = await ctx.graphql<{ @@ -1099,9 +1070,9 @@ async function handleProjectDelete(id: string, options: ProjectCommandOptions): if (options.jsonEnvelope) { return ctx.emitSuccess(result); } else if (options.json) { - process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); + stdout.write(`${JSON.stringify(result, null, 2)}\n`); } else { - process.stdout.write(`Deleted project ${id}\n`); + stdout.write(`Deleted project ${id}\n`); } return ExitCode.Success; diff --git a/src/commands/relation.ts b/src/commands/relation.ts index a6852d1..9236d46 100644 --- a/src/commands/relation.ts +++ b/src/commands/relation.ts @@ -1,28 +1,20 @@ +import { commandIO, type CommandOptions, type CommandIO } from "../core/runtime/options.js"; import { emitValidationError } from "../core/output/validation-error.js"; import type { PageInfo } from "../core/output/envelope.js"; import { ExitCode } from "../core/errors/exit-codes.js"; -import type { FetchLike } from "../core/transport/graphql.js"; import { paginateGraphQL, validatePaginationOptions } from "../core/pagination/pagination.js"; import type { PaginationOptions } from "../core/pagination/pagination.js"; import { emitDryRunResult } from "../core/output/dry-run.js"; import { normalizeRetryOptions } from "../core/transport/retry.js"; -import { CommandContext } from "../core/runtime/command-context.js"; +import { createCommandContext } from "../core/runtime/command-context.js"; const RELATION_TYPES = ["blocks", "duplicate", "related", "similar"] as const; export type IssueRelationType = (typeof RELATION_TYPES)[number]; export type IssueRelationDirection = "outbound" | "inbound"; -export interface RelationCommandOptions { - json: boolean; - jsonEnvelope: boolean; +export interface RelationCommandOptions extends CommandOptions { jsonl?: boolean; - profile?: string; - configFile: string; - credentialsFile: string; - apiUrl?: string; - env: Record; - fetchImpl?: FetchLike; dryRun?: boolean; issue?: string; related?: string; @@ -31,8 +23,6 @@ export interface RelationCommandOptions { max?: number; pageSize?: number; quiet?: boolean; - noRetry?: boolean; - maxRetries?: number; } interface RelationIssue { @@ -133,27 +123,6 @@ mutation RelationDelete($id: String!) { } }`; -function buildContext(options: RelationCommandOptions): CommandContext { - return new CommandContext({ - json: options.json, - jsonEnvelope: options.jsonEnvelope, - ...(options.profile === undefined ? {} : { profile: options.profile }), - configFile: options.configFile, - credentialsFile: options.credentialsFile, - ...(options.apiUrl === undefined ? {} : { apiUrl: options.apiUrl }), - env: options.env, - ...(options.fetchImpl === undefined ? {} : { fetchImpl: options.fetchImpl }), - ...(options.noRetry === true || options.maxRetries !== undefined - ? { - retry: { - ...(options.noRetry === true ? { noRetry: true } : {}), - ...(options.maxRetries === undefined ? {} : { maxRetries: options.maxRetries }) - } - } - : {}) - }); -} - function relationTypeFromInput(value: string): IssueRelationType | undefined { const normalized = value.trim().toLowerCase().replaceAll("_", "-"); if (normalized === "related-to" || normalized === "relatedto") { @@ -162,15 +131,18 @@ function relationTypeFromInput(value: string): IssueRelationType | undefined { return RELATION_TYPES.find((type) => type === normalized); } -function printHumanRelation(relation: NormalizedIssueRelation): void { - process.stdout.write( +function printHumanRelation(relation: NormalizedIssueRelation, options: CommandIO): void { + const { stdout } = commandIO(options); + stdout.write( `${relation.type} [${relation.direction}] ${relation.issue.identifier} -> ${relation.relatedIssue.identifier}\n` ); - process.stdout.write(` ID: ${relation.id}\n`); + stdout.write(` ID: ${relation.id}\n`); } async function handleRelationList(issueInput: string, options: RelationCommandOptions): Promise { + const { stderr, stdout } = commandIO(options); const paginationOptions: PaginationOptions = { + stderr: commandIO(options).stderr, ...(options.all === true ? { all: true } : {}), ...(options.max === undefined ? {} : { max: options.max }), ...(options.pageSize === undefined ? {} : { pageSize: options.pageSize }), @@ -181,7 +153,7 @@ async function handleRelationList(issueInput: string, options: RelationCommandOp return emitValidationError(validationError, options); } - const ctx = buildContext(options); + const ctx = createCommandContext(options); try { const lookup = await ctx.graphql<{ @@ -260,25 +232,25 @@ async function handleRelationList(issueInput: string, options: RelationCommandOp const inboundNote = inboundSkipped && issue.inverseRelations.nodes.length > 0 ? " Outbound relations exhausted the limit; inbound relations were not fetched." : ""; - process.stderr.write( + stderr.write( `Warning: results truncated at ${relations.length} items.${inboundNote} Use --all to fetch all results, or --max for a specific limit.\n` ); } if (options.jsonl === true) { for (const relation of relations) { - process.stdout.write(`${JSON.stringify(relation)}\n`); + stdout.write(`${JSON.stringify(relation)}\n`); } } else if (options.jsonEnvelope) { return ctx.emitSuccess(relations, { hasNextPage }); } else if (options.json) { - process.stdout.write(`${JSON.stringify(relations, null, 2)}\n`); + stdout.write(`${JSON.stringify(relations, null, 2)}\n`); } else { for (const relation of relations) { - printHumanRelation(relation); + printHumanRelation(relation, options); } if (relations.length === 0) { - process.stdout.write("No relations found.\n"); + stdout.write("No relations found.\n"); } } @@ -289,6 +261,7 @@ async function handleRelationList(issueInput: string, options: RelationCommandOp } async function handleRelationCreate(options: RelationCommandOptions): Promise { + const { stdout } = commandIO(options); if (options.issue === undefined || options.issue.trim() === "") { return emitValidationError("--issue is required for relation create.", options); } @@ -306,7 +279,7 @@ async function handleRelationCreate(options: RelationCommandOptions): Promise ${relation.relatedIssue.identifier}\n`); - process.stdout.write(` ID: ${relation.id}\n`); + stdout.write(`Created ${relation.type} relation ${relation.issue.identifier} -> ${relation.relatedIssue.identifier}\n`); + stdout.write(` ID: ${relation.id}\n`); } return ExitCode.Success; } catch (error) { @@ -366,11 +339,12 @@ async function handleRelationCreate(options: RelationCommandOptions): Promise { + const { stdout } = commandIO(options); if (options.dryRun === true) { return emitDryRunResult("delete", "relation", { id: relationId }, options); } - const ctx = buildContext(options); + const ctx = createCommandContext(options); try { const response = await ctx.graphql<{ issueRelationDelete: { success: boolean; entityId: string }; @@ -386,9 +360,9 @@ async function handleRelationDelete(relationId: string, options: RelationCommand if (options.jsonEnvelope) { return ctx.emitSuccess(result); } else if (options.json) { - process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); + stdout.write(`${JSON.stringify(result, null, 2)}\n`); } else { - process.stdout.write(`Deleted relation ${result.id}\n`); + stdout.write(`Deleted relation ${result.id}\n`); } return ExitCode.Success; } catch (error) { diff --git a/src/commands/schema.ts b/src/commands/schema.ts index 1357162..222a7e9 100644 --- a/src/commands/schema.ts +++ b/src/commands/schema.ts @@ -1,13 +1,10 @@ +import { commandIO, type CommandOptions } from "../core/runtime/options.js"; import { join, dirname } from "node:path"; import { mapCommandFailure } from "../core/errors/command-failure.js"; import { ExitCode } from "../core/errors/exit-codes.js"; import { emitValidationError } from "../core/output/validation-error.js"; import { failureEnvelope, successEnvelope } from "../core/output/envelope.js"; -import type { CommandError } from "../core/output/envelope.js"; -import { resolveStoredProfile } from "../core/auth/runtime.js"; -import { executeGraphQL } from "../core/transport/graphql.js"; -import type { FetchLike } from "../core/transport/graphql.js"; -import { executeGraphQLWithRetry, normalizeRetryOptions, type RetryOptions } from "../core/transport/retry.js"; +import { createCommandContext } from "../core/runtime/command-context.js"; import { INTROSPECTION_QUERY } from "../core/schema/introspection-query.js"; import { computeSchemaFingerprint, @@ -21,19 +18,8 @@ import type { SchemaMetadata } from "../core/schema/schema-meta.js"; import { diffSchemas, formatDiffSummary } from "../core/schema/schema-diff.js"; import type { SchemaDiff } from "../core/schema/schema-diff.js"; -export interface SchemaCommandOptions { - json: boolean; - jsonEnvelope: boolean; - profile?: string; - configFile: string; - credentialsFile: string; - apiUrl?: string; +export interface SchemaCommandOptions extends CommandOptions { outputDir?: string; - env: Record; - fetchImpl?: FetchLike; - // retry flags - noRetry?: boolean; - maxRetries?: number; } export async function handleSchemaCommand( @@ -58,6 +44,7 @@ export async function handleSchemaCommand( } async function handleSchemaVersion(positionals: string[], options: SchemaCommandOptions): Promise { + const { stdout } = commandIO(options); if (positionals.length > 0) { return emitValidationError("schema version does not accept positional arguments.", options); } @@ -67,51 +54,36 @@ async function handleSchemaVersion(positionals: string[], options: SchemaCommand if (options.jsonEnvelope) { const envelope = successEnvelope(output, { sourceLayer: "curated" }); - process.stdout.write(`${JSON.stringify(envelope, null, 2)}\n`); + stdout.write(`${JSON.stringify(envelope, null, 2)}\n`); return ExitCode.Success; } if (options.json) { - process.stdout.write(`${JSON.stringify(output, null, 2)}\n`); + stdout.write(`${JSON.stringify(output, null, 2)}\n`); return ExitCode.Success; } if (output.status === "not-bundled") { - process.stdout.write("No schema is bundled. Run linearctl schema pull to fetch the live schema.\n"); + stdout.write("No schema is bundled. Run linearctl schema pull to fetch the live schema.\n"); } else { - process.stdout.write(`Schema version: ${output.schemaVersion}\n`); - process.stdout.write(`Bundled at: ${output.bundledAt}\n`); - process.stdout.write(`Source: ${output.source}\n`); + stdout.write(`Schema version: ${output.schemaVersion}\n`); + stdout.write(`Bundled at: ${output.bundledAt}\n`); + stdout.write(`Source: ${output.source}\n`); } return ExitCode.Success; } async function handleSchemaPull(positionals: string[], options: SchemaCommandOptions): Promise { + const { stdout, stderr } = commandIO(options); if (positionals.length > 0) { return emitValidationError("schema pull does not accept positional arguments.", options); } try { - const profile = await resolveStoredProfile({ - paths: { - configFile: options.configFile, - credentialsFile: options.credentialsFile - }, - ...(options.profile === undefined ? {} : { explicitProfile: options.profile }), - env: options.env - }); - - const response = await executeSchemaGraphQL<{ __schema: unknown }>({ - query: INTROSPECTION_QUERY, - credentials: profile.credentials, - ...(options.apiUrl === undefined - ? profile.metadata.baseUrl === undefined - ? {} - : { apiUrl: profile.metadata.baseUrl } - : { apiUrl: options.apiUrl }), - ...(options.fetchImpl === undefined ? {} : { fetchImpl: options.fetchImpl }) - }, normalizeRetryOptions(options)); + const ctx = createCommandContext(options); + const profile = await ctx.resolveProfile(); + const response = await ctx.graphql<{ __schema: unknown }>(INTROSPECTION_QUERY); if (response.body.data === undefined || response.body.data.__schema === undefined) { return emitSchemaFailure("introspection response did not contain schema data.", options); @@ -145,21 +117,21 @@ async function handleSchemaPull(positionals: string[], options: SchemaCommandOpt profile: profile.name, ...(schemaVersion === null ? {} : { schemaVersion }) }); - process.stdout.write(`${JSON.stringify(envelope, null, 2)}\n`); + stdout.write(`${JSON.stringify(envelope, null, 2)}\n`); return ExitCode.Success; } if (options.json) { - process.stdout.write(`${JSON.stringify(output, null, 2)}\n`); + stdout.write(`${JSON.stringify(output, null, 2)}\n`); return ExitCode.Success; } - process.stdout.write(`Schema pulled successfully.\n`); + stdout.write(`Schema pulled successfully.\n`); if (schemaVersion !== null) { - process.stdout.write(`Version: ${schemaVersion}\n`); + stdout.write(`Version: ${schemaVersion}\n`); } - process.stdout.write(`Schema: ${output.schemaFile}\n`); - process.stdout.write(`Metadata: ${output.metaFile}\n`); + stdout.write(`Schema: ${output.schemaFile}\n`); + stdout.write(`Metadata: ${output.metaFile}\n`); return ExitCode.Success; } catch (error) { @@ -170,9 +142,9 @@ async function handleSchemaPull(positionals: string[], options: SchemaCommandOpt sourceLayer: "curated", ...(options.profile === undefined ? {} : { profile: options.profile }) }); - process.stdout.write(`${JSON.stringify(envelope, null, 2)}\n`); + stdout.write(`${JSON.stringify(envelope, null, 2)}\n`); } else { - process.stderr.write(`Error: ${failure.error.message}\n`); + stderr.write(`Error: ${failure.error.message}\n`); } return failure.exitCode; @@ -180,6 +152,7 @@ async function handleSchemaPull(positionals: string[], options: SchemaCommandOpt } async function handleSchemaCheck(positionals: string[], options: SchemaCommandOptions): Promise { + const { stdout, stderr } = commandIO(options); if (positionals.length > 0) { return emitValidationError("schema check does not accept positional arguments.", options); } @@ -188,25 +161,8 @@ async function handleSchemaCheck(positionals: string[], options: SchemaCommandOp const bundledMeta = await loadPreferredSchemaMetadata(options.configFile); const bundledVersion = bundledMeta.schemaVersion; - const profile = await resolveStoredProfile({ - paths: { - configFile: options.configFile, - credentialsFile: options.credentialsFile - }, - ...(options.profile === undefined ? {} : { explicitProfile: options.profile }), - env: options.env - }); - - const response = await executeSchemaGraphQL<{ __schema: unknown }>({ - query: INTROSPECTION_QUERY, - credentials: profile.credentials, - ...(options.apiUrl === undefined - ? profile.metadata.baseUrl === undefined - ? {} - : { apiUrl: profile.metadata.baseUrl } - : { apiUrl: options.apiUrl }), - ...(options.fetchImpl === undefined ? {} : { fetchImpl: options.fetchImpl }) - }, normalizeRetryOptions(options)); + const ctx = createCommandContext(options); + const response = await ctx.graphql<{ __schema: unknown }>(INTROSPECTION_QUERY); if (response.body.data === undefined || response.body.data.__schema === undefined) { return emitSchemaFailure("introspection response did not contain schema data.", options); @@ -238,22 +194,22 @@ async function handleSchemaCheck(positionals: string[], options: SchemaCommandOp if (options.jsonEnvelope) { const envelope = successEnvelope(output, { sourceLayer: "curated" }); - process.stdout.write(`${JSON.stringify(envelope, null, 2)}\n`); + stdout.write(`${JSON.stringify(envelope, null, 2)}\n`); return drifted ? ExitCode.SchemaDrift : ExitCode.Success; } if (options.json) { - process.stdout.write(`${JSON.stringify(output, null, 2)}\n`); + stdout.write(`${JSON.stringify(output, null, 2)}\n`); return drifted ? ExitCode.SchemaDrift : ExitCode.Success; } if (drifted) { - process.stdout.write(`Schema drift detected. Bundled: ${bundledVersion ?? "(none)"}, Live: ${liveVersion ?? "(unknown)"}\n`); + stdout.write(`Schema drift detected. Bundled: ${bundledVersion ?? "(none)"}, Live: ${liveVersion ?? "(unknown)"}\n`); if (diff !== null) { - process.stdout.write(`${formatDiffSummary(diff)}\n`); + stdout.write(`${formatDiffSummary(diff)}\n`); } } else { - process.stdout.write("Schema is up to date.\n"); + stdout.write("Schema is up to date.\n"); } return drifted ? ExitCode.SchemaDrift : ExitCode.Success; @@ -265,9 +221,9 @@ async function handleSchemaCheck(positionals: string[], options: SchemaCommandOp sourceLayer: "curated", ...(options.profile === undefined ? {} : { profile: options.profile }) }); - process.stdout.write(`${JSON.stringify(envelope, null, 2)}\n`); + stdout.write(`${JSON.stringify(envelope, null, 2)}\n`); } else { - process.stderr.write(`Error: ${failure.error.message}\n`); + stderr.write(`Error: ${failure.error.message}\n`); } return failure.exitCode; @@ -279,27 +235,7 @@ function defaultSchemaOutputDir(configFile: string): string { } function emitSchemaFailure(message: string, options: SchemaCommandOptions): number { - const error: CommandError = { category: "general", message }; - if (options.jsonEnvelope) { - const envelope = failureEnvelope([error], { - sourceLayer: "curated", - ...(options.profile === undefined ? {} : { profile: options.profile }) - }); - process.stdout.write(`${JSON.stringify(envelope, null, 2)}\n`); - } else { - process.stderr.write(`Error: ${message}\n`); - } - return ExitCode.GeneralError; -} - -function executeSchemaGraphQL( - input: Parameters>[0], - retry: RetryOptions | undefined -) { - if (retry !== undefined) { - return executeGraphQLWithRetry({ ...input, retry }); - } - return executeGraphQL(input); + return createCommandContext(options).emitFailure([{ category: "general", message }]); } function extractSchemaVersion(schema: Record): string | null { diff --git a/src/commands/skills.ts b/src/commands/skills.ts index 8745f9b..9cfd73d 100644 --- a/src/commands/skills.ts +++ b/src/commands/skills.ts @@ -1,14 +1,14 @@ +import { commandIO, type CommandOutputOptions, type CommandIO } from "../core/runtime/options.js"; import { writeFile, mkdir, readFile } from "node:fs/promises"; import { createInterface } from "node:readline"; +import { Writable } from "node:stream"; import { existsSync } from "node:fs"; import { join } from "node:path"; import { homedir } from "node:os"; import { ExitCode } from "../core/errors/exit-codes.js"; import { EMBEDDED_SKILLS } from "../generated/embedded-skills.js"; -export interface SkillsCommandOptions { - json: boolean; - jsonEnvelope: boolean; +export interface SkillsCommandOptions extends CommandOutputOptions { scope?: string; stdinStream?: NodeJS.ReadableStream; } @@ -110,17 +110,28 @@ async function inspectSkillInstall( } } -async function promptScope(stdinStream?: NodeJS.ReadableStream): Promise<"user" | "project"> { +async function promptScope(stdinStream?: NodeJS.ReadableStream, options: CommandIO = {}): Promise<"user" | "project"> { + const { stderr } = commandIO(options); const rl = createInterface({ input: stdinStream ?? process.stdin, - output: process.stderr + output: new Writable({ + write(chunk, _encoding, callback) { + try { + stderr.write(chunk); + callback(); + } catch (error) { + callback(error as Error); + } + } + }), + terminal: "isTTY" in stderr && stderr.isTTY === true }); return new Promise((resolve) => { - process.stderr.write("\nWhere should skills be installed?\n"); - process.stderr.write(" 1. Project level (.claude/skills/ and .codex/skills/ in current directory)\n"); - process.stderr.write(" 2. User level (~/.claude/skills/ and ~/.codex/skills/)\n"); - process.stderr.write("\n"); + stderr.write("\nWhere should skills be installed?\n"); + stderr.write(" 1. Project level (.claude/skills/ and .codex/skills/ in current directory)\n"); + stderr.write(" 2. User level (~/.claude/skills/ and ~/.codex/skills/)\n"); + stderr.write("\n"); const ask = () => { rl.question("Choice [1]: ", (answer) => { @@ -132,7 +143,7 @@ async function promptScope(stdinStream?: NodeJS.ReadableStream): Promise<"user" rl.close(); resolve("user"); } else { - process.stderr.write(" Please enter 1 or 2.\n"); + stderr.write(" Please enter 1 or 2.\n"); ask(); } }); @@ -143,18 +154,19 @@ async function promptScope(stdinStream?: NodeJS.ReadableStream): Promise<"user" } async function handleSkillsInstall(options: SkillsCommandOptions): Promise { + const { stderr, stdout } = commandIO(options); let scope: "user" | "project"; if (options.scope === "user" || options.scope === "project") { scope = options.scope; } else if (options.scope !== undefined) { - process.stderr.write(`Error: --scope must be "project" or "user"\n`); + stderr.write(`Error: --scope must be "project" or "user"\n`); return ExitCode.ValidationError; } else if (options.json || options.jsonEnvelope || !isTty(options.stdinStream)) { // Non-interactive mode defaults to project scope = "project"; } else { - scope = await promptScope(options.stdinStream); + scope = await promptScope(options.stdinStream, options); } const targets = discoverAgentTargets(scope); @@ -175,13 +187,13 @@ async function handleSkillsInstall(options: SkillsCommandOptions): Promise { + const { stdout } = commandIO(options); const targets = knownAgentTargets(); const entries: SkillListEntry[] = await Promise.all( Object.entries(EMBEDDED_SKILLS).map(async ([name, skill]) => ({ @@ -201,20 +214,20 @@ async function handleSkillsList(options: SkillsCommandOptions): Promise ); if (options.jsonEnvelope) { - process.stdout.write(`${JSON.stringify({ ok: true, data: entries }, null, 2)}\n`); + stdout.write(`${JSON.stringify({ ok: true, data: entries }, null, 2)}\n`); } else if (options.json) { - process.stdout.write(`${JSON.stringify(entries, null, 2)}\n`); + stdout.write(`${JSON.stringify(entries, null, 2)}\n`); } else { - process.stdout.write("Available skills:\n"); + stdout.write("Available skills:\n"); for (const entry of entries) { - process.stdout.write(` ${entry.name} (${entry.filename})\n`); + stdout.write(` ${entry.name} (${entry.filename})\n`); for (const install of entry.installs) { const status = install.error !== undefined ? `inspection failed: ${install.error}` : install.installed ? `installed, ${install.upToDate ? "up to date" : "out of date"}` : "not installed"; - process.stdout.write(` ${install.tool} (${install.scope}): ${status} — ${install.path}\n`); + stdout.write(` ${install.tool} (${install.scope}): ${status} — ${install.path}\n`); } } } @@ -226,6 +239,7 @@ export async function handleSkillsCommand( positionals: string[], options: SkillsCommandOptions ): Promise { + const { stderr } = commandIO(options); const [subcommand] = positionals; if (subcommand === "install") { @@ -236,6 +250,6 @@ export async function handleSkillsCommand( return handleSkillsList(options); } - process.stderr.write("Error: usage: linearctl skills install or linearctl skills list\n"); + stderr.write("Error: usage: linearctl skills install or linearctl skills list\n"); return ExitCode.ValidationError; } diff --git a/src/commands/state.ts b/src/commands/state.ts index 17696da..4ce915f 100644 --- a/src/commands/state.ts +++ b/src/commands/state.ts @@ -1,27 +1,20 @@ +import { commandIO, type CommandOptions, type CommandIO } from "../core/runtime/options.js"; import { emitValidationError } from "../core/output/validation-error.js"; import type { PageInfo } from "../core/output/envelope.js"; import { ExitCode } from "../core/errors/exit-codes.js"; -import type { FetchLike, GraphQLErrorPayload } from "../core/transport/graphql.js"; +import type { GraphQLErrorPayload } from "../core/transport/graphql.js"; import { paginateGraphQL, validatePaginationOptions } from "../core/pagination/pagination.js"; import type { PaginationOptions } from "../core/pagination/pagination.js"; import { streamPaginateGraphQL } from "../core/pagination/streaming.js"; import { emitDryRunResult } from "../core/output/dry-run.js"; import { resolveTeamId, resolveStateId, looksLikeId } from "../core/resolution/resolve.js"; import { normalizeRetryOptions } from "../core/transport/retry.js"; -import { CommandContext } from "../core/runtime/command-context.js"; +import { createCommandContext } from "../core/runtime/command-context.js"; const VALID_STATE_TYPES = ["backlog", "unstarted", "started", "completed", "canceled"] as const; -export interface StateCommandOptions { - json: boolean; - jsonEnvelope: boolean; +export interface StateCommandOptions extends CommandOptions { jsonl?: boolean; - profile?: string; - configFile: string; - credentialsFile: string; - apiUrl?: string; - env: Record; - fetchImpl?: FetchLike; dryRun?: boolean; // state create flags name?: string; @@ -37,9 +30,6 @@ export interface StateCommandOptions { pageSize?: number; after?: string; quiet?: boolean; - // retry flags - noRetry?: boolean; - maxRetries?: number; } interface RawWorkflowState { @@ -141,41 +131,21 @@ export function normalizeWorkflowState(raw: RawWorkflowState): NormalizedWorkflo }; } -function printHumanState(state: NormalizedWorkflowState): void { - process.stdout.write(`${state.name} ${state.type} (${state.color})\n`); +function printHumanState(state: NormalizedWorkflowState, options: CommandIO): void { + const { stdout } = commandIO(options); + stdout.write(`${state.name} ${state.type} (${state.color})\n`); if (state.description !== null) { - process.stdout.write(` Description: ${state.description}\n`); + stdout.write(` Description: ${state.description}\n`); } - process.stdout.write(` Team: ${state.team.name}\n`); -} - -/** Build a CommandContext from state handler options */ -function buildContext(options: StateCommandOptions): CommandContext { - return new CommandContext({ - json: options.json, - jsonEnvelope: options.jsonEnvelope, - ...(options.profile === undefined ? {} : { profile: options.profile }), - configFile: options.configFile, - credentialsFile: options.credentialsFile, - ...(options.apiUrl === undefined ? {} : { apiUrl: options.apiUrl }), - env: options.env, - ...(options.fetchImpl === undefined ? {} : { fetchImpl: options.fetchImpl }), - ...(options.noRetry === true || options.maxRetries !== undefined - ? { - retry: { - ...(options.noRetry === true ? { noRetry: true } : {}), - ...(options.maxRetries !== undefined ? { maxRetries: options.maxRetries } : {}), - }, - } - : {}), - }); + stdout.write(` Team: ${state.team.name}\n`); } async function handleStateGet( identifier: string, options: StateCommandOptions ): Promise { - const ctx = buildContext(options); + const { stdout } = commandIO(options); + const ctx = createCommandContext(options); try { let response = await ctx.graphql<{ workflowState: RawWorkflowState | null }>( @@ -207,9 +177,9 @@ async function handleStateGet( if (options.jsonEnvelope) { return ctx.emitSuccess(state); } else if (options.json) { - process.stdout.write(`${JSON.stringify(state, null, 2)}\n`); + stdout.write(`${JSON.stringify(state, null, 2)}\n`); } else { - printHumanState(state); + printHumanState(state, options); } return ExitCode.Success; @@ -219,7 +189,9 @@ async function handleStateGet( } async function handleStateList(options: StateCommandOptions): Promise { + const { stdout } = commandIO(options); const paginationOptions: PaginationOptions = { + stderr: commandIO(options).stderr, all: options.all, max: options.max, pageSize: options.pageSize, @@ -232,7 +204,7 @@ async function handleStateList(options: StateCommandOptions): Promise { return emitValidationError(validationError, options); } - const ctx = buildContext(options); + const ctx = createCommandContext(options); try { const profile = await ctx.resolveProfile(); @@ -269,7 +241,7 @@ async function handleStateList(options: StateCommandOptions): Promise { ...commonPaginateInput, options: { ...paginationOptions, all: paginationOptions.all ?? true }, onItem: (raw) => { - process.stdout.write(`${JSON.stringify(normalizeWorkflowState(raw))}\n`); + stdout.write(`${JSON.stringify(normalizeWorkflowState(raw))}\n`); } }); } else { @@ -283,11 +255,11 @@ async function handleStateList(options: StateCommandOptions): Promise { if (options.jsonEnvelope) { return ctx.emitSuccess(states, pageInfo); } else if (options.json) { - process.stdout.write(`${JSON.stringify(states, null, 2)}\n`); + stdout.write(`${JSON.stringify(states, null, 2)}\n`); } else { for (const state of states) { - printHumanState(state); - process.stdout.write("\n"); + printHumanState(state, options); + stdout.write("\n"); } } } @@ -299,6 +271,7 @@ async function handleStateList(options: StateCommandOptions): Promise { } async function handleStateCreate(options: StateCommandOptions): Promise { + const { stdout } = commandIO(options); if (options.name === undefined) { return emitValidationError("--name is required for state create.", options); } @@ -335,7 +308,7 @@ async function handleStateCreate(options: StateCommandOptions): Promise input.position = pos; } - const ctx = buildContext(options); + const ctx = createCommandContext(options); try { const resolverOpts = await ctx.resolverOptions(); @@ -365,9 +338,9 @@ async function handleStateCreate(options: StateCommandOptions): Promise if (options.jsonEnvelope) { return ctx.emitSuccess(state); } else if (options.json) { - process.stdout.write(`${JSON.stringify(state, null, 2)}\n`); + stdout.write(`${JSON.stringify(state, null, 2)}\n`); } else { - process.stdout.write(`Created state: ${state.name} (${state.type})\n`); + stdout.write(`Created state: ${state.name} (${state.type})\n`); } return ExitCode.Success; @@ -377,7 +350,8 @@ async function handleStateCreate(options: StateCommandOptions): Promise } async function handleStateArchive(identifier: string, options: StateCommandOptions): Promise { - const ctx = buildContext(options); + const { stdout } = commandIO(options); + const ctx = createCommandContext(options); try { const profile = await ctx.resolveProfile(); @@ -410,9 +384,9 @@ async function handleStateArchive(identifier: string, options: StateCommandOptio if (options.jsonEnvelope) { return ctx.emitSuccess(result); } else if (options.json) { - process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); + stdout.write(`${JSON.stringify(result, null, 2)}\n`); } else { - process.stdout.write(`Archived state ${stateId}\n`); + stdout.write(`Archived state ${stateId}\n`); } return ExitCode.Success; } catch (error) { diff --git a/src/commands/team.ts b/src/commands/team.ts index b2caf98..44b0b8b 100644 --- a/src/commands/team.ts +++ b/src/commands/team.ts @@ -1,26 +1,18 @@ +import { commandIO, type CommandOptions, type CommandIO } from "../core/runtime/options.js"; import { emitValidationError } from "../core/output/validation-error.js"; import type { PageInfo } from "../core/output/envelope.js"; import { ExitCode } from "../core/errors/exit-codes.js"; -import type { FetchLike } from "../core/transport/graphql.js"; import { loadOptionalConfig } from "../core/auth/runtime.js"; import { setProfileMetadata, writeLinearConfigFile } from "../core/config/config-file.js"; import { paginateGraphQL, validatePaginationOptions } from "../core/pagination/pagination.js"; import type { PaginationOptions } from "../core/pagination/pagination.js"; import { streamPaginateGraphQL } from "../core/pagination/streaming.js"; import { normalizeRetryOptions } from "../core/transport/retry.js"; -import { CommandContext } from "../core/runtime/command-context.js"; +import { createCommandContext } from "../core/runtime/command-context.js"; import { resolveTeamId, looksLikeId, ResolutionError } from "../core/resolution/resolve.js"; -export interface TeamCommandOptions { - json: boolean; - jsonEnvelope: boolean; +export interface TeamCommandOptions extends CommandOptions { jsonl?: boolean; - profile?: string; - configFile: string; - credentialsFile: string; - apiUrl?: string; - env: Record; - fetchImpl?: FetchLike; setDefault?: boolean; // pagination flags all?: boolean; @@ -28,9 +20,6 @@ export interface TeamCommandOptions { pageSize?: number; after?: string; quiet?: boolean; - // retry flags - noRetry?: boolean; - maxRetries?: number; } interface RawTeam { @@ -146,46 +135,27 @@ export function normalizeTeamMember(raw: RawTeamMember): NormalizedTeamMember { }; } -function printHumanTeam(team: NormalizedTeam): void { - process.stdout.write(`${team.key} ${team.name}\n`); +function printHumanTeam(team: NormalizedTeam, options: CommandIO): void { + const { stdout } = commandIO(options); + stdout.write(`${team.key} ${team.name}\n`); if (team.description !== null) { - process.stdout.write(` Description: ${team.description}\n`); + stdout.write(` Description: ${team.description}\n`); } } -function printHumanTeamMember(member: NormalizedTeamMember): void { +function printHumanTeamMember(member: NormalizedTeamMember, options: CommandIO): void { + const { stdout } = commandIO(options); const email = member.email ?? ""; const status = member.active ? "active" : "inactive"; - process.stdout.write(`${member.name}\t${member.displayName}\t${email}\t${status}\n`); -} - -/** Build a CommandContext from team handler options */ -function buildContext(options: TeamCommandOptions): CommandContext { - return new CommandContext({ - json: options.json, - jsonEnvelope: options.jsonEnvelope, - ...(options.profile === undefined ? {} : { profile: options.profile }), - configFile: options.configFile, - credentialsFile: options.credentialsFile, - ...(options.apiUrl === undefined ? {} : { apiUrl: options.apiUrl }), - env: options.env, - ...(options.fetchImpl === undefined ? {} : { fetchImpl: options.fetchImpl }), - ...(options.noRetry === true || options.maxRetries !== undefined - ? { - retry: { - ...(options.noRetry === true ? { noRetry: true } : {}), - ...(options.maxRetries !== undefined ? { maxRetries: options.maxRetries } : {}), - }, - } - : {}), - }); + stdout.write(`${member.name}\t${member.displayName}\t${email}\t${status}\n`); } async function handleTeamGet( identifier: string, options: TeamCommandOptions ): Promise { - const ctx = buildContext(options); + const { stderr, stdout } = commandIO(options); + const ctx = createCommandContext(options); try { let response = await ctx.graphql<{ team: RawTeam | null }>( @@ -223,16 +193,16 @@ async function handleTeamGet( }); await writeLinearConfigFile(options.configFile, updatedConfig); if (!options.json && !options.jsonEnvelope) { - process.stderr.write(`Default team set to "${team.key}" (${team.name}) for profile "${profile.name}".\n`); + stderr.write(`Default team set to "${team.key}" (${team.name}) for profile "${profile.name}".\n`); } } if (options.jsonEnvelope) { return ctx.emitSuccess(team); } else if (options.json) { - process.stdout.write(`${JSON.stringify(team, null, 2)}\n`); + stdout.write(`${JSON.stringify(team, null, 2)}\n`); } else { - printHumanTeam(team); + printHumanTeam(team, options); } return ExitCode.Success; @@ -242,7 +212,9 @@ async function handleTeamGet( } async function handleTeamList(options: TeamCommandOptions): Promise { + const { stdout } = commandIO(options); const paginationOptions: PaginationOptions = { + stderr: commandIO(options).stderr, all: options.all, max: options.max, pageSize: options.pageSize, @@ -255,7 +227,7 @@ async function handleTeamList(options: TeamCommandOptions): Promise { return emitValidationError(validationError, options); } - const ctx = buildContext(options); + const ctx = createCommandContext(options); try { const profile = await ctx.resolveProfile(); @@ -281,7 +253,7 @@ async function handleTeamList(options: TeamCommandOptions): Promise { ...commonPaginateInput, options: { ...paginationOptions, all: paginationOptions.all ?? true }, onItem: (raw) => { - process.stdout.write(`${JSON.stringify(normalizeTeam(raw))}\n`); + stdout.write(`${JSON.stringify(normalizeTeam(raw))}\n`); } }); } else { @@ -295,11 +267,11 @@ async function handleTeamList(options: TeamCommandOptions): Promise { if (options.jsonEnvelope) { return ctx.emitSuccess(teams, pageInfo); } else if (options.json) { - process.stdout.write(`${JSON.stringify(teams, null, 2)}\n`); + stdout.write(`${JSON.stringify(teams, null, 2)}\n`); } else { for (const team of teams) { - printHumanTeam(team); - process.stdout.write("\n"); + printHumanTeam(team, options); + stdout.write("\n"); } } } @@ -314,7 +286,9 @@ async function handleTeamMembers( identifier: string, options: TeamCommandOptions ): Promise { + const { stdout } = commandIO(options); const paginationOptions: PaginationOptions = { + stderr: commandIO(options).stderr, all: options.all, max: options.max, pageSize: options.pageSize, @@ -327,7 +301,7 @@ async function handleTeamMembers( return emitValidationError(validationError, options); } - const ctx = buildContext(options); + const ctx = createCommandContext(options); try { const profile = await ctx.resolveProfile(); @@ -361,7 +335,7 @@ async function handleTeamMembers( ...commonPaginateInput, options: { ...paginationOptions, all: paginationOptions.all ?? true }, onItem: (raw) => { - process.stdout.write(`${JSON.stringify(normalizeTeamMember(raw))}\n`); + stdout.write(`${JSON.stringify(normalizeTeamMember(raw))}\n`); } }); } else { @@ -375,10 +349,10 @@ async function handleTeamMembers( if (options.jsonEnvelope) { return ctx.emitSuccess(members, pageInfo); } else if (options.json) { - process.stdout.write(`${JSON.stringify(members, null, 2)}\n`); + stdout.write(`${JSON.stringify(members, null, 2)}\n`); } else { for (const member of members) { - printHumanTeamMember(member); + printHumanTeamMember(member, options); } } } diff --git a/src/commands/user.ts b/src/commands/user.ts index 9346fcd..adc62b2 100644 --- a/src/commands/user.ts +++ b/src/commands/user.ts @@ -1,33 +1,22 @@ +import { commandIO, type CommandOptions, type CommandIO } from "../core/runtime/options.js"; import { emitValidationError } from "../core/output/validation-error.js"; import type { PageInfo } from "../core/output/envelope.js"; import { ExitCode } from "../core/errors/exit-codes.js"; -import type { FetchLike } from "../core/transport/graphql.js"; import { paginateGraphQL, validatePaginationOptions } from "../core/pagination/pagination.js"; import type { PaginationOptions } from "../core/pagination/pagination.js"; import { streamPaginateGraphQL } from "../core/pagination/streaming.js"; import { normalizeRetryOptions } from "../core/transport/retry.js"; -import { CommandContext } from "../core/runtime/command-context.js"; +import { createCommandContext } from "../core/runtime/command-context.js"; import { resolveUserId, looksLikeId } from "../core/resolution/resolve.js"; -export interface UserCommandOptions { - json: boolean; - jsonEnvelope: boolean; +export interface UserCommandOptions extends CommandOptions { jsonl?: boolean; - profile?: string; - configFile: string; - credentialsFile: string; - apiUrl?: string; - env: Record; - fetchImpl?: FetchLike; // pagination flags all?: boolean; max?: number; pageSize?: number; after?: string; quiet?: boolean; - // retry flags - noRetry?: boolean; - maxRetries?: number; } interface RawUser { @@ -111,40 +100,20 @@ export function normalizeUser(raw: RawUser): NormalizedUser { }; } -function printHumanUser(user: NormalizedUser): void { - process.stdout.write(`${user.displayName} <${user.email}>\n`); - process.stdout.write(` Active: ${user.active}\n`); - process.stdout.write(` Admin: ${user.admin}\n`); - process.stdout.write(` URL: ${user.url}\n`); -} - -/** Build a CommandContext from user handler options */ -function buildContext(options: UserCommandOptions): CommandContext { - return new CommandContext({ - json: options.json, - jsonEnvelope: options.jsonEnvelope, - ...(options.profile === undefined ? {} : { profile: options.profile }), - configFile: options.configFile, - credentialsFile: options.credentialsFile, - ...(options.apiUrl === undefined ? {} : { apiUrl: options.apiUrl }), - env: options.env, - ...(options.fetchImpl === undefined ? {} : { fetchImpl: options.fetchImpl }), - ...(options.noRetry === true || options.maxRetries !== undefined - ? { - retry: { - ...(options.noRetry === true ? { noRetry: true } : {}), - ...(options.maxRetries !== undefined ? { maxRetries: options.maxRetries } : {}), - }, - } - : {}), - }); +function printHumanUser(user: NormalizedUser, options: CommandIO): void { + const { stdout } = commandIO(options); + stdout.write(`${user.displayName} <${user.email}>\n`); + stdout.write(` Active: ${user.active}\n`); + stdout.write(` Admin: ${user.admin}\n`); + stdout.write(` URL: ${user.url}\n`); } async function handleUserGet( identifier: string, options: UserCommandOptions ): Promise { - const ctx = buildContext(options); + const { stdout } = commandIO(options); + const ctx = createCommandContext(options); try { let response = await ctx.graphql<{ user: RawUser | null }>( @@ -175,9 +144,9 @@ async function handleUserGet( if (options.jsonEnvelope) { return ctx.emitSuccess(user); } else if (options.json) { - process.stdout.write(`${JSON.stringify(user, null, 2)}\n`); + stdout.write(`${JSON.stringify(user, null, 2)}\n`); } else { - printHumanUser(user); + printHumanUser(user, options); } return ExitCode.Success; @@ -187,7 +156,8 @@ async function handleUserGet( } async function handleUserMe(options: UserCommandOptions): Promise { - const ctx = buildContext(options); + const { stdout } = commandIO(options); + const ctx = createCommandContext(options); try { const response = await ctx.graphql<{ viewer: RawUser | null }>( @@ -210,9 +180,9 @@ async function handleUserMe(options: UserCommandOptions): Promise { if (options.jsonEnvelope) { return ctx.emitSuccess(user); } else if (options.json) { - process.stdout.write(`${JSON.stringify(user, null, 2)}\n`); + stdout.write(`${JSON.stringify(user, null, 2)}\n`); } else { - printHumanUser(user); + printHumanUser(user, options); } return ExitCode.Success; @@ -222,7 +192,9 @@ async function handleUserMe(options: UserCommandOptions): Promise { } async function handleUserList(options: UserCommandOptions): Promise { + const { stdout } = commandIO(options); const paginationOptions: PaginationOptions = { + stderr: commandIO(options).stderr, all: options.all, max: options.max, pageSize: options.pageSize, @@ -235,7 +207,7 @@ async function handleUserList(options: UserCommandOptions): Promise { return emitValidationError(validationError, options); } - const ctx = buildContext(options); + const ctx = createCommandContext(options); try { const profile = await ctx.resolveProfile(); @@ -261,7 +233,7 @@ async function handleUserList(options: UserCommandOptions): Promise { ...commonPaginateInput, options: { ...paginationOptions, all: paginationOptions.all ?? true }, onItem: (raw) => { - process.stdout.write(`${JSON.stringify(normalizeUser(raw))}\n`); + stdout.write(`${JSON.stringify(normalizeUser(raw))}\n`); } }); } else { @@ -275,11 +247,11 @@ async function handleUserList(options: UserCommandOptions): Promise { if (options.jsonEnvelope) { return ctx.emitSuccess(users, result.pageInfo); } else if (options.json) { - process.stdout.write(`${JSON.stringify(users, null, 2)}\n`); + stdout.write(`${JSON.stringify(users, null, 2)}\n`); } else { for (const user of users) { - printHumanUser(user); - process.stdout.write("\n"); + printHumanUser(user, options); + stdout.write("\n"); } } } diff --git a/src/commands/workspace.ts b/src/commands/workspace.ts index 9f4cf52..7ba0dbc 100644 --- a/src/commands/workspace.ts +++ b/src/commands/workspace.ts @@ -1,20 +1,10 @@ +import { commandIO, type CommandOptions } from "../core/runtime/options.js"; import { ExitCode } from "../core/errors/exit-codes.js"; import { loadOptionalConfig, loadOptionalCredentials } from "../core/auth/runtime.js"; import { setProfileMetadata, writeLinearConfigFile } from "../core/config/config-file.js"; -import type { FetchLike } from "../core/transport/graphql.js"; -import { CommandContext } from "../core/runtime/command-context.js"; - -export interface WorkspaceCommandOptions { - json: boolean; - jsonEnvelope: boolean; - configFile: string; - credentialsFile: string; - env: Record; - fetchImpl?: FetchLike; - // retry flags - noRetry?: boolean; - maxRetries?: number; -} +import { CommandContext, createCommandContext } from "../core/runtime/command-context.js"; + +export interface WorkspaceCommandOptions extends CommandOptions {} interface WorkspaceListEntry { profile: string; @@ -38,27 +28,8 @@ interface ViewerWorkspaceResponse { }; } -/** Build a CommandContext from workspace handler options */ -function buildContext(options: WorkspaceCommandOptions): CommandContext { - return new CommandContext({ - json: options.json, - jsonEnvelope: options.jsonEnvelope, - configFile: options.configFile, - credentialsFile: options.credentialsFile, - env: options.env, - ...(options.fetchImpl === undefined ? {} : { fetchImpl: options.fetchImpl }), - ...(options.noRetry === true || options.maxRetries !== undefined - ? { - retry: { - ...(options.noRetry === true ? { noRetry: true } : {}), - ...(options.maxRetries !== undefined ? { maxRetries: options.maxRetries } : {}), - }, - } - : {}), - }); -} - async function handleWorkspaceList(options: WorkspaceCommandOptions): Promise { + const { stdout } = commandIO(options); const [config, credentials] = await Promise.all([ loadOptionalConfig(options.configFile), loadOptionalCredentials(options.credentialsFile) @@ -88,15 +59,7 @@ async function handleWorkspaceList(options: WorkspaceCommandOptions): Promise { + const { stderr } = commandIO(options); const [subcommand, ...extraPositionals] = positionals; if (subcommand === "list" || subcommand === undefined) { if (extraPositionals.length > 0) { - process.stderr.write("Error: workspace list does not accept positional arguments.\n"); + stderr.write("Error: workspace list does not accept positional arguments.\n"); return ExitCode.ValidationError; } return handleWorkspaceList(options); } - process.stderr.write("Error: unsupported workspace command. Try linearctl workspace list.\n"); + stderr.write("Error: unsupported workspace command. Try linearctl workspace list.\n"); return ExitCode.ValidationError; } diff --git a/src/core/io/file-transfer.ts b/src/core/io/file-transfer.ts index 091807f..46ab9a7 100644 --- a/src/core/io/file-transfer.ts +++ b/src/core/io/file-transfer.ts @@ -213,6 +213,9 @@ export async function downloadFile( { signal } ); signal.throwIfAborted(); + // Commit boundary: rename has no cancellation API. Once dispatched, await + // its actual result even if cancellation arrives; reporting an abort after + // a successful replacement would misrepresent the destination's state. // No unlink-first fallback: a failed rename must preserve the destination. await rename(stagingPath, outputPath); return size; diff --git a/src/core/output/dry-run.ts b/src/core/output/dry-run.ts index 708bc73..132bdb4 100644 --- a/src/core/output/dry-run.ts +++ b/src/core/output/dry-run.ts @@ -1,5 +1,6 @@ import { successEnvelope } from "./envelope.js"; import { ExitCode } from "../errors/exit-codes.js"; +import { commandIO, type CommandOutputOptions } from "../runtime/options.js"; export interface DryRunResult { dryRun: true; @@ -8,10 +9,7 @@ export interface DryRunResult { input: Record; } -export interface DryRunOutputOptions { - json: boolean; - jsonEnvelope: boolean; -} +export interface DryRunOutputOptions extends CommandOutputOptions {} export function emitDryRunResult( action: string, @@ -19,18 +17,19 @@ export function emitDryRunResult( input: Record, options: DryRunOutputOptions ): number { + const { stdout } = commandIO(options); const result: DryRunResult = { dryRun: true, action, resource, input }; if (options.jsonEnvelope) { const envelope = successEnvelope(result, { sourceLayer: "curated" }); - process.stdout.write(`${JSON.stringify(envelope, null, 2)}\n`); + stdout.write(`${JSON.stringify(envelope, null, 2)}\n`); } else if (options.json) { - process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); + stdout.write(`${JSON.stringify(result, null, 2)}\n`); } else { const summary = Object.entries(input) .map(([k, v]) => `${k}=${JSON.stringify(v)}`) .join(", "); - process.stdout.write(`Dry run: would ${action} ${resource} with ${summary}\n`); + stdout.write(`Dry run: would ${action} ${resource} with ${summary}\n`); } return ExitCode.Success; diff --git a/src/core/output/validation-error.ts b/src/core/output/validation-error.ts index 8aef4f2..41b776c 100644 --- a/src/core/output/validation-error.ts +++ b/src/core/output/validation-error.ts @@ -2,7 +2,9 @@ import { failureEnvelope } from "./envelope.js"; import type { CommandSourceLayer } from "./envelope.js"; import { ExitCode } from "../errors/exit-codes.js"; -export interface ValidationErrorOptions { +import { commandIO, type CommandIO } from "../runtime/options.js"; + +export interface ValidationErrorOptions extends CommandIO { jsonEnvelope: boolean; sourceLayer?: CommandSourceLayer; profile?: string; @@ -14,9 +16,9 @@ export function emitValidationError(message: string, options: ValidationErrorOpt [{ category: "validation", message }], { sourceLayer: options.sourceLayer ?? "curated", ...(options.profile === undefined ? {} : { profile: options.profile }) } ); - process.stdout.write(`${JSON.stringify(envelope, null, 2)}\n`); + commandIO(options).stdout.write(`${JSON.stringify(envelope, null, 2)}\n`); } else { - process.stderr.write(`Error: ${message}\n`); + commandIO(options).stderr.write(`Error: ${message}\n`); } return ExitCode.ValidationError; } diff --git a/src/core/pagination/pagination.ts b/src/core/pagination/pagination.ts index 6974427..2c2dc03 100644 --- a/src/core/pagination/pagination.ts +++ b/src/core/pagination/pagination.ts @@ -4,7 +4,9 @@ import type { FetchLike } from "../transport/graphql.js"; import type { RetryOptions } from "../transport/retry.js"; import { fetchPaginationPage } from "./page.js"; -export interface PaginationOptions { +import { commandIO, type CommandIO } from "../runtime/options.js"; + +export interface PaginationOptions extends CommandIO { all?: boolean | undefined; max?: number | undefined; pageSize?: number | undefined; @@ -116,7 +118,7 @@ export async function paginateGraphQL( const guidance = options.after === undefined ? "Use --all to fetch all results, or --max for a specific limit." : "Use --max for a specific limit."; - process.stderr.write( + commandIO(options).stderr.write( `Warning: results truncated at ${items.length} items. ${guidance}\n` ); } @@ -125,7 +127,7 @@ export async function paginateGraphQL( if (limit !== undefined && items.length >= limit) { if (options.all === true && options.max === undefined && items.length >= SAFETY_CAP && !options.quiet) { - process.stderr.write( + commandIO(options).stderr.write( `Warning: --all fetched ${SAFETY_CAP} items (safety cap). Use --max to fetch more.\n` ); } diff --git a/src/core/pagination/streaming.ts b/src/core/pagination/streaming.ts index 6355309..0a13744 100644 --- a/src/core/pagination/streaming.ts +++ b/src/core/pagination/streaming.ts @@ -6,6 +6,8 @@ import { fetchPaginationPage } from "./page.js"; import { buildPaginationVariables } from "./pagination.js"; import type { PaginationOptions } from "./pagination.js"; +import { commandIO } from "../runtime/options.js"; + const SAFETY_CAP = 10_000; export interface StreamPaginateGraphQLInput { @@ -75,7 +77,7 @@ export async function streamPaginateGraphQL( if (totalItems >= limit) { if (options.max === undefined && totalItems >= SAFETY_CAP && !options.quiet) { - process.stderr.write( + commandIO(options).stderr.write( `Warning: --jsonl fetched ${SAFETY_CAP} items (safety cap). Use --max to fetch more.\n` ); } diff --git a/src/core/runtime/command-context.ts b/src/core/runtime/command-context.ts index d16bc94..358a98c 100644 --- a/src/core/runtime/command-context.ts +++ b/src/core/runtime/command-context.ts @@ -8,24 +8,17 @@ import { resolveStoredProfile } from "../auth/runtime.js"; import type { ResolvedProfile } from "../auth/profile-resolution.js"; -import { type ExecutedGraphQLResponse, type FetchLike, type GraphQLErrorPayload } from "../transport/graphql.js"; -import { executeGraphQLWithRetry, type RetryOptions } from "../transport/retry.js"; +import { type ExecutedGraphQLResponse, type GraphQLErrorPayload } from "../transport/graphql.js"; +import { executeGraphQLWithRetry, normalizeRetryOptions, type RetryOptions } from "../transport/retry.js"; import { failureEnvelope, successEnvelope, formatCommandErrorHuman, type CommandSourceLayer, type PageInfo, type CommandError } from "../output/envelope.js"; import { exitCodeForErrors, mapCommandFailure, mapGraphQLErrorPayload } from "../errors/command-failure.js"; import type { WorkflowResult } from "./workflow.js"; import { ExitCode } from "../errors/exit-codes.js"; import type { ResolverOptions } from "../resolution/resolve.js"; +import { commandIO, type CommandOptions, type OutputStream } from "./options.js"; /** Options needed to create a command context */ -export interface CommandContextOptions { - json: boolean; - jsonEnvelope: boolean; - profile?: string; - configFile: string; - credentialsFile: string; - apiUrl?: string; - env: Record; - fetchImpl?: FetchLike; +export interface CommandContextOptions extends CommandOptions { /** Retry configuration — controls --no-retry and --max-retries */ retry?: RetryOptions; /** The source layer for envelope metadata */ @@ -43,9 +36,17 @@ export class CommandContext { private _profile: ResolvedProfile | undefined; private readonly options: CommandContextOptions; private readonly layer: CommandSourceLayer; + readonly stdout: OutputStream; + readonly stderr: OutputStream; constructor(options: CommandContextOptions) { - this.options = options; + const io = commandIO(options); + this.stdout = io.stdout; + this.stderr = io.stderr; + this.options = { + ...options, + retry: { ...normalizeRetryOptions(options), ...options.retry, stderr: io.stderr }, + }; this.layer = options.sourceLayer ?? "curated"; } @@ -62,6 +63,7 @@ export class CommandContext { }, ...(this.options.profile === undefined ? {} : { explicitProfile: this.options.profile }), env: this.options.env, + ...(this.options.fetchImpl === undefined ? {} : { fetchImpl: this.options.fetchImpl }), }); return this._profile; @@ -78,26 +80,15 @@ export class CommandContext { }; } - /** - * Execute a GraphQL query/mutation with automatic retry on rate limits. - * Uses executeGraphQLWithRetry when retry is configured, otherwise executeGraphQL. - */ + /** Execute a GraphQL query/mutation with the same transport and retry policy as resolvers. */ async graphql( query: string, variables?: Record ): Promise> { - const profile = await this.resolveProfile(); - const input = { + return executeGraphQLWithRetry({ query, ...(variables === undefined ? {} : { variables }), - credentials: profile.credentials, - ...this.apiUrlOption(profile), - ...(this.options.fetchImpl === undefined ? {} : { fetchImpl: this.options.fetchImpl }), - }; - - return executeGraphQLWithRetry({ - ...input, - ...(this.options.retry === undefined ? {} : { retry: this.options.retry }), + ...await this.resolverOptions(), }); } @@ -106,9 +97,9 @@ export class CommandContext { const profileName = this._profile?.name; if (this.options.jsonEnvelope) { const envelope = successEnvelope(data, { sourceLayer: this.layer, ...(profileName ? { profile: profileName } : {}) }, pageInfo ?? null); - process.stdout.write(`${JSON.stringify(envelope, null, 2)}\n`); + this.stdout.write(`${JSON.stringify(envelope, null, 2)}\n`); } else if (this.options.json) { - process.stdout.write(`${JSON.stringify(data, null, 2)}\n`); + this.stdout.write(`${JSON.stringify(data, null, 2)}\n`); } return ExitCode.Success; } @@ -119,9 +110,9 @@ export class CommandContext { const profileName = this._profile?.name ?? this.options.profile; if (this.options.jsonEnvelope) { const envelope = failureEnvelope(errors, { sourceLayer: this.layer, ...(profileName ? { profile: profileName } : {}) }); - process.stdout.write(`${JSON.stringify(envelope, null, 2)}\n`); + this.stdout.write(`${JSON.stringify(envelope, null, 2)}\n`); } else { - process.stderr.write(`${formatCommandErrorHuman(errors[0] ?? { category: "general", message: "command failed" })}\n`); + this.stderr.write(`${formatCommandErrorHuman(errors[0] ?? { category: "general", message: "command failed" })}\n`); } return resolvedExitCode; } @@ -148,13 +139,13 @@ export class CommandContext { ...(profile === undefined ? {} : { profile }), partial: workflow.partialSuccess, }); - process.stdout.write(`${JSON.stringify(envelope, null, 2)}\n`); + this.stdout.write(`${JSON.stringify(envelope, null, 2)}\n`); } else { for (const error of workflow.errors) { - process.stderr.write(`${formatCommandErrorHuman(error)}\n`); + this.stderr.write(`${formatCommandErrorHuman(error)}\n`); } if (workflow.partialSuccess) { - process.stderr.write(`Completed resources: ${JSON.stringify(resources)}\n${recovery}\n`); + this.stderr.write(`Completed resources: ${JSON.stringify(resources)}\n${recovery}\n`); } } return workflow.exitCode; diff --git a/src/core/runtime/options.ts b/src/core/runtime/options.ts new file mode 100644 index 0000000..92f6291 --- /dev/null +++ b/src/core/runtime/options.ts @@ -0,0 +1,35 @@ +import type { FetchLike } from "../transport/graphql.js"; +import type { RetryOptionInput } from "../transport/retry.js"; + +/** Minimal writable contract, also implemented by process.stdout/stderr. */ +export type OutputStream = Pick; + +export interface CommandIO { + stdout?: OutputStream; + stderr?: OutputStream; +} + +export interface CommandOutputOptions extends CommandIO { + json: boolean; + jsonEnvelope: boolean; +} + +/** Dependencies shared by network-backed commands. */ +export interface CommandRuntimeOptions extends CommandIO { + profile?: string; + configFile: string; + credentialsFile: string; + apiUrl?: string; + env: Record; + fetchImpl?: FetchLike; +} + +export interface CommandOptions extends CommandRuntimeOptions, CommandOutputOptions, RetryOptionInput {} + +/** Defaults are resolved per call; no process-global runtime state is installed. */ +export function commandIO(options: CommandIO): { stdout: OutputStream; stderr: OutputStream } { + return { + stdout: options.stdout ?? process.stdout, + stderr: options.stderr ?? process.stderr, + }; +} diff --git a/src/core/schema/freshness.ts b/src/core/schema/freshness.ts index ff2cd2f..c1ac60f 100644 --- a/src/core/schema/freshness.ts +++ b/src/core/schema/freshness.ts @@ -2,7 +2,7 @@ import { mkdir, readFile, writeFile } from "node:fs/promises"; import { dirname, join } from "node:path"; import { resolveStoredProfile } from "../auth/runtime.js"; import { loadLinearConfigFile } from "../config/config-file.js"; -import type { FetchLike } from "../transport/graphql.js"; +import { commandIO, type CommandRuntimeOptions } from "../runtime/options.js"; import { executeGraphQL } from "../transport/graphql.js"; import { INTROSPECTION_QUERY } from "./introspection-query.js"; import { @@ -12,14 +12,8 @@ import { writeSchemaMetadata } from "./schema-meta.js"; -export interface SchemaFreshnessOptions { +export interface SchemaFreshnessOptions extends CommandRuntimeOptions { commandName?: string; - profile?: string; - configFile: string; - credentialsFile: string; - apiUrl?: string; - env: Record; - fetchImpl?: FetchLike; now?: Date; cacheFile?: string; schemaOutputDir?: string; @@ -73,7 +67,8 @@ export async function maybeWarnForStaleSchema(options: SchemaFreshnessOptions): credentialsFile: options.credentialsFile }, ...(options.profile === undefined ? {} : { explicitProfile: options.profile }), - env: options.env + env: options.env, + ...(options.fetchImpl === undefined ? {} : { fetchImpl: options.fetchImpl }) }); const response = await executeGraphQL<{ __schema: unknown }>({ @@ -115,11 +110,11 @@ export async function maybeWarnForStaleSchema(options: SchemaFreshnessOptions): bundledAt: now.toISOString(), source: "introspection" }); - process.stderr.write("linearctl schema was stale and has been updated automatically.\n"); + commandIO(options).stderr.write("linearctl schema was stale and has been updated automatically.\n"); return; } - process.stderr.write(formatStaleSchemaWarning(schemaAgeDays(bundledMeta.bundledAt, now))); + commandIO(options).stderr.write(formatStaleSchemaWarning(schemaAgeDays(bundledMeta.bundledAt, now))); } catch { // Startup freshness checks are advisory; command execution should continue. } diff --git a/src/core/transport/retry.ts b/src/core/transport/retry.ts index eb741b5..c94a0a6 100644 --- a/src/core/transport/retry.ts +++ b/src/core/transport/retry.ts @@ -1,7 +1,9 @@ import type { ExecutedGraphQLResponse, GraphQLRequestInput } from "./graphql.js"; import { executeGraphQL, GraphQLTransportError } from "./graphql.js"; -export interface RetryOptions { +import { commandIO, type CommandIO } from "../runtime/options.js"; + +export interface RetryOptions extends CommandIO { noRetry?: boolean; maxRetries?: number; onRetryDelay?: (event: RetryDelayEvent) => void; @@ -14,7 +16,7 @@ export interface RetryDelayEvent { source: "retry-after" | "backoff"; } -export interface RetryOptionInput { +export interface RetryOptionInput extends CommandIO { noRetry?: boolean; maxRetries?: number; } @@ -53,7 +55,7 @@ export async function executeGraphQLWithRetry( input.retry?.onRetryDelay?.({ delayMs: delay, attempt: attempt + 1, maxRetries, source }); const retrySource = source === "retry-after" ? " from Retry-After" : ""; - process.stderr.write( + commandIO(input.retry ?? {}).stderr.write( `Warning: rate limited, retrying in ${Math.round(delay / 1000)}s${retrySource} (attempt ${attempt + 1}/${maxRetries})...\n` ); @@ -73,6 +75,7 @@ export function normalizeRetryOptions(input: RetryOptionInput): RetryOptions { } return { + ...(input.stderr === undefined ? {} : { stderr: input.stderr }), ...(input.noRetry === true ? { noRetry: true } : {}), ...(input.maxRetries !== undefined ? { maxRetries: input.maxRetries } : {}), }; diff --git a/src/generated/embedded-skills.ts b/src/generated/embedded-skills.ts index f2f625c..93647d9 100644 --- a/src/generated/embedded-skills.ts +++ b/src/generated/embedded-skills.ts @@ -2,7 +2,7 @@ export const EMBEDDED_SKILLS: Record = { "linearctl": { filename: "linearctl.md", - content: "---\nname: linearctl\ndescription: Agent-first CLI for the Linear API — curated commands, generated API, and raw GraphQL with stable JSON output contracts\n---\n\n# linearctl\n\nDefault skill for all Linear CLI usage. Use this skill for any request involving Linear data unless raw GraphQL is explicitly required or the curated/generated layers cannot cover the operation.\n\n## First-time setup\n\nIf the user has not configured the CLI yet, help them bootstrap:\n\n1. Create the config directory: `mkdir -p ~/.config/linear`\n2. Create a credentials file with their API key:\n ```bash\n export LINEAR_API_KEY=lin_api_...\n linearctl auth login --profile --api-key-env LINEAR_API_KEY --set-default\n ```\n3. Set a default team: `linearctl team list --json` to find team keys, then `linearctl team get --set-default`\n4. Verify: `linearctl auth whoami --json`\n\nAPI keys are created at https://linear.app/settings/api. For browser OAuth and unattended client-credentials OAuth, see https://linear.app/settings/api/applications.\n\n## Command routing\n\n1. Use curated commands when they cover the operation.\n2. Otherwise use generated `linearctl api` commands.\n3. Otherwise use `linearctl gql`.\n\nRaw GraphQL should not be used merely because it is possible. It is the fallback for gaps only.\n\n## Available curated commands\n\n### Issues\n- `linearctl issue get --json` / `linearctl issue view --json` — fetch a single issue by identifier (e.g. INF-2975) or UUID; JSON includes `dueDate`, `projectMilestone`, `trashed`, and `archivedAt`, so write/delete verification must check those fields instead of assuming exit 0 means live\n- `linearctl issue list [--search |--query ] [--state [,...] ...] [--status ] [--assignee ] [--team ] [--label ] [--priority <0-4>] [--due-date ] [--cycle ] [--project ] [--created-after ] [--updated-after ] [--completed-after ] [--order-by ] [--all-teams] [--all] [--max |--limit ] [--json]` — list issues with filters; repeated or comma-separated `--state` values are unioned; `--assignee none`/`unassigned` finds unassigned work; `--due-date none` finds issues without a due date; `--status` aliases `--state`; `--search`/`--query` routes to full-text search and composes with the other filters; friendly names resolve case-insensitively\n- `linearctl issue search [|--query ] [--team ] [--all] --json` — full-text search across issues; profile default teams are not applied, so pass `--team` to scope explicitly\n- `linearctl issue create --title --team <id> [--description <text>|--description-file <path|->] [--priority <0-4>] [--estimate <n>] [--due-date <YYYY-MM-DD>] [--assignee <id>] [--label <id>] [--state <id>] [--cycle <id>] [--project <name|id>] [--project-milestone <id>|--milestone <id>] --json` — create an issue\n- `linearctl issue update <identifier> [--title <text>] [--description <text>|--description-file <path|->] [--priority <0-4>] [--estimate <n>] [--due-date <YYYY-MM-DD|none>] [--assignee <id|none>] [--label <name|id>] [--state <id>] [--cycle <id|none>] [--project <name|id|none>] [--project-milestone <id|none>|--milestone <id|none>] [--parent <identifier|none>] --json` — update an issue; `none` clears nullable fields and `--label` adds without replacing existing labels\n- `linearctl issue close <identifier> [--state <name>] --json` — close an issue (transitions to a terminal completed/canceled workflow state; defaults to \"Done\", use --state to pick another)\n- `linearctl issue delete <identifier> --json` — delete/trash an issue by identifier or UUID\n- `linearctl issue assign <identifier> <assignee-id> --json` — assign an issue\n- `linearctl issue attach-slack <identifier> --url <slack-url> [--sync] [--title <text>] --json` — link a Slack thread to an issue (--sync enables bidirectional comment sync)\n- `linearctl issue comment <identifier> (--body <text>|--body-file <path|->) --json` — add a comment to an issue\n\n### Issue relations\n- `linearctl relation list <issue> [--all] [--max <n>] --json` — list both outbound and inbound relations for an issue; each item includes `direction`, `issue`, and `relatedIssue`\n- `linearctl relation create --issue <issue> --related <issue> --type <blocks|duplicate|related|similar> --json` — create a relation after resolving both identifiers; for `duplicate`, `--issue` is the duplicate and `--related` is canonical\n- `linearctl relation delete <relation-id> --json` — delete a relation\n\n### Bulk operations\n- `linearctl issue bulk-update --ids <id1,id2,...> [--state <id>] [--assignee <id|none>] [--priority <0-4>] [--estimate <n>] [--due-date <YYYY-MM-DD|none>] [--label <id>] [--cycle <id|none>] [--project-milestone <id|none>|--milestone <id|none>] --json` — `--label` is additive\n- `linearctl issue bulk-close --ids <id1,id2,...> [--state <name|id>] --json` — transition issues to a completed/canceled workflow state, matching `issue close`\n- `linearctl issue bulk-archive --ids <id1,id2,...> --json` — archive multiple issues\n- `linearctl issue bulk-delete --ids <id1,id2,...> --yes|--confirm --json` — delete/trash multiple issues; `--confirm` is accepted as an alias for `--yes`\n- `linearctl issue bulk-assign --ids <id1,id2,...> --assignee <id> --json`\n\n### Projects\n- `linearctl project get <name|id> --json` — richer single-project detail payload than `project list` (includes progress/health/currentProgress, and milestones with id, name, description, targetDate, sortOrder, createdAt, updatedAt); project names resolve by exact match, unique prefix, or unique substring\n- `linearctl project list [--query <text>|--search <text>|--name <text>] [--team <id>] [--state <status-type> ...] [--all-teams] --json` — includes portfolio fields (`progress`, `health`, `description`, `updatedAt`, `currentProgress`), normalized `milestones` with `name`, `targetDate`, `progress`, and `status`, and milestone truncation metadata (`milestonesPageInfo`, `milestonesTruncated`) in JSON output; human output shows progress, health, description, updated time, and milestone summaries; `--state` values: backlog, planned, started, paused, completed, canceled; repeated `--state` values are unioned; text flags filter project names\n- `linearctl project create --name <name> [--description <text>|--description-file <path|->] [--content <text>|--content-file <path|->] [--team <id>] [--lead <user-id|email|\"me\">] [--status <id|name|type>|--state <name|type>] [--start-date <YYYY-MM-DD>] [--target-date <YYYY-MM-DD>] --json`\n- `linearctl project create-with-issues --name <name> --team <id> --issues-json <json> [--description <text>|--description-file <path|->] [--content <text>|--content-file <path|->] [--lead <user-id|email|\"me\">] [--status <id|name|type>|--state <name|type>] [--start-date <YYYY-MM-DD>] [--target-date <YYYY-MM-DD>] --json` — create a project then batch-create linked issues (reports partial success if issue creation fails after project was created)\n- `linearctl project update <id> [--name <text>] [--description <text>|--description-file <path|->] [--content <text>|--content-file <path|->] [--status <id|name|type>|--state <name|type>] [--lead <user-id|email|\"me\">] [--start-date <YYYY-MM-DD>] [--target-date <YYYY-MM-DD>] --json` — `--status`/`--state` accepts status names, state types, or status IDs; `--state` remains an alias\n- `linearctl project delete <id> --json`\n\n### Project statuses\n- `linearctl project-status list --json` — list workspace-level project statuses\n- `linearctl project-status get <id> --json`\n- `linearctl project-status create --name <name> --status-type <type> --color <hex> --json` (types: backlog, planned, started, paused, completed, canceled)\n- `linearctl project-status delete <id> --json` — archives the status\n\n### Cycles\n- `linearctl cycle get <id> --json` — includes progress/scope fields (`progress`, derived `scopeCount`, `completedScopeCount`, `inProgressScopeCount`, `startedScopeCount`, issue counts, history arrays, and uncompleted issues captured on close)\n- `linearctl cycle list [--team <id>] [--all-teams] --json`\n- `linearctl cycle current [--team <id>] --json` — get the currently active cycle for a team; includes progress/scope fields\n- `linearctl cycle create --team <id> [--name <text>] [--starts-at <date>] [--ends-at <date>] --json`\n- `linearctl cycle update <id> [--name <text>] [--starts-at <date>] [--ends-at <date>] --json`\n- `linearctl cycle archive <id> --json`\n- `linearctl cycle delete <id> --json` — Linear does not hard-delete cycles; this archives the cycle and reports `requestedAction: \"delete\"` / `performedAction: \"archive\"` in JSON and dry-run output\n\n### Teams\n- `linearctl team get <id-or-key> [--set-default] --json` — fetch team; --set-default saves as profile default\n- `linearctl team list --json`\n- `linearctl team members <id-or-key> [--all] --json` — list team members with `id`, `name`, `displayName`, `email`, and `active`\n\n### Users\n- `linearctl user get <id> --json`\n- `linearctl user me --json`\n- `linearctl user list --json`\n\n### Labels\n- `linearctl label get <id> --json`\n- `linearctl label list [--team <id>] [--all-teams] --json`\n- `linearctl label create --name <name> [--description <text>] [--color <hex>] [--team <id>] [--parent <name|id>|--group] --json` — create a group with `--group`, or a child label under a group with `--parent`\n- `linearctl label delete <id> --json`\n\n### Comments\n- `linearctl comment list <issue> --json` / `linearctl comment list --issue <id> --json` — reads the issue's comments connection; with human-readable issue identifiers, a missing parent issue returns exit 4 / `category: \"not-found\"` instead of an empty list\n- `linearctl comment create --issue <id> (--body <text>|--body-file <path|->) --json`\n- `linearctl comment update <id> (--body <text>|--body-file <path|->) --json`\n- `linearctl comment delete <id> --json`\n\n### Attachments\n- `linearctl attachment list --issue <id> --json`\n- `linearctl attachment create --issue <id> --url <url> --title <title> --json`\n- `linearctl attachment delete <id> --json`\n\n### Files\n- `linearctl file upload <path> [--issue <id>] [--transfer-timeout <seconds>] --json`\n- `linearctl file url <attachment-id> [--expires-in <seconds>] --json`\n- `linearctl file download <url> [--output <path>] [--transfer-timeout <seconds>] --json`\n- Upload/download stream with backpressure. Upload sources must be regular files; do not modify them during transfer. `--transfer-timeout` is a total PUT/GET deadline in whole seconds (default 120, range 1–2147483), including redirects, response bodies, and download writes; GraphQL requests keep their separate timeout/retry policy. Transfers are not automatically retried. Ctrl-C/SIGINT or SIGTERM cancels an active transfer; timeout/cancellation returns exit 1.\n- Downloads stage beside the destination and atomically overwrite it only after success. Transfer/write/rename failures preserve existing contents and attempt staging cleanup; cleanup failures do not change the primary transfer outcome. The parent must exist and be writable. Destination symlinks are replaced rather than followed; new files have private permissions (0600 on POSIX), not the old metadata. Cleanup failures or SIGKILL/crashes can leave staging directories; this is not a crash-durability guarantee.\n- Requests and redirects require HTTPS, with at most five redirects; downloads must start at `uploads.linear.app`. Same-host redirects keep signed upload headers or Linear authorization; cross-host redirects drop sensitive headers permanently. Redirected PUTs replay the file from byte zero.\n\n### Workflow states\n- `linearctl state list [--team <id>] [--all-teams] --json` — list issue workflow states for a team\n- `linearctl state get <id> --json`\n- `linearctl state create --name <name> --team <id> --state-type <type> --json` (types: backlog, unstarted, started, completed, canceled)\n- `linearctl state archive <id|name> [--team <id>] --json`\n- `linearctl state delete <id|name> [--team <id>] --json`\n\n### Skills\n- `linearctl skills install [--json]` — auto-detect agents and install skill files\n- `linearctl skills list --json` — list embedded skills plus install status, paths, and `upToDate` state for Claude Code and Codex at user and project scope; uninspectable targets report a per-target `error` without hiding other results\n\n### Schema\n- `linearctl schema version --json`\n- `linearctl schema pull --json`\n- `linearctl schema check --json`\n- Normal commands run best-effort schema freshness checks after command output, skip help/dry-run paths, cache successful or failed attempts for 24 hours, and warn on stderr when the effective schema metadata is stale. `schema pull` writes `schema.json` and `schema-meta.json`; commands prefer pulled metadata from the profile config directory when present. Configure `[schema] stale_after_days` and `auto_update = true` in the linear config to opt into automatic schema pulls (`schema.autoUpdate`).\n\n### Auth\n- `linearctl auth status --json`\n- `linearctl auth login --profile <name> --api-key-env <ENV>`\n- `linearctl auth login --profile <name> --oauth --oauth-client-id <id>` — browser-based PKCE login\n- `linearctl auth login --profile <name> --oauth-client-credentials --oauth-client-id <id> (--oauth-client-secret-env <ENV>|--oauth-client-secret-stdin)` — non-interactive login; never pass the client secret as an argument and it is not persisted\n- `linearctl auth logout --profile <name>`\n- `linearctl auth switch <profile>`\n- `linearctl auth whoami --json`\n- OAuth refresh, login, and logout serialize credentials-file updates across CLI processes; token errors omit raw response bodies.\n\n### Workspace\n- `linearctl workspace list --json`\n\n## Generated commands\n\nWhen no curated command exists, use `linearctl api <resource> <operation>`:\n- `linearctl --help` — grouped overview of curated resources\n- `linearctl <resource> --help` — full usage lines for one curated resource\n- `linearctl api search <term>` — discover available generated commands\n- `linearctl api <resource> --help` — list operations for a resource\n- `linearctl api <resource> <operation> --help` — show generated operation usage and input flags\n- `linearctl api <resource> <operation> --id <id> --json` — execute a generated command\n- `linearctl api <resource> <operation> --input-json '<json>' --json` — execute with JSON input\n\n## Output modes\n\n- Use `--json` when parsing output programmatically\n- Use `--json-envelope` only when metadata (pagination, rate limits, complexity) is needed\n- Parse-level validation errors also emit failure envelopes when `--json-envelope` is set\n- Use `--jsonl --all` for streaming all list results, or `--jsonl --max <n>` to stream a bounded set; `--jsonl` no longer implies `--all`\n- Do not parse human-readable default output\n- Bulk operations fail non-zero when any item fails. With `--json-envelope`, partial failures return `ok: false`, populate `errors[]`, include per-item `data.succeeded`/`data.failed`, and set `meta.partial: true` when some items succeeded. Per-item failures preserve mapped categories, and the command exit code is selected by category priority: auth, rate-limit, not-found, then general.\n- GraphQL retry is default-on for rate limits (`--max-retries` defaults to 3); pass `--no-retry` to disable it.\n\n## Default team\n\nEach profile can have a default team. When set, list commands (issue, project, cycle, label) automatically filter to that team. `issue search` is the exception: it searches the whole workspace unless `--team` is passed.\n\n- Set it: `linearctl team get <key> --set-default`\n- Override per-command: `--team <other>`\n- Bypass and see all teams: `--all-teams`\n- `--team` and `--all-teams` cannot be used together\n\n## Name resolution\n\nCurated commands resolve friendly names to IDs automatically:\n- `--team \"Infrastructure\"` or `--team INF` resolves to the team's UUID\n- `--assignee \"me\"` resolves to the current user's ID\n- `--assignee \"aborges\"` resolves by Linear displayName\n- `--assignee \"quentin@example.com\"` resolves by email\n- `--assignee none` / `unassigned` filters for no assignee on `issue list` and clears the assignee on `issue update`\n- `--state \"In Progress\"` resolves to the workflow state ID (team-scoped)\n- `--label \"bug\"` resolves to the label ID (team-scoped when possible)\n- `--project \"Terraform Tech Debt\"` resolves by exact project name, unique prefix, or unique substring\n\nIf a value looks like a UUID, it's passed through directly. On ambiguous matches, the CLI errors with candidates. Case-insensitive resolution prefers exact case-sensitive matches first; users prefer email matches, and labels under `--team` prefer team-scoped labels over same-named workspace labels.\n\n## Dry run\n\nUse `--dry-run` on any mutating command to preview what would happen without executing:\n- `linearctl issue create --title \"test\" --team INF --dry-run --json`\n- `linearctl issue bulk-close --ids \"id1,id2\" --dry-run --json`\n- Works on create, update, close, assign, comment, delete, and upload operations\n- Dry runs validate and resolve friendly names before emitting the preview payload\n\n## Pagination\n\n- Default list behavior returns the first page only (up to 50 items)\n- **When results are truncated, a warning is emitted to stderr** — check stderr to know if you have incomplete data\n- Use `--all` to fetch all results (with `--max` or `--limit` to limit)\n- Use `--max <n>` or `--limit <n>` to cap total results\n- Use `--quiet` / `-q` to suppress the truncation warning (useful when piping JSON)\n- Add filters before broad pagination whenever possible\n- Prefer `--jsonl` for large result sets — it streams one object per line; pass `--all` or `--max <n>`\n\n## Profile selection\n\n1. If an explicit profile is specified, use `--profile <name>`\n2. Otherwise rely on `LINEAR_PROFILE` env var\n3. Otherwise run `linearctl auth status` to check the default profile\n4. Do not silently choose among multiple profiles\n\n## Error handling\n\n| Exit code | Meaning | Action |\n|---|---|---|\n| 0 | Success | |\n| 1 | General error | Read stderr for details |\n| 2 | Auth error | Run `linearctl auth status`, re-authenticate if needed |\n| 3 | Rate limit | Wait, reduce result count, add filters |\n| 4 | Not found | Verify identifier/ID |\n| 5 | Validation error | Check flags and input |\n| 6 | Schema drift | Fall back to `linearctl gql`, update CLI |\n\nMissing referenced Linear entities, including issues reported by inline GraphQL errors such as `Could not find referenced Issue`, map to exit 4 / `category: \"not-found\"` consistently across curated get, update, delete, comment-list parent lookup, and bulk paths.\n\n## Anti-patterns\n\n- Do not use `linearctl gql` when curated or generated commands cover the task\n- Do not use `--all` without `--max` unless explicitly asked for everything\n- Do not parse human-mode output programmatically\n- Do not guess profile names\n- Do not pass secrets as CLI arguments\n- Do not retry immediately after rate-limit exhaustion\n- Do not run destructive operations without explicit user confirmation\n" + content: "---\nname: linearctl\ndescription: Agent-first CLI for the Linear API — curated commands, generated API, and raw GraphQL with stable JSON output contracts\n---\n\n# linearctl\n\nDefault skill for all Linear CLI usage. Use this skill for any request involving Linear data unless raw GraphQL is explicitly required or the curated/generated layers cannot cover the operation.\n\n## First-time setup\n\nIf the user has not configured the CLI yet, help them bootstrap:\n\n1. Create the config directory: `mkdir -p ~/.config/linear`\n2. Create a credentials file with their API key:\n ```bash\n export LINEAR_API_KEY=lin_api_...\n linearctl auth login --profile <name> --api-key-env LINEAR_API_KEY --set-default\n ```\n3. Set a default team: `linearctl team list --json` to find team keys, then `linearctl team get <key> --set-default`\n4. Verify: `linearctl auth whoami --json`\n\nAPI keys are created at https://linear.app/settings/api. For browser OAuth and unattended client-credentials OAuth, see https://linear.app/settings/api/applications.\n\n## Command routing\n\n1. Use curated commands when they cover the operation.\n2. Otherwise use generated `linearctl api` commands.\n3. Otherwise use `linearctl gql`.\n\nRaw GraphQL should not be used merely because it is possible. It is the fallback for gaps only.\n\n## Available curated commands\n\n### Issues\n- `linearctl issue get <identifier> --json` / `linearctl issue view <identifier> --json` — fetch a single issue by identifier (e.g. INF-2975) or UUID; JSON includes `dueDate`, `projectMilestone`, `trashed`, and `archivedAt`, so write/delete verification must check those fields instead of assuming exit 0 means live\n- `linearctl issue list [--search <text>|--query <text>] [--state <name>[,<name>...] ...] [--status <name>] [--assignee <name|displayName|email|\"me\"|id|none>] [--team <id|key|name>] [--label <name|id>] [--priority <0-4>] [--due-date <YYYY-MM-DD|none>] [--cycle <id>] [--project <name|id>] [--created-after <date>] [--updated-after <date>] [--completed-after <date>] [--order-by <field>] [--all-teams] [--all] [--max <n>|--limit <n>] [--json]` — list issues with filters; repeated or comma-separated `--state` values are unioned; `--assignee none`/`unassigned` finds unassigned work; `--due-date none` finds issues without a due date; `--status` aliases `--state`; `--search`/`--query` routes to full-text search and composes with the other filters; friendly names resolve case-insensitively\n- `linearctl issue search [<text>|--query <text>] [--team <id|key|name>] [--all] --json` — full-text search across issues; profile default teams are not applied, so pass `--team` to scope explicitly\n- `linearctl issue create --title <title> --team <id> [--description <text>|--description-file <path|->] [--priority <0-4>] [--estimate <n>] [--due-date <YYYY-MM-DD>] [--assignee <id>] [--label <id>] [--state <id>] [--cycle <id>] [--project <name|id>] [--project-milestone <id>|--milestone <id>] --json` — create an issue\n- `linearctl issue update <identifier> [--title <text>] [--description <text>|--description-file <path|->] [--priority <0-4>] [--estimate <n>] [--due-date <YYYY-MM-DD|none>] [--assignee <id|none>] [--label <name|id>] [--state <id>] [--cycle <id|none>] [--project <name|id|none>] [--project-milestone <id|none>|--milestone <id|none>] [--parent <identifier|none>] --json` — update an issue; `none` clears nullable fields and `--label` adds without replacing existing labels\n- `linearctl issue close <identifier> [--state <name>] --json` — close an issue (transitions to a terminal completed/canceled workflow state; defaults to \"Done\", use --state to pick another)\n- `linearctl issue delete <identifier> --json` — delete/trash an issue by identifier or UUID\n- `linearctl issue assign <identifier> <assignee-id> --json` — assign an issue\n- `linearctl issue attach-slack <identifier> --url <slack-url> [--sync] [--title <text>] --json` — link a Slack thread to an issue (--sync enables bidirectional comment sync)\n- `linearctl issue comment <identifier> (--body <text>|--body-file <path|->) --json` — add a comment to an issue\n\n### Issue relations\n- `linearctl relation list <issue> [--all] [--max <n>] --json` — list both outbound and inbound relations for an issue; each item includes `direction`, `issue`, and `relatedIssue`\n- `linearctl relation create --issue <issue> --related <issue> --type <blocks|duplicate|related|similar> --json` — create a relation after resolving both identifiers; for `duplicate`, `--issue` is the duplicate and `--related` is canonical\n- `linearctl relation delete <relation-id> --json` — delete a relation\n\n### Bulk operations\n- `linearctl issue bulk-update --ids <id1,id2,...> [--state <id>] [--assignee <id|none>] [--priority <0-4>] [--estimate <n>] [--due-date <YYYY-MM-DD|none>] [--label <id>] [--cycle <id|none>] [--project-milestone <id|none>|--milestone <id|none>] --json` — `--label` is additive\n- `linearctl issue bulk-close --ids <id1,id2,...> [--state <name|id>] --json` — transition issues to a completed/canceled workflow state, matching `issue close`\n- `linearctl issue bulk-archive --ids <id1,id2,...> --json` — archive multiple issues\n- `linearctl issue bulk-delete --ids <id1,id2,...> --yes|--confirm --json` — delete/trash multiple issues; `--confirm` is accepted as an alias for `--yes`\n- `linearctl issue bulk-assign --ids <id1,id2,...> --assignee <id> --json`\n\n### Projects\n- `linearctl project get <name|id> --json` — richer single-project detail payload than `project list` (includes progress/health/currentProgress, and milestones with id, name, description, targetDate, sortOrder, createdAt, updatedAt); project names resolve by exact match, unique prefix, or unique substring\n- `linearctl project list [--query <text>|--search <text>|--name <text>] [--team <id>] [--state <status-type> ...] [--all-teams] --json` — includes portfolio fields (`progress`, `health`, `description`, `updatedAt`, `currentProgress`), normalized `milestones` with `name`, `targetDate`, `progress`, and `status`, and milestone truncation metadata (`milestonesPageInfo`, `milestonesTruncated`) in JSON output; human output shows progress, health, description, updated time, and milestone summaries; `--state` values: backlog, planned, started, paused, completed, canceled; repeated `--state` values are unioned; text flags filter project names\n- `linearctl project create --name <name> [--description <text>|--description-file <path|->] [--content <text>|--content-file <path|->] [--team <id>] [--lead <user-id|email|\"me\">] [--status <id|name|type>|--state <name|type>] [--start-date <YYYY-MM-DD>] [--target-date <YYYY-MM-DD>] --json`\n- `linearctl project create-with-issues --name <name> --team <id> --issues-json <json> [--description <text>|--description-file <path|->] [--content <text>|--content-file <path|->] [--lead <user-id|email|\"me\">] [--status <id|name|type>|--state <name|type>] [--start-date <YYYY-MM-DD>] [--target-date <YYYY-MM-DD>] --json` — create a project then batch-create linked issues (reports partial success if issue creation fails after project was created)\n- `linearctl project update <id> [--name <text>] [--description <text>|--description-file <path|->] [--content <text>|--content-file <path|->] [--status <id|name|type>|--state <name|type>] [--lead <user-id|email|\"me\">] [--start-date <YYYY-MM-DD>] [--target-date <YYYY-MM-DD>] --json` — `--status`/`--state` accepts status names, state types, or status IDs; `--state` remains an alias\n- `linearctl project delete <id> --json`\n\n### Project statuses\n- `linearctl project-status list --json` — list workspace-level project statuses\n- `linearctl project-status get <id> --json`\n- `linearctl project-status create --name <name> --status-type <type> --color <hex> --json` (types: backlog, planned, started, paused, completed, canceled)\n- `linearctl project-status delete <id> --json` — archives the status\n\n### Cycles\n- `linearctl cycle get <id> --json` — includes progress/scope fields (`progress`, derived `scopeCount`, `completedScopeCount`, `inProgressScopeCount`, `startedScopeCount`, issue counts, history arrays, and uncompleted issues captured on close)\n- `linearctl cycle list [--team <id>] [--all-teams] --json`\n- `linearctl cycle current [--team <id>] --json` — get the currently active cycle for a team; includes progress/scope fields\n- `linearctl cycle create --team <id> [--name <text>] [--starts-at <date>] [--ends-at <date>] --json`\n- `linearctl cycle update <id> [--name <text>] [--starts-at <date>] [--ends-at <date>] --json`\n- `linearctl cycle archive <id> --json`\n- `linearctl cycle delete <id> --json` — Linear does not hard-delete cycles; this archives the cycle and reports `requestedAction: \"delete\"` / `performedAction: \"archive\"` in JSON and dry-run output\n\n### Teams\n- `linearctl team get <id-or-key> [--set-default] --json` — fetch team; --set-default saves as profile default\n- `linearctl team list --json`\n- `linearctl team members <id-or-key> [--all] --json` — list team members with `id`, `name`, `displayName`, `email`, and `active`\n\n### Users\n- `linearctl user get <id> --json`\n- `linearctl user me --json`\n- `linearctl user list --json`\n\n### Labels\n- `linearctl label get <id> --json`\n- `linearctl label list [--team <id>] [--all-teams] --json`\n- `linearctl label create --name <name> [--description <text>] [--color <hex>] [--team <id>] [--parent <name|id>|--group] --json` — create a group with `--group`, or a child label under a group with `--parent`\n- `linearctl label delete <id> --json`\n\n### Comments\n- `linearctl comment list <issue> --json` / `linearctl comment list --issue <id> --json` — reads the issue's comments connection; with human-readable issue identifiers, a missing parent issue returns exit 4 / `category: \"not-found\"` instead of an empty list\n- `linearctl comment create --issue <id> (--body <text>|--body-file <path|->) --json`\n- `linearctl comment update <id> (--body <text>|--body-file <path|->) --json`\n- `linearctl comment delete <id> --json`\n\n### Attachments\n- `linearctl attachment list --issue <id> --json`\n- `linearctl attachment create --issue <id> --url <url> --title <title> --json`\n- `linearctl attachment delete <id> --json`\n\n### Files\n- `linearctl file upload <path> [--issue <id>] [--transfer-timeout <seconds>] --json`\n- `linearctl file url <attachment-id> [--expires-in <seconds>] --json`\n- `linearctl file download <url> [--output <path>] [--transfer-timeout <seconds>] --json`\n- Upload/download stream with backpressure. Upload sources must be regular files; do not modify them during transfer. `--transfer-timeout` is a total PUT/GET deadline in whole seconds (default 120, range 1–2147483), including redirects, response bodies, and download writes; GraphQL requests keep their separate timeout/retry policy. Transfers are not automatically retried. Ctrl-C/SIGINT or SIGTERM cancels an active transfer; timeout/cancellation returns exit 1 before download commit. The final atomic rename is checked for cancellation before dispatch, but cannot be cancelled once in flight; its actual result is reported even if the deadline or cancellation arrives meanwhile.\n- Downloads stage beside the destination and atomically overwrite it only after success. Transfer/write/rename failures preserve existing contents and attempt staging cleanup; cleanup failures do not change the primary transfer outcome. The parent must exist and be writable. Destination symlinks are replaced rather than followed; new files have private permissions (0600 on POSIX), not the old metadata. Cleanup failures or SIGKILL/crashes can leave staging directories; this is not a crash-durability guarantee.\n- Requests and redirects require HTTPS, with at most five redirects; downloads must start at `uploads.linear.app`. Same-host redirects keep signed upload headers or Linear authorization; cross-host redirects drop sensitive headers permanently. Redirected PUTs replay the file from byte zero.\n\n### Workflow states\n- `linearctl state list [--team <id>] [--all-teams] --json` — list issue workflow states for a team\n- `linearctl state get <id> --json`\n- `linearctl state create --name <name> --team <id> --state-type <type> --json` (types: backlog, unstarted, started, completed, canceled)\n- `linearctl state archive <id|name> [--team <id>] --json`\n- `linearctl state delete <id|name> [--team <id>] --json`\n\n### Skills\n- `linearctl skills install [--json]` — auto-detect agents and install skill files\n- `linearctl skills list --json` — list embedded skills plus install status, paths, and `upToDate` state for Claude Code and Codex at user and project scope; uninspectable targets report a per-target `error` without hiding other results\n\n### Schema\n- `linearctl schema version --json`\n- `linearctl schema pull --json`\n- `linearctl schema check --json`\n- Normal commands run best-effort schema freshness checks after command output, skip help/dry-run paths, cache successful or failed attempts for 24 hours, and warn on stderr when the effective schema metadata is stale. `schema pull` writes `schema.json` and `schema-meta.json`; commands prefer pulled metadata from the profile config directory when present. Configure `[schema] stale_after_days` and `auto_update = true` in the linear config to opt into automatic schema pulls (`schema.autoUpdate`).\n\n### Auth\n- `linearctl auth status --json`\n- `linearctl auth login --profile <name> --api-key-env <ENV>`\n- `linearctl auth login --profile <name> --oauth --oauth-client-id <id>` — browser-based PKCE login\n- `linearctl auth login --profile <name> --oauth-client-credentials --oauth-client-id <id> (--oauth-client-secret-env <ENV>|--oauth-client-secret-stdin)` — non-interactive login; never pass the client secret as an argument and it is not persisted\n- `linearctl auth logout --profile <name>`\n- `linearctl auth switch <profile>`\n- `linearctl auth whoami --json`\n- OAuth refresh, login, and logout serialize credentials-file updates across CLI processes; token errors omit raw response bodies.\n\n### Workspace\n- `linearctl workspace list --json`\n\n## Generated commands\n\nWhen no curated command exists, use `linearctl api <resource> <operation>`:\n- `linearctl --help` — grouped overview of curated resources\n- `linearctl <resource> --help` — full usage lines for one curated resource\n- `linearctl api search <term>` — discover available generated commands\n- `linearctl api <resource> --help` — list operations for a resource\n- `linearctl api <resource> <operation> --help` — show generated operation usage and input flags\n- `linearctl api <resource> <operation> --id <id> --json` — execute a generated command\n- `linearctl api <resource> <operation> --input-json '<json>' --json` — execute with JSON input\n\n## Output modes\n\n- Use `--json` when parsing output programmatically\n- Use `--json-envelope` only when metadata (pagination, rate limits, complexity) is needed\n- Parse-level validation errors also emit failure envelopes when `--json-envelope` is set\n- Use `--jsonl --all` for streaming all list results, or `--jsonl --max <n>` to stream a bounded set; `--jsonl` no longer implies `--all`\n- Do not parse human-readable default output\n- Bulk operations fail non-zero when any item fails. With `--json-envelope`, partial failures return `ok: false`, populate `errors[]`, include per-item `data.succeeded`/`data.failed`, and set `meta.partial: true` when some items succeeded. Per-item failures preserve mapped categories, and the command exit code is selected by category priority: auth, rate-limit, not-found, then general.\n- GraphQL retry is default-on for rate limits (`--max-retries` defaults to 3); pass `--no-retry` to disable it.\n\n## Default team\n\nEach profile can have a default team. When set, list commands (issue, project, cycle, label) automatically filter to that team. `issue search` is the exception: it searches the whole workspace unless `--team` is passed.\n\n- Set it: `linearctl team get <key> --set-default`\n- Override per-command: `--team <other>`\n- Bypass and see all teams: `--all-teams`\n- `--team` and `--all-teams` cannot be used together\n\n## Name resolution\n\nCurated commands resolve friendly names to IDs automatically:\n- `--team \"Infrastructure\"` or `--team INF` resolves to the team's UUID\n- `--assignee \"me\"` resolves to the current user's ID\n- `--assignee \"aborges\"` resolves by Linear displayName\n- `--assignee \"quentin@example.com\"` resolves by email\n- `--assignee none` / `unassigned` filters for no assignee on `issue list` and clears the assignee on `issue update`\n- `--state \"In Progress\"` resolves to the workflow state ID (team-scoped)\n- `--label \"bug\"` resolves to the label ID (team-scoped when possible)\n- `--project \"Terraform Tech Debt\"` resolves by exact project name, unique prefix, or unique substring\n\nIf a value looks like a UUID, it's passed through directly. On ambiguous matches, the CLI errors with candidates. Case-insensitive resolution prefers exact case-sensitive matches first; users prefer email matches, and labels under `--team` prefer team-scoped labels over same-named workspace labels.\n\n## Dry run\n\nUse `--dry-run` on any mutating command to preview what would happen without executing:\n- `linearctl issue create --title \"test\" --team INF --dry-run --json`\n- `linearctl issue bulk-close --ids \"id1,id2\" --dry-run --json`\n- Works on create, update, close, assign, comment, delete, and upload operations\n- Dry runs validate and resolve friendly names before emitting the preview payload\n\n## Pagination\n\n- Default list behavior returns the first page only (up to 50 items)\n- **When results are truncated, a warning is emitted to stderr** — check stderr to know if you have incomplete data\n- Use `--all` to fetch all results (with `--max` or `--limit` to limit)\n- Use `--max <n>` or `--limit <n>` to cap total results; the last occurrence of either alias wins, including across leading and command-position flags\n- Use `--quiet` / `-q` to suppress the truncation warning (useful when piping JSON)\n- Add filters before broad pagination whenever possible\n- Prefer `--jsonl` for large result sets — it streams one object per line; pass `--all` or `--max <n>`\n\n## Profile selection\n\n1. If an explicit profile is specified, use `--profile <name>`\n2. Otherwise rely on `LINEAR_PROFILE` env var\n3. Otherwise run `linearctl auth status` to check the default profile\n4. Do not silently choose among multiple profiles\n\n## Error handling\n\n| Exit code | Meaning | Action |\n|---|---|---|\n| 0 | Success | |\n| 1 | General error | Read stderr for details |\n| 2 | Auth error | Run `linearctl auth status`, re-authenticate if needed |\n| 3 | Rate limit | Wait, reduce result count, add filters |\n| 4 | Not found | Verify identifier/ID |\n| 5 | Validation error | Check flags and input |\n| 6 | Schema drift | Fall back to `linearctl gql`, update CLI |\n\nMissing referenced Linear entities, including issues reported by inline GraphQL errors such as `Could not find referenced Issue`, map to exit 4 / `category: \"not-found\"` consistently across curated get, update, delete, comment-list parent lookup, and bulk paths.\n\n## Anti-patterns\n\n- Do not use `linearctl gql` when curated or generated commands cover the task\n- Do not use `--all` without `--max` unless explicitly asked for everything\n- Do not parse human-mode output programmatically\n- Do not guess profile names\n- Do not pass secrets as CLI arguments\n- Do not retry immediately after rate-limit exhaustion\n- Do not run destructive operations without explicit user confirmation\n" }, "linearctl-raw-gql": { filename: "linearctl-raw-gql.md", diff --git a/src/index.ts b/src/index.ts index 470ea1b..99c7d1f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -15,6 +15,7 @@ export * from "./core/registry/commands.js"; export * from "./core/registry/help.js"; export * from "./core/registry/option-mapping.js"; export * from "./core/runtime/command-context.js"; +export * from "./core/runtime/options.js"; export * from "./core/runtime/workflow.js"; export * from "./core/output/envelope.js"; export * from "./core/output/validation-error.js"; diff --git a/tests/cli/main.test.ts b/tests/cli/main.test.ts index 72a73b6..7cd5aef 100644 --- a/tests/cli/main.test.ts +++ b/tests/cli/main.test.ts @@ -41,28 +41,13 @@ async function runMainWithThrowingFetch(args: string[]) { const fetchImpl = vi.fn(async () => { throw new Error("unexpected network call"); }) as unknown as typeof fetch; - const stdoutSpy = vi.spyOn(process.stdout, "write").mockImplementation((chunk: string | Uint8Array) => { - stdout += String(chunk); - return true; - }); - const stderrSpy = vi.spyOn(process.stderr, "write").mockImplementation((chunk: string | Uint8Array) => { - stderr += String(chunk); - return true; - }); - - let code: number; - try { - code = await main(await hermeticArgs(args), { - env: {}, - stdin: Readable.from([]), - stdout: { write: (chunk: string | Uint8Array) => { stdout += String(chunk); return true; } }, - stderr: { write: (chunk: string | Uint8Array) => { stderr += String(chunk); return true; } }, - fetchImpl - }); - } finally { - stdoutSpy.mockRestore(); - stderrSpy.mockRestore(); - } + const code = await main(await hermeticArgs(args), { + env: {}, + stdin: Readable.from([]), + stdout: { write: (chunk: string | Uint8Array) => { stdout += String(chunk); return true; } }, + stderr: { write: (chunk: string | Uint8Array) => { stderr += String(chunk); return true; } }, + fetchImpl + }); return { code, stdout, stderr, fetchImpl }; } diff --git a/tests/cli/runtime.test.ts b/tests/cli/runtime.test.ts new file mode 100644 index 0000000..ab2a7e6 --- /dev/null +++ b/tests/cli/runtime.test.ts @@ -0,0 +1,175 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { Readable } from "node:stream"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { main } from "../../src/cli/main.js"; +import { writeCredentialsFile } from "../../src/core/auth/credentials.js"; +import { writeLinearConfigFile } from "../../src/core/config/config-file.js"; +import { COMMAND_REGISTRY } from "../../src/core/registry/commands.js"; +import { ExitCode } from "../../src/core/errors/exit-codes.js"; +import type { FetchLike } from "../../src/core/transport/graphql.js"; +import { captureCommandOutput } from "../helpers/output.js"; + +const team = { id: "team-1", key: "ENG", name: "Engineering", description: "Build things" }; +const response = (data: unknown) => new Response(JSON.stringify({ data })); + +describe("injected CLI runtime", () => { + let directory: string; + let paths: { configFile: string; credentialsFile: string }; + + beforeEach(async () => { + directory = await mkdtemp(join(tmpdir(), "linearctl-runtime-")); + paths = { configFile: join(directory, "config"), credentialsFile: join(directory, "credentials") }; + await writeLinearConfigFile(paths.configFile, { + defaultProfile: "work", + profiles: { work: { workspace: "Workspace", workspaceId: "workspace-1", userEmail: "user@example.com" } }, + }); + await writeCredentialsFile(paths.credentialsFile, { + profiles: { work: { profileName: "work", type: "api_key", apiKey: "lin_api_test" } }, + }); + // Keep the advisory freshness request separate from handler transport tests. + await writeFile(join(directory, "schema-freshness.json"), JSON.stringify({ lastCheckedAt: new Date().toISOString() })); + }); + + afterEach(async () => { + await rm(directory, { recursive: true, force: true }); + }); + + async function run(args: string[], fetchImpl: FetchLike = async () => { throw new Error("Unexpected network request"); }) { + const output = captureCommandOutput(); + const code = await main([...args, "--config", paths.configFile, "--credentials", paths.credentialsFile], { + ...output.io, env: {}, stdin: Readable.from([]), fetchImpl, + }); + return { code, stdout: output.stdout.join(""), stderr: output.stderr.join("") }; + } + + it.each(COMMAND_REGISTRY.map(({ name }) => name))("routes %s validation errors to supplied stderr", async (name) => { + const result = await run([name, "not-a-command"]); + expect(result.code).toBe(ExitCode.ValidationError); + expect(result.stderr).toContain("Error:"); + expect(result.stdout).toBe(""); + }); + + it.each([ + { args: ["api", "--help"], expected: "Available resources:" }, + { args: ["api", "team", "--help"], expected: "Operations:" }, + { args: ["api", "search", "team"], expected: "Commands matching" }, + { args: ["auth", "status"], expected: "Profiles:" }, + { args: ["workspace", "list"], expected: "Workspace ID" }, + { args: ["schema", "version", "--json"], expected: "schemaVersion" }, + { args: ["skills", "list", "--json"], expected: "linearctl" }, + { args: ["issue", "update", "ENG-1", "--title", "New title", "--dry-run"], expected: "Dry run:" }, + ])("routes local output for $args to supplied stdout", async ({ args, expected }) => { + const result = await run(args); + expect(result.code).toBe(0); + expect(result.stdout).toContain(expected); + expect(result.stderr).toBe(""); + }); + + it.each([ + { args: ["team", "get", "team-1"], expected: "ENG Engineering" }, + { args: ["team", "get", "team-1", "--json"], expected: '"name": "Engineering"' }, + { args: ["team", "get", "team-1", "--json-envelope"], expected: '"ok": true' }, + { args: ["team", "list", "--jsonl", "--max", "1"], expected: '"name":"Engineering"' }, + { args: ["gql", "query", "{ team { id } }", "--raw"], expected: '"data"' }, + { args: ["gql", "query", "{ team { id } }", "--json"], expected: '"team"' }, + { args: ["gql", "query", "{ team { id } }", "--json-envelope"], expected: '"sourceLayer": "raw-graphql"' }, + { args: ["api", "team", "get", "--id", "team-1", "--json-envelope"], expected: '"sourceLayer": "generated"' }, + ])("routes network output for $args to supplied stdout", async ({ args, expected }) => { + const fetchImpl = vi.fn<FetchLike>().mockImplementation(async () => response({ + team, teams: { nodes: [team], pageInfo: { hasNextPage: false } }, + })); + const result = await run(args, fetchImpl); + expect(result.code).toBe(0); + expect(result.stdout).toContain(expected); + expect(result.stderr).toBe(""); + expect(fetchImpl).toHaveBeenCalledOnce(); + }); + + it.each([ + ["--max", "10", "team", "list", "--limit", "20"], + ["--limit", "10", "team", "list", "--max", "20"], + ["team", "list", "--max", "10", "--limit", "20"], + ["team", "list", "--limit", "10", "--max", "20"], + ["--max=10", "--limit=20", "team", "list"], + ["--limit=10", "--max=20", "team", "list"], + ["team", "list", "--max=10", "--limit=15", "--max=20"], + ])("uses the last pagination alias in %j", async (...args) => { + const fetchImpl = vi.fn<FetchLike>().mockImplementation(async (_url, init) => { + expect(JSON.parse(String(init?.body)).variables.first).toBe(20); + return response({ teams: { nodes: [team], pageInfo: { hasNextPage: false } } }); + }); + const result = await run([...args, "--json"], fetchImpl); + expect(result.code).toBe(ExitCode.Success); + expect(fetchImpl).toHaveBeenCalledOnce(); + }); + + it("names the winning alias in pagination validation errors", async () => { + const result = await run(["--max", "10", "team", "list", "--limit=0", "--json-envelope"]); + expect(result.code).toBe(ExitCode.ValidationError); + expect(JSON.parse(result.stdout).errors[0].message).toBe("--limit must be a positive integer"); + }); + + it("routes retry and pagination warnings to supplied stderr without corrupting JSON", async () => { + const fetchImpl = vi.fn<FetchLike>() + .mockResolvedValueOnce(new Response("rate limited", { status: 429, headers: { "retry-after": "0" } })) + .mockResolvedValueOnce(response({ teams: { nodes: [team], pageInfo: { hasNextPage: true, endCursor: "next" } } })); + const result = await run(["team", "list", "--json", "--max-retries", "1"], fetchImpl); + expect(result.code).toBe(0); + expect(JSON.parse(result.stdout)).toMatchObject([{ name: "Engineering" }]); + expect(result.stderr).toContain("rate limited"); + expect(result.stderr).toContain("results truncated"); + expect(fetchImpl).toHaveBeenCalledTimes(2); + }); + + it("preserves validation envelopes on supplied stdout", async () => { + const result = await run(["issue", "get", "--json-envelope"]); + expect(result.code).toBe(ExitCode.ValidationError); + expect(JSON.parse(result.stdout)).toMatchObject({ ok: false, errors: [{ category: "validation" }] }); + expect(result.stderr).toBe(""); + }); + + it("preserves caught error envelopes and exit codes on supplied stdout", async () => { + const result = await run(["team", "get", "team-1", "--json-envelope"], async () => new Response("Unauthorized", { status: 401 })); + expect(result.code).toBe(ExitCode.AuthenticationError); + expect(JSON.parse(result.stdout)).toMatchObject({ ok: false, errors: [{ category: "authentication" }] }); + expect(result.stderr).toBe(""); + }); + + it("preserves caught generated API failure metadata", async () => { + const result = await run(["api", "team", "get", "--id", "team-1", "--json-envelope"], async () => { + throw new Error("Network unavailable"); + }); + expect(result.code).toBe(ExitCode.GeneralError); + expect(JSON.parse(result.stdout).meta).toEqual({ sourceLayer: "generated" }); + expect(result.stderr).toBe(""); + }); + + it("preserves bulk partial-failure output on supplied streams", async () => { + const fetchImpl = vi.fn<FetchLike>() + .mockResolvedValueOnce(response({ issueArchive: { success: true } })) + .mockResolvedValueOnce(response({ issueArchive: { success: false } })); + const result = await run(["issue", "bulk-archive", "--ids", "ENG-1,ENG-2", "--json-envelope"], fetchImpl); + expect(result.code).toBe(1); + expect(JSON.parse(result.stdout)).toMatchObject({ + ok: false, meta: { partial: true }, + data: { succeeded: [{ identifier: "ENG-1" }], failed: [{ identifier: "ENG-2" }] }, + }); + expect(result.stderr).toBe(""); + }); + + it("keeps concurrent invocations' output isolated", async () => { + const [left, right] = await Promise.all([ + run(["team", "get", "team-1", "--json"], async () => { + await new Promise(resolve => setTimeout(resolve, 10)); + return response({ team: { ...team, name: "Left" } }); + }), + run(["team", "get", "team-1", "--json"], async () => response({ team: { ...team, name: "Right" } })), + ]); + expect(left.code).toBe(0); + expect(right.code).toBe(0); + expect(JSON.parse(left.stdout).name).toBe("Left"); + expect(JSON.parse(right.stdout).name).toBe("Right"); + }); +}); diff --git a/tests/core/io/file-transfer.test.ts b/tests/core/io/file-transfer.test.ts index e884a42..672af2e 100644 --- a/tests/core/io/file-transfer.test.ts +++ b/tests/core/io/file-transfer.test.ts @@ -1,7 +1,7 @@ import { createHash } from "node:crypto"; import { createReadStream, createWriteStream } from "node:fs"; import type { WriteStream } from "node:fs"; -import { mkdir, mkdtemp, open, readFile, readdir, rm, stat, symlink, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, open, readFile, readdir, rename, rm, stat, symlink, writeFile } from "node:fs/promises"; import { createServer } from "node:http"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -18,7 +18,7 @@ vi.mock("node:fs", async (importOriginal) => { vi.mock("node:fs/promises", async (importOriginal) => { const actual = await importOriginal<typeof import("node:fs/promises")>(); - return { ...actual, rm: vi.fn(actual.rm) }; + return { ...actual, rm: vi.fn(actual.rm), rename: vi.fn(actual.rename) }; }); const url = "https://uploads.linear.app/file"; @@ -36,6 +36,7 @@ beforeEach(async () => { afterEach(async () => { vi.mocked(createWriteStream).mockReset(); vi.mocked(rm).mockReset(); + vi.mocked(rename).mockReset(); await rm(directory, { recursive: true, force: true }); expect([process.listenerCount("SIGINT"), process.listenerCount("SIGTERM")]).toEqual(listeners); }); @@ -169,6 +170,21 @@ describe("streaming downloads", () => { expect((await readdir(directory)).some((entry) => entry.startsWith(".linearctl-download-"))).toBe(true); }); + it("reports the actual commit result when cancellation arrives after rename dispatch", async () => { + await writeFile(destination, "original"); + const controller = new AbortController(); + const actual = await vi.importActual<typeof import("node:fs/promises")>("node:fs/promises"); + vi.mocked(rename).mockImplementationOnce(async (source, target) => { + controller.abort(); + await actual.rename(source, target); + }); + await expect(downloadFile(async () => new Response("new"), url, headers, destination, { + signal: controller.signal, + })).resolves.toBe(3); + expect(await readFile(destination, "utf8")).toBe("new"); + expect(await readdir(directory)).toEqual(["destination"]); + }); + it("cleans up when the destination cannot be replaced", async () => { await mkdir(destination); await writeFile(join(destination, "keep"), "original"); diff --git a/tests/core/runtime/command-context.test.ts b/tests/core/runtime/command-context.test.ts index b14c619..c3c17ca 100644 --- a/tests/core/runtime/command-context.test.ts +++ b/tests/core/runtime/command-context.test.ts @@ -1,182 +1,199 @@ -import { describe, expect, it, beforeEach } from "vitest"; -import { CommandContext } from "../../../src/core/runtime/command-context.js"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { mkdtemp, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { CommandContext, createCommandContext } from "../../../src/core/runtime/command-context.js"; import type { FetchLike } from "../../../src/core/transport/graphql.js"; import { ExitCode } from "../../../src/core/errors/exit-codes.js"; import { writeLinearConfigFile } from "../../../src/core/config/config-file.js"; -import { writeCredentialsFile } from "../../../src/core/auth/credentials.js"; -import * as fs from "node:fs"; -import { join } from "node:path"; -import * as os from "node:os"; - -function captureOutput() { - const stdout: string[] = []; - const stderr: string[] = []; - const originalStdoutWrite = process.stdout.write.bind(process.stdout); - const originalStderrWrite = process.stderr.write.bind(process.stderr); - process.stdout.write = (chunk: string | Uint8Array) => { - stdout.push(typeof chunk === "string" ? chunk : new TextDecoder().decode(chunk)); - return true; - }; - process.stderr.write = (chunk: string | Uint8Array) => { - stderr.push(typeof chunk === "string" ? chunk : new TextDecoder().decode(chunk)); - return true; - }; - return { - stdout, - stderr, - restore() { - process.stdout.write = originalStdoutWrite; - process.stderr.write = originalStderrWrite; - }, - }; -} - -async function writeProfileFiles(dir: string): Promise<{ configFile: string; credentialsFile: string }> { - const configFile = join(dir, "config"); - const credentialsFile = join(dir, "credentials"); - - await writeLinearConfigFile(configFile, { - defaultProfile: "test", - profiles: { test: {} }, - }); - await writeCredentialsFile(credentialsFile, { - profiles: { - test: { - profileName: "test", - type: "api_key", - apiKey: "lin_api_test", - }, - }, - }); - - return { configFile, credentialsFile }; -} +import { loadCredentialsFile, writeCredentialsFile } from "../../../src/core/auth/credentials.js"; +import { captureCommandOutput } from "../../helpers/output.js"; +import { runTwoStepWorkflow } from "../../../src/core/runtime/workflow.js"; function makeFetch(body: object, status = 200): FetchLike { - return async () => - new Response(JSON.stringify(body), { - status, - headers: { "content-type": "application/json" }, - }); + return async () => new Response(JSON.stringify(body), { status }); } describe("CommandContext", () => { - let tmpDir: string; + let directory: string; + let paths: { configFile: string; credentialsFile: string }; beforeEach(async () => { - tmpDir = await fs.promises.mkdtemp(join(os.tmpdir(), "ctx-test-")); + directory = await mkdtemp(join(tmpdir(), "ctx-test-")); + paths = { configFile: join(directory, "config"), credentialsFile: join(directory, "credentials") }; + await writeLinearConfigFile(paths.configFile, { + defaultProfile: "test", + profiles: { test: { baseUrl: "https://profile.example/graphql" } }, + }); + await writeCredentialsFile(paths.credentialsFile, { + profiles: { test: { profileName: "test", type: "api_key", apiKey: "lin_api_test" } }, + }); }); + afterEach(async () => { + await rm(directory, { recursive: true, force: true }); + }); + + function options() { + return { ...paths, json: true, jsonEnvelope: false, env: {} }; + } + it("resolves profile and caches it", async () => { - const { configFile, credentialsFile } = await writeProfileFiles(tmpDir); - const ctx = new CommandContext({ - json: true, - jsonEnvelope: false, - configFile, - credentialsFile, - env: {}, - }); + const ctx = createCommandContext(options()); + const profile = await ctx.resolveProfile(); + expect(await ctx.resolveProfile()).toBe(profile); + expect(profile.name).toBe("test"); + }); - const profile1 = await ctx.resolveProfile(); - const profile2 = await ctx.resolveProfile(); - expect(profile1).toBe(profile2); // Same reference — cached - expect(profile1.name).toBe("test"); + it.each([ + { profile: "explicit", env: { LINEAR_PROFILE: "environment" }, expected: "explicit" }, + { profile: undefined, env: { LINEAR_PROFILE: "environment" }, expected: "environment" }, + { profile: undefined, env: {}, expected: "test" }, + ])("preserves profile precedence ($expected)", async ({ profile, env, expected }) => { + await writeCredentialsFile(paths.credentialsFile, { + profiles: { + test: { profileName: "test", type: "api_key", apiKey: "default-key" }, + environment: { profileName: "environment", type: "api_key", apiKey: "environment-key" }, + explicit: { profileName: "explicit", type: "api_key", apiKey: "explicit-key" }, + }, + }); + const ctx = createCommandContext({ ...options(), env, ...(profile === undefined ? {} : { profile }) }); + expect((await ctx.resolveProfile()).name).toBe(expected); }); it("executes GraphQL with resolved profile", async () => { - const { configFile, credentialsFile } = await writeProfileFiles(tmpDir); - const fetchImpl = makeFetch({ data: { viewer: { id: "user-1" } } }); - - const ctx = new CommandContext({ - json: true, - jsonEnvelope: false, - configFile, - credentialsFile, - env: {}, - fetchImpl, + const ctx = createCommandContext({ + ...options(), + fetchImpl: makeFetch({ data: { viewer: { id: "user-1" } } }), }); - - const response = await ctx.graphql<{ viewer: { id: string } }>( - "query { viewer { id } }" - ); - + const response = await ctx.graphql<{ viewer: { id: string } }>("query { viewer { id } }"); expect(response.body.data?.viewer.id).toBe("user-1"); }); - it("emitSuccess outputs JSON envelope when jsonEnvelope is true", async () => { - const { configFile, credentialsFile } = await writeProfileFiles(tmpDir); - const ctx = new CommandContext({ - json: true, - jsonEnvelope: true, - configFile, - credentialsFile, - env: {}, - }); - - // Resolve profile first so the name appears in output + it.each([false, true])("emits success to supplied stdout (envelope=%s)", async (jsonEnvelope) => { + const output = captureCommandOutput(); + const ctx = createCommandContext({ ...options(), ...output.io, jsonEnvelope, sourceLayer: "generated" }); await ctx.resolveProfile(); - - const output = captureOutput(); - const exitCode = ctx.emitSuccess({ name: "test project" }); - output.restore(); - - expect(exitCode).toBe(ExitCode.Success); - const parsed = JSON.parse(output.stdout.join("")); - expect(parsed.ok).toBe(true); - expect(parsed.data.name).toBe("test project"); - expect(parsed.meta.sourceLayer).toBe("curated"); - expect(parsed.meta.profile).toBe("test"); + const data = { name: "test project" }; + expect(ctx.emitSuccess(data)).toBe(ExitCode.Success); + const result = JSON.parse(output.stdout.join("")); + expect(result).toEqual(jsonEnvelope ? { + ok: true, data, pageInfo: null, errors: [], meta: { sourceLayer: "generated", profile: "test" }, + } : data); + expect(output.stderr).toEqual([]); }); - it("emitFailure outputs error to stderr when not envelope mode", () => { - const ctx = new CommandContext({ - json: false, - jsonEnvelope: false, - configFile: "/tmp/nonexistent", - credentialsFile: "/tmp/nonexistent", - env: {}, - }); - - const output = captureOutput(); - const exitCode = ctx.emitFailure([{ category: "not-found", message: "Project not found" }], ExitCode.NotFound); - output.restore(); + it.each([false, true])("emits failures to supplied streams (envelope=%s)", (jsonEnvelope) => { + const output = captureCommandOutput(); + const ctx = createCommandContext({ ...options(), ...output.io, jsonEnvelope, profile: "explicit" }); + expect(ctx.emitNotFound("Project not found")).toBe(ExitCode.NotFound); + if (jsonEnvelope) { + expect(JSON.parse(output.stdout.join(""))).toMatchObject({ + ok: false, errors: [{ category: "not-found", message: "Project not found" }], + meta: { sourceLayer: "curated", profile: "explicit" }, + }); + expect(output.stderr).toEqual([]); + } else { + expect(output.stderr.join("")).toContain("Project not found"); + expect(output.stdout).toEqual([]); + } + }); - expect(exitCode).toBe(ExitCode.NotFound); - expect(output.stderr.join("")).toContain("Project not found"); + it.each(["human", "json", "envelope"])("emits recoverable workflow failures to supplied streams (%s)", async (mode) => { + const output = captureCommandOutput(); + const ctx = createCommandContext({ ...options(), ...output.io, json: mode === "json", jsonEnvelope: mode === "envelope" }); + const workflow = await runTwoStepWorkflow( + { name: "create", execute: async () => ({ id: "created-1" }) }, + () => ({ name: "attach", execute: async () => { throw new Error("Attachment failed"); } }) + ); + expect(ctx.emitWorkflowFailure(workflow, { resource: workflow.completed.first }, "Reuse the created resource")).toBe(ExitCode.GeneralError); + if (mode === "human") { + expect(output.stdout).toEqual([]); + expect(output.stderr.join("")).toContain("Attachment failed"); + expect(output.stderr.join("")).toContain("created-1"); + expect(output.stderr.join("")).toContain("Reuse the created resource"); + } else { + expect(output.stderr).toEqual([]); + expect(JSON.parse(output.stdout.join(""))).toMatchObject({ + ok: false, meta: { partial: true }, + errors: [{ details: { resource: { id: "created-1" }, recovery: "Reuse the created resource" } }], + }); + } }); it("hasErrors detects GraphQL errors", () => { - const ctx = new CommandContext({ - json: true, - jsonEnvelope: false, - configFile: "/tmp/nonexistent", - credentialsFile: "/tmp/nonexistent", - env: {}, - }); - + const ctx = new CommandContext(options()); expect(ctx.hasErrors(undefined)).toBe(false); expect(ctx.hasErrors([])).toBe(false); expect(ctx.hasErrors([{ message: "error" }])).toBe(true); }); - it("uses retry when configured", async () => { - const { configFile, credentialsFile } = await writeProfileFiles(tmpDir); - const fetchImpl = makeFetch({ data: { viewer: { id: "user-1" } } }); - - const ctx = new CommandContext({ - json: true, - jsonEnvelope: false, - configFile, - credentialsFile, - env: {}, - fetchImpl, - retry: { noRetry: false, maxRetries: 2 }, + it("uses retry when configured and emits retry warnings to supplied stderr", async () => { + const output = captureCommandOutput(); + const onRetryDelay = vi.fn(); + const fetchImpl = vi.fn<FetchLike>() + .mockResolvedValueOnce(new Response("rate limited", { status: 429, headers: { "retry-after": "0" } })) + .mockResolvedValueOnce(new Response(JSON.stringify({ data: { viewer: { id: "user-1" } } }))); + const ctx = createCommandContext({ + ...options(), ...output.io, fetchImpl, maxRetries: 1, retry: { onRetryDelay }, }); + expect((await ctx.graphql<{ viewer: { id: string } }>("{ viewer { id } }")).body.data?.viewer.id).toBe("user-1"); + expect(fetchImpl).toHaveBeenCalledTimes(2); + expect(onRetryDelay).toHaveBeenCalledOnce(); + expect(output.stderr.join("")).toContain("attempt 1/1"); + expect(output.stdout).toEqual([]); + }); - const response = await ctx.graphql<{ viewer: { id: string } }>( - "query { viewer { id } }" - ); + it("normalizes no-retry and max-retries for both GraphQL and resolvers", async () => { + const output = captureCommandOutput(); + const fetchImpl = vi.fn<FetchLike>().mockImplementation(makeFetch({}, 429)); + const ctx = createCommandContext({ ...options(), ...output.io, fetchImpl, noRetry: true, maxRetries: 4 }); + const resolver = await ctx.resolverOptions(); + expect(resolver.retry).toMatchObject({ noRetry: true, maxRetries: 4, stderr: output.io.stderr }); + expect(resolver.fetchImpl).toBe(fetchImpl); + await expect(ctx.graphql("{ viewer { id } }")).rejects.toThrow(); + expect(fetchImpl).toHaveBeenCalledOnce(); + expect(output.stderr).toEqual([]); + expect(() => createCommandContext({ ...options(), maxRetries: -1 })).toThrow(RangeError); + }); - expect(response.body.data?.viewer.id).toBe("user-1"); + it.each([undefined, "https://override.example/graphql"])("preserves API URL precedence (%s)", async (apiUrl) => { + const fetchImpl = vi.fn<FetchLike>().mockImplementation(makeFetch({ data: {} })); + const ctx = createCommandContext({ ...options(), fetchImpl, ...(apiUrl === undefined ? {} : { apiUrl }) }); + await ctx.graphql("{ viewer { id } }"); + expect(String(fetchImpl.mock.calls[0]?.[0])).toBe(apiUrl ?? "https://profile.example/graphql"); + expect((await ctx.resolverOptions()).apiUrl).toBe(apiUrl ?? "https://profile.example/graphql"); + }); + + it("uses the supplied transport for OAuth refresh, GraphQL, and resolver credentials", async () => { + await writeCredentialsFile(paths.credentialsFile, { + profiles: { + test: { + profileName: "test", type: "oauth", accessToken: "expired-access", refreshToken: "old-refresh", + expiresAt: new Date(0).toISOString(), oauthClientId: "client-123", + }, + }, + }); + const fetchImpl = vi.fn<FetchLike>().mockImplementation(async (url, init) => { + if (String(url) === "https://api.linear.app/oauth/token") { + const body = new URLSearchParams(String(init?.body)); + expect(body.get("grant_type")).toBe("refresh_token"); + expect(body.get("refresh_token")).toBe("old-refresh"); + expect(body.get("client_id")).toBe("client-123"); + return new Response(JSON.stringify({ + access_token: "fresh-access", refresh_token: "fresh-refresh", expires_in: 3600, token_type: "Bearer", + })); + } + expect(String(url)).toBe("https://profile.example/graphql"); + expect(new Headers(init?.headers).get("authorization")).toBe("Bearer fresh-access"); + return new Response(JSON.stringify({ data: { viewer: { id: "user-1" } } })); + }); + const ctx = createCommandContext({ ...options(), fetchImpl }); + await ctx.graphql("{ viewer { id } }"); + const resolver = await ctx.resolverOptions(); + expect(resolver.fetchImpl).toBe(fetchImpl); + expect(resolver.credentials).toMatchObject({ accessToken: "fresh-access", refreshToken: "fresh-refresh" }); + expect(fetchImpl).toHaveBeenCalledTimes(2); + expect((await loadCredentialsFile(paths.credentialsFile)).profiles.test).toMatchObject({ accessToken: "fresh-access" }); }); }); diff --git a/tests/core/runtime/output.test.ts b/tests/core/runtime/output.test.ts new file mode 100644 index 0000000..1222439 --- /dev/null +++ b/tests/core/runtime/output.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest"; +import { emitDryRunResult } from "../../../src/core/output/dry-run.js"; +import { emitValidationError } from "../../../src/core/output/validation-error.js"; +import { streamPaginateGraphQL } from "../../../src/core/pagination/streaming.js"; +import { ExitCode } from "../../../src/core/errors/exit-codes.js"; +import { captureCommandOutput } from "../../helpers/output.js"; + +describe("shared output helpers", () => { + it.each(["human", "json", "envelope"])("emits dry runs to supplied stdout (%s)", (mode) => { + const output = captureCommandOutput(); + const code = emitDryRunResult("update", "issue", { title: "New title" }, { + ...output.io, json: mode === "json", jsonEnvelope: mode === "envelope", + }); + expect(code).toBe(ExitCode.Success); + expect(output.stderr).toEqual([]); + const text = output.stdout.join(""); + if (mode === "human") { + expect(text).toContain("Dry run: would update issue"); + } else { + const result = JSON.parse(text); + expect(mode === "envelope" ? result.data : result).toMatchObject({ dryRun: true, input: { title: "New title" } }); + } + }); + + it.each([false, true])("emits validation failures to supplied streams (envelope=%s)", (jsonEnvelope) => { + const output = captureCommandOutput(); + expect(emitValidationError("Invalid input", { ...output.io, jsonEnvelope })).toBe(ExitCode.ValidationError); + if (jsonEnvelope) { + expect(JSON.parse(output.stdout.join(""))).toMatchObject({ ok: false, errors: [{ category: "validation", message: "Invalid input" }] }); + expect(output.stderr).toEqual([]); + } else { + expect(output.stderr).toEqual(["Error: Invalid input\n"]); + expect(output.stdout).toEqual([]); + } + }); + + it.each([false, true])("routes the streaming safety-cap warning to supplied stderr (quiet=%s)", async (quiet) => { + const output = captureCommandOutput(); + let count = 0; + const result = await streamPaginateGraphQL<number>({ + query: "{ items { id } }", + credentials: { type: "api_key", apiKey: "test" }, + options: { ...output.io, all: true, quiet }, + fetchImpl: async () => new Response(JSON.stringify({ data: {} })), + extractConnection: () => ({ nodes: Array.from({ length: 250 }, (_, i) => i), pageInfo: { hasNextPage: true, endCursor: String(count) } }), + onItem: () => { count++; }, + }); + expect(result.totalItems).toBe(10_000); + expect(output.stderr.join("")).toBe(quiet ? "" : "Warning: --jsonl fetched 10000 items (safety cap). Use --max to fetch more.\n"); + expect(output.stdout).toEqual([]); + }); +}); diff --git a/tests/core/schema/freshness.test.ts b/tests/core/schema/freshness.test.ts index a5cb92e..758afc7 100644 --- a/tests/core/schema/freshness.test.ts +++ b/tests/core/schema/freshness.test.ts @@ -6,6 +6,7 @@ import { writeCredentialsFile } from "../../../src/core/auth/credentials.js"; import { writeLinearConfigFile } from "../../../src/core/config/config-file.js"; import { maybeWarnForStaleSchema } from "../../../src/core/schema/freshness.js"; import type { FetchLike } from "../../../src/core/transport/graphql.js"; +import { captureCommandOutput } from "../../helpers/output.js"; const FRESHNESS_TEST_NOW = new Date("2099-01-01T00:00:00.000Z"); @@ -156,6 +157,22 @@ describe("maybeWarnForStaleSchema", () => { expect(fetchImpl).not.toHaveBeenCalled(); }); + it.each([false, true])("uses supplied stderr for advisory output (autoUpdate=%s)", async (autoUpdate) => { + const directory = await mkdtemp(join(tmpdir(), "linear-cli-freshness-")); + const paths = await writeProfileFiles(directory, autoUpdate); + const output = captureCommandOutput(); + await maybeWarnForStaleSchema({ + ...paths, + ...output.io, + commandName: "issue", + env: {}, + fetchImpl: async () => driftedSchemaResponse(), + now: FRESHNESS_TEST_NOW + }); + expect(output.stderr.join("")).toContain(autoUpdate ? "updated automatically" : "linearctl schema is"); + expect(output.stdout).toEqual([]); + }); + it("records failed freshness attempts so slow checks are throttled", async () => { const directory = await mkdtemp(join(tmpdir(), "linear-cli-freshness-")); const { configFile, credentialsFile } = await writeProfileFiles(directory); diff --git a/tests/helpers/output.ts b/tests/helpers/output.ts new file mode 100644 index 0000000..c105218 --- /dev/null +++ b/tests/helpers/output.ts @@ -0,0 +1,18 @@ +import type { CommandIO, OutputStream } from "../../src/core/runtime/options.js"; + +/** Capture a command's own streams without changing process globals. */ +export function captureCommandOutput() { + const stdout: string[] = []; + const stderr: string[] = []; + const stream = (chunks: string[]): OutputStream => ({ + write(chunk: string | Uint8Array) { + chunks.push(typeof chunk === "string" ? chunk : Buffer.from(chunk).toString()); + return true; + }, + }); + return { + stdout, + stderr, + io: { stdout: stream(stdout), stderr: stream(stderr) } satisfies CommandIO, + }; +}