@goodfellow-cms/gitlab 0.7.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;
@@ -84,6 +89,8 @@ export declare class GitLabBackend implements GitBackend {
84
89
  revision(): Promise<string>;
85
90
  /** The branch's latest commit, without moving the revision reads are pinned to. */
86
91
  private head;
92
+ /** Every item of one of GitLab's lists, page by page, up to `REVIEW_PAGES` pages. */
93
+ private everyPage;
87
94
  /** Every file in one folder at a commit, page by page. */
88
95
  private listFolder;
89
96
  /** Path → blob id for the editable files at a commit. Commits never change, so this is cached. */
package/dist/index.js CHANGED
@@ -2,15 +2,22 @@ import { ConflictError, DomainError, DraftError, EDITABLE_FOLDERS, EditorsError,
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,6 +719,10 @@ 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. */
@@ -847,11 +875,8 @@ var GitLabBackend = class GitLabBackend {
847
875
  return (await response.json()).id;
848
876
  },
849
877
  listBranches: async (prefix) => {
850
- const query = new URLSearchParams({
851
- search: `^${prefix}`,
852
- per_page: "100"
853
- });
854
- 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));
855
880
  },
856
881
  openReview: async ({ branch, title, description, reviewers = [] }) => {
857
882
  const response = await gitlabRequest(this.api, `${project}/merge_requests`, {
@@ -880,11 +905,7 @@ var GitLabBackend = class GitLabBackend {
880
905
  return { url: review.web_url };
881
906
  },
882
907
  listReviews: async (prefix) => {
883
- const query = new URLSearchParams({
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) => ({
908
+ return (await this.everyPage(`${project}/merge_requests?state=opened`)).filter((request) => request.source_branch.startsWith(prefix)).map((request) => ({
888
909
  branch: request.source_branch,
889
910
  url: request.web_url,
890
911
  title: request.title
@@ -910,6 +931,16 @@ var GitLabBackend = class GitLabBackend {
910
931
  async head() {
911
932
  return (await gitlabJson(this.api, this.repo(`/branches/${encodeURIComponent(this.branch)}`))).commit.id;
912
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
+ }
913
944
  /** Every file in one folder at a commit, page by page. */
914
945
  async listFolder(sha, dir) {
915
946
  const files = [];
@@ -1132,12 +1163,13 @@ var GitLabBackend = class GitLabBackend {
1132
1163
  return response.ok ? response.text() : void 0;
1133
1164
  }
1134
1165
  async changedPaths(from, to) {
1135
- const comparison = await gitlabJson(this.api, this.repo(`/compare?${new URLSearchParams({
1166
+ const diffs = (await gitlabJson(this.api, this.repo(`/compare?${new URLSearchParams({
1136
1167
  from,
1137
1168
  to,
1138
1169
  straight: "true"
1139
- })}`));
1140
- 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]))];
1141
1173
  }
1142
1174
  /**
1143
1175
  * Reads the commit's pipeline and external statuses (GitLab Pages jobs, or
@@ -1529,19 +1561,25 @@ function gitlab(options) {
1529
1561
  }
1530
1562
  async function connect(initial, remember) {
1531
1563
  let credentials = initial;
1532
- 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 } = {}) => {
1533
1579
  if (credentials.type === "token") return credentials.token;
1534
- if (refresh || credentials.expiresAt - 6e4 < Date.now()) {
1535
- const { verifier } = credentials;
1536
- credentials = toTokens(await requestTokens({
1537
- client_id: options.clientId ?? "",
1538
- refresh_token: credentials.refresh,
1539
- grant_type: "refresh_token",
1540
- redirect_uri: redirectUri(location().href()),
1541
- code_verifier: verifier
1542
- }), verifier);
1543
- storage.save(JSON.stringify(credentials), remember);
1544
- }
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);
1545
1583
  return credentials.access;
1546
1584
  };
1547
1585
  const api = {
@@ -1584,18 +1622,18 @@ function gitlab(options) {
1584
1622
  const returnedState = url.searchParams.get("state");
1585
1623
  const error = url.searchParams.get("error");
1586
1624
  if (!code && !error) return null;
1587
- const saved = pending.load();
1588
- if (!saved) return null;
1589
- pending.clear();
1590
- const redirect = JSON.parse(saved);
1591
1625
  for (const key of [
1592
1626
  "code",
1593
1627
  "state",
1594
1628
  "error",
1595
1629
  "error_description"
1596
1630
  ]) url.searchParams.delete(key);
1597
- url.hash = redirect.hash;
1631
+ const saved = pending.load();
1632
+ if (saved) url.hash = JSON.parse(saved).hash;
1598
1633
  location().replace(url.toString());
1634
+ if (!saved) return null;
1635
+ pending.clear();
1636
+ const redirect = JSON.parse(saved);
1599
1637
  if (error || !code || returnedState !== redirect.state) throw new SignInError("redirect-failed", "Signing in with GitLab was cancelled or didn't finish.");
1600
1638
  return connect(toTokens(await requestTokens({
1601
1639
  client_id: options.clientId ?? "",
package/dist/testing.d.ts CHANGED
@@ -172,6 +172,8 @@ export declare function fakeGitLab(options: FakeGitLabOptions): {
172
172
  requests: Request[];
173
173
  commit: (changes: FileChange[], message: string, author?: string, branch?: string) => string;
174
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;
175
177
  handle: (request: Request) => Promise<Response>;
176
178
  fetch: typeof fetch;
177
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,7 +570,7 @@ 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,
569
575
  title: review.title,
570
576
  source_branch: review.branch,
@@ -908,6 +914,10 @@ function fakeGitLab(options) {
908
914
  requests,
909
915
  commit,
910
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
+ },
911
921
  handle,
912
922
  fetch: ((input, init) => handle(new Request(input, init)))
913
923
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodfellow-cms/gitlab",
3
- "version": "0.7.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.7.0"
38
+ "@goodfellow-cms/core": "0.8.0"
39
39
  },
40
40
  "devDependencies": {
41
41
  "@puckeditor/core": "0.23.0"