// 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 { const { owner, name } = repoParts(repo); return githubRequest(token, "PATCH", `/repos/${owner}/${name}/issues/${issueNumber}`, { state: "closed" }); } export async function issueStaleClose(repo: string, token: string, options: GitHubOptions): Promise { 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> = []; 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.", }; }