1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { assert, it } from "@effect/vitest";
3import * as Context from "effect/Context";
4import * as Deferred from "effect/Deferred";
5import * as Effect from "effect/Effect";
6import * as FileSystem from "effect/FileSystem";
7import * as Layer from "effect/Layer";
8import * as Ref from "effect/Ref";
9import * as Schedule from "effect/Schedule";
10import {
11 ProjectId,
12 GitCommandError,
13 type PullRequestActivity,
14 type PullRequestDetail,
15 type PullRequestStack,
16 type PullRequestRef,
17} from "@t3tools/contracts";
18import type { ReviewGuideGenerationInput } from "@t3tools/provider-core/server/textGeneration";
19
20import * as ServerConfig from "../config.ts";
21import * as SqlitePersistence from "../persistence/Sqlite.ts";
22import * as PullRequestReviewGuides from "../persistence/PullRequestReviewGuides.ts";
23import * as ProjectService from "../project/ProjectService.ts";
24import * as ProviderRegistry from "../provider/ProviderRegistry.ts";
25import * as ServerSettings from "../serverSettings.ts";
26import * as TextGeneration from "../textGeneration/TextGeneration.ts";
27import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
28import * as VcsProcess from "../vcs/VcsProcess.ts";
29import * as PullRequestReviewGuideService from "./PullRequestReviewGuideService.ts";
30import * as PullRequestService from "./PullRequestService.ts";
31
32const projectId = ProjectId.make("project-1");
33/** The test project is a checkout of the repository its pull requests come from. */
34const ACME_APP = {
35 canonicalKey: "github.com/acme/app",
36 locator: {
37 source: "git-remote" as const,
38 remoteName: "origin",
39 remoteUrl: "https://github.com/acme/app.git",
40 },
41 provider: "github",
42 displayName: "acme/app",
43 owner: "acme",
44 name: "app",
45};
46
47const reference: PullRequestRef = {
48 projectId,
49 host: "github.com",
50 repository: "acme/app",
51 number: 7,
52};
53
54const git = (cwd: string, args: ReadonlyArray<string>) =>
55 Effect.gen(function* () {
56 const driver = yield* GitVcsDriver.GitVcsDriver;
57 const result = yield* driver.execute({ operation: "test.git", cwd, args, timeoutMs: 10_000 });
58 return result.stdout.trim();
59 });
60
61/**
62 * A remote whose `main` has moved on past where the change's base branch left it, with the
63 * change stacked on that base branch. A guide for the change must see only its own commit.
64 */
65const makeRepositories = Effect.gen(function* () {
66 const fs = yield* FileSystem.FileSystem;
67 const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-review-guide-" });
68 const remote = `${root}/remote`;
69 const workspace = `${root}/workspace`;
70 yield* fs.makeDirectory(remote);
71 yield* git(remote, ["init", "--quiet", "--initial-branch=main"]);
72 yield* git(remote, ["config", "user.email", "test@example.com"]);
73 yield* git(remote, ["config", "user.name", "Test"]);
74 const commit = (path: string, message: string, contents = message) =>
75 fs
76 .writeFileString(`${remote}/${path}`, contents)
77 .pipe(
78 Effect.andThen(git(remote, ["add", path])),
79 Effect.andThen(git(remote, ["commit", "--quiet", "-m", message])),
80 Effect.andThen(git(remote, ["rev-parse", "HEAD"])),
81 );
82 yield* commit("base.txt", "base");
83 yield* git(remote, ["checkout", "--quiet", "-b", "stack-base"]);
84 const lowerSha = yield* commit("lower.txt", "lower layer");
85 yield* git(remote, ["checkout", "--quiet", "-b", "feature"]);
86 // Ten lines, so an excerpt reaching past them has something to be clamped to.
87 const headSha = yield* commit(
88 "feature.txt",
89 "the change",
90 Array.from({ length: 10 }, (_, index) => `line ${index + 1}`).join("\n") + "\n",
91 );
92 // A layer above the change, which uses what the change adds.
93 yield* git(remote, ["checkout", "--quiet", "-b", "upper"]);
94 const upperSha = yield* commit("upper.txt", "upper layer");
95 yield* git(remote, ["checkout", "--quiet", "main"]);
96 yield* commit("unrelated.txt", "main moved on");
97 yield* git(root, ["clone", "--quiet", remote, workspace]);
98 return { root, workspace, headSha, lowerSha, upperSha };
99});
100
101const detail = (workspaceRoot: string, headSha: string): PullRequestDetail =>
102 ({
103 number: 7,
104 title: "Add the feature",
105 body: "Adds the feature.",
106 baseBranch: "stack-base",
107 headBranch: "feature",
108 headSha,
109 workspaceRoot,
110 }) as PullRequestDetail;
111
112const activity: PullRequestActivity = {
113 comments: [],
114 commentCount: 0,
115 commentsTruncated: false,
116 reviewThreads: [],
117 commits: [],
118};
119
120/** The change as the middle layer of three; the top layer's head is not on any fetched branch. */
121const stack = (repositories: {
122 readonly headSha: string;
123 readonly lowerSha: string;
124 readonly upperSha: string;
125}): PullRequestStack => ({
126 id: "stack",
127 number: 1,
128 url: "https://example.com/stack/1",
129 base: "main",
130 layers: [
131 {
132 number: 6,
133 title: "Lower",
134 headSha: repositories.lowerSha,
135 headBranch: "stack-base",
136 state: "open",
137 },
138 {
139 number: 7,
140 title: "Add the feature",
141 headSha: repositories.headSha,
142 headBranch: "feature",
143 state: "open",
144 },
145 {
146 number: 8,
147 title: "Upper",
148 headSha: repositories.upperSha.slice(0, 12),
149 headBranch: "upper",
150 state: "open",
151 },
152 { number: 9, title: "Unpushed", headSha: "f".repeat(40), headBranch: "gone", state: "open" },
153 ],
154});
155
156const makeLayer = (input: {
157 readonly root: string;
158 readonly workspace: string;
159 readonly headSha: string;
160 readonly lowerSha: string;
161 readonly upperSha: string;
162 readonly generate: TextGeneration.TextGeneration["Service"]["generateReviewGuide"];
163 /** Completed when a run stores its guide, which is the last thing a run does but forget it. */
164 readonly stored: Deferred.Deferred<void>;
165 /** The server's beta switch; on unless a test is about it being off. */
166 readonly enabled?: boolean;
167 /** The head the host reports now, for a test that pushes between runs. */
168 readonly currentHead?: Ref.Ref<string>;
169 /** The project's own repository; defaults to the one the test reference names. */
170 readonly identity?: typeof ACME_APP;
171}) =>
172 PullRequestReviewGuideService.layer.pipe(
173 Layer.provide(
174 Layer.effect(
175 PullRequestReviewGuides.PullRequestReviewGuideRepository,
176 Effect.gen(function* () {
177 const repository = yield* PullRequestReviewGuides.PullRequestReviewGuideRepository;
178 return {
179 get: repository.get,
180 put: (key, guide) =>
181 repository
182 .put(key, guide)
183 .pipe(Effect.tap(() => Deferred.succeed(input.stored, undefined))),
184 } satisfies PullRequestReviewGuides.PullRequestReviewGuideRepository["Service"];
185 }),
186 ).pipe(Layer.provide(PullRequestReviewGuides.layer)),
187 ),
188 Layer.provide(
189 Layer.succeed(PullRequestService.PullRequestService, {
190 detail: () =>
191 (input.currentHead === undefined
192 ? Effect.succeed(input.headSha)
193 : Ref.get(input.currentHead)
194 ).pipe(Effect.map((head) => detail(input.workspace, head))),
195 activity: () => Effect.succeed(activity),
196 invalidate: () => Effect.void,
197 stack: () => Effect.succeed(stack(input)),
198 } as unknown as PullRequestService.PullRequestService["Service"]),
199 ),
200 Layer.provide(
201 Layer.succeed(ProjectService.ProjectService, {
202 getShell: () =>
203 Effect.succeedSome({
204 id: projectId,
205 workspaceRoot: input.workspace,
206 repositoryIdentity: input.identity ?? ACME_APP,
207 }),
208 } as unknown as ProjectService.ProjectService["Service"]),
209 ),
210 Layer.provide(
211 Layer.succeed(ProviderRegistry.ProviderRegistry, {
212 getProviders: Effect.succeed([]),
213 } as unknown as ProviderRegistry.ProviderRegistry["Service"]),
214 ),
215 Layer.provide(
216 Layer.succeed(TextGeneration.TextGeneration, {
217 generateReviewGuide: input.generate,
218 } as unknown as TextGeneration.TextGeneration["Service"]),
219 ),
220 Layer.provide(ServerSettings.layerTest({ reviewGuidesEnabled: input.enabled ?? true })),
221 Layer.provide(ServerConfig.layerTest(input.root, { prefix: "t3-review-guide-config-" })),
222 );
223
224const layerBase = Layer.mergeAll(
225 GitVcsDriver.layer.pipe(
226 Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-review-guide-git-" })),
227 Layer.provideMerge(VcsProcess.layer),
228 ),
229 SqlitePersistence.layerMemory,
230).pipe(Layer.provideMerge(NodeServices.layer));
231
232type Repositories = Effect.Success<typeof makeRepositories>;
233
234const awaitRunFailure = (
235 service: PullRequestReviewGuideService.PullRequestReviewGuideService["Service"],
236 input: PullRequestRef,
237) =>
238 service.get(input).pipe(
239 Effect.flatMap((result) =>
240 result.run?._tag === "Failed"
241 ? Effect.succeed(result.run.detail)
242 : Effect.fail("still running" as const),
243 ),
244 Effect.retry(Schedule.spaced("20 millis")),
245 );
246
247/** A workspace cloned with only `main`, so the change's head is not there until fetched. */
248const startSingleBranchRun = (input: {
249 readonly repositoryName: string;
250 readonly reportedHead: (repositories: Repositories) => string;
251 readonly refuseCommitFetch: boolean;
252 readonly prepare: (
253 repositories: Repositories,
254 workspace: string,
255 ) => Effect.Effect<void, GitCommandError, GitVcsDriver.GitVcsDriver>;
256 /** Runs inside the head branch's fetch, after the service asked for it and before git runs. */
257 readonly onBranchFetch?:
258 | ((
259 repositories: Repositories,
260 ) => Effect.Effect<void, GitCommandError, GitVcsDriver.GitVcsDriver>)
261 | undefined;
262}) =>
263 Effect.gen(function* () {
264 const repositories = yield* makeRepositories;
265 const workspace = `${repositories.root}/single-branch`;
266 yield* git(repositories.root, [
267 "clone",
268 "--quiet",
269 "--no-local",
270 "--single-branch",
271 "--branch",
272 "main",
273 `${repositories.root}/remote`,
274 workspace,
275 ]);
276 yield* input.prepare(repositories, workspace);
277 const reportedHead = input.reportedHead(repositories);
278 const stored = yield* Deferred.make<void>();
279 const driver = yield* GitVcsDriver.GitVcsDriver;
280 const fetchedRefs: Array<string> = [];
281 const observed: Array<{ cwd: string; head: string; changed: string }> = [];
282 const onBranchFetch = input.onBranchFetch;
283 const observedDriver = {
284 ...driver,
285 execute: (call: GitVcsDriver.ExecuteGitInput) =>
286 Effect.gen(function* () {
287 const ref = call.args.at(-1);
288 if (call.args[0] !== "fetch" || ref === undefined) return yield* driver.execute(call);
289 fetchedRefs.push(ref);
290 if (input.refuseCommitFetch && ref === repositories.headSha) {
291 return yield* new GitCommandError({
292 operation: call.operation,
293 command: "git",
294 cwd: call.cwd,
295 detail: "The test remote refuses fetches by commit.",
296 exitCode: 128,
297 });
298 }
299 if (ref === "refs/heads/feature" && onBranchFetch !== undefined) {
300 yield* onBranchFetch(repositories).pipe(
301 Effect.provideService(GitVcsDriver.GitVcsDriver, driver),
302 );
303 }
304 return yield* driver.execute(call);
305 }),
306 };
307 const readCheckout = (cwd: string, args: ReadonlyArray<string>) =>
308 driver
309 .execute({ operation: "test.readCheckout", cwd, args })
310 .pipe(Effect.map((result) => result.stdout.trim()));
311 const context = yield* Layer.build(
312 makeLayer({
313 ...repositories,
314 headSha: reportedHead,
315 workspace,
316 stored,
317 identity: {
318 ...ACME_APP,
319 canonicalKey: `github.com/acme/${input.repositoryName}`,
320 displayName: `acme/${input.repositoryName}`,
321 name: input.repositoryName,
322 },
323 generate: (generation) =>
324 Effect.gen(function* () {
325 observed.push({
326 cwd: generation.cwd,
327 head: yield* readCheckout(generation.cwd, ["rev-parse", "HEAD"]),
328 changed: yield* readCheckout(generation.cwd, [
329 "diff",
330 "--name-only",
331 `${generation.baseCommit}...HEAD`,
332 ]),
333 });
334 return { stops: [] };
335 }).pipe(Effect.orDie),
336 }).pipe(Layer.provide(Layer.succeed(GitVcsDriver.GitVcsDriver, observedDriver))),
337 );
338 const service = Context.get(
339 context,
340 PullRequestReviewGuideService.PullRequestReviewGuideService,
341 );
342 const guideReference = { ...reference, repository: `acme/${input.repositoryName}` };
343 yield* service.generate(guideReference);
344 return {
345 repositories,
346 reportedHead,
347 service,
348 reference: guideReference,
349 stored,
350 fetchedRefs,
351 observed,
352 };
353 });
354
355// Runs drive real git, which the test clock does not advance, so waits use real time.
356it.layer(layerBase, { excludeTestServices: true })("PullRequestReviewGuideService", (it) => {
357 it.effect.each([
358 {
359 name: "a short head already fetched",
360 shortHash: true,
361 prepare: "fetch-feature",
362 pushDuringFetch: false,
363 refuseCommitFetch: true,
364 expectedFetches: [],
365 },
366 {
367 name: "a short head on its branch",
368 shortHash: true,
369 prepare: "none",
370 pushDuringFetch: false,
371 refuseCommitFetch: true,
372 expectedFetches: ["refs/heads/feature"],
373 },
374 {
375 name: "a short head whose branch moves during the fetch",
376 shortHash: true,
377 prepare: "none",
378 pushDuringFetch: true,
379 refuseCommitFetch: true,
380 expectedFetches: ["refs/heads/feature"],
381 },
382 {
383 name: "a full head served by commit",
384 shortHash: false,
385 prepare: "none",
386 pushDuringFetch: false,
387 refuseCommitFetch: false,
388 expectedFetches: ["<head>"],
389 },
390 {
391 name: "a full head on its branch",
392 shortHash: false,
393 prepare: "none",
394 pushDuringFetch: false,
395 refuseCommitFetch: true,
396 expectedFetches: ["<head>", "refs/heads/feature"],
397 },
398 {
399 name: "a full head only on its pull ref",
400 shortHash: false,
401 prepare: "refs/pull/7/head",
402 pushDuringFetch: false,
403 refuseCommitFetch: true,
404 expectedFetches: ["<head>", "refs/heads/feature", "refs/pull/7/head"],
405 },
406 {
407 name: "a full head only on its merge request ref",
408 shortHash: false,
409 prepare: "refs/merge-requests/7/head",
410 pushDuringFetch: false,
411 refuseCommitFetch: true,
412 expectedFetches: [
413 "<head>",
414 "refs/heads/feature",
415 "refs/pull/7/head",
416 "refs/merge-requests/7/head",
417 ],
418 },
419 ])(
420 "writes a guide for the reported commit: $name",
421 ({ shortHash, prepare, pushDuringFetch, refuseCommitFetch, expectedFetches }) =>
422 Effect.scoped(
423 Effect.gen(function* () {
424 const run = yield* startSingleBranchRun({
425 repositoryName: "fetched-head",
426 reportedHead: (repositories) =>
427 shortHash ? repositories.headSha.slice(0, 12) : repositories.headSha,
428 refuseCommitFetch,
429 prepare: (repositories, workspace) => {
430 const remote = `${repositories.root}/remote`;
431 if (prepare === "fetch-feature") {
432 return git(workspace, ["fetch", "--quiet", "origin", "feature"]).pipe(
433 Effect.asVoid,
434 );
435 }
436 if (prepare === "none") return Effect.void;
437 return git(remote, ["update-ref", prepare, repositories.headSha]).pipe(
438 Effect.andThen(
439 git(remote, ["update-ref", "refs/heads/feature", repositories.lowerSha]),
440 ),
441 Effect.asVoid,
442 );
443 },
444 onBranchFetch: pushDuringFetch
445 ? (repositories) => {
446 const remote = `${repositories.root}/remote`;
447 return git(remote, ["checkout", "--quiet", "feature"]).pipe(
448 Effect.andThen(
449 git(remote, [
450 "commit",
451 "--quiet",
452 "--allow-empty",
453 "-m",
454 "push during fetch",
455 ]),
456 ),
457 Effect.asVoid,
458 );
459 }
460 : undefined,
461 });
462 yield* Effect.raceFirst(
463 Deferred.await(run.stored),
464 awaitRunFailure(run.service, run.reference).pipe(
465 Effect.flatMap((detail) => Effect.die(`run failed: ${detail}`)),
466 ),
467 );
468 const { headSha } = run.repositories;
469 const result = yield* run.service.get(run.reference);
470 assert.strictEqual(result.guide?.headSha, run.reportedHead);
471 assert.deepStrictEqual(result.guide?.stops[0]?.files, [{ path: "feature.txt" }]);
472 assert.deepStrictEqual(run.observed, [
473 { cwd: run.observed[0]?.cwd, head: headSha, changed: "feature.txt" },
474 ]);
475 const headRefs = new Set([
476 headSha,
477 run.reportedHead,
478 "refs/heads/feature",
479 "refs/pull/7/head",
480 "refs/merge-requests/7/head",
481 ]);
482 assert.deepStrictEqual(
483 run.fetchedRefs.filter((ref) => headRefs.has(ref)),
484 expectedFetches.map((ref) => (ref === "<head>" ? headSha : ref)),
485 );
486 const fs = yield* FileSystem.FileSystem;
487 assert.isFalse(yield* fs.exists(run.observed[0]?.cwd ?? ""));
488 }),
489 ),
490 );
491
492 it.effect.each([
493 {
494 name: "a branch named like the short head",
495 reportedHead: (repositories: Repositories) => repositories.headSha.slice(0, 12),
496 shadow: "branch",
497 expected: ["a branch or tag rather than the commit"],
498 },
499 {
500 name: "a tag named like the short head",
501 reportedHead: (repositories: Repositories) => repositories.headSha.slice(0, 12),
502 shadow: "tag",
503 expected: ["a branch or tag rather than the commit"],
504 },
505 {
506 name: "a head that is not a commit hash",
507 reportedHead: (repositories: Repositories) =>
508 `--upload-pack=touch ${repositories.root}/injected`,
509 shadow: "none",
510 expected: ["not a commit hash"],
511 },
512 {
513 name: "a head no source has",
514 reportedHead: () => "e".repeat(40),
515 shadow: "none",
516 expected: [
517 "local objects:",
518 "the commit:",
519 "branch feature:",
520 "refs/pull/7/head:",
521 "refs/merge-requests/7/head:",
522 ],
523 },
524 ])("refuses to write a guide for $name", ({ reportedHead, shadow, expected }) =>
525 Effect.scoped(
526 Effect.gen(function* () {
527 const run = yield* startSingleBranchRun({
528 repositoryName: "refused-head",
529 reportedHead,
530 refuseCommitFetch: false,
531 prepare: (repositories, workspace) =>
532 git(workspace, ["fetch", "--quiet", "origin", "feature"]).pipe(
533 Effect.andThen(
534 shadow === "none"
535 ? Effect.void
536 : git(workspace, [shadow, repositories.headSha.slice(0, 12), "HEAD"]),
537 ),
538 Effect.asVoid,
539 ),
540 });
541 const detail = yield* Effect.raceFirst(
542 awaitRunFailure(run.service, run.reference),
543 Deferred.await(run.stored).pipe(Effect.andThen(Effect.die("a guide was written"))),
544 );
545 for (const part of expected) assert.include(detail, part);
546 assert.deepStrictEqual(run.observed, []);
547 assert.isFalse(run.fetchedRefs.some((ref) => ref.startsWith("-")));
548 const fs = yield* FileSystem.FileSystem;
549 assert.isFalse(yield* fs.exists(`${run.repositories.root}/injected`));
550 }),
551 ),
552 );
553
554 it.effect("briefs the change against its own base in a checkout it then removes", () =>
555 Effect.scoped(
556 Effect.gen(function* () {
557 const repositories = yield* makeRepositories;
558 const seen = yield* Deferred.make<{
559 readonly input: ReviewGuideGenerationInput;
560 readonly changed: string;
561 readonly head: string;
562 readonly upper: string;
563 }>();
564 const finish = yield* Deferred.make<void>();
565 // The fake model reads the checkout with the same driver, captured so it needs nothing.
566 const driver = yield* GitVcsDriver.GitVcsDriver;
567 const readCheckout = (cwd: string, args: ReadonlyArray<string>) =>
568 git(cwd, args).pipe(
569 Effect.provideService(GitVcsDriver.GitVcsDriver, driver),
570 Effect.orDie,
571 );
572 const stored = yield* Deferred.make<void>();
573
574 // Built in this test's scope: runs are forked into the service's scope and must outlive
575 // the call that obtains the service.
576 const context = yield* Layer.build(
577 makeLayer({
578 ...repositories,
579 stored,
580 generate: (input) =>
581 Effect.gen(function* () {
582 const changed = yield* readCheckout(input.cwd, [
583 "diff",
584 "--name-only",
585 `${input.baseCommit}...HEAD`,
586 ]);
587 const head = yield* readCheckout(input.cwd, ["rev-parse", "HEAD"]);
588 // Another layer's head is readable from the checkout, to check a seam.
589 const upper = yield* readCheckout(input.cwd, [
590 "show",
591 `${repositories.upperSha}:upper.txt`,
592 ]);
593 yield* Deferred.succeed(seen, { input, changed, head, upper });
594 yield* Deferred.await(finish);
595 // Written the way a model gets it wrong: an excerpt past the end of the file, a
596 // note outside what is left of it, and a changed file left off the tour.
597 return {
598 stops: [
599 {
600 title: "The feature",
601 summary: "The change.",
602 attention: null,
603 excerpts: [
604 { path: "feature.txt", side: "new", start: 4, end: 40, caption: "" },
605 ],
606 files: [],
607 notes: [
608 { path: "feature.txt", line: 5, title: "Kept", body: "Inside." },
609 { path: "feature.txt", line: 30, title: "Dropped", body: "Outside." },
610 ],
611 questions: [],
612 },
613 ],
614 };
615 }),
616 }),
617 );
618 const service = Context.get(
619 context,
620 PullRequestReviewGuideService.PullRequestReviewGuideService,
621 );
622
623 // Generate returns while the run is still reading the change.
624 const started = yield* service.generate(reference);
625 assert.strictEqual(started.run?._tag, "Running");
626 assert.strictEqual(started.guide, null);
627 assert.isTrue(started.started);
628
629 // A run that fails before reaching the model reports why rather than leaving this waiting.
630 const failedRun = service.get(reference).pipe(
631 Effect.flatMap((result) =>
632 result.run?._tag === "Failed"
633 ? Effect.die(`run failed: ${result.run.detail}`)
634 : Effect.fail("still running" as const),
635 ),
636 Effect.retry(Schedule.spaced("20 millis")),
637 );
638 const observed = yield* Effect.raceFirst(Deferred.await(seen), failedRun);
639 assert.strictEqual(observed.head, repositories.headSha);
640 assert.strictEqual(observed.changed, "feature.txt");
641 assert.strictEqual(observed.input.baseBranch, "stack-base");
642 // The stack comes along, bottom first; a layer whose head could not be fetched is named
643 // without one.
644 assert.deepStrictEqual(observed.input.stack, [
645 { number: 6, title: "Lower", state: "open", headSha: repositories.lowerSha },
646 { number: 7, title: "Add the feature", state: "open", headSha: repositories.headSha },
647 { number: 8, title: "Upper", state: "open", headSha: repositories.upperSha },
648 { number: 9, title: "Unpushed", state: "open", headSha: null },
649 ]);
650 assert.strictEqual(observed.upper, "upper layer");
651
652 // A second press while running joins the run instead of starting another.
653 const joined = yield* service.generate(reference);
654 assert.strictEqual(joined.run, started.run);
655 assert.isFalse(joined.started);
656
657 yield* Deferred.succeed(finish, undefined);
658 yield* Deferred.await(stored);
659 const finished = yield* service.get(reference);
660 assert.strictEqual(finished.guide?.headSha, repositories.headSha);
661 const guide = finished.guide;
662 assert.isNotNull(guide);
663 const [story] = guide.stops;
664 assert.deepStrictEqual(story?.excerpts, [
665 { path: "feature.txt", side: "new", start: 4, end: 10, caption: "" },
666 ]);
667 assert.deepStrictEqual(
668 story?.notes.map((note) => note.title),
669 ["Kept"],
670 );
671 // lower.txt is below the change's base, so it is neither in the change nor on the tour.
672 assert.strictEqual(guide.stops.length, 1);
673
674 const fs = yield* FileSystem.FileSystem;
675 assert.isFalse(yield* fs.exists(observed.input.cwd));
676 const worktrees = yield* git(repositories.workspace, ["worktree", "list", "--porcelain"]);
677 assert.notInclude(worktrees, observed.input.cwd);
678 }),
679 ),
680 );
681
682 it.effect("refuses to write a guide while the server has review guides off", () =>
683 Effect.scoped(
684 Effect.gen(function* () {
685 const repositories = yield* makeRepositories;
686 const stored = yield* Deferred.make<void>();
687 const context = yield* Layer.build(
688 makeLayer({
689 ...repositories,
690 stored,
691 enabled: false,
692 generate: () => Effect.die("a guide must not be written while the beta is off"),
693 }),
694 );
695 const service = Context.get(
696 context,
697 PullRequestReviewGuideService.PullRequestReviewGuideService,
698 );
699 const result = yield* Effect.flip(service.generate(reference));
700 assert.include(result.message, "Review guides are off");
701 }),
702 ),
703 );
704
705 it.effect(
706 "keeps a newer run's guide when an older run for the same pull request finishes late",
707 () =>
708 Effect.scoped(
709 Effect.gen(function* () {
710 const repositories = yield* makeRepositories;
711 const stored = yield* Deferred.make<void>();
712 const currentHead = yield* Ref.make(repositories.headSha);
713 // The old run waits on this; the new one answers at once.
714 const releaseOld = yield* Deferred.make<void>();
715 const oldStarted = yield* Deferred.make<void>();
716 const answer = (title: string) => ({
717 stops: [
718 {
719 title,
720 summary: "",
721 attention: null,
722 excerpts: [],
723 files: [],
724 notes: [],
725 questions: [],
726 },
727 ],
728 });
729 const context = yield* Layer.build(
730 makeLayer({
731 ...repositories,
732 stored,
733 currentHead,
734 generate: (input) =>
735 input.cwd.length > 0 && input.baseCommit.length > 0
736 ? Ref.get(currentHead).pipe(
737 Effect.flatMap((head) =>
738 head === repositories.headSha
739 ? Deferred.succeed(oldStarted, undefined).pipe(
740 Effect.andThen(Deferred.await(releaseOld)),
741 Effect.as(answer("old head")),
742 )
743 : Effect.succeed(answer("new head")),
744 ),
745 )
746 : Effect.die("unreachable"),
747 }),
748 );
749 const service = Context.get(
750 context,
751 PullRequestReviewGuideService.PullRequestReviewGuideService,
752 );
753
754 yield* service.generate(reference);
755 const firstFailed = service.get(reference).pipe(
756 Effect.flatMap((result) =>
757 result.run?._tag === "Failed"
758 ? Effect.die(`first run failed: ${result.run.detail}`)
759 : Effect.fail("still running" as const),
760 ),
761 Effect.retry(Schedule.spaced("20 millis")),
762 );
763 yield* Effect.raceFirst(Deferred.await(oldStarted), firstFailed);
764 // A push: the host reports the upper layer's commit as the head now, and a new run starts.
765 yield* Ref.set(currentHead, repositories.upperSha);
766 const second = yield* service.generate(reference);
767 assert.strictEqual(second.run?._tag, "Running");
768 assert.strictEqual(second.run?.headSha, repositories.upperSha);
769 // A run that fails reports why rather than leaving this waiting.
770 const failedRun = service.get(reference).pipe(
771 Effect.flatMap((result) =>
772 result.run?._tag === "Failed"
773 ? Effect.die(`run failed: ${result.run.detail}`)
774 : Effect.fail("still running" as const),
775 ),
776 Effect.retry(Schedule.spaced("20 millis")),
777 );
778 yield* Effect.raceFirst(Deferred.await(stored), failedRun);
779
780 // The old run finishes last. Its guide is for a head nobody is looking at any more.
781 yield* Deferred.succeed(releaseOld, undefined);
782 const settled = yield* service.get(reference).pipe(
783 Effect.filterOrFail(
784 (result) => result.run === null,
785 () => "still running" as const,
786 ),
787 Effect.retry({ schedule: Schedule.spaced("20 millis"), times: 250 }),
788 );
789 assert.strictEqual(settled.guide?.headSha, repositories.upperSha);
790 // Give the late run every chance to write over the newer guide; it must not.
791 yield* Effect.sleep("200 millis");
792 const after = yield* service.get(reference);
793 assert.isNull(after.run);
794 assert.strictEqual(after.guide?.headSha, repositories.upperSha);
795 assert.strictEqual(after.guide?.stops[0]?.title, "new head");
796 }),
797 ),
798 );
799
800 it.effect("refuses a pull request from a repository the project is not a checkout of", () =>
801 Effect.scoped(
802 Effect.gen(function* () {
803 const repositories = yield* makeRepositories;
804 const stored = yield* Deferred.make<void>();
805 const context = yield* Layer.build(
806 makeLayer({
807 ...repositories,
808 stored,
809 identity: {
810 ...ACME_APP,
811 canonicalKey: "github.com/acme/other",
812 displayName: "acme/other",
813 name: "other",
814 },
815 generate: () =>
816 Effect.die("a guide must not be written from another repository's checkout"),
817 }),
818 );
819 const service = Context.get(
820 context,
821 PullRequestReviewGuideService.PullRequestReviewGuideService,
822 );
823 const result = yield* Effect.flip(service.generate(reference));
824 assert.include(result.message, "in another repository");
825 }),
826 ),
827 );
828});