@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/README.md +2 -2
- package/dist/index.d.ts +49 -2
- package/dist/index.js +778 -7
- package/dist/testing.d.ts +100 -1
- package/dist/testing.js +528 -10
- package/package.json +5 -5
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)
|
|
176
|
-
|
|
177
|
-
|
|
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 };
|