Open History to see the last commit
1import * as Cause from "effect/Cause";
2import * as Clock from "effect/Clock";
3import * as Context from "effect/Context";
4import * as DateTime from "effect/DateTime";
5import * as Effect from "effect/Effect";
6import * as FileSystem from "effect/FileSystem";
7import * as Layer from "effect/Layer";
8import * as Option from "effect/Option";
9import * as Ref from "effect/Ref";
10import * as Result from "effect/Result";
11import * as Semaphore from "effect/Semaphore";
12
13import {
14 PullRequestOperationError,
15 type PullRequestDetail,
16 type PullRequestRef,
17 type PullRequestReviewGuide,
18 type PullRequestReviewGuideResult,
19 type PullRequestReviewGuideRun,
20 type PullRequestStack,
21 ProjectId,
22 type RepositoryIdentity,
23 type ThreadPullRequestKey,
24} from "@t3tools/contracts";
25import type { ReviewGuideStackLayer } from "@t3tools/provider-core/server/textGeneration";
26import { resolveProjectSettings } from "@t3tools/shared/projectSettings";
27import { sourceControlDefinitions } from "../sourceControl/sourceControlDefinitions.ts";
28
29import * as ServerConfig from "../config.ts";
30import * as PullRequestReviewGuides from "../persistence/PullRequestReviewGuides.ts";
31import * as ProjectService from "../project/ProjectService.ts";
32import * as ProviderRegistry from "../provider/ProviderRegistry.ts";
33import * as ServerSettings from "../serverSettings.ts";
34import * as TextGeneration from "../textGeneration/TextGeneration.ts";
35import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
36import { PATCH_RENDER_PREFIX_ARGS } from "../vcs/GitVcsDriverCore.ts";
37import * as PullRequestService from "./PullRequestService.ts";
38import { pullRequestSyncKey } from "./pullRequestSyncKey.ts";
39import { reviewGuideInputFromPullRequest } from "./pullRequestReviewGuideInput.ts";
40import { normalizeReviewGuide, parseReviewGuideChange } from "./pullRequestReviewGuideNormalize.ts";
41
42const COMMIT_HASH_PATTERN = /^[0-9a-f]{7,64}$/;
43
44export class PullRequestReviewGuideService extends Context.Service<
45 PullRequestReviewGuideService,
46 {
47 readonly get: (
48 input: PullRequestRef,
49 ) => Effect.Effect<PullRequestReviewGuideResult, PullRequestService.PullRequestError>;
50 readonly generate: (
51 input: PullRequestRef,
52 ) => Effect.Effect<
53 PullRequestReviewGuideResult & { readonly started: boolean },
54 PullRequestService.PullRequestError
55 >;
56 }
57>()("t3/pullRequest/PullRequestReviewGuideService") {}
58
59const keyString = (key: ThreadPullRequestKey) => `${key.host}/${key.repository}#${key.number}`;
60
61const nowIso = Clock.currentTimeMillis.pipe(
62 Effect.map((millis) => DateTime.formatIso(DateTime.makeUnsafe(millis))),
63);
64
65const make = Effect.gen(function* () {
66 const pullRequests = yield* PullRequestService.PullRequestService;
67 const guides = yield* PullRequestReviewGuides.PullRequestReviewGuideRepository;
68 const projects = yield* ProjectService.ProjectService;
69 const serverSettings = yield* ServerSettings.ServerSettingsService;
70 const providerRegistry = yield* ProviderRegistry.ProviderRegistry;
71 const textGeneration = yield* TextGeneration.TextGeneration;
72 const git = yield* GitVcsDriver.GitVcsDriver;
73 const fileSystem = yield* FileSystem.FileSystem;
74 const config = yield* ServerConfig.ServerConfig;
75 const serviceScope = yield* Effect.scope;
76 const runs = yield* Ref.make<ReadonlyMap<string, PullRequestReviewGuideRun>>(new Map());
77 let runCounter = 0;
78 // Serialize claims and guide writes so a newer claim cannot steal ownership between checking and storing.
79 const ownership = yield* Semaphore.make(1);
80
81 const keyFor = Effect.fn("PullRequestReviewGuideService.keyFor")(function* (
82 input: PullRequestRef,
83 ) {
84 const project = Option.getOrUndefined(
85 yield* projects.getShell(input.projectId).pipe(Effect.orElseSucceed(() => Option.none())),
86 );
87 const key = pullRequestSyncKey(input, project?.repositoryIdentity ?? undefined);
88 if (key === null || project === undefined) {
89 return yield* new PullRequestOperationError({
90 operation: "reviewGuide",
91 detail: "Could not identify this pull request's host.",
92 });
93 }
94 return { key, workspaceRoot: project.workspaceRoot, identity: project.repositoryIdentity };
95 });
96
97 const requireOwnRepository = Effect.fn("PullRequestReviewGuideService.requireOwnRepository")(
98 function* (key: ThreadPullRequestKey, identity: RepositoryIdentity | null | undefined) {
99 const own =
100 identity == null
101 ? null
102 : pullRequestSyncKey(
103 {
104 projectId: ProjectId.make("own"),
105 number: key.number,
106 repository: sourceControlDefinitions.repositorySelector(identity) ?? "",
107 },
108 identity,
109 );
110 if (
111 identity == null ||
112 own === null ||
113 own.host !== key.host ||
114 own.repository !== key.repository
115 ) {
116 return yield* new PullRequestOperationError({
117 operation: "generateReviewGuide",
118 detail:
119 "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.",
120 });
121 }
122 return identity.locator;
123 },
124 );
125
126 const readStored = (key: ThreadPullRequestKey) =>
127 guides.get(key).pipe(
128 Effect.mapError(
129 (cause) =>
130 new PullRequestOperationError({
131 operation: "reviewGuide",
132 detail: "Could not read the stored review guide.",
133 cause,
134 }),
135 ),
136 Effect.map(Option.getOrNull),
137 );
138
139 const get: PullRequestReviewGuideService["Service"]["get"] = Effect.fn(
140 "PullRequestReviewGuideService.get",
141 )(function* (input) {
142 const { key } = yield* keyFor(input);
143 const [guide, detail, current] = yield* Effect.all(
144 [readStored(key), pullRequests.detail(input), Ref.get(runs)],
145 { concurrency: "unbounded" },
146 );
147 return {
148 guide,
149 run: current.get(keyString(key)) ?? null,
150 currentHeadSha: detail.headSha ?? null,
151 };
152 });
153
154 const checkoutHead = Effect.fn("PullRequestReviewGuideService.checkoutHead")(function* (
155 workspaceRoot: string,
156 locator: RepositoryIdentity["locator"],
157 detail: PullRequestDetail,
158 headSha: string,
159 stack: PullRequestStack | null,
160 runId: string,
161 ) {
162 // Identity names can be stale; match effective fetch URLs, including insteadOf rewrites.
163 const names = yield* git
164 .execute({
165 operation: "PullRequestReviewGuideService.listRemotes",
166 cwd: workspaceRoot,
167 args: ["remote"],
168 })
169 .pipe(Effect.map((result) => result.stdout.split("\n").filter((name) => name.length > 0)));
170 const remote = yield* Effect.findFirst(
171 [locator.remoteName, ...names.filter((name) => name !== locator.remoteName)],
172 (name) =>
173 git
174 .execute({
175 operation: "PullRequestReviewGuideService.remoteUrl",
176 cwd: workspaceRoot,
177 args: ["remote", "get-url", "--", name],
178 })
179 .pipe(
180 Effect.map((result) => result.stdout.trim() === locator.remoteUrl),
181 Effect.orElseSucceed(() => false),
182 ),
183 ).pipe(Effect.map(Option.getOrUndefined));
184 if (remote === undefined) {
185 return yield* new PullRequestOperationError({
186 operation: "generateReviewGuide",
187 detail: "This project's checkout no longer has a remote for the pull request's repository.",
188 });
189 }
190 const fetch = (args: ReadonlyArray<string>) =>
191 git.execute({
192 operation: "PullRequestReviewGuideService.fetch",
193 cwd: workspaceRoot,
194 args: ["fetch", "--quiet", "--no-tags", "--", remote, ...args],
195 timeoutMs: 5 * 60_000,
196 });
197 // Reject invalid branch names and refspec operators before fetching host-provided names.
198 yield* git
199 .execute({
200 operation: "PullRequestReviewGuideService.checkBaseBranch",
201 cwd: workspaceRoot,
202 args: ["check-ref-format", "--branch", detail.baseBranch],
203 })
204 .pipe(
205 Effect.filterOrFail(
206 () => !detail.baseBranch.startsWith("+") && !detail.baseBranch.includes(":"),
207 () =>
208 new PullRequestOperationError({
209 operation: "generateReviewGuide",
210 detail: "The pull request's base branch name is not one git can fetch.",
211 }),
212 ),
213 );
214 // FETCH_HEAD is workspace-global; isolate this run's base from concurrent fetches.
215 const baseRef = `refs/t3/review-guide/${runId}/base`;
216 yield* fetch([`+refs/heads/${detail.baseBranch}:${baseRef}`]);
217 yield* Effect.addFinalizer(() =>
218 git
219 .execute({
220 operation: "PullRequestReviewGuideService.dropBaseRef",
221 cwd: workspaceRoot,
222 args: ["update-ref", "-d", baseRef],
223 })
224 .pipe(Effect.ignore({ log: true })),
225 );
226 const baseTip = yield* git.resolveCommit({ cwd: workspaceRoot, revision: baseRef });
227 const fetchHead = Effect.fn("PullRequestReviewGuideService.fetchHead")(function* (
228 number: number,
229 reportedSha: string,
230 branch: string,
231 ) {
232 const sha = reportedSha.toLowerCase();
233 if (!COMMIT_HASH_PATTERN.test(sha)) {
234 return yield* new PullRequestOperationError({
235 operation: "generateReviewGuide",
236 detail: `The host reported a head for #${number} that is not a commit hash.`,
237 });
238 }
239 // rev-parse prefers same-named branches/tags over short hashes.
240 const resolveHead = git.resolveCommit({ cwd: workspaceRoot, revision: sha }).pipe(
241 Effect.filterOrFail(
242 ({ commitSha }) => commitSha.startsWith(sha),
243 ({ commitSha }) =>
244 new PullRequestOperationError({
245 operation: "generateReviewGuide",
246 detail: `${sha} names ${commitSha} here, a branch or tag rather than the commit.`,
247 }),
248 ),
249 );
250 const fetchAndResolve = (ref: string) => fetch([ref]).pipe(Effect.andThen(resolveHead));
251 const headRef = sourceControlDefinitions.get(detail.provider).changeRequestHeadRef?.(number);
252 const branchRef = `refs/heads/${branch}`;
253 const checkBranch = git.execute({
254 operation: "PullRequestReviewGuideService.checkHeadBranch",
255 cwd: workspaceRoot,
256 args: ["check-ref-format", branchRef],
257 });
258 const sources = [
259 { source: "local objects", head: resolveHead },
260 ...(sha.length === 40 || sha.length === 64
261 ? [{ source: "the commit", head: fetchAndResolve(sha) }]
262 : []),
263 {
264 source: `branch ${branch}`,
265 head: checkBranch.pipe(Effect.andThen(fetchAndResolve(branchRef))),
266 },
267 ...(headRef === undefined ? [] : [{ source: headRef, head: fetchAndResolve(headRef) }]),
268 ];
269 const failures: Array<string> = [];
270 for (const { source, head } of sources) {
271 const result = yield* Effect.result(head);
272 if (Result.isSuccess(result)) return result.success;
273 failures.push(`${source}: ${result.failure.detail}`);
274 }
275 return yield* new PullRequestOperationError({
276 operation: "generateReviewGuide",
277 detail: `Could not find head ${sha} of #${number}. ${failures.join(" ")}`,
278 });
279 });
280 const resolvedHead = yield* fetchHead(detail.number, headSha, detail.headBranch);
281 const layers: ReadonlyArray<ReviewGuideStackLayer> = yield* Effect.forEach(
282 stack?.layers ?? [],
283 (layer) => {
284 const described = { number: layer.number, title: layer.title ?? null, state: layer.state };
285 if (layer.number === detail.number) return Effect.succeed({ ...described, headSha });
286 const sha = layer.headSha;
287 if (sha === undefined) return Effect.succeed({ ...described, headSha: null });
288 return fetchHead(layer.number, sha, layer.headBranch).pipe(
289 Effect.map(({ commitSha }) => ({ ...described, headSha: commitSha })),
290 Effect.catch((error) =>
291 Effect.logInfo("Review guide could not read a stack layer's head", {
292 layer: layer.number,
293 detail: error.detail,
294 }).pipe(Effect.as({ ...described, headSha: null })),
295 ),
296 );
297 },
298 { concurrency: 4 },
299 );
300 const directory = yield* fileSystem.makeTempDirectoryScoped({
301 directory: config.worktreesDir,
302 prefix: "review-guide-",
303 });
304 const path = `${directory}/checkout`;
305 yield* git.execute({
306 operation: "PullRequestReviewGuideService.addWorktree",
307 cwd: workspaceRoot,
308 // Scratch checkout for reading the change: repository hooks (installs, LFS) only slow or fail it.
309 args: [
310 "-c",
311 "core.hooksPath=/dev/null",
312 "worktree",
313 "add",
314 "--detach",
315 "--quiet",
316 path,
317 resolvedHead.commitSha,
318 ],
319 timeoutMs: 5 * 60_000,
320 });
321 yield* Effect.addFinalizer(() =>
322 git
323 .removeWorktree({ cwd: workspaceRoot, path, force: true })
324 .pipe(Effect.ignore({ log: true })),
325 );
326 const mergeBase = yield* git.execute({
327 operation: "PullRequestReviewGuideService.mergeBase",
328 cwd: path,
329 args: ["merge-base", baseTip.commitSha, "HEAD"],
330 });
331 return { path, baseCommit: mergeBase.stdout.trim(), stack: layers };
332 });
333
334 const readChange = Effect.fn("PullRequestReviewGuideService.readChange")(function* (
335 cwd: string,
336 baseCommit: string,
337 ) {
338 const diff = (args: ReadonlyArray<string>) =>
339 git
340 .execute({
341 operation: "PullRequestReviewGuideService.readChange",
342 cwd,
343 // Override user diff prefixes so the parser can find headers.
344 args: [
345 "-c",
346 "core.quotePath=false",
347 "diff",
348 ...PATCH_RENDER_PREFIX_ARGS,
349 ...args,
350 `${baseCommit}...HEAD`,
351 ],
352 timeoutMs: 60_000,
353 maxOutputBytes: 64 * 1024 * 1024,
354 })
355 .pipe(Effect.map((result) => result.stdout));
356 const [nameOnly, patch] = yield* Effect.all(
357 [
358 diff(["--name-only", "-z"]),
359 diff(["--unified=0", "--no-color", "--no-ext-diff", "--no-textconv"]),
360 ],
361 { concurrency: "unbounded" },
362 );
363 return parseReviewGuideChange(nameOnly, patch);
364 });
365
366 const write = Effect.fn("PullRequestReviewGuideService.write")(function* (
367 input: PullRequestRef,
368 key: ThreadPullRequestKey,
369 workspaceRoot: string,
370 locator: RepositoryIdentity["locator"],
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 pullRequests.stack(input).pipe(Effect.orElseSucceed(() => null)),
382 ],
383 { concurrency: "unbounded" },
384 );
385 const projectSettings = resolveProjectSettings(settings, input.projectId).settings;
386 const modelSelection = ServerSettings.resolveReviewGuideModelSelection(
387 projectSettings,
388 providers,
389 input.projectId,
390 );
391 const { generated, change } = yield* checkoutHead(
392 workspaceRoot,
393 locator,
394 detail,
395 headSha,
396 stack,
397 runId,
398 ).pipe(
399 Effect.flatMap((checkout) =>
400 Effect.all(
401 {
402 generated: textGeneration.generateReviewGuide({
403 ...reviewGuideInputFromPullRequest(detail, activity, checkout.baseCommit),
404 cwd: checkout.path,
405 modelSelection,
406 projectId: input.projectId,
407 instructions: projectSettings.reviewGuideInstructions,
408 stack: checkout.stack,
409 }),
410 change: readChange(checkout.path, checkout.baseCommit),
411 },
412 { concurrency: "unbounded" },
413 ),
414 ),
415 Effect.scoped,
416 );
417 const guide: PullRequestReviewGuide = {
418 headSha,
419 generatedAt: yield* nowIso,
420 modelSelection,
421 ...normalizeReviewGuide(generated, change),
422 };
423 yield* ownership.withPermits(1)(
424 Effect.gen(function* () {
425 if (yield* owns) yield* guides.put(key, guide);
426 }),
427 );
428 });
429
430 const failureDetail = (cause: Cause.Cause<unknown>): string => {
431 const error = Cause.squash(cause);
432 if (error instanceof Error && "detail" in error && typeof error.detail === "string") {
433 return error.detail;
434 }
435 return "The review guide could not be written.";
436 };
437
438 const generate: PullRequestReviewGuideService["Service"]["generate"] = Effect.fn(
439 "PullRequestReviewGuideService.generate",
440 )(function* (input) {
441 const settings = yield* serverSettings.getSettings.pipe(
442 Effect.mapError(
443 (cause) =>
444 new PullRequestOperationError({
445 operation: "generateReviewGuide",
446 detail: "Could not read the server settings.",
447 cause,
448 }),
449 ),
450 );
451 if (!settings.reviewGuidesEnabled) {
452 return yield* new PullRequestOperationError({
453 operation: "generateReviewGuide",
454 detail: "Review guides are off. Turn them on in Settings → Source Control.",
455 });
456 }
457 const { key, workspaceRoot, identity } = yield* keyFor(input);
458 const locator = yield* requireOwnRepository(key, identity);
459 const runKey = keyString(key);
460 yield* pullRequests.invalidate({ reference: input });
461 const [detail, stored] = yield* Effect.all([pullRequests.detail(input), readStored(key)], {
462 concurrency: "unbounded",
463 });
464 const currentHeadSha = detail.headSha ?? null;
465 if (currentHeadSha === null) {
466 return yield* new PullRequestOperationError({
467 operation: "generateReviewGuide",
468 detail: "This host did not report the pull request's head commit.",
469 });
470 }
471 const running: PullRequestReviewGuideRun = {
472 _tag: "Running",
473 headSha: currentHeadSha,
474 startedAt: yield* nowIso,
475 };
476 // Claim atomically and re-read the head so stale clicks cannot replace a newer head's run.
477 const claimed = yield* ownership.withPermits(1)(
478 pullRequests.detail(input).pipe(
479 Effect.flatMap((latest) =>
480 (latest.headSha ?? null) !== currentHeadSha
481 ? Effect.fail(
482 new PullRequestOperationError({
483 operation: "generateReviewGuide",
484 detail: "The pull request changed while the guide was starting. Try again.",
485 }),
486 )
487 : Ref.modify(runs, (current) => {
488 const existing = current.get(runKey);
489 if (existing?._tag === "Running" && existing.headSha === currentHeadSha) {
490 return [existing, current] as const;
491 }
492 return [running, new Map(current).set(runKey, running)] as const;
493 }),
494 ),
495 ),
496 );
497 if (claimed !== running) return { guide: stored, run: claimed, currentHeadSha, started: false };
498
499 const owns = Ref.get(runs).pipe(Effect.map((current) => current.get(runKey) === running));
500 const finish = (next: PullRequestReviewGuideRun | null) =>
501 Ref.update(runs, (current) => {
502 if (current.get(runKey) !== running) return current;
503 const updated = new Map(current);
504 if (next === null) updated.delete(runKey);
505 else updated.set(runKey, next);
506 return updated;
507 });
508 const runId = `${runCounter++}`;
509 yield* write(input, key, workspaceRoot, locator, detail, currentHeadSha, runId, owns).pipe(
510 Effect.andThen(finish(null)),
511 Effect.catchCause((cause) =>
512 Cause.hasInterruptsOnly(cause)
513 ? finish(null)
514 : Effect.logWarning("Review guide generation failed", {
515 key: runKey,
516 cause: Cause.pretty(cause),
517 }).pipe(
518 Effect.andThen(nowIso),
519 Effect.flatMap((failedAt) =>
520 finish({
521 _tag: "Failed",
522 headSha: currentHeadSha,
523 failedAt,
524 detail: failureDetail(cause),
525 }),
526 ),
527 ),
528 ),
529 Effect.forkIn(serviceScope),
530 );
531 return { guide: stored, run: running, currentHeadSha, started: true };
532 });
533
534 return PullRequestReviewGuideService.of({ get, generate });
535});
536
537export const layer = Layer.effect(PullRequestReviewGuideService, make);