@goodfellow-cms/gitlab 0.4.0 → 0.6.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 +1 -1
- package/dist/index.d.ts +26 -2
- package/dist/index.js +340 -29
- package/dist/testing.d.ts +31 -6
- package/dist/testing.js +143 -8
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -8,7 +8,7 @@ import { gitlab } from "@goodfellow-cms/gitlab";
|
|
|
8
8
|
export default defineConfig({ blocks, backend: gitlab({ project: "your-name/your-site" }) });
|
|
9
9
|
```
|
|
10
10
|
|
|
11
|
-
[Goodfellow](https://github.com/goodfellow-cms/goodfellow-cms) is a website builder for people who aren't developers, with no server or database: pages are files in your own GitHub or
|
|
11
|
+
[Goodfellow](https://github.com/goodfellow-cms/goodfellow-cms) is a website builder for people who aren't developers, with no server or database: pages are files in your own GitHub, GitLab or Bitbucket repository, edited in a visual editor built on [Puck](https://puckeditor.com). To start a new site, run `npm create goodfellow@latest my-site`. See the [documentation](https://goodfellow-cms.github.io/goodfellow-cms/) for everything else.
|
|
12
12
|
|
|
13
13
|
More in the docs: [Hosts and business sites](https://goodfellow-cms.github.io/goodfellow-cms/docs/hosts).
|
|
14
14
|
|
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, PluginTasks, 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`. */
|
|
@@ -12,6 +12,15 @@ interface ApiOptions {
|
|
|
12
12
|
fetch: typeof fetch;
|
|
13
13
|
}
|
|
14
14
|
//#endregion
|
|
15
|
+
//#region src/tasks.d.ts
|
|
16
|
+
interface GitLabTasksOptions {
|
|
17
|
+
/** How often to look for the answer, and how long to wait for it. */
|
|
18
|
+
pollMs?: number;
|
|
19
|
+
timeoutMs?: number;
|
|
20
|
+
/** Waits between looks; tests pass one that doesn't. */
|
|
21
|
+
wait?: (ms: number) => Promise<void>;
|
|
22
|
+
}
|
|
23
|
+
//#endregion
|
|
15
24
|
//#region src/backend.d.ts
|
|
16
25
|
interface GitLabBackendOptions {
|
|
17
26
|
api: ApiOptions;
|
|
@@ -26,6 +35,8 @@ interface GitLabBackendOptions {
|
|
|
26
35
|
};
|
|
27
36
|
/** The commit reads see until `revision()` is next called, as a full-stack site's server reads what a browser loaded. */
|
|
28
37
|
revision?: string;
|
|
38
|
+
/** How plugins' tasks wait for their answers; tests pass short waits. */
|
|
39
|
+
tasks?: GitLabTasksOptions;
|
|
29
40
|
/** The project holds only the site's content, with its code in a project of its own (`GitBackend.contentOnly`). */
|
|
30
41
|
contentOnly?: boolean;
|
|
31
42
|
/** The code project, for a project that holds only content, where the site's updates run. */
|
|
@@ -46,6 +57,8 @@ export declare class GitLabBackend implements GitBackend {
|
|
|
46
57
|
/** Absent where the project holds only content: the update job runs where the code is. */
|
|
47
58
|
readonly updates?: SiteUpdates;
|
|
48
59
|
readonly drafts: SiteDrafts;
|
|
60
|
+
readonly scheduledBuilds: ScheduledBuilds;
|
|
61
|
+
readonly tasks: PluginTasks;
|
|
49
62
|
readonly contentOnly: boolean;
|
|
50
63
|
private readonly options;
|
|
51
64
|
private readonly api;
|
|
@@ -56,23 +69,32 @@ export declare class GitLabBackend implements GitBackend {
|
|
|
56
69
|
private readonly trees;
|
|
57
70
|
private readonly blobs;
|
|
58
71
|
constructor(options: GitLabBackendOptions);
|
|
72
|
+
repositoryVisibility(): Promise<RepositoryVisibility>;
|
|
59
73
|
/** Drafts on branches of the project, with merge requests for review. */
|
|
60
74
|
private draftHost;
|
|
61
75
|
private repo;
|
|
62
76
|
revision(): Promise<string>;
|
|
77
|
+
/** The branch's latest commit, without moving the revision reads are pinned to. */
|
|
78
|
+
private head;
|
|
63
79
|
/** Every file in one folder at a commit, page by page. */
|
|
64
80
|
private listFolder;
|
|
65
81
|
/** Path → blob id for the editable files at a commit. Commits never change, so this is cached. */
|
|
66
82
|
private tree;
|
|
67
83
|
private currentTree;
|
|
68
84
|
read(path: string): Promise<string | undefined>;
|
|
85
|
+
private readFrom;
|
|
69
86
|
/** Reads many text files in a few GraphQL requests rather than one request each. */
|
|
70
87
|
readFiles(paths: string[]): Promise<Map<string, string>>;
|
|
88
|
+
private readFilesFrom;
|
|
71
89
|
readBytes(path: string): Promise<Uint8Array | undefined>;
|
|
90
|
+
private bytesFrom;
|
|
72
91
|
list(dir: string): Promise<string[]>;
|
|
92
|
+
filesAt(revision: string): RevisionFiles;
|
|
73
93
|
/** GitLab's tree doesn't say how large files are, so they're asked for with GraphQL, in batches. */
|
|
74
94
|
fileSizes(dir: string): Promise<Map<string, number>>;
|
|
75
95
|
private lastCommitFor;
|
|
96
|
+
/** Whether a file exists at a commit: from the cached tree in the editable folders, and from GitLab elsewhere, such as the config. */
|
|
97
|
+
private existsAt;
|
|
76
98
|
write(changes: FileChange[], { message, expectedRevision }: WriteOptions): Promise<{
|
|
77
99
|
revision: string;
|
|
78
100
|
}>;
|
|
@@ -81,9 +103,11 @@ export declare class GitLabBackend implements GitBackend {
|
|
|
81
103
|
changedPaths(from: string, to: string): Promise<string[]>;
|
|
82
104
|
/**
|
|
83
105
|
* Reads the commit's pipeline and external statuses (GitLab Pages jobs, or
|
|
84
|
-
* Vercel). Before any exist, the site counts as building
|
|
106
|
+
* Vercel or Cloudflare Pages). Before any exist, the site counts as building
|
|
107
|
+
* if it has pipelines, or if the host reported on the commit before.
|
|
85
108
|
*/
|
|
86
109
|
deployStatus(revision: string): Promise<DeployStatus>;
|
|
110
|
+
private statuses;
|
|
87
111
|
/** Why a commit's pipeline failed: the failed job's reason, and its log for which command failed and why. */
|
|
88
112
|
private jobProblem;
|
|
89
113
|
signOut(): void;
|
package/dist/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ConflictError, DomainError, DraftError, EDITABLE_FOLDERS, EditorsError, GitApiError, REBUILD_JOB, SetupError, SignInError, UpdatesError, assertContentChanges, branchDrafts, buildStepFor, credentialStorage, encodeBase64Bytes, findBuildCause, findUpdateResult } from "@goodfellow-cms/core";
|
|
1
|
+
import { ConflictError, DomainError, DraftError, EDITABLE_FOLDERS, EditorsError, GitApiError, LOCKFILE_JOB, NIGHTLY_VARIABLE, PluginTaskError, REBUILD_JOB, ScheduledBuildsError, SetupError, SignInError, TASK_JOB, TASK_POLL_MS, TASK_RESULT_FILE, TASK_TIMEOUT_MS, UpdatesError, assertContentChanges, branchDrafts, buildStepFor, credentialStorage, encodeBase64Bytes, findBuildCause, findUpdateResult, hostSource, taskRequestProblem } 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,146 @@ 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
|
|
414
|
+
//#region src/tasks.ts
|
|
415
|
+
/**
|
|
416
|
+
* Plugins' tasks on GitLab, through the starters' `plugin-task` job: a
|
|
417
|
+
* pipeline started with the request's variables (`GOODFELLOW_TASK_PLUGIN` and
|
|
418
|
+
* the rest), whose job leaves the sealed answer as its artifact, read here.
|
|
419
|
+
* Starting pipelines on the main branch needs Developer access, which
|
|
420
|
+
* editors have once `letEditorsPublish()` lets them publish.
|
|
421
|
+
*/
|
|
422
|
+
function gitlabTasks(api, project, branch, readSetup, options = {}) {
|
|
423
|
+
const base = `/projects/${encodeURIComponent(project)}`;
|
|
424
|
+
const wait = options.wait ?? ((ms) => new Promise((done) => setTimeout(done, ms)));
|
|
425
|
+
const supported = async () => (await readSetup().catch(() => void 0) ?? "").includes("$GOODFELLOW_TASK_PLUGIN");
|
|
426
|
+
return {
|
|
427
|
+
supported,
|
|
428
|
+
secretsPlace: async () => ({
|
|
429
|
+
kind: "gitlab",
|
|
430
|
+
url: `${api.apiUrl.replace(/\/api\/v4\/?$/, "")}/${project}/-/settings/ci_cd#js-cicd-variables-settings`
|
|
431
|
+
}),
|
|
432
|
+
async run(request) {
|
|
433
|
+
const problem = taskRequestProblem(request);
|
|
434
|
+
if (problem) throw new PluginTaskError("failed", problem);
|
|
435
|
+
if (!await supported()) throw new PluginTaskError("missing", "The site's setup files don't run plugins' tasks: .gitlab-ci.yml needs the plugin-task job.");
|
|
436
|
+
const variable = (key, value) => ({
|
|
437
|
+
key,
|
|
438
|
+
variable_type: "env_var",
|
|
439
|
+
value
|
|
440
|
+
});
|
|
441
|
+
let pipeline;
|
|
442
|
+
try {
|
|
443
|
+
pipeline = await gitlabJson(api, `${base}/pipeline`, {
|
|
444
|
+
method: "POST",
|
|
445
|
+
body: {
|
|
446
|
+
ref: branch,
|
|
447
|
+
variables: [
|
|
448
|
+
variable("GOODFELLOW_TASK_REQUEST", request.id),
|
|
449
|
+
variable("GOODFELLOW_TASK_PLUGIN", request.plugin),
|
|
450
|
+
variable("GOODFELLOW_TASK_NAME", request.task),
|
|
451
|
+
variable("GOODFELLOW_TASK_INPUT", request.input),
|
|
452
|
+
variable("GOODFELLOW_TASK_KEY", request.key)
|
|
453
|
+
]
|
|
454
|
+
}
|
|
455
|
+
});
|
|
456
|
+
} catch (error) {
|
|
457
|
+
if (error instanceof GitApiError && (error.status === 403 || error.status === 401)) throw new PluginTaskError("not-allowed", "Running plugins' tasks needs a sign-in that can publish to the site.");
|
|
458
|
+
throw error;
|
|
459
|
+
}
|
|
460
|
+
const deadline = Date.now() + (options.timeoutMs ?? TASK_TIMEOUT_MS);
|
|
461
|
+
for (;;) {
|
|
462
|
+
const job = (await gitlabJson(api, `${base}/pipelines/${pipeline.id}/jobs?per_page=100`)).find((candidate) => candidate.name === TASK_JOB);
|
|
463
|
+
if (job?.status === "success") {
|
|
464
|
+
const answer = await gitlabRequest(api, `${base}/jobs/${job.id}/artifacts/${TASK_RESULT_FILE}`, { allow: [404] });
|
|
465
|
+
if (answer.ok) return await answer.text();
|
|
466
|
+
throw new PluginTaskError("failed", "The site's build service ran the task but left no answer.");
|
|
467
|
+
}
|
|
468
|
+
if (job && [
|
|
469
|
+
"failed",
|
|
470
|
+
"canceled",
|
|
471
|
+
"skipped"
|
|
472
|
+
].includes(job.status)) throw new PluginTaskError("failed", "The site's build service couldn't run the task.");
|
|
473
|
+
if (Date.now() >= deadline) throw new PluginTaskError("timeout", "The site's build service didn't answer in time.");
|
|
474
|
+
await wait(options.pollMs ?? TASK_POLL_MS);
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
};
|
|
478
|
+
}
|
|
479
|
+
//#endregion
|
|
332
480
|
//#region src/updates.ts
|
|
333
481
|
const CI_FILE = ".gitlab-ci.yml";
|
|
334
482
|
/** When the nightly schedule the Updates screen adds runs, in UTC, as the GitHub Pages workflow's does. */
|
|
@@ -348,12 +496,16 @@ function gitlabUpdates(api, project, branch, readAt, jobName = "pages") {
|
|
|
348
496
|
async function schedules() {
|
|
349
497
|
return gitlabJson(api, `${base}/pipeline_schedules`);
|
|
350
498
|
}
|
|
499
|
+
/** The nightly schedule: the branch's, other than those the admin panel adds for scheduled changes. */
|
|
500
|
+
async function nightlySchedule() {
|
|
501
|
+
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 "));
|
|
502
|
+
}
|
|
351
503
|
return {
|
|
352
504
|
async setup() {
|
|
353
505
|
if (!(await readAt(CI_FILE))?.includes("update --publish")) return "missing";
|
|
354
506
|
try {
|
|
355
507
|
const [settings, found] = await Promise.all([gitlabJson(api, base), schedules()]);
|
|
356
|
-
const scheduled = found.some((schedule) =>
|
|
508
|
+
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
509
|
return settings.ci_push_repository_for_job_token_allowed && scheduled ? "ready" : "needs-setup";
|
|
358
510
|
} catch (error) {
|
|
359
511
|
if (error instanceof GitApiError && error.status === 403) return "needs-setup";
|
|
@@ -365,7 +517,7 @@ function gitlabUpdates(api, project, branch, readAt, jobName = "pages") {
|
|
|
365
517
|
method: "PUT",
|
|
366
518
|
body: { ci_push_repository_for_job_token_allowed: true }
|
|
367
519
|
}).catch(notAllowed);
|
|
368
|
-
if (!
|
|
520
|
+
if (!await nightlySchedule().catch(notAllowed)) await gitlabRequest(api, `${base}/pipeline_schedules`, {
|
|
369
521
|
method: "POST",
|
|
370
522
|
body: {
|
|
371
523
|
description: "Rebuild the site and install Goodfellow's fixes each night",
|
|
@@ -397,7 +549,7 @@ function gitlabUpdates(api, project, branch, readAt, jobName = "pages") {
|
|
|
397
549
|
state: "running",
|
|
398
550
|
...run
|
|
399
551
|
};
|
|
400
|
-
const job = (await gitlabJson(api, `${base}/pipelines/${pipeline.id}/jobs?per_page=100`)).find((candidate) => candidate.name === jobName);
|
|
552
|
+
const job = (await gitlabJson(api, `${base}/pipelines/${pipeline.id}/jobs?per_page=100`)).find((candidate) => candidate.name === jobName || candidate.name === "update");
|
|
401
553
|
if (!job) continue;
|
|
402
554
|
const response = await gitlabRequest(api, `${base}/jobs/${job.id}/trace`, { allow: [403, 404] });
|
|
403
555
|
const result = response.ok ? findUpdateResult(await response.text()) : void 0;
|
|
@@ -421,6 +573,61 @@ function gitlabUpdates(api, project, branch, readAt, jobName = "pages") {
|
|
|
421
573
|
}]
|
|
422
574
|
}
|
|
423
575
|
}).catch(notAllowed);
|
|
576
|
+
},
|
|
577
|
+
nightly: {
|
|
578
|
+
async supported() {
|
|
579
|
+
return (await readAt(CI_FILE))?.includes("nightly") ?? false;
|
|
580
|
+
},
|
|
581
|
+
async get() {
|
|
582
|
+
try {
|
|
583
|
+
const found = await nightlySchedule();
|
|
584
|
+
if (!found) return void 0;
|
|
585
|
+
if (!found.active) return "off";
|
|
586
|
+
return (await gitlabJson(api, `${base}/pipeline_schedules/${found.id}`)).variables?.find((variable) => variable.key === NIGHTLY_VARIABLE)?.value === "always" ? "always" : "changed";
|
|
587
|
+
} catch (error) {
|
|
588
|
+
if (error instanceof GitApiError && error.status === 403) return void 0;
|
|
589
|
+
throw error;
|
|
590
|
+
}
|
|
591
|
+
},
|
|
592
|
+
async set(mode) {
|
|
593
|
+
const found = await nightlySchedule().catch(notAllowed);
|
|
594
|
+
if (!found) throw new UpdatesError("missing", "The site has no nightly schedule yet: turn on updates first.");
|
|
595
|
+
const path = `${base}/pipeline_schedules/${found.id}`;
|
|
596
|
+
await gitlabRequest(api, path, {
|
|
597
|
+
method: "PUT",
|
|
598
|
+
body: { active: mode !== "off" }
|
|
599
|
+
}).catch(notAllowed);
|
|
600
|
+
if (mode === "always") {
|
|
601
|
+
const body = {
|
|
602
|
+
key: NIGHTLY_VARIABLE,
|
|
603
|
+
value: "always"
|
|
604
|
+
};
|
|
605
|
+
if ((await gitlabRequest(api, `${path}/variables/${NIGHTLY_VARIABLE}`, {
|
|
606
|
+
method: "PUT",
|
|
607
|
+
body,
|
|
608
|
+
allow: [404]
|
|
609
|
+
}).catch(notAllowed)).status === 404) await gitlabRequest(api, `${path}/variables`, {
|
|
610
|
+
method: "POST",
|
|
611
|
+
body
|
|
612
|
+
}).catch(notAllowed);
|
|
613
|
+
} else if (mode === "changed") await gitlabRequest(api, `${path}/variables/${NIGHTLY_VARIABLE}`, {
|
|
614
|
+
method: "DELETE",
|
|
615
|
+
allow: [404]
|
|
616
|
+
}).catch(notAllowed);
|
|
617
|
+
}
|
|
618
|
+
},
|
|
619
|
+
async rebuild() {
|
|
620
|
+
await gitlabRequest(api, `${base}/pipeline`, {
|
|
621
|
+
method: "POST",
|
|
622
|
+
body: {
|
|
623
|
+
ref: branch,
|
|
624
|
+
variables: [{
|
|
625
|
+
key: "GOODFELLOW_REBUILD",
|
|
626
|
+
variable_type: "env_var",
|
|
627
|
+
value: "true"
|
|
628
|
+
}]
|
|
629
|
+
}
|
|
630
|
+
}).catch(notAllowed);
|
|
424
631
|
}
|
|
425
632
|
};
|
|
426
633
|
}
|
|
@@ -479,6 +686,9 @@ function gitlabCodeUpdates(options) {
|
|
|
479
686
|
},
|
|
480
687
|
async start(target) {
|
|
481
688
|
await (await updates()).start(target);
|
|
689
|
+
},
|
|
690
|
+
async rebuild() {
|
|
691
|
+
await (await updates()).rebuild?.();
|
|
482
692
|
}
|
|
483
693
|
};
|
|
484
694
|
}
|
|
@@ -490,6 +700,19 @@ const GRAPHQL_BATCH = 50;
|
|
|
490
700
|
/** The color and section codes in GitLab's job logs. */
|
|
491
701
|
const ANSI_CODES = new RegExp(`${String.fromCharCode(27)}\\[[\\d;]*[A-Za-z]`, "g");
|
|
492
702
|
/**
|
|
703
|
+
* Whether a status says when the site is live. A job that only asks another
|
|
704
|
+
* host to rebuild the site doesn't, and nor do the nightly update and the
|
|
705
|
+
* job that writes the lockfile where Cloudflare builds the site, which run
|
|
706
|
+
* on the newest commit too.
|
|
707
|
+
*/
|
|
708
|
+
function builds(status) {
|
|
709
|
+
return status.name !== REBUILD_JOB && status.name !== "update" && status.name !== LOCKFILE_JOB;
|
|
710
|
+
}
|
|
711
|
+
function listFrom(tree, dir) {
|
|
712
|
+
const prefix = `${dir}/`;
|
|
713
|
+
return [...tree.keys()].filter((path) => path.startsWith(prefix)).sort();
|
|
714
|
+
}
|
|
715
|
+
/**
|
|
493
716
|
* The site's files on GitLab. Reads come from the commit `revision()` last
|
|
494
717
|
* returned. Saves are single commits; each changed file carries the commit it
|
|
495
718
|
* was last changed in, so GitLab refuses to overwrite anyone else's edits.
|
|
@@ -501,6 +724,8 @@ var GitLabBackend = class GitLabBackend {
|
|
|
501
724
|
/** Absent where the project holds only content: the update job runs where the code is. */
|
|
502
725
|
updates;
|
|
503
726
|
drafts;
|
|
727
|
+
scheduledBuilds;
|
|
728
|
+
tasks;
|
|
504
729
|
contentOnly;
|
|
505
730
|
options;
|
|
506
731
|
api;
|
|
@@ -538,6 +763,12 @@ var GitLabBackend = class GitLabBackend {
|
|
|
538
763
|
});
|
|
539
764
|
}
|
|
540
765
|
this.drafts = branchDrafts(this, this.draftHost());
|
|
766
|
+
this.scheduledBuilds = gitlabScheduledBuilds(this.api, options.project, this.branch, async () => this.readAt(".gitlab-ci.yml", this.pinned ?? await this.revision()));
|
|
767
|
+
this.tasks = gitlabTasks(this.api, options.project, this.branch, async () => this.readAt(".gitlab-ci.yml", this.pinned ?? await this.revision()), options.tasks);
|
|
768
|
+
}
|
|
769
|
+
async repositoryVisibility() {
|
|
770
|
+
const { visibility } = await gitlabJson(this.api, `/projects/${this.project}`);
|
|
771
|
+
return visibility === "public" || visibility === "internal" ? visibility : "private";
|
|
541
772
|
}
|
|
542
773
|
/** Drafts on branches of the project, with merge requests for review. */
|
|
543
774
|
draftHost() {
|
|
@@ -571,6 +802,13 @@ var GitLabBackend = class GitLabBackend {
|
|
|
571
802
|
if (!response.ok) throw new DraftError("not-found", `There's no draft ${branch}.`);
|
|
572
803
|
return (await response.json()).id;
|
|
573
804
|
},
|
|
805
|
+
listBranches: async (prefix) => {
|
|
806
|
+
const query = new URLSearchParams({
|
|
807
|
+
search: `^${prefix}`,
|
|
808
|
+
per_page: "100"
|
|
809
|
+
});
|
|
810
|
+
return (await gitlabJson(this.api, this.repo(`/branches?${query}`))).map((found) => found.name).filter((name) => name.startsWith(prefix));
|
|
811
|
+
},
|
|
574
812
|
openReview: async ({ branch, title, description }) => {
|
|
575
813
|
const response = await gitlabRequest(this.api, `${project}/merge_requests`, {
|
|
576
814
|
method: "POST",
|
|
@@ -603,10 +841,13 @@ var GitLabBackend = class GitLabBackend {
|
|
|
603
841
|
return `/projects/${this.project}/repository${path}`;
|
|
604
842
|
}
|
|
605
843
|
async revision() {
|
|
606
|
-
|
|
607
|
-
this.pinned = branch.commit.id;
|
|
844
|
+
this.pinned = await this.head();
|
|
608
845
|
return this.pinned;
|
|
609
846
|
}
|
|
847
|
+
/** The branch's latest commit, without moving the revision reads are pinned to. */
|
|
848
|
+
async head() {
|
|
849
|
+
return (await gitlabJson(this.api, this.repo(`/branches/${encodeURIComponent(this.branch)}`))).commit.id;
|
|
850
|
+
}
|
|
610
851
|
/** Every file in one folder at a commit, page by page. */
|
|
611
852
|
async listFolder(sha, dir) {
|
|
612
853
|
const files = [];
|
|
@@ -639,8 +880,11 @@ var GitLabBackend = class GitLabBackend {
|
|
|
639
880
|
return this.tree(this.pinned ?? await this.revision());
|
|
640
881
|
}
|
|
641
882
|
async read(path) {
|
|
642
|
-
|
|
643
|
-
|
|
883
|
+
return this.readFrom(await this.currentTree(), path);
|
|
884
|
+
}
|
|
885
|
+
readFrom(tree, path) {
|
|
886
|
+
const sha = tree.get(path);
|
|
887
|
+
if (!sha) return Promise.resolve(void 0);
|
|
644
888
|
let blob = this.blobs.get(sha);
|
|
645
889
|
if (!blob) {
|
|
646
890
|
blob = gitlabRequest(this.api, this.repo(`/blobs/${sha}/raw`)).then((response) => response.text());
|
|
@@ -651,10 +895,12 @@ var GitLabBackend = class GitLabBackend {
|
|
|
651
895
|
}
|
|
652
896
|
/** Reads many text files in a few GraphQL requests rather than one request each. */
|
|
653
897
|
async readFiles(paths) {
|
|
654
|
-
const
|
|
898
|
+
const ref = this.pinned ?? await this.revision();
|
|
899
|
+
return this.readFilesFrom(ref, await this.tree(ref), paths);
|
|
900
|
+
}
|
|
901
|
+
async readFilesFrom(ref, tree, paths) {
|
|
655
902
|
const wanted = paths.filter((path) => tree.has(path));
|
|
656
903
|
const fetch = wanted.filter((path) => !this.blobs.has(tree.get(path) ?? ""));
|
|
657
|
-
const ref = this.pinned ?? await this.revision();
|
|
658
904
|
for (let start = 0; start < fetch.length; start += GRAPHQL_BATCH) {
|
|
659
905
|
const batch = fetch.slice(start, start + GRAPHQL_BATCH);
|
|
660
906
|
const response = await gitlabJson(this.api, "/../graphql", {
|
|
@@ -677,20 +923,30 @@ var GitLabBackend = class GitLabBackend {
|
|
|
677
923
|
}
|
|
678
924
|
const result = /* @__PURE__ */ new Map();
|
|
679
925
|
for (const path of wanted) {
|
|
680
|
-
const text = await this.
|
|
926
|
+
const text = await this.readFrom(tree, path);
|
|
681
927
|
if (text !== void 0) result.set(path, text);
|
|
682
928
|
}
|
|
683
929
|
return result;
|
|
684
930
|
}
|
|
685
931
|
async readBytes(path) {
|
|
686
|
-
|
|
932
|
+
return this.bytesFrom(await this.currentTree(), path);
|
|
933
|
+
}
|
|
934
|
+
async bytesFrom(tree, path) {
|
|
935
|
+
const sha = tree.get(path);
|
|
687
936
|
if (!sha) return void 0;
|
|
688
937
|
const response = await gitlabRequest(this.api, this.repo(`/blobs/${sha}/raw`));
|
|
689
938
|
return new Uint8Array(await response.arrayBuffer());
|
|
690
939
|
}
|
|
691
940
|
async list(dir) {
|
|
692
|
-
|
|
693
|
-
|
|
941
|
+
return listFrom(await this.currentTree(), dir);
|
|
942
|
+
}
|
|
943
|
+
filesAt(revision) {
|
|
944
|
+
return {
|
|
945
|
+
read: async (path) => this.readFrom(await this.tree(revision), path),
|
|
946
|
+
readFiles: async (paths) => this.readFilesFrom(revision, await this.tree(revision), paths),
|
|
947
|
+
readBytes: async (path) => this.bytesFrom(await this.tree(revision), path),
|
|
948
|
+
list: async (dir) => listFrom(await this.tree(revision), dir)
|
|
949
|
+
};
|
|
694
950
|
}
|
|
695
951
|
/** GitLab's tree doesn't say how large files are, so they're asked for with GraphQL, in batches. */
|
|
696
952
|
async fileSizes(dir) {
|
|
@@ -721,12 +977,20 @@ var GitLabBackend = class GitLabBackend {
|
|
|
721
977
|
async lastCommitFor(path, ref) {
|
|
722
978
|
return (await gitlabJson(this.api, this.repo(`/files/${encodeURIComponent(path)}?ref=${ref}`))).last_commit_id;
|
|
723
979
|
}
|
|
980
|
+
/** Whether a file exists at a commit: from the cached tree in the editable folders, and from GitLab elsewhere, such as the config. */
|
|
981
|
+
async existsAt(tree, path, ref) {
|
|
982
|
+
if (EDITABLE_FOLDERS.some((folder) => path.startsWith(`${folder}/`))) return tree.has(path);
|
|
983
|
+
return this.lastCommitFor(path, ref).then(() => true, (error) => {
|
|
984
|
+
if (error instanceof GitApiError && error.status === 404) return false;
|
|
985
|
+
throw error;
|
|
986
|
+
});
|
|
987
|
+
}
|
|
724
988
|
async write(changes, { message, expectedRevision }) {
|
|
725
989
|
if (this.contentOnly) assertContentChanges(changes);
|
|
726
990
|
if (await this.revision() !== expectedRevision) throw new ConflictError();
|
|
727
991
|
const existing = await this.tree(expectedRevision);
|
|
728
992
|
const toApply = (await Promise.all(changes.map(async (change) => {
|
|
729
|
-
const exists =
|
|
993
|
+
const exists = await this.existsAt(existing, change.path, expectedRevision);
|
|
730
994
|
if ("delete" in change) return exists ? {
|
|
731
995
|
action: "delete",
|
|
732
996
|
file_path: change.path,
|
|
@@ -798,15 +1062,24 @@ var GitLabBackend = class GitLabBackend {
|
|
|
798
1062
|
}
|
|
799
1063
|
/**
|
|
800
1064
|
* Reads the commit's pipeline and external statuses (GitLab Pages jobs, or
|
|
801
|
-
* Vercel). Before any exist, the site counts as building
|
|
1065
|
+
* Vercel or Cloudflare Pages). Before any exist, the site counts as building
|
|
1066
|
+
* if it has pipelines, or if the host reported on the commit before.
|
|
802
1067
|
*/
|
|
803
1068
|
async deployStatus(revision) {
|
|
804
1069
|
try {
|
|
805
|
-
const all = await
|
|
806
|
-
const statuses = all.filter(
|
|
1070
|
+
const all = await this.statuses(revision);
|
|
1071
|
+
const statuses = all.filter(builds);
|
|
807
1072
|
if (all.length > 0 && statuses.length === 0) return { state: "unknown" };
|
|
808
|
-
if (statuses.length === 0)
|
|
809
|
-
|
|
1073
|
+
if (statuses.length === 0) {
|
|
1074
|
+
if ((await gitlabJson(this.api, `/projects/${this.project}/pipelines?per_page=1`)).length > 0) return { state: "building" };
|
|
1075
|
+
const { parent_ids: parents = [] } = await gitlabJson(this.api, this.repo(`/commits/${revision}`));
|
|
1076
|
+
return { state: (parents[0] ? (await this.statuses(parents[0])).filter(builds) : []).length > 0 ? "building" : "unknown" };
|
|
1077
|
+
}
|
|
1078
|
+
const failed = statuses.find((status) => status.status === "failed") ?? statuses.find((status) => status.status === "canceled");
|
|
1079
|
+
if (failed?.status === "canceled") {
|
|
1080
|
+
const head = await this.head();
|
|
1081
|
+
if (head !== revision) return this.deployStatus(head);
|
|
1082
|
+
}
|
|
810
1083
|
if (failed) {
|
|
811
1084
|
const problem = await this.jobProblem(revision).catch(() => void 0);
|
|
812
1085
|
return {
|
|
@@ -828,6 +1101,9 @@ var GitLabBackend = class GitLabBackend {
|
|
|
828
1101
|
throw error;
|
|
829
1102
|
}
|
|
830
1103
|
}
|
|
1104
|
+
statuses(revision) {
|
|
1105
|
+
return gitlabJson(this.api, this.repo(`/commits/${revision}/statuses`));
|
|
1106
|
+
}
|
|
831
1107
|
/** Why a commit's pipeline failed: the failed job's reason, and its log for which command failed and why. */
|
|
832
1108
|
async jobProblem(revision) {
|
|
833
1109
|
const [pipeline] = await gitlabJson(this.api, `/projects/${this.project}/pipelines?${new URLSearchParams({
|
|
@@ -1029,6 +1305,29 @@ function gitlabSetup(options = {}) {
|
|
|
1029
1305
|
onStep?.("connect");
|
|
1030
1306
|
if (!await connectProjects(site.link)) warnings.push("connect-unavailable");
|
|
1031
1307
|
}
|
|
1308
|
+
let siteUrl;
|
|
1309
|
+
if (site.pages && site.domain) {
|
|
1310
|
+
onStep?.("pages");
|
|
1311
|
+
try {
|
|
1312
|
+
await gitlabRequest(api, `/projects/${project.id}/pages/domains`, {
|
|
1313
|
+
method: "POST",
|
|
1314
|
+
body: {
|
|
1315
|
+
domain: site.domain,
|
|
1316
|
+
auto_ssl_enabled: true
|
|
1317
|
+
}
|
|
1318
|
+
});
|
|
1319
|
+
siteUrl = `https://${site.domain}/`;
|
|
1320
|
+
} catch (error) {
|
|
1321
|
+
if (!(error instanceof GitApiError) || ![
|
|
1322
|
+
400,
|
|
1323
|
+
403,
|
|
1324
|
+
404,
|
|
1325
|
+
409,
|
|
1326
|
+
422
|
|
1327
|
+
].includes(error.status)) throw error;
|
|
1328
|
+
warnings.push("domain-unavailable");
|
|
1329
|
+
}
|
|
1330
|
+
}
|
|
1032
1331
|
onStep?.("files");
|
|
1033
1332
|
await gitlabRequest(api, `/projects/${project.id}/repository/commits`, {
|
|
1034
1333
|
method: "POST",
|
|
@@ -1050,6 +1349,7 @@ function gitlabSetup(options = {}) {
|
|
|
1050
1349
|
return {
|
|
1051
1350
|
repo: project.path_with_namespace,
|
|
1052
1351
|
webUrl: project.web_url,
|
|
1352
|
+
...siteUrl && { siteUrl },
|
|
1053
1353
|
buildsUrl: `${project.web_url}/-/pipelines`,
|
|
1054
1354
|
pagesUrl: site.pages ? `${project.web_url}/pages` : void 0,
|
|
1055
1355
|
warnings
|
|
@@ -1265,7 +1565,18 @@ function gitlab(options) {
|
|
|
1265
1565
|
}
|
|
1266
1566
|
return {
|
|
1267
1567
|
name: "GitLab",
|
|
1568
|
+
source: hostSource("@goodfellow-cms/gitlab", "gitlab", options, [
|
|
1569
|
+
"fetch",
|
|
1570
|
+
"storage",
|
|
1571
|
+
"location"
|
|
1572
|
+
]),
|
|
1268
1573
|
canRedirect: Boolean(options.clientId),
|
|
1574
|
+
setup: () => gitlabSetup({
|
|
1575
|
+
...options.url && { url: options.url },
|
|
1576
|
+
...options.fetch && { fetch: options.fetch },
|
|
1577
|
+
...options.storage && { storage: options.storage },
|
|
1578
|
+
...options.location && { location: options.location }
|
|
1579
|
+
}),
|
|
1269
1580
|
redirectAddressesListed: Boolean(options.clientId),
|
|
1270
1581
|
tokenLinks: [{
|
|
1271
1582
|
url: `${webUrl}/-/user_settings/personal_access_tokens?${new URLSearchParams({
|
package/dist/testing.d.ts
CHANGED
|
@@ -23,6 +23,11 @@ export interface FakeGitLabOptions {
|
|
|
23
23
|
deployAfterChecks?: number;
|
|
24
24
|
/** The job each published commit's pipeline runs: `pages` by default. */
|
|
25
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;
|
|
26
31
|
/** The job the update runs in, which `addUpdateRun()` logs: `pages` by default, as in the starters, or a code project's `update`. */
|
|
27
32
|
updateJob?: string;
|
|
28
33
|
/** Groups the users belong to, which new projects can be created in. */
|
|
@@ -31,8 +36,30 @@ export interface FakeGitLabOptions {
|
|
|
31
36
|
pages?: {
|
|
32
37
|
uniqueDomain?: boolean;
|
|
33
38
|
};
|
|
39
|
+
/** Domains another GitLab Pages site already uses. */
|
|
40
|
+
takenDomains?: string[];
|
|
34
41
|
/** Other GitLab accounts that exist, which can be added to the project. Every token's user exists and is a member. */
|
|
35
42
|
accounts?: string[];
|
|
43
|
+
/** Who can see the project. Private by default. */
|
|
44
|
+
visibility?: "public" | "internal" | "private";
|
|
45
|
+
/**
|
|
46
|
+
* Answers plugins' tasks, as the site's `plugin-task` job would: given each
|
|
47
|
+
* pipeline's task variables, it gives the text the job leaves as its
|
|
48
|
+
* artifact, or nothing for a job that fails. Without it, task pipelines fail.
|
|
49
|
+
*/
|
|
50
|
+
tasks?: (variables: Record<string, string>) => string | undefined | Promise<string | undefined>;
|
|
51
|
+
}
|
|
52
|
+
/** A pipeline schedule, such as the nightly one or one the admin panel adds for a scheduled change. */
|
|
53
|
+
export interface FakeSchedule {
|
|
54
|
+
id: number;
|
|
55
|
+
ref: string;
|
|
56
|
+
cron: string;
|
|
57
|
+
cronTimezone?: string;
|
|
58
|
+
description?: string;
|
|
59
|
+
active: boolean;
|
|
60
|
+
/** Who added it, who alone (with Maintainers) can change it. */
|
|
61
|
+
owner?: string;
|
|
62
|
+
variables?: Record<string, string>;
|
|
36
63
|
}
|
|
37
64
|
/** A member of the site's project. */
|
|
38
65
|
export interface FakeMember {
|
|
@@ -77,6 +104,8 @@ export interface FakeCreatedProject {
|
|
|
77
104
|
cron: string;
|
|
78
105
|
active: boolean;
|
|
79
106
|
}>;
|
|
107
|
+
/** Domains of its own its Pages were given. */
|
|
108
|
+
pagesDomains: string[];
|
|
80
109
|
}
|
|
81
110
|
export declare function fakeGitLab(options: FakeGitLabOptions): {
|
|
82
111
|
/** How many GraphQL requests read files, as `readFiles()` makes. */
|
|
@@ -94,15 +123,11 @@ export declare function fakeGitLab(options: FakeGitLabOptions): {
|
|
|
94
123
|
failureReason?: string;
|
|
95
124
|
trace?: string;
|
|
96
125
|
}) => void;
|
|
126
|
+
cancelBuild: (sha: string) => void;
|
|
97
127
|
/** The project's update settings, and the pipelines the update ran in, which tests add with `addUpdateRun()`. */
|
|
98
128
|
updateSettings: {
|
|
99
129
|
jobTokenPush: boolean;
|
|
100
|
-
schedules:
|
|
101
|
-
id: number;
|
|
102
|
-
ref: string;
|
|
103
|
-
cron: string;
|
|
104
|
-
active: boolean;
|
|
105
|
-
}>;
|
|
130
|
+
schedules: FakeSchedule[];
|
|
106
131
|
};
|
|
107
132
|
updateRuns: {
|
|
108
133
|
id: number;
|
package/dist/testing.js
CHANGED
|
@@ -158,6 +158,7 @@ function fakeGitLab(options) {
|
|
|
158
158
|
jobTokenPush: false,
|
|
159
159
|
schedules: []
|
|
160
160
|
};
|
|
161
|
+
let scheduleIds = 0;
|
|
161
162
|
/** Pipelines the update ran in: scheduled, or started through the API, with the pages job's log. */
|
|
162
163
|
const updateRuns = [];
|
|
163
164
|
/** Simulates a scheduled (or API) pipeline whose pages job logged `trace`. */
|
|
@@ -172,7 +173,9 @@ function fakeGitLab(options) {
|
|
|
172
173
|
updateRuns.push(added);
|
|
173
174
|
return added;
|
|
174
175
|
}
|
|
175
|
-
/**
|
|
176
|
+
/** Pipelines started for plugins' tasks, with their `plugin-task` job's artifact. */
|
|
177
|
+
const taskRuns = [];
|
|
178
|
+
/** Failed jobs, by commit, which tests add with `failBuild()` and `cancelBuild()`. */
|
|
176
179
|
const failures = /* @__PURE__ */ new Map();
|
|
177
180
|
/**
|
|
178
181
|
* Simulates the pipeline for a commit failing: its `pages` job (or another,
|
|
@@ -182,10 +185,21 @@ function fakeGitLab(options) {
|
|
|
182
185
|
if (!pipelines.has(sha)) pipelines.set(sha, { checks: 0 });
|
|
183
186
|
failures.set(sha, {
|
|
184
187
|
job: failure.job ?? "pages",
|
|
188
|
+
status: "failed",
|
|
185
189
|
failureReason: failure.failureReason ?? "script_failure",
|
|
186
190
|
trace: failure.trace ?? ""
|
|
187
191
|
});
|
|
188
192
|
}
|
|
193
|
+
/** Simulates a commit's `pages` job being cancelled, as a newer pipeline on the branch cancels an interruptible one. */
|
|
194
|
+
function cancelBuild(sha) {
|
|
195
|
+
if (!pipelines.has(sha)) pipelines.set(sha, { checks: 0 });
|
|
196
|
+
failures.set(sha, {
|
|
197
|
+
job: "pages",
|
|
198
|
+
status: "canceled",
|
|
199
|
+
failureReason: "",
|
|
200
|
+
trace: ""
|
|
201
|
+
});
|
|
202
|
+
}
|
|
189
203
|
const pipelineId = (sha) => [...pipelines.keys()].indexOf(sha) + 1;
|
|
190
204
|
const requests = [];
|
|
191
205
|
let counter = 0;
|
|
@@ -302,7 +316,8 @@ function fakeGitLab(options) {
|
|
|
302
316
|
repo: new FakeRepo({}),
|
|
303
317
|
jobTokenAllowlist: [],
|
|
304
318
|
jobTokenPush: false,
|
|
305
|
-
schedules: []
|
|
319
|
+
schedules: [],
|
|
320
|
+
pagesDomains: []
|
|
306
321
|
};
|
|
307
322
|
created.set(fullPath, project);
|
|
308
323
|
return json({
|
|
@@ -339,6 +354,15 @@ function fakeGitLab(options) {
|
|
|
339
354
|
target_project_id: body.target_project_id
|
|
340
355
|
}, 201);
|
|
341
356
|
}
|
|
357
|
+
if (rest === "/pages/domains" && request.method === "POST") {
|
|
358
|
+
const body = await request.json();
|
|
359
|
+
if (options.takenDomains?.includes(body.domain)) return json({ message: { domain: ["has already been taken"] } }, 400);
|
|
360
|
+
project.pagesDomains.push(body.domain);
|
|
361
|
+
return json({
|
|
362
|
+
domain: body.domain,
|
|
363
|
+
url: `https://${body.domain}`
|
|
364
|
+
}, 201);
|
|
365
|
+
}
|
|
342
366
|
if (rest === "/pipeline_schedules" && request.method === "POST") {
|
|
343
367
|
const body = await request.json();
|
|
344
368
|
project.schedules.push({
|
|
@@ -481,12 +505,21 @@ function fakeGitLab(options) {
|
|
|
481
505
|
if (membersResponse) return membersResponse;
|
|
482
506
|
if (rest === "" && request.method !== "PUT") return json({
|
|
483
507
|
default_branch: repo.defaultBranch,
|
|
508
|
+
visibility: options.visibility ?? "private",
|
|
484
509
|
permissions: {
|
|
485
510
|
project_access: { access_level: accessLevel },
|
|
486
511
|
group_access: null
|
|
487
512
|
},
|
|
488
513
|
ci_push_repository_for_job_token_allowed: updateSettings.jobTokenPush
|
|
489
514
|
});
|
|
515
|
+
if (rest === "/repository/branches" && request.method === "GET") {
|
|
516
|
+
const search = url.searchParams.get("search") ?? "";
|
|
517
|
+
const matches = (name) => search.startsWith("^") ? name.startsWith(search.slice(1)) : name.includes(search);
|
|
518
|
+
return json([...repo.branches].filter(([name]) => matches(name)).map(([name, sha]) => ({
|
|
519
|
+
name,
|
|
520
|
+
commit: { id: sha }
|
|
521
|
+
})));
|
|
522
|
+
}
|
|
490
523
|
if (rest === "/repository/branches" && request.method === "POST") {
|
|
491
524
|
if (accessLevel < 30) return json({ message: "403 Forbidden" }, 403);
|
|
492
525
|
const name = url.searchParams.get("branch") ?? "";
|
|
@@ -617,6 +650,15 @@ function fakeGitLab(options) {
|
|
|
617
650
|
committed_date: commit.date
|
|
618
651
|
})));
|
|
619
652
|
}
|
|
653
|
+
const commitSha = rest.match(/^\/repository\/commits\/([0-9a-f]{40})$/)?.[1];
|
|
654
|
+
if (commitSha) {
|
|
655
|
+
const found = repo.commitAt(commitSha);
|
|
656
|
+
if (!found) return json({ message: "404 Commit Not Found" }, 404);
|
|
657
|
+
return json({
|
|
658
|
+
id: commitSha,
|
|
659
|
+
parent_ids: found.parent ? [found.parent] : []
|
|
660
|
+
});
|
|
661
|
+
}
|
|
620
662
|
if (rest === "/repository/compare") return json({ diffs: repo.changedPaths(url.searchParams.get("from") ?? "", url.searchParams.get("to") ?? "").map((changedPath) => ({
|
|
621
663
|
old_path: changedPath,
|
|
622
664
|
new_path: changedPath
|
|
@@ -630,7 +672,7 @@ function fakeGitLab(options) {
|
|
|
630
672
|
const id = pipelineId(statuses[1] ?? "");
|
|
631
673
|
return json([{
|
|
632
674
|
name: failure.job,
|
|
633
|
-
status:
|
|
675
|
+
status: failure.status,
|
|
634
676
|
target_url: `${webUrl}/${options.project}/-/jobs/${id}`
|
|
635
677
|
}]);
|
|
636
678
|
}
|
|
@@ -651,10 +693,25 @@ function fakeGitLab(options) {
|
|
|
651
693
|
})));
|
|
652
694
|
if (rest === "/pipeline" && request.method === "POST") {
|
|
653
695
|
if (accessLevel < 30) return json({ message: "403 Forbidden" }, 403);
|
|
696
|
+
const body = await request.json();
|
|
697
|
+
const variables = Object.fromEntries((body.variables ?? []).map(({ key, value }) => [key, value]));
|
|
698
|
+
if (variables.GOODFELLOW_TASK_PLUGIN) {
|
|
699
|
+
const id = 9e4 + taskRuns.length;
|
|
700
|
+
taskRuns.push({
|
|
701
|
+
id,
|
|
702
|
+
job: id + 5e4,
|
|
703
|
+
variables,
|
|
704
|
+
answer: await options.tasks?.(variables)
|
|
705
|
+
});
|
|
706
|
+
return json({
|
|
707
|
+
id,
|
|
708
|
+
status: "created"
|
|
709
|
+
}, 201);
|
|
710
|
+
}
|
|
654
711
|
const run = addUpdateRun({
|
|
655
712
|
source: "api",
|
|
656
713
|
status: "created",
|
|
657
|
-
variables:
|
|
714
|
+
variables: body.variables
|
|
658
715
|
});
|
|
659
716
|
return json({
|
|
660
717
|
id: run.id,
|
|
@@ -665,23 +722,100 @@ function fakeGitLab(options) {
|
|
|
665
722
|
if (accessLevel < 30) return json({ message: "403 Forbidden" }, 403);
|
|
666
723
|
if (request.method === "POST") {
|
|
667
724
|
const body = await request.json();
|
|
725
|
+
const protection = protectedBranches.get(body.ref);
|
|
726
|
+
if (protection && accessLevel < protection.merge) return json({ message: "403 Forbidden" }, 403);
|
|
668
727
|
const schedule = {
|
|
669
|
-
id:
|
|
728
|
+
id: ++scheduleIds,
|
|
670
729
|
ref: body.ref,
|
|
671
730
|
cron: body.cron,
|
|
672
|
-
active: body.active ?? true
|
|
731
|
+
active: body.active ?? true,
|
|
732
|
+
...body.description !== void 0 && { description: body.description },
|
|
733
|
+
...body.cron_timezone !== void 0 && { cronTimezone: body.cron_timezone },
|
|
734
|
+
owner: user.username
|
|
673
735
|
};
|
|
674
736
|
updateSettings.schedules.push(schedule);
|
|
675
737
|
return json(schedule, 201);
|
|
676
738
|
}
|
|
677
739
|
return json(updateSettings.schedules);
|
|
678
740
|
}
|
|
741
|
+
const scheduleMatch = rest.match(/^\/pipeline_schedules\/(\d+)(\/variables)?(?:\/([\w-]+))?$/);
|
|
742
|
+
if (scheduleMatch) {
|
|
743
|
+
const schedule = updateSettings.schedules.find((candidate) => candidate.id === Number(scheduleMatch[1]));
|
|
744
|
+
if (!schedule) return json({ message: "404 Not found" }, 404);
|
|
745
|
+
const variables = () => Object.entries(schedule.variables ?? {}).map(([key, value]) => ({
|
|
746
|
+
key,
|
|
747
|
+
value,
|
|
748
|
+
variable_type: "env_var"
|
|
749
|
+
}));
|
|
750
|
+
if (request.method === "GET" && !scheduleMatch[2]) {
|
|
751
|
+
if (accessLevel < 30) return json({ message: "403 Forbidden" }, 403);
|
|
752
|
+
return json({
|
|
753
|
+
...schedule,
|
|
754
|
+
variables: variables()
|
|
755
|
+
});
|
|
756
|
+
}
|
|
757
|
+
if (accessLevel < 30 || accessLevel < 40 && schedule.owner !== user.username) return json({ message: "403 Forbidden" }, 403);
|
|
758
|
+
const key = scheduleMatch[3];
|
|
759
|
+
if (scheduleMatch[2] && !key && request.method === "POST") {
|
|
760
|
+
const body = await request.json();
|
|
761
|
+
schedule.variables = {
|
|
762
|
+
...schedule.variables,
|
|
763
|
+
[body.key]: body.value
|
|
764
|
+
};
|
|
765
|
+
return json(body, 201);
|
|
766
|
+
}
|
|
767
|
+
if (scheduleMatch[2] && key) {
|
|
768
|
+
if (!(key in (schedule.variables ?? {}))) return json({ message: "404 Variable Not Found" }, 404);
|
|
769
|
+
if (request.method === "PUT") {
|
|
770
|
+
const body = await request.json();
|
|
771
|
+
schedule.variables = {
|
|
772
|
+
...schedule.variables,
|
|
773
|
+
[key]: body.value
|
|
774
|
+
};
|
|
775
|
+
return json({
|
|
776
|
+
key,
|
|
777
|
+
value: body.value
|
|
778
|
+
});
|
|
779
|
+
}
|
|
780
|
+
if (request.method === "DELETE") {
|
|
781
|
+
const { [key]: _removed, ...rest } = schedule.variables ?? {};
|
|
782
|
+
schedule.variables = rest;
|
|
783
|
+
return json({ key });
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
if (!scheduleMatch[2] && request.method === "PUT") {
|
|
787
|
+
const body = await request.json();
|
|
788
|
+
if (body.active !== void 0) schedule.active = body.active;
|
|
789
|
+
if (body.cron !== void 0) schedule.cron = body.cron;
|
|
790
|
+
if (body.description !== void 0) schedule.description = body.description;
|
|
791
|
+
return json({
|
|
792
|
+
...schedule,
|
|
793
|
+
variables: variables()
|
|
794
|
+
});
|
|
795
|
+
}
|
|
796
|
+
if (!scheduleMatch[2] && request.method === "DELETE") {
|
|
797
|
+
updateSettings.schedules = updateSettings.schedules.filter((candidate) => candidate !== schedule);
|
|
798
|
+
return new Response(null, { status: 204 });
|
|
799
|
+
}
|
|
800
|
+
}
|
|
679
801
|
if (rest === "" && request.method === "PUT") {
|
|
680
802
|
if (accessLevel < 40) return json({ message: "403 Forbidden" }, 403);
|
|
681
803
|
const body = await request.json();
|
|
682
804
|
if (body.ci_push_repository_for_job_token_allowed !== void 0) updateSettings.jobTokenPush = body.ci_push_repository_for_job_token_allowed;
|
|
683
805
|
return json({ ci_push_repository_for_job_token_allowed: updateSettings.jobTokenPush });
|
|
684
806
|
}
|
|
807
|
+
const taskJobs = rest.match(/^\/pipelines\/(\d+)\/jobs$/);
|
|
808
|
+
const taskRun = taskJobs && taskRuns.find((candidate) => candidate.id === Number(taskJobs[1]));
|
|
809
|
+
if (taskRun) return json([{
|
|
810
|
+
id: taskRun.job,
|
|
811
|
+
name: "plugin-task",
|
|
812
|
+
status: taskRun.answer === void 0 ? "failed" : "success"
|
|
813
|
+
}]);
|
|
814
|
+
const artifact = rest.match(/^\/jobs\/(\d+)\/artifacts\/(.+)$/);
|
|
815
|
+
if (artifact) {
|
|
816
|
+
const run = taskRuns.find((candidate) => candidate.job === Number(artifact[1]));
|
|
817
|
+
return run?.answer !== void 0 && artifact[2] === "goodfellow-task-result.json" ? new Response(`${run.answer}\n`) : json({ message: "404 Not Found" }, 404);
|
|
818
|
+
}
|
|
685
819
|
const updateJobs = rest.match(/^\/pipelines\/(\d{4,})\/jobs$/);
|
|
686
820
|
if (updateJobs) {
|
|
687
821
|
const run = updateRuns.find((candidate) => candidate.id === Number(updateJobs[1]));
|
|
@@ -698,7 +832,7 @@ function fakeGitLab(options) {
|
|
|
698
832
|
}
|
|
699
833
|
if (rest === "/pipelines") {
|
|
700
834
|
const sha = url.searchParams.get("sha");
|
|
701
|
-
return json([...pipelines.keys()].map((pipelineSha, index) => ({
|
|
835
|
+
return json([...options.external ? [] : pipelines.keys()].map((pipelineSha, index) => ({
|
|
702
836
|
id: index + 1,
|
|
703
837
|
sha: pipelineSha
|
|
704
838
|
})).filter((pipeline) => !sha || pipeline.sha === sha));
|
|
@@ -712,7 +846,7 @@ function fakeGitLab(options) {
|
|
|
712
846
|
return json([failure ? {
|
|
713
847
|
id,
|
|
714
848
|
name: failure.job,
|
|
715
|
-
status:
|
|
849
|
+
status: failure.status,
|
|
716
850
|
failure_reason: failure.failureReason
|
|
717
851
|
} : {
|
|
718
852
|
id,
|
|
@@ -738,6 +872,7 @@ function fakeGitLab(options) {
|
|
|
738
872
|
invitations,
|
|
739
873
|
protectedBranches,
|
|
740
874
|
failBuild,
|
|
875
|
+
cancelBuild,
|
|
741
876
|
/** The project's update settings, and the pipelines the update ran in, which tests add with `addUpdateRun()`. */
|
|
742
877
|
updateSettings,
|
|
743
878
|
updateRuns,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodfellow-cms/gitlab",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.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.6.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"
|