// 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): Array> { const text = body ?? ""; const attachments: Array> = []; const seen = new Set(); const add = (rawUrl: string, marker: GitHubIssueAttachment["marker"], details: Partial = {}) => { const url = normalizedAttachmentUrl(rawUrl); if (url === null || seen.has(url)) return; seen.add(url); attachments.push({ ...source, marker, url, assetId: attachmentAssetId(url), alt: details.alt ?? null, width: details.width ?? null, height: details.height ?? null, }); }; for (const match of text.matchAll(/!\[([^\]]*)\]\((https:\/\/github\.com\/user-attachments\/assets\/[A-Za-z0-9._-]+(?:\?[^\s)"']+)?)\)/giu)) { add(match[2] ?? "", "markdown-image", { alt: match[1] ?? null }); } for (const match of text.matchAll(/]*>/giu)) { const tag = match[0] ?? ""; const src = htmlAttribute(tag, "src"); if (src === null) continue; add(src, "html-img", { alt: htmlAttribute(tag, "alt"), width: htmlAttribute(tag, "width"), height: htmlAttribute(tag, "height"), }); } for (const match of text.matchAll(GITHUB_USER_ATTACHMENT_URL_PATTERN)) add(match[0] ?? "", "plain-url"); return attachments; } 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(); const attachments: GitHubIssueAttachment[] = []; const append = (items: Array>) => { for (const item of items) { if (dedupe.has(item.url)) continue; dedupe.add(item.url); attachments.push({ ...item, index: attachments.length + 1 }); } }; append(extractIssueAttachmentsFromBody(issue.body, { source: "issue-body", sourceUrl: issue.html_url, author: issue.user?.login ?? null, createdAt: issue.created_at ?? null, updatedAt: issue.updated_at ?? null, })); for (const comment of comments) { append(extractIssueAttachmentsFromBody(comment.body, { source: "comment", sourceUrl: comment.html_url, commentId: comment.id, author: comment.user?.login ?? null, createdAt: comment.created_at ?? null, updatedAt: comment.updated_at ?? null, })); } return { issue, comments, attachments }; } export function issueAttachmentSummary(attachment: GitHubIssueAttachment): Record { return { index: attachment.index, url: attachment.url, assetId: attachment.assetId, source: attachment.source, sourceUrl: attachment.sourceUrl, commentId: attachment.commentId ?? null, marker: attachment.marker, alt: attachment.alt ?? null, width: attachment.width ?? null, height: attachment.height ?? null, author: attachment.author ?? null, createdAt: attachment.createdAt ?? null, updatedAt: attachment.updatedAt ?? null, }; } export async function issueAttachmentList(repo: string, token: string, issueNumber: number): Promise { const commandName = "issue attachment list"; const collected = await collectIssueAttachments(repo, token, issueNumber, commandName); if (isGitHubCommandResult(collected)) return collected; return { ok: true, command: commandName, repo, readOnly: true, issue: issueSummary(collected.issue), commentsScanned: collected.comments.length, count: collected.attachments.length, attachments: collected.attachments.map(issueAttachmentSummary), next: collected.attachments.length > 0 ? { command: `bun scripts/cli.ts gh issue attachment download ${issueNumber} --repo ${repo} --attachment 1 --output /tmp/gh-issue-${issueNumber}-attachment-1`, } : null, request: { method: "GET", paths: [ `/repos/{owner}/{repo}/issues/${issueNumber}`, `/repos/{owner}/{repo}/issues/${issueNumber}/comments`, ], }, rest: true, }; } 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 = {}): 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 { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); try { const headers: Record = { Accept: "image/*,application/octet-stream,*/*", "User-Agent": USER_AGENT, }; if (useAuth && token.length > 0 && shouldSendGitHubWebAuth(url)) headers.Authorization = `Bearer ${token}`; return await fetch(url, { method: "GET", redirect, signal: controller.signal, headers, }); } catch (error) { const classified = classifyGitHubFetchFailure(error); return errorPayload(classified.reason, classified.message, { request: { method: "GET", path: redactedUrl(url) }, details: { ...classified.details, url: redactedUrl(url) }, retryable: classified.retryable, commanderAction: classified.commanderAction, }); } finally { clearTimeout(timeout); } } export async function readAttachmentResponse(response: Response, url: string): Promise { if (!response.ok) return attachmentFetchError(url, response.status, response.statusText || "GitHub attachment download failed"); const contentLengthHeader = response.headers.get("content-length"); const contentLength = contentLengthHeader === null ? null : Number(contentLengthHeader); if (contentLength !== null && Number.isFinite(contentLength) && contentLength > MAX_ISSUE_ATTACHMENT_DOWNLOAD_BYTES) { return attachmentFetchError(url, 413, "GitHub attachment is larger than the UniDesk gh download safety limit", { contentLength, maxBytes: MAX_ISSUE_ATTACHMENT_DOWNLOAD_BYTES, }); } let buffer: Buffer; try { buffer = Buffer.from(await response.arrayBuffer()); } catch (error) { const classified = classifyGitHubFetchFailure(error); return errorPayload(classified.reason, classified.message, { request: { method: "GET", path: redactedUrl(url) }, details: { ...classified.details, url: redactedUrl(url), phase: "read-body" }, retryable: classified.retryable, commanderAction: classified.commanderAction, }); } if (buffer.byteLength > MAX_ISSUE_ATTACHMENT_DOWNLOAD_BYTES) { return attachmentFetchError(url, 413, "GitHub attachment body exceeded the UniDesk gh download safety limit", { bytes: buffer.byteLength, maxBytes: MAX_ISSUE_ATTACHMENT_DOWNLOAD_BYTES, }); } return { buffer, contentType: response.headers.get("content-type"), contentLengthHeader, finalUrl: response.url, }; } export async function downloadGitHubAttachment(url: string, token: string): Promise { const direct = await fetchAttachmentResponse(url, "", false, "follow"); if (!isGitHubError(direct)) { if (direct.ok) return readAttachmentResponse(direct, url); } else { return direct; } const authenticated = await fetchAttachmentResponse(url, token, true, "manual"); if (isGitHubError(authenticated)) return authenticated; if (authenticated.status >= 300 && authenticated.status < 400) { const location = authenticated.headers.get("location"); if (location === null || location.length === 0) return attachmentFetchError(url, authenticated.status, "GitHub attachment redirect did not include a Location header"); const redirectedUrl = new URL(location, url).toString(); const redirected = await fetchAttachmentResponse(redirectedUrl, "", false, "follow"); if (isGitHubError(redirected)) return redirected; return readAttachmentResponse(redirected, redirectedUrl); } return readAttachmentResponse(authenticated, url); } export function extractRenderedAttachmentUrlCandidates(html: string | null | undefined, assetId: string | null, source: Record): Array<{ url: string; source: Record }> { if (assetId === null || assetId.length === 0) return []; const text = html ?? ""; const candidates: Array<{ url: string; source: Record }> = []; const seen = new Set(); for (const match of text.matchAll(/https?:\/\/[^"'<> ]+/giu)) { const decoded = decodeHtmlUrl(match[0] ?? ""); if (!decoded.includes(assetId)) continue; if (!/private-user-images\.githubusercontent\.com|github-production-user-asset|github\.com\/user-attachments\/assets/iu.test(decoded)) continue; if (seen.has(decoded)) continue; seen.add(decoded); candidates.push({ url: decoded, source }); } return candidates; } export async function resolveRenderedIssueAttachmentUrl(repo: string, token: string, issueNumber: number, attachment: GitHubIssueAttachment): Promise<{ url: string; source: Record } | GitHubErrorPayload | null> { const { owner, name } = repoParts(repo); const query = ` query UniDeskIssueAttachmentRenderedUrl($owner: String!, $name: String!, $number: Int!) { repository(owner: $owner, name: $name) { issue(number: $number) { bodyHTML comments(first: 100) { nodes { id url bodyHTML } } } } } `; const response = await githubGraphqlRequest<{ repository?: { issue?: { bodyHTML?: string | null; comments?: { nodes?: Array<{ id?: string; url?: string; bodyHTML?: string | null } | null> | null; } | null; } | null; } | null; }>(token, query, { owner, name, number: issueNumber }); if (isGitHubError(response)) return response; const issue = response.repository?.issue; if (issue === null || issue === undefined) return null; const candidates = [ ...extractRenderedAttachmentUrlCandidates(issue.bodyHTML, attachment.assetId, { source: "issue-bodyHTML" }), ...((issue.comments?.nodes ?? []).flatMap((comment) => extractRenderedAttachmentUrlCandidates(comment?.bodyHTML, attachment.assetId, { source: "comment-bodyHTML", commentNodeId: comment?.id ?? null, commentUrl: comment?.url ?? null, }))), ]; return candidates[0] ?? null; } export async function issueAttachmentDownload(repo: string, token: string, issueNumber: number, options: GitHubOptions): Promise { const commandName = "issue attachment download"; const collected = await collectIssueAttachments(repo, token, issueNumber, commandName); if (isGitHubCommandResult(collected)) return collected; const attachment = selectIssueAttachment(collected.attachments, options.attachmentSelector); if (attachment === null) { return validationError(commandName, repo, "No matching GitHub user attachment found in the issue body or comments", { issueNumber, selector: options.attachmentSelector ?? null, attachments: collected.attachments.map(issueAttachmentSummary), }); } if (options.dryRun) { return { ok: true, command: commandName, repo, dryRun: true, planned: true, issue: issueSummary(collected.issue), attachment: issueAttachmentSummary(attachment), outputPath: options.outputPath ?? defaultAttachmentOutputPath(repo, issueNumber, attachment, null), noWrite: true, }; } let downloaded = await downloadGitHubAttachment(attachment.url, token); let renderedFallback: Record | null = null; if (isGitHubError(downloaded)) { const rendered = await resolveRenderedIssueAttachmentUrl(repo, token, issueNumber, attachment); if (isGitHubError(rendered)) { return commandError(commandName, repo, downloaded, { issueNumber, attachment: issueAttachmentSummary(attachment), renderedFallback: { attempted: true, ok: false, degradedReason: rendered.degradedReason, message: rendered.message, }, }); } if (rendered !== null) { renderedFallback = { attempted: true, ok: true, source: rendered.source, url: redactedUrl(rendered.url), }; downloaded = await downloadGitHubAttachment(rendered.url, token); } else { renderedFallback = { attempted: true, ok: false, reason: "rendered-bodyHTML-url-not-found", }; } } if (isGitHubError(downloaded)) return commandError(commandName, repo, downloaded, { issueNumber, attachment: issueAttachmentSummary(attachment), renderedFallback }); const outputPath = options.outputPath ?? defaultAttachmentOutputPath(repo, issueNumber, attachment, downloaded.contentType); mkdirSync(path.dirname(outputPath), { recursive: true }); writeFileSync(outputPath, downloaded.buffer); return { ok: true, command: commandName, repo, issue: issueSummary(collected.issue), attachment: issueAttachmentSummary(attachment), output: { path: outputPath, bytes: downloaded.buffer.byteLength, sha256: binarySha(downloaded.buffer), contentType: downloaded.contentType, contentLengthHeader: downloaded.contentLengthHeader, finalUrl: redactedUrl(downloaded.finalUrl), }, request: { method: "GET", url: attachment.url, maxBytes: MAX_ISSUE_ATTACHMENT_DOWNLOAD_BYTES, }, renderedFallback, valuesPrinted: false, }; }