@goodfellow-cms/gitlab 0.5.0 → 0.7.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, RepositoryVisibility, RevisionFiles, ScheduledBuilds, 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. */
@@ -33,6 +44,13 @@ interface GitLabBackendOptions {
33
44
  project: string;
34
45
  webUrl: string;
35
46
  };
47
+ /** What's read at each commit, shared with the backends of the project's other branches (drafts), since commits never change. */
48
+ caches?: GitLabCaches;
49
+ }
50
+ /** Path → blob id for the editable files at each commit, and each blob's text, by id. */
51
+ interface GitLabCaches {
52
+ trees: Map<string, Promise<Map<string, string>>>;
53
+ blobs: Map<string, Promise<string>>;
36
54
  }
37
55
  /**
38
56
  * The site's files on GitLab. Reads come from the commit `revision()` last
@@ -47,6 +65,7 @@ export declare class GitLabBackend implements GitBackend {
47
65
  readonly updates?: SiteUpdates;
48
66
  readonly drafts: SiteDrafts;
49
67
  readonly scheduledBuilds: ScheduledBuilds;
68
+ readonly tasks: PluginTasks;
50
69
  readonly contentOnly: boolean;
51
70
  private readonly options;
52
71
  private readonly api;
@@ -54,6 +73,7 @@ export declare class GitLabBackend implements GitBackend {
54
73
  private readonly branch;
55
74
  private readonly onSignOut;
56
75
  private pinned?;
76
+ private readonly caches;
57
77
  private readonly trees;
58
78
  private readonly blobs;
59
79
  constructor(options: GitLabBackendOptions);
@@ -86,6 +106,8 @@ export declare class GitLabBackend implements GitBackend {
86
106
  write(changes: FileChange[], { message, expectedRevision }: WriteOptions): Promise<{
87
107
  revision: string;
88
108
  }>;
109
+ /** Lists `revision`'s editable files as `before`'s with `changes` made, keeping the text written. */
110
+ private carryTree;
89
111
  history(path: string, { page, perPage }?: HistoryOptions): Promise<FileVersion[]>;
90
112
  readAt(path: string, revision: string): Promise<string | undefined>;
