edc437cdd8
Co-authored-by: Codex <codex@noreply.local>
143 lines
5.5 KiB
TypeScript
143 lines
5.5 KiB
TypeScript
// SPEC: PJ2026-010606 GitHub入口 draft-2026-06-25-gh-split
|
|
// Moved mechanically from scripts/src/gh.ts:7474-7605.
|
|
|
|
import { repoParts } from "./auth-and-safety";
|
|
import { commandError, githubRequest, isGitHubError, runnerDisposition } from "./client";
|
|
import { issueListPaginationSummary, listIssues } from "./issue-read";
|
|
import { issueLifecycleBatchSummary } from "./issue-summary";
|
|
import { MAX_ISSUE_LIST_LIMIT } from "./types";
|
|
import type { GitHubCommandResult, GitHubDegradedReason, GitHubErrorPayload, GitHubIssue, GitHubOptions, RunnerDisposition } from "./types";
|
|
|
|
export function parseGitHubTimestamp(value: string | undefined): number | null {
|
|
if (value === undefined) return null;
|
|
const millis = Date.parse(value);
|
|
return Number.isFinite(millis) ? millis : null;
|
|
}
|
|
|
|
export function inactiveIssueCandidates(issues: GitHubIssue[], cutoffMs: number): GitHubIssue[] {
|
|
return issues.filter((issue) => {
|
|
const updatedAtMs = parseGitHubTimestamp(issue.updated_at);
|
|
return updatedAtMs !== null && updatedAtMs < cutoffMs;
|
|
});
|
|
}
|
|
|
|
export async function closeIssueForBatch(repo: string, token: string, issueNumber: number): Promise<GitHubIssue | GitHubErrorPayload> {
|
|
const { owner, name } = repoParts(repo);
|
|
return githubRequest<GitHubIssue>(token, "PATCH", `/repos/${owner}/${name}/issues/${issueNumber}`, { state: "closed" });
|
|
}
|
|
|
|
export async function issueStaleClose(repo: string, token: string, options: GitHubOptions): Promise<GitHubCommandResult> {
|
|
const command = "issue stale-close";
|
|
const observedAt = new Date();
|
|
const cutoffMs = observedAt.getTime() - Math.round(options.inactiveHours * 60 * 60 * 1000);
|
|
const cutoffAt = new Date(cutoffMs).toISOString();
|
|
const result = await listIssues(token, repo, "open", options.limit, "", options.labels);
|
|
if (isGitHubError(result)) {
|
|
return commandError(command, repo, result, {
|
|
state: "open",
|
|
limit: options.limit,
|
|
inactiveHours: options.inactiveHours,
|
|
cutoffAt,
|
|
labels: options.labels,
|
|
});
|
|
}
|
|
const staleIssues = inactiveIssueCandidates(result.items, cutoffMs);
|
|
const base = {
|
|
ok: true,
|
|
command,
|
|
repo,
|
|
dryRun: options.dryRun,
|
|
state: "open",
|
|
inactiveHours: options.inactiveHours,
|
|
observedAt: observedAt.toISOString(),
|
|
cutoffAt,
|
|
limit: options.limit,
|
|
labels: options.labels,
|
|
scannedCount: result.items.length,
|
|
rawCount: result.rawCount,
|
|
staleCount: staleIssues.length,
|
|
pagination: issueListPaginationSummary(result),
|
|
hasMore: result.hasMore,
|
|
stale: issueLifecycleBatchSummary(staleIssues),
|
|
policy: {
|
|
basis: "GitHub issue updatedAt",
|
|
selectedWhen: "updatedAt is older than observedAt - inactiveHours",
|
|
commentsAndStateChangesCountAsActivity: true,
|
|
pullRequestsFiltered: true,
|
|
},
|
|
readCommands: {
|
|
dryRun: `bun scripts/cli.ts gh issue stale-close --repo ${repo} --inactive-hours ${options.inactiveHours} --limit ${options.limit} --dry-run`,
|
|
openList: `bun scripts/cli.ts gh issue list --repo ${repo} --state open --limit ${options.limit} --json number,title,state,url,updatedAt`,
|
|
},
|
|
...(result.hasMore && options.limit < MAX_ISSUE_LIST_LIMIT
|
|
? {
|
|
next: {
|
|
command: `bun scripts/cli.ts gh issue stale-close --repo ${repo} --inactive-hours ${options.inactiveHours} --limit ${Math.min(options.limit * 2, MAX_ISSUE_LIST_LIMIT)} --dry-run`,
|
|
note: "The scan reached the bounded --limit before GitHub pagination was exhausted; increase --limit before treating the cleanup as complete.",
|
|
},
|
|
}
|
|
: result.hasMore
|
|
? {
|
|
scanLimit: {
|
|
maxLimitReached: true,
|
|
maxLimit: MAX_ISSUE_LIST_LIMIT,
|
|
note: "The cleanup reached the maximum bounded issue scan; narrow with --label or split the policy before treating it as complete.",
|
|
},
|
|
}
|
|
: {}),
|
|
};
|
|
if (options.dryRun) {
|
|
return {
|
|
...base,
|
|
planned: true,
|
|
wouldCloseCount: staleIssues.length,
|
|
wouldCloseNumbers: staleIssues.map((issue) => issue.number),
|
|
note: staleIssues.length === 0
|
|
? "No open issues matched the inactive-hours policy; no GitHub issue would be closed."
|
|
: "Dry-run only; no GitHub issue was closed.",
|
|
};
|
|
}
|
|
|
|
const closed: GitHubIssue[] = [];
|
|
const failures: Array<Record<string, unknown>> = [];
|
|
for (const issue of staleIssues) {
|
|
const closedIssue = await closeIssueForBatch(repo, token, issue.number);
|
|
if (isGitHubError(closedIssue)) {
|
|
failures.push({
|
|
number: issue.number,
|
|
title: issue.title,
|
|
url: issue.html_url,
|
|
updatedAt: issue.updated_at ?? null,
|
|
degradedReason: closedIssue.degradedReason,
|
|
runnerDisposition: closedIssue.runnerDisposition,
|
|
message: closedIssue.message,
|
|
status: closedIssue.status ?? null,
|
|
});
|
|
continue;
|
|
}
|
|
closed.push(closedIssue);
|
|
}
|
|
|
|
return {
|
|
...base,
|
|
dryRun: false,
|
|
planned: false,
|
|
closedCount: closed.length,
|
|
failedCount: failures.length,
|
|
closed: issueLifecycleBatchSummary(closed),
|
|
failures,
|
|
rest: true,
|
|
ok: failures.length === 0,
|
|
...(failures.length > 0
|
|
? {
|
|
degradedReason: "github-transient" as GitHubDegradedReason,
|
|
runnerDisposition: "infra-blocked" as RunnerDisposition,
|
|
retryable: true,
|
|
}
|
|
: {}),
|
|
note: failures.length === 0
|
|
? "Closed all open issues that matched the inactive-hours policy."
|
|
: "Some stale issue close operations failed; rerun the same command after checking failures.",
|
|
};
|
|
}
|