diff --git a/CHANGELOG.md b/CHANGELOG.md index 351e7cabd..f1cb2396d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,25 @@ # Changelog +## [1.61.0.0] - 2026-07-14 + +## **GitHub Codex Review 现在可以作为可选、限时且可核验的合并门禁。** + +`/land-and-deploy` 新增了一个默认关闭的 GitHub Codex Review 检查。开启后,它只认可 `chatgpt-codex-connector[bot]` 对当前 PR HEAD 的反馈,通过 HEAD 标记避免重复请求,并分别限制确认与完成等待时间。云端超时或 API 异常只会给出明确告警,不会把未知状态伪装成通过;但当前 HEAD 上尚未解决的 Codex Review 线程会阻止合并。 + +### 主要变化 + +#### Added + +- 新增 `gstack-github-codex-review` CLI,提供 `assess`、`status` 和 `run` 三种模式;stdout 始终输出单个 JSON 文档,适合流程编排。 +- 新增 `github_codex_review: off | risk | always` 配置,以及确认、完成两个正整数超时配置;默认 `off`,不会访问 GitHub 或消耗云端 Review 额度。 +- `/land-and-deploy` 接入有界检查,并区分 `COMPLETED`、`TIMEOUT`、`ERROR`、`BLOCKED` 等确定状态。 +- `setup` 会把共享 `lib/` 与 `bin/` 一起安装到 Agents、Codex、Factory、OpenCode 和 Kiro 运行目录,保证 CLI 的模块依赖完整。 + +#### Verification + +- 423 项定向测试覆盖风险判定、幂等请求、当前 HEAD 过滤、机器人身份校验、REST/GraphQL 分页、大响应管道、超时和阻塞语义。 +- 完整测试套件、构建、生成文档一致性、shell 语法和 `git diff --check` 均通过。 + ## [1.60.1.0] - 2026-07-09 ## **The /autoplan dual-voice eval is back on the board, catching real regressions.** diff --git a/README.md b/README.md index 4bb177c3a..a29442708 100644 --- a/README.md +++ b/README.md @@ -245,6 +245,7 @@ Beyond the slash-command skills, gstack ships standalone CLIs for workflows that | `gstack-taste-update` | **Design taste learning** — writes approvals and rejections from `/design-shotgun` into a persistent per-project taste profile. Decays 5%/week. Feeds back into future variant generation so the system learns what you actually pick. | | `gstack-ios-qa-daemon` | **iOS QA daemon** — Mac-side broker between an agent and a connected iPhone over USB CoreDevice. Loopback by default; `--tailnet` opens a Tailscale-facing listener with identity-gated capability tiers. Single-instance via flock on `~/.gstack/ios-qa-daemon.pid`. See [docs/howto-ios-testing-with-gstack.md](docs/howto-ios-testing-with-gstack.md). | | `gstack-ios-qa-mint` | **iOS allowlist manager** — owner-grant CLI for the tailnet allowlist. `grant`/`revoke`/`list` against `~/.gstack/ios-qa-allowlist.json` (mode 0600). Remote agents never auto-allowlist; this is the explicit-intent path. | +| `gstack-github-codex-review` | **Bounded GitHub Codex Review gate** — assess PR risk, inspect the connector for the current head SHA, or idempotently request and wait for review. Emits exactly one JSON object on stdout for automation. | ### Continuous checkpoint mode (opt-in, local by default) diff --git a/VERSION b/VERSION index c4190e004..82b3fa4c6 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.60.1.0 +1.61.0.0 diff --git a/bin/gstack-config b/bin/gstack-config index 15c7300c5..6bfdfe3a7 100755 --- a/bin/gstack-config +++ b/bin/gstack-config @@ -96,6 +96,15 @@ CONFIG_HEADER='# gstack configuration — edit freely, changes take effect on ne # # /document-release skip the outside-voice step entirely. # # An invalid value is REJECTED (existing value preserved) so # # a typo cannot silently turn paid Codex calls on or off. +# +# github_codex_review: off # off | risk | always. Controls GitHub Connector +# # Review requested from /land-and-deploy. This is +# # separate from local cross-model codex_reviews. +# # risk/always may consume GitHub Codex Review quota. +# github_codex_ack_timeout_seconds: 120 +# # Positive integer. Max wait for the bot ACK. +# github_codex_completion_timeout_seconds: 600 +# # Positive integer. Max wait after ACK for completion. # gstack_contributor: false # true = file field reports when gstack misbehaves # skip_eng_review: false # true = skip eng review gate in /ship (not recommended) # @@ -123,6 +132,9 @@ lookup_default() { checkpoint_push) echo "false" ;; explain_level) echo "default" ;; codex_reviews) echo "enabled" ;; + github_codex_review) echo "off" ;; + github_codex_ack_timeout_seconds) echo "120" ;; + github_codex_completion_timeout_seconds) echo "600" ;; gstack_contributor) echo "false" ;; skip_eng_review) echo "false" ;; workspace_root) echo "$HOME/conductor/workspaces" ;; @@ -321,6 +333,17 @@ case "${1:-}" in echo "Error: codex_reviews '$VALUE' not recognized. Valid values: enabled, disabled. Existing value left unchanged." >&2 exit 1 fi + # GitHub Connector Review can consume quota. Invalid values must never + # silently change whether it runs or how long /land-and-deploy waits. + if [ "$KEY" = "github_codex_review" ] && [ "$VALUE" != "off" ] && [ "$VALUE" != "risk" ] && [ "$VALUE" != "always" ]; then + echo "Error: github_codex_review '$VALUE' not recognized. Valid values: off, risk, always. Existing value left unchanged." >&2 + exit 1 + fi + if { [ "$KEY" = "github_codex_ack_timeout_seconds" ] || [ "$KEY" = "github_codex_completion_timeout_seconds" ]; } && \ + ! printf '%s' "$VALUE" | grep -qE '^[1-9][0-9]*$'; then + echo "Error: $KEY must be a positive integer. Existing value left unchanged." >&2 + exit 1 + fi mkdir -p "$STATE_DIR" # Write annotated header on first creation if [ ! -f "$CONFIG_FILE" ]; then @@ -349,7 +372,9 @@ case "${1:-}" in echo "# ─── Active values (including defaults for unset keys) ───" for KEY in proactive routing_declined telemetry auto_upgrade update_check \ skill_prefix checkpoint_mode checkpoint_push explain_level \ - codex_reviews gstack_contributor skip_eng_review workspace_root \ + codex_reviews github_codex_review \ + github_codex_ack_timeout_seconds github_codex_completion_timeout_seconds \ + gstack_contributor skip_eng_review workspace_root \ artifacts_sync_mode artifacts_sync_mode_prompted plan_tune_hooks; do VALUE=$(grep -E "^${KEY}:" "$CONFIG_FILE" 2>/dev/null | tail -1 | awk '{print $2}' | tr -d '[:space:]' || true) SOURCE="default" @@ -365,7 +390,9 @@ case "${1:-}" in echo "# gstack-config defaults" for KEY in proactive routing_declined telemetry auto_upgrade update_check \ skill_prefix checkpoint_mode checkpoint_push explain_level \ - codex_reviews gstack_contributor skip_eng_review workspace_root \ + codex_reviews github_codex_review \ + github_codex_ack_timeout_seconds github_codex_completion_timeout_seconds \ + gstack_contributor skip_eng_review workspace_root \ artifacts_sync_mode artifacts_sync_mode_prompted plan_tune_hooks; do printf ' %-24s %s\n' "$KEY:" "$(lookup_default "$KEY")" done diff --git a/bin/gstack-github-codex-review b/bin/gstack-github-codex-review new file mode 100755 index 000000000..e29c3ead8 --- /dev/null +++ b/bin/gstack-github-codex-review @@ -0,0 +1,124 @@ +#!/usr/bin/env bun + +import { + createRealDependencies, + GithubCodexReviewError, + runGithubCodexReview, + type ReviewCommand, + type ReviewMode, + type ReviewOptions, + type ReviewResult, +} from "../lib/github-codex-review"; + +function usage(): string { + return `gstack-github-codex-review — 有界 GitHub Codex Review 辅助检查 + +Usage: + gstack-github-codex-review assess [options] + gstack-github-codex-review status [options] + gstack-github-codex-review run [options] + +Options: + --mode off|risk|always 覆盖本次模式;默认读取 gstack-config + --pr 指定 PR;默认从当前分支发现 + --ack-timeout 等待机器人 ACK;默认 120 + --completion-timeout ACK 后等待完成;默认 600 + --poll-interval 轮询间隔;默认 5 + --no-wait 创建或复用请求后立即返回 + --help 显示帮助 + +最快启用: + gstack-config set github_codex_review risk + gstack-github-codex-review assess + +该功能默认关闭。risk/always 可能消耗 GitHub Codex Review 额度。 +stdout 始终输出单个 JSON;运行进度写入 stderr。 +`; +} + +function parsePositive(raw: string | undefined, flag: string): number { + const value = Number(raw); + if (!raw || !Number.isFinite(value) || value <= 0) { + throw new GithubCodexReviewError("invalid_argument", `${flag} 需要正数。`); + } + return value; +} + +function parseArgs(argv: string[]): ReviewOptions | "help" { + if (argv.includes("--help") || argv.includes("-h")) return "help"; + const command = argv.shift() as ReviewCommand | undefined; + if (command !== "assess" && command !== "status" && command !== "run") { + throw new GithubCodexReviewError("invalid_argument", "首个参数必须是 assess、status 或 run。 "); + } + const options: ReviewOptions = { command }; + while (argv.length > 0) { + const flag = argv.shift(); + switch (flag) { + case "--mode": { + const mode = argv.shift() as ReviewMode | undefined; + if (mode !== "off" && mode !== "risk" && mode !== "always") { + throw new GithubCodexReviewError("invalid_argument", "--mode 允许值:off、risk、always。 "); + } + options.mode = mode; + break; + } + case "--pr": { + const value = parsePositive(argv.shift(), "--pr"); + if (!Number.isInteger(value)) throw new GithubCodexReviewError("invalid_argument", "--pr 需要正整数。 "); + options.prNumber = value; + break; + } + case "--ack-timeout": + options.ackTimeoutSeconds = parsePositive(argv.shift(), "--ack-timeout"); + break; + case "--completion-timeout": + options.completionTimeoutSeconds = parsePositive(argv.shift(), "--completion-timeout"); + break; + case "--poll-interval": + options.pollIntervalSeconds = parsePositive(argv.shift(), "--poll-interval"); + break; + case "--no-wait": + options.noWait = true; + break; + default: + throw new GithubCodexReviewError("invalid_argument", `未知参数:${flag}`); + } + } + return options; +} + +function argumentError(error: unknown): ReviewResult { + const message = error instanceof Error ? error.message : String(error); + return { + schema_version: 1, + status: "ERROR", + blocking: false, + reason: error instanceof GithubCodexReviewError ? error.reasonCode : "invalid_argument", + message, + mode: null, + config_source: "argument", + pr: null, + request: { comment_id: null, comment_url: null, created: false }, + timeout_stage: null, + unresolved_threads: [], + warnings: [message], + elapsed_ms: 0, + exit_code: 64, + }; +} + +try { + const parsed = parseArgs(process.argv.slice(2)); + if (parsed === "help") { + process.stdout.write(usage()); + } else { + const result = await runGithubCodexReview(parsed, createRealDependencies()); + process.stdout.write(`${JSON.stringify(result)}\n`); + process.exitCode = result.exit_code; + } +} catch (error) { + const result = argumentError(error); + process.stdout.write(`${JSON.stringify(result)}\n`); + console.error(`${result.message}\n\n${usage()}`); + process.exitCode = result.exit_code; +} diff --git a/docs/skills.md b/docs/skills.md index 8e8cb7adc..47c5ae50c 100644 --- a/docs/skills.md +++ b/docs/skills.md @@ -675,6 +675,17 @@ First run on a new project triggers a dry-run walk-through so you can verify the Run `/setup-deploy` first. It detects your platform (Fly.io, Render, Vercel, Netlify, Heroku, GitHub Actions, or custom), discovers your production URL and health check endpoints, and writes the config to CLAUDE.md. One-time, 60 seconds. +### Optional GitHub Codex Review gate + +The gate is disabled by default. Set `github_codex_review` to `risk` to request connector review only for large or sensitive changes, or to `always` for every non-empty PR. The helper only accepts reviews from `chatgpt-codex-connector[bot]` for the current head SHA, avoids duplicate requests with a head marker, and uses bounded waits (120 seconds for acknowledgement, 600 seconds for completion). API failures and timeouts warn without blocking; known unresolved current-head findings block landing. + +```bash +gstack-config set github_codex_review risk +gstack-github-codex-review assess +gstack-github-codex-review status +gstack-github-codex-review run +``` + ### Example ``` diff --git a/land-and-deploy/SKILL.md b/land-and-deploy/SKILL.md index 54ebf52c0..4b67f71cc 100644 --- a/land-and-deploy/SKILL.md +++ b/land-and-deploy/SKILL.md @@ -1324,6 +1324,43 @@ and tell the user: "I found and fixed a few issues during the review. The fixes **If review is CURRENT:** Skip this sub-step entirely — no question asked. +### 3.5a-ter: GitHub Codex Review (opt-in, bounded) + +Run the deterministic helper before the test checks. It reads +`github_codex_review` (`off | risk | always`) and timeout defaults from +`gstack-config`; `off` is the default and does not access GitHub or spend cloud +Review quota. + +```bash +_GSTACK_CODEX_RESULT=$(mktemp /tmp/gstack-github-codex-review-XXXXXXXX) +set +e +"$GSTACK_BIN/gstack-github-codex-review" run > "$_GSTACK_CODEX_RESULT" +_GSTACK_CODEX_EXIT=$? +set -e +cat "$_GSTACK_CODEX_RESULT" +echo "HELPER_EXIT_CODE=$_GSTACK_CODEX_EXIT" +rm -f "$_GSTACK_CODEX_RESULT" +``` + +Parse the single JSON document and record its status in the readiness report: + +- `SKIPPED`: informational only. Do not add a warning. +- `COMPLETED`: current HEAD has a completed GitHub Codex Review (or bot 👍) + and no known unresolved current-HEAD thread. +- `TIMEOUT`: add a warning with `timeout_stage` and `message`; continue using + local review, tests, and CI. Never keep polling after the helper returns. +- `ERROR`: add a warning with `reason` and `message`; this means cloud status is + unknown, not passed. Continue using local review, tests, and CI. +- `BLOCKED` or helper exit `2`: add every `unresolved_threads[].url` as a + **BLOCKER** and do not merge. +- `REQUESTED` / `ACKNOWLEDGED`: show the status. These normally appear only in + explicit `--no-wait` use; they are not proof of completion. +- Helper exit `64`: configuration/argument error. Add a warning with the exact + fix from `message`; do not invent a status. + +This is a gstack merge-path auxiliary check, not a repository-level required +check. It does not cover merges performed outside `/land-and-deploy`. + ### 3.5b: Test results **Free tests — run them now:** @@ -1419,7 +1456,8 @@ Build the full readiness report: ║ ├─ Eng Review: CURRENT / STALE (N commits) / — ║ ║ ├─ CEO Review: CURRENT / — (optional) ║ ║ ├─ Design Review: CURRENT / — (optional) ║ -║ └─ Codex Review: CURRENT / — (optional) ║ +║ ├─ Codex Review: CURRENT / — (local outside voice) ║ +║ └─ GitHub Codex: COMPLETED / TIMEOUT / ERROR / SKIPPED ║ ║ ║ ║ TESTS ║ ║ ├─ Free tests: PASS / FAIL (blocker) ║ diff --git a/land-and-deploy/SKILL.md.tmpl b/land-and-deploy/SKILL.md.tmpl index 98976ad02..b1390446c 100644 --- a/land-and-deploy/SKILL.md.tmpl +++ b/land-and-deploy/SKILL.md.tmpl @@ -447,6 +447,43 @@ and tell the user: "I found and fixed a few issues during the review. The fixes **If review is CURRENT:** Skip this sub-step entirely — no question asked. +### 3.5a-ter: GitHub Codex Review (opt-in, bounded) + +Run the deterministic helper before the test checks. It reads +`github_codex_review` (`off | risk | always`) and timeout defaults from +`gstack-config`; `off` is the default and does not access GitHub or spend cloud +Review quota. + +```bash +_GSTACK_CODEX_RESULT=$(mktemp /tmp/gstack-github-codex-review-XXXXXXXX) +set +e +"$GSTACK_BIN/gstack-github-codex-review" run > "$_GSTACK_CODEX_RESULT" +_GSTACK_CODEX_EXIT=$? +set -e +cat "$_GSTACK_CODEX_RESULT" +echo "HELPER_EXIT_CODE=$_GSTACK_CODEX_EXIT" +rm -f "$_GSTACK_CODEX_RESULT" +``` + +Parse the single JSON document and record its status in the readiness report: + +- `SKIPPED`: informational only. Do not add a warning. +- `COMPLETED`: current HEAD has a completed GitHub Codex Review (or bot 👍) + and no known unresolved current-HEAD thread. +- `TIMEOUT`: add a warning with `timeout_stage` and `message`; continue using + local review, tests, and CI. Never keep polling after the helper returns. +- `ERROR`: add a warning with `reason` and `message`; this means cloud status is + unknown, not passed. Continue using local review, tests, and CI. +- `BLOCKED` or helper exit `2`: add every `unresolved_threads[].url` as a + **BLOCKER** and do not merge. +- `REQUESTED` / `ACKNOWLEDGED`: show the status. These normally appear only in + explicit `--no-wait` use; they are not proof of completion. +- Helper exit `64`: configuration/argument error. Add a warning with the exact + fix from `message`; do not invent a status. + +This is a gstack merge-path auxiliary check, not a repository-level required +check. It does not cover merges performed outside `/land-and-deploy`. + ### 3.5b: Test results **Free tests — run them now:** @@ -542,7 +579,8 @@ Build the full readiness report: ║ ├─ Eng Review: CURRENT / STALE (N commits) / — ║ ║ ├─ CEO Review: CURRENT / — (optional) ║ ║ ├─ Design Review: CURRENT / — (optional) ║ -║ └─ Codex Review: CURRENT / — (optional) ║ +║ ├─ Codex Review: CURRENT / — (local outside voice) ║ +║ └─ GitHub Codex: COMPLETED / TIMEOUT / ERROR / SKIPPED ║ ║ ║ ║ TESTS ║ ║ ├─ Free tests: PASS / FAIL (blocker) ║ diff --git a/lib/github-codex-review.ts b/lib/github-codex-review.ts new file mode 100644 index 000000000..8997a78ad --- /dev/null +++ b/lib/github-codex-review.ts @@ -0,0 +1,733 @@ +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), + }; +} diff --git a/package.json b/package.json index 846438a60..9ffb1caf7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "1.60.1.0", + "version": "1.61.0.0", "description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.", "license": "MIT", "type": "module", diff --git a/setup b/setup index 275236cd3..05178a378 100755 --- a/setup +++ b/setup @@ -748,8 +748,9 @@ create_agents_sidecar() { local agents_gstack="$repo_root/.agents/skills/gstack" mkdir -p "$agents_gstack" - # Sidecar directories that skills reference at runtime - for asset in bin browse review qa; do + # Sidecar directories that skills reference at runtime. Some Bun helpers in + # bin/ import shared modules from ../lib, so bin and lib must travel together. + for asset in bin lib browse review qa; do local src="$SOURCE_GSTACK_DIR/$asset" local dst="$agents_gstack/$asset" if [ -d "$src" ] || [ -f "$src" ]; then @@ -796,6 +797,9 @@ create_codex_runtime_root() { if [ -d "$gstack_dir/bin" ]; then _link_or_copy "$gstack_dir/bin" "$codex_gstack/bin" fi + if [ -d "$gstack_dir/lib" ]; then + _link_or_copy "$gstack_dir/lib" "$codex_gstack/lib" + fi if [ -d "$gstack_dir/browse/dist" ]; then _link_or_copy "$gstack_dir/browse/dist" "$codex_gstack/browse/dist" fi @@ -836,6 +840,9 @@ create_factory_runtime_root() { if [ -d "$gstack_dir/bin" ]; then _link_or_copy "$gstack_dir/bin" "$factory_gstack/bin" fi + if [ -d "$gstack_dir/lib" ]; then + _link_or_copy "$gstack_dir/lib" "$factory_gstack/lib" + fi if [ -d "$gstack_dir/browse/dist" ]; then _link_or_copy "$gstack_dir/browse/dist" "$factory_gstack/browse/dist" fi @@ -874,6 +881,9 @@ create_opencode_runtime_root() { if [ -d "$gstack_dir/bin" ]; then _link_or_copy "$gstack_dir/bin" "$opencode_gstack/bin" fi + if [ -d "$gstack_dir/lib" ]; then + _link_or_copy "$gstack_dir/lib" "$opencode_gstack/lib" + fi if [ -d "$gstack_dir/browse/dist" ]; then _link_or_copy "$gstack_dir/browse/dist" "$opencode_gstack/browse/dist" fi @@ -1115,6 +1125,7 @@ if [ "$INSTALL_KIRO" -eq 1 ]; then [ -L "$KIRO_GSTACK" ] && rm -f "$KIRO_GSTACK" mkdir -p "$KIRO_GSTACK" "$KIRO_GSTACK/browse" "$KIRO_GSTACK/gstack-upgrade" "$KIRO_GSTACK/review" _link_or_copy "$SOURCE_GSTACK_DIR/bin" "$KIRO_GSTACK/bin" + _link_or_copy "$SOURCE_GSTACK_DIR/lib" "$KIRO_GSTACK/lib" _link_or_copy "$SOURCE_GSTACK_DIR/browse/dist" "$KIRO_GSTACK/browse/dist" _link_or_copy "$SOURCE_GSTACK_DIR/browse/bin" "$KIRO_GSTACK/browse/bin" # ETHOS.md — referenced by "Search Before Building" in all skill preambles diff --git a/test/gen-skill-docs.test.ts b/test/gen-skill-docs.test.ts index 2fb783ffd..e89a2313d 100644 --- a/test/gen-skill-docs.test.ts +++ b/test/gen-skill-docs.test.ts @@ -2424,11 +2424,12 @@ describe('setup script validation', () => { }); test('create_agents_sidecar links runtime assets', () => { - // Sidecar must link bin, browse, review, qa + // Sidecar must link bin + lib together, plus browse, review, qa. const fnStart = setupContent.indexOf('create_agents_sidecar()'); const fnEnd = setupContent.indexOf('}', setupContent.indexOf('done', fnStart)); const fnBody = setupContent.slice(fnStart, fnEnd); expect(fnBody).toContain('bin'); + expect(fnBody).toContain('lib'); expect(fnBody).toContain('browse'); expect(fnBody).toContain('review'); expect(fnBody).toContain('qa'); @@ -2439,6 +2440,7 @@ describe('setup script validation', () => { const fnEnd = setupContent.indexOf('}', setupContent.indexOf('done', setupContent.indexOf('review/', fnStart))); const fnBody = setupContent.slice(fnStart, fnEnd); expect(fnBody).toContain('gstack/SKILL.md'); + expect(fnBody).toContain('"$gstack_dir/lib" "$codex_gstack/lib"'); expect(fnBody).toContain('browse/dist'); expect(fnBody).toContain('browse/bin'); expect(fnBody).toContain('gstack-upgrade/SKILL.md'); diff --git a/test/gstack-github-codex-review.test.ts b/test/gstack-github-codex-review.test.ts new file mode 100644 index 000000000..567464937 --- /dev/null +++ b/test/gstack-github-codex-review.test.ts @@ -0,0 +1,459 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "fs"; +import { tmpdir } from "os"; +import { join, resolve } from "path"; +import { spawnSync } from "child_process"; + +import { + assessRisk, + GithubCodexReviewError, + isCodexBotLogin, + markerFor, + runGithubCodexReview, + type IssueComment, + type PullRequestContext, + type PullRequestReview, + type Reaction, + type ReviewDependencies, + type ReviewThread, +} from "../lib/github-codex-review"; + +const ROOT = resolve(import.meta.dir, ".."); +const BIN = join(ROOT, "bin", "gstack-github-codex-review"); +const CONFIG = join(ROOT, "bin", "gstack-config"); +const tempDirs: string[] = []; + +afterEach(() => { + for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true }); +}); + +function tempDir(prefix: string): string { + const dir = mkdtempSync(join(tmpdir(), prefix)); + tempDirs.push(dir); + return dir; +} + +function pr(overrides: Partial = {}): PullRequestContext { + return { + owner: "owner", + repo: "repo", + number: 7, + head: "a".repeat(40), + additions: 30, + deletions: 10, + changedFiles: 2, + files: ["src/app.ts", "test/app.test.ts"], + url: "https://github.com/owner/repo/pull/7", + ...overrides, + }; +} + +interface FakeState { + now: number; + pullRequest: PullRequestContext; + comments: IssueComment[]; + reactions: Reaction[]; + reviews: PullRequestReview[]; + threads: ReviewThread[]; + createCalls: number; + getPrCalls: number; + createErrors: number; + threadError?: Error; + onSleep?: (state: FakeState) => void; +} + +function fakeDeps(overrides: Partial = {}): { deps: ReviewDependencies; state: FakeState } { + const state: FakeState = { + now: 0, + pullRequest: pr(), + comments: [], + reactions: [], + reviews: [], + threads: [], + createCalls: 0, + getPrCalls: 0, + createErrors: 0, + ...overrides, + }; + const config: Record = { + github_codex_review: "risk", + github_codex_ack_timeout_seconds: "1", + github_codex_completion_timeout_seconds: "1", + }; + const deps: ReviewDependencies = { + now: () => state.now, + sleep: async (ms) => { + state.now += ms; + state.onSleep?.(state); + }, + progress: () => undefined, + loadConfig: async (key) => config[key] || "", + getPullRequest: async () => { + state.getPrCalls++; + return state.pullRequest; + }, + listComments: async () => [...state.comments], + listReactions: async () => [...state.reactions], + listReviews: async () => [...state.reviews], + listReviewThreads: async () => { + if (state.threadError) throw state.threadError; + return [...state.threads]; + }, + createComment: async (_ctx, body) => { + state.createCalls++; + if (state.createErrors > 0) { + state.createErrors--; + throw new GithubCodexReviewError("github_unavailable", "connection reset"); + } + const comment = { id: 99, body, html_url: "https://github.com/owner/repo/pull/7#issuecomment-99" }; + state.comments.push(comment); + return comment; + }, + }; + return { deps, state }; +} + +describe("GitHub Codex Review pure rules", () => { + test("normalizes the connector bot login only", () => { + expect(isCodexBotLogin("chatgpt-codex-connector")).toBe(true); + expect(isCodexBotLogin("chatgpt-codex-connector[bot]")).toBe(true); + expect(isCodexBotLogin("someone-else[bot]")).toBe(false); + }); + + test("risk mode skips docs-only even when the diff is large", () => { + const result = assessRisk("risk", pr({ + additions: 1_000, + changedFiles: 20, + files: ["docs/guide.md", "README.md", "CHANGELOG.md", "VERSION"], + })); + expect(result).toEqual({ required: false, reason: "docs_only" }); + }); + + test("risk mode triggers on file count, line count, and sensitive paths", () => { + expect(assessRisk("risk", pr({ changedFiles: 10 })).reason).toBe("changed_files_threshold"); + expect(assessRisk("risk", pr({ additions: 150, deletions: 50 })).reason).toBe("changed_lines_threshold"); + expect(assessRisk("risk", pr({ files: ["src/security/token.ts"] })).reason).toBe("sensitive_path"); + expect(assessRisk("risk", pr()).required).toBe(false); + }); +}); + +describe("GitHub Codex Review state machine", () => { + test("off mode does not access GitHub", async () => { + const { deps, state } = fakeDeps(); + const result = await runGithubCodexReview({ command: "run", mode: "off" }, deps); + expect(result.status).toBe("SKIPPED"); + expect(result.reason).toBe("mode_off"); + expect(state.getPrCalls).toBe(0); + expect(state.createCalls).toBe(0); + }); + + test("assess reports a high-risk PR without creating a comment", async () => { + const { deps, state } = fakeDeps({ pullRequest: pr({ files: ["src/auth/login.ts"] }) }); + const result = await runGithubCodexReview({ command: "assess", mode: "risk" }, deps); + expect(result.status).toBe("REQUESTED"); + expect(result.reason).toBe("sensitive_path"); + expect(state.createCalls).toBe(0); + }); + + test("sequential reruns reuse the current-head marker", async () => { + const head = "b".repeat(40); + const existing = { + id: 3, + body: `${markerFor(head)}\n@codex review`, + html_url: "https://github.com/owner/repo/pull/7#issuecomment-3", + }; + const { deps, state } = fakeDeps({ + pullRequest: pr({ head, files: ["src/auth/login.ts"] }), + comments: [existing], + }); + const result = await runGithubCodexReview({ command: "run", mode: "risk", noWait: true }, deps); + expect(result.status).toBe("REQUESTED"); + expect(result.request.comment_id).toBe(3); + expect(state.createCalls).toBe(0); + }); + + test("ignores user reactions and accepts bot ACK", async () => { + const head = "c".repeat(40); + const { deps } = fakeDeps({ + pullRequest: pr({ head, files: ["src/auth/login.ts"] }), + comments: [{ id: 4, body: markerFor(head), html_url: "comment" }], + reactions: [ + { content: "eyes", user: { login: "human" } }, + { content: "eyes", user: { login: "chatgpt-codex-connector[bot]" } }, + ], + }); + const result = await runGithubCodexReview({ command: "status", mode: "risk" }, deps); + expect(result.status).toBe("ACKNOWLEDGED"); + }); + + test("completion evidence wins even if ACK was never observed", async () => { + const head = "d".repeat(40); + const { deps } = fakeDeps({ + pullRequest: pr({ head, files: ["src/auth/login.ts"] }), + reviews: [{ user: { login: "chatgpt-codex-connector" }, commit_id: head, html_url: "review" }], + }); + const result = await runGithubCodexReview({ command: "run", mode: "risk" }, deps); + expect(result.status).toBe("COMPLETED"); + expect(result.timeout_stage).toBeNull(); + }); + + test("old-head reviews are ignored and a new request is created", async () => { + const head = "e".repeat(40); + const { deps, state } = fakeDeps({ + pullRequest: pr({ head, files: ["src/auth/login.ts"] }), + reviews: [{ user: { login: "chatgpt-codex-connector" }, commit_id: "old", html_url: "old" }], + }); + const result = await runGithubCodexReview({ command: "run", mode: "risk", noWait: true }, deps); + expect(result.status).toBe("REQUESTED"); + expect(result.request.created).toBe(true); + expect(state.createCalls).toBe(1); + }); + + test("unresolved current-head connector threads block", async () => { + const head = "f".repeat(40); + const { deps } = fakeDeps({ + pullRequest: pr({ head, files: ["src/auth/login.ts"] }), + reviews: [{ user: { login: "chatgpt-codex-connector" }, commit_id: head, html_url: "review" }], + threads: [{ + id: "T1", + isResolved: false, + isOutdated: false, + url: "thread", + authorLogin: "chatgpt-codex-connector[bot]", + reviewCommit: head, + }], + }); + const result = await runGithubCodexReview({ command: "status", mode: "risk" }, deps); + expect(result.status).toBe("BLOCKED"); + expect(result.exit_code).toBe(2); + expect(result.unresolved_threads).toEqual([{ id: "T1", url: "thread" }]); + }); + + test("thread lookup failure never masquerades as completed", async () => { + const head = "1".repeat(40); + const { deps } = fakeDeps({ + pullRequest: pr({ head, files: ["src/auth/login.ts"] }), + reviews: [{ user: { login: "chatgpt-codex-connector" }, commit_id: head }], + threadError: new Error("graphql unavailable"), + }); + const result = await runGithubCodexReview({ command: "status", mode: "risk" }, deps); + expect(result.status).toBe("ERROR"); + expect(result.reason).toBe("review_threads_unavailable"); + expect(result.exit_code).toBe(0); + }); + + test("unknown POST result is read back before retrying", async () => { + const head = "2".repeat(40); + const { deps, state } = fakeDeps({ + pullRequest: pr({ head, files: ["src/auth/login.ts"] }), + createErrors: 1, + }); + const originalList = deps.listComments; + let reads = 0; + deps.listComments = async (...args) => { + reads++; + if (state.createCalls === 1 && state.comments.length === 0) { + state.comments.push({ id: 5, body: markerFor(head), html_url: "server-created" }); + } + return originalList(...args); + }; + const result = await runGithubCodexReview({ command: "run", mode: "risk", noWait: true }, deps); + expect(result.request.comment_id).toBe(5); + expect(result.request.created).toBe(false); + expect(state.createCalls).toBe(1); + expect(reads).toBeGreaterThan(0); + }); + + test("ACK timeout is bounded and non-blocking", async () => { + const { deps, state } = fakeDeps({ pullRequest: pr({ files: ["src/auth/login.ts"] }) }); + const result = await runGithubCodexReview({ + command: "run", + mode: "risk", + ackTimeoutSeconds: 1, + completionTimeoutSeconds: 1, + pollIntervalSeconds: 1, + }, deps); + expect(result.status).toBe("TIMEOUT"); + expect(result.timeout_stage).toBe("ack"); + expect(result.exit_code).toBe(0); + expect(state.now).toBe(1_000); + }); + + test("completion timeout starts after ACK", async () => { + const head = "3".repeat(40); + const { deps, state } = fakeDeps({ + pullRequest: pr({ head, files: ["src/auth/login.ts"] }), + comments: [{ id: 6, body: markerFor(head), html_url: "comment" }], + reactions: [{ content: "eyes", user: { login: "chatgpt-codex-connector" } }], + }); + const result = await runGithubCodexReview({ + command: "run", + mode: "risk", + ackTimeoutSeconds: 1, + completionTimeoutSeconds: 1, + pollIntervalSeconds: 1, + }, deps); + expect(result.status).toBe("TIMEOUT"); + expect(result.timeout_stage).toBe("completion"); + expect(state.now).toBe(1_000); + }); +}); + +describe("gstack-config contract", () => { + test("exposes safe defaults", () => { + const home = tempDir("gstack-codex-config-"); + const env = { ...process.env, GSTACK_HOME: home, HOME: home }; + expect(spawnSync(CONFIG, ["get", "github_codex_review"], { env, encoding: "utf8" }).stdout).toBe("off"); + expect(spawnSync(CONFIG, ["get", "github_codex_ack_timeout_seconds"], { env, encoding: "utf8" }).stdout).toBe("120"); + expect(spawnSync(CONFIG, ["get", "github_codex_completion_timeout_seconds"], { env, encoding: "utf8" }).stdout).toBe("600"); + }); + + test("rejects invalid quota-affecting values without overwriting", () => { + const home = tempDir("gstack-codex-config-"); + const env = { ...process.env, GSTACK_HOME: home, HOME: home }; + expect(spawnSync(CONFIG, ["set", "github_codex_review", "risk"], { env }).status).toBe(0); + expect(spawnSync(CONFIG, ["set", "github_codex_review", "sometimes"], { env }).status).not.toBe(0); + expect(spawnSync(CONFIG, ["get", "github_codex_review"], { env, encoding: "utf8" }).stdout).toBe("risk"); + expect(spawnSync(CONFIG, ["set", "github_codex_ack_timeout_seconds", "0"], { env }).status).not.toBe(0); + expect(spawnSync(CONFIG, ["get", "github_codex_ack_timeout_seconds"], { env, encoding: "utf8" }).stdout).toBe("120"); + }); +}); + +describe("land-and-deploy generation contract", () => { + test("template and generated Claude skill contain the bounded helper gate", () => { + for (const file of ["land-and-deploy/SKILL.md.tmpl", "land-and-deploy/SKILL.md"]) { + const content = readFileSync(join(ROOT, file), "utf8"); + expect(content).toContain("3.5a-ter: GitHub Codex Review (opt-in, bounded)"); + expect(content).toContain('"$GSTACK_BIN/gstack-github-codex-review" run'); + expect(content).not.toContain("~/.claude/skills/gstack/bin/gstack-github-codex-review"); + expect(content).toContain("BLOCKED"); + expect(content).toContain("TIMEOUT"); + } + }); +}); + +describe("real CLI with mock gh", () => { + test("finds a current-head marker on a later REST page", () => { + const home = tempDir("gstack-codex-cli-"); + const fakeBin = tempDir("gstack-codex-gh-"); + const head = "4".repeat(40); + const script = `#!/bin/bash +case "$1 $2" in + "repo view") echo '{"nameWithOwner":"owner/repo"}' ;; + "pr view") echo '{"number":7,"headRefOid":"${head}","additions":20,"deletions":10,"changedFiles":1,"url":"https://github.com/owner/repo/pull/7"}' ;; + "api --paginate") + case "$*" in + *pulls/7/files*) echo '[[{"filename":"src/auth/login.ts"}]]' ;; + *issues/7/comments*) echo '[[ ],[{"id":8,"body":"${markerFor(head)}\\n@codex review","html_url":"comment-8"}]]' ;; + *issues/comments/8/reactions*) echo '[[]]' ;; + *pulls/7/reviews*) echo '[[]]' ;; + *) echo '[[]]' ;; + esac + ;; + *) echo "unexpected gh args: $*" >&2; exit 1 ;; +esac +`; + const gh = join(fakeBin, "gh"); + writeFileSync(gh, script, { mode: 0o755 }); + const env = { + ...process.env, + HOME: home, + GSTACK_HOME: home, + PATH: `${fakeBin}:${process.env.PATH}`, + }; + const result = spawnSync(BIN, ["status", "--mode", "risk", "--pr", "7"], { env, encoding: "utf8" }); + expect(result.status).toBe(0); + const parsed = JSON.parse(result.stdout); + expect(parsed.status).toBe("REQUESTED"); + expect(parsed.request.comment_id).toBe(8); + }); + + test("invalid CLI arguments still emit one JSON document", () => { + const result = spawnSync(BIN, ["status", "--mode", "invalid"], { encoding: "utf8" }); + expect(result.status).toBe(64); + const parsed = JSON.parse(result.stdout); + expect(parsed.status).toBe("ERROR"); + expect(parsed.reason).toBe("invalid_argument"); + }); + + test("drains a GitHub response larger than the child-process pipe buffer", () => { + const home = tempDir("gstack-codex-cli-"); + const fakeBin = tempDir("gstack-codex-gh-"); + const head = "5".repeat(40); + const script = `#!/bin/bash +case "$1 $2" in + "repo view") echo '{"nameWithOwner":"owner/repo"}' ;; + "pr view") echo '{"number":7,"headRefOid":"${head}","additions":20,"deletions":10,"changedFiles":1,"url":"https://github.com/owner/repo/pull/7"}' ;; + "api --paginate") + case "$*" in + *pulls/7/files*) echo '[[{"filename":"src/auth/login.ts"}]]' ;; + *issues/7/comments*) + printf '[[' + for i in $(seq 1 2000); do + [ "$i" -gt 1 ] && printf ',' + printf '{"id":%s,"body":"%0100d","html_url":"comment-%s"}' "$i" 0 "$i" + done + printf ']]\\n' + ;; + *pulls/7/reviews*) echo '[[]]' ;; + *) echo '[[]]' ;; + esac + ;; + *) echo "unexpected gh args: $*" >&2; exit 1 ;; +esac +`; + const gh = join(fakeBin, "gh"); + writeFileSync(gh, script, { mode: 0o755 }); + const env = { + ...process.env, + HOME: home, + GSTACK_HOME: home, + PATH: `${fakeBin}:${process.env.PATH}`, + }; + const result = spawnSync(BIN, ["status", "--mode", "risk", "--pr", "7"], { + env, + encoding: "utf8", + timeout: 5_000, + }); + expect(result.error).toBeUndefined(); + expect(result.status).toBe(0); + expect(JSON.parse(result.stdout).status).toBe("SKIPPED"); + }); + + test("finds an unresolved connector thread on a later GraphQL page", () => { + const home = tempDir("gstack-codex-cli-"); + const fakeBin = tempDir("gstack-codex-gh-"); + const head = "6".repeat(40); + const script = `#!/bin/bash +case "$1 $2" in + "repo view") echo '{"nameWithOwner":"owner/repo"}' ;; + "pr view") echo '{"number":7,"headRefOid":"${head}","additions":20,"deletions":10,"changedFiles":1,"url":"https://github.com/owner/repo/pull/7"}' ;; + "api --paginate") + case "$*" in + *pulls/7/files*) echo '[[{"filename":"src/auth/login.ts"}]]' ;; + *issues/7/comments*) echo '[[]]' ;; + *pulls/7/reviews*) echo '[[{"user":{"login":"chatgpt-codex-connector[bot]"},"commit_id":"${head}","html_url":"review"}]]' ;; + *) echo '[[]]' ;; + esac + ;; + "api graphql") + echo '[{"data":{"repository":{"pullRequest":{"reviewThreads":{"nodes":[]}}}}},{"data":{"repository":{"pullRequest":{"reviewThreads":{"nodes":[{"id":"THREAD_2","isResolved":false,"isOutdated":false,"comments":{"nodes":[{"url":"thread-2","author":{"login":"chatgpt-codex-connector[bot]"},"pullRequestReview":{"commit":{"oid":"${head}"}}}]}}]}}}}}]' + ;; + *) echo "unexpected gh args: $*" >&2; exit 1 ;; +esac +`; + const gh = join(fakeBin, "gh"); + writeFileSync(gh, script, { mode: 0o755 }); + const env = { + ...process.env, + HOME: home, + GSTACK_HOME: home, + PATH: `${fakeBin}:${process.env.PATH}`, + }; + const result = spawnSync(BIN, ["status", "--mode", "risk", "--pr", "7"], { env, encoding: "utf8" }); + expect(result.status).toBe(2); + const parsed = JSON.parse(result.stdout); + expect(parsed.status).toBe("BLOCKED"); + expect(parsed.unresolved_threads).toEqual([{ id: "THREAD_2", url: "thread-2" }]); + }); +});