@goodfellow-cms/github 0.1.0 → 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.
- package/README.md +2 -2
- package/dist/index.d.ts +46 -4
- package/dist/index.js +690 -29
- package/dist/testing.d.ts +83 -1
- package/dist/testing.js +370 -5
- package/package.json +5 -5
package/dist/testing.d.ts
CHANGED
|
@@ -6,6 +6,52 @@ export interface FakeGitHubUser {
|
|
|
6
6
|
name?: string;
|
|
7
7
|
/** Whether the user may publish. Defaults to true. */
|
|
8
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;
|
|
9
55
|
}
|
|
10
56
|
export interface FakeDeploymentStatus {
|
|
11
57
|
state: "queued" | "in_progress" | "success" | "failure" | "error" | "inactive";
|
|
@@ -22,6 +68,29 @@ export interface FakeGitHubOptions {
|
|
|
22
68
|
* reports success after being checked this many times. Off by default.
|
|
23
69
|
*/
|
|
24
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[];
|
|
25
94
|
}
|
|
26
95
|
interface Deployment {
|
|
27
96
|
id: number;
|
|
@@ -31,11 +100,24 @@ interface Deployment {
|
|
|
31
100
|
}
|
|
32
101
|
export declare function fakeGitHub(options: FakeGitHubOptions): {
|
|
33
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;
|
|
34
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;
|
|
35
117
|
/** Every request received, for checking what the backend sent. */
|
|
36
118
|
requests: Request[];
|
|
37
119
|
/** Simulates someone else publishing. */
|
|
38
|
-
commit: (changes: FileChange[], message: string, expectedHead?: string) => string;
|
|
120
|
+
commit: (changes: FileChange[], message: string, expectedHead?: string, author?: string) => string;
|
|
39
121
|
handle: (request: Request) => Promise<Response>;
|
|
40
122
|
/** A `fetch` that answers from the fake instead of the network. */
|
|
41
123
|
fetch: typeof fetch;
|
package/dist/testing.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
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
|
|
@@ -19,8 +19,246 @@ function fakeGitHub(options) {
|
|
|
19
19
|
name: "Test Editor"
|
|
20
20
|
} };
|
|
21
21
|
const deployments = [];
|
|
22
|
+
const runs = [];
|
|
22
23
|
const requests = [];
|
|
23
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
|
+
}
|
|
24
262
|
function deploy(sha) {
|
|
25
263
|
if (options.deployAfterChecks === void 0) return;
|
|
26
264
|
deployments.unshift({
|
|
@@ -30,8 +268,11 @@ function fakeGitHub(options) {
|
|
|
30
268
|
checks: 0
|
|
31
269
|
});
|
|
32
270
|
}
|
|
33
|
-
function commit(changes, message, expectedHead) {
|
|
34
|
-
const sha = repo.commit(changes, message, {
|
|
271
|
+
function commit(changes, message, expectedHead, author) {
|
|
272
|
+
const sha = repo.commit(changes, message, {
|
|
273
|
+
expectedHead,
|
|
274
|
+
author
|
|
275
|
+
});
|
|
35
276
|
deploy(sha);
|
|
36
277
|
return sha;
|
|
37
278
|
}
|
|
@@ -46,6 +287,13 @@ function fakeGitHub(options) {
|
|
|
46
287
|
name: user.name ?? null,
|
|
47
288
|
avatar_url: `https://avatars.example/${user.login}`
|
|
48
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
|
+
}
|
|
49
297
|
if (path === "/graphql" && request.method === "POST") {
|
|
50
298
|
const { variables } = await request.json();
|
|
51
299
|
const { input } = variables;
|
|
@@ -66,7 +314,7 @@ function fakeGitHub(options) {
|
|
|
66
314
|
})), ...(input.fileChanges.deletions ?? []).map((deletion) => ({
|
|
67
315
|
path: deletion.path,
|
|
68
316
|
delete: true
|
|
69
|
-
}))], input.message.headline, input.expectedHeadOid) } } } });
|
|
317
|
+
}))], input.message.headline, input.expectedHeadOid, user.name ?? user.login) } } } });
|
|
70
318
|
} catch (error) {
|
|
71
319
|
if (error instanceof FakeConflictError) return json({
|
|
72
320
|
data: { createCommitOnBranch: null },
|
|
@@ -81,11 +329,44 @@ function fakeGitHub(options) {
|
|
|
81
329
|
const match = path.match(/^\/repos\/([^/]+\/[^/]+)(\/.*)?$/);
|
|
82
330
|
if (!match || decodeURIComponent(match[1] ?? "") !== options.repo) return json({ message: "Not Found" }, 404);
|
|
83
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
|
+
}
|
|
84
360
|
if (rest === "") return json({
|
|
85
361
|
full_name: options.repo,
|
|
86
362
|
default_branch: repo.defaultBranch,
|
|
87
|
-
permissions: {
|
|
363
|
+
permissions: {
|
|
364
|
+
push: user.push !== false,
|
|
365
|
+
admin: user.admin === true
|
|
366
|
+
}
|
|
88
367
|
});
|
|
368
|
+
const people = await handleCollaborators(request, rest, user);
|
|
369
|
+
if (people) return people;
|
|
89
370
|
const ref = rest.match(/^\/git\/ref\/heads\/(.+)$/);
|
|
90
371
|
if (ref) {
|
|
91
372
|
const sha = repo.branches.get(decodeURIComponent(ref[1] ?? ""));
|
|
@@ -117,6 +398,79 @@ function fakeGitHub(options) {
|
|
|
117
398
|
encoding: "base64"
|
|
118
399
|
});
|
|
119
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
|
+
}
|
|
120
474
|
const compare = rest.match(/^\/compare\/([0-9a-f]{40})\.\.\.([0-9a-f]{40})$/);
|
|
121
475
|
if (compare) return json({ files: repo.changedPaths(compare[1] ?? "", compare[2] ?? "").map((filename) => ({ filename })) });
|
|
122
476
|
if (rest === "/deployments") {
|
|
@@ -141,7 +495,18 @@ function fakeGitHub(options) {
|
|
|
141
495
|
}
|
|
142
496
|
return {
|
|
143
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,
|
|
144
506
|
deployments,
|
|
507
|
+
/** GitHub Actions workflow runs, which tests add with `failBuild()`. */
|
|
508
|
+
runs,
|
|
509
|
+
failBuild,
|
|
145
510
|
/** Every request received, for checking what the backend sent. */
|
|
146
511
|
requests,
|
|
147
512
|
/** Simulates someone else publishing. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodfellow-cms/github",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Edit a Goodfellow site stored on GitHub, straight from the browser.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"goodfellow",
|
|
@@ -10,12 +10,12 @@
|
|
|
10
10
|
"github",
|
|
11
11
|
"git-based-cms"
|
|
12
12
|
],
|
|
13
|
-
"homepage": "https://
|
|
14
|
-
"bugs": "https://github.com/
|
|
13
|
+
"homepage": "https://goodfellow-cms.github.io/goodfellow-cms/docs/hosts",
|
|
14
|
+
"bugs": "https://github.com/goodfellow-cms/goodfellow-cms/issues",
|
|
15
15
|
"license": "MIT",
|
|
16
16
|
"repository": {
|
|
17
17
|
"type": "git",
|
|
18
|
-
"url": "git+https://github.com/
|
|
18
|
+
"url": "git+https://github.com/goodfellow-cms/goodfellow-cms.git",
|
|
19
19
|
"directory": "packages/github"
|
|
20
20
|
},
|
|
21
21
|
"type": "module",
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
"dist"
|
|
36
36
|
],
|
|
37
37
|
"dependencies": {
|
|
38
|
-
"@goodfellow-cms/core": "0.
|
|
38
|
+
"@goodfellow-cms/core": "0.2.0"
|
|
39
39
|
},
|
|
40
40
|
"devDependencies": {
|
|
41
41
|
"@puckeditor/core": "0.23.0"
|