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 */
15import * as Cause from "effect/Cause";
16import * as Clock from "effect/Clock";
17import * as Context from "effect/Context";
18import * as DateTime from "effect/DateTime";
19import * as Effect from "effect/Effect";
20import * as FileSystem from "effect/FileSystem";
21import * as Layer from "effect/Layer";
22import * as Option from "effect/Option";
23import * as Ref from "effect/Ref";
24import * as Result from "effect/Result";
25import * as Semaphore from "effect/Semaphore";
26
27import {
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";
39import type { ReviewGuideStackLayer } from "@t3tools/provider-core/server/textGeneration";
40import { resolveProjectSettings } from "@t3tools/shared/projectSettings";
41import { sourceControlRepositorySelector } from "@t3tools/shared/sourceControl";
42
43import * as ServerConfig from "../config.ts";
44import * as PullRequestReviewGuides from "../persistence/PullRequestReviewGuides.ts";
45import * as ProjectService from "../project/ProjectService.ts";
46import * as ProviderRegistry from "../provider/ProviderRegistry.ts";
47import * as ServerSettings from "../serverSettings.ts";
48import * as TextGeneration from "../textGeneration/TextGeneration.ts";
49import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
50import { PATCH_RENDER_PREFIX_ARGS } from "../vcs/GitVcsDriverCore.ts";
51import * as PullRequestService from "./PullRequestService.ts";
52import { pullRequestSyncKey } from "./pullRequestSyncKey.ts";
53import { reviewGuideInputFromPullRequest } from "./pullRequestReviewGuideInput.ts";
54import { normalizeReviewGuide, parseReviewGuideChange } from "./pullRequestReviewGuideNormalize.ts";
55
56const COMMIT_HASH_PATTERN = /^[0-9a-f]{7,64}$/;
57
58export 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
80const keyString = (key: ThreadPullRequestKey) => `${key.host}/${key.repository}#${key.number}`;
81
82const nowIso = Clock.currentTimeMillis.pipe(
83 Effect.map((millis) => DateTime.formatIso(DateTime.makeUnsafe(millis))),
84);
85
86const 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
548export const layer = Layer.effect(PullRequestReviewGuideService, make);