1import * as Context from "effect/Context";
2import * as DateTime from "effect/DateTime";
3import * as Effect from "effect/Effect";
4import * as FileSystem from "effect/FileSystem";
5import * as Layer from "effect/Layer";
6import * as Path from "effect/Path";
7
8import {
9 VcsRepositoryDetectionError,
10 VcsUnsupportedOperationError,
11 type ReviewDiffFileContentsInput,
12 type ReviewDiffFileContentsResult,
13 type ReviewDiffPreviewError,
14 type ReviewDiffPreviewInput,
15 type ReviewDiffPreviewResult,
16} from "@t3tools/contracts";
17
18import * as ServerConfig from "../config.ts";
19import * as ProjectStore from "../orchestration-v2/ProjectStore.ts";
20import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
21import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts";
22import * as ServerSettings from "../serverSettings.ts";
23import { isFilesystemRoot, managedWorktreesDirectories } from "../worktreesDirectory.ts";
24import * as HostProcess from "@t3tools/shared/HostProcess";
25
26export class ReviewService extends Context.Service<
27 ReviewService,
28 {
29 readonly getDiffPreview: (
30 input: ReviewDiffPreviewInput,
31 ) => Effect.Effect<ReviewDiffPreviewResult, ReviewDiffPreviewError>;
32 readonly getDiffFileContents: (
33 input: ReviewDiffFileContentsInput,
34 ) => Effect.Effect<ReviewDiffFileContentsResult, ReviewDiffPreviewError>;
35 }
36>()("t3/review/ReviewService") {}
37
38/** @public Service construction is part of the canonical Effect module API. */
39export const make = Effect.gen(function* () {
40 const config = yield* ServerConfig.ServerConfig;
41 const fileSystem = yield* FileSystem.FileSystem;
42 const path = yield* Path.Path;
43 const vcsRegistry = yield* VcsDriverRegistry.VcsDriverRegistry;
44 const git = yield* GitVcsDriver.GitVcsDriver;
45 const settings = yield* ServerSettings.ServerSettingsService;
46 const projectStore = yield* ProjectStore.ProjectStoreV2;
47
48 const canonicalizePath = (value: string) => {
49 const resolvedPath = path.resolve(value);
50 return fileSystem.realPath(resolvedPath).pipe(
51 Effect.catchTags({
52 PlatformError: (cause) =>
53 cause.reason._tag === "NotFound"
54 ? Effect.succeed(resolvedPath)
55 : Effect.fail(
56 new VcsRepositoryDetectionError({
57 operation: "ReviewService.assertWorkspaceBoundCwd.canonicalizePath",
58 cwd: resolvedPath,
59 detail: "Failed to resolve a path while validating the review workspace.",
60 cause,
61 }),
62 ),
63 }),
64 );
65 };
66
67 const isWithinRoot = (candidate: string, root: string) => {
68 const relative = path.relative(root, candidate);
69 return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
70 };
71
72 const assertWorkspaceBoundCwd = Effect.fn("ReviewService.assertWorkspaceBoundCwd")(function* (
73 operation: "ReviewService.getDiffPreview" | "ReviewService.getDiffFileContents",
74 cwd: string,
75 ) {
76 const worktreesDirectories = yield* settings.getSettings.pipe(
77 Effect.orElseSucceed(() => ({ worktreesDirectory: "", previousWorktreesDirectories: [] })),
78 );
79 const home = yield* HostProcess.HomeDirectory;
80 const [candidate, workspaceRoot, worktreesRoots] = yield* Effect.all([
81 canonicalizePath(cwd),
82 canonicalizePath(config.cwd),
83 // A managed root that cannot be resolved, or resolves to a filesystem
84 // root through a symlink, is skipped rather than failing every review.
85 Effect.forEach(
86 managedWorktreesDirectories(worktreesDirectories, config.worktreesDir, path, home),
87 (directory) => canonicalizePath(directory).pipe(Effect.orElseSucceed(() => null)),
88 ).pipe(
89 Effect.map((roots) =>
90 roots.filter((root): root is string => root !== null && !isFilesystemRoot(root, path)),
91 ),
92 ),
93 ]);
94
95 if (
96 isWithinRoot(candidate, workspaceRoot) ||
97 worktreesRoots.some((root) => isWithinRoot(candidate, root))
98 ) {
99 return;
100 }
101
102 // Registered projects can live outside the server cwd, which is the home
103 // directory in packaged desktop builds, e.g. a repository on another
104 // Windows drive. Unreadable or unresolvable project roots grant nothing.
105 const projects = yield* projectStore.listShells().pipe(Effect.orElseSucceed(() => []));
106 for (const project of projects) {
107 const root = yield* canonicalizePath(project.workspaceRoot).pipe(
108 Effect.orElseSucceed(() => null),
109 );
110 if (root !== null && isWithinRoot(candidate, root)) {
111 return;
112 }
113 }
114
115 return yield* new VcsRepositoryDetectionError({
116 operation,
117 cwd,
118 detail:
119 operation === "ReviewService.getDiffPreview"
120 ? "Review diff preview cwd must stay within the configured workspace root."
121 : "Review diff file contents cwd must stay within the configured workspace root.",
122 });
123 });
124
125 const getDiffPreview: ReviewService["Service"]["getDiffPreview"] = Effect.fn(
126 "ReviewService.getDiffPreview",
127 )(function* (input) {
128 yield* assertWorkspaceBoundCwd("ReviewService.getDiffPreview", input.cwd);
129
130 const handle = yield* vcsRegistry.detect({ cwd: input.cwd, requestedKind: "auto" });
131 if (!handle) {
132 return {
133 cwd: input.cwd,
134 generatedAt: yield* DateTime.now,
135 sources: [],
136 };
137 }
138
139 const getDriverDiffPreview = handle.driver.getDiffPreview;
140 if (!getDriverDiffPreview) {
141 if (handle.kind === "git") {
142 return yield* git.getReviewDiffPreview(input);
143 }
144 return yield* new VcsUnsupportedOperationError({
145 operation: "ReviewService.getDiffPreview",
146 kind: handle.kind,
147 detail: `The ${handle.kind} VCS driver does not support review diff previews.`,
148 });
149 }
150
151 return yield* getDriverDiffPreview(input);
152 });
153
154 const getDiffFileContents: ReviewService["Service"]["getDiffFileContents"] = Effect.fn(
155 "ReviewService.getDiffFileContents",
156 )(function* (input) {
157 yield* assertWorkspaceBoundCwd("ReviewService.getDiffFileContents", input.cwd);
158
159 const handle = yield* vcsRegistry.detect({ cwd: input.cwd, requestedKind: "auto" });
160 if (handle?.kind !== "git") {
161 return yield* new VcsUnsupportedOperationError({
162 operation: "ReviewService.getDiffFileContents",
163 kind: handle?.kind ?? "unknown",
164 detail: "Unchanged diff expansion currently requires a Git repository.",
165 });
166 }
167
168 return yield* git.getReviewDiffFileContents(input);
169 });
170
171 return ReviewService.of({
172 getDiffPreview,
173 getDiffFileContents,
174 });
175});
176
177export const layer = Layer.effect(ReviewService, make);