mirror of https://github.com/garrytan/gstack.git
feat: 增加有界 GitHub Codex Review 门禁
新增风险判定、当前 HEAD 状态机、幂等请求、限时轮询与 land-and-deploy 接入,并覆盖分页、大响应和异常路径。
This commit is contained in:
parent
7c9df1c568
commit
f323997d79
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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
|
||||
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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) ║
|
||||
|
|
|
|||
|
|
@ -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) ║
|
||||
|
|
|
|||
|
|
@ -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
15
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
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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" }]);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue