Skip to content

Commit 51ceae5

Browse files
committed
feat: add file attachment support for issues and comments
- Add `issue attach` command to attach files to issues - Add `--attach` flag on `issue comment add` - Show attachments section in `issue view` with auto-download - Add `attachment_dir` and `auto_download_attachments` config options - Add network permission for storage.googleapis.com (upload destination)
1 parent 01d92b7 commit 51ceae5

File tree

14 files changed

+737
-21
lines changed

14 files changed

+737
-21
lines changed

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,9 @@
3838
- bulk operations support for issue delete (--bulk flag) ([#95](https://github.com/schpet/linear-cli/pull/95); thanks @skgbafa)
3939
- document management commands (list, view, create, update, delete) ([#95](https://github.com/schpet/linear-cli/pull/95); thanks @skgbafa)
4040
- auto-generate skill documentation from cli help output with deno task generate-skill-docs
41+
- file attachment support for issues and comments via `issue attach` command and `--attach` flag on `issue comment add`
42+
- attachments section in `issue view` output with automatic download to local cache
43+
- `attachment_dir` and `auto_download_attachments` config options
4144

4245
## [1.7.0] - 2026-01-09
4346

deno.json

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,14 @@
55
"exports": "./src/main.ts",
66
"license": "MIT",
77
"tasks": {
8-
"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 ",
9-
"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",
8+
"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 ",
9+
"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",
1010
"uninstall": "deno uninstall -g linear",
1111
"sync-schema": "deno task dev schema -o graphql/schema.graphql",
1212
"codegen": "deno run --allow-all npm:@graphql-codegen/cli/graphql-codegen-esm",
1313
"check": "deno check src/main.ts",
14-
"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",
15-
"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",
14+
"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",
15+
"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",
1616
"lefthook-install": "deno run --allow-run --allow-read --allow-write --allow-env npm:lefthook install",
1717
"validate": "deno task check && deno fmt && deno lint",
1818
"generate-skill-docs": "deno run --allow-run --allow-read --allow-write skills/linear-cli/scripts/generate-docs.ts"

dist-workspace.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ binaries = ["linear"]
1313
build-command = [
1414
"sh",
1515
"-c",
16-
"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",
16+
"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",
1717
]
1818

1919
# Config for 'dist'

docs/deno-permissions.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ The following hosts must be allowed for full functionality:
2020
- `api.linear.app` - GraphQL API
2121
- `uploads.linear.app` - Private file uploads/downloads
2222
- `public.linear.app` - Public image downloads
23+
- `storage.googleapis.com` - File upload destination (Linear's storage backend)
2324

2425
## Files to Update
2526

src/commands/issue/issue-attach.ts

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import { Command } from "@cliffy/command"
2+
import { gql } from "../../__codegen__/gql.ts"
3+
import type { AttachmentCreateInput } from "../../__codegen__/graphql.ts"
4+
import { getGraphQLClient } from "../../utils/graphql.ts"
5+
import { getIssueId, getIssueIdentifier } from "../../utils/linear.ts"
6+
import { getNoIssueFoundMessage } from "../../utils/vcs.ts"
7+
import { uploadFile, validateFilePath } from "../../utils/upload.ts"
8+
import { basename } from "@std/path"
9+
10+
export const attachCommand = new Command()
11+
.name("attach")
12+
.description("Attach a file to an issue")
13+
.arguments("<issueId:string> <filepath:string>")
14+
.option("-t, --title <title:string>", "Custom title for the attachment")
15+
.option(
16+
"-c, --comment <body:string>",
17+
"Add a comment body linked to the attachment",
18+
)
19+
.action(async (options, issueId, filepath) => {
20+
const { title, comment } = options
21+
22+
try {
23+
const resolvedIdentifier = await getIssueIdentifier(issueId)
24+
if (!resolvedIdentifier) {
25+
console.error(getNoIssueFoundMessage())
26+
Deno.exit(1)
27+
}
28+
29+
// Validate file exists
30+
await validateFilePath(filepath)
31+
32+
// Get the issue UUID (attachmentCreate needs UUID, not identifier)
33+
const issueUuid = await getIssueId(resolvedIdentifier)
34+
if (!issueUuid) {
35+
console.error(`✗ Issue not found: ${resolvedIdentifier}`)
36+
Deno.exit(1)
37+
}
38+
39+
// Upload the file
40+
const uploadResult = await uploadFile(filepath, {
41+
showProgress: Deno.stdout.isTerminal(),
42+
})
43+
console.log(`✓ Uploaded ${uploadResult.filename}`)
44+
45+
// Create the attachment
46+
const mutation = gql(`
47+
mutation AttachmentCreate($input: AttachmentCreateInput!) {
48+
attachmentCreate(input: $input) {
49+
success
50+
attachment {
51+
id
52+
url
53+
title
54+
}
55+
}
56+
}
57+
`)
58+
59+
const client = getGraphQLClient()
60+
const attachmentTitle = title || basename(filepath)
61+
62+
const input: AttachmentCreateInput = {
63+
issueId: issueUuid,
64+
title: attachmentTitle,
65+
url: uploadResult.assetUrl,
66+
commentBody: comment,
67+
}
68+
69+
const data = await client.request(mutation, { input })
70+
71+
if (!data.attachmentCreate.success) {
72+
throw new Error("Failed to create attachment")
73+
}
74+
75+
const attachment = data.attachmentCreate.attachment
76+
console.log(`✓ Attachment created: ${attachment.title}`)
77+
console.log(attachment.url)
78+
} catch (error) {
79+
console.error("✗ Failed to attach file", error)
80+
Deno.exit(1)
81+
}
82+
})

src/commands/issue/issue-comment-add.ts

Lines changed: 61 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,25 @@ import { gql } from "../../__codegen__/gql.ts"
44
import { getGraphQLClient } from "../../utils/graphql.ts"
55
import { getIssueIdentifier } from "../../utils/linear.ts"
66
import { getNoIssueFoundMessage } from "../../utils/vcs.ts"
7+
import {
8+
formatAsMarkdownLink,
9+
uploadFile,
10+
validateFilePath,
11+
} from "../../utils/upload.ts"
712

813
export const commentAddCommand = new Command()
914
.name("add")
1015
.description("Add a comment to an issue or reply to a comment")
1116
.arguments("[issueId:string]")
1217
.option("-b, --body <text:string>", "Comment body text")
1318
.option("-p, --parent <id:string>", "Parent comment ID for replies")
19+
.option(
20+
"-a, --attach <filepath:string>",
21+
"Attach a file to the comment (can be used multiple times)",
22+
{ collect: true },
23+
)
1424
.action(async (options, issueId) => {
15-
const { body, parent } = options
25+
const { body, parent, attach } = options
1626

1727
try {
1828
const resolvedIdentifier = await getIssueIdentifier(issueId)
@@ -21,10 +31,38 @@ export const commentAddCommand = new Command()
2131
Deno.exit(1)
2232
}
2333

34+
// Validate and upload attachments first
35+
const attachments = attach || []
36+
const uploadedFiles: {
37+
filename: string
38+
assetUrl: string
39+
isImage: boolean
40+
}[] = []
41+
42+
if (attachments.length > 0) {
43+
// Validate all files exist before uploading
44+
for (const filepath of attachments) {
45+
await validateFilePath(filepath)
46+
}
47+
48+
// Upload files
49+
for (const filepath of attachments) {
50+
const result = await uploadFile(filepath, {
51+
showProgress: Deno.stdout.isTerminal(),
52+
})
53+
uploadedFiles.push({
54+
filename: result.filename,
55+
assetUrl: result.assetUrl,
56+
isImage: result.contentType.startsWith("image/"),
57+
})
58+
console.log(`✓ Uploaded ${result.filename}`)
59+
}
60+
}
61+
2462
let commentBody = body
2563

26-
// If no body provided, prompt for it
27-
if (!commentBody) {
64+
// If no body provided and no attachments, prompt for it
65+
if (!commentBody && uploadedFiles.length === 0) {
2866
commentBody = await Input.prompt({
2967
message: "Comment body",
3068
default: "",
@@ -36,6 +74,26 @@ export const commentAddCommand = new Command()
3674
}
3775
}
3876

77+
// Append attachment links to comment body
78+
if (uploadedFiles.length > 0) {
79+
const attachmentLinks = uploadedFiles.map((file) => {
80+
return formatAsMarkdownLink({
81+
filename: file.filename,
82+
assetUrl: file.assetUrl,
83+
contentType: file.isImage
84+
? "image/png"
85+
: "application/octet-stream",
86+
size: 0,
87+
})
88+
})
89+
90+
if (commentBody) {
91+
commentBody = `${commentBody}\n\n${attachmentLinks.join("\n")}`
92+
} else {
93+
commentBody = attachmentLinks.join("\n")
94+
}
95+
}
96+
3997
const mutation = gql(`
4098
mutation AddComment($input: CommentCreateInput!) {
4199
commentCreate(input: $input) {

0 commit comments

Comments
 (0)