diff --git a/scripts/src/gh.ts b/scripts/src/gh.ts index abeddb27..cdd93667 100644 --- a/scripts/src/gh.ts +++ b/scripts/src/gh.ts @@ -1,6 +1,7 @@ import { createHash } from "node:crypto"; import { execFileSync } from "node:child_process"; -import { existsSync, readFileSync } from "node:fs"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import path from "node:path"; import { ApplyPatchV2Error, deriveUpdatedContent, parseApplyPatchV2, type PatchHunk } from "./apply-patch-v2"; import { coreInternalFetch } from "./microservices"; @@ -9,6 +10,7 @@ const GITHUB_API = (process.env.UNIDESK_GITHUB_API_URL ?? "https://api.github.co const USER_AGENT = "unidesk-cli-gh"; const PREVIEW_CHARS = 240; const REQUEST_TIMEOUT_MS = 20_000; +const MAX_ISSUE_ATTACHMENT_DOWNLOAD_BYTES = 25 * 1024 * 1024; const MIN_SAFE_ISSUE_BODY_CHARS = 20; const MAX_INLINE_ISSUE_COMMENT_BODY_CHARS = 1000; const GITHUB_REST_PAGE_SIZE = 100; @@ -60,6 +62,7 @@ const GH_VALUE_OPTIONS = new Set([ "--value", "--section", "--to", "--status", "--row-file", "--category", "--branch", "--tasks", "--summary", "--focus", "--validation", "--progress", "--number", "--pr", "--search", "--inactive-hours", "--comment", "--comment-file", "--description", + "--attachment", "--output", ]); const GH_FLAG_OPTIONS = new Set(["--dry-run", "--draft", "--notify-claudeqq-brief-diff", "--allow-short-body", "--body-stdin", "--body-patch-stdin", "--comment-stdin", "--raw", "--full", "--stat", "--merge", "--squash", "--rebase", "--delete-branch", "--private", "--public", "--auto-init"]); const MIN_SAFE_BODY_SCAN_CHARS = MIN_SAFE_ISSUE_BODY_CHARS; @@ -382,6 +385,8 @@ interface GitHubOptions { boardRowUpsertValues: BoardRowUpsertValues; mergeMethod: PullRequestMergeMethod; deleteBranch: boolean; + attachmentSelector?: string; + outputPath?: string; } interface GitHubShorthandReference { @@ -405,6 +410,29 @@ interface IssueProfileValidationContext { issueMetadataFetched?: boolean; } +interface GitHubIssueAttachment { + index: number; + url: string; + assetId: string | null; + source: "issue-body" | "comment"; + sourceUrl: string; + commentId?: number; + author?: string | null; + createdAt?: string | null; + updatedAt?: string | null; + marker: "markdown-image" | "html-img" | "plain-url"; + alt?: string | null; + width?: string | null; + height?: string | null; +} + +interface GitHubAttachmentDownload { + buffer: Buffer; + contentType: string | null; + contentLengthHeader: string | null; + finalUrl: string; +} + interface GitHubErrorPayload { ok: false; degradedReason: GitHubDegradedReason; @@ -722,6 +750,7 @@ function allowsNumberTargetAlias(top: string | undefined, sub: string | undefine if (top === "issue") { if (sub === "read" || sub === "view" || sub === "edit" || sub === "update" || sub === "patch" || sub === "close" || sub === "reopen" || sub === "delete") return true; if (sub === "comment") return true; + if (sub === "attachment" && (third === "list" || third === "download")) return true; if (sub === "board-row" && ["get", "update", "add", "move", "delete", "upsert"].includes(third ?? "")) return true; return false; } @@ -933,6 +962,8 @@ function parseOptions(args: string[]): GitHubOptions { boardRowUpsertValues: parseBoardRowUpsertValues(args), mergeMethod: parsePullRequestMergeMethod(args), deleteBranch: hasFlag(args, "--delete-branch"), + attachmentSelector: optionValue(args, "--attachment"), + outputPath: optionValue(args, "--output"), }; } @@ -1278,7 +1309,7 @@ async function withNumberOptionHint(result: GitHubCommandResult | Promise"')]+)?/giu; + +function normalizedAttachmentUrl(raw: string): string | null { + try { + const url = new URL(raw); + if (url.protocol !== "https:" || url.hostname !== "github.com" || !url.pathname.startsWith("/user-attachments/assets/")) return null; + return url.toString(); + } catch { + return null; + } +} + +function redactedUrl(raw: string): string { + try { + const url = new URL(raw); + url.search = ""; + url.hash = ""; + return url.toString(); + } catch { + return raw.split("?")[0] ?? raw; + } +} + +function shouldSendGitHubWebAuth(raw: string): boolean { + try { + return new URL(raw).hostname === "github.com"; + } catch { + return false; + } +} + +function decodeHtmlUrl(raw: string): string { + return raw + .replace(/&/giu, "&") + .replace(///giu, "/") + .replace(/=/giu, "="); +} + +function attachmentAssetId(url: string): string | null { + try { + const parsed = new URL(url); + return parsed.pathname.split("/").filter(Boolean).at(-1) ?? null; + } catch { + return null; + } +} + +function htmlAttribute(tag: string, name: string): string | null { + const match = new RegExp(`\\b${name}\\s*=\\s*(\"([^\"]*)\"|'([^']*)'|([^\\s>]+))`, "iu").exec(tag); + return match?.[2] ?? match?.[3] ?? match?.[4] ?? null; +} + +function extractIssueAttachmentsFromBody(body: string | null | undefined, source: Omit): Array> { + const text = body ?? ""; + const attachments: Array> = []; + const seen = new Set(); + const add = (rawUrl: string, marker: GitHubIssueAttachment["marker"], details: Partial = {}) => { + const url = normalizedAttachmentUrl(rawUrl); + if (url === null || seen.has(url)) return; + seen.add(url); + attachments.push({ + ...source, + marker, + url, + assetId: attachmentAssetId(url), + alt: details.alt ?? null, + width: details.width ?? null, + height: details.height ?? null, + }); + }; + + for (const match of text.matchAll(/!\[([^\]]*)\]\((https:\/\/github\.com\/user-attachments\/assets\/[A-Za-z0-9._-]+(?:\?[^\s)"']+)?)\)/giu)) { + add(match[2] ?? "", "markdown-image", { alt: match[1] ?? null }); + } + for (const match of text.matchAll(/]*>/giu)) { + const tag = match[0] ?? ""; + const src = htmlAttribute(tag, "src"); + if (src === null) continue; + add(src, "html-img", { + alt: htmlAttribute(tag, "alt"), + width: htmlAttribute(tag, "width"), + height: htmlAttribute(tag, "height"), + }); + } + for (const match of text.matchAll(GITHUB_USER_ATTACHMENT_URL_PATTERN)) add(match[0] ?? "", "plain-url"); + return attachments; +} + +async function collectIssueAttachments(repo: string, token: string, issueNumber: number, commandName: string): Promise<{ issue: GitHubIssue; comments: GitHubComment[]; attachments: GitHubIssueAttachment[] } | GitHubCommandFailure> { + const issue = await getIssue(token, repo, issueNumber); + if (isGitHubError(issue)) return commandError(commandName, repo, issue, { issueNumber }); + const comments = await listIssueComments(token, repo, issueNumber); + if (isGitHubError(comments)) return commandError(commandName, repo, comments, { issueNumber, issue: issueSummary(issue) }); + const dedupe = new Set(); + const attachments: GitHubIssueAttachment[] = []; + const append = (items: Array>) => { + for (const item of items) { + if (dedupe.has(item.url)) continue; + dedupe.add(item.url); + attachments.push({ ...item, index: attachments.length + 1 }); + } + }; + append(extractIssueAttachmentsFromBody(issue.body, { + source: "issue-body", + sourceUrl: issue.html_url, + author: issue.user?.login ?? null, + createdAt: issue.created_at ?? null, + updatedAt: issue.updated_at ?? null, + })); + for (const comment of comments) { + append(extractIssueAttachmentsFromBody(comment.body, { + source: "comment", + sourceUrl: comment.html_url, + commentId: comment.id, + author: comment.user?.login ?? null, + createdAt: comment.created_at ?? null, + updatedAt: comment.updated_at ?? null, + })); + } + return { issue, comments, attachments }; +} + +function issueAttachmentSummary(attachment: GitHubIssueAttachment): Record { + return { + index: attachment.index, + url: attachment.url, + assetId: attachment.assetId, + source: attachment.source, + sourceUrl: attachment.sourceUrl, + commentId: attachment.commentId ?? null, + marker: attachment.marker, + alt: attachment.alt ?? null, + width: attachment.width ?? null, + height: attachment.height ?? null, + author: attachment.author ?? null, + createdAt: attachment.createdAt ?? null, + updatedAt: attachment.updatedAt ?? null, + }; +} + +async function issueAttachmentList(repo: string, token: string, issueNumber: number): Promise { + const commandName = "issue attachment list"; + const collected = await collectIssueAttachments(repo, token, issueNumber, commandName); + if (isGitHubCommandResult(collected)) return collected; + return { + ok: true, + command: commandName, + repo, + readOnly: true, + issue: issueSummary(collected.issue), + commentsScanned: collected.comments.length, + count: collected.attachments.length, + attachments: collected.attachments.map(issueAttachmentSummary), + next: collected.attachments.length > 0 + ? { + command: `bun scripts/cli.ts gh issue attachment download ${issueNumber} --repo ${repo} --attachment 1 --output /tmp/gh-issue-${issueNumber}-attachment-1`, + } + : null, + request: { + method: "GET", + paths: [ + `/repos/{owner}/{repo}/issues/${issueNumber}`, + `/repos/{owner}/{repo}/issues/${issueNumber}/comments`, + ], + }, + rest: true, + }; +} + +function selectIssueAttachment(attachments: GitHubIssueAttachment[], selector: string | undefined): GitHubIssueAttachment | null { + if (attachments.length === 0) return null; + if (selector === undefined) return attachments[0] ?? null; + const trimmed = selector.trim(); + if (/^\d+$/u.test(trimmed)) { + const index = Number(trimmed); + return attachments.find((attachment) => attachment.index === index) ?? null; + } + const url = normalizedAttachmentUrl(trimmed); + if (url !== null) return attachments.find((attachment) => attachment.url === url) ?? null; + return attachments.find((attachment) => attachment.assetId === trimmed) ?? null; +} + +function attachmentExtension(contentType: string | null): string { + const normalized = (contentType ?? "").split(";")[0]?.trim().toLowerCase(); + if (normalized === "image/png") return ".png"; + if (normalized === "image/jpeg" || normalized === "image/jpg") return ".jpg"; + if (normalized === "image/gif") return ".gif"; + if (normalized === "image/webp") return ".webp"; + if (normalized === "image/svg+xml") return ".svg"; + return ".bin"; +} + +function defaultAttachmentOutputPath(repo: string, issueNumber: number, attachment: GitHubIssueAttachment, contentType: string | null): string { + const safeRepo = repo.replace(/[^A-Za-z0-9._-]+/gu, "_"); + return path.join("/tmp/unidesk-gh-attachments", `${safeRepo}-issue-${issueNumber}-attachment-${attachment.index}${attachmentExtension(contentType)}`); +} + +function attachmentFetchError(url: string, status: number, message: string, details: Record = {}): GitHubErrorPayload { + const reason: GitHubDegradedReason = status === 401 + ? "auth-failed" + : status === 403 + ? "permission-denied" + : status === 404 + ? "validation-failed" + : "invalid-response"; + return errorPayload(reason, message, { + status, + details: { + url: redactedUrl(url), + ...details, + }, + request: { method: "GET", path: redactedUrl(url) }, + }); +} + +async function fetchAttachmentResponse(url: string, token: string, useAuth: boolean, redirect: RequestRedirect): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); + try { + const headers: Record = { + Accept: "image/*,application/octet-stream,*/*", + "User-Agent": USER_AGENT, + }; + if (useAuth && token.length > 0 && shouldSendGitHubWebAuth(url)) headers.Authorization = `Bearer ${token}`; + return await fetch(url, { + method: "GET", + redirect, + signal: controller.signal, + headers, + }); + } catch (error) { + const classified = classifyGitHubFetchFailure(error); + return errorPayload(classified.reason, classified.message, { + request: { method: "GET", path: redactedUrl(url) }, + details: { ...classified.details, url: redactedUrl(url) }, + retryable: classified.retryable, + commanderAction: classified.commanderAction, + }); + } finally { + clearTimeout(timeout); + } +} + +async function readAttachmentResponse(response: Response, url: string): Promise { + if (!response.ok) return attachmentFetchError(url, response.status, response.statusText || "GitHub attachment download failed"); + const contentLengthHeader = response.headers.get("content-length"); + const contentLength = contentLengthHeader === null ? null : Number(contentLengthHeader); + if (contentLength !== null && Number.isFinite(contentLength) && contentLength > MAX_ISSUE_ATTACHMENT_DOWNLOAD_BYTES) { + return attachmentFetchError(url, 413, "GitHub attachment is larger than the UniDesk gh download safety limit", { + contentLength, + maxBytes: MAX_ISSUE_ATTACHMENT_DOWNLOAD_BYTES, + }); + } + let buffer: Buffer; + try { + buffer = Buffer.from(await response.arrayBuffer()); + } catch (error) { + const classified = classifyGitHubFetchFailure(error); + return errorPayload(classified.reason, classified.message, { + request: { method: "GET", path: redactedUrl(url) }, + details: { ...classified.details, url: redactedUrl(url), phase: "read-body" }, + retryable: classified.retryable, + commanderAction: classified.commanderAction, + }); + } + if (buffer.byteLength > MAX_ISSUE_ATTACHMENT_DOWNLOAD_BYTES) { + return attachmentFetchError(url, 413, "GitHub attachment body exceeded the UniDesk gh download safety limit", { + bytes: buffer.byteLength, + maxBytes: MAX_ISSUE_ATTACHMENT_DOWNLOAD_BYTES, + }); + } + return { + buffer, + contentType: response.headers.get("content-type"), + contentLengthHeader, + finalUrl: response.url, + }; +} + +async function downloadGitHubAttachment(url: string, token: string): Promise { + const direct = await fetchAttachmentResponse(url, "", false, "follow"); + if (!isGitHubError(direct)) { + if (direct.ok) return readAttachmentResponse(direct, url); + } else { + return direct; + } + + const authenticated = await fetchAttachmentResponse(url, token, true, "manual"); + if (isGitHubError(authenticated)) return authenticated; + if (authenticated.status >= 300 && authenticated.status < 400) { + const location = authenticated.headers.get("location"); + if (location === null || location.length === 0) return attachmentFetchError(url, authenticated.status, "GitHub attachment redirect did not include a Location header"); + const redirectedUrl = new URL(location, url).toString(); + const redirected = await fetchAttachmentResponse(redirectedUrl, "", false, "follow"); + if (isGitHubError(redirected)) return redirected; + return readAttachmentResponse(redirected, redirectedUrl); + } + return readAttachmentResponse(authenticated, url); +} + +function extractRenderedAttachmentUrlCandidates(html: string | null | undefined, assetId: string | null, source: Record): Array<{ url: string; source: Record }> { + if (assetId === null || assetId.length === 0) return []; + const text = html ?? ""; + const candidates: Array<{ url: string; source: Record }> = []; + const seen = new Set(); + for (const match of text.matchAll(/https?:\/\/[^"'<> ]+/giu)) { + const decoded = decodeHtmlUrl(match[0] ?? ""); + if (!decoded.includes(assetId)) continue; + if (!/private-user-images\.githubusercontent\.com|github-production-user-asset|github\.com\/user-attachments\/assets/iu.test(decoded)) continue; + if (seen.has(decoded)) continue; + seen.add(decoded); + candidates.push({ url: decoded, source }); + } + return candidates; +} + +async function resolveRenderedIssueAttachmentUrl(repo: string, token: string, issueNumber: number, attachment: GitHubIssueAttachment): Promise<{ url: string; source: Record } | GitHubErrorPayload | null> { + const { owner, name } = repoParts(repo); + const query = ` + query UniDeskIssueAttachmentRenderedUrl($owner: String!, $name: String!, $number: Int!) { + repository(owner: $owner, name: $name) { + issue(number: $number) { + bodyHTML + comments(first: 100) { + nodes { + id + url + bodyHTML + } + } + } + } + } + `; + const response = await githubGraphqlRequest<{ + repository?: { + issue?: { + bodyHTML?: string | null; + comments?: { + nodes?: Array<{ id?: string; url?: string; bodyHTML?: string | null } | null> | null; + } | null; + } | null; + } | null; + }>(token, query, { owner, name, number: issueNumber }); + if (isGitHubError(response)) return response; + const issue = response.repository?.issue; + if (issue === null || issue === undefined) return null; + const candidates = [ + ...extractRenderedAttachmentUrlCandidates(issue.bodyHTML, attachment.assetId, { source: "issue-bodyHTML" }), + ...((issue.comments?.nodes ?? []).flatMap((comment) => extractRenderedAttachmentUrlCandidates(comment?.bodyHTML, attachment.assetId, { + source: "comment-bodyHTML", + commentNodeId: comment?.id ?? null, + commentUrl: comment?.url ?? null, + }))), + ]; + return candidates[0] ?? null; +} + +async function issueAttachmentDownload(repo: string, token: string, issueNumber: number, options: GitHubOptions): Promise { + const commandName = "issue attachment download"; + const collected = await collectIssueAttachments(repo, token, issueNumber, commandName); + if (isGitHubCommandResult(collected)) return collected; + const attachment = selectIssueAttachment(collected.attachments, options.attachmentSelector); + if (attachment === null) { + return validationError(commandName, repo, "No matching GitHub user attachment found in the issue body or comments", { + issueNumber, + selector: options.attachmentSelector ?? null, + attachments: collected.attachments.map(issueAttachmentSummary), + }); + } + if (options.dryRun) { + return { + ok: true, + command: commandName, + repo, + dryRun: true, + planned: true, + issue: issueSummary(collected.issue), + attachment: issueAttachmentSummary(attachment), + outputPath: options.outputPath ?? defaultAttachmentOutputPath(repo, issueNumber, attachment, null), + noWrite: true, + }; + } + let downloaded = await downloadGitHubAttachment(attachment.url, token); + let renderedFallback: Record | null = null; + if (isGitHubError(downloaded)) { + const rendered = await resolveRenderedIssueAttachmentUrl(repo, token, issueNumber, attachment); + if (isGitHubError(rendered)) { + return commandError(commandName, repo, downloaded, { + issueNumber, + attachment: issueAttachmentSummary(attachment), + renderedFallback: { + attempted: true, + ok: false, + degradedReason: rendered.degradedReason, + message: rendered.message, + }, + }); + } + if (rendered !== null) { + renderedFallback = { + attempted: true, + ok: true, + source: rendered.source, + url: redactedUrl(rendered.url), + }; + downloaded = await downloadGitHubAttachment(rendered.url, token); + } else { + renderedFallback = { + attempted: true, + ok: false, + reason: "rendered-bodyHTML-url-not-found", + }; + } + } + if (isGitHubError(downloaded)) return commandError(commandName, repo, downloaded, { issueNumber, attachment: issueAttachmentSummary(attachment), renderedFallback }); + const outputPath = options.outputPath ?? defaultAttachmentOutputPath(repo, issueNumber, attachment, downloaded.contentType); + mkdirSync(path.dirname(outputPath), { recursive: true }); + writeFileSync(outputPath, downloaded.buffer); + return { + ok: true, + command: commandName, + repo, + issue: issueSummary(collected.issue), + attachment: issueAttachmentSummary(attachment), + output: { + path: outputPath, + bytes: downloaded.buffer.byteLength, + sha256: binarySha(downloaded.buffer), + contentType: downloaded.contentType, + contentLengthHeader: downloaded.contentLengthHeader, + finalUrl: redactedUrl(downloaded.finalUrl), + }, + request: { + method: "GET", + url: attachment.url, + maxBytes: MAX_ISSUE_ATTACHMENT_DOWNLOAD_BYTES, + }, + renderedFallback, + valuesPrinted: false, + }; +} + function shellWord(value: string): string { return JSON.stringify(value); } @@ -7155,6 +7633,8 @@ export function ghHelp(): unknown { "bun scripts/cli.ts gh issue list [owner/repo] [--state open|closed|all] [--limit N] [--search text] [--label label[,label...]]... [--repo owner/name] [--json number,title,state,closed,closedAt,url,updatedAt,createdAt,author,labels] [--raw|--full]", "bun scripts/cli.ts gh issue view [--repo owner/name] [--number N compat] [--json body,title,state,closed,closedAt,comments,commentCount] [--raw|--full]", "bun scripts/cli.ts gh issue read [--repo owner/name] [--number N compat] [--raw|--full] [compatibility alias for issue view]", + "bun scripts/cli.ts gh issue attachment list [--repo owner/name] [--number N compat]", + "bun scripts/cli.ts gh issue attachment download [--repo owner/name] [--number N compat] [--attachment index|assetId|url] [--output path] [--dry-run]", "bun scripts/cli.ts gh issue create --title (--body-stdin|--body-file <file|->) [--label label[,label...]]... [--repo owner/name] [--dry-run]", "bun scripts/cli.ts gh issue update <number> --mode replace|append (--body-stdin|--body-file <file|->) [--title title] [--repo owner/name] [--number N compat] [--dry-run] [--expect-updated-at ts|--expect-body-sha sha256] [--body-profile auto|code-queue-board|commander-brief] [--allow-short-body] [--full|--raw]", "bun scripts/cli.ts gh issue patch <number|url|owner/repo#number> --body-patch-stdin [--repo owner/name] [--number N compat] [--dry-run] [--expect-updated-at ts|--expect-body-sha sha256] [--body-profile auto|code-queue-board|commander-brief] [--allow-short-body] [--full|--raw]", @@ -7206,6 +7686,7 @@ export function ghHelp(): unknown { "issue list defaults to --state open and bounded --limit 30; it paginates GitHub REST/Search pages internally when --limit exceeds GitHub's per-page cap and discloses pagination/rawCount/hasMore so operators do not mistake a single page for the full repository. --search uses GitHub Search Issues API with repo/type/state qualifiers for low-friction dedupe lookup before creating a new issue. Supported --json fields are number,title,state,closed,closedAt,url,updatedAt,createdAt,author,labels and unknown fields fail structurally.", "PR list defaults to --state all for compatibility with earlier UniDesk CLI behavior; supported states are open, closed, and all.", "issue view is the canonical GitHub CLI-compatible read path; read remains a UniDesk compatibility alias. View/read accept positional numbers, GitHub issue URLs, and owner/repo#number shorthand, deriving --repo unless an explicit conflicting --repo is supplied. --number is accepted on single issue/comment numeric target commands for low-friction compatibility and returns a standard syntax hint; list/create/scan-escape/cleanup-plan/board-audit/board-row list do not accept it. Comment delete treats --number as commentId, not an issue number. View supports lifecycle fields closed/closedAt plus legacy --json field selection such as --json body and still exposes .data.issue.body for compatibility; unsupported fields fail structurally.", + "issue attachment list/download scan issue body and comments for GitHub user attachment URLs (`https://github.com/user-attachments/assets/...`). list is read-only and returns bounded attachment metadata. download writes the selected attachment to --output or /tmp/unidesk-gh-attachments, returns bytes/SHA-256/content-type/path, redacts redirected signed URL query parameters, and never prints binary bytes.", "--raw and --full are explicit full-disclosure aliases for gh issue list/read/view/update/edit/patch and gh pr list/read/view. For issue writes, default success output omits full issue.body and returns bodyChars/bodySha/bodyPreview plus readCommands; --full|--raw includes the full returned issue body only on commands that explicitly support full disclosure.", "GitHub CLI output larger than 20 KiB is automatically written to /tmp/unidesk-cli-output/*.json; stdout stays bounded JSON with outputTruncated=true, the dump path, total bytes/lines, and head/tail previews.", "issue create accepts --body-stdin or --body-file <file|-> plus repeatable --label values and comma-separated labels; inline --body is intentionally unsupported for issue creation. Dry-run prints the parsed labels and non-dry-run sends them in the GitHub REST create-issue payload.", @@ -7285,6 +7766,14 @@ export async function runGhCommand(args: string[]): Promise<GitHubCommandResult ], }); } + if (optionWasProvided(args, "--attachment") && !(top === "issue" && sub === "attachment" && third === "download")) { + const command = [top, sub, third].filter((value): value is string => value !== undefined).join(" ") || "gh"; + return validationError(command, options.repo, "--attachment is only supported by gh issue attachment download"); + } + if (optionWasProvided(args, "--output") && !(top === "issue" && sub === "attachment" && third === "download")) { + const command = [top, sub, third].filter((value): value is string => value !== undefined).join(" ") || "gh"; + return validationError(command, options.repo, "--output is only supported by gh issue attachment download"); + } if (optionWasProvided(args, "--stat") && !(top === "pr" && (sub === "diff" || sub === "files"))) { const command = [top, sub].filter((value): value is string => value !== undefined).join(" ") || "gh"; return validationError(command, options.repo, "--stat is only supported by gh pr diff <number> --stat.", { @@ -7411,6 +7900,25 @@ export async function runGhCommand(args: string[]): Promise<GitHubCommandResult if (top === "issue") { if (sub === "delete") return unsupportedCommand("issue delete", options.repo, "GitHub REST does not support hard-deleting issues; use gh issue close for lifecycle deletion semantics."); + if (sub === "attachment") { + const action = third; + const commandName = `issue attachment ${action ?? ""}`.trim(); + if (action !== "list" && action !== "download") { + return unsupportedCommand(commandName, options.repo, "issue attachment supported commands are list and download.", { + supportedCommands: [ + "bun scripts/cli.ts gh issue attachment list <number> --repo owner/name", + "bun scripts/cli.ts gh issue attachment download <number> --repo owner/name --attachment 1 --output /tmp/issue-attachment.png", + ], + }); + } + const resolved = resolvePositionalIssueReference(args, 3, commandName, options); + if (isGitHubCommandResult(resolved)) return resolved; + const { token, probe } = resolveToken(true); + const missing = authRequired(resolved.repo, commandName, probe); + if (missing !== null || token === null) return missing ?? authRequired(resolved.repo, commandName, { present: false, source: null, ghFallbackAttempted: true }); + if (action === "list") return withNumberOptionHint(issueAttachmentList(resolved.repo, token, resolved.number), resolved); + return withNumberOptionHint(issueAttachmentDownload(resolved.repo, token, resolved.number, { ...options, repo: resolved.repo }), resolved); + } if (sub === "comment" && third === "delete") { const resolved = resolvePositionalNumberReference("issue", args, 3, "issue comment delete", options); if (isGitHubCommandResult(resolved)) return resolved;