@goodfellow-cms/gitlab 0.1.0 → 0.3.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
@@ -1,4 +1,4 @@
1
- import { ConflictError, EDITABLE_FOLDERS, GitApiError, SignInError, credentialStorage, encodeBase64Bytes } from "@goodfellow-cms/core";
1
+ import { ConflictError, DomainError, DraftError, EDITABLE_FOLDERS, EditorsError, GitApiError, SetupError, SignInError, UpdatesError, branchDrafts, buildStepFor, credentialStorage, encodeBase64Bytes, findBuildCause, findUpdateResult } 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 = {}) {
@@ -25,15 +25,424 @@ async function gitlabJson(api, path, init) {
25
25
  return await (await gitlabRequest(api, path, init)).json();
26
26
  }
27
27
  //#endregion
28
+ //#region src/editors.ts
29
+ /** GitLab's roles: Developer, Maintainer and Owner. */
30
+ const DEVELOPER = 30;
31
+ const MAINTAINER = 40;
32
+ const MEMBER = "member:";
33
+ const INVITATION = "invitation:";
34
+ const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
35
+ const USERNAME = /^[\w][\w.-]*$/;
36
+ function roleOf(level) {
37
+ if (level >= MAINTAINER) return "owner";
38
+ return level >= DEVELOPER ? "editor" : "viewer";
39
+ }
40
+ function levelOf(role) {
41
+ return role === "owner" ? MAINTAINER : DEVELOPER;
42
+ }
43
+ function notAllowed$1(error) {
44
+ if (error instanceof GitApiError && (error.status === 401 || error.status === 403)) throw new EditorsError("not-allowed", "Changing editors on GitLab needs the Maintainer role in the project.");
45
+ throw error;
46
+ }
47
+ /**
48
+ * The project's members. Editors are Developers, and owners Maintainers,
49
+ * who can also add and remove members. GitLab protects the main branch so
50
+ * only Maintainers can publish to it, until `letEditorsPublish()` lets
51
+ * Developers publish too.
52
+ */
53
+ function gitlabEditors(api, project, branch, username) {
54
+ const base = `/projects/${encodeURIComponent(project)}`;
55
+ const protectedPath = `${base}/protected_branches/${encodeURIComponent(branch)}`;
56
+ async function all(path) {
57
+ const found = [];
58
+ for (let page = 1; page <= 10; page++) {
59
+ const items = await gitlabJson(api, `${path}${path.includes("?") ? "&" : "?"}per_page=100&page=${page}`);
60
+ found.push(...items);
61
+ if (items.length < 100) break;
62
+ }
63
+ return found;
64
+ }
65
+ return {
66
+ invitesByEmail: true,
67
+ async list() {
68
+ const [everyone, direct] = await Promise.all([all(`${base}/members/all`), all(`${base}/members`)]);
69
+ const own = new Set(direct.map((member) => member.id));
70
+ const self = everyone.find((member) => member.username.toLowerCase() === username.toLowerCase());
71
+ const manage = (self?.access_level ?? 0) >= MAINTAINER;
72
+ const invitations = manage ? await all(`${base}/invitations`).catch(() => []) : [];
73
+ const protection = await gitlabRequest(api, protectedPath, { allow: [404] });
74
+ const levels = protection.ok ? (await protection.json()).push_access_levels ?? [] : [];
75
+ const editorsCanPublish = !protection.ok || levels.some((level) => level.access_level !== null && level.access_level <= DEVELOPER);
76
+ return {
77
+ editors: [...everyone.map((member) => ({
78
+ id: `${MEMBER}${member.id}`,
79
+ login: member.username,
80
+ name: member.name,
81
+ avatarUrl: member.avatar_url,
82
+ role: roleOf(member.access_level),
83
+ ...!own.has(member.id) && { inherited: true },
84
+ ...member.id === self?.id && { self: true }
85
+ })), ...invitations.map((invitation) => ({
86
+ id: `${INVITATION}${invitation.invite_email}`,
87
+ email: invitation.invite_email,
88
+ role: roleOf(invitation.access_level),
89
+ invited: true
90
+ }))],
91
+ manage: manage ? "yes" : "no",
92
+ editorsCanPublish
93
+ };
94
+ },
95
+ async invite(who, role) {
96
+ const name = who.trim().replace(/^@/, "");
97
+ if (name.includes("@")) {
98
+ if (!EMAIL.test(name)) throw new EditorsError("invalid", "That isn't an email address.");
99
+ const result = await gitlabJson(api, `${base}/invitations`, {
100
+ method: "POST",
101
+ body: {
102
+ email: name,
103
+ access_level: levelOf(role)
104
+ }
105
+ }).catch(notAllowed$1);
106
+ if (result.status !== "success") {
107
+ const message = typeof result.message === "string" ? result.message : Object.values(result.message ?? {})[0];
108
+ if (/already/i.test(message ?? "")) throw new EditorsError("already", "They've already been invited.");
109
+ throw new EditorsError("invalid", `GitLab didn't accept the invitation: ${message ?? result.status}`);
110
+ }
111
+ return;
112
+ }
113
+ if (!USERNAME.test(name)) throw new EditorsError("invalid", "That isn't a GitLab username or email address.");
114
+ const [user] = await gitlabJson(api, `/users?${new URLSearchParams({ username: name })}`);
115
+ if (!user) throw new EditorsError("not-found", `There's no GitLab account named ${name}.`);
116
+ try {
117
+ await gitlabRequest(api, `${base}/members`, {
118
+ method: "POST",
119
+ body: {
120
+ user_id: user.id,
121
+ access_level: levelOf(role)
122
+ }
123
+ });
124
+ } catch (error) {
125
+ if (error instanceof GitApiError && error.status === 409) throw new EditorsError("already", `${name} already edits the site.`);
126
+ notAllowed$1(error);
127
+ }
128
+ },
129
+ async setRole(editor, role) {
130
+ await gitlabRequest(api, editor.id.startsWith(INVITATION) ? `${base}/invitations/${encodeURIComponent(editor.id.slice(11))}` : `${base}/members/${editor.id.slice(7)}`, {
131
+ method: "PUT",
132
+ body: { access_level: levelOf(role) }
133
+ }).catch(notAllowed$1);
134
+ },
135
+ async remove(editor) {
136
+ await gitlabRequest(api, editor.id.startsWith(INVITATION) ? `${base}/invitations/${encodeURIComponent(editor.id.slice(11))}` : `${base}/members/${editor.id.slice(7)}`, { method: "DELETE" }).catch(notAllowed$1);
137
+ },
138
+ async letEditorsPublish() {
139
+ const protect = (level) => gitlabRequest(api, `${base}/protected_branches`, {
140
+ method: "POST",
141
+ body: {
142
+ name: branch,
143
+ push_access_level: level,
144
+ merge_access_level: MAINTAINER,
145
+ allow_force_push: false
146
+ }
147
+ });
148
+ await gitlabRequest(api, protectedPath, {
149
+ method: "DELETE",
150
+ allow: [404]
151
+ }).catch(notAllowed$1);
152
+ try {
153
+ await protect(DEVELOPER);
154
+ } catch (error) {
155
+ await protect(MAINTAINER).catch(() => void 0);
156
+ notAllowed$1(error);
157
+ }
158
+ }
159
+ };
160
+ }
161
+ //#endregion
162
+ //#region src/pages.ts
163
+ /** GitLab.com Pages' addresses for an apex domain, from GitLab's documentation. */
164
+ const PAGES_IPV4 = "35.185.44.232";
165
+ const PAGES_IPV6 = "2600:1901:0:7b8a::";
166
+ /** The domains `connect()` adds: the domain, and `www.` for an apex domain that wants it. */
167
+ function domainsFor(domain, { apex, www }) {
168
+ return apex && www ? [domain, `www.${domain}`] : [domain];
169
+ }
170
+ /**
171
+ * The records GitLab Pages needs: its address for an apex domain, or an alias
172
+ * of the namespace's gitlab.io otherwise, and a TXT record per domain that
173
+ * proves it's the owner's, with the codes GitLab gave.
174
+ */
175
+ function gitlabPagesRecords(project, domain, options, codes) {
176
+ const target = `${(project.split("/")[0] ?? "").toLowerCase()}.gitlab.io`;
177
+ const site = options.apex ? [{
178
+ type: "A",
179
+ name: domain,
180
+ value: PAGES_IPV4,
181
+ purpose: "site"
182
+ }, {
183
+ type: "AAAA",
184
+ name: domain,
185
+ value: PAGES_IPV6,
186
+ purpose: "site",
187
+ optional: true
188
+ }] : [{
189
+ type: "CNAME",
190
+ name: domain,
191
+ value: target,
192
+ purpose: "site"
193
+ }];
194
+ const www = options.apex && options.www ? [{
195
+ type: "CNAME",
196
+ name: `www.${domain}`,
197
+ value: target,
198
+ purpose: "www",
199
+ optional: true
200
+ }] : [];
201
+ const verification = domainsFor(domain, options).flatMap((name) => {
202
+ const code = codes[name];
203
+ if (!code) return [];
204
+ const value = code.startsWith("gitlab-pages-verification-code=") ? code : `gitlab-pages-verification-code=${code}`;
205
+ return [{
206
+ type: "TXT",
207
+ name: `_gitlab-pages-verification-code.${name}`,
208
+ value,
209
+ purpose: "verification",
210
+ optional: name !== domain
211
+ }];
212
+ });
213
+ return [
214
+ ...site,
215
+ ...www,
216
+ ...verification
217
+ ];
218
+ }
219
+ /** GitLab Pages, for a site's project: where it's published, and its custom domains. */
220
+ function gitlabPages(api, project) {
221
+ const pagesPath = `/projects/${encodeURIComponent(project)}/pages`;
222
+ const domainPath = (domain) => `${pagesPath}/domains/${encodeURIComponent(domain)}`;
223
+ function notAllowed(error) {
224
+ if (error instanceof GitApiError && (error.status === 401 || error.status === 403)) throw new DomainError("not-allowed", "Connecting a domain on GitLab needs the Maintainer role in the project.");
225
+ throw error;
226
+ }
227
+ async function pages() {
228
+ const response = await gitlabRequest(api, pagesPath, { allow: [404] }).catch(notAllowed);
229
+ return response.ok ? await response.json() : void 0;
230
+ }
231
+ async function codes(domains) {
232
+ const found = await Promise.all(domains.map(async (domain) => {
233
+ const response = await gitlabRequest(api, domainPath(domain), { allow: [404] });
234
+ if (!response.ok) return [domain, ""];
235
+ return [domain, (await response.json()).verification_code ?? ""];
236
+ }));
237
+ return Object.fromEntries(found);
238
+ }
239
+ async function verified(domain) {
240
+ const found = await gitlabJson(api, domainPath(domain)).catch(notAllowed);
241
+ if (found.verified) return found;
242
+ const response = await gitlabRequest(api, `${domainPath(domain)}/verify`, {
243
+ method: "PUT",
244
+ allow: [
245
+ 400,
246
+ 404,
247
+ 422
248
+ ]
249
+ });
250
+ return response.ok ? await response.json() : found;
251
+ }
252
+ return {
253
+ async site() {
254
+ const found = await pages();
255
+ if (!found) return void 0;
256
+ const names = (await gitlabJson(api, `${pagesPath}/domains`).catch(() => [])).map((entry) => entry.domain);
257
+ const domain = (found.primary_domain && names.includes(found.primary_domain) ? found.primary_domain : void 0) ?? names.sort((a, b) => a.length - b.length)[0];
258
+ return {
259
+ url: found.url,
260
+ domain
261
+ };
262
+ },
263
+ async connect(domain, options) {
264
+ const found = await pages();
265
+ if (found && found.is_unique_domain_enabled === false) await gitlabRequest(api, pagesPath, {
266
+ method: "PATCH",
267
+ body: { pages_unique_domain_enabled: true }
268
+ }).catch(notAllowed);
269
+ for (const name of domainsFor(domain, options)) try {
270
+ await gitlabRequest(api, `${pagesPath}/domains`, {
271
+ method: "POST",
272
+ body: {
273
+ domain: name,
274
+ auto_ssl_enabled: true
275
+ }
276
+ });
277
+ } catch (error) {
278
+ if (error instanceof GitApiError && error.status === 400) {
279
+ if (/taken/i.test(error.message)) {
280
+ if ((await gitlabRequest(api, domainPath(name), { allow: [404] })).ok) continue;
281
+ throw new DomainError("taken", "Another GitLab Pages site already uses this domain.");
282
+ }
283
+ throw new DomainError("invalid", "GitLab Pages doesn't accept this domain.");
284
+ }
285
+ notAllowed(error);
286
+ }
287
+ return gitlabPagesRecords(project, domain, options, await codes(domainsFor(domain, options)));
288
+ },
289
+ async records(domain, options) {
290
+ return gitlabPagesRecords(project, domain, options, await codes(domainsFor(domain, options)));
291
+ },
292
+ async status(domain) {
293
+ const found = await verified(domain);
294
+ const www = await gitlabRequest(api, domainPath(`www.${domain}`), { allow: [404] });
295
+ if (www.ok && !(await www.json()).verified) await verified(`www.${domain}`);
296
+ const settings = await pages();
297
+ return {
298
+ verified: found.verified,
299
+ certificate: found.certificate && !found.certificate.expired ? "ready" : "pending",
300
+ httpsOnly: settings?.force_https === true,
301
+ primary: settings?.primary_domain === void 0 || settings.primary_domain === domain
302
+ };
303
+ },
304
+ async secure(domain) {
305
+ const found = await gitlabJson(api, domainPath(domain)).catch(notAllowed);
306
+ if (!found.verified || !found.certificate) throw new DomainError("not-ready", "The domain's certificate isn't ready yet.");
307
+ try {
308
+ await gitlabRequest(api, pagesPath, {
309
+ method: "PATCH",
310
+ body: {
311
+ pages_https_only: true,
312
+ pages_primary_domain: domain
313
+ }
314
+ });
315
+ } catch (error) {
316
+ if (!(error instanceof GitApiError && error.status === 400)) notAllowed(error);
317
+ await gitlabRequest(api, pagesPath, {
318
+ method: "PATCH",
319
+ body: { pages_https_only: true }
320
+ }).catch(notAllowed);
321
+ }
322
+ },
323
+ async disconnect(domain) {
324
+ for (const name of [domain, `www.${domain}`]) await gitlabRequest(api, domainPath(name), {
325
+ method: "DELETE",
326
+ allow: [404]
327
+ }).catch(notAllowed);
328
+ }
329
+ };
330
+ }
331
+ //#endregion
332
+ //#region src/updates.ts
333
+ const CI_FILE = ".gitlab-ci.yml";
334
+ /** When the nightly schedule the Updates screen adds runs, in UTC, as the GitHub Pages workflow's does. */
335
+ const NIGHTLY = "17 4 * * *";
336
+ function notAllowed(error) {
337
+ if (error instanceof GitApiError && (error.status === 401 || error.status === 403)) throw new UpdatesError("not-allowed", "Setting up updates on GitLab needs the Maintainer role in the project.");
338
+ throw error;
339
+ }
340
+ /**
341
+ * The site's nightly update, which runs in its GitLab Pages job on scheduled
342
+ * pipelines, and on pipelines the Updates screen starts with `GOODFELLOW_UPDATE`.
343
+ * It publishes with the job's own token, which GitLab only allows once the
344
+ * project lets job tokens push.
345
+ */
346
+ function gitlabUpdates(api, project, branch, readAt) {
347
+ const base = `/projects/${encodeURIComponent(project)}`;
348
+ async function schedules() {
349
+ return gitlabJson(api, `${base}/pipeline_schedules`);
350
+ }
351
+ return {
352
+ async setup() {
353
+ if (!(await readAt(CI_FILE))?.includes("update --publish")) return "missing";
354
+ try {
355
+ const [settings, found] = await Promise.all([gitlabJson(api, base), schedules()]);
356
+ const scheduled = found.some((schedule) => schedule.active && (schedule.ref === branch || schedule.ref === `refs/heads/${branch}`));
357
+ return settings.ci_push_repository_for_job_token_allowed && scheduled ? "ready" : "needs-setup";
358
+ } catch (error) {
359
+ if (error instanceof GitApiError && error.status === 403) return "needs-setup";
360
+ throw error;
361
+ }
362
+ },
363
+ async enable() {
364
+ await gitlabRequest(api, base, {
365
+ method: "PUT",
366
+ body: { ci_push_repository_for_job_token_allowed: true }
367
+ }).catch(notAllowed);
368
+ if (!(await schedules().catch(notAllowed)).some((schedule) => schedule.active && schedule.ref.endsWith(branch))) await gitlabRequest(api, `${base}/pipeline_schedules`, {
369
+ method: "POST",
370
+ body: {
371
+ description: "Rebuild the site and install Goodfellow's fixes each night",
372
+ ref: branch,
373
+ cron: NIGHTLY,
374
+ cron_timezone: "UTC",
375
+ active: true
376
+ }
377
+ }).catch(notAllowed);
378
+ },
379
+ async lastRun() {
380
+ const pipelines = await gitlabJson(api, `${base}/pipelines?${new URLSearchParams({
381
+ ref: branch,
382
+ per_page: "20"
383
+ })}`);
384
+ for (const pipeline of pipelines) {
385
+ if (pipeline.source !== "schedule" && pipeline.source !== "api") continue;
386
+ const run = {
387
+ date: pipeline.created_at,
388
+ detailsUrl: pipeline.web_url
389
+ };
390
+ if ([
391
+ "created",
392
+ "pending",
393
+ "running",
394
+ "waiting_for_resource",
395
+ "preparing"
396
+ ].includes(pipeline.status)) return {
397
+ state: "running",
398
+ ...run
399
+ };
400
+ const job = (await gitlabJson(api, `${base}/pipelines/${pipeline.id}/jobs?per_page=100`)).find((candidate) => candidate.name === "pages");
401
+ if (!job) continue;
402
+ const response = await gitlabRequest(api, `${base}/jobs/${job.id}/trace`, { allow: [403, 404] });
403
+ const result = response.ok ? findUpdateResult(await response.text()) : void 0;
404
+ if (!result) continue;
405
+ return {
406
+ state: result.state,
407
+ result,
408
+ ...run
409
+ };
410
+ }
411
+ },
412
+ async start(target) {
413
+ await gitlabRequest(api, `${base}/pipeline`, {
414
+ method: "POST",
415
+ body: {
416
+ ref: branch,
417
+ variables: [{
418
+ key: "GOODFELLOW_UPDATE",
419
+ variable_type: "env_var",
420
+ value: target
421
+ }]
422
+ }
423
+ }).catch(notAllowed);
424
+ }
425
+ };
426
+ }
427
+ //#endregion
28
428
  //#region src/backend.ts
29
429
  const PAGE_SIZE = 100;
430
+ /** How many files one GraphQL request reads. */
431
+ const GRAPHQL_BATCH = 50;
432
+ /** The color and section codes in GitLab's job logs. */
433
+ const ANSI_CODES = new RegExp(`${String.fromCharCode(27)}\\[[\\d;]*[A-Za-z]`, "g");
30
434
  /**
31
435
  * The site's files on GitLab. Reads come from the commit `revision()` last
32
436
  * returned. Saves are single commits; each changed file carries the commit it
33
437
  * was last changed in, so GitLab refuses to overwrite anyone else's edits.
34
438
  */
35
- var GitLabBackend = class {
439
+ var GitLabBackend = class GitLabBackend {
36
440
  user;
441
+ pages;
442
+ editors;
443
+ updates;
444
+ drafts;
445
+ options;
37
446
  api;
38
447
  project;
39
448
  branch;
@@ -42,11 +451,76 @@ var GitLabBackend = class {
42
451
  trees = /* @__PURE__ */ new Map();
43
452
  blobs = /* @__PURE__ */ new Map();
44
453
  constructor(options) {
454
+ this.options = options;
45
455
  this.api = options.api;
46
456
  this.project = encodeURIComponent(options.project);
47
457
  this.branch = options.branch;
48
458
  this.user = options.user;
49
459
  this.onSignOut = options.onSignOut;
460
+ this.pages = gitlabPages(this.api, options.project);
461
+ this.editors = gitlabEditors(this.api, options.project, this.branch, this.user.login);
462
+ this.updates = gitlabUpdates(this.api, options.project, this.branch, async (path) => this.readAt(path, this.pinned ?? await this.revision()));
463
+ this.drafts = branchDrafts(this, this.draftHost());
464
+ }
465
+ /** Drafts on branches of the project, with merge requests for review. */
466
+ draftHost() {
467
+ const project = `/projects/${this.project}`;
468
+ return {
469
+ liveBranch: this.branch,
470
+ branchStore: (branch) => new GitLabBackend({
471
+ ...this.options,
472
+ branch
473
+ }),
474
+ createBranch: async (branch, revision) => {
475
+ if ((await gitlabRequest(this.api, this.repo(`/branches?${new URLSearchParams({
476
+ branch,
477
+ ref: revision
478
+ })}`), {
479
+ method: "POST",
480
+ allow: [400]
481
+ })).status === 400) throw new DraftError("exists", `There's already a draft called ${branch}.`);
482
+ },
483
+ deleteBranch: async (branch) => {
484
+ await gitlabRequest(this.api, this.repo(`/branches/${encodeURIComponent(branch)}`), {
485
+ method: "DELETE",
486
+ allow: [404]
487
+ });
488
+ },
489
+ mergeBase: async (branch) => {
490
+ const query = new URLSearchParams();
491
+ query.append("refs[]", this.branch);
492
+ query.append("refs[]", branch);
493
+ const response = await gitlabRequest(this.api, this.repo(`/merge_base?${query}`), { allow: [400, 404] });
494
+ if (!response.ok) throw new DraftError("not-found", `There's no draft ${branch}.`);
495
+ return (await response.json()).id;
496
+ },
497
+ openReview: async ({ branch, title, description }) => {
498
+ const response = await gitlabRequest(this.api, `${project}/merge_requests`, {
499
+ method: "POST",
500
+ body: {
501
+ source_branch: branch,
502
+ target_branch: this.branch,
503
+ title,
504
+ description,
505
+ remove_source_branch: true
506
+ },
507
+ allow: [
508
+ 403,
509
+ 404,
510
+ 409
511
+ ]
512
+ });
513
+ if (response.ok) return { url: (await response.json()).web_url };
514
+ if (response.status !== 409) throw new DraftError("no-reviews", "This sign-in can't open merge requests on the site's project.");
515
+ const query = new URLSearchParams({
516
+ source_branch: branch,
517
+ state: "opened"
518
+ });
519
+ const [open] = await gitlabJson(this.api, `${project}/merge_requests?${query}`);
520
+ if (!open) throw new GitApiError(409, "GitLab couldn't open the merge request.");
521
+ return { url: open.web_url };
522
+ }
523
+ };
50
524
  }
51
525
  repo(path) {
52
526
  return `/projects/${this.project}/repository${path}`;
@@ -98,6 +572,39 @@ var GitLabBackend = class {
98
572
  }
99
573
  return blob;
100
574
  }
575
+ /** Reads many text files in a few GraphQL requests rather than one request each. */
576
+ async readFiles(paths) {
577
+ const tree = await this.currentTree();
578
+ const wanted = paths.filter((path) => tree.has(path));
579
+ const fetch = wanted.filter((path) => !this.blobs.has(tree.get(path) ?? ""));
580
+ const ref = this.pinned ?? await this.revision();
581
+ for (let start = 0; start < fetch.length; start += GRAPHQL_BATCH) {
582
+ const batch = fetch.slice(start, start + GRAPHQL_BATCH);
583
+ const response = await gitlabJson(this.api, "/../graphql", {
584
+ method: "POST",
585
+ body: {
586
+ query: `query ($project: ID!, $ref: String!, $paths: [String!]!) {
587
+ project(fullPath: $project) { repository { blobs(ref: $ref, paths: $paths) { nodes { path rawTextBlob } } } }
588
+ }`,
589
+ variables: {
590
+ project: decodeURIComponent(this.project),
591
+ ref,
592
+ paths: batch
593
+ }
594
+ }
595
+ });
596
+ for (const node of response.data?.project?.repository?.blobs?.nodes ?? []) {
597
+ const sha = tree.get(node.path);
598
+ if (sha && typeof node.rawTextBlob === "string") this.blobs.set(sha, Promise.resolve(node.rawTextBlob));
599
+ }
600
+ }
601
+ const result = /* @__PURE__ */ new Map();
602
+ for (const path of wanted) {
603
+ const text = await this.read(path);
604
+ if (text !== void 0) result.set(path, text);
605
+ }
606
+ return result;
607
+ }
101
608
  async readBytes(path) {
102
609
  const sha = (await this.currentTree()).get(path);
103
610
  if (!sha) return void 0;
@@ -108,6 +615,32 @@ var GitLabBackend = class {
108
615
  const prefix = `${dir}/`;
109
616
  return [...(await this.currentTree()).keys()].filter((path) => path.startsWith(prefix)).sort();
110
617
  }
618
+ /** GitLab's tree doesn't say how large files are, so they're asked for with GraphQL, in batches. */
619
+ async fileSizes(dir) {
620
+ const paths = await this.list(dir);
621
+ const ref = this.pinned ?? await this.revision();
622
+ const sizes = /* @__PURE__ */ new Map();
623
+ for (let start = 0; start < paths.length; start += GRAPHQL_BATCH) {
624
+ const response = await gitlabJson(this.api, "/../graphql", {
625
+ method: "POST",
626
+ body: {
627
+ query: `query ($project: ID!, $ref: String!, $paths: [String!]!) {
628
+ project(fullPath: $project) { repository { blobs(ref: $ref, paths: $paths) { nodes { path size } } } }
629
+ }`,
630
+ variables: {
631
+ project: decodeURIComponent(this.project),
632
+ ref,
633
+ paths: paths.slice(start, start + GRAPHQL_BATCH)
634
+ }
635
+ }
636
+ });
637
+ for (const node of response.data?.project?.repository?.blobs?.nodes ?? []) {
638
+ const size = Number(node.size);
639
+ if (Number.isFinite(size)) sizes.set(node.path, size);
640
+ }
641
+ }
642
+ return sizes;
643
+ }
111
644
  async lastCommitFor(path, ref) {
112
645
  return (await gitlabJson(this.api, this.repo(`/files/${encodeURIComponent(path)}?ref=${ref}`))).last_commit_id;
113
646
  }
@@ -155,6 +688,24 @@ var GitLabBackend = class {
155
688
  throw error;
156
689
  }
157
690
  }
691
+ async history(path, { page = 1, perPage = 20 } = {}) {
692
+ const query = new URLSearchParams({
693
+ ref_name: this.pinned ?? await this.revision(),
694
+ path,
695
+ per_page: String(perPage),
696
+ page: String(page)
697
+ });
698
+ return (await gitlabJson(this.api, this.repo(`/commits?${query}`))).map((commit) => ({
699
+ revision: commit.id,
700
+ date: commit.authored_date ?? commit.committed_date ?? "",
701
+ author: commit.author_name || void 0,
702
+ message: commit.title
703
+ }));
704
+ }
705
+ async readAt(path, revision) {
706
+ const response = await gitlabRequest(this.api, this.repo(`/files/${encodeURIComponent(path)}/raw?${new URLSearchParams({ ref: revision })}`), { allow: [404] });
707
+ return response.ok ? response.text() : void 0;
708
+ }
158
709
  async changedPaths(from, to) {
159
710
  const comparison = await gitlabJson(this.api, this.repo(`/compare?${new URLSearchParams({
160
711
  from,
@@ -172,10 +723,14 @@ var GitLabBackend = class {
172
723
  const statuses = await gitlabJson(this.api, this.repo(`/commits/${revision}/statuses`));
173
724
  if (statuses.length === 0) return { state: (await gitlabJson(this.api, `/projects/${this.project}/pipelines?per_page=1`)).length > 0 ? "building" : "unknown" };
174
725
  const failed = statuses.find((status) => status.status === "failed" || status.status === "canceled");
175
- if (failed) return {
176
- state: "failed",
177
- detailsUrl: failed.target_url ?? void 0
178
- };
726
+ if (failed) {
727
+ const problem = await this.jobProblem(revision).catch(() => void 0);
728
+ return {
729
+ state: "failed",
730
+ detailsUrl: failed.target_url ?? void 0,
731
+ ...problem && { problem }
732
+ };
733
+ }
179
734
  if (statuses.every((status) => status.status === "success" || status.status === "skipped")) return {
180
735
  state: "live",
181
736
  detailsUrl: statuses[0]?.target_url ?? void 0
@@ -189,6 +744,39 @@ var GitLabBackend = class {
189
744
  throw error;
190
745
  }
191
746
  }
747
+ /** Why a commit's pipeline failed: the failed job's reason, and its log for which command failed and why. */
748
+ async jobProblem(revision) {
749
+ const [pipeline] = await gitlabJson(this.api, `/projects/${this.project}/pipelines?${new URLSearchParams({
750
+ sha: revision,
751
+ per_page: "1"
752
+ })}`);
753
+ if (!pipeline) return void 0;
754
+ const failed = (await gitlabJson(this.api, `/projects/${this.project}/pipelines/${pipeline.id}/jobs?per_page=100`)).find((job) => job.status === "failed");
755
+ if (!failed) return void 0;
756
+ const reason = failed.failure_reason ?? "";
757
+ const detail = `${failed.name}: ${reason}`;
758
+ if (reason === "ci_quota_exceeded") return {
759
+ step: "not-started",
760
+ detail
761
+ };
762
+ if (/runner_system|stuck_or_timeout|scheduler|api_failure|data_integrity|job_execution_timeout/.test(reason)) return {
763
+ step: "host",
764
+ detail
765
+ };
766
+ if (failed.name === "pages:deploy") return {
767
+ step: "deploy",
768
+ detail
769
+ };
770
+ const response = await gitlabRequest(this.api, `/projects/${this.project}/jobs/${failed.id}/trace`, { allow: [403, 404] });
771
+ const trace = response.ok ? await response.text() : "";
772
+ const cause = findBuildCause(trace);
773
+ const command = trace.split("\n").map((line) => line.replace(ANSI_CODES, "").replace(/^.*\r/, "")).filter((line) => line.startsWith("$ ")).at(-1);
774
+ return {
775
+ step: cause ? "build" : (command ? buildStepFor(command) : void 0) ?? "build",
776
+ detail,
777
+ ...cause && { cause }
778
+ };
779
+ }
192
780
  signOut() {
193
781
  this.onSignOut();
194
782
  }
@@ -223,6 +811,188 @@ function redirectUri(href) {
223
811
  return `${url.origin}${url.pathname.replace(/\/?$/, "/")}`;
224
812
  }
225
813
  //#endregion
814
+ //#region src/setup.ts
815
+ /** The branch the deploy setups build from. */
816
+ const MAIN = "main";
817
+ function browserLocation$1() {
818
+ return {
819
+ href: () => window.location.href,
820
+ assign: (url) => window.location.assign(url),
821
+ replace: (url) => window.history.replaceState(window.history.state, "", url)
822
+ };
823
+ }
824
+ /** Signing in to GitLab to create a site's project, with GitLab Pages if wanted. */
825
+ function gitlabSetup(options = {}) {
826
+ const webUrl = (options.url ?? "https://gitlab.com").replace(/\/$/, "");
827
+ const apiUrl = `${webUrl}/api/v4`;
828
+ const fetcher = options.fetch ?? ((input, init) => globalThis.fetch(input, init));
829
+ const pending = credentialStorage("goodfellow:gitlab-setup:pending", options.storage);
830
+ const location = () => options.location ?? browserLocation$1();
831
+ async function requestTokens(params) {
832
+ const response = await fetcher(`${webUrl}/oauth/token`, {
833
+ method: "POST",
834
+ headers: { "content-type": "application/x-www-form-urlencoded" },
835
+ body: new URLSearchParams(params)
836
+ });
837
+ if (!response.ok) throw new SignInError("invalid", `GitLab didn't accept the sign-in (${response.status}).`);
838
+ return await response.json();
839
+ }
840
+ async function connect(initial) {
841
+ let credentials = initial;
842
+ const token = async ({ refresh } = {}) => {
843
+ if (credentials.type === "token") return credentials.token;
844
+ if (refresh || credentials.expiresAt - 6e4 < Date.now()) {
845
+ const { verifier } = credentials;
846
+ credentials = toTokens(await requestTokens({
847
+ client_id: options.clientId ?? "",
848
+ refresh_token: credentials.refresh,
849
+ grant_type: "refresh_token",
850
+ redirect_uri: redirectUri(location().href()),
851
+ code_verifier: verifier
852
+ }), verifier);
853
+ }
854
+ return credentials.access;
855
+ };
856
+ const api = {
857
+ apiUrl,
858
+ token,
859
+ canRefresh: credentials.type === "oauth",
860
+ fetch: fetcher
861
+ };
862
+ const user = await gitlabJson(api, "/user");
863
+ async function isAvailable(owner, name) {
864
+ return (await gitlabRequest(api, `/projects/${encodeURIComponent(`${owner.path}/${name}`)}`, { allow: [404] })).status === 404;
865
+ }
866
+ return {
867
+ user: {
868
+ login: user.username,
869
+ name: user.name,
870
+ avatarUrl: user.avatar_url
871
+ },
872
+ async owners() {
873
+ const owners = (await gitlabJson(api, "/namespaces?per_page=100")).map((namespace) => ({
874
+ path: namespace.full_path,
875
+ name: namespace.kind === "user" ? user.name ?? namespace.name : namespace.name,
876
+ kind: namespace.kind === "user" ? "personal" : "organization",
877
+ id: namespace.id
878
+ }));
879
+ return [...owners.filter((owner) => owner.kind === "personal"), ...owners.filter((o) => o.kind !== "personal")];
880
+ },
881
+ isAvailable,
882
+ async createSite(site, onStep) {
883
+ onStep?.("repository");
884
+ if (!await isAvailable(site.owner, site.name)) throw new SetupError("name-taken", `There's already a project called ${site.owner.path}/${site.name}.`);
885
+ let project;
886
+ try {
887
+ project = await gitlabJson(api, "/projects", {
888
+ method: "POST",
889
+ body: {
890
+ name: site.name,
891
+ path: site.name,
892
+ namespace_id: site.owner.id,
893
+ visibility: site.private ? "private" : "public",
894
+ description: site.description,
895
+ ...site.pages && { pages_access_level: site.private ? "public" : "enabled" }
896
+ }
897
+ });
898
+ } catch (error) {
899
+ if (error instanceof GitApiError && error.status === 400) throw new SetupError("name-invalid", `GitLab can't use "${site.name}" as a project name.`);
900
+ if (error instanceof GitApiError && (error.status === 403 || error.status === 404)) throw new SetupError("not-allowed", `This sign-in can't create projects in ${site.owner.path}.`);
901
+ throw error;
902
+ }
903
+ onStep?.("files");
904
+ await gitlabRequest(api, `/projects/${project.id}/repository/commits`, {
905
+ method: "POST",
906
+ body: {
907
+ branch: MAIN,
908
+ commit_message: site.message,
909
+ actions: site.files.flatMap((file) => "delete" in file ? [] : "content" in file ? [{
910
+ action: "create",
911
+ file_path: file.path,
912
+ content: file.content
913
+ }] : [{
914
+ action: "create",
915
+ file_path: file.path,
916
+ content: encodeBase64Bytes(file.bytes),
917
+ encoding: "base64"
918
+ }])
919
+ }
920
+ });
921
+ return {
922
+ repo: project.path_with_namespace,
923
+ webUrl: project.web_url,
924
+ buildsUrl: `${project.web_url}/-/pipelines`,
925
+ pagesUrl: site.pages ? `${project.web_url}/pages` : void 0,
926
+ warnings: []
927
+ };
928
+ }
929
+ };
930
+ }
931
+ return {
932
+ name: "GitLab",
933
+ canRedirect: Boolean(options.clientId),
934
+ tokenLinks: [{
935
+ url: `${webUrl}/-/user_settings/personal_access_tokens?${new URLSearchParams({
936
+ name: "Goodfellow site setup",
937
+ scopes: "api"
938
+ })}`,
939
+ label: "signIn.token.create"
940
+ }],
941
+ async restore() {
942
+ const url = new URL(location().href());
943
+ const code = url.searchParams.get("code");
944
+ const error = url.searchParams.get("error");
945
+ if (!code && !error) return null;
946
+ const saved = pending.load();
947
+ if (!saved) return null;
948
+ pending.clear();
949
+ const redirect = JSON.parse(saved);
950
+ const returnedState = url.searchParams.get("state");
951
+ for (const key of [
952
+ "code",
953
+ "state",
954
+ "error",
955
+ "error_description"
956
+ ]) url.searchParams.delete(key);
957
+ location().replace(url.toString());
958
+ if (error || !code || returnedState !== redirect.state) throw new SignInError("redirect-failed", "Signing in with GitLab was cancelled or didn't finish.");
959
+ return connect(toTokens(await requestTokens({
960
+ client_id: options.clientId ?? "",
961
+ code,
962
+ grant_type: "authorization_code",
963
+ redirect_uri: redirectUri(url.toString()),
964
+ code_verifier: redirect.verifier
965
+ }), redirect.verifier));
966
+ },
967
+ async signInWithToken(token) {
968
+ const trimmed = token.trim();
969
+ if (!trimmed) throw new SignInError("invalid", "Paste a token to sign in.");
970
+ return connect({
971
+ type: "token",
972
+ token: trimmed
973
+ });
974
+ },
975
+ async startRedirect() {
976
+ if (!options.clientId) throw new Error("gitlabSetup({ clientId }) is needed to sign in with GitLab.");
977
+ const redirect = {
978
+ state: randomString(16),
979
+ verifier: randomString(48)
980
+ };
981
+ pending.save(JSON.stringify(redirect), false);
982
+ const query = new URLSearchParams({
983
+ client_id: options.clientId,
984
+ redirect_uri: redirectUri(location().href()),
985
+ response_type: "code",
986
+ state: redirect.state,
987
+ scope: "api",
988
+ code_challenge: await codeChallenge(redirect.verifier),
989
+ code_challenge_method: "S256"
990
+ });
991
+ location().assign(`${webUrl}/oauth/authorize?${query}`);
992
+ }
993
+ };
994
+ }
995
+ //#endregion
226
996
  //#region src/index.ts
227
997
  const MIN_ACCESS_LEVEL_TO_PUBLISH = 30;
228
998
  function browserLocation() {
@@ -325,6 +1095,7 @@ function gitlab(options) {
325
1095
  return {
326
1096
  name: "GitLab",
327
1097
  canRedirect: Boolean(options.clientId),
1098
+ redirectAddressesListed: Boolean(options.clientId),
328
1099
  tokenLinks: [{
329
1100
  url: `${webUrl}/-/user_settings/personal_access_tokens?${new URLSearchParams({
330
1101
  name: "Goodfellow admin",
@@ -378,4 +1149,4 @@ function gitlab(options) {
378
1149
  };
379
1150
  }
380
1151
  //#endregion
381
- export { GitLabBackend, gitlab, redirectUri };
1152
+ export { GitLabBackend, gitlab, gitlabPagesRecords, gitlabSetup, redirectUri };