@goodfellow-cms/gitlab 0.6.0 → 0.8.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 +18 -1
- package/dist/index.js +175 -58
- package/dist/testing.d.ts +4 -0
- package/dist/testing.js +34 -3
- package/package.json +2 -2
package/dist/index.d.ts
CHANGED
|
@@ -3,9 +3,14 @@ import { ConnectDomainOptions, DeployStatus, DnsRecord, FileChange, FileVersion,
|
|
|
3
3
|
interface ApiOptions {
|
|
4
4
|
/** The API root, such as `https://gitlab.com/api/v4`. */
|
|
5
5
|
apiUrl: string;
|
|
6
|
-
/**
|
|
6
|
+
/**
|
|
7
|
+
* Returns a current access token, refreshing it if needed. `refresh` asks
|
|
8
|
+
* for a new one after GitLab refused `stale`, which another request may
|
|
9
|
+
* already have replaced.
|
|
10
|
+
*/
|
|
7
11
|
token: (options?: {
|
|
8
12
|
refresh?: boolean;
|
|
13
|
+
stale?: string;
|
|
9
14
|
}) => Promise<string>;
|
|
10
15
|
/** Whether the token can be refreshed after a 401. */
|
|
11
16
|
canRefresh: boolean;
|
|
@@ -44,6 +49,13 @@ interface GitLabBackendOptions {
|
|
|
44
49
|
project: string;
|
|
45
50
|
webUrl: string;
|
|
46
51
|
};
|
|
52
|
+
/** What's read at each commit, shared with the backends of the project's other branches (drafts), since commits never change. */
|
|
53
|
+
caches?: GitLabCaches;
|
|
54
|
+
}
|
|
55
|
+
/** Path → blob id for the editable files at each commit, and each blob's text, by id. */
|
|
56
|
+
interface GitLabCaches {
|
|
57
|
+
trees: Map<string, Promise<Map<string, string>>>;
|
|
58
|
+
blobs: Map<string, Promise<string>>;
|
|
47
59
|
}
|
|
48
60
|
/**
|
|
49
61
|
* The site's files on GitLab. Reads come from the commit `revision()` last
|
|
@@ -66,6 +78,7 @@ export declare class GitLabBackend implements GitBackend {
|
|
|
66
78
|
private readonly branch;
|
|
67
79
|
private readonly onSignOut;
|
|
68
80
|
private pinned?;
|
|
81
|
+
private readonly caches;
|
|
69
82
|
private readonly trees;
|
|
70
83
|
private readonly blobs;
|
|
71
84
|
constructor(options: GitLabBackendOptions);
|
|
@@ -76,6 +89,8 @@ export declare class GitLabBackend implements GitBackend {
|
|
|
76
89
|
revision(): Promise<string>;
|
|
77
90
|
/** The branch's latest commit, without moving the revision reads are pinned to. */
|
|
78
91
|
private head;
|
|
92
|
+
/** Every item of one of GitLab's lists, page by page, up to `REVIEW_PAGES` pages. */
|
|
93
|
+
private everyPage;
|
|
79
94
|
/** Every file in one folder at a commit, page by page. */
|
|
80
95
|
private listFolder;
|
|
81
96
|
/** Path → blob id for the editable files at a commit. Commits never change, so this is cached. */
|
|
@@ -98,6 +113,8 @@ export declare class GitLabBackend implements GitBackend {
|
|
|
98
113
|
write(changes: FileChange[], { message, expectedRevision }: WriteOptions): Promise<{
|
|
99
114
|
revision: string;
|
|
100
115
|
}>;
|
|
116
|
+
/** Lists `revision`'s editable files as `before`'s with `changes` made, keeping the text written. */
|
|
117
|
+
private carryTree;
|
|
101
118
|
history(path: string, { page, perPage }?: HistoryOptions): Promise<FileVersion[]>;
|
|
102
119
|
readAt(path: string, revision: string): Promise<string | undefined>;
|
|
103
120
|
changedPaths(from: string, to: string): Promise<string[]>;
|
package/dist/index.js
CHANGED
|
@@ -1,16 +1,23 @@
|
|
|
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 = {}) {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
...
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
5
|
+
let sent;
|
|
6
|
+
const send = async (refresh) => {
|
|
7
|
+
sent = await api.token(refresh ? {
|
|
8
|
+
refresh,
|
|
9
|
+
...sent !== void 0 && { stale: sent }
|
|
10
|
+
} : {});
|
|
11
|
+
return api.fetch(`${api.apiUrl}${path}`, {
|
|
12
|
+
method: init.method ?? "GET",
|
|
13
|
+
headers: {
|
|
14
|
+
authorization: `Bearer ${sent}`,
|
|
15
|
+
...init.body !== void 0 && { "content-type": "application/json" }
|
|
16
|
+
},
|
|
17
|
+
body: init.body === void 0 ? void 0 : JSON.stringify(init.body),
|
|
18
|
+
cache: "no-store"
|
|
19
|
+
});
|
|
20
|
+
};
|
|
14
21
|
let response = await send(false);
|
|
15
22
|
if (response.status === 401 && api.canRefresh) response = await send(true);
|
|
16
23
|
if (response.status === 401) throw new SignInError("invalid", "GitLab didn't accept the sign-in. It may have expired.");
|
|
@@ -346,7 +353,11 @@ function notAllowed$1(error) {
|
|
|
346
353
|
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
354
|
throw error;
|
|
348
355
|
}
|
|
349
|
-
/**
|
|
356
|
+
/**
|
|
357
|
+
* A cron line for one instant, in UTC. GitLab's crons have no year, so it
|
|
358
|
+
* comes again a year later unless `sync()` has removed it since, which only
|
|
359
|
+
* builds the site again, since the schedule's variable skips the update.
|
|
360
|
+
*/
|
|
350
361
|
function cronAt(at) {
|
|
351
362
|
return `${at.getUTCMinutes()} ${at.getUTCHours()} ${at.getUTCDate()} ${at.getUTCMonth() + 1} *`;
|
|
352
363
|
}
|
|
@@ -373,7 +384,12 @@ function gitlabScheduledBuilds(api, project, branch, readCi, now = () => /* @__P
|
|
|
373
384
|
if (wanted.size >= 50) break;
|
|
374
385
|
wanted.set(minute(at), at);
|
|
375
386
|
}
|
|
376
|
-
const found =
|
|
387
|
+
const found = [];
|
|
388
|
+
for (let page = 1; page <= 10; page++) {
|
|
389
|
+
const listed = await gitlabJson(api, `${base}?per_page=100&page=${page}`).catch(notAllowed$1);
|
|
390
|
+
found.push(...listed);
|
|
391
|
+
if (listed.length < 100) break;
|
|
392
|
+
}
|
|
377
393
|
const ours = /* @__PURE__ */ new Map();
|
|
378
394
|
for (const schedule of found) {
|
|
379
395
|
if (!schedule.description?.startsWith("Goodfellow: build the site for a scheduled change at ")) continue;
|
|
@@ -398,14 +414,22 @@ function gitlabScheduledBuilds(api, project, branch, readCi, now = () => /* @__P
|
|
|
398
414
|
active: true
|
|
399
415
|
}
|
|
400
416
|
}).catch(notAllowed$1);
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
417
|
+
try {
|
|
418
|
+
await gitlabRequest(api, `${base}/${created.id}/variables`, {
|
|
419
|
+
method: "POST",
|
|
420
|
+
body: {
|
|
421
|
+
key: SCHEDULED_CHANGE_VARIABLE,
|
|
422
|
+
value: time,
|
|
423
|
+
variable_type: "env_var"
|
|
424
|
+
}
|
|
425
|
+
});
|
|
426
|
+
} catch (error) {
|
|
427
|
+
await gitlabRequest(api, `${base}/${created.id}`, {
|
|
428
|
+
method: "DELETE",
|
|
429
|
+
allow: [403, 404]
|
|
430
|
+
}).catch(() => {});
|
|
431
|
+
notAllowed$1(error);
|
|
432
|
+
}
|
|
409
433
|
}
|
|
410
434
|
}
|
|
411
435
|
};
|
|
@@ -695,10 +719,22 @@ function gitlabCodeUpdates(options) {
|
|
|
695
719
|
//#endregion
|
|
696
720
|
//#region src/backend.ts
|
|
697
721
|
const PAGE_SIZE = 100;
|
|
722
|
+
/** The most files GitLab's compare lists, by default. */
|
|
723
|
+
const COMPARE_FILES_LIMIT = 1e3;
|
|
724
|
+
/** How many pages of drafts' branches and merge requests are read: a thousand of each. */
|
|
725
|
+
const REVIEW_PAGES = 10;
|
|
698
726
|
/** How many files one GraphQL request reads. */
|
|
699
727
|
const GRAPHQL_BATCH = 50;
|
|
700
728
|
/** The color and section codes in GitLab's job logs. */
|
|
701
729
|
const ANSI_CODES = new RegExp(`${String.fromCharCode(27)}\\[[\\d;]*[A-Za-z]`, "g");
|
|
730
|
+
/** Git's id for a file's contents (the SHA-1 of `blob <size>\0` and them), as GitLab's tree lists it. */
|
|
731
|
+
async function gitBlobId(bytes) {
|
|
732
|
+
const header = new TextEncoder().encode(`blob ${bytes.length}\0`);
|
|
733
|
+
const data = new Uint8Array(header.length + bytes.length);
|
|
734
|
+
data.set(header);
|
|
735
|
+
data.set(bytes, header.length);
|
|
736
|
+
return [...new Uint8Array(await crypto.subtle.digest("SHA-1", data))].map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
737
|
+
}
|
|
702
738
|
/**
|
|
703
739
|
* Whether a status says when the site is live. A job that only asks another
|
|
704
740
|
* host to rebuild the site doesn't, and nor do the nightly update and the
|
|
@@ -733,8 +769,9 @@ var GitLabBackend = class GitLabBackend {
|
|
|
733
769
|
branch;
|
|
734
770
|
onSignOut;
|
|
735
771
|
pinned;
|
|
736
|
-
|
|
737
|
-
|
|
772
|
+
caches;
|
|
773
|
+
trees;
|
|
774
|
+
blobs;
|
|
738
775
|
constructor(options) {
|
|
739
776
|
this.options = options;
|
|
740
777
|
this.api = options.api;
|
|
@@ -743,6 +780,12 @@ var GitLabBackend = class GitLabBackend {
|
|
|
743
780
|
this.user = options.user;
|
|
744
781
|
this.onSignOut = options.onSignOut;
|
|
745
782
|
this.pinned = options.revision;
|
|
783
|
+
this.caches = options.caches ?? {
|
|
784
|
+
trees: /* @__PURE__ */ new Map(),
|
|
785
|
+
blobs: /* @__PURE__ */ new Map()
|
|
786
|
+
};
|
|
787
|
+
this.trees = this.caches.trees;
|
|
788
|
+
this.blobs = this.caches.blobs;
|
|
746
789
|
this.pages = gitlabPages(this.api, options.project);
|
|
747
790
|
this.editors = gitlabEditors(this.api, options.project, this.branch, this.user.login);
|
|
748
791
|
this.contentOnly = options.contentOnly === true;
|
|
@@ -773,11 +816,40 @@ var GitLabBackend = class GitLabBackend {
|
|
|
773
816
|
/** Drafts on branches of the project, with merge requests for review. */
|
|
774
817
|
draftHost() {
|
|
775
818
|
const project = `/projects/${this.project}`;
|
|
819
|
+
const openRequests = (branch) => gitlabJson(this.api, `${project}/merge_requests?${new URLSearchParams({
|
|
820
|
+
source_branch: branch,
|
|
821
|
+
state: "opened"
|
|
822
|
+
})}`);
|
|
823
|
+
/**
|
|
824
|
+
* Asks people to review a merge request, by their usernames. GitLab's free
|
|
825
|
+
* plan takes one reviewer, so where it refuses them all, the first is asked.
|
|
826
|
+
*/
|
|
827
|
+
const askReviewers = async (iid, usernames) => {
|
|
828
|
+
const ids = [];
|
|
829
|
+
for (const username of usernames) {
|
|
830
|
+
const [found] = await gitlabJson(this.api, `/users?${new URLSearchParams({ username })}`).catch(() => []);
|
|
831
|
+
if (found) ids.push(found.id);
|
|
832
|
+
}
|
|
833
|
+
for (const reviewerIds of ids.length > 1 ? [ids, ids.slice(0, 1)] : [ids]) {
|
|
834
|
+
if (reviewerIds.length === 0) return;
|
|
835
|
+
if ((await gitlabRequest(this.api, `${project}/merge_requests/${iid}`, {
|
|
836
|
+
method: "PUT",
|
|
837
|
+
body: { reviewer_ids: reviewerIds },
|
|
838
|
+
allow: [
|
|
839
|
+
400,
|
|
840
|
+
403,
|
|
841
|
+
404,
|
|
842
|
+
422
|
|
843
|
+
]
|
|
844
|
+
})).ok) return;
|
|
845
|
+
}
|
|
846
|
+
};
|
|
776
847
|
return {
|
|
777
848
|
liveBranch: this.branch,
|
|
778
849
|
branchStore: (branch) => new GitLabBackend({
|
|
779
850
|
...this.options,
|
|
780
|
-
branch
|
|
851
|
+
branch,
|
|
852
|
+
caches: this.caches
|
|
781
853
|
}),
|
|
782
854
|
createBranch: async (branch, revision) => {
|
|
783
855
|
if ((await gitlabRequest(this.api, this.repo(`/branches?${new URLSearchParams({
|
|
@@ -803,13 +875,10 @@ var GitLabBackend = class GitLabBackend {
|
|
|
803
875
|
return (await response.json()).id;
|
|
804
876
|
},
|
|
805
877
|
listBranches: async (prefix) => {
|
|
806
|
-
const query = new URLSearchParams({
|
|
807
|
-
|
|
808
|
-
per_page: "100"
|
|
809
|
-
});
|
|
810
|
-
return (await gitlabJson(this.api, this.repo(`/branches?${query}`))).map((found) => found.name).filter((name) => name.startsWith(prefix));
|
|
878
|
+
const query = new URLSearchParams({ search: `^${prefix}` });
|
|
879
|
+
return (await this.everyPage(this.repo(`/branches?${query}`))).map((found) => found.name).filter((name) => name.startsWith(prefix));
|
|
811
880
|
},
|
|
812
|
-
openReview: async ({ branch, title, description }) => {
|
|
881
|
+
openReview: async ({ branch, title, description, reviewers = [] }) => {
|
|
813
882
|
const response = await gitlabRequest(this.api, `${project}/merge_requests`, {
|
|
814
883
|
method: "POST",
|
|
815
884
|
body: {
|
|
@@ -825,15 +894,29 @@ var GitLabBackend = class GitLabBackend {
|
|
|
825
894
|
409
|
|
826
895
|
]
|
|
827
896
|
});
|
|
828
|
-
|
|
829
|
-
if (response.
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
897
|
+
let review;
|
|
898
|
+
if (response.ok) review = await response.json();
|
|
899
|
+
else if (response.status !== 409) throw new DraftError("no-reviews", "This sign-in can't open merge requests on the site's project.");
|
|
900
|
+
else {
|
|
901
|
+
[review] = await openRequests(branch);
|
|
902
|
+
if (!review) throw new GitApiError(409, "GitLab couldn't open the merge request.");
|
|
903
|
+
}
|
|
904
|
+
await askReviewers(review.iid, reviewers);
|
|
905
|
+
return { url: review.web_url };
|
|
906
|
+
},
|
|
907
|
+
listReviews: async (prefix) => {
|
|
908
|
+
return (await this.everyPage(`${project}/merge_requests?state=opened`)).filter((request) => request.source_branch.startsWith(prefix)).map((request) => ({
|
|
909
|
+
branch: request.source_branch,
|
|
910
|
+
url: request.web_url,
|
|
911
|
+
title: request.title
|
|
912
|
+
}));
|
|
913
|
+
},
|
|
914
|
+
closeReview: async (branch) => {
|
|
915
|
+
for (const request of await openRequests(branch)) await gitlabRequest(this.api, `${project}/merge_requests/${request.iid}`, {
|
|
916
|
+
method: "PUT",
|
|
917
|
+
body: { state_event: "close" },
|
|
918
|
+
allow: [403, 404]
|
|
833
919
|
});
|
|
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
920
|
}
|
|
838
921
|
};
|
|
839
922
|
}
|
|
@@ -848,6 +931,16 @@ var GitLabBackend = class GitLabBackend {
|
|
|
848
931
|
async head() {
|
|
849
932
|
return (await gitlabJson(this.api, this.repo(`/branches/${encodeURIComponent(this.branch)}`))).commit.id;
|
|
850
933
|
}
|
|
934
|
+
/** Every item of one of GitLab's lists, page by page, up to `REVIEW_PAGES` pages. */
|
|
935
|
+
async everyPage(path) {
|
|
936
|
+
const items = [];
|
|
937
|
+
for (let page = 1; page <= REVIEW_PAGES; page++) {
|
|
938
|
+
const found = await gitlabJson(this.api, `${path}&per_page=${PAGE_SIZE}&page=${page}`);
|
|
939
|
+
items.push(...found);
|
|
940
|
+
if (found.length < PAGE_SIZE) break;
|
|
941
|
+
}
|
|
942
|
+
return items;
|
|
943
|
+
}
|
|
851
944
|
/** Every file in one folder at a commit, page by page. */
|
|
852
945
|
async listFolder(sha, dir) {
|
|
853
946
|
const files = [];
|
|
@@ -1026,6 +1119,7 @@ var GitLabBackend = class GitLabBackend {
|
|
|
1026
1119
|
}
|
|
1027
1120
|
});
|
|
1028
1121
|
this.pinned = commit.id;
|
|
1122
|
+
if (commit.parent_ids?.length === 1 && commit.parent_ids[0] === expectedRevision) await this.carryTree(existing, commit.id, changes).catch(() => {});
|
|
1029
1123
|
return { revision: commit.id };
|
|
1030
1124
|
} catch (error) {
|
|
1031
1125
|
if (!(error instanceof GitApiError)) throw error;
|
|
@@ -1034,6 +1128,22 @@ var GitLabBackend = class GitLabBackend {
|
|
|
1034
1128
|
throw error;
|
|
1035
1129
|
}
|
|
1036
1130
|
}
|
|
1131
|
+
/** Lists `revision`'s editable files as `before`'s with `changes` made, keeping the text written. */
|
|
1132
|
+
async carryTree(before, revision, changes) {
|
|
1133
|
+
if (this.trees.has(revision) || !globalThis.crypto?.subtle) return;
|
|
1134
|
+
const tree = new Map(before);
|
|
1135
|
+
for (const change of changes) {
|
|
1136
|
+
if (!EDITABLE_FOLDERS.some((folder) => change.path.startsWith(`${folder}/`))) continue;
|
|
1137
|
+
if ("delete" in change) {
|
|
1138
|
+
tree.delete(change.path);
|
|
1139
|
+
continue;
|
|
1140
|
+
}
|
|
1141
|
+
const id = await gitBlobId("bytes" in change ? change.bytes : new TextEncoder().encode(change.content));
|
|
1142
|
+
tree.set(change.path, id);
|
|
1143
|
+
if ("content" in change && !this.blobs.has(id)) this.blobs.set(id, Promise.resolve(change.content));
|
|
1144
|
+
}
|
|
1145
|
+
this.trees.set(revision, Promise.resolve(tree));
|
|
1146
|
+
}
|
|
1037
1147
|
async history(path, { page = 1, perPage = 20 } = {}) {
|
|
1038
1148
|
const query = new URLSearchParams({
|
|
1039
1149
|
ref_name: this.pinned ?? await this.revision(),
|
|
@@ -1053,12 +1163,13 @@ var GitLabBackend = class GitLabBackend {
|
|
|
1053
1163
|
return response.ok ? response.text() : void 0;
|
|
1054
1164
|
}
|
|
1055
1165
|
async changedPaths(from, to) {
|
|
1056
|
-
const
|
|
1166
|
+
const diffs = (await gitlabJson(this.api, this.repo(`/compare?${new URLSearchParams({
|
|
1057
1167
|
from,
|
|
1058
1168
|
to,
|
|
1059
1169
|
straight: "true"
|
|
1060
|
-
})}`));
|
|
1061
|
-
|
|
1170
|
+
})}`))).diffs ?? [];
|
|
1171
|
+
if (diffs.length >= COMPARE_FILES_LIMIT) throw new GitApiError(0, `More files changed between ${from} and ${to} than GitLab lists.`);
|
|
1172
|
+
return [...new Set(diffs.flatMap((diff) => [diff.old_path, diff.new_path]))];
|
|
1062
1173
|
}
|
|
1063
1174
|
/**
|
|
1064
1175
|
* Reads the commit's pipeline and external statuses (GitLab Pages jobs, or
|
|
@@ -1435,7 +1546,7 @@ function browserLocation() {
|
|
|
1435
1546
|
function gitlab(options) {
|
|
1436
1547
|
const webUrl = (options.url ?? "https://gitlab.com").replace(/\/$/, "");
|
|
1437
1548
|
const apiUrl = `${webUrl}/api/v4`;
|
|
1438
|
-
const fetcher = options.fetch ?? ((input, init) => globalThis.fetch(input, init));
|
|
1549
|
+
const fetcher = withRateLimits(options.fetch ?? ((input, init) => globalThis.fetch(input, init)), { host: "GitLab" });
|
|
1439
1550
|
const storage = credentialStorage(`goodfellow:gitlab:${webUrl}:${options.project}`, options.storage);
|
|
1440
1551
|
const pending = credentialStorage(`goodfellow:gitlab:pending:${options.project}`, options.storage);
|
|
1441
1552
|
const location = () => options.location ?? browserLocation();
|
|
@@ -1450,19 +1561,25 @@ function gitlab(options) {
|
|
|
1450
1561
|
}
|
|
1451
1562
|
async function connect(initial, remember) {
|
|
1452
1563
|
let credentials = initial;
|
|
1453
|
-
|
|
1564
|
+
let renewing;
|
|
1565
|
+
const renew = (current) => renewing ??= (async () => {
|
|
1566
|
+
credentials = toTokens(await requestTokens({
|
|
1567
|
+
client_id: options.clientId ?? "",
|
|
1568
|
+
refresh_token: current.refresh,
|
|
1569
|
+
grant_type: "refresh_token",
|
|
1570
|
+
redirect_uri: redirectUri(location().href()),
|
|
1571
|
+
code_verifier: current.verifier
|
|
1572
|
+
}), current.verifier);
|
|
1573
|
+
storage.save(JSON.stringify(credentials), remember);
|
|
1574
|
+
return credentials.access;
|
|
1575
|
+
})().finally(() => {
|
|
1576
|
+
renewing = void 0;
|
|
1577
|
+
});
|
|
1578
|
+
const token = async ({ refresh, stale } = {}) => {
|
|
1454
1579
|
if (credentials.type === "token") return credentials.token;
|
|
1455
|
-
if (
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
client_id: options.clientId ?? "",
|
|
1459
|
-
refresh_token: credentials.refresh,
|
|
1460
|
-
grant_type: "refresh_token",
|
|
1461
|
-
redirect_uri: redirectUri(location().href()),
|
|
1462
|
-
code_verifier: verifier
|
|
1463
|
-
}), verifier);
|
|
1464
|
-
storage.save(JSON.stringify(credentials), remember);
|
|
1465
|
-
}
|
|
1580
|
+
if (renewing) return renewing;
|
|
1581
|
+
if (refresh && stale !== void 0 && stale !== credentials.access) return credentials.access;
|
|
1582
|
+
if (refresh || credentials.expiresAt - 6e4 < Date.now()) return renew(credentials);
|
|
1466
1583
|
return credentials.access;
|
|
1467
1584
|
};
|
|
1468
1585
|
const api = {
|
|
@@ -1505,18 +1622,18 @@ function gitlab(options) {
|
|
|
1505
1622
|
const returnedState = url.searchParams.get("state");
|
|
1506
1623
|
const error = url.searchParams.get("error");
|
|
1507
1624
|
if (!code && !error) return null;
|
|
1508
|
-
const saved = pending.load();
|
|
1509
|
-
if (!saved) return null;
|
|
1510
|
-
pending.clear();
|
|
1511
|
-
const redirect = JSON.parse(saved);
|
|
1512
1625
|
for (const key of [
|
|
1513
1626
|
"code",
|
|
1514
1627
|
"state",
|
|
1515
1628
|
"error",
|
|
1516
1629
|
"error_description"
|
|
1517
1630
|
]) url.searchParams.delete(key);
|
|
1518
|
-
|
|
1631
|
+
const saved = pending.load();
|
|
1632
|
+
if (saved) url.hash = JSON.parse(saved).hash;
|
|
1519
1633
|
location().replace(url.toString());
|
|
1634
|
+
if (!saved) return null;
|
|
1635
|
+
pending.clear();
|
|
1636
|
+
const redirect = JSON.parse(saved);
|
|
1520
1637
|
if (error || !code || returnedState !== redirect.state) throw new SignInError("redirect-failed", "Signing in with GitLab was cancelled or didn't finish.");
|
|
1521
1638
|
return connect(toTokens(await requestTokens({
|
|
1522
1639
|
client_id: options.clientId ?? "",
|
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. */
|
|
@@ -170,6 +172,8 @@ export declare function fakeGitLab(options: FakeGitLabOptions): {
|
|
|
170
172
|
requests: Request[];
|
|
171
173
|
commit: (changes: FileChange[], message: string, author?: string, branch?: string) => string;
|
|
172
174
|
authorize: (authorizeUrl: string) => string;
|
|
175
|
+
/** Ends every OAuth access token GitLab has given, as their two hours running out does; refresh tokens still work. */
|
|
176
|
+
expireAccessTokens: () => void;
|
|
173
177
|
handle: (request: Request) => Promise<Response>;
|
|
174
178
|
fetch: typeof fetch;
|
|
175
179
|
};
|
package/dist/testing.js
CHANGED
|
@@ -17,6 +17,12 @@ async function sha256Base64Url(text) {
|
|
|
17
17
|
for (const byte of digest) binary += String.fromCharCode(byte);
|
|
18
18
|
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
19
19
|
}
|
|
20
|
+
/** One page of a list, as GitLab answers with `per_page` and `page`. */
|
|
21
|
+
function onePage(url, items) {
|
|
22
|
+
const perPage = Number(url.searchParams.get("per_page") ?? 20);
|
|
23
|
+
const page = Number(url.searchParams.get("page") ?? 1);
|
|
24
|
+
return items.slice((page - 1) * perPage, page * perPage);
|
|
25
|
+
}
|
|
20
26
|
function fakeGitLab(options) {
|
|
21
27
|
const webUrl = options.url ?? "https://gitlab.com";
|
|
22
28
|
const repo = new FakeRepo(options.files);
|
|
@@ -515,7 +521,7 @@ function fakeGitLab(options) {
|
|
|
515
521
|
if (rest === "/repository/branches" && request.method === "GET") {
|
|
516
522
|
const search = url.searchParams.get("search") ?? "";
|
|
517
523
|
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]) => ({
|
|
524
|
+
return json(onePage(url, [...repo.branches].filter(([name]) => matches(name))).map(([name, sha]) => ({
|
|
519
525
|
name,
|
|
520
526
|
commit: { id: sha }
|
|
521
527
|
})));
|
|
@@ -564,11 +570,30 @@ function fakeGitLab(options) {
|
|
|
564
570
|
}
|
|
565
571
|
if (rest === "/merge_requests" && request.method === "GET") {
|
|
566
572
|
const source = url.searchParams.get("source_branch");
|
|
567
|
-
return json(repo.reviews.filter((review) => review.state === "open" && (!source || review.branch === source)).map((review) => ({
|
|
573
|
+
return json(onePage(url, repo.reviews.filter((review) => review.state === "open" && (!source || review.branch === source))).map((review) => ({
|
|
568
574
|
iid: review.number,
|
|
575
|
+
title: review.title,
|
|
576
|
+
source_branch: review.branch,
|
|
569
577
|
web_url: `${webUrl}/${options.project}/-/merge_requests/${review.number}`
|
|
570
578
|
})));
|
|
571
579
|
}
|
|
580
|
+
const mergeRequest = rest.match(/^\/merge_requests\/(\d+)$/);
|
|
581
|
+
if (mergeRequest && request.method === "PUT") {
|
|
582
|
+
if (accessLevel < 30) return json({ message: "403 Forbidden" }, 403);
|
|
583
|
+
const review = repo.reviews.find((found) => found.number === Number(mergeRequest[1]));
|
|
584
|
+
if (!review) return json({ message: "404 Not found" }, 404);
|
|
585
|
+
const body = await request.json();
|
|
586
|
+
if (body.reviewer_ids) {
|
|
587
|
+
if (body.reviewer_ids.length > 1 && !options.premium) return json({ message: "Too many reviewers" }, 400);
|
|
588
|
+
const names = new Map([...accounts].map(([username, id]) => [id, username]));
|
|
589
|
+
review.reviewers = body.reviewer_ids.flatMap((id) => names.get(id) ?? []);
|
|
590
|
+
}
|
|
591
|
+
if (body.state_event === "close") review.state = "closed";
|
|
592
|
+
return json({
|
|
593
|
+
iid: review.number,
|
|
594
|
+
state: review.state === "open" ? "opened" : review.state
|
|
595
|
+
});
|
|
596
|
+
}
|
|
572
597
|
if (rest === "/repository/tree") {
|
|
573
598
|
const ref = url.searchParams.get("ref") ?? repo.defaultBranch;
|
|
574
599
|
const dir = url.searchParams.get("path") ?? "";
|
|
@@ -630,9 +655,11 @@ function fakeGitLab(options) {
|
|
|
630
655
|
name: body.author_name,
|
|
631
656
|
email: body.author_email
|
|
632
657
|
});
|
|
658
|
+
const parent = repo.commitAt(sha)?.parent;
|
|
633
659
|
return json({
|
|
634
660
|
id: sha,
|
|
635
|
-
message: body.commit_message
|
|
661
|
+
message: body.commit_message,
|
|
662
|
+
parent_ids: parent ? [parent] : []
|
|
636
663
|
}, 201);
|
|
637
664
|
}
|
|
638
665
|
if (rest === "/repository/commits" && request.method === "GET") {
|
|
@@ -887,6 +914,10 @@ function fakeGitLab(options) {
|
|
|
887
914
|
requests,
|
|
888
915
|
commit,
|
|
889
916
|
authorize,
|
|
917
|
+
/** Ends every OAuth access token GitLab has given, as their two hours running out does; refresh tokens still work. */
|
|
918
|
+
expireAccessTokens: () => {
|
|
919
|
+
for (const [token] of tokens) if (token.startsWith("oauth-access-")) tokens.delete(token);
|
|
920
|
+
},
|
|
890
921
|
handle,
|
|
891
922
|
fetch: ((input, init) => handle(new Request(input, init)))
|
|
892
923
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodfellow-cms/gitlab",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.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.8.0"
|
|
39
39
|
},
|
|
40
40
|
"devDependencies": {
|
|
41
41
|
"@puckeditor/core": "0.23.0"
|