edc437cdd8
Co-authored-by: Codex <codex@noreply.local>
456 lines
18 KiB
TypeScript
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(/&/giu, "&")
|
|
.replace(///giu, "/")
|
|
.replace(/=/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,
|
|
};
|
|
}
|