Files
pikasTech-unidesk/scripts/src/gh/issue-list.ts
T
2026-07-05 12:46:49 +00:00

184 lines
8.0 KiB
TypeScript

// 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<GitHubCommandResult> {
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;
}