@goodfellow-cms/gitlab 0.4.0 → 0.6.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/README.md CHANGED
@@ -8,7 +8,7 @@ import { gitlab } from "@goodfellow-cms/gitlab";
8
8
  export default defineConfig({ blocks, backend: gitlab({ project: "your-name/your-site" }) });
9
9
  ```
10
10
 
11
- [Goodfellow](https://github.com/goodfellow-cms/goodfellow-cms) is a website builder for people who aren't developers, with no server or database: pages are files in your own GitHub or GitLab repository, edited in a visual editor built on [Puck](https://puckeditor.com). To start a new site, run `npm create goodfellow@latest my-site`. See the [documentation](https://goodfellow-cms.github.io/goodfellow-cms/) for everything else.
11
+ [Goodfellow](https://github.com/goodfellow-cms/goodfellow-cms) is a website builder for people who aren't developers, with no server or database: pages are files in your own GitHub, GitLab or Bitbucket repository, edited in a visual editor built on [Puck](https://puckeditor.com). To start a new site, run `npm create goodfellow@latest my-site`. See the [documentation](https://goodfellow-cms.github.io/goodfellow-cms/) for everything else.
12
12
 
13
13
  More in the docs: [Hosts and business sites](https://goodfellow-cms.github.io/goodfellow-cms/docs/hosts).
14
14
 
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, PluginTasks, 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`. */
@@ -12,6 +12,15 @@ interface ApiOptions {
12
12
  fetch: typeof fetch;
13
13
  }
14
14
  //#endregion
15
+ //#region src/tasks.d.ts
16
+ interface GitLabTasksOptions {
17
+ /** How often to look for the answer, and how long to wait for it. */
18
+ pollMs?: number;
19
+ timeoutMs?: number;
20
+ /** Waits between looks; tests pass one that doesn't. */
21
+ wait?: (ms: number) => Promise<void>;
22
+ }
23
+ //#endregion
15
24
  //#region src/backend.d.ts
