From 7ce19732299f075619f4d83994360ec6acb16de3 Mon Sep 17 00:00:00 2001 From: Peter Schilling Date: Tue, 15 Sep 2026 08:40:03 -0700 Subject: [PATCH] Follow-up to #285: report already-archived issues and add bulk archive Linear's issueArchive answers success for an issue that is already archived, so the new command silently "archived" it a second time. The details query now selects archivedAt and the command says the issue is already archived and returns without prompting or mutating, matching initiative archive. issue archive also gains --bulk, --bulk-file, and --bulk-stdin, mirroring issue delete: the use case behind #284 is cleaning up stale issues, which is many-at-once work, and every other lifecycle command on issues and initiatives already supports it. An already-archived issue counts as done in bulk mode; an unknown one is a per-item failure and the command exits non-zero. The mutation-failure message no longer repeats the handler prefix ("Failed to archive issue: Failed to archive issue"), and the changelog credits the contribution. Claude-Session: https://claude.ai/code/session_01A9qEGri4p2HZMQSuYsBmub --- CHANGELOG.md | 1 + README.md | 1 + docs/usage.md | 1 + skills/linear-cli/references/issue.md | 9 +- src/commands/issue/issue-archive.ts | 182 +++++++++++- .../__snapshots__/issue-archive.test.ts.snap | 75 ++++- test/commands/issue/issue-archive.test.ts | 276 ++++++++++++++++++ 7 files changed, 536 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 33c7c727..0a145a11 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- `issue archive ` archives an issue through Linear's `issueArchive` mutation, distinct from `issue delete`, which trashes it. It resolves identifiers like the other issue commands, prompts with the identifier and title unless `--confirm`/`-y` is passed, reports an already-archived issue instead of silently succeeding, and takes `--bulk`, `--bulk-file`, and `--bulk-stdin` like `issue delete` ([#285](https://github.com/schpet/linear-cli/pull/285); thanks @martin-piliar for the command and the report in [#284](https://github.com/schpet/linear-cli/issues/284)) - `document comment list|add`, `project comment list|add`, and `initiative comment list|add`, mirroring `issue comment`. Documents take a UUID or slug, projects and initiatives a UUID, slug, or name; `add` takes `--body` or `--body-file`. Every comment `add`, including `issue comment add`, now takes `--reply-to ` to answer in a thread (`-p`/`--parent` remain aliases). Comment lists now fetch every page instead of stopping at 50, and their `--json` nodes, plus the comments in `issue view --json`, carry `quotedText` (the passage an inline comment is anchored to) alongside `parent.id` ([#230](https://github.com/schpet/linear-cli/issues/230)) - every command that takes a team now accepts its key, name, or UUID, resolved through one shared lookup: `team states`, `team members`, `team delete`, `label list/create/delete --team`, `cycle list/view --team`, `project list/create/update --team`, `document list/create/update --team`, and `issue query/mine/create/update --team`. Keys stay canonical and win over a same-spelled name; an unknown team errors with the list of valid keys instead of an empty result or a raw API error. Previously only keys worked, which is why [#276](https://github.com/schpet/linear-cli/issues/276) asked for `team list --json` as a name-to-key lookup - `issue query --state` and `issue mine --state` accept a workflow state name or ID as well as the six state types, looked up within the queried team scope (all teams under `--all-teams`, where a name matches every team's same-named state). An unknown name errors and lists the scope's states, and types and names can be mixed diff --git a/README.md b/README.md index 07984080..85f0184e 100644 --- a/README.md +++ b/README.md @@ -148,6 +148,7 @@ linear issue update # update an issue (interactive prompts) linear issue update ENG-123 --milestone "Phase 2" # set milestone on existing issue linear issue update ENG-123 --clear-due-date --clear-parent # remove values (also --clear-estimate, --clear-project, --clear-milestone, --clear-cycle, --unassign) linear issue archive ENG-123 --confirm # archive an issue +linear issue archive --confirm --bulk ENG-123 ENG-124 # archive several issues linear issue delete # delete an issue linear issue comment list # list comments on current issue linear issue comment add # add a comment to current issue diff --git a/docs/usage.md b/docs/usage.md index 5d5a6685..d731e460 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -247,6 +247,7 @@ archive an issue: ```bash linear issue archive TEAM-123 --confirm +linear issue archive --confirm --bulk TEAM-123 TEAM-124 # several at once; --bulk-file and --bulk-stdin also work ``` #### issue comments diff --git a/skills/linear-cli/references/issue.md b/skills/linear-cli/references/issue.md index 6f4f3270..55891413 100644 --- a/skills/linear-cli/references/issue.md +++ b/skills/linear-cli/references/issue.md @@ -112,9 +112,12 @@ Description: Options: - -h, --help - Show this help. - --workspace - Target workspace (uses credentials) - -y, --confirm - Skip confirmation prompt + -h, --help - Show this help. + --workspace - Target workspace (uses credentials) + -y, --confirm - Skip confirmation prompt + --bulk - Archive multiple issues by identifier (e.g., TC-123 TC-124) + --bulk-file - Read issue identifiers from a file (one per line) + --bulk-stdin - Read issue identifiers from stdin ``` ### attach diff --git a/src/commands/issue/issue-archive.ts b/src/commands/issue/issue-archive.ts index e3acb982..0b287882 100644 --- a/src/commands/issue/issue-archive.ts +++ b/src/commands/issue/issue-archive.ts @@ -4,21 +4,59 @@ import type { GraphQLClient } from "graphql-request" import { gql } from "../../__codegen__/gql.ts" import { getGraphQLClient } from "../../utils/graphql.ts" import { getIssueIdentifier } from "../../utils/linear.ts" +import { + type BulkOperationResult, + collectBulkIds, + executeBulkOperations, + isBulkMode, + printBulkSummary, +} from "../../utils/bulk.ts" import { CliError, handleError, + isClientError, + isNotFoundError, NotFoundError, + translateNotFound, ValidationError, } from "../../utils/errors.ts" +interface IssueArchiveResult extends BulkOperationResult { + identifier?: string +} + export const archiveCommand = new Command() .name("archive") .description("Archive an issue") .arguments("[issueId:string]") .option("-y, --confirm", "Skip confirmation prompt") - .action(async ({ confirm }, issueId) => { + .option( + "--bulk ", + "Archive multiple issues by identifier (e.g., TC-123 TC-124)", + ) + .option( + "--bulk-file ", + "Read issue identifiers from a file (one per line)", + ) + .option("--bulk-stdin", "Read issue identifiers from stdin") + .action(async ({ confirm, bulk, bulkFile, bulkStdin }, issueId) => { try { const client = getGraphQLClient() + + if (isBulkMode({ bulk, bulkFile, bulkStdin })) { + if (issueId != null) { + throw new ValidationError( + "Cannot combine a positional issue ID with --bulk", + { + suggestion: + "Pass every identifier through --bulk (or --bulk-file / --bulk-stdin), or drop the positional one.", + }, + ) + } + await handleBulkArchive(client, { bulk, bulkFile, bulkStdin, confirm }) + return + } + await archiveIssue(client, issueId, { confirm }) } catch (error) { handleError(error, "Failed to archive issue") @@ -43,16 +81,31 @@ async function archiveIssue( issue(id: $id) { identifier title + archivedAt } } `) - const issueDetails = await client.request(detailsQuery, { id: resolvedId }) + // Linear answers an unknown identifier with a GraphQL not-found error + // rather than a null issue; translate both into the same clean error. + const issueDetails = await translateNotFound( + "Issue", + resolvedId, + () => client.request(detailsQuery, { id: resolvedId }), + ) if (!issueDetails.issue) { throw new NotFoundError("Issue", resolvedId) } - const { identifier, title } = issueDetails.issue + const { identifier, title, archivedAt } = issueDetails.issue + + // Linear's issueArchive reports success on an already-archived issue, so + // say so instead of prompting for (and reporting) a no-op. + if (archivedAt != null) { + console.log(`Issue "${identifier}: ${title}" is already archived.`) + return + } + if (!options.confirm) { if (!Deno.stdin.isTerminal()) { throw new ValidationError( @@ -81,8 +134,129 @@ async function archiveIssue( const result = await client.request(archiveMutation, { id: resolvedId }) if (!result.issueArchive.success) { - throw new CliError("Failed to archive issue") + throw new CliError("Linear reported the archive as unsuccessful") } console.log(`✓ Successfully archived issue: ${identifier}: ${title}`) } + +async function handleBulkArchive( + client: GraphQLClient, + options: { + bulk?: string[] + bulkFile?: string + bulkStdin?: boolean + confirm?: boolean + }, +): Promise { + const ids = await collectBulkIds({ + bulk: options.bulk, + bulkFile: options.bulkFile, + bulkStdin: options.bulkStdin, + }) + + if (ids.length === 0) { + throw new ValidationError("No issue identifiers provided for bulk archive") + } + + console.log(`Found ${ids.length} issue(s) to archive.`) + + if (!options.confirm) { + if (!Deno.stdin.isTerminal()) { + throw new ValidationError( + "Interactive confirmation required", + { suggestion: "Use --confirm to skip." }, + ) + } + const confirmed = await Confirm.prompt({ + message: `Archive ${ids.length} issue(s)?`, + default: false, + }) + if (!confirmed) { + console.log("Bulk archive cancelled.") + return + } + } + + const detailsQuery = gql(` + query GetIssueDetailsForBulkArchive($id: String!) { + issue(id: $id) { + identifier + title + archivedAt + } + } + `) + + const archiveMutation = gql(` + mutation BulkArchiveIssue($id: String!) { + issueArchive(id: $id) { + success + } + } + `) + + const archiveOperation = async ( + issueIdInput: string, + ): Promise => { + const resolvedId = await getIssueIdentifier(issueIdInput) + if (!resolvedId) { + return { + id: issueIdInput, + identifier: issueIdInput, + success: false, + error: "Issue not found", + } + } + + const notFound: IssueArchiveResult = { + id: resolvedId, + identifier: resolvedId, + success: false, + error: "Issue not found", + } + let details + try { + details = await client.request(detailsQuery, { id: resolvedId }) + } catch (error) { + if (isClientError(error) && isNotFoundError(error)) return notFound + throw error + } + if (!details.issue) return notFound + + const { identifier, title, archivedAt } = details.issue + const name = `${identifier}: ${title}` + + // Already archived counts as done: the requested end state holds. + if (archivedAt != null) { + return { id: resolvedId, identifier, name, success: true } + } + + const result = await client.request(archiveMutation, { id: resolvedId }) + if (!result.issueArchive.success) { + return { + id: resolvedId, + identifier, + name, + success: false, + error: "Archive operation failed", + } + } + + return { id: resolvedId, identifier, name, success: true } + } + + const summary = await executeBulkOperations(ids, archiveOperation, { + showProgress: true, + }) + + printBulkSummary(summary, { + entityName: "issue", + operationName: "archived", + showDetails: true, + }) + + if (summary.failed > 0) { + Deno.exit(1) + } +} diff --git a/test/commands/issue/__snapshots__/issue-archive.test.ts.snap b/test/commands/issue/__snapshots__/issue-archive.test.ts.snap index 727459d3..7553447e 100644 --- a/test/commands/issue/__snapshots__/issue-archive.test.ts.snap +++ b/test/commands/issue/__snapshots__/issue-archive.test.ts.snap @@ -11,8 +11,11 @@ Description: Options: - -h, --help - Show this help. - -y, --confirm - Skip confirmation prompt + -h, --help - Show this help. + -y, --confirm - Skip confirmation prompt + --bulk - Archive multiple issues by identifier (e.g., TC-123 TC-124) + --bulk-file - Read issue identifiers from a file (one per line) + --bulk-stdin - Read issue identifiers from stdin " stderr: @@ -43,3 +46,71 @@ stderr: "✗ Failed to archive issue: Issue not found: ENG-404 " `; + +snapshot[`Issue Archive Command - Already Archived 1`] = ` +stdout: +'Issue "ENG-123: Archive this issue" is already archived. +' +stderr: +"" +`; + +snapshot[`Issue Archive Command - Mutation Failure 1`] = ` +stdout: +"" +stderr: +"✗ Failed to archive issue: Linear reported the archive as unsuccessful +" +`; + +snapshot[`Issue Archive Command - Bulk Archive 1`] = ` +stdout: +"Found 2 issue(s) to archive. + +✓ Successfully archived 2 issues +" +stderr: +"" +`; + +snapshot[`Issue Archive Command - Bulk Archive Reports Unknown Issue 1`] = ` +stdout: +"Found 2 issue(s) to archive. + +Completed: 1/2 issues archived + ✓ Succeeded: 1 + ✗ Failed: 1 + +Failed operations: + - ENG-404: Issue not found +" +stderr: +"" +`; + +snapshot[`Issue Archive Command - Bulk Requires Confirmation 1`] = ` +stdout: +"Found 1 issue(s) to archive. +" +stderr: +"✗ Failed to archive issue: Interactive confirmation required + Use --confirm to skip. +" +`; + +snapshot[`Issue Archive Command - Issue Not Found API Error 1`] = ` +stdout: +"" +stderr: +"✗ Failed to archive issue: Issue not found: ENG-404 +" +`; + +snapshot[`Issue Archive Command - Rejects Positional With Bulk 1`] = ` +stdout: +"" +stderr: +"✗ Failed to archive issue: Cannot combine a positional issue ID with --bulk + Pass every identifier through --bulk (or --bulk-file / --bulk-stdin), or drop the positional one. +" +`; diff --git a/test/commands/issue/issue-archive.test.ts b/test/commands/issue/issue-archive.test.ts index 750bc85f..50a41b14 100644 --- a/test/commands/issue/issue-archive.test.ts +++ b/test/commands/issue/issue-archive.test.ts @@ -126,3 +126,279 @@ await snapshotTest({ } }, }) + +// Follow-up to #285: Linear's issueArchive reports success on an issue that is +// already archived, so the command must say so instead of prompting for a +// no-op. No ArchiveIssue mock is registered: a stray mutation would surface +// as a mock miss in the snapshot. +await snapshotTest({ + name: "Issue Archive Command - Already Archived", + meta: import.meta, + colors: false, + args: ["ENG-123", "--confirm"], + denoArgs: commonDenoArgs, + async fn() { + const server = new MockLinearServer([ + { + queryName: "GetIssueArchiveDetails", + variables: { id: "ENG-123" }, + response: { + data: { + issue: { + identifier: "ENG-123", + title: "Archive this issue", + archivedAt: "2026-01-01T00:00:00.000Z", + }, + }, + }, + }, + ]) + + try { + await server.start() + Deno.env.set("LINEAR_GRAPHQL_ENDPOINT", server.getEndpoint()) + Deno.env.set("LINEAR_API_KEY", "Bearer test-token") + + await archiveCommand.parse() + } finally { + await server.stop() + Deno.env.delete("LINEAR_GRAPHQL_ENDPOINT") + Deno.env.delete("LINEAR_API_KEY") + } + }, +}) + +await snapshotTest({ + name: "Issue Archive Command - Mutation Failure", + meta: import.meta, + colors: false, + canFail: true, + args: ["ENG-123", "--confirm"], + denoArgs: commonDenoArgs, + async fn() { + const server = new MockLinearServer([ + { + queryName: "GetIssueArchiveDetails", + variables: { id: "ENG-123" }, + response: { + data: { + issue: { + identifier: "ENG-123", + title: "Archive this issue", + archivedAt: null, + }, + }, + }, + }, + { + queryName: "ArchiveIssue", + variables: { id: "ENG-123" }, + response: { data: { issueArchive: { success: false } } }, + }, + ]) + + try { + await server.start() + Deno.env.set("LINEAR_GRAPHQL_ENDPOINT", server.getEndpoint()) + Deno.env.set("LINEAR_API_KEY", "Bearer test-token") + + await archiveCommand.parse() + } finally { + await server.stop() + Deno.env.delete("LINEAR_GRAPHQL_ENDPOINT") + Deno.env.delete("LINEAR_API_KEY") + } + }, +}) + +function bulkIssue(identifier: string, archivedAt: string | null) { + return { + queryName: "GetIssueDetailsForBulkArchive", + variables: { id: identifier }, + response: { + data: { + issue: { identifier, title: `Issue ${identifier}`, archivedAt }, + }, + }, + } +} + +function bulkArchiveOk(identifier: string) { + return { + queryName: "BulkArchiveIssue", + variables: { id: identifier }, + response: { data: { issueArchive: { success: true } } }, + } +} + +// Bulk mirrors `issue delete --bulk`. An already-archived issue counts as +// done (the requested end state holds) and sends no mutation. +await snapshotTest({ + name: "Issue Archive Command - Bulk Archive", + meta: import.meta, + colors: false, + args: ["--confirm", "--bulk", "ENG-1", "eng-2"], + denoArgs: commonDenoArgs, + async fn() { + const server = new MockLinearServer([ + bulkIssue("ENG-1", null), + bulkArchiveOk("ENG-1"), + bulkIssue("ENG-2", "2026-01-01T00:00:00.000Z"), + ]) + + try { + await server.start() + Deno.env.set("LINEAR_GRAPHQL_ENDPOINT", server.getEndpoint()) + Deno.env.set("LINEAR_API_KEY", "Bearer test-token") + + await archiveCommand.parse() + } finally { + await server.stop() + Deno.env.delete("LINEAR_GRAPHQL_ENDPOINT") + Deno.env.delete("LINEAR_API_KEY") + } + }, +}) + +// An unknown issue is a per-item failure and the command exits non-zero, +// like `issue delete --bulk`. +await snapshotTest({ + name: "Issue Archive Command - Bulk Archive Reports Unknown Issue", + meta: import.meta, + colors: false, + canFail: true, + args: ["--confirm", "--bulk", "ENG-1", "ENG-404"], + denoArgs: commonDenoArgs, + async fn() { + const server = new MockLinearServer([ + bulkIssue("ENG-1", null), + bulkArchiveOk("ENG-1"), + // Linear answers an unknown identifier with a not-found GraphQL error + // (not a null issue); it must become a clean per-item failure. + { + queryName: "GetIssueDetailsForBulkArchive", + variables: { id: "ENG-404" }, + status: 400, + response: { + errors: [{ + message: "Entity not found: Issue", + path: ["issue"], + extensions: { + type: "invalid input", + code: "INPUT_ERROR", + userError: true, + userPresentableMessage: "Could not find referenced Issue.", + }, + }], + data: null, + }, + }, + ]) + + try { + await server.start() + Deno.env.set("LINEAR_GRAPHQL_ENDPOINT", server.getEndpoint()) + Deno.env.set("LINEAR_API_KEY", "Bearer test-token") + + await archiveCommand.parse() + } finally { + await server.stop() + Deno.env.delete("LINEAR_GRAPHQL_ENDPOINT") + Deno.env.delete("LINEAR_API_KEY") + } + }, +}) + +await snapshotTest({ + name: "Issue Archive Command - Bulk Requires Confirmation", + meta: import.meta, + colors: false, + canFail: true, + args: ["--bulk", "ENG-1"], + denoArgs: commonDenoArgs, + async fn() { + const server = new MockLinearServer([]) + + try { + await server.start() + Deno.env.set("LINEAR_GRAPHQL_ENDPOINT", server.getEndpoint()) + Deno.env.set("LINEAR_API_KEY", "Bearer test-token") + + await archiveCommand.parse() + } finally { + await server.stop() + Deno.env.delete("LINEAR_GRAPHQL_ENDPOINT") + Deno.env.delete("LINEAR_API_KEY") + } + }, +}) + +// The same not-found GraphQL error in single mode becomes the usual error. +await snapshotTest({ + name: "Issue Archive Command - Issue Not Found API Error", + meta: import.meta, + colors: false, + canFail: true, + args: ["ENG-404", "--confirm"], + denoArgs: commonDenoArgs, + async fn() { + const server = new MockLinearServer([ + { + queryName: "GetIssueArchiveDetails", + variables: { id: "ENG-404" }, + status: 400, + response: { + errors: [{ + message: "Entity not found: Issue", + path: ["issue"], + extensions: { + type: "invalid input", + code: "INPUT_ERROR", + userError: true, + userPresentableMessage: "Could not find referenced Issue.", + }, + }], + data: null, + }, + }, + ]) + + try { + await server.start() + Deno.env.set("LINEAR_GRAPHQL_ENDPOINT", server.getEndpoint()) + Deno.env.set("LINEAR_API_KEY", "Bearer test-token") + + await archiveCommand.parse() + } finally { + await server.stop() + Deno.env.delete("LINEAR_GRAPHQL_ENDPOINT") + Deno.env.delete("LINEAR_API_KEY") + } + }, +}) + +// A positional identifier alongside --bulk would otherwise be silently +// dropped, leaving an explicitly requested issue untouched. +await snapshotTest({ + name: "Issue Archive Command - Rejects Positional With Bulk", + meta: import.meta, + colors: false, + canFail: true, + args: ["ENG-1", "--confirm", "--bulk", "ENG-2"], + denoArgs: commonDenoArgs, + async fn() { + const server = new MockLinearServer([]) + + try { + await server.start() + Deno.env.set("LINEAR_GRAPHQL_ENDPOINT", server.getEndpoint()) + Deno.env.set("LINEAR_API_KEY", "Bearer test-token") + + await archiveCommand.parse() + } finally { + await server.stop() + Deno.env.delete("LINEAR_GRAPHQL_ENDPOINT") + Deno.env.delete("LINEAR_API_KEY") + } + }, +})