@goodfellow-cms/gitlab 0.7.0 → 0.9.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.");
@@ -136,6 +143,11 @@ function gitlabEditors(api, project, branch, username) {
136
143
  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
144
  },
138
145
  async letEditorsPublish() {
146
+ const read = async () => {
147
+ const answer = await gitlabRequest(api, protectedPath, { allow: [404] }).catch(notAllowed$2);
148
+ return answer.ok ? await answer.json() : void 0;
149
+ };
150
+ const developersMay = (protection) => !protection || ["push_access_levels", "merge_access_levels"].every((kind) => (protection[kind] ?? []).some((level) => level.access_level !== null && level.access_level <= DEVELOPER));
139
151
  const protect = (level) => gitlabRequest(api, `${base}/protected_branches`, {
140
152
  method: "POST",
141
153
  body: {
@@ -145,6 +157,30 @@ function gitlabEditors(api, project, branch, username) {
145
157
  allow_force_push: false
146
158
  }
147
159
  });
160
+ const current = await read();
161
+ if (!current) {
162
+ await protect(DEVELOPER).catch(notAllowed$2);
163
+ return;
164
+ }
165
+ if (developersMay(current)) return;
166
+ const swap = (levels = []) => [{ access_level: DEVELOPER }, ...levels.flatMap((level) => level.id === void 0 ? [] : [{
167
+ id: level.id,
168
+ _destroy: true
169
+ }])];
170
+ if ((await gitlabRequest(api, protectedPath, {
171
+ method: "PATCH",
172
+ body: {
173
+ allow_force_push: false,
174
+ allowed_to_push: swap(current.push_access_levels),
175
+ allowed_to_merge: swap(current.merge_access_levels)
176
+ },
177
+ allow: [
178
+ 400,
179
+ 404,
180
+ 405,
181
+ 422
182
+ ]
183
+ }).catch(notAllowed$2)).ok && developersMay(await read())) return;
148
184
  await gitlabRequest(api, protectedPath, {
149
185
  method: "DELETE",
150
186
  allow: [404]
@@ -155,6 +191,11 @@ function gitlabEditors(api, project, branch, username) {
155
191
  await protect(MAINTAINER).catch(() => void 0);
156
192
  notAllowed$2(error);
157
193
  }
194
+ const after = await read();
195
+ if (!after || !developersMay(after)) {
196
+ if (!after) await protect(MAINTAINER).catch(() => void 0);
197
+ throw new Error("GitLab didn't keep the main branch's new protection. Try again.");
198
+ }
158
199
  }
159
200
  };
160
201
  }
@@ -346,7 +387,11 @@ function notAllowed$1(error) {
346
387
  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
388
  throw error;
348
389
  }
349
- /** A cron line for one instant, in UTC: it comes once a year, and `sync()` removes it once it has passed. */
390
+ /**
391
+ * A cron line for one instant, in UTC. GitLab's crons have no year, so it
392
+ * comes again a year later unless `sync()` has removed it since, which only
393
+ * builds the site again, since the schedule's variable skips the update.
394
+ */
350
395
  function cronAt(at) {
351
396
  return `${at.getUTCMinutes()} ${at.getUTCHours()} ${at.getUTCDate()} ${at.getUTCMonth() + 1} *`;
352
397
  }
@@ -373,7 +418,12 @@ function gitlabScheduledBuilds(api, project, branch, readCi, now = () => /* @__P
373
418
  if (wanted.size >= 50) break;
374
419
  wanted.set(minute(at), at);
375
420
  }
376
- const found = await gitlabJson(api, `${base}?per_page=100`).catch(notAllowed$1);
421
+ const found = [];
422
+ for (let page = 1; page <= 10; page++) {
423
+ const listed = await gitlabJson(api, `${base}?per_page=100&page=${page}`).catch(notAllowed$1);
424
+ found.push(...listed);
425
+ if (listed.length < 100) break;
426
+ }
377
427
  const ours = /* @__PURE__ */ new Map();
378
428
  for (const schedule of found) {
379
429
  if (!schedule.description?.startsWith("Goodfellow: build the site for a scheduled change at ")) continue;
@@ -398,14 +448,22 @@ function gitlabScheduledBuilds(api, project, branch, readCi, now = () => /* @__P
398
448
  active: true
399
449
  }
400
450
  }).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);
451
+ try {
452
+ await gitlabRequest(api, `${base}/${created.id}/variables`, {
453
+ method: "POST",
454
+ body: {
455
+ key: SCHEDULED_CHANGE_VARIABLE,
456
+ value: time,
457
+ variable_type: "env_var"
458
+ }
459
+ });
460
+ } catch (error) {
461
+ await gitlabRequest(api, `${base}/${created.id}`, {
462
+ method: "DELETE",
463
+ allow: [403, 404]
464
+ }).catch(() => {});
465
+ notAllowed$1(error);
466
+ }
409
467
  }
410
468
  }
411
469
  };
@@ -695,6 +753,10 @@ function gitlabCodeUpdates(options) {
695
753
  //#endregion
696
754
  //#region src/backend.ts
697
755
  const PAGE_SIZE = 100;
756
+ /** The most files GitLab's compare lists, by default. */
757
+ const COMPARE_FILES_LIMIT = 1e3;
758
+ /** How many pages of drafts' branches and merge requests are read: a thousand of each. */
759
+ const REVIEW_PAGES = 10;
698
760
  /** How many files one GraphQL request reads. */
699
761
  const GRAPHQL_BATCH = 50;
700
762
  /** The color and section codes in GitLab's job logs. */
@@ -847,11 +909,8 @@ var GitLabBackend = class GitLabBackend {
847
909
  return (await response.json()).id;
848
910
  },
849
911
  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));
912
+ const query = new URLSearchParams({ search: `^${prefix}` });
913
+ return (await this.everyPage(this.repo(`/branches?${query}`))).map((found) => found.name).filter((name) => name.startsWith(prefix));
855
914
  },
856
915
  openReview: async ({ branch, title, description, reviewers = [] }) => {
857
916
  const response = await gitlabRequest(this.api, `${project}/merge_requests`, {
@@ -880,11 +939,7 @@ var GitLabBackend = class GitLabBackend {
880
939
  return { url: review.web_url };
881
940
  },
882
941
  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) => ({
942
+ return (await this.everyPage(`${project}/merge_requests?state=opened`)).filter((request) => request.source_branch.startsWith(prefix)).map((request) => ({
888
943
  branch: request.source_branch,
889
944
  url: request.web_url,
890
945
  title: request.title
@@ -910,6 +965,16 @@ var GitLabBackend = class GitLabBackend {
910
965
  async head() {
911
966
  return (await gitlabJson(this.api, this.repo(`/branches/${encodeURIComponent(this.branch)}`))).commit.id;
912
967
  }
968
+ /** Every item of one of GitLab's lists, page by page, up to `REVIEW_PAGES` pages. */
969
+ async everyPage(path) {
970
+ const items = [];
971
+ for (let page = 1; page <= REVIEW_PAGES; page++) {
972
+ const found = await gitlabJson(this.api, `${path}&per_page=${PAGE_SIZE}&page=${page}`);
973
+ items.push(...found);
974
+ if (found.length < PAGE_SIZE) break;
975
+ }
976
+ return items;
977
+ }
913
978
  /** Every file in one folder at a commit, page by page. */
914
979
  async listFolder(sha, dir) {
915
980
  const files = [];
@@ -1132,12 +1197,13 @@ var GitLabBackend = class GitLabBackend {
1132
1197
  return response.ok ? response.text() : void 0;
1133
1198
  }
1134
1199
  async changedPaths(from, to) {
1135
- const comparison = await gitlabJson(this.api, this.repo(`/compare?${new URLSearchParams({
1200
+ const diffs = (await gitlabJson(this.api, this.repo(`/compare?${new URLSearchParams({
1136
1201
  from,
1137
1202
  to,
1138
1203
  straight: "true"
1139
- })}`));
1140
- return [...new Set((comparison.diffs ?? []).flatMap((diff) => [diff.old_path, diff.new_path]))];
1204
+ })}`))).diffs ?? [];
1205
+ if (diffs.length >= COMPARE_FILES_LIMIT) throw new GitApiError(0, `More files changed between ${from} and ${to} than GitLab lists.`);
1206
+ return [...new Set(diffs.flatMap((diff) => [diff.old_path, diff.new_path]))];
1141
1207
  }
1142
1208
  /**
1143
1209
  * Reads the commit's pipeline and external statuses (GitLab Pages jobs, or
@@ -1529,19 +1595,25 @@ function gitlab(options) {
1529
1595
  }
1530
1596
  async function connect(initial, remember) {
1531
1597
  let credentials = initial;
1532
- const token = async ({ refresh } = {}) => {
1598
+ let renewing;
1599
+ const renew = (current) => renewing ??= (async () => {
1600
+ credentials = toTokens(await requestTokens({
1601
+ client_id: options.clientId ?? "",
1602
+ refresh_token: current.refresh,
1603
+ grant_type: "refresh_token",
1604
+ redirect_uri: redirectUri(location().href()),
1605
+ code_verifier: current.verifier
1606
+ }), current.verifier);
1607
+ storage.save(JSON.stringify(credentials), remember);
1608
+ return credentials.access;
1609
+ })().finally(() => {
1610
+ renewing = void 0;
1611
+ });
1612
+ const token = async ({ refresh, stale } = {}) => {
1533
1613
  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
- }
1614
+ if (renewing) return renewing;
1615
+ if (refresh && stale !== void 0 && stale !== credentials.access) return credentials.access;
1616
+ if (refresh || credentials.expiresAt - 6e4 < Date.now()) return renew(credentials);
1545
1617
  return credentials.access;
1546
1618
  };
1547
1619
  const api = {
@@ -1584,18 +1656,18 @@ function gitlab(options) {
1584
1656
  const returnedState = url.searchParams.get("state");
1585
1657
  const error = url.searchParams.get("error");
1586
1658
  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
1659
  for (const key of [
1592
1660
  "code",
1593
1661
  "state",
1594
1662
  "error",
1595
1663
  "error_description"
1596
1664
  ]) url.searchParams.delete(key);
1597
- url.hash = redirect.hash;
1665
+ const saved = pending.load();
1666
+ if (saved) url.hash = JSON.parse(saved).hash;
1598
1667
  location().replace(url.toString());
1668
+ if (!saved) return null;
1669
+ pending.clear();
1670
+ const redirect = JSON.parse(saved);
1599
1671
  if (error || !code || returnedState !== redirect.state) throw new SignInError("redirect-failed", "Signing in with GitLab was cancelled or didn't finish.");
1600
1672
  return connect(toTokens(await requestTokens({
1601
1673
  client_id: options.clientId ?? "",
package/dist/testing.d.ts CHANGED
@@ -50,6 +50,8 @@ export interface FakeGitLabOptions {
50
50
  * artifact, or nothing for a job that fails. Without it, task pipelines fail.
51
51
  */
52
52
  tasks?: (variables: Record<string, string>) => string | undefined | Promise<string | undefined>;
53
+ /** GitLab before 15.6, which can't change a protected branch's access levels, only remove and add it. */
54
+ noProtectionChanges?: boolean;
53
55
  }
54
56
  /** A pipeline schedule, such as the nightly one or one the admin panel adds for a scheduled change. */
55
57
  export interface FakeSchedule {
@@ -120,6 +122,7 @@ export declare function fakeGitLab(options: FakeGitLabOptions): {
120
122
  merge: number;
121
123
  forcePush: boolean;
122
124
  }>;
125
+ protectionChanges: string[];
123
126
  failBuild: (sha: string, failure?: {
124
127
  job?: string;
125
128
  failureReason?: string;
@@ -172,6 +175,8 @@ export declare function fakeGitLab(options: FakeGitLabOptions): {
172
175
  requests: Request[];
173
176
  commit: (changes: FileChange[], message: string, author?: string, branch?: string) => string;
174
177
  authorize: (authorizeUrl: string) => string;
178
+ /** Ends every OAuth access token GitLab has given, as their two hours running out does; refresh tokens still work. */
179
+ expireAccessTokens: () => void;
175
180
  handle: (request: Request) => Promise<Response>;
176
181
  fetch: typeof fetch;
177
182
  };
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);
@@ -47,6 +53,8 @@ function fakeGitLab(options) {
47
53
  merge: 40,
48
54
  forcePush: false
49
55
  }]]);
56
+ /** Each change to a protected branch, in order: `DELETE main`, `POST main`, `PATCH main`. */
57
+ const protectionChanges = [];
50
58
  /** The project's members and invitations, and its protected branches. */
51
59
  async function handleMembers(request, url, rest, accessLevel) {
52
60
  const forbidden = () => json({ message: "403 Forbidden" }, 403);
@@ -128,20 +136,48 @@ function fakeGitLab(options) {
128
136
  if (!found) return json({ message: "404 Not found" }, 404);
129
137
  if (request.method === "GET") return json({
130
138
  name,
131
- push_access_levels: [{ access_level: found.push }],
132
- merge_access_levels: [{ access_level: found.merge }],
139
+ push_access_levels: [{
140
+ id: 1,
141
+ access_level: found.push
142
+ }],
143
+ merge_access_levels: [{
144
+ id: 2,
145
+ access_level: found.merge
146
+ }],
133
147
  allow_force_push: found.forcePush
134
148
  });
135
149
  if (request.method === "DELETE") {
136
150
  if (accessLevel < 40) return forbidden();
137
151
  protectedBranches.delete(name);
152
+ protectionChanges.push(`DELETE ${name}`);
138
153
  return new Response(null, { status: 204 });
139
154
  }
155
+ if (request.method === "PATCH" && !options.noProtectionChanges) {
156
+ if (accessLevel < 40) return forbidden();
157
+ const body = await request.json();
158
+ const levelFrom = (levels, id, now) => {
159
+ if (!levels) return now;
160
+ const kept = levels.some((level) => level.id === id && level._destroy) ? [] : [now];
161
+ const added = levels.flatMap((level) => level.access_level === void 0 ? [] : [level.access_level]);
162
+ return kept.length + added.length === 1 ? kept[0] ?? added[0] ?? now : void 0;
163
+ };
164
+ const push = levelFrom(body.allowed_to_push, 1, found.push);
165
+ const merge = levelFrom(body.allowed_to_merge, 2, found.merge);
166
+ if (push === void 0 || merge === void 0) return json({ message: "Only one access level allowed" }, 422);
167
+ protectedBranches.set(name, {
168
+ push,
169
+ merge,
170
+ forcePush: body.allow_force_push ?? found.forcePush
171
+ });
172
+ protectionChanges.push(`PATCH ${name}`);
173
+ return json({ name });
174
+ }
140
175
  }
141
176
  if (rest === "/protected_branches" && request.method === "POST") {
142
177
  if (accessLevel < 40) return forbidden();
143
178
  const body = await request.json();
144
179
  if (protectedBranches.has(body.name)) return json({ message: "Protected branch already exists" }, 409);
180
+ protectionChanges.push(`POST ${body.name}`);
145
181
  protectedBranches.set(body.name, {
146
182
  push: body.push_access_level ?? 40,
147
183
  merge: body.merge_access_level ?? 40,
@@ -515,7 +551,7 @@ function fakeGitLab(options) {
515
551
  if (rest === "/repository/branches" && request.method === "GET") {
516
552
  const search = url.searchParams.get("search") ?? "";
517
553
  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]) => ({
554
+ return json(onePage(url, [...repo.branches].filter(([name]) => matches(name))).map(([name, sha]) => ({
519
555
  name,
520
556
  commit: { id: sha }
521
557
  })));
@@ -564,7 +600,7 @@ function fakeGitLab(options) {
564
600
  }
565
601
  if (rest === "/merge_requests" && request.method === "GET") {
566
602
  const source = url.searchParams.get("source_branch");
567
- return json(repo.reviews.filter((review) => review.state === "open" && (!source || review.branch === source)).map((review) => ({
603
+ return json(onePage(url, repo.reviews.filter((review) => review.state === "open" && (!source || review.branch === source))).map((review) => ({
568
604
  iid: review.number,
569
605
  title: review.title,
570
606
  source_branch: review.branch,
@@ -892,6 +928,7 @@ function fakeGitLab(options) {
892
928
  members,
893
929
  invitations,
894
930
  protectedBranches,
931
+ protectionChanges,
895
932
  failBuild,
896
933
  cancelBuild,
897
934
  /** The project's update settings, and the pipelines the update ran in, which tests add with `addUpdateRun()`. */
@@ -908,6 +945,10 @@ function fakeGitLab(options) {
908
945
  requests,
909
946
  commit,
910
947
  authorize,
948
+ /** Ends every OAuth access token GitLab has given, as their two hours running out does; refresh tokens still work. */
949
+ expireAccessTokens: () => {
950
+ for (const [token] of tokens) if (token.startsWith("oauth-access-")) tokens.delete(token);
951
+ },
911
952
  handle,
912
953
  fetch: ((input, init) => handle(new Request(input, init)))
913
954
  };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@goodfellow-cms/gitlab",
3
- "version": "0.7.0",
4
- "description": "Edit a Goodfellow site stored on GitLab, straight from the browser.",
3
+ "version": "0.9.0",
4
+ "description": "Edit a Goodfellow CMS site stored on GitLab, straight from the browser.",
5
5
  "keywords": [
6
6
  "goodfellow",
7
7
  "cms",
@@ -12,6 +12,10 @@
12
12
  ],
13
13
  "homepage": "https://goodfellow-cms.github.io/goodfellow-cms/docs/hosts",
14
14
  "bugs": "https://github.com/goodfellow-cms/goodfellow-cms/issues",
15
+ "funding": {
16
+ "type": "github",
17
+ "url": "https://github.com/sponsors/goodfellow-cms"
18
+ },
15
19
  "license": "MIT",
16
20
  "repository": {
17
21
  "type": "git",
@@ -35,7 +39,7 @@
35
39
  "dist"
36
40
  ],
37
41
  "dependencies": {
38
- "@goodfellow-cms/core": "0.7.0"
42
+ "@goodfellow-cms/core": "0.9.0"
39
43
  },
40
44
  "devDependencies": {
41
45
  "@puckeditor/core": "0.23.0"