@goodfellow-cms/gitlab 0.6.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/dist/index.d.ts +10 -0
- package/dist/index.js +92 -13
- package/dist/testing.d.ts +2 -0
- package/dist/testing.js +22 -1
- package/package.json +2 -2
package/dist/index.d.ts
CHANGED
|
@@ -44,6 +44,13 @@ interface GitLabBackendOptions {
|
|
|
44
44
|
project: string;
|
|
45
45
|
webUrl: string;
|
|
46
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>>;
|
|
47
54
|
}
|
|
48
55
|
/**
|
|
49
56
|
* The site's files on GitLab. Reads come from the commit `revision()` last
|
|
@@ -66,6 +73,7 @@ export declare class GitLabBackend implements GitBackend {
|
|
|
66
73
|
private readonly branch;
|
|
67
74
|
private readonly onSignOut;
|
|
68
75
|
private pinned?;
|
|
76
|
+
private readonly caches;
|
|
69
77
|
private readonly trees;
|
|
70
78
|
private readonly blobs;
|
|
71
79
|
constructor(options: GitLabBackendOptions);
|
|
@@ -98,6 +106,8 @@ export declare class GitLabBackend implements GitBackend {
|
|
|
98
106
|
write(changes: FileChange[], { message, expectedRevision }: WriteOptions): Promise<{
|
|
99
107
|
revision: string;
|
|
100
108
|
}>;
|
|
109
|
+
/** Lists `revision`'s editable files as `before`'s with `changes` made, keeping the text written. */
|
|
110
|
+
private carryTree;
|
|
101
111
|
history(path: string, { page, perPage }?: HistoryOptions): Promise<FileVersion[]>;
|
|
102
112
|
readAt(path: string, revision: string): Promise<string | undefined>;
|
|
103
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, 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";
|
|
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 = {}) {
|
|
@@ -699,6 +699,14 @@ const PAGE_SIZE = 100;
|
|
|
699
699
|
const GRAPHQL_BATCH = 50;
|
|
700
700
|
/** The color and section codes in GitLab's job logs. */
|
|
701
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
|
+
}
|
|
702
710
|
/**
|
|
703
711
|
* Whether a status says when the site is live. A job that only asks another
|
|
704
712
|
* host to rebuild the site doesn't, and nor do the nightly update and the
|
|
@@ -733,8 +741,9 @@ var GitLabBackend = class GitLabBackend {
|
|
|
733
741
|
branch;
|
|
734
742
|
onSignOut;
|
|
735
743
|
pinned;
|
|
736
|
-
|
|
737
|
-
|
|
744
|
+
caches;
|
|
745
|
+
trees;
|
|
746
|
+
blobs;
|
|
738
747
|
constructor(options) {
|
|
739
748
|
this.options = options;
|
|
740
749
|
this.api = options.api;
|
|
@@ -743,6 +752,12 @@ var GitLabBackend = class GitLabBackend {
|
|
|
743
752
|
this.user = options.user;
|
|
744
753
|
this.onSignOut = options.onSignOut;
|
|
745
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;
|
|
746
761
|
this.pages = gitlabPages(this.api, options.project);
|
|
747
762
|
this.editors = gitlabEditors(this.api, options.project, this.branch, this.user.login);
|
|
748
763
|
this.contentOnly = options.contentOnly === true;
|
|
@@ -773,11 +788,40 @@ var GitLabBackend = class GitLabBackend {
|
|
|
773
788
|
/** Drafts on branches of the project, with merge requests for review. */
|
|
774
789
|
draftHost() {
|
|
775
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
|
+
};
|
|
776
819
|
return {
|
|
777
820
|
liveBranch: this.branch,
|
|
778
821
|
branchStore: (branch) => new GitLabBackend({
|
|
779
822
|
...this.options,
|
|
780
|
-
branch
|
|
823
|
+
branch,
|
|
824
|
+
caches: this.caches
|
|
781
825
|
}),
|
|
782
826
|
createBranch: async (branch, revision) => {
|
|
783
827
|
if ((await gitlabRequest(this.api, this.repo(`/branches?${new URLSearchParams({
|
|
@@ -809,7 +853,7 @@ var GitLabBackend = class GitLabBackend {
|
|
|
809
853
|
});
|
|
810
854
|
return (await gitlabJson(this.api, this.repo(`/branches?${query}`))).map((found) => found.name).filter((name) => name.startsWith(prefix));
|
|
811
855
|
},
|
|
812
|
-
openReview: async ({ branch, title, description }) => {
|
|
856
|
+
openReview: async ({ branch, title, description, reviewers = [] }) => {
|
|
813
857
|
const response = await gitlabRequest(this.api, `${project}/merge_requests`, {
|
|
814
858
|
method: "POST",
|
|
815
859
|
body: {
|
|
@@ -825,15 +869,33 @@ var GitLabBackend = class GitLabBackend {
|
|
|
825
869
|
409
|
|
826
870
|
]
|
|
827
871
|
});
|
|
828
|
-
|
|
829
|
-
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) => {
|
|
830
883
|
const query = new URLSearchParams({
|
|
831
|
-
|
|
832
|
-
|
|
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]
|
|
833
898
|
});
|
|
834
|
-
const [open] = await gitlabJson(this.api, `${project}/merge_requests?${query}`);
|
|
835
|
-
if (!open) throw new GitApiError(409, "GitLab couldn't open the merge request.");
|
|
836
|
-
return { url: open.web_url };
|
|
837
899
|
}
|
|
838
900
|
};
|
|
839
901
|
}
|
|
@@ -1026,6 +1088,7 @@ var GitLabBackend = class GitLabBackend {
|
|
|
1026
1088
|
}
|
|
1027
1089
|
});
|
|
1028
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(() => {});
|
|
1029
1092
|
return { revision: commit.id };
|
|
1030
1093
|
} catch (error) {
|
|
1031
1094
|
if (!(error instanceof GitApiError)) throw error;
|
|
@@ -1034,6 +1097,22 @@ var GitLabBackend = class GitLabBackend {
|
|
|
1034
1097
|
throw error;
|
|
1035
1098
|
}
|
|
1036
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
|
+
}
|
|
1037
1116
|
async history(path, { page = 1, perPage = 20 } = {}) {
|
|
1038
1117
|
const query = new URLSearchParams({
|
|
1039
1118
|
ref_name: this.pinned ?? await this.revision(),
|
|
@@ -1435,7 +1514,7 @@ function browserLocation() {
|
|
|
1435
1514
|
function gitlab(options) {
|
|
1436
1515
|
const webUrl = (options.url ?? "https://gitlab.com").replace(/\/$/, "");
|
|
1437
1516
|
const apiUrl = `${webUrl}/api/v4`;
|
|
1438
|
-
const fetcher = options.fetch ?? ((input, init) => globalThis.fetch(input, init));
|
|
1517
|
+
const fetcher = withRateLimits(options.fetch ?? ((input, init) => globalThis.fetch(input, init)), { host: "GitLab" });
|
|
1439
1518
|
const storage = credentialStorage(`goodfellow:gitlab:${webUrl}:${options.project}`, options.storage);
|
|
1440
1519
|
const pending = credentialStorage(`goodfellow:gitlab:pending:${options.project}`, options.storage);
|
|
1441
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. */
|
package/dist/testing.js
CHANGED
|
@@ -566,9 +566,28 @@ function fakeGitLab(options) {
|
|
|
566
566
|
const source = url.searchParams.get("source_branch");
|
|
567
567
|
return json(repo.reviews.filter((review) => review.state === "open" && (!source || review.branch === source)).map((review) => ({
|
|
568
568
|
iid: review.number,
|
|
569
|
+
title: review.title,
|
|
570
|
+
source_branch: review.branch,
|
|
569
571
|
web_url: `${webUrl}/${options.project}/-/merge_requests/${review.number}`
|
|
570
572
|
})));
|
|
571
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
|
+
}
|
|
572
591
|
if (rest === "/repository/tree") {
|
|
573
592
|
const ref = url.searchParams.get("ref") ?? repo.defaultBranch;
|
|
574
593
|
const dir = url.searchParams.get("path") ?? "";
|
|
@@ -630,9 +649,11 @@ function fakeGitLab(options) {
|
|
|
630
649
|
name: body.author_name,
|
|
631
650
|
email: body.author_email
|
|
632
651
|
});
|
|
652
|
+
const parent = repo.commitAt(sha)?.parent;
|
|
633
653
|
return json({
|
|
634
654
|
id: sha,
|
|
635
|
-
message: body.commit_message
|
|
655
|
+
message: body.commit_message,
|
|
656
|
+
parent_ids: parent ? [parent] : []
|
|
636
657
|
}, 201);
|
|
637
658
|
}
|
|
638
659
|
if (rest === "/repository/commits" && request.method === "GET") {
|
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"
|