@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/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, login) {
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: { push: true }
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: repo.blobSha(body.encoding === "base64" ? fakeFileFromBytes(decodeBase64(body.content)) : body.content) }, 201);
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
- const files = /* @__PURE__ */ new Map();
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/") && user(request)?.workflow === false) return json({ message: "Resource not accessible by personal access token" }, 404);
229
- const content = entry.content ?? repo.blobs.get(entry.sha ?? "");
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 = repo.blobSha(`tree:${[...files.keys()].join(",")}:${trees.size}`);
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
- return json({ sha: repo.addCommit(body.parents[0], body.message, new Map(files)) }, 201);
242
- }
243
- if (rest === "/pages" && method === "POST") {
244
- if (site.private && !options.paidPlan) return json({ message: "Your current plan does not support GitHub Pages for this repository." }, 422);
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
- if (rest === "/rulesets" && method === "POST") {
253
- if (site.private && !options.paidPlan) return json({ message: "Upgrade to GitHub Pro" }, 403);
254
- site.rulesets.push(await request.json());
255
- return json({ id: site.rulesets.length }, 201);
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 user = tokens[request.headers.get("authorization")?.replace(/^Bearer /, "") ?? ""];
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") return json({
286
- login: user.login,
287
- name: user.name ?? null,
288
- avatar_url: `https://avatars.example/${user.login}`
289
- });
290
- const setup = await handleCreated(request, path, user.login);
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
- if (rest === "/actions/workflows/deploy.yml/dispatches" && request.method === "POST") {
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 (rest === "/actions/workflows/deploy.yml/runs") {
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: runs.length,
437
- workflow_runs: [...runs].reverse().map((run) => ({
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\/([0-9a-f]{40})\.\.\.([0-9a-f]{40})$/);
475
- if (compare) return json({ files: repo.changedPaths(compare[1] ?? "", compare[2] ?? "").map((filename) => ({ filename })) });
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
- /** Repositories created through the API, by `owner/name`. */
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. */