91
113
  changedPaths(from: string, to: string): Promise<string[]>;
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
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";
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, withRateLimits } 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 = {}) {
@@ -411,6 +411,72 @@ function gitlabScheduledBuilds(api, project, branch, readCi, now = () => /* @__P
411
411
  };
412
412
  }
413
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
414
480
  //#region src/updates.ts
415
481
  const CI_FILE = ".gitlab-ci.yml";
416
482
  /** When the nightly schedule the Updates screen adds runs, in UTC, as the GitHub Pages workflow's does. */
@@ -633,13 +699,22 @@ const PAGE_SIZE = 100;
633
699
  const GRAPHQL_BATCH = 50;
634
700
  /** The color and section codes in GitLab's job logs. */
635
701
  const ANSI_CODES = new RegExp(`${String.fromCharCode(27)}\\[[\\d;]*[A-Za-z]`, "g");
702
+ /** Git's id for a file's contents (the SHA-1 of `blob <size>\0` and them), as GitLab's tree lists it. */
703
+ async function gitBlobId(bytes) {
704
+ const header = new TextEncoder().encode(`blob ${bytes.length}\0`);
705
+ const data = new Uint8Array(header.length + bytes.length);
706
+ data.set(header);
707
+ data.set(bytes, header.length);
708
+ return [...new Uint8Array(await crypto.subtle.digest("SHA-1", data))].map((byte) => byte.toString(16).padStart(2, "0")).join("");
709
+ }
636
710
  /**
637
711
  * 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.
712
+ * host to rebuild the site doesn't, and nor do the nightly update and the
713
+ * job that writes the lockfile where Cloudflare builds the site, which run
714
+ * on the newest commit too.
640
715
  */
641
716
  function builds(status) {
642
- return status.name !== REBUILD_JOB && status.name !== "update";
717
+ return status.name !== REBUILD_JOB && status.name !== "update" && status.name !== LOCKFILE_JOB;
643
718
  }
644
719
  function listFrom(tree, dir) {
645
720
  const prefix = `${dir}/`;
@@ -658,6 +733,7 @@ var GitLabBackend = class GitLabBackend {
658
733
  updates;
659
734
  drafts;
660
735
  scheduledBuilds;
736
+ tasks;
661
737
  contentOnly;
662
738
  options;
663
739
  api;
@@ -665,8 +741,9 @@ var GitLabBackend = class GitLabBackend {
665
741
  branch;
666
742
  onSignOut;
667
743
  pinned;
668
- trees = /* @__PURE__ */ new Map();
669
- blobs = /* @__PURE__ */ new Map();
744
+ caches;
745
+ trees;
746
+ blobs;
670
747
  constructor(options) {
671
748
  this.options = options;
672
749
  this.api = options.api;
@@ -675,6 +752,12 @@ var GitLabBackend = class GitLabBackend {
675
752
  this.user = options.user;
676
753
  this.onSignOut = options.onSignOut;
677
754
  this.pinned = options.revision;
755
+ this.caches = options.caches ?? {
756
+ trees: /* @__PURE__ */ new Map(),
757
+ blobs: /* @__PURE__ */ new Map()
758
+ };
759
+ this.trees = this.caches.trees;
760
+ this.blobs = this.caches.blobs;
678
761
  this.pages = gitlabPages(this.api, options.project);
679
762
  this.editors = gitlabEditors(this.api, options.project, this.branch, this.user.login);
680
763
  this.contentOnly = options.contentOnly === true;
@@ -696,6 +779,7 @@ var GitLabBackend = class GitLabBackend {
696
779
  }
697
780
  this.drafts = branchDrafts(this, this.draftHost());
698
781
  this.scheduledBuilds = gitlabScheduledBuilds(this.api, options.project, this.branch, async () => this.readAt(".gitlab-ci.yml", this.pinned ?? await this.revision()));
782
+ this.tasks = gitlabTasks(this.api, options.project, this.branch, async () => this.readAt(".gitlab-ci.yml", this.pinned ?? await this.revision()), options.tasks);
699
783
  }
700
784
  async repositoryVisibility() {
701
785
  const { visibility } = await gitlabJson(this.api, `/projects/${this.project}`);
@@ -704,11 +788,40 @@ var GitLabBackend = class GitLabBackend {
704
788
  /** Drafts on branches of the project, with merge requests for review. */
705
789
  draftHost() {
706
790
  const project = `/projects/${this.project}`;
791
+ const openRequests = (branch) => gitlabJson(this.api, `${project}/merge_requests?${new URLSearchParams({
792
+ source_branch: branch,
793
+ state: "opened"
794
+ })}`);
795
+ /**
796
+ * Asks people to review a merge request, by their usernames. GitLab's free
797
+ * plan takes one reviewer, so where it refuses them all, the first is asked.
798
+ */
799
+ const askReviewers = async (iid, usernames) => {
800
+ const ids = [];
801
+ for (const username of usernames) {
802
+ const [found] = await gitlabJson(this.api, `/users?${new URLSearchParams({ username })}`).catch(() => []);
803
+ if (found) ids.push(found.id);
804
+ }
805
+ for (const reviewerIds of ids.length > 1 ? [ids, ids.slice(0, 1)] : [ids]) {
806
+ if (reviewerIds.length === 0) return;
807
+ if ((await gitlabRequest(this.api, `${project}/merge_requests/${iid}`, {
808
+ method: "PUT",
809
+ body: { reviewer_ids: reviewerIds },
810
+ allow: [
811
+ 400,
812
+ 403,
813
+ 404,
814
+ 422
815
+ ]
816
+ })).ok) return;
817
+ }
818
+ };
707
819
  return {
708
820
  liveBranch: this.branch,
709
821
  branchStore: (branch) => new GitLabBackend({
710
822
  ...this.options,
711
- branch
823
+ branch,
824
+ caches: this.caches
712
825
  }),
713
826
  createBranch: async (branch, revision) => {
714
827
  if ((await gitlabRequest(this.api, this.repo(`/branches?${new URLSearchParams({
@@ -740,7 +853,7 @@ var GitLabBackend = class GitLabBackend {
740
853
  });
741
854
  return (await gitlabJson(this.api, this.repo(`/branches?${query}`))).map((found) => found.name).filter((name) => name.startsWith(prefix));
742
855
  },
743
- openReview: async ({ branch, title, description }) => {
856
+ openReview: async ({ branch, title, description, reviewers = [] }) => {
744
857
  const response = await gitlabRequest(this.api, `${project}/merge_requests`, {
745
858
  method: "POST",
746
859
  body: {
@@ -756,15 +869,33 @@ var GitLabBackend = class GitLabBackend {
756
869
  409
757
870
  ]
758
871
  });
759
- if (response.ok) return { url: (await response.json()).web_url };
760
- if (response.status !== 409) throw new DraftError("no-reviews", "This sign-in can't open merge requests on the site's project.");
872
+ let review;
873
+ if (response.ok) review = await response.json();
874
+ else if (response.status !== 409) throw new DraftError("no-reviews", "This sign-in can't open merge requests on the site's project.");
875
+ else {
876
+ [review] = await openRequests(branch);
877
+ if (!review) throw new GitApiError(409, "GitLab couldn't open the merge request.");
878
+ }
879
+ await askReviewers(review.iid, reviewers);
880
+ return { url: review.web_url };
881
+ },
882
+ listReviews: async (prefix) => {
761
883
  const query = new URLSearchParams({
762
- source_branch: branch,
763
- state: "opened"
884
+ state: "opened",
885
+ per_page: "100"
886
+ });
887
+ return (await gitlabJson(this.api, `${project}/merge_requests?${query}`)).filter((request) => request.source_branch.startsWith(prefix)).map((request) => ({
888
+ branch: request.source_branch,
889
+ url: request.web_url,
890
+ title: request.title
891
+ }));
892
+ },
893
+ closeReview: async (branch) => {
894
+ for (const request of await openRequests(branch)) await gitlabRequest(this.api, `${project}/merge_requests/${request.iid}`, {
895
+ method: "PUT",
896
+ body: { state_event: "close" },
897
+ allow: [403, 404]
764
898
  });
765
- const [open] = await gitlabJson(this.api, `${project}/merge_requests?${query}`);
766
- if (!open) throw new GitApiError(409, "GitLab couldn't open the merge request.");
767
- return { url: open.web_url };
768
899
  }
769
900
  };
770
901
  }
@@ -957,6 +1088,7 @@ var GitLabBackend = class GitLabBackend {
957
1088
  }
958
1089
  });
959
1090
  this.pinned = commit.id;
1091
+ if (commit.parent_ids?.length === 1 && commit.parent_ids[0] === expectedRevision) await this.carryTree(existing, commit.id, changes).catch(() => {});
960
1092
  return { revision: commit.id };
961
1093
  } catch (error) {
962
1094
  if (!(error instanceof GitApiError)) throw error;
@@ -965,6 +1097,22 @@ var GitLabBackend = class GitLabBackend {
965
1097
  throw error;
966
1098
  }
967
1099
  }
1100
+ /** Lists `revision`'s editable files as `before`'s with `changes` made, keeping the text written. */
1101
+ async carryTree(before, revision, changes) {
1102
+ if (this.trees.has(revision) || !globalThis.crypto?.subtle) return;
1103
+ const tree = new Map(before);
1104
+ for (const change of changes) {
1105
+ if (!EDITABLE_FOLDERS.some((folder) => change.path.startsWith(`${folder}/`))) continue;
1106
+ if ("delete" in change) {
1107
+ tree.delete(change.path);
1108
+ continue;
1109
+ }
1110
+ const id = await gitBlobId("bytes" in change ? change.bytes : new TextEncoder().encode(change.content));
1111
+ tree.set(change.path, id);
1112
+ if ("content" in change && !this.blobs.has(id)) this.blobs.set(id, Promise.resolve(change.content));
1113
+ }
1114
+ this.trees.set(revision, Promise.resolve(tree));
1115
+ }
968
1116
  async history(path, { page = 1, perPage = 20 } = {}) {
969
1117
  const query = new URLSearchParams({
970
1118
  ref_name: this.pinned ?? await this.revision(),
@@ -1366,7 +1514,7 @@ function browserLocation() {
1366
1514
  function gitlab(options) {
1367
1515
  const webUrl = (options.url ?? "https://gitlab.com").replace(/\/$/, "");
1368
1516
  const apiUrl = `${webUrl}/api/v4`;
1369
- const fetcher = options.fetch ?? ((input, init) => globalThis.fetch(input, init));
1517
+ const fetcher = withRateLimits(options.fetch ?? ((input, init) => globalThis.fetch(input, init)), { host: "GitLab" });
1370
1518
  const storage = credentialStorage(`goodfellow:gitlab:${webUrl}:${options.project}`, options.storage);
1371
1519
  const pending = credentialStorage(`goodfellow:gitlab:pending:${options.project}`, options.storage);
1372
1520
  const location = () => options.location ?? browserLocation();
package/dist/testing.d.ts CHANGED
@@ -28,6 +28,8 @@ export interface FakeGitLabOptions {
28
28
  * Pages: a commit status with no pipeline the project lists.
29
29
  */
30
30
  external?: boolean;
31
+ /** A paid plan, whose merge requests take several reviewers; the free plan takes one. */
32
+ premium?: boolean;
31
33
  /** The job the update runs in, which `addUpdateRun()` logs: `pages` by default, as in the starters, or a code project's `update`. */
32
34
  updateJob?: string;
33
35
  /** Groups the users belong to, which new projects can be created in. */
@@ -42,6 +44,12 @@ export interface FakeGitLabOptions {
42
44
  accounts?: string[];
43
45
  /** Who can see the project. Private by default. */
44
46
  visibility?: "public" | "internal" | "private";
47
+ /**
48
+ * Answers plugins' tasks, as the site's `plugin-task` job would: given each
49
+ * pipeline's task variables, it gives the text the job leaves as its
50
+ * artifact, or nothing for a job that fails. Without it, task pipelines fail.
51
+ */
52
+ tasks?: (variables: Record<string, string>) => string | undefined | Promise<string | undefined>;
45
53
  }
46
54
  /** A pipeline schedule, such as the nightly one or one the admin panel adds for a scheduled change. */
47
55
  export interface FakeSchedule {
package/dist/testing.js CHANGED
@@ -173,6 +173,8 @@ function fakeGitLab(options) {
173
173
  updateRuns.push(added);
174
174
  return added;
175
175
  }
176
+ /** Pipelines started for plugins' tasks, with their `plugin-task` job's artifact. */
177
+ const taskRuns = [];
176
178
  /** Failed jobs, by commit, which tests add with `failBuild()` and `cancelBuild()`. */
177
179
  const failures = /* @__PURE__ */ new Map();
178
180
  /**
@@ -564,9 +566,28 @@ function fakeGitLab(options) {
564
566
  const source = url.searchParams.get("source_branch");
565
567
  return json(repo.reviews.filter((review) => review.state === "open" && (!source || review.branch === source)).map((review) => ({
566
568
  iid: review.number,
569
+ title: review.title,
570
+ source_branch: review.branch,
567
571
  web_url: `${webUrl}/${options.project}/-/merge_requests/${review.number}`
568
572
  })));
569
573
  }
574
+ const mergeRequest = rest.match(/^\/merge_requests\/(\d+)$/);
575
+ if (mergeRequest && request.method === "PUT") {
576
+ if (accessLevel < 30) return json({ message: "403 Forbidden" }, 403);
577
+ const review = repo.reviews.find((found) => found.number === Number(mergeRequest[1]));
578
+ if (!review) return json({ message: "404 Not found" }, 404);
579
+ const body = await request.json();
580
+ if (body.reviewer_ids) {
581
+ if (body.reviewer_ids.length > 1 && !options.premium) return json({ message: "Too many reviewers" }, 400);
582
+ const names = new Map([...accounts].map(([username, id]) => [id, username]));
583
+ review.reviewers = body.reviewer_ids.flatMap((id) => names.get(id) ?? []);
584
+ }
585
+ if (body.state_event === "close") review.state = "closed";
586
+ return json({
587
+ iid: review.number,
588
+ state: review.state === "open" ? "opened" : review.state
589
+ });
590
+ }
570
591
  if (rest === "/repository/tree") {
571
592
  const ref = url.searchParams.get("ref") ?? repo.defaultBranch;
572
593
  const dir = url.searchParams.get("path") ?? "";
@@ -628,9 +649,11 @@ function fakeGitLab(options) {
628
649
  name: body.author_name,
629
650
  email: body.author_email
630
651
  });
652
+ const parent = repo.commitAt(sha)?.parent;
631
653
  return json({
632
654
  id: sha,
633
- message: body.commit_message
655
+ message: body.commit_message,
656
+ parent_ids: parent ? [parent] : []
634
657
  }, 201);
635
658
  }
636
659
  if (rest === "/repository/commits" && request.method === "GET") {
@@ -691,10 +714,25 @@ function fakeGitLab(options) {
691
714
  })));
692
715
  if (rest === "/pipeline" && request.method === "POST") {
693
716
  if (accessLevel < 30) return json({ message: "403 Forbidden" }, 403);
717
+ const body = await request.json();
718
+ const variables = Object.fromEntries((body.variables ?? []).map(({ key, value }) => [key, value]));
719
+ if (variables.GOODFELLOW_TASK_PLUGIN) {
720
+ const id = 9e4 + taskRuns.length;
721
+ taskRuns.push({
722
+ id,
723
+ job: id + 5e4,
724
+ variables,
725
+ answer: await options.tasks?.(variables)
726
+ });
727
+ return json({
728
+ id,
729
+ status: "created"
730
+ }, 201);
731
+ }
694
732
  const run = addUpdateRun({
695
733
  source: "api",
696
734
  status: "created",
697
- variables: (await request.json()).variables
735
+ variables: body.variables
698
736
  });
699
737
  return json({
700
738
  id: run.id,
@@ -787,6 +825,18 @@ function fakeGitLab(options) {
787
825
  if (body.ci_push_repository_for_job_token_allowed !== void 0) updateSettings.jobTokenPush = body.ci_push_repository_for_job_token_allowed;
788
826
  return json({ ci_push_repository_for_job_token_allowed: updateSettings.jobTokenPush });
789
827
  }
828
+ const taskJobs = rest.match(/^\/pipelines\/(\d+)\/jobs$/);
829
+ const taskRun = taskJobs && taskRuns.find((candidate) => candidate.id === Number(taskJobs[1]));
830
+ if (taskRun) return json([{
831
+ id: taskRun.job,
832
+ name: "plugin-task",
833
+ status: taskRun.answer === void 0 ? "failed" : "success"
834
+ }]);
835
+ const artifact = rest.match(/^\/jobs\/(\d+)\/artifacts\/(.+)$/);
836
+ if (artifact) {
837
+ const run = taskRuns.find((candidate) => candidate.job === Number(artifact[1]));
838
+ return run?.answer !== void 0 && artifact[2] === "goodfellow-task-result.json" ? new Response(`${run.answer}\n`) : json({ message: "404 Not Found" }, 404);
839
+ }
790
840
  const updateJobs = rest.match(/^\/pipelines\/(\d{4,})\/jobs$/);
791
841
  if (updateJobs) {
792
842
  const run = updateRuns.find((candidate) => candidate.id === Number(updateJobs[1]));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodfellow-cms/gitlab",
3
- "version": "0.5.0",
3
+ "version": "0.7.0",
4
4
  "description": "Edit a Goodfellow site stored on GitLab, straight from the browser.",
5
5
  "keywords": [
6
6
  "goodfellow",
@@ -35,7 +35,7 @@
35
35
  "dist"
36
36
  ],
37
37
  "dependencies": {
38
- "@goodfellow-cms/core": "0.5.0"
38
+ "@goodfellow-cms/core": "0.7.0"
39
39
  },
40
40
  "devDependencies": {
41
41
  "@puckeditor/core": "0.23.0"