diff --git a/CHANGELOG.md b/CHANGELOG.md index 72465b1..21b45d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,9 @@ - global user config is now merged with project config (`~/.config/linear/linear.toml` on Unix, `%APPDATA%\linear\linear.toml` on Windows); project values override global, env vars override both - requests now include a User-Agent header (schpet-linear-cli/VERSION) +- 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/CLAUDE.md b/CLAUDE.md index 2a049b3..6e0baa5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,6 +9,12 @@ - import: use dynamic import only when necessary, the static form is preferable - avoid the typescript `any` type - prefer strict typing, if you can't find a good way to fix a type issue (particularly with graphql data or documents) explain the problem instead of working around it +## permissions + +- deno permissions (--allow-env, --allow-net, etc.) are configured in multiple files that must stay in sync +- see [docs/deno-permissions.md](docs/deno-permissions.md) for the full list of files to update when adding new permissions +- key files: `deno.json` (tasks), `dist-workspace.toml` (release builds), test files + ## tests - tests on commands should mirror the directory structure of the src, e.g. diff --git a/deno.json b/deno.json index 4a3725c..2ddb819 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 --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 --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 --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", "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 --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 --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 --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", "lefthook-install": "deno run --allow-run --allow-read --allow-write --allow-env npm:lefthook install" }, "imports": { diff --git a/dist-workspace.toml b/dist-workspace.toml index ec8133e..53dce9f 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' --allow-read --allow-write --allow-run --allow-net=api.linear.app --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' --allow-read --allow-write --allow-run --allow-net=api.linear.app,uploads.linear.app,public.linear.app --quiet src/main.ts", ] # Config for 'dist' diff --git a/docs/deno-permissions.md b/docs/deno-permissions.md new file mode 100644 index 0000000..5d0c58b --- /dev/null +++ b/docs/deno-permissions.md @@ -0,0 +1,90 @@ +# Deno Permissions Configuration + +This document tracks all locations where Deno permission flags are configured. When adding new permissions (e.g., new network hosts, environment variables), **all files must be updated** to stay in sync. + +## Permission Types + +| Permission | Purpose | +| --------------- | ------------------------------------------------------------- | +| `--allow-env` | Environment variable access (API keys, config, editor, pager) | +| `--allow-net` | Network access to Linear API and file storage | +| `--allow-read` | File system read access | +| `--allow-write` | File system write access | +| `--allow-run` | Execute subprocesses (git, jj, editor, pager) | +| `--allow-sys` | System info access (hostname) | + +## Network Hosts + +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 + +## Files to Update + +### Primary Configuration + +These files define permissions for production use: + +| File | Lines | Purpose | +| --------------------- | ---------- | ------------------------------------------ | +| `deno.json` | 8-9, 14-15 | `dev`, `install`, `test`, `snapshot` tasks | +| `dist-workspace.toml` | 16 | Binary compilation for releases | + +### Test Configuration + +Test files define their own `denoArgs` arrays. Most use a shared helper: + +| File | Lines | Notes | +| -------------------------------------------- | ----- | ----------------------------------------------- | +| `test/utils/test-helpers.ts` | 5-9 | Shared `commonDenoArgs` used by milestone tests | +| `test/commands/issue/issue-view.test.ts` | 10-14 | Local `denoArgs` | +| `test/commands/issue/issue-describe.test.ts` | 7-11 | Local `denoArgs` | +| `test/commands/issue/issue-commits.test.ts` | 6-10 | Local `denoArgs` | +| `test/commands/team/team-list.test.ts` | 8-12 | Local `denoArgs` | +| `test/commands/project/project-view.test.ts` | 7-11 | Local `denoArgs` | + +Note: Test files often use broader permissions (e.g., `--allow-net` without host restrictions) since they run against mock servers. + +## Environment Variables + +### Runtime Variables + +Used by the CLI during normal operation: + +``` +GITHUB_*, GH_* - GitHub integration +LINEAR_* - Linear API key and config +NODE_ENV - Environment detection +EDITOR - Text editor for descriptions +PAGER - Pager for long output +NO_COLOR - Disable color output +TMPDIR, TMP, TEMP - Temp directory for downloads +XDG_CONFIG_HOME - Config file location (Linux) +HOME - Home directory +APPDATA - Config file location (Windows) +``` + +### Test-Only Variables + +Additional variables needed for tests: + +``` +SNAPSHOT_TEST_NAME - Cliffy snapshot testing +CLIFFY_SNAPSHOT_FAKE_TIME - Time mocking in tests +PATH - Process execution +SystemRoot - Windows compatibility +MOCK_GIT_BRANCH_COMMAND - Git mocking in tests +TEST_CURRENT_TIME - Time mocking +``` + +## Checklist for Adding Permissions + +When adding a new permission: + +1. [ ] Update `deno.json` tasks: `dev`, `install`, `test`, `snapshot` +2. [ ] Update `dist-workspace.toml` build command +3. [ ] Update `test/utils/test-helpers.ts` if tests need it +4. [ ] Update individual test files if they have local `denoArgs` +5. [ ] Document the new permission in this file 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 328c439..eb5c68d 100644 --- a/src/commands/issue/issue-view.ts +++ b/src/commands/issue/issue-view.ts @@ -16,7 +16,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" @@ -62,6 +62,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)) @@ -132,6 +146,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 @@ -159,6 +186,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) @@ -392,7 +427,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, @@ -401,12 +473,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) @@ -469,33 +549,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) @@ -510,3 +606,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 = getOption("api_key") + 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 75f9400..ef19bb3 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 afd9ade..652b015 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: [], + }, }, }, }, diff --git a/test/commands/milestone/__snapshots__/milestone-view.test.ts.snap b/test/commands/milestone/__snapshots__/milestone-view.test.ts.snap index 4ca337b..3b203f7 100644 --- a/test/commands/milestone/__snapshots__/milestone-view.test.ts.snap +++ b/test/commands/milestone/__snapshots__/milestone-view.test.ts.snap @@ -60,8 +60,8 @@ stdout: **Project:** Test Project (test-proj) **Project URL:** https://linear.app/test/project/test-proj -**Created:** 1 minute ago -**Updated:** 1 minute ago +**Created:** 4 days ago +**Updated:** 4 days ago _No issues in this milestone yet._ " @@ -78,7 +78,7 @@ stdout: **Project:** Product Team (product) **Project URL:** https://linear.app/test/project/product -**Created:** 4 days ago +**Created:** 1/5/2026 **Updated:** 1 minute ago ## Description