Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

### Added

- `issue archive <id>` 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 <commentId>` 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
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 6 additions & 3 deletions skills/linear-cli/references/issue.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,9 +112,12 @@ Description:

Options:

-h, --help - Show this help.
--workspace <slug> - Target workspace (uses credentials)
-y, --confirm - Skip confirmation prompt
-h, --help - Show this help.
--workspace <slug> - Target workspace (uses credentials)
-y, --confirm - Skip confirmation prompt
--bulk <ids...> - Archive multiple issues by identifier (e.g., TC-123 TC-124)
--bulk-file <file> - Read issue identifiers from a file (one per line)
--bulk-stdin - Read issue identifiers from stdin
```

### attach
Expand Down
182 changes: 178 additions & 4 deletions src/commands/issue/issue-archive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <ids...:string>",
"Archive multiple issues by identifier (e.g., TC-123 TC-124)",
)
.option(
"--bulk-file <file:string>",
"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")
Expand All @@ -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(
Expand Down Expand Up @@ -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<void> {
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<IssueArchiveResult> => {
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)
}
}
75 changes: 73 additions & 2 deletions test/commands/issue/__snapshots__/issue-archive.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -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 <ids...> - Archive multiple issues by identifier (e.g., TC-123 TC-124)
--bulk-file <file> - Read issue identifiers from a file (one per line)
--bulk-stdin - Read issue identifiers from stdin

"
stderr:
Expand Down Expand Up @@ -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.
"
`;
Loading
Loading