@goodfellow-cms/gitlab 0.8.0 → 0.10.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.js CHANGED
@@ -143,6 +143,11 @@ function gitlabEditors(api, project, branch, username) {
143
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);
144
144
  },
145
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));
146
151
  const protect = (level) => gitlabRequest(api, `${base}/protected_branches`, {
147
152
  method: "POST",
148
153
  body: {
@@ -152,6 +157,30 @@ function gitlabEditors(api, project, branch, username) {
152
157
  allow_force_push: false
153
158
  }
154
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;
155
184
  await gitlabRequest(api, protectedPath, {
156
185
  method: "DELETE",
157
186
  allow: [404]
@@ -162,6 +191,11 @@ function gitlabEditors(api, project, branch, username) {
162
191
  await protect(MAINTAINER).catch(() => void 0);
163
192
  notAllowed$2(error);
164
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
+ }
165
199
  }
166
200
  };
167
201
  }
@@ -1334,27 +1368,77 @@ function gitlabSetup(options = {}) {
1334
1368
  async function isAvailable(owner, name) {
1335
1369
  return (await gitlabRequest(api, `/projects/${encodeURIComponent(`${owner.path}/${name}`)}`, { allow: [404] })).status === 404;
1336
1370
  }
1371
+ const projectPath = (path) => `/projects/${encodeURIComponent(path)}`;
1372
+ /**
1373
+ * Makes one connection between a site's projects, giving what's left to do
1374
+ * by hand where GitLab refuses it, with what it answered.
1375
+ */
1376
+ async function attempt(unmade, request, left) {
1377
+ try {
1378
+ await request();
1379
+ } catch (error) {
1380
+ if (!(error instanceof GitApiError)) throw error;
1381
+ left.push({
1382
+ ...unmade,
1383
+ reason: error.message
1384
+ });
1385
+ }
1386
+ }
1337
1387
  /**
1338
1388
  * Lets each project's pipelines reach the other, as their setup files say:
1339
1389
  * a job token is refused by a project whose job token allowlist doesn't
1340
1390
  * name its project, even a public one. Also lets the code project's job
1341
- * token publish updates, and runs pipelines each night where they need it.
1342
- * Returns whether it worked.
1391
+ * token publish updates. Runs before the project's files are written,
1392
+ * since their first pipeline reads the other project. Each is tried on its
1393
+ * own, so one GitLab refuses doesn't keep the others from being made.
1343
1394
  */
1344
- async function connectProjects(link) {
1345
- const project = (path) => `/projects/${encodeURIComponent(path)}`;
1346
- try {
1347
- const ids = /* @__PURE__ */ new Map();
1348
- for (const which of ["code", "content"]) ids.set(which, (await gitlabJson(api, project(link[which]))).id);
1349
- for (const { reader, read } of link.links.reads) await gitlabRequest(api, `${project(link[read])}/job_token_scope/allowlist`, {
1350
- method: "POST",
1351
- body: { target_project_id: ids.get(reader) }
1352
- });
1353
- if (link.links.updates) await gitlabRequest(api, project(link[link.links.updates]), {
1395
+ async function connectProjects(link, left) {
1396
+ const ids = /* @__PURE__ */ new Map();
1397
+ for (const which of ["code", "content"]) try {
1398
+ ids.set(which, (await gitlabJson(api, projectPath(link[which]))).id);
1399
+ } catch (error) {
1400
+ if (!(error instanceof GitApiError)) throw error;
1401
+ }
1402
+ for (const { reader, read } of link.links.reads) {
1403
+ const target = ids.get(reader);
1404
+ await attempt({
1405
+ kind: "read",
1406
+ reader: link[reader],
1407
+ read: link[read],
1408
+ reason: ""
1409
+ }, async () => {
1410
+ if (target === void 0) throw new GitApiError(404, `GitLab couldn't find ${link[reader]}.`);
1411
+ await gitlabRequest(api, `${projectPath(link[read])}/job_token_scope/allowlist`, {
1412
+ method: "POST",
1413
+ body: { target_project_id: target }
1414
+ });
1415
+ }, left);
1416
+ }
1417
+ if (link.links.updates) {
1418
+ const repo = link[link.links.updates];
1419
+ await attempt({
1420
+ kind: "push",
1421
+ repo,
1422
+ reason: ""
1423
+ }, () => gitlabRequest(api, projectPath(repo), {
1354
1424
  method: "PUT",
1355
1425
  body: { ci_push_repository_for_job_token_allowed: true }
1356
- });
1357
- for (const which of link.links.nightly) await gitlabRequest(api, `${project(link[which])}/pipeline_schedules`, {
1426
+ }), left);
1427
+ }
1428
+ }
1429
+ /**
1430
+ * Runs the pipelines that need it each night. After the project's files
1431
+ * are written, since a schedule names the main branch, which an empty
1432
+ * project doesn't have yet.
1433
+ */
1434
+ async function scheduleNightly(link, left) {
1435
+ for (const which of link.links.nightly) {
1436
+ const repo = link[which];
1437
+ await attempt({
1438
+ kind: "nightly",
1439
+ repo,
1440
+ reason: ""
1441
+ }, () => gitlabRequest(api, `${projectPath(repo)}/pipeline_schedules`, {
1358
1442
  method: "POST",
1359
1443
  body: {
1360
1444
  description: which === link.links.updates ? "Rebuild the site and install Goodfellow's fixes each night" : "Rebuild the site each night",
@@ -1363,15 +1447,7 @@ function gitlabSetup(options = {}) {
1363
1447
  cron_timezone: "UTC",
1364
1448
  active: true
1365
1449
  }
1366
- });
1367
- return true;
1368
- } catch (error) {
1369
- if (error instanceof GitApiError && [
1370
- 400,
1371
- 403,
1372
- 404
1373
- ].includes(error.status)) return false;
1374
- throw error;
1450
+ }), left);
1375
1451
  }
1376
1452
  }
1377
1453
  return {
@@ -1412,9 +1488,10 @@ function gitlabSetup(options = {}) {
1412
1488
  throw error;
1413
1489
  }
1414
1490
  const warnings = [];
1491
+ const unconnected = [];
1415
1492
  if (site.link) {
1416
1493
  onStep?.("connect");
1417
- if (!await connectProjects(site.link)) warnings.push("connect-unavailable");
1494
+ await connectProjects(site.link, unconnected);
1418
1495
  }
1419
1496
  let siteUrl;
1420
1497
  if (site.pages && site.domain) {
@@ -1457,13 +1534,16 @@ function gitlabSetup(options = {}) {
1457
1534
  }])
1458
1535
  }
1459
1536
  });
1537
+ if (site.link) await scheduleNightly(site.link, unconnected);
1538
+ if (unconnected.length) warnings.push("connect-unavailable");
1460
1539
  return {
1461
1540
  repo: project.path_with_namespace,
1462
1541
  webUrl: project.web_url,
1463
1542
  ...siteUrl && { siteUrl },
1464
1543
  buildsUrl: `${project.web_url}/-/pipelines`,
1465
1544
  pagesUrl: site.pages ? `${project.web_url}/pages` : void 0,
1466
- warnings
1545
+ warnings,
1546
+ ...unconnected.length && { unconnected }
1467
1547
  };
1468
1548
  }
1469
1549
  };
package/dist/testing.d.ts CHANGED
@@ -40,6 +40,8 @@ export interface FakeGitLabOptions {
40
40
  };
41
41
  /** Domains another GitLab Pages site already uses. */
42
42
  takenDomains?: string[];
43
+ /** What connecting created projects GitLab refuses, as a plan or a sign-in might: job token allowlists, job token pushes or pipeline schedules. */
44
+ refuseConnections?: Array<"allowlist" | "push" | "schedule">;
43
45
  /** Other GitLab accounts that exist, which can be added to the project. Every token's user exists and is a member. */
44
46
  accounts?: string[];
45
47
  /** Who can see the project. Private by default. */
@@ -50,6 +52,8 @@ export interface FakeGitLabOptions {
50
52
  * artifact, or nothing for a job that fails. Without it, task pipelines fail.
51
53
  */
52
54
  tasks?: (variables: Record<string, string>) => string | undefined | Promise<string | undefined>;
55
+ /** GitLab before 15.6, which can't change a protected branch's access levels, only remove and add it. */
56
+ noProtectionChanges?: boolean;
53
57
  }
54
58
  /** A pipeline schedule, such as the nightly one or one the admin panel adds for a scheduled change. */
55
59
  export interface FakeSchedule {
@@ -120,6 +124,7 @@ export declare function fakeGitLab(options: FakeGitLabOptions): {
120
124
  merge: number;
121
125
  forcePush: boolean;
122
126
  }>;
127
+ protectionChanges: string[];
123
128
  failBuild: (sha: string, failure?: {
124
129
  job?: string;
125
130
  failureReason?: string;
package/dist/testing.js CHANGED
@@ -53,6 +53,8 @@ function fakeGitLab(options) {
53
53
  merge: 40,
54
54
  forcePush: false
55
55
  }]]);
56
+ /** Each change to a protected branch, in order: `DELETE main`, `POST main`, `PATCH main`. */
57
+ const protectionChanges = [];
56
58
  /** The project's members and invitations, and its protected branches. */
57
59
  async function handleMembers(request, url, rest, accessLevel) {
58
60
  const forbidden = () => json({ message: "403 Forbidden" }, 403);
@@ -134,20 +136,48 @@ function fakeGitLab(options) {
134
136
  if (!found) return json({ message: "404 Not found" }, 404);
135
137
  if (request.method === "GET") return json({
136
138
  name,
137
- push_access_levels: [{ access_level: found.push }],
138
- 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
+ }],
139
147
  allow_force_push: found.forcePush
140
148
  });
141
149
  if (request.method === "DELETE") {
142
150
  if (accessLevel < 40) return forbidden();
143
151
  protectedBranches.delete(name);
152
+ protectionChanges.push(`DELETE ${name}`);
144
153
  return new Response(null, { status: 204 });
145
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
+ }
146
175
  }
