@goodfellow-cms/gitlab 0.4.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 +14 -2
- package/dist/index.js +271 -29
- package/dist/testing.d.ts +25 -6
- package/dist/testing.js +113 -7
- 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`. */
|
|
@@ -46,6 +46,7 @@ export declare class GitLabBackend implements GitBackend {
|
|
|
46
46
|
/** Absent where the project holds only content: the update job runs where the code is. */
|
|
47
47
|
readonly updates?: SiteUpdates;
|
|
48
48
|
readonly drafts: SiteDrafts;
|
|
49
|
+
readonly scheduledBuilds: ScheduledBuilds;
|
|
49
50
|
readonly contentOnly: boolean;
|
|
50
51
|
private readonly options;
|
|
51
52
|
private readonly api;
|
|
@@ -56,23 +57,32 @@ export declare class GitLabBackend implements GitBackend {
|
|
|
56
57
|
private readonly trees;
|
|
57
58
|
private readonly blobs;
|
|
58
59
|
constructor(options: GitLabBackendOptions);
|
|
60
|
+
repositoryVisibility(): Promise<RepositoryVisibility>;
|
|
59
61
|
/** Drafts on branches of the project, with merge requests for review. */
|
|
60
62
|
private draftHost;
|
|
61
63
|
private repo;
|
|
62
64
|
revision(): Promise<string>;
|
|
65
|
+
/** The branch's latest commit, without moving the revision reads are pinned to. */
|
|
66
|
+
private head;
|
|
63
67
|
/** Every file in one folder at a commit, page by page. */
|
|
64
68
|
private listFolder;
|
|
65
69
|
/** Path → blob id for the editable files at a commit. Commits never change, so this is cached. */
|
|
66
70
|
private tree;
|
|
67
71
|
private currentTree;
|
|
68
72
|
read(path: string): Promise<string | undefined>;
|
|
73
|
+
private readFrom;
|
|
69
74
|
/** Reads many text files in a few GraphQL requests rather than one request each. */
|
|
70
75
|
readFiles(paths: string[]): Promise<Map<string, string>>;
|
|
76
|
+
private readFilesFrom;
|
|
71
77
|
readBytes(path: string): Promise<Uint8Array | undefined>;
|
|
78
|
+
private bytesFrom;
|
|
72
79
|
list(dir: string): Promise<string[]>;
|
|
80
|
+
filesAt(revision: string): RevisionFiles;
|
|
73
81
|
/** GitLab's tree doesn't say how large files are, so they're asked for with GraphQL, in batches. */
|
|
74
82
|
fileSizes(dir: string): Promise<Map<string, number>>;
|
|
75
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;
|
|
76
86
|
write(changes: FileChange[], { message, expectedRevision }: WriteOptions): Promise<{
|
|
77
87
|
revision: string;
|
|
78
88
|
}>;
|
|
@@ -81,9 +91,11 @@ export declare class GitLabBackend implements GitBackend {
|
|
|
81
91
|
changedPaths(from: string, to: string): Promise<string[]>;
|
|
82
92
|
/**
|
|
83
93
|
* Reads the commit's pipeline and external statuses (GitLab Pages jobs, or
|
|
84
|
-
* 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.
|
|
85
96
|
*/
|
|
86
97
|
deployStatus(revision: string): Promise<DeployStatus>;
|
|
98
|
+
private statuses;
|
|
87
99
|
/** Why a commit's pipeline failed: the failed job's reason, and its log for which command failed and why. */
|
|
88
100
|
private jobProblem;
|
|
89
101
|
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, 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. */
|
|
@@ -348,12 +430,16 @@ function gitlabUpdates(api, project, branch, readAt, jobName = "pages") {
|
|
|
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, jobName = "pages") {
|
|
|
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, jobName = "pages") {
|
|
|
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 === jobName);
|
|
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,61 @@ function gitlabUpdates(api, project, branch, readAt, jobName = "pages") {
|
|
|
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);
|
|
424
565
|
}
|
|
425
566
|
};
|
|
426
567
|
}
|
|
@@ -479,6 +620,9 @@ function gitlabCodeUpdates(options) {
|
|
|
479
620
|
},
|
|
480
621
|
async start(target) {
|
|
481
622
|
await (await updates()).start(target);
|
|
623
|
+
},
|
|
624
|
+
async rebuild() {
|
|
625
|
+
await (await updates()).rebuild?.();
|
|
482
626
|
}
|
|
483
627
|
};
|
|
484
628
|
}
|
|
@@ -490,6 +634,18 @@ const GRAPHQL_BATCH = 50;
|
|
|
490
634
|
/** The color and section codes in GitLab's job logs. */
|
|
491
635
|
const ANSI_CODES = new RegExp(`${String.fromCharCode(27)}\\[[\\d;]*[A-Za-z]`, "g");
|
|
492
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
|
+
/**
|
|
493
649
|
* The site's files on GitLab. Reads come from the commit `revision()` last
|
|
494
650
|
* returned. Saves are single commits; each changed file carries the commit it
|
|
495
651
|
* was last changed in, so GitLab refuses to overwrite anyone else's edits.
|
|
@@ -501,6 +657,7 @@ var GitLabBackend = class GitLabBackend {
|
|
|
501
657
|
/** Absent where the project holds only content: the update job runs where the code is. */
|
|
502
658
|
updates;
|
|
503
659
|
drafts;
|
|
660
|
+
scheduledBuilds;
|
|
504
661
|
contentOnly;
|
|
505
662
|
options;
|
|
506
663
|
api;
|
|
@@ -538,6 +695,11 @@ var GitLabBackend = class GitLabBackend {
|
|
|
538
695
|
});
|
|
539
696
|
}
|
|
540
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";
|
|
541
703
|
}
|
|
542
704
|
/** Drafts on branches of the project, with merge requests for review. */
|
|
543
705
|
draftHost() {
|
|
@@ -571,6 +733,13 @@ var GitLabBackend = class GitLabBackend {
|
|
|
571
733
|
if (!response.ok) throw new DraftError("not-found", `There's no draft ${branch}.`);
|
|
572
734
|
return (await response.json()).id;
|
|
573
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
|
+
},
|
|
574
743
|
openReview: async ({ branch, title, description }) => {
|
|
575
744
|
const response = await gitlabRequest(this.api, `${project}/merge_requests`, {
|
|
576
745
|
method: "POST",
|
|
@@ -603,10 +772,13 @@ var GitLabBackend = class GitLabBackend {
|
|
|
603
772
|
return `/projects/${this.project}/repository${path}`;
|
|
604
773
|
}
|
|
605
774
|
async revision() {
|
|
606
|
-
|
|
607
|
-
this.pinned = branch.commit.id;
|
|
775
|
+
this.pinned = await this.head();
|
|
608
776
|
return this.pinned;
|
|
609
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
|
+
}
|
|
610
782
|
/** Every file in one folder at a commit, page by page. */
|
|
611
783
|
async listFolder(sha, dir) {
|
|
612
784
|
const files = [];
|
|
@@ -639,8 +811,11 @@ var GitLabBackend = class GitLabBackend {
|
|
|
639
811
|
return this.tree(this.pinned ?? await this.revision());
|
|
640
812
|
}
|
|
641
813
|
async read(path) {
|
|
642
|
-
|
|
643
|
-
|
|
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);
|
|
644
819
|
let blob = this.blobs.get(sha);
|
|
645
820
|
if (!blob) {
|
|
646
821
|
blob = gitlabRequest(this.api, this.repo(`/blobs/${sha}/raw`)).then((response) => response.text());
|
|
@@ -651,10 +826,12 @@ var GitLabBackend = class GitLabBackend {
|
|
|
651
826
|
}
|
|
652
827
|
/** Reads many text files in a few GraphQL requests rather than one request each. */
|
|
653
828
|
async readFiles(paths) {
|
|
654
|
-
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) {
|
|
655
833
|
const wanted = paths.filter((path) => tree.has(path));
|
|
656
834
|
const fetch = wanted.filter((path) => !this.blobs.has(tree.get(path) ?? ""));
|
|
657
|
-
const ref = this.pinned ?? await this.revision();
|
|
658
835
|
for (let start = 0; start < fetch.length; start += GRAPHQL_BATCH) {
|
|
659
836
|
const batch = fetch.slice(start, start + GRAPHQL_BATCH);
|
|
660
837
|
const response = await gitlabJson(this.api, "/../graphql", {
|
|
@@ -677,20 +854,30 @@ var GitLabBackend = class GitLabBackend {
|
|
|
677
854
|
}
|
|
678
855
|
const result = /* @__PURE__ */ new Map();
|
|
679
856
|
for (const path of wanted) {
|
|
680
|
-
const text = await this.
|
|
857
|
+
const text = await this.readFrom(tree, path);
|
|
681
858
|
if (text !== void 0) result.set(path, text);
|
|
682
859
|
}
|
|
683
860
|
return result;
|
|
684
861
|
}
|
|
685
862
|
async readBytes(path) {
|
|
686
|
-
|
|
863
|
+
return this.bytesFrom(await this.currentTree(), path);
|
|
864
|
+
}
|
|
865
|
+
async bytesFrom(tree, path) {
|
|
866
|
+
const sha = tree.get(path);
|
|
687
867
|
if (!sha) return void 0;
|
|
688
868
|
const response = await gitlabRequest(this.api, this.repo(`/blobs/${sha}/raw`));
|
|
689
869
|
return new Uint8Array(await response.arrayBuffer());
|
|
690
870
|
}
|
|
691
871
|
async list(dir) {
|
|
692
|
-
|
|
693
|
-
|
|
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
|
+
};
|
|
694
881
|
}
|
|
695
882
|
/** GitLab's tree doesn't say how large files are, so they're asked for with GraphQL, in batches. */
|
|
696
883
|
async fileSizes(dir) {
|
|
@@ -721,12 +908,20 @@ var GitLabBackend = class GitLabBackend {
|
|
|
721
908
|
async lastCommitFor(path, ref) {
|
|
722
909
|
return (await gitlabJson(this.api, this.repo(`/files/${encodeURIComponent(path)}?ref=${ref}`))).last_commit_id;
|
|
723
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
|
+
}
|
|
724
919
|
async write(changes, { message, expectedRevision }) {
|
|
725
920
|
if (this.contentOnly) assertContentChanges(changes);
|
|
726
921
|
if (await this.revision() !== expectedRevision) throw new ConflictError();
|
|
727
922
|
const existing = await this.tree(expectedRevision);
|
|
728
923
|
const toApply = (await Promise.all(changes.map(async (change) => {
|
|
729
|
-
const exists =
|
|
924
|
+
const exists = await this.existsAt(existing, change.path, expectedRevision);
|
|
730
925
|
if ("delete" in change) return exists ? {
|
|
731
926
|
action: "delete",
|
|
732
927
|
file_path: change.path,
|
|
@@ -798,15 +993,24 @@ var GitLabBackend = class GitLabBackend {
|
|
|
798
993
|
}
|
|
799
994
|
/**
|
|
800
995
|
* Reads the commit's pipeline and external statuses (GitLab Pages jobs, or
|
|
801
|
-
* 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.
|
|
802
998
|
*/
|
|
803
999
|
async deployStatus(revision) {
|
|
804
1000
|
try {
|
|
805
|
-
const all = await
|
|
806
|
-
const statuses = all.filter(
|
|
1001
|
+
const all = await this.statuses(revision);
|
|
1002
|
+
const statuses = all.filter(builds);
|
|
807
1003
|
if (all.length > 0 && statuses.length === 0) return { state: "unknown" };
|
|
808
|
-
if (statuses.length === 0)
|
|
809
|
-
|
|
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
|
+
}
|
|
810
1014
|
if (failed) {
|
|
811
1015
|
const problem = await this.jobProblem(revision).catch(() => void 0);
|
|
812
1016
|
return {
|
|
@@ -828,6 +1032,9 @@ var GitLabBackend = class GitLabBackend {
|
|
|
828
1032
|
throw error;
|
|
829
1033
|
}
|
|
830
1034
|
}
|
|
1035
|
+
statuses(revision) {
|
|
1036
|
+
return gitlabJson(this.api, this.repo(`/commits/${revision}/statuses`));
|
|
1037
|
+
}
|
|
831
1038
|
/** Why a commit's pipeline failed: the failed job's reason, and its log for which command failed and why. */
|
|
832
1039
|
async jobProblem(revision) {
|
|
833
1040
|
const [pipeline] = await gitlabJson(this.api, `/projects/${this.project}/pipelines?${new URLSearchParams({
|
|
@@ -1029,6 +1236,29 @@ function gitlabSetup(options = {}) {
|
|
|
1029
1236
|
onStep?.("connect");
|
|
1030
1237
|
if (!await connectProjects(site.link)) warnings.push("connect-unavailable");
|
|
1031
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
|
+
}
|
|
1032
1262
|
onStep?.("files");
|
|
1033
1263
|
await gitlabRequest(api, `/projects/${project.id}/repository/commits`, {
|
|
1034
1264
|
method: "POST",
|
|
@@ -1050,6 +1280,7 @@ function gitlabSetup(options = {}) {
|
|
|
1050
1280
|
return {
|
|
1051
1281
|
repo: project.path_with_namespace,
|
|
1052
1282
|
webUrl: project.web_url,
|
|
1283
|
+
...siteUrl && { siteUrl },
|
|
1053
1284
|
buildsUrl: `${project.web_url}/-/pipelines`,
|
|
1054
1285
|
pagesUrl: site.pages ? `${project.web_url}/pages` : void 0,
|
|
1055
1286
|
warnings
|
|
@@ -1265,7 +1496,18 @@ function gitlab(options) {
|
|
|
1265
1496
|
}
|
|
1266
1497
|
return {
|
|
1267
1498
|
name: "GitLab",
|
|
1499
|
+
source: hostSource("@goodfellow-cms/gitlab", "gitlab", options, [
|
|
1500
|
+
"fetch",
|
|
1501
|
+
"storage",
|
|
1502
|
+
"location"
|
|
1503
|
+
]),
|
|
1268
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
|
+
}),
|
|
1269
1511
|
redirectAddressesListed: Boolean(options.clientId),
|
|
1270
1512
|
tokenLinks: [{
|
|
1271
1513
|
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,24 @@ 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
|
+
/** 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>;
|
|
36
57
|
}
|
|
37
58
|
/** A member of the site's project. */
|
|
38
59
|
export interface FakeMember {
|
|
@@ -77,6 +98,8 @@ export interface FakeCreatedProject {
|
|
|
77
98
|
cron: string;
|
|
78
99
|
active: boolean;
|
|
79
100
|
}>;
|
|
101
|
+
/** Domains of its own its Pages were given. */
|
|
102
|
+
pagesDomains: string[];
|
|
80
103
|
}
|
|
81
104
|
export declare function fakeGitLab(options: FakeGitLabOptions): {
|
|
82
105
|
/** How many GraphQL requests read files, as `readFiles()` makes. */
|
|
@@ -94,15 +117,11 @@ export declare function fakeGitLab(options: FakeGitLabOptions): {
|
|
|
94
117
|
failureReason?: string;
|
|
95
118
|
trace?: string;
|
|
96
119
|
}) => void;
|
|
120
|
+
cancelBuild: (sha: string) => void;
|
|
97
121
|
/** The project's update settings, and the pipelines the update ran in, which tests add with `addUpdateRun()`. */
|
|
98
122
|
updateSettings: {
|
|
99
123
|
jobTokenPush: boolean;
|
|
100
|
-
schedules:
|
|
101
|
-
id: number;
|
|
102
|
-
ref: string;
|
|
103
|
-
cron: string;
|
|
104
|
-
active: boolean;
|
|
105
|
-
}>;
|
|
124
|
+
schedules: FakeSchedule[];
|
|
106
125
|
};
|
|
107
126
|
updateRuns: {
|
|
108
127
|
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,7 @@ function fakeGitLab(options) {
|
|
|
172
173
|
updateRuns.push(added);
|
|
173
174
|
return added;
|
|
174
175
|
}
|
|
175
|
-
/** Failed jobs, by commit, which tests add with `failBuild()`. */
|
|
176
|
+
/** Failed jobs, by commit, which tests add with `failBuild()` and `cancelBuild()`. */
|
|
176
177
|
const failures = /* @__PURE__ */ new Map();
|
|
177
178
|
/**
|
|
178
179
|
* Simulates the pipeline for a commit failing: its `pages` job (or another,
|
|
@@ -182,10 +183,21 @@ function fakeGitLab(options) {
|
|
|
182
183
|
if (!pipelines.has(sha)) pipelines.set(sha, { checks: 0 });
|
|
183
184
|
failures.set(sha, {
|
|
184
185
|
job: failure.job ?? "pages",
|
|
186
|
+
status: "failed",
|
|
185
187
|
failureReason: failure.failureReason ?? "script_failure",
|
|
186
188
|
trace: failure.trace ?? ""
|
|
187
189
|
});
|
|
188
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
|
+
}
|
|
189
201
|
const pipelineId = (sha) => [...pipelines.keys()].indexOf(sha) + 1;
|
|
190
202
|
const requests = [];
|
|
191
203
|
let counter = 0;
|
|
@@ -302,7 +314,8 @@ function fakeGitLab(options) {
|
|
|
302
314
|
repo: new FakeRepo({}),
|
|
303
315
|
jobTokenAllowlist: [],
|
|
304
316
|
jobTokenPush: false,
|
|
305
|
-
schedules: []
|
|
317
|
+
schedules: [],
|
|
318
|
+
pagesDomains: []
|
|
306
319
|
};
|
|
307
320
|
created.set(fullPath, project);
|
|
308
321
|
return json({
|
|
@@ -339,6 +352,15 @@ function fakeGitLab(options) {
|
|
|
339
352
|
target_project_id: body.target_project_id
|
|
340
353
|
}, 201);
|
|
341
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
|
+
}
|
|
342
364
|
if (rest === "/pipeline_schedules" && request.method === "POST") {
|
|
343
365
|
const body = await request.json();
|
|
344
366
|
project.schedules.push({
|
|
@@ -481,12 +503,21 @@ function fakeGitLab(options) {
|
|
|
481
503
|
if (membersResponse) return membersResponse;
|
|
482
504
|
if (rest === "" && request.method !== "PUT") return json({
|
|
483
505
|
default_branch: repo.defaultBranch,
|
|
506
|
+
visibility: options.visibility ?? "private",
|
|
484
507
|
permissions: {
|
|
485
508
|
project_access: { access_level: accessLevel },
|
|
486
509
|
group_access: null
|
|
487
510
|
},
|
|
488
511
|
ci_push_repository_for_job_token_allowed: updateSettings.jobTokenPush
|
|
489
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
|
+
}
|
|
490
521
|
if (rest === "/repository/branches" && request.method === "POST") {
|
|
491
522
|
if (accessLevel < 30) return json({ message: "403 Forbidden" }, 403);
|
|
492
523
|
const name = url.searchParams.get("branch") ?? "";
|
|
@@ -617,6 +648,15 @@ function fakeGitLab(options) {
|
|
|
617
648
|
committed_date: commit.date
|
|
618
649
|
})));
|
|
619
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
|
+
}
|
|
620
660
|
if (rest === "/repository/compare") return json({ diffs: repo.changedPaths(url.searchParams.get("from") ?? "", url.searchParams.get("to") ?? "").map((changedPath) => ({
|
|
621
661
|
old_path: changedPath,
|
|
622
662
|
new_path: changedPath
|
|
@@ -630,7 +670,7 @@ function fakeGitLab(options) {
|
|
|
630
670
|
const id = pipelineId(statuses[1] ?? "");
|
|
631
671
|
return json([{
|
|
632
672
|
name: failure.job,
|
|
633
|
-
status:
|
|
673
|
+
status: failure.status,
|
|
634
674
|
target_url: `${webUrl}/${options.project}/-/jobs/${id}`
|
|
635
675
|
}]);
|
|
636
676
|
}
|
|
@@ -665,17 +705,82 @@ function fakeGitLab(options) {
|
|
|
665
705
|
if (accessLevel < 30) return json({ message: "403 Forbidden" }, 403);
|
|
666
706
|
if (request.method === "POST") {
|
|
667
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);
|
|
668
710
|
const schedule = {
|
|
669
|
-
id:
|
|
711
|
+
id: ++scheduleIds,
|
|
670
712
|
ref: body.ref,
|
|
671
713
|
cron: body.cron,
|
|
672
|
-
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
|
|
673
718
|
};
|
|
674
719
|
updateSettings.schedules.push(schedule);
|
|
675
720
|
return json(schedule, 201);
|
|
676
721
|
}
|
|
677
722
|
return json(updateSettings.schedules);
|
|
678
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
|
+
}
|
|
679
784
|
if (rest === "" && request.method === "PUT") {
|
|
680
785
|
if (accessLevel < 40) return json({ message: "403 Forbidden" }, 403);
|
|
681
786
|
const body = await request.json();
|
|
@@ -698,7 +803,7 @@ function fakeGitLab(options) {
|
|
|
698
803
|
}
|
|
699
804
|
if (rest === "/pipelines") {
|
|
700
805
|
const sha = url.searchParams.get("sha");
|
|
701
|
-
return json([...pipelines.keys()].map((pipelineSha, index) => ({
|
|
806
|
+
return json([...options.external ? [] : pipelines.keys()].map((pipelineSha, index) => ({
|
|
702
807
|
id: index + 1,
|
|
703
808
|
sha: pipelineSha
|
|
704
809
|
})).filter((pipeline) => !sha || pipeline.sha === sha));
|
|
@@ -712,7 +817,7 @@ function fakeGitLab(options) {
|
|
|
712
817
|
return json([failure ? {
|
|
713
818
|
id,
|
|
714
819
|
name: failure.job,
|
|
715
|
-
status:
|
|
820
|
+
status: failure.status,
|
|
716
821
|
failure_reason: failure.failureReason
|
|
717
822
|
} : {
|
|
718
823
|
id,
|
|
@@ -738,6 +843,7 @@ function fakeGitLab(options) {
|
|
|
738
843
|
invitations,
|
|
739
844
|
protectedBranches,
|
|
740
845
|
failBuild,
|
|
846
|
+
cancelBuild,
|
|
741
847
|
/** The project's update settings, and the pipelines the update ran in, which tests add with `addUpdateRun()`. */
|
|
742
848
|
updateSettings,
|
|
743
849
|
updateRuns,
|
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"
|