@goodfellow-cms/github 0.1.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
@@ -1,11 +1,33 @@
1
1
  import { decodeBase64, encodeBase64Bytes } from "@goodfellow-cms/core";
2
- import { FakeConflictError, FakeRepo, fakeFileBytes } from "@goodfellow-cms/core/testing";
2
+ import { FakeConflictError, FakeRepo, fakeFileBytes, fakeFileFromBytes } from "@goodfellow-cms/core/testing";
3
3
  //#region src/testing.ts
4
4
  /**
5
5
  * A fake of the parts of GitHub's API that `@goodfellow-cms/github` uses, backed by
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,9 +40,322 @@ 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 = [];
55
+ const runs = [];
22
56
  const requests = [];
23
57
  let nextDeployment = 1;
58
+ let graphqlReads = 0;
59
+ const created = /* @__PURE__ */ new Map();
60
+ const [owner = "", name = ""] = options.repo.split("/");
61
+ const pages = { current: options.pages ? {
62
+ html_url: `https://${owner.toLowerCase()}.github.io/${name}/`,
63
+ cname: null,
64
+ https_enforced: false
65
+ } : void 0 };
66
+ const trees = /* @__PURE__ */ new Map();
67
+ /** The repository's collaborators and their permission. Every token's user is one. */
68
+ const collaborators = new Map(Object.values(tokens).map((user) => [user.login, user.admin ? "admin" : user.push === false ? "read" : "push"]));
69
+ const invitations = [];
70
+ const accounts = /* @__PURE__ */ new Set([...Object.values(tokens).map((user) => user.login), ...options.accounts ?? []]);
71
+ let nextInvitation = 1;
72
+ /** Collaborators and invitations, which only owners' tokens with the Administration permission can change. */
73
+ async function handleCollaborators(request, rest, user) {
74
+ const forbidden = () => json({ message: "Resource not accessible by personal access token" }, 403);
75
+ const canManage = user.admin === true && user.administration === true;
76
+ if (rest === "/collaborators" && request.method === "GET") return json([...collaborators].map(([login, permission]) => ({
77
+ login,
78
+ avatar_url: `https://avatars.example/${login}`,
79
+ permissions: {
80
+ admin: permission === "admin",
81
+ maintain: false,
82
+ push: permission !== "read",
83
+ pull: true
84
+ }
85
+ })));
86
+ if (rest === "/invitations" && request.method === "GET") {
87
+ if (!canManage) return forbidden();
88
+ return json(invitations.map((invitation) => ({
89
+ id: invitation.id,
90
+ invitee: {
91
+ login: invitation.login,
92
+ avatar_url: `https://avatars.example/${invitation.login}`
93
+ },
94
+ permissions: invitation.permission
95
+ })));
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
+ }
115
+ const collaborator = rest.match(/^\/collaborators\/([^/]+)$/);
116
+ if (collaborator) {
117
+ if (!canManage) return forbidden();
118
+ const login = decodeURIComponent(collaborator[1] ?? "");
119
+ if (request.method === "DELETE") {
120
+ collaborators.delete(login);
121
+ return new Response(null, { status: 204 });
122
+ }
123
+ if (request.method === "PUT") {
124
+ if (!accounts.has(login)) return json({ message: "Not Found" }, 404);
125
+ const { permission = "push" } = await request.json();
126
+ if (collaborators.has(login)) {
127
+ collaborators.set(login, permission);
128
+ return new Response(null, { status: 204 });
129
+ }
130
+ const existing = invitations.find((invitation) => invitation.login === login);
131
+ const invitation = existing ?? {
132
+ id: nextInvitation++,
133
+ login,
134
+ permission: "write"
135
+ };
136
+ invitation.permission = permission === "admin" ? "admin" : "write";
137
+ if (!existing) invitations.push(invitation);
138
+ return json({
139
+ id: invitation.id,
140
+ invitee: { login },
141
+ permissions: invitation.permission
142
+ }, 201);
143
+ }
144
+ }
145
+ const invitation = rest.match(/^\/invitations\/(\d+)$/);
146
+ if (invitation) {
147
+ if (!canManage) return forbidden();
148
+ const index = invitations.findIndex((candidate) => candidate.id === Number(invitation[1]));
149
+ if (index === -1) return json({ message: "Not Found" }, 404);
150
+ if (request.method === "DELETE") {
151
+ invitations.splice(index, 1);
152
+ return new Response(null, { status: 204 });
153
+ }
154
+ if (request.method === "PATCH") {
155
+ const { permissions } = await request.json();
156
+ invitations[index].permission = permissions;
157
+ return json({
158
+ id: invitations[index]?.id,
159
+ permissions
160
+ });
161
+ }
162
+ }
163
+ }
164
+ /**
165
+ * Simulates the deploy workflow failing for a commit at a step, such as
166
+ * `"Run npm ci"`, in the `build` job or another, or before any step ran.
167
+ */
168
+ function failBuild(sha, step, job = "build") {
169
+ const run = {
170
+ id: runs.length + 1,
171
+ sha,
172
+ status: "completed",
173
+ conclusion: "failure",
174
+ jobs: [{
175
+ name: job,
176
+ conclusion: "failure",
177
+ steps: step ? [{
178
+ name: "Set up job",
179
+ conclusion: "success"
180
+ }, {
181
+ name: step,
182
+ conclusion: "failure"
183
+ }] : []
184
+ }]
185
+ };
186
+ runs.push(run);
187
+ return run;
188
+ }
189
+ /** Simulates an invited person accepting the invitation. */
190
+ function accept(login) {
191
+ const index = invitations.findIndex((invitation) => invitation.login === login);
192
+ const [invitation] = index === -1 ? [] : invitations.splice(index, 1);
193
+ if (invitation) collaborators.set(login, invitation.permission === "admin" ? "admin" : "push");
194
+ }
195
+ /** Repository creation and the Git data API, for repositories created through the fake. */
196
+ async function handleCreated(request, path, principal) {
197
+ const login = principal.login;
198
+ if (path === "/user/orgs") return json((options.orgs ?? []).map((org) => ({ login: org })));
199
+ const create = path === "/user/repos" ? login : path.match(/^\/orgs\/([^/]+)\/repos$/)?.[1];
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);
202
+ if (path !== "/user/repos" && !options.orgs?.includes(create)) return json({ message: "Not Found" }, 404);
203
+ const body = await request.json();
204
+ if (!/^[\w.-]+$/.test(body.name)) return json({ message: "Repository creation failed." }, 422);
205
+ const fullName = `${create}/${body.name}`;
206
+ if (created.has(fullName) || fullName === options.repo) return json({
207
+ message: "Repository creation failed.",
208
+ errors: [{ message: "name already exists on this account" }]
209
+ }, 422);
210
+ const repo = new FakeRepo({ "README.md": `# ${body.name}\n` }, options.newRepoBranch ?? "main");
211
+ const id = nextRepoId++;
212
+ created.set(fullName, {
213
+ id,
214
+ fullName,
215
+ private: body.private === true,
216
+ description: body.description,
217
+ repo,
218
+ rulesets: []
219
+ });
220
+ return json({
221
+ id,
222
+ full_name: fullName,
223
+ html_url: `https://github.com/${fullName}`,
224
+ default_branch: repo.defaultBranch
225
+ }, 201);
226
+ }
227
+ const match = path.match(/^\/repos\/([^/]+\/[^/]+)(\/.*)?$/);
228
+ const site = created.get(decodeURIComponent(match?.[1] ?? ""));
229
+ if (!match || !site) return void 0;
230
+ if (!canSee(principal, site.fullName)) return json({ message: "Not Found" }, 404);
231
+ const { repo } = site;
232
+ const rest = match[2] ?? "";
233
+ const method = request.method;
234
+ if (rest === "" && method === "GET") return json({
235
+ id: site.id,
236
+ full_name: site.fullName,
237
+ default_branch: repo.defaultBranch,
238
+ permissions: {
239
+ push: true,
240
+ admin: true
241
+ }
242
+ });
243
+ if (rest === "" && method === "PATCH") {
244
+ const body = await request.json();
245
+ if (body.default_branch) {
246
+ if (!repo.branches.has(body.default_branch)) return json({ message: "Not Found" }, 422);
247
+ repo.defaultBranch = body.default_branch;
248
+ }
249
+ return json({
250
+ full_name: site.fullName,
251
+ default_branch: repo.defaultBranch
252
+ });
253
+ }
254
+ const ref = rest.match(/^\/git\/refs?\/heads\/(.+)$/);
255
+ if (ref && method === "GET") {
256
+ const sha = repo.branches.get(decodeURIComponent(ref[1] ?? ""));
257
+ return sha ? json({ object: { sha } }) : json({ message: "Not Found" }, 404);
258
+ }
259
+ if (ref && method === "DELETE") {
260
+ repo.branches.delete(decodeURIComponent(ref[1] ?? ""));
261
+ return new Response(null, { status: 204 });
262
+ }
263
+ if (rest === "/git/refs" && method === "POST") {
264
+ const body = await request.json();
265
+ repo.branches.set(body.ref.replace(/^refs\/heads\//, ""), body.sha);
266
+ return json({
267
+ ref: body.ref,
268
+ object: { sha: body.sha }
269
+ }, 201);
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
+ }
313
+ if (rest === "/git/blobs" && method === "POST") {
314
+ if (principal.push === false) return json({ message: "Resource not accessible by integration" }, 403);
315
+ const body = await request.json();
316
+ return json({ sha: target.blobSha(body.encoding === "base64" ? fakeFileFromBytes(decodeBase64(body.content)) : body.content) }, 201);
317
+ }
318
+ if (rest === "/git/trees" && method === "POST") {
319
+ const body = await request.json();
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);
322
+ for (const entry of body.tree) {
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 ?? "");
329
+ if (content === void 0) return json({ message: `No blob ${entry.sha}` }, 422);
330
+ files.set(entry.path, content);
331
+ }
332
+ const sha = target.blobSha(`tree:${[...files.keys()].join(",")}:${trees.size}`);
333
+ trees.set(sha, files);
334
+ return json({ sha }, 201);
335
+ }
336
+ if (rest === "/git/commits" && method === "POST") {
337
+ const body = await request.json();
338
+ const files = trees.get(body.tree);
339
+ if (!files) return json({ message: "Tree not found" }, 422);
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);
344
+ }
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 } });
357
+ }
358
+ }
24
359
  function deploy(sha) {
25
360
  if (options.deployAfterChecks === void 0) return;
26
361
  deployments.unshift({
@@ -30,23 +365,281 @@ function fakeGitHub(options) {
30
365
  checks: 0
31
366
  });
32
367
  }
33
- function commit(changes, message, expectedHead) {
34
- const sha = repo.commit(changes, message, { expectedHead });
35
- deploy(sha);
368
+ function commit(changes, message, expectedHead, author, branch = repo.defaultBranch) {
369
+ const sha = repo.commit(changes, message, {
370
+ expectedHead,
371
+ author,
372
+ branch
373
+ });
374
+ if (branch === repo.defaultBranch) deploy(sha);
36
375
  return sha;
37
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
+ }
38
555
  async function handle(request) {
39
556
  requests.push(request.clone());
40
557
  const url = new URL(request.url);
41
558
  const path = url.pathname;
42
- 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 /, "") ?? "");
43
562
  if (!user) return json({ message: "Bad credentials" }, 401);
44
- if (path === "/user") return json({
45
- login: user.login,
46
- name: user.name ?? null,
47
- avatar_url: `https://avatars.example/${user.login}`
48
- });
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);
613
+ if (setup) return setup;
614
+ const account = path.match(/^\/users\/([^/]+)$/);
615
+ if (account) {
616
+ const login = decodeURIComponent(account[1] ?? "");
617
+ return accounts.has(login) ? json({ login }) : json({ message: "Not Found" }, 404);
618
+ }
49
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
+ }
50
643
  const { variables } = await request.json();