16
25
  interface GitLabBackendOptions {
17
26
  api: ApiOptions;
@@ -26,6 +35,8 @@ interface GitLabBackendOptions {
26
35
  };
27
36
  /** The commit reads see until `revision()` is next called, as a full-stack site's server reads what a browser loaded. */
28
37
  revision?: string;
38
+ /** How plugins' tasks wait for their answers; tests pass short waits. */
39
+ tasks?: GitLabTasksOptions;
29
40
  /** The project holds only the site's content, with its code in a project of its own (`GitBackend.contentOnly`). */
30
41
  contentOnly?: boolean;
31
42
  /** The code project, for a project that holds only content, where the site's updates run. */
@@ -46,6 +57,8 @@ export declare class GitLabBackend implements GitBackend {
46
57
  /** Absent where the project holds only content: the update job runs where the code is. */
47
58
  readonly updates?: SiteUpdates;
48
59
  readonly drafts: SiteDrafts;
60
+ readonly scheduledBuilds: ScheduledBuilds;
61
+ readonly tasks: PluginTasks;
49
62
  readonly contentOnly: boolean;
50
63
  private readonly options;
51
64
  private readonly api;
@@ -56,23 +69,32 @@ export declare class GitLabBackend implements GitBackend {
56
69
  private readonly trees;
57
70
  private readonly blobs;
58
71
  constructor(options: GitLabBackendOptions);
72
+ repositoryVisibility(): Promise<RepositoryVisibility>;
59
73
  /** Drafts on branches of the project, with merge requests for review. */
60
74
  private draftHost;
61
75
  private repo;
62
76
  revision(): Promise<string>;
77
+ /** The branch's latest commit, without moving the revision reads are pinned to. */
78
+ private head;
63
79
  /** Every file in one folder at a commit, page by page. */
64
80
  private listFolder;
65
81
  /** Path → blob id for the editable files at a commit. Commits never change, so this is cached. */
66
82
  private tree;
67
83
  private currentTree;
68
84
  read(path: string): Promise<string | undefined>;
85
+ private readFrom;
69
86
  /** Reads many text files in a few GraphQL requests rather than one request each. */
70
87
  readFiles(paths: string[]): Promise<Map<string, string>>;
88
+ private readFilesFrom;
71
89
  readBytes(path: string): Promise<Uint8Array | undefined>;
90
+ private bytesFrom;
72
91
  list(dir: string): Promise<string[]>;
92
+ filesAt(revision: string): RevisionFiles;
73
93
  /** GitLab's tree doesn't say how large files are, so they're asked for with GraphQL, in batches. */
74
94
  fileSizes(dir: string): Promise<Map<string, number>>;
75
95
  private lastCommitFor;
96
+ /** Whether a file exists at a commit: from the cached tree in the editable folders, and from GitLab elsewhere, such as the config. */
97
+ private existsAt;
76
98
  write(changes: FileChange[], { message, expectedRevision }: WriteOptions): Promise<{
77
99
  revision: string;
78
100
  }>;
@@ -81,9 +103,11 @@ export declare class GitLabBackend implements GitBackend {
81
103
  changedPaths(from: string, to: string): Promise<string[]>;
82
104
  /**
83
105
  * 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.
106
+ * Vercel or Cloudflare Pages). Before any exist, the site counts as building
107
+ * if it has pipelines, or if the host reported on the commit before.
85
108
  */
86
109
  deployStatus(revision: string): Promise<DeployStatus>;
110
+ private statuses;
87
111
  /** Why a commit's pipeline failed: the failed job's reason, and its log for which command failed and why. */
88
112
  private jobProblem;
89
113
  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, LOCKFILE_JOB, NIGHTLY_VARIABLE, PluginTaskError, REBUILD_JOB, ScheduledBuildsError, SetupError, SignInError, TASK_JOB, TASK_POLL_MS, TASK_RESULT_FILE, TASK_TIMEOUT_MS, UpdatesError, assertContentChanges, branchDrafts, buildStepFor, credentialStorage, encodeBase64Bytes, findBuildCause, findUpdateResult, hostSource, taskRequestProblem } 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,146 @@ 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
414
+ //#region src/tasks.ts
415
+ /**
416
+ * Plugins' tasks on GitLab, through the starters' `plugin-task` job: a
417
+ * pipeline started with the request's variables (`GOODFELLOW_TASK_PLUGIN` and
418
+ * the rest), whose job leaves the sealed answer as its artifact, read here.
419
+ * Starting pipelines on the main branch needs Developer access, which
420
+ * editors have once `letEditorsPublish()` lets them publish.
421
+ */
422
+ function gitlabTasks(api, project, branch, readSetup, options = {}) {
423
+ const base = `/projects/${encodeURIComponent(project)}`;
424
+ const wait = options.wait ?? ((ms) => new Promise((done) => setTimeout(done, ms)));
425
+ const supported = async () => (await readSetup().catch(() => void 0) ?? "").includes("$GOODFELLOW_TASK_PLUGIN");
426
+ return {
427
+ supported,
428
+ secretsPlace: async () => ({
429
+ kind: "gitlab",
430
+ url: `${api.apiUrl.replace(/\/api\/v4\/?$/, "")}/${project}/-/settings/ci_cd#js-cicd-variables-settings`
431
+ }),
432
+ async run(request) {
433
+ const problem = taskRequestProblem(request);
434
+ if (problem) throw new PluginTaskError("failed", problem);
435
+ if (!await supported()) throw new PluginTaskError("missing", "The site's setup files don't run plugins' tasks: .gitlab-ci.yml needs the plugin-task job.");
436
+ const variable = (key, value) => ({
437
+ key,
438
+ variable_type: "env_var",
439
+ value
440
+ });
441
+ let pipeline;
442
+ try {
443
+ pipeline = await gitlabJson(api, `${base}/pipeline`, {
444
+ method: "POST",
445
+ body: {
446
+ ref: branch,
447
+ variables: [
448
+ variable("GOODFELLOW_TASK_REQUEST", request.id),
449
+ variable("GOODFELLOW_TASK_PLUGIN", request.plugin),
450
+ variable("GOODFELLOW_TASK_NAME", request.task),
451
+ variable("GOODFELLOW_TASK_INPUT", request.input),
452
+ variable("GOODFELLOW_TASK_KEY", request.key)
453
+ ]
454
+ }
455
+ });
456
+ } catch (error) {
457
+ if (error instanceof GitApiError && (error.status === 403 || error.status === 401)) throw new PluginTaskError("not-allowed", "Running plugins' tasks needs a sign-in that can publish to the site.");
458
+ throw error;
459
+ }
460
+ const deadline = Date.now() + (options.timeoutMs ?? TASK_TIMEOUT_MS);
461
+ for (;;) {
462
+ const job = (await gitlabJson(api, `${base}/pipelines/${pipeline.id}/jobs?per_page=100`)).find((candidate) => candidate.name === TASK_JOB);
463
+ if (job?.status === "success") {
464
+ const answer = await gitlabRequest(api, `${base}/jobs/${job.id}/artifacts/${TASK_RESULT_FILE}`, { allow: [404] });
465
+ if (answer.ok) return await answer.text();
466
+ throw new PluginTaskError("failed", "The site's build service ran the task but left no answer.");
467
+ }
468
+ if (job && [
469
+ "failed",
470
+ "canceled",
471
+ "skipped"
472
+ ].includes(job.status)) throw new PluginTaskError("failed", "The site's build service couldn't run the task.");
473
+ if (Date.now() >= deadline) throw new PluginTaskError("timeout", "The site's build service didn't answer in time.");
474
+ await wait(options.pollMs ?? TASK_POLL_MS);
475
+ }
476
+ }
477
+ };
478
+ }
479
+ //#endregion
332
480
  //#region src/updates.ts
333
481
  const CI_FILE = ".gitlab-ci.yml";
334
482
  /** When the nightly schedule the Updates screen adds runs, in UTC, as the GitHub Pages workflow's does. */
@@ -348,12 +496,16 @@ function gitlabUpdates(api, project, branch, readAt, jobName = "pages") {
348
496
  async function schedules() {
349
497
  return gitlabJson(api, `${base}/pipeline_schedules`);
350
498
  }
499
+ /** The nightly schedule: the branch's, other than those the admin panel adds for scheduled changes. */
500
+ async function nightlySchedule() {
501
+ 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 "));
502
+ }
351
503
  return {
352
504
  async setup() {
353
505
  if (!(await readAt(CI_FILE))?.includes("update --publish")) return "missing";
354
506
  try {
355
507
  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}`));
508
+ 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
509
  return settings.ci_push_repository_for_job_token_allowed && scheduled ? "ready" : "needs-setup";
358
510
  } catch (error) {
359
511
  if (error instanceof GitApiError && error.status === 403) return "needs-setup";
@@ -365,7 +517,7 @@ function gitlabUpdates(api, project, branch, readAt, jobName = "pages") {
365
517
  method: "PUT",
366
518
  body: { ci_push_repository_for_job_token_allowed: true }
367
519
  }).catch(notAllowed);
368
- if (!(await schedules().catch(notAllowed)).some((schedule) => schedule.active && schedule.ref.endsWith(branch))) await gitlabRequest(api, `${base}/pipeline_schedules`, {
520
+ if (!await nightlySchedule().catch(notAllowed)) await gitlabRequest(api, `${base}/pipeline_schedules`, {
369
521
  method: "POST",
370
522
  body: {
371
523
  description: "Rebuild the site and install Goodfellow's fixes each night",
@@ -397,7 +549,7 @@ function gitlabUpdates(api, project, branch, readAt, jobName = "pages") {
397
549
  state: "running",
398
550
  ...run
399
551
  };
400
- const job = (await gitlabJson(api, `${base}/pipelines/${pipeline.id}/jobs?per_page=100`)).find((candidate) => candidate.name === jobName);
552
+ const job = (await gitlabJson(api, `${base}/pipelines/${pipeline.id}/jobs?per_page=100`)).find((candidate) => candidate.name === jobName || candidate.name === "update");
401
553
  if (!job) continue;
402
554
  const response = await gitlabRequest(api, `${base}/jobs/${job.id}/trace`, { allow: [403, 404] });
403
555
  const result = response.ok ? findUpdateResult(await response.text()) : void 0;
@@ -421,6 +573,61 @@ function gitlabUpdates(api, project, branch, readAt, jobName = "pages") {
421
573
  }]
422
574
  }
423
575
  }).catch(notAllowed);
576
+ },
577
+ nightly: {
578
+ async supported() {
579
+ return (await readAt(CI_FILE))?.includes("nightly") ?? false;
580
+ },
581
+ async get() {
582
+ try {
583
+ const found = await nightlySchedule();
584
+ if (!found) return void 0;
585
+ if (!found.active) return "off";
586
+ return (await gitlabJson(api, `${base}/pipeline_schedules/${found.id}`)).variables?.find((variable) => variable.key === NIGHTLY_VARIABLE)?.value === "always" ? "always" : "changed";
587
+ } catch (error) {
588
+ if (error instanceof GitApiError && error.status === 403) return void 0;
589
+ throw error;
590
+ }
591
+ },
592
+ async set(mode) {
593
+ const found = await nightlySchedule().catch(notAllowed);
594
+ if (!found) throw new UpdatesError("missing", "The site has no nightly schedule yet: turn on updates first.");
595
+ const path = `${base}/pipeline_schedules/${found.id}`;
596
+ await gitlabRequest(api, path, {
597
+ method: "PUT",
598
+ body: { active: mode !== "off" }
599
+ }).catch(notAllowed);
600
+ if (mode === "always") {
601
+ const body = {
602
+ key: NIGHTLY_VARIABLE,
603
+ value: "always"
604
+ };
605
+ if ((await gitlabRequest(api, `${path}/variables/${NIGHTLY_VARIABLE}`, {
606
+ method: "PUT",
607
+ body,
608
+ allow: [404]
609
+ }).catch(notAllowed)).status === 404) await gitlabRequest(api, `${path}/variables`, {
610
+ method: "POST",
611
+ body
612
+ }).catch(notAllowed);
613
+ } else if (mode === "changed") await gitlabRequest(api, `${path}/variables/${NIGHTLY_VARIABLE}`, {
614
+ method: "DELETE",
615
+ allow: [404]
616
+ }).catch(notAllowed);
617
+ }
618
+ },
619
+ async rebuild() {
620
+ await gitlabRequest(api, `${base}/pipeline`, {
621
+ method: "POST",
622
+ body: {
623
+ ref: branch,
624
+ variables: [{
625
+ key: "GOODFELLOW_REBUILD",
626
+ variable_type: "env_var",
627
+ value: "true"
628
+ }]
629
+ }
630
+ }).catch(notAllowed);
424
631
  }
425
632
  };
426
633
  }
@@ -479,6 +686,9 @@ function gitlabCodeUpdates(options) {
479
686
  },
480
687
  async start(target) {
481
688
  await (await updates()).start(target);
689
+ },
690
+ async rebuild() {
691
+ await (await updates()).rebuild?.();
482
692
  }
483
693
  };
484
694
  }
@@ -490,6 +700,19 @@ const GRAPHQL_BATCH = 50;
490
700
  /** The color and section codes in GitLab's job logs. */
491
701
  const ANSI_CODES = new RegExp(`${String.fromCharCode(27)}\\[[\\d;]*[A-Za-z]`, "g");
492
702
  /**
703
+ * Whether a status says when the site is live. A job that only asks another
704
+ * host to rebuild the site doesn't, and nor do the nightly update and the
705
+ * job that writes the lockfile where Cloudflare builds the site, which run
706
+ * on the newest commit too.
707
+ */
708
+ function builds(status) {
709
+ return status.name !== REBUILD_JOB && status.name !== "update" && status.name !== LOCKFILE_JOB;
710
+ }
711
+ function listFrom(tree, dir) {
712
+ const prefix = `${dir}/`;
713
+ return [...tree.keys()].filter((path) => path.startsWith(prefix)).sort();
714
+ }
715
+ /**
493
716
  * The site's files on GitLab. Reads come from the commit `revision()` last
494
717
  * returned. Saves are single commits; each changed file carries the commit it
495
718
  * was last changed in, so GitLab refuses to overwrite anyone else's edits.
@@ -501,6 +724,8 @@ var GitLabBackend = class GitLabBackend {
501
724
  /** Absent where the project holds only content: the update job runs where the code is. */
502
725
  updates;
503
726
  drafts;
727
+ scheduledBuilds;
728
+ tasks;
504
729
  contentOnly;
505
730
  options;
506
731
  api;
@@ -538,6 +763,12 @@ var GitLabBackend = class GitLabBackend {
538
763
  });
539
764
  }
540
765
  this.drafts = branchDrafts(this, this.draftHost());
766
+ this.scheduledBuilds = gitlabScheduledBuilds(this.api, options.project, this.branch, async () => this.readAt(".gitlab-ci.yml", this.pinned ?? await this.revision()));
767
+ this.tasks = gitlabTasks(this.api, options.project, this.branch, async () => this.readAt(".gitlab-ci.yml", this.pinned ?? await this.revision()), options.tasks);
768
+ }
769
+ async repositoryVisibility() {
770
+ const { visibility } = await gitlabJson(this.api, `/projects/${this.project}`);
771
+ return visibility === "public" || visibility === "internal" ? visibility : "private";
541
772
  }
542
773
  /** Drafts on branches of the project, with merge requests for review. */
543
774
  draftHost() {
@@ -571,6 +802,13 @@ var GitLabBackend = class GitLabBackend {
571
802
  if (!response.ok) throw new DraftError("not-found", `There's no draft ${branch}.`);
572
803
  return (await response.json()).id;
573
804
  },
805
+ listBranches: async (prefix) => {
806
+ const query = new URLSearchParams({
807
+ search: `^${prefix}`,
808
+ per_page: "100"
809
+ });
810
+ return (await gitlabJson(this.api, this.repo(`/branches?${query}`))).map((found) => found.name).filter((name) => name.startsWith(prefix));
811
+ },
574
812
  openReview: async ({ branch, title, description }) => {
575
813
  const response = await gitlabRequest(this.api, `${project}/merge_requests`, {
576
814
  method: "POST",
@@ -603,10 +841,13 @@ var GitLabBackend = class GitLabBackend {
603
841
  return `/projects/${this.project}/repository${path}`;
604
842
  }
605
843
  async revision() {
606
- const branch = await gitlabJson(this.api, this.repo(`/branches/${encodeURIComponent(this.branch)}`));
607
- this.pinned = branch.commit.id;
844
+ this.pinned = await this.head();
608
845
  return this.pinned;
609
846
  }
847
+ /** The branch's latest commit, without moving the revision reads are pinned to. */
848
+ async head() {
849
+ return (await gitlabJson(this.api, this.repo(`/branches/${encodeURIComponent(this.branch)}`))).commit.id;
850
+ }
610
851
  /** Every file in one folder at a commit, page by page. */
611
852
  async listFolder(sha, dir) {
612
853
  const files = [];
@@ -639,8 +880,11 @@ var GitLabBackend = class GitLabBackend {
639
880
  return this.tree(this.pinned ?? await this.revision());
640
881
  }
641
882
  async read(path) {
642
- const sha = (await this.currentTree()).get(path);
643
- if (!sha) return void 0;
883
+ return this.readFrom(await this.currentTree(), path);
884
+ }
885
+ readFrom(tree, path) {
886
+ const sha = tree.get(path);
887
+ if (!sha) return Promise.resolve(void 0);
644
888
  let blob = this.blobs.get(sha);
645
889
  if (!blob) {
646
890
  blob = gitlabRequest(this.api, this.repo(`/blobs/${sha}/raw`)).then((response) => response.text());
@@ -651,10 +895,12 @@ var GitLabBackend = class GitLabBackend {
651
895
  }
652
896
  /** Reads many text files in a few GraphQL requests rather than one request each. */
653
897
  async readFiles(paths) {
654
- const tree = await this.currentTree();
898
+ const ref = this.pinned ?? await this.revision();
899
+ return this.readFilesFrom(ref, await this.tree(ref), paths);
900
+ }
901
+ async readFilesFrom(ref, tree, paths) {
655
902
  const wanted = paths.filter((path) => tree.has(path));
656
903
  const fetch = wanted.filter((path) => !this.blobs.has(tree.get(path) ?? ""));
657
- const ref = this.pinned ?? await this.revision();
658
904
  for (let start = 0; start < fetch.length; start += GRAPHQL_BATCH) {
659
905
  const batch = fetch.slice(start, start + GRAPHQL_BATCH);
660
906
  const response = await gitlabJson(this.api, "/../graphql", {
@@ -677,20 +923,30 @@ var GitLabBackend = class GitLabBackend {
677
923
  }
678
924
  const result = /* @__PURE__ */ new Map();
679
925
  for (const path of wanted) {
680
- const text = await this.read(path);
926
+ const text = await this.readFrom(tree, path);
681
927
  if (text !== void 0) result.set(path, text);
682
928
  }
683
929
  return result;
684
930
  }
685
931
  async readBytes(path) {
686
- const sha = (await this.currentTree()).get(path);
932
+ return this.bytesFrom(await this.currentTree(), path);
933
+ }
934
+ async bytesFrom(tree, path) {
935
+ const sha = tree.get(path);
687
936
  if (!sha) return void 0;
688
937
  const response = await gitlabRequest(this.api, this.repo(`/blobs/${sha}/raw`));
689
938
  return new Uint8Array(await response.arrayBuffer());
690
939
  }
691
940
  async list(dir) {
692
- const prefix = `${dir}/`;
693
- return [...(await this.currentTree()).keys()].filter((path) => path.startsWith(prefix)).sort();
941
+ return listFrom(await this.currentTree(), dir);
942
+ }
943
+ filesAt(revision) {
944
+ return {
945
+ read: async (path) => this.readFrom(await this.tree(revision), path),
946
+ readFiles: async (paths) => this.readFilesFrom(revision, await this.tree(revision), paths),
947
+ readBytes: async (path) => this.bytesFrom(await this.tree(revision), path),
948
+ list: async (dir) => listFrom(await this.tree(revision), dir)
949
+ };
694
950
  }
695
951
  /** GitLab's tree doesn't say how large files are, so they're asked for with GraphQL, in batches. */
696
952
  async fileSizes(dir) {
@@ -721,12 +977,20 @@ var GitLabBackend = class GitLabBackend {
721
977
  async lastCommitFor(path, ref) {
722
978
  return (await gitlabJson(this.api, this.repo(`/files/${encodeURIComponent(path)}?ref=${ref}`))).last_commit_id;
723
979
  }
980
+ /** Whether a file exists at a commit: from the cached tree in the editable folders, and from GitLab elsewhere, such as the config. */
981
+ async existsAt(tree, path, ref) {
982
+ if (EDITABLE_FOLDERS.some((folder) => path.startsWith(`${folder}/`))) return tree.has(path);
983
+ return this.lastCommitFor(path, ref).then(() => true, (error) => {
984
+ if (error instanceof GitApiError && error.status === 404) return false;
985
+ throw error;
986
+ });
987
+ }
724
988
  async write(changes, { message, expectedRevision }) {
725
989
  if (this.contentOnly) assertContentChanges(changes);
726
990
  if (await this.revision() !== expectedRevision) throw new ConflictError();
727
991
  const existing = await this.tree(expectedRevision);
728
992
  const toApply = (await Promise.all(changes.map(async (change) => {
729
- const exists = existing.has(change.path);
993
+ const exists = await this.existsAt(existing, change.path, expectedRevision);
730
994
  if ("delete" in change) return exists ? {
731
995
  action: "delete",
732
996
  file_path: change.path,
@@ -798,15 +1062,24 @@ var GitLabBackend = class GitLabBackend {
798
1062
  }
799
1063
  /**
800
1064
  * 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.
1065
+ * Vercel or Cloudflare Pages). Before any exist, the site counts as building
1066
+ * if it has pipelines, or if the host reported on the commit before.
802
1067
  */
803
1068
  async deployStatus(revision) {
804
1069
  try {
805
- const all = await gitlabJson(this.api, this.repo(`/commits/${revision}/statuses`));
806
- const statuses = all.filter((status) => status.name !== REBUILD_JOB);
1070
+ const all = await this.statuses(revision);
1071
+ const statuses = all.filter(builds);
807
1072
  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");
1073
+ if (statuses.length === 0) {
1074
+ if ((await gitlabJson(this.api, `/projects/${this.project}/pipelines?per_page=1`)).length > 0) return { state: "building" };
1075
+ const { parent_ids: parents = [] } = await gitlabJson(this.api, this.repo(`/commits/${revision}`));
1076
+ return { state: (parents[0] ? (await this.statuses(parents[0])).filter(builds) : []).length > 0 ? "building" : "unknown" };
1077
+ }
1078
+ const failed = statuses.find((status) => status.status === "failed") ?? statuses.find((status) => status.status === "canceled");
1079
+ if (failed?.status === "canceled") {
1080
+ const head = await this.head();
1081
+ if (head !== revision) return this.deployStatus(head);
1082
+ }
810
1083
  if (failed) {
811
1084
  const problem = await this.jobProblem(revision).catch(() => void 0);
812
1085
  return {
@@ -828,6 +1101,9 @@ var GitLabBackend = class GitLabBackend {
828
1101
  throw error;
829
1102
  }
830
1103
  }
1104
+ statuses(revision) {
1105
+ return gitlabJson(this.api, this.repo(`/commits/${revision}/statuses`));
1106
+ }
831
1107
  /** Why a commit's pipeline failed: the failed job's reason, and its log for which command failed and why. */
832
1108
  async jobProblem(revision) {
833
1109
  const [pipeline] = await gitlabJson(this.api, `/projects/${this.project}/pipelines?${new URLSearchParams({
@@ -1029,6 +1305,29 @@ function gitlabSetup(options = {}) {
1029
1305
  onStep?.("connect");
1030
1306
  if (!await connectProjects(site.link)) warnings.push("connect-unavailable");
1031
1307
  }
1308
+ let siteUrl;
1309
+ if (site.pages && site.domain) {
1310
+ onStep?.("pages");
1311
+ try {
1312
+ await gitlabRequest(api, `/projects/${project.id}/pages/domains`, {
1313
+ method: "POST",
1314
+ body: {
1315
+ domain: site.domain,
1316
+ auto_ssl_enabled: true
1317
+ }
1318
+ });
1319
+ siteUrl = `https://${site.domain}/`;
1320
+ } catch (error) {
1321
+ if (!(error instanceof GitApiError) || ![
1322
+ 400,
1323
+ 403,
1324
+ 404,
1325
+ 409,
1326
+ 422
1327
+ ].includes(error.status)) throw error;
1328
+ warnings.push("domain-unavailable");
1329
+ }
1330
+ }
1032
1331
  onStep?.("files");
1033
1332
  await gitlabRequest(api, `/projects/${project.id}/repository/commits`, {
1034
1333
  method: "POST",
@@ -1050,6 +1349,7 @@ function gitlabSetup(options = {}) {
1050
1349
  return {
1051
1350
  repo: project.path_with_namespace,
1052
1351
  webUrl: project.web_url,
1352
+ ...siteUrl && { siteUrl },
1053
1353
  buildsUrl: `${project.web_url}/-/pipelines`,
1054
1354
  pagesUrl: site.pages ? `${project.web_url}/pages` : void 0,
1055
1355
  warnings
@@ -1265,7 +1565,18 @@ function gitlab(options) {
1265
1565
  }
1266
1566
  return {
1267
1567
  name: "GitLab",
1568
+ source: hostSource("@goodfellow-cms/gitlab", "gitlab", options, [
1569
+ "fetch",
1570
+ "storage",
1571
+ "location"
1572
+ ]),
1268
1573
  canRedirect: Boolean(options.clientId),
1574
+ setup: () => gitlabSetup({
1575
+ ...options.url && { url: options.url },
1576
+ ...options.fetch && { fetch: options.fetch },
1577
+ ...options.storage && { storage: options.storage },
1578
+ ...options.location && { location: options.location }
1579
+ }),
1269
1580
  redirectAddressesListed: Boolean(options.clientId),
1270
1581
  tokenLinks: [{
1271
1582
  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,30 @@ 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
+ * Answers plugins' tasks, as the site's `plugin-task` job would: given each
47
+ * pipeline's task variables, it gives the text the job leaves as its
48
+ * artifact, or nothing for a job that fails. Without it, task pipelines fail.
49
+ */
50
+ tasks?: (variables: Record<string, string>) => string | undefined | Promise<string | undefined>;
51
+ }
52
+ /** A pipeline schedule, such as the nightly one or one the admin panel adds for a scheduled change. */
53
+ export interface FakeSchedule {
54
+ id: number;
55
+ ref: string;
56
+ cron: string;
57
+ cronTimezone?: string;
58
+ description?: string;
59
+ active: boolean;
60
+ /** Who added it, who alone (with Maintainers) can change it. */
61
+ owner?: string;
62
+ variables?: Record<string, string>;
36
63
  }
37
64
  /** A member of the site's project. */
38
65
  export interface FakeMember {
@@ -77,6 +104,8 @@ export interface FakeCreatedProject {
77
104
  cron: string;
78
105
  active: boolean;
79
106
  }>;
107
+ /** Domains of its own its Pages were given. */
108
+ pagesDomains: string[];
80
109
  }
81
110
  export declare function fakeGitLab(options: FakeGitLabOptions): {
82
111
  /** How many GraphQL requests read files, as `readFiles()` makes. */
@@ -94,15 +123,11 @@ export declare function fakeGitLab(options: FakeGitLabOptions): {
94
123
  failureReason?: string;
95
124
  trace?: string;
96
125
  }) => void;
126
+ cancelBuild: (sha: string) => void;
97
127
  /** The project's update settings, and the pipelines the update ran in, which tests add with `addUpdateRun()`. */
98
128
  updateSettings: {
99
129
  jobTokenPush: boolean;
100
- schedules: Array<{
101
- id: number;
102
- ref: string;
103
- cron: string;
104
- active: boolean;
105
- }>;
130
+ schedules: FakeSchedule[];
106
131
  };
107
132
  updateRuns: {
108
133
  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,9 @@ 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
+ /** Pipelines started for plugins' tasks, with their `plugin-task` job's artifact. */
177
+ const taskRuns = [];
178
+ /** Failed jobs, by commit, which tests add with `failBuild()` and `cancelBuild()`. */
176
179
  const failures = /* @__PURE__ */ new Map();
177
180
  /**
178
181
  * Simulates the pipeline for a commit failing: its `pages` job (or another,
@@ -182,10 +185,21 @@ function fakeGitLab(options) {
182
185
  if (!pipelines.has(sha)) pipelines.set(sha, { checks: 0 });
183
186
  failures.set(sha, {
184
187
  job: failure.job ?? "pages",
188
+ status: "failed",
185
189
  failureReason: failure.failureReason ?? "script_failure",
186
190
  trace: failure.trace ?? ""
187
191
  });
188
192
  }
193
+ /** Simulates a commit's `pages` job being cancelled, as a newer pipeline on the branch cancels an interruptible one. */
194
+ function cancelBuild(sha) {
195
+ if (!pipelines.has(sha)) pipelines.set(sha, { checks: 0 });
196
+ failures.set(sha, {
197
+ job: "pages",
198
+ status: "canceled",
199
+ failureReason: "",
200
+ trace: ""
201
+ });
202
+ }
189
203
  const pipelineId = (sha) => [...pipelines.keys()].indexOf(sha) + 1;
190
204
  const requests = [];
191
205
  let counter = 0;
@@ -302,7 +316,8 @@ function fakeGitLab(options) {
302
316
  repo: new FakeRepo({}),
303
317
  jobTokenAllowlist: [],
304
318
  jobTokenPush: false,
305
- schedules: []
319
+ schedules: [],
320
+ pagesDomains: []
306
321
  };
307
322
  created.set(fullPath, project);
308
323
  return json({
@@ -339,6 +354,15 @@ function fakeGitLab(options) {
339
354
  target_project_id: body.target_project_id
340
355
  }, 201);
341
356
  }
357
+ if (rest === "/pages/domains" && request.method === "POST") {
358
+ const body = await request.json();
359
+ if (options.takenDomains?.includes(body.domain)) return json({ message: { domain: ["has already been taken"] } }, 400);
360
+ project.pagesDomains.push(body.domain);
361
+ return json({
362
+ domain: body.domain,
363
+ url: `https://${body.domain}`
364
+ }, 201);
365
+ }
342
366
  if (rest === "/pipeline_schedules" && request.method === "POST") {
343
367
  const body = await request.json();
344
368
  project.schedules.push({
@@ -481,12 +505,21 @@ function fakeGitLab(options) {
481
505
  if (membersResponse) return membersResponse;
482
506
  if (rest === "" && request.method !== "PUT") return json({
483
507
  default_branch: repo.defaultBranch,
508
+ visibility: options.visibility ?? "private",
484
509
  permissions: {
485
510
  project_access: { access_level: accessLevel },
486
511
  group_access: null
487
512
  },
488
513
  ci_push_repository_for_job_token_allowed: updateSettings.jobTokenPush
489
514
  });
515
+ if (rest === "/repository/branches" && request.method === "GET") {
516
+ const search = url.searchParams.get("search") ?? "";
517
+ const matches = (name) => search.startsWith("^") ? name.startsWith(search.slice(1)) : name.includes(search);
518
+ return json([...repo.branches].filter(([name]) => matches(name)).map(([name, sha]) => ({
519
+ name,
520
+ commit: { id: sha }
521
+ })));
522
+ }
490
523
  if (rest === "/repository/branches" && request.method === "POST") {
491
524
  if (accessLevel < 30) return json({ message: "403 Forbidden" }, 403);
492
525
  const name = url.searchParams.get("branch") ?? "";
@@ -617,6 +650,15 @@ function fakeGitLab(options) {
617
650
  committed_date: commit.date
618
651
  })));
619
652
  }
653
+ const commitSha = rest.match(/^\/repository\/commits\/([0-9a-f]{40})$/)?.[1];
654
+ if (commitSha) {
655
+ const found = repo.commitAt(commitSha);
656
+ if (!found) return json({ message: "404 Commit Not Found" }, 404);
657
+ return json({
658
+ id: commitSha,
659
+ parent_ids: found.parent ? [found.parent] : []
660
+ });
661
+ }
620
662
  if (rest === "/repository/compare") return json({ diffs: repo.changedPaths(url.searchParams.get("from") ?? "", url.searchParams.get("to") ?? "").map((changedPath) => ({
621
663
  old_path: changedPath,
622
664
  new_path: changedPath
@@ -630,7 +672,7 @@ function fakeGitLab(options) {
630
672
  const id = pipelineId(statuses[1] ?? "");
631
673
  return json([{
632
674
  name: failure.job,
633
- status: "failed",
675
+ status: failure.status,
634
676
  target_url: `${webUrl}/${options.project}/-/jobs/${id}`
635
677
  }]);
636
678
  }
@@ -651,10 +693,25 @@ function fakeGitLab(options) {
651
693
  })));
652
694
  if (rest === "/pipeline" && request.method === "POST") {
653
695
  if (accessLevel < 30) return json({ message: "403 Forbidden" }, 403);
696
+ const body = await request.json();
697
+ const variables = Object.fromEntries((body.variables ?? []).map(({ key, value }) => [key, value]));
698
+ if (variables.GOODFELLOW_TASK_PLUGIN) {
699
+ const id = 9e4 + taskRuns.length;
700
+ taskRuns.push({
701
+ id,
702
+ job: id + 5e4,
703
+ variables,
704
+ answer: await options.tasks?.(variables)
705
+ });
706
+ return json({
707
+ id,
708
+ status: "created"
709
+ }, 201);
710
+ }
654
711
  const run = addUpdateRun({
655
712
  source: "api",
656
713
  status: "created",
657
- variables: (await request.json()).variables
714
+ variables: body.variables
658
715
  });
659
716
  return json({
660
717
  id: run.id,
@@ -665,23 +722,100 @@ function fakeGitLab(options) {
665
722
  if (accessLevel < 30) return json({ message: "403 Forbidden" }, 403);
666
723
  if (request.method === "POST") {
667
724
  const body = await request.json();
725
+ const protection = protectedBranches.get(body.ref);
726
+ if (protection && accessLevel < protection.merge) return json({ message: "403 Forbidden" }, 403);
668
727
  const schedule = {
669
- id: updateSettings.schedules.length + 1,
728
+ id: ++scheduleIds,
670
729
  ref: body.ref,
671
730
  cron: body.cron,
672
- active: body.active ?? true
731
+ active: body.active ?? true,
732
+ ...body.description !== void 0 && { description: body.description },
733
+ ...body.cron_timezone !== void 0 && { cronTimezone: body.cron_timezone },
734
+ owner: user.username
673
735
  };
674
736
  updateSettings.schedules.push(schedule);
675
737
  return json(schedule, 201);
676
738
  }
677
739
  return json(updateSettings.schedules);
678
740
  }
741
+ const scheduleMatch = rest.match(/^\/pipeline_schedules\/(\d+)(\/variables)?(?:\/([\w-]+))?$/);
742
+ if (scheduleMatch) {
743
+ const schedule = updateSettings.schedules.find((candidate) => candidate.id === Number(scheduleMatch[1]));
744
+ if (!schedule) return json({ message: "404 Not found" }, 404);
745
+ const variables = () => Object.entries(schedule.variables ?? {}).map(([key, value]) => ({
746
+ key,
747
+ value,
748
+ variable_type: "env_var"
749
+ }));
750
+ if (request.method === "GET" && !scheduleMatch[2]) {
751
+ if (accessLevel < 30) return json({ message: "403 Forbidden" }, 403);
752
+ return json({
753
+ ...schedule,
754
+ variables: variables()
755
+ });
756
+ }
757
+ if (accessLevel < 30 || accessLevel < 40 && schedule.owner !== user.username) return json({ message: "403 Forbidden" }, 403);
758
+ const key = scheduleMatch[3];
759
+ if (scheduleMatch[2] && !key && request.method === "POST") {
760
+ const body = await request.json();
761
+ schedule.variables = {
762
+ ...schedule.variables,
763
+ [body.key]: body.value
764
+ };
765
+ return json(body, 201);
766
+ }
767
+ if (scheduleMatch[2] && key) {
768
+ if (!(key in (schedule.variables ?? {}))) return json({ message: "404 Variable Not Found" }, 404);
769
+ if (request.method === "PUT") {
770
+ const body = await request.json();
771
+ schedule.variables = {
772
+ ...schedule.variables,
773
+ [key]: body.value
774
+ };
775
+ return json({
776
+ key,
777
+ value: body.value
778
+ });
779
+ }
780
+ if (request.method === "DELETE") {
781
+ const { [key]: _removed, ...rest } = schedule.variables ?? {};
782
+ schedule.variables = rest;
783
+ return json({ key });
784
+ }
785
+ }
786
+ if (!scheduleMatch[2] && request.method === "PUT") {
787
+ const body = await request.json();
788
+ if (body.active !== void 0) schedule.active = body.active;
789
+ if (body.cron !== void 0) schedule.cron = body.cron;
790
+ if (body.description !== void 0) schedule.description = body.description;
791
+ return json({
792
+ ...schedule,
793
+ variables: variables()
794
+ });
795
+ }
796
+ if (!scheduleMatch[2] && request.method === "DELETE") {
797
+ updateSettings.schedules = updateSettings.schedules.filter((candidate) => candidate !== schedule);
798
+ return new Response(null, { status: 204 });
799
+ }
800
+ }
679
801
  if (rest === "" && request.method === "PUT") {
680
802
  if (accessLevel < 40) return json({ message: "403 Forbidden" }, 403);
681
803
  const body = await request.json();
682
804
  if (body.ci_push_repository_for_job_token_allowed !== void 0) updateSettings.jobTokenPush = body.ci_push_repository_for_job_token_allowed;
683
805
  return json({ ci_push_repository_for_job_token_allowed: updateSettings.jobTokenPush });
684
806
  }
807
+ const taskJobs = rest.match(/^\/pipelines\/(\d+)\/jobs$/);
808
+ const taskRun = taskJobs && taskRuns.find((candidate) => candidate.id === Number(taskJobs[1]));
809
+ if (taskRun) return json([{
810
+ id: taskRun.job,
811
+ name: "plugin-task",
812
+ status: taskRun.answer === void 0 ? "failed" : "success"
813
+ }]);
814
+ const artifact = rest.match(/^\/jobs\/(\d+)\/artifacts\/(.+)$/);
815
+ if (artifact) {
816
+ const run = taskRuns.find((candidate) => candidate.job === Number(artifact[1]));
817
+ return run?.answer !== void 0 && artifact[2] === "goodfellow-task-result.json" ? new Response(`${run.answer}\n`) : json({ message: "404 Not Found" }, 404);
818
+ }
685
819
  const updateJobs = rest.match(/^\/pipelines\/(\d{4,})\/jobs$/);
686
820
  if (updateJobs) {
687
821
  const run = updateRuns.find((candidate) => candidate.id === Number(updateJobs[1]));
@@ -698,7 +832,7 @@ function fakeGitLab(options) {
698
832
  }
699
833
  if (rest === "/pipelines") {
700
834
  const sha = url.searchParams.get("sha");
701
- return json([...pipelines.keys()].map((pipelineSha, index) => ({
835
+ return json([...options.external ? [] : pipelines.keys()].map((pipelineSha, index) => ({
702
836
  id: index + 1,
703
837
  sha: pipelineSha
704
838
  })).filter((pipeline) => !sha || pipeline.sha === sha));
@@ -712,7 +846,7 @@ function fakeGitLab(options) {
712
846
  return json([failure ? {
713
847
  id,
714
848
  name: failure.job,
715
- status: "failed",
849
+ status: failure.status,
716
850
  failure_reason: failure.failureReason
717
851
  } : {
718
852
  id,
@@ -738,6 +872,7 @@ function fakeGitLab(options) {
738
872
  invitations,
739
873
  protectedBranches,
740
874
  failBuild,
875
+ cancelBuild,
741
876
  /** The project's update settings, and the pipelines the update ran in, which tests add with `addUpdateRun()`. */
742
877
  updateSettings,
743
878
  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.6.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.6.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"