147
176
  if (rest === "/protected_branches" && request.method === "POST") {
148
177
  if (accessLevel < 40) return forbidden();
149
178
  const body = await request.json();
150
179
  if (protectedBranches.has(body.name)) return json({ message: "Protected branch already exists" }, 409);
180
+ protectionChanges.push(`POST ${body.name}`);
151
181
  protectedBranches.set(body.name, {
152
182
  push: body.push_access_level ?? 40,
153
183
  merge: body.merge_access_level ?? 40,
@@ -345,6 +375,7 @@ function fakeGitLab(options) {
345
375
  });
346
376
  if (rest === "" && request.method === "PUT") {
347
377
  const body = await request.json();
378
+ if (options.refuseConnections?.includes("push")) return json({ message: "403 Forbidden" }, 403);
348
379
  if (body.ci_push_repository_for_job_token_allowed !== void 0) project.jobTokenPush = body.ci_push_repository_for_job_token_allowed;
349
380
  return json({
350
381
  id: project.id,
@@ -353,6 +384,7 @@ function fakeGitLab(options) {
353
384
  }
354
385
  if (rest === "/job_token_scope/allowlist" && request.method === "POST") {
355
386
  const body = await request.json();
387
+ if (options.refuseConnections?.includes("allowlist")) return json({ message: "403 Forbidden" }, 403);
356
388
  if (![...created.values()].some((candidate) => candidate.id === body.target_project_id)) return json({ message: "404 Project Not Found" }, 404);
357
389
  if (!project.jobTokenAllowlist.includes(body.target_project_id)) project.jobTokenAllowlist.push(body.target_project_id);
358
390
  return json({
@@ -371,6 +403,8 @@ function fakeGitLab(options) {
371
403
  }
372
404
  if (rest === "/pipeline_schedules" && request.method === "POST") {
373
405
  const body = await request.json();
406
+ if (options.refuseConnections?.includes("schedule")) return json({ message: { ref: ["is ambiguous"] } }, 400);
407
+ if (project.repo.files().size === 0) return json({ message: { ref: ["is ambiguous"] } }, 400);
374
408
  project.schedules.push({
375
409
  ...body,
376
410
  active: body.active !== false
@@ -898,6 +932,7 @@ function fakeGitLab(options) {
898
932
  members,
899
933
  invitations,
900
934
  protectedBranches,
935
+ protectionChanges,
901
936
  failBuild,
902
937
  cancelBuild,
903
938
  /** The project's update settings, and the pipelines the update ran in, which tests add with `addUpdateRun()`. */
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@goodfellow-cms/gitlab",
3
- "version": "0.8.0",
4
- "description": "Edit a Goodfellow site stored on GitLab, straight from the browser.",
3
+ "version": "0.10.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.8.0"
42
+ "@goodfellow-cms/core": "0.10.0"
39
43
  },
40
44
  "devDependencies": {
41
45
  "@puckeditor/core": "0.23.0"