| 1 | /** |
| 2 | * Guided reviews: a maintainer-facing brief on a pull request, written by the environment's |
| 3 | * source-control writer model. See `PullRequestReviewGuide` in contracts for what a guide is for. |
| 4 | * |
| 5 | * The model reads the change itself, read-only, from a disposable checkout of the head, so a |
| 6 | * guide has no size limit. That takes minutes on a large change, so a run belongs to the server |
| 7 | * rather than to the request that started it: Generate returns as soon as the run is under way |
| 8 | * and readers watch its state through `get`. |
| 9 | * |
| 10 | * Generation is explicit and the result is stored per pull request, so opening a guide never |
| 11 | * spends a model run. A push leaves the stored guide in place and the reader sees it as stale. |
| 12 | * |
| 13 | * @module pullRequest/PullRequestReviewGuideService |
| 14 | */ |
| 15 | import * as Cause from "effect/Cause"; |
| 16 | import * as Clock from "effect/Clock"; |
| 17 | import * as Context from "effect/Context"; |
| 18 | import * as DateTime from "effect/DateTime"; |
| 19 | import * as Effect from "effect/Effect"; |
| 20 | import * as FileSystem from "effect/FileSystem"; |
| 21 | import * as Layer from "effect/Layer"; |
| 22 | import * as Option from "effect/Option"; |
| 23 | import * as Ref from "effect/Ref"; |
| 24 | import * as Result from "effect/Result"; |
| 25 | import * as Semaphore from "effect/Semaphore"; |
| 26 | |
| 27 | import { |
| 28 | PullRequestOperationError, |
| 29 | type PullRequestDetail, |
| 30 | type PullRequestRef, |
| 31 | type PullRequestReviewGuide, |
| 32 | type PullRequestReviewGuideResult, |
| 33 | type PullRequestReviewGuideRun, |
| 34 | type PullRequestStack, |
| 35 | ProjectId, |
| 36 | type RepositoryIdentity, |
| 37 | type ThreadPullRequestKey, |
| 38 | } from "@t3tools/contracts"; |
| 39 | import type { ReviewGuideStackLayer } from "@t3tools/provider-core/server/textGeneration"; |
| 40 | import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; |
| 41 | import { sourceControlRepositorySelector } from "@t3tools/shared/sourceControl"; |
| 42 | |
| 43 | import * as ServerConfig from "../config.ts"; |
| 44 | import * as PullRequestReviewGuides from "../persistence/PullRequestReviewGuides.ts"; |
| 45 | import * as ProjectService from "../project/ProjectService.ts"; |
| 46 | import * as ProviderRegistry from "../provider/ProviderRegistry.ts"; |
| 47 | import * as ServerSettings from "../serverSettings.ts"; |
| 48 | import * as TextGeneration from "../textGeneration/TextGeneration.ts"; |
| 49 | import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; |
| 50 | import { PATCH_RENDER_PREFIX_ARGS } from "../vcs/GitVcsDriverCore.ts"; |
| 51 | import * as PullRequestService from "./PullRequestService.ts"; |
| 52 | import { pullRequestSyncKey } from "./pullRequestSyncKey.ts"; |
| 53 | import { reviewGuideInputFromPullRequest } from "./pullRequestReviewGuideInput.ts"; |
| 54 | import { normalizeReviewGuide, parseReviewGuideChange } from "./pullRequestReviewGuideNormalize.ts"; |
| 55 | |
| 56 | const COMMIT_HASH_PATTERN = /^[0-9a-f]{7,64}$/; |
| 57 | |
| 58 | export class PullRequestReviewGuideService extends Context.Service< |
| 59 | PullRequestReviewGuideService, |
| 60 | { |
| 61 | /** The stored guide, any run in progress, and the head now. Never runs a model. */ |
| 62 | readonly get: ( |
| 63 | input: PullRequestRef, |
| 64 | ) => Effect.Effect<PullRequestReviewGuideResult, PullRequestService.PullRequestError>; |
| 65 | /** |
| 66 | * Start writing a guide for the current head and return once it is under way. Joins a run |
| 67 | * already going for that head. A stored guide stays readable until the new one replaces it, |
| 68 | * so a reader can rewrite one after changing the guide model or instructions. `started` says |
| 69 | * whether this call began the run or joined one already going. |
| 70 | */ |
| 71 | readonly generate: ( |
| 72 | input: PullRequestRef, |
| 73 | ) => Effect.Effect< |
| 74 | PullRequestReviewGuideResult & { readonly started: boolean }, |
| 75 | PullRequestService.PullRequestError |
| 76 | >; |
| 77 | } |
| 78 | >()("t3/pullRequest/PullRequestReviewGuideService") {} |
| 79 | |
| 80 | const keyString = (key: ThreadPullRequestKey) => `${key.host}/${key.repository}#${key.number}`; |
| 81 | |
| 82 | const nowIso = Clock.currentTimeMillis.pipe( |
| 83 | Effect.map((millis) => DateTime.formatIso(DateTime.makeUnsafe(millis))), |
| 84 | ); |
| 85 | |
| 86 | const make = Effect.gen(function* () { |
| 87 | const pullRequests = yield* PullRequestService.PullRequestService; |
| 88 | const guides = yield* PullRequestReviewGuides.PullRequestReviewGuideRepository; |
| 89 | const projects = yield* ProjectService.ProjectService; |
| 90 | const serverSettings = yield* ServerSettings.ServerSettingsService; |
| 91 | const providerRegistry = yield* ProviderRegistry.ProviderRegistry; |
| 92 | const textGeneration = yield* TextGeneration.TextGeneration; |
| 93 | const git = yield* GitVcsDriver.GitVcsDriver; |
| 94 | const fileSystem = yield* FileSystem.FileSystem; |
| 95 | const config = yield* ServerConfig.ServerConfig; |
| 96 | // Runs outlive the request that started them and end with the server. |
| 97 | const serviceScope = yield* Effect.scope; |
| 98 | // In memory on purpose: a restart drops a run in progress, and a reader presses Generate again. |
| 99 | const runs = yield* Ref.make<ReadonlyMap<string, PullRequestReviewGuideRun>>(new Map()); |
| 100 | // Names each run's private git refs; unique for the server's lifetime, like the runs map. |
| 101 | let runCounter = 0; |
| 102 | // Claiming a run and storing a finished guide take turns, so a run that still owns its pull |
| 103 | // request when it checks is the one whose guide is stored: a newer claim cannot land between. |
| 104 | const ownership = yield* Semaphore.make(1); |
| 105 | |
| 106 | const keyFor = Effect.fn("PullRequestReviewGuideService.keyFor")(function* ( |
| 107 | input: PullRequestRef, |
| 108 | ) { |
| 109 | const project = Option.getOrUndefined( |
| 110 | yield* projects.getShell(input.projectId).pipe(Effect.orElseSucceed(() => Option.none())), |
| 111 | ); |
| 112 | const key = pullRequestSyncKey(input, project?.repositoryIdentity ?? undefined); |
| 113 | if (key === null || project === undefined) { |
| 114 | return yield* new PullRequestOperationError({ |
| 115 | operation: "reviewGuide", |
| 116 | detail: "Could not identify this pull request's host.", |
| 117 | }); |
| 118 | } |
| 119 | return { key, workspaceRoot: project.workspaceRoot, identity: project.repositoryIdentity }; |
| 120 | }); |
| 121 | |
| 122 | /** |
| 123 | * A guide reads the change in a checkout fetched from the project's own remote, so the pull |
| 124 | * request has to be in that repository. One from another repository is refused rather than |
| 125 | * measured against commits that are not its own. |
| 126 | */ |
| 127 | const requireOwnRepository = Effect.fn("PullRequestReviewGuideService.requireOwnRepository")( |
| 128 | function* (key: ThreadPullRequestKey, identity: RepositoryIdentity | null | undefined) { |
| 129 | const own = |
| 130 | identity == null |
| 131 | ? null |
| 132 | : pullRequestSyncKey( |
| 133 | { |
| 134 | projectId: ProjectId.make("own"), |
| 135 | number: key.number, |
| 136 | repository: sourceControlRepositorySelector(identity) ?? "", |
| 137 | }, |
| 138 | identity, |
| 139 | ); |
| 140 | if (own === null || own.host !== key.host || own.repository !== key.repository) { |
| 141 | return yield* new PullRequestOperationError({ |
| 142 | operation: "generateReviewGuide", |
| 143 | detail: |
| 144 | "Guides are written from this project's checkout, and this pull request is in another repository. Write it from a project that is a checkout of that repository.", |
| 145 | }); |
| 146 | } |
| 147 | }, |
| 148 | ); |
| 149 | |
| 150 | const readStored = (key: ThreadPullRequestKey) => |
| 151 | guides.get(key).pipe( |
| 152 | Effect.mapError( |
| 153 | (cause) => |
| 154 | new PullRequestOperationError({ |
| 155 | operation: "reviewGuide", |
| 156 | detail: "Could not read the stored review guide.", |
| 157 | cause, |
| 158 | }), |
| 159 | ), |
| 160 | Effect.map(Option.getOrNull), |
| 161 | ); |
| 162 | |
| 163 | const get: PullRequestReviewGuideService["Service"]["get"] = Effect.fn( |
| 164 | "PullRequestReviewGuideService.get", |
| 165 | )(function* (input) { |
| 166 | const { key } = yield* keyFor(input); |
| 167 | const [guide, detail, current] = yield* Effect.all( |
| 168 | [readStored(key), pullRequests.detail(input), Ref.get(runs)], |
| 169 | { concurrency: "unbounded" }, |
| 170 | ); |
| 171 | return { |
| 172 | guide, |
| 173 | run: current.get(keyString(key)) ?? null, |
| 174 | currentHeadSha: detail.headSha ?? null, |
| 175 | }; |
| 176 | }); |
| 177 | |
| 178 | /** |
| 179 | * A detached checkout of the head beside the project, removed when the run ends. Pinned to |
| 180 | * the reported commit, so a branch checked out elsewhere works and the checkout is exactly |
| 181 | * what the host reported. |
| 182 | */ |
| 183 | const checkoutHead = Effect.fn("PullRequestReviewGuideService.checkoutHead")(function* ( |
| 184 | workspaceRoot: string, |
| 185 | detail: PullRequestDetail, |
| 186 | headSha: string, |
| 187 | stack: PullRequestStack | null, |
| 188 | runId: string, |
| 189 | ) { |
| 190 | const remote = yield* git.resolvePrimaryRemoteName(workspaceRoot); |
| 191 | const fetch = (args: ReadonlyArray<string>) => |
| 192 | git.execute({ |
| 193 | operation: "PullRequestReviewGuideService.fetch", |
| 194 | cwd: workspaceRoot, |
| 195 | args: ["fetch", "--quiet", "--no-tags", remote, ...args], |
| 196 | timeoutMs: 5 * 60_000, |
| 197 | }); |
| 198 | // The base branch is what the change is measured from; the head commit is the change. |
| 199 | // The host names the base branch. A name git rejects, or one that would change the refspec |
| 200 | // (a leading `+`, a `:`), is refused before it reaches `git fetch`. |
| 201 | yield* git |
| 202 | .execute({ |
| 203 | operation: "PullRequestReviewGuideService.checkBaseBranch", |
| 204 | cwd: workspaceRoot, |
| 205 | args: ["check-ref-format", "--branch", detail.baseBranch], |
| 206 | }) |
| 207 | .pipe( |
| 208 | Effect.filterOrFail( |
| 209 | () => !detail.baseBranch.startsWith("+") && !detail.baseBranch.includes(":"), |
| 210 | () => |
| 211 | new PullRequestOperationError({ |
| 212 | operation: "generateReviewGuide", |
| 213 | detail: "The pull request's base branch name is not one git can fetch.", |
| 214 | }), |
| 215 | ), |
| 216 | ); |
| 217 | // Fetched into a ref of this run's own: FETCH_HEAD is shared by every fetch in the |
| 218 | // workspace, so a concurrent run could otherwise measure this change from its base. |
| 219 | const baseRef = `refs/t3/review-guide/${runId}/base`; |
| 220 | yield* fetch([`+refs/heads/${detail.baseBranch}:${baseRef}`]); |
| 221 | yield* Effect.addFinalizer(() => |
| 222 | git |
| 223 | .execute({ |
| 224 | operation: "PullRequestReviewGuideService.dropBaseRef", |
| 225 | cwd: workspaceRoot, |
| 226 | args: ["update-ref", "-d", baseRef], |
| 227 | }) |
| 228 | .pipe(Effect.ignore({ log: true })), |
| 229 | ); |
| 230 | const baseTip = yield* git.resolveCommit({ cwd: workspaceRoot, revision: baseRef }); |
| 231 | const fetchHead = Effect.fn("PullRequestReviewGuideService.fetchHead")(function* ( |
| 232 | number: number, |
| 233 | reportedSha: string, |
| 234 | branch: string, |
| 235 | ) { |
| 236 | const sha = reportedSha.toLowerCase(); |
| 237 | if (!COMMIT_HASH_PATTERN.test(sha)) { |
| 238 | return yield* new PullRequestOperationError({ |
| 239 | operation: "generateReviewGuide", |
| 240 | detail: `The host reported a head for #${number} that is not a commit hash.`, |
| 241 | }); |
| 242 | } |
| 243 | // Not redundant: rev-parse prefers a branch or tag named like a short hash over the commit. |
| 244 | const resolveHead = git.resolveCommit({ cwd: workspaceRoot, revision: sha }).pipe( |
| 245 | Effect.filterOrFail( |
| 246 | ({ commitSha }) => commitSha.startsWith(sha), |
| 247 | ({ commitSha }) => |
| 248 | new PullRequestOperationError({ |
| 249 | operation: "generateReviewGuide", |
| 250 | detail: `${sha} names ${commitSha} here, a branch or tag rather than the commit.`, |
| 251 | }), |
| 252 | ), |
| 253 | ); |
| 254 | const fetchAndResolve = (ref: string) => fetch([ref]).pipe(Effect.andThen(resolveHead)); |
| 255 | const branchRef = `refs/heads/${branch}`; |
| 256 | const checkBranch = git.execute({ |
| 257 | operation: "PullRequestReviewGuideService.checkHeadBranch", |
| 258 | cwd: workspaceRoot, |
| 259 | args: ["check-ref-format", branchRef], |
| 260 | }); |
| 261 | const sources = [ |
| 262 | { source: "local objects", head: resolveHead }, |
| 263 | ...(sha.length === 40 || sha.length === 64 |
| 264 | ? [{ source: "the commit", head: fetchAndResolve(sha) }] |
| 265 | : []), |
| 266 | { |
| 267 | source: `branch ${branch}`, |
| 268 | head: checkBranch.pipe(Effect.andThen(fetchAndResolve(branchRef))), |
| 269 | }, |
| 270 | { source: `refs/pull/${number}/head`, head: fetchAndResolve(`refs/pull/${number}/head`) }, |
| 271 | { |
| 272 | source: `refs/merge-requests/${number}/head`, |
| 273 | head: fetchAndResolve(`refs/merge-requests/${number}/head`), |
| 274 | }, |
| 275 | ]; |
| 276 | const failures: Array<string> = []; |
| 277 | for (const { source, head } of sources) { |
| 278 | const result = yield* Effect.result(head); |
| 279 | if (Result.isSuccess(result)) return result.success; |
| 280 | failures.push(`${source}: ${result.failure.detail}`); |
| 281 | } |
| 282 | return yield* new PullRequestOperationError({ |
| 283 | operation: "generateReviewGuide", |
| 284 | detail: `Could not find head ${sha} of #${number}. ${failures.join(" ")}`, |
| 285 | }); |
| 286 | }); |
| 287 | const resolvedHead = yield* fetchHead(detail.number, headSha, detail.headBranch); |
| 288 | // The other layers of a stack are context the model may read across a seam. A layer that |
| 289 | // cannot be fetched is still named in the prompt, without a head to read. |
| 290 | const layers: ReadonlyArray<ReviewGuideStackLayer> = yield* Effect.forEach( |
| 291 | stack?.layers ?? [], |
| 292 | (layer) => { |
| 293 | const described = { number: layer.number, title: layer.title ?? null, state: layer.state }; |
| 294 | if (layer.number === detail.number) return Effect.succeed({ ...described, headSha }); |
| 295 | const sha = layer.headSha; |
| 296 | if (sha === undefined) return Effect.succeed({ ...described, headSha: null }); |
| 297 | return fetchHead(layer.number, sha, layer.headBranch).pipe( |
| 298 | Effect.map(({ commitSha }) => ({ ...described, headSha: commitSha })), |
| 299 | Effect.catch((error) => |
| 300 | Effect.logInfo("Review guide could not read a stack layer's head", { |
| 301 | layer: layer.number, |
| 302 | detail: error.detail, |
| 303 | }).pipe(Effect.as({ ...described, headSha: null })), |
| 304 | ), |
| 305 | ); |
| 306 | }, |
| 307 | { concurrency: 4 }, |
| 308 | ); |
| 309 | const directory = yield* fileSystem.makeTempDirectoryScoped({ |
| 310 | directory: config.worktreesDir, |
| 311 | prefix: "review-guide-", |
| 312 | }); |
| 313 | const path = `${directory}/checkout`; |
| 314 | yield* git.execute({ |
| 315 | operation: "PullRequestReviewGuideService.addWorktree", |
| 316 | cwd: workspaceRoot, |
| 317 | args: ["worktree", "add", "--detach", "--quiet", path, resolvedHead.commitSha], |
| 318 | timeoutMs: 5 * 60_000, |
| 319 | }); |
| 320 | yield* Effect.addFinalizer(() => |
| 321 | git |
| 322 | .removeWorktree({ cwd: workspaceRoot, path, force: true }) |
| 323 | .pipe(Effect.ignore({ log: true })), |
| 324 | ); |
| 325 | // Measured from where the head left the base, so a stacked change is briefed alone rather |
| 326 | // than with everything below it that the base has not taken yet. |
| 327 | const mergeBase = yield* git.execute({ |
| 328 | operation: "PullRequestReviewGuideService.mergeBase", |
| 329 | cwd: path, |
| 330 | args: ["merge-base", baseTip.commitSha, "HEAD"], |
| 331 | }); |
| 332 | return { path, baseCommit: mergeBase.stdout.trim(), stack: layers }; |
| 333 | }); |
| 334 | |
| 335 | /** The changed paths and their hunks, which a generated guide is held to. */ |
| 336 | const readChange = Effect.fn("PullRequestReviewGuideService.readChange")(function* ( |
| 337 | cwd: string, |
| 338 | baseCommit: string, |
| 339 | ) { |
| 340 | const diff = (args: ReadonlyArray<string>) => |
| 341 | git |
| 342 | .execute({ |
| 343 | operation: "PullRequestReviewGuideService.readChange", |
| 344 | cwd, |
| 345 | // Fixed prefixes: a user's diff.noPrefix or custom prefixes would hide the headers the |
| 346 | // parser reads. |
| 347 | args: [ |
| 348 | "-c", |
| 349 | "core.quotePath=false", |
| 350 | "diff", |
| 351 | ...PATCH_RENDER_PREFIX_ARGS, |
| 352 | ...args, |
| 353 | `${baseCommit}...HEAD`, |
| 354 | ], |
| 355 | timeoutMs: 60_000, |
| 356 | // Hunk headers only, but a large change still has many of them. |
| 357 | maxOutputBytes: 64 * 1024 * 1024, |
| 358 | }) |
| 359 | .pipe(Effect.map((result) => result.stdout)); |
| 360 | const [nameOnly, patch] = yield* Effect.all( |
| 361 | [diff(["--name-only", "-z"]), diff(["--unified=0", "--no-color", "--no-ext-diff"])], |
| 362 | { concurrency: "unbounded" }, |
| 363 | ); |
| 364 | return parseReviewGuideChange(nameOnly, patch); |
| 365 | }); |
| 366 | |
| 367 | const write = Effect.fn("PullRequestReviewGuideService.write")(function* ( |
| 368 | input: PullRequestRef, |
| 369 | key: ThreadPullRequestKey, |
| 370 | workspaceRoot: string, |
| 371 | detail: PullRequestDetail, |
| 372 | headSha: string, |
| 373 | runId: string, |
| 374 | owns: Effect.Effect<boolean>, |
| 375 | ) { |
| 376 | const [activity, settings, providers, stack] = yield* Effect.all( |
| 377 | [ |
| 378 | pullRequests.activity(input), |
| 379 | serverSettings.getSettings, |
| 380 | providerRegistry.getProviders, |
| 381 | // A guide without its stack is still a guide; the stack only adds seams to point at. |
| 382 | pullRequests.stack(input).pipe(Effect.orElseSucceed(() => null)), |
| 383 | ], |
| 384 | { concurrency: "unbounded" }, |
| 385 | ); |
| 386 | const projectSettings = resolveProjectSettings(settings, input.projectId).settings; |
| 387 | const modelSelection = ServerSettings.resolveReviewGuideModelSelection( |
| 388 | projectSettings, |
| 389 | providers, |
| 390 | input.projectId, |
| 391 | ); |
| 392 | // The checkout lives only as long as the model is reading it. The change's hunks are read |
| 393 | // from it too, so the guide can be held to the lines the change actually touched. |
| 394 | const { generated, change } = yield* checkoutHead( |
| 395 | workspaceRoot, |
| 396 | detail, |
| 397 | headSha, |
| 398 | stack, |
| 399 | runId, |
| 400 | ).pipe( |
| 401 | Effect.flatMap((checkout) => |
| 402 | Effect.all( |
| 403 | { |
| 404 | generated: textGeneration.generateReviewGuide({ |
| 405 | ...reviewGuideInputFromPullRequest(detail, activity, checkout.baseCommit), |
| 406 | cwd: checkout.path, |
| 407 | modelSelection, |
| 408 | instructions: projectSettings.reviewGuideInstructions, |
| 409 | stack: checkout.stack, |
| 410 | }), |
| 411 | change: readChange(checkout.path, checkout.baseCommit), |
| 412 | }, |
| 413 | { concurrency: "unbounded" }, |
| 414 | ), |
| 415 | ), |
| 416 | Effect.scoped, |
| 417 | ); |
| 418 | const guide: PullRequestReviewGuide = { |
| 419 | headSha, |
| 420 | generatedAt: yield* nowIso, |
| 421 | modelSelection, |
| 422 | ...normalizeReviewGuide(generated, change), |
| 423 | }; |
| 424 | // A newer run for this pull request, say after a push, owns its guide now; this one's result |
| 425 | // is for an older head and is dropped rather than written over it. |
| 426 | yield* ownership.withPermits(1)( |
| 427 | Effect.gen(function* () { |
| 428 | if (yield* owns) yield* guides.put(key, guide); |
| 429 | }), |
| 430 | ); |
| 431 | }); |
| 432 | |
| 433 | /** What a reader is told when a run fails; the full cause goes to the log. */ |
| 434 | const failureDetail = (cause: Cause.Cause<unknown>): string => { |
| 435 | const error = Cause.squash(cause); |
| 436 | if (error instanceof Error && "detail" in error && typeof error.detail === "string") { |
| 437 | return error.detail; |
| 438 | } |
| 439 | return "The review guide could not be written."; |
| 440 | }; |
| 441 | |
| 442 | const generate: PullRequestReviewGuideService["Service"]["generate"] = Effect.fn( |
| 443 | "PullRequestReviewGuideService.generate", |
| 444 | )(function* (input) { |
| 445 | // A beta the server owns, so every client and agent sees the same answer. |
| 446 | const settings = yield* serverSettings.getSettings.pipe( |
| 447 | Effect.mapError( |
| 448 | (cause) => |
| 449 | new PullRequestOperationError({ |
| 450 | operation: "generateReviewGuide", |
| 451 | detail: "Could not read the server settings.", |
| 452 | cause, |
| 453 | }), |
| 454 | ), |
| 455 | ); |
| 456 | if (!settings.reviewGuidesEnabled) { |
| 457 | return yield* new PullRequestOperationError({ |
| 458 | operation: "generateReviewGuide", |
| 459 | detail: "Review guides are off. Turn them on in Settings → Source Control.", |
| 460 | }); |
| 461 | } |
| 462 | const { key, workspaceRoot, identity } = yield* keyFor(input); |
| 463 | yield* requireOwnRepository(key, identity); |
| 464 | const runKey = keyString(key); |
| 465 | // A press asks for the head as it is now, not a read shared from the last minute, so a guide |
| 466 | // is never started for a commit the pull request has already moved past. |
| 467 | yield* pullRequests.invalidate({ reference: input }); |
| 468 | const [detail, stored] = yield* Effect.all([pullRequests.detail(input), readStored(key)], { |
| 469 | concurrency: "unbounded", |
| 470 | }); |
| 471 | const currentHeadSha = detail.headSha ?? null; |
| 472 | if (currentHeadSha === null) { |
| 473 | return yield* new PullRequestOperationError({ |
| 474 | operation: "generateReviewGuide", |
| 475 | detail: "This host did not report the pull request's head commit.", |
| 476 | }); |
| 477 | } |
| 478 | const running: PullRequestReviewGuideRun = { |
| 479 | _tag: "Running", |
| 480 | headSha: currentHeadSha, |
| 481 | startedAt: yield* nowIso, |
| 482 | }; |
| 483 | // Claimed atomically, so two readers pressing Generate start one run between them. The head |
| 484 | // is read again under the claim: a press that read an older head before a push must not |
| 485 | // replace a run another press started for the new one, so it asks to be pressed again. |
| 486 | const claimed = yield* ownership.withPermits(1)( |
| 487 | pullRequests.detail(input).pipe( |
| 488 | Effect.flatMap((latest) => |
| 489 | (latest.headSha ?? null) !== currentHeadSha |
| 490 | ? Effect.fail( |
| 491 | new PullRequestOperationError({ |
| 492 | operation: "generateReviewGuide", |
| 493 | detail: "The pull request changed while the guide was starting. Try again.", |
| 494 | }), |
| 495 | ) |
| 496 | : Ref.modify(runs, (current) => { |
| 497 | const existing = current.get(runKey); |
| 498 | if (existing?._tag === "Running" && existing.headSha === currentHeadSha) { |
| 499 | return [existing, current] as const; |
| 500 | } |
| 501 | return [running, new Map(current).set(runKey, running)] as const; |
| 502 | }), |
| 503 | ), |
| 504 | ), |
| 505 | ); |
| 506 | if (claimed !== running) return { guide: stored, run: claimed, currentHeadSha, started: false }; |
| 507 | |
| 508 | // The run is identified by its own Running value: a later claim replaces it, and from then |
| 509 | // on this run's completion leaves the newer run's state and guide alone. |
| 510 | const owns = Ref.get(runs).pipe(Effect.map((current) => current.get(runKey) === running)); |
| 511 | const finish = (next: PullRequestReviewGuideRun | null) => |
| 512 | Ref.update(runs, (current) => { |
| 513 | if (current.get(runKey) !== running) return current; |
| 514 | const updated = new Map(current); |
| 515 | if (next === null) updated.delete(runKey); |
| 516 | else updated.set(runKey, next); |
| 517 | return updated; |
| 518 | }); |
| 519 | const runId = `${runCounter++}`; |
| 520 | yield* write(input, key, workspaceRoot, detail, currentHeadSha, runId, owns).pipe( |
| 521 | Effect.andThen(finish(null)), |
| 522 | Effect.catchCause((cause) => |
| 523 | Cause.hasInterruptsOnly(cause) |
| 524 | ? finish(null) |
| 525 | : Effect.logWarning("Review guide generation failed", { |
| 526 | key: runKey, |
| 527 | cause: Cause.pretty(cause), |
| 528 | }).pipe( |
| 529 | Effect.andThen(nowIso), |
| 530 | Effect.flatMap((failedAt) => |
| 531 | finish({ |
| 532 | _tag: "Failed", |
| 533 | headSha: currentHeadSha, |
| 534 | failedAt, |
| 535 | detail: failureDetail(cause), |
| 536 | }), |
| 537 | ), |
| 538 | ), |
| 539 | ), |
| 540 | Effect.forkIn(serviceScope), |
| 541 | ); |
| 542 | return { guide: stored, run: running, currentHeadSha, started: true }; |
| 543 | }); |
| 544 | |
| 545 | return PullRequestReviewGuideService.of({ get, generate }); |
| 546 | }); |
| 547 | |
| 548 | export const layer = Layer.effect(PullRequestReviewGuideService, make); |