264 lines
13 KiB
TypeScript
264 lines
13 KiB
TypeScript
// 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<GitHubComment[] | GitHubErrorPayload> {
|
|
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<GitHubComment[]>(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<string, unknown> {
|
|
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<GitHubIssueListResult | GitHubErrorPayload> {
|
|
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<GitHubIssueSearchResponse>(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<GitHubIssue[]>(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<GitHubIssue | GitHubErrorPayload> {
|
|
const { owner, name } = repoParts(repo);
|
|
return githubRequest<GitHubIssue>(token, "GET", `/repos/${owner}/${name}/issues/${issueNumber}`);
|
|
}
|
|
|
|
export async function getIssueComment(token: string, repo: string, commentId: number): Promise<GitHubComment | GitHubErrorPayload> {
|
|
const { owner, name } = repoParts(repo);
|
|
return githubRequest<GitHubComment>(token, "GET", `/repos/${owner}/${name}/issues/comments/${commentId}`);
|
|
}
|
|
|
|
async function listRecentIssueComments(token: string, repo: string, issueNumber: number, totalComments: number, limit: number): Promise<GitHubComment[] | GitHubErrorPayload> {
|
|
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<string, unknown> | null {
|
|
if (fields === undefined) return null;
|
|
const summary = issueSummary(issue);
|
|
const selected: Record<string, unknown> = {};
|
|
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<string, unknown> {
|
|
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 <number>; 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<string, unknown> | null = null, options: { includeFullCommentBodies?: boolean } = {}): Promise<GitHubCommandResult> {
|
|
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<string, unknown> | null = null, options: { includeFullCommentBodies?: boolean } = {}): Promise<GitHubCommandResult> {
|
|
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<GitHubCommandResult> {
|
|
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 <commentId> --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 <commentId> --repo ${repo} --full`,
|
|
issue: `bun scripts/cli.ts gh issue view ${issueNumber} --repo ${repo}`,
|
|
},
|
|
};
|
|
}
|
|
|
|
function recordOrEmpty(value: unknown): Record<string, unknown> {
|
|
return value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
|
}
|