diff --git a/src/clawsweeper.ts b/src/clawsweeper.ts index 64a0ef2cb6..167ee32723 100644 --- a/src/clawsweeper.ts +++ b/src/clawsweeper.ts @@ -24,7 +24,7 @@ import { repositoryProfileForSlug, type RepositoryProfile, } from "./repository-profiles.js"; -import { codexEnv } from "./codex-env.js"; +import { codexEnv, codexLoginMethodConfig } from "./codex-env.js"; import { ghRetryKind, ghRetryWaitMs, @@ -66,7 +66,7 @@ import { } from "./clawsweeper-args.js"; import { escapeRegExp, safeOutputTail, trimMiddle, truncateText } from "./clawsweeper-text.js"; -export { codexEnv } from "./codex-env.js"; +export { codexEnv, resolveCodexLoginMethod, codexLoginMethodConfig } from "./codex-env.js"; export { parseGhJson, parseGhJsonLines } from "./github-json.js"; export { itemNumbersArg } from "./clawsweeper-args.js"; export { safeOutputTail } from "./clawsweeper-text.js"; @@ -6174,12 +6174,14 @@ function runCodex(options: { } const codexConfig = [ `model_reasoning_effort="${options.reasoningEffort}"`, - 'forced_login_method="api"', + codexLoginMethodConfig(), 'approval_policy="never"', ]; if (options.serviceTier) codexConfig.splice(1, 0, `service_tier="${options.serviceTier}"`); + const codexBin = process.env.CODEX_BIN ?? "codex"; + const useShell = process.platform === "win32"; const result = spawnSync( - "codex", + codexBin, [ "exec", "-m", @@ -6207,6 +6209,7 @@ function runCodex(options: { input: prompt, maxBuffer: 128 * 1024 * 1024, timeout: options.timeoutMs, + ...(useShell ? { shell: true } : {}), }, ); const dirtyAfter = openclawDirtyStatus(options.openclawDir); @@ -6434,11 +6437,13 @@ function runCodexAssist(options: { writeFileSync(promptPath, prompt, "utf8"); const codexConfig = [ `model_reasoning_effort="${options.reasoningEffort}"`, - 'forced_login_method="api"', + codexLoginMethodConfig(), 'approval_policy="never"', ]; + const codexBin = process.env.CODEX_BIN ?? "codex"; + const useShell = process.platform === "win32"; const result = spawnSync( - "codex", + codexBin, [ "exec", "-m", @@ -6457,6 +6462,7 @@ function runCodexAssist(options: { input: prompt, maxBuffer: 32 * 1024 * 1024, timeout: options.timeoutMs, + ...(useShell ? { shell: true } : {}), }, ); if (result.error || result.status !== 0 || !existsSync(outputPath)) { @@ -16908,6 +16914,130 @@ function checkCommand(): void { console.log("ok"); } +function localReviewCommand(args: Args): void { + const targetDir = resolve(stringArg(args.target_dir, ".")); + const targetRepoArg = stringArg(args.repo, ""); + const baseBranch = stringArg(args.base, "main"); + const model = stringArg(args.codex_model, DEFAULT_CODEX_MODEL); + const reasoningEffort = stringArg(args.codex_reasoning_effort, DEFAULT_REASONING_EFFORT); + const sandboxMode = stringArg(args.codex_sandbox, "read-only"); + const serviceTier = stringArg(args.codex_service_tier, DEFAULT_SERVICE_TIER); + const timeoutMs = numberArg(args.codex_timeout_ms, 600_000); + const outputDir = resolve(stringArg(args.output_dir, join(homedir(), ".clawsweeper-local-reviews"))); + ensureDir(outputDir); + + const repoName = + targetRepoArg || + run("git", ["remote", "get-url", "origin"], { cwd: targetDir }) + .replace(/.*github\.com[:/]/, "") + .replace(/\.git\s*$/, "") + .trim(); + + const diff = run("git", ["diff", `${baseBranch}...HEAD`], { cwd: targetDir }); + if (!diff.trim()) { + console.error("[local-review] no diff against " + baseBranch); + process.exit(1); + } + + const commitTitle = run("git", ["log", "--format=%s", "-1"], { cwd: targetDir }).trim(); + const commitBody = run("git", ["log", "--format=%b", "-1"], { cwd: targetDir }).trim(); + const author = run("git", ["log", "--format=%an", "-1"], { cwd: targetDir }).trim(); + const headSha = run("git", ["rev-parse", "HEAD"], { cwd: targetDir }).trim(); + const baseSha = run("git", ["rev-parse", baseBranch], { cwd: targetDir }).trim(); + const changedFiles = run("git", ["diff", "--name-only", `${baseBranch}...HEAD`], { cwd: targetDir }) + .trim() + .split("\n") + .filter(Boolean); + + const prBodyFile = stringArg(args.pr_body_file, ""); + let prBodyOverride = ""; + if (prBodyFile) { + const resolvedBodyFile = resolve(prBodyFile); + if (!existsSync(resolvedBodyFile)) { + throw new Error(`--pr-body-file not found: ${resolvedBodyFile}`); + } + prBodyOverride = readFileSync(resolvedBodyFile, "utf8").trim(); + } + const prBody = prBodyOverride || commitBody || commitTitle; + + const now = new Date().toISOString(); + const item: Item = { + repo: repoName, + number: 0, + kind: "pull_request", + title: commitTitle, + url: `local://${repoName}@${headSha.slice(0, 8)}`, + createdAt: now, + updatedAt: now, + closedAt: null, + author, + authorAssociation: "CONTRIBUTOR", + labels: [], + locked: false, + activeLockReason: null, + }; + + const context: ItemContext = { + issue: { title: commitTitle, body: prBody, user: { login: author }, state: "open" }, + comments: [], + timeline: [], + pullRequest: { + title: commitTitle, + body: prBody, + head: { sha: headSha, ref: "HEAD" }, + base: { sha: baseSha, ref: baseBranch }, + diff, + changed_files: changedFiles.length, + additions: diff.split("\n").filter((l: string) => l.startsWith("+") && !l.startsWith("+++")).length, + deletions: diff.split("\n").filter((l: string) => l.startsWith("-") && !l.startsWith("---")).length, + mergeable: true, + user: { login: author }, + }, + pullFiles: changedFiles.map((f: string) => ({ filename: f, status: "modified", patch: "" })), + pullCommits: [{ sha: headSha, commit: { message: commitTitle + "\n\n" + commitBody } }], + pullReviewComments: [], + counts: { comments: 0, timeline: 0 }, + }; + + const git: GitInfo = { mainSha: baseSha, latestRelease: null }; + + try { + setTargetRepo(repoName); + } catch { + setTargetRepo("openclaw/openclaw"); + } + + console.error( + `[local-review] repo=${repoName} base=${baseBranch} files=${changedFiles.length} diff=${diff.length} chars`, + ); + console.error(`[local-review] title: ${commitTitle}`); + console.error(`[local-review] model=${model} reasoning=${reasoningEffort}`); + + const staleOutputPath = join(outputDir, `${item.number}.json`); + if (existsSync(staleOutputPath)) { + unlinkSync(staleOutputPath); + } + + const decision = runCodex({ + item, + context, + git, + model, + openclawDir: targetDir, + reasoningEffort, + sandboxMode, + serviceTier, + timeoutMs, + workDir: outputDir, + additionalPrompt: `This is a LOCAL review of staged changes, not a GitHub PR. The diff is from git diff ${baseBranch}...HEAD. Focus on code correctness, not PR metadata.`, + }); + + const outputPath = join(outputDir, "local-review.json"); + writeFileSync(outputPath, JSON.stringify(decision, null, 2), "utf8"); + console.error(`[local-review] decision written to ${outputPath}`); + console.log(JSON.stringify(decision, null, 2)); +} + export async function main(argv = process.argv.slice(2)): Promise { const args = parseArgs(argv); const command = args._[0] ?? "review"; @@ -16927,6 +17057,7 @@ export async function main(argv = process.argv.slice(2)): Promise { } else if (command === "status") statusCommand(args); else if (command === "assist") assistCommand(args); else if (command === "check") checkCommand(); + else if (command === "local-review") localReviewCommand(args); else { console.error(`Unknown command: ${command}`); process.exit(1); diff --git a/src/codex-env.ts b/src/codex-env.ts index adb62cf131..5026e41a70 100644 --- a/src/codex-env.ts +++ b/src/codex-env.ts @@ -1,3 +1,17 @@ +const VALID_LOGIN_METHODS = new Set(["api", "chatgpt"]); + +export function resolveCodexLoginMethod(): string { + const value = process.env.CLAWSWEEPER_CODEX_LOGIN_METHOD?.trim().toLowerCase(); + if (value && VALID_LOGIN_METHODS.has(value)) { + return value; + } + return "api"; +} + +export function codexLoginMethodConfig(): string { + return `forced_login_method="${resolveCodexLoginMethod()}"`; +} + export type CodexEnvOptions = { ghToken?: string | undefined; }; diff --git a/src/commit-sweeper.ts b/src/commit-sweeper.ts index 28a91f29be..3eee537ecf 100644 --- a/src/commit-sweeper.ts +++ b/src/commit-sweeper.ts @@ -11,7 +11,7 @@ import { import { publishCheckFromReport, splitFrontMatter } from "./commit-checks.js"; import { argBool, argNumber, argString, parseArgs, type Args } from "./clawsweeper-args.js"; import { safeOutputTail } from "./clawsweeper-text.js"; -import { codexEnv } from "./codex-env.js"; +import { codexEnv, codexLoginMethodConfig } from "./codex-env.js"; import { runText } from "./command.js"; import { ghRetryKind, ghRetryWaitMs } from "./github-retry.js"; import { DEFAULT_TARGET_REPO, repositoryProfileFor } from "./repository-profiles.js"; @@ -296,12 +296,14 @@ function runCodex(options: { ); const codexConfig = [ `model_reasoning_effort="${options.reasoningEffort}"`, - 'forced_login_method="api"', + codexLoginMethodConfig(), 'approval_policy="never"', ]; if (options.serviceTier) codexConfig.splice(1, 0, `service_tier="${options.serviceTier}"`); + const codexBin = process.env.CODEX_BIN ?? "codex"; + const useShell = process.platform === "win32"; const result = spawnSync( - "codex", + codexBin, [ "exec", "-m", @@ -322,6 +324,7 @@ function runCodex(options: { input: readFileSync(promptPath, "utf8"), maxBuffer: 128 * 1024 * 1024, timeout: options.timeoutMs, + ...(useShell ? { shell: true } : {}), }, ); if (result.error || result.status !== 0 || !existsSync(outputPath)) { diff --git a/src/pr-close-coverage-proof.ts b/src/pr-close-coverage-proof.ts index 0b6f08685a..4944588730 100644 --- a/src/pr-close-coverage-proof.ts +++ b/src/pr-close-coverage-proof.ts @@ -1,7 +1,7 @@ import { spawnSync } from "node:child_process"; import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs"; import { join } from "node:path"; -import { codexEnv } from "./codex-env.js"; +import { codexEnv, codexLoginMethodConfig } from "./codex-env.js"; import { safeOutputTail, truncateText } from "./clawsweeper-text.js"; export type PrCloseCoverageProofModelDecision = "covered" | "keep_open"; @@ -252,14 +252,16 @@ export function runPrCloseCoverageProofModel(options: { if (existsSync(outputPath)) unlinkSync(outputPath); const codexConfig = [ `model_reasoning_effort="${options.runtime.reasoningEffort}"`, - 'forced_login_method="api"', + codexLoginMethodConfig(), 'approval_policy="never"', ]; if (options.runtime.serviceTier) { codexConfig.splice(1, 0, `service_tier="${options.runtime.serviceTier}"`); } + const codexBin = process.env.CODEX_BIN ?? "codex"; + const useShell = process.platform === "win32"; const result = spawnSync( - "codex", + codexBin, [ "exec", "-m", @@ -282,6 +284,7 @@ export function runPrCloseCoverageProofModel(options: { input: prompt, maxBuffer: 64 * 1024 * 1024, timeout: options.runtime.timeoutMs, + ...(useShell ? { shell: true } : {}), }, ); if (result.error) { diff --git a/test/clawsweeper.test.ts b/test/clawsweeper.test.ts index bc2a79b84a..3066ae080b 100644 --- a/test/clawsweeper.test.ts +++ b/test/clawsweeper.test.ts @@ -34,6 +34,8 @@ import { referencingMergedPullRequestsForIssueForTest, configSurfaceChangeFromPullFilesForTest, codexEnv, + resolveCodexLoginMethod, + codexLoginMethodConfig, dashboardClosedAt, extractLatestClawSweeperReviewForTest, filterReviewContextCommentsForTest, @@ -13873,6 +13875,100 @@ process.exit(1); } }); +test("runCodex accepts synthetic local-review Item and ItemContext shapes", () => { + const root = mkdtempSync(tmpPrefix); + const openclawDir = join(root, "openclaw"); + const workDir = join(root, "codex-work"); + const binDir = join(root, "bin"); + mkdirSync(openclawDir, { recursive: true }); + mkdirSync(binDir, { recursive: true }); + execFileSync("git", ["init"], { cwd: openclawDir, stdio: "ignore" }); + const scriptBody = `const fs = require("node:fs"); +const outputIndex = process.argv.indexOf("--output-last-message"); +fs.writeFileSync(process.argv[outputIndex + 1], process.env.CODEX_DECISION_JSON); +`; + if (process.platform === "win32") { + const scriptPath = join(binDir, "codex-fake.js"); + writeFileSync(scriptPath, scriptBody); + writeFileSync(join(binDir, "codex.cmd"), `@echo off\nnode "${scriptPath}" %*\n`); + } else { + const codexPath = join(binDir, "codex"); + writeFileSync(codexPath, `#!/usr/bin/env node\n${scriptBody}`, { mode: 0o755 }); + } + const originalPath = process.env.PATH; + const originalDecision = process.env.CODEX_DECISION_JSON; + process.env.PATH = `${binDir}${delimiter}${process.env.PATH ?? ""}`; + process.env.CODEX_DECISION_JSON = JSON.stringify( + closeDecision({ + decision: "keep_open", + closeReason: "none", + confidence: "medium", + summary: "Local review test.", + bestSolution: "Verify synthetic shapes.", + closeComment: "", + workReason: "Test.", + }), + ); + try { + const decision = runCodexForTest({ + item: { + repo: "test/repo", + number: 0, + kind: "pull_request", + title: "local change", + url: "local://test/repo@abc12345", + createdAt: "2026-06-04T00:00:00Z", + updatedAt: "2026-06-04T00:00:00Z", + closedAt: null, + author: "localuser", + authorAssociation: "CONTRIBUTOR", + labels: [], + locked: false, + activeLockReason: null, + }, + context: { + issue: { title: "local change", body: "test body", user: { login: "localuser" }, state: "open" }, + comments: [], + timeline: [], + pullRequest: { + title: "local change", + body: "test body", + head: { sha: "abc1234567890", ref: "HEAD" }, + base: { sha: "def0987654321", ref: "main" }, + diff: "+added line\n-removed line", + changed_files: 1, + additions: 1, + deletions: 1, + mergeable: true, + user: { login: "localuser" }, + }, + pullFiles: [{ filename: "test.ts", status: "modified", patch: "" }], + pullCommits: [{ sha: "abc1234567890", commit: { message: "local change" } }], + pullReviewComments: [], + counts: { comments: 0, timeline: 0 }, + }, + git: { mainSha: "def0987654321", latestRelease: null }, + model: "gpt-test", + openclawDir, + reasoningEffort: "high", + sandboxMode: "read-only", + serviceTier: "", + timeoutMs: 10_000, + workDir, + prompt: "Review this local change.", + }); + + assert.equal(decision.decision, "keep_open"); + assert.equal(decision.summary, "Local review test."); + } finally { + if (originalPath === undefined) delete process.env.PATH; + else process.env.PATH = originalPath; + if (originalDecision === undefined) delete process.env.CODEX_DECISION_JSON; + else process.env.CODEX_DECISION_JSON = originalDecision; + rmSync(root, { recursive: true, force: true }); + } +}); + test("decision parser enforces required schema-shaped evidence", () => { assert.equal(parseDecision(closeDecision()).decision, "close"); assert.equal(parseDecision(closeDecision({ itemCategory: "skill" })).itemCategory, "skill"); @@ -16727,6 +16823,54 @@ test("codex subprocess env can expose an explicit read-only GitHub token", () => } }); +test("resolveCodexLoginMethod defaults to api", () => { + const original = process.env.CLAWSWEEPER_CODEX_LOGIN_METHOD; + try { + delete process.env.CLAWSWEEPER_CODEX_LOGIN_METHOD; + assert.equal(resolveCodexLoginMethod(), "api"); + } finally { + if (original === undefined) delete process.env.CLAWSWEEPER_CODEX_LOGIN_METHOD; + else process.env.CLAWSWEEPER_CODEX_LOGIN_METHOD = original; + } +}); + +test("resolveCodexLoginMethod accepts chatgpt", () => { + const original = process.env.CLAWSWEEPER_CODEX_LOGIN_METHOD; + try { + process.env.CLAWSWEEPER_CODEX_LOGIN_METHOD = "chatgpt"; + assert.equal(resolveCodexLoginMethod(), "chatgpt"); + } finally { + if (original === undefined) delete process.env.CLAWSWEEPER_CODEX_LOGIN_METHOD; + else process.env.CLAWSWEEPER_CODEX_LOGIN_METHOD = original; + } +}); + +test("resolveCodexLoginMethod rejects invalid values", () => { + const original = process.env.CLAWSWEEPER_CODEX_LOGIN_METHOD; + try { + process.env.CLAWSWEEPER_CODEX_LOGIN_METHOD = "oauth-browser"; + assert.equal(resolveCodexLoginMethod(), "api"); + process.env.CLAWSWEEPER_CODEX_LOGIN_METHOD = ""; + assert.equal(resolveCodexLoginMethod(), "api"); + } finally { + if (original === undefined) delete process.env.CLAWSWEEPER_CODEX_LOGIN_METHOD; + else process.env.CLAWSWEEPER_CODEX_LOGIN_METHOD = original; + } +}); + +test("codexLoginMethodConfig produces valid Codex config string", () => { + const original = process.env.CLAWSWEEPER_CODEX_LOGIN_METHOD; + try { + delete process.env.CLAWSWEEPER_CODEX_LOGIN_METHOD; + assert.equal(codexLoginMethodConfig(), 'forced_login_method="api"'); + process.env.CLAWSWEEPER_CODEX_LOGIN_METHOD = "chatgpt"; + assert.equal(codexLoginMethodConfig(), 'forced_login_method="chatgpt"'); + } finally { + if (original === undefined) delete process.env.CLAWSWEEPER_CODEX_LOGIN_METHOD; + else process.env.CLAWSWEEPER_CODEX_LOGIN_METHOD = original; + } +}); + test("related title search terms keep issue-specific words", () => { assert.deepEqual( relatedTitleSearchTerms(