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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@
- bulk operations support for issue delete (--bulk flag) ([#95](https://github.com/schpet/linear-cli/pull/95); thanks @skgbafa)
- document management commands (list, view, create, update, delete) ([#95](https://github.com/schpet/linear-cli/pull/95); thanks @skgbafa)
- auto-generate skill documentation from cli help output with deno task generate-skill-docs
- file attachment support for issues and comments via `issue attach` command and `--attach` flag on `issue comment add`
- attachments section in `issue view` output with automatic download to local cache
- `attachment_dir` and `auto_download_attachments` config options

## [1.7.0] - 2026-01-09

Expand Down
8 changes: 4 additions & 4 deletions deno.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,14 @@
"exports": "./src/main.ts",
"license": "MIT",
"tasks": {
"dev": "deno task codegen && deno run '--allow-env=GITHUB_*,GH_*,LINEAR_*,NODE_ENV,EDITOR,PAGER,NO_COLOR,TMPDIR,TMP,TEMP,XDG_CONFIG_HOME,HOME,APPDATA' --allow-read --allow-write --allow-run --allow-net=api.linear.app,uploads.linear.app,public.linear.app --allow-sys=hostname --quiet src/main.ts ",
"install": "deno task codegen && deno install -c ./deno.json '--allow-env=GITHUB_*,GH_*,LINEAR_*,NODE_ENV,EDITOR,PAGER,NO_COLOR,TMPDIR,TMP,TEMP,XDG_CONFIG_HOME,HOME,APPDATA' --allow-read --allow-write --allow-run --allow-net=api.linear.app,uploads.linear.app,public.linear.app --allow-sys=hostname --quiet -g -f -n linear ./src/main.ts",
"dev": "deno task codegen && deno run '--allow-env=GITHUB_*,GH_*,LINEAR_*,NODE_ENV,EDITOR,PAGER,NO_COLOR,TMPDIR,TMP,TEMP,XDG_CONFIG_HOME,HOME,APPDATA' --allow-read --allow-write --allow-run --allow-net=api.linear.app,uploads.linear.app,public.linear.app,storage.googleapis.com --allow-sys=hostname --quiet src/main.ts ",
"install": "deno task codegen && deno install -c ./deno.json '--allow-env=GITHUB_*,GH_*,LINEAR_*,NODE_ENV,EDITOR,PAGER,NO_COLOR,TMPDIR,TMP,TEMP,XDG_CONFIG_HOME,HOME,APPDATA' --allow-read --allow-write --allow-run --allow-net=api.linear.app,uploads.linear.app,public.linear.app,storage.googleapis.com --allow-sys=hostname --quiet -g -f -n linear ./src/main.ts",
"uninstall": "deno uninstall -g linear",
"sync-schema": "deno task dev schema -o graphql/schema.graphql",
"codegen": "deno run --allow-all npm:@graphql-codegen/cli/graphql-codegen-esm",
"check": "deno check src/main.ts",
"test": "deno test '--allow-env=GITHUB_*,GH_*,LINEAR_*,NODE_ENV,EDITOR,PAGER,SNAPSHOT_TEST_NAME,CLIFFY_SNAPSHOT_FAKE_TIME,NO_COLOR,TMPDIR,TMP,TEMP,XDG_CONFIG_HOME,HOME,APPDATA,PATH,SystemRoot' --allow-read --allow-write --allow-run --allow-net=api.linear.app,uploads.linear.app,public.linear.app --allow-sys=hostname --quiet",
"snapshot": "deno test '--allow-env=GITHUB_*,GH_*,LINEAR_*,NODE_ENV,EDITOR,PAGER,SNAPSHOT_TEST_NAME,CLIFFY_SNAPSHOT_FAKE_TIME,NO_COLOR,TMPDIR,TMP,TEMP,XDG_CONFIG_HOME,HOME,APPDATA,PATH,SystemRoot' --allow-read --allow-write --allow-run --allow-net=api.linear.app,uploads.linear.app,public.linear.app --allow-sys=hostname -- --update",
"test": "deno test '--allow-env=GITHUB_*,GH_*,LINEAR_*,NODE_ENV,EDITOR,PAGER,SNAPSHOT_TEST_NAME,CLIFFY_SNAPSHOT_FAKE_TIME,NO_COLOR,TMPDIR,TMP,TEMP,XDG_CONFIG_HOME,HOME,APPDATA,PATH,SystemRoot' --allow-read --allow-write --allow-run --allow-net=api.linear.app,uploads.linear.app,public.linear.app,storage.googleapis.com --allow-sys=hostname --quiet",
"snapshot": "deno test '--allow-env=GITHUB_*,GH_*,LINEAR_*,NODE_ENV,EDITOR,PAGER,SNAPSHOT_TEST_NAME,CLIFFY_SNAPSHOT_FAKE_TIME,NO_COLOR,TMPDIR,TMP,TEMP,XDG_CONFIG_HOME,HOME,APPDATA,PATH,SystemRoot' --allow-read --allow-write --allow-run --allow-net=api.linear.app,uploads.linear.app,public.linear.app,storage.googleapis.com --allow-sys=hostname -- --update",
"lefthook-install": "deno run --allow-run --allow-read --allow-write --allow-env npm:lefthook install",
"validate": "deno task check && deno fmt && deno lint",
"generate-skill-docs": "deno run --allow-run --allow-read --allow-write skills/linear-cli/scripts/generate-docs.ts"
Expand Down
2 changes: 1 addition & 1 deletion dist-workspace.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ binaries = ["linear"]
build-command = [
"sh",
"-c",
"deno compile --target=$CARGO_DIST_TARGET -o linear '--allow-env=GITHUB_*,GH_*,LINEAR_*,NODE_ENV,EDITOR,PAGER,NO_COLOR,TMPDIR,TMP,TEMP,XDG_CONFIG_HOME,HOME,APPDATA' --allow-read --allow-write --allow-run --allow-net=api.linear.app,uploads.linear.app,public.linear.app --allow-sys=hostname --quiet src/main.ts",
"deno compile --target=$CARGO_DIST_TARGET -o linear '--allow-env=GITHUB_*,GH_*,LINEAR_*,NODE_ENV,EDITOR,PAGER,NO_COLOR,TMPDIR,TMP,TEMP,XDG_CONFIG_HOME,HOME,APPDATA' --allow-read --allow-write --allow-run --allow-net=api.linear.app,uploads.linear.app,public.linear.app,storage.googleapis.com --allow-sys=hostname --quiet src/main.ts",
]

# Config for 'dist'
Expand Down
1 change: 1 addition & 0 deletions docs/deno-permissions.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ The following hosts must be allowed for full functionality:
- `api.linear.app` - GraphQL API
- `uploads.linear.app` - Private file uploads/downloads
- `public.linear.app` - Public image downloads
- `storage.googleapis.com` - File upload destination (Linear's storage backend)

## Files to Update

Expand Down
82 changes: 82 additions & 0 deletions src/commands/issue/issue-attach.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { Command } from "@cliffy/command"
import { gql } from "../../__codegen__/gql.ts"
import type { AttachmentCreateInput } from "../../__codegen__/graphql.ts"
import { getGraphQLClient } from "../../utils/graphql.ts"
import { getIssueId, getIssueIdentifier } from "../../utils/linear.ts"
import { getNoIssueFoundMessage } from "../../utils/vcs.ts"
import { uploadFile, validateFilePath } from "../../utils/upload.ts"
import { basename } from "@std/path"

export const attachCommand = new Command()
.name("attach")
.description("Attach a file to an issue")
.arguments("<issueId:string> <filepath:string>")
.option("-t, --title <title:string>", "Custom title for the attachment")
.option(
"-c, --comment <body:string>",
"Add a comment body linked to the attachment",
)
.action(async (options, issueId, filepath) => {
const { title, comment } = options

try {
const resolvedIdentifier = await getIssueIdentifier(issueId)
if (!resolvedIdentifier) {
console.error(getNoIssueFoundMessage())
Deno.exit(1)
}

// Validate file exists
await validateFilePath(filepath)

// Get the issue UUID (attachmentCreate needs UUID, not identifier)
const issueUuid = await getIssueId(resolvedIdentifier)
if (!issueUuid) {
console.error(`✗ Issue not found: ${resolvedIdentifier}`)
Deno.exit(1)
}

// Upload the file
const uploadResult = await uploadFile(filepath, {
showProgress: Deno.stdout.isTerminal(),
})
console.log(`✓ Uploaded ${uploadResult.filename}`)

// Create the attachment
const mutation = gql(`
mutation AttachmentCreate($input: AttachmentCreateInput!) {
attachmentCreate(input: $input) {
success
attachment {
id
url
title
}
}
}
`)

const client = getGraphQLClient()
const attachmentTitle = title || basename(filepath)

const input: AttachmentCreateInput = {
issueId: issueUuid,
title: attachmentTitle,
url: uploadResult.assetUrl,
commentBody: comment,
}

const data = await client.request(mutation, { input })

if (!data.attachmentCreate.success) {
throw new Error("Failed to create attachment")
}

const attachment = data.attachmentCreate.attachment
console.log(`✓ Attachment created: ${attachment.title}`)
console.log(attachment.url)
} catch (error) {
console.error("✗ Failed to attach file", error)
Deno.exit(1)
}
})
64 changes: 61 additions & 3 deletions src/commands/issue/issue-comment-add.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,25 @@ import { gql } from "../../__codegen__/gql.ts"
import { getGraphQLClient } from "../../utils/graphql.ts"
import { getIssueIdentifier } from "../../utils/linear.ts"
import { getNoIssueFoundMessage } from "../../utils/vcs.ts"
import {
formatAsMarkdownLink,
uploadFile,
validateFilePath,
} from "../../utils/upload.ts"

export const commentAddCommand = new Command()
.name("add")
.description("Add a comment to an issue or reply to a comment")
.arguments("[issueId:string]")
.option("-b, --body <text:string>", "Comment body text")
.option("-p, --parent <id:string>", "Parent comment ID for replies")
.option(
"-a, --attach <filepath:string>",
"Attach a file to the comment (can be used multiple times)",
{ collect: true },
)
.action(async (options, issueId) => {
const { body, parent } = options
const { body, parent, attach } = options

try {
const resolvedIdentifier = await getIssueIdentifier(issueId)
Expand All @@ -21,10 +31,38 @@ export const commentAddCommand = new Command()
Deno.exit(1)
}

// Validate and upload attachments first
const attachments = attach || []
const uploadedFiles: {
filename: string
assetUrl: string
isImage: boolean
}[] = []

if (attachments.length > 0) {
// Validate all files exist before uploading
for (const filepath of attachments) {
await validateFilePath(filepath)
}

// Upload files
for (const filepath of attachments) {
const result = await uploadFile(filepath, {
showProgress: Deno.stdout.isTerminal(),
})
uploadedFiles.push({
filename: result.filename,
assetUrl: result.assetUrl,
isImage: result.contentType.startsWith("image/"),
})
console.log(`✓ Uploaded ${result.filename}`)
}
}

let commentBody = body

// If no body provided, prompt for it
if (!commentBody) {
// If no body provided and no attachments, prompt for it
if (!commentBody && uploadedFiles.length === 0) {
commentBody = await Input.prompt({
message: "Comment body",
default: "",
Expand All @@ -36,6 +74,26 @@ export const commentAddCommand = new Command()
}
}

// Append attachment links to comment body
if (uploadedFiles.length > 0) {
const attachmentLinks = uploadedFiles.map((file) => {
return formatAsMarkdownLink({
filename: file.filename,
assetUrl: file.assetUrl,
contentType: file.isImage
? "image/png"
: "application/octet-stream",
size: 0,
})
})

if (commentBody) {
commentBody = `${commentBody}\n\n${attachmentLinks.join("\n")}`
} else {
commentBody = attachmentLinks.join("\n")
}
}

const mutation = gql(`
mutation AddComment($input: CommentCreateInput!) {
commentCreate(input: $input) {
Expand Down
Loading