Skip to content
Source
Sign inSign up

PullRequestReviewGuideService.test.ts

587 lines21.5 KB

1import * as NodeCrypto from "@effect/platform-node/NodeCrypto";
2import * as NodeServices from "@effect/platform-node/NodeServices";
3import { assert, it } from "@effect/vitest";
4import * as Context from "effect/Context";
5import * as Deferred from "effect/Deferred";
6import * as Effect from "effect/Effect";
7import * as Fiber from "effect/Fiber";
8import * as FileSystem from "effect/FileSystem";
9import * as Layer from "effect/Layer";
10import * as Ref from "effect/Ref";
11import * as Queue from "effect/Queue";
12import * as Tracer from "effect/Tracer";
13import {
14 ProjectId,
15 GitCommandError,
16 type PullRequestActivity,
17 type PullRequestDetail,
18 type PullRequestStack,
19 type PullRequestRef,
20} from "@t3tools/contracts";
21​
22import * as ServerConfig from "../config.ts";
23import * as SqlitePersistence from "../persistence/Sqlite.ts";
24import * as PullRequestReviewGuides from "../persistence/PullRequestReviewGuides.ts";
25import * as ProjectService from "../project/ProjectService.ts";
26import * as ProviderRegistry from "../provider/ProviderRegistry.ts";
27import * as ServerSettings from "../serverSettings.ts";
28import * as TextGeneration from "../textGeneration/TextGeneration.ts";
29import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
30import * as VcsProcess from "../vcs/VcsProcess.ts";
31import * as PullRequestReviewGuideService from "./PullRequestReviewGuideService.ts";
32import * as PullRequestService from "./PullRequestService.ts";
33​
34const projectId = ProjectId.make("project-1");
35const ACME_APP = {
36 canonicalKey: "github.com/acme/app",
37 locator: {
38 source: "git-remote" as const,
39 remoteName: "origin",
40 remoteUrl: "https://github.com/acme/app.git",
41 },
42 provider: "github",
43 displayName: "acme/app",
44 owner: "acme",
45 name: "app",
46};
47​
48const reference: PullRequestRef = {
49 projectId,
50 host: "github.com",
51 repository: "acme/app",
52 number: 7,
53};
54​
55const git = (cwd: string, args: ReadonlyArray<string>) =>
56 Effect.gen(function* () {
57 const driver = yield* GitVcsDriver.GitVcsDriver;
58 const result = yield* driver.execute({ operation: "test.git", cwd, args, timeoutMs: 10_000 });
59 return result.stdout.trim();
60 });
61​
62const makeRepositories = Effect.gen(function* () {
63 const fs = yield* FileSystem.FileSystem;
64 const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-review-guide-" });
65 const remote = `${root}/remote`;
66 const workspace = `${root}/workspace`;
67 yield* fs.makeDirectory(remote);
68 yield* git(remote, ["init", "--quiet", "--initial-branch=main"]);
69 yield* git(remote, ["config", "user.email", "test@example.com"]);
70 yield* git(remote, ["config", "user.name", "Test"]);
71 const commit = (path: string, message: string, contents = message) =>
72 fs
73 .writeFileString(`${remote}/${path}`, contents)
74 .pipe(
75 Effect.andThen(git(remote, ["add", path])),
76 Effect.andThen(git(remote, ["commit", "--quiet", "-m", message])),
77 Effect.andThen(git(remote, ["rev-parse", "HEAD"])),
78 );
79 yield* commit("base.txt", "base");
80 yield* git(remote, ["checkout", "--quiet", "-b", "stack-base"]);
81 const lowerSha = yield* commit("lower.txt", "lower layer");
82 yield* git(remote, ["checkout", "--quiet", "-b", "feature"]);
83 const headSha = yield* commit(
84 "feature.txt",
85 "the change",
86 Array.from({ length: 10 }, (_, index) => `line ${index + 1}`).join("\n") + "\n",
87 );
88 yield* git(remote, ["checkout", "--quiet", "-b", "upper"]);
89 const upperSha = yield* commit("upper.txt", "upper layer");
90 yield* git(remote, ["checkout", "--quiet", "main"]);
91 yield* commit("unrelated.txt", "main moved on");
92 yield* git(root, ["clone", "--quiet", remote, workspace]);
93 return { root, remote, workspace, headSha, lowerSha, upperSha };
94});
95​
96const detail = (workspaceRoot: string, headSha: string): PullRequestDetail =>
97 ({
98 provider: "github",
99 number: 7,
100 title: "Add the feature",
101 body: "Adds the feature.",
102 baseBranch: "stack-base",
103 headBranch: "feature",
104 headSha,
105 workspaceRoot,
106 }) as PullRequestDetail;
107​
108const activity: PullRequestActivity = {
109 comments: [],
110 commentCount: 0,
111 commentsTruncated: false,
112 reviewThreads: [],
113 commits: [],
114};
115​
116const stack = (repositories: {
117 readonly headSha: string;
118 readonly lowerSha: string;
119 readonly upperSha: string;
120}): PullRequestStack => ({
121 id: "stack",
122 number: 1,
123 url: "https://example.com/stack/1",
124 base: "main",
125 layers: [
126 {
127 number: 6,
128 title: "Lower",
129 headSha: repositories.lowerSha,
130 headBranch: "stack-base",
131 state: "open",
132 },
133 {
134 number: 7,
135 title: "Add the feature",
136 headSha: repositories.headSha,
137 headBranch: "feature",
138 state: "open",
139 },
140 {
141 number: 8,
142 title: "Upper",
143 headSha: repositories.upperSha.slice(0, 12),
144 headBranch: "upper",
145 state: "open",
146 },
147 { number: 9, title: "Unpushed", headSha: "f".repeat(40), headBranch: "gone", state: "open" },
148 ],
149});
150​
151const makeLayer = (input: {
152 readonly root: string;
153 readonly remote: string;
154 readonly workspace: string;
155 readonly headSha: string;
156 readonly lowerSha: string;
157 readonly upperSha: string;
158 readonly generate: TextGeneration.TextGeneration["Service"]["generateReviewGuide"];
159 readonly stored: Deferred.Deferred<void>;
160 readonly enabled?: boolean;
161 readonly currentHead?: Ref.Ref<string>;
162 readonly identity?: typeof ACME_APP;
163}) =>
164 PullRequestReviewGuideService.layer.pipe(
165 Layer.provide(
166 Layer.effect(
167 PullRequestReviewGuides.PullRequestReviewGuideRepository,
168 Effect.gen(function* () {
169 const repository = yield* PullRequestReviewGuides.PullRequestReviewGuideRepository;
170 return {
171 get: repository.get,
172 put: (key, guide) =>
173 repository
174 .put(key, guide)
175 .pipe(Effect.tap(() => Deferred.succeed(input.stored, undefined))),
176 } satisfies PullRequestReviewGuides.PullRequestReviewGuideRepository["Service"];
177 }),
178 ).pipe(Layer.provide(PullRequestReviewGuides.layer)),
179 ),
180 Layer.provide(
181 Layer.succeed(PullRequestService.PullRequestService, {
182 detail: () =>
183 (input.currentHead === undefined
184 ? Effect.succeed(input.headSha)
185 : Ref.get(input.currentHead)
186 ).pipe(Effect.map((head) => detail(input.workspace, head))),
187 activity: () => Effect.succeed(activity),
188 invalidate: () => Effect.void,
189 stack: () => Effect.succeed(stack(input)),
190 } as unknown as PullRequestService.PullRequestService["Service"]),
191 ),
192 Layer.provide(
193 Layer.succeed(ProjectService.ProjectService, {
194 getShell: () =>
195 Effect.succeedSome({
196 id: projectId,
197 workspaceRoot: input.workspace,
198 repositoryIdentity: {
199 ...(input.identity ?? ACME_APP),
200 locator: { ...ACME_APP.locator, remoteUrl: input.remote },
201 },
202 }),
203 } as unknown as ProjectService.ProjectService["Service"]),
204 ),
205 Layer.provide(
206 Layer.succeed(ProviderRegistry.ProviderRegistry, {
207 getProviders: Effect.succeed([]),
208 } as unknown as ProviderRegistry.ProviderRegistry["Service"]),
209 ),
210 Layer.provide(
211 Layer.succeed(TextGeneration.TextGeneration, {
212 generateReviewGuide: input.generate,
213 } as unknown as TextGeneration.TextGeneration["Service"]),
214 ),
215 Layer.provide(ServerSettings.layerTest({ reviewGuidesEnabled: input.enabled ?? true })),
216 Layer.provide(ServerConfig.layerTest(input.root, { prefix: "t3-review-guide-config-" })),
217 );
218​
219const layerBase = Layer.mergeAll(
220 GitVcsDriver.layer.pipe(
221 Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-review-guide-git-" })),
222 Layer.provideMerge(VcsProcess.layer),
223 ),
224 SqlitePersistence.layerMemory,
225).pipe(Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)));
226​
227type Repositories = Effect.Success<typeof makeRepositories>;
228​
229// A run forks into the service's scope, so a test learns it settled when the run's `write` span
230// ends; the run records its outcome synchronously right after.
231const watchRuns = Effect.gen(function* () {
232 const ended = yield* Queue.unbounded<void>();
233 const tracer = Tracer.make({
234 span: (options) => {
235 const span = new Tracer.NativeSpan(options);
236 if (options.name !== "PullRequestReviewGuideService.write") return span;
237 const end = span.end.bind(span);
238 span.end = (endTime, exit) => {
239 end(endTime, exit);
240 Queue.offerUnsafe(ended, undefined);
241 };
242 return span;
243 },
244 });
245 return {
246 generate: (
247 service: PullRequestReviewGuideService.PullRequestReviewGuideService["Service"],
248 input: PullRequestRef,
249 ) => service.generate(input).pipe(Effect.withTracer(tracer)),
250 settled: (
251 service: PullRequestReviewGuideService.PullRequestReviewGuideService["Service"],
252 input: PullRequestRef,
253 ) => Queue.take(ended).pipe(Effect.andThen(service.get(input))),
254 };
255});
256​
257const startSingleBranchRun = (input: {
258 readonly repositoryName: string;
259 readonly reportedHead: (repositories: Repositories) => string;
260 readonly refuseCommitFetch: boolean;
261 readonly prepare: (
262 repositories: Repositories,
263 workspace: string,
264 ) => Effect.Effect<void, GitCommandError, GitVcsDriver.GitVcsDriver>;
265 readonly onBranchFetch?:
266 | ((
267 repositories: Repositories,
268 ) => Effect.Effect<void, GitCommandError, GitVcsDriver.GitVcsDriver>)
269 | undefined;
270}) =>
271 Effect.gen(function* () {
272 const repositories = yield* makeRepositories;
273 const workspace = `${repositories.root}/single-branch`;
274 yield* git(repositories.root, [
275 "clone",
276 "--quiet",
277 "--no-local",
278 "--single-branch",
279 "--branch",
280 "main",
281 `${repositories.root}/remote`,
282 workspace,
283 ]);
284 yield* input.prepare(repositories, workspace);
285 const reportedHead = input.reportedHead(repositories);
286 const stored = yield* Deferred.make<void>();
287 const driver = yield* GitVcsDriver.GitVcsDriver;
288 const fetchedRefs: Array<string> = [];
289 const observed: Array<{ cwd: string; head: string; changed: string }> = [];
290 const onBranchFetch = input.onBranchFetch;
291 const observedDriver = {
292 ...driver,
293 execute: (call: GitVcsDriver.ExecuteGitInput) =>
294 Effect.gen(function* () {
295 const ref = call.args.at(-1);
296 if (call.args[0] !== "fetch" || ref === undefined) return yield* driver.execute(call);
297 fetchedRefs.push(ref);
298 if (input.refuseCommitFetch && ref === repositories.headSha) {
299 return yield* new GitCommandError({
300 operation: call.operation,
301 command: "git",
302 cwd: call.cwd,
303 detail: "The test remote refuses fetches by commit.",
304 exitCode: 128,
305 });
306 }
307 if (ref === "refs/heads/feature" && onBranchFetch !== undefined) {
308 yield* onBranchFetch(repositories).pipe(
309 Effect.provideService(GitVcsDriver.GitVcsDriver, driver),
310 );
311 }
312 return yield* driver.execute(call);
313 }),
314 };
315 const readCheckout = (cwd: string, args: ReadonlyArray<string>) =>
316 driver
317 .execute({ operation: "test.readCheckout", cwd, args })
318 .pipe(Effect.map((result) => result.stdout.trim()));
319 const context = yield* Layer.build(
320 makeLayer({
321 ...repositories,
322 headSha: reportedHead,
323 workspace,
324 stored,
325 identity: {
326 ...ACME_APP,
327 canonicalKey: `github.com/acme/${input.repositoryName}`,
328 displayName: `acme/${input.repositoryName}`,
329 name: input.repositoryName,
330 },
331 generate: (generation) =>
332 Effect.gen(function* () {
333 observed.push({
334 cwd: generation.cwd,
335 head: yield* readCheckout(generation.cwd, ["rev-parse", "HEAD"]),
336 changed: yield* readCheckout(generation.cwd, [
337 "diff",
338 "--name-only",
339 `${generation.baseCommit}...HEAD`,
340 ]),
341 });
342 return { stops: [] };
343 }).pipe(Effect.orDie),
344 }).pipe(Layer.provide(Layer.succeed(GitVcsDriver.GitVcsDriver, observedDriver))),
345 );
346 const service = Context.get(
347 context,
348 PullRequestReviewGuideService.PullRequestReviewGuideService,
349 );
350 const guideReference = { ...reference, repository: `acme/${input.repositoryName}` };
351 const runs = yield* watchRuns;
352 yield* runs.generate(service, guideReference);
353 return {
354 repositories,
355 reportedHead,
356 service,
357 reference: guideReference,
358 stored,
359 fetchedRefs,
360 observed,
361 settled: runs.settled(service, guideReference),
362 };
363 });
364​
365it.layer(layerBase, { excludeTestServices: true })("PullRequestReviewGuideService", (it) => {
366 it.effect.each([
367 {
368 name: "a branch named like the short head",
369 reportedHead: (repositories: Repositories) => repositories.headSha.slice(0, 12),
370 shadow: "branch",
371 expected: ["a branch or tag rather than the commit"],
372 },
373 {
374 name: "a tag named like the short head",
375 reportedHead: (repositories: Repositories) => repositories.headSha.slice(0, 12),
376 shadow: "tag",
377 expected: ["a branch or tag rather than the commit"],
378 },
379 {
380 name: "a head that is not a commit hash",
381 reportedHead: (repositories: Repositories) =>
382 `--upload-pack=touch ${repositories.root}/injected`,
383 shadow: "none",
384 expected: ["not a commit hash"],
385 },
386 {
387 name: "a head no source has",
388 reportedHead: () => "e".repeat(40),
389 shadow: "none",
390 expected: ["local objects:", "the commit:", "branch feature:", "refs/pull/7/head:"],
391 },
392 ])("refuses to write a guide for $name", ({ reportedHead, shadow, expected }) =>
393 Effect.gen(function* () {
394 const run = yield* startSingleBranchRun({
395 repositoryName: "refused-head",
396 reportedHead,
397 refuseCommitFetch: false,
398 prepare: (repositories, workspace) =>
399 git(workspace, ["fetch", "--quiet", "origin", "feature"]).pipe(
400 Effect.andThen(
401 shadow === "none"
402 ? Effect.void
403 : git(workspace, [shadow, repositories.headSha.slice(0, 12), "HEAD"]),
404 ),
405 Effect.asVoid,
406 ),
407 });
408 const result = yield* run.settled;
409 assert.isFalse(yield* Deferred.isDone(run.stored), "a guide was written");
410 assert.strictEqual(result.run?._tag, "Failed");
411 const detail = result.run?._tag === "Failed" ? result.run.detail : "";
412 for (const part of expected) assert.include(detail, part);
413 // Only the host's own head ref is tried.
414 assert.notInclude(detail, "refs/merge-requests/");
415 assert.deepStrictEqual(run.observed, []);
416 assert.isFalse(run.fetchedRefs.some((ref) => ref.startsWith("-")));
417 const fs = yield* FileSystem.FileSystem;
418 assert.isFalse(yield* fs.exists(`${run.repositories.root}/injected`));
419 }),
420 );
421​
422 it.effect("measures only the PR change using the renamed verified remote's fetch URL", () =>
423 Effect.gen(function* () {
424 const repositories = yield* makeRepositories;
425 const fork = `${repositories.root}/fork`;
426 yield* git(repositories.root, ["clone", "--quiet", repositories.remote, fork]);
427 yield* git(fork, ["branch", "stack-base", "main~1"]);
428 // The cached identity still names origin, which now belongs to the stale fork.
429 yield* git(repositories.workspace, ["remote", "rename", "origin", "upstream"]);
430 yield* git(repositories.workspace, ["remote", "add", "origin", fork]);
431 yield* git(repositories.workspace, [
432 "config",
433 `url.${repositories.remote}.insteadOf`,
434 "review-upstream:",
435 ]);
436 yield* git(repositories.workspace, ["remote", "set-url", "upstream", "review-upstream:"]);
437 const changed = yield* Deferred.make<string>();
438 const driver = yield* GitVcsDriver.GitVcsDriver;
439 const stored = yield* Deferred.make<void>();
440 const context = yield* Layer.build(
441 makeLayer({
442 ...repositories,
443 stored,
444 generate: (input) =>
445 git(input.cwd, ["diff", "--name-only", `${input.baseCommit}...HEAD`]).pipe(
446 Effect.provideService(GitVcsDriver.GitVcsDriver, driver),
447 Effect.orDie,
448 Effect.flatMap((names) => Deferred.succeed(changed, names)),
449 Effect.as({ stops: [] }),
450 ),
451 }),
452 );
453 const service = Context.get(
454 context,
455 PullRequestReviewGuideService.PullRequestReviewGuideService,
456 );
457 yield* service.generate(reference);
458 assert.strictEqual(yield* Deferred.await(changed), "feature.txt");
459 yield* Deferred.await(stored);
460 const result = yield* service.get(reference);
461 assert.strictEqual(result.guide?.headSha, repositories.headSha);
462 assert.deepStrictEqual(result.guide?.stops[0]?.files, [{ path: "feature.txt" }]);
463 }),
464 );
465​
466 it.effect("keeps excerpts despite the checkout's diff driver and a failing hook", () =>
467 Effect.gen(function* () {
468 const repositories = yield* makeRepositories;
469 const fs = yield* FileSystem.FileSystem;
470 yield* fs.writeFileString(
471 `${repositories.workspace}/.git/info/attributes`,
472 "feature.txt diff=blank\n",
473 );
474 yield* git(repositories.workspace, ["config", "diff.blank.textconv", "true"]);
475 const hook = `${repositories.workspace}/.git/hooks/post-checkout`;
476 yield* fs.writeFileString(hook, "#!/bin/sh\nexit 1\n");
477 yield* fs.chmod(hook, 0o755);
478 const stored = yield* Deferred.make<void>();
479 const context = yield* Layer.build(
480 makeLayer({
481 ...repositories,
482 stored,
483 generate: () =>
484 Effect.succeed({
485 stops: [
486 {
487 title: "The feature",
488 summary: "The change.",
489 attention: null,
490 excerpts: [{ path: "feature.txt", side: "new", start: 1, end: 3, caption: "" }],
491 files: [],
492 notes: [],
493 questions: [],
494 },
495 ],
496 }),
497 }),
498 );
499 const service = Context.get(
500 context,
501 PullRequestReviewGuideService.PullRequestReviewGuideService,
502 );
503 const runs = yield* watchRuns;
504 yield* runs.generate(service, reference);
505 const result = yield* runs.settled(service, reference);
506 assert.notStrictEqual(result.run?._tag, "Failed", JSON.stringify(result.run));
507 assert.deepStrictEqual(result.guide?.stops[0]?.excerpts, [
508 { path: "feature.txt", side: "new", start: 1, end: 3, caption: "" },
509 ]);
510 }),
511 );
512​
513 it.effect("refuses generation when the verified remote disappeared despite a usable origin", () =>
514 Effect.gen(function* () {
515 const repositories = yield* makeRepositories;
516 const fork = `${repositories.root}/fork`;
517 yield* git(repositories.root, ["clone", "--quiet", repositories.remote, fork]);
518 yield* git(fork, ["branch", "stack-base", repositories.lowerSha]);
519 yield* git(repositories.workspace, ["remote", "set-url", "origin", fork]);
520 const started = yield* Deferred.make<Fiber.Fiber<unknown, unknown>>();
521 const stored = yield* Deferred.make<void>();
522 const generated = yield* Ref.make(false);
523 const driver = yield* GitVcsDriver.GitVcsDriver;
524 const context = yield* Layer.build(
525 makeLayer({
526 ...repositories,
527 stored,
528 generate: () => Ref.set(generated, true).pipe(Effect.as({ stops: [] })),
529 }).pipe(
530 Layer.provide(
531 Layer.succeed(GitVcsDriver.GitVcsDriver, {
532 ...driver,
533 execute: (call) =>
534 Effect.withFiber((fiber) =>
535 Deferred.succeed(started, fiber).pipe(Effect.andThen(driver.execute(call))),
536 ),
537 }),
538 ),
539 ),
540 );
541 const service = Context.get(
542 context,
543 PullRequestReviewGuideService.PullRequestReviewGuideService,
544 );
545 const before = yield* service.get(reference);
546 yield* service.generate(reference);
547 // Join the background run so its failure is visible without polling service state.
548 yield* Fiber.await(yield* Deferred.await(started));
549 const result = yield* service.get(reference);
550 assert.strictEqual(result.run?._tag, "Failed");
551 assert.include(
552 result.run?._tag === "Failed" ? result.run.detail : "",
553 "no longer has a remote for the pull request's repository",
554 );
555 assert.deepStrictEqual(result.guide, before.guide);
556 assert.isFalse(yield* Deferred.isDone(stored));
557 assert.isFalse(yield* Ref.get(generated));
558 }),
559 );
560​
561 it.effect("refuses a pull request from a repository the project is not a checkout of", () =>
562 Effect.gen(function* () {
563 const repositories = yield* makeRepositories;
564 const stored = yield* Deferred.make<void>();
565 const context = yield* Layer.build(
566 makeLayer({
567 ...repositories,
568 stored,
569 identity: {
570 ...ACME_APP,
571 canonicalKey: "github.com/acme/other",
572 displayName: "acme/other",
573 name: "other",
574 },
575 generate: () =>
576 Effect.die("a guide must not be written from another repository's checkout"),
577 }),
578 );
579 const service = Context.get(
580 context,
581 PullRequestReviewGuideService.PullRequestReviewGuideService,
582 );
583 const result = yield* Effect.flip(service.generate(reference));
584 assert.include(result.message, "in another repository");
585 }),
586 );
587});