| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 2 | import { assert, it } from "@effect/vitest"; |
| 3 | import * as Context from "effect/Context"; |
| 4 | import * as Deferred from "effect/Deferred"; |
| 5 | import * as Effect from "effect/Effect"; |
| 6 | import * as FileSystem from "effect/FileSystem"; |
| 7 | import * as Layer from "effect/Layer"; |
| 8 | import * as Ref from "effect/Ref"; |
| 9 | import * as Schedule from "effect/Schedule"; |
| 10 | import { |
| 11 | ProjectId, |
| 12 | GitCommandError, |
| 13 | type PullRequestActivity, |
| 14 | type PullRequestDetail, |
| 15 | type PullRequestStack, |
| 16 | type PullRequestRef, |
| 17 | } from "@t3tools/contracts"; |
| 18 | import type { ReviewGuideGenerationInput } from "@t3tools/provider-core/server/textGeneration"; |
| 19 | |
| 20 | import * as ServerConfig from "../config.ts"; |
| 21 | import * as SqlitePersistence from "../persistence/Sqlite.ts"; |
| 22 | import * as PullRequestReviewGuides from "../persistence/PullRequestReviewGuides.ts"; |
| 23 | import * as ProjectService from "../project/ProjectService.ts"; |
| 24 | import * as ProviderRegistry from "../provider/ProviderRegistry.ts"; |
| 25 | import * as ServerSettings from "../serverSettings.ts"; |
| 26 | import * as TextGeneration from "../textGeneration/TextGeneration.ts"; |
| 27 | import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; |
| 28 | import * as VcsProcess from "../vcs/VcsProcess.ts"; |
| 29 | import * as PullRequestReviewGuideService from "./PullRequestReviewGuideService.ts"; |
| 30 | import * as PullRequestService from "./PullRequestService.ts"; |
| 31 | |
| 32 | const projectId = ProjectId.make("project-1"); |
| 33 | /** The test project is a checkout of the repository its pull requests come from. */ |
| 34 | const 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 | |
| 47 | const reference: PullRequestRef = { |
| 48 | projectId, |
| 49 | host: "github.com", |
| 50 | repository: "acme/app", |
| 51 | number: 7, |
| 52 | }; |
| 53 | |
| 54 | const 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 | */ |
| 65 | const 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 | |
| 101 | const 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 | |
| 112 | const 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. */ |
| 121 | const 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 | |
| 156 | const 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 | |
| 224 | const 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 | |
| 232 | type Repositories = Effect.Success<typeof makeRepositories>; |
| 233 | |
| 234 | const 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. */ |
| 248 | const 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. |
| 356 | it.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 | }); |