@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/README.md +2 -2
- package/dist/index.d.ts +114 -6
- package/dist/index.js +1352 -47
- package/dist/testing.d.ts +133 -1
- package/dist/testing.js +798 -15
- package/package.json +5 -5
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, {
|
|
35
|
-
|
|
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
|
|
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")
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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: {
|
|
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
|
-
|
|
121
|
-
|
|
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. */
|