@goodfellow-cms/gitlab 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,642 @@
1
+ import { decodeBase64 } from "@goodfellow-cms/core";
2
+ import { FakeRepo, fakeFileBytes } from "@goodfellow-cms/core/testing";
3
+ //#region src/testing.ts
4
+ /**
5
+ * A fake of the parts of GitLab's API that `@goodfellow-cms/gitlab` uses, backed by
6
+ * an in-memory repository, including OAuth with PKCE. For tests only.
7
+ */
8
+ function json(body, status = 200) {
9
+ return new Response(JSON.stringify(body), {
10
+ status,
11
+ headers: { "content-type": "application/json" }
12
+ });
13
+ }
14
+ async function sha256Base64Url(text) {
15
+ const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text)));
16
+ let binary = "";
17
+ for (const byte of digest) binary += String.fromCharCode(byte);
18
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
19
+ }
20
+ function fakeGitLab(options) {
21
+ const webUrl = options.url ?? "https://gitlab.com";
22
+ const repo = new FakeRepo(options.files);
23
+ const tokens = new Map(Object.entries(options.tokens ?? { "test-token": {
24
+ username: "editor",
25
+ name: "Test Editor"
26
+ } }));
27
+ let nextUserId = 1;
28
+ /** Every GitLab account, by username, with its id. */
29
+ const accounts = /* @__PURE__ */ new Map();
30
+ for (const name of [...[...tokens.values(), ...options.oauth ? [options.oauth.user] : []].map((user) => user.username), ...options.accounts ?? []]) if (!accounts.has(name)) accounts.set(name, nextUserId++);
31
+ const members = /* @__PURE__ */ new Map();
32
+ for (const user of [...tokens.values(), ...options.oauth ? [options.oauth.user] : []]) {
33
+ const id = accounts.get(user.username) ?? 0;
34
+ members.set(id, {
35
+ id,
36
+ username: user.username,
37
+ name: user.name,
38
+ accessLevel: user.accessLevel ?? 40
39
+ });
40
+ }
41
+ const invitations = /* @__PURE__ */ new Map();
42
+ /** Protected branches and who may push to them: GitLab protects the default branch for Maintainers. */
43
+ const protectedBranches = /* @__PURE__ */ new Map([[repo.defaultBranch, {
44
+ push: 40,
45
+ merge: 40,
46
+ forcePush: false
47
+ }]]);
48
+ /** The project's members and invitations, and its protected branches. */
49
+ async function handleMembers(request, url, rest, accessLevel) {
50
+ const forbidden = () => json({ message: "403 Forbidden" }, 403);
51
+ const list = (items) => {
52
+ const perPage = Number(url.searchParams.get("per_page") ?? 20);
53
+ const page = Number(url.searchParams.get("page") ?? 1);
54
+ return json(items.slice((page - 1) * perPage, page * perPage));
55
+ };
56
+ const shown = (member) => ({
57
+ id: member.id,
58
+ username: member.username,
59
+ name: member.name ?? member.username,
60
+ avatar_url: `https://avatars.example/${member.username}`,
61
+ access_level: member.accessLevel
62
+ });
63
+ if (rest === "/members/all" && request.method === "GET") return list([...members.values()].map(shown));
64
+ if (rest === "/members" && request.method === "GET") return list([...members.values()].filter((member) => !member.inherited).map(shown));
65
+ if (rest === "/members" && request.method === "POST") {
66
+ if (accessLevel < 40) return forbidden();
67
+ const body = await request.json();
68
+ const username = [...accounts].find(([, id]) => id === body.user_id)?.[0];
69
+ if (!username) return json({ message: "404 User Not Found" }, 404);
70
+ if (members.has(body.user_id)) return json({ message: "Member already exists" }, 409);
71
+ members.set(body.user_id, {
72
+ id: body.user_id,
73
+ username,
74
+ accessLevel: body.access_level
75
+ });
76
+ return json(shown(members.get(body.user_id)), 201);
77
+ }
78
+ const member = rest.match(/^\/members\/(\d+)$/);
79
+ if (member) {
80
+ if (accessLevel < 40) return forbidden();
81
+ const found = members.get(Number(member[1]));
82
+ if (!found || found.inherited) return json({ message: "404 Member Not Found" }, 404);
83
+ if (request.method === "DELETE") {
84
+ members.delete(found.id);
85
+ return new Response(null, { status: 204 });
86
+ }
87
+ if (request.method === "PUT") {
88
+ found.accessLevel = (await request.json()).access_level;
89
+ return json(shown(found));
90
+ }
91
+ }
92
+ if (rest === "/invitations") {
93
+ if (accessLevel < 40) return forbidden();
94
+ if (request.method === "GET") return list([...invitations].map(([email, level]) => ({
95
+ invite_email: email,
96
+ access_level: level
97
+ })));
98
+ if (request.method === "POST") {
99
+ const body = await request.json();
100
+ if (invitations.has(body.email)) return json({
101
+ status: "error",
102
+ message: { [body.email]: "Invite email has already been taken" }
103
+ }, 201);
104
+ invitations.set(body.email, body.access_level);
105
+ return json({ status: "success" }, 201);
106
+ }
107
+ }
108
+ const invitation = rest.match(/^\/invitations\/([^/]+)$/);
109
+ if (invitation) {
110
+ if (accessLevel < 40) return forbidden();
111
+ const email = decodeURIComponent(invitation[1] ?? "");
112
+ if (!invitations.has(email)) return json({ message: "404 Not Found" }, 404);
113
+ if (request.method === "DELETE") {
114
+ invitations.delete(email);
115
+ return new Response(null, { status: 204 });
116
+ }
117
+ if (request.method === "PUT") {
118
+ invitations.set(email, (await request.json()).access_level);
119
+ return json({ invite_email: email });
120
+ }
121
+ }
122
+ const protection = rest.match(/^\/protected_branches\/([^/]+)$/);
123
+ if (protection) {
124
+ const name = decodeURIComponent(protection[1] ?? "");
125
+ const found = protectedBranches.get(name);
126
+ if (!found) return json({ message: "404 Not found" }, 404);
127
+ if (request.method === "GET") return json({
128
+ name,
129
+ push_access_levels: [{ access_level: found.push }],
130
+ merge_access_levels: [{ access_level: found.merge }],
131
+ allow_force_push: found.forcePush
132
+ });
133
+ if (request.method === "DELETE") {
134
+ if (accessLevel < 40) return forbidden();
135
+ protectedBranches.delete(name);
136
+ return new Response(null, { status: 204 });
137
+ }
138
+ }
139
+ if (rest === "/protected_branches" && request.method === "POST") {
140
+ if (accessLevel < 40) return forbidden();
141
+ const body = await request.json();
142
+ if (protectedBranches.has(body.name)) return json({ message: "Protected branch already exists" }, 409);
143
+ protectedBranches.set(body.name, {
144
+ push: body.push_access_level ?? 40,
145
+ merge: body.merge_access_level ?? 40,
146
+ forcePush: body.allow_force_push ?? false
147
+ });
148
+ return json({ name: body.name }, 201);
149
+ }
150
+ }
151
+ const codes = /* @__PURE__ */ new Map();
152
+ const refreshTokens = /* @__PURE__ */ new Map();
153
+ const pipelines = /* @__PURE__ */ new Map();
154
+ /** The project's settings for updates: whether job tokens may push, and its pipeline schedules. */
155
+ const updateSettings = {
156
+ jobTokenPush: false,
157
+ schedules: []
158
+ };
159
+ /** Pipelines the update ran in: scheduled, or started through the API, with the pages job's log. */
160
+ const updateRuns = [];
161
+ /** Simulates a scheduled (or API) pipeline whose pages job logged `trace`. */
162
+ function addUpdateRun(run) {
163
+ const added = {
164
+ id: 1e3 + updateRuns.length,
165
+ source: "schedule",
166
+ status: "success",
167
+ trace: "",
168
+ ...run
169
+ };
170
+ updateRuns.push(added);
171
+ return added;
172
+ }
173
+ /** Failed jobs, by commit, which tests add with `failBuild()`. */
174
+ const failures = /* @__PURE__ */ new Map();
175
+ /**
176
+ * Simulates the pipeline for a commit failing: its `pages` job (or another,
177
+ * such as `pages:deploy`) fails for a GitLab failure reason, with a log.
178
+ */
179
+ function failBuild(sha, failure = {}) {
180
+ if (!pipelines.has(sha)) pipelines.set(sha, { checks: 0 });
181
+ failures.set(sha, {
182
+ job: failure.job ?? "pages",
183
+ failureReason: failure.failureReason ?? "script_failure",
184
+ trace: failure.trace ?? ""
185
+ });
186
+ }
187
+ const pipelineId = (sha) => [...pipelines.keys()].indexOf(sha) + 1;
188
+ const requests = [];
189
+ let counter = 0;
190
+ const created = /* @__PURE__ */ new Map();
191
+ const pages = options.pages ? {
192
+ uniqueDomain: options.pages.uniqueDomain ?? true,
193
+ forceHttps: true,
194
+ primaryDomain: null,
195
+ domains: /* @__PURE__ */ new Map(),
196
+ dnsReady: /* @__PURE__ */ new Set()
197
+ } : void 0;
198
+ function pagesUrl() {
199
+ const [namespace = "", ...rest] = options.project.split("/");
200
+ return pages?.uniqueDomain ? `https://${rest.join("-")}-1a2b3c.gitlab.io` : `https://${namespace}.gitlab.io/${rest.join("/")}`;
201
+ }
202
+ /** GitLab Pages' settings and custom domains, for the site's own project. */
203
+ async function handlePages(request, rest, accessLevel) {
204
+ if (!rest.startsWith("/pages")) return void 0;
205
+ if (!pages) return json({ message: "404 Not Found" }, 404);
206
+ if (accessLevel < 40) return json({ message: "403 Forbidden" }, 403);
207
+ const method = request.method;
208
+ const domainJson = (name, domain) => ({
209
+ domain: name,
210
+ url: `https://${name}`,
211
+ verified: domain.verified,
212
+ verification_code: domain.verificationCode,
213
+ auto_ssl_enabled: true,
214
+ certificate: domain.certificate ? {
215
+ subject: `/CN=${name}`,
216
+ expired: false
217
+ } : null
218
+ });
219
+ if (rest === "/pages" && method === "GET") return json({
220
+ url: pagesUrl(),
221
+ is_unique_domain_enabled: pages.uniqueDomain,
222
+ force_https: pages.forceHttps,
223
+ primary_domain: pages.primaryDomain
224
+ });
225
+ if (rest === "/pages" && method === "PATCH") {
226
+ const body = await request.json();
227
+ if (body.pages_primary_domain !== void 0 && !pages.domains.has(body.pages_primary_domain)) return json({ message: "Primary domain must be one of the project's domains" }, 400);
228
+ if (body.pages_unique_domain_enabled !== void 0) pages.uniqueDomain = body.pages_unique_domain_enabled;
229
+ if (body.pages_https_only !== void 0) pages.forceHttps = body.pages_https_only;
230
+ if (body.pages_primary_domain !== void 0) pages.primaryDomain = body.pages_primary_domain;
231
+ return json({
232
+ url: pagesUrl(),
233
+ is_unique_domain_enabled: pages.uniqueDomain,
234
+ force_https: pages.forceHttps
235
+ });
236
+ }
237
+ if (rest === "/pages/domains" && method === "GET") return json([...pages.domains].map(([name, domain]) => domainJson(name, domain)));
238
+ if (rest === "/pages/domains" && method === "POST") {
239
+ const body = await request.json();
240
+ if (pages.domains.has(body.domain)) return json({ message: { domain: ["has already been taken"] } }, 400);
241
+ if (!/^[a-z0-9.-]+\.[a-z]+$/.test(body.domain)) return json({ message: { domain: ["is invalid"] } }, 400);
242
+ counter += 1;
243
+ const domain = {
244
+ verified: false,
245
+ verificationCode: `code-${counter}`,
246
+ certificate: false
247
+ };
248
+ pages.domains.set(body.domain, domain);
249
+ return json(domainJson(body.domain, domain), 201);
250
+ }
251
+ const one = rest.match(/^\/pages\/domains\/([^/]+)(\/verify)?$/);
252
+ const name = decodeURIComponent(one?.[1] ?? "");
253
+ const domain = pages.domains.get(name);
254
+ if (!one || !domain) return json({ message: "404 Not Found" }, 404);
255
+ if (one[2] && method === "PUT") {
256
+ if (!pages.dnsReady.has(name)) return json({ message: "Failed to verify domain ownership" }, 400);
257
+ domain.verified = true;
258
+ return json(domainJson(name, domain));
259
+ }
260
+ if (method === "GET") return json(domainJson(name, domain));
261
+ if (method === "DELETE") {
262
+ pages.domains.delete(name);
263
+ if (pages.primaryDomain === name) pages.primaryDomain = null;
264
+ return new Response(null, { status: 204 });
265
+ }
266
+ return json({ message: "404 Not Found" }, 404);
267
+ }
268
+ const groups = (options.groups ?? []).map((group, index) => ({
269
+ id: 100 + index,
270
+ path: group
271
+ }));
272
+ /** Creating projects, and committing to the projects created, as the setup page does. */
273
+ async function handleCreated(request, path, user) {
274
+ if (path === "/namespaces") return json([{
275
+ id: 1,
276
+ kind: "user",
277
+ full_path: user.username,
278
+ name: user.name ?? user.username
279
+ }, ...groups.map((group) => ({
280
+ id: group.id,
281
+ kind: "group",
282
+ full_path: group.path,
283
+ name: group.path
284
+ }))]);
285
+ if (path === "/projects" && request.method === "POST") {
286
+ const body = await request.json();
287
+ const namespace = body.namespace_id === void 0 || body.namespace_id === 1 ? user.username : groups.find((group) => group.id === body.namespace_id)?.path;
288
+ if (!namespace) return json({ message: "404 Namespace Not Found" }, 404);
289
+ if (!/^[\w.-]+$/.test(body.path)) return json({ message: { path: ["is invalid"] } }, 400);
290
+ const fullPath = `${namespace}/${body.path}`;
291
+ if (created.has(fullPath) || fullPath === options.project) return json({ message: { name: ["has already been taken"] } }, 400);
292
+ counter += 1;
293
+ const project = {
294
+ id: 1e3 + counter,
295
+ path: fullPath,
296
+ visibility: body.visibility,
297
+ description: body.description,
298
+ pagesAccessLevel: body.pages_access_level,
299
+ repo: new FakeRepo({})
300
+ };
301
+ created.set(fullPath, project);
302
+ return json({
303
+ id: project.id,
304
+ path_with_namespace: fullPath,
305
+ web_url: `${webUrl}/${fullPath}`
306
+ }, 201);
307
+ }
308
+ const match = path.match(/^\/projects\/([^/]+)(\/.*)?$/);
309
+ const key = decodeURIComponent(match?.[1] ?? "");
310
+ const project = created.get(key) ?? [...created.values()].find((candidate) => String(candidate.id) === key);
311
+ if (!match || !project) return void 0;
312
+ const rest = match[2] ?? "";
313
+ if (rest === "") return json({
314
+ id: project.id,
315
+ path_with_namespace: project.path,
316
+ default_branch: "main"
317
+ });
318
+ if (rest === "/repository/commits" && request.method === "POST") {
319
+ const body = await request.json();
320
+ return json({
321
+ id: project.repo.commit(body.actions.map((action) => action.encoding === "base64" ? {
322
+ path: action.file_path,
323
+ bytes: decodeBase64(action.content ?? "")
324
+ } : {
325
+ path: action.file_path,
326
+ content: action.content ?? ""
327
+ }), body.commit_message, { branch: body.branch }),
328
+ message: body.commit_message
329
+ }, 201);
330
+ }
331
+ return json({ message: "404 Not Found" }, 404);
332
+ }
333
+ function commit(changes, message, author) {
334
+ const sha = repo.commit(changes, message, { author });
335
+ if (options.deployAfterChecks !== void 0) pipelines.set(sha, { checks: 0 });
336
+ return sha;
337
+ }
338
+ function issueTokens(user) {
339
+ counter += 1;
340
+ const access = `oauth-access-${counter}`;
341
+ const refresh = `oauth-refresh-${counter}`;
342
+ tokens.set(access, user);
343
+ refreshTokens.set(refresh, user);
344
+ return {
345
+ access_token: access,
346
+ refresh_token: refresh,
347
+ expires_in: options.oauth?.expiresIn ?? 7200,
348
+ token_type: "Bearer"
349
+ };
350
+ }
351
+ /**
352
+ * What GitLab's sign-in page does once the person approves: returns the
353
+ * address the browser is sent back to, with a one-time code.
354
+ */
355
+ function authorize(authorizeUrl) {
356
+ const params = new URL(authorizeUrl).searchParams;
357
+ if (params.get("client_id") !== options.oauth?.clientId) throw new Error("Unknown OAuth application");
358
+ if (params.get("code_challenge_method") !== "S256") throw new Error("PKCE with S256 is required");
359
+ counter += 1;
360
+ const code = `code-${counter}`;
361
+ codes.set(code, {
362
+ challenge: params.get("code_challenge") ?? "",
363
+ redirectUri: params.get("redirect_uri") ?? ""
364
+ });
365
+ const back = new URL(params.get("redirect_uri") ?? "");
366
+ back.searchParams.set("code", code);
367
+ back.searchParams.set("state", params.get("state") ?? "");
368
+ return back.toString();
369
+ }
370
+ async function handle(request) {
371
+ requests.push(request.clone());
372
+ const url = new URL(request.url);
373
+ if (url.pathname === "/oauth/token" && request.method === "POST") {
374
+ const body = new URLSearchParams(await request.text());
375
+ if (body.get("client_id") !== options.oauth?.clientId || !options.oauth) return json({ error: "invalid_client" }, 401);
376
+ if (body.get("grant_type") === "authorization_code") {
377
+ const issued = codes.get(body.get("code") ?? "");
378
+ codes.delete(body.get("code") ?? "");
379
+ if (!issued || issued.redirectUri !== body.get("redirect_uri")) return json({ error: "invalid_grant" }, 400);
380
+ if (await sha256Base64Url(body.get("code_verifier") ?? "") !== issued.challenge) return json({
381
+ error: "invalid_grant",
382
+ error_description: "PKCE verification failed"
383
+ }, 400);
384
+ return json(issueTokens(options.oauth.user));
385
+ }
386
+ if (body.get("grant_type") === "refresh_token") {
387
+ const user = refreshTokens.get(body.get("refresh_token") ?? "");
388
+ refreshTokens.delete(body.get("refresh_token") ?? "");
389
+ return user ? json(issueTokens(user)) : json({ error: "invalid_grant" }, 400);
390
+ }
391
+ return json({ error: "unsupported_grant_type" }, 400);
392
+ }
393
+ if (!url.pathname.startsWith("/api/v4/")) return json({ message: "404 Not Found" }, 404);
394
+ const user = tokens.get(request.headers.get("authorization")?.replace(/^Bearer /, "") ?? "");
395
+ if (!user) return json({ message: "401 Unauthorized" }, 401);
396
+ const path = url.pathname.slice(7);
397
+ if (path === "/user") return json({
398
+ username: user.username,
399
+ name: user.name,
400
+ avatar_url: `https://avatars.example/${user.username}`
401
+ });
402
+ const setup = await handleCreated(request, path, user);
403
+ if (setup) return setup;
404
+ if (path === "/users") {
405
+ const name = url.searchParams.get("username") ?? "";
406
+ const id = [...accounts].find(([username]) => username.toLowerCase() === name.toLowerCase())?.[1];
407
+ return json(id === void 0 ? [] : [{
408
+ id,
409
+ username: name
410
+ }]);
411
+ }
412
+ const projectMatch = path.match(/^\/projects\/([^/]+)(\/.*)?$/);
413
+ if (!projectMatch || decodeURIComponent(projectMatch[1] ?? "") !== options.project) return json({ message: "404 Project Not Found" }, 404);
414
+ const rest = projectMatch[2] ?? "";
415
+ const accessLevel = members.get(accounts.get(user.username) ?? 0)?.accessLevel ?? 0;
416
+ const pagesResponse = await handlePages(request, rest, accessLevel);
417
+ if (pagesResponse) return pagesResponse;
418
+ const membersResponse = await handleMembers(request, url, rest, accessLevel);
419
+ if (membersResponse) return membersResponse;
420
+ if (rest === "" && request.method !== "PUT") return json({
421
+ default_branch: repo.defaultBranch,
422
+ permissions: {
423
+ project_access: { access_level: accessLevel },
424
+ group_access: null
425
+ },
426
+ ci_push_repository_for_job_token_allowed: updateSettings.jobTokenPush
427
+ });
428
+ const branch = rest.match(/^\/repository\/branches\/(.+)$/);
429
+ if (branch) {
430
+ const sha = repo.branches.get(decodeURIComponent(branch[1] ?? ""));
431
+ return sha ? json({ commit: { id: sha } }) : json({ message: "404 Branch Not Found" }, 404);
432
+ }
433
+ if (rest === "/repository/tree") {
434
+ const ref = url.searchParams.get("ref") ?? repo.defaultBranch;
435
+ const dir = url.searchParams.get("path") ?? "";
436
+ const perPage = Number(url.searchParams.get("per_page") ?? 20);
437
+ const page = Number(url.searchParams.get("page") ?? 1);
438
+ const entries = [...repo.files(ref)].filter(([file]) => file.startsWith(`${dir}/`)).map(([file, content]) => ({
439
+ id: repo.blobSha(content),
440
+ type: "blob",
441
+ path: file,
442
+ name: file.split("/").at(-1)
443
+ }));
444
+ if (entries.length === 0) return json({ message: "404 Tree Not Found" }, 404);
445
+ return json(entries.slice((page - 1) * perPage, page * perPage));
446
+ }
447
+ const blob = rest.match(/^\/repository\/blobs\/([0-9a-f]{40})\/raw$/);
448
+ if (blob) {
449
+ const content = repo.blobs.get(blob[1] ?? "");
450
+ return content === void 0 ? json({ message: "404 Blob Not Found" }, 404) : new Response(fakeFileBytes(content));
451
+ }
452
+ const raw = rest.match(/^\/repository\/files\/([^/]+)\/raw$/);
453
+ if (raw) {
454
+ const ref = url.searchParams.get("ref") ?? repo.defaultBranch;
455
+ if (!repo.commitAt(repo.branches.get(ref) ?? ref)) return json({ message: "404 Commit Not Found" }, 404);
456
+ const content = repo.files(ref).get(decodeURIComponent(raw[1] ?? ""));
457
+ return content === void 0 ? json({ message: "404 File Not Found" }, 404) : new Response(fakeFileBytes(content));
458
+ }
459
+ const file = rest.match(/^\/repository\/files\/([^/]+)$/);
460
+ if (file) {
461
+ const filePath = decodeURIComponent(file[1] ?? "");
462
+ const ref = url.searchParams.get("ref") ?? repo.defaultBranch;
463
+ const lastCommit = repo.files(ref).has(filePath) ? repo.lastCommitFor(filePath, ref) : void 0;
464
+ return lastCommit ? json({
465
+ file_path: filePath,
466
+ last_commit_id: lastCommit
467
+ }) : json({ message: "404 File Not Found" }, 404);
468
+ }
469
+ if (rest === "/repository/commits" && request.method === "POST") {
470
+ if (accessLevel < 30) return json({ message: "403 Forbidden" }, 403);
471
+ const pushTo = await request.clone().json();
472
+ if (accessLevel < (protectedBranches.get(pushTo.branch)?.push ?? 0)) return json({ message: "You are not allowed to push into this branch" }, 403);
473
+ const body = await request.json();
474
+ const files = repo.files(body.branch);
475
+ for (const action of body.actions) {
476
+ if (action.action === "create" && files.has(action.file_path)) return json({ message: "A file with this name already exists" }, 400);
477
+ if ((action.action === "update" || action.action === "delete") && !files.has(action.file_path)) return json({ message: "A file with this name doesn't exist" }, 400);
478
+ if (action.last_commit_id && repo.lastCommitFor(action.file_path, body.branch) !== action.last_commit_id) return json({ message: "You are attempting to update a file that has changed since you started editing it." }, 400);
479
+ }
480
+ return json({
481
+ id: commit(body.actions.map((action) => action.action === "delete" ? {
482
+ path: action.file_path,
483
+ delete: true
484
+ } : action.encoding === "base64" ? {
485
+ path: action.file_path,
486
+ bytes: decodeBase64(action.content ?? "")
487
+ } : {
488
+ path: action.file_path,
489
+ content: action.content ?? ""
490
+ }), body.commit_message, user.name ?? user.username),
491
+ message: body.commit_message
492
+ }, 201);
493
+ }
494
+ if (rest === "/repository/commits" && request.method === "GET") {
495
+ const ref = url.searchParams.get("ref_name") ?? repo.defaultBranch;
496
+ const file = url.searchParams.get("path");
497
+ if (!repo.commitAt(repo.branches.get(ref) ?? ref)) return json({ message: "404 Reference Not Found" }, 404);
498
+ const perPage = Number(url.searchParams.get("per_page") ?? 20);
499
+ const page = Number(url.searchParams.get("page") ?? 1);
500
+ return json((file ? repo.history(file, ref) : []).slice((page - 1) * perPage, page * perPage).map((commit) => ({
501
+ id: commit.sha,
502
+ title: commit.message.split("\n")[0],
503
+ message: commit.message,
504
+ author_name: commit.author ?? "GitLab",
505
+ authored_date: commit.date,
506
+ committed_date: commit.date
507
+ })));
508
+ }
509
+ if (rest === "/repository/compare") return json({ diffs: repo.changedPaths(url.searchParams.get("from") ?? "", url.searchParams.get("to") ?? "").map((changedPath) => ({
510
+ old_path: changedPath,
511
+ new_path: changedPath
512
+ })) });
513
+ const statuses = rest.match(/^\/repository\/commits\/([0-9a-f]{40})\/statuses$/);
514
+ if (statuses) {
515
+ const pipeline = pipelines.get(statuses[1] ?? "");
516
+ if (!pipeline) return json([]);
517
+ const failure = failures.get(statuses[1] ?? "");
518
+ if (failure) {
519
+ const id = pipelineId(statuses[1] ?? "");
520
+ return json([{
521
+ name: failure.job,
522
+ status: "failed",
523
+ target_url: `${webUrl}/${options.project}/-/jobs/${id}`
524
+ }]);
525
+ }
526
+ pipeline.checks += 1;
527
+ return json([{
528
+ name: "pages",
529
+ status: pipeline.checks > (options.deployAfterChecks ?? 0) ? "success" : "running",
530
+ target_url: `${webUrl}/${options.project}/-/jobs/1`
531
+ }]);
532
+ }
533
+ if (rest === "/pipelines" && url.searchParams.has("ref")) return json([...updateRuns].reverse().map((run) => ({
534
+ id: run.id,
535
+ source: run.source,
536
+ status: run.status,
537
+ created_at: "2026-01-05T04:17:00Z",
538
+ web_url: `${webUrl}/${options.project}/-/pipelines/${run.id}`
539
+ })));
540
+ if (rest === "/pipeline" && request.method === "POST") {
541
+ if (accessLevel < 30) return json({ message: "403 Forbidden" }, 403);
542
+ const run = addUpdateRun({
543
+ source: "api",
544
+ status: "created",
545
+ variables: (await request.json()).variables
546
+ });
547
+ return json({
548
+ id: run.id,
549
+ status: run.status
550
+ }, 201);
551
+ }
552
+ if (rest === "/pipeline_schedules") {
553
+ if (accessLevel < 30) return json({ message: "403 Forbidden" }, 403);
554
+ if (request.method === "POST") {
555
+ const body = await request.json();
556
+ const schedule = {
557
+ id: updateSettings.schedules.length + 1,
558
+ ref: body.ref,
559
+ cron: body.cron,
560
+ active: body.active ?? true
561
+ };
562
+ updateSettings.schedules.push(schedule);
563
+ return json(schedule, 201);
564
+ }
565
+ return json(updateSettings.schedules);
566
+ }
567
+ if (rest === "" && request.method === "PUT") {
568
+ if (accessLevel < 40) return json({ message: "403 Forbidden" }, 403);
569
+ const body = await request.json();
570
+ if (body.ci_push_repository_for_job_token_allowed !== void 0) updateSettings.jobTokenPush = body.ci_push_repository_for_job_token_allowed;
571
+ return json({ ci_push_repository_for_job_token_allowed: updateSettings.jobTokenPush });
572
+ }
573
+ const updateJobs = rest.match(/^\/pipelines\/(\d{4,})\/jobs$/);
574
+ if (updateJobs) {
575
+ const run = updateRuns.find((candidate) => candidate.id === Number(updateJobs[1]));
576
+ return run ? json([{
577
+ id: run.id,
578
+ name: "pages",
579
+ status: run.status
580
+ }]) : json({ message: "404 Not Found" }, 404);
581
+ }
582
+ const updateTrace = rest.match(/^\/jobs\/(\d{4,})\/trace$/);
583
+ if (updateTrace) {
584
+ const run = updateRuns.find((candidate) => candidate.id === Number(updateTrace[1]));
585
+ return run ? new Response(run.trace) : json({ message: "404 Not Found" }, 404);
586
+ }
587
+ if (rest === "/pipelines") {
588
+ const sha = url.searchParams.get("sha");
589
+ return json([...pipelines.keys()].map((pipelineSha, index) => ({
590
+ id: index + 1,
591
+ sha: pipelineSha
592
+ })).filter((pipeline) => !sha || pipeline.sha === sha));
593
+ }
594
+ const pipelineJobs = rest.match(/^\/pipelines\/(\d+)\/jobs$/);
595
+ if (pipelineJobs) {
596
+ const id = Number(pipelineJobs[1]);
597
+ const sha = [...pipelines.keys()][id - 1];
598
+ if (!sha) return json({ message: "404 Not Found" }, 404);
599
+ const failure = failures.get(sha);
600
+ return json([failure ? {
601
+ id,
602
+ name: failure.job,
603
+ status: "failed",
604
+ failure_reason: failure.failureReason
605
+ } : {
606
+ id,
607
+ name: "pages",
608
+ status: "success"
609
+ }]);
610
+ }
611
+ const trace = rest.match(/^\/jobs\/(\d+)\/trace$/);
612
+ if (trace) {
613
+ const sha = [...pipelines.keys()][Number(trace[1]) - 1];
614
+ const failure = sha ? failures.get(sha) : void 0;
615
+ return failure ? new Response(failure.trace) : json({ message: "404 Not Found" }, 404);
616
+ }
617
+ return json({ message: "404 Not Found" }, 404);
618
+ }
619
+ return {
620
+ /** The project's members by user id, invitations by email, and protected branches, which tests can change. */
621
+ members,
622
+ invitations,
623
+ protectedBranches,
624
+ failBuild,
625
+ /** The project's update settings, and the pipelines the update ran in, which tests add with `addUpdateRun()`. */
626
+ updateSettings,
627
+ updateRuns,
628
+ addUpdateRun,
629
+ repo,
630
+ /** Projects created through the API, by full path. */
631
+ created,
632
+ /** The site's GitLab Pages, if it has them, which tests can change. */
633
+ pages,
634
+ requests,
635
+ commit,
636
+ authorize,
637
+ handle,
638
+ fetch: ((input, init) => handle(new Request(input, init)))
639
+ };
640
+ }
641
+ //#endregion
642
+ export { fakeGitLab };