Skip to content

Commit 968a43f

Browse files
committed
feat(worker): add a setup phase to pre-PR checks
1 parent 05e7b9e commit 968a43f

8 files changed

Lines changed: 368 additions & 8 deletions

File tree

apps/dashboard/components/cockpit/screens/pre-pr-checks.tsx

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,10 @@ export function PrePrChecksScreen({
3030
const savedRepos = versions[0]?.config.repositories ?? [];
3131
const dirty = JSON.stringify(repos) !== JSON.stringify(savedRepos);
3232
const valid = repos.every(
33-
(r) => r.commands.length > 0 && r.commands.every((c) => c.trim().length > 0),
33+
(r) =>
34+
r.commands.length > 0 &&
35+
r.commands.every((c) => c.trim().length > 0) &&
36+
(r.setup ?? []).every((c) => c.trim().length > 0),
3437
);
3538

3639
function applyVersion(version: PrePrCheckConfigVersion) {
@@ -135,6 +138,54 @@ export function PrePrChecksScreen({
135138
</button>
136139
)}
137140
</div>
141+
<div className="font-body text-[12px] font-semibold text-neutral-800">Setup</div>
142+
<p className="font-body text-[11px] text-neutral-500 mb-[6px]">
143+
Runs once before the checks below, for installing a toolchain the sandbox does not
144+
ship. A failed setup command blocks the run and is never sent to the agent fix cycles.
145+
</p>
146+
{(repo.setup ?? []).map((command, si) => (
147+
<div key={`setup-${si}`} className="flex items-center gap-2 mb-[6px]">
148+
<span className="font-mono text-[11px] text-neutral-400 w-4 text-right">{si + 1}.</span>
149+
<input
150+
value={command}
151+
disabled={!canEdit}
152+
onChange={(e) =>
153+
updateRepo(index, {
154+
...repo,
155+
setup: (repo.setup ?? []).map((c, i) => (i === si ? e.target.value : c)),
156+
})
157+
}
158+
placeholder="make bootstrap"
159+
className="flex-1 rounded-[3px] border border-neutral-200 bg-white px-2 py-[6px] font-mono text-[12px] text-neutral-900 disabled:bg-app-bg"
160+
/>
161+
{canEdit && (
162+
<button
163+
onClick={() =>
164+
updateRepo(index, {
165+
...repo,
166+
setup: (repo.setup ?? []).filter((_, i) => i !== si),
167+
})
168+
}
169+
aria-label="Remove setup command"
170+
className="appearance-none border-none bg-transparent font-mono text-[13px] text-neutral-400 hover:text-red-600 cursor-pointer"
171+
>
172+
×
173+
</button>
174+
)}
175+
</div>
176+
))}
177+
{canEdit && (
178+
<button
179+
onClick={() => updateRepo(index, { ...repo, setup: [...(repo.setup ?? []), ""] })}
180+
className="appearance-none border-none bg-transparent font-body text-[12px] text-mariner cursor-pointer px-0"
181+
>
182+
+ Add setup command
183+
</button>
184+
)}
185+
186+
<div className="font-body text-[12px] font-semibold text-neutral-800 mt-3 mb-[6px]">
187+
Checks
188+
</div>
138189
{repo.commands.map((command, ci) => (
139190
<div key={ci} className="flex items-center gap-2 mb-[6px]">
140191
<span className="font-mono text-[11px] text-neutral-400 w-4 text-right">{ci + 1}.</span>

apps/shared/contracts/domain.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,8 @@ export type VcsProviderKind = "github" | "gitlab";
225225
export interface PrePrCheckRepositoryConfig {
226226
provider: VcsProviderKind;
227227
repoPath: string;
228+
/** Provisioning commands run before `commands`. Absent in older configs. */
229+
setup?: string[];
228230
commands: string[];
229231
}
230232

apps/worker/src/pre-pr-checks/config.test.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,50 @@ describe("prePrCheckConfigSchema", () => {
1616
expect(prePrCheckConfigSchema.safeParse({ repositories: [] }).success).toBe(true);
1717
});
1818

19+
it("accepts a stored config with no setup key and defaults it to empty", () => {
20+
const result = prePrCheckConfigSchema.safeParse({
21+
repositories: [{ provider: "github", repoPath: "acme/web", commands: ["pnpm test"] }],
22+
});
23+
expect(result.success).toBe(true);
24+
if (result.success) {
25+
expect(result.data.repositories[0]!.setup).toEqual([]);
26+
}
27+
});
28+
29+
it("accepts per-repo setup commands and keeps their order", () => {
30+
const result = prePrCheckConfigSchema.safeParse({
31+
repositories: [
32+
{
33+
provider: "github",
34+
repoPath: "acme/web",
35+
setup: ["make bootstrap", "make deps"],
36+
commands: ["make lint"],
37+
},
38+
],
39+
});
40+
expect(result.success).toBe(true);
41+
if (result.success) {
42+
expect(result.data.repositories[0]!.setup).toEqual(["make bootstrap", "make deps"]);
43+
}
44+
});
45+
46+
it("accepts an empty setup list but rejects a blank setup command", () => {
47+
expect(
48+
prePrCheckConfigSchema.safeParse({
49+
repositories: [
50+
{ provider: "github", repoPath: "acme/web", setup: [], commands: ["pnpm test"] },
51+
],
52+
}).success,
53+
).toBe(true);
54+
expect(
55+
prePrCheckConfigSchema.safeParse({
56+
repositories: [
57+
{ provider: "github", repoPath: "acme/web", setup: [" "], commands: ["pnpm test"] },
58+
],
59+
}).success,
60+
).toBe(false);
61+
});
62+
1963
it("rejects a repository with no commands", () => {
2064
const result = prePrCheckConfigSchema.safeParse({
2165
repositories: [{ provider: "github", repoPath: "acme/web", commands: [] }],

apps/worker/src/pre-pr-checks/config.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,12 @@ import { z } from "zod";
33
export interface PrePrCheckRepositoryConfig {
44
provider: "github" | "gitlab";
55
repoPath: string;
6+
/**
7+
* Provisioning commands run before this repository's checks: toolchain
8+
* installs the sandbox image does not ship. Optional and absent from every
9+
* config stored before this field existed, so it must stay defaultable.
10+
*/
11+
setup?: string[];
612
commands: string[];
713
}
814

@@ -19,6 +25,9 @@ export const prePrCheckConfigSchema = z
1925
.object({
2026
provider: z.enum(["github", "gitlab"]),
2127
repoPath: z.string().trim().min(1),
28+
// No .min(1): a repository without provisioning is the normal case,
29+
// and every config stored before this field omits the key entirely.
30+
setup: z.array(z.string().trim().min(1)).default([]),
2231
commands: z.array(z.string().trim().min(1)).min(1),
2332
})
2433
.strict(),

apps/worker/src/pre-pr-checks/runner.test.ts

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,147 @@ describe("runPrePrChecksWithFixes", () => {
178178
expect(result.failures).toHaveLength(1);
179179
});
180180

181+
it("runs a repository's setup commands before its checks, in authored order", async () => {
182+
const shellCommands: string[] = [];
183+
mockRunCommand.mockImplementation((cmd, args) => {
184+
if (cmd === "cat" && args[0] === WORKSPACE_MANIFEST_PATH) {
185+
return commandResult(0, JSON.stringify(manifest));
186+
}
187+
if (cmd === "git" && args[0] === "-C" && args[2] === "rev-parse") {
188+
return commandResult(0, "web-head");
189+
}
190+
const shell = shellCommand(cmd);
191+
if (shell) shellCommands.push(shell);
192+
return commandResult(0, "");
193+
});
194+
195+
const result = await runPrePrChecksWithFixes(
196+
"sbx-test-123",
197+
{
198+
repositories: [
199+
{
200+
provider: "github",
201+
repoPath: "acme/web",
202+
setup: ["make bootstrap", "make deps"],
203+
commands: ["pnpm typecheck"],
204+
},
205+
],
206+
},
207+
"codex",
208+
"gpt-5",
209+
0,
210+
);
211+
212+
expect(result.passed).toBe(true);
213+
expect(result.setupFailed).toBe(false);
214+
expect(shellCommands).toEqual(["make bootstrap", "make deps", "pnpm typecheck"]);
215+
expect(mockRunCommand).toHaveBeenCalledWith({
216+
cmd: "bash",
217+
args: ["-lc", "make bootstrap"],
218+
cwd: "/vercel/sandbox",
219+
});
220+
});
221+
222+
it("stops a repository's checks and runs no fix cycles when its setup fails", async () => {
223+
const shellCommands: string[] = [];
224+
mockRunCommand.mockImplementation((cmd, args) => {
225+
const artifact = phaseArtifactCommand(cmd, args, "codex");
226+
if (artifact) return artifact;
227+
if (cmd === "cat" && args[0] === WORKSPACE_MANIFEST_PATH) {
228+
return commandResult(0, JSON.stringify(manifest));
229+
}
230+
if (cmd === "git" && args[0] === "-C" && args[2] === "rev-parse") {
231+
return commandResult(0, "web-head");
232+
}
233+
const shell = shellCommand(cmd);
234+
if (shell) {
235+
shellCommands.push(shell);
236+
if (shell === "make bootstrap") {
237+
return commandResult(127, "", "bash: line 1: toolchain: command not found");
238+
}
239+
}
240+
return commandResult(0, "");
241+
});
242+
243+
const result = await runPrePrChecksWithFixes(
244+
"sbx-test-123",
245+
{
246+
repositories: [
247+
{
248+
provider: "github",
249+
repoPath: "acme/web",
250+
setup: ["make bootstrap"],
251+
commands: ["pnpm typecheck"],
252+
},
253+
],
254+
},
255+
"codex",
256+
"gpt-5",
257+
);
258+
259+
expect(result.passed).toBe(false);
260+
expect(result.setupFailed).toBe(true);
261+
expect(result.fixCycles).toBe(0);
262+
expect(result.results).toEqual([]);
263+
expect(shellCommands).toEqual(["make bootstrap"]);
264+
expect(mockWriteFiles).not.toHaveBeenCalled();
265+
});
266+
267+
it("reports a setup failure distinctly from a check failure", async () => {
268+
mockRunCommand.mockImplementation((cmd, args) => {
269+
if (cmd === "cat" && args[0] === WORKSPACE_MANIFEST_PATH) {
270+
return commandResult(0, JSON.stringify(manifest));
271+
}
272+
if (cmd === "git" && args[0] === "-C" && args[2] === "rev-parse") {
273+
return commandResult(0, "changed-head");
274+
}
275+
const shell = shellCommand(cmd);
276+
if (shell === "make bootstrap") {
277+
return commandResult(127, "", "bash: line 1: toolchain: command not found");
278+
}
279+
if (shell === "pnpm test") return commandResult(1, "", "2 tests failed");
280+
return commandResult(0, "");
281+
});
282+
283+
const result = await runPrePrChecksWithFixes(
284+
"sbx-test-123",
285+
{
286+
repositories: [
287+
{
288+
provider: "github",
289+
repoPath: "acme/web",
290+
setup: ["make bootstrap"],
291+
commands: ["pnpm typecheck"],
292+
},
293+
{ provider: "gitlab", repoPath: "acme/api", commands: ["pnpm test"] },
294+
],
295+
},
296+
"codex",
297+
"gpt-5",
298+
0,
299+
);
300+
301+
expect(result.failures).toHaveLength(2);
302+
expect(result.failures[0]).toMatchObject({
303+
provider: "github",
304+
repoPath: "acme/web",
305+
command: "make bootstrap",
306+
exitCode: 127,
307+
phase: "setup",
308+
});
309+
expect(result.failures[1]).toMatchObject({
310+
provider: "gitlab",
311+
repoPath: "acme/api",
312+
command: "pnpm test",
313+
});
314+
expect(result.failures[1]!.phase).toBeUndefined();
315+
expect(result.summary).toContain("SETUP FAILED for github:acme/web");
316+
expect(result.summary).toContain("toolchain: command not found");
317+
expect(result.summary).toContain("no agent fix cycles were run");
318+
expect(result.summary).toContain("gitlab:acme/api");
319+
expect(result.summary).not.toContain("SETUP FAILED for gitlab:acme/api");
320+
});
321+
181322
it("fails a check that exits 0 while reporting its dependencies are not installed", async () => {
182323
mockRunCommand.mockImplementation((cmd, args) => {
183324
if (cmd === "cat" && args[0] === WORKSPACE_MANIFEST_PATH) {
@@ -732,6 +873,22 @@ function phaseArtifactCommand(
732873
return null;
733874
}
734875

876+
/** The shell command text of a `bash -lc` invocation, or null for anything else. */
877+
function shellCommand(cmd: unknown): string | null {
878+
const objectCommand = cmd as { cmd?: unknown; args?: unknown };
879+
if (
880+
typeof cmd === "object" &&
881+
cmd !== null &&
882+
objectCommand.cmd === "bash" &&
883+
Array.isArray(objectCommand.args) &&
884+
objectCommand.args[0] === "-lc" &&
885+
typeof objectCommand.args[1] === "string"
886+
) {
887+
return objectCommand.args[1];
888+
}
889+
return null;
890+
}
891+
735892
function isConfiguredCheck(cmd: unknown): boolean {
736893
const objectCommand = cmd as { cmd?: unknown; args?: unknown };
737894
return (

0 commit comments

Comments
 (0)