@goodfellow-cms/gitlab 0.1.0 → 0.2.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 +2 -2
- package/dist/index.d.ts +41 -2
- package/dist/index.js +651 -6
- package/dist/testing.d.ts +98 -1
- package/dist/testing.js +451 -9
- package/package.json +5 -5
package/dist/testing.d.ts
CHANGED
|
@@ -21,11 +21,108 @@ export interface FakeGitLabOptions {
|
|
|
21
21
|
};
|
|
22
22
|
/** Simulates a GitLab Pages pipeline that succeeds after being checked this many times. Off by default. */
|
|
23
23
|
deployAfterChecks?: number;
|
|
24
|
+
/** Groups the users belong to, which new projects can be created in. */
|
|
25
|
+
groups?: string[];
|
|
26
|
+
/** Publishes the site with GitLab Pages. Off by default. */
|
|
27
|
+
pages?: {
|
|
28
|
+
uniqueDomain?: boolean;
|
|
29
|
+
};
|
|
30
|
+
/** Other GitLab accounts that exist, which can be added to the project. Every token's user exists and is a member. */
|
|
31
|
+
accounts?: string[];
|
|
32
|
+
}
|
|
33
|
+
/** A member of the site's project. */
|
|
34
|
+
export interface FakeMember {
|
|
35
|
+
id: number;
|
|
36
|
+
username: string;
|
|
37
|
+
name?: string;
|
|
38
|
+
accessLevel: number;
|
|
39
|
+
/** A member through a group rather than the project itself. */
|
|
40
|
+
inherited?: boolean;
|
|
41
|
+
}
|
|
42
|
+
/** A custom domain added to the site's GitLab Pages. */
|
|
43
|
+
export interface FakePagesDomain {
|
|
44
|
+
verified: boolean;
|
|
45
|
+
verificationCode: string;
|
|
46
|
+
/** Whether Let's Encrypt has issued the certificate. */
|
|
47
|
+
certificate: boolean;
|
|
48
|
+
}
|
|
49
|
+
/** The site's GitLab Pages settings, which tests can change. */
|
|
50
|
+
export interface FakeGitLabPages {
|
|
51
|
+
uniqueDomain: boolean;
|
|
52
|
+
forceHttps: boolean;
|
|
53
|
+
primaryDomain: string | null;
|
|
54
|
+
domains: Map<string, FakePagesDomain>;
|
|
55
|
+
/** Domains whose TXT record GitLab would find, so verifying them works. */
|
|
56
|
+
dnsReady: Set<string>;
|
|
57
|
+
}
|
|
58
|
+
/** A project created through the fake's API, as the setup page creates them. */
|
|
59
|
+
export interface FakeCreatedProject {
|
|
60
|
+
id: number;
|
|
61
|
+
path: string;
|
|
62
|
+
visibility: string;
|
|
63
|
+
description?: string;
|
|
64
|
+
pagesAccessLevel?: string;
|
|
65
|
+
repo: FakeRepo;
|
|
24
66
|
}
|
|
25
67
|
export declare function fakeGitLab(options: FakeGitLabOptions): {
|
|
68
|
+
/** The project's members by user id, invitations by email, and protected branches, which tests can change. */
|
|
69
|
+
members: Map<number, FakeMember>;
|
|
70
|
+
invitations: Map<string, number>;
|
|
71
|
+
protectedBranches: Map<string, {
|
|
72
|
+
push: number;
|
|
73
|
+
merge: number;
|
|
74
|
+
forcePush: boolean;
|
|
75
|
+
}>;
|
|
76
|
+
failBuild: (sha: string, failure?: {
|
|
77
|
+
job?: string;
|
|
78
|
+
failureReason?: string;
|
|
79
|
+
trace?: string;
|
|
80
|
+
}) => void;
|
|
81
|
+
/** The project's update settings, and the pipelines the update ran in, which tests add with `addUpdateRun()`. */
|
|
82
|
+
updateSettings: {
|
|
83
|
+
jobTokenPush: boolean;
|
|
84
|
+
schedules: Array<{
|
|
85
|
+
id: number;
|
|
86
|
+
ref: string;
|
|
87
|
+
cron: string;
|
|
88
|
+
active: boolean;
|
|
89
|
+
}>;
|
|
90
|
+
};
|
|
91
|
+
updateRuns: {
|
|
92
|
+
id: number;
|
|
93
|
+
source: string;
|
|
94
|
+
status: string;
|
|
95
|
+
variables?: Array<{
|
|
96
|
+
key: string;
|
|
97
|
+
value: string;
|
|
98
|
+
}>;
|
|
99
|
+
trace: string;
|
|
100
|
+
}[];
|
|
101
|
+
addUpdateRun: (run: {
|
|
102
|
+
source?: string;
|
|
103
|
+
status?: string;
|
|
104
|
+
trace?: string;
|
|
105
|
+
variables?: Array<{
|
|
106
|
+
key: string;
|
|
107
|
+
value: string;
|
|
108
|
+
}>;
|
|
109
|
+
}) => {
|
|
110
|
+
variables?: Array<{
|
|
111
|
+
key: string;
|
|
112
|
+
value: string;
|
|
113
|
+
}>;
|
|
114
|
+
id: number;
|
|
115
|
+
source: string;
|
|
116
|
+
status: string;
|
|
117
|
+
trace: string;
|
|
118
|
+
};
|
|
26
119
|
repo: FakeRepo;
|
|
120
|
+
/** Projects created through the API, by full path. */
|
|
121
|
+
created: Map<string, FakeCreatedProject>;
|
|
122
|
+
/** The site's GitLab Pages, if it has them, which tests can change. */
|
|
123
|
+
pages: FakeGitLabPages | undefined;
|
|
27
124
|
requests: Request[];
|
|
28
|
-
commit: (changes: FileChange[], message: string) => string;
|
|
125
|
+
commit: (changes: FileChange[], message: string, author?: string) => string;
|
|
29
126
|
authorize: (authorizeUrl: string) => string;
|
|
30
127
|
handle: (request: Request) => Promise<Response>;
|
|
31
128
|
fetch: typeof fetch;
|
package/dist/testing.js
CHANGED
|
@@ -24,13 +24,314 @@ function fakeGitLab(options) {
|
|
|
24
24
|
username: "editor",
|
|
25
25
|
name: "Test Editor"
|
|
26
26
|
} }));
|
|
27
|
+
let nextUserId = 1;
|
|
28
|
+
/** Every GitLab account, by username, with its id. */
|
|
29
|
+
const accounts = /* @__PURE__ */ new Map();
|
|
30
|
+
for (const name of [...[...tokens.values(), ...options.oauth ? [options.oauth.user] : []].map((user) => user.username), ...options.accounts ?? []]) if (!accounts.has(name)) accounts.set(name, nextUserId++);
|
|
31
|
+
const members = /* @__PURE__ */ new Map();
|
|
32
|
+
for (const user of [...tokens.values(), ...options.oauth ? [options.oauth.user] : []]) {
|
|
33
|
+
const id = accounts.get(user.username) ?? 0;
|
|
34
|
+
members.set(id, {
|
|
35
|
+
id,
|
|
36
|
+
username: user.username,
|
|
37
|
+
name: user.name,
|
|
38
|
+
accessLevel: user.accessLevel ?? 40
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
const invitations = /* @__PURE__ */ new Map();
|
|
42
|
+
/** Protected branches and who may push to them: GitLab protects the default branch for Maintainers. */
|
|
43
|
+
const protectedBranches = /* @__PURE__ */ new Map([[repo.defaultBranch, {
|
|
44
|
+
push: 40,
|
|
45
|
+
merge: 40,
|
|
46
|
+
forcePush: false
|
|
47
|
+
}]]);
|
|
48
|
+
/** The project's members and invitations, and its protected branches. */
|
|
49
|
+
async function handleMembers(request, url, rest, accessLevel) {
|
|
50
|
+
const forbidden = () => json({ message: "403 Forbidden" }, 403);
|
|
51
|
+
const list = (items) => {
|
|
52
|
+
const perPage = Number(url.searchParams.get("per_page") ?? 20);
|
|
53
|
+
const page = Number(url.searchParams.get("page") ?? 1);
|
|
54
|
+
return json(items.slice((page - 1) * perPage, page * perPage));
|
|
55
|
+
};
|
|
56
|
+
const shown = (member) => ({
|
|
57
|
+
id: member.id,
|
|
58
|
+
username: member.username,
|
|
59
|
+
name: member.name ?? member.username,
|
|
60
|
+
avatar_url: `https://avatars.example/${member.username}`,
|
|
61
|
+
access_level: member.accessLevel
|
|
62
|
+
});
|
|
63
|
+
if (rest === "/members/all" && request.method === "GET") return list([...members.values()].map(shown));
|
|
64
|
+
if (rest === "/members" && request.method === "GET") return list([...members.values()].filter((member) => !member.inherited).map(shown));
|
|
65
|
+
if (rest === "/members" && request.method === "POST") {
|
|
66
|
+
if (accessLevel < 40) return forbidden();
|
|
67
|
+
const body = await request.json();
|
|
68
|
+
const username = [...accounts].find(([, id]) => id === body.user_id)?.[0];
|
|
69
|
+
if (!username) return json({ message: "404 User Not Found" }, 404);
|
|
70
|
+
if (members.has(body.user_id)) return json({ message: "Member already exists" }, 409);
|
|
71
|
+
members.set(body.user_id, {
|
|
72
|
+
id: body.user_id,
|
|
73
|
+
username,
|
|
74
|
+
accessLevel: body.access_level
|
|
75
|
+
});
|
|
76
|
+
return json(shown(members.get(body.user_id)), 201);
|
|
77
|
+
}
|
|
78
|
+
const member = rest.match(/^\/members\/(\d+)$/);
|
|
79
|
+
if (member) {
|
|
80
|
+
if (accessLevel < 40) return forbidden();
|
|
81
|
+
const found = members.get(Number(member[1]));
|
|
82
|
+
if (!found || found.inherited) return json({ message: "404 Member Not Found" }, 404);
|
|
83
|
+
if (request.method === "DELETE") {
|
|
84
|
+
members.delete(found.id);
|
|
85
|
+
return new Response(null, { status: 204 });
|
|
86
|
+
}
|
|
87
|
+
if (request.method === "PUT") {
|
|
88
|
+
found.accessLevel = (await request.json()).access_level;
|
|
89
|
+
return json(shown(found));
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
if (rest === "/invitations") {
|
|
93
|
+
if (accessLevel < 40) return forbidden();
|
|
94
|
+
if (request.method === "GET") return list([...invitations].map(([email, level]) => ({
|
|
95
|
+
invite_email: email,
|
|
96
|
+
access_level: level
|
|
97
|
+
})));
|
|
98
|
+
if (request.method === "POST") {
|
|
99
|
+
const body = await request.json();
|
|
100
|
+
if (invitations.has(body.email)) return json({
|
|
101
|
+
status: "error",
|
|
102
|
+
message: { [body.email]: "Invite email has already been taken" }
|
|
103
|
+
}, 201);
|
|
104
|
+
invitations.set(body.email, body.access_level);
|
|
105
|
+
return json({ status: "success" }, 201);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
const invitation = rest.match(/^\/invitations\/([^/]+)$/);
|
|
109
|
+
if (invitation) {
|
|
110
|
+
if (accessLevel < 40) return forbidden();
|
|
111
|
+
const email = decodeURIComponent(invitation[1] ?? "");
|
|
112
|
+
if (!invitations.has(email)) return json({ message: "404 Not Found" }, 404);
|
|
113
|
+
if (request.method === "DELETE") {
|
|
114
|
+
invitations.delete(email);
|
|
115
|
+
return new Response(null, { status: 204 });
|
|
116
|
+
}
|
|
117
|
+
if (request.method === "PUT") {
|
|
118
|
+
invitations.set(email, (await request.json()).access_level);
|
|
119
|
+
return json({ invite_email: email });
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
const protection = rest.match(/^\/protected_branches\/([^/]+)$/);
|
|
123
|
+
if (protection) {
|
|
124
|
+
const name = decodeURIComponent(protection[1] ?? "");
|
|
125
|
+
const found = protectedBranches.get(name);
|
|
126
|
+
if (!found) return json({ message: "404 Not found" }, 404);
|
|
127
|
+
if (request.method === "GET") return json({
|
|
128
|
+
name,
|
|
129
|
+
push_access_levels: [{ access_level: found.push }],
|
|
130
|
+
merge_access_levels: [{ access_level: found.merge }],
|
|
131
|
+
allow_force_push: found.forcePush
|
|
132
|
+
});
|
|
133
|
+
if (request.method === "DELETE") {
|
|
134
|
+
if (accessLevel < 40) return forbidden();
|
|
135
|
+
protectedBranches.delete(name);
|
|
136
|
+
return new Response(null, { status: 204 });
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
if (rest === "/protected_branches" && request.method === "POST") {
|
|
140
|
+
if (accessLevel < 40) return forbidden();
|
|
141
|
+
const body = await request.json();
|
|
142
|
+
if (protectedBranches.has(body.name)) return json({ message: "Protected branch already exists" }, 409);
|
|
143
|
+
protectedBranches.set(body.name, {
|
|
144
|
+
push: body.push_access_level ?? 40,
|
|
145
|
+
merge: body.merge_access_level ?? 40,
|
|
146
|
+
forcePush: body.allow_force_push ?? false
|
|
147
|
+
});
|
|
148
|
+
return json({ name: body.name }, 201);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
27
151
|
const codes = /* @__PURE__ */ new Map();
|
|
28
152
|
const refreshTokens = /* @__PURE__ */ new Map();
|
|
29
153
|
const pipelines = /* @__PURE__ */ new Map();
|
|
154
|
+
/** The project's settings for updates: whether job tokens may push, and its pipeline schedules. */
|
|
155
|
+
const updateSettings = {
|
|
156
|
+
jobTokenPush: false,
|
|
157
|
+
schedules: []
|
|
158
|
+
};
|
|
159
|
+
/** Pipelines the update ran in: scheduled, or started through the API, with the pages job's log. */
|
|
160
|
+
const updateRuns = [];
|
|
161
|
+
/** Simulates a scheduled (or API) pipeline whose pages job logged `trace`. */
|
|
162
|
+
function addUpdateRun(run) {
|
|
163
|
+
const added = {
|
|
164
|
+
id: 1e3 + updateRuns.length,
|
|
165
|
+
source: "schedule",
|
|
166
|
+
status: "success",
|
|
167
|
+
trace: "",
|
|
168
|
+
...run
|
|
169
|
+
};
|
|
170
|
+
updateRuns.push(added);
|
|
171
|
+
return added;
|
|
172
|
+
}
|
|
173
|
+
/** Failed jobs, by commit, which tests add with `failBuild()`. */
|
|
174
|
+
const failures = /* @__PURE__ */ new Map();
|
|
175
|
+
/**
|
|
176
|
+
* Simulates the pipeline for a commit failing: its `pages` job (or another,
|
|
177
|
+
* such as `pages:deploy`) fails for a GitLab failure reason, with a log.
|
|
178
|
+
*/
|
|
179
|
+
function failBuild(sha, failure = {}) {
|
|
180
|
+
if (!pipelines.has(sha)) pipelines.set(sha, { checks: 0 });
|
|
181
|
+
failures.set(sha, {
|
|
182
|
+
job: failure.job ?? "pages",
|
|
183
|
+
failureReason: failure.failureReason ?? "script_failure",
|
|
184
|
+
trace: failure.trace ?? ""
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
const pipelineId = (sha) => [...pipelines.keys()].indexOf(sha) + 1;
|
|
30
188
|
const requests = [];
|
|
31
189
|
let counter = 0;
|
|
32
|
-
|
|
33
|
-
|
|
190
|
+
const created = /* @__PURE__ */ new Map();
|
|
191
|
+
const pages = options.pages ? {
|
|
192
|
+
uniqueDomain: options.pages.uniqueDomain ?? true,
|
|
193
|
+
forceHttps: true,
|
|
194
|
+
primaryDomain: null,
|
|
195
|
+
domains: /* @__PURE__ */ new Map(),
|
|
196
|
+
dnsReady: /* @__PURE__ */ new Set()
|
|
197
|
+
} : void 0;
|
|
198
|
+
function pagesUrl() {
|
|
199
|
+
const [namespace = "", ...rest] = options.project.split("/");
|
|
200
|
+
return pages?.uniqueDomain ? `https://${rest.join("-")}-1a2b3c.gitlab.io` : `https://${namespace}.gitlab.io/${rest.join("/")}`;
|
|
201
|
+
}
|
|
202
|
+
/** GitLab Pages' settings and custom domains, for the site's own project. */
|
|
203
|
+
async function handlePages(request, rest, accessLevel) {
|
|
204
|
+
if (!rest.startsWith("/pages")) return void 0;
|
|
205
|
+
if (!pages) return json({ message: "404 Not Found" }, 404);
|
|
206
|
+
if (accessLevel < 40) return json({ message: "403 Forbidden" }, 403);
|
|
207
|
+
const method = request.method;
|
|
208
|
+
const domainJson = (name, domain) => ({
|
|
209
|
+
domain: name,
|
|
210
|
+
url: `https://${name}`,
|
|
211
|
+
verified: domain.verified,
|
|
212
|
+
verification_code: domain.verificationCode,
|
|
213
|
+
auto_ssl_enabled: true,
|
|
214
|
+
certificate: domain.certificate ? {
|
|
215
|
+
subject: `/CN=${name}`,
|
|
216
|
+
expired: false
|
|
217
|
+
} : null
|
|
218
|
+
});
|
|
219
|
+
if (rest === "/pages" && method === "GET") return json({
|
|
220
|
+
url: pagesUrl(),
|
|
221
|
+
is_unique_domain_enabled: pages.uniqueDomain,
|
|
222
|
+
force_https: pages.forceHttps,
|
|
223
|
+
primary_domain: pages.primaryDomain
|
|
224
|
+
});
|
|
225
|
+
if (rest === "/pages" && method === "PATCH") {
|
|
226
|
+
const body = await request.json();
|
|
227
|
+
if (body.pages_primary_domain !== void 0 && !pages.domains.has(body.pages_primary_domain)) return json({ message: "Primary domain must be one of the project's domains" }, 400);
|
|
228
|
+
if (body.pages_unique_domain_enabled !== void 0) pages.uniqueDomain = body.pages_unique_domain_enabled;
|
|
229
|
+
if (body.pages_https_only !== void 0) pages.forceHttps = body.pages_https_only;
|
|
230
|
+
if (body.pages_primary_domain !== void 0) pages.primaryDomain = body.pages_primary_domain;
|
|
231
|
+
return json({
|
|
232
|
+
url: pagesUrl(),
|
|
233
|
+
is_unique_domain_enabled: pages.uniqueDomain,
|
|
234
|
+
force_https: pages.forceHttps
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
if (rest === "/pages/domains" && method === "GET") return json([...pages.domains].map(([name, domain]) => domainJson(name, domain)));
|
|
238
|
+
if (rest === "/pages/domains" && method === "POST") {
|
|
239
|
+
const body = await request.json();
|
|
240
|
+
if (pages.domains.has(body.domain)) return json({ message: { domain: ["has already been taken"] } }, 400);
|
|
241
|
+
if (!/^[a-z0-9.-]+\.[a-z]+$/.test(body.domain)) return json({ message: { domain: ["is invalid"] } }, 400);
|
|
242
|
+
counter += 1;
|
|
243
|
+
const domain = {
|
|
244
|
+
verified: false,
|
|
245
|
+
verificationCode: `code-${counter}`,
|
|
246
|
+
certificate: false
|
|
247
|
+
};
|
|
248
|
+
pages.domains.set(body.domain, domain);
|
|
249
|
+
return json(domainJson(body.domain, domain), 201);
|
|
250
|
+
}
|
|
251
|
+
const one = rest.match(/^\/pages\/domains\/([^/]+)(\/verify)?$/);
|
|
252
|
+
const name = decodeURIComponent(one?.[1] ?? "");
|
|
253
|
+
const domain = pages.domains.get(name);
|
|
254
|
+
if (!one || !domain) return json({ message: "404 Not Found" }, 404);
|
|
255
|
+
if (one[2] && method === "PUT") {
|
|
256
|
+
if (!pages.dnsReady.has(name)) return json({ message: "Failed to verify domain ownership" }, 400);
|
|
257
|
+
domain.verified = true;
|
|
258
|
+
return json(domainJson(name, domain));
|
|
259
|
+
}
|
|
260
|
+
if (method === "GET") return json(domainJson(name, domain));
|
|
261
|
+
if (method === "DELETE") {
|
|
262
|
+
pages.domains.delete(name);
|
|
263
|
+
if (pages.primaryDomain === name) pages.primaryDomain = null;
|
|
264
|
+
return new Response(null, { status: 204 });
|
|
265
|
+
}
|
|
266
|
+
return json({ message: "404 Not Found" }, 404);
|
|
267
|
+
}
|
|
268
|
+
const groups = (options.groups ?? []).map((group, index) => ({
|
|
269
|
+
id: 100 + index,
|
|
270
|
+
path: group
|
|
271
|
+
}));
|
|
272
|
+
/** Creating projects, and committing to the projects created, as the setup page does. */
|
|
273
|
+
async function handleCreated(request, path, user) {
|
|
274
|
+
if (path === "/namespaces") return json([{
|
|
275
|
+
id: 1,
|
|
276
|
+
kind: "user",
|
|
277
|
+
full_path: user.username,
|
|
278
|
+
name: user.name ?? user.username
|
|
279
|
+
}, ...groups.map((group) => ({
|
|
280
|
+
id: group.id,
|
|
281
|
+
kind: "group",
|
|
282
|
+
full_path: group.path,
|
|
283
|
+
name: group.path
|
|
284
|
+
}))]);
|
|
285
|
+
if (path === "/projects" && request.method === "POST") {
|
|
286
|
+
const body = await request.json();
|
|
287
|
+
const namespace = body.namespace_id === void 0 || body.namespace_id === 1 ? user.username : groups.find((group) => group.id === body.namespace_id)?.path;
|
|
288
|
+
if (!namespace) return json({ message: "404 Namespace Not Found" }, 404);
|
|
289
|
+
if (!/^[\w.-]+$/.test(body.path)) return json({ message: { path: ["is invalid"] } }, 400);
|
|
290
|
+
const fullPath = `${namespace}/${body.path}`;
|
|
291
|
+
if (created.has(fullPath) || fullPath === options.project) return json({ message: { name: ["has already been taken"] } }, 400);
|
|
292
|
+
counter += 1;
|
|
293
|
+
const project = {
|
|
294
|
+
id: 1e3 + counter,
|
|
295
|
+
path: fullPath,
|
|
296
|
+
visibility: body.visibility,
|
|
297
|
+
description: body.description,
|
|
298
|
+
pagesAccessLevel: body.pages_access_level,
|
|
299
|
+
repo: new FakeRepo({})
|
|
300
|
+
};
|
|
301
|
+
created.set(fullPath, project);
|
|
302
|
+
return json({
|
|
303
|
+
id: project.id,
|
|
304
|
+
path_with_namespace: fullPath,
|
|
305
|
+
web_url: `${webUrl}/${fullPath}`
|
|
306
|
+
}, 201);
|
|
307
|
+
}
|
|
308
|
+
const match = path.match(/^\/projects\/([^/]+)(\/.*)?$/);
|
|
309
|
+
const key = decodeURIComponent(match?.[1] ?? "");
|
|
310
|
+
const project = created.get(key) ?? [...created.values()].find((candidate) => String(candidate.id) === key);
|
|
311
|
+
if (!match || !project) return void 0;
|
|
312
|
+
const rest = match[2] ?? "";
|
|
313
|
+
if (rest === "") return json({
|
|
314
|
+
id: project.id,
|
|
315
|
+
path_with_namespace: project.path,
|
|
316
|
+
default_branch: "main"
|
|
317
|
+
});
|
|
318
|
+
if (rest === "/repository/commits" && request.method === "POST") {
|
|
319
|
+
const body = await request.json();
|
|
320
|
+
return json({
|
|
321
|
+
id: project.repo.commit(body.actions.map((action) => action.encoding === "base64" ? {
|
|
322
|
+
path: action.file_path,
|
|
323
|
+
bytes: decodeBase64(action.content ?? "")
|
|
324
|
+
} : {
|
|
325
|
+
path: action.file_path,
|
|
326
|
+
content: action.content ?? ""
|
|
327
|
+
}), body.commit_message, { branch: body.branch }),
|
|
328
|
+
message: body.commit_message
|
|
329
|
+
}, 201);
|
|
330
|
+
}
|
|
331
|
+
return json({ message: "404 Not Found" }, 404);
|
|
332
|
+
}
|
|
333
|
+
function commit(changes, message, author) {
|
|
334
|
+
const sha = repo.commit(changes, message, { author });
|
|
34
335
|
if (options.deployAfterChecks !== void 0) pipelines.set(sha, { checks: 0 });
|
|
35
336
|
return sha;
|
|
36
337
|
}
|
|
@@ -98,16 +399,31 @@ function fakeGitLab(options) {
|
|
|
98
399
|
name: user.name,
|
|
99
400
|
avatar_url: `https://avatars.example/${user.username}`
|
|
100
401
|
});
|
|
402
|
+
const setup = await handleCreated(request, path, user);
|
|
403
|
+
if (setup) return setup;
|
|
404
|
+
if (path === "/users") {
|
|
405
|
+
const name = url.searchParams.get("username") ?? "";
|
|
406
|
+
const id = [...accounts].find(([username]) => username.toLowerCase() === name.toLowerCase())?.[1];
|
|
407
|
+
return json(id === void 0 ? [] : [{
|
|
408
|
+
id,
|
|
409
|
+
username: name
|
|
410
|
+
}]);
|
|
411
|
+
}
|
|
101
412
|
const projectMatch = path.match(/^\/projects\/([^/]+)(\/.*)?$/);
|
|
102
413
|
if (!projectMatch || decodeURIComponent(projectMatch[1] ?? "") !== options.project) return json({ message: "404 Project Not Found" }, 404);
|
|
103
414
|
const rest = projectMatch[2] ?? "";
|
|
104
|
-
const accessLevel = user.accessLevel ??
|
|
105
|
-
|
|
415
|
+
const accessLevel = members.get(accounts.get(user.username) ?? 0)?.accessLevel ?? 0;
|
|
416
|
+
const pagesResponse = await handlePages(request, rest, accessLevel);
|
|
417
|
+
if (pagesResponse) return pagesResponse;
|
|
418
|
+
const membersResponse = await handleMembers(request, url, rest, accessLevel);
|
|
419
|
+
if (membersResponse) return membersResponse;
|
|
420
|
+
if (rest === "" && request.method !== "PUT") return json({
|
|
106
421
|
default_branch: repo.defaultBranch,
|
|
107
422
|
permissions: {
|
|
108
423
|
project_access: { access_level: accessLevel },
|
|
109
424
|
group_access: null
|
|
110
|
-
}
|
|
425
|
+
},
|
|
426
|
+
ci_push_repository_for_job_token_allowed: updateSettings.jobTokenPush
|
|
111
427
|
});
|
|
112
428
|
const branch = rest.match(/^\/repository\/branches\/(.+)$/);
|
|
113
429
|
if (branch) {
|
|
@@ -133,6 +449,13 @@ function fakeGitLab(options) {
|
|
|
133
449
|
const content = repo.blobs.get(blob[1] ?? "");
|
|
134
450
|
return content === void 0 ? json({ message: "404 Blob Not Found" }, 404) : new Response(fakeFileBytes(content));
|
|
135
451
|
}
|
|
452
|
+
const raw = rest.match(/^\/repository\/files\/([^/]+)\/raw$/);
|
|
453
|
+
if (raw) {
|
|
454
|
+
const ref = url.searchParams.get("ref") ?? repo.defaultBranch;
|
|
455
|
+
if (!repo.commitAt(repo.branches.get(ref) ?? ref)) return json({ message: "404 Commit Not Found" }, 404);
|
|
456
|
+
const content = repo.files(ref).get(decodeURIComponent(raw[1] ?? ""));
|
|
457
|
+
return content === void 0 ? json({ message: "404 File Not Found" }, 404) : new Response(fakeFileBytes(content));
|
|
458
|
+
}
|
|
136
459
|
const file = rest.match(/^\/repository\/files\/([^/]+)$/);
|
|
137
460
|
if (file) {
|
|
138
461
|
const filePath = decodeURIComponent(file[1] ?? "");
|
|
@@ -145,6 +468,8 @@ function fakeGitLab(options) {
|
|
|
145
468
|
}
|
|
146
469
|
if (rest === "/repository/commits" && request.method === "POST") {
|
|
147
470
|
if (accessLevel < 30) return json({ message: "403 Forbidden" }, 403);
|
|
471
|
+
const pushTo = await request.clone().json();
|
|
472
|
+
if (accessLevel < (protectedBranches.get(pushTo.branch)?.push ?? 0)) return json({ message: "You are not allowed to push into this branch" }, 403);
|
|
148
473
|
const body = await request.json();
|
|
149
474
|
const files = repo.files(body.branch);
|
|
150
475
|
for (const action of body.actions) {
|
|
@@ -162,10 +487,25 @@ function fakeGitLab(options) {
|
|
|
162
487
|
} : {
|
|
163
488
|
path: action.file_path,
|
|
164
489
|
content: action.content ?? ""
|
|
165
|
-
}), body.commit_message),
|
|
490
|
+
}), body.commit_message, user.name ?? user.username),
|
|
166
491
|
message: body.commit_message
|
|
167
492
|
}, 201);
|
|
168
493
|
}
|
|
494
|
+
if (rest === "/repository/commits" && request.method === "GET") {
|
|
495
|
+
const ref = url.searchParams.get("ref_name") ?? repo.defaultBranch;
|
|
496
|
+
const file = url.searchParams.get("path");
|
|
497
|
+
if (!repo.commitAt(repo.branches.get(ref) ?? ref)) return json({ message: "404 Reference Not Found" }, 404);
|
|
498
|
+
const perPage = Number(url.searchParams.get("per_page") ?? 20);
|
|
499
|
+
const page = Number(url.searchParams.get("page") ?? 1);
|
|
500
|
+
return json((file ? repo.history(file, ref) : []).slice((page - 1) * perPage, page * perPage).map((commit) => ({
|
|
501
|
+
id: commit.sha,
|
|
502
|
+
title: commit.message.split("\n")[0],
|
|
503
|
+
message: commit.message,
|
|
504
|
+
author_name: commit.author ?? "GitLab",
|
|
505
|
+
authored_date: commit.date,
|
|
506
|
+
committed_date: commit.date
|
|
507
|
+
})));
|
|
508
|
+
}
|
|
169
509
|
if (rest === "/repository/compare") return json({ diffs: repo.changedPaths(url.searchParams.get("from") ?? "", url.searchParams.get("to") ?? "").map((changedPath) => ({
|
|
170
510
|
old_path: changedPath,
|
|
171
511
|
new_path: changedPath
|
|
@@ -174,6 +514,15 @@ function fakeGitLab(options) {
|
|
|
174
514
|
if (statuses) {
|
|
175
515
|
const pipeline = pipelines.get(statuses[1] ?? "");
|
|
176
516
|
if (!pipeline) return json([]);
|
|
517
|
+
const failure = failures.get(statuses[1] ?? "");
|
|
518
|
+
if (failure) {
|
|
519
|
+
const id = pipelineId(statuses[1] ?? "");
|
|
520
|
+
return json([{
|
|
521
|
+
name: failure.job,
|
|
522
|
+
status: "failed",
|
|
523
|
+
target_url: `${webUrl}/${options.project}/-/jobs/${id}`
|
|
524
|
+
}]);
|
|
525
|
+
}
|
|
177
526
|
pipeline.checks += 1;
|
|
178
527
|
return json([{
|
|
179
528
|
name: "pages",
|
|
@@ -181,14 +530,107 @@ function fakeGitLab(options) {
|
|
|
181
530
|
target_url: `${webUrl}/${options.project}/-/jobs/1`
|
|
182
531
|
}]);
|
|
183
532
|
}
|
|
184
|
-
if (rest === "/pipelines") return json([...
|
|
185
|
-
id:
|
|
186
|
-
|
|
533
|
+
if (rest === "/pipelines" && url.searchParams.has("ref")) return json([...updateRuns].reverse().map((run) => ({
|
|
534
|
+
id: run.id,
|
|
535
|
+
source: run.source,
|
|
536
|
+
status: run.status,
|
|
537
|
+
created_at: "2026-01-05T04:17:00Z",
|
|
538
|
+
web_url: `${webUrl}/${options.project}/-/pipelines/${run.id}`
|
|
187
539
|
})));
|
|
540
|
+
if (rest === "/pipeline" && request.method === "POST") {
|
|
541
|
+
if (accessLevel < 30) return json({ message: "403 Forbidden" }, 403);
|
|
542
|
+
const run = addUpdateRun({
|
|
543
|
+
source: "api",
|
|
544
|
+
status: "created",
|
|
545
|
+
variables: (await request.json()).variables
|
|
546
|
+
});
|
|
547
|
+
return json({
|
|
548
|
+
id: run.id,
|
|
549
|
+
status: run.status
|
|
550
|
+
}, 201);
|
|
551
|
+
}
|
|
552
|
+
if (rest === "/pipeline_schedules") {
|
|
553
|
+
if (accessLevel < 30) return json({ message: "403 Forbidden" }, 403);
|
|
554
|
+
if (request.method === "POST") {
|
|
555
|
+
const body = await request.json();
|
|
556
|
+
const schedule = {
|
|
557
|
+
id: updateSettings.schedules.length + 1,
|
|
558
|
+
ref: body.ref,
|
|
559
|
+
cron: body.cron,
|
|
560
|
+
active: body.active ?? true
|
|
561
|
+
};
|
|
562
|
+
updateSettings.schedules.push(schedule);
|
|
563
|
+
return json(schedule, 201);
|
|
564
|
+
}
|
|
565
|
+
return json(updateSettings.schedules);
|
|
566
|
+
}
|
|
567
|
+
if (rest === "" && request.method === "PUT") {
|
|
568
|
+
if (accessLevel < 40) return json({ message: "403 Forbidden" }, 403);
|
|
569
|
+
const body = await request.json();
|
|
570
|
+
if (body.ci_push_repository_for_job_token_allowed !== void 0) updateSettings.jobTokenPush = body.ci_push_repository_for_job_token_allowed;
|
|
571
|
+
return json({ ci_push_repository_for_job_token_allowed: updateSettings.jobTokenPush });
|
|
572
|
+
}
|
|
573
|
+
const updateJobs = rest.match(/^\/pipelines\/(\d{4,})\/jobs$/);
|
|
574
|
+
if (updateJobs) {
|
|
575
|
+
const run = updateRuns.find((candidate) => candidate.id === Number(updateJobs[1]));
|
|
576
|
+
return run ? json([{
|
|
577
|
+
id: run.id,
|
|
578
|
+
name: "pages",
|
|
579
|
+
status: run.status
|
|
580
|
+
}]) : json({ message: "404 Not Found" }, 404);
|
|
581
|
+
}
|
|
582
|
+
const updateTrace = rest.match(/^\/jobs\/(\d{4,})\/trace$/);
|
|
583
|
+
if (updateTrace) {
|
|
584
|
+
const run = updateRuns.find((candidate) => candidate.id === Number(updateTrace[1]));
|
|
585
|
+
return run ? new Response(run.trace) : json({ message: "404 Not Found" }, 404);
|
|
586
|
+
}
|
|
587
|
+
if (rest === "/pipelines") {
|
|
588
|
+
const sha = url.searchParams.get("sha");
|
|
589
|
+
return json([...pipelines.keys()].map((pipelineSha, index) => ({
|
|
590
|
+
id: index + 1,
|
|
591
|
+
sha: pipelineSha
|
|
592
|
+
})).filter((pipeline) => !sha || pipeline.sha === sha));
|
|
593
|
+
}
|
|
594
|
+
const pipelineJobs = rest.match(/^\/pipelines\/(\d+)\/jobs$/);
|
|
595
|
+
if (pipelineJobs) {
|
|
596
|
+
const id = Number(pipelineJobs[1]);
|
|
597
|
+
const sha = [...pipelines.keys()][id - 1];
|
|
598
|
+
if (!sha) return json({ message: "404 Not Found" }, 404);
|
|
599
|
+
const failure = failures.get(sha);
|
|
600
|
+
return json([failure ? {
|
|
601
|
+
id,
|
|
602
|
+
name: failure.job,
|
|
603
|
+
status: "failed",
|
|
604
|
+
failure_reason: failure.failureReason
|
|
605
|
+
} : {
|
|
606
|
+
id,
|
|
607
|
+
name: "pages",
|
|
608
|
+
status: "success"
|
|
609
|
+
}]);
|
|
610
|
+
}
|
|
611
|
+
const trace = rest.match(/^\/jobs\/(\d+)\/trace$/);
|
|
612
|
+
if (trace) {
|
|
613
|
+
const sha = [...pipelines.keys()][Number(trace[1]) - 1];
|
|
614
|
+
const failure = sha ? failures.get(sha) : void 0;
|
|
615
|
+
return failure ? new Response(failure.trace) : json({ message: "404 Not Found" }, 404);
|
|
616
|
+
}
|
|
188
617
|
return json({ message: "404 Not Found" }, 404);
|
|
189
618
|
}
|
|
190
619
|
return {
|
|
620
|
+
/** The project's members by user id, invitations by email, and protected branches, which tests can change. */
|
|
621
|
+
members,
|
|
622
|
+
invitations,
|
|
623
|
+
protectedBranches,
|
|
624
|
+
failBuild,
|
|
625
|
+
/** The project's update settings, and the pipelines the update ran in, which tests add with `addUpdateRun()`. */
|
|
626
|
+
updateSettings,
|
|
627
|
+
updateRuns,
|
|
628
|
+
addUpdateRun,
|
|
191
629
|
repo,
|
|
630
|
+
/** Projects created through the API, by full path. */
|
|
631
|
+
created,
|
|
632
|
+
/** The site's GitLab Pages, if it has them, which tests can change. */
|
|
633
|
+
pages,
|
|
192
634
|
requests,
|
|
193
635
|
commit,
|
|
194
636
|
authorize,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodfellow-cms/gitlab",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Edit a Goodfellow site stored on GitLab, straight from the browser.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"goodfellow",
|
|
@@ -10,12 +10,12 @@
|
|
|
10
10
|
"gitlab",
|
|
11
11
|
"git-based-cms"
|
|
12
12
|
],
|
|
13
|
-
"homepage": "https://
|
|
14
|
-
"bugs": "https://github.com/
|
|
13
|
+
"homepage": "https://goodfellow-cms.github.io/goodfellow-cms/docs/hosts",
|
|
14
|
+
"bugs": "https://github.com/goodfellow-cms/goodfellow-cms/issues",
|
|
15
15
|
"license": "MIT",
|
|
16
16
|
"repository": {
|
|
17
17
|
"type": "git",
|
|
18
|
-
"url": "git+https://github.com/
|
|
18
|
+
"url": "git+https://github.com/goodfellow-cms/goodfellow-cms.git",
|
|
19
19
|
"directory": "packages/gitlab"
|
|
20
20
|
},
|
|
21
21
|
"type": "module",
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
"dist"
|
|
36
36
|
],
|
|
37
37
|
"dependencies": {
|
|
38
|
-
"@goodfellow-cms/core": "0.
|
|
38
|
+
"@goodfellow-cms/core": "0.2.0"
|
|
39
39
|
},
|
|
40
40
|
"devDependencies": {
|
|
41
41
|
"@puckeditor/core": "0.23.0"
|