@goodfellow-cms/gitlab 0.6.0 → 0.8.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
@@ -3,9 +3,14 @@ import { ConnectDomainOptions, DeployStatus, DnsRecord, FileChange, FileVersion,
3
3
  interface ApiOptions {
4
4
  /** The API root, such as `https://gitlab.com/api/v4`. */
5
5
  apiUrl: string;
6
- /** Returns a current access token, refreshing it if needed. */
6
+ /**
7
+ * Returns a current access token, refreshing it if needed. `refresh` asks
8
+ * for a new one after GitLab refused `stale`, which another request may
9
+ * already have replaced.
10
+ */
7
11
  token: (options?: {
8
12
  refresh?: boolean;
13
+ stale?: string;
9
14
  }) => Promise<string>;
10
15
  /** Whether the token can be refreshed after a 401. */
11
16
  canRefresh: boolean;
@@ -44,6 +49,13 @@ interface GitLabBackendOptions {
44
49
  project: string;
45
50
  webUrl: string;
46
51
  };
52
+ /** What's read at each commit, shared with the backends of the project's other branches (drafts), since commits never change. */
53
+ caches?: GitLabCaches;
54
+ }
55
+ /** Path → blob id for the editable files at each commit, and each blob's text, by id. */
56
+ interface GitLabCaches {
57
+ trees: Map<string, Promise<Map<string, string>>>;
58
+ blobs: Map<string, Promise<string>>;
47
59
  }
48
60
  /**
49
61
  * The site's files on GitLab. Reads come from the commit `revision()` last
@@ -66,6 +78,7 @@ export declare class GitLabBackend implements GitBackend {
66
78
  private readonly branch;
67
79
  private readonly onSignOut;
68
80
  private pinned?;
81
+ private readonly caches;
69
82
  private readonly trees;
70
83
  private readonly blobs;
71
84
  constructor(options: GitLabBackendOptions);
@@ -76,6 +89,8 @@ export declare class GitLabBackend implements GitBackend {
76
89
  revision(): Promise<string>;
77
90
  /** The branch's latest commit, without moving the revision reads are pinned to. */
78
91
  private head;
92
+ /** Every item of one of GitLab's lists, page by page, up to `REVIEW_PAGES` pages. */
93
+ private everyPage;
79
94
  /** Every file in one folder at a commit, page by page. */
80
95
  private listFolder;
81
96
  /** Path → blob id for the editable files at a commit. Commits never change, so this is cached. */
@@ -98,6 +113,8 @@ export declare class GitLabBackend implements GitBackend {
98
113
  write(changes: FileChange[], { message, expectedRevision }: WriteOptions): Promise<{
99
114
  revision: string;
100
115
  }>;
116
+ /** Lists `revision`'s editable files as `before`'s with `changes` made, keeping the text written. */
117
+ private carryTree;
101
118
  history(path: string, { page, perPage }?: HistoryOptions): Promise<FileVersion[]>;
102
119
  readAt(path: string, revision: string): Promise<string | undefined>;
103
120
  changedPaths(from: string, to: string): Promise<string[]>;
package/dist/index.js CHANGED
@@ -1,16 +1,23 @@
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 = {}) {
5
- const send = async (refresh) => api.fetch(`${api.apiUrl}${path}`, {
6
- method: init.method ?? "GET",
7
- headers: {
8
- authorization: `Bearer ${await api.token({ refresh })}`,
9
- ...init.body !== void 0 && { "content-type": "application/json" }
10
- },
11
- body: init.body === void 0 ? void 0 : JSON.stringify(init.body),
12
- cache: "no-store"
13
- });
5
+ let sent;
6
+ const send = async (refresh) => {
7
+ sent = await api.token(refresh ? {
8
+ refresh,
9
+ ...sent !== void 0 && { stale: sent }
10
+ } : {});
11
+ return api.fetch(`${api.apiUrl}${path}`, {
12
+ method: init.method ?? "GET",
13
+ headers: {
14
+ authorization: `Bearer ${sent}`,
15
+ ...init.body !== void 0 && { "content-type": "application/json" }
16
+ },
17
+ body: init.body === void 0 ? void 0 : JSON.stringify(init.body),
18
+ cache: "no-store"
19
+ });
20
+ };
14
21
  let response = await send(false);
15
22
  if (response.status === 401 && api.canRefresh) response = await send(true);
16
23
  if (response.status === 401) throw new SignInError("invalid", "GitLab didn't accept the sign-in. It may have expired.");
@@ -346,7 +353,11 @@ function notAllowed$1(error) {
346
353
  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
354
  throw error;
348
355
  }
349
- /** A cron line for one instant, in UTC: it comes once a year, and `sync()` removes it once it has passed. */
356
+ /**
357
+ * A cron line for one instant, in UTC. GitLab's crons have no year, so it
358
+ * comes again a year later unless `sync()` has removed it since, which only
359
+ * builds the site again, since the schedule's variable skips the update.
360
+ */
350
361
  function cronAt(at) {
351
362
  return `${at.getUTCMinutes()} ${at.getUTCHours()} ${at.getUTCDate()} ${at.getUTCMonth() + 1} *`;
352
363
  }
@@ -373,7 +384,12 @@ function gitlabScheduledBuilds(api, project, branch, readCi, now = () => /* @__P
373
384
  if (wanted.size >= 50) break;
374
385
  wanted.set(minute(at), at);
375
386
  }
376
- const found = await gitlabJson(api, `${base}?per_page=100`).catch(notAllowed$1);
387
+ const found = [];
388
+ for (let page = 1; page <= 10; page++) {
389
+ const listed = await gitlabJson(api, `${base}?per_page=100&page=${page}`).catch(notAllowed$1);
390
+ found.push(...listed);
391
+ if (listed.length < 100) break;
392
+ }
377
393
  const ours = /* @__PURE__ */ new Map();
378
394
  for (const schedule of found) {
379
395
  if (!schedule.description?.startsWith("Goodfellow: build the site for a scheduled change at ")) continue;
@@ -398,14 +414,22 @@ function gitlabScheduledBuilds(api, project, branch, readCi, now = () => /* @__P
398
414
  active: true
399
415
  }
400
416
  }).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);
417
+ try {
418
+ await gitlabRequest(api, `${base}/${created.id}/variables`, {
419
+ method: "POST",
420
+ body: {
421
+ key: SCHEDULED_CHANGE_VARIABLE,
422
+ value: time,
423
+ variable_type: "env_var"
424
+ }
425
+ });
426
+ } catch (error) {
427
+ await gitlabRequest(api, `${base}/${created.id}`, {
428
+ method: "DELETE",
429
+ allow: [403, 404]
430
+ }).catch(() => {});
431
+ notAllowed$1(error);
432
+ }
409
433
  }
410
434
  }
411
435
  };
@@ -695,10 +719,22 @@ function gitlabCodeUpdates(options) {
695
719
  //#endregion
696
720
  //#region src/backend.ts
697
721
  const PAGE_SIZE = 100;
722
+ /** The most files GitLab's compare lists, by default. */
723
+ const COMPARE_FILES_LIMIT = 1e3;
724
+ /** How many pages of drafts' branches and merge requests are read: a thousand of each. */
725
+ const REVIEW_PAGES = 10;
698
726
  /** How many files one GraphQL request reads. */
699
727
  const GRAPHQL_BATCH = 50;
700
728
  /** The color and section codes in GitLab's job logs. */
701
729
  const ANSI_CODES = new RegExp(`${String.fromCharCode(27)}\\[[\\d;]*[A-Za-z]`, "g");
730
+ /** Git's id for a file's contents (the SHA-1 of `blob <size>\0` and them), as GitLab's tree lists it. */
731
+ async function gitBlobId(bytes) {
732
+ const header = new TextEncoder().encode(`blob ${bytes.length}\0`);
733
+ const data = new Uint8Array(header.length + bytes.length);
734
+ data.set(header);
735
+ data.set(bytes, header.length);
736
+ return [...new Uint8Array(await crypto.subtle.digest("SHA-1", data))].map((byte) => byte.toString(16).padStart(2, "0")).join("");
737
+ }
702
738
  /**
703
739
  * Whether a status says when the site is live. A job that only asks another
704
740
  * host to rebuild the site doesn't, and nor do the nightly update and the
@@ -733,8 +769,9 @@ var GitLabBackend = class GitLabBackend {
733
769
  branch;
734
770
  onSignOut;
735
771
  pinned;
736
- trees = /* @__PURE__ */ new Map();
737
- blobs = /* @__PURE__ */ new Map();
772
+ caches;
773
+ trees;
774
+ blobs;
738
775
  constructor(options) {
739
776
  this.options = options;
740
777
  this.api = options.api;
@@ -743,6 +780,12 @@ var GitLabBackend = class GitLabBackend {
743
780
  this.user = options.user;
744
781
  this.onSignOut = options.onSignOut;
745
782
  this.pinned = options.revision;
783
+ this.caches = options.caches ?? {
784
+ trees: /* @__PURE__ */ new Map(),
785
+ blobs: /* @__PURE__ */ new Map()
786
+ };
787
+ this.trees = this.caches.trees;
788
+ this.blobs = this.caches.blobs;
746
789
  this.pages = gitlabPages(this.api, options.project);
747
790
  this.editors = gitlabEditors(this.api, options.project, this.branch, this.user.login);
748
791
  this.contentOnly = options.contentOnly === true;
@@ -773,11 +816,40 @@ var GitLabBackend = class GitLabBackend {
773
816
  /** Drafts on branches of the project, with merge requests for review. */
774
817
  draftHost() {
775
818
  const project = `/projects/${this.project}`;
819
+ const openRequests = (branch) => gitlabJson(this.api, `${project}/merge_requests?${new URLSearchParams({
820
+ source_branch: branch,
821
+ state: "opened"
822
+ })}`);
823
+ /**
824
+ * Asks people to review a merge request, by their usernames. GitLab's free
825
+ * plan takes one reviewer, so where it refuses them all, the first is asked.
826
+ */
827
+ const askReviewers = async (iid, usernames) => {
828
+ const ids = [];
829
+ for (const username of usernames) {
830
+ const [found] = await gitlabJson(this.api, `/users?${new URLSearchParams({ username })}`).catch(() => []);
831
+ if (found) ids.push(found.id);
832
+ }
833
+ for (const reviewerIds of ids.length > 1 ? [ids, ids.slice(0, 1)] : [ids]) {
834
+ if (reviewerIds.length === 0) return;
835
+ if ((await gitlabRequest(this.api, `${project}/merge_requests/${iid}`, {
836
+ method: "PUT",
837
+ body: { reviewer_ids: reviewerIds },
838
+ allow: [
839
+ 400,
840
+ 403,
841
+ 404,
842
+ 422
843
+ ]
844
+ })).ok) return;
845
+ }
846
+ };
776
847
  return {
777
848
  liveBranch: this.branch,
778
849
  branchStore: (branch) => new GitLabBackend({
779
850
  ...this.options,
780
- branch
851
+ branch,
852
+ caches: this.caches
781
853
  }),
782
854
  createBranch: async (branch, revision) => {
783
855
  if ((await gitlabRequest(this.api, this.repo(`/branches?${new URLSearchParams({
@@ -803,13 +875,10 @@ var GitLabBackend = class GitLabBackend {
803
875
  return (await response.json()).id;
804
876
  },
805
877
  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));
878
+ const query = new URLSearchParams({ search: `^${prefix}` });
879
+ return (await this.everyPage(this.repo(`/branches?${query}`))).map((found) => found.name).filter((name) => name.startsWith(prefix));
811
880
  },
812
- openReview: async ({ branch, title, description }) => {
881
+ openReview: async ({ branch, title, description, reviewers = [] }) => {
813
882
  const response = await gitlabRequest(this.api, `${project}/merge_requests`, {
814
883
  method: "POST",
815
884
  body: {
@@ -825,15 +894,29 @@ var GitLabBackend = class GitLabBackend {
825
894
  409
826
895
  ]
827
896
  });
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.");
830
- const query = new URLSearchParams({
831
- source_branch: branch,
832
- state: "opened"
897
+ let review;
898
+ if (response.ok) review = await response.json();
899
+ else if (response.status !== 409) throw new DraftError("no-reviews", "This sign-in can't open merge requests on the site's project.");
900
+ else {
901
+ [review] = await openRequests(branch);
902
+ if (!review) throw new GitApiError(409, "GitLab couldn't open the merge request.");
903
+ }
904
+ await askReviewers(review.iid, reviewers);
905
+ return { url: review.web_url };
906
+ },
907
+ listReviews: async (prefix) => {
908
+ return (await this.everyPage(`${project}/merge_requests?state=opened`)).filter((request) => request.source_branch.startsWith(prefix)).map((request) => ({
909
+ branch: request.source_branch,
910
+ url: request.web_url,
911
+ title: request.title
912
+ }));
913
+ },
914
+ closeReview: async (branch) => {
915
+ for (const request of await openRequests(branch)) await gitlabRequest(this.api, `${project}/merge_requests/${request.iid}`, {
916
+ method: "PUT",
917
+ body: { state_event: "close" },
918
+ allow: [403, 404]
833
919
  });
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
920
  }
838
921
  };
839
922
  }
@@ -848,6 +931,16 @@ var GitLabBackend = class GitLabBackend {
848
931
  async head() {
849
932
  return (await gitlabJson(this.api, this.repo(`/branches/${encodeURIComponent(this.branch)}`))).commit.id;
850
933
  }
934
+ /** Every item of one of GitLab's lists, page by page, up to `REVIEW_PAGES` pages. */
935
+ async everyPage(path) {
936
+ const items = [];
937
+ for (let page = 1; page <= REVIEW_PAGES; page++) {
938
+ const found = await gitlabJson(this.api, `${path}&per_page=${PAGE_SIZE}&page=${page}`);
939
+ items.push(...found);
940
+ if (found.length < PAGE_SIZE) break;
941
+ }
942
+ return items;
943
+ }
851
944
  /** Every file in one folder at a commit, page by page. */
852
945
  async listFolder(sha, dir) {
853
946
  const files = [];
@@ -1026,6 +1119,7 @@ var GitLabBackend = class GitLabBackend {
1026
1119
  }
1027
1120
  });
1028
1121
  this.pinned = commit.id;
1122
+ if (commit.parent_ids?.length === 1 && commit.parent_ids[0] === expectedRevision) await this.carryTree(existing, commit.id, changes).catch(() => {});
1029
1123
  return { revision: commit.id };
1030
1124
  } catch (error) {
1031
1125
  if (!(error instanceof GitApiError)) throw error;
@@ -1034,6 +1128,22 @@ var GitLabBackend = class GitLabBackend {
1034
1128
  throw error;
1035
1129
  }
1036
1130
  }
1131
+ /** Lists `revision`'s editable files as `before`'s with `changes` made, keeping the text written. */
1132
+ async carryTree(before, revision, changes) {
1133
+ if (this.trees.has(revision) || !globalThis.crypto?.subtle) return;
1134
+ const tree = new Map(before);
1135
+ for (const change of changes) {
1136
+ if (!EDITABLE_FOLDERS.some((folder) => change.path.startsWith(`${folder}/`))) continue;
1137
+ if ("delete" in change) {
1138
+ tree.delete(change.path);
1139
+ continue;
1140
+ }
1141
+ const id = await gitBlobId("bytes" in change ? change.bytes : new TextEncoder().encode(change.content));
1142
+ tree.set(change.path, id);
1143
+ if ("content" in change && !this.blobs.has(id)) this.blobs.set(id, Promise.resolve(change.content));
1144
+ }
1145
+ this.trees.set(revision, Promise.resolve(tree));
1146
+ }
1037
1147
  async history(path, { page = 1, perPage = 20 } = {}) {
1038
1148
  const query = new URLSearchParams({
1039
1149
  ref_name: this.pinned ?? await this.revision(),
@@ -1053,12 +1163,13 @@ var GitLabBackend = class GitLabBackend {
1053
1163
  return response.ok ? response.text() : void 0;
1054
1164
  }
1055
1165
  async changedPaths(from, to) {
1056
- const comparison = await gitlabJson(this.api, this.repo(`/compare?${new URLSearchParams({
1166
+ const diffs = (await gitlabJson(this.api, this.repo(`/compare?${new URLSearchParams({
1057
1167
  from,
1058
1168
  to,
1059
1169
  straight: "true"
1060
- })}`));
1061
- return [...new Set((comparison.diffs ?? []).flatMap((diff) => [diff.old_path, diff.new_path]))];
1170
+ })}`))).diffs ?? [];
1171
+ if (diffs.length >= COMPARE_FILES_LIMIT) throw new GitApiError(0, `More files changed between ${from} and ${to} than GitLab lists.`);
1172
+ return [...new Set(diffs.flatMap((diff) => [diff.old_path, diff.new_path]))];
1062
1173
  }
1063
1174
  /**
1064
1175
  * Reads the commit's pipeline and external statuses (GitLab Pages jobs, or
@@ -1435,7 +1546,7 @@ function browserLocation() {
1435
1546
  function gitlab(options) {
1436
1547
  const webUrl = (options.url ?? "https://gitlab.com").replace(/\/$/, "");
1437
1548
  const apiUrl = `${webUrl}/api/v4`;
1438
- const fetcher = options.fetch ?? ((input, init) => globalThis.fetch(input, init));
1549
+ const fetcher = withRateLimits(options.fetch ?? ((input, init) => globalThis.fetch(input, init)), { host: "GitLab" });
1439
1550
  const storage = credentialStorage(`goodfellow:gitlab:${webUrl}:${options.project}`, options.storage);
1440
1551
  const pending = credentialStorage(`goodfellow:gitlab:pending:${options.project}`, options.storage);
1441
1552
  const location = () => options.location ?? browserLocation();
@@ -1450,19 +1561,25 @@ function gitlab(options) {
1450
1561
  }
1451
1562
  async function connect(initial, remember) {
1452
1563
  let credentials = initial;
1453
- const token = async ({ refresh } = {}) => {
1564
+ let renewing;
1565
+ const renew = (current) => renewing ??= (async () => {
1566
+ credentials = toTokens(await requestTokens({
1567
+ client_id: options.clientId ?? "",
1568
+ refresh_token: current.refresh,
1569
+ grant_type: "refresh_token",
1570
+ redirect_uri: redirectUri(location().href()),
1571
+ code_verifier: current.verifier
1572
+ }), current.verifier);
1573
+ storage.save(JSON.stringify(credentials), remember);
1574
+ return credentials.access;
1575
+ })().finally(() => {
1576
+ renewing = void 0;
1577
+ });
1578
+ const token = async ({ refresh, stale } = {}) => {
1454
1579
  if (credentials.type === "token") return credentials.token;
1455
- if (refresh || credentials.expiresAt - 6e4 < Date.now()) {
1456
- const { verifier } = credentials;
1457
- credentials = toTokens(await requestTokens({
1458
- client_id: options.clientId ?? "",
1459
- refresh_token: credentials.refresh,
1460
- grant_type: "refresh_token",
1461
- redirect_uri: redirectUri(location().href()),
1462
- code_verifier: verifier
1463
- }), verifier);
1464
- storage.save(JSON.stringify(credentials), remember);
1465
- }
1580
+ if (renewing) return renewing;
1581
+ if (refresh && stale !== void 0 && stale !== credentials.access) return credentials.access;
1582
+ if (refresh || credentials.expiresAt - 6e4 < Date.now()) return renew(credentials);
1466
1583
  return credentials.access;
1467
1584
  };
1468
1585
  const api = {
@@ -1505,18 +1622,18 @@ function gitlab(options) {
1505
1622
  const returnedState = url.searchParams.get("state");
1506
1623
  const error = url.searchParams.get("error");
1507
1624
  if (!code && !error) return null;
1508
- const saved = pending.load();
1509
- if (!saved) return null;
1510
- pending.clear();
1511
- const redirect = JSON.parse(saved);
1512
1625
  for (const key of [
1513
1626
  "code",
1514
1627
  "state",
1515
1628
  "error",
1516
1629
  "error_description"
1517
1630
  ]) url.searchParams.delete(key);
1518
- url.hash = redirect.hash;
1631
+ const saved = pending.load();
1632
+ if (saved) url.hash = JSON.parse(saved).hash;
1519
1633
  location().replace(url.toString());
1634
+ if (!saved) return null;
1635
+ pending.clear();
1636
+ const redirect = JSON.parse(saved);
1520
1637
  if (error || !code || returnedState !== redirect.state) throw new SignInError("redirect-failed", "Signing in with GitLab was cancelled or didn't finish.");
1521
1638
  return connect(toTokens(await requestTokens({
1522
1639
  client_id: options.clientId ?? "",
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. */
@@ -170,6 +172,8 @@ export declare function fakeGitLab(options: FakeGitLabOptions): {
170
172
  requests: Request[];
171
173
  commit: (changes: FileChange[], message: string, author?: string, branch?: string) => string;
172
174
  authorize: (authorizeUrl: string) => string;
175
+ /** Ends every OAuth access token GitLab has given, as their two hours running out does; refresh tokens still work. */
176
+ expireAccessTokens: () => void;
173
177
  handle: (request: Request) => Promise<Response>;
174
178
  fetch: typeof fetch;
175
179
  };
package/dist/testing.js CHANGED
@@ -17,6 +17,12 @@ async function sha256Base64Url(text) {
17
17
  for (const byte of digest) binary += String.fromCharCode(byte);
18
18
  return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
19
19
  }
20
+ /** One page of a list, as GitLab answers with `per_page` and `page`. */
21
+ function onePage(url, items) {
22
+ const perPage = Number(url.searchParams.get("per_page") ?? 20);
23
+ const page = Number(url.searchParams.get("page") ?? 1);
24
+ return items.slice((page - 1) * perPage, page * perPage);
25
+ }
20
26
  function fakeGitLab(options) {
21
27
  const webUrl = options.url ?? "https://gitlab.com";
22
28
  const repo = new FakeRepo(options.files);
@@ -515,7 +521,7 @@ function fakeGitLab(options) {
515
521
  if (rest === "/repository/branches" && request.method === "GET") {
516
522
  const search = url.searchParams.get("search") ?? "";
517
523
  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]) => ({
524
+ return json(onePage(url, [...repo.branches].filter(([name]) => matches(name))).map(([name, sha]) => ({
519
525
  name,
520
526
  commit: { id: sha }
521
527
  })));
@@ -564,11 +570,30 @@ function fakeGitLab(options) {
564
570
  }
565
571
  if (rest === "/merge_requests" && request.method === "GET") {
566
572
  const source = url.searchParams.get("source_branch");
567
- return json(repo.reviews.filter((review) => review.state === "open" && (!source || review.branch === source)).map((review) => ({
573
+ return json(onePage(url, repo.reviews.filter((review) => review.state === "open" && (!source || review.branch === source))).map((review) => ({
568
574
  iid: review.number,
575
+ title: review.title,
576
+ source_branch: review.branch,
569
577
  web_url: `${webUrl}/${options.project}/-/merge_requests/${review.number}`
570
578
  })));
571
579
  }
580
+ const mergeRequest = rest.match(/^\/merge_requests\/(\d+)$/);
581
+ if (mergeRequest && request.method === "PUT") {
582
+ if (accessLevel < 30) return json({ message: "403 Forbidden" }, 403);
583
+ const review = repo.reviews.find((found) => found.number === Number(mergeRequest[1]));
584
+ if (!review) return json({ message: "404 Not found" }, 404);
585
+ const body = await request.json();
586
+ if (body.reviewer_ids) {
587
+ if (body.reviewer_ids.length > 1 && !options.premium) return json({ message: "Too many reviewers" }, 400);
588
+ const names = new Map([...accounts].map(([username, id]) => [id, username]));
589
+ review.reviewers = body.reviewer_ids.flatMap((id) => names.get(id) ?? []);
590
+ }
591
+ if (body.state_event === "close") review.state = "closed";
592
+ return json({
593
+ iid: review.number,
594
+ state: review.state === "open" ? "opened" : review.state
595
+ });
596
+ }
572
597
  if (rest === "/repository/tree") {
573
598
  const ref = url.searchParams.get("ref") ?? repo.defaultBranch;
574
599
  const dir = url.searchParams.get("path") ?? "";
@@ -630,9 +655,11 @@ function fakeGitLab(options) {
630
655
  name: body.author_name,
631
656
  email: body.author_email
632
657
  });
658
+ const parent = repo.commitAt(sha)?.parent;
633
659
  return json({
634
660
  id: sha,
635
- message: body.commit_message
661
+ message: body.commit_message,
662
+ parent_ids: parent ? [parent] : []
636
663
  }, 201);
637
664
  }
638
665
  if (rest === "/repository/commits" && request.method === "GET") {
@@ -887,6 +914,10 @@ function fakeGitLab(options) {
887
914
  requests,
888
915
  commit,
889
916
  authorize,
917
+ /** Ends every OAuth access token GitLab has given, as their two hours running out does; refresh tokens still work. */
918
+ expireAccessTokens: () => {
919
+ for (const [token] of tokens) if (token.startsWith("oauth-access-")) tokens.delete(token);
920
+ },
890
921
  handle,
891
922
  fetch: ((input, init) => handle(new Request(input, init)))
892
923
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodfellow-cms/gitlab",
3
- "version": "0.6.0",
3
+ "version": "0.8.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.8.0"
39
39
  },
40
40
  "devDependencies": {
41
41
  "@puckeditor/core": "0.23.0"