authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-10-09 16:22:36-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-09 16:22:36-07:00
log49f849ecce3667abab7ce9e6aaa42dfd2e3c45d7
treeb737bd3a6ae8b6de10d03797422bc5dc8e675abd
parentc85dc642681a944e644cbbbea7257d274822c21a
signature Signed by PGP key B5690EEEBB952194

refactor(source-control): GitManager reads provider resolvers, not host kinds (#17617)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

13 files changed, 615 insertions(+), 532 deletions(-)

apps/server/src/git/GitManager.test.ts+29-6
......@@ -44,6 +44,7 @@ import {
4444} from "@t3tools/contracts";
4545import * as DateTime from "effect/DateTime";
4646import { decodeGitHubPullRequestListJson } from "@t3tools/source-control-github/server/gitHubPullRequests";
47import * as GitHubChangeRequestTemplate from "@t3tools/source-control-github/server/gitHubChangeRequestTemplate";
4748import * as GitLabCli from "@t3tools/source-control-gitlab/server/GitLabCli";
4849import type * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost";
4950import * as TestSourceControlHost from "@t3tools/source-control-testing/TestSourceControlHost";
......@@ -55,6 +56,7 @@ import * as VcsProjectConfig from "../vcs/VcsProjectConfig.ts";
5556import * as VcsStatusBroadcaster from "../vcs/VcsStatusBroadcaster.ts";
5657import * as BackgroundPolicy from "../background/BackgroundPolicy.ts";
5758import * as GitWorkflowService from "./GitWorkflowService.ts";
59import * as ForgejoSourceControlProvider from "@t3tools/source-control-forgejo/server/ForgejoSourceControlProvider";
5860import * as GitLabSourceControlProvider from "@t3tools/source-control-gitlab/server/GitLabSourceControlProvider";
5961import {
6062 ForgejoPullRequestSchema,
......@@ -545,6 +547,13 @@ function createGitHubProviderWithFakeGh(scenario: FakeGhScenario = {}): {
545547 return {
546548 service: {
547549 kind: "github",
550 // The GitHub provider's own lookup rule and template convention, which GitManager reads
551 // instead of the kind.
552 headBranchProbe: ({ headSelectors }) => ({
553 headSelectors: headSelectors.filter((selector) => !selector.includes(":")),
554 limit: 100,
555 }),
556
548557 listChangeRequests: (input) =>
549558 input.state === "open"
550559 ? execute({
......@@ -745,7 +754,18 @@ function makeManager(input?: {
745754 );
746755 const layerSourceControlRegistry = Layer.effect(
747756 SourceControlProviderRegistry.SourceControlProviderRegistry,
748 Effect.succeed(input?.sourceControlProvider ?? fakeGitHubProvider).pipe(
757 Effect.gen(function* () {
758 // GitHub reads its PR template with git, which the fake gh cannot answer; give the fake
759 // the package's real reader over the test repository's git.
760 const git = yield* GitVcsDriver.GitVcsDriver;
761 return (
762 input?.sourceControlProvider ?? {
763 ...fakeGitHubProvider,
764 readChangeRequestTemplate: ({ cwd, treeish }: { cwd: string; treeish: string }) =>
765 GitHubChangeRequestTemplate.detect(cwd, treeish, git.execute),
766 }
767 );
768 }).pipe(
749769 Effect.map((provider) =>
750770 SourceControlProviderRegistry.SourceControlProviderRegistry.of({
751771 resolveLink: (input) => provider.resolveLink?.(input),
......@@ -769,9 +789,12 @@ function makeManager(input?: {
769789 runForThread: () => Effect.succeed({ status: "no-script" as const }),
770790 },
771791 ),
772 layerVcsDriver,
773792 layerServerSettings,
774 ).pipe(Layer.provideMerge(layerSourceControlRegistry), Layer.provideMerge(NodeServices.layer));
793 ).pipe(
794 Layer.provideMerge(layerSourceControlRegistry),
795 Layer.provideMerge(layerVcsDriver),
796 Layer.provideMerge(NodeServices.layer),
797 );
775798 // Built into the test's scope: the manager reads these stores after this returns.
776799 const layerStores = Layer.merge(ProjectionStore.layer, ProjectStore.layer).pipe(
777800 Layer.provideMerge(SqlitePersistence.layerMemory),
......@@ -4232,7 +4255,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => {
42324255 };
42334256 const repository = GitManager.parseRepositoryNameWithOwnerFromRemoteUrl(
42344257 `https://forgejo.example/forgejo/${owner}/project.git`,
4235 "forgejo",
4258 { repositoryNameFromRemoteUrl: ForgejoSourceControlProvider.repositoryNameFromRemoteUrl },
42364259 );
42374260 expect(repository).toBe(`${owner}/project`);
42384261 const context = {
......@@ -4253,13 +4276,13 @@ it.layer(layerGitManagerTest)("GitManager", (it) => {
42534276 expect(
42544277 GitManager.parseRepositoryNameWithOwnerFromRemoteUrl(
42554278 "git@forgejo.example:maria/project.git",
4256 "forgejo",
4279 { repositoryNameFromRemoteUrl: ForgejoSourceControlProvider.repositoryNameFromRemoteUrl },
42574280 ),
42584281 ).toBe("maria/project");
42594282 expect(
42604283 GitManager.parseRepositoryNameWithOwnerFromRemoteUrl(
42614284 "https://gitlab.example/group/maria/project.git",
4262 "gitlab",
4285 {},
42634286 ),
42644287 ).toBe("group/maria/project");
42654288 }),
apps/server/src/git/GitManager.ts+40-52
......@@ -35,7 +35,6 @@ import {
3535 ModelSelection,
3636 type ProjectId,
3737 SourceControlProviderError,
38 type SourceControlProviderKind,
3938 type SourceControlWritingStyleSettings,
4039 type ThreadId,
4140 type ThreadPullRequestKey,
......@@ -81,8 +80,8 @@ import { detachStackFrame } from "./detachStackFrame.ts";
8180import * as ServerSettings from "../serverSettings.ts";
8281import type { GitManagerServiceError } from "@t3tools/contracts";
8382import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
83import * as SourceControlProvider from "@t3tools/source-control-core/server/SourceControlProvider";
8484import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts";
85import { detectPrTemplate } from "../sourceControl/PrTemplateDetection.ts";
8685import type { ChangeRequest } from "@t3tools/contracts";
8786
8887export interface GitActionProgressReporter {
......@@ -314,27 +313,20 @@ function resolvePullRequestWorktreeLocalBranchName(
314313 return `t3code/pr-${pullRequest.number}/${suffix}`;
315314}
316315
316/** The repository's `owner/name` from a remote URL, as `provider` reads its remote paths. */
317317export function parseRepositoryNameWithOwnerFromRemoteUrl(
318318 url: string | null,
319 providerKind?: ChangeRequest["provider"],
319 provider?: Pick<
320 SourceControlProvider.SourceControlProvider["Service"],
321 "repositoryNameFromRemoteUrl"
322 >,
320323): string | null {
321324 const trimmed = url?.trim() ?? "";
322 if (trimmed.length === 0) {
323 return null;
324 }
325
326 const match =
327 /^(?:[^@/\s]+@[^:/\s]+:|(?:ssh|https?|git):\/\/[^/]+\/)((?:[^/\s]+\/)+[^/\s]+?)(?:\.git)?\/?$/iu.exec(
328 trimmed,
329 );
330 const repositoryNameWithOwner = match?.[1]?.trim() ?? "";
331 // Forgejo HTTP paths can include an installation mount; its API always names owner/repo.
332 if (providerKind === "forgejo" && /^https?:\/\//iu.test(trimmed)) {
333 return repositoryNameWithOwner.length > 0
334 ? repositoryNameWithOwner.split("/").slice(-2).join("/")
335 : null;
336 }
337 return repositoryNameWithOwner.length > 0 ? repositoryNameWithOwner : null;
325 if (trimmed.length === 0) return null;
326 return (
327 provider?.repositoryNameFromRemoteUrl?.(trimmed) ??
328 SourceControlProvider.repositoryPathFromRemoteUrl(trimmed)
329 );
338330}
339331
340332function parseRepositoryOwnerLogin(nameWithOwner: string | null): string | null {
......@@ -625,24 +617,18 @@ function parseCustomCommitMessage(raw: string): { subject: string; body: string
625617 };
626618}
627619
628// Without the owner selector, a bare branch name also lists same-named
629// branches on other forks (`main`, `patch-1`), so GitHub probes ask for a full
630// page and let matchesBranchHeadContext pick the right head. gh fetches up to
631// 100 in one request, and GitHub prices a first:100 connection like first:1.
632const GITHUB_HEAD_BRANCH_PROBE_LIMIT = 100;
633
634// `gh pr list --head` filters on the head ref name alone and accepts anything, so an
635// `owner:branch` or `remote:branch` selector silently lists zero pull requests
636// while spending a GraphQL call. Git branch names cannot contain ":", and the
637// bare head branch is always among the selectors, so GitHub probes skip them and
638// leave the owner check to matchesBranchHeadContext.
639function probeableHeadSelectors(
640 providerKind: SourceControlProviderKind,
620/** The selectors and page size `provider` asks about when looking up a branch's change requests. */
621function headBranchProbe(
622 provider: SourceControlProvider.SourceControlProvider["Service"],
641623 headSelectors: ReadonlyArray<string>,
642): ReadonlyArray<string> {
643 return providerKind === "github"
644 ? headSelectors.filter((selector) => !selector.includes(":"))
645 : headSelectors;
624 state: "open" | "all",
625) {
626 return (
627 provider.headBranchProbe?.({ headSelectors, state }) ?? {
628 headSelectors,
629 limit: state === "open" ? 1 : 20,
630 }
631 );
646632}
647633
648634function appendUnique(values: string[], next: string | null | undefined): void {
......@@ -1430,15 +1416,14 @@ export const make = Effect.gen(function* () {
14301416 /^https?:\/\//iu.test(remoteUrl) &&
14311417 (repositoryNameWithOwner?.split("/").length ?? 0) > 2
14321418 ) {
1433 const detected = detectSourceControlProviderFromGitRemoteUrl(remoteUrl);
1434 const kind =
1435 detected?.kind === "unknown"
1436 ? yield* sourceControlProvider(cwd).pipe(
1437 Effect.map((provider) => provider.kind),
1438 Effect.orElseSucceed(() => undefined),
1439 )
1440 : detected?.kind;
1441 repositoryNameWithOwner = parseRepositoryNameWithOwnerFromRemoteUrl(remoteUrl, kind);
1419 const kind = detectSourceControlProviderFromGitRemoteUrl(remoteUrl)?.kind;
1420 const provider =
1421 kind === undefined
1422 ? undefined
1423 : yield* (
1424 kind === "unknown" ? sourceControlProvider(cwd) : sourceControlProviders.get(kind)
1425 ).pipe(Effect.orElseSucceed(() => undefined));
1426 repositoryNameWithOwner = parseRepositoryNameWithOwnerFromRemoteUrl(remoteUrl, provider);
14421427 }
14431428 return {
14441429 remoteUrlKey: remoteUrl ? normalizeGitRemoteUrl(remoteUrl) : null,
......@@ -1701,13 +1686,13 @@ export const make = Effect.gen(function* () {
17011686 >,
17021687 ) {
17031688 const provider = yield* sourceControlProvider(cwd);
1704 const headSelectors = probeableHeadSelectors(provider.kind, headContext.headSelectors);
1705 for (const headSelector of headSelectors) {
1689 const probe = headBranchProbe(provider, headContext.headSelectors, "open");
1690 for (const headSelector of probe.headSelectors) {
17061691 const pullRequests = yield* provider.listChangeRequests({
17071692 cwd,
17081693 headSelector,
17091694 state: "open",
1710 limit: provider.kind === "github" ? GITHUB_HEAD_BRANCH_PROBE_LIMIT : 1,
1695 limit: probe.limit,
17111696 });
17121697 const normalizedPullRequests = pullRequests.map(toPullRequestInfo);
17131698
......@@ -1733,12 +1718,13 @@ export const make = Effect.gen(function* () {
17331718 const parsedByNumber = new Map<number, PullRequestInfo>();
17341719
17351720 const provider = yield* sourceControlProvider(cwd);
1736 for (const headSelector of probeableHeadSelectors(provider.kind, headContext.headSelectors)) {
1721 const probe = headBranchProbe(provider, headContext.headSelectors, "all");
1722 for (const headSelector of probe.headSelectors) {
17371723 const pullRequests = yield* provider.listChangeRequests({
17381724 cwd,
17391725 headSelector,
17401726 state: "all",
1741 limit: provider.kind === "github" ? GITHUB_HEAD_BRANCH_PROBE_LIMIT : 20,
1727 limit: probe.limit,
17421728 });
17431729
17441730 for (const pr of pullRequests.map(toPullRequestInfo)) {
......@@ -2129,8 +2115,10 @@ export const make = Effect.gen(function* () {
21292115 const rangeContext = yield* gitCore.readRangeContext(cwd, baseRangeRef);
21302116 const policy = yield* resolveStylePolicy(cwd, settings);
21312117 const changeRequestTemplate =
2132 settings.style.followChangeRequestTemplates && provider.kind === "github"
2133 ? Option.getOrUndefined(yield* detectPrTemplate(cwd, baseRangeRef, gitCore.execute))
2118 settings.style.followChangeRequestTemplates && provider.readChangeRequestTemplate
2119 ? Option.getOrUndefined(
2120 yield* provider.readChangeRequestTemplate({ cwd, treeish: baseRangeRef }),
2121 )
21342122 : undefined;
21352123
21362124 const generated = yield* textGeneration.generatePrContent({
apps/server/src/sourceControl/PrTemplateDetection.test.ts deleted-255
......@@ -1,255 +0,0 @@
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 { detectPrTemplate } from "./PrTemplateDetection.ts";
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* detectPrTemplate(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);
apps/server/src/sourceControl/PrTemplateDetection.ts deleted-177
......@@ -1,177 +0,0 @@
1import type { GitCommandError } from "@t3tools/contracts";
2import * as Effect from "effect/Effect";
3import * as Option from "effect/Option";
4
5import type * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
6
7const TEMPLATE_MAX_BYTES = 8_000;
8const TREE_LIST_MAX_BYTES = 100_000;
9const TRUNCATION_MARKER = "[truncated]";
10
11const TEMPLATE_PATHS = [
12 ".github/pull_request_template.md",
13 ".github/PULL_REQUEST_TEMPLATE.md",
14 "pull_request_template.md",
15 "PULL_REQUEST_TEMPLATE.md",
16 "docs/pull_request_template.md",
17 "docs/PULL_REQUEST_TEMPLATE.md",
18] as const;
19
20const TEMPLATE_DIRECTORIES = [
21 ".github/PULL_REQUEST_TEMPLATE",
22 "PULL_REQUEST_TEMPLATE",
23 "docs/PULL_REQUEST_TEMPLATE",
24] as const;
25
26const TREE_PATHS = [...TEMPLATE_PATHS, ...TEMPLATE_DIRECTORIES] as const;
27
28type ExecuteGit = GitVcsDriver.GitVcsDriver["Service"]["execute"];
29
30interface TemplateTreeEntry {
31 readonly objectId: string;
32 readonly path: string;
33}
34
35function parseTemplateTreeEntries(output: string): ReadonlyArray<TemplateTreeEntry> {
36 const entries: TemplateTreeEntry[] = [];
37 for (const record of output.split("\0")) {
38 if (record.length === 0) {
39 continue;
40 }
41
42 const separator = record.indexOf("\t");
43 if (separator < 0) {
44 continue;
45 }
46
47 const [mode, type, objectId] = record.slice(0, separator).split(" ");
48 if (
49 type !== "blob" ||
50 (mode !== "100644" && mode !== "100755") ||
51 !objectId ||
52 !/^[0-9a-f]{40,64}$/.test(objectId)
53 ) {
54 continue;
55 }
56
57 entries.push({ objectId, path: record.slice(separator + 1) });
58 }
59 return entries;
60}
61
62function readTemplateBlob(input: {
63 readonly cwd: string;
64 readonly executeGit: ExecuteGit;
65 readonly entry: TemplateTreeEntry;
66}): Effect.Effect<Option.Option<string>, GitCommandError> {
67 return input
68 .executeGit({
69 operation: "PrTemplateDetection.readTemplateBlob",
70 cwd: input.cwd,
71 args: ["cat-file", "blob", input.entry.objectId],
72 maxOutputBytes: TEMPLATE_MAX_BYTES,
73 appendTruncationMarker: true,
74 })
75 .pipe(
76 Effect.map((result) => {
77 const template = result.stdout.trim();
78 if (template.length === 0) {
79 return Option.none();
80 }
81 return Option.some(
82 result.stdoutTruncated && !template.endsWith(TRUNCATION_MARKER)
83 ? `${template}\n\n${TRUNCATION_MARKER}`
84 : template,
85 );
86 }),
87 );
88}
89
90type DirectoryTemplateResult =
91 | { readonly _tag: "None" }
92 | { readonly _tag: "Ambiguous" }
93 | { readonly _tag: "Template"; readonly template: string };
94
95function readTemplateDirectory(input: {
96 readonly cwd: string;
97 readonly executeGit: ExecuteGit;
98 readonly entries: ReadonlyArray<TemplateTreeEntry>;
99 readonly directory: string;
100}): Effect.Effect<DirectoryTemplateResult, GitCommandError> {
101 return Effect.gen(function* () {
102 const prefix = `${input.directory}/`;
103 const candidates = input.entries.filter((entry) => {
104 if (!entry.path.startsWith(prefix)) {
105 return false;
106 }
107 const relativePath = entry.path.slice(prefix.length);
108 return !relativePath.includes("/") && relativePath.toLowerCase().endsWith(".md");
109 });
110
111 const templates: string[] = [];
112 for (const entry of candidates) {
113 const template = yield* readTemplateBlob({ ...input, entry });
114 if (Option.isSome(template)) {
115 templates.push(template.value);
116 if (templates.length > 1) {
117 return { _tag: "Ambiguous" } as const;
118 }
119 }
120 }
121
122 return templates[0]
123 ? ({ _tag: "Template", template: templates[0] } as const)
124 : ({ _tag: "None" } as const);
125 });
126}
127
128export const detectPrTemplate = Effect.fn("detectPrTemplate")(function* (
129 cwd: string,
130 treeish: string,
131 executeGit: ExecuteGit,
132) {
133 return yield* Effect.gen(function* () {
134 // Worktree paths can be replaced between validation and open. Read regular blobs from the
135 // committed base tree so repository-controlled symlinks and path races never reach the host filesystem.
136 const result = yield* executeGit({
137 operation: "PrTemplateDetection.listTemplates",
138 cwd,
139 args: ["ls-tree", "-r", "-z", "--full-tree", treeish, "--", ...TREE_PATHS],
140 maxOutputBytes: TREE_LIST_MAX_BYTES,
141 appendTruncationMarker: true,
142 });
143 if (result.stdoutTruncated) {
144 return Option.none();
145 }
146
147 const entries = parseTemplateTreeEntries(result.stdout);
148 const entriesByPath = new Map(entries.map((entry) => [entry.path, entry]));
149 for (const templatePath of TEMPLATE_PATHS) {
150 const entry = entriesByPath.get(templatePath);
151 if (!entry) {
152 continue;
153 }
154 const template = yield* readTemplateBlob({ cwd, executeGit, entry });
155 if (Option.isSome(template)) {
156 return template;
157 }
158 }
159
160 for (const directory of TEMPLATE_DIRECTORIES) {
161 const directoryTemplate = yield* readTemplateDirectory({
162 cwd,
163 executeGit,
164 entries,
165 directory,
166 });
167 if (directoryTemplate._tag === "Template") {
168 return Option.some(directoryTemplate.template);
169 }
170 if (directoryTemplate._tag === "Ambiguous") {
171 return Option.none();
172 }
173 }
174
175 return Option.none();
176 }).pipe(Effect.orElseSucceed(() => Option.none()));
177});
apps/server/src/sourceControl/SourceControlProviderRegistry.ts+1-2
......@@ -158,8 +158,7 @@ function bindProviderContext(
158158 }
159159
160160 return SourceControlProvider.SourceControlProvider.of({
161 kind: provider.kind,
162 ...(provider.resolveLink ? { resolveLink: provider.resolveLink } : {}),
161 ...provider,
163162 listChangeRequests: (input) =>
164163 provider.listChangeRequests({
165164 ...input,
apps/server/src/sourceControl/gitHubChangeRequestTemplate.test.ts created+255
......@@ -0,0 +1,255 @@
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);
packages/source-control-core/src/server/SourceControlHost.ts+2
......@@ -83,6 +83,8 @@ export class SourceControlHost extends Context.Service<
8383 readonly operation: string;
8484 readonly cwd: string;
8585 readonly args: ReadonlyArray<string>;
86 readonly maxOutputBytes?: number;
87 readonly appendTruncationMarker?: boolean;
8688 }) => Effect.Effect<SourceControlGitOutput, GitCommandError>;
8789 readonly resolveCommit: (input: {
8890 readonly cwd: string;
packages/source-control-core/src/server/SourceControlProvider.ts+39
......@@ -1,5 +1,6 @@
11import * as Context from "effect/Context";
22import * as Effect from "effect/Effect";
3import type * as Option from "effect/Option";
34import type {
45 ChangeRequest,
56 ChangeRequestState,
......@@ -92,10 +93,48 @@ export function sourceControlRefFromInput(input: {
9293 return input.source ?? parseSourceControlOwnerRef(input.headSelector);
9394}
9495
96/** The repository path a remote URL names (`owner/name`, or deeper for nested groups). */
97export function repositoryPathFromRemoteUrl(url: string | null): string | null {
98 const trimmed = url?.trim() ?? "";
99 if (trimmed.length === 0) {
100 return null;
101 }
102
103 const match =
104 /^(?:[^@/\s]+@[^:/\s]+:|(?:ssh|https?|git):\/\/[^/]+\/)((?:[^/\s]+\/)+[^/\s]+?)(?:\.git)?\/?$/iu.exec(
105 trimmed,
106 );
107 const path = match?.[1]?.trim() ?? "";
108 return path.length > 0 ? path : null;
109}
110
95111export class SourceControlProvider extends Context.Service<
96112 SourceControlProvider,
97113 {
98114 readonly kind: SourceControlProviderKind;
115 /**
116 * How to look up a branch's change requests: which head selectors to ask about, and how many
117 * results to read per selector. The caller checks the owner against what comes back, so a
118 * host that cannot search `owner:branch` can drop those selectors without losing anything.
119 * Absent asks about every selector, reading 1 for the open lookup and 20 for any state.
120 */
121 readonly headBranchProbe?: (input: {
122 readonly headSelectors: ReadonlyArray<string>;
123 readonly state: "open" | "all";
124 }) => { readonly headSelectors: ReadonlyArray<string>; readonly limit: number };
125 /**
126 * The repository's change request template at `treeish`, for change request content
127 * generation to follow. Absent means the host has no template convention.
128 */
129 readonly readChangeRequestTemplate?: (input: {
130 readonly cwd: string;
131 readonly treeish: string;
132 }) => Effect.Effect<Option.Option<string>>;
133 /**
134 * The repository's `owner/name` from a remote URL, for a host whose remote paths can carry
135 * more than that (Forgejo's HTTP installation mount). Absent means the whole path.
136 */
137 readonly repositoryNameFromRemoteUrl?: (url: string) => string | null;
99138 /** Optional capability for issue and change-request subjects. */
100139 readonly resolveLink?: ResolveSourceControlLink;
101140 readonly listChangeRequests: (input: {
packages/source-control-forgejo/src/server/ForgejoSourceControlProvider.test.ts+40-40
......@@ -1,13 +1,12 @@
11import { assert, it } from "@effect/vitest";
22import * as Effect from "effect/Effect";
3import type * as Context from "effect/Context";
43import * as FileSystem from "effect/FileSystem";
54import * as Layer from "effect/Layer";
65import * as Option from "effect/Option";
76import * as Schema from "effect/Schema";
87import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
9import * as FetchHttpClient from "effect/http/FetchHttpClient";
108import * as HttpClient from "effect/http/HttpClient";
9import * as HttpClientRequest from "effect/http/HttpClientRequest";
1110import * as HttpClientResponse from "effect/http/HttpClientResponse";
1211import { VcsProcessSpawnError } from "@t3tools/contracts";
1312import { firstNonEmptyLine } from "@t3tools/source-control-core/server/discovery";
......@@ -20,38 +19,45 @@ import * as ForgejoSourceControlProvider from "./ForgejoSourceControlProvider.ts
2019const processOutput = TestSourceControlHost.processOutput;
2120
2221const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown));
22const decodeJson = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown));
23
24/** The JSON body a request sends. Forgejo encodes bodies as UTF-8 text. */
25const requestJson = (request: HttpClientRequest.HttpClientRequest): unknown => {
26 assert.strictEqual(request.body._tag, "Uint8Array");
27 return request.body._tag === "Uint8Array"
28 ? decodeJson(new TextDecoder().decode(request.body.body))
29 : undefined;
30};
2331const encodeJsonEffect = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown));
2432
2533it.effect("submits a Forgejo review without sending its summary in the preliminary GET", () => {
2634 const methods: string[] = [];
27 const fetchReview = async (
28 ...[input, init]: Parameters<Context.Service.Shape<typeof FetchHttpClient.Fetch>>
29 ) => {
30 const request = new Request(input instanceof Request ? input.url : String(input), {
31 ...(init?.method === undefined ? {} : { method: init.method }),
32 ...(init?.headers === undefined ? {} : { headers: init.headers }),
33 ...(init?.body === undefined ? {} : { body: init.body }),
34 });
35 const reviewClient = HttpClient.make((request) => {
3536 methods.push(request.method);
3637 if (request.method === "GET") {
3738 assert.strictEqual(request.url, "https://forgejo.test/api/v1/repos/maria/project/pulls/42");
38 return new Response(
39 encodeJson({
40 number: 42,
41 title: "Review target",
42 body: "",
43 html_url: "https://forgejo.test/maria/project/pulls/42",
44 user: { login: "maria" },
45 state: "open",
46 merged: false,
47 head: { ref: "feature", sha: "head", repo: null },
48 base: { ref: "main", sha: "base", repo: null },
49 created_at: "2026-09-13T00:00:00Z",
50 updated_at: "2026-09-13T00:00:00Z",
51 closed_at: null,
52 merged_at: null,
53 labels: [],
54 }),
39 return Effect.succeed(
40 HttpClientResponse.fromWeb(
41 request,
42 new Response(
43 encodeJson({
44 number: 42,
45 title: "Review target",
46 body: "",
47 html_url: "https://forgejo.test/maria/project/pulls/42",
48 user: { login: "maria" },
49 state: "open",
50 merged: false,
51 head: { ref: "feature", sha: "head", repo: null },
52 base: { ref: "main", sha: "base", repo: null },
53 created_at: "2026-09-13T00:00:00Z",
54 updated_at: "2026-09-13T00:00:00Z",
55 closed_at: null,
56 merged_at: null,
57 labels: [],
58 }),
59 ),
60 ),
5561 );
5662 }
5763 assert.strictEqual(request.method, "POST");
......@@ -59,14 +65,16 @@ it.effect("submits a Forgejo review without sending its summary in the prelimina
5965 request.url,
6066 "https://forgejo.test/api/v1/repos/maria/project/pulls/42/reviews",
6167 );
62 assert.deepStrictEqual(JSON.parse(await request.text()), {
68 assert.deepStrictEqual(requestJson(request), {
6369 event: "COMMENT",
6470 body: "Review summary",
6571 commit_id: "head",
6672 comments: [],
6773 });
68 return new Response('{"id":1}', { status: 200 });
69 };
74 return Effect.succeed(
75 HttpClientResponse.fromWeb(request, new Response('{"id":1}', { status: 200 })),
76 );
77 });
7078 return Effect.gen(function* () {
7179 const cli = yield* ForgejoCli.make;
7280 const provider = yield* ForgejoPullRequestProvider.make.pipe(
......@@ -83,11 +91,7 @@ it.effect("submits a Forgejo review without sending its summary in the prelimina
8391 });
8492 assert.deepStrictEqual(methods, ["GET", "POST"]);
8593 }).pipe(
86 Effect.provideService(
87 FetchHttpClient.Fetch,
88 Object.assign(fetchReview, { preconnect: () => undefined }),
89 ),
90 Effect.provide(FetchHttpClient.layer),
94 Effect.provideService(HttpClient.HttpClient, reviewClient),
9195 Effect.provideService(
9296 FileSystem.FileSystem,
9397 FileSystem.makeNoop({
......@@ -837,11 +841,7 @@ it.effect("prefers fj for HTTP and ported SSH aliases on root servers", () => {
837841 }
838842 assert.strictEqual(request.method, "POST");
839843 assert.strictEqual(request.headers.authorization, "token test-token");
840 assert.strictEqual(request.body._tag, "Uint8Array");
841 if (request.body._tag === "Uint8Array")
842 assert.deepStrictEqual(JSON.parse(new TextDecoder().decode(request.body.body)), {
843 body: "verified through fj",
844 });
844 assert.deepStrictEqual(requestJson(request), { body: "verified through fj" });
845845 return Effect.succeed(
846846 HttpClientResponse.fromWeb(request, new Response('{"id":99}', { status: 201 })),
847847 );
packages/source-control-forgejo/src/server/ForgejoSourceControlProvider.ts+9
......@@ -158,6 +158,14 @@ export const makeDiscovery = Effect.gen(function* () {
158158 } satisfies SourceControlManagedCliDiscoverySpec;
159159});
160160
161/** HTTP paths can include an installation mount; Forgejo's API always names owner/repo. */
162export function repositoryNameFromRemoteUrl(url: string): string | null {
163 const path = SourceControlProvider.repositoryPathFromRemoteUrl(url);
164 return path === null || !/^https?:\/\//iu.test(url.trim())
165 ? path
166 : path.split("/").slice(-2).join("/");
167}
168
161169const RepositorySchema = Schema.Struct({
162170 full_name: Schema.String,
163171 clone_url: Schema.String,
......@@ -228,6 +236,7 @@ export const make = Effect.gen(function* () {
228236 });
229237 return SourceControlProvider.SourceControlProvider.of({
230238 kind: "forgejo",
239 repositoryNameFromRemoteUrl,
231240 listChangeRequests: (input) =>
232241 Effect.gen(function* () {
233242 const repo = yield* cli.resolveRepository(input);
packages/source-control-github/package.json+4
......@@ -15,6 +15,10 @@
1515 "types": "./src/server/gitHubAuthStatus.ts",
1616 "import": "./src/server/gitHubAuthStatus.ts"
1717 },
18 "./server/gitHubChangeRequestTemplate": {
19 "types": "./src/server/gitHubChangeRequestTemplate.ts",
20 "import": "./src/server/gitHubChangeRequestTemplate.ts"
21 },
1822 "./server/gitHubConditionalChecks": {
1923 "types": "./src/server/gitHubConditionalChecks.ts",
2024 "import": "./src/server/gitHubConditionalChecks.ts"
packages/source-control-github/src/server/GitHubSourceControlProvider.ts+13
......@@ -23,6 +23,7 @@ import { decodeJsonResult } from "@t3tools/shared/schemaJson";
2323import { isSshRemoteUrl } from "@t3tools/shared/sourceControl";
2424
2525import * as GitHubApi from "./GitHubApi.ts";
26import * as GitHubChangeRequestTemplate from "./gitHubChangeRequestTemplate.ts";
2627import {
2728 decodeGitHubPullRequestEntries,
2829 type NormalizedGitHubPullRequestRecord,
......@@ -939,6 +940,18 @@ export const make = Effect.gen(function* () {
939940
940941 return SourceControlProvider.SourceControlProvider.of({
941942 kind: "github",
943 // `gh pr list --head` filters on the head ref name alone and accepts anything, so an
944 // `owner:branch` or `remote:branch` selector silently lists zero pull requests while
945 // spending a GraphQL call; the bare branch is always among the selectors. Without the owner,
946 // a bare branch also lists same-named branches on other forks (`main`, `patch-1`), so read a
947 // full page and let the owner check pick the right head. gh fetches up to 100 in one
948 // request, and GitHub prices a first:100 connection like first:1.
949 headBranchProbe: ({ headSelectors }) => ({
950 headSelectors: headSelectors.filter((selector) => !selector.includes(":")),
951 limit: 100,
952 }),
953 readChangeRequestTemplate: ({ cwd, treeish }) =>
954 GitHubChangeRequestTemplate.detect(cwd, treeish, git.execute),
942955 resolveLink: (input) => {
943956 // Automatic enrichment must not send ambient CLI credentials to a host from message text.
944957 if (input.url.host !== "github.com") return undefined;
packages/source-control-github/src/server/gitHubChangeRequestTemplate.ts created+183
......@@ -0,0 +1,183 @@
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});