feat: support GitHub issue attachment download

This commit is contained in:
Codex
2026-06-15 10:19:35 +00:00
parent 89f0939718
commit ae1785b74e
+510 -2
View File
@@ -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<GitHub
};
}
function isGitHubCommandResult(value: GitHubResolvedNumberReference | GitHubCommandResult): value is GitHubCommandResult {
function isGitHubCommandResult(value: unknown): value is GitHubCommandResult {
return typeof (value as { ok?: unknown }).ok === "boolean";
}
@@ -1587,6 +1618,10 @@ function bodySha(text: string): string {
return createHash("sha256").update(text, "utf8").digest("hex");
}
function binarySha(data: Buffer): string {
return createHash("sha256").update(data).digest("hex");
}
function normalizeExpectedSha(raw: string): string {
const value = raw.replace(/^sha256:/iu, "").toLowerCase();
if (!/^[a-f0-9]{64}$/u.test(value)) throw new Error("--expect-body-sha must be a 64-character SHA-256 hex value, optionally prefixed with sha256:");
@@ -5681,6 +5716,449 @@ async function issueView(repo: string, token: string, issueNumber: number, jsonF
return issueRead(repo, token, issueNumber, jsonFields, "issue view", disclosure);
}
const GITHUB_USER_ATTACHMENT_URL_PATTERN = /https:\/\/github\.com\/user-attachments\/assets\/[A-Za-z0-9._-]+(?:\?[^\s<>"')]+)?/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(/&amp;/giu, "&")
.replace(/&#x2F;/giu, "/")
.replace(/&#x3D;/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<GitHubIssueAttachment, "index" | "url" | "assetId" | "marker">): Array<Omit<GitHubIssueAttachment, "index">> {
const text = body ?? "";
const attachments: Array<Omit<GitHubIssueAttachment, "index">> = [];
const seen = new Set<string>();
const add = (rawUrl: string, marker: GitHubIssueAttachment["marker"], details: Partial<GitHubIssueAttachment> = {}) => {
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(/<img\b[^>]*>/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<string>();
const attachments: GitHubIssueAttachment[] = [];
const append = (items: Array<Omit<GitHubIssueAttachment, "index">>) => {
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<string, unknown> {
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<GitHubCommandResult> {
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<string, unknown> = {}): 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<Response | GitHubErrorPayload> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
try {
const headers: Record<string, string> = {
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<GitHubAttachmentDownload | GitHubErrorPayload> {
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<GitHubAttachmentDownload | GitHubErrorPayload> {
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<string, unknown>): Array<{ url: string; source: Record<string, unknown> }> {
if (assetId === null || assetId.length === 0) return [];
const text = html ?? "";
const candidates: Array<{ url: string; source: Record<string, unknown> }> = [];
const seen = new Set<string>();
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<string, unknown> } | 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<GitHubCommandResult> {
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<string, unknown> | 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 <number|url|owner/repo#number> [--repo owner/name] [--number N compat] [--json body,title,state,closed,closedAt,comments,commentCount] [--raw|--full]",
"bun scripts/cli.ts gh issue read <number|url|owner/repo#number> [--repo owner/name] [--number N compat] [--raw|--full] [compatibility alias for issue view]",
"bun scripts/cli.ts gh issue attachment list <number|url|owner/repo#number> [--repo owner/name] [--number N compat]",
"bun scripts/cli.ts gh issue attachment download <number|url|owner/repo#number> [--repo owner/name] [--number N compat] [--attachment index|assetId|url] [--output path] [--dry-run]",
"bun scripts/cli.ts gh issue create --title <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;