@goodfellow-cms/gitlab 0.3.0 → 0.5.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 +42 -3
- package/dist/index.js +447 -33
- package/dist/testing.d.ts +44 -6
- package/dist/testing.js +170 -22
- package/package.json +3 -3
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ConnectDomainOptions, DeployStatus, DnsRecord, FileChange, FileVersion, GitBackend, GitHost, GitUser, HistoryOptions, PagesDomains, SetupHost, SiteDrafts, SiteEditors, SiteUpdates, StorageLike, WriteOptions } from "@goodfellow-cms/core";
|
|
1
|
+
import { ConnectDomainOptions, DeployStatus, DnsRecord, FileChange, FileVersion, GitBackend, GitHost, GitUser, HistoryOptions, PagesDomains, RepositoryVisibility, RevisionFiles, ScheduledBuilds, SetupHost, SiteDrafts, SiteEditors, SiteUpdates, StorageLike, WriteOptions } from "@goodfellow-cms/core";
|
|
2
2
|
//#region src/api.d.ts
|
|
3
3
|
interface ApiOptions {
|
|
4
4
|
/** The API root, such as `https://gitlab.com/api/v4`. */
|
|
@@ -19,6 +19,20 @@ interface GitLabBackendOptions {
|
|
|
19
19
|
branch: string;
|
|
20
20
|
user: GitUser;
|
|
21
21
|
onSignOut: () => void;
|
|
22
|
+
/** Who commits name as their author, where that isn't the token's owner, as on a full-stack site's server. */
|
|
23
|
+
author?: {
|
|
24
|
+
name: string;
|
|
25
|
+
email: string;
|
|
26
|
+
};
|
|
27
|
+
/** The commit reads see until `revision()` is next called, as a full-stack site's server reads what a browser loaded. */
|
|
28
|
+
revision?: string;
|
|
29
|
+
/** The project holds only the site's content, with its code in a project of its own (`GitBackend.contentOnly`). */
|
|
30
|
+
contentOnly?: boolean;
|
|
31
|
+
/** The code project, for a project that holds only content, where the site's updates run. */
|
|
32
|
+
code?: {
|
|
33
|
+
project: string;
|
|
34
|
+
webUrl: string;
|
|
35
|
+
};
|
|
22
36
|
}
|
|
23
37
|
/**
|
|
24
38
|
* The site's files on GitLab. Reads come from the commit `revision()` last
|
|
@@ -29,8 +43,11 @@ export declare class GitLabBackend implements GitBackend {
|
|
|
29
43
|
readonly user: GitUser;
|
|
30
44
|
readonly pages: PagesDomains;
|
|
31
45
|
readonly editors: SiteEditors;
|
|
32
|
-
|
|
46
|
+
/** Absent where the project holds only content: the update job runs where the code is. */
|
|
47
|
+
readonly updates?: SiteUpdates;
|
|
33
48
|
readonly drafts: SiteDrafts;
|
|
49
|
+
readonly scheduledBuilds: ScheduledBuilds;
|
|
50
|
+
readonly contentOnly: boolean;
|
|
34
51
|
private readonly options;
|
|
35
52
|
private readonly api;
|
|
36
53
|
private readonly project;
|
|
@@ -40,23 +57,32 @@ export declare class GitLabBackend implements GitBackend {
|
|
|
40
57
|
private readonly trees;
|
|
41
58
|
private readonly blobs;
|
|
42
59
|
constructor(options: GitLabBackendOptions);
|
|
60
|
+
repositoryVisibility(): Promise<RepositoryVisibility>;
|
|
43
61
|
/** Drafts on branches of the project, with merge requests for review. */
|
|
44
62
|
private draftHost;
|
|
45
63
|
private repo;
|
|
46
64
|
revision(): Promise<string>;
|
|
65
|
+
/** The branch's latest commit, without moving the revision reads are pinned to. */
|
|
66
|
+
private head;
|
|
47
67
|
/** Every file in one folder at a commit, page by page. */
|
|
48
68
|
private listFolder;
|
|
49
69
|
/** Path → blob id for the editable files at a commit. Commits never change, so this is cached. */
|
|
50
70
|
private tree;
|
|
51
71
|
private currentTree;
|
|
52
72
|
read(path: string): Promise<string | undefined>;
|
|
73
|
+
private readFrom;
|
|
53
74
|
/** Reads many text files in a few GraphQL requests rather than one request each. */
|
|
54
75
|
readFiles(paths: string[]): Promise<Map<string, string>>;
|
|
76
|
+
private readFilesFrom;
|
|
55
77
|
readBytes(path: string): Promise<Uint8Array | undefined>;
|
|
78
|
+
private bytesFrom;
|
|
56
79
|
list(dir: string): Promise<string[]>;
|
|
80
|
+
filesAt(revision: string): RevisionFiles;
|
|
57
81
|
/** GitLab's tree doesn't say how large files are, so they're asked for with GraphQL, in batches. */
|
|
58
82
|
fileSizes(dir: string): Promise<Map<string, number>>;
|
|
59
83
|
private lastCommitFor;
|
|
84
|
+
/** Whether a file exists at a commit: from the cached tree in the editable folders, and from GitLab elsewhere, such as the config. */
|
|
85
|
+
private existsAt;
|
|
60
86
|
write(changes: FileChange[], { message, expectedRevision }: WriteOptions): Promise<{
|
|
61
87
|
revision: string;
|
|
62
88
|
}>;
|
|
@@ -65,9 +91,11 @@ export declare class GitLabBackend implements GitBackend {
|
|
|
65
91
|
changedPaths(from: string, to: string): Promise<string[]>;
|
|
66
92
|
/**
|
|
67
93
|
* Reads the commit's pipeline and external statuses (GitLab Pages jobs, or
|
|
68
|
-
* Vercel). Before any exist, the site counts as building
|
|
94
|
+
* Vercel or Cloudflare Pages). Before any exist, the site counts as building
|
|
95
|
+
* if it has pipelines, or if the host reported on the commit before.
|
|
69
96
|
*/
|
|
70
97
|
deployStatus(revision: string): Promise<DeployStatus>;
|
|
98
|
+
private statuses;
|
|
71
99
|
/** Why a commit's pipeline failed: the failed job's reason, and its log for which command failed and why. */
|
|
72
100
|
private jobProblem;
|
|
73
101
|
signOut(): void;
|
|
@@ -130,6 +158,17 @@ export interface GitLabOptions {
|
|
|
130
158
|
* one-click "Sign in with GitLab". Without it, people sign in with a token.
|
|
131
159
|
*/
|
|
132
160
|
clientId?: string;
|
|
161
|
+
/**
|
|
162
|
+
* The project holds only the site's content (`content/` and
|
|
163
|
+
* `public/media/`), and its code is in a project of its own. The admin
|
|
164
|
+
* panel then leaves adding blocks, which changes code, to a developer.
|
|
165
|
+
*/
|
|
166
|
+
contentOnly?: boolean;
|
|
167
|
+
/**
|
|
168
|
+
* With `contentOnly`, the project the site's code is kept in, where its
|
|
169
|
+
* updates run: the Updates screen follows them, for people who can see it.
|
|
170
|
+
*/
|
|
171
|
+
code?: string;
|
|
133
172
|
/** For tests. */
|
|
134
173
|
fetch?: typeof fetch;
|
|
135
174
|
/** For tests. */
|
package/dist/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ConflictError, DomainError, DraftError, EDITABLE_FOLDERS, EditorsError, GitApiError, SetupError, SignInError, UpdatesError, branchDrafts, buildStepFor, credentialStorage, encodeBase64Bytes, findBuildCause, findUpdateResult } from "@goodfellow-cms/core";
|
|
1
|
+
import { ConflictError, DomainError, DraftError, EDITABLE_FOLDERS, EditorsError, GitApiError, NIGHTLY_VARIABLE, REBUILD_JOB, ScheduledBuildsError, SetupError, SignInError, UpdatesError, assertContentChanges, branchDrafts, buildStepFor, credentialStorage, encodeBase64Bytes, findBuildCause, findUpdateResult, hostSource } 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 = {}) {
|
|
@@ -40,7 +40,7 @@ function roleOf(level) {
|
|
|
40
40
|
function levelOf(role) {
|
|
41
41
|
return role === "owner" ? MAINTAINER : DEVELOPER;
|
|
42
42
|
}
|
|
43
|
-
function notAllowed$
|
|
43
|
+
function notAllowed$2(error) {
|
|
44
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
45
|
throw error;
|
|
46
46
|
}
|
|
@@ -102,7 +102,7 @@ function gitlabEditors(api, project, branch, username) {
|
|
|
102
102
|
email: name,
|
|
103
103
|
access_level: levelOf(role)
|
|
104
104
|
}
|
|
105
|
-
}).catch(notAllowed$
|
|
105
|
+
}).catch(notAllowed$2);
|
|
106
106
|
if (result.status !== "success") {
|
|
107
107
|
const message = typeof result.message === "string" ? result.message : Object.values(result.message ?? {})[0];
|
|
108
108
|
if (/already/i.test(message ?? "")) throw new EditorsError("already", "They've already been invited.");
|
|
@@ -123,17 +123,17 @@ function gitlabEditors(api, project, branch, username) {
|
|
|
123
123
|
});
|
|
124
124
|
} catch (error) {
|
|
125
125
|
if (error instanceof GitApiError && error.status === 409) throw new EditorsError("already", `${name} already edits the site.`);
|
|
126
|
-
notAllowed$
|
|
126
|
+
notAllowed$2(error);
|
|
127
127
|
}
|
|
128
128
|
},
|
|
129
129
|
async setRole(editor, role) {
|
|
130
130
|
await gitlabRequest(api, editor.id.startsWith(INVITATION) ? `${base}/invitations/${encodeURIComponent(editor.id.slice(11))}` : `${base}/members/${editor.id.slice(7)}`, {
|
|
131
131
|
method: "PUT",
|
|
132
132
|
body: { access_level: levelOf(role) }
|
|
133
|
-
}).catch(notAllowed$
|
|
133
|
+
}).catch(notAllowed$2);
|
|
134
134
|
},
|
|
135
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$
|
|
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$2);
|
|
137
137
|
},
|
|
138
138
|
async letEditorsPublish() {
|
|
139
139
|
const protect = (level) => gitlabRequest(api, `${base}/protected_branches`, {
|
|
@@ -141,19 +141,19 @@ function gitlabEditors(api, project, branch, username) {
|
|
|
141
141
|
body: {
|
|
142
142
|
name: branch,
|
|
143
143
|
push_access_level: level,
|
|
144
|
-
merge_access_level:
|
|
144
|
+
merge_access_level: level,
|
|
145
145
|
allow_force_push: false
|
|
146
146
|
}
|
|
147
147
|
});
|
|
148
148
|
await gitlabRequest(api, protectedPath, {
|
|
149
149
|
method: "DELETE",
|
|
150
150
|
allow: [404]
|
|
151
|
-
}).catch(notAllowed$
|
|
151
|
+
}).catch(notAllowed$2);
|
|
152
152
|
try {
|
|
153
153
|
await protect(DEVELOPER);
|
|
154
154
|
} catch (error) {
|
|
155
155
|
await protect(MAINTAINER).catch(() => void 0);
|
|
156
|
-
notAllowed$
|
|
156
|
+
notAllowed$2(error);
|
|
157
157
|
}
|
|
158
158
|
}
|
|
159
159
|
};
|
|
@@ -250,6 +250,7 @@ function gitlabPages(api, project) {
|
|
|
250
250
|
return response.ok ? await response.json() : found;
|
|
251
251
|
}
|
|
252
252
|
return {
|
|
253
|
+
severalDomains: true,
|
|
253
254
|
async site() {
|
|
254
255
|
const found = await pages();
|
|
255
256
|
if (!found) return void 0;
|
|
@@ -301,9 +302,16 @@ function gitlabPages(api, project) {
|
|
|
301
302
|
primary: settings?.primary_domain === void 0 || settings.primary_domain === domain
|
|
302
303
|
};
|
|
303
304
|
},
|
|
304
|
-
async secure(domain) {
|
|
305
|
+
async secure(domain, { primary = true } = {}) {
|
|
305
306
|
const found = await gitlabJson(api, domainPath(domain)).catch(notAllowed);
|
|
306
307
|
if (!found.verified || !found.certificate) throw new DomainError("not-ready", "The domain's certificate isn't ready yet.");
|
|
308
|
+
if (!primary) {
|
|
309
|
+
await gitlabRequest(api, pagesPath, {
|
|
310
|
+
method: "PATCH",
|
|
311
|
+
body: { pages_https_only: true }
|
|
312
|
+
}).catch(notAllowed);
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
307
315
|
try {
|
|
308
316
|
await gitlabRequest(api, pagesPath, {
|
|
309
317
|
method: "PATCH",
|
|
@@ -329,6 +337,80 @@ function gitlabPages(api, project) {
|
|
|
329
337
|
};
|
|
330
338
|
}
|
|
331
339
|
//#endregion
|
|
340
|
+
//#region src/scheduled-builds.ts
|
|
341
|
+
/** The start of the description of the pipeline schedules the admin panel adds, which `sync()` keeps. */
|
|
342
|
+
const SCHEDULED_BUILD_DESCRIPTION = "Goodfellow: build the site for a scheduled change at ";
|
|
343
|
+
/** The variable those schedules' pipelines have, which the starters' pipelines read to skip the nightly update. */
|
|
344
|
+
const SCHEDULED_CHANGE_VARIABLE = "GOODFELLOW_SCHEDULED_CHANGE";
|
|
345
|
+
function notAllowed$1(error) {
|
|
346
|
+
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
|
+
throw error;
|
|
348
|
+
}
|
|
349
|
+
/** A cron line for one instant, in UTC: it comes once a year, and `sync()` removes it once it has passed. */
|
|
350
|
+
function cronAt(at) {
|
|
351
|
+
return `${at.getUTCMinutes()} ${at.getUTCHours()} ${at.getUTCDate()} ${at.getUTCMonth() + 1} *`;
|
|
352
|
+
}
|
|
353
|
+
/** An instant to the minute, as the schedules' descriptions and variables name it: `2026-11-29T14:00Z`. */
|
|
354
|
+
function minute(at) {
|
|
355
|
+
return `${at.toISOString().slice(0, 16)}Z`;
|
|
356
|
+
}
|
|
357
|
+
/**
|
|
358
|
+
* Scheduled changes on GitLab go live at their time: the admin panel adds a
|
|
359
|
+
* pipeline schedule for each, which builds the site then. The pipeline is the
|
|
360
|
+
* project's own, so any project with a `.gitlab-ci.yml` that builds the site
|
|
361
|
+
* on its main branch does.
|
|
362
|
+
*/
|
|
363
|
+
function gitlabScheduledBuilds(api, project, branch, readCi, now = () => /* @__PURE__ */ new Date()) {
|
|
364
|
+
const base = `/projects/${encodeURIComponent(project)}/pipeline_schedules`;
|
|
365
|
+
return {
|
|
366
|
+
async timing() {
|
|
367
|
+
return await readCi() === void 0 ? "none" : "exact";
|
|
368
|
+
},
|
|
369
|
+
async sync(times) {
|
|
370
|
+
const after = now();
|
|
371
|
+
const wanted = /* @__PURE__ */ new Map();
|
|
372
|
+
for (const at of [...times].filter((time) => time > after).sort((a, b) => a.getTime() - b.getTime())) {
|
|
373
|
+
if (wanted.size >= 50) break;
|
|
374
|
+
wanted.set(minute(at), at);
|
|
375
|
+
}
|
|
376
|
+
const found = await gitlabJson(api, `${base}?per_page=100`).catch(notAllowed$1);
|
|
377
|
+
const ours = /* @__PURE__ */ new Map();
|
|
378
|
+
for (const schedule of found) {
|
|
379
|
+
if (!schedule.description?.startsWith("Goodfellow: build the site for a scheduled change at ")) continue;
|
|
380
|
+
ours.set(schedule.description.slice(53), schedule);
|
|
381
|
+
}
|
|
382
|
+
for (const [time, schedule] of ours) {
|
|
383
|
+
if (wanted.has(time)) continue;
|
|
384
|
+
await gitlabRequest(api, `${base}/${schedule.id}`, {
|
|
385
|
+
method: "DELETE",
|
|
386
|
+
allow: [403, 404]
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
for (const [time, at] of wanted) {
|
|
390
|
+
if (ours.has(time)) continue;
|
|
391
|
+
const created = await gitlabJson(api, base, {
|
|
392
|
+
method: "POST",
|
|
393
|
+
body: {
|
|
394
|
+
description: `${SCHEDULED_BUILD_DESCRIPTION}${time}`,
|
|
395
|
+
ref: branch,
|
|
396
|
+
cron: cronAt(at),
|
|
397
|
+
cron_timezone: "UTC",
|
|
398
|
+
active: true
|
|
399
|
+
}
|
|
400
|
+
}).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);
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
//#endregion
|
|
332
414
|
//#region src/updates.ts
|
|
333
415
|
const CI_FILE = ".gitlab-ci.yml";
|
|
334
416
|
/** When the nightly schedule the Updates screen adds runs, in UTC, as the GitHub Pages workflow's does. */
|
|
@@ -343,17 +425,21 @@ function notAllowed(error) {
|
|
|
343
425
|
* It publishes with the job's own token, which GitLab only allows once the
|
|
344
426
|
* project lets job tokens push.
|
|
345
427
|
*/
|
|
346
|
-
function gitlabUpdates(api, project, branch, readAt) {
|
|
428
|
+
function gitlabUpdates(api, project, branch, readAt, jobName = "pages") {
|
|
347
429
|
const base = `/projects/${encodeURIComponent(project)}`;
|
|
348
430
|
async function schedules() {
|
|
349
431
|
return gitlabJson(api, `${base}/pipeline_schedules`);
|
|
350
432
|
}
|
|
433
|
+
/** The nightly schedule: the branch's, other than those the admin panel adds for scheduled changes. */
|
|
434
|
+
async function nightlySchedule() {
|
|
435
|
+
return (await schedules()).find((schedule) => (schedule.ref === branch || schedule.ref === `refs/heads/${branch}`) && !schedule.description?.startsWith("Goodfellow: build the site for a scheduled change at "));
|
|
436
|
+
}
|
|
351
437
|
return {
|
|
352
438
|
async setup() {
|
|
353
439
|
if (!(await readAt(CI_FILE))?.includes("update --publish")) return "missing";
|
|
354
440
|
try {
|
|
355
441
|
const [settings, found] = await Promise.all([gitlabJson(api, base), schedules()]);
|
|
356
|
-
const scheduled = found.some((schedule) =>
|
|
442
|
+
const scheduled = found.some((schedule) => (schedule.ref === branch || schedule.ref === `refs/heads/${branch}`) && (schedule.active || !schedule.description?.startsWith("Goodfellow: build the site for a scheduled change at ")));
|
|
357
443
|
return settings.ci_push_repository_for_job_token_allowed && scheduled ? "ready" : "needs-setup";
|
|
358
444
|
} catch (error) {
|
|
359
445
|
if (error instanceof GitApiError && error.status === 403) return "needs-setup";
|
|
@@ -365,7 +451,7 @@ function gitlabUpdates(api, project, branch, readAt) {
|
|
|
365
451
|
method: "PUT",
|
|
366
452
|
body: { ci_push_repository_for_job_token_allowed: true }
|
|
367
453
|
}).catch(notAllowed);
|
|
368
|
-
if (!
|
|
454
|
+
if (!await nightlySchedule().catch(notAllowed)) await gitlabRequest(api, `${base}/pipeline_schedules`, {
|
|
369
455
|
method: "POST",
|
|
370
456
|
body: {
|
|
371
457
|
description: "Rebuild the site and install Goodfellow's fixes each night",
|
|
@@ -397,7 +483,7 @@ function gitlabUpdates(api, project, branch, readAt) {
|
|
|
397
483
|
state: "running",
|
|
398
484
|
...run
|
|
399
485
|
};
|
|
400
|
-
const job = (await gitlabJson(api, `${base}/pipelines/${pipeline.id}/jobs?per_page=100`)).find((candidate) => candidate.name === "
|
|
486
|
+
const job = (await gitlabJson(api, `${base}/pipelines/${pipeline.id}/jobs?per_page=100`)).find((candidate) => candidate.name === jobName || candidate.name === "update");
|
|
401
487
|
if (!job) continue;
|
|
402
488
|
const response = await gitlabRequest(api, `${base}/jobs/${job.id}/trace`, { allow: [403, 404] });
|
|
403
489
|
const result = response.ok ? findUpdateResult(await response.text()) : void 0;
|
|
@@ -421,6 +507,122 @@ function gitlabUpdates(api, project, branch, readAt) {
|
|
|
421
507
|
}]
|
|
422
508
|
}
|
|
423
509
|
}).catch(notAllowed);
|
|
510
|
+
},
|
|
511
|
+
nightly: {
|
|
512
|
+
async supported() {
|
|
513
|
+
return (await readAt(CI_FILE))?.includes("nightly") ?? false;
|
|
514
|
+
},
|
|
515
|
+
async get() {
|
|
516
|
+
try {
|
|
517
|
+
const found = await nightlySchedule();
|
|
518
|
+
if (!found) return void 0;
|
|
519
|
+
if (!found.active) return "off";
|
|
520
|
+
return (await gitlabJson(api, `${base}/pipeline_schedules/${found.id}`)).variables?.find((variable) => variable.key === NIGHTLY_VARIABLE)?.value === "always" ? "always" : "changed";
|
|
521
|
+
} catch (error) {
|
|
522
|
+
if (error instanceof GitApiError && error.status === 403) return void 0;
|
|
523
|
+
throw error;
|
|
524
|
+
}
|
|
525
|
+
},
|
|
526
|
+
async set(mode) {
|
|
527
|
+
const found = await nightlySchedule().catch(notAllowed);
|
|
528
|
+
if (!found) throw new UpdatesError("missing", "The site has no nightly schedule yet: turn on updates first.");
|
|
529
|
+
const path = `${base}/pipeline_schedules/${found.id}`;
|
|
530
|
+
await gitlabRequest(api, path, {
|
|
531
|
+
method: "PUT",
|
|
532
|
+
body: { active: mode !== "off" }
|
|
533
|
+
}).catch(notAllowed);
|
|
534
|
+
if (mode === "always") {
|
|
535
|
+
const body = {
|
|
536
|
+
key: NIGHTLY_VARIABLE,
|
|
537
|
+
value: "always"
|
|
538
|
+
};
|
|
539
|
+
if ((await gitlabRequest(api, `${path}/variables/${NIGHTLY_VARIABLE}`, {
|
|
540
|
+
method: "PUT",
|
|
541
|
+
body,
|
|
542
|
+
allow: [404]
|
|
543
|
+
}).catch(notAllowed)).status === 404) await gitlabRequest(api, `${path}/variables`, {
|
|
544
|
+
method: "POST",
|
|
545
|
+
body
|
|
546
|
+
}).catch(notAllowed);
|
|
547
|
+
} else if (mode === "changed") await gitlabRequest(api, `${path}/variables/${NIGHTLY_VARIABLE}`, {
|
|
548
|
+
method: "DELETE",
|
|
549
|
+
allow: [404]
|
|
550
|
+
}).catch(notAllowed);
|
|
551
|
+
}
|
|
552
|
+
},
|
|
553
|
+
async rebuild() {
|
|
554
|
+
await gitlabRequest(api, `${base}/pipeline`, {
|
|
555
|
+
method: "POST",
|
|
556
|
+
body: {
|
|
557
|
+
ref: branch,
|
|
558
|
+
variables: [{
|
|
559
|
+
key: "GOODFELLOW_REBUILD",
|
|
560
|
+
variable_type: "env_var",
|
|
561
|
+
value: "true"
|
|
562
|
+
}]
|
|
563
|
+
}
|
|
564
|
+
}).catch(notAllowed);
|
|
565
|
+
}
|
|
566
|
+
};
|
|
567
|
+
}
|
|
568
|
+
function noAccess(error) {
|
|
569
|
+
return error instanceof GitApiError && (error.status === 403 || error.status === 404);
|
|
570
|
+
}
|
|
571
|
+
/**
|
|
572
|
+
* The nightly update of a site whose content is kept in a project of its own,
|
|
573
|
+
* which runs in the code project's `update` job. The sign-in needs to see the
|
|
574
|
+
* code project too; where it can't, `setup()` says `no-access`.
|
|
575
|
+
*/
|
|
576
|
+
function gitlabCodeUpdates(options) {
|
|
577
|
+
const { api, project } = options;
|
|
578
|
+
let branch;
|
|
579
|
+
const liveBranch = () => {
|
|
580
|
+
branch ??= gitlabJson(api, `/projects/${encodeURIComponent(project)}`).then((found) => found.default_branch);
|
|
581
|
+
branch.catch(() => {
|
|
582
|
+
branch = void 0;
|
|
583
|
+
});
|
|
584
|
+
return branch;
|
|
585
|
+
};
|
|
586
|
+
/** The code project at its newest commit, read again each time, since updates change it. */
|
|
587
|
+
const files = async () => options.open(await liveBranch());
|
|
588
|
+
/** A file as it is now: its setup files are outside the folders backends list, so they're read at the newest commit. */
|
|
589
|
+
const readNow = async (path) => {
|
|
590
|
+
const backend = await files();
|
|
591
|
+
return backend.readAt(path, await backend.revision());
|
|
592
|
+
};
|
|
593
|
+
const updates = async () => gitlabUpdates(api, project, await liveBranch(), readNow, "update");
|
|
594
|
+
return {
|
|
595
|
+
code: {
|
|
596
|
+
repo: project,
|
|
597
|
+
webUrl: `${options.webUrl}/${project}`,
|
|
598
|
+
read: readNow,
|
|
599
|
+
history: async (path, history) => (await files()).history(path, history),
|
|
600
|
+
readAt: async (path, revision) => (await files()).readAt(path, revision)
|
|
601
|
+
},
|
|
602
|
+
async setup() {
|
|
603
|
+
try {
|
|
604
|
+
return await (await updates()).setup();
|
|
605
|
+
} catch (error) {
|
|
606
|
+
if (noAccess(error)) return "no-access";
|
|
607
|
+
throw error;
|
|
608
|
+
}
|
|
609
|
+
},
|
|
610
|
+
async enable() {
|
|
611
|
+
await (await updates()).enable?.();
|
|
612
|
+
},
|
|
613
|
+
async lastRun() {
|
|
614
|
+
try {
|
|
615
|
+
return await (await updates()).lastRun();
|
|
616
|
+
} catch (error) {
|
|
617
|
+
if (noAccess(error)) return void 0;
|
|
618
|
+
throw error;
|
|
619
|
+
}
|
|
620
|
+
},
|
|
621
|
+
async start(target) {
|
|
622
|
+
await (await updates()).start(target);
|
|
623
|
+
},
|
|
624
|
+
async rebuild() {
|
|
625
|
+
await (await updates()).rebuild?.();
|
|
424
626
|
}
|
|
425
627
|
};
|
|
426
628
|
}
|
|
@@ -432,6 +634,18 @@ const GRAPHQL_BATCH = 50;
|
|
|
432
634
|
/** The color and section codes in GitLab's job logs. */
|
|
433
635
|
const ANSI_CODES = new RegExp(`${String.fromCharCode(27)}\\[[\\d;]*[A-Za-z]`, "g");
|
|
434
636
|
/**
|
|
637
|
+
* Whether a status says when the site is live. A job that only asks another
|
|
638
|
+
* host to rebuild the site doesn't, and nor does the nightly update where
|
|
639
|
+
* Cloudflare builds the site, which runs on the newest commit too.
|
|
640
|
+
*/
|
|
641
|
+
function builds(status) {
|
|
642
|
+
return status.name !== REBUILD_JOB && status.name !== "update";
|
|
643
|
+
}
|
|
644
|
+
function listFrom(tree, dir) {
|
|
645
|
+
const prefix = `${dir}/`;
|
|
646
|
+
return [...tree.keys()].filter((path) => path.startsWith(prefix)).sort();
|
|
647
|
+
}
|
|
648
|
+
/**
|
|
435
649
|
* The site's files on GitLab. Reads come from the commit `revision()` last
|
|
436
650
|
* returned. Saves are single commits; each changed file carries the commit it
|
|
437
651
|
* was last changed in, so GitLab refuses to overwrite anyone else's edits.
|
|
@@ -440,8 +654,11 @@ var GitLabBackend = class GitLabBackend {
|
|
|
440
654
|
user;
|
|
441
655
|
pages;
|
|
442
656
|
editors;
|
|
657
|
+
/** Absent where the project holds only content: the update job runs where the code is. */
|
|
443
658
|
updates;
|
|
444
659
|
drafts;
|
|
660
|
+
scheduledBuilds;
|
|
661
|
+
contentOnly;
|
|
445
662
|
options;
|
|
446
663
|
api;
|
|
447
664
|
project;
|
|
@@ -457,10 +674,32 @@ var GitLabBackend = class GitLabBackend {
|
|
|
457
674
|
this.branch = options.branch;
|
|
458
675
|
this.user = options.user;
|
|
459
676
|
this.onSignOut = options.onSignOut;
|
|
677
|
+
this.pinned = options.revision;
|
|
460
678
|
this.pages = gitlabPages(this.api, options.project);
|
|
461
679
|
this.editors = gitlabEditors(this.api, options.project, this.branch, this.user.login);
|
|
462
|
-
this.
|
|
680
|
+
this.contentOnly = options.contentOnly === true;
|
|
681
|
+
if (!this.contentOnly) this.updates = gitlabUpdates(this.api, options.project, this.branch, async (path) => this.readAt(path, this.pinned ?? await this.revision()));
|
|
682
|
+
else if (options.code) {
|
|
683
|
+
const code = options.code;
|
|
684
|
+
this.updates = gitlabCodeUpdates({
|
|
685
|
+
api: this.api,
|
|
686
|
+
project: code.project,
|
|
687
|
+
webUrl: code.webUrl,
|
|
688
|
+
open: (branch) => new GitLabBackend({
|
|
689
|
+
api: this.api,
|
|
690
|
+
project: code.project,
|
|
691
|
+
branch,
|
|
692
|
+
user: this.user,
|
|
693
|
+
onSignOut: () => {}
|
|
694
|
+
})
|
|
695
|
+
});
|
|
696
|
+
}
|
|
463
697
|
this.drafts = branchDrafts(this, this.draftHost());
|
|
698
|
+
this.scheduledBuilds = gitlabScheduledBuilds(this.api, options.project, this.branch, async () => this.readAt(".gitlab-ci.yml", this.pinned ?? await this.revision()));
|
|
699
|
+
}
|
|
700
|
+
async repositoryVisibility() {
|
|
701
|
+
const { visibility } = await gitlabJson(this.api, `/projects/${this.project}`);
|
|
702
|
+
return visibility === "public" || visibility === "internal" ? visibility : "private";
|
|
464
703
|
}
|
|
465
704
|
/** Drafts on branches of the project, with merge requests for review. */
|
|
466
705
|
draftHost() {
|
|
@@ -494,6 +733,13 @@ var GitLabBackend = class GitLabBackend {
|
|
|
494
733
|
if (!response.ok) throw new DraftError("not-found", `There's no draft ${branch}.`);
|
|
495
734
|
return (await response.json()).id;
|
|
496
735
|
},
|
|
736
|
+
listBranches: async (prefix) => {
|
|
737
|
+
const query = new URLSearchParams({
|
|
738
|
+
search: `^${prefix}`,
|
|
739
|
+
per_page: "100"
|
|
740
|
+
});
|
|
741
|
+
return (await gitlabJson(this.api, this.repo(`/branches?${query}`))).map((found) => found.name).filter((name) => name.startsWith(prefix));
|
|
742
|
+
},
|
|
497
743
|
openReview: async ({ branch, title, description }) => {
|
|
498
744
|
const response = await gitlabRequest(this.api, `${project}/merge_requests`, {
|
|
499
745
|
method: "POST",
|
|
@@ -526,10 +772,13 @@ var GitLabBackend = class GitLabBackend {
|
|
|
526
772
|
return `/projects/${this.project}/repository${path}`;
|
|
527
773
|
}
|
|
528
774
|
async revision() {
|
|
529
|
-
|
|
530
|
-
this.pinned = branch.commit.id;
|
|
775
|
+
this.pinned = await this.head();
|
|
531
776
|
return this.pinned;
|
|
532
777
|
}
|
|
778
|
+
/** The branch's latest commit, without moving the revision reads are pinned to. */
|
|
779
|
+
async head() {
|
|
780
|
+
return (await gitlabJson(this.api, this.repo(`/branches/${encodeURIComponent(this.branch)}`))).commit.id;
|
|
781
|
+
}
|
|
533
782
|
/** Every file in one folder at a commit, page by page. */
|
|
534
783
|
async listFolder(sha, dir) {
|
|
535
784
|
const files = [];
|
|
@@ -562,8 +811,11 @@ var GitLabBackend = class GitLabBackend {
|
|
|
562
811
|
return this.tree(this.pinned ?? await this.revision());
|
|
563
812
|
}
|
|
564
813
|
async read(path) {
|
|
565
|
-
|
|
566
|
-
|
|
814
|
+
return this.readFrom(await this.currentTree(), path);
|
|
815
|
+
}
|
|
816
|
+
readFrom(tree, path) {
|
|
817
|
+
const sha = tree.get(path);
|
|
818
|
+
if (!sha) return Promise.resolve(void 0);
|
|
567
819
|
let blob = this.blobs.get(sha);
|
|
568
820
|
if (!blob) {
|
|
569
821
|
blob = gitlabRequest(this.api, this.repo(`/blobs/${sha}/raw`)).then((response) => response.text());
|
|
@@ -574,10 +826,12 @@ var GitLabBackend = class GitLabBackend {
|
|
|
574
826
|
}
|
|
575
827
|
/** Reads many text files in a few GraphQL requests rather than one request each. */
|
|
576
828
|
async readFiles(paths) {
|
|
577
|
-
const
|
|
829
|
+
const ref = this.pinned ?? await this.revision();
|
|
830
|
+
return this.readFilesFrom(ref, await this.tree(ref), paths);
|
|
831
|
+
}
|
|
832
|
+
async readFilesFrom(ref, tree, paths) {
|
|
578
833
|
const wanted = paths.filter((path) => tree.has(path));
|
|
579
834
|
const fetch = wanted.filter((path) => !this.blobs.has(tree.get(path) ?? ""));
|
|
580
|
-
const ref = this.pinned ?? await this.revision();
|
|
581
835
|
for (let start = 0; start < fetch.length; start += GRAPHQL_BATCH) {
|
|
582
836
|
const batch = fetch.slice(start, start + GRAPHQL_BATCH);
|
|
583
837
|
const response = await gitlabJson(this.api, "/../graphql", {
|
|
@@ -600,20 +854,30 @@ var GitLabBackend = class GitLabBackend {
|
|
|
600
854
|
}
|
|
601
855
|
const result = /* @__PURE__ */ new Map();
|
|
602
856
|
for (const path of wanted) {
|
|
603
|
-
const text = await this.
|
|
857
|
+
const text = await this.readFrom(tree, path);
|
|
604
858
|
if (text !== void 0) result.set(path, text);
|
|
605
859
|
}
|
|
606
860
|
return result;
|
|
607
861
|
}
|
|
608
862
|
async readBytes(path) {
|
|
609
|
-
|
|
863
|
+
return this.bytesFrom(await this.currentTree(), path);
|
|
864
|
+
}
|
|
865
|
+
async bytesFrom(tree, path) {
|
|
866
|
+
const sha = tree.get(path);
|
|
610
867
|
if (!sha) return void 0;
|
|
611
868
|
const response = await gitlabRequest(this.api, this.repo(`/blobs/${sha}/raw`));
|
|
612
869
|
return new Uint8Array(await response.arrayBuffer());
|
|
613
870
|
}
|
|
614
871
|
async list(dir) {
|
|
615
|
-
|
|
616
|
-
|
|
872
|
+
return listFrom(await this.currentTree(), dir);
|
|
873
|
+
}
|
|
874
|
+
filesAt(revision) {
|
|
875
|
+
return {
|
|
876
|
+
read: async (path) => this.readFrom(await this.tree(revision), path),
|
|
877
|
+
readFiles: async (paths) => this.readFilesFrom(revision, await this.tree(revision), paths),
|
|
878
|
+
readBytes: async (path) => this.bytesFrom(await this.tree(revision), path),
|
|
879
|
+
list: async (dir) => listFrom(await this.tree(revision), dir)
|
|
880
|
+
};
|
|
617
881
|
}
|
|
618
882
|
/** GitLab's tree doesn't say how large files are, so they're asked for with GraphQL, in batches. */
|
|
619
883
|
async fileSizes(dir) {
|
|
@@ -644,11 +908,20 @@ var GitLabBackend = class GitLabBackend {
|
|
|
644
908
|
async lastCommitFor(path, ref) {
|
|
645
909
|
return (await gitlabJson(this.api, this.repo(`/files/${encodeURIComponent(path)}?ref=${ref}`))).last_commit_id;
|
|
646
910
|
}
|
|
911
|
+
/** Whether a file exists at a commit: from the cached tree in the editable folders, and from GitLab elsewhere, such as the config. */
|
|
912
|
+
async existsAt(tree, path, ref) {
|
|
913
|
+
if (EDITABLE_FOLDERS.some((folder) => path.startsWith(`${folder}/`))) return tree.has(path);
|
|
914
|
+
return this.lastCommitFor(path, ref).then(() => true, (error) => {
|
|
915
|
+
if (error instanceof GitApiError && error.status === 404) return false;
|
|
916
|
+
throw error;
|
|
917
|
+
});
|
|
918
|
+
}
|
|
647
919
|
async write(changes, { message, expectedRevision }) {
|
|
920
|
+
if (this.contentOnly) assertContentChanges(changes);
|
|
648
921
|
if (await this.revision() !== expectedRevision) throw new ConflictError();
|
|
649
922
|
const existing = await this.tree(expectedRevision);
|
|
650
923
|
const toApply = (await Promise.all(changes.map(async (change) => {
|
|
651
|
-
const exists =
|
|
924
|
+
const exists = await this.existsAt(existing, change.path, expectedRevision);
|
|
652
925
|
if ("delete" in change) return exists ? {
|
|
653
926
|
action: "delete",
|
|
654
927
|
file_path: change.path,
|
|
@@ -676,7 +949,11 @@ var GitLabBackend = class GitLabBackend {
|
|
|
676
949
|
body: {
|
|
677
950
|
branch: this.branch,
|
|
678
951
|
commit_message: message,
|
|
679
|
-
actions: toApply
|
|
952
|
+
actions: toApply,
|
|
953
|
+
...this.options.author && {
|
|
954
|
+
author_name: this.options.author.name,
|
|
955
|
+
author_email: this.options.author.email
|
|
956
|
+
}
|
|
680
957
|
}
|
|
681
958
|
});
|
|
682
959
|
this.pinned = commit.id;
|
|
@@ -716,13 +993,24 @@ var GitLabBackend = class GitLabBackend {
|
|
|
716
993
|
}
|
|
717
994
|
/**
|
|
718
995
|
* Reads the commit's pipeline and external statuses (GitLab Pages jobs, or
|
|
719
|
-
* Vercel). Before any exist, the site counts as building
|
|
996
|
+
* Vercel or Cloudflare Pages). Before any exist, the site counts as building
|
|
997
|
+
* if it has pipelines, or if the host reported on the commit before.
|
|
720
998
|
*/
|
|
721
999
|
async deployStatus(revision) {
|
|
722
1000
|
try {
|
|
723
|
-
const
|
|
724
|
-
|
|
725
|
-
|
|
1001
|
+
const all = await this.statuses(revision);
|
|
1002
|
+
const statuses = all.filter(builds);
|
|
1003
|
+
if (all.length > 0 && statuses.length === 0) return { state: "unknown" };
|
|
1004
|
+
if (statuses.length === 0) {
|
|
1005
|
+
if ((await gitlabJson(this.api, `/projects/${this.project}/pipelines?per_page=1`)).length > 0) return { state: "building" };
|
|
1006
|
+
const { parent_ids: parents = [] } = await gitlabJson(this.api, this.repo(`/commits/${revision}`));
|
|
1007
|
+
return { state: (parents[0] ? (await this.statuses(parents[0])).filter(builds) : []).length > 0 ? "building" : "unknown" };
|
|
1008
|
+
}
|
|
1009
|
+
const failed = statuses.find((status) => status.status === "failed") ?? statuses.find((status) => status.status === "canceled");
|
|
1010
|
+
if (failed?.status === "canceled") {
|
|
1011
|
+
const head = await this.head();
|
|
1012
|
+
if (head !== revision) return this.deployStatus(head);
|
|
1013
|
+
}
|
|
726
1014
|
if (failed) {
|
|
727
1015
|
const problem = await this.jobProblem(revision).catch(() => void 0);
|
|
728
1016
|
return {
|
|
@@ -744,6 +1032,9 @@ var GitLabBackend = class GitLabBackend {
|
|
|
744
1032
|
throw error;
|
|
745
1033
|
}
|
|
746
1034
|
}
|
|
1035
|
+
statuses(revision) {
|
|
1036
|
+
return gitlabJson(this.api, this.repo(`/commits/${revision}/statuses`));
|
|
1037
|
+
}
|
|
747
1038
|
/** Why a commit's pipeline failed: the failed job's reason, and its log for which command failed and why. */
|
|
748
1039
|
async jobProblem(revision) {
|
|
749
1040
|
const [pipeline] = await gitlabJson(this.api, `/projects/${this.project}/pipelines?${new URLSearchParams({
|
|
@@ -863,6 +1154,46 @@ function gitlabSetup(options = {}) {
|
|
|
863
1154
|
async function isAvailable(owner, name) {
|
|
864
1155
|
return (await gitlabRequest(api, `/projects/${encodeURIComponent(`${owner.path}/${name}`)}`, { allow: [404] })).status === 404;
|
|
865
1156
|
}
|
|
1157
|
+
/**
|
|
1158
|
+
* Lets each project's pipelines reach the other, as their setup files say:
|
|
1159
|
+
* a job token is refused by a project whose job token allowlist doesn't
|
|
1160
|
+
* name its project, even a public one. Also lets the code project's job
|
|
1161
|
+
* token publish updates, and runs pipelines each night where they need it.
|
|
1162
|
+
* Returns whether it worked.
|
|
1163
|
+
*/
|
|
1164
|
+
async function connectProjects(link) {
|
|
1165
|
+
const project = (path) => `/projects/${encodeURIComponent(path)}`;
|
|
1166
|
+
try {
|
|
1167
|
+
const ids = /* @__PURE__ */ new Map();
|
|
1168
|
+
for (const which of ["code", "content"]) ids.set(which, (await gitlabJson(api, project(link[which]))).id);
|
|
1169
|
+
for (const { reader, read } of link.links.reads) await gitlabRequest(api, `${project(link[read])}/job_token_scope/allowlist`, {
|
|
1170
|
+
method: "POST",
|
|
1171
|
+
body: { target_project_id: ids.get(reader) }
|
|
1172
|
+
});
|
|
1173
|
+
if (link.links.updates) await gitlabRequest(api, project(link[link.links.updates]), {
|
|
1174
|
+
method: "PUT",
|
|
1175
|
+
body: { ci_push_repository_for_job_token_allowed: true }
|
|
1176
|
+
});
|
|
1177
|
+
for (const which of link.links.nightly) await gitlabRequest(api, `${project(link[which])}/pipeline_schedules`, {
|
|
1178
|
+
method: "POST",
|
|
1179
|
+
body: {
|
|
1180
|
+
description: which === link.links.updates ? "Rebuild the site and install Goodfellow's fixes each night" : "Rebuild the site each night",
|
|
1181
|
+
ref: MAIN,
|
|
1182
|
+
cron: NIGHTLY,
|
|
1183
|
+
cron_timezone: "UTC",
|
|
1184
|
+
active: true
|
|
1185
|
+
}
|
|
1186
|
+
});
|
|
1187
|
+
return true;
|
|
1188
|
+
} catch (error) {
|
|
1189
|
+
if (error instanceof GitApiError && [
|
|
1190
|
+
400,
|
|
1191
|
+
403,
|
|
1192
|
+
404
|
|
1193
|
+
].includes(error.status)) return false;
|
|
1194
|
+
throw error;
|
|
1195
|
+
}
|
|
1196
|
+
}
|
|
866
1197
|
return {
|
|
867
1198
|
user: {
|
|
868
1199
|
login: user.username,
|
|
@@ -900,6 +1231,34 @@ function gitlabSetup(options = {}) {
|
|
|
900
1231
|
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
1232
|
throw error;
|
|
902
1233
|
}
|
|
1234
|
+
const warnings = [];
|
|
1235
|
+
if (site.link) {
|
|
1236
|
+
onStep?.("connect");
|
|
1237
|
+
if (!await connectProjects(site.link)) warnings.push("connect-unavailable");
|
|
1238
|
+
}
|
|
1239
|
+
let siteUrl;
|
|
1240
|
+
if (site.pages && site.domain) {
|
|
1241
|
+
onStep?.("pages");
|
|
1242
|
+
try {
|
|
1243
|
+
await gitlabRequest(api, `/projects/${project.id}/pages/domains`, {
|
|
1244
|
+
method: "POST",
|
|
1245
|
+
body: {
|
|
1246
|
+
domain: site.domain,
|
|
1247
|
+
auto_ssl_enabled: true
|
|
1248
|
+
}
|
|
1249
|
+
});
|
|
1250
|
+
siteUrl = `https://${site.domain}/`;
|
|
1251
|
+
} catch (error) {
|
|
1252
|
+
if (!(error instanceof GitApiError) || ![
|
|
1253
|
+
400,
|
|
1254
|
+
403,
|
|
1255
|
+
404,
|
|
1256
|
+
409,
|
|
1257
|
+
422
|
|
1258
|
+
].includes(error.status)) throw error;
|
|
1259
|
+
warnings.push("domain-unavailable");
|
|
1260
|
+
}
|
|
1261
|
+
}
|
|
903
1262
|
onStep?.("files");
|
|
904
1263
|
await gitlabRequest(api, `/projects/${project.id}/repository/commits`, {
|
|
905
1264
|
method: "POST",
|
|
@@ -921,9 +1280,10 @@ function gitlabSetup(options = {}) {
|
|
|
921
1280
|
return {
|
|
922
1281
|
repo: project.path_with_namespace,
|
|
923
1282
|
webUrl: project.web_url,
|
|
1283
|
+
...siteUrl && { siteUrl },
|
|
924
1284
|
buildsUrl: `${project.web_url}/-/pipelines`,
|
|
925
1285
|
pagesUrl: site.pages ? `${project.web_url}/pages` : void 0,
|
|
926
|
-
warnings
|
|
1286
|
+
warnings
|
|
927
1287
|
};
|
|
928
1288
|
}
|
|
929
1289
|
};
|
|
@@ -1056,6 +1416,11 @@ function gitlab(options) {
|
|
|
1056
1416
|
api,
|
|
1057
1417
|
project: options.project,
|
|
1058
1418
|
branch: options.branch ?? project.default_branch,
|
|
1419
|
+
...options.contentOnly && { contentOnly: true },
|
|
1420
|
+
...options.contentOnly && options.code && { code: {
|
|
1421
|
+
project: options.code,
|
|
1422
|
+
webUrl
|
|
1423
|
+
} },
|
|
1059
1424
|
user: {
|
|
1060
1425
|
login: user.username,
|
|
1061
1426
|
name: user.name,
|
|
@@ -1092,9 +1457,57 @@ function gitlab(options) {
|
|
|
1092
1457
|
code_verifier: redirect.verifier
|
|
1093
1458
|
}), redirect.verifier), redirect.remember);
|
|
1094
1459
|
}
|
|
1460
|
+
/** The project through a token of the site's own, such as a project access token, on a full-stack site's server. */
|
|
1461
|
+
function server({ token }) {
|
|
1462
|
+
const api = {
|
|
1463
|
+
apiUrl,
|
|
1464
|
+
token: async () => typeof token === "string" ? token : token(),
|
|
1465
|
+
canRefresh: false,
|
|
1466
|
+
fetch: fetcher
|
|
1467
|
+
};
|
|
1468
|
+
let branch;
|
|
1469
|
+
const liveBranch = () => {
|
|
1470
|
+
branch ??= options.branch ? Promise.resolve(options.branch) : gitlabJson(api, `/projects/${encodeURIComponent(options.project)}`).then((project) => project.default_branch);
|
|
1471
|
+
branch.catch(() => {
|
|
1472
|
+
branch = void 0;
|
|
1473
|
+
});
|
|
1474
|
+
return branch;
|
|
1475
|
+
};
|
|
1476
|
+
const backend = async (revision, author) => new GitLabBackend({
|
|
1477
|
+
api,
|
|
1478
|
+
project: options.project,
|
|
1479
|
+
branch: await liveBranch(),
|
|
1480
|
+
...options.contentOnly && { contentOnly: true },
|
|
1481
|
+
user: {
|
|
1482
|
+
login: author?.name ?? options.project,
|
|
1483
|
+
...author && { name: author.name }
|
|
1484
|
+
},
|
|
1485
|
+
onSignOut: () => {},
|
|
1486
|
+
...revision && { revision },
|
|
1487
|
+
...author && { author }
|
|
1488
|
+
});
|
|
1489
|
+
return {
|
|
1490
|
+
...options.contentOnly && { contentOnly: true },
|
|
1491
|
+
async revision() {
|
|
1492
|
+
return (await backend()).revision();
|
|
1493
|
+
},
|
|
1494
|
+
at: backend
|
|
1495
|
+
};
|
|
1496
|
+
}
|
|
1095
1497
|
return {
|
|
1096
1498
|
name: "GitLab",
|
|
1499
|
+
source: hostSource("@goodfellow-cms/gitlab", "gitlab", options, [
|
|
1500
|
+
"fetch",
|
|
1501
|
+
"storage",
|
|
1502
|
+
"location"
|
|
1503
|
+
]),
|
|
1097
1504
|
canRedirect: Boolean(options.clientId),
|
|
1505
|
+
setup: () => gitlabSetup({
|
|
1506
|
+
...options.url && { url: options.url },
|
|
1507
|
+
...options.fetch && { fetch: options.fetch },
|
|
1508
|
+
...options.storage && { storage: options.storage },
|
|
1509
|
+
...options.location && { location: options.location }
|
|
1510
|
+
}),
|
|
1098
1511
|
redirectAddressesListed: Boolean(options.clientId),
|
|
1099
1512
|
tokenLinks: [{
|
|
1100
1513
|
url: `${webUrl}/-/user_settings/personal_access_tokens?${new URLSearchParams({
|
|
@@ -1145,7 +1558,8 @@ function gitlab(options) {
|
|
|
1145
1558
|
code_challenge_method: "S256"
|
|
1146
1559
|
});
|
|
1147
1560
|
location().assign(`${webUrl}/oauth/authorize?${query}`);
|
|
1148
|
-
}
|
|
1561
|
+
},
|
|
1562
|
+
server
|
|
1149
1563
|
};
|
|
1150
1564
|
}
|
|
1151
1565
|
//#endregion
|
package/dist/testing.d.ts
CHANGED
|
@@ -21,14 +21,39 @@ export interface FakeGitLabOptions {
|
|
|
21
21
|
};
|
|
22
22
|
/** Simulates a GitLab Pages pipeline that succeeds after being checked this many times. Off by default. */
|
|
23
23
|
deployAfterChecks?: number;
|
|
24
|
+
/** The job each published commit's pipeline runs: `pages` by default. */
|
|
25
|
+
deployJob?: string;
|
|
26
|
+
/**
|
|
27
|
+
* Has `deployJob` report as a host outside GitLab does, such as Cloudflare
|
|
28
|
+
* Pages: a commit status with no pipeline the project lists.
|
|
29
|
+
*/
|
|
30
|
+
external?: boolean;
|
|
31
|
+
/** The job the update runs in, which `addUpdateRun()` logs: `pages` by default, as in the starters, or a code project's `update`. */
|
|
32
|
+
updateJob?: string;
|
|
24
33
|
/** Groups the users belong to, which new projects can be created in. */
|
|
25
34
|
groups?: string[];
|
|
26
35
|
/** Publishes the site with GitLab Pages. Off by default. */
|
|
27
36
|
pages?: {
|
|
28
37
|
uniqueDomain?: boolean;
|
|
29
38
|
};
|
|
39
|
+
/** Domains another GitLab Pages site already uses. */
|
|
40
|
+
takenDomains?: string[];
|
|
30
41
|
/** Other GitLab accounts that exist, which can be added to the project. Every token's user exists and is a member. */
|
|
31
42
|
accounts?: string[];
|
|
43
|
+
/** Who can see the project. Private by default. */
|
|
44
|
+
visibility?: "public" | "internal" | "private";
|
|
45
|
+
}
|
|
46
|
+
/** A pipeline schedule, such as the nightly one or one the admin panel adds for a scheduled change. */
|
|
47
|
+
export interface FakeSchedule {
|
|
48
|
+
id: number;
|
|
49
|
+
ref: string;
|
|
50
|
+
cron: string;
|
|
51
|
+
cronTimezone?: string;
|
|
52
|
+
description?: string;
|
|
53
|
+
active: boolean;
|
|
54
|
+
/** Who added it, who alone (with Maintainers) can change it. */
|
|
55
|
+
owner?: string;
|
|
56
|
+
variables?: Record<string, string>;
|
|
32
57
|
}
|
|
33
58
|
/** A member of the site's project. */
|
|
34
59
|
export interface FakeMember {
|
|
@@ -63,6 +88,18 @@ export interface FakeCreatedProject {
|
|
|
63
88
|
description?: string;
|
|
64
89
|
pagesAccessLevel?: string;
|
|
65
90
|
repo: FakeRepo;
|
|
91
|
+
/** The projects whose job tokens may reach this one (its job token allowlist), by id. */
|
|
92
|
+
jobTokenAllowlist: number[];
|
|
93
|
+
/** Whether its pipelines' job tokens may push to it. */
|
|
94
|
+
jobTokenPush: boolean;
|
|
95
|
+
schedules: Array<{
|
|
96
|
+
description: string;
|
|
97
|
+
ref: string;
|
|
98
|
+
cron: string;
|
|
99
|
+
active: boolean;
|
|
100
|
+
}>;
|
|
101
|
+
/** Domains of its own its Pages were given. */
|
|
102
|
+
pagesDomains: string[];
|
|
66
103
|
}
|
|
67
104
|
export declare function fakeGitLab(options: FakeGitLabOptions): {
|
|
68
105
|
/** How many GraphQL requests read files, as `readFiles()` makes. */
|
|
@@ -80,15 +117,11 @@ export declare function fakeGitLab(options: FakeGitLabOptions): {
|
|
|
80
117
|
failureReason?: string;
|
|
81
118
|
trace?: string;
|
|
82
119
|
}) => void;
|
|
120
|
+
cancelBuild: (sha: string) => void;
|
|
83
121
|
/** The project's update settings, and the pipelines the update ran in, which tests add with `addUpdateRun()`. */
|
|
84
122
|
updateSettings: {
|
|
85
123
|
jobTokenPush: boolean;
|
|
86
|
-
schedules:
|
|
87
|
-
id: number;
|
|
88
|
-
ref: string;
|
|
89
|
-
cron: string;
|
|
90
|
-
active: boolean;
|
|
91
|
-
}>;
|
|
124
|
+
schedules: FakeSchedule[];
|
|
92
125
|
};
|
|
93
126
|
updateRuns: {
|
|
94
127
|
id: number;
|
|
@@ -123,6 +156,11 @@ export declare function fakeGitLab(options: FakeGitLabOptions): {
|
|
|
123
156
|
created: Map<string, FakeCreatedProject>;
|
|
124
157
|
/** The site's GitLab Pages, if it has them, which tests can change. */
|
|
125
158
|
pages: FakeGitLabPages | undefined;
|
|
159
|
+
/** Who commits that named their author did name, by commit. */
|
|
160
|
+
commitAuthors: Map<string, {
|
|
161
|
+
name: string;
|
|
162
|
+
email: string;
|
|
163
|
+
}>;
|
|
126
164
|
requests: Request[];
|
|
127
165
|
commit: (changes: FileChange[], message: string, author?: string, branch?: string) => string;
|
|
128
166
|
authorize: (authorizeUrl: string) => string;
|
package/dist/testing.js
CHANGED
|
@@ -20,6 +20,8 @@ async function sha256Base64Url(text) {
|
|
|
20
20
|
function fakeGitLab(options) {
|
|
21
21
|
const webUrl = options.url ?? "https://gitlab.com";
|
|
22
22
|
const repo = new FakeRepo(options.files);
|
|
23
|
+
/** Who each commit that named its author did name, by commit. */
|
|
24
|
+
const commitAuthors = /* @__PURE__ */ new Map();
|
|
23
25
|
const tokens = new Map(Object.entries(options.tokens ?? { "test-token": {
|
|
24
26
|
username: "editor",
|
|
25
27
|
name: "Test Editor"
|
|
@@ -156,6 +158,7 @@ function fakeGitLab(options) {
|
|
|
156
158
|
jobTokenPush: false,
|
|
157
159
|
schedules: []
|
|
158
160
|
};
|
|
161
|
+
let scheduleIds = 0;
|
|
159
162
|
/** Pipelines the update ran in: scheduled, or started through the API, with the pages job's log. */
|
|
160
163
|
const updateRuns = [];
|
|
161
164
|
/** Simulates a scheduled (or API) pipeline whose pages job logged `trace`. */
|
|
@@ -170,7 +173,7 @@ function fakeGitLab(options) {
|
|
|
170
173
|
updateRuns.push(added);
|
|
171
174
|
return added;
|
|
172
175
|
}
|
|
173
|
-
/** Failed jobs, by commit, which tests add with `failBuild()`. */
|
|
176
|
+
/** Failed jobs, by commit, which tests add with `failBuild()` and `cancelBuild()`. */
|
|
174
177
|
const failures = /* @__PURE__ */ new Map();
|
|
175
178
|
/**
|
|
176
179
|
* Simulates the pipeline for a commit failing: its `pages` job (or another,
|
|
@@ -180,10 +183,21 @@ function fakeGitLab(options) {
|
|
|
180
183
|
if (!pipelines.has(sha)) pipelines.set(sha, { checks: 0 });
|
|
181
184
|
failures.set(sha, {
|
|
182
185
|
job: failure.job ?? "pages",
|
|
186
|
+
status: "failed",
|
|
183
187
|
failureReason: failure.failureReason ?? "script_failure",
|
|
184
188
|
trace: failure.trace ?? ""
|
|
185
189
|
});
|
|
186
190
|
}
|
|
191
|
+
/** Simulates a commit's `pages` job being cancelled, as a newer pipeline on the branch cancels an interruptible one. */
|
|
192
|
+
function cancelBuild(sha) {
|
|
193
|
+
if (!pipelines.has(sha)) pipelines.set(sha, { checks: 0 });
|
|
194
|
+
failures.set(sha, {
|
|
195
|
+
job: "pages",
|
|
196
|
+
status: "canceled",
|
|
197
|
+
failureReason: "",
|
|
198
|
+
trace: ""
|
|
199
|
+
});
|
|
200
|
+
}
|
|
187
201
|
const pipelineId = (sha) => [...pipelines.keys()].indexOf(sha) + 1;
|
|
188
202
|
const requests = [];
|
|
189
203
|
let counter = 0;
|
|
@@ -297,7 +311,11 @@ function fakeGitLab(options) {
|
|
|
297
311
|
visibility: body.visibility,
|
|
298
312
|
description: body.description,
|
|
299
313
|
pagesAccessLevel: body.pages_access_level,
|
|
300
|
-
repo: new FakeRepo({})
|
|
314
|
+
repo: new FakeRepo({}),
|
|
315
|
+
jobTokenAllowlist: [],
|
|
316
|
+
jobTokenPush: false,
|
|
317
|
+
schedules: [],
|
|
318
|
+
pagesDomains: []
|
|
301
319
|
};
|
|
302
320
|
created.set(fullPath, project);
|
|
303
321
|
return json({
|
|
@@ -311,11 +329,49 @@ function fakeGitLab(options) {
|
|
|
311
329
|
const project = created.get(key) ?? [...created.values()].find((candidate) => String(candidate.id) === key);
|
|
312
330
|
if (!match || !project) return void 0;
|
|
313
331
|
const rest = match[2] ?? "";
|
|
314
|
-
if (rest === "") return json({
|
|
332
|
+
if (rest === "" && request.method === "GET") return json({
|
|
315
333
|
id: project.id,
|
|
316
334
|
path_with_namespace: project.path,
|
|
317
|
-
default_branch: "main"
|
|
335
|
+
default_branch: "main",
|
|
336
|
+
ci_push_repository_for_job_token_allowed: project.jobTokenPush
|
|
318
337
|
});
|
|
338
|
+
if (rest === "" && request.method === "PUT") {
|
|
339
|
+
const body = await request.json();
|
|
340
|
+
if (body.ci_push_repository_for_job_token_allowed !== void 0) project.jobTokenPush = body.ci_push_repository_for_job_token_allowed;
|
|
341
|
+
return json({
|
|
342
|
+
id: project.id,
|
|
343
|
+
ci_push_repository_for_job_token_allowed: project.jobTokenPush
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
if (rest === "/job_token_scope/allowlist" && request.method === "POST") {
|
|
347
|
+
const body = await request.json();
|
|
348
|
+
if (![...created.values()].some((candidate) => candidate.id === body.target_project_id)) return json({ message: "404 Project Not Found" }, 404);
|
|
349
|
+
if (!project.jobTokenAllowlist.includes(body.target_project_id)) project.jobTokenAllowlist.push(body.target_project_id);
|
|
350
|
+
return json({
|
|
351
|
+
source_project_id: project.id,
|
|
352
|
+
target_project_id: body.target_project_id
|
|
353
|
+
}, 201);
|
|
354
|
+
}
|
|
355
|
+
if (rest === "/pages/domains" && request.method === "POST") {
|
|
356
|
+
const body = await request.json();
|
|
357
|
+
if (options.takenDomains?.includes(body.domain)) return json({ message: { domain: ["has already been taken"] } }, 400);
|
|
358
|
+
project.pagesDomains.push(body.domain);
|
|
359
|
+
return json({
|
|
360
|
+
domain: body.domain,
|
|
361
|
+
url: `https://${body.domain}`
|
|
362
|
+
}, 201);
|
|
363
|
+
}
|
|
364
|
+
if (rest === "/pipeline_schedules" && request.method === "POST") {
|
|
365
|
+
const body = await request.json();
|
|
366
|
+
project.schedules.push({
|
|
367
|
+
...body,
|
|
368
|
+
active: body.active !== false
|
|
369
|
+
});
|
|
370
|
+
return json({
|
|
371
|
+
id: project.schedules.length,
|
|
372
|
+
...body
|
|
373
|
+
}, 201);
|
|
374
|
+
}
|
|
319
375
|
if (rest === "/repository/commits" && request.method === "POST") {
|
|
320
376
|
const body = await request.json();
|
|
321
377
|
return json({
|
|
@@ -447,12 +503,21 @@ function fakeGitLab(options) {
|
|
|
447
503
|
if (membersResponse) return membersResponse;
|
|
448
504
|
if (rest === "" && request.method !== "PUT") return json({
|
|
449
505
|
default_branch: repo.defaultBranch,
|
|
506
|
+
visibility: options.visibility ?? "private",
|
|
450
507
|
permissions: {
|
|
451
508
|
project_access: { access_level: accessLevel },
|
|
452
509
|
group_access: null
|
|
453
510
|
},
|
|
454
511
|
ci_push_repository_for_job_token_allowed: updateSettings.jobTokenPush
|
|
455
512
|
});
|
|
513
|
+
if (rest === "/repository/branches" && request.method === "GET") {
|
|
514
|
+
const search = url.searchParams.get("search") ?? "";
|
|
515
|
+
const matches = (name) => search.startsWith("^") ? name.startsWith(search.slice(1)) : name.includes(search);
|
|
516
|
+
return json([...repo.branches].filter(([name]) => matches(name)).map(([name, sha]) => ({
|
|
517
|
+
name,
|
|
518
|
+
commit: { id: sha }
|
|
519
|
+
})));
|
|
520
|
+
}
|
|
456
521
|
if (rest === "/repository/branches" && request.method === "POST") {
|
|
457
522
|
if (accessLevel < 30) return json({ message: "403 Forbidden" }, 403);
|
|
458
523
|
const name = url.searchParams.get("branch") ?? "";
|
|
@@ -549,17 +614,22 @@ function fakeGitLab(options) {
|
|
|
549
614
|
if ((action.action === "update" || action.action === "delete") && !files.has(action.file_path)) return json({ message: "A file with this name doesn't exist" }, 400);
|
|
550
615
|
if (action.last_commit_id && repo.lastCommitFor(action.file_path, body.branch) !== action.last_commit_id) return json({ message: "You are attempting to update a file that has changed since you started editing it." }, 400);
|
|
551
616
|
}
|
|
617
|
+
const sha = commit(body.actions.map((action) => action.action === "delete" ? {
|
|
618
|
+
path: action.file_path,
|
|
619
|
+
delete: true
|
|
620
|
+
} : action.encoding === "base64" ? {
|
|
621
|
+
path: action.file_path,
|
|
622
|
+
bytes: decodeBase64(action.content ?? "")
|
|
623
|
+
} : {
|
|
624
|
+
path: action.file_path,
|
|
625
|
+
content: action.content ?? ""
|
|
626
|
+
}), body.commit_message, body.author_name ?? user.name ?? user.username, body.branch);
|
|
627
|
+
if (body.author_name && body.author_email) commitAuthors.set(sha, {
|
|
628
|
+
name: body.author_name,
|
|
629
|
+
email: body.author_email
|
|
630
|
+
});
|
|
552
631
|
return json({
|
|
553
|
-
id:
|
|
554
|
-
path: action.file_path,
|
|
555
|
-
delete: true
|
|
556
|
-
} : action.encoding === "base64" ? {
|
|
557
|
-
path: action.file_path,
|
|
558
|
-
bytes: decodeBase64(action.content ?? "")
|
|
559
|
-
} : {
|
|
560
|
-
path: action.file_path,
|
|
561
|
-
content: action.content ?? ""
|
|
562
|
-
}), body.commit_message, user.name ?? user.username, body.branch),
|
|
632
|
+
id: sha,
|
|
563
633
|
message: body.commit_message
|
|
564
634
|
}, 201);
|
|
565
635
|
}
|
|
@@ -578,6 +648,15 @@ function fakeGitLab(options) {
|
|
|
578
648
|
committed_date: commit.date
|
|
579
649
|
})));
|
|
580
650
|
}
|
|
651
|
+
const commitSha = rest.match(/^\/repository\/commits\/([0-9a-f]{40})$/)?.[1];
|
|
652
|
+
if (commitSha) {
|
|
653
|
+
const found = repo.commitAt(commitSha);
|
|
654
|
+
if (!found) return json({ message: "404 Commit Not Found" }, 404);
|
|
655
|
+
return json({
|
|
656
|
+
id: commitSha,
|
|
657
|
+
parent_ids: found.parent ? [found.parent] : []
|
|
658
|
+
});
|
|
659
|
+
}
|
|
581
660
|
if (rest === "/repository/compare") return json({ diffs: repo.changedPaths(url.searchParams.get("from") ?? "", url.searchParams.get("to") ?? "").map((changedPath) => ({
|
|
582
661
|
old_path: changedPath,
|
|
583
662
|
new_path: changedPath
|
|
@@ -591,14 +670,15 @@ function fakeGitLab(options) {
|
|
|
591
670
|
const id = pipelineId(statuses[1] ?? "");
|
|
592
671
|
return json([{
|
|
593
672
|
name: failure.job,
|
|
594
|
-
status:
|
|
673
|
+
status: failure.status,
|
|
595
674
|
target_url: `${webUrl}/${options.project}/-/jobs/${id}`
|
|
596
675
|
}]);
|
|
597
676
|
}
|
|
598
677
|
pipeline.checks += 1;
|
|
678
|
+
const done = pipeline.checks > (options.deployAfterChecks ?? 0);
|
|
599
679
|
return json([{
|
|
600
|
-
name: "pages",
|
|
601
|
-
status:
|
|
680
|
+
name: options.deployJob ?? "pages",
|
|
681
|
+
status: done ? "success" : "running",
|
|
602
682
|
target_url: `${webUrl}/${options.project}/-/jobs/1`
|
|
603
683
|
}]);
|
|
604
684
|
}
|
|
@@ -625,17 +705,82 @@ function fakeGitLab(options) {
|
|
|
625
705
|
if (accessLevel < 30) return json({ message: "403 Forbidden" }, 403);
|
|
626
706
|
if (request.method === "POST") {
|
|
627
707
|
const body = await request.json();
|
|
708
|
+
const protection = protectedBranches.get(body.ref);
|
|
709
|
+
if (protection && accessLevel < protection.merge) return json({ message: "403 Forbidden" }, 403);
|
|
628
710
|
const schedule = {
|
|
629
|
-
id:
|
|
711
|
+
id: ++scheduleIds,
|
|
630
712
|
ref: body.ref,
|
|
631
713
|
cron: body.cron,
|
|
632
|
-
active: body.active ?? true
|
|
714
|
+
active: body.active ?? true,
|
|
715
|
+
...body.description !== void 0 && { description: body.description },
|
|
716
|
+
...body.cron_timezone !== void 0 && { cronTimezone: body.cron_timezone },
|
|
717
|
+
owner: user.username
|
|
633
718
|
};
|
|
634
719
|
updateSettings.schedules.push(schedule);
|
|
635
720
|
return json(schedule, 201);
|
|
636
721
|
}
|
|
637
722
|
return json(updateSettings.schedules);
|
|
638
723
|
}
|
|
724
|
+
const scheduleMatch = rest.match(/^\/pipeline_schedules\/(\d+)(\/variables)?(?:\/([\w-]+))?$/);
|
|
725
|
+
if (scheduleMatch) {
|
|
726
|
+
const schedule = updateSettings.schedules.find((candidate) => candidate.id === Number(scheduleMatch[1]));
|
|
727
|
+
if (!schedule) return json({ message: "404 Not found" }, 404);
|
|
728
|
+
const variables = () => Object.entries(schedule.variables ?? {}).map(([key, value]) => ({
|
|
729
|
+
key,
|
|
730
|
+
value,
|
|
731
|
+
variable_type: "env_var"
|
|
732
|
+
}));
|
|
733
|
+
if (request.method === "GET" && !scheduleMatch[2]) {
|
|
734
|
+
if (accessLevel < 30) return json({ message: "403 Forbidden" }, 403);
|
|
735
|
+
return json({
|
|
736
|
+
...schedule,
|
|
737
|
+
variables: variables()
|
|
738
|
+
});
|
|
739
|
+
}
|
|
740
|
+
if (accessLevel < 30 || accessLevel < 40 && schedule.owner !== user.username) return json({ message: "403 Forbidden" }, 403);
|
|
741
|
+
const key = scheduleMatch[3];
|
|
742
|
+
if (scheduleMatch[2] && !key && request.method === "POST") {
|
|
743
|
+
const body = await request.json();
|
|
744
|
+
schedule.variables = {
|
|
745
|
+
...schedule.variables,
|
|
746
|
+
[body.key]: body.value
|
|
747
|
+
};
|
|
748
|
+
return json(body, 201);
|
|
749
|
+
}
|
|
750
|
+
if (scheduleMatch[2] && key) {
|
|
751
|
+
if (!(key in (schedule.variables ?? {}))) return json({ message: "404 Variable Not Found" }, 404);
|
|
752
|
+
if (request.method === "PUT") {
|
|
753
|
+
const body = await request.json();
|
|
754
|
+
schedule.variables = {
|
|
755
|
+
...schedule.variables,
|
|
756
|
+
[key]: body.value
|
|
757
|
+
};
|
|
758
|
+
return json({
|
|
759
|
+
key,
|
|
760
|
+
value: body.value
|
|
761
|
+
});
|
|
762
|
+
}
|
|
763
|
+
if (request.method === "DELETE") {
|
|
764
|
+
const { [key]: _removed, ...rest } = schedule.variables ?? {};
|
|
765
|
+
schedule.variables = rest;
|
|
766
|
+
return json({ key });
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
if (!scheduleMatch[2] && request.method === "PUT") {
|
|
770
|
+
const body = await request.json();
|
|
771
|
+
if (body.active !== void 0) schedule.active = body.active;
|
|
772
|
+
if (body.cron !== void 0) schedule.cron = body.cron;
|
|
773
|
+
if (body.description !== void 0) schedule.description = body.description;
|
|
774
|
+
return json({
|
|
775
|
+
...schedule,
|
|
776
|
+
variables: variables()
|
|
777
|
+
});
|
|
778
|
+
}
|
|
779
|
+
if (!scheduleMatch[2] && request.method === "DELETE") {
|
|
780
|
+
updateSettings.schedules = updateSettings.schedules.filter((candidate) => candidate !== schedule);
|
|
781
|
+
return new Response(null, { status: 204 });
|
|
782
|
+
}
|
|
783
|
+
}
|
|
639
784
|
if (rest === "" && request.method === "PUT") {
|
|
640
785
|
if (accessLevel < 40) return json({ message: "403 Forbidden" }, 403);
|
|
641
786
|
const body = await request.json();
|
|
@@ -647,7 +792,7 @@ function fakeGitLab(options) {
|
|
|
647
792
|
const run = updateRuns.find((candidate) => candidate.id === Number(updateJobs[1]));
|
|
648
793
|
return run ? json([{
|
|
649
794
|
id: run.id,
|
|
650
|
-
name: "pages",
|
|
795
|
+
name: options.updateJob ?? "pages",
|
|
651
796
|
status: run.status
|
|
652
797
|
}]) : json({ message: "404 Not Found" }, 404);
|
|
653
798
|
}
|
|
@@ -658,7 +803,7 @@ function fakeGitLab(options) {
|
|
|
658
803
|
}
|
|
659
804
|
if (rest === "/pipelines") {
|
|
660
805
|
const sha = url.searchParams.get("sha");
|
|
661
|
-
return json([...pipelines.keys()].map((pipelineSha, index) => ({
|
|
806
|
+
return json([...options.external ? [] : pipelines.keys()].map((pipelineSha, index) => ({
|
|
662
807
|
id: index + 1,
|
|
663
808
|
sha: pipelineSha
|
|
664
809
|
})).filter((pipeline) => !sha || pipeline.sha === sha));
|
|
@@ -672,7 +817,7 @@ function fakeGitLab(options) {
|
|
|
672
817
|
return json([failure ? {
|
|
673
818
|
id,
|
|
674
819
|
name: failure.job,
|
|
675
|
-
status:
|
|
820
|
+
status: failure.status,
|
|
676
821
|
failure_reason: failure.failureReason
|
|
677
822
|
} : {
|
|
678
823
|
id,
|
|
@@ -698,6 +843,7 @@ function fakeGitLab(options) {
|
|
|
698
843
|
invitations,
|
|
699
844
|
protectedBranches,
|
|
700
845
|
failBuild,
|
|
846
|
+
cancelBuild,
|
|
701
847
|
/** The project's update settings, and the pipelines the update ran in, which tests add with `addUpdateRun()`. */
|
|
702
848
|
updateSettings,
|
|
703
849
|
updateRuns,
|
|
@@ -707,6 +853,8 @@ function fakeGitLab(options) {
|
|
|
707
853
|
created,
|
|
708
854
|
/** The site's GitLab Pages, if it has them, which tests can change. */
|
|
709
855
|
pages,
|
|
856
|
+
/** Who commits that named their author did name, by commit. */
|
|
857
|
+
commitAuthors,
|
|
710
858
|
requests,
|
|
711
859
|
commit,
|
|
712
860
|
authorize,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodfellow-cms/gitlab",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Edit a Goodfellow site stored on GitLab, straight from the browser.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"goodfellow",
|
|
@@ -35,13 +35,13 @@
|
|
|
35
35
|
"dist"
|
|
36
36
|
],
|
|
37
37
|
"dependencies": {
|
|
38
|
-
"@goodfellow-cms/core": "0.
|
|
38
|
+
"@goodfellow-cms/core": "0.5.0"
|
|
39
39
|
},
|
|
40
40
|
"devDependencies": {
|
|
41
41
|
"@puckeditor/core": "0.23.0"
|
|
42
42
|
},
|
|
43
43
|
"engines": {
|
|
44
|
-
"node": ">=
|
|
44
|
+
"node": "^22.23.4 || ^24.22.1 || >=26.12.1"
|
|
45
45
|
},
|
|
46
46
|
"publishConfig": {
|
|
47
47
|
"access": "public"
|