@goodfellow-cms/github 0.2.0 → 0.4.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/deploy-keys-DAj2oX-N.js +108 -0
- package/dist/index.d.ts +98 -7
- package/dist/index.js +1079 -203
- package/dist/testing.d.ts +73 -2
- package/dist/testing.js +572 -58
- package/package.json +4 -2
package/dist/testing.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { n as openSealedSecret } from "./deploy-keys-DAj2oX-N.js";
|
|
1
2
|
import { decodeBase64, encodeBase64Bytes } from "@goodfellow-cms/core";
|
|
2
3
|
import { FakeConflictError, FakeRepo, fakeFileBytes, fakeFileFromBytes } from "@goodfellow-cms/core/testing";
|
|
3
4
|
//#region src/testing.ts
|
|
@@ -6,6 +7,28 @@ import { FakeConflictError, FakeRepo, fakeFileBytes, fakeFileFromBytes } from "@
|
|
|
6
7
|
* an in-memory repository. For tests only: pass `fake.fetch` as the backend's
|
|
7
8
|
* `fetch`, or route browser requests to `fake.handle` in end-to-end tests.
|
|
8
9
|
*/
|
|
10
|
+
/** GitHub's number for an account, worked out from its login when a test doesn't give one. */
|
|
11
|
+
function accountId(login) {
|
|
12
|
+
let hash = 7;
|
|
13
|
+
for (const char of login.toLowerCase()) hash = (hash * 31 + char.charCodeAt(0)) % 9e6;
|
|
14
|
+
return 1e6 + hash;
|
|
15
|
+
}
|
|
16
|
+
function same(a, b) {
|
|
17
|
+
return a.toLowerCase() === b.toLowerCase();
|
|
18
|
+
}
|
|
19
|
+
function covers(installation, fullName) {
|
|
20
|
+
const [owner = ""] = fullName.split("/");
|
|
21
|
+
if (!same(installation.account, owner)) return false;
|
|
22
|
+
return installation.repositories === "all" || installation.repositories.some((repo) => same(repo, fullName));
|
|
23
|
+
}
|
|
24
|
+
function decodeJwtPart(part) {
|
|
25
|
+
try {
|
|
26
|
+
const base64 = part.replace(/-/g, "+").replace(/_/g, "/");
|
|
27
|
+
return JSON.parse(atob(base64 + "=".repeat((4 - base64.length % 4) % 4)));
|
|
28
|
+
} catch {
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
9
32
|
function json(body, status = 200) {
|
|
10
33
|
return new Response(JSON.stringify(body), {
|
|
11
34
|
status,
|
|
@@ -18,10 +41,22 @@ function fakeGitHub(options) {
|
|
|
18
41
|
login: "editor",
|
|
19
42
|
name: "Test Editor"
|
|
20
43
|
} };
|
|
44
|
+
/** Every token that works: the test's own, and those apps' sign-ins make. */
|
|
45
|
+
const principals = new Map(Object.entries(tokens));
|
|
46
|
+
const apps = options.apps ?? [];
|
|
47
|
+
const issued = [];
|
|
48
|
+
/** OAuth codes waiting to be exchanged, and refresh tokens, by the app and person they're for. */
|
|
49
|
+
const codes = /* @__PURE__ */ new Map();
|
|
50
|
+
const refreshTokens = /* @__PURE__ */ new Map();
|
|
51
|
+
/** Who each commit made through the REST API names as its author. */
|
|
52
|
+
const commitAuthors = /* @__PURE__ */ new Map();
|
|
53
|
+
let nextToken = 1;
|
|
54
|
+
let nextRepoId = 100;
|
|
21
55
|
const deployments = [];
|
|
22
56
|
const runs = [];
|
|
23
57
|
const requests = [];
|
|
24
58
|
let nextDeployment = 1;
|
|
59
|
+
let graphqlReads = 0;
|
|
25
60
|
const created = /* @__PURE__ */ new Map();
|
|
26
61
|
const [owner = "", name = ""] = options.repo.split("/");
|
|
27
62
|
const pages = { current: options.pages ? {
|
|
@@ -60,6 +95,24 @@ function fakeGitHub(options) {
|
|
|
60
95
|
permissions: invitation.permission
|
|
61
96
|
})));
|
|
62
97
|
}
|
|
98
|
+
const permission = rest.match(/^\/collaborators\/([^/]+)\/permission$/);
|
|
99
|
+
if (permission && request.method === "GET") {
|
|
100
|
+
const login = decodeURIComponent(permission[1] ?? "");
|
|
101
|
+
if (!accounts.has(login)) return json({ message: "Not Found" }, 404);
|
|
102
|
+
const role = collaborators.get(login);
|
|
103
|
+
const level = role === "admin" ? "admin" : role === "push" ? "write" : role === "read" ? "read" : "none";
|
|
104
|
+
return json({
|
|
105
|
+
permission: level,
|
|
106
|
+
user: {
|
|
107
|
+
login,
|
|
108
|
+
permissions: {
|
|
109
|
+
admin: level === "admin",
|
|
110
|
+
push: level === "admin" || level === "write",
|
|
111
|
+
pull: level !== "none"
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
}
|
|
63
116
|
const collaborator = rest.match(/^\/collaborators\/([^/]+)$/);
|
|
64
117
|
if (collaborator) {
|
|
65
118
|
if (!canManage) return forbidden();
|
|
@@ -140,11 +193,28 @@ function fakeGitHub(options) {
|
|
|
140
193
|
const [invitation] = index === -1 ? [] : invitations.splice(index, 1);
|
|
141
194
|
if (invitation) collaborators.set(login, invitation.permission === "admin" ? "admin" : "push");
|
|
142
195
|
}
|
|
196
|
+
/** Adds another repository, as if someone had made it, such as a site's code repository beside its content's. */
|
|
197
|
+
function addRepo(fullName, repoOptions = {}) {
|
|
198
|
+
const added = {
|
|
199
|
+
id: nextRepoId++,
|
|
200
|
+
fullName,
|
|
201
|
+
private: repoOptions.private === true,
|
|
202
|
+
repo: new FakeRepo(repoOptions.files ?? {}, "main"),
|
|
203
|
+
rulesets: [],
|
|
204
|
+
deployKeys: [],
|
|
205
|
+
secrets: /* @__PURE__ */ new Map(),
|
|
206
|
+
collaborators: new Map(Object.entries(repoOptions.collaborators ?? {}))
|
|
207
|
+
};
|
|
208
|
+
created.set(fullName, added);
|
|
209
|
+
return added;
|
|
210
|
+
}
|
|
143
211
|
/** Repository creation and the Git data API, for repositories created through the fake. */
|
|
144
|
-
async function handleCreated(request, path,
|
|
212
|
+
async function handleCreated(request, path, principal) {
|
|
213
|
+
const login = principal.login;
|
|
145
214
|
if (path === "/user/orgs") return json((options.orgs ?? []).map((org) => ({ login: org })));
|
|
146
215
|
const create = path === "/user/repos" ? login : path.match(/^\/orgs\/([^/]+)\/repos$/)?.[1];
|
|
147
216
|
if (create !== void 0 && request.method === "POST") {
|
|
217
|
+
if (principal.bot || principal.app && principal.permissions?.administration !== "write") return json({ message: "Resource not accessible by integration" }, 403);
|
|
148
218
|
if (path !== "/user/repos" && !options.orgs?.includes(create)) return json({ message: "Not Found" }, 404);
|
|
149
219
|
const body = await request.json();
|
|
150
220
|
if (!/^[\w.-]+$/.test(body.name)) return json({ message: "Repository creation failed." }, 422);
|
|
@@ -154,14 +224,19 @@ function fakeGitHub(options) {
|
|
|
154
224
|
errors: [{ message: "name already exists on this account" }]
|
|
155
225
|
}, 422);
|
|
156
226
|
const repo = new FakeRepo({ "README.md": `# ${body.name}\n` }, options.newRepoBranch ?? "main");
|
|
227
|
+
const id = nextRepoId++;
|
|
157
228
|
created.set(fullName, {
|
|
229
|
+
id,
|
|
158
230
|
fullName,
|
|
159
231
|
private: body.private === true,
|
|
160
232
|
description: body.description,
|
|
161
233
|
repo,
|
|
162
|
-
rulesets: []
|
|
234
|
+
rulesets: [],
|
|
235
|
+
deployKeys: [],
|
|
236
|
+
secrets: /* @__PURE__ */ new Map()
|
|
163
237
|
});
|
|
164
238
|
return json({
|
|
239
|
+
id,
|
|
165
240
|
full_name: fullName,
|
|
166
241
|
html_url: `https://github.com/${fullName}`,
|
|
167
242
|
default_branch: repo.defaultBranch
|
|
@@ -170,14 +245,55 @@ function fakeGitHub(options) {
|
|
|
170
245
|
const match = path.match(/^\/repos\/([^/]+\/[^/]+)(\/.*)?$/);
|
|
171
246
|
const site = created.get(decodeURIComponent(match?.[1] ?? ""));
|
|
172
247
|
if (!match || !site) return void 0;
|
|
248
|
+
if (!canSee(principal, site.fullName)) return json({ message: "Not Found" }, 404);
|
|
173
249
|
const { repo } = site;
|
|
174
250
|
const rest = match[2] ?? "";
|
|
175
251
|
const method = request.method;
|
|
176
252
|
if (rest === "" && method === "GET") return json({
|
|
253
|
+
id: site.id,
|
|
177
254
|
full_name: site.fullName,
|
|
255
|
+
private: site.private,
|
|
178
256
|
default_branch: repo.defaultBranch,
|
|
179
|
-
permissions: {
|
|
257
|
+
permissions: {
|
|
258
|
+
push: true,
|
|
259
|
+
admin: true
|
|
260
|
+
}
|
|
180
261
|
});
|
|
262
|
+
const tree = rest.match(/^\/git\/trees\/([0-9a-f]{40})$/);
|
|
263
|
+
if (tree && method === "GET" && repo.commitAt(tree[1] ?? "")) {
|
|
264
|
+
const entries = [...repo.files(tree[1] ?? "")].map(([file, content]) => ({
|
|
265
|
+
path: file,
|
|
266
|
+
type: "blob",
|
|
267
|
+
sha: repo.blobSha(content),
|
|
268
|
+
size: fakeFileBytes(content).length
|
|
269
|
+
}));
|
|
270
|
+
return json({
|
|
271
|
+
sha: tree[1],
|
|
272
|
+
tree: entries,
|
|
273
|
+
truncated: false
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
const blob = rest.match(/^\/git\/blobs\/([0-9a-f]{40})$/);
|
|
277
|
+
if (blob && method === "GET") {
|
|
278
|
+
const content = repo.blobs.get(blob[1] ?? "");
|
|
279
|
+
if (content === void 0) return json({ message: "Not Found" }, 404);
|
|
280
|
+
return new Response(fakeFileBytes(content));
|
|
281
|
+
}
|
|
282
|
+
const contents = rest.match(/^\/contents\/(.+)$/);
|
|
283
|
+
if (contents && method === "GET") {
|
|
284
|
+
if (principal.app && !principal.permissions?.contents) return json({ message: "Resource not accessible" }, 403);
|
|
285
|
+
const file = repo.files().get(decodeURIComponent(contents[1] ?? ""));
|
|
286
|
+
if (file === void 0) return json({ message: "Not Found" }, 404);
|
|
287
|
+
return new Response(typeof file === "string" ? file : fakeFileBytes(file));
|
|
288
|
+
}
|
|
289
|
+
const permission = rest.match(/^\/collaborators\/([^/]+)\/permission$/);
|
|
290
|
+
if (permission && method === "GET") {
|
|
291
|
+
const login = decodeURIComponent(permission[1] ?? "");
|
|
292
|
+
return json({
|
|
293
|
+
permission: site.collaborators?.get(login) ?? "none",
|
|
294
|
+
user: { login }
|
|
295
|
+
});
|
|
296
|
+
}
|
|
181
297
|
if (rest === "" && method === "PATCH") {
|
|
182
298
|
const body = await request.json();
|
|
183
299
|
if (body.default_branch) {
|
|
@@ -198,17 +314,6 @@ function fakeGitHub(options) {
|
|
|
198
314
|
repo.branches.delete(decodeURIComponent(ref[1] ?? ""));
|
|
199
315
|
return new Response(null, { status: 204 });
|
|
200
316
|
}
|
|
201
|
-
if (ref && method === "PATCH") {
|
|
202
|
-
const body = await request.json();
|
|
203
|
-
const branch = decodeURIComponent(ref[1] ?? "");
|
|
204
|
-
const commit = repo.commitAt(body.sha);
|
|
205
|
-
if (!commit || !repo.branches.has(branch)) return json({ message: "Reference does not exist" }, 422);
|
|
206
|
-
if (!body.force && commit.parent !== repo.branches.get(branch)) return json({ message: "Update is not a fast forward" }, 422);
|
|
207
|
-
if (site.rulesets.length > 0 && commit.parent !== repo.branches.get(branch)) return json({ message: "Cannot force-push to this branch" }, 422);
|
|
208
|
-
repo.branches.set(branch, body.sha);
|
|
209
|
-
deploy(body.sha);
|
|
210
|
-
return json({ object: { sha: body.sha } });
|
|
211
|
-
}
|
|
212
317
|
if (rest === "/git/refs" && method === "POST") {
|
|
213
318
|
const body = await request.json();
|
|
214
319
|
repo.branches.set(body.ref.replace(/^refs\/heads\//, ""), body.sha);
|
|
@@ -217,20 +322,104 @@ function fakeGitHub(options) {
|
|
|
217
322
|
object: { sha: body.sha }
|
|
218
323
|
}, 201);
|
|
219
324
|
}
|
|
325
|
+
const data = await handleGitData(request, rest, repo, principal, site.rulesets.length > 0);
|
|
326
|
+
if (data) return data;
|
|
327
|
+
if (rest === "/pages" && method === "POST") {
|
|
328
|
+
if (site.private && !options.paidPlan) return json({ message: "Your current plan does not support GitHub Pages for this repository." }, 422);
|
|
329
|
+
site.pages = await request.json();
|
|
330
|
+
const [owner, name] = site.fullName.split("/");
|
|
331
|
+
return json({
|
|
332
|
+
html_url: `https://${owner}.github.io/${name}/`,
|
|
333
|
+
build_type: site.pages.build_type
|
|
334
|
+
}, 201);
|
|
335
|
+
}
|
|
336
|
+
const notAllowed = (permission) => principal.app ? principal.permissions?.[permission] !== "write" : principal.secrets === false;
|
|
337
|
+
if (rest === "/keys" && method === "POST") {
|
|
338
|
+
if (notAllowed("administration")) return json({ message: "Resource not accessible by integration" }, 403);
|
|
339
|
+
const body = await request.json();
|
|
340
|
+
if (!/^ssh-ed25519 [A-Za-z0-9+/=]+( |$)/.test(body.key)) return json({ message: "key is invalid" }, 422);
|
|
341
|
+
site.deployKeys.push({
|
|
342
|
+
title: body.title,
|
|
343
|
+
key: body.key,
|
|
344
|
+
read_only: body.read_only === true
|
|
345
|
+
});
|
|
346
|
+
return json({
|
|
347
|
+
id: site.deployKeys.length,
|
|
348
|
+
...body
|
|
349
|
+
}, 201);
|
|
350
|
+
}
|
|
351
|
+
if (rest === "/actions/secrets/public-key" && method === "GET") {
|
|
352
|
+
if (notAllowed("secrets")) return json({ message: "Resource not accessible by integration" }, 403);
|
|
353
|
+
site.secretKey ??= await crypto.subtle.generateKey({ name: "X25519" }, true, ["deriveBits"]);
|
|
354
|
+
const raw = new Uint8Array(await crypto.subtle.exportKey("raw", site.secretKey.publicKey));
|
|
355
|
+
return json({
|
|
356
|
+
key_id: `key-${site.id}`,
|
|
357
|
+
key: encodeBase64Bytes(raw)
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
const secret = rest.match(/^\/actions\/secrets\/([A-Z0-9_]+)$/);
|
|
361
|
+
if (secret && method === "PUT") {
|
|
362
|
+
if (notAllowed("secrets")) return json({ message: "Resource not accessible by integration" }, 403);
|
|
363
|
+
const body = await request.json();
|
|
364
|
+
if (!site.secretKey || body.key_id !== `key-${site.id}`) return json({ message: "Bad key id" }, 422);
|
|
365
|
+
try {
|
|
366
|
+
site.secrets.set(secret[1] ?? "", await openSealedSecret(decodeBase64(body.encrypted_value), site.secretKey));
|
|
367
|
+
} catch {
|
|
368
|
+
return json({ message: "Bad encrypted value" }, 422);
|
|
369
|
+
}
|
|
370
|
+
return new Response(null, { status: 201 });
|
|
371
|
+
}
|
|
372
|
+
if (rest === "/rulesets" && method === "POST") {
|
|
373
|
+
if (site.private && !options.paidPlan) return json({ message: "Upgrade to GitHub Pro" }, 403);
|
|
374
|
+
site.rulesets.push(await request.json());
|
|
375
|
+
return json({ id: site.rulesets.length }, 201);
|
|
376
|
+
}
|
|
377
|
+
return json({ message: "Not Found" }, 404);
|
|
378
|
+
}
|
|
379
|
+
/** Whether a token may see a repository: an app's only where it's installed, and an installation's only its own. */
|
|
380
|
+
function canSee(principal, fullName) {
|
|
381
|
+
if (principal.repositories) return principal.repositories.some((repo) => same(repo, fullName));
|
|
382
|
+
if (principal.app) return principal.app.installations.some((installation) => covers(installation, fullName));
|
|
383
|
+
return true;
|
|
384
|
+
}
|
|
385
|
+
function treeOf(target, commitSha) {
|
|
386
|
+
const sha = target.blobSha(`tree-of:${commitSha}`);
|
|
387
|
+
if (!trees.has(sha)) trees.set(sha, new Map(target.files(commitSha)));
|
|
388
|
+
return sha;
|
|
389
|
+
}
|
|
390
|
+
/** Git's data API on a repository: blobs, trees, commits and moving a branch, as REST commits need. */
|
|
391
|
+
async function handleGitData(request, rest, target, principal, protectedHistory) {
|
|
392
|
+
const method = request.method;
|
|
393
|
+
const commitSha = rest.match(/^\/git\/commits\/([0-9a-f]{40})$/);
|
|
394
|
+
if (commitSha && method === "GET") {
|
|
395
|
+
const found = target.commitAt(commitSha[1] ?? "");
|
|
396
|
+
if (!found) return json({ message: "Not Found" }, 404);
|
|
397
|
+
return json({
|
|
398
|
+
sha: found.sha,
|
|
399
|
+
tree: { sha: treeOf(target, found.sha) },
|
|
400
|
+
parents: found.parent ? [{ sha: found.parent }] : []
|
|
401
|
+
});
|
|
402
|
+
}
|
|
220
403
|
if (rest === "/git/blobs" && method === "POST") {
|
|
404
|
+
if (principal.push === false) return json({ message: "Resource not accessible by integration" }, 403);
|
|
221
405
|
const body = await request.json();
|
|
222
|
-
return json({ sha:
|
|
406
|
+
return json({ sha: target.blobSha(body.encoding === "base64" ? fakeFileFromBytes(decodeBase64(body.content)) : body.content) }, 201);
|
|
223
407
|
}
|
|
224
408
|
if (rest === "/git/trees" && method === "POST") {
|
|
225
409
|
const body = await request.json();
|
|
226
|
-
|
|
410
|
+
if (body.base_tree && !trees.has(body.base_tree)) return json({ message: "Tree not found" }, 422);
|
|
411
|
+
const files = new Map(body.base_tree ? trees.get(body.base_tree) : void 0);
|
|
227
412
|
for (const entry of body.tree) {
|
|
228
|
-
if (entry.path.startsWith(".github/workflows/") &&
|
|
229
|
-
|
|
413
|
+
if (entry.path.startsWith(".github/workflows/") && principal.workflow === false) return json({ message: "Resource not accessible by personal access token" }, 404);
|
|
414
|
+
if (entry.sha === null) {
|
|
415
|
+
files.delete(entry.path);
|
|
416
|
+
continue;
|
|
417
|
+
}
|
|
418
|
+
const content = entry.content ?? target.blobs.get(entry.sha ?? "");
|
|
230
419
|
if (content === void 0) return json({ message: `No blob ${entry.sha}` }, 422);
|
|
231
420
|
files.set(entry.path, content);
|
|
232
421
|
}
|
|
233
|
-
const sha =
|
|
422
|
+
const sha = target.blobSha(`tree:${[...files.keys()].join(",")}:${trees.size}`);
|
|
234
423
|
trees.set(sha, files);
|
|
235
424
|
return json({ sha }, 201);
|
|
236
425
|
}
|
|
@@ -238,26 +427,24 @@ function fakeGitHub(options) {
|
|
|
238
427
|
const body = await request.json();
|
|
239
428
|
const files = trees.get(body.tree);
|
|
240
429
|
if (!files) return json({ message: "Tree not found" }, 422);
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
site.pages = await request.json();
|
|
246
|
-
const [owner, name] = site.fullName.split("/");
|
|
247
|
-
return json({
|
|
248
|
-
html_url: `https://${owner}.github.io/${name}/`,
|
|
249
|
-
build_type: site.pages.build_type
|
|
250
|
-
}, 201);
|
|
430
|
+
const author = body.author?.name ?? (principal.bot ? principal.login : principal.name ?? principal.login);
|
|
431
|
+
const sha = target.addCommit(body.parents[0], body.message, new Map(files), author);
|
|
432
|
+
commitAuthors.set(sha, body.author ?? { name: author });
|
|
433
|
+
return json({ sha }, 201);
|
|
251
434
|
}
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
435
|
+
const ref = rest.match(/^\/git\/refs?\/heads\/(.+)$/);
|
|
436
|
+
if (ref && method === "PATCH") {
|
|
437
|
+
if (principal.push === false) return json({ message: "Resource not accessible by integration" }, 403);
|
|
438
|
+
const body = await request.json();
|
|
439
|
+
const branch = decodeURIComponent(ref[1] ?? "");
|
|
440
|
+
const found = target.commitAt(body.sha);
|
|
441
|
+
if (!found || !target.branches.has(branch)) return json({ message: "Reference does not exist" }, 422);
|
|
442
|
+
if (!body.force && found.parent !== target.branches.get(branch)) return json({ message: "Update is not a fast forward" }, 422);
|
|
443
|
+
if (protectedHistory && found.parent !== target.branches.get(branch)) return json({ message: "Cannot force-push to this branch" }, 422);
|
|
444
|
+
target.branches.set(branch, body.sha);
|
|
445
|
+
if (branch === target.defaultBranch) deploy(body.sha);
|
|
446
|
+
return json({ object: { sha: body.sha } });
|
|
256
447
|
}
|
|
257
|
-
return json({ message: "Not Found" }, 404);
|
|
258
|
-
}
|
|
259
|
-
function user(request) {
|
|
260
|
-
return tokens[request.headers.get("authorization")?.replace(/^Bearer /, "") ?? ""];
|
|
261
448
|
}
|
|
262
449
|
function deploy(sha) {
|
|
263
450
|
if (options.deployAfterChecks === void 0) return;
|
|
@@ -268,26 +455,251 @@ function fakeGitHub(options) {
|
|
|
268
455
|
checks: 0
|
|
269
456
|
});
|
|
270
457
|
}
|
|
271
|
-
function commit(changes, message, expectedHead, author) {
|
|
458
|
+
function commit(changes, message, expectedHead, author, branch = repo.defaultBranch) {
|
|
272
459
|
const sha = repo.commit(changes, message, {
|
|
273
460
|
expectedHead,
|
|
274
|
-
author
|
|
461
|
+
author,
|
|
462
|
+
branch
|
|
275
463
|
});
|
|
276
|
-
deploy(sha);
|
|
464
|
+
if (branch === repo.defaultBranch) deploy(sha);
|
|
277
465
|
return sha;
|
|
278
466
|
}
|
|
467
|
+
/** The person a login belongs to, as the test's tokens describe them. */
|
|
468
|
+
function person(login) {
|
|
469
|
+
return Object.values(tokens).find((candidate) => same(candidate.login, login)) ?? { login };
|
|
470
|
+
}
|
|
471
|
+
/** Simulates someone signing in to an app on GitHub's page: the code GitHub sends back to the app's callback. */
|
|
472
|
+
function authorize(login, clientId) {
|
|
473
|
+
const app = apps.find((candidate) => candidate.clientId === clientId);
|
|
474
|
+
if (!app) throw new Error(`No app with client ID ${clientId}`);
|
|
475
|
+
const code = `code-${nextToken++}`;
|
|
476
|
+
codes.set(code, {
|
|
477
|
+
app,
|
|
478
|
+
login
|
|
479
|
+
});
|
|
480
|
+
return code;
|
|
481
|
+
}
|
|
482
|
+
/**
|
|
483
|
+
* Simulates someone installing an app on GitHub's install page, choosing
|
|
484
|
+
* these repositories, or adding them to its installation on the account,
|
|
485
|
+
* and being signed in as they go: the code GitHub sends back to the app's callback.
|
|
486
|
+
*/
|
|
487
|
+
function install(slug, login, repositories, account = repositories[0]?.split("/")[0] ?? login) {
|
|
488
|
+
const app = apps.find((candidate) => candidate.slug === slug);
|
|
489
|
+
if (!app) throw new Error(`No app ${slug}`);
|
|
490
|
+
let installation = app.installations.find((candidate) => same(candidate.account, account));
|
|
491
|
+
if (!installation) {
|
|
492
|
+
installation = {
|
|
493
|
+
id: 1e3 + nextToken++,
|
|
494
|
+
account,
|
|
495
|
+
repositories: []
|
|
496
|
+
};
|
|
497
|
+
app.installations.push(installation);
|
|
498
|
+
}
|
|
499
|
+
const chosen = installation.repositories;
|
|
500
|
+
if (chosen !== "all") {
|
|
501
|
+
for (const repo of repositories) if (!chosen.some((name) => same(name, repo))) chosen.push(repo);
|
|
502
|
+
}
|
|
503
|
+
return {
|
|
504
|
+
code: authorize(login, app.clientId),
|
|
505
|
+
installation: installation.id
|
|
506
|
+
};
|
|
507
|
+
}
|
|
508
|
+
function userToken(app, login) {
|
|
509
|
+
const access = `ghu_fake${nextToken++}`;
|
|
510
|
+
const refresh = `ghr_fake${nextToken++}`;
|
|
511
|
+
const base = person(login);
|
|
512
|
+
principals.set(access, {
|
|
513
|
+
...base,
|
|
514
|
+
app,
|
|
515
|
+
permissions: app.permissions,
|
|
516
|
+
administration: app.permissions.administration === "write",
|
|
517
|
+
pages: app.permissions.pages === "write",
|
|
518
|
+
workflow: app.permissions.workflows === "write",
|
|
519
|
+
actions: app.permissions.actions !== void 0,
|
|
520
|
+
actionsWrite: app.permissions.actions === "write"
|
|
521
|
+
});
|
|
522
|
+
refreshTokens.set(refresh, {
|
|
523
|
+
app,
|
|
524
|
+
login
|
|
525
|
+
});
|
|
526
|
+
issued.push({
|
|
527
|
+
token: access,
|
|
528
|
+
kind: "user",
|
|
529
|
+
app: app.slug,
|
|
530
|
+
login,
|
|
531
|
+
permissions: app.permissions
|
|
532
|
+
});
|
|
533
|
+
return json({
|
|
534
|
+
access_token: access,
|
|
535
|
+
token_type: "bearer",
|
|
536
|
+
scope: "",
|
|
537
|
+
expires_in: 28800,
|
|
538
|
+
refresh_token: refresh,
|
|
539
|
+
refresh_token_expires_in: 15897600
|
|
540
|
+
});
|
|
541
|
+
}
|
|
542
|
+
async function appFromJwt(jwt) {
|
|
543
|
+
const [header, payload, signature] = jwt.split(".");
|
|
544
|
+
const claims = decodeJwtPart(payload ?? "");
|
|
545
|
+
if (!header || !claims || !signature || decodeJwtPart(header)?.alg !== "RS256") return void 0;
|
|
546
|
+
const app = apps.find((candidate) => String(candidate.id) === String(claims.iss));
|
|
547
|
+
if (!app || typeof claims.exp !== "number" || claims.exp * 1e3 < Date.now()) return void 0;
|
|
548
|
+
if (app.publicKey) {
|
|
549
|
+
const base64 = signature.replace(/-/g, "+").replace(/_/g, "/");
|
|
550
|
+
const bytes = Uint8Array.from(atob(base64 + "=".repeat((4 - base64.length % 4) % 4)), (char) => char.charCodeAt(0));
|
|
551
|
+
if (!await crypto.subtle.verify("RSASSA-PKCS1-v1_5", app.publicKey, bytes, new TextEncoder().encode(`${header}.${payload}`))) return void 0;
|
|
552
|
+
}
|
|
553
|
+
return app;
|
|
554
|
+
}
|
|
555
|
+
/** What GitHub Apps call: OAuth's token exchange, revoking, and the app's own endpoints, which take its JWT. */
|
|
556
|
+
async function handleApps(request, path) {
|
|
557
|
+
const authorization = request.headers.get("authorization") ?? "";
|
|
558
|
+
if (path === "/login/oauth/access_token" && request.method === "POST") {
|
|
559
|
+
const body = await request.json();
|
|
560
|
+
const app = apps.find((candidate) => candidate.clientId === body.client_id);
|
|
561
|
+
if (!app || app.clientSecret !== body.client_secret) return json({ error: "incorrect_client_credentials" });
|
|
562
|
+
if (body.grant_type === "refresh_token") {
|
|
563
|
+
const found = refreshTokens.get(body.refresh_token ?? "");
|
|
564
|
+
if (!found || found.app !== app) return json({ error: "bad_refresh_token" });
|
|
565
|
+
refreshTokens.delete(body.refresh_token ?? "");
|
|
566
|
+
return userToken(app, found.login);
|
|
567
|
+
}
|
|
568
|
+
const found = codes.get(body.code ?? "");
|
|
569
|
+
if (!found || found.app !== app) return json({ error: "bad_verification_code" });
|
|
570
|
+
codes.delete(body.code ?? "");
|
|
571
|
+
return userToken(app, found.login);
|
|
572
|
+
}
|
|
573
|
+
const revoke = path.match(/^\/applications\/([^/]+)\/token$/);
|
|
574
|
+
if (revoke && request.method === "DELETE") {
|
|
575
|
+
const [id, secret] = atob(authorization.replace(/^Basic /, "")).split(":");
|
|
576
|
+
const app = apps.find((candidate) => candidate.clientId === decodeURIComponent(revoke[1] ?? ""));
|
|
577
|
+
if (!app || app.clientId !== id || app.clientSecret !== secret) return json({ message: "Not Found" }, 404);
|
|
578
|
+
const { access_token: token } = await request.json();
|
|
579
|
+
principals.delete(token);
|
|
580
|
+
for (const item of issued) if (item.token === token) item.revoked = true;
|
|
581
|
+
return new Response(null, { status: 204 });
|
|
582
|
+
}
|
|
583
|
+
if (path === "/installation/token" && request.method === "DELETE") {
|
|
584
|
+
const token = authorization.replace(/^Bearer /, "");
|
|
585
|
+
if (!principals.get(token)?.bot) return json({ message: "Bad credentials" }, 401);
|
|
586
|
+
principals.delete(token);
|
|
587
|
+
for (const item of issued) if (item.token === token) item.revoked = true;
|
|
588
|
+
return new Response(null, { status: 204 });
|
|
589
|
+
}
|
|
590
|
+
if (!/^Bearer ey/.test(authorization)) return void 0;
|
|
591
|
+
const app = await appFromJwt(authorization.slice(7));
|
|
592
|
+
if (!app) return json({ message: "A JSON web token could not be decoded" }, 401);
|
|
593
|
+
const installed = path.match(/^\/repos\/([^/]+\/[^/]+)\/installation$/);
|
|
594
|
+
if (installed) {
|
|
595
|
+
const fullName = decodeURIComponent(installed[1] ?? "");
|
|
596
|
+
const installation = app.installations.find((candidate) => covers(candidate, fullName));
|
|
597
|
+
return installation ? json({
|
|
598
|
+
id: installation.id,
|
|
599
|
+
account: { login: installation.account },
|
|
600
|
+
app_slug: app.slug
|
|
601
|
+
}) : json({ message: "Not Found" }, 404);
|
|
602
|
+
}
|
|
603
|
+
const mint = path.match(/^\/app\/installations\/(\d+)\/access_tokens$/);
|
|
604
|
+
if (mint && request.method === "POST") {
|
|
605
|
+
const installation = app.installations.find((candidate) => candidate.id === Number(mint[1]));
|
|
606
|
+
if (!installation) return json({ message: "Not Found" }, 404);
|
|
607
|
+
const body = await request.json();
|
|
608
|
+
const repositories = (body.repositories ?? []).map((name) => `${installation.account}/${name}`);
|
|
609
|
+
if (repositories.some((repo) => !covers(installation, repo))) return json({ message: "There is at least one repository that does not exist or is not accessible" }, 422);
|
|
610
|
+
const permissions = body.permissions ?? app.permissions;
|
|
611
|
+
for (const [name, level] of Object.entries(permissions)) {
|
|
612
|
+
const allowed = app.permissions[name];
|
|
613
|
+
if (!allowed || level === "write" && allowed !== "write") return json({ message: `The permissions requested are not granted to this installation.` }, 422);
|
|
614
|
+
}
|
|
615
|
+
const token = `ghs_fake${nextToken++}`;
|
|
616
|
+
principals.set(token, {
|
|
617
|
+
login: `${app.slug}[bot]`,
|
|
618
|
+
bot: true,
|
|
619
|
+
app,
|
|
620
|
+
repositories,
|
|
621
|
+
permissions,
|
|
622
|
+
push: permissions.contents === "write",
|
|
623
|
+
workflow: permissions.workflows === "write",
|
|
624
|
+
pages: permissions.pages === "write",
|
|
625
|
+
administration: permissions.administration === "write",
|
|
626
|
+
admin: permissions.administration === "write",
|
|
627
|
+
actions: permissions.actions !== void 0,
|
|
628
|
+
actionsWrite: permissions.actions === "write"
|
|
629
|
+
});
|
|
630
|
+
issued.push({
|
|
631
|
+
token,
|
|
632
|
+
kind: "installation",
|
|
633
|
+
app: app.slug,
|
|
634
|
+
repositories,
|
|
635
|
+
permissions
|
|
636
|
+
});
|
|
637
|
+
return json({
|
|
638
|
+
token,
|
|
639
|
+
expires_at: new Date(Date.now() + 36e5).toISOString(),
|
|
640
|
+
permissions
|
|
641
|
+
}, 201);
|
|
642
|
+
}
|
|
643
|
+
return json({ message: "Not Found" }, 404);
|
|
644
|
+
}
|
|
279
645
|
async function handle(request) {
|
|
280
646
|
requests.push(request.clone());
|
|
281
647
|
const url = new URL(request.url);
|
|
282
648
|
const path = url.pathname;
|
|
283
|
-
const
|
|
649
|
+
const fromApp = await handleApps(request, path);
|
|
650
|
+
if (fromApp) return fromApp;
|
|
651
|
+
const user = principals.get(request.headers.get("authorization")?.replace(/^Bearer /, "") ?? "");
|
|
284
652
|
if (!user) return json({ message: "Bad credentials" }, 401);
|
|
285
|
-
if (path === "/user")
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
653
|
+
if (path === "/user") {
|
|
654
|
+
if (user.bot) return json({ message: "Resource not accessible by integration" }, 403);
|
|
655
|
+
return json({
|
|
656
|
+
id: user.id ?? accountId(user.login),
|
|
657
|
+
login: user.login,
|
|
658
|
+
name: user.name ?? null,
|
|
659
|
+
avatar_url: `https://avatars.example/${user.login}`
|
|
660
|
+
});
|
|
661
|
+
}
|
|
662
|
+
if (path === "/user/installations" && user.app && !user.bot) {
|
|
663
|
+
const mine = user.app.installations.filter((installation) => same(installation.account, user.login) || (options.orgs ?? []).some((org) => same(org, installation.account)));
|
|
664
|
+
return json({
|
|
665
|
+
total_count: mine.length,
|
|
666
|
+
installations: mine.map((installation) => ({
|
|
667
|
+
id: installation.id,
|
|
668
|
+
account: {
|
|
669
|
+
login: installation.account,
|
|
670
|
+
type: installation.accountType ?? "User"
|
|
671
|
+
},
|
|
672
|
+
repository_selection: installation.repositories === "all" ? "all" : "selected"
|
|
673
|
+
}))
|
|
674
|
+
});
|
|
675
|
+
}
|
|
676
|
+
const listRepos = path.match(/^\/user\/installations\/(\d+)\/repositories$/);
|
|
677
|
+
if (listRepos && request.method === "GET" && user.app && !user.bot) {
|
|
678
|
+
const installation = user.app.installations.find((candidate) => candidate.id === Number(listRepos[1]));
|
|
679
|
+
if (!installation) return json({ message: "Not Found" }, 404);
|
|
680
|
+
const repositories = (installation.repositories === "all" ? [options.repo, ...created.keys()].filter((name) => same(name.split("/")[0] ?? "", installation.account)) : installation.repositories).map((name) => ({
|
|
681
|
+
full_name: name,
|
|
682
|
+
default_branch: same(name, options.repo) ? repo.defaultBranch : created.get(name)?.repo.defaultBranch ?? "main",
|
|
683
|
+
permissions: {
|
|
684
|
+
admin: user.admin === true,
|
|
685
|
+
push: user.push !== false,
|
|
686
|
+
pull: true
|
|
687
|
+
}
|
|
688
|
+
}));
|
|
689
|
+
return json({
|
|
690
|
+
total_count: repositories.length,
|
|
691
|
+
repositories
|
|
692
|
+
});
|
|
693
|
+
}
|
|
694
|
+
const addRepo = path.match(/^\/user\/installations\/(\d+)\/repositories\/(\d+)$/);
|
|
695
|
+
if (addRepo && request.method === "PUT" && user.app && !user.bot) {
|
|
696
|
+
const installation = user.app.installations.find((candidate) => candidate.id === Number(addRepo[1]));
|
|
697
|
+
const target = [...created.values()].find((candidate) => candidate.id === Number(addRepo[2]));
|
|
698
|
+
if (!installation || !target) return json({ message: "Not Found" }, 404);
|
|
699
|
+
if (installation.repositories !== "all" && !installation.repositories.includes(target.fullName)) installation.repositories.push(target.fullName);
|
|
700
|
+
return new Response(null, { status: 204 });
|
|
701
|
+
}
|
|
702
|
+
const setup = await handleCreated(request, path, user);
|
|
291
703
|
if (setup) return setup;
|
|
292
704
|
const account = path.match(/^\/users\/([^/]+)$/);
|
|
293
705
|
if (account) {
|
|
@@ -295,6 +707,29 @@ function fakeGitHub(options) {
|
|
|
295
707
|
return accounts.has(login) ? json({ login }) : json({ message: "Not Found" }, 404);
|
|
296
708
|
}
|
|
297
709
|
if (path === "/graphql" && request.method === "POST") {
|
|
710
|
+
const body = await request.clone().json();
|
|
711
|
+
if (body.query.trimStart().startsWith("query")) {
|
|
712
|
+
if (`${body.variables?.owner}/${body.variables?.name}` !== options.repo) return json({
|
|
713
|
+
data: { repository: null },
|
|
714
|
+
errors: [{
|
|
715
|
+
type: "NOT_FOUND",
|
|
716
|
+
message: "Could not resolve"
|
|
717
|
+
}]
|
|
718
|
+
});
|
|
719
|
+
const repository = {};
|
|
720
|
+
for (const [, alias, oid] of body.query.matchAll(/(\w+): object\(oid: "([0-9a-f]{40})"\)/g)) {
|
|
721
|
+
const content = repo.blobs.get(oid ?? "");
|
|
722
|
+
repository[alias ?? ""] = content === void 0 ? null : typeof content === "string" ? {
|
|
723
|
+
text: content,
|
|
724
|
+
isBinary: false
|
|
725
|
+
} : {
|
|
726
|
+
text: null,
|
|
727
|
+
isBinary: true
|
|
728
|
+
};
|
|
729
|
+
}
|
|
730
|
+
graphqlReads += 1;
|
|
731
|
+
return json({ data: { repository } });
|
|
732
|
+
}
|
|
298
733
|
const { variables } = await request.json();
|
|
299
734
|
const { input } = variables;
|
|
300
735
|
if (input.branch.repositoryNameWithOwner !== options.repo) return json({ errors: [{
|
|
@@ -314,7 +749,7 @@ function fakeGitHub(options) {
|
|
|
314
749
|
})), ...(input.fileChanges.deletions ?? []).map((deletion) => ({
|
|
315
750
|
path: deletion.path,
|
|
316
751
|
delete: true
|
|
317
|
-
}))], input.message.headline, input.expectedHeadOid, user.name ?? user.login) } } } });
|
|
752
|
+
}))], input.message.headline, input.expectedHeadOid, user.name ?? user.login, input.branch.branchName) } } } });
|
|
318
753
|
} catch (error) {
|
|
319
754
|
if (error instanceof FakeConflictError) return json({
|
|
320
755
|
data: { createCommitOnBranch: null },
|
|
@@ -328,7 +763,58 @@ function fakeGitHub(options) {
|
|
|
328
763
|
}
|
|
329
764
|
const match = path.match(/^\/repos\/([^/]+\/[^/]+)(\/.*)?$/);
|
|
330
765
|
if (!match || decodeURIComponent(match[1] ?? "") !== options.repo) return json({ message: "Not Found" }, 404);
|
|
766
|
+
if (!canSee(user, options.repo)) return json({ message: "Not Found" }, 404);
|
|
331
767
|
const rest = match[2] ?? "";
|
|
768
|
+
const contents = rest.match(/^\/contents\/(.+)$/);
|
|
769
|
+
if (contents && request.method === "GET") {
|
|
770
|
+
if (user.bot && !user.permissions?.contents) return json({ message: "Resource not accessible by integration" }, 403);
|
|
771
|
+
const file = repo.files(url.searchParams.get("ref") ?? repo.defaultBranch).get(decodeURIComponent(contents[1] ?? ""));
|
|
772
|
+
if (file === void 0) return json({ message: "Not Found" }, 404);
|
|
773
|
+
return request.headers.get("accept")?.includes("raw") ? new Response(fakeFileBytes(file)) : json({
|
|
774
|
+
content: encodeBase64Bytes(fakeFileBytes(file)),
|
|
775
|
+
encoding: "base64"
|
|
776
|
+
});
|
|
777
|
+
}
|
|
778
|
+
const data = await handleGitData(request, rest, repo, user, false);
|
|
779
|
+
if (data) return data;
|
|
780
|
+
if (rest === "/git/refs" && request.method === "POST") {
|
|
781
|
+
if (user.push === false) return json({ message: "Resource not accessible by integration" }, 403);
|
|
782
|
+
const body = await request.json();
|
|
783
|
+
const branch = body.ref.replace(/^refs\/heads\//, "");
|
|
784
|
+
if (repo.branches.has(branch)) return json({ message: "Reference already exists" }, 422);
|
|
785
|
+
if (!repo.commitAt(body.sha)) return json({ message: "Object does not exist" }, 422);
|
|
786
|
+
repo.branches.set(branch, body.sha);
|
|
787
|
+
return json({
|
|
788
|
+
ref: body.ref,
|
|
789
|
+
object: { sha: body.sha }
|
|
790
|
+
}, 201);
|
|
791
|
+
}
|
|
792
|
+
const branchRef = rest.match(/^\/git\/refs\/heads\/(.+)$/);
|
|
793
|
+
if (branchRef && request.method === "DELETE") {
|
|
794
|
+
if (user.push === false) return json({ message: "Resource not accessible by integration" }, 403);
|
|
795
|
+
const branch = decodeURIComponent(branchRef[1] ?? "");
|
|
796
|
+
if (!repo.branches.has(branch)) return json({ message: "Reference does not exist" }, 422);
|
|
797
|
+
repo.branches.delete(branch);
|
|
798
|
+
return new Response(null, { status: 204 });
|
|
799
|
+
}
|
|
800
|
+
if (rest === "/pulls" && request.method === "POST") {
|
|
801
|
+
if (user.bot && user.permissions?.pull_requests !== "write") return json({ message: "Resource not accessible by integration" }, 403);
|
|
802
|
+
const body = await request.json();
|
|
803
|
+
if (!repo.branches.has(body.head) || !repo.branches.has(body.base)) return json({ message: "Validation Failed" }, 422);
|
|
804
|
+
if (repo.reviews.some((review) => review.branch === body.head && review.state === "open")) return json({ message: "A pull request already exists" }, 422);
|
|
805
|
+
const review = repo.openReview(body.head, body.title, body.body ?? "", body.base);
|
|
806
|
+
return json({
|
|
807
|
+
number: review.number,
|
|
808
|
+
html_url: `https://github.com/${options.repo}/pull/${review.number}`
|
|
809
|
+
}, 201);
|
|
810
|
+
}
|
|
811
|
+
if (rest === "/pulls" && request.method === "GET") {
|
|
812
|
+
const head = url.searchParams.get("head")?.split(":")[1];
|
|
813
|
+
return json(repo.reviews.filter((review) => review.state === "open" && (!head || review.branch === head)).map((review) => ({
|
|
814
|
+
number: review.number,
|
|
815
|
+
html_url: `https://github.com/${options.repo}/pull/${review.number}`
|
|
816
|
+
})));
|
|
817
|
+
}
|
|
332
818
|
if (rest === "/pages" && request.method === "GET") {
|
|
333
819
|
const site = pages.current;
|
|
334
820
|
if (!site) return json({ message: "Not Found" }, 404);
|
|
@@ -358,6 +844,7 @@ function fakeGitHub(options) {
|
|
|
358
844
|
return new Response(null, { status: 204 });
|
|
359
845
|
}
|
|
360
846
|
if (rest === "") return json({
|
|
847
|
+
id: 1,
|
|
361
848
|
full_name: options.repo,
|
|
362
849
|
default_branch: repo.defaultBranch,
|
|
363
850
|
permissions: {
|
|
@@ -384,7 +871,8 @@ function fakeGitHub(options) {
|
|
|
384
871
|
tree: [...repo.files(commitSha)].map(([file, content]) => ({
|
|
385
872
|
path: file,
|
|
386
873
|
type: "blob",
|
|
387
|
-
sha: repo.blobSha(content)
|
|
874
|
+
sha: repo.blobSha(content),
|
|
875
|
+
size: fakeFileBytes(content).length
|
|
388
876
|
})),
|
|
389
877
|
truncated: false
|
|
390
878
|
});
|
|
@@ -416,7 +904,10 @@ function fakeGitHub(options) {
|
|
|
416
904
|
author: null
|
|
417
905
|
})));
|
|
418
906
|
}
|
|
419
|
-
|
|
907
|
+
const workflowFile = rest.match(/^\/actions\/workflows\/([\w.-]+)\/(?:dispatches|runs)$/)?.[1];
|
|
908
|
+
const workflowPath = `.github/workflows/${workflowFile}`;
|
|
909
|
+
const ofWorkflow = (run) => (run.path ?? ".github/workflows/deploy.yml") === workflowPath;
|
|
910
|
+
if (workflowFile && rest.endsWith("/dispatches") && request.method === "POST") {
|
|
420
911
|
if (!(user.actionsWrite ?? user.administration)) return json({ message: "Resource not accessible by personal access token" }, 403);
|
|
421
912
|
const body = await request.json();
|
|
422
913
|
runs.push({
|
|
@@ -426,15 +917,17 @@ function fakeGitHub(options) {
|
|
|
426
917
|
inputs: body.inputs,
|
|
427
918
|
status: "queued",
|
|
428
919
|
conclusion: null,
|
|
920
|
+
path: workflowPath,
|
|
429
921
|
jobs: []
|
|
430
922
|
});
|
|
431
923
|
return new Response(null, { status: 204 });
|
|
432
924
|
}
|
|
433
|
-
if (
|
|
925
|
+
if (workflowFile && rest.endsWith("/runs")) {
|
|
434
926
|
if (user.actions === false) return json({ message: "Resource not accessible by personal access token" }, 403);
|
|
927
|
+
const found = runs.filter(ofWorkflow);
|
|
435
928
|
return json({
|
|
436
|
-
total_count:
|
|
437
|
-
workflow_runs: [...
|
|
929
|
+
total_count: found.length,
|
|
930
|
+
workflow_runs: [...found].reverse().map((run) => ({
|
|
438
931
|
id: run.id,
|
|
439
932
|
event: run.event ?? "push",
|
|
440
933
|
head_sha: run.sha,
|
|
@@ -458,8 +951,8 @@ function fakeGitHub(options) {
|
|
|
458
951
|
head_sha: run.sha,
|
|
459
952
|
status: run.status,
|
|
460
953
|
conclusion: run.conclusion,
|
|
461
|
-
name: "Deploy to GitHub Pages",
|
|
462
|
-
path: ".github/workflows/deploy.yml",
|
|
954
|
+
name: run.path ? "Rebuild on Vercel" : "Deploy to GitHub Pages",
|
|
955
|
+
path: run.path ?? ".github/workflows/deploy.yml",
|
|
463
956
|
html_url: htmlUrl(run)
|
|
464
957
|
}))
|
|
465
958
|
});
|
|
@@ -471,8 +964,18 @@ function fakeGitHub(options) {
|
|
|
471
964
|
jobs: run.jobs
|
|
472
965
|
}) : json({ message: "Not Found" }, 404);
|
|
473
966
|
}
|
|
474
|
-
const compare = rest.match(/^\/compare\/(
|
|
475
|
-
if (compare)
|
|
967
|
+
const compare = rest.match(/^\/compare\/(.+)\.\.\.(.+)$/);
|
|
968
|
+
if (compare) {
|
|
969
|
+
const [from, to] = [compare[1], compare[2]].map((ref) => decodeURIComponent(ref ?? ""));
|
|
970
|
+
const resolve = (ref) => repo.branches.get(ref) ?? (repo.commitAt(ref) ? ref : void 0);
|
|
971
|
+
const [a, b] = [resolve(from ?? ""), resolve(to ?? "")];
|
|
972
|
+
if (!a || !b) return json({ message: "Not Found" }, 404);
|
|
973
|
+
const base = repo.mergeBase(a, b) ?? a;
|
|
974
|
+
return json({
|
|
975
|
+
merge_base_commit: { sha: base },
|
|
976
|
+
files: repo.changedPaths(base, b).map((filename) => ({ filename }))
|
|
977
|
+
});
|
|
978
|
+
}
|
|
476
979
|
if (rest === "/deployments") {
|
|
477
980
|
const sha = url.searchParams.get("sha");
|
|
478
981
|
return json(deployments.filter((deployment) => !sha || deployment.sha === sha).slice(0, Number(url.searchParams.get("per_page") ?? 30)).map(({ id, sha }) => ({
|
|
@@ -495,8 +998,19 @@ function fakeGitHub(options) {
|
|
|
495
998
|
}
|
|
496
999
|
return {
|
|
497
1000
|
repo,
|
|
498
|
-
/**
|
|
1001
|
+
/** How many GraphQL requests read files, as `readFiles()` makes. */
|
|
1002
|
+
get graphqlReads() {
|
|
1003
|
+
return graphqlReads;
|
|
1004
|
+
},
|
|
1005
|
+
/** Tokens apps' sign-ins made, in order. */
|
|
1006
|
+
issued,
|
|
1007
|
+
/** Who commits made through the REST API name as their author, by commit. */
|
|
1008
|
+
commitAuthors,
|
|
1009
|
+
authorize,
|
|
1010
|
+
install,
|
|
1011
|
+
/** Repositories created through the API, or added with `addRepo()`, by `owner/name`. */
|
|
499
1012
|
created,
|
|
1013
|
+
addRepo,
|
|
500
1014
|
/** The site's GitHub Pages settings, which tests can change, such as to issue the certificate. */
|
|
501
1015
|
pages,
|
|
502
1016
|
/** The repository's collaborators, by login, and invitations waiting to be accepted. */
|