| ... | ... | @@ -9,6 +9,7 @@ import * as Ref from "effect/Ref"; |
| 9 | 9 | import * as Schedule from "effect/Schedule"; |
| 10 | 10 | import { |
| 11 | 11 | ProjectId, |
| 12 | GitCommandError, |
| 12 | 13 | type PullRequestActivity, |
| 13 | 14 | type PullRequestDetail, |
| 14 | 15 | type PullRequestStack, |
| ... | ... | @@ -144,7 +145,7 @@ const stack = (repositories: { |
| 144 | 145 | { |
| 145 | 146 | number: 8, |
| 146 | 147 | title: "Upper", |
| 147 | | headSha: repositories.upperSha, |
| 148 | headSha: repositories.upperSha.slice(0, 12), |
| 148 | 149 | headBranch: "upper", |
| 149 | 150 | state: "open", |
| 150 | 151 | }, |
| ... | ... | @@ -228,8 +229,328 @@ const layerBase = Layer.mergeAll( |
| 228 | 229 | SqlitePersistence.layerMemory, |
| 229 | 230 | ).pipe(Layer.provideMerge(NodeServices.layer)); |
| 230 | 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 | |
| 231 | 355 | // Runs drive real git, which the test clock does not advance, so waits use real time. |
| 232 | 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 | |
| 233 | 554 | it.effect("briefs the change against its own base in a checkout it then removes", () => |
| 234 | 555 | Effect.scoped( |
| 235 | 556 | Effect.gen(function* () { |