Skip to content

Commit d67b782

Browse files
feat: automate codex pr worktrees
1 parent 7b116b1 commit d67b782

1 file changed

Lines changed: 381 additions & 0 deletions

File tree

‎pr-worktree-codex.mjs‎

Lines changed: 381 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,381 @@
1+
#!/usr/bin/env node
2+
3+
import { execFile } from "node:child_process";
4+
import { promises as fs } from "node:fs";
5+
import os from "node:os";
6+
import path from "node:path";
7+
import process from "node:process";
8+
import { Codex } from "@codex-native/sdk";
9+
10+
const DEFAULT_REMOTE = "origin";
11+
const DEFAULT_CONCURRENCY = Math.max(os.cpus().length - 1, 1);
12+
13+
async function main() {
14+
const config = await buildConfig();
15+
16+
await ensureBinaryAvailable("git");
17+
await ensureBinaryAvailable("gh");
18+
await ensureBinaryAvailable("pnpm");
19+
20+
await fs.mkdir(config.worktreeRoot, { recursive: true });
21+
22+
const prs = await listOpenPullRequests(config);
23+
if (prs.length === 0) {
24+
console.log("No open pull requests detected. Nothing to do.");
25+
return;
26+
}
27+
28+
console.log(`Discovered ${prs.length} open pull request${prs.length === 1 ? "" : "s"}.`);
29+
30+
const existingWorktrees = await getExistingWorktreePaths(config.repoRoot);
31+
const gitMutex = createMutex();
32+
const installMutex = createMutex();
33+
const codex = new Codex();
34+
35+
const results = await runWithConcurrency(prs, config.concurrency, async (pr) => {
36+
const safeSlug = slugifyRef(pr.headRefName) || `head-${pr.number}`;
37+
const worktreeDir = `pr-${pr.number}-${safeSlug}`;
38+
const worktreePath = path.join(config.worktreeRoot, worktreeDir);
39+
const remoteRef = `refs/remotes/${config.remote}/pr-worktree/${pr.number}`;
40+
let createdWorktree = false;
41+
42+
try {
43+
await gitMutex.run(async () => {
44+
await runCommand("git", [
45+
"fetch",
46+
"--force",
47+
config.remote,
48+
`pull/${pr.number}/head:${remoteRef}`,
49+
], { cwd: config.repoRoot });
50+
51+
const knownWorktree = existingWorktrees.has(worktreePath);
52+
const pathAlreadyExists = await pathExists(worktreePath);
53+
54+
if (!knownWorktree && pathAlreadyExists) {
55+
// Remove stale directory so worktree add succeeds.
56+
await fs.rm(worktreePath, { recursive: true, force: true });
57+
}
58+
59+
if (!knownWorktree) {
60+
await runCommand("git", [
61+
"worktree",
62+
"add",
63+
"--force",
64+
"--detach",
65+
worktreePath,
66+
remoteRef,
67+
], { cwd: config.repoRoot });
68+
existingWorktrees.add(worktreePath);
69+
console.log(`[PR ${pr.number}] Created worktree at ${worktreePath}`);
70+
createdWorktree = true;
71+
} else {
72+
await runCommand("git", ["reset", "--hard", remoteRef], { cwd: worktreePath });
73+
await runCommand("git", ["clean", "-fdx"], { cwd: worktreePath });
74+
console.log(`[PR ${pr.number}] Refreshed existing worktree at ${worktreePath}`);
75+
}
76+
});
77+
} catch (error) {
78+
console.error(`[PR ${pr.number}] Failed to prepare worktree:`, error);
79+
return { pr, worktreePath, error };
80+
}
81+
82+
const needsInstall = createdWorktree || !(await pathExists(path.join(worktreePath, "node_modules")));
83+
84+
if (needsInstall) {
85+
try {
86+
console.log(`[PR ${pr.number}] Running pnpm install...`);
87+
await installMutex.run(async () => {
88+
await runCommand("pnpm", ["install"], { cwd: worktreePath });
89+
});
90+
} catch (error) {
91+
console.error(`[PR ${pr.number}] pnpm install failed:`, error);
92+
return { pr, worktreePath, error };
93+
}
94+
}
95+
96+
if (config.dryRun) {
97+
console.log(`[PR ${pr.number}] Dry run enabled; skipping Codex automation.`);
98+
return { pr, worktreePath };
99+
}
100+
101+
try {
102+
const thread = codex.startThread({
103+
workingDirectory: worktreePath,
104+
sandboxMode: "workspace-write",
105+
fullAuto: true,
106+
});
107+
108+
const prompt = buildAgentPrompt(pr, worktreePath);
109+
console.log(`[PR ${pr.number}] Starting Codex turn...`);
110+
111+
const turn = await thread.run(prompt);
112+
113+
console.log(`[PR ${pr.number}] Codex summary:`);
114+
console.log(turn.finalResponse.trim());
115+
116+
return { pr, worktreePath, turn };
117+
} catch (error) {
118+
console.error(`[PR ${pr.number}] Codex automation failed:`, error);
119+
return { pr, worktreePath, error };
120+
}
121+
});
122+
123+
summarizeResults(results);
124+
}
125+
126+
async function buildConfig() {
127+
const args = process.argv.slice(2);
128+
let repoRoot = process.cwd();
129+
let worktreeRoot = path.resolve(repoRoot, "..", "codex-pr-worktrees");
130+
let concurrency = DEFAULT_CONCURRENCY;
131+
let remote = DEFAULT_REMOTE;
132+
let dryRun = false;
133+
134+
for (let i = 0; i < args.length; i += 1) {
135+
const arg = args[i];
136+
switch (arg) {
137+
case "--repo": {
138+
const value = args[i + 1];
139+
if (!value) {
140+
throw new Error("--repo flag requires a path argument");
141+
}
142+
repoRoot = path.resolve(value);
143+
i += 1;
144+
break;
145+
}
146+
case "--worktree-root": {
147+
const value = args[i + 1];
148+
if (!value) {
149+
throw new Error("--worktree-root flag requires a path argument");
150+
}
151+
worktreeRoot = path.resolve(value);
152+
i += 1;
153+
break;
154+
}
155+
case "--concurrency": {
156+
const value = Number(args[i + 1]);
157+
if (!Number.isFinite(value) || value <= 0) {
158+
throw new Error("--concurrency flag requires a positive integer");
159+
}
160+
concurrency = Math.floor(value);
161+
i += 1;
162+
break;
163+
}
164+
case "--remote": {
165+
const value = args[i + 1];
166+
if (!value) {
167+
throw new Error("--remote flag requires a remote name");
168+
}
169+
remote = value;
170+
i += 1;
171+
break;
172+
}
173+
case "--dry-run":
174+
dryRun = true;
175+
break;
176+
case "--help":
177+
case "-h":
178+
printUsage();
179+
process.exit(0);
180+
break;
181+
default:
182+
if (arg.startsWith("-")) {
183+
console.warn(`Unknown option ${arg} (ignored).`);
184+
}
185+
break;
186+
}
187+
}
188+
189+
return { repoRoot, worktreeRoot, concurrency, remote, dryRun };
190+
}
191+
192+
function printUsage() {
193+
console.log(`Usage: node pr-worktree-codex.mjs [options]
194+
195+
Options:
196+
--repo <path> Override repository root (default: current directory)
197+
--worktree-root <path> Directory to place PR worktrees (default: ../codex-pr-worktrees)
198+
--remote <name> Remote to fetch pull requests from (default: origin)
199+
--concurrency <n> Maximum concurrent Codex runs (default: cpu count - 1)
200+
--dry-run Prepare worktrees without invoking Codex
201+
--help Show this message
202+
`);
203+
}
204+
205+
async function ensureBinaryAvailable(binary) {
206+
try {
207+
await runCommand(binary, ["--version"]);
208+
} catch (error) {
209+
throw new Error(`Required binary '${binary}' is not available in PATH`);
210+
}
211+
}
212+
213+
async function listOpenPullRequests(config) {
214+
const jsonFields = [
215+
"number",
216+
"title",
217+
"headRefName",
218+
"headRepository",
219+
"headRepositoryOwner",
220+
"url",
221+
];
222+
223+
const args = [
224+
"pr",
225+
"list",
226+
"--state",
227+
"open",
228+
"--limit",
229+
"200",
230+
"--json",
231+
jsonFields.join(","),
232+
];
233+
234+
const { stdout } = await runCommand("gh", args, { cwd: config.repoRoot });
235+
const parsed = JSON.parse(stdout);
236+
237+
return parsed.map((entry) => ({
238+
number: entry.number,
239+
title: entry.title,
240+
headRefName: entry.headRefName,
241+
headRepository: entry.headRepository ?? null,
242+
headRepositoryOwner: entry.headRepositoryOwner ?? null,
243+
url: entry.url,
244+
}));
245+
}
246+
247+
async function getExistingWorktreePaths(repoRoot) {
248+
const { stdout } = await runCommand("git", ["worktree", "list", "--porcelain"], { cwd: repoRoot });
249+
250+
const blocks = stdout.split(/\n(?=worktree )/).map((block) => block.trim()).filter(Boolean);
251+
const paths = new Set();
252+
253+
for (const block of blocks) {
254+
for (const line of block.split("\n")) {
255+
if (line.startsWith("worktree ")) {
256+
const worktreePath = line.substring("worktree ".length).trim();
257+
paths.add(path.resolve(worktreePath));
258+
}
259+
}
260+
}
261+
262+
return paths;
263+
}
264+
265+
function createMutex() {
266+
let chain = Promise.resolve();
267+
return {
268+
run(fn) {
269+
const next = chain.then(() => fn());
270+
chain = next.catch(() => {});
271+
return next;
272+
},
273+
};
274+
}
275+
276+
async function runWithConcurrency(items, concurrency, handler) {
277+
if (items.length === 0) {
278+
return [];
279+
}
280+
281+
const effectiveConcurrency = Math.max(1, Math.min(concurrency, items.length));
282+
const results = new Array(items.length);
283+
let index = 0;
284+
285+
async function worker() {
286+
while (true) {
287+
const currentIndex = index;
288+
index += 1;
289+
if (currentIndex >= items.length) {
290+
break;
291+
}
292+
results[currentIndex] = await handler(items[currentIndex], currentIndex);
293+
}
294+
}
295+
296+
const workers = Array.from({ length: effectiveConcurrency }, () => worker());
297+
await Promise.all(workers);
298+
return results;
299+
}
300+
301+
function slugifyRef(ref) {
302+
return ref.replace(/[^a-zA-Z0-9._-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
303+
}
304+
305+
function buildAgentPrompt(pr, worktreePath) {
306+
return [
307+
`You are an automated Codex maintainer responsible for validating pull request #${pr.number}.`,
308+
`The worktree directory is ${worktreePath}.`,
309+
`Tasks:`,
310+
"1. Run 'gh pr checks " + pr.number + "' to gather CI status.",
311+
"2. Investigate and fix any failures using the available repository tools.",
312+
"3. Re-run 'gh pr checks " + pr.number + "' after applying fixes.",
313+
"4. Summarize the resulting status and any manual follow-ups required.",
314+
"Always operate from the repository root, use shell commands responsibly, and include test results in the summary.",
315+
].join("\n");
316+
}
317+
318+
async function runCommand(command, args = [], options = {}) {
319+
return new Promise((resolve, reject) => {
320+
execFile(command, args, {
321+
encoding: "utf8",
322+
maxBuffer: 10 * 1024 * 1024,
323+
...options,
324+
}, (error, stdout, stderr) => {
325+
if (error) {
326+
error.stdout = stdout;
327+
error.stderr = stderr;
328+
reject(error);
329+
return;
330+
}
331+
resolve({ stdout, stderr });
332+
});
333+
});
334+
}
335+
336+
async function pathExists(target) {
337+
try {
338+
await fs.access(target);
339+
return true;
340+
} catch {
341+
return false;
342+
}
343+
}
344+
345+
function summarizeResults(results) {
346+
const successes = [];
347+
const failures = [];
348+
349+
for (const result of results) {
350+
if (!result) {
351+
continue;
352+
}
353+
354+
if (result.error) {
355+
const message = result.error instanceof Error ? result.error.message : String(result.error);
356+
failures.push({ number: result.pr.number, message });
357+
} else {
358+
successes.push(result.pr.number);
359+
}
360+
}
361+
362+
console.log("\n=== Summary ===");
363+
364+
if (successes.length) {
365+
console.log(`Codex processed PRs: ${successes.join(", ")}`);
366+
}
367+
368+
if (failures.length) {
369+
console.log("Failures:");
370+
for (const failure of failures) {
371+
console.log(`- PR ${failure.number}: ${failure.message}`);
372+
}
373+
}
374+
}
375+
376+
main().catch((error) => {
377+
console.error("Fatal error:", error);
378+
process.exit(1);
379+
});
380+
381+

0 commit comments

Comments
 (0)