feat: support GitHub issue attachment download
This commit is contained in:
+510
-2
@@ -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(/&/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<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;
|
||||
|
||||
Reference in New Issue
Block a user