From 51ceae5387855b0a977924af452a7fb4f56bb220 Mon Sep 17 00:00:00 2001 From: Peter Schilling Date: Wed, 14 Jan 2026 11:16:57 -0800 Subject: [PATCH] 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) --- CHANGELOG.md | 3 + deno.json | 8 +- dist-workspace.toml | 2 +- docs/deno-permissions.md | 1 + src/commands/issue/issue-attach.ts | 82 +++++ src/commands/issue/issue-comment-add.ts | 64 +++- src/commands/issue/issue-view.ts | 232 ++++++++++++- src/commands/issue/issue.ts | 2 + src/config.ts | 2 + src/utils/linear.ts | 30 ++ src/utils/upload.ts | 304 ++++++++++++++++++ .../__snapshots__/issue-describe.test.ts.snap | 2 +- .../__snapshots__/issue-view.test.ts.snap | 8 +- test/commands/issue/issue-view.test.ts | 18 ++ 14 files changed, 737 insertions(+), 21 deletions(-) create mode 100644 src/commands/issue/issue-attach.ts create mode 100644 src/utils/upload.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index a914a00..e88ac98 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/deno.json b/deno.json index de20509..0f07480 100644 --- a/deno.json +++ b/deno.json @@ -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" diff --git a/dist-workspace.toml b/dist-workspace.toml index 8d76f11..b528406 100644 --- a/dist-workspace.toml +++ b/dist-workspace.toml @@ -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' diff --git a/docs/deno-permissions.md b/docs/deno-permissions.md index 5d0c58b..3208a68 100644 --- a/docs/deno-permissions.md +++ b/docs/deno-permissions.md @@ -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 diff --git a/src/commands/issue/issue-attach.ts b/src/commands/issue/issue-attach.ts new file mode 100644 index 0000000..0ad824a --- /dev/null +++ b/src/commands/issue/issue-attach.ts @@ -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(" ") + .option("-t, --title ", "Custom title for the attachment") + .option( + "-c, --comment ", + "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) + } + }) diff --git a/src/commands/issue/issue-comment-add.ts b/src/commands/issue/issue-comment-add.ts index 47969e0..67c86e9 100644 --- a/src/commands/issue/issue-comment-add.ts +++ b/src/commands/issue/issue-comment-add.ts @@ -4,6 +4,11 @@ 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") @@ -11,8 +16,13 @@ export const commentAddCommand = new Command() .arguments("[issueId:string]") .option("-b, --body ", "Comment body text") .option("-p, --parent ", "Parent comment ID for replies") + .option( + "-a, --attach ", + "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) @@ -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: "", @@ -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) { diff --git a/src/commands/issue/issue-view.ts b/src/commands/issue/issue-view.ts index fe82890..ab83ccc 100644 --- a/src/commands/issue/issue-view.ts +++ b/src/commands/issue/issue-view.ts @@ -17,7 +17,7 @@ import { unified } from "unified" import remarkParse from "remark-parse" import remarkStringify from "remark-stringify" import { visit } from "unist-util-visit" -import type { Image, Root } from "mdast" +import type { Image, Link, Root } from "mdast" import { shouldEnableHyperlinks } from "../../utils/hyperlink.ts" import { createHyperlinkExtension } from "../../utils/charmd-hyperlink-extension.ts" @@ -63,6 +63,20 @@ export const viewCommand = new Command() ) } + // Download attachments if enabled + let attachmentPaths: Map | undefined + const shouldDownloadAttachments = shouldDownload && + getOption("auto_download_attachments") !== false + if ( + shouldDownloadAttachments && issueData.attachments && + issueData.attachments.length > 0 + ) { + attachmentPaths = await downloadAttachments( + issueData.identifier, + issueData.attachments, + ) + } + // Handle JSON output if (json) { console.log(JSON.stringify(issueData, null, 2)) @@ -133,6 +147,19 @@ export const viewCommand = new Command() outputLines.push(...renderedHierarchy.split("\n")) } + // Add attachments section + if (issueData.attachments && issueData.attachments.length > 0) { + const attachmentsMarkdown = formatAttachmentsAsMarkdown( + issueData.attachments, + attachmentPaths, + ) + const renderedAttachments = renderMarkdown(attachmentsMarkdown, { + lineWidth: terminalWidth, + extensions, + }) + outputLines.push(...renderedAttachments.split("\n")) + } + // Add comments if enabled if (showComments && issueComments && issueComments.length > 0) { outputLines.push("") // Empty line before comments @@ -160,6 +187,14 @@ export const viewCommand = new Command() issueData.children, ) + // Add attachments + if (issueData.attachments && issueData.attachments.length > 0) { + markdown += formatAttachmentsAsMarkdown( + issueData.attachments, + attachmentPaths, + ) + } + if (showComments && issueComments && issueComments.length > 0) { markdown += "\n\n## Comments\n\n" markdown += formatCommentsAsMarkdown(issueComments) @@ -393,7 +428,44 @@ export function extractImageInfo( } /** - * replace image URLs in markdown with local file paths using remark + * Link info extracted from markdown + */ +export interface LinkInfo { + url: string + text: string | null +} + +/** + * Extract link URLs from markdown content that point to Linear uploads + */ +export function extractLinearLinkInfo( + content: string | null | undefined, +): LinkInfo[] { + if (!content) return [] + + const links: LinkInfo[] = [] + + const tree = unified().use(remarkParse).parse(content) + + visit(tree, "link", (node: Link) => { + // Only extract links to Linear uploads + if ( + node.url && + (node.url.includes("uploads.linear.app") || + node.url.includes("public.linear.app")) + ) { + // Get link text from first child if it's a text node + const textNode = node.children[0] + const text = textNode && textNode.type === "text" ? textNode.value : null + links.push({ url: node.url, text }) + } + }) + + return links +} + +/** + * replace image and link URLs in markdown with local file paths using remark */ export async function replaceImageUrls( content: string, @@ -402,12 +474,20 @@ export async function replaceImageUrls( const processor = unified() .use(remarkParse) .use(() => (tree: Root) => { + // Replace image URLs visit(tree, "image", (node: Image) => { const localPath = urlToPath.get(node.url) if (localPath) { node.url = localPath } }) + // Replace link URLs + visit(tree, "link", (node: Link) => { + const localPath = urlToPath.get(node.url) + if (localPath) { + node.url = localPath + } + }) }) .use(remarkStringify) @@ -470,33 +550,49 @@ async function downloadImage( } /** - * Download all images from issue description and comments + * Download all images and linked files from issue description and comments * Returns a map of URL to local file path */ async function downloadIssueImages( description: string | null | undefined, comments?: Array<{ body: string }>, ): Promise> { - const imagesByUrl = new Map() + // Map of URL to alt text/link text (used as filename) + const filesByUrl = new Map() + // Extract images for (const img of extractImageInfo(description)) { - if (!imagesByUrl.has(img.url)) { - imagesByUrl.set(img.url, img.alt) + if (!filesByUrl.has(img.url)) { + filesByUrl.set(img.url, img.alt) + } + } + + // Extract links to Linear uploads + for (const link of extractLinearLinkInfo(description)) { + if (!filesByUrl.has(link.url)) { + filesByUrl.set(link.url, link.text) } } if (comments) { for (const comment of comments) { + // Extract images from comments for (const img of extractImageInfo(comment.body)) { - if (!imagesByUrl.has(img.url)) { - imagesByUrl.set(img.url, img.alt) + if (!filesByUrl.has(img.url)) { + filesByUrl.set(img.url, img.alt) + } + } + // Extract links to Linear uploads from comments + for (const link of extractLinearLinkInfo(comment.body)) { + if (!filesByUrl.has(link.url)) { + filesByUrl.set(link.url, link.text) } } } } const urlToPath = new Map() - for (const [url, alt] of imagesByUrl) { + for (const [url, alt] of filesByUrl) { try { const path = await downloadImage(url, alt) urlToPath.set(url, path) @@ -511,3 +607,121 @@ async function downloadIssueImages( return urlToPath } + +// Type for attachments +type AttachmentInfo = { + id: string + title: string + url: string + subtitle?: string | null + metadata: Record + createdAt: string +} + +function getAttachmentCacheDir(): string { + const configuredDir = getOption("attachment_dir") + if (configuredDir) { + return configuredDir + } + return join( + Deno.env.get("TMPDIR") || Deno.env.get("TMP") || Deno.env.get("TEMP") || + "/tmp", + "linear-cli-attachments", + ) +} + +/** + * Download attachments to cache directory + * Returns a map of attachment URL to local file path + */ +async function downloadAttachments( + issueIdentifier: string, + attachments: AttachmentInfo[], +): Promise> { + const urlToPath = new Map() + const cacheDir = getAttachmentCacheDir() + const issueDir = join(cacheDir, issueIdentifier) + await ensureDir(issueDir) + + for (const attachment of attachments) { + try { + // Skip non-file URLs (e.g., external links) + // Linear uses uploads.linear.app for private and public.linear.app for public images + const isLinearUpload = attachment.url.includes("uploads.linear.app") || + attachment.url.includes("public.linear.app") + if (!isLinearUpload) { + continue + } + + const filename = sanitize(attachment.title) + const filepath = join(issueDir, filename) + + // Check if file already exists + try { + await Deno.stat(filepath) + urlToPath.set(attachment.url, filepath) + continue + } catch { + // File doesn't exist, download it + } + + const headers: Record = {} + // Only add auth header for private uploads, not public URLs + if (attachment.url.includes("uploads.linear.app")) { + const apiKey = getResolvedApiKey() + if (apiKey) { + headers["Authorization"] = apiKey + } + } + + const response = await fetch(attachment.url, { headers }) + if (!response.ok) { + throw new Error( + `Failed to download: ${response.status} ${response.statusText}`, + ) + } + + const data = new Uint8Array(await response.arrayBuffer()) + await Deno.writeFile(filepath, data) + urlToPath.set(attachment.url, filepath) + } catch (error) { + console.error( + `Failed to download attachment "${attachment.title}": ${ + error instanceof Error ? error.message : error + }`, + ) + } + } + + return urlToPath +} + +/** + * Format attachments as markdown for display + */ +function formatAttachmentsAsMarkdown( + attachments: AttachmentInfo[], + localPaths?: Map, +): string { + if (attachments.length === 0) { + return "" + } + + let markdown = "\n\n## Attachments\n\n" + + for (const attachment of attachments) { + const localPath = localPaths?.get(attachment.url) + + if (localPath) { + markdown += `- **${attachment.title}**: ${localPath}\n` + } else { + markdown += `- **${attachment.title}**: ${attachment.url}\n` + } + + if (attachment.subtitle) { + markdown += ` _${attachment.subtitle}_\n` + } + } + + return markdown +} diff --git a/src/commands/issue/issue.ts b/src/commands/issue/issue.ts index 477a4cd..69a7cbd 100644 --- a/src/commands/issue/issue.ts +++ b/src/commands/issue/issue.ts @@ -1,4 +1,5 @@ import { Command } from "@cliffy/command" +import { attachCommand } from "./issue-attach.ts" import { commentCommand } from "./issue-comment.ts" import { createCommand } from "./issue-create.ts" import { deleteCommand } from "./issue-delete.ts" @@ -31,3 +32,4 @@ export const issueCommand = new Command() .command("create", createCommand) .command("update", updateCommand) .command("comment", commentCommand) + .command("attach", attachCommand) diff --git a/src/config.ts b/src/config.ts index 51fbce4..ce51a42 100644 --- a/src/config.ts +++ b/src/config.ts @@ -140,6 +140,8 @@ const OptionsSchema = v.object({ vcs: v.optional(v.picklist(["git", "jj"])), download_images: v.optional(BooleanLike), hyperlink_format: v.optional(v.string()), + attachment_dir: v.optional(v.string()), + auto_download_attachments: v.optional(BooleanLike), }) export type Options = v.InferOutput diff --git a/src/utils/linear.ts b/src/utils/linear.ts index 7979caa..b20b0d1 100644 --- a/src/utils/linear.ts +++ b/src/utils/linear.ts @@ -184,6 +184,14 @@ export async function fetchIssueDetails( externalUser?: { name: string; displayName: string } | null parent?: { id: string } | null }> + attachments?: Array<{ + id: string + title: string + url: string + subtitle?: string | null + metadata: Record + createdAt: string + }> }> { const { Spinner } = await import("@std/cli/unstable-spinner") const spinner = showSpinner ? new Spinner() : null @@ -237,6 +245,16 @@ export async function fetchIssueDetails( } } } + attachments(first: 50) { + nodes { + id + title + url + subtitle + metadata + createdAt + } + } } } `) @@ -271,6 +289,16 @@ export async function fetchIssueDetails( } } } + attachments(first: 50) { + nodes { + id + title + url + subtitle + metadata + createdAt + } + } } } `) @@ -284,6 +312,7 @@ export async function fetchIssueDetails( ...data.issue, children: data.issue.children?.nodes || [], comments: data.issue.comments?.nodes || [], + attachments: data.issue.attachments?.nodes || [], } } else { const data = await client.request(queryWithoutComments, { id: issueId }) @@ -291,6 +320,7 @@ export async function fetchIssueDetails( return { ...data.issue, children: data.issue.children?.nodes || [], + attachments: data.issue.attachments?.nodes || [], } } } catch (error) { diff --git a/src/utils/upload.ts b/src/utils/upload.ts new file mode 100644 index 0000000..3f64ba1 --- /dev/null +++ b/src/utils/upload.ts @@ -0,0 +1,304 @@ +import { gql } from "../__codegen__/gql.ts" +import { getGraphQLClient } from "./graphql.ts" +import { basename, extname } from "@std/path" + +/** + * MIME type mapping for common file extensions + */ +const MIME_TYPES: Record = { + // Images + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".gif": "image/gif", + ".webp": "image/webp", + ".svg": "image/svg+xml", + ".ico": "image/x-icon", + ".bmp": "image/bmp", + ".tiff": "image/tiff", + ".tif": "image/tiff", + + // Documents + ".pdf": "application/pdf", + ".doc": "application/msword", + ".docx": + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + ".xls": "application/vnd.ms-excel", + ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ".ppt": "application/vnd.ms-powerpoint", + ".pptx": + "application/vnd.openxmlformats-officedocument.presentationml.presentation", + + // Text + ".txt": "text/plain", + ".md": "text/markdown", + ".markdown": "text/markdown", + ".csv": "text/csv", + ".tsv": "text/tab-separated-values", + ".html": "text/html", + ".htm": "text/html", + ".css": "text/css", + ".xml": "text/xml", + + // Code + ".js": "text/javascript", + ".mjs": "text/javascript", + ".ts": "text/typescript", + ".tsx": "text/typescript", + ".jsx": "text/javascript", + ".json": "application/json", + ".yaml": "text/yaml", + ".yml": "text/yaml", + ".toml": "text/toml", + ".sh": "text/x-shellscript", + ".bash": "text/x-shellscript", + ".py": "text/x-python", + ".rb": "text/x-ruby", + ".go": "text/x-go", + ".rs": "text/x-rust", + ".java": "text/x-java", + ".c": "text/x-c", + ".cpp": "text/x-c++", + ".h": "text/x-c", + ".hpp": "text/x-c++", + + // Archives + ".zip": "application/zip", + ".tar": "application/x-tar", + ".gz": "application/gzip", + ".7z": "application/x-7z-compressed", + ".rar": "application/vnd.rar", + + // Audio + ".mp3": "audio/mpeg", + ".wav": "audio/wav", + ".ogg": "audio/ogg", + ".m4a": "audio/mp4", + + // Video + ".mp4": "video/mp4", + ".webm": "video/webm", + ".mov": "video/quicktime", + ".avi": "video/x-msvideo", + + // Other + ".wasm": "application/wasm", +} + +/** + * Maximum file size for uploads (100MB) + */ +const MAX_FILE_SIZE = 100 * 1024 * 1024 + +/** + * Get MIME type from file extension + */ +export function getMimeType(filepath: string): string { + const ext = extname(filepath).toLowerCase() + return MIME_TYPES[ext] || "application/octet-stream" +} + +/** + * Result of a successful file upload + */ +export interface UploadResult { + /** The permanent URL where the file is accessible */ + assetUrl: string + /** The original filename */ + filename: string + /** The file size in bytes */ + size: number + /** The MIME type of the file */ + contentType: string +} + +/** + * Options for file upload + */ +export interface UploadOptions { + /** Make the file publicly accessible (only works for images, default: auto-detect) */ + makePublic?: boolean + /** Show progress indicator */ + showProgress?: boolean +} + +/** + * Check if a file type can be uploaded as public + * Linear only allows public uploads for images (excluding SVG) + */ +function canBePublic(contentType: string): boolean { + const publicTypes = [ + "image/png", + "image/jpeg", + "image/gif", + "image/webp", + "image/bmp", + "image/tiff", + ] + return publicTypes.includes(contentType) +} + +/** + * Upload a file to Linear's cloud storage + * + * This is a two-step process: + * 1. Request a signed upload URL from Linear's GraphQL API + * 2. Upload the file directly to the signed URL + * + * @param filepath - Path to the file to upload + * @param options - Upload options + * @returns The asset URL and file metadata + */ +export async function uploadFile( + filepath: string, + options: UploadOptions = {}, +): Promise { + const { showProgress = false } = options + + // Read file and get metadata + const fileInfo = await Deno.stat(filepath) + if (!fileInfo.isFile) { + throw new Error(`Not a file: ${filepath}`) + } + + const size = fileInfo.size + if (size > MAX_FILE_SIZE) { + throw new Error( + `File too large: ${(size / 1024 / 1024).toFixed(2)}MB exceeds limit of ${ + MAX_FILE_SIZE / 1024 / 1024 + }MB`, + ) + } + + const filename = basename(filepath) + const contentType = getMimeType(filepath) + + // Step 1: Request signed upload URL + const mutation = gql(` + mutation FileUpload($contentType: String!, $filename: String!, $size: Int!, $makePublic: Boolean) { + fileUpload(contentType: $contentType, filename: $filename, size: $size, makePublic: $makePublic) { + success + uploadFile { + assetUrl + uploadUrl + headers { + key + value + } + } + } + } + `) + + const client = getGraphQLClient() + const { Spinner } = await import("@std/cli/unstable-spinner") + const spinner = showProgress + ? new Spinner({ message: `Uploading ${filename}...` }) + : null + spinner?.start() + + // Auto-detect makePublic based on file type (only images can be public) + const makePublic = options.makePublic ?? canBePublic(contentType) + + try { + const data = await client.request(mutation, { + contentType, + filename, + size, + makePublic, + }) + + if (!data.fileUpload.success || !data.fileUpload.uploadFile) { + throw new Error("Failed to get upload URL from Linear") + } + + const { assetUrl, uploadUrl, headers } = data.fileUpload.uploadFile + + // Step 2: Upload file to signed URL + const fileData = await Deno.readFile(filepath) + + // Build headers - start with Content-Type which is required by the signed URL + const uploadHeaders: Record = { + "content-type": contentType, + } + + // Add headers returned from Linear (may override content-type if provided) + for (const header of headers) { + uploadHeaders[header.key] = header.value + } + + const response = await fetch(uploadUrl, { + method: "PUT", + headers: uploadHeaders, + body: fileData, + }) + + if (!response.ok) { + const errorText = await response.text() + throw new Error( + `Failed to upload file: ${response.status} ${response.statusText} - ${errorText}`, + ) + } + + spinner?.stop() + + return { + assetUrl, + filename, + size, + contentType, + } + } catch (error) { + spinner?.stop() + throw error + } +} + +/** + * Upload multiple files to Linear's cloud storage + * + * @param filepaths - Array of file paths to upload + * @param options - Upload options + * @returns Array of upload results + */ +export async function uploadFiles( + filepaths: string[], + options: UploadOptions = {}, +): Promise { + const results: UploadResult[] = [] + + for (const filepath of filepaths) { + const result = await uploadFile(filepath, options) + results.push(result) + } + + return results +} + +/** + * Check if a file exists and is readable + */ +export async function validateFilePath(filepath: string): Promise { + try { + const info = await Deno.stat(filepath) + if (!info.isFile) { + throw new Error(`Not a file: ${filepath}`) + } + } catch (error) { + if (error instanceof Deno.errors.NotFound) { + throw new Error(`File not found: ${filepath}`) + } + throw error + } +} + +/** + * Format an uploaded file as a markdown link + */ +export function formatAsMarkdownLink(result: UploadResult): string { + const isImage = result.contentType.startsWith("image/") + if (isImage) { + return `![${result.filename}](${result.assetUrl})` + } + return `[${result.filename}](${result.assetUrl})` +} diff --git a/test/commands/issue/__snapshots__/issue-describe.test.ts.snap b/test/commands/issue/__snapshots__/issue-describe.test.ts.snap index 6f6bffb..9a4e9e2 100644 --- a/test/commands/issue/__snapshots__/issue-describe.test.ts.snap +++ b/test/commands/issue/__snapshots__/issue-describe.test.ts.snap @@ -43,7 +43,7 @@ stderr: snapshot[`Issue Describe Command - Issue Not Found 1`] = ` stdout: -'Error: Issue not found: TEST-999: {"response":{"errors":[{"message":"Issue not found: TEST-999","extensions":{"code":"NOT_FOUND"}}],"status":200,"headers":{}},"request":{"query":"query GetIssueDetails(\$id: String!) {\\\\n issue(id: \$id) {\\\\n identifier\\\\n title\\\\n description\\\\n url\\\\n branchName\\\\n state {\\\\n name\\\\n color\\\\n }\\\\n parent {\\\\n identifier\\\\n title\\\\n state {\\\\n name\\\\n color\\\\n }\\\\n }\\\\n children {\\\\n nodes {\\\\n identifier\\\\n title\\\\n state {\\\\n name\\\\n color\\\\n }\\\\n }\\\\n }\\\\n }\\\\n}","variables":{"id":"TEST-999"}}} +'Error: Issue not found: TEST-999: {"response":{"errors":[{"message":"Issue not found: TEST-999","extensions":{"code":"NOT_FOUND"}}],"status":200,"headers":{}},"request":{"query":"query GetIssueDetails(\$id: String!) {\\\\n issue(id: \$id) {\\\\n identifier\\\\n title\\\\n description\\\\n url\\\\n branchName\\\\n state {\\\\n name\\\\n color\\\\n }\\\\n parent {\\\\n identifier\\\\n title\\\\n state {\\\\n name\\\\n color\\\\n }\\\\n }\\\\n children {\\\\n nodes {\\\\n identifier\\\\n title\\\\n state {\\\\n name\\\\n color\\\\n }\\\\n }\\\\n }\\\\n attachments(first: 50) {\\\\n nodes {\\\\n id\\\\n title\\\\n url\\\\n subtitle\\\\n metadata\\\\n createdAt\\\\n }\\\\n }\\\\n }\\\\n}","variables":{"id":"TEST-999"}}} ' stderr: "✗ Failed to fetch issue details diff --git a/test/commands/issue/__snapshots__/issue-view.test.ts.snap b/test/commands/issue/__snapshots__/issue-view.test.ts.snap index e997649..46c87bc 100644 --- a/test/commands/issue/__snapshots__/issue-view.test.ts.snap +++ b/test/commands/issue/__snapshots__/issue-view.test.ts.snap @@ -97,7 +97,7 @@ stderr: snapshot[`Issue View Command - Issue Not Found 1`] = ` stdout: -'Error: Issue not found: TEST-999: {"response":{"errors":[{"message":"Issue not found: TEST-999","extensions":{"code":"NOT_FOUND"}}],"status":200,"headers":{}},"request":{"query":"query GetIssueDetailsWithComments(\$id: String!) {\\\\n issue(id: \$id) {\\\\n identifier\\\\n title\\\\n description\\\\n url\\\\n branchName\\\\n state {\\\\n name\\\\n color\\\\n }\\\\n parent {\\\\n identifier\\\\n title\\\\n state {\\\\n name\\\\n color\\\\n }\\\\n }\\\\n children {\\\\n nodes {\\\\n identifier\\\\n title\\\\n state {\\\\n name\\\\n color\\\\n }\\\\n }\\\\n }\\\\n comments(first: 50, orderBy: createdAt) {\\\\n nodes {\\\\n id\\\\n body\\\\n createdAt\\\\n user {\\\\n name\\\\n displayName\\\\n }\\\\n externalUser {\\\\n name\\\\n displayName\\\\n }\\\\n parent {\\\\n id\\\\n }\\\\n }\\\\n }\\\\n }\\\\n}","variables":{"id":"TEST-999"}}} +'Error: Issue not found: TEST-999: {"response":{"errors":[{"message":"Issue not found: TEST-999","extensions":{"code":"NOT_FOUND"}}],"status":200,"headers":{}},"request":{"query":"query GetIssueDetailsWithComments(\$id: String!) {\\\\n issue(id: \$id) {\\\\n identifier\\\\n title\\\\n description\\\\n url\\\\n branchName\\\\n state {\\\\n name\\\\n color\\\\n }\\\\n parent {\\\\n identifier\\\\n title\\\\n state {\\\\n name\\\\n color\\\\n }\\\\n }\\\\n children {\\\\n nodes {\\\\n identifier\\\\n title\\\\n state {\\\\n name\\\\n color\\\\n }\\\\n }\\\\n }\\\\n comments(first: 50, orderBy: createdAt) {\\\\n nodes {\\\\n id\\\\n body\\\\n createdAt\\\\n user {\\\\n name\\\\n displayName\\\\n }\\\\n externalUser {\\\\n name\\\\n displayName\\\\n }\\\\n parent {\\\\n id\\\\n }\\\\n }\\\\n }\\\\n attachments(first: 50) {\\\\n nodes {\\\\n id\\\\n title\\\\n url\\\\n subtitle\\\\n metadata\\\\n createdAt\\\\n }\\\\n }\\\\n }\\\\n}","variables":{"id":"TEST-999"}}} ' stderr: "✗ Failed to fetch issue details @@ -117,7 +117,8 @@ stdout: "color": "#f87462" }, "parent": null, - "children": [] + "children": [], + "attachments": [] } ' stderr: @@ -163,7 +164,8 @@ stdout: "id": "comment-1" } } - ] + ], + "attachments": [] } \` stderr: diff --git a/test/commands/issue/issue-view.test.ts b/test/commands/issue/issue-view.test.ts index 07999b0..3d03f1d 100644 --- a/test/commands/issue/issue-view.test.ts +++ b/test/commands/issue/issue-view.test.ts @@ -96,6 +96,9 @@ await snapshotTest({ comments: { nodes: [], }, + attachments: { + nodes: [], + }, }, }, }, @@ -146,6 +149,9 @@ await snapshotTest({ children: { nodes: [], }, + attachments: { + nodes: [], + }, }, }, }, @@ -252,6 +258,9 @@ await snapshotTest({ }, ], }, + attachments: { + nodes: [], + }, }, }, }, @@ -341,6 +350,9 @@ await snapshotTest({ children: { nodes: [], }, + attachments: { + nodes: [], + }, }, }, }, @@ -421,6 +433,9 @@ await snapshotTest({ }, ], }, + attachments: { + nodes: [], + }, }, }, }, @@ -502,6 +517,9 @@ await snapshotTest({ }, ], }, + attachments: { + nodes: [], + }, }, }, },