// SPEC: PJ2026-010606 GitHub入口 draft-2026-06-25-gh-split // Moved mechanically from scripts/src/gh.ts:5949-6089. import path from "node:path"; import { repoParts } from "./auth-and-safety"; import { codeQueueBoardCommanderBriefHint } from "./body-profiles"; import { commandError, githubRequest, isGitHubError, issueBodyReadCommands, issueCommentReadCommands } from "./client"; import { issueSummary } from "./issue-summary"; import { commentSummary, compactCommentSummary, compactIssueViewCommentSummary } from "./render"; import { GITHUB_REST_PAGE_SIZE } from "./types"; import type { GitHubCommandResult, GitHubComment, GitHubErrorPayload, GitHubIssue, GitHubIssueListPage, GitHubIssueListResult, GitHubIssueSearchResponse, IssueListState, IssueViewJsonField } from "./types"; function preferredIssueCommentsCommand(repo: string, issueNumber: number): string { return `bun scripts/cli.ts gh issue comments ${issueNumber} --repo ${repo}`; } export async function listIssueComments(token: string, repo: string, issueNumber: number, options: { page?: number; perPage?: number } = {}): Promise { const { owner, name } = repoParts(repo); const params = new URLSearchParams({ per_page: String(options.perPage ?? GITHUB_REST_PAGE_SIZE), page: String(options.page ?? 1), }); return githubRequest(token, "GET", `/repos/${owner}/${name}/issues/${issueNumber}/comments?${params.toString()}`); } export function githubSearchLabelQualifier(label: string): string { if (/^[A-Za-z0-9_.:-]+$/u.test(label)) return `label:${label}`; return `label:"${label.replace(/["\\]/gu, "\\$&")}"`; } export function issueListPaginationSummary(result: GitHubIssueListResult): Record { return { pageSize: result.pageSize, fetchedPages: result.fetchedPages, exhausted: result.exhausted, hasMore: result.hasMore, pages: result.pages, }; } export async function listIssues(token: string, repo: string, state: IssueListState, limit: number, search?: string, labels: string[] = []): Promise { const { owner, name } = repoParts(repo); const normalizedSearch = String(search ?? "").trim(); const pageSize = GITHUB_REST_PAGE_SIZE; const collected: GitHubIssue[] = []; const pages: GitHubIssueListPage[] = []; let rawCount = 0; let fetchedPages = 0; let exhausted = false; let searchTotalCount: number | undefined; let searchIncomplete: boolean | undefined; const maxPages = Math.max(1, Math.ceil(limit / pageSize) + 10); for (let page = 1; collected.length < limit && page <= maxPages; page += 1) { const perPage = pageSize; let path: string; let rawIssueItems: GitHubIssue[]; if (normalizedSearch) { const qualifiers = [`repo:${owner}/${name}`, "type:issue"]; if (state !== "all") qualifiers.push(`state:${state}`); for (const label of labels) qualifiers.push(githubSearchLabelQualifier(label)); const params = new URLSearchParams({ q: `${normalizedSearch} ${qualifiers.join(" ")}`, per_page: String(perPage), page: String(page) }); path = `/search/issues?${params.toString()}`; const response = await githubRequest(token, "GET", path); if (isGitHubError(response)) return response; rawIssueItems = Array.isArray(response.items) ? response.items : []; if (searchTotalCount === undefined) searchTotalCount = response.total_count; searchIncomplete = searchIncomplete === true || response.incomplete_results === true; } else { const params = new URLSearchParams({ state, per_page: String(perPage), page: String(page) }); if (labels.length > 0) params.set("labels", labels.join(",")); path = `/repos/${owner}/${name}/issues?${params.toString()}`; const response = await githubRequest(token, "GET", path); if (isGitHubError(response)) return response; rawIssueItems = response; } const issueItems = rawIssueItems.filter((issue) => issue.pull_request === undefined); rawCount += rawIssueItems.length; fetchedPages += 1; pages.push({ path, rawCount: rawIssueItems.length, issueCount: issueItems.length }); collected.push(...issueItems); if (rawIssueItems.length < perPage) { exhausted = true; break; } } const hasMore = collected.length > limit || !exhausted; return { items: collected.slice(0, limit), rawCount, fetchedPages, pageSize, exhausted, hasMore, pages, searchTotalCount, searchIncomplete, }; } export async function getIssue(token: string, repo: string, issueNumber: number): Promise { const { owner, name } = repoParts(repo); return githubRequest(token, "GET", `/repos/${owner}/${name}/issues/${issueNumber}`); } export async function getIssueComment(token: string, repo: string, commentId: number): Promise { const { owner, name } = repoParts(repo); return githubRequest(token, "GET", `/repos/${owner}/${name}/issues/comments/${commentId}`); } async function listRecentIssueComments(token: string, repo: string, issueNumber: number, totalComments: number, limit: number): Promise { if (totalComments <= 0 || limit <= 0) return []; const boundedLimit = Math.min(limit, GITHUB_REST_PAGE_SIZE); const pages: GitHubComment[][] = []; let collected = 0; let page = Math.max(1, Math.ceil(totalComments / GITHUB_REST_PAGE_SIZE)); while (page >= 1 && collected < boundedLimit) { const pageComments = await listIssueComments(token, repo, issueNumber, { page, perPage: GITHUB_REST_PAGE_SIZE }); if (isGitHubError(pageComments)) return pageComments; pages.unshift(pageComments); collected += pageComments.length; page -= 1; } return pages.flat().slice(-boundedLimit); } export function selectedIssueJson(issue: GitHubIssue, comments: GitHubComment[] | null, fields: IssueViewJsonField[] | undefined, options: { includeFullCommentBodies?: boolean } = {}): Record | null { if (fields === undefined) return null; const summary = issueSummary(issue); const selected: Record = {}; for (const field of fields) { if (field === "comments") { selected.comments = (comments ?? []).map(options.includeFullCommentBodies === true ? commentSummary : compactIssueViewCommentSummary); } else { selected[field] = summary[field]; } } return selected; } function issueCommentsCompatibility(repo: string, issueNumber: number, comments: GitHubComment[] | null, includeFullCommentBodies: boolean): Record { const preferredCommand = preferredIssueCommentsCommand(repo, issueNumber); return { commentsCompacted: comments !== null && !includeFullCommentBodies, commentBodiesOmitted: comments !== null && !includeFullCommentBodies, fullCommentBodiesIncluded: comments !== null && includeFullCommentBodies, commentsPath: comments === null ? null : ".data.json.comments", preferredCommand: comments === null ? null : preferredCommand, migrationHint: comments === null ? null : "For bounded recent comment progress, prefer gh issue comments ; gh issue view --json comments remains the legacy compatibility path.", readCommands: { ...(comments !== null && !includeFullCommentBodies ? issueCommentReadCommands(repo, issueNumber) : {}), ...(comments === null ? {} : { preferred: preferredCommand }), }, }; } export async function issueRead(repo: string, token: string, issueNumber: number, jsonFields: IssueViewJsonField[] | undefined, commandName = "issue read", disclosure: Record | null = null, options: { includeFullCommentBodies?: boolean } = {}): Promise { const issue = await getIssue(token, repo, issueNumber); if (isGitHubError(issue)) return commandError(commandName, repo, issue, { issueNumber }); const needsComments = jsonFields === undefined || jsonFields.includes("comments"); const includeBody = jsonFields === undefined || jsonFields.includes("body"); const requestedCommentsField = jsonFields?.includes("comments") === true; const includeFullCommentBodies = options.includeFullCommentBodies === true; const comments = needsComments ? await listIssueComments(token, repo, issueNumber) : null; if (isGitHubError(comments)) return commandError(commandName, repo, comments, { issueNumber, issue: issueSummary(issue, { includeBody, includePreview: false }) }); const commentsCompatibility = issueCommentsCompatibility(repo, issueNumber, comments, includeFullCommentBodies); return { ok: true, command: commandName, repo, ...((disclosure === null && !requestedCommentsField) ? {} : { disclosure: { ...(disclosure ?? {}), ...(requestedCommentsField ? { preferredCommand: commentsCompatibility.preferredCommand, commentsPath: commentsCompatibility.commentsPath, legacyCompatibilityPath: ".data.json.comments", migrationHint: commentsCompatibility.migrationHint, boundedAlternative: "gh issue comments returns a bounded recent-comment summary and is the preferred human/operator path.", } : {}), }, }), issue: issueSummary(issue, { includeBody, includePreview: false }), codeQueueBoardHint: codeQueueBoardCommanderBriefHint(issueNumber, issue.body ?? ""), ...(comments === null || jsonFields !== undefined ? {} : { comments: comments.map(includeFullCommentBodies ? commentSummary : compactCommentSummary) }), ...(jsonFields === undefined ? {} : { jsonFields, json: selectedIssueJson(issue, comments, jsonFields, { includeFullCommentBodies }), compatibility: { legacyJsonBodyPath: includeBody ? ".data.issue.body" : null, bodyOmitted: !includeBody, ...commentsCompatibility, readCommands: { ...(includeBody ? {} : issueBodyReadCommands(repo, issueNumber)), ...recordOrEmpty(commentsCompatibility.readCommands), }, }, }), }; } export async function issueView(repo: string, token: string, issueNumber: number, jsonFields: IssueViewJsonField[] | undefined, disclosure: Record | null = null, options: { includeFullCommentBodies?: boolean } = {}): Promise { return issueRead(repo, token, issueNumber, jsonFields, "issue view", disclosure, options); } export async function issueComments(repo: string, token: string, issueNumber: number, limit: number, options: { includeFullCommentBodies?: boolean } = {}): Promise { const issue = await getIssue(token, repo, issueNumber); if (isGitHubError(issue)) return commandError("issue comments", repo, issue, { issueNumber }); const totalComments = Math.max(0, issue.comments ?? 0); const boundedLimit = Math.min(limit, GITHUB_REST_PAGE_SIZE); const includeFullCommentBodies = options.includeFullCommentBodies === true; const comments = await listRecentIssueComments(token, repo, issueNumber, totalComments, boundedLimit); if (isGitHubError(comments)) return commandError("issue comments", repo, comments, { issueNumber, issue: issueSummary(issue, { includeBody: false, includePreview: false }), }); const shownFrom = comments.length === 0 ? 0 : Math.max(1, totalComments - comments.length + 1); return { ok: true, command: "issue comments", repo, issue: issueSummary(issue, { includeBody: false, includePreview: false }), issueNumber, limit: boundedLimit, totalComments, returned: comments.length, omitted: Math.max(0, totalComments - comments.length), commentRange: { from: shownFrom, to: comments.length === 0 ? 0 : shownFrom + comments.length - 1, total: totalComments, }, comments: comments.map(includeFullCommentBodies ? commentSummary : compactCommentSummary), disclosure: { boundedRecentComments: true, fullCommentBodiesIncluded: includeFullCommentBodies, bodyOmitted: !includeFullCommentBodies, note: includeFullCommentBodies ? "issue comments returns a bounded recent-comment list with full bodies because --full or --raw was explicitly requested." : "issue comments defaults to bounded recent-comment previews; use --full or --raw for full bodies, or issue comment view --full for a single comment.", }, compatibility: { commentsPath: ".data.comments", issuePath: ".data.issue", authorField: "string|null", previewField: includeFullCommentBodies ? null : "bodyPreview", fullBodyField: includeFullCommentBodies ? "body" : null, nestedIssueViewCommentsPath: ".data.json.comments", }, readCommands: { self: preferredIssueCommentsCommand(repo, issueNumber), full: `bun scripts/cli.ts gh issue comments ${issueNumber} --repo ${repo} --limit ${boundedLimit} --full`, raw: `bun scripts/cli.ts gh issue comments ${issueNumber} --repo ${repo} --limit ${boundedLimit} --raw`, comment: `bun scripts/cli.ts gh issue comment view --repo ${repo} --full`, issue: `bun scripts/cli.ts gh issue view ${issueNumber} --repo ${repo}`, }, }; } function recordOrEmpty(value: unknown): Record { return value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record : {}; }