@goodfellow-cms/gitlab 0.4.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`. */
@@ -46,6 +46,7 @@ export declare class GitLabBackend implements GitBackend {
46
46
  /** Absent where the project holds only content: the update job runs where the code is. */
47
47
  readonly updates?: SiteUpdates;
48
48
  readonly drafts: SiteDrafts;
49
+ readonly scheduledBuilds: ScheduledBuilds;
49
50
  readonly contentOnly: boolean;
50
51
  private readonly options;
51
52
  private readonly api;
@@ -56,23 +57,32 @@ export declare class GitLabBackend implements GitBackend {
56
57
  private readonly trees;
57
58
  private readonly blobs;
58
59
  constructor(options: GitLabBackendOptions);
60
+ repositoryVisibility(): Promise<RepositoryVisibility>;
59
61
  /** Drafts on branches of the project, with merge requests for review. */
60
62
  private draftHost;
61
63
  private repo;
62
64
  revision(): Promise<string>;
65
+ /** The branch's latest commit, without moving the revision reads are pinned to. */
66
+ private head;
63
67
  /** Every file in one folder at a commit, page by page. */
64
68
  private listFolder;
65
69
  /** Path → blob id for the editable files at a commit. Commits never change, so this is cached. */
66
70
  private tree;
67
71
  private currentTree;
68
72
  read(path: string): Promise<string | undefined>;
73
+ private readFrom;
69
74
  /** Reads many text files in a few GraphQL requests rather than one request each. */
70
75
  readFiles(paths: string[]): Promise<Map<string, string>>;
76
+ private readFilesFrom;
71
77
  readBytes(path: string): Promise<Uint8Array | undefined>;
78
+ private bytesFrom;
72
79
  list(dir: string): Promise<string[]>;
80
+ filesAt(revision: string): RevisionFiles;
73
81
  /** GitLab's tree doesn't say how large files are, so they're asked for with GraphQL, in batches. */
74
82
  fileSizes(dir: string): Promise<Map<string, number>>;
75
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;
76
86
  write(changes: FileChange[], { message, expectedRevision }: WriteOptions): Promise<{
77
87
  revision: string;
78
88
  }>;
@@ -81,9 +91,11 @@ export declare class GitLabBackend implements GitBackend {
81
91
  changedPaths(from: string, to: string): Promise<string[]>;
82
92
  /**
83
93
  * Reads the commit's pipeline and external statuses (GitLab Pages jobs, or
84
- * 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.
85
96
  */
86
97
  deployStatus(revision: string): Promise<DeployStatus>;
98
+ private statuses;
87
99
  /** Why a commit's pipeline failed: the failed job's reason, and its log for which command failed and why. */
88
100
  private jobProblem;
89
101
  signOut(): void;
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { ConflictError, DomainError, DraftError, EDITABLE_FOLDERS, EditorsError, GitApiError, REBUILD_JOB, SetupError, SignInError, UpdatesError, assertContentChanges, 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. */
@@ -348,12 +430,16 @@ function gitlabUpdates(api, project, branch, readAt, jobName = "pages") {
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, jobName = "pages") {
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, jobName = "pages") {
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 === jobName);
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,61 @@ function gitlabUpdates(api, project, branch, readAt, jobName = "pages") {
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);
424
565
  }
425
566
  };
426
567
  }
@@ -479,6 +620,9 @@ function gitlabCodeUpdates(options) {
479
620
  },
480
621
  async start(target) {
481
622
  await (await updates()).start(target);
623
+ },
624
+ async rebuild() {
625
+ await (await updates()).rebuild?.();
482
626
  }
483
627
  };
484
628
  }
@@ -490,6 +634,18 @@ const GRAPHQL_BATCH = 50;
490
634
  /** The color and section codes in GitLab's job logs. */
491
635
  const ANSI_CODES = new RegExp(`${String.fromCharCode(27)}\\[[\\d;]*[A-Za-z]`, "g");
492
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
+ /**
493
649
  * The site's files on GitLab. Reads come from the commit `revision()` last
494
650
  * returned. Saves are single commits; each changed file carries the commit it
495
651
  * was last changed in, so GitLab refuses to overwrite anyone else's edits.
@@ -501,6 +657,7 @@ var GitLabBackend = class GitLabBackend {
501
657
  /** Absent where the project holds only content: the update job runs where the code is. */
502
658
  updates;
503
659
  drafts;
660
+ scheduledBuilds;
504
661
  contentOnly;
505
662
  options;
506
663
  api;
@@ -538,6 +695,11 @@ var GitLabBackend = class GitLabBackend {
538
695
  });
539
696
  }
540
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";
541
703
  }
542
704
  /** Drafts on branches of the project, with merge requests for review. */
543
705
  draftHost() {
@@ -571,6 +733,13 @@ var GitLabBackend = class GitLabBackend {
571
733
  if (!response.ok) throw new DraftError("not-found", `There's no draft ${branch}.`);
572
734
  return (await response.json()).id;
573
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
+ },
574
743
  openReview: async ({ branch, title, description }) => {
575
744
  const response = await gitlabRequest(this.api, `${project}/merge_requests`, {
576
745
  method: "POST",
@@ -603,10 +772,13 @@ var GitLabBackend = class GitLabBackend {
603
772
  return `/projects/${this.project}/repository${path}`;
604
773
  }
605
774
  async revision() {
606
- const branch = await gitlabJson(this.api, this.repo(`/branches/${encodeURIComponent(this.branch)}`));
607
- this.pinned = branch.commit.id;
775
+ this.pinned = await this.head();
608
776
  return this.pinned;
609
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
+ }
610
782
  /** Every file in one folder at a commit, page by page. */
611
783
  async listFolder(sha, dir) {
612
784
  const files = [];
@@ -639,8 +811,11 @@ var GitLabBackend = class GitLabBackend {
639
811
  return this.tree(this.pinned ?? await this.revision());
640
812
  }
641
813
  async read(path) {
642
- const sha = (await this.currentTree()).get(path);
643
- 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);
644
819
  let blob = this.blobs.get(sha);
645
820
  if (!blob) {
646
821
  blob = gitlabRequest(this.api, this.repo(`/blobs/${sha}/raw`)).then((response) => response.text());
@@ -651,10 +826,12 @@ var GitLabBackend = class GitLabBackend {
651
826
  }
652
827
  /** Reads many text files in a few GraphQL requests rather than one request each. */
653
828
  async readFiles(paths) {
654
- 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) {
655
833
  const wanted = paths.filter((path) => tree.has(path));
656
834
  const fetch = wanted.filter((path) => !this.blobs.has(tree.get(path) ?? ""));
657
- const ref = this.pinned ?? await this.revision();
658
835
  for (let start = 0; start < fetch.length; start += GRAPHQL_BATCH) {
659
836
  const batch = fetch.slice(start, start + GRAPHQL_BATCH);
660
837
  const response = await gitlabJson(this.api, "/../graphql", {
@@ -677,20 +854,30 @@ var GitLabBackend = class GitLabBackend {
677
854
  }
678
855
  const result = /* @__PURE__ */ new Map();
679
856
  for (const path of wanted) {
680
- const text = await this.read(path);
857
+ const text = await this.readFrom(tree, path);
681
858
  if (text !== void 0) result.set(path, text);
682
859
  }
683
860
  return result;
684
861
  }
685
862
  async readBytes(path) {
686
- 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);
687
867
  if (!sha) return void 0;
688
868
  const response = await gitlabRequest(this.api, this.repo(`/blobs/${sha}/raw`));
689
869
  return new Uint8Array(await response.arrayBuffer());
690
870
  }
691
871
  async list(dir) {
692
- const prefix = `${dir}/`;
693
- 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
+ };
694
881
  }
695
882
  /** GitLab's tree doesn't say how large files are, so they're asked for with GraphQL, in batches. */
696
883
  async fileSizes(dir) {
@@ -721,12 +908,20 @@ var GitLabBackend = class GitLabBackend {
721
908
  async lastCommitFor(path, ref) {
722
909
  return (await gitlabJson(this.api, this.repo(`/files/${encodeURIComponent(path)}?ref=${ref}`))).last_commit_id;
723
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
+ }
724
919
  async write(changes, { message, expectedRevision }) {
725
920
  if (this.contentOnly) assertContentChanges(changes);
726
921
  if (await this.revision() !== expectedRevision) throw new ConflictError();
727
922
  const existing = await this.tree(expectedRevision);
728
923
  const toApply = (await Promise.all(changes.map(async (change) => {
729
- const exists = existing.has(change.path);
924
+ const exists = await this.existsAt(existing, change.path, expectedRevision);
730
925
  if ("delete" in change) return exists ? {
731
926
  action: "delete",
732
927
  file_path: change.path,
@@ -798,15 +993,24 @@ var GitLabBackend = class GitLabBackend {
798
993
  }
799
994
  /**
800
995
  * Reads the commit's pipeline and external statuses (GitLab Pages jobs, or
801
- * 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.
802
998
  */
803
999
  async deployStatus(revision) {
804
1000
  try {
805
- const all = await gitlabJson(this.api, this.repo(`/commits/${revision}/statuses`));
806
- const statuses = all.filter((status) => status.name !== REBUILD_JOB);
1001
+ const all = await this.statuses(revision);
1002
+ const statuses = all.filter(builds);
807
1003
  if (all.length > 0 && statuses.length === 0) return { state: "unknown" };
808
- if (statuses.length === 0) return { state: (await gitlabJson(this.api, `/projects/${this.project}/pipelines?per_page=1`)).length > 0 ? "building" : "unknown" };
809
- const failed = statuses.find((status) => status.status === "failed" || status.status === "canceled");
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
+ }
810
1014
  if (failed) {
811
1015
  const problem = await this.jobProblem(revision).catch(() => void 0);
812
1016
  return {
@@ -828,6 +1032,9 @@ var GitLabBackend = class GitLabBackend {
828
1032
  throw error;
829
1033
  }
830
1034
  }
1035
+ statuses(revision) {
1036
+ return gitlabJson(this.api, this.repo(`/commits/${revision}/statuses`));
1037
+ }
831
1038
  /** Why a commit's pipeline failed: the failed job's reason, and its log for which command failed and why. */
832
1039
  async jobProblem(revision) {
833
1040
  const [pipeline] = await gitlabJson(this.api, `/projects/${this.project}/pipelines?${new URLSearchParams({
@@ -1029,6 +1236,29 @@ function gitlabSetup(options = {}) {
1029
1236
  onStep?.("connect");
1030
1237
  if (!await connectProjects(site.link)) warnings.push("connect-unavailable");
1031
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
+ }
1032
1262
  onStep?.("files");
1033
1263
  await gitlabRequest(api, `/projects/${project.id}/repository/commits`, {
1034
1264
  method: "POST",
@@ -1050,6 +1280,7 @@ function gitlabSetup(options = {}) {
1050
1280
  return {
1051
1281
  repo: project.path_with_namespace,
1052
1282
  webUrl: project.web_url,
1283
+ ...siteUrl && { siteUrl },
1053
1284
  buildsUrl: `${project.web_url}/-/pipelines`,
1054
1285
  pagesUrl: site.pages ? `${project.web_url}/pages` : void 0,
1055
1286
  warnings
@@ -1265,7 +1496,18 @@ function gitlab(options) {
1265
1496
  }
1266
1497
  return {
1267
1498
  name: "GitLab",
1499
+ source: hostSource("@goodfellow-cms/gitlab", "gitlab", options, [
1500
+ "fetch",
1501
+ "storage",
1502
+ "location"
1503
+ ]),
1268
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
+ }),
1269
1511
  redirectAddressesListed: Boolean(options.clientId),
1270
1512
  tokenLinks: [{
1271
1513
  url: `${webUrl}/-/user_settings/personal_access_tokens?${new URLSearchParams({
package/dist/testing.d.ts CHANGED
@@ -23,6 +23,11 @@ export interface FakeGitLabOptions {
23
23
  deployAfterChecks?: number;
24
24
  /** The job each published commit's pipeline runs: `pages` by default. */
25
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;
26
31
  /** The job the update runs in, which `addUpdateRun()` logs: `pages` by default, as in the starters, or a code project's `update`. */
27
32
  updateJob?: string;
28
33
  /** Groups the users belong to, which new projects can be created in. */
@@ -31,8 +36,24 @@ export interface FakeGitLabOptions {
31
36
  pages?: {
32
37
  uniqueDomain?: boolean;
33
38
  };
39
+ /** Domains another GitLab Pages site already uses. */
40
+ takenDomains?: string[];
34
41
  /** Other GitLab accounts that exist, which can be added to the project. Every token's user exists and is a member. */
35
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>;
36
57
  }
37
58
  /** A member of the site's project. */
38
59
  export interface FakeMember {
@@ -77,6 +98,8 @@ export interface FakeCreatedProject {
77
98
  cron: string;
78
99
  active: boolean;
79
100
  }>;
101
+ /** Domains of its own its Pages were given. */
102
+ pagesDomains: string[];
80
103
  }
81
104
  export declare function fakeGitLab(options: FakeGitLabOptions): {
82
105
  /** How many GraphQL requests read files, as `readFiles()` makes. */
@@ -94,15 +117,11 @@ export declare function fakeGitLab(options: FakeGitLabOptions): {
94
117
  failureReason?: string;
95
118
  trace?: string;
96
119
  }) => void;
120
+ cancelBuild: (sha: string) => void;
97
121
  /** The project's update settings, and the pipelines the update ran in, which tests add with `addUpdateRun()`. */
98
122
  updateSettings: {
99
123
  jobTokenPush: boolean;
100
- schedules: Array<{
101
- id: number;
102
- ref: string;
103
- cron: string;
104
- active: boolean;
105
- }>;
124
+ schedules: FakeSchedule[];
106
125
  };
107
126
  updateRuns: {
108
127
  id: number;
package/dist/testing.js CHANGED
@@ -158,6 +158,7 @@ function fakeGitLab(options) {
158
158
  jobTokenPush: false,
159
159
  schedules: []
160
160
  };
161
+ let scheduleIds = 0;
161
162
  /** Pipelines the update ran in: scheduled, or started through the API, with the pages job's log. */
162
163
  const updateRuns = [];
163
164
  /** Simulates a scheduled (or API) pipeline whose pages job logged `trace`. */
@@ -172,7 +173,7 @@ function fakeGitLab(options) {
172
173
  updateRuns.push(added);
173
174
  return added;
174
175
  }
175
- /** Failed jobs, by commit, which tests add with `failBuild()`. */
176
+ /** Failed jobs, by commit, which tests add with `failBuild()` and `cancelBuild()`. */
176
177
  const failures = /* @__PURE__ */ new Map();
177
178
  /**
178
179
  * Simulates the pipeline for a commit failing: its `pages` job (or another,
@@ -182,10 +183,21 @@ function fakeGitLab(options) {
182
183
  if (!pipelines.has(sha)) pipelines.set(sha, { checks: 0 });
183
184
  failures.set(sha, {
184
185
  job: failure.job ?? "pages",
186
+ status: "failed",
185
187
  failureReason: failure.failureReason ?? "script_failure",
186
188
  trace: failure.trace ?? ""
187
189
  });
188
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
+ }
189
201
  const pipelineId = (sha) => [...pipelines.keys()].indexOf(sha) + 1;
190
202
  const requests = [];
191
203
  let counter = 0;
@@ -302,7 +314,8 @@ function fakeGitLab(options) {
302
314
  repo: new FakeRepo({}),
303
315
  jobTokenAllowlist: [],
304
316
  jobTokenPush: false,
305
- schedules: []
317
+ schedules: [],
318
+ pagesDomains: []
306
319
  };
307
320
  created.set(fullPath, project);
308
321
  return json({
@@ -339,6 +352,15 @@ function fakeGitLab(options) {
339
352
  target_project_id: body.target_project_id
340
353
  }, 201);
341
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
+ }
342
364
  if (rest === "/pipeline_schedules" && request.method === "POST") {
343
365
  const body = await request.json();
344
366
  project.schedules.push({
@@ -481,12 +503,21 @@ function fakeGitLab(options) {
481
503
  if (membersResponse) return membersResponse;
482
504
  if (rest === "" && request.method !== "PUT") return json({
483
505
  default_branch: repo.defaultBranch,
506
+ visibility: options.visibility ?? "private",
484
507
  permissions: {
485
508
  project_access: { access_level: accessLevel },
486
509
  group_access: null
487
510
  },
488
511
  ci_push_repository_for_job_token_allowed: updateSettings.jobTokenPush
489
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
+ }
490
521
  if (rest === "/repository/branches" && request.method === "POST") {
491
522
  if (accessLevel < 30) return json({ message: "403 Forbidden" }, 403);
492
523
  const name = url.searchParams.get("branch") ?? "";
@@ -617,6 +648,15 @@ function fakeGitLab(options) {
617
648
  committed_date: commit.date
618
649
  })));
619
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
+ }
620
660
  if (rest === "/repository/compare") return json({ diffs: repo.changedPaths(url.searchParams.get("from") ?? "", url.searchParams.get("to") ?? "").map((changedPath) => ({
621
661
  old_path: changedPath,
622
662
  new_path: changedPath
@@ -630,7 +670,7 @@ function fakeGitLab(options) {
630
670
  const id = pipelineId(statuses[1] ?? "");
631
671
  return json([{
632
672
  name: failure.job,
633
- status: "failed",
673
+ status: failure.status,
634
674
  target_url: `${webUrl}/${options.project}/-/jobs/${id}`
635
675
  }]);
636
676
  }
@@ -665,17 +705,82 @@ function fakeGitLab(options) {
665
705
  if (accessLevel < 30) return json({ message: "403 Forbidden" }, 403);
666
706
  if (request.method === "POST") {
667
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);
668
710
  const schedule = {
669
- id: updateSettings.schedules.length + 1,
711
+ id: ++scheduleIds,
670
712
  ref: body.ref,
671
713
  cron: body.cron,
672
- 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
673
718
  };
674
719
  updateSettings.schedules.push(schedule);
675
720
  return json(schedule, 201);
676
721
  }
677
722
  return json(updateSettings.schedules);
678
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
+ }
679
784
  if (rest === "" && request.method === "PUT") {
680
785
  if (accessLevel < 40) return json({ message: "403 Forbidden" }, 403);
681
786
  const body = await request.json();
@@ -698,7 +803,7 @@ function fakeGitLab(options) {
698
803
  }
699
804
  if (rest === "/pipelines") {
700
805
  const sha = url.searchParams.get("sha");
701
- return json([...pipelines.keys()].map((pipelineSha, index) => ({
806
+ return json([...options.external ? [] : pipelines.keys()].map((pipelineSha, index) => ({
702
807
  id: index + 1,
703
808
  sha: pipelineSha
704
809
  })).filter((pipeline) => !sha || pipeline.sha === sha));
@@ -712,7 +817,7 @@ function fakeGitLab(options) {
712
817
  return json([failure ? {
713
818
  id,
714
819
  name: failure.job,
715
- status: "failed",
820
+ status: failure.status,
716
821
  failure_reason: failure.failureReason
717
822
  } : {
718
823
  id,
@@ -738,6 +843,7 @@ function fakeGitLab(options) {
738
843
  invitations,
739
844
  protectedBranches,
740
845
  failBuild,
846
+ cancelBuild,
741
847
  /** The project's update settings, and the pipelines the update ran in, which tests add with `addUpdateRun()`. */
742
848
  updateSettings,
743
849
  updateRuns,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodfellow-cms/gitlab",
3
- "version": "0.4.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.4.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"