1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { assert, it } from "@effect/vitest";
3import * as Effect from "effect/Effect";
4import * as FileSystem from "effect/FileSystem";
5import * as Layer from "effect/Layer";
6import * as Option from "effect/Option";
7import * as Path from "effect/Path";
8
9import * as ServerConfig from "../config.ts";
10import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
11import * as VcsProcess from "../vcs/VcsProcess.ts";
12import * as GitHubChangeRequestTemplate from "@t3tools/source-control-github/server/gitHubChangeRequestTemplate";
13import { symlinksSupported } from "@t3tools/shared/testing/symlinks";
14
15const SINGLE_TEMPLATE_PATHS = [
16 ".github/pull_request_template.md",
17 ".github/PULL_REQUEST_TEMPLATE.md",
18 "pull_request_template.md",
19 "PULL_REQUEST_TEMPLATE.md",
20 "docs/pull_request_template.md",
21 "docs/PULL_REQUEST_TEMPLATE.md",
22] as const;
23
24const TEMPLATE_DIRECTORIES = [
25 ".github/PULL_REQUEST_TEMPLATE",
26 "PULL_REQUEST_TEMPLATE",
27 "docs/PULL_REQUEST_TEMPLATE",
28] as const;
29
30const layerPrTemplateDetectionTest = GitVcsDriver.layer.pipe(
31 Layer.provide(
32 ServerConfig.layerTest(process.cwd(), {
33 prefix: "t3-pr-template-test-",
34 }),
35 ),
36 Layer.provideMerge(VcsProcess.layer),
37 Layer.provideMerge(NodeServices.layer),
38);
39
40const runGit = (cwd: string, args: ReadonlyArray<string>) =>
41 Effect.gen(function* () {
42 const git = yield* GitVcsDriver.GitVcsDriver;
43 return yield* git.execute({
44 operation: "PrTemplateDetection.test.runGit",
45 cwd,
46 args,
47 });
48 });
49
50const runWithTempDirectory = <A, E, R>(
51 test: (cwd: string) => Effect.Effect<A, E, R | FileSystem.FileSystem | Path.Path>,
52) =>
53 Effect.scoped(
54 Effect.gen(function* () {
55 const fileSystem = yield* FileSystem.FileSystem;
56 const cwd = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-pr-template-" });
57 yield* runGit(cwd, ["init", "--initial-branch=main"]);
58 yield* runGit(cwd, ["config", "user.email", "test@example.com"]);
59 yield* runGit(cwd, ["config", "user.name", "Test User"]);
60 return yield* test(cwd);
61 }),
62 ).pipe(Effect.provide(layerPrTemplateDetectionTest));
63
64const writeTemplate = (cwd: string, relativePath: string, contents: string) =>
65 Effect.gen(function* () {
66 const fileSystem = yield* FileSystem.FileSystem;
67 const path = yield* Path.Path;
68 const templatePath = path.join(cwd, relativePath);
69 yield* fileSystem.makeDirectory(path.dirname(templatePath), { recursive: true });
70 yield* fileSystem.writeFileString(templatePath, contents);
71 return templatePath;
72 });
73
74const commitTemplates = (cwd: string) =>
75 Effect.gen(function* () {
76 yield* runGit(cwd, ["add", "-A"]);
77 yield* runGit(cwd, ["commit", "--allow-empty", "-m", "Add pull request templates"]);
78 });
79
80const detectTemplate = (cwd: string, treeish = "HEAD") =>
81 Effect.gen(function* () {
82 const git = yield* GitVcsDriver.GitVcsDriver;
83 return yield* GitHubChangeRequestTemplate.detect(cwd, treeish, git.execute);
84 });
85
86it.effect.each(SINGLE_TEMPLATE_PATHS)("recognizes $0", (relativePath) =>
87 runWithTempDirectory((cwd) =>
88 Effect.gen(function* () {
89 yield* writeTemplate(cwd, relativePath, `template from ${relativePath}`);
90 yield* commitTemplates(cwd);
91
92 const template = yield* detectTemplate(cwd);
93 assert.strictEqual(Option.getOrUndefined(template), `template from ${relativePath}`);
94 }),
95 ),
96);
97
98it.effect("reads templates from the requested base tree", () =>
99 runWithTempDirectory((cwd) =>
100 Effect.gen(function* () {
101 yield* writeTemplate(cwd, "README.md", "initial\n");
102 yield* commitTemplates(cwd);
103 yield* runGit(cwd, ["branch", "feature"]);
104 yield* writeTemplate(cwd, ".github/pull_request_template.md", "base template");
105 yield* commitTemplates(cwd);
106 yield* runGit(cwd, ["checkout", "feature"]);
107
108 assert.isTrue(Option.isNone(yield* detectTemplate(cwd)));
109 assert.strictEqual(
110 Option.getOrUndefined(yield* detectTemplate(cwd, "main")),
111 "base template",
112 );
113 }),
114 ),
115);
116
117it.effect("uses the first non-empty template in the configured path order", () =>
118 runWithTempDirectory((cwd) =>
119 Effect.gen(function* () {
120 yield* writeTemplate(cwd, ".github/pull_request_template.md", " \n");
121 yield* writeTemplate(cwd, ".github/PULL_REQUEST_TEMPLATE.md", " ## Preferred template \n");
122 yield* writeTemplate(cwd, "pull_request_template.md", "## Later template");
123 yield* commitTemplates(cwd);
124
125 const template = yield* detectTemplate(cwd);
126 assert.strictEqual(Option.getOrUndefined(template), "## Preferred template");
127 }),
128 ),
129);
130
131it.effect.each(TEMPLATE_DIRECTORIES)("recognizes the $0 directory", (relativeDirectory) =>
132 runWithTempDirectory((cwd) =>
133 Effect.gen(function* () {
134 yield* writeTemplate(cwd, `${relativeDirectory}/template.MD`, "directory template");
135 yield* commitTemplates(cwd);
136
137 const template = yield* detectTemplate(cwd);
138 assert.strictEqual(Option.getOrUndefined(template), "directory template");
139 }),
140 ),
141);
142
143it.effect.skipIf(!symlinksSupported)(
144 "skips unusable directory entries and uses the one valid template",
145 () =>
146 runWithTempDirectory((cwd) =>
147 Effect.gen(function* () {
148 const fileSystem = yield* FileSystem.FileSystem;
149 const path = yield* Path.Path;
150 const templateDirectory = path.join(cwd, ".github", "PULL_REQUEST_TEMPLATE");
151 yield* fileSystem.makeDirectory(path.join(templateDirectory, "b-directory.md"), {
152 recursive: true,
153 });
154 yield* fileSystem.writeFileString(path.join(templateDirectory, "a-empty.md"), " \n");
155 yield* fileSystem.symlink(
156 path.join(templateDirectory, "missing.md"),
157 path.join(templateDirectory, "c-broken.md"),
158 );
159 yield* fileSystem.writeFileString(path.join(templateDirectory, "z-valid.md"), "valid");
160 yield* commitTemplates(cwd);
161
162 const template = yield* detectTemplate(cwd);
163 assert.strictEqual(Option.getOrUndefined(template), "valid");
164 }),
165 ),
166);
167
168it.effect("does not guess between multiple directory templates", () =>
169 runWithTempDirectory((cwd) =>
170 Effect.gen(function* () {
171 yield* writeTemplate(cwd, ".github/PULL_REQUEST_TEMPLATE/a.md", "first");
172 yield* writeTemplate(cwd, ".github/PULL_REQUEST_TEMPLATE/b.md", "second");
173 yield* writeTemplate(cwd, "PULL_REQUEST_TEMPLATE/fallback.md", "fallback");
174 yield* commitTemplates(cwd);
175
176 const template = yield* detectTemplate(cwd);
177 assert.isTrue(Option.isNone(template));
178 }),
179 ),
180);
181
182it.effect.skipIf(!symlinksSupported)(
183 "rejects a committed template symlink escaping the repository",
184 () =>
185 runWithTempDirectory((cwd) =>
186 Effect.gen(function* () {
187 const fileSystem = yield* FileSystem.FileSystem;
188 const path = yield* Path.Path;
189 const outsideDirectory = yield* fileSystem.makeTempDirectoryScoped({
190 prefix: "t3-pr-template-outside-",
191 });
192 const outsideTemplate = path.join(outsideDirectory, "secret.md");
193 yield* fileSystem.writeFileString(outsideTemplate, "LOCAL_SECRET_SENTINEL");
194 const escapedTemplatePath = path.join(cwd, ".github", "pull_request_template.md");
195 yield* fileSystem.makeDirectory(path.dirname(escapedTemplatePath), { recursive: true });
196 yield* fileSystem.symlink(outsideTemplate, escapedTemplatePath);
197 yield* writeTemplate(cwd, "pull_request_template.md", "safe template");
198 yield* commitTemplates(cwd);
199
200 const template = yield* detectTemplate(cwd);
201 assert.strictEqual(Option.getOrUndefined(template), "safe template");
202 assert.notInclude(
203 Option.getOrElse(template, () => ""),
204 "LOCAL_SECRET_SENTINEL",
205 );
206 }),
207 ),
208);
209
210it.effect.skipIf(!symlinksSupported)(
211 "reads the committed template when a worktree parent is replaced",
212 () =>
213 runWithTempDirectory((cwd) =>
214 Effect.gen(function* () {
215 const fileSystem = yield* FileSystem.FileSystem;
216 const path = yield* Path.Path;
217 const outsideDirectory = yield* fileSystem.makeTempDirectoryScoped({
218 prefix: "t3-pr-template-outside-",
219 });
220 const templatePath = yield* writeTemplate(
221 cwd,
222 ".github/pull_request_template.md",
223 "committed template",
224 );
225 yield* commitTemplates(cwd);
226 yield* writeTemplate(outsideDirectory, "pull_request_template.md", "LOCAL_SECRET_SENTINEL");
227
228 const templateDirectory = path.dirname(templatePath);
229 yield* fileSystem.rename(templateDirectory, path.join(cwd, ".github-original"));
230 yield* fileSystem.symlink(outsideDirectory, templateDirectory);
231
232 const template = yield* detectTemplate(cwd);
233 assert.strictEqual(Option.getOrUndefined(template), "committed template");
234 assert.notInclude(
235 Option.getOrElse(template, () => ""),
236 "LOCAL_SECRET_SENTINEL",
237 );
238 }),
239 ),
240);
241
242it.effect("bounds template reads and marks truncated content", () =>
243 runWithTempDirectory((cwd) =>
244 Effect.gen(function* () {
245 const prefix = "a".repeat(8_000);
246 yield* writeTemplate(cwd, ".github/pull_request_template.md", `${prefix}SECRET_SENTINEL`);
247 yield* commitTemplates(cwd);
248
249 const template = Option.getOrThrow(yield* detectTemplate(cwd));
250 assert.strictEqual(template, `${prefix}\n\n[truncated]`);
251 assert.lengthOf(template.match(/\[truncated\]/g) ?? [], 1);
252 assert.notInclude(template, "SECRET_SENTINEL");
253 }),
254 ),
255);