@goodfellow-cms/github 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,126 @@
1
+ import { FileChange } from "@goodfellow-cms/core";
2
+ import { FakeRepo } from "@goodfellow-cms/core/testing";
3
+ //#region src/testing.d.ts
4
+ export interface FakeGitHubUser {
5
+ login: string;
6
+ name?: string;
7
+ /** Whether the user may publish. Defaults to true. */
8
+ push?: boolean;
9
+ /** Whether the token may write `.github/workflows/`. Defaults to true. */
10
+ workflow?: boolean;
11
+ /** Whether the token may change GitHub Pages settings. Defaults to true. */
12
+ pages?: boolean;
13
+ /** Whether the user is an owner (admin) of the site's repository. Defaults to false. */
14
+ admin?: boolean;
15
+ /** Whether the token has GitHub's Administration permission, which changing collaborators needs. Defaults to false. */
16
+ administration?: boolean;
17
+ /** Whether the token may read GitHub Actions. Defaults to true. */
18
+ actions?: boolean;
19
+ /** Whether the token may run workflows, as owner tokens can. Defaults to the `administration` setting. */
20
+ actionsWrite?: boolean;
21
+ }
22
+ /** A GitHub Actions workflow run, with its jobs and their steps. */
23
+ export interface FakeRun {
24
+ id: number;
25
+ sha: string;
26
+ /** What started it: `push` (the default), `schedule` or `workflow_dispatch`. */
27
+ event?: string;
28
+ /** For `workflow_dispatch`, the inputs it was given. */
29
+ inputs?: Record<string, string>;
30
+ createdAt?: string;
31
+ status: "queued" | "in_progress" | "completed";
32
+ conclusion: "success" | "failure" | "startup_failure" | "cancelled" | null;
33
+ jobs: Array<{
34
+ name: string;
35
+ conclusion: string | null;
36
+ steps: Array<{
37
+ name: string;
38
+ conclusion: string | null;
39
+ }>;
40
+ }>;
41
+ }
42
+ /** A repository invitation that hasn't been accepted yet. */
43
+ export interface FakeInvitation {
44
+ id: number;
45
+ login: string;
46
+ permission: "admin" | "write";
47
+ }
48
+ /** The site's GitHub Pages settings, as `GET /repos/{repo}/pages` reports them. */
49
+ export interface FakePages {
50
+ html_url: string;
51
+ cname: string | null;
52
+ https_enforced: boolean;
53
+ /** The certificate's state, such as `new` while it's being issued and `approved` once it's ready. */
54
+ certificate?: string;
55
+ }
56
+ export interface FakeDeploymentStatus {
57
+ state: "queued" | "in_progress" | "success" | "failure" | "error" | "inactive";
58
+ log_url?: string;
59
+ }
60
+ export interface FakeGitHubOptions {
61
+ /** `owner/name`. */
62
+ repo: string;
63
+ files?: Record<string, string>;
64
+ /** Access tokens and the users they belong to. */
65
+ tokens?: Record<string, FakeGitHubUser>;
66
+ /**
67
+ * Simulates GitHub Pages or Vercel: every commit gets a deployment that
68
+ * reports success after being checked this many times. Off by default.
69
+ */
70
+ deployAfterChecks?: number;
71
+ /** Organizations the users belong to, which new repositories can be created in. */
72
+ orgs?: string[];
73
+ /** The default branch of repositories created through the API, as an account's settings choose. Defaults to `main`. */
74
+ newRepoBranch?: string;
75
+ /** Whether private repositories can have GitHub Pages and rules, as on paid plans. Off by default, as on the free plan. */
76
+ paidPlan?: boolean;
77
+ /** Publishes the site with GitHub Pages, at `https://<owner>.github.io/<name>/`. Off by default. */
78
+ pages?: boolean;
79
+ /** Domains another GitHub Pages site already uses. */
80
+ takenDomains?: string[];
81
+ /** Other GitHub accounts that exist, which can be invited. Every token's user exists too. */
82
+ accounts?: string[];
83
+ }
84
+ /** A repository created through the fake's API, as the setup page creates them. */
85
+ export interface FakeCreatedRepo {
86
+ fullName: string;
87
+ private: boolean;
88
+ description?: string;
89
+ repo: FakeRepo;
90
+ pages?: {
91
+ build_type: string;
92
+ };
93
+ rulesets: unknown[];
94
+ }
95
+ interface Deployment {
96
+ id: number;
97
+ sha: string;
98
+ statuses: FakeDeploymentStatus[];
99
+ checks: number;
100
+ }
101
+ export declare function fakeGitHub(options: FakeGitHubOptions): {
102
+ repo: FakeRepo;
103
+ /** Repositories created through the API, by `owner/name`. */
104
+ created: Map<string, FakeCreatedRepo>;
105
+ /** The site's GitHub Pages settings, which tests can change, such as to issue the certificate. */
106
+ pages: {
107
+ current?: FakePages;
108
+ };
109
+ /** The repository's collaborators, by login, and invitations waiting to be accepted. */
110
+ collaborators: Map<string, "admin" | "push" | "read">;
111
+ invitations: FakeInvitation[];
112
+ accept: (login: string) => void;
113
+ deployments: Deployment[];
114
+ /** GitHub Actions workflow runs, which tests add with `failBuild()`. */
115
+ runs: FakeRun[];
116
+ failBuild: (sha: string, step?: string, job?: string) => FakeRun;
117
+ /** Every request received, for checking what the backend sent. */
118
+ requests: Request[];
119
+ /** Simulates someone else publishing. */
120
+ commit: (changes: FileChange[], message: string, expectedHead?: string, author?: string) => string;
121
+ handle: (request: Request) => Promise<Response>;
122
+ /** A `fetch` that answers from the fake instead of the network. */
123
+ fetch: typeof fetch;
124
+ };
125
+ export type FakeGitHub = ReturnType<typeof fakeGitHub>;
126
+ //#endregion
@@ -0,0 +1,520 @@
1
+ import { decodeBase64, encodeBase64Bytes } from "@goodfellow-cms/core";
2
+ import { FakeConflictError, FakeRepo, fakeFileBytes, fakeFileFromBytes } from "@goodfellow-cms/core/testing";
3
+ //#region src/testing.ts
4
+ /**
5
+ * A fake of the parts of GitHub's API that `@goodfellow-cms/github` uses, backed by
6
+ * an in-memory repository. For tests only: pass `fake.fetch` as the backend's
7
+ * `fetch`, or route browser requests to `fake.handle` in end-to-end tests.
8
+ */
9
+ function json(body, status = 200) {
10
+ return new Response(JSON.stringify(body), {
11
+ status,
12
+ headers: { "content-type": "application/json" }
13
+ });
14
+ }
15
+ function fakeGitHub(options) {
16
+ const repo = new FakeRepo(options.files);
17
+ const tokens = options.tokens ?? { "test-token": {
18
+ login: "editor",
19
+ name: "Test Editor"
20
+ } };
21
+ const deployments = [];
22
+ const runs = [];
23
+ const requests = [];
24
+ let nextDeployment = 1;
25
+ const created = /* @__PURE__ */ new Map();
26
+ const [owner = "", name = ""] = options.repo.split("/");
27
+ const pages = { current: options.pages ? {
28
+ html_url: `https://${owner.toLowerCase()}.github.io/${name}/`,
29
+ cname: null,
30
+ https_enforced: false
31
+ } : void 0 };
32
+ const trees = /* @__PURE__ */ new Map();
33
+ /** The repository's collaborators and their permission. Every token's user is one. */
34
+ const collaborators = new Map(Object.values(tokens).map((user) => [user.login, user.admin ? "admin" : user.push === false ? "read" : "push"]));
35
+ const invitations = [];
36
+ const accounts = /* @__PURE__ */ new Set([...Object.values(tokens).map((user) => user.login), ...options.accounts ?? []]);
37
+ let nextInvitation = 1;
38
+ /** Collaborators and invitations, which only owners' tokens with the Administration permission can change. */
39
+ async function handleCollaborators(request, rest, user) {
40
+ const forbidden = () => json({ message: "Resource not accessible by personal access token" }, 403);
41
+ const canManage = user.admin === true && user.administration === true;
42
+ if (rest === "/collaborators" && request.method === "GET") return json([...collaborators].map(([login, permission]) => ({
43
+ login,
44
+ avatar_url: `https://avatars.example/${login}`,
45
+ permissions: {
46
+ admin: permission === "admin",
47
+ maintain: false,
48
+ push: permission !== "read",
49
+ pull: true
50
+ }
51
+ })));
52
+ if (rest === "/invitations" && request.method === "GET") {
53
+ if (!canManage) return forbidden();
54
+ return json(invitations.map((invitation) => ({
55
+ id: invitation.id,
56
+ invitee: {
57
+ login: invitation.login,
58
+ avatar_url: `https://avatars.example/${invitation.login}`
59
+ },
60
+ permissions: invitation.permission
61
+ })));
62
+ }
63
+ const collaborator = rest.match(/^\/collaborators\/([^/]+)$/);
64
+ if (collaborator) {
65
+ if (!canManage) return forbidden();
66
+ const login = decodeURIComponent(collaborator[1] ?? "");
67
+ if (request.method === "DELETE") {
68
+ collaborators.delete(login);
69
+ return new Response(null, { status: 204 });
70
+ }
71
+ if (request.method === "PUT") {
72
+ if (!accounts.has(login)) return json({ message: "Not Found" }, 404);
73
+ const { permission = "push" } = await request.json();
74
+ if (collaborators.has(login)) {
75
+ collaborators.set(login, permission);
76
+ return new Response(null, { status: 204 });
77
+ }
78
+ const existing = invitations.find((invitation) => invitation.login === login);
79
+ const invitation = existing ?? {
80
+ id: nextInvitation++,
81
+ login,
82
+ permission: "write"
83
+ };
84
+ invitation.permission = permission === "admin" ? "admin" : "write";
85
+ if (!existing) invitations.push(invitation);
86
+ return json({
87
+ id: invitation.id,
88
+ invitee: { login },
89
+ permissions: invitation.permission
90
+ }, 201);
91
+ }
92
+ }
93
+ const invitation = rest.match(/^\/invitations\/(\d+)$/);
94
+ if (invitation) {
95
+ if (!canManage) return forbidden();
96
+ const index = invitations.findIndex((candidate) => candidate.id === Number(invitation[1]));
97
+ if (index === -1) return json({ message: "Not Found" }, 404);
98
+ if (request.method === "DELETE") {
99
+ invitations.splice(index, 1);
100
+ return new Response(null, { status: 204 });
101
+ }
102
+ if (request.method === "PATCH") {
103
+ const { permissions } = await request.json();
104
+ invitations[index].permission = permissions;
105
+ return json({
106
+ id: invitations[index]?.id,
107
+ permissions
108
+ });
109
+ }
110
+ }
111
+ }
112
+ /**
113
+ * Simulates the deploy workflow failing for a commit at a step, such as
114
+ * `"Run npm ci"`, in the `build` job or another, or before any step ran.
115
+ */
116
+ function failBuild(sha, step, job = "build") {
117
+ const run = {
118
+ id: runs.length + 1,
119
+ sha,
120
+ status: "completed",
121
+ conclusion: "failure",
122
+ jobs: [{
123
+ name: job,
124
+ conclusion: "failure",
125
+ steps: step ? [{
126
+ name: "Set up job",
127
+ conclusion: "success"
128
+ }, {
129
+ name: step,
130
+ conclusion: "failure"
131
+ }] : []
132
+ }]
133
+ };
134
+ runs.push(run);
135
+ return run;
136
+ }
137
+ /** Simulates an invited person accepting the invitation. */
138
+ function accept(login) {
139
+ const index = invitations.findIndex((invitation) => invitation.login === login);
140
+ const [invitation] = index === -1 ? [] : invitations.splice(index, 1);
141
+ if (invitation) collaborators.set(login, invitation.permission === "admin" ? "admin" : "push");
142
+ }
143
+ /** Repository creation and the Git data API, for repositories created through the fake. */
144
+ async function handleCreated(request, path, login) {
145
+ if (path === "/user/orgs") return json((options.orgs ?? []).map((org) => ({ login: org })));
146
+ const create = path === "/user/repos" ? login : path.match(/^\/orgs\/([^/]+)\/repos$/)?.[1];
147
+ if (create !== void 0 && request.method === "POST") {
148
+ if (path !== "/user/repos" && !options.orgs?.includes(create)) return json({ message: "Not Found" }, 404);
149
+ const body = await request.json();
150
+ if (!/^[\w.-]+$/.test(body.name)) return json({ message: "Repository creation failed." }, 422);
151
+ const fullName = `${create}/${body.name}`;
152
+ if (created.has(fullName) || fullName === options.repo) return json({
153
+ message: "Repository creation failed.",
154
+ errors: [{ message: "name already exists on this account" }]
155
+ }, 422);
156
+ const repo = new FakeRepo({ "README.md": `# ${body.name}\n` }, options.newRepoBranch ?? "main");
157
+ created.set(fullName, {
158
+ fullName,
159
+ private: body.private === true,
160
+ description: body.description,
161
+ repo,
162
+ rulesets: []
163
+ });
164
+ return json({
165
+ full_name: fullName,
166
+ html_url: `https://github.com/${fullName}`,
167
+ default_branch: repo.defaultBranch
168
+ }, 201);
169
+ }
170
+ const match = path.match(/^\/repos\/([^/]+\/[^/]+)(\/.*)?$/);
171
+ const site = created.get(decodeURIComponent(match?.[1] ?? ""));
172
+ if (!match || !site) return void 0;
173
+ const { repo } = site;
174
+ const rest = match[2] ?? "";
175
+ const method = request.method;
176
+ if (rest === "" && method === "GET") return json({
177
+ full_name: site.fullName,
178
+ default_branch: repo.defaultBranch,
179
+ permissions: { push: true }
180
+ });
181
+ if (rest === "" && method === "PATCH") {
182
+ const body = await request.json();
183
+ if (body.default_branch) {
184
+ if (!repo.branches.has(body.default_branch)) return json({ message: "Not Found" }, 422);
185
+ repo.defaultBranch = body.default_branch;
186
+ }
187
+ return json({
188
+ full_name: site.fullName,
189
+ default_branch: repo.defaultBranch
190
+ });
191
+ }
192
+ const ref = rest.match(/^\/git\/refs?\/heads\/(.+)$/);
193
+ if (ref && method === "GET") {
194
+ const sha = repo.branches.get(decodeURIComponent(ref[1] ?? ""));
195
+ return sha ? json({ object: { sha } }) : json({ message: "Not Found" }, 404);
196
+ }
197
+ if (ref && method === "DELETE") {
198
+ repo.branches.delete(decodeURIComponent(ref[1] ?? ""));
199
+ return new Response(null, { status: 204 });
200
+ }
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
+ if (rest === "/git/refs" && method === "POST") {
213
+ const body = await request.json();
214
+ repo.branches.set(body.ref.replace(/^refs\/heads\//, ""), body.sha);
215
+ return json({
216
+ ref: body.ref,
217
+ object: { sha: body.sha }
218
+ }, 201);
219
+ }
220
+ if (rest === "/git/blobs" && method === "POST") {
221
+ const body = await request.json();
222
+ return json({ sha: repo.blobSha(body.encoding === "base64" ? fakeFileFromBytes(decodeBase64(body.content)) : body.content) }, 201);
223
+ }
224
+ if (rest === "/git/trees" && method === "POST") {
225
+ const body = await request.json();
226
+ const files = /* @__PURE__ */ new Map();
227
+ 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 ?? "");
230
+ if (content === void 0) return json({ message: `No blob ${entry.sha}` }, 422);
231
+ files.set(entry.path, content);
232
+ }
233
+ const sha = repo.blobSha(`tree:${[...files.keys()].join(",")}:${trees.size}`);
234
+ trees.set(sha, files);
235
+ return json({ sha }, 201);
236
+ }
237
+ if (rest === "/git/commits" && method === "POST") {
238
+ const body = await request.json();
239
+ const files = trees.get(body.tree);
240
+ 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);
251
+ }
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);
256
+ }
257
+ return json({ message: "Not Found" }, 404);
258
+ }
259
+ function user(request) {
260
+ return tokens[request.headers.get("authorization")?.replace(/^Bearer /, "") ?? ""];
261
+ }
262
+ function deploy(sha) {
263
+ if (options.deployAfterChecks === void 0) return;
264
+ deployments.unshift({
265
+ id: nextDeployment++,
266
+ sha,
267
+ statuses: [{ state: "in_progress" }],
268
+ checks: 0
269
+ });
270
+ }
271
+ function commit(changes, message, expectedHead, author) {
272
+ const sha = repo.commit(changes, message, {
273
+ expectedHead,
274
+ author
275
+ });
276
+ deploy(sha);
277
+ return sha;
278
+ }
279
+ async function handle(request) {
280
+ requests.push(request.clone());
281
+ const url = new URL(request.url);
282
+ const path = url.pathname;
283
+ const user = tokens[request.headers.get("authorization")?.replace(/^Bearer /, "") ?? ""];
284
+ 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);
291
+ if (setup) return setup;
292
+ const account = path.match(/^\/users\/([^/]+)$/);
293
+ if (account) {
294
+ const login = decodeURIComponent(account[1] ?? "");
295
+ return accounts.has(login) ? json({ login }) : json({ message: "Not Found" }, 404);
296
+ }
297
+ if (path === "/graphql" && request.method === "POST") {
298
+ const { variables } = await request.json();
299
+ const { input } = variables;
300
+ if (input.branch.repositoryNameWithOwner !== options.repo) return json({ errors: [{
301
+ type: "NOT_FOUND",
302
+ message: "Could not resolve to a Repository"
303
+ }] });
304
+ if (user.push === false) return json({ errors: [{
305
+ type: "FORBIDDEN",
306
+ message: "Resource not accessible"
307
+ }] });
308
+ const files = repo.files(input.branch.branchName);
309
+ for (const deletion of input.fileChanges.deletions ?? []) if (!files.has(deletion.path)) return json({ errors: [{ message: `No file at ${deletion.path}` }] });
310
+ try {
311
+ return json({ data: { createCommitOnBranch: { commit: { oid: commit([...(input.fileChanges.additions ?? []).map((addition) => ({
312
+ path: addition.path,
313
+ bytes: decodeBase64(addition.contents)
314
+ })), ...(input.fileChanges.deletions ?? []).map((deletion) => ({
315
+ path: deletion.path,
316
+ delete: true
317
+ }))], input.message.headline, input.expectedHeadOid, user.name ?? user.login) } } } });
318
+ } catch (error) {
319
+ if (error instanceof FakeConflictError) return json({
320
+ data: { createCommitOnBranch: null },
321
+ errors: [{
322
+ type: "STALE_DATA",
323
+ message: "Expected branch to point to another commit"
324
+ }]
325
+ });
326
+ throw error;
327
+ }
328
+ }
329
+ const match = path.match(/^\/repos\/([^/]+\/[^/]+)(\/.*)?$/);
330
+ if (!match || decodeURIComponent(match[1] ?? "") !== options.repo) return json({ message: "Not Found" }, 404);
331
+ const rest = match[2] ?? "";
332
+ if (rest === "/pages" && request.method === "GET") {
333
+ const site = pages.current;
334
+ if (!site) return json({ message: "Not Found" }, 404);
335
+ return json({
336
+ html_url: site.cname ? `https://${site.cname}/` : site.html_url,
337
+ cname: site.cname,
338
+ https_enforced: site.https_enforced,
339
+ https_certificate: site.cname ? {
340
+ state: site.certificate ?? "new",
341
+ domains: [site.cname]
342
+ } : null
343
+ });
344
+ }
345
+ if (rest === "/pages" && request.method === "PUT") {
346
+ const site = pages.current;
347
+ if (!site) return json({ message: "Not Found" }, 404);
348
+ if (user.pages === false) return json({ message: "Resource not accessible by personal access token" }, 403);
349
+ const body = await request.json();
350
+ if (body.cname && options.takenDomains?.includes(body.cname)) return json({ message: `The CNAME \`${body.cname}\` is already taken.` }, 422);
351
+ if (body.https_enforced && !["approved", "issued"].includes(site.certificate ?? "")) return json({ message: "The certificate does not exist yet" }, 422);
352
+ if (body.cname !== void 0) {
353
+ site.cname = body.cname;
354
+ site.certificate = body.cname ? "new" : void 0;
355
+ if (!body.cname) site.https_enforced = false;
356
+ }
357
+ if (body.https_enforced !== void 0) site.https_enforced = body.https_enforced;
358
+ return new Response(null, { status: 204 });
359
+ }
360
+ if (rest === "") return json({
361
+ full_name: options.repo,
362
+ default_branch: repo.defaultBranch,
363
+ permissions: {
364
+ push: user.push !== false,
365
+ admin: user.admin === true
366
+ }
367
+ });
368
+ const people = await handleCollaborators(request, rest, user);
369
+ if (people) return people;
370
+ const ref = rest.match(/^\/git\/ref\/heads\/(.+)$/);
371
+ if (ref) {
372
+ const sha = repo.branches.get(decodeURIComponent(ref[1] ?? ""));
373
+ return sha ? json({ object: {
374
+ sha,
375
+ type: "commit"
376
+ } }) : json({ message: "Not Found" }, 404);
377
+ }
378
+ const tree = rest.match(/^\/git\/trees\/([0-9a-f]{40})$/);
379
+ if (tree) {
380
+ const commitSha = tree[1] ?? "";
381
+ if (!repo.commitAt(commitSha)) return json({ message: "Not Found" }, 404);
382
+ return json({
383
+ sha: commitSha,
384
+ tree: [...repo.files(commitSha)].map(([file, content]) => ({
385
+ path: file,
386
+ type: "blob",
387
+ sha: repo.blobSha(content)
388
+ })),
389
+ truncated: false
390
+ });
391
+ }
392
+ const blob = rest.match(/^\/git\/blobs\/([0-9a-f]{40})$/);
393
+ if (blob) {
394
+ const content = repo.blobs.get(blob[1] ?? "");
395
+ if (content === void 0) return json({ message: "Not Found" }, 404);
396
+ return request.headers.get("accept")?.includes("raw") ? new Response(fakeFileBytes(content)) : json({
397
+ content: encodeBase64Bytes(fakeFileBytes(content)),
398
+ encoding: "base64"
399
+ });
400
+ }
401
+ if (rest === "/commits") {
402
+ const ref = url.searchParams.get("sha") ?? repo.defaultBranch;
403
+ const file = url.searchParams.get("path");
404
+ if (!repo.commitAt(repo.branches.get(ref) ?? ref)) return json({ message: "No commit found for SHA" }, 422);
405
+ const perPage = Number(url.searchParams.get("per_page") ?? 30);
406
+ const page = Number(url.searchParams.get("page") ?? 1);
407
+ return json((file ? repo.history(file, ref) : []).slice((page - 1) * perPage, page * perPage).map((commit) => ({
408
+ sha: commit.sha,
409
+ commit: {
410
+ message: commit.message,
411
+ author: {
412
+ name: commit.author ?? "GitHub",
413
+ date: commit.date
414
+ }
415
+ },
416
+ author: null
417
+ })));
418
+ }
419
+ if (rest === "/actions/workflows/deploy.yml/dispatches" && request.method === "POST") {
420
+ if (!(user.actionsWrite ?? user.administration)) return json({ message: "Resource not accessible by personal access token" }, 403);
421
+ const body = await request.json();
422
+ runs.push({
423
+ id: runs.length + 1,
424
+ sha: repo.head(body.ref),
425
+ event: "workflow_dispatch",
426
+ inputs: body.inputs,
427
+ status: "queued",
428
+ conclusion: null,
429
+ jobs: []
430
+ });
431
+ return new Response(null, { status: 204 });
432
+ }
433
+ if (rest === "/actions/workflows/deploy.yml/runs") {
434
+ if (user.actions === false) return json({ message: "Resource not accessible by personal access token" }, 403);
435
+ return json({
436
+ total_count: runs.length,
437
+ workflow_runs: [...runs].reverse().map((run) => ({
438
+ id: run.id,
439
+ event: run.event ?? "push",
440
+ head_sha: run.sha,
441
+ status: run.status,
442
+ conclusion: run.conclusion,
443
+ created_at: run.createdAt ?? "2026-01-05T04:17:00Z",
444
+ html_url: `https://github.com/${options.repo}/actions/runs/${run.id}`
445
+ }))
446
+ });
447
+ }
448
+ if (rest === "/actions/runs" || rest.startsWith("/actions/runs/")) {
449
+ if (user.actions === false) return json({ message: "Resource not accessible by personal access token" }, 403);
450
+ const htmlUrl = (run) => `https://github.com/${options.repo}/actions/runs/${run.id}`;
451
+ if (rest === "/actions/runs") {
452
+ const sha = url.searchParams.get("head_sha");
453
+ const found = runs.filter((run) => !sha || run.sha === sha);
454
+ return json({
455
+ total_count: found.length,
456
+ workflow_runs: found.map((run) => ({
457
+ id: run.id,
458
+ head_sha: run.sha,
459
+ status: run.status,
460
+ conclusion: run.conclusion,
461
+ name: "Deploy to GitHub Pages",
462
+ path: ".github/workflows/deploy.yml",
463
+ html_url: htmlUrl(run)
464
+ }))
465
+ });
466
+ }
467
+ const jobs = rest.match(/^\/actions\/runs\/(\d+)\/jobs$/);
468
+ const run = runs.find((candidate) => candidate.id === Number(jobs?.[1]));
469
+ return run ? json({
470
+ total_count: run.jobs.length,
471
+ jobs: run.jobs
472
+ }) : json({ message: "Not Found" }, 404);
473
+ }
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 })) });
476
+ if (rest === "/deployments") {
477
+ const sha = url.searchParams.get("sha");
478
+ return json(deployments.filter((deployment) => !sha || deployment.sha === sha).slice(0, Number(url.searchParams.get("per_page") ?? 30)).map(({ id, sha }) => ({
479
+ id,
480
+ sha
481
+ })));
482
+ }
483
+ const statuses = rest.match(/^\/deployments\/(\d+)\/statuses$/);
484
+ if (statuses) {
485
+ const deployment = deployments.find((candidate) => candidate.id === Number(statuses[1]));
486
+ if (!deployment) return json({ message: "Not Found" }, 404);
487
+ deployment.checks += 1;
488
+ if (deployment.checks > (options.deployAfterChecks ?? 0) && deployment.statuses[0]?.state === "in_progress") deployment.statuses.unshift({
489
+ state: "success",
490
+ log_url: `https://github.com/${options.repo}/actions/runs/${deployment.id}`
491
+ });
492
+ return json(deployment.statuses);
493
+ }
494
+ return json({ message: "Not Found" }, 404);
495
+ }
496
+ return {
497
+ repo,
498
+ /** Repositories created through the API, by `owner/name`. */
499
+ created,
500
+ /** The site's GitHub Pages settings, which tests can change, such as to issue the certificate. */
501
+ pages,
502
+ /** The repository's collaborators, by login, and invitations waiting to be accepted. */
503
+ collaborators,
504
+ invitations,
505
+ accept,
506
+ deployments,
507
+ /** GitHub Actions workflow runs, which tests add with `failBuild()`. */
508
+ runs,
509
+ failBuild,
510
+ /** Every request received, for checking what the backend sent. */
511
+ requests,
512
+ /** Simulates someone else publishing. */
513
+ commit,
514
+ handle,
515
+ /** A `fetch` that answers from the fake instead of the network. */
516
+ fetch: ((input, init) => handle(new Request(input, init)))
517
+ };
518
+ }
519
+ //#endregion
520
+ export { fakeGitHub };