1/**
2 * GitHub's pull request template convention: the first single template found at a known path,
3 * or the only Markdown file in a template directory, read from the committed base tree.
4 *
5 * @module source-control-github/server/gitHubChangeRequestTemplate
6 */
7import type { GitCommandError } from "@t3tools/contracts";
8import * as Effect from "effect/Effect";
9import * as Option from "effect/Option";
10
11import type * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost";
12
13const TEMPLATE_MAX_BYTES = 8_000;
14const TREE_LIST_MAX_BYTES = 100_000;
15const TRUNCATION_MARKER = "[truncated]";
16
17const TEMPLATE_PATHS = [
18 ".github/pull_request_template.md",
19 ".github/PULL_REQUEST_TEMPLATE.md",
20 "pull_request_template.md",
21 "PULL_REQUEST_TEMPLATE.md",
22 "docs/pull_request_template.md",
23 "docs/PULL_REQUEST_TEMPLATE.md",
24] as const;
25
26const TEMPLATE_DIRECTORIES = [
27 ".github/PULL_REQUEST_TEMPLATE",
28 "PULL_REQUEST_TEMPLATE",
29 "docs/PULL_REQUEST_TEMPLATE",
30] as const;
31
32const TREE_PATHS = [...TEMPLATE_PATHS, ...TEMPLATE_DIRECTORIES] as const;
33
34type ExecuteGit = SourceControlHost.SourceControlHost["Service"]["git"]["execute"];
35
36interface TemplateTreeEntry {
37 readonly objectId: string;
38 readonly path: string;
39}
40
41function parseTemplateTreeEntries(output: string): ReadonlyArray<TemplateTreeEntry> {
42 const entries: TemplateTreeEntry[] = [];
43 for (const record of output.split("\0")) {
44 if (record.length === 0) {
45 continue;
46 }
47
48 const separator = record.indexOf("\t");
49 if (separator < 0) {
50 continue;
51 }
52
53 const [mode, type, objectId] = record.slice(0, separator).split(" ");
54 if (
55 type !== "blob" ||
56 (mode !== "100644" && mode !== "100755") ||
57 !objectId ||
58 !/^[0-9a-f]{40,64}$/.test(objectId)
59 ) {
60 continue;
61 }
62
63 entries.push({ objectId, path: record.slice(separator + 1) });
64 }
65 return entries;
66}
67
68function readTemplateBlob(input: {
69 readonly cwd: string;
70 readonly executeGit: ExecuteGit;
71 readonly entry: TemplateTreeEntry;
72}): Effect.Effect<Option.Option<string>, GitCommandError> {
73 return input
74 .executeGit({
75 operation: "gitHubChangeRequestTemplate.readTemplateBlob",
76 cwd: input.cwd,
77 args: ["cat-file", "blob", input.entry.objectId],
78 maxOutputBytes: TEMPLATE_MAX_BYTES,
79 appendTruncationMarker: true,
80 })
81 .pipe(
82 Effect.map((result) => {
83 const template = result.stdout.trim();
84 if (template.length === 0) {
85 return Option.none();
86 }
87 return Option.some(
88 result.stdoutTruncated && !template.endsWith(TRUNCATION_MARKER)
89 ? `${template}\n\n${TRUNCATION_MARKER}`
90 : template,
91 );
92 }),
93 );
94}
95
96type DirectoryTemplateResult =
97 | { readonly _tag: "None" }
98 | { readonly _tag: "Ambiguous" }
99 | { readonly _tag: "Template"; readonly template: string };
100
101function readTemplateDirectory(input: {
102 readonly cwd: string;
103 readonly executeGit: ExecuteGit;
104 readonly entries: ReadonlyArray<TemplateTreeEntry>;
105 readonly directory: string;
106}): Effect.Effect<DirectoryTemplateResult, GitCommandError> {
107 return Effect.gen(function* () {
108 const prefix = `${input.directory}/`;
109 const candidates = input.entries.filter((entry) => {
110 if (!entry.path.startsWith(prefix)) {
111 return false;
112 }
113 const relativePath = entry.path.slice(prefix.length);
114 return !relativePath.includes("/") && relativePath.toLowerCase().endsWith(".md");
115 });
116
117 const templates: string[] = [];
118 for (const entry of candidates) {
119 const template = yield* readTemplateBlob({ ...input, entry });
120 if (Option.isSome(template)) {
121 templates.push(template.value);
122 if (templates.length > 1) {
123 return { _tag: "Ambiguous" } as const;
124 }
125 }
126 }
127
128 return templates[0]
129 ? ({ _tag: "Template", template: templates[0] } as const)
130 : ({ _tag: "None" } as const);
131 });
132}
133
134export const detect = Effect.fn("gitHubChangeRequestTemplate.detect")(function* (
135 cwd: string,
136 treeish: string,
137 executeGit: ExecuteGit,
138) {
139 return yield* Effect.gen(function* () {
140 // Worktree paths can be replaced between validation and open. Read regular blobs from the
141 // committed base tree so repository-controlled symlinks and path races never reach the host filesystem.
142 const result = yield* executeGit({
143 operation: "gitHubChangeRequestTemplate.listTemplates",
144 cwd,
145 args: ["ls-tree", "-r", "-z", "--full-tree", treeish, "--", ...TREE_PATHS],
146 maxOutputBytes: TREE_LIST_MAX_BYTES,
147 appendTruncationMarker: true,
148 });
149 if (result.stdoutTruncated) {
150 return Option.none();
151 }
152
153 const entries = parseTemplateTreeEntries(result.stdout);
154 const entriesByPath = new Map(entries.map((entry) => [entry.path, entry]));
155 for (const templatePath of TEMPLATE_PATHS) {
156 const entry = entriesByPath.get(templatePath);
157 if (!entry) {
158 continue;
159 }
160 const template = yield* readTemplateBlob({ cwd, executeGit, entry });
161 if (Option.isSome(template)) {
162 return template;
163 }
164 }
165
166 for (const directory of TEMPLATE_DIRECTORIES) {
167 const directoryTemplate = yield* readTemplateDirectory({
168 cwd,
169 executeGit,
170 entries,
171 directory,
172 });
173 if (directoryTemplate._tag === "Template") {
174 return Option.some(directoryTemplate.template);
175 }
176 if (directoryTemplate._tag === "Ambiguous") {
177 return Option.none();
178 }
179 }
180
181 return Option.none();
182 }).pipe(Effect.orElseSucceed(() => Option.none()));
183});