Public: anyone can read this; the crew can change it.

Sentinel: red:01m23vygyffx5hg2f5vm8r74sr was degraded 142 minutes today: trevato/forge: main is red at ea64226 — test-shards: over 45 s: test/crew.test.ts (52.5s); 8 pull checks wait for it to… #2328

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/2314-sentinel-red-01m23vygyffx5hg2f5vm8r74sr → main 2 commits fork 2 threads · 2 unresolved

  • Merged. scout/2314-sentinel-red-01m23vygyffx5hg2f5vm8r74sr was merged into main as 2467da0.

Review

Sign in to review this change.
2 files changed +296 −245
test/crew-early.test.ts added +294
@@ -0,0 +1,294 @@
1 /**
2 * The crew loop with a stand-in Claude Code, second half: the pull that opens
3 * at the first push, while the run is still going, and what the run's end
4 * does to it. Split from `crew.test.ts` so each half stays under the slow-file
5 * limit (#2314).
6 */
7
8 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
9 import { existsSync, rmSync, writeFileSync } from "node:fs";
10 import { join } from "node:path";
11 import type { App } from "../src/app.ts";
12 import {
13 addComment,
14 assignIssue,
15 editIssue,
16 openIssue,
17 setIssueState,
18 } from "../src/actions/issues.ts";
19 import { pullOfBranch, watchForPull } from "../src/crew/early.ts";
20 import type { CrewHandle } from "../src/crew/dispatch.ts";
21 import { resolveOid } from "../src/git/merge.ts";
22 import { abortAgentRun } from "../src/crew/running.ts";
23 import { getAgentByName } from "../src/core/agents.ts";
24 import { createIssue, getIssue, listComments } from "../src/core/issues.ts";
25 import { listPulls } from "../src/core/pulls.ts";
26 import { repoDir } from "../src/git/repos.ts";
27 import { getForkOf, type Repo } from "../src/core/repos.ts";
28 import { listRuns, type Run } from "../src/core/runs.ts";
29 import type { User } from "../src/core/users.ts";
30 import { readRunLog } from "../src/runs/log.ts";
31 import {
32 agentRunSettled,
33 startStandInCrew,
34 stopStandInCrew,
outdated

Servers is only used to type an unused variable; remove this import.

35 type StandInCrew,
36 } from "./helpers/crew-standin.ts";
37
38 describe("the crew, with a stand-in Claude Code: the early pull", () => {
39 let standIn: StandInCrew;
40 let dataDir: string;
41 let app: App;
42 let crew: CrewHandle;
43 let alice: User;
44 let bot: User;
45 let repo: Repo;
46
47 const settled = (where: (run: Run) => boolean, timeoutMs?: number) =>
48 agentRunSettled(app, repo, where, timeoutMs);
49
50 beforeAll(async () => {
51 standIn = await startStandInCrew();
52 ({ dataDir, app, crew, alice, bot, repo } = standIn);
53 }, 30_000);
54
55 afterAll(() => stopStandInCrew(standIn));
56
57 test("the pull opens at the first push, in progress, and the run's end fills that same pull in", async () => {
58 const issue = openIssue(app, alice, repo, {
59 title: "Plant crocuses",
outdated

servers, argsFile and promptFile are never read in this file; destructure only what the tests use, and put spaces after the commas.

60 body: "LINGER-ME by the gate.",
61 });
62 expect(issue.ok).toBe(true);
63 if (!issue.ok) return;
64 expect(assignIssue(app, alice, repo, issue.value, bot).ok).toBe(true);
65 const branch = `bot/${issue.value.number}-plant-crocuses`;
66 const mine = () =>
67 listPulls(app.db, repo.id, { state: "open", limit: 200 }).filter(
68 (p) => p.headBranch === branch,
69 );
70 // The agent has pushed and is still working: the pull is already there.
71 const deadline = Date.now() + 20_000;
72 while (mine().length === 0 && Date.now() < deadline) await Bun.sleep(100);
73 const early = mine()[0]!;
74 expect(early).toBeDefined();
75 expect(early.title).toBe("Plant crocuses");
76 expect(early.baseBranch).toBe(repo.defaultBranch);
77 expect(early.body).toStartWith("In progress:");
78 expect(early.body).toMatch(/\/runs\/[\w-]+/);
79 expect(early.body).toContain(`Closes #${issue.value.number}`);
80 expect(existsSync(join(dataDir, "release"))).toBe(false);
81
82 writeFileSync(join(dataDir, "release"), "");
83 const run = await settled((r) => r.branch === branch);
84 expect(run.state).toBe("ok");
85 expect(run.summary).toMatch(new RegExp(`^opened pull #${early.number}\\b`));
86 const after = mine();
87 expect(after.length).toBe(1);
88 expect(after[0]!.number).toBe(early.number);
89 expect(after[0]!.body).not.toContain("In progress:");
90 expect(after[0]!.body).toContain("Added CREW.md as asked.");
91 expect(after[0]!.body).toContain(`Closes #${issue.value.number}`);
92 const log = readRunLog(dataDir, run.id).lines.join("\n");
93 expect(log).toContain(`opened pull #${early.number} at the first push`);
94 }, 60_000);
95
96 test("a body a person edited while the run was going is left alone at the run's end", async () => {
97 rmSync(join(dataDir, "release"), { force: true });
98 const issue = openIssue(app, alice, repo, {
99 title: "Plant snowdrops",
100 body: "LINGER-ME under the oak.",
101 });
102 if (!issue.ok) throw new Error("issue");
103 expect(assignIssue(app, alice, repo, issue.value, bot).ok).toBe(true);
104 const branch = `bot/${issue.value.number}-plant-snowdrops`;
105 const mine = () =>
106 listPulls(app.db, repo.id, { state: "open", limit: 200 }).filter(
107 (p) => p.headBranch === branch,
108 );
109 const deadline = Date.now() + 20_000;
110 while (mine().length === 0 && Date.now() < deadline) await Bun.sleep(100);
111 const early = mine()[0]!;
112 expect(
113 editIssue(app, alice, repo, early, { body: "My own words.\n\nCloses #1" }).ok,
114 ).toBe(true);
115 writeFileSync(join(dataDir, "release"), "");
116 const run = await settled((r) => r.branch === branch);
117 expect(run.summary).toMatch(new RegExp(`^updated pull #${early.number}\\b`));
118 expect(mine()[0]!.body).toBe("My own words.\n\nCloses #1");
119 }, 60_000);
120
121 /** An agent's issue and ask, assigned, with the pull its first push opens. */
122 async function pullAfterPush(word: string, slug: string) {
123 rmSync(join(dataDir, "release"), { force: true });
124 const issue = openIssue(app, alice, repo, { title: `Weed ${slug}`, body: word });
125 if (!issue.ok) throw new Error("issue");
126 expect(assignIssue(app, alice, repo, issue.value, bot).ok).toBe(true);
127 const branch = `bot/${issue.value.number}-weed-${slug}`;
128 const mine = () =>
129 listPulls(app.db, repo.id, { state: "all", limit: 200 }).filter(
130 (p) => p.headBranch === branch,
131 );
132 const deadline = Date.now() + 20_000;
133 while (mine().length === 0 && Date.now() < deadline) await Bun.sleep(100);
134 expect(mine()[0]!.state).toBe("open");
135 writeFileSync(join(dataDir, "release"), "");
136 const run = await settled((r) => r.branch === branch);
137 return { run, pull: mine()[0]! };
138 }
139
140 test("a run that declines after its pull opened early closes that pull with the reason", async () => {
141 const { run, pull } = await pullAfterPush("PUSH-THEN-DECLINE", "declines");
142 expect(pull.state).toBe("closed");
143 expect(pull.body).not.toContain("In progress:");
144 expect(pull.body).toContain("Declined: this turned out to be out of scope.");
145 expect(pull.body).toContain(`/runs/${run.id}`);
146 }, 60_000);
147
148 test("a run that fails after its pull opened early has pushed work: the pull stays open for review", async () => {
149 const { run, pull } = await pullAfterPush("PUSH-THEN-FAIL", "fails");
150 expect(run.state).toBe("ok");
151 expect(pull.state).toBe("open");
152 expect(pull.body).not.toContain("In progress:");
153 }, 60_000);
154
155 test("an agent may close a pull it wrote and nothing else", () => {
156 const make = (authorId: string, kind: "issue" | "pull") => {
157 const made = createIssue(app.db, {
158 repoId: repo.id,
159 authorId,
160 title: `Withdraw ${kind}`,
161 kind,
162 });
163 if (!made.ok) throw new Error("issue");
164 return made.value;
165 };
166 const own = make(bot.id, "pull");
167 expect(setIssueState(app, bot, repo, own, "closed").ok).toBe(true);
168 const theirs = make(alice.id, "pull");
169 const refused = setIssueState(app, bot, repo, theirs, "closed");
170 expect(refused.ok).toBe(false);
171 expect(getIssue(app.db, repo.id, theirs.number)!.state).toBe("open");
172 const ownIssue = make(bot.id, "issue");
173 expect(setIssueState(app, bot, repo, ownIssue, "closed").ok).toBe(false);
174 expect(getIssue(app.db, repo.id, ownIssue.number)!.state).toBe("open");
175 });
176
177 test("a pull a person commented on stays open when the run declines, with a comment", async () => {
178 rmSync(join(dataDir, "release"), { force: true });
179 const issue = openIssue(app, alice, repo, {
180 title: "Weed PUSH-THEN-DECLINE twice",
181 body: "PUSH-THEN-DECLINE",
182 });
183 if (!issue.ok) throw new Error("issue");
184 expect(assignIssue(app, alice, repo, issue.value, bot).ok).toBe(true);
185 const branch = `bot/${issue.value.number}-weed-push-then-decline-twice`;
186 const mine = () =>
187 listPulls(app.db, repo.id, { state: "all", limit: 200 }).filter(
188 (p) => p.headBranch === branch,
189 );
190 const deadline = Date.now() + 20_000;
191 while (mine().length === 0 && Date.now() < deadline) await Bun.sleep(100);
192 const early = mine()[0]!;
193 expect(addComment(app, alice, repo, early, "I like where this goes.").ok).toBe(true);
194 writeFileSync(join(dataDir, "release"), "");
195 await settled((r) => r.branch === branch);
196 // The comment queued a follow-up run; let it end before the next test.
197 const idle = Date.now() + 20_000;
198 while (
199 listRuns(app.db, repo.id, { kind: "agent" }).some(
200 (r) => r.state === "queued" || r.state === "running",
201 ) &&
202 Date.now() < idle
203 )
204 await Bun.sleep(100);
205 expect(mine()[0]!.state).toBe("open");
206 const last = listComments(app.db, early.id).at(-1)!;
207 expect(last.body).toContain("this turned out to be out of scope.");
208 expect(last.body).toContain("Left open");
209 }, 60_000);
210
211 test("a run cancelled after its first push leaves the pull saying the run ended, with the log", async () => {
212 // The stand-in waits for the release file; take it away so it lingers.
213 rmSync(join(dataDir, "release"), { force: true });
214 const issue = openIssue(app, alice, repo, {
215 title: "Plant bluebells",
216 body: "LINGER-ME in the shade.",
217 });
218 if (!issue.ok) throw new Error("issue");
219 expect(assignIssue(app, alice, repo, issue.value, bot).ok).toBe(true);
220 const branch = `bot/${issue.value.number}-plant-bluebells`;
221 const mine = () =>
222 listPulls(app.db, repo.id, { state: "open", limit: 200 }).filter(
223 (p) => p.headBranch === branch,
224 );
225 const deadline = Date.now() + 20_000;
226 while (mine().length === 0 && Date.now() < deadline) await Bun.sleep(100);
227 expect(mine()[0]!.body).toStartWith("In progress:");
228 const running = listRuns(app.db, repo.id, { kind: "agent" }).find(
229 (r) => r.branch === branch,
230 )!;
231 expect(abortAgentRun(running.id)).toBe(true);
232 const run = await settled((r) => r.branch === branch);
233 expect(run.summary).toContain("cancelled");
234 const body = mine()[0]!.body;
235 expect(body).not.toContain("In progress:");
236 expect(body).toStartWith("The run ended before it reported");
237 expect(body).toContain(`/runs/${run.id}`);
238 expect(body).toContain(`Closes #${issue.value.number}`);
239 }, 60_000);
240
241 test("an early open that fails is tried once and leaves no pull behind", async () => {
242 const agent = getAgentByName(app.db, "bot")!;
243 const fork = getForkOf(app.db, bot.id, repo.id)!;
244 const bare = repoDir(app.reposRoot, repo.ownerName, repo.name);
245 const forkBare = repoDir(app.reposRoot, fork.ownerName, fork.name);
246 const issue = openIssue(app, alice, repo, { title: "Plant ferns", body: "Shade." });
247 if (!issue.ok) throw new Error("issue");
248 // A branch one commit beyond the base, in the fork.
249 const branch = "bot/early-fails";
250 const git = async (...args: string[]) => {
251 const p = Bun.spawn(["git", ...args], {
252 cwd: forkBare,
253 stdout: "pipe",
254 stderr: "pipe",
255 env: {
256 ...process.env,
257 GIT_AUTHOR_NAME: "t",
258 GIT_AUTHOR_EMAIL: "[email protected]",
259 GIT_COMMITTER_NAME: "t",
260 GIT_COMMITTER_EMAIL: "[email protected]",
261 },
262 });
263 const out = await new Response(p.stdout).text();
264 expect(await p.exited).toBe(0);
265 return out.trim();
266 };
267 const baseTip = (await resolveOid(bare, `refs/heads/${repo.defaultBranch}`))!;
268 const tree = await git("rev-parse", `${baseTip}^{tree}`);
269 const commit = await git("commit-tree", tree, "-p", baseTip, "-m", "ferns");
270 await git("update-ref", `refs/heads/${branch}`, commit);
271
272 const lines: string[] = [];
273 // An issue with no title is one the forge cannot open a pull for.
274 const untitled = { ...issue.value, title: "" };
275 const run = listRuns(app.db, repo.id, { kind: "agent" })[0]!;
276 const early = watchForPull({
277 app, repo, agent, issue: untitled, run, fork, branch, forkBare, bare,
278 forgeUrl: "http://forge.test",
279 write: (l) => lines.push(l),
280 variantGroup: null,
281 continuing: false,
282 intervalMs: 20,
283 });
284 const deadline = Date.now() + 5_000;
285 while (lines.length === 0 && Date.now() < deadline) await Bun.sleep(20);
286 expect(lines.length).toBe(1);
287 expect(lines[0]).toContain("could not open the pull early");
288 // Many looks later it has not tried again, and no pull stands.
289 await Bun.sleep(300);
290 await early.stop();
291 expect(lines.length).toBe(1);
292 expect(pullOfBranch(app, repo, fork, branch)).toBeUndefined();
293 }, 30_000);
294 });
test/crew.test.ts +2 −245
@@ -1,6 +1,7 @@
11 /**
22 * The crew loop with a stand-in for Claude Code (`helpers/crew-standin.ts`):
33 * one pull's life — assigned, cloned, worked, landed, fed back on, closed.
4 * The early pull is in `crew-early.test.ts`.
45 * The rest of the loop is in the other `crew-*.test.ts` files, each on a
56 * forge of its own so they run apart and in parallel (#1798).
67 */
@@ -11,9 +12,7 @@ import {
1112 mkdirSync,
1213 readFileSync,
1314 realpathSync,
14 rmSync,
1515 symlinkSync,
16 writeFileSync,
1716 } from "node:fs";
1817 import { mkdtemp, rm } from "node:fs/promises";
1918 import { tmpdir } from "node:os";
@@ -22,18 +21,14 @@ import type { App } from "../src/app.ts";
2221 import {
2322 addComment,
2423 assignIssue,
25 editIssue,
2624 openIssue,
2725 setIssueState,
2826 } from "../src/actions/issues.ts";
29 import { pullOfBranch, trailerFor, watchForPull } from "../src/crew/early.ts";
30 import { openPull } from "../src/actions/pulls.ts";
3127 import { sandboxAvailable } from "../src/crew/sandbox.ts";
3228 import { queueAgentRun, type CrewHandle } from "../src/crew/dispatch.ts";
33 import { abortAgentRun } from "../src/crew/running.ts";
3429 import { workdirFor } from "../src/crew/runner.ts";
3530 import { getAgentByName } from "../src/core/agents.ts";
36 import { createIssue, getIssue, listComments } from "../src/core/issues.ts";
31 import { getIssue, listComments } from "../src/core/issues.ts";
3732 import { listPulls } from "../src/core/pulls.ts";
3833 import { resolveOid } from "../src/git/merge.ts";
3934 import { listPaths } from "../src/git/read.ts";
@@ -373,242 +368,4 @@ describe("the crew, with a stand-in Claude Code", () => {
373368 `pull #${closed.number} was closed before the run began`,
374369 );
375370 }, 30_000);
376
377 test("the pull opens at the first push, in progress, and the run's end fills that same pull in", async () => {
378 const issue = openIssue(app, alice, repo, {
379 title: "Plant crocuses",
380 body: "LINGER-ME by the gate.",
381 });
382 expect(issue.ok).toBe(true);
383 if (!issue.ok) return;
384 expect(assignIssue(app, alice, repo, issue.value, bot).ok).toBe(true);
385 const branch = `bot/${issue.value.number}-plant-crocuses`;
386 const mine = () =>
387 listPulls(app.db, repo.id, { state: "open", limit: 200 }).filter(
388 (p) => p.headBranch === branch,
389 );
390 // The agent has pushed and is still working: the pull is already there.
391 const deadline = Date.now() + 20_000;
392 while (mine().length === 0 && Date.now() < deadline) await Bun.sleep(100);
393 const early = mine()[0]!;
394 expect(early).toBeDefined();
395 expect(early.title).toBe("Plant crocuses");
396 expect(early.baseBranch).toBe(repo.defaultBranch);
397 expect(early.body).toStartWith("In progress:");
398 expect(early.body).toMatch(/\/runs\/[\w-]+/);
399 expect(early.body).toContain(`Closes #${issue.value.number}`);
400 expect(existsSync(join(dataDir, "release"))).toBe(false);
401
402 writeFileSync(join(dataDir, "release"), "");
403 const run = await settled((r) => r.branch === branch);
404 expect(run.state).toBe("ok");
405 expect(run.summary).toMatch(new RegExp(`^opened pull #${early.number}\\b`));
406 const after = mine();
407 expect(after.length).toBe(1);
408 expect(after[0]!.number).toBe(early.number);
409 expect(after[0]!.body).not.toContain("In progress:");
410 expect(after[0]!.body).toContain("Added CREW.md as asked.");
411 expect(after[0]!.body).toContain(`Closes #${issue.value.number}`);
412 const log = readRunLog(dataDir, run.id).lines.join("\n");
413 expect(log).toContain(`opened pull #${early.number} at the first push`);
414 }, 60_000);
415
416 test("a body a person edited while the run was going is left alone at the run's end", async () => {
417 rmSync(join(dataDir, "release"), { force: true });
418 const issue = openIssue(app, alice, repo, {
419 title: "Plant snowdrops",
420 body: "LINGER-ME under the oak.",
421 });
422 if (!issue.ok) throw new Error("issue");
423 expect(assignIssue(app, alice, repo, issue.value, bot).ok).toBe(true);
424 const branch = `bot/${issue.value.number}-plant-snowdrops`;
425 const mine = () =>
426 listPulls(app.db, repo.id, { state: "open", limit: 200 }).filter(
427 (p) => p.headBranch === branch,
428 );
429 const deadline = Date.now() + 20_000;
430 while (mine().length === 0 && Date.now() < deadline) await Bun.sleep(100);
431 const early = mine()[0]!;
432 expect(
433 editIssue(app, alice, repo, early, { body: "My own words.\n\nCloses #1" }).ok,
434 ).toBe(true);
435 writeFileSync(join(dataDir, "release"), "");
436 const run = await settled((r) => r.branch === branch);
437 expect(run.summary).toMatch(new RegExp(`^updated pull #${early.number}\\b`));
438 expect(mine()[0]!.body).toBe("My own words.\n\nCloses #1");
439 }, 60_000);
440
441 /** An agent's issue and ask, assigned, with the pull its first push opens. */
442 async function pullAfterPush(word: string, slug: string) {
443 rmSync(join(dataDir, "release"), { force: true });
444 const issue = openIssue(app, alice, repo, { title: `Weed ${slug}`, body: word });
445 if (!issue.ok) throw new Error("issue");
446 expect(assignIssue(app, alice, repo, issue.value, bot).ok).toBe(true);
447 const branch = `bot/${issue.value.number}-weed-${slug}`;
448 const mine = () =>
449 listPulls(app.db, repo.id, { state: "all", limit: 200 }).filter(
450 (p) => p.headBranch === branch,
451 );
452 const deadline = Date.now() + 20_000;
453 while (mine().length === 0 && Date.now() < deadline) await Bun.sleep(100);
454 expect(mine()[0]!.state).toBe("open");
455 writeFileSync(join(dataDir, "release"), "");
456 const run = await settled((r) => r.branch === branch);
457 return { run, pull: mine()[0]! };
458 }
459
460 test("a run that declines after its pull opened early closes that pull with the reason", async () => {
461 const { run, pull } = await pullAfterPush("PUSH-THEN-DECLINE", "declines");
462 expect(pull.state).toBe("closed");
463 expect(pull.body).not.toContain("In progress:");
464 expect(pull.body).toContain("Declined: this turned out to be out of scope.");
465 expect(pull.body).toContain(`/runs/${run.id}`);
466 }, 60_000);
467
468 test("a run that fails after its pull opened early has pushed work: the pull stays open for review", async () => {
469 const { run, pull } = await pullAfterPush("PUSH-THEN-FAIL", "fails");
470 expect(run.state).toBe("ok");
471 expect(pull.state).toBe("open");
472 expect(pull.body).not.toContain("In progress:");
473 }, 60_000);
474
475 test("an agent may close a pull it wrote and nothing else", () => {
476 const make = (authorId: string, kind: "issue" | "pull") => {
477 const made = createIssue(app.db, {
478 repoId: repo.id,
479 authorId,
480 title: `Withdraw ${kind}`,
481 kind,
482 });
483 if (!made.ok) throw new Error("issue");
484 return made.value;
485 };
486 const own = make(bot.id, "pull");
487 expect(setIssueState(app, bot, repo, own, "closed").ok).toBe(true);
488 const theirs = make(alice.id, "pull");
489 const refused = setIssueState(app, bot, repo, theirs, "closed");
490 expect(refused.ok).toBe(false);
491 expect(getIssue(app.db, repo.id, theirs.number)!.state).toBe("open");
492 const ownIssue = make(bot.id, "issue");
493 expect(setIssueState(app, bot, repo, ownIssue, "closed").ok).toBe(false);
494 expect(getIssue(app.db, repo.id, ownIssue.number)!.state).toBe("open");
495 });
496
497 test("a pull a person commented on stays open when the run declines, with a comment", async () => {
498 rmSync(join(dataDir, "release"), { force: true });
499 const issue = openIssue(app, alice, repo, {
500 title: "Weed PUSH-THEN-DECLINE twice",
501 body: "PUSH-THEN-DECLINE",
502 });
503 if (!issue.ok) throw new Error("issue");
504 expect(assignIssue(app, alice, repo, issue.value, bot).ok).toBe(true);
505 const branch = `bot/${issue.value.number}-weed-push-then-decline-twice`;
506 const mine = () =>
507 listPulls(app.db, repo.id, { state: "all", limit: 200 }).filter(
508 (p) => p.headBranch === branch,
509 );
510 const deadline = Date.now() + 20_000;
511 while (mine().length === 0 && Date.now() < deadline) await Bun.sleep(100);
512 const early = mine()[0]!;
513 expect(addComment(app, alice, repo, early, "I like where this goes.").ok).toBe(true);
514 writeFileSync(join(dataDir, "release"), "");
515 await settled((r) => r.branch === branch);
516 // The comment queued a follow-up run; let it end before the next test.
517 const idle = Date.now() + 20_000;
518 while (
519 listRuns(app.db, repo.id, { kind: "agent" }).some(
520 (r) => r.state === "queued" || r.state === "running",
521 ) &&
522 Date.now() < idle
523 )
524 await Bun.sleep(100);
525 expect(mine()[0]!.state).toBe("open");
526 const last = listComments(app.db, early.id).at(-1)!;
527 expect(last.body).toContain("this turned out to be out of scope.");
528 expect(last.body).toContain("Left open");
529 }, 60_000);
530
531 test("a run cancelled after its first push leaves the pull saying the run ended, with the log", async () => {
532 // The stand-in waits for the release file; take it away so it lingers.
533 rmSync(join(dataDir, "release"), { force: true });
534 const issue = openIssue(app, alice, repo, {
535 title: "Plant bluebells",
536 body: "LINGER-ME in the shade.",
537 });
538 if (!issue.ok) throw new Error("issue");
539 expect(assignIssue(app, alice, repo, issue.value, bot).ok).toBe(true);
540 const branch = `bot/${issue.value.number}-plant-bluebells`;
541 const mine = () =>
542 listPulls(app.db, repo.id, { state: "open", limit: 200 }).filter(
543 (p) => p.headBranch === branch,
544 );
545 const deadline = Date.now() + 20_000;
546 while (mine().length === 0 && Date.now() < deadline) await Bun.sleep(100);
547 expect(mine()[0]!.body).toStartWith("In progress:");
548 const running = listRuns(app.db, repo.id, { kind: "agent" }).find(
549 (r) => r.branch === branch,
550 )!;
551 expect(abortAgentRun(running.id)).toBe(true);
552 const run = await settled((r) => r.branch === branch);
553 expect(run.summary).toContain("cancelled");
554 const body = mine()[0]!.body;
555 expect(body).not.toContain("In progress:");
556 expect(body).toStartWith("The run ended before it reported");
557 expect(body).toContain(`/runs/${run.id}`);
558 expect(body).toContain(`Closes #${issue.value.number}`);
559 }, 60_000);
560
561 test("an early open that fails is tried once and leaves no pull behind", async () => {
562 const agent = getAgentByName(app.db, "bot")!;
563 const fork = getForkOf(app.db, bot.id, repo.id)!;
564 const bare = repoDir(app.reposRoot, repo.ownerName, repo.name);
565 const forkBare = repoDir(app.reposRoot, fork.ownerName, fork.name);
566 const issue = openIssue(app, alice, repo, { title: "Plant ferns", body: "Shade." });
567 if (!issue.ok) throw new Error("issue");
568 // A branch one commit beyond the base, in the fork.
569 const branch = "bot/early-fails";
570 const git = async (...args: string[]) => {
571 const p = Bun.spawn(["git", ...args], {
572 cwd: forkBare,
573 stdout: "pipe",
574 stderr: "pipe",
575 env: {
576 ...process.env,
577 GIT_AUTHOR_NAME: "t",
578 GIT_AUTHOR_EMAIL: "[email protected]",
579 GIT_COMMITTER_NAME: "t",
580 GIT_COMMITTER_EMAIL: "[email protected]",
581 },
582 });
583 const out = await new Response(p.stdout).text();
584 expect(await p.exited).toBe(0);
585 return out.trim();
586 };
587 const baseTip = (await resolveOid(bare, `refs/heads/${repo.defaultBranch}`))!;
588 const tree = await git("rev-parse", `${baseTip}^{tree}`);
589 const commit = await git("commit-tree", tree, "-p", baseTip, "-m", "ferns");
590 await git("update-ref", `refs/heads/${branch}`, commit);
591
592 const lines: string[] = [];
593 // An issue with no title is one the forge cannot open a pull for.
594 const untitled = { ...issue.value, title: "" };
595 const run = listRuns(app.db, repo.id, { kind: "agent" })[0]!;
596 const early = watchForPull({
597 app, repo, agent, issue: untitled, run, fork, branch, forkBare, bare,
598 forgeUrl: "http://forge.test",
599 write: (l) => lines.push(l),
600 variantGroup: null,
601 continuing: false,
602 intervalMs: 20,
603 });
604 const deadline = Date.now() + 5_000;
605 while (lines.length === 0 && Date.now() < deadline) await Bun.sleep(20);
606 expect(lines.length).toBe(1);
607 expect(lines[0]).toContain("could not open the pull early");
608 // Many looks later it has not tried again, and no pull stands.
609 await Bun.sleep(300);
610 await early.stop();
611 expect(lines.length).toBe(1);
612 expect(pullOfBranch(app, repo, fork, branch)).toBeUndefined();
613 }, 30_000);
614371 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs