Open History to see the last commit
1// @effect-diagnostics nodeBuiltinImport:off
2import * as NodeFS from "node:fs";
3import * as NodeOS from "node:os";
4import * as NodePath from "node:path";
5
6import type { GitCommandError } from "@t3tools/contracts";
7
8import { assert, expect, it } from "@effect/vitest";
9import * as Effect from "effect/Effect";
10import * as Layer from "effect/Layer";
11import * as Option from "effect/Option";
12import * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost";
13import * as TestSourceControlHost from "@t3tools/source-control-testing/TestSourceControlHost";
14
15import * as GitLabCli from "./GitLabCli.ts";
16import * as GitLabSourceControlProvider from "./GitLabSourceControlProvider.ts";
17
18it.effect("lists a nested-group fork's merge requests by source branch and names its project", () =>
19 Effect.gen(function* () {
20 const branch = "feature/NestedGroups";
21 const output = JSON.stringify([
22 {
23 iid: 2,
24 title: "Another subgroup's fork",
25 web_url: "https://gitlab.com/Group/Upstream/Repository/-/merge_requests/2",
26 target_branch: "main",
27 source_branch: branch,
28 state: "opened",
29 updated_at: "2026-04-08T15:00:00Z",
30 source_project_id: 102,
31 target_project_id: 100,
32 source_project: { path_with_namespace: "Group/Other/Fork" },
33 },
34 {
35 iid: 1,
36 title: "This subgroup's fork",
37 web_url: "https://gitlab.com/Group/Upstream/Repository/-/merge_requests/1",
38 target_branch: "main",
39 source_branch: branch,
40 state: "opened",
41 updated_at: "2026-04-07T15:00:00Z",
42 source_project_id: 101,
43 target_project_id: 100,
44 source_project: { path_with_namespace: "Group/Subgroup/Fork" },
45 },
46 ]);
47 const calls: SourceControlHost.SourceControlProcessInput[] = [];
48 const provider = yield* GitLabSourceControlProvider.make.pipe(
49 Effect.provide(
50 GitLabCli.layer.pipe(
51 Layer.provideMerge(
52 TestSourceControlHost.layer({
53 process: {
54 run: (input) =>
55 Effect.sync(() => {
56 calls.push(input);
57 return TestSourceControlHost.processOutput(output);
58 }),
59 },
60 }),
61 ),
62 ),
63 ),
64 );
65
66 for (const headSelector of [branch, `Group/Subgroup:${branch}`]) {
67 const changeRequests = yield* provider.listChangeRequests({
68 cwd: "/repo",
69 headSelector,
70 state: "all",
71 limit: 20,
72 });
73 expect(changeRequests).toMatchObject([
74 {
75 number: 2,
76 headRefName: branch,
77 isCrossRepository: true,
78 headRepositoryNameWithOwner: "Group/Other/Fork",
79 },
80 {
81 number: 1,
82 url: "https://gitlab.com/Group/Upstream/Repository/-/merge_requests/1",
83 headRefName: branch,
84 isCrossRepository: true,
85 headRepositoryNameWithOwner: "Group/Subgroup/Fork",
86 },
87 ]);
88 }
89 assert.lengthOf(calls, 2);
90 for (const call of calls) {
91 expect(call.command).toBe("glab");
92 expect(call.args).toEqual([
93 "mr",
94 "list",
95 "--source-branch",
96 branch,
97 "--all",
98 "--per-page",
99 "20",
100 "--output",
101 "json",
102 ]);
103 }
104 }),
105);
106
107const withRepository = <A, E>(
108 test: (input: {
109 readonly git: (...args: string[]) => Effect.Effect<string, GitCommandError>;
110 readonly write: (path: string, contents: string) => Effect.Effect<void>;
111 readonly readTemplate: (treeish?: string) => Effect.Effect<Option.Option<string>>;
112 }) => Effect.Effect<A, E>,
113) =>
114 Effect.gen(function* () {
115 const host = yield* SourceControlHost.SourceControlHost;
116 const cwd = yield* Effect.acquireRelease(
117 Effect.sync(() => NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-gitlab-template-"))),
118 (directory) => Effect.sync(() => NodeFS.rmSync(directory, { recursive: true, force: true })),
119 );
120 const git = (...args: string[]) =>
121 host.git
122 .execute({ operation: "GitLabTemplate.test", cwd, args })
123 .pipe(Effect.map((result) => result.stdout.trim()));
124 const write = (path: string, contents: string) =>
125 Effect.sync(() => {
126 NodeFS.mkdirSync(NodePath.dirname(NodePath.join(cwd, path)), { recursive: true });
127 NodeFS.writeFileSync(NodePath.join(cwd, path), contents);
128 });
129 yield* git("init", "--initial-branch=main");
130 yield* git("config", "user.email", "test@example.com");
131 yield* git("config", "user.name", "Test User");
132 yield* write("README.md", "hello\n");
133 yield* git("add", "README.md");
134 yield* git("commit", "-m", "Initial commit");
135 const provider = yield* GitLabSourceControlProvider.make.pipe(
136 Effect.provide(Layer.mock(GitLabCli.GitLabCli)({})),
137 );
138 return yield* test({
139 git,
140 write,
141 readTemplate: (treeish = "HEAD") => provider.readChangeRequestTemplate!({ cwd, treeish }),
142 });
143 }).pipe(Effect.scoped, Effect.provide(TestSourceControlHost.layerWithRealGit()));
144
145const DEFAULT_TEMPLATE = ".gitlab/merge_request_templates/Default.md";
146
147it.effect("ignores unavailable or non-regular GitLab default templates", () =>
148 withRepository(({ git, write, readTemplate }) =>
149 Effect.gen(function* () {
150 expect(yield* readTemplate()).toEqual(Option.none());
151
152 yield* write(".gitlab/merge_request_templates/Bugfix.md", "Not the default");
153 yield* git("add", ".gitlab");
154 yield* git("commit", "-m", "Add a selectable template");
155 expect(yield* readTemplate()).toEqual(Option.none());
156
157 yield* write(DEFAULT_TEMPLATE, " \n\n");
158 yield* git("add", ".gitlab");
159 yield* git("commit", "-m", "Add a blank default");
160 expect(yield* readTemplate()).toEqual(Option.none());
161
162 // Index modes exercise committed symlinks and submodules even on hosts without symlinks.
163 const blob = yield* git("rev-parse", "HEAD:README.md");
164 yield* git("update-index", "--cacheinfo", "120000", blob, DEFAULT_TEMPLATE);
165 yield* git("commit", "-m", "Replace default with a symlink");
166 expect(yield* readTemplate()).toEqual(Option.none());
167
168 const commit = yield* git("rev-parse", "HEAD");
169 yield* git("update-index", "--cacheinfo", "160000", commit, DEFAULT_TEMPLATE);
170 yield* git("commit", "-m", "Replace default with a submodule");
171 expect(yield* readTemplate()).toEqual(Option.none());
172 expect(yield* readTemplate("missing-base")).toEqual(Option.none());
173 }),
174 ),
175);
176
177it.effect("bounds GitLab template content and marks truncation", () =>
178 withRepository(({ git, write, readTemplate }) =>
179 Effect.gen(function* () {
180 const prefix = "a".repeat(8_000);
181 yield* write(DEFAULT_TEMPLATE, prefix + "EXCLUDED_SUFFIX");
182 yield* git("add", ".gitlab");
183 yield* git("commit", "-m", "Add a large template");
184
185 expect(yield* readTemplate()).toEqual(Option.some(prefix + "\n\n[truncated]"));
186 }),
187 ),
188);