@goodfellow-cms/gitlab 0.3.0 → 0.5.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.
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { ConnectDomainOptions, DeployStatus, DnsRecord, FileChange, FileVersion, GitBackend, GitHost, GitUser, HistoryOptions, PagesDomains, SetupHost, SiteDrafts, SiteEditors, SiteUpdates, StorageLike, WriteOptions } from "@goodfellow-cms/core";
1
+ import { ConnectDomainOptions, DeployStatus, DnsRecord, FileChange, FileVersion, GitBackend, GitHost, GitUser, HistoryOptions, PagesDomains, RepositoryVisibility, RevisionFiles, ScheduledBuilds, SetupHost, SiteDrafts, SiteEditors, SiteUpdates, StorageLike, WriteOptions } from "@goodfellow-cms/core";
2
2
  //#region src/api.d.ts
3
3
  interface ApiOptions {
4
4
  /** The API root, such as `https://gitlab.com/api/v4`. */
@@ -19,6 +19,20 @@ interface GitLabBackendOptions {
19
19
  branch: string;
20
20
  user: GitUser;
21
21
  onSignOut: () => void;
22
+ /** Who commits name as their author, where that isn't the token's owner, as on a full-stack site's server. */
23
+ author?: {
24
+ name: string;
25
+ email: string;
26
+ };
27
+ /** The commit reads see until `revision()` is next called, as a full-stack site's server reads what a browser loaded. */
28
+ revision?: string;
29
+ /** The project holds only the site's content, with its code in a project of its own (`GitBackend.contentOnly`). */
30
+ contentOnly?: boolean;
31
+ /** The code project, for a project that holds only content, where the site's updates run. */
32
+ code?: {
33
+ project: string;
34
+ webUrl: string;
35
+ };
22
36
  }
23
37
  /**
24
38
  * The site's files on GitLab. Reads come from the commit `revision()` last
@@ -29,8 +43,11 @@ export declare class GitLabBackend implements GitBackend {
29
43
  readonly user: GitUser;
30
44
  readonly pages: PagesDomains;
31
45
  readonly editors: SiteEditors;
32
- readonly updates: SiteUpdates;
46
+ /** Absent where the project holds only content: the update job runs where the code is. */
47
+ readonly updates?: SiteUpdates;
33
48
  readonly drafts: SiteDrafts;
49
+ readonly scheduledBuilds: ScheduledBuilds;
50
+ readonly contentOnly: boolean;
34
51
  private readonly options;
35
52
  private readonly api;
36
53
  private readonly project;
@@ -40,23 +57,32 @@ export declare class GitLabBackend implements GitBackend {
40
57
  private readonly trees;
41
58
  private readonly blobs;
42
59
  constructor(options: GitLabBackendOptions);
60
+ repositoryVisibility(): Promise<RepositoryVisibility>;
43
61
  /** Drafts on branches of the project, with merge requests for review. */
44
62
  private draftHost;
45
63
  private repo;
46
64
  revision(): Promise<string>;
65
+ /** The branch's latest commit, without moving the revision reads are pinned to. */
66
+ private head;
47
67
  /** Every file in one folder at a commit, page by page. */
48
68
  private listFolder;
49
69
  /** Path → blob id for the editable files at a commit. Commits never change, so this is cached. */
50
70
  private tree;
51
71
  private currentTree;
52
72
  read(path: string): Promise<string | undefined>;
73
+ private readFrom;
53
74
  /** Reads many text files in a few GraphQL requests rather than one request each. */
54
75
  readFiles(paths: string[]): Promise<Map<string, string>>;
76
+ private readFilesFrom;
55
77
  readBytes(path: string): Promise<Uint8Array | undefined>;
78
+ private bytesFrom;
56
79
  list(dir: string): Promise<string[]>;
80
+ filesAt(revision: string): RevisionFiles;
57
81
  /** GitLab's tree doesn't say how large files are, so they're asked for with GraphQL, in batches. */
58
82
  fileSizes(dir: string): Promise<Map<string, number>>;
59
83
  private lastCommitFor;
84
+ /** Whether a file exists at a commit: from the cached tree in the editable folders, and from GitLab elsewhere, such as the config. */
85
+ private existsAt;
60
86
  write(changes: FileChange[], { message, expectedRevision }: WriteOptions): Promise<{
61
87
  revision: string;
62
88
  }>;
@@ -65,9 +91,11 @@ export declare class GitLabBackend implements GitBackend {
65
91
  changedPaths(from: string, to: string): Promise<string[]>;
66
92
  /**
67
93
  * Reads the commit's pipeline and external statuses (GitLab Pages jobs, or
68
- * Vercel). Before any exist, the site counts as building if it has pipelines.
94
+ * Vercel or Cloudflare Pages). Before any exist, the site counts as building
95
+ * if it has pipelines, or if the host reported on the commit before.
69
96
  */
70
97
  deployStatus(revision: string): Promise<DeployStatus>;
98
+ private statuses;
71
99
  /** Why a commit's pipeline failed: the failed job's reason, and its log for which command failed and why. */
72
100
  private jobProblem;
73
101
  signOut(): void;
@@ -130,6 +158,17 @@ export interface GitLabOptions {
130
158
  * one-click "Sign in with GitLab". Without it, people sign in with a token.
131
159
  */
132
160
  clientId?: string;
161
+ /**
162
+ * The project holds only the site's content (`content/` and
163
+ * `public/media/`), and its code is in a project of its own. The admin
164
+ * panel then leaves adding blocks, which changes code, to a developer.
165
+ */
166
+ contentOnly?: boolean;
167
+ /**
168
+ * With `contentOnly`, the project the site's code is kept in, where its
169
+ * updates run: the Updates screen follows them, for people who can see it.
170
+ */
171
+ code?: string;
133
172
  /** For tests. */
134
173
  fetch?: typeof fetch;
135
174
  /** For tests. */
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { ConflictError, DomainError, DraftError, EDITABLE_FOLDERS, EditorsError, GitApiError, SetupError, SignInError, UpdatesError, branchDrafts, buildStepFor, credentialStorage, encodeBase64Bytes, findBuildCause, findUpdateResult } from "@goodfellow-cms/core";
1
+ import { ConflictError, DomainError, DraftError, EDITABLE_FOLDERS, EditorsError, GitApiError, NIGHTLY_VARIABLE, REBUILD_JOB, ScheduledBuildsError, SetupError, SignInError, UpdatesError, assertContentChanges, branchDrafts, buildStepFor, credentialStorage, encodeBase64Bytes, findBuildCause, findUpdateResult, hostSource } from "@goodfellow-cms/core";
2
2
  //#region src/api.ts
3
3
  /** Calls the GitLab API. A 401 refreshes the token once; after that it means the sign-in has expired. */
4
4
  async function gitlabRequest(api, path, init = {}) {
@@ -40,7 +40,7 @@ function roleOf(level) {
40
40
  function levelOf(role) {
41
41
  return role === "owner" ? MAINTAINER : DEVELOPER;
42
42
  }
43
- function notAllowed$1(error) {
43
+ function notAllowed$2(error) {
44
44
  if (error instanceof GitApiError && (error.status === 401 || error.status === 403)) throw new EditorsError("not-allowed", "Changing editors on GitLab needs the Maintainer role in the project.");
45
45
  throw error;
46
46
  }
@@ -102,7 +102,7 @@ function gitlabEditors(api, project, branch, username) {
102
102
  email: name,
103
103
  access_level: levelOf(role)
104
104
  }
105
- }).catch(notAllowed$1);
105
+ }).catch(notAllowed$2);
106
106
  if (result.status !== "success") {
107
107
  const message = typeof result.message === "string" ? result.message : Object.values(result.message ?? {})[0];
108
108
  if (/already/i.test(message ?? "")) throw new EditorsError("already", "They've already been invited.");
@@ -123,17 +123,17 @@ function gitlabEditors(api, project, branch, username) {
123
123
  });
124
124
  } catch (error) {
125
125
  if (error instanceof GitApiError && error.status === 409) throw new EditorsError("already", `${name} already edits the site.`);
126
- notAllowed$1(error);
126
+ notAllowed$2(error);
127
127
  }
128
128
  },
129
129
  async setRole(editor, role) {
130
130
  await gitlabRequest(api, editor.id.startsWith(INVITATION) ? `${base}/invitations/${encodeURIComponent(editor.id.slice(11))}` : `${base}/members/${editor.id.slice(7)}`, {
131
131
  method: "PUT",
132
132
  body: { access_level: levelOf(role) }
133
- }).catch(notAllowed$1);
133
+ }).catch(notAllowed$2);
134
134
  },
