feat: 增加有界 GitHub Codex Review 门禁

新增风险判定、当前 HEAD 状态机、幂等请求、限时轮询与 land-and-deploy 接入,并覆盖分页、大响应和异常路径。
This commit is contained in:
3Mad 2026-07-14 13:18:55 +08:00
parent 7c9df1c568
commit f323997d79
10 changed files with 1451 additions and 7 deletions

View File

@ -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)

View File

@ -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" ;;
@ -318,6 +330,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
@ -346,7 +369,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"
@ -362,7 +387,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

124
bin/gstack-github-codex-review Executable file
View File

@ -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 <number> 指定 PR默认从当前分支发现
--ack-timeout <seconds> 等待机器人 ACK默认 120
--completion-timeout <sec> ACK 后等待完成;默认 600
--poll-interval <seconds> 轮询间隔;默认 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;
}

View File

@ -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
```

View File

@ -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) ║

View File

@ -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) ║

733
lib/github-codex-review.ts Normal file
View File

@ -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<void>;
loadConfig(key: string): Promise<string>;
getPullRequest(prNumber?: number, deadlineMs?: number): Promise<PullRequestContext>;
listComments(pr: PullRequestContext, deadlineMs?: number): Promise<IssueComment[]>;
listReactions(pr: PullRequestContext, commentId: number, deadlineMs?: number): Promise<Reaction[]>;
listReviews(pr: PullRequestContext, deadlineMs?: number): Promise<PullRequestReview[]>;
listReviewThreads(pr: PullRequestContext, deadlineMs?: number): Promise<ReviewThread[]>;
createComment(pr: PullRequestContext, body: string, deadlineMs?: number): Promise<IssueComment>;
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 `<!-- gstack-github-codex-review head=${head} -->`;
}
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<Snapshot> {
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<boolean> {
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<ReviewResult> {
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<GhRunResult> {
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<typeof setTimeout> | 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<string> {
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<T>(args: string[], deadlineMs?: number, attempts = 3): Promise<T> {
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<T>(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<unknown>([
"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<unknown>([
"api",
"--paginate",
"--slurp",
`repos/${pr.owner}/${pr.repo}/issues/${pr.number}/comments?per_page=100`,
], deadlineMs);
return flattenSlurpedPages<IssueComment>(pages);
},
listReactions: async (pr, commentId, deadlineMs) => {
const pages = await ghJson<unknown>([
"api",
"--paginate",
"--slurp",
"-H",
"Accept: application/vnd.github+json",
`repos/${pr.owner}/${pr.repo}/issues/comments/${commentId}/reactions?per_page=100`,
], deadlineMs);
return flattenSlurpedPages<Reaction>(pages);
},
listReviews: async (pr, deadlineMs) => {
const pages = await ghJson<unknown>([
"api",
"--paginate",
"--slurp",
`repos/${pr.owner}/${pr.repo}/pulls/${pr.number}/reviews?per_page=100`,
], deadlineMs);
return flattenSlurpedPages<PullRequestReview>(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<Array<{
data?: {
repository?: {
pullRequest?: {
reviewThreads?: {
nodes?: Array<{
id: string;
isResolved: boolean;
isOutdated: boolean;
comments?: {
nodes?: Array<{
url?: string | null;
author?: { login?: string | null } | null;
pullRequestReview?: { commit?: { oid?: string | null } | null } | null;
}>;
};
}>;
};
};
};
};
}>>([
"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<IssueComment>([
"api",
"-X",
"POST",
`repos/${pr.owner}/${pr.repo}/issues/${pr.number}/comments`,
"-f",
`body=${body}`,
], deadlineMs, 1),
};
}

15
setup
View File

@ -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

View File

@ -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');

View File

@ -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> = {}): 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<FakeState> = {}): { 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<string, string> = {
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" }]);
});
});