// SPEC: PJ2026-010606 GitHub入口 draft-2026-06-25-gh-split // Moved mechanically from scripts/src/gh.ts:6534-6664. import path from "node:path"; import { commandError, isGitHubError, validationError } from "./client"; import { githubSearchLabelQualifier, issueListPaginationSummary, listIssues } from "./issue-read"; import { issueListSummary } from "./issue-summary"; import { isRecord } from "./notify-claudeqq"; import { ghShort, ghTable, ghText } from "./render"; import { GITHUB_REST_PAGE_SIZE, ISSUE_LIST_JSON_FIELDS, MAX_ISSUE_LIST_LIMIT } from "./types"; import type { GitHubCommandResult, IssueListJsonField, IssueListState } from "./types"; const COMPACT_JSON_ISSUE_LIMIT = 40; export function shellWord(value: string): string { return JSON.stringify(value); } export function issueListNextCommand(repo: string, state: IssueListState, limit: number, search: string, labels: string[], titlePrefix: string): string { const parts = [ "bun scripts/cli.ts gh issue list", "--repo", shellWord(repo), "--state", state, "--limit", String(Math.min(limit * 2, MAX_ISSUE_LIST_LIMIT)), ]; if (search.length > 0) parts.push("--search", shellWord(search)); if (titlePrefix.length > 0) parts.push("--title-prefix", shellWord(titlePrefix)); for (const label of labels) parts.push("--label", shellWord(label)); return parts.join(" "); } export function issueListLabelText(value: unknown): string { if (!Array.isArray(value)) return ghText(value); const labels = value .map((item) => typeof item === "string" ? item : isRecord(item) ? ghText(item.name) : ghText(item)) .filter((item) => item !== "-"); return labels.length === 0 ? "-" : labels.join(","); } export function withIssueListRendered(result: GitHubCommandResult): GitHubCommandResult { return { ...result, contentType: "text/plain", renderedText: renderIssueListTable(result), }; } export function renderIssueListTable(result: GitHubCommandResult): string { const issues = Array.isArray(result.issues) ? result.issues.map((item) => isRecord(item) ? item : {}) : []; const rows = issues.map((issue) => [ issue.number === undefined ? "-" : `#${ghText(issue.number)}`, ghText(issue.state), ghShort(ghText(issue.updatedAt), 19), ghShort(issueListLabelText(issue.labels), 32), ghShort(ghText(issue.title), 96), ]); const next = isRecord(result.next) ? result.next : {}; const lines = [ "gh issue list (observed)", "", ghTable(["ISSUE", "STATE", "UPDATED", "LABELS", "TITLE"], rows), "", "Summary:", ` repo=${ghText(result.repo)} state=${ghText(result.state)} count=${ghText(result.count)} raw=${ghText(result.rawCount)} hasMore=${ghText(result.hasMore)}`, ` search=${ghText(result.search)} labels=${issueListLabelText(result.labels)} titlePrefix=${ghText(result.titlePrefix)}`, ]; if (result.searchTotalCount !== undefined) lines.push(` searchTotal=${ghText(result.searchTotalCount)} incomplete=${ghText(result.searchIncomplete)}`); if (typeof next.command === "string" && next.command.length > 0) { lines.push("", "Next:", ` ${next.command}`); } lines.push("", "Disclosure:"); lines.push(" default view is a bounded table; use --json for selected fields or --full/--raw for structured pagination and request metadata."); return lines.join("\n"); } export async function issueList(repo: string, token: string, state: IssueListState, limit: number, jsonFields: IssueListJsonField[] | undefined, search?: string, labels: string[] = [], noDump = false, titlePrefix?: string): Promise { const normalizedSearch = String(search ?? "").trim(); const normalizedTitlePrefix = String(titlePrefix ?? "").trim(); if (titlePrefix !== undefined && normalizedTitlePrefix.length === 0) return validationError("issue list", repo, "--title-prefix requires a non-empty value"); const result = await listIssues(token, repo, state, limit, normalizedSearch, labels); if (isGitHubError(result)) return commandError("issue list", repo, result, { state, limit, search: normalizedSearch || null, titlePrefix: normalizedTitlePrefix || null, labels }); const listedIssues = result.items; const issues = normalizedTitlePrefix.length > 0 ? listedIssues.filter((issue) => issue.title.startsWith(normalizedTitlePrefix)) : listedIssues; const fields = jsonFields ?? ISSUE_LIST_JSON_FIELDS.slice(); const selectedIssues = issues.map((issue) => issueListSummary(issue, fields)); const compactJson = jsonFields !== undefined && !noDump; const returnedIssues = compactJson ? selectedIssues.slice(0, COMPACT_JSON_ISSUE_LIMIT) : selectedIssues; const omittedIssues = Math.max(0, selectedIssues.length - returnedIssues.length); const fullCommand = [ "bun scripts/cli.ts gh issue list", "--repo", shellWord(repo), "--state", state, "--limit", String(limit), ...(normalizedSearch.length > 0 ? ["--search", shellWord(normalizedSearch)] : []), ...(normalizedTitlePrefix.length > 0 ? ["--title-prefix", shellWord(normalizedTitlePrefix)] : []), ...labels.flatMap((label) => ["--label", shellWord(label)]), "--json", shellWord(fields.join(",")), "--full", ].join(" "); const payload: GitHubCommandResult = { ok: true, command: "issue list", repo, ...(noDump ? { noDump: true } : {}), state, limit, search: normalizedSearch || null, titlePrefix: normalizedTitlePrefix || null, ...(normalizedTitlePrefix.length > 0 ? { titleFilter: { field: "title", operator: "startsWith", prefix: normalizedTitlePrefix, scope: "bounded-listed-issues", inputCount: listedIssues.length, outputCount: issues.length, filteredOut: listedIssues.length - issues.length, }, } : {}), labels, count: issues.length, rawCount: result.rawCount, searchTotalCount: result.searchTotalCount, searchIncomplete: result.searchIncomplete, ...(compactJson ? {} : { pagination: issueListPaginationSummary(result) }), hasMore: result.hasMore, jsonFields: fields, issues: returnedIssues, ...(compactJson ? { issueListDisclosure: { mode: "bounded-json", returned: returnedIssues.length, omitted: omittedIssues, totalMatchedInBoundedScan: issues.length, inlineLimit: COMPACT_JSON_ISSUE_LIMIT, fullCommand, note: "Default --json issue list output is bounded for interactive use; add --full for complete selected fields and pagination/request metadata.", }, } : {}), ...(omittedIssues > 0 ? { omittedIssues, fullCommand, } : {}), ...(result.hasMore && limit < MAX_ISSUE_LIST_LIMIT ? { next: { command: issueListNextCommand(repo, state, limit, normalizedSearch, labels, normalizedTitlePrefix), note: "Increase --limit to continue scanning beyond the current bounded result set.", }, } : result.hasMore ? { scanLimit: { maxLimitReached: true, maxLimit: MAX_ISSUE_LIST_LIMIT, note: "The command reached the maximum bounded issue scan; narrow with --state/--label/--search before treating the repository as exhausted.", }, } : {}), ...(compactJson ? {} : { request: { method: "GET", path: normalizedSearch ? "/search/issues" : "/repos/{owner}/{repo}/issues", query: normalizedSearch ? { q: `${normalizedSearch} repo:${repo} type:issue${state === "all" ? "" : ` state:${state}`}${labels.map((label) => ` ${githubSearchLabelQualifier(label)}`).join("")}`, per_page: GITHUB_REST_PAGE_SIZE } : { state, labels, per_page: GITHUB_REST_PAGE_SIZE }, localTitlePrefixFilter: normalizedTitlePrefix || null, }, }), note: "GitHub's issues endpoint may include pull requests; this command filters pull requests from .issues.", }; return jsonFields === undefined && !noDump ? withIssueListRendered(payload) : payload; }