51
644
  const { input } = variables;
52
645
  if (input.branch.repositoryNameWithOwner !== options.repo) return json({ errors: [{
@@ -66,7 +659,7 @@ function fakeGitHub(options) {
66
659
  })), ...(input.fileChanges.deletions ?? []).map((deletion) => ({
67
660
  path: deletion.path,
68
661
  delete: true
69
- }))], input.message.headline, input.expectedHeadOid) } } } });
662
+ }))], input.message.headline, input.expectedHeadOid, user.name ?? user.login, input.branch.branchName) } } } });
70
663
  } catch (error) {
71
664
  if (error instanceof FakeConflictError) return json({
72
665
  data: { createCommitOnBranch: null },
@@ -80,12 +673,97 @@ function fakeGitHub(options) {
80
673
  }
81
674
  const match = path.match(/^\/repos\/([^/]+\/[^/]+)(\/.*)?$/);
82
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);
83
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
+ }
728
+ if (rest === "/pages" && request.method === "GET") {
729
+ const site = pages.current;
730
+ if (!site) return json({ message: "Not Found" }, 404);
731
+ return json({
732
+ html_url: site.cname ? `https://${site.cname}/` : site.html_url,
733
+ cname: site.cname,
734
+ https_enforced: site.https_enforced,
735
+ https_certificate: site.cname ? {
736
+ state: site.certificate ?? "new",
737
+ domains: [site.cname]
738
+ } : null
739
+ });
740
+ }
741
+ if (rest === "/pages" && request.method === "PUT") {
742
+ const site = pages.current;
743
+ if (!site) return json({ message: "Not Found" }, 404);
744
+ if (user.pages === false) return json({ message: "Resource not accessible by personal access token" }, 403);
745
+ const body = await request.json();
746
+ if (body.cname && options.takenDomains?.includes(body.cname)) return json({ message: `The CNAME \`${body.cname}\` is already taken.` }, 422);
747
+ if (body.https_enforced && !["approved", "issued"].includes(site.certificate ?? "")) return json({ message: "The certificate does not exist yet" }, 422);
748
+ if (body.cname !== void 0) {
749
+ site.cname = body.cname;
750
+ site.certificate = body.cname ? "new" : void 0;
751
+ if (!body.cname) site.https_enforced = false;
752
+ }
753
+ if (body.https_enforced !== void 0) site.https_enforced = body.https_enforced;
754
+ return new Response(null, { status: 204 });
755
+ }
84
756
  if (rest === "") return json({
757
+ id: 1,
85
758
  full_name: options.repo,
86
759
  default_branch: repo.defaultBranch,
87
- permissions: { push: user.push !== false }
760
+ permissions: {
761
+ push: user.push !== false,
762
+ admin: user.admin === true
763
+ }
88
764
  });
765
+ const people = await handleCollaborators(request, rest, user);
766
+ if (people) return people;
89
767
  const ref = rest.match(/^\/git\/ref\/heads\/(.+)$/);
90
768
  if (ref) {
91
769
  const sha = repo.branches.get(decodeURIComponent(ref[1] ?? ""));
@@ -103,7 +781,8 @@ function fakeGitHub(options) {
103
781
  tree: [...repo.files(commitSha)].map(([file, content]) => ({
104
782
  path: file,
105
783
  type: "blob",
106
- sha: repo.blobSha(content)
784
+ sha: repo.blobSha(content),
785
+ size: fakeFileBytes(content).length
107
786
  })),
108
787
  truncated: false
109
788
  });
@@ -117,8 +796,91 @@ function fakeGitHub(options) {
117
796
  encoding: "base64"
118
797
  });
