Files
pikasTech-unidesk/scripts/src/gh/auth-pr-read.ts
T
2026-07-10 06:27:46 +02:00

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);
}