@goodfellow-cms/gitlab 0.5.0 → 0.7.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 +23 -1
- package/dist/index.js +164 -16
- package/dist/testing.d.ts +8 -0
- package/dist/testing.js +52 -2
- package/package.json +2 -2
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, RepositoryVisibility, RevisionFiles, ScheduledBuilds, 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. */
|
|
@@ -33,6 +44,13 @@ interface GitLabBackendOptions {
|
|
|
33
44
|
project: string;
|
|
34
45
|
webUrl: string;
|
|
35
46
|
};
|
|
47
|
+
/** What's read at each commit, shared with the backends of the project's other branches (drafts), since commits never change. */
|
|
48
|
+
caches?: GitLabCaches;
|
|
49
|
+
}
|
|
50
|
+
/** Path → blob id for the editable files at each commit, and each blob's text, by id. */
|
|
51
|
+
interface GitLabCaches {
|
|
52
|
+
trees: Map<string, Promise<Map<string, string>>>;
|
|
53
|
+
blobs: Map<string, Promise<string>>;
|
|
36
54
|
}
|
|
37
55
|
/**
|
|
38
56
|
* The site's files on GitLab. Reads come from the commit `revision()` last
|
|
@@ -47,6 +65,7 @@ export declare class GitLabBackend implements GitBackend {
|
|
|
47
65
|
readonly updates?: SiteUpdates;
|
|
48
66
|
readonly drafts: SiteDrafts;
|
|
49
67
|
readonly scheduledBuilds: ScheduledBuilds;
|
|
68
|
+
readonly tasks: PluginTasks;
|
|
50
69
|
readonly contentOnly: boolean;
|
|
51
70
|
private readonly options;
|
|
52
71
|
private readonly api;
|
|
@@ -54,6 +73,7 @@ export declare class GitLabBackend implements GitBackend {
|
|
|
54
73
|
private readonly branch;
|
|
55
74
|
private readonly onSignOut;
|
|
56
75
|
private pinned?;
|
|
76
|
+
private readonly caches;
|
|
57
77
|
private readonly trees;
|
|
58
78
|
private readonly blobs;
|
|
59
79
|
constructor(options: GitLabBackendOptions);
|
|
@@ -86,6 +106,8 @@ export declare class GitLabBackend implements GitBackend {
|
|
|
86
106
|
write(changes: FileChange[], { message, expectedRevision }: WriteOptions): Promise<{
|
|
87
107
|
revision: string;
|
|
88
108
|
}>;
|
|
109
|
+
/** Lists `revision`'s editable files as `before`'s with `changes` made, keeping the text written. */
|
|
110
|
+
private carryTree;
|
|
89
111
|
history(path: string, { page, perPage }?: HistoryOptions): Promise<FileVersion[]>;
|
|
90
112
|
readAt(path: string, revision: string): Promise<string | undefined>;
|
|
91
113
|
changedPaths(from: string, to: string): Promise<string[]>;
|
package/dist/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
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";
|
|
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, withRateLimits } 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 = {}) {
|
|
@@ -411,6 +411,72 @@ function gitlabScheduledBuilds(api, project, branch, readCi, now = () => /* @__P
|
|
|
411
411
|
};
|
|
412
412
|
}
|
|
413
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
|
|
414
480
|
//#region src/updates.ts
|
|
415
481
|
const CI_FILE = ".gitlab-ci.yml";
|
|
416
482
|
/** When the nightly schedule the Updates screen adds runs, in UTC, as the GitHub Pages workflow's does. */
|
|
@@ -633,13 +699,22 @@ const PAGE_SIZE = 100;
|
|
|
633
699
|
const GRAPHQL_BATCH = 50;
|
|
634
700
|
/** The color and section codes in GitLab's job logs. */
|
|
635
701
|
const ANSI_CODES = new RegExp(`${String.fromCharCode(27)}\\[[\\d;]*[A-Za-z]`, "g");
|
|
702
|
+
/** Git's id for a file's contents (the SHA-1 of `blob <size>\0` and them), as GitLab's tree lists it. */
|
|
703
|
+
async function gitBlobId(bytes) {
|
|
704
|
+
const header = new TextEncoder().encode(`blob ${bytes.length}\0`);
|
|
705
|
+
const data = new Uint8Array(header.length + bytes.length);
|
|
706
|
+
data.set(header);
|
|
707
|
+
data.set(bytes, header.length);
|
|
708
|
+
return [...new Uint8Array(await crypto.subtle.digest("SHA-1", data))].map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
709
|
+
}
|
|
636
710
|
/**
|
|
637
711
|
* 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
|
|
639
|
-
*
|
|
712
|
+
* host to rebuild the site doesn't, and nor do the nightly update and the
|
|
713
|
+
* job that writes the lockfile where Cloudflare builds the site, which run
|
|
714
|
+
* on the newest commit too.
|
|
640
715
|
*/
|
|
641
716
|
function builds(status) {
|
|
642
|
-
return status.name !== REBUILD_JOB && status.name !== "update";
|
|
717
|
+
return status.name !== REBUILD_JOB && status.name !== "update" && status.name !== LOCKFILE_JOB;
|
|
643
718
|
}
|
|
644
719
|
function listFrom(tree, dir) {
|
|
645
720
|
const prefix = `${dir}/`;
|
|
@@ -658,6 +733,7 @@ var GitLabBackend = class GitLabBackend {
|
|
|
658
733
|
updates;
|
|
659
734
|
drafts;
|
|
660
735
|
scheduledBuilds;
|
|
736
|
+
tasks;
|
|
661
737
|
contentOnly;
|
|
662
738
|
options;
|
|
663
739
|
api;
|
|
@@ -665,8 +741,9 @@ var GitLabBackend = class GitLabBackend {
|
|
|
665
741
|
branch;
|
|
666
742
|
onSignOut;
|
|
667
743
|
pinned;
|
|
668
|
-
|
|
669
|
-
|
|
744
|
+
caches;
|
|
745
|
+
trees;
|
|
746
|
+
blobs;
|
|
670
747
|
constructor(options) {
|
|
671
748
|
this.options = options;
|
|
672
749
|
this.api = options.api;
|
|
@@ -675,6 +752,12 @@ var GitLabBackend = class GitLabBackend {
|
|
|
675
752
|
this.user = options.user;
|
|
676
753
|
this.onSignOut = options.onSignOut;
|
|
677
754
|
this.pinned = options.revision;
|
|
755
|
+
this.caches = options.caches ?? {
|
|
756
|
+
trees: /* @__PURE__ */ new Map(),
|
|
757
|
+
blobs: /* @__PURE__ */ new Map()
|
|
758
|
+
};
|
|
759
|
+
this.trees = this.caches.trees;
|
|
760
|
+
this.blobs = this.caches.blobs;
|
|
678
761
|
this.pages = gitlabPages(this.api, options.project);
|
|
679
762
|
this.editors = gitlabEditors(this.api, options.project, this.branch, this.user.login);
|
|
680
763
|
this.contentOnly = options.contentOnly === true;
|
|
@@ -696,6 +779,7 @@ var GitLabBackend = class GitLabBackend {
|
|
|
696
779
|
}
|
|
697
780
|
this.drafts = branchDrafts(this, this.draftHost());
|
|
698
781
|
this.scheduledBuilds = gitlabScheduledBuilds(this.api, options.project, this.branch, async () => this.readAt(".gitlab-ci.yml", this.pinned ?? await this.revision()));
|
|
782
|
+
this.tasks = gitlabTasks(this.api, options.project, this.branch, async () => this.readAt(".gitlab-ci.yml", this.pinned ?? await this.revision()), options.tasks);
|
|
699
783
|
}
|
|
700
784
|
async repositoryVisibility() {
|
|
701
785
|
const { visibility } = await gitlabJson(this.api, `/projects/${this.project}`);
|
|
@@ -704,11 +788,40 @@ var GitLabBackend = class GitLabBackend {
|
|
|
704
788
|
/** Drafts on branches of the project, with merge requests for review. */
|
|
705
789
|
draftHost() {
|
|
706
790
|
const project = `/projects/${this.project}`;
|
|
791
|
+
const openRequests = (branch) => gitlabJson(this.api, `${project}/merge_requests?${new URLSearchParams({
|
|
792
|
+
source_branch: branch,
|
|
793
|
+
state: "opened"
|
|
794
|
+
})}`);
|
|
795
|
+
/**
|
|
796
|
+
* Asks people to review a merge request, by their usernames. GitLab's free
|
|
797
|
+
* plan takes one reviewer, so where it refuses them all, the first is asked.
|
|
798
|
+
*/
|
|
799
|
+
const askReviewers = async (iid, usernames) => {
|
|
800
|
+
const ids = [];
|
|
801
|
+
for (const username of usernames) {
|
|
802
|
+
const [found] = await gitlabJson(this.api, `/users?${new URLSearchParams({ username })}`).catch(() => []);
|
|
803
|
+
if (found) ids.push(found.id);
|
|
804
|
+
}
|
|
805
|
+
for (const reviewerIds of ids.length > 1 ? [ids, ids.slice(0, 1)] : [ids]) {
|
|
806
|
+
if (reviewerIds.length === 0) return;
|
|
807
|
+
if ((await gitlabRequest(this.api, `${project}/merge_requests/${iid}`, {
|
|
808
|
+
method: "PUT",
|
|
809
|
+
body: { reviewer_ids: reviewerIds },
|
|
810
|
+
allow: [
|
|
811
|
+
400,
|
|
812
|
+
403,
|
|
813
|
+
404,
|
|
814
|
+
422
|
|
815
|
+
]
|
|
816
|
+
})).ok) return;
|
|
817
|
+
}
|
|
818
|
+
};
|
|
707
819
|
return {
|
|
708
820
|
liveBranch: this.branch,
|
|
709
821
|
branchStore: (branch) => new GitLabBackend({
|
|
710
822
|
...this.options,
|
|
711
|
-
branch
|
|
823
|
+
branch,
|
|
824
|
+
caches: this.caches
|
|
712
825
|
}),
|
|
713
826
|
createBranch: async (branch, revision) => {
|
|
714
827
|
if ((await gitlabRequest(this.api, this.repo(`/branches?${new URLSearchParams({
|
|
@@ -740,7 +853,7 @@ var GitLabBackend = class GitLabBackend {
|
|
|
740
853
|
});
|
|
741
854
|
return (await gitlabJson(this.api, this.repo(`/branches?${query}`))).map((found) => found.name).filter((name) => name.startsWith(prefix));
|
|
742
855
|
},
|
|
743
|
-
openReview: async ({ branch, title, description }) => {
|
|
856
|
+
openReview: async ({ branch, title, description, reviewers = [] }) => {
|
|
744
857
|
const response = await gitlabRequest(this.api, `${project}/merge_requests`, {
|
|
745
858
|
method: "POST",
|
|
746
859
|
body: {
|
|
@@ -756,15 +869,33 @@ var GitLabBackend = class GitLabBackend {
|
|
|
756
869
|
409
|
|
757
870
|
]
|
|
758
871
|
});
|
|
759
|
-
|
|
760
|
-
if (response.
|
|
872
|
+
let review;
|
|
873
|
+
if (response.ok) review = await response.json();
|
|
874
|
+
else if (response.status !== 409) throw new DraftError("no-reviews", "This sign-in can't open merge requests on the site's project.");
|
|
875
|
+
else {
|
|
876
|
+
[review] = await openRequests(branch);
|
|
877
|
+
if (!review) throw new GitApiError(409, "GitLab couldn't open the merge request.");
|
|
878
|
+
}
|
|
879
|
+
await askReviewers(review.iid, reviewers);
|
|
880
|
+
return { url: review.web_url };
|
|
881
|
+
},
|
|
882
|
+
listReviews: async (prefix) => {
|
|
761
883
|
const query = new URLSearchParams({
|
|
762
|
-
|
|
763
|
-
|
|
884
|
+
state: "opened",
|
|
885
|
+
per_page: "100"
|
|
886
|
+
});
|
|
887
|
+
return (await gitlabJson(this.api, `${project}/merge_requests?${query}`)).filter((request) => request.source_branch.startsWith(prefix)).map((request) => ({
|
|
888
|
+
branch: request.source_branch,
|
|
889
|
+
url: request.web_url,
|
|
890
|
+
title: request.title
|
|
891
|
+
}));
|
|
892
|
+
},
|
|
893
|
+
closeReview: async (branch) => {
|
|
894
|
+
for (const request of await openRequests(branch)) await gitlabRequest(this.api, `${project}/merge_requests/${request.iid}`, {
|
|
895
|
+
method: "PUT",
|
|
896
|
+
body: { state_event: "close" },
|
|
897
|
+
allow: [403, 404]
|
|
764
898
|
});
|
|
765
|
-
const [open] = await gitlabJson(this.api, `${project}/merge_requests?${query}`);
|
|
766
|
-
if (!open) throw new GitApiError(409, "GitLab couldn't open the merge request.");
|
|
767
|
-
return { url: open.web_url };
|
|
768
899
|
}
|
|
769
900
|
};
|
|
770
901
|
}
|
|
@@ -957,6 +1088,7 @@ var GitLabBackend = class GitLabBackend {
|
|
|
957
1088
|
}
|
|
958
1089
|
});
|
|
959
1090
|
this.pinned = commit.id;
|
|
1091
|
+
if (commit.parent_ids?.length === 1 && commit.parent_ids[0] === expectedRevision) await this.carryTree(existing, commit.id, changes).catch(() => {});
|
|
960
1092
|
return { revision: commit.id };
|
|
961
1093
|
} catch (error) {
|
|
962
1094
|
if (!(error instanceof GitApiError)) throw error;
|
|
@@ -965,6 +1097,22 @@ var GitLabBackend = class GitLabBackend {
|
|
|
965
1097
|
throw error;
|
|
966
1098
|
}
|
|
967
1099
|
}
|
|
1100
|
+
/** Lists `revision`'s editable files as `before`'s with `changes` made, keeping the text written. */
|
|
1101
|
+
async carryTree(before, revision, changes) {
|
|
1102
|
+
if (this.trees.has(revision) || !globalThis.crypto?.subtle) return;
|
|
1103
|
+
const tree = new Map(before);
|
|
1104
|
+
for (const change of changes) {
|
|
1105
|
+
if (!EDITABLE_FOLDERS.some((folder) => change.path.startsWith(`${folder}/`))) continue;
|
|
1106
|
+
if ("delete" in change) {
|
|
1107
|
+
tree.delete(change.path);
|
|
1108
|
+
continue;
|
|
1109
|
+
}
|
|
1110
|
+
const id = await gitBlobId("bytes" in change ? change.bytes : new TextEncoder().encode(change.content));
|
|
1111
|
+
tree.set(change.path, id);
|
|
1112
|
+
if ("content" in change && !this.blobs.has(id)) this.blobs.set(id, Promise.resolve(change.content));
|
|
1113
|
+
}
|
|
1114
|
+
this.trees.set(revision, Promise.resolve(tree));
|
|
1115
|
+
}
|
|
968
1116
|
async history(path, { page = 1, perPage = 20 } = {}) {
|
|
969
1117
|
const query = new URLSearchParams({
|
|
970
1118
|
ref_name: this.pinned ?? await this.revision(),
|
|
@@ -1366,7 +1514,7 @@ function browserLocation() {
|
|
|
1366
1514
|
function gitlab(options) {
|
|
1367
1515
|
const webUrl = (options.url ?? "https://gitlab.com").replace(/\/$/, "");
|
|
1368
1516
|
const apiUrl = `${webUrl}/api/v4`;
|
|
1369
|
-
const fetcher = options.fetch ?? ((input, init) => globalThis.fetch(input, init));
|
|
1517
|
+
const fetcher = withRateLimits(options.fetch ?? ((input, init) => globalThis.fetch(input, init)), { host: "GitLab" });
|
|
1370
1518
|
const storage = credentialStorage(`goodfellow:gitlab:${webUrl}:${options.project}`, options.storage);
|
|
1371
1519
|
const pending = credentialStorage(`goodfellow:gitlab:pending:${options.project}`, options.storage);
|
|
1372
1520
|
const location = () => options.location ?? browserLocation();
|
package/dist/testing.d.ts
CHANGED
|
@@ -28,6 +28,8 @@ export interface FakeGitLabOptions {
|
|
|
28
28
|
* Pages: a commit status with no pipeline the project lists.
|
|
29
29
|
*/
|
|
30
30
|
external?: boolean;
|
|
31
|
+
/** A paid plan, whose merge requests take several reviewers; the free plan takes one. */
|
|
32
|
+
premium?: boolean;
|
|
31
33
|
/** The job the update runs in, which `addUpdateRun()` logs: `pages` by default, as in the starters, or a code project's `update`. */
|
|
32
34
|
updateJob?: string;
|
|
33
35
|
/** Groups the users belong to, which new projects can be created in. */
|
|
@@ -42,6 +44,12 @@ export interface FakeGitLabOptions {
|
|
|
42
44
|
accounts?: string[];
|
|
43
45
|
/** Who can see the project. Private by default. */
|
|
44
46
|
visibility?: "public" | "internal" | "private";
|
|
47
|
+
/**
|
|
48
|
+
* Answers plugins' tasks, as the site's `plugin-task` job would: given each
|
|
49
|
+
* pipeline's task variables, it gives the text the job leaves as its
|
|
50
|
+
* artifact, or nothing for a job that fails. Without it, task pipelines fail.
|
|
51
|
+
*/
|
|
52
|
+
tasks?: (variables: Record<string, string>) => string | undefined | Promise<string | undefined>;
|
|
45
53
|
}
|
|
46
54
|
/** A pipeline schedule, such as the nightly one or one the admin panel adds for a scheduled change. */
|
|
47
55
|
export interface FakeSchedule {
|
package/dist/testing.js
CHANGED
|
@@ -173,6 +173,8 @@ function fakeGitLab(options) {
|
|
|
173
173
|
updateRuns.push(added);
|
|
174
174
|
return added;
|
|
175
175
|
}
|
|
176
|
+
/** Pipelines started for plugins' tasks, with their `plugin-task` job's artifact. */
|
|
177
|
+
const taskRuns = [];
|
|
176
178
|
/** Failed jobs, by commit, which tests add with `failBuild()` and `cancelBuild()`. */
|
|
177
179
|
const failures = /* @__PURE__ */ new Map();
|
|
178
180
|
/**
|
|
@@ -564,9 +566,28 @@ function fakeGitLab(options) {
|
|
|
564
566
|
const source = url.searchParams.get("source_branch");
|
|
565
567
|
return json(repo.reviews.filter((review) => review.state === "open" && (!source || review.branch === source)).map((review) => ({
|
|
566
568
|
iid: review.number,
|
|
569
|
+
title: review.title,
|
|
570
|
+
source_branch: review.branch,
|
|
567
571
|
web_url: `${webUrl}/${options.project}/-/merge_requests/${review.number}`
|
|
568
572
|
})));
|
|
569
573
|
}
|
|
574
|
+
const mergeRequest = rest.match(/^\/merge_requests\/(\d+)$/);
|
|
575
|
+
if (mergeRequest && request.method === "PUT") {
|
|
576
|
+
if (accessLevel < 30) return json({ message: "403 Forbidden" }, 403);
|
|
577
|
+
const review = repo.reviews.find((found) => found.number === Number(mergeRequest[1]));
|
|
578
|
+
if (!review) return json({ message: "404 Not found" }, 404);
|
|
579
|
+
const body = await request.json();
|
|
580
|
+
if (body.reviewer_ids) {
|
|
581
|
+
if (body.reviewer_ids.length > 1 && !options.premium) return json({ message: "Too many reviewers" }, 400);
|
|
582
|
+
const names = new Map([...accounts].map(([username, id]) => [id, username]));
|
|
583
|
+
review.reviewers = body.reviewer_ids.flatMap((id) => names.get(id) ?? []);
|
|
584
|
+
}
|
|
585
|
+
if (body.state_event === "close") review.state = "closed";
|
|
586
|
+
return json({
|
|
587
|
+
iid: review.number,
|
|
588
|
+
state: review.state === "open" ? "opened" : review.state
|
|
589
|
+
});
|
|
590
|
+
}
|
|
570
591
|
if (rest === "/repository/tree") {
|
|
571
592
|
const ref = url.searchParams.get("ref") ?? repo.defaultBranch;
|
|
572
593
|
const dir = url.searchParams.get("path") ?? "";
|
|
@@ -628,9 +649,11 @@ function fakeGitLab(options) {
|
|
|
628
649
|
name: body.author_name,
|
|
629
650
|
email: body.author_email
|
|
630
651
|
});
|
|
652
|
+
const parent = repo.commitAt(sha)?.parent;
|
|
631
653
|
return json({
|
|
632
654
|
id: sha,
|
|
633
|
-
message: body.commit_message
|
|
655
|
+
message: body.commit_message,
|
|
656
|
+
parent_ids: parent ? [parent] : []
|
|
634
657
|
}, 201);
|
|
635
658
|
}
|
|
636
659
|
if (rest === "/repository/commits" && request.method === "GET") {
|
|
@@ -691,10 +714,25 @@ function fakeGitLab(options) {
|
|
|
691
714
|
})));
|
|
692
715
|
if (rest === "/pipeline" && request.method === "POST") {
|
|
693
716
|
if (accessLevel < 30) return json({ message: "403 Forbidden" }, 403);
|
|
717
|
+
const body = await request.json();
|
|
718
|
+
const variables = Object.fromEntries((body.variables ?? []).map(({ key, value }) => [key, value]));
|
|
719
|
+
if (variables.GOODFELLOW_TASK_PLUGIN) {
|
|
720
|
+
const id = 9e4 + taskRuns.length;
|
|
721
|
+
taskRuns.push({
|
|
722
|
+
id,
|
|
723
|
+
job: id + 5e4,
|
|
724
|
+
variables,
|
|
725
|
+
answer: await options.tasks?.(variables)
|
|
726
|
+
});
|
|
727
|
+
return json({
|
|
728
|
+
id,
|
|
729
|
+
status: "created"
|
|
730
|
+
}, 201);
|
|
731
|
+
}
|
|
694
732
|
const run = addUpdateRun({
|
|
695
733
|
source: "api",
|
|
696
734
|
status: "created",
|
|
697
|
-
variables:
|
|
735
|
+
variables: body.variables
|
|
698
736
|
});
|
|
699
737
|
return json({
|
|
700
738
|
id: run.id,
|
|
@@ -787,6 +825,18 @@ function fakeGitLab(options) {
|
|
|
787
825
|
if (body.ci_push_repository_for_job_token_allowed !== void 0) updateSettings.jobTokenPush = body.ci_push_repository_for_job_token_allowed;
|
|
788
826
|
return json({ ci_push_repository_for_job_token_allowed: updateSettings.jobTokenPush });
|
|
789
827
|
}
|
|
828
|
+
const taskJobs = rest.match(/^\/pipelines\/(\d+)\/jobs$/);
|
|
829
|
+
const taskRun = taskJobs && taskRuns.find((candidate) => candidate.id === Number(taskJobs[1]));
|
|
830
|
+
if (taskRun) return json([{
|
|
831
|
+
id: taskRun.job,
|
|
832
|
+
name: "plugin-task",
|
|
833
|
+
status: taskRun.answer === void 0 ? "failed" : "success"
|
|
834
|
+
}]);
|
|
835
|
+
const artifact = rest.match(/^\/jobs\/(\d+)\/artifacts\/(.+)$/);
|
|
836
|
+
if (artifact) {
|
|
837
|
+
const run = taskRuns.find((candidate) => candidate.job === Number(artifact[1]));
|
|
838
|
+
return run?.answer !== void 0 && artifact[2] === "goodfellow-task-result.json" ? new Response(`${run.answer}\n`) : json({ message: "404 Not Found" }, 404);
|
|
839
|
+
}
|
|
790
840
|
const updateJobs = rest.match(/^\/pipelines\/(\d{4,})\/jobs$/);
|
|
791
841
|
if (updateJobs) {
|
|
792
842
|
const run = updateRuns.find((candidate) => candidate.id === Number(updateJobs[1]));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodfellow-cms/gitlab",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "Edit a Goodfellow site stored on GitLab, straight from the browser.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"goodfellow",
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
"dist"
|
|
36
36
|
],
|
|
37
37
|
"dependencies": {
|
|
38
|
-
"@goodfellow-cms/core": "0.
|
|
38
|
+
"@goodfellow-cms/core": "0.7.0"
|
|
39
39
|
},
|
|
40
40
|
"devDependencies": {
|
|
41
41
|
"@puckeditor/core": "0.23.0"
|