import { join } from "path"; export type ReviewMode = "off" | "risk" | "always"; export type ReviewStatus = | "SKIPPED" | "REQUESTED" | "ACKNOWLEDGED" | "COMPLETED" | "TIMEOUT" | "BLOCKED" | "ERROR"; export type ReviewCommand = "assess" | "status" | "run"; export interface ReviewOptions { command: ReviewCommand; mode?: ReviewMode; prNumber?: number; ackTimeoutSeconds?: number; completionTimeoutSeconds?: number; pollIntervalSeconds?: number; noWait?: boolean; } export interface PullRequestContext { owner: string; repo: string; number: number; head: string; additions: number; deletions: number; changedFiles: number; files: string[]; url: string; } export interface IssueComment { id: number; body: string; html_url: string; } export interface Reaction { content: string; user?: { login?: string | null } | null; } export interface PullRequestReview { html_url?: string | null; commit_id?: string | null; user?: { login?: string | null } | null; } export interface ReviewThread { id: string; isResolved: boolean; isOutdated: boolean; url: string | null; authorLogin: string | null; reviewCommit: string | null; } export interface ReviewDependencies { now(): number; sleep(ms: number): Promise; loadConfig(key: string): Promise; getPullRequest(prNumber?: number, deadlineMs?: number): Promise; listComments(pr: PullRequestContext, deadlineMs?: number): Promise; listReactions(pr: PullRequestContext, commentId: number, deadlineMs?: number): Promise; listReviews(pr: PullRequestContext, deadlineMs?: number): Promise; listReviewThreads(pr: PullRequestContext, deadlineMs?: number): Promise; createComment(pr: PullRequestContext, body: string, deadlineMs?: number): Promise; progress(message: string): void; } export interface ReviewResult { schema_version: 1; status: ReviewStatus; blocking: boolean; reason: string; message: string; mode: ReviewMode | null; config_source: "default" | "config" | "argument"; pr: { number: number; head: string; url: string } | null; request: { comment_id: number | null; comment_url: string | null; created: boolean }; timeout_stage: "ack" | "completion" | null; unresolved_threads: Array<{ id: string; url: string | null }>; warnings: string[]; elapsed_ms: number; exit_code: 0 | 2 | 64 | 70; } export interface RiskAssessment { required: boolean; reason: string; } interface Snapshot { requestComment: IssueComment | null; acknowledged: boolean; completed: boolean; reviewUrl: string | null; unresolvedThreads: ReviewThread[]; } export class GithubCodexReviewError extends Error { constructor( readonly reasonCode: string, message: string, ) { super(message); this.name = "GithubCodexReviewError"; } } export const DEFAULT_ACK_TIMEOUT_SECONDS = 120; export const DEFAULT_COMPLETION_TIMEOUT_SECONDS = 600; export const DEFAULT_POLL_INTERVAL_SECONDS = 5; const BOT_LOGIN = "chatgpt-codex-connector"; const DOC_ONLY_PREFIXES = ["docs/", ".github/ISSUE_TEMPLATE/"]; const SENSITIVE_SEGMENTS = new Set([ "auth", "security", "permissions", "secrets", "migrations", "migrate", "schema", "database", "db", "deploy", "infra", "infrastructure", "terraform", "k8s", "kubernetes", ]); export function isCodexBotLogin(login: string | null | undefined): boolean { const normalized = (login || "").toLowerCase().replace(/\[bot\]$/, ""); return normalized === BOT_LOGIN; } export function markerFor(head: string): string { return ``; } function isDocsOnlyPath(file: string): boolean { if (DOC_ONLY_PREFIXES.some((prefix) => file.startsWith(prefix))) return true; const basename = file.split("/").pop() || file; return basename.startsWith("README") || basename.startsWith("CHANGELOG") || file === "VERSION"; } function isSensitivePath(file: string): boolean { if (file.startsWith(".github/workflows/")) return true; return file.split("/").some((segment) => SENSITIVE_SEGMENTS.has(segment.toLowerCase())); } export function assessRisk(mode: ReviewMode, pr: PullRequestContext): RiskAssessment { if (mode === "off") return { required: false, reason: "mode_off" }; if (pr.changedFiles === 0 && pr.additions + pr.deletions === 0) { return { required: false, reason: "empty_pr" }; } if (mode === "always") return { required: true, reason: "mode_always" }; if (pr.files.length > 0 && pr.files.every(isDocsOnlyPath)) { return { required: false, reason: "docs_only" }; } if (pr.changedFiles >= 10) return { required: true, reason: "changed_files_threshold" }; if (pr.additions + pr.deletions >= 200) return { required: true, reason: "changed_lines_threshold" }; if (pr.files.some(isSensitivePath)) return { required: true, reason: "sensitive_path" }; return { required: false, reason: "low_risk" }; } function emptyResult(startedAt: number, source: ReviewResult["config_source"]): ReviewResult { return { schema_version: 1, status: "ERROR", blocking: false, reason: "uninitialized", message: "GitHub Codex Review 状态尚未初始化。", mode: null, config_source: source, pr: null, request: { comment_id: null, comment_url: null, created: false }, timeout_stage: null, unresolved_threads: [], warnings: [], elapsed_ms: Math.max(0, Date.now() - startedAt), exit_code: 0, }; } function finish(result: ReviewResult, deps: ReviewDependencies, startedAt: number): ReviewResult { result.elapsed_ms = Math.max(0, deps.now() - startedAt); return result; } function positiveNumber(raw: string, key: string): number { const value = Number(raw); if (!Number.isFinite(value) || value <= 0) { throw new GithubCodexReviewError("invalid_config", `${key} 必须是正数,当前值为 ${JSON.stringify(raw)}。`); } return value; } async function resolveOptions( options: ReviewOptions, deps: ReviewDependencies, ): Promise<{ mode: ReviewMode; source: ReviewResult["config_source"]; ackTimeoutSeconds: number; completionTimeoutSeconds: number; pollIntervalSeconds: number; }> { const source = options.mode ? "argument" : "config"; const modeRaw = options.mode || (await deps.loadConfig("github_codex_review")) || "off"; if (modeRaw !== "off" && modeRaw !== "risk" && modeRaw !== "always") { throw new GithubCodexReviewError( "invalid_config", `github_codex_review 值 ${JSON.stringify(modeRaw)} 无效;允许值:off、risk、always。`, ); } const ackTimeoutSeconds = options.ackTimeoutSeconds ?? positiveNumber( (await deps.loadConfig("github_codex_ack_timeout_seconds")) || String(DEFAULT_ACK_TIMEOUT_SECONDS), "github_codex_ack_timeout_seconds", ); const completionTimeoutSeconds = options.completionTimeoutSeconds ?? positiveNumber( (await deps.loadConfig("github_codex_completion_timeout_seconds")) || String(DEFAULT_COMPLETION_TIMEOUT_SECONDS), "github_codex_completion_timeout_seconds", ); const pollIntervalSeconds = options.pollIntervalSeconds ?? DEFAULT_POLL_INTERVAL_SECONDS; for (const [key, value] of [ ["ack timeout", ackTimeoutSeconds], ["completion timeout", completionTimeoutSeconds], ["poll interval", pollIntervalSeconds], ] as const) { if (!Number.isFinite(value) || value <= 0) { throw new GithubCodexReviewError("invalid_argument", `${key} 必须是正数。`); } } return { mode: modeRaw, source, ackTimeoutSeconds, completionTimeoutSeconds, pollIntervalSeconds }; } async function readSnapshot( deps: ReviewDependencies, pr: PullRequestContext, deadlineMs?: number, ): Promise { const marker = markerFor(pr.head); const [comments, reviews] = await Promise.all([ deps.listComments(pr, deadlineMs), deps.listReviews(pr, deadlineMs), ]); const requestComment = comments.find((comment) => comment.body.includes(marker)) || null; const currentReviews = reviews.filter( (review) => isCodexBotLogin(review.user?.login) && review.commit_id === pr.head, ); let acknowledged = false; let reactionCompleted = false; if (requestComment) { const reactions = await deps.listReactions(pr, requestComment.id, deadlineMs); acknowledged = reactions.some( (reaction) => reaction.content === "eyes" && isCodexBotLogin(reaction.user?.login), ); reactionCompleted = reactions.some( (reaction) => reaction.content === "+1" && isCodexBotLogin(reaction.user?.login), ); } let unresolvedThreads: ReviewThread[] = []; if (currentReviews.length > 0) { try { const threads = await deps.listReviewThreads(pr, deadlineMs); unresolvedThreads = threads.filter( (thread) => !thread.isResolved && !thread.isOutdated && isCodexBotLogin(thread.authorLogin) && thread.reviewCommit === pr.head, ); } catch (error) { const message = error instanceof Error ? error.message : String(error); throw new GithubCodexReviewError( "review_threads_unavailable", `已找到当前 HEAD 的 Codex Review,但无法确认 review thread 状态:${message}`, ); } } return { requestComment, acknowledged, completed: reactionCompleted || currentReviews.length > 0, reviewUrl: currentReviews[0]?.html_url || null, unresolvedThreads, }; } function applySnapshot(result: ReviewResult, snapshot: Snapshot): ReviewResult { result.request = { comment_id: snapshot.requestComment?.id || null, comment_url: snapshot.requestComment?.html_url || null, created: result.request.created, }; result.unresolved_threads = snapshot.unresolvedThreads.map((thread) => ({ id: thread.id, url: thread.url })); if (snapshot.unresolvedThreads.length > 0) { result.status = "BLOCKED"; result.blocking = true; result.reason = "unresolved_current_head_threads"; result.message = `当前 HEAD 有 ${snapshot.unresolvedThreads.length} 个未解决的 Codex Review thread。`; result.exit_code = 2; } else if (snapshot.completed) { result.status = "COMPLETED"; result.blocking = false; result.reason = "current_head_review_completed"; result.message = snapshot.reviewUrl ? `当前 HEAD 的 Codex Review 已完成:${snapshot.reviewUrl}` : "目标机器人已通过 👍 确认当前 HEAD 无建议。"; result.exit_code = 0; } else if (snapshot.acknowledged) { result.status = "ACKNOWLEDGED"; result.reason = "request_acknowledged"; result.message = "目标机器人已确认收到当前 HEAD 的 Review 请求。"; } else if (snapshot.requestComment) { result.status = "REQUESTED"; result.reason = "request_pending_ack"; result.message = "当前 HEAD 已请求 Codex Review,正在等待机器人确认。"; } else { result.status = "SKIPPED"; result.reason = "not_requested"; result.message = "当前 HEAD 尚未请求 Codex Review。"; } return result; } async function createCommentSafely( deps: ReviewDependencies, pr: PullRequestContext, deadlineMs: number, ): Promise<{ comment: IssueComment; created: boolean }> { const marker = markerFor(pr.head); const body = `${marker}\n@codex review`; let lastError: unknown; for (let attempt = 1; attempt <= 3; attempt++) { try { return { comment: await deps.createComment(pr, body, deadlineMs), created: true }; } catch (error) { lastError = error; const comments = await deps.listComments(pr, deadlineMs); const existing = comments.find((comment) => comment.body.includes(marker)); if (existing) return { comment: existing, created: false }; if (attempt < 3) { const remaining = deadlineMs - deps.now(); if (remaining <= 0) break; await deps.sleep(Math.min(250 * 2 ** (attempt - 1), remaining)); } } } throw lastError instanceof Error ? lastError : new GithubCodexReviewError("comment_create_failed", "无法创建 Codex Review 请求评论。 "); } async function sleepUntilNextPoll( deps: ReviewDependencies, pollIntervalSeconds: number, deadlineMs: number, ): Promise { const remaining = deadlineMs - deps.now(); if (remaining <= 0) return false; await deps.sleep(Math.min(pollIntervalSeconds * 1000, remaining)); return deps.now() < deadlineMs; } function errorResult( base: ReviewResult, error: unknown, deps: ReviewDependencies, startedAt: number, ): ReviewResult { const known = error instanceof GithubCodexReviewError; base.status = "ERROR"; base.blocking = false; base.reason = known ? error.reasonCode : "unexpected_error"; base.message = known ? error.message : `GitHub Codex Review 检查失败:${error instanceof Error ? error.message : String(error)}`; base.warnings.push(base.message); base.exit_code = known && (error.reasonCode === "invalid_argument" || error.reasonCode === "invalid_config") ? 64 : 0; return finish(base, deps, startedAt); } export async function runGithubCodexReview( options: ReviewOptions, deps: ReviewDependencies, ): Promise { const startedAt = deps.now(); let result = emptyResult(startedAt, options.mode ? "argument" : "config"); try { const resolved = await resolveOptions(options, deps); result.mode = resolved.mode; result.config_source = resolved.source; if (resolved.mode === "off") { result.status = "SKIPPED"; result.reason = "mode_off"; result.message = "GitHub Codex Review 已关闭;未访问 GitHub,也未创建请求。"; return finish(result, deps, startedAt); } const pr = await deps.getPullRequest(options.prNumber); result.pr = { number: pr.number, head: pr.head, url: pr.url }; const assessment = assessRisk(resolved.mode, pr); if (!assessment.required) { result.status = "SKIPPED"; result.reason = assessment.reason; result.message = assessment.reason === "docs_only" ? "该 PR 仅包含文档或版本文件,按 risk 模式跳过云端 Review。" : "该 PR 未命中 GitHub Codex Review 风险规则。"; return finish(result, deps, startedAt); } if (options.command === "assess") { result.status = "REQUESTED"; result.reason = assessment.reason; result.message = "该 PR 命中规则,需要 GitHub Codex Review。"; return finish(result, deps, startedAt); } let snapshot = await readSnapshot(deps, pr); applySnapshot(result, snapshot); if (options.command === "status" || result.status === "BLOCKED" || result.status === "COMPLETED") { return finish(result, deps, startedAt); } if (!snapshot.requestComment) { const createDeadline = deps.now() + Math.max(30_000, resolved.pollIntervalSeconds * 1000 * 3); deps.progress(`正在为 PR #${pr.number} 的当前 HEAD 请求 GitHub Codex Review...`); const created = await createCommentSafely(deps, pr, createDeadline); result.request = { comment_id: created.comment.id, comment_url: created.comment.html_url, created: created.created, }; snapshot = { ...snapshot, requestComment: created.comment }; applySnapshot(result, snapshot); result.request.created = created.created; } if (options.noWait) return finish(result, deps, startedAt); if (!snapshot.acknowledged) { const ackDeadline = deps.now() + resolved.ackTimeoutSeconds * 1000; deps.progress(`已请求 Review;最多等待 ${resolved.ackTimeoutSeconds} 秒取得 ACK...`); while (true) { snapshot = await readSnapshot(deps, pr, ackDeadline); applySnapshot(result, snapshot); if (result.status === "BLOCKED" || result.status === "COMPLETED") { return finish(result, deps, startedAt); } if (result.status === "ACKNOWLEDGED") break; if (!(await sleepUntilNextPoll(deps, resolved.pollIntervalSeconds, ackDeadline))) { result.status = "TIMEOUT"; result.reason = "ack_timeout"; result.message = `等待 ${resolved.ackTimeoutSeconds} 秒仍未取得机器人 ACK;继续依赖本地 Review、测试和 CI。`; result.timeout_stage = "ack"; result.warnings.push(result.message); return finish(result, deps, startedAt); } } } const completionDeadline = deps.now() + resolved.completionTimeoutSeconds * 1000; deps.progress(`已取得 ACK;最多再等待 ${resolved.completionTimeoutSeconds} 秒完成 Review...`); while (true) { snapshot = await readSnapshot(deps, pr, completionDeadline); applySnapshot(result, snapshot); if (result.status === "BLOCKED" || result.status === "COMPLETED") { return finish(result, deps, startedAt); } if (!(await sleepUntilNextPoll(deps, resolved.pollIntervalSeconds, completionDeadline))) { result.status = "TIMEOUT"; result.reason = "completion_timeout"; result.message = `取得 ACK 后等待 ${resolved.completionTimeoutSeconds} 秒仍未完成 Review;继续依赖本地 Review、测试和 CI。`; result.timeout_stage = "completion"; result.warnings.push(result.message); return finish(result, deps, startedAt); } } } catch (error) { return errorResult(result, error, deps, startedAt); } } interface GhRunResult { stdout: string; stderr: string; } function reasonForGhError(stderr: string): string { const lower = stderr.toLowerCase(); if (lower.includes("authentication") || lower.includes("not logged") || lower.includes("gh auth login")) { return "gh_auth_required"; } if (lower.includes("rate limit")) return "github_rate_limited"; if (lower.includes("could not resolve") || lower.includes("network") || lower.includes("timeout")) { return "github_unavailable"; } return "github_api_error"; } async function executeGh(args: string[], timeoutMs: number): Promise { const process = Bun.spawn(["gh", ...args], { stdout: "pipe", stderr: "pipe" }); // Drain both pipes while the child is running. Waiting for exit first can // deadlock when a paginated GitHub response fills the OS pipe buffer. const stdoutPromise = new Response(process.stdout).text(); const stderrPromise = new Response(process.stderr).text(); let timeout: ReturnType | undefined; const timedOut = new Promise<"timeout">((resolve) => { timeout = setTimeout(() => resolve("timeout"), Math.max(1, timeoutMs)); }); const outcome = await Promise.race([process.exited.then(() => "exited" as const), timedOut]); if (timeout) clearTimeout(timeout); if (outcome === "timeout") { process.kill(9); await process.exited.catch(() => undefined); await Promise.allSettled([stdoutPromise, stderrPromise]); throw new GithubCodexReviewError("github_unavailable", `gh 调用超过 ${timeoutMs}ms,已终止。`); } const [stdout, stderr] = await Promise.all([stdoutPromise, stderrPromise]); if (process.exitCode !== 0) { const trimmed = stderr.trim() || stdout.trim() || `gh exited ${process.exitCode}`; const reason = reasonForGhError(trimmed); const fix = reason === "gh_auth_required" ? " 运行:gh auth login。" : " 请检查 GitHub CLI、网络和仓库权限。"; throw new GithubCodexReviewError(reason, `${trimmed}.${fix}`); } return { stdout, stderr }; } async function ghText( args: string[], deadlineMs?: number, attempts = 3, ): Promise { const effectiveDeadline = deadlineMs ?? performance.now() + 30_000; let lastError: unknown; for (let attempt = 1; attempt <= attempts; attempt++) { const remaining = effectiveDeadline - performance.now(); if (remaining <= 0) { throw new GithubCodexReviewError("github_unavailable", "GitHub API 调用已达到当前阶段 deadline。 "); } try { return (await executeGh(args, remaining)).stdout; } catch (error) { lastError = error; if (attempt < attempts) { const delay = Math.min(250 * 2 ** (attempt - 1), Math.max(0, remaining)); if (delay > 0) await new Promise((resolve) => setTimeout(resolve, delay)); } } } throw lastError; } async function ghJson(args: string[], deadlineMs?: number, attempts = 3): Promise { const text = await ghText(args, deadlineMs, attempts); try { return JSON.parse(text) as T; } catch { throw new GithubCodexReviewError("github_api_error", `gh 返回了无法解析的 JSON:${text.slice(0, 200)}`); } } function flattenSlurpedPages(value: unknown): T[] { if (!Array.isArray(value)) return []; if (value.every(Array.isArray)) return value.flat() as T[]; return value as T[]; } export function createRealDependencies(): ReviewDependencies { const configBin = join(import.meta.dir, "../bin/gstack-config"); return { now: () => performance.now(), sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)), progress: (message) => console.error(message), loadConfig: async (key) => { const process = Bun.spawn([configBin, "get", key], { stdout: "pipe", stderr: "pipe" }); const [stdout, stderr, code] = await Promise.all([ new Response(process.stdout).text(), new Response(process.stderr).text(), process.exited, ]); if (code !== 0) throw new GithubCodexReviewError("invalid_config", stderr.trim() || `无法读取配置 ${key}`); return stdout.trim(); }, getPullRequest: async (prNumber, deadlineMs) => { const repoView = await ghJson<{ nameWithOwner: string }>(["repo", "view", "--json", "nameWithOwner"], deadlineMs); const [owner, repo] = repoView.nameWithOwner.split("/"); if (!owner || !repo) throw new GithubCodexReviewError("pr_not_found", "无法识别当前 GitHub 仓库。 "); const prArgs = ["pr", "view"]; if (prNumber != null) prArgs.push(String(prNumber)); prArgs.push("--json", "number,headRefOid,additions,deletions,changedFiles,url"); const info = await ghJson<{ number: number; headRefOid: string; additions: number; deletions: number; changedFiles: number; url: string; }>(prArgs, deadlineMs); const pages = await ghJson([ "api", "--paginate", "--slurp", `repos/${owner}/${repo}/pulls/${info.number}/files?per_page=100`, ], deadlineMs); const files = flattenSlurpedPages<{ filename?: string }>(pages) .map((file) => file.filename || "") .filter(Boolean); return { owner, repo, number: info.number, head: info.headRefOid, additions: info.additions, deletions: info.deletions, changedFiles: info.changedFiles, files, url: info.url, }; }, listComments: async (pr, deadlineMs) => { const pages = await ghJson([ "api", "--paginate", "--slurp", `repos/${pr.owner}/${pr.repo}/issues/${pr.number}/comments?per_page=100`, ], deadlineMs); return flattenSlurpedPages(pages); }, listReactions: async (pr, commentId, deadlineMs) => { const pages = await ghJson([ "api", "--paginate", "--slurp", "-H", "Accept: application/vnd.github+json", `repos/${pr.owner}/${pr.repo}/issues/comments/${commentId}/reactions?per_page=100`, ], deadlineMs); return flattenSlurpedPages(pages); }, listReviews: async (pr, deadlineMs) => { const pages = await ghJson([ "api", "--paginate", "--slurp", `repos/${pr.owner}/${pr.repo}/pulls/${pr.number}/reviews?per_page=100`, ], deadlineMs); return flattenSlurpedPages(pages); }, listReviewThreads: async (pr, deadlineMs) => { const query = `query($owner:String!,$repo:String!,$number:Int!,$endCursor:String){ repository(owner:$owner,name:$repo){ pullRequest(number:$number){ reviewThreads(first:100,after:$endCursor){ nodes{ id isResolved isOutdated comments(first:1){nodes{url author{login} pullRequestReview{commit{oid}}}} } pageInfo{hasNextPage endCursor} } } } }`; const pages = await ghJson; }; }>; }; }; }; }; }>>([ "api", "graphql", "--paginate", "--slurp", "-f", `query=${query}`, "-F", `owner=${pr.owner}`, "-F", `repo=${pr.repo}`, "-F", `number=${pr.number}`, ], deadlineMs); return pages.flatMap((page) => page.data?.repository?.pullRequest?.reviewThreads?.nodes || []) .map((thread) => { const first = thread.comments?.nodes?.[0]; return { id: thread.id, isResolved: thread.isResolved, isOutdated: thread.isOutdated, url: first?.url || null, authorLogin: first?.author?.login || null, reviewCommit: first?.pullRequestReview?.commit?.oid || null, }; }); }, createComment: async (pr, body, deadlineMs) => ghJson([ "api", "-X", "POST", `repos/${pr.owner}/${pr.repo}/issues/${pr.number}/comments`, "-f", `body=${body}`, ], deadlineMs, 1), }; }