135
135
  async remove(editor) {
136
- await gitlabRequest(api, editor.id.startsWith(INVITATION) ? `${base}/invitations/${encodeURIComponent(editor.id.slice(11))}` : `${base}/members/${editor.id.slice(7)}`, { method: "DELETE" }).catch(notAllowed$1);
136
+ await gitlabRequest(api, editor.id.startsWith(INVITATION) ? `${base}/invitations/${encodeURIComponent(editor.id.slice(11))}` : `${base}/members/${editor.id.slice(7)}`, { method: "DELETE" }).catch(notAllowed$2);
137
137
  },
138
138
  async letEditorsPublish() {
139
139
  const protect = (level) => gitlabRequest(api, `${base}/protected_branches`, {
@@ -141,19 +141,19 @@ function gitlabEditors(api, project, branch, username) {
141
141
  body: {
142
142
  name: branch,
143
143
  push_access_level: level,
144
- merge_access_level: MAINTAINER,
144
+ merge_access_level: level,
145
145
  allow_force_push: false
146
146
  }
147
147
  });
148
148
  await gitlabRequest(api, protectedPath, {
149
149
  method: "DELETE",
150
150
  allow: [404]
151
- }).catch(notAllowed$1);
151
+ }).catch(notAllowed$2);
152
152
  try {
153
153
  await protect(DEVELOPER);
154
154
  } catch (error) {
155
155
  await protect(MAINTAINER).catch(() => void 0);
156
- notAllowed$1(error);
156
+ notAllowed$2(error);
157
157
  }
158
158
  }
159
159
  };
@@ -250,6 +250,7 @@ function gitlabPages(api, project) {
250
250
  return response.ok ? await response.json() : found;
251
251
  }