119
798
  }
120
- const compare = rest.match(/^\/compare\/([0-9a-f]{40})\.\.\.([0-9a-f]{40})$/);
121
- if (compare) return json({ files: repo.changedPaths(compare[1] ?? "", compare[2] ?? "").map((filename) => ({ filename })) });
799
+ if (rest === "/commits") {
800
+ const ref = url.searchParams.get("sha") ?? repo.defaultBranch;
801
+ const file = url.searchParams.get("path");
802
+ if (!repo.commitAt(repo.branches.get(ref) ?? ref)) return json({ message: "No commit found for SHA" }, 422);
803
+ const perPage = Number(url.searchParams.get("per_page") ?? 30);
804
+ const page = Number(url.searchParams.get("page") ?? 1);
805
+ return json((file ? repo.history(file, ref) : []).slice((page - 1) * perPage, page * perPage).map((commit) => ({
806
+ sha: commit.sha,
807
+ commit: {
808
+ message: commit.message,
809
+ author: {
810
+ name: commit.author ?? "GitHub",
811
+ date: commit.date
812
+ }
813
+ },
814
+ author: null
815
+ })));
816
+ }
817
+ if (rest === "/actions/workflows/deploy.yml/dispatches" && request.method === "POST") {
818
+ if (!(user.actionsWrite ?? user.administration)) return json({ message: "Resource not accessible by personal access token" }, 403);
819
+ const body = await request.json();
820
+ runs.push({
821
+ id: runs.length + 1,
822
+ sha: repo.head(body.ref),
823
+ event: "workflow_dispatch",
824
+ inputs: body.inputs,
825
+ status: "queued",
826
+ conclusion: null,
827
+ jobs: []
828
+ });
829
+ return new Response(null, { status: 204 });
830
+ }
831
+ if (rest === "/actions/workflows/deploy.yml/runs") {
832
+ if (user.actions === false) return json({ message: "Resource not accessible by personal access token" }, 403);
833
+ return json({
834
+ total_count: runs.length,
835
+ workflow_runs: [...runs].reverse().map((run) => ({
836
+ id: run.id,
837
+ event: run.event ?? "push",
838
+ head_sha: run.sha,
839
+ status: run.status,
840
+ conclusion: run.conclusion,
841
+ created_at: run.createdAt ?? "2026-01-05T04:17:00Z",
842
+ html_url: `https://github.com/${options.repo}/actions/runs/${run.id}`
843
+ }))
844
+ });
845
+ }
846
+ if (rest === "/actions/runs" || rest.startsWith("/actions/runs/")) {
847
+ if (user.actions === false) return json({ message: "Resource not accessible by personal access token" }, 403);
848
+ const htmlUrl = (run) => `https://github.com/${options.repo}/actions/runs/${run.id}`;
849
+ if (rest === "/actions/runs") {
850
+ const sha = url.searchParams.get("head_sha");
851
+ const found = runs.filter((run) => !sha || run.sha === sha);
852
+ return json({
853
+ total_count: found.length,
854
+ workflow_runs: found.map((run) => ({
855
+ id: run.id,
856
+ head_sha: run.sha,
857
+ status: run.status,
858
+ conclusion: run.conclusion,
859
+ name: "Deploy to GitHub Pages",
860
+ path: ".github/workflows/deploy.yml",
861
+ html_url: htmlUrl(run)
862
+ }))
863
+ });
864
+ }
865
+ const jobs = rest.match(/^\/actions\/runs\/(\d+)\/jobs$/);
866
+ const run = runs.find((candidate) => candidate.id === Number(jobs?.[1]));
867
+ return run ? json({
868
+ total_count: run.jobs.length,
869
+ jobs: run.jobs
870
+ }) : json({ message: "Not Found" }, 404);
871
+ }
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
+ }
122
884
  if (rest === "/deployments") {
123
885
  const sha = url.searchParams.get("sha");
124
886
  return json(deployments.filter((deployment) => !sha || deployment.sha === sha).slice(0, Number(url.searchParams.get("per_page") ?? 30)).map(({ id, sha }) => ({
@@ -141,7 +903,28 @@ function fakeGitHub(options) {
141
903
  }
142
904
  return {
143
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,
916
+ /** Repositories created through the API, by `owner/name`. */
917
+ created,
918
+ /** The site's GitHub Pages settings, which tests can change, such as to issue the certificate. */
919
+ pages,
920
+ /** The repository's collaborators, by login, and invitations waiting to be accepted. */
921
+ collaborators,
922
+ invitations,
923
+ accept,
144
924
  deployments,
925
+ /** GitHub Actions workflow runs, which tests add with `failBuild()`. */
926
+ runs,
927
+ failBuild,
145
928
  /** Every request received, for checking what the backend sent. */
146
929
  requests,
147
930
  /** Simulates someone else publishing. */