@goodfellow-cms/gitlab 0.6.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/dist/index.d.ts CHANGED
@@ -44,6 +44,13 @@ interface GitLabBackendOptions {
44
44
  project: string;
45
45
  webUrl: string;
46
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>>;
47
54
  }
48
55
  /**
49
56
  * The site's files on GitLab. Reads come from the commit `revision()` last
@@ -66,6 +73,7 @@ export declare class GitLabBackend implements GitBackend {
66
73
  private readonly branch;
67
74
  private readonly onSignOut;
68
75
  private pinned?;
76
+ private readonly caches;
69
77
  private readonly trees;
70
78
  private readonly blobs;
71
79
  constructor(options: GitLabBackendOptions);
@@ -98,6 +106,8 @@ export declare class GitLabBackend implements GitBackend {
98
106
  write(changes: FileChange[], { message, expectedRevision }: WriteOptions): Promise<{
99
107
  revision: string;
100
108
  }>;
109
+ /** Lists `revision`'s editable files as `before`'s with `changes` made, keeping the text written. */
110
+ private carryTree;
101
111
  history(path: string, { page, perPage }?: HistoryOptions): Promise<FileVersion[]>;
102
112
  readAt(path: string, revision: string): Promise<string | undefined>;
103
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, 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";
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 = {}) {
@@ -699,6 +699,14 @@ const PAGE_SIZE = 100;
699
699
  const GRAPHQL_BATCH = 50;
700
700
  /** The color and section codes in GitLab's job logs. */
701
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
+ }
702
710
  /**
703
711
  * Whether a status says when the site is live. A job that only asks another
704
712
  * host to rebuild the site doesn't, and nor do the nightly update and the
@@ -733,8 +741,9 @@ var GitLabBackend = class GitLabBackend {
733
741
  branch;
734
742
  onSignOut;
735
743
  pinned;
736
- trees = /* @__PURE__ */ new Map();
737
- blobs = /* @__PURE__ */ new Map();
744
+ caches;
745
+ trees;
746
+ blobs;
738
747
  constructor(options) {
739
748
  this.options = options;
740
749
  this.api = options.api;
@@ -743,6 +752,12 @@ var GitLabBackend = class GitLabBackend {
743
752
  this.user = options.user;
744
753
  this.onSignOut = options.onSignOut;
745
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;
746
761
  this.pages = gitlabPages(this.api, options.project);
747
762
  this.editors = gitlabEditors(this.api, options.project, this.branch, this.user.login);
748
763
  this.contentOnly = options.contentOnly === true;
@@ -773,11 +788,40 @@ var GitLabBackend = class GitLabBackend {
773
788
  /** Drafts on branches of the project, with merge requests for review. */
774
789
  draftHost() {
775
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
+ };
776
819
  return {
777
820
  liveBranch: this.branch,
778
821
  branchStore: (branch) => new GitLabBackend({
779
822
  ...this.options,
780
- branch
823
+ branch,
824
+ caches: this.caches
781
825
  }),
782
826
  createBranch: async (branch, revision) => {
783
827
  if ((await gitlabRequest(this.api, this.repo(`/branches?${new URLSearchParams({
@@ -809,7 +853,7 @@ var GitLabBackend = class GitLabBackend {
809
853
  });
810
854
  return (await gitlabJson(this.api, this.repo(`/branches?${query}`))).map((found) => found.name).filter((name) => name.startsWith(prefix));
811
855
  },
812
- openReview: async ({ branch, title, description }) => {
856
+ openReview: async ({ branch, title, description, reviewers = [] }) => {
813
857
  const response = await gitlabRequest(this.api, `${project}/merge_requests`, {
814
858
  method: "POST",
815
859
  body: {
@@ -825,15 +869,33 @@ var GitLabBackend = class GitLabBackend {
825
869
  409
826
870
  ]
827
871
  });
828
- if (response.ok) return { url: (await response.json()).web_url };
829
- 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) => {
830
883
  const query = new URLSearchParams({
831
- source_branch: branch,
832
- 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]
833
898
  });
834
- const [open] = await gitlabJson(this.api, `${project}/merge_requests?${query}`);
835
- if (!open) throw new GitApiError(409, "GitLab couldn't open the merge request.");
836
- return { url: open.web_url };
837
899
  }
838
900
  };
839
901
  }
@@ -1026,6 +1088,7 @@ var GitLabBackend = class GitLabBackend {
1026
1088
  }
1027
1089
  });
1028
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(() => {});
1029
1092
  return { revision: commit.id };
1030
1093
  } catch (error) {
1031
1094
  if (!(error instanceof GitApiError)) throw error;
@@ -1034,6 +1097,22 @@ var GitLabBackend = class GitLabBackend {
1034
1097
  throw error;
1035
1098
  }
1036
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
+ }
1037
1116
  async history(path, { page = 1, perPage = 20 } = {}) {
1038
1117
  const query = new URLSearchParams({
1039
1118
  ref_name: this.pinned ?? await this.revision(),
@@ -1435,7 +1514,7 @@ function browserLocation() {
1435
1514
  function gitlab(options) {
1436
1515
  const webUrl = (options.url ?? "https://gitlab.com").replace(/\/$/, "");
1437
1516
  const apiUrl = `${webUrl}/api/v4`;
1438
- const fetcher = options.fetch ?? ((input, init) => globalThis.fetch(input, init));
1517
+ const fetcher = withRateLimits(options.fetch ?? ((input, init) => globalThis.fetch(input, init)), { host: "GitLab" });
1439
1518
  const storage = credentialStorage(`goodfellow:gitlab:${webUrl}:${options.project}`, options.storage);
1440
1519
  const pending = credentialStorage(`goodfellow:gitlab:pending:${options.project}`, options.storage);
1441
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. */
package/dist/testing.js CHANGED
@@ -566,9 +566,28 @@ function fakeGitLab(options) {
566
566
  const source = url.searchParams.get("source_branch");
567
567
  return json(repo.reviews.filter((review) => review.state === "open" && (!source || review.branch === source)).map((review) => ({
568
568
  iid: review.number,
569
+ title: review.title,
570
+ source_branch: review.branch,
569
571
  web_url: `${webUrl}/${options.project}/-/merge_requests/${review.number}`
570
572
  })));
571
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
+ }
572
591
  if (rest === "/repository/tree") {
573
592
  const ref = url.searchParams.get("ref") ?? repo.defaultBranch;
574
593
  const dir = url.searchParams.get("path") ?? "";
@@ -630,9 +649,11 @@ function fakeGitLab(options) {
630
649
  name: body.author_name,
631
650
  email: body.author_email
632
651
  });
652
+ const parent = repo.commitAt(sha)?.parent;
633
653
  return json({
634
654
  id: sha,
635
- message: body.commit_message
655
+ message: body.commit_message,
656
+ parent_ids: parent ? [parent] : []
636
657
  }, 201);
637
658
  }
638
659
  if (rest === "/repository/commits" && request.method === "GET") {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodfellow-cms/gitlab",
3
- "version": "0.6.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.6.0"
38
+ "@goodfellow-cms/core": "0.7.0"
39
39
  },
40
40
  "devDependencies": {
41
41
  "@puckeditor/core": "0.23.0"