Files
pikasTech-unidesk/scripts/src/gh/attachments.ts
T
2026-06-26 00:16:53 +08:00

456 lines
18 KiB
TypeScript

// SPEC: PJ2026-010606 GitHub入口 draft-2026-06-25-gh-split
// Moved mechanically from scripts/src/gh.ts:6091-6532.
import path from "node:path";
import { mkdirSync, writeFileSync } from "node:fs";
import { binarySha, repoParts } from "./auth-and-safety";
import { classifyGitHubFetchFailure, commandError, errorPayload, githubGraphqlRequest, isGitHubError, validationError } from "./client";
import { getIssue, listIssueComments } from "./issue-read";
import { issueSummary } from "./issue-summary";
import { isGitHubCommandResult } from "./refs";
import { MAX_ISSUE_ATTACHMENT_DOWNLOAD_BYTES, REQUEST_TIMEOUT_MS, USER_AGENT } from "./types";
import type { GitHubAttachmentDownload, GitHubCommandFailure, GitHubCommandResult, GitHubComment, GitHubDegradedReason, GitHubErrorPayload, GitHubIssue, GitHubIssueAttachment, GitHubOptions } from "./types";
export const GITHUB_USER_ATTACHMENT_URL_PATTERN = /https:\/\/github\.com\/user-attachments\/assets\/[A-Za-z0-9._-]+(?:\?[^\s<>"')]+)?/giu;
export 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;
}
}
export function redactedUrl(raw: string): string {
try {
const url = new URL(raw);
url.search = "";
url.hash = "";
return url.toString();
} catch {
return raw.split("?")[0] ?? raw;
}
}
export function shouldSendGitHubWebAuth(raw: string): boolean {
try {
return new URL(raw).hostname === "github.com";
} catch {
return false;
}
}
export function decodeHtmlUrl(raw: string): string {
return raw
.replace(/&amp;/giu, "&")
.replace(/&#x2F;/giu, "/")
.replace(/&#x3D;/giu, "=");
}
export function attachmentAssetId(url: string): string | null {
try {
const parsed = new URL(url);
return parsed.pathname.split("/").filter(Boolean).at(-1) ?? null;
} catch {
return null;
}
}
export 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;
}
export 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;
}
export 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 };
}
export 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,
};
}
export 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,
};
}
export 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;
}
export 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";
}
export 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)}`);
}
export 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) },
});
}
export 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);
}
}
export 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,
};
}
export 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);
}
export 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;
}
export 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;
}
export 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,
};
}