252
252
  return {
253
+ severalDomains: true,
253
254
  async site() {
254
255
  const found = await pages();
255
256
  if (!found) return void 0;
@@ -301,9 +302,16 @@ function gitlabPages(api, project) {
301
302
  primary: settings?.primary_domain === void 0 || settings.primary_domain === domain
302
303
  };
303
304
  },
304
- async secure(domain) {
305
+ async secure(domain, { primary = true } = {}) {
305
306
  const found = await gitlabJson(api, domainPath(domain)).catch(notAllowed);
306
307
  if (!found.verified || !found.certificate) throw new DomainError("not-ready", "The domain's certificate isn't ready yet.");
308
+ if (!primary) {
309
+ await gitlabRequest(api, pagesPath, {
310
+ method: "PATCH",
311
+ body: { pages_https_only: true }
312
+ }).catch(notAllowed);
313
+ return;
314
+ }
307
315
  try {
308
316
  await gitlabRequest(api, pagesPath, {
309
317
  method: "PATCH",
@@ -329,6 +337,80 @@ function gitlabPages(api, project) {
329
337
  };
330
338
  }
331
339
  //#endregion
340
+ //#region src/scheduled-builds.ts
341
+ /** The start of the description of the pipeline schedules the admin panel adds, which `sync()` keeps. */
342
+ const SCHEDULED_BUILD_DESCRIPTION = "Goodfellow: build the site for a scheduled change at ";
343
+ /** The variable those schedules' pipelines have, which the starters' pipelines read to skip the nightly update. */
344
+ const SCHEDULED_CHANGE_VARIABLE = "GOODFELLOW_SCHEDULED_CHANGE";
345
+ function notAllowed$1(error) {
346
+ if (error instanceof GitApiError && (error.status === 401 || error.status === 403)) throw new ScheduledBuildsError("not-allowed", "Adding pipeline schedules on GitLab needs the Developer role, with permission to merge to the main branch.");
347
+ throw error;
348
+ }
349
+ /** A cron line for one instant, in UTC: it comes once a year, and `sync()` removes it once it has passed. */
350
+ function cronAt(at) {
351
+ return `${at.getUTCMinutes()} ${at.getUTCHours()} ${at.getUTCDate()} ${at.getUTCMonth() + 1} *`;
352
+ }
353
+ /** An instant to the minute, as the schedules' descriptions and variables name it: `2026-11-29T14:00Z`. */
354
+ function minute(at) {
355
+ return `${at.toISOString().slice(0, 16)}Z`;
356
+ }
357
+ /**
358
+ * Scheduled changes on GitLab go live at their time: the admin panel adds a
359
+ * pipeline schedule for each, which builds the site then. The pipeline is the
360
+ * project's own, so any project with a `.gitlab-ci.yml` that builds the site
361
+ * on its main branch does.
362
+ */
363
+ function gitlabScheduledBuilds(api, project, branch, readCi, now = () => /* @__PURE__ */ new Date()) {
364
+ const base = `/projects/${encodeURIComponent(project)}/pipeline_schedules`;
365
+ return {
366
+ async timing() {
367
+ return await readCi() === void 0 ? "none" : "exact";
368
+ },
369
+ async sync(times) {
370
+ const after = now();
371
+ const wanted = /* @__PURE__ */ new Map();
372
+ for (const at of [...times].filter((time) => time > after).sort((a, b) => a.getTime() - b.getTime())) {
373
+ if (wanted.size >= 50) break;
374
+ wanted.set(minute(at), at);
375
+ }
376
+ const found = await gitlabJson(api, `${base}?per_page=100`).catch(notAllowed$1);
377
+ const ours = /* @__PURE__ */ new Map();
378
+ for (const schedule of found) {
379
+ if (!schedule.description?.startsWith("Goodfellow: build the site for a scheduled change at ")) continue;
380
+ ours.set(schedule.description.slice(53), schedule);
381
+ }
382
+ for (const [time, schedule] of ours) {
383
+ if (wanted.has(time)) continue;
384
+ await gitlabRequest(api, `${base}/${schedule.id}`, {
385
+ method: "DELETE",
386
+ allow: [403, 404]
387
+ });
388
+ }
389
+ for (const [time, at] of wanted) {
390
+ if (ours.has(time)) continue;
391
+ const created = await gitlabJson(api, base, {
392
+ method: "POST",
393
+ body: {
394
+ description: `${SCHEDULED_BUILD_DESCRIPTION}${time}`,
395
+ ref: branch,
396
+ cron: cronAt(at),
397
+ cron_timezone: "UTC",
398
+ active: true
399
+ }
400
+ }).catch(notAllowed$1);
401
+ await gitlabRequest(api, `${base}/${created.id}/variables`, {
402
+ method: "POST",
403
+ body: {
404
+ key: SCHEDULED_CHANGE_VARIABLE,
405
+ value: time,
406
+ variable_type: "env_var"
407
+ }
408
+ }).catch(notAllowed$1);
409
+ }
410
+ }
411
+ };
412
+ }
413
+ //#endregion
332
414
  //#region src/updates.ts
333
415
  const CI_FILE = ".gitlab-ci.yml";
334
416
  /** When the nightly schedule the Updates screen adds runs, in UTC, as the GitHub Pages workflow's does. */
@@ -343,17 +425,21 @@ function notAllowed(error) {
343
425
  * It publishes with the job's own token, which GitLab only allows once the
344
426
  * project lets job tokens push.
345
427
  */
346
- function gitlabUpdates(api, project, branch, readAt) {
428
+ function gitlabUpdates(api, project, branch, readAt, jobName = "pages") {
347
429
  const base = `/projects/${encodeURIComponent(project)}`;
348
430
  async function schedules() {
349
431
  return gitlabJson(api, `${base}/pipeline_schedules`);
350
432
  }
433
+ /** The nightly schedule: the branch's, other than those the admin panel adds for scheduled changes. */
434
+ async function nightlySchedule() {
435
+ return (await schedules()).find((schedule) => (schedule.ref === branch || schedule.ref === `refs/heads/${branch}`) && !schedule.description?.startsWith("Goodfellow: build the site for a scheduled change at "));
436
+ }
351
437
  return {
352
438
  async setup() {
353
439
  if (!(await readAt(CI_FILE))?.includes("update --publish")) return "missing";
354
440
  try {
355
441
  const [settings, found] = await Promise.all([gitlabJson(api, base), schedules()]);
356
- const scheduled = found.some((schedule) => schedule.active && (schedule.ref === branch || schedule.ref === `refs/heads/${branch}`));
442
+ const scheduled = found.some((schedule) => (schedule.ref === branch || schedule.ref === `refs/heads/${branch}`) && (schedule.active || !schedule.description?.startsWith("Goodfellow: build the site for a scheduled change at ")));
357
443
  return settings.ci_push_repository_for_job_token_allowed && scheduled ? "ready" : "needs-setup";
358
444
  } catch (error) {
359
445
  if (error instanceof GitApiError && error.status === 403) return "needs-setup";
@@ -365,7 +451,7 @@ function gitlabUpdates(api, project, branch, readAt) {
365
451
  method: "PUT",
366
452
  body: { ci_push_repository_for_job_token_allowed: true }
367
453
  }).catch(notAllowed);
368
- if (!(await schedules().catch(notAllowed)).some((schedule) => schedule.active && schedule.ref.endsWith(branch))) await gitlabRequest(api, `${base}/pipeline_schedules`, {
454
+ if (!await nightlySchedule().catch(notAllowed)) await gitlabRequest(api, `${base}/pipeline_schedules`, {
369
455
  method: "POST",
370
456
  body: {
371
457
  description: "Rebuild the site and install Goodfellow's fixes each night",
@@ -397,7 +483,7 @@ function gitlabUpdates(api, project, branch, readAt) {
397
483
  state: "running",
398
484
  ...run
399
485
  };
400
- const job = (await gitlabJson(api, `${base}/pipelines/${pipeline.id}/jobs?per_page=100`)).find((candidate) => candidate.name === "pages");
486
+ const job = (await gitlabJson(api, `${base}/pipelines/${pipeline.id}/jobs?per_page=100`)).find((candidate) => candidate.name === jobName || candidate.name === "update");
401
487
  if (!job) continue;
402
488
  const response = await gitlabRequest(api, `${base}/jobs/${job.id}/trace`, { allow: [403, 404] });
403
489
  const result = response.ok ? findUpdateResult(await response.text()) : void 0;
@@ -421,6 +507,122 @@ function gitlabUpdates(api, project, branch, readAt) {
421
507
  }]
422
508
  }
423
509
  }).catch(notAllowed);
510
+ },
511
+ nightly: {
512
+ async supported() {
513
+ return (await readAt(CI_FILE))?.includes("nightly") ?? false;
514
+ },
515
+ async get() {
516
+ try {
517
+ const found = await nightlySchedule();
518
+ if (!found) return void 0;
519
+ if (!found.active) return "off";
520
+ return (await gitlabJson(api, `${base}/pipeline_schedules/${found.id}`)).variables?.find((variable) => variable.key === NIGHTLY_VARIABLE)?.value === "always" ? "always" : "changed";
521
+ } catch (error) {
522
+ if (error instanceof GitApiError && error.status === 403) return void 0;
523
+ throw error;
524
+ }
525
+ },
526
+ async set(mode) {
527
+ const found = await nightlySchedule().catch(notAllowed);
528
+ if (!found) throw new UpdatesError("missing", "The site has no nightly schedule yet: turn on updates first.");
529
+ const path = `${base}/pipeline_schedules/${found.id}`;
530
+ await gitlabRequest(api, path, {
531
+ method: "PUT",
532
+ body: { active: mode !== "off" }
533
+ }).catch(notAllowed);
534
+ if (mode === "always") {
535
+ const body = {
536
+ key: NIGHTLY_VARIABLE,
537
+ value: "always"
538
+ };
539
+ if ((await gitlabRequest(api, `${path}/variables/${NIGHTLY_VARIABLE}`, {
540
+ method: "PUT",
541
+ body,
542
+ allow: [404]
543
+ }).catch(notAllowed)).status === 404) await gitlabRequest(api, `${path}/variables`, {
544
+ method: "POST",
545
+ body
546
+ }).catch(notAllowed);
547
+ } else if (mode === "changed") await gitlabRequest(api, `${path}/variables/${NIGHTLY_VARIABLE}`, {
548
+ method: "DELETE",
549
+ allow: [404]
550
+ }).catch(notAllowed);
551
+ }
552
+ },
553
+ async rebuild() {
554
+ await gitlabRequest(api, `${base}/pipeline`, {
555
+ method: "POST",
556
+ body: {
557
+ ref: branch,
558
+ variables: [{
559
+ key: "GOODFELLOW_REBUILD",
560
+ variable_type: "env_var",
561
+ value: "true"
562
+ }]
563
+ }
564
+ }).catch(notAllowed);
565
+ }
566
+ };
567
+ }
568
+ function noAccess(error) {
569
+ return error instanceof GitApiError && (error.status === 403 || error.status === 404);
570
+ }
571
+ /**
572
+ * The nightly update of a site whose content is kept in a project of its own,
573
+ * which runs in the code project's `update` job. The sign-in needs to see the
574
+ * code project too; where it can't, `setup()` says `no-access`.
575
+ */
576
+ function gitlabCodeUpdates(options) {
577
+ const { api, project } = options;
578
+ let branch;
579
+ const liveBranch = () => {
580
+ branch ??= gitlabJson(api, `/projects/${encodeURIComponent(project)}`).then((found) => found.default_branch);
581
+ branch.catch(() => {
582
+ branch = void 0;
583
+ });
584
+ return branch;
585
+ };
586
+ /** The code project at its newest commit, read again each time, since updates change it. */
587
+ const files = async () => options.open(await liveBranch());
588
+ /** A file as it is now: its setup files are outside the folders backends list, so they're read at the newest commit. */
589
+ const readNow = async (path) => {
590
+ const backend = await files();
591
+ return backend.readAt(path, await backend.revision());
592
+ };
593
+ const updates = async () => gitlabUpdates(api, project, await liveBranch(), readNow, "update");
594
+ return {
595
+ code: {
596
+ repo: project,
597
+ webUrl: `${options.webUrl}/${project}`,
598
+ read: readNow,
599
+ history: async (path, history) => (await files()).history(path, history),
600
+ readAt: async (path, revision) => (await files()).readAt(path, revision)
601
+ },
602
+ async setup() {
603
+ try {
604
+ return await (await updates()).setup();
605
+ } catch (error) {
606
+ if (noAccess(error)) return "no-access";
607
+ throw error;
608
+ }
609
+ },
610
+ async enable() {
611
+ await (await updates()).enable?.();
612
+ },
613
+ async lastRun() {
614
+ try {
615
+ return await (await updates()).lastRun();
616
+ } catch (error) {
617
+ if (noAccess(error)) return void 0;
618
+ throw error;
619
+ }
620
+ },
621
+ async start(target) {
622
+ await (await updates()).start(target);
623
+ },
624
+ async rebuild() {
625
+ await (await updates()).rebuild?.();
424
626
  }
425
627
  };
426
628
  }
@@ -432,6 +634,18 @@ const GRAPHQL_BATCH = 50;
432
634
  /** The color and section codes in GitLab's job logs. */
433
635
  const ANSI_CODES = new RegExp(`${String.fromCharCode(27)}\\[[\\d;]*[A-Za-z]`, "g");
434
636
  /**
637
+ * Whether a status says when the site is live. A job that only asks another
638
+ * host to rebuild the site doesn't, and nor does the nightly update where
639
+ * Cloudflare builds the site, which runs on the newest commit too.
640
+ */
641
+ function builds(status) {
642
+ return status.name !== REBUILD_JOB && status.name !== "update";
643
+ }
644
+ function listFrom(tree, dir) {
645
+ const prefix = `${dir}/`;
646
+ return [...tree.keys()].filter((path) => path.startsWith(prefix)).sort();
647
+ }
648
+ /**
435
649
  * The site's files on GitLab. Reads come from the commit `revision()` last
436
650
  * returned. Saves are single commits; each changed file carries the commit it
437
651
  * was last changed in, so GitLab refuses to overwrite anyone else's edits.
@@ -440,8 +654,11 @@ var GitLabBackend = class GitLabBackend {
440
654
  user;
441
655
  pages;
442
656
  editors;
657
+ /** Absent where the project holds only content: the update job runs where the code is. */
443
658
  updates;
444
659
  drafts;
660
+ scheduledBuilds;
661
+ contentOnly;
445
662
  options;
446
663
  api;
447
664
  project;
@@ -457,10 +674,32 @@ var GitLabBackend = class GitLabBackend {
457
674
  this.branch = options.branch;
458
675
  this.user = options.user;
459
676
  this.onSignOut = options.onSignOut;
677
+ this.pinned = options.revision;
460
678
  this.pages = gitlabPages(this.api, options.project);
461
679
  this.editors = gitlabEditors(this.api, options.project, this.branch, this.user.login);
462
- this.updates = gitlabUpdates(this.api, options.project, this.branch, async (path) => this.readAt(path, this.pinned ?? await this.revision()));
680
+ this.contentOnly = options.contentOnly === true;
681
+ if (!this.contentOnly) this.updates = gitlabUpdates(this.api, options.project, this.branch, async (path) => this.readAt(path, this.pinned ?? await this.revision()));
682
+ else if (options.code) {
683
+ const code = options.code;
684
+ this.updates = gitlabCodeUpdates({
685
+ api: this.api,
686
+ project: code.project,
687
+ webUrl: code.webUrl,
688
+ open: (branch) => new GitLabBackend({
689
+ api: this.api,
690
+ project: code.project,
691
+ branch,
692
+ user: this.user,
693
+ onSignOut: () => {}
694
+ })
695
+ });
696
+ }
463
697
  this.drafts = branchDrafts(this, this.draftHost());
698
+ this.scheduledBuilds = gitlabScheduledBuilds(this.api, options.project, this.branch, async () => this.readAt(".gitlab-ci.yml", this.pinned ?? await this.revision()));
699
+ }
700
+ async repositoryVisibility() {
701
+ const { visibility } = await gitlabJson(this.api, `/projects/${this.project}`);
702
+ return visibility === "public" || visibility === "internal" ? visibility : "private";
464
703
  }
465
704
  /** Drafts on branches of the project, with merge requests for review. */
466
705
  draftHost() {
@@ -494,6 +733,13 @@ var GitLabBackend = class GitLabBackend {
494
733
  if (!response.ok) throw new DraftError("not-found", `There's no draft ${branch}.`);
495
734
  return (await response.json()).id;
496
735
  },
736
+ listBranches: async (prefix) => {
737
+ const query = new URLSearchParams({
738
+ search: `^${prefix}`,
739
+ per_page: "100"
740
+ });
741
+ return (await gitlabJson(this.api, this.repo(`/branches?${query}`))).map((found) => found.name).filter((name) => name.startsWith(prefix));
742
+ },
497
743
  openReview: async ({ branch, title, description }) => {
498
744
  const response = await gitlabRequest(this.api, `${project}/merge_requests`, {
499
745
  method: "POST",
@@ -526,10 +772,13 @@ var GitLabBackend = class GitLabBackend {
526
772
  return `/projects/${this.project}/repository${path}`;
527
773
  }
528
774
  async revision() {
529
- const branch = await gitlabJson(this.api, this.repo(`/branches/${encodeURIComponent(this.branch)}`));
530
- this.pinned = branch.commit.id;
775
+ this.pinned = await this.head();
531
776
  return this.pinned;
532
777
  }
778
+ /** The branch's latest commit, without moving the revision reads are pinned to. */
779
+ async head() {
780
+ return (await gitlabJson(this.api, this.repo(`/branches/${encodeURIComponent(this.branch)}`))).commit.id;
781
+ }
533
782
  /** Every file in one folder at a commit, page by page. */
534
783
  async listFolder(sha, dir) {
535
784
  const files = [];
@@ -562,8 +811,11 @@ var GitLabBackend = class GitLabBackend {
562
811
  return this.tree(this.pinned ?? await this.revision());
563
812
  }
564
813
  async read(path) {
565
- const sha = (await this.currentTree()).get(path);
566
- if (!sha) return void 0;
814
+ return this.readFrom(await this.currentTree(), path);
815
+ }
816
+ readFrom(tree, path) {
817
+ const sha = tree.get(path);
818
+ if (!sha) return Promise.resolve(void 0);
567
819
  let blob = this.blobs.get(sha);
568
820
  if (!blob) {
569
821
  blob = gitlabRequest(this.api, this.repo(`/blobs/${sha}/raw`)).then((response) => response.text());
@@ -574,10 +826,12 @@ var GitLabBackend = class GitLabBackend {
574
826
  }
575
827
  /** Reads many text files in a few GraphQL requests rather than one request each. */
576
828
  async readFiles(paths) {
577
- const tree = await this.currentTree();
829
+ const ref = this.pinned ?? await this.revision();
830
+ return this.readFilesFrom(ref, await this.tree(ref), paths);
831
+ }
832
+ async readFilesFrom(ref, tree, paths) {
578
833
  const wanted = paths.filter((path) => tree.has(path));
579
834
  const fetch = wanted.filter((path) => !this.blobs.has(tree.get(path) ?? ""));
580
- const ref = this.pinned ?? await this.revision();
581
835
  for (let start = 0; start < fetch.length; start += GRAPHQL_BATCH) {
582
836
  const batch = fetch.slice(start, start + GRAPHQL_BATCH);
583
837
  const response = await gitlabJson(this.api, "/../graphql", {
@@ -600,20 +854,30 @@ var GitLabBackend = class GitLabBackend {
600
854
  }
601
855
  const result = /* @__PURE__ */ new Map();
602
856
  for (const path of wanted) {
603
- const text = await this.read(path);
857
+ const text = await this.readFrom(tree, path);
604
858
  if (text !== void 0) result.set(path, text);
605
859
  }
606
860
  return result;
607
861
  }
608
862
  async readBytes(path) {
609
- const sha = (await this.currentTree()).get(path);
863
+ return this.bytesFrom(await this.currentTree(), path);
864
+ }
865
+ async bytesFrom(tree, path) {
866
+ const sha = tree.get(path);
610
867
  if (!sha) return void 0;
611
868
  const response = await gitlabRequest(this.api, this.repo(`/blobs/${sha}/raw`));
612
869
  return new Uint8Array(await response.arrayBuffer());
613
870
  }
614
871
  async list(dir) {
615
- const prefix = `${dir}/`;
616
- return [...(await this.currentTree()).keys()].filter((path) => path.startsWith(prefix)).sort();
872
+ return listFrom(await this.currentTree(), dir);
873
+ }
874
+ filesAt(revision) {
875
+ return {
876
+ read: async (path) => this.readFrom(await this.tree(revision), path),
877
+ readFiles: async (paths) => this.readFilesFrom(revision, await this.tree(revision), paths),
878
+ readBytes: async (path) => this.bytesFrom(await this.tree(revision), path),
879
+ list: async (dir) => listFrom(await this.tree(revision), dir)
880
+ };
617
881
  }
618
882
  /** GitLab's tree doesn't say how large files are, so they're asked for with GraphQL, in batches. */
619
883
  async fileSizes(dir) {
@@ -644,11 +908,20 @@ var GitLabBackend = class GitLabBackend {
644
908
  async lastCommitFor(path, ref) {
645
909
  return (await gitlabJson(this.api, this.repo(`/files/${encodeURIComponent(path)}?ref=${ref}`))).last_commit_id;
646
910
  }
911
+ /** Whether a file exists at a commit: from the cached tree in the editable folders, and from GitLab elsewhere, such as the config. */
912
+ async existsAt(tree, path, ref) {
913
+ if (EDITABLE_FOLDERS.some((folder) => path.startsWith(`${folder}/`))) return tree.has(path);
914
+ return this.lastCommitFor(path, ref).then(() => true, (error) => {
915
+ if (error instanceof GitApiError && error.status === 404) return false;
916
+ throw error;
917
+ });
918
+ }
647
919
  async write(changes, { message, expectedRevision }) {
920
+ if (this.contentOnly) assertContentChanges(changes);
648
921
  if (await this.revision() !== expectedRevision) throw new ConflictError();
649
922
  const existing = await this.tree(expectedRevision);
650
923
  const toApply = (await Promise.all(changes.map(async (change) => {
651
- const exists = existing.has(change.path);
924
+ const exists = await this.existsAt(existing, change.path, expectedRevision);
652
925
  if ("delete" in change) return exists ? {
653
926
  action: "delete",
654
927
  file_path: change.path,
@@ -676,7 +949,11 @@ var GitLabBackend = class GitLabBackend {
676
949
  body: {
677
950
  branch: this.branch,
678
951
  commit_message: message,
679
- actions: toApply
952
+ actions: toApply,
953
+ ...this.options.author && {
954
+ author_name: this.options.author.name,
955
+ author_email: this.options.author.email
956
+ }
680
957
  }
681
958
  });
682
959
  this.pinned = commit.id;
@@ -716,13 +993,24 @@ var GitLabBackend = class GitLabBackend {
716
993
  }
717
994
  /**
718
995
  * Reads the commit's pipeline and external statuses (GitLab Pages jobs, or
719
- * Vercel). Before any exist, the site counts as building if it has pipelines.
996
+ * Vercel or Cloudflare Pages). Before any exist, the site counts as building
997
+ * if it has pipelines, or if the host reported on the commit before.
720
998
  */
721
999
  async deployStatus(revision) {
722
1000
  try {
723
- const statuses = await gitlabJson(this.api, this.repo(`/commits/${revision}/statuses`));
724
- if (statuses.length === 0) return { state: (await gitlabJson(this.api, `/projects/${this.project}/pipelines?per_page=1`)).length > 0 ? "building" : "unknown" };
725
- const failed = statuses.find((status) => status.status === "failed" || status.status === "canceled");
1001
+ const all = await this.statuses(revision);
1002
+ const statuses = all.filter(builds);
1003
+ if (all.length > 0 && statuses.length === 0) return { state: "unknown" };
1004
+ if (statuses.length === 0) {
1005
+ if ((await gitlabJson(this.api, `/projects/${this.project}/pipelines?per_page=1`)).length > 0) return { state: "building" };
1006
+ const { parent_ids: parents = [] } = await gitlabJson(this.api, this.repo(`/commits/${revision}`));
1007
+ return { state: (parents[0] ? (await this.statuses(parents[0])).filter(builds) : []).length > 0 ? "building" : "unknown" };
1008
+ }
1009
+ const failed = statuses.find((status) => status.status === "failed") ?? statuses.find((status) => status.status === "canceled");
1010
+ if (failed?.status === "canceled") {
1011
+ const head = await this.head();
1012
+ if (head !== revision) return this.deployStatus(head);
1013
+ }
726
1014
  if (failed) {
727
1015
  const problem = await this.jobProblem(revision).catch(() => void 0);
728
1016
  return {
@@ -744,6 +1032,9 @@ var GitLabBackend = class GitLabBackend {
744
1032
  throw error;
745
1033
  }
746
1034
  }
1035
+ statuses(revision) {
1036
+ return gitlabJson(this.api, this.repo(`/commits/${revision}/statuses`));
1037
+ }
747
1038
  /** Why a commit's pipeline failed: the failed job's reason, and its log for which command failed and why. */
748
1039
  async jobProblem(revision) {
749
1040
  const [pipeline] = await gitlabJson(this.api, `/projects/${this.project}/pipelines?${new URLSearchParams({
@@ -863,6 +1154,46 @@ function gitlabSetup(options = {}) {
863
1154
  async function isAvailable(owner, name) {
864
1155
  return (await gitlabRequest(api, `/projects/${encodeURIComponent(`${owner.path}/${name}`)}`, { allow: [404] })).status === 404;
865
1156
  }
1157
+ /**
1158
+ * Lets each project's pipelines reach the other, as their setup files say:
1159
+ * a job token is refused by a project whose job token allowlist doesn't
1160
+ * name its project, even a public one. Also lets the code project's job
1161
+ * token publish updates, and runs pipelines each night where they need it.
1162
+ * Returns whether it worked.
1163
+ */
1164
+ async function connectProjects(link) {
1165
+ const project = (path) => `/projects/${encodeURIComponent(path)}`;
1166
+ try {
1167
+ const ids = /* @__PURE__ */ new Map();
1168
+ for (const which of ["code", "content"]) ids.set(which, (await gitlabJson(api, project(link[which]))).id);
1169
+ for (const { reader, read } of link.links.reads) await gitlabRequest(api, `${project(link[read])}/job_token_scope/allowlist`, {
1170
+ method: "POST",
1171
+ body: { target_project_id: ids.get(reader) }
1172
+ });
1173
+ if (link.links.updates) await gitlabRequest(api, project(link[link.links.updates]), {
1174
+ method: "PUT",
1175
+ body: { ci_push_repository_for_job_token_allowed: true }
1176
+ });
1177
+ for (const which of link.links.nightly) await gitlabRequest(api, `${project(link[which])}/pipeline_schedules`, {
1178
+ method: "POST",
1179
+ body: {
1180
+ description: which === link.links.updates ? "Rebuild the site and install Goodfellow's fixes each night" : "Rebuild the site each night",
1181
+ ref: MAIN,
1182
+ cron: NIGHTLY,
1183
+ cron_timezone: "UTC",
1184
+ active: true
1185
+ }
1186
+ });
1187
+ return true;
1188
+ } catch (error) {
1189
+ if (error instanceof GitApiError && [
1190
+ 400,
1191
+ 403,
1192
+ 404
1193
+ ].includes(error.status)) return false;
1194
+ throw error;
1195
+ }
1196
+ }
866
1197
  return {
867
1198
  user: {
868
1199
  login: user.username,
@@ -900,6 +1231,34 @@ function gitlabSetup(options = {}) {
900
1231
  if (error instanceof GitApiError && (error.status === 403 || error.status === 404)) throw new SetupError("not-allowed", `This sign-in can't create projects in ${site.owner.path}.`);
901
1232
  throw error;
902
1233
  }
1234
+ const warnings = [];
1235
+ if (site.link) {
1236
+ onStep?.("connect");
1237
+ if (!await connectProjects(site.link)) warnings.push("connect-unavailable");
1238
+ }
1239
+ let siteUrl;
1240
+ if (site.pages && site.domain) {
1241
+ onStep?.("pages");
1242
+ try {
1243
+ await gitlabRequest(api, `/projects/${project.id}/pages/domains`, {
1244
+ method: "POST",
1245
+ body: {
1246
+ domain: site.domain,
1247
+ auto_ssl_enabled: true
1248
+ }
1249
+ });
1250
+ siteUrl = `https://${site.domain}/`;
1251
+ } catch (error) {
1252
+ if (!(error instanceof GitApiError) || ![
1253
+ 400,
1254
+ 403,
1255
+ 404,
1256
+ 409,
1257
+ 422
1258
+ ].includes(error.status)) throw error;
1259
+ warnings.push("domain-unavailable");
1260
+ }
1261
+ }
903
1262
  onStep?.("files");
904
1263
  await gitlabRequest(api, `/projects/${project.id}/repository/commits`, {
905
1264
  method: "POST",
@@ -921,9 +1280,10 @@ function gitlabSetup(options = {}) {
921
1280
  return {
922
1281
  repo: project.path_with_namespace,
923
1282
  webUrl: project.web_url,
1283
+ ...siteUrl && { siteUrl },
924
1284
  buildsUrl: `${project.web_url}/-/pipelines`,
925
1285
  pagesUrl: site.pages ? `${project.web_url}/pages` : void 0,
926
- warnings: []
1286
+ warnings
927
1287
  };
928
1288
  }
929
1289
  };
@@ -1056,6 +1416,11 @@ function gitlab(options) {
1056
1416
  api,
1057
1417
  project: options.project,
1058
1418
  branch: options.branch ?? project.default_branch,
1419
+ ...options.contentOnly && { contentOnly: true },
1420
+ ...options.contentOnly && options.code && { code: {
1421
+ project: options.code,
1422
+ webUrl
1423
+ } },
1059
1424
  user: {
1060
1425
  login: user.username,
1061
1426
  name: user.name,
@@ -1092,9 +1457,57 @@ function gitlab(options) {
1092
1457
  code_verifier: redirect.verifier
1093
1458
  }), redirect.verifier), redirect.remember);
1094
1459
  }
1460
+ /** The project through a token of the site's own, such as a project access token, on a full-stack site's server. */
1461
+ function server({ token }) {
1462
+ const api = {
1463
+ apiUrl,
1464
+ token: async () => typeof token === "string" ? token : token(),
1465
+ canRefresh: false,
1466
+ fetch: fetcher
1467
+ };
1468
+ let branch;
1469
+ const liveBranch = () => {
1470
+ branch ??= options.branch ? Promise.resolve(options.branch) : gitlabJson(api, `/projects/${encodeURIComponent(options.project)}`).then((project) => project.default_branch);
1471
+ branch.catch(() => {
1472
+ branch = void 0;
1473
+ });
1474
+ return branch;
1475
+ };
1476
+ const backend = async (revision, author) => new GitLabBackend({
1477
+ api,
1478
+ project: options.project,
1479
+ branch: await liveBranch(),
1480
+ ...options.contentOnly && { contentOnly: true },
1481
+ user: {
1482
+ login: author?.name ?? options.project,
1483
+ ...author && { name: author.name }
1484
+ },
1485
+ onSignOut: () => {},
1486
+ ...revision && { revision },
1487
+ ...author && { author }
1488
+ });
1489
+ return {
1490
+ ...options.contentOnly && { contentOnly: true },
1491
+ async revision() {
1492
+ return (await backend()).revision();
1493
+ },
1494
+ at: backend
1495
+ };
1496
+ }
1095
1497
  return {
1096
1498
  name: "GitLab",
1499
+ source: hostSource("@goodfellow-cms/gitlab", "gitlab", options, [
1500
+ "fetch",
1501
+ "storage",
1502
+ "location"
1503
+ ]),
1097
1504
  canRedirect: Boolean(options.clientId),
1505
+ setup: () => gitlabSetup({
1506
+ ...options.url && { url: options.url },
1507
+ ...options.fetch && { fetch: options.fetch },
1508
+ ...options.storage && { storage: options.storage },
1509
+ ...options.location && { location: options.location }
1510
+ }),
1098
1511
  redirectAddressesListed: Boolean(options.clientId),
1099
1512
  tokenLinks: [{
1100
1513
  url: `${webUrl}/-/user_settings/personal_access_tokens?${new URLSearchParams({
@@ -1145,7 +1558,8 @@ function gitlab(options) {
1145
1558
  code_challenge_method: "S256"
1146
1559
  });
1147
1560
  location().assign(`${webUrl}/oauth/authorize?${query}`);
1148
- }
1561
+ },
1562
+ server
1149
1563
  };
1150
1564
  }
1151
1565
  //#endregion
package/dist/testing.d.ts CHANGED
@@ -21,14 +21,39 @@ export interface FakeGitLabOptions {
21
21
  };
22
22
  /** Simulates a GitLab Pages pipeline that succeeds after being checked this many times. Off by default. */
23
23
  deployAfterChecks?: number;
24
+ /** The job each published commit's pipeline runs: `pages` by default. */
25
+ deployJob?: string;
26
+ /**
27
+ * Has `deployJob` report as a host outside GitLab does, such as Cloudflare
28
+ * Pages: a commit status with no pipeline the project lists.
29
+ */
30
+ external?: boolean;
31
+ /** The job the update runs in, which `addUpdateRun()` logs: `pages` by default, as in the starters, or a code project's `update`. */
32
+ updateJob?: string;
24
33
  /** Groups the users belong to, which new projects can be created in. */
25
34
  groups?: string[];
26
35
  /** Publishes the site with GitLab Pages. Off by default. */
27
36
  pages?: {
28
37
  uniqueDomain?: boolean;
29
38
  };
39
+ /** Domains another GitLab Pages site already uses. */
40
+ takenDomains?: string[];
30
41
  /** Other GitLab accounts that exist, which can be added to the project. Every token's user exists and is a member. */
31
42
  accounts?: string[];
43
+ /** Who can see the project. Private by default. */
44
+ visibility?: "public" | "internal" | "private";
45
+ }
46
+ /** A pipeline schedule, such as the nightly one or one the admin panel adds for a scheduled change. */
47
+ export interface FakeSchedule {
48
+ id: number;
49
+ ref: string;
50
+ cron: string;
51
+ cronTimezone?: string;
52
+ description?: string;
53
+ active: boolean;
54
+ /** Who added it, who alone (with Maintainers) can change it. */
55
+ owner?: string;
56
+ variables?: Record<string, string>;
32
57
  }
33
58
  /** A member of the site's project. */
34
59
  export interface FakeMember {
@@ -63,6 +88,18 @@ export interface FakeCreatedProject {
63
88
  description?: string;
64
89
  pagesAccessLevel?: string;
65
90
  repo: FakeRepo;
91
+ /** The projects whose job tokens may reach this one (its job token allowlist), by id. */
92
+ jobTokenAllowlist: number[];
93
+ /** Whether its pipelines' job tokens may push to it. */
94
+ jobTokenPush: boolean;
95
+ schedules: Array<{
96
+ description: string;
97
+ ref: string;
98
+ cron: string;
99
+ active: boolean;
100
+ }>;
101
+ /** Domains of its own its Pages were given. */
102
+ pagesDomains: string[];
66
103
  }
67
104
  export declare function fakeGitLab(options: FakeGitLabOptions): {
68
105
  /** How many GraphQL requests read files, as `readFiles()` makes. */
@@ -80,15 +117,11 @@ export declare function fakeGitLab(options: FakeGitLabOptions): {
80
117
  failureReason?: string;
81
118
  trace?: string;
82
119
  }) => void;
120
+ cancelBuild: (sha: string) => void;
83
121
  /** The project's update settings, and the pipelines the update ran in, which tests add with `addUpdateRun()`. */
84
122
  updateSettings: {
85
123
  jobTokenPush: boolean;
86
- schedules: Array<{
87
- id: number;
88
- ref: string;
89
- cron: string;
90
- active: boolean;
91
- }>;
124
+ schedules: FakeSchedule[];
92
125
  };
93
126
  updateRuns: {
94
127
  id: number;
@@ -123,6 +156,11 @@ export declare function fakeGitLab(options: FakeGitLabOptions): {
123
156
  created: Map<string, FakeCreatedProject>;
124
157
  /** The site's GitLab Pages, if it has them, which tests can change. */
125
158
  pages: FakeGitLabPages | undefined;
159
+ /** Who commits that named their author did name, by commit. */
160
+ commitAuthors: Map<string, {
161
+ name: string;
162
+ email: string;
163
+ }>;
126
164
  requests: Request[];
127
165
  commit: (changes: FileChange[], message: string, author?: string, branch?: string) => string;
128
166
  authorize: (authorizeUrl: string) => string;
package/dist/testing.js CHANGED
@@ -20,6 +20,8 @@ async function sha256Base64Url(text) {
20
20
  function fakeGitLab(options) {
21
21
  const webUrl = options.url ?? "https://gitlab.com";
22
22
  const repo = new FakeRepo(options.files);
23
+ /** Who each commit that named its author did name, by commit. */
24
+ const commitAuthors = /* @__PURE__ */ new Map();
23
25
  const tokens = new Map(Object.entries(options.tokens ?? { "test-token": {
24
26
  username: "editor",
25
27
  name: "Test Editor"
@@ -156,6 +158,7 @@ function fakeGitLab(options) {
156
158
  jobTokenPush: false,
157
159
  schedules: []
158
160
  };
161
+ let scheduleIds = 0;
159
162
  /** Pipelines the update ran in: scheduled, or started through the API, with the pages job's log. */
160
163
  const updateRuns = [];
161
164
  /** Simulates a scheduled (or API) pipeline whose pages job logged `trace`. */
@@ -170,7 +173,7 @@ function fakeGitLab(options) {
170
173
  updateRuns.push(added);
171
174
  return added;
172
175
  }
173
- /** Failed jobs, by commit, which tests add with `failBuild()`. */
176
+ /** Failed jobs, by commit, which tests add with `failBuild()` and `cancelBuild()`. */
174
177
  const failures = /* @__PURE__ */ new Map();
175
178
  /**
176
179
  * Simulates the pipeline for a commit failing: its `pages` job (or another,
@@ -180,10 +183,21 @@ function fakeGitLab(options) {
180
183
  if (!pipelines.has(sha)) pipelines.set(sha, { checks: 0 });
181
184
  failures.set(sha, {
182
185
  job: failure.job ?? "pages",
186
+ status: "failed",
183
187
  failureReason: failure.failureReason ?? "script_failure",
184
188
  trace: failure.trace ?? ""
185
189
  });
186
190
  }
191
+ /** Simulates a commit's `pages` job being cancelled, as a newer pipeline on the branch cancels an interruptible one. */
192
+ function cancelBuild(sha) {
193
+ if (!pipelines.has(sha)) pipelines.set(sha, { checks: 0 });
194
+ failures.set(sha, {
195
+ job: "pages",
196
+ status: "canceled",
197
+ failureReason: "",
198
+ trace: ""
199
+ });
200
+ }
187
201
  const pipelineId = (sha) => [...pipelines.keys()].indexOf(sha) + 1;
188
202
  const requests = [];
189
203
  let counter = 0;
@@ -297,7 +311,11 @@ function fakeGitLab(options) {
297
311
  visibility: body.visibility,
298
312
  description: body.description,
299
313
  pagesAccessLevel: body.pages_access_level,
300
- repo: new FakeRepo({})
314
+ repo: new FakeRepo({}),
315
+ jobTokenAllowlist: [],
316
+ jobTokenPush: false,
317
+ schedules: [],
318
+ pagesDomains: []
301
319
  };
302
320
  created.set(fullPath, project);
303
321
  return json({
@@ -311,11 +329,49 @@ function fakeGitLab(options) {
311
329
  const project = created.get(key) ?? [...created.values()].find((candidate) => String(candidate.id) === key);
312
330
  if (!match || !project) return void 0;
313
331
  const rest = match[2] ?? "";
314
- if (rest === "") return json({
332
+ if (rest === "" && request.method === "GET") return json({
315
333
  id: project.id,
316
334
  path_with_namespace: project.path,
317
- default_branch: "main"
335
+ default_branch: "main",
336
+ ci_push_repository_for_job_token_allowed: project.jobTokenPush
318
337
  });
338
+ if (rest === "" && request.method === "PUT") {
339
+ const body = await request.json();
340
+ if (body.ci_push_repository_for_job_token_allowed !== void 0) project.jobTokenPush = body.ci_push_repository_for_job_token_allowed;
341
+ return json({
342
+ id: project.id,
343
+ ci_push_repository_for_job_token_allowed: project.jobTokenPush
344
+ });
345
+ }
346
+ if (rest === "/job_token_scope/allowlist" && request.method === "POST") {
347
+ const body = await request.json();
348
+ if (![...created.values()].some((candidate) => candidate.id === body.target_project_id)) return json({ message: "404 Project Not Found" }, 404);
349
+ if (!project.jobTokenAllowlist.includes(body.target_project_id)) project.jobTokenAllowlist.push(body.target_project_id);
350
+ return json({
351
+ source_project_id: project.id,
352
+ target_project_id: body.target_project_id
353
+ }, 201);
354
+ }
355
+ if (rest === "/pages/domains" && request.method === "POST") {
356
+ const body = await request.json();
357
+ if (options.takenDomains?.includes(body.domain)) return json({ message: { domain: ["has already been taken"] } }, 400);
358
+ project.pagesDomains.push(body.domain);
359
+ return json({
360
+ domain: body.domain,
361
+ url: `https://${body.domain}`
362
+ }, 201);
363
+ }
364
+ if (rest === "/pipeline_schedules" && request.method === "POST") {
365
+ const body = await request.json();
366
+ project.schedules.push({
367
+ ...body,
368
+ active: body.active !== false
369
+ });
370
+ return json({
371
+ id: project.schedules.length,
372
+ ...body
373
+ }, 201);
374
+ }
319
375
  if (rest === "/repository/commits" && request.method === "POST") {
320
376
  const body = await request.json();
321
377
  return json({
@@ -447,12 +503,21 @@ function fakeGitLab(options) {
447
503
  if (membersResponse) return membersResponse;
448
504
  if (rest === "" && request.method !== "PUT") return json({
449
505
  default_branch: repo.defaultBranch,
506
+ visibility: options.visibility ?? "private",
450
507
  permissions: {
451
508
  project_access: { access_level: accessLevel },
452
509
  group_access: null
453
510
  },
454
511
  ci_push_repository_for_job_token_allowed: updateSettings.jobTokenPush
455
512
  });
513
+ if (rest === "/repository/branches" && request.method === "GET") {
514
+ const search = url.searchParams.get("search") ?? "";
515
+ const matches = (name) => search.startsWith("^") ? name.startsWith(search.slice(1)) : name.includes(search);
516
+ return json([...repo.branches].filter(([name]) => matches(name)).map(([name, sha]) => ({
517
+ name,
518
+ commit: { id: sha }
519
+ })));
520
+ }
456
521
  if (rest === "/repository/branches" && request.method === "POST") {
457
522
  if (accessLevel < 30) return json({ message: "403 Forbidden" }, 403);
458
523
  const name = url.searchParams.get("branch") ?? "";
@@ -549,17 +614,22 @@ function fakeGitLab(options) {
549
614
  if ((action.action === "update" || action.action === "delete") && !files.has(action.file_path)) return json({ message: "A file with this name doesn't exist" }, 400);
550
615
  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);
551
616
  }
617
+ const sha = commit(body.actions.map((action) => action.action === "delete" ? {
618
+ path: action.file_path,
619
+ delete: true
620
+ } : action.encoding === "base64" ? {
621
+ path: action.file_path,
622
+ bytes: decodeBase64(action.content ?? "")
623
+ } : {
624
+ path: action.file_path,
625
+ content: action.content ?? ""
626
+ }), body.commit_message, body.author_name ?? user.name ?? user.username, body.branch);
627
+ if (body.author_name && body.author_email) commitAuthors.set(sha, {
628
+ name: body.author_name,
629
+ email: body.author_email
630
+ });
552
631
  return json({
553
- id: commit(body.actions.map((action) => action.action === "delete" ? {
554
- path: action.file_path,
555
- delete: true
556
- } : action.encoding === "base64" ? {
557
- path: action.file_path,
558
- bytes: decodeBase64(action.content ?? "")
559
- } : {
560
- path: action.file_path,
561
- content: action.content ?? ""
562
- }), body.commit_message, user.name ?? user.username, body.branch),
632
+ id: sha,
563
633
  message: body.commit_message
564
634
  }, 201);
565
635
  }
@@ -578,6 +648,15 @@ function fakeGitLab(options) {
578
648
  committed_date: commit.date
579
649
  })));
580
650
  }
651
+ const commitSha = rest.match(/^\/repository\/commits\/([0-9a-f]{40})$/)?.[1];
652
+ if (commitSha) {
653
+ const found = repo.commitAt(commitSha);
654
+ if (!found) return json({ message: "404 Commit Not Found" }, 404);
655
+ return json({
656
+ id: commitSha,
657
+ parent_ids: found.parent ? [found.parent] : []
658
+ });
659
+ }
581
660
  if (rest === "/repository/compare") return json({ diffs: repo.changedPaths(url.searchParams.get("from") ?? "", url.searchParams.get("to") ?? "").map((changedPath) => ({
582
661
  old_path: changedPath,
583
662
  new_path: changedPath
@@ -591,14 +670,15 @@ function fakeGitLab(options) {
591
670
  const id = pipelineId(statuses[1] ?? "");
592
671
  return json([{
593
672
  name: failure.job,
594
- status: "failed",
673
+ status: failure.status,
595
674
  target_url: `${webUrl}/${options.project}/-/jobs/${id}`
596
675
  }]);
597
676
  }
598
677
  pipeline.checks += 1;
678
+ const done = pipeline.checks > (options.deployAfterChecks ?? 0);
599
679
  return json([{
600
- name: "pages",
601
- status: pipeline.checks > (options.deployAfterChecks ?? 0) ? "success" : "running",
680
+ name: options.deployJob ?? "pages",
681
+ status: done ? "success" : "running",
602
682
  target_url: `${webUrl}/${options.project}/-/jobs/1`
603
683
  }]);
604
684
  }
@@ -625,17 +705,82 @@ function fakeGitLab(options) {
625
705
  if (accessLevel < 30) return json({ message: "403 Forbidden" }, 403);
626
706
  if (request.method === "POST") {
627
707
  const body = await request.json();
708
+ const protection = protectedBranches.get(body.ref);
709
+ if (protection && accessLevel < protection.merge) return json({ message: "403 Forbidden" }, 403);
628
710
  const schedule = {
629
- id: updateSettings.schedules.length + 1,
711
+ id: ++scheduleIds,
630
712
  ref: body.ref,
631
713
  cron: body.cron,
632
- active: body.active ?? true
714
+ active: body.active ?? true,
715
+ ...body.description !== void 0 && { description: body.description },
716
+ ...body.cron_timezone !== void 0 && { cronTimezone: body.cron_timezone },
717
+ owner: user.username
633
718
  };
634
719
  updateSettings.schedules.push(schedule);
635
720
  return json(schedule, 201);
636
721
  }
637
722
  return json(updateSettings.schedules);
638
723
  }
724
+ const scheduleMatch = rest.match(/^\/pipeline_schedules\/(\d+)(\/variables)?(?:\/([\w-]+))?$/);
725
+ if (scheduleMatch) {
726
+ const schedule = updateSettings.schedules.find((candidate) => candidate.id === Number(scheduleMatch[1]));
727
+ if (!schedule) return json({ message: "404 Not found" }, 404);
728
+ const variables = () => Object.entries(schedule.variables ?? {}).map(([key, value]) => ({
729
+ key,
730
+ value,
731
+ variable_type: "env_var"
732
+ }));
733
+ if (request.method === "GET" && !scheduleMatch[2]) {
734
+ if (accessLevel < 30) return json({ message: "403 Forbidden" }, 403);
735
+ return json({
736
+ ...schedule,
737
+ variables: variables()
738
+ });
739
+ }
740
+ if (accessLevel < 30 || accessLevel < 40 && schedule.owner !== user.username) return json({ message: "403 Forbidden" }, 403);
741
+ const key = scheduleMatch[3];
742
+ if (scheduleMatch[2] && !key && request.method === "POST") {
743
+ const body = await request.json();
744
+ schedule.variables = {
745
+ ...schedule.variables,
746
+ [body.key]: body.value
747
+ };
748
+ return json(body, 201);
749
+ }
750
+ if (scheduleMatch[2] && key) {
751
+ if (!(key in (schedule.variables ?? {}))) return json({ message: "404 Variable Not Found" }, 404);
752
+ if (request.method === "PUT") {
753
+ const body = await request.json();
754
+ schedule.variables = {
755
+ ...schedule.variables,
756
+ [key]: body.value
757
+ };
758
+ return json({
759
+ key,
760
+ value: body.value
761
+ });
762
+ }
763
+ if (request.method === "DELETE") {
764
+ const { [key]: _removed, ...rest } = schedule.variables ?? {};
765
+ schedule.variables = rest;
766
+ return json({ key });
767
+ }
768
+ }
769
+ if (!scheduleMatch[2] && request.method === "PUT") {
770
+ const body = await request.json();
771
+ if (body.active !== void 0) schedule.active = body.active;
772
+ if (body.cron !== void 0) schedule.cron = body.cron;
773
+ if (body.description !== void 0) schedule.description = body.description;
774
+ return json({
775
+ ...schedule,
776
+ variables: variables()
777
+ });
778
+ }
779
+ if (!scheduleMatch[2] && request.method === "DELETE") {
780
+ updateSettings.schedules = updateSettings.schedules.filter((candidate) => candidate !== schedule);
781
+ return new Response(null, { status: 204 });
782
+ }
783
+ }
639
784
  if (rest === "" && request.method === "PUT") {
640
785
  if (accessLevel < 40) return json({ message: "403 Forbidden" }, 403);
641
786
  const body = await request.json();
@@ -647,7 +792,7 @@ function fakeGitLab(options) {
647
792
  const run = updateRuns.find((candidate) => candidate.id === Number(updateJobs[1]));
648
793
  return run ? json([{
649
794
  id: run.id,
650
- name: "pages",
795
+ name: options.updateJob ?? "pages",
651
796
  status: run.status
652
797
  }]) : json({ message: "404 Not Found" }, 404);
653
798
  }
@@ -658,7 +803,7 @@ function fakeGitLab(options) {
658
803
  }
659
804
  if (rest === "/pipelines") {
660
805
  const sha = url.searchParams.get("sha");
661
- return json([...pipelines.keys()].map((pipelineSha, index) => ({
806
+ return json([...options.external ? [] : pipelines.keys()].map((pipelineSha, index) => ({
662
807
  id: index + 1,
663
808
  sha: pipelineSha
664
809
  })).filter((pipeline) => !sha || pipeline.sha === sha));
@@ -672,7 +817,7 @@ function fakeGitLab(options) {
672
817
  return json([failure ? {
673
818
  id,
674
819
  name: failure.job,
675
- status: "failed",
820
+ status: failure.status,
676
821
  failure_reason: failure.failureReason
677
822
  } : {
678
823
  id,
@@ -698,6 +843,7 @@ function fakeGitLab(options) {
698
843
  invitations,
699
844
  protectedBranches,
700
845
  failBuild,
846
+ cancelBuild,
701
847
  /** The project's update settings, and the pipelines the update ran in, which tests add with `addUpdateRun()`. */
702
848
  updateSettings,
703
849
  updateRuns,
@@ -707,6 +853,8 @@ function fakeGitLab(options) {
707
853
  created,
708
854
  /** The site's GitLab Pages, if it has them, which tests can change. */
709
855
  pages,
856
+ /** Who commits that named their author did name, by commit. */
857
+ commitAuthors,
710
858
  requests,
711
859
  commit,
712
860
  authorize,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodfellow-cms/gitlab",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "Edit a Goodfellow site stored on GitLab, straight from the browser.",
5
5
  "keywords": [
6
6
  "goodfellow",
@@ -35,13 +35,13 @@
35
35
  "dist"
36
36
  ],
37
37
  "dependencies": {
38
- "@goodfellow-cms/core": "0.3.0"
38
+ "@goodfellow-cms/core": "0.5.0"
39
39
  },
40
40
  "devDependencies": {
41
41
  "@puckeditor/core": "0.23.0"
42
42
  },
43
43
  "engines": {
44
- "node": ">=22"
44
+ "node": "^22.23.4 || ^24.22.1 || >=26.12.1"
45
45
  },
46
46
  "publishConfig": {
47
47
  "access": "public"