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