Files
pikasTech-unidesk/scripts/src/gh/stale-close.ts
T
2026-06-26 00:16:53 +08:00

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.",
};
}