213 lines
9.8 KiB
TypeScript
213 lines
9.8 KiB
TypeScript
// SPEC: PJ2026-010606 GitHub入口 draft-2026-06-25-gh-split
|
|
// Moved mechanically from scripts/src/gh.ts:7964-8156.
|
|
|
|
import path from "node:path";
|
|
import { ghBinaryPath, repoParts, resolveToken } from "./auth-and-safety";
|
|
import { commandError, errorPayload, githubRequest, isGitHubError, runnerDisposition } from "./client";
|
|
import { issueRead } from "./issue-read";
|
|
import { needsPrGraphqlMetadata, numberOrNull, prCloseoutMetadata, prCloseoutMetadataError, prCompactSummary, prFileSummary, prGraphqlMetadata, prMetadataSummary, prSummary, selectedPrJson, sumPrFileStats } from "./pr-summary";
|
|
import { MAX_PR_FILES_LIMIT, PR_LIST_JSON_FIELDS } from "./types";
|
|
import type { GitHubCommandResult, GitHubDegradedReason, GitHubIssue, GitHubPullRequest, GitHubPullRequestFile, PrListJsonField, PrListState, PrReadJsonField } from "./types";
|
|
|
|
export async function authStatus(repo: string): Promise<GitHubCommandResult> {
|
|
const ghPath = ghBinaryPath();
|
|
const { token, probe } = resolveToken(ghPath !== null);
|
|
const degraded: GitHubDegradedReason[] = [];
|
|
if (ghPath === null) degraded.push("missing-binary");
|
|
if (!probe.present || token === null) {
|
|
if (ghPath === null) {
|
|
return {
|
|
ok: false,
|
|
command: "auth status",
|
|
repo,
|
|
degradedReason: "missing-binary",
|
|
degraded,
|
|
runnerDisposition: runnerDisposition("missing-binary"),
|
|
gh: { binaryFound: false, path: null },
|
|
token: probe,
|
|
probes: { restApi: "skipped", repo: "skipped", issueRead: "skipped" },
|
|
};
|
|
}
|
|
degraded.push("missing-token");
|
|
return commandError("auth status", repo, errorPayload("missing-token", "GH_TOKEN, GITHUB_TOKEN, or the YAML-declared GitHub token source is required", { details: probe }), {
|
|
degraded,
|
|
gh: { binaryFound: ghPath !== null, path: ghPath },
|
|
token: probe,
|
|
probes: { restApi: "skipped", repo: "skipped", issueRead: "skipped" },
|
|
});
|
|
}
|
|
|
|
const { owner, name } = repoParts(repo);
|
|
const api = await githubRequest<Record<string, unknown>>(token, "GET", "/rate_limit");
|
|
if (isGitHubError(api)) {
|
|
return commandError("auth status", repo, api, {
|
|
degraded: [...degraded, api.degradedReason],
|
|
gh: { binaryFound: ghPath !== null, path: ghPath },
|
|
token: probe,
|
|
probes: { restApi: api, repo: "skipped", issueRead: "skipped" },
|
|
});
|
|
}
|
|
|
|
const repoProbe = await githubRequest<{ full_name?: string; private?: boolean }>(token, "GET", `/repos/${owner}/${name}`);
|
|
if (isGitHubError(repoProbe)) {
|
|
return commandError("auth status", repo, repoProbe, {
|
|
degraded: [...degraded, repoProbe.degradedReason],
|
|
gh: { binaryFound: ghPath !== null, path: ghPath },
|
|
token: probe,
|
|
probes: { restApi: "ok", repo: repoProbe, issueRead: "skipped" },
|
|
});
|
|
}
|
|
|
|
const issueProbe = await githubRequest<GitHubIssue[]>(token, "GET", `/repos/${owner}/${name}/issues?per_page=1&state=all`);
|
|
if (isGitHubError(issueProbe)) {
|
|
return commandError("auth status", repo, issueProbe, {
|
|
degraded: [...degraded, issueProbe.degradedReason],
|
|
gh: { binaryFound: ghPath !== null, path: ghPath },
|
|
token: probe,
|
|
probes: { restApi: "ok", repo: { ok: true, fullName: repoProbe.full_name ?? repo, private: repoProbe.private ?? null }, issueRead: issueProbe },
|
|
});
|
|
}
|
|
|
|
return {
|
|
ok: true,
|
|
command: "auth status",
|
|
repo,
|
|
degraded,
|
|
gh: { binaryFound: ghPath !== null, path: ghPath },
|
|
token: probe,
|
|
probes: {
|
|
restApi: "ok",
|
|
repo: { ok: true, fullName: repoProbe.full_name ?? repo, private: repoProbe.private ?? null },
|
|
issueRead: { ok: true, readable: true, sampleCount: issueProbe.length },
|
|
},
|
|
restFallback: true,
|
|
};
|
|
}
|
|
|
|
export async function prList(repo: string, token: string, state: PrListState, limit: number, jsonFields: PrListJsonField[] | undefined, noDump = false): Promise<GitHubCommandResult> {
|
|
const { owner, name } = repoParts(repo);
|
|
const prs = await githubRequest<GitHubPullRequest[]>(token, "GET", `/repos/${owner}/${name}/pulls?state=${state}&per_page=${limit}`);
|
|
if (isGitHubError(prs)) return commandError("pr list", repo, prs, { state, limit });
|
|
const fields = jsonFields ?? PR_LIST_JSON_FIELDS.slice();
|
|
return {
|
|
ok: true,
|
|
command: "pr list",
|
|
repo,
|
|
...(noDump ? { noDump: true } : {}),
|
|
state,
|
|
limit,
|
|
count: prs.length,
|
|
jsonFields: fields,
|
|
pullRequests: prs.map((pr) => selectedPrJson(prSummary(pr), fields)),
|
|
};
|
|
}
|
|
|
|
export async function prFiles(repo: string, token: string, number: number, limit: number, commandName = "pr files"): Promise<GitHubCommandResult> {
|
|
const { owner, name } = repoParts(repo);
|
|
const boundedLimit = Math.min(limit, MAX_PR_FILES_LIMIT);
|
|
const pr = await githubRequest<GitHubPullRequest>(token, "GET", `/repos/${owner}/${name}/pulls/${number}`);
|
|
if (isGitHubError(pr)) return commandError(commandName, repo, pr, { number });
|
|
const perPage = Math.max(1, Math.min(100, boundedLimit));
|
|
const files: GitHubPullRequestFile[] = [];
|
|
let page = 1;
|
|
while (files.length < boundedLimit) {
|
|
const remaining = boundedLimit - files.length;
|
|
const pageSize = Math.min(perPage, remaining);
|
|
const path = `/repos/${owner}/${name}/pulls/${number}/files?per_page=${pageSize}&page=${page}`;
|
|
const pageFiles = await githubRequest<GitHubPullRequestFile[]>(token, "GET", path);
|
|
if (isGitHubError(pageFiles)) return commandError(commandName, repo, pageFiles, { number, phase: "fetch-pr-files", filesReturned: files.length });
|
|
files.push(...pageFiles);
|
|
if (pageFiles.length < pageSize || pageFiles.length === 0) break;
|
|
page += 1;
|
|
}
|
|
const totalFiles = numberOrNull(pr.changed_files);
|
|
const listedStats = sumPrFileStats(files);
|
|
const totalAdditions = numberOrNull(pr.additions);
|
|
const totalDeletions = numberOrNull(pr.deletions);
|
|
const fullStatsAvailable = totalAdditions !== null && totalDeletions !== null;
|
|
const totalChanges = fullStatsAvailable ? totalAdditions + totalDeletions : listedStats.changes;
|
|
const truncated = totalFiles !== null ? files.length < totalFiles : files.length >= boundedLimit;
|
|
const nextLimit = totalFiles === null ? MAX_PR_FILES_LIMIT : Math.min(totalFiles, MAX_PR_FILES_LIMIT);
|
|
const nextCommand = truncated
|
|
? `bun scripts/cli.ts gh pr files ${number} --repo ${repo} --limit ${nextLimit}`
|
|
: `bun scripts/cli.ts gh pr view ${number} --repo ${repo} --json body,title,state,head,base`;
|
|
return {
|
|
ok: true,
|
|
command: commandName,
|
|
repo,
|
|
readOnly: true,
|
|
rawDiffIncluded: false,
|
|
pullRequest: prCompactSummary(pr),
|
|
summary: {
|
|
files: totalFiles ?? files.length,
|
|
additions: totalAdditions ?? listedStats.additions,
|
|
deletions: totalDeletions ?? listedStats.deletions,
|
|
changes: totalChanges,
|
|
commits: numberOrNull(pr.commits),
|
|
source: fullStatsAvailable && totalFiles !== null ? "pull-request-rest" : "listed-files-rest",
|
|
},
|
|
files: files.map(prFileSummary),
|
|
filesReturned: files.length,
|
|
limit: boundedLimit,
|
|
truncation: {
|
|
truncated,
|
|
requestedLimit: limit,
|
|
appliedLimit: boundedLimit,
|
|
returned: files.length,
|
|
totalFiles,
|
|
maxLimit: MAX_PR_FILES_LIMIT,
|
|
},
|
|
next: {
|
|
command: nextCommand,
|
|
purpose: truncated ? "Fetch a larger bounded file summary page." : "Fetch full PR metadata/body; raw diffs remain intentionally excluded.",
|
|
},
|
|
request: {
|
|
method: "GET",
|
|
paths: [
|
|
`/repos/${owner}/${name}/pulls/${number}`,
|
|
`/repos/${owner}/${name}/pulls/${number}/files`,
|
|
],
|
|
},
|
|
};
|
|
}
|
|
|
|
export async function prRead(repo: string, token: string, number: number, jsonFields: PrReadJsonField[] | undefined, commandName = "pr read", disclosure: Record<string, unknown> | null = null): Promise<GitHubCommandResult> {
|
|
const { owner, name } = repoParts(repo);
|
|
const pr = await githubRequest<GitHubPullRequest>(token, "GET", `/repos/${owner}/${name}/pulls/${number}`);
|
|
if (isGitHubError(pr)) return commandError(commandName, repo, pr, { number });
|
|
const fullSummary = prSummary(pr);
|
|
const includeBody = jsonFields === undefined || jsonFields.includes("body");
|
|
const summary = includeBody ? fullSummary : prSummary(pr, { includeBody: false, includePreview: false });
|
|
const metadata = needsPrGraphqlMetadata(jsonFields) ? await prGraphqlMetadata(repo, token, number) : null;
|
|
if (isGitHubError(metadata)) return commandError(commandName, repo, metadata, { number, phase: "fetch-pr-closeout-metadata", pullRequest: summary, requestedJsonFields: jsonFields ?? [], closeoutMetadata: prCloseoutMetadataError(metadata) });
|
|
const selectionSummary = metadata === null ? fullSummary : { ...fullSummary, ...prMetadataSummary(metadata) };
|
|
const selectedJson = jsonFields === undefined ? null : selectedPrJson(selectionSummary, jsonFields);
|
|
if (includeBody && selectedJson !== null) delete selectedJson.body;
|
|
return {
|
|
ok: true,
|
|
command: commandName,
|
|
repo,
|
|
...(disclosure === null ? {} : { disclosure }),
|
|
pullRequest: summary,
|
|
...(metadata === null ? {} : { closeoutMetadata: prCloseoutMetadata(metadata) }),
|
|
...(jsonFields === undefined ? {} : {
|
|
jsonFields,
|
|
json: selectedJson,
|
|
compatibility: {
|
|
legacyJsonBodyPath: includeBody ? ".data.pullRequest.body" : null,
|
|
bodyPath: includeBody ? ".data.pullRequest.body" : null,
|
|
omittedDuplicateBodyPath: includeBody ? ".data.json.body" : null,
|
|
duplicateBodyOmitted: includeBody,
|
|
bodyOmitted: !includeBody,
|
|
bodyMigrationHint: includeBody
|
|
? "Read the full body from .data.pullRequest.body; .data.json.body is intentionally omitted so large bodies are emitted only once."
|
|
: null,
|
|
},
|
|
}),
|
|
};
|
|
}
|
|
|
|
export async function prView(repo: string, token: string, number: number, jsonFields: PrReadJsonField[] | undefined, disclosure: Record<string, unknown> | null = null): Promise<GitHubCommandResult> {
|
|
return prRead(repo, token, number, jsonFields, "pr view", disclosure);
|
|
}
|