@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.
package/dist/index.js ADDED
@@ -0,0 +1,926 @@
1
+ import { ConflictError, DomainError, EditorsError, GitApiError, SetupError, SignInError, UpdatesError, buildStepFor, compareReleases, credentialStorage, encodeBase64, encodeBase64Bytes, siteVersion } from "@goodfellow-cms/core";
2
+ //#region src/api.ts
3
+ /** Calls the GitHub REST or GraphQL API. Treats 401 as an expired sign-in; other errors become `GitApiError`. */
4
+ async function githubRequest({ apiUrl, token, fetch }, path, init = {}) {
5
+ const response = await fetch(`${apiUrl}${path}`, {
6
+ method: init.method ?? "GET",
7
+ headers: {
8
+ accept: init.accept ?? "application/vnd.github+json",
9
+ authorization: `Bearer ${token}`,
10
+ "x-github-api-version": "2022-11-28",
11
+ ...init.body !== void 0 && { "content-type": "application/json" }
12
+ },
13
+ body: init.body === void 0 ? void 0 : JSON.stringify(init.body),
14
+ cache: "no-store"
15
+ });
16
+ if (response.status === 401) throw new SignInError("invalid", "GitHub didn't accept the sign-in. It may have expired.");
17
+ if (!response.ok && !init.allow?.includes(response.status)) {
18
+ const body = await response.json().catch(() => ({}));
19
+ throw new GitApiError(response.status, `GitHub answered ${response.status}: ${body.message ?? response.statusText}`);
20
+ }
21
+ return response;
22
+ }
23
+ async function githubJson(options, path, init) {
24
+ return await (await githubRequest(options, path, init)).json();
25
+ }
26
+ /** Path segments of a repository name, encoded for URLs: `owner/name` stays `owner/name`. */
27
+ function repoPath(repo) {
28
+ return repo.split("/").map((part) => encodeURIComponent(part)).join("/");
29
+ }
30
+ //#endregion
31
+ //#region src/editors.ts
32
+ /** GitHub account names: letters, digits and single hyphens, up to 39 characters. */
33
+ const USERNAME = /^[a-z\d](?:[a-z\d]|-(?=[a-z\d])){0,38}$/i;
34
+ const INVITATION = "invitation:";
35
+ function roleOf(permissions) {
36
+ if (permissions?.admin) return "owner";
37
+ return permissions?.maintain || permissions?.push ? "editor" : "viewer";
38
+ }
39
+ function invitationRole(permission) {
40
+ if (permission === "admin") return "owner";
41
+ return permission === "write" || permission === "maintain" ? "editor" : "viewer";
42
+ }
43
+ function isNotAllowed(error) {
44
+ return error instanceof GitApiError && (error.status === 403 || error.status === 404);
45
+ }
46
+ /**
47
+ * The repository's collaborators. Anyone who can publish can list them, but
48
+ * inviting and removing people needs a token with GitHub's Administration
49
+ * permission, which signing in doesn't ask for: `manage()` gives the owner
50
+ * token when one is in use.
51
+ */
52
+ function githubEditors(read, manage, repo, login) {
53
+ const path = `/repos/${repo}`;
54
+ function managing() {
55
+ const api = manage();
56
+ if (!api) throw new EditorsError("not-allowed", "Changing editors on GitHub needs an owner token.");
57
+ return api;
58
+ }
59
+ async function change(run) {
60
+ try {
61
+ await run(managing());
62
+ } catch (error) {
63
+ if (isNotAllowed(error)) throw new EditorsError("not-allowed", "This token can't change the site's editors.");
64
+ throw error;
65
+ }
66
+ }
67
+ async function collaborators(api) {
68
+ const all = [];
69
+ for (let page = 1; page <= 10; page++) {
70
+ const found = await githubJson(api, `${path}/collaborators?per_page=100&page=${page}`);
71
+ all.push(...found);
72
+ if (found.length < 100) break;
73
+ }
74
+ return all;
75
+ }
76
+ return {
77
+ invitesByEmail: false,
78
+ async list() {
79
+ const api = manage() ?? read;
80
+ const people = await collaborators(api);
81
+ let invitations;
82
+ try {
83
+ invitations = await githubJson(api, `${path}/invitations?per_page=100`);
84
+ } catch (error) {
85
+ if (!isNotAllowed(error)) throw error;
86
+ }
87
+ const editors = [...people.map((person) => ({
88
+ id: person.login,
89
+ login: person.login,
90
+ avatarUrl: person.avatar_url,
91
+ role: roleOf(person.permissions),
92
+ ...person.login.toLowerCase() === login.toLowerCase() && { self: true }
93
+ })), ...(invitations ?? []).flatMap((invitation) => invitation.invitee ? [{
94
+ id: `${INVITATION}${invitation.id}`,
95
+ login: invitation.invitee.login,
96
+ avatarUrl: invitation.invitee.avatar_url,
97
+ role: invitationRole(invitation.permissions),
98
+ invited: true
99
+ }] : [])];
100
+ const owner = editors.some((editor) => editor.self && editor.role === "owner");
101
+ return {
102
+ editors,
103
+ manage: invitations ? "yes" : owner ? "token" : "no",
104
+ editorsCanPublish: true
105
+ };
106
+ },
107
+ async invite(who, role) {
108
+ const name = who.trim().replace(/^@/, "");
109
+ if (!USERNAME.test(name)) throw new EditorsError("invalid", "That isn't a GitHub username.");
110
+ const api = managing();
111
+ try {
112
+ await githubRequest(api, `${path}/collaborators/${encodeURIComponent(name)}`, {
113
+ method: "PUT",
114
+ body: { permission: role === "owner" ? "admin" : "push" }
115
+ });
116
+ } catch (error) {
117
+ if (error instanceof GitApiError && error.status === 404) {
118
+ if ((await githubRequest(api, `/users/${encodeURIComponent(name)}`, { allow: [404] })).status === 404) throw new EditorsError("not-found", `There's no GitHub account named ${name}.`);
119
+ }
120
+ if (isNotAllowed(error)) throw new EditorsError("not-allowed", "This token can't change the site's editors.");
121
+ if (error instanceof GitApiError && error.status === 422) throw new EditorsError("invalid", `GitHub didn't accept the invitation: ${error.message}`);
122
+ throw error;
123
+ }
124
+ },
125
+ async setRole(editor, role) {
126
+ await change(async (api) => {
127
+ if (editor.id.startsWith(INVITATION)) await githubRequest(api, `${path}/invitations/${editor.id.slice(11)}`, {
128
+ method: "PATCH",
129
+ body: { permissions: role === "owner" ? "admin" : "write" }
130
+ });
131
+ else await githubRequest(api, `${path}/collaborators/${encodeURIComponent(editor.id)}`, {
132
+ method: "PUT",
133
+ body: { permission: role === "owner" ? "admin" : "push" }
134
+ });
135
+ });
136
+ },
137
+ async remove(editor) {
138
+ await change((api) => githubRequest(api, editor.id.startsWith(INVITATION) ? `${path}/invitations/${editor.id.slice(11)}` : `${path}/collaborators/${encodeURIComponent(editor.id)}`, { method: "DELETE" }));
139
+ }
140
+ };
141
+ }
142
+ /** The owner token: GitHub's Administration and Pages permissions, for this tab only. */
143
+ function githubOwnerAccess(base, repo, tokenLink) {
144
+ let owner;
145
+ return {
146
+ access: {
147
+ tokenLink,
148
+ get active() {
149
+ return owner !== void 0;
150
+ },
151
+ async applyToken(token) {
152
+ const api = {
153
+ ...base,
154
+ token: token.trim()
155
+ };
156
+ try {
157
+ if (!(await githubJson(api, `/repos/${repo}`)).permissions?.admin) throw new EditorsError("not-allowed", "This token's account isn't an owner of the site.");
158
+ await githubJson(api, `/repos/${repo}/invitations?per_page=1`);
159
+ } catch (error) {
160
+ if (error instanceof EditorsError) throw error;
161
+ if (error instanceof SignInError || isNotAllowed(error)) throw new EditorsError("not-allowed", "This token can't change the site's editors.");
162
+ throw error;
163
+ }
164
+ owner = api;
165
+ },
166
+ forget() {
167
+ owner = void 0;
168
+ }
169
+ },
170
+ api: () => owner
171
+ };
172
+ }
173
+ //#endregion
174
+ //#region src/pages.ts
175
+ /** GitHub Pages' addresses for an apex domain, from GitHub's documentation. */
176
+ const PAGES_IPV4 = [
177
+ "185.199.108.153",
178
+ "185.199.109.153",
179
+ "185.199.110.153",
180
+ "185.199.111.153"
181
+ ];
182
+ const PAGES_IPV6 = [
183
+ "2606:50c0:8000::153",
184
+ "2606:50c0:8001::153",
185
+ "2606:50c0:8002::153",
186
+ "2606:50c0:8003::153"
187
+ ];
188
+ /** Certificate states in which GitHub serves the domain over HTTPS. */
189
+ const READY = /* @__PURE__ */ new Set([
190
+ "approved",
191
+ "issued",
192
+ "uploaded"
193
+ ]);
194
+ const FAILED = /* @__PURE__ */ new Set([
195
+ "errored",
196
+ "bad_authz",
197
+ "authorization_revoked"
198
+ ]);
199
+ /** The records GitHub Pages needs: its addresses for an apex domain, or an alias of the owner's github.io. */
200
+ function githubPagesRecords(repo, domain, { apex, www }) {
201
+ const target = `${(repo.split("/")[0] ?? "").toLowerCase()}.github.io`;
202
+ if (!apex) return [{
203
+ type: "CNAME",
204
+ name: domain,
205
+ value: target,
206
+ purpose: "site"
207
+ }];
208
+ return [
209
+ ...PAGES_IPV4.map((value) => ({
210
+ type: "A",
211
+ name: domain,
212
+ value,
213
+ purpose: "site"
214
+ })),
215
+ ...PAGES_IPV6.map((value) => ({
216
+ type: "AAAA",
217
+ name: domain,
218
+ value,
219
+ purpose: "site",
220
+ optional: true
221
+ })),
222
+ ...www ? [{
223
+ type: "CNAME",
224
+ name: `www.${domain}`,
225
+ value: target,
226
+ purpose: "www",
227
+ optional: true
228
+ }] : []
229
+ ];
230
+ }
231
+ /**
232
+ * GitHub Pages, for a site's repository: where it's published, and its custom
233
+ * domain. `api` gives the owner token when one is in use (see `ownerAccess`).
234
+ */
235
+ function githubPages(api, repo) {
236
+ const path = `/repos/${repo}/pages`;
237
+ async function pages() {
238
+ const response = await githubRequest(api(), path, { allow: [404] });
239
+ return response.status === 404 ? void 0 : await response.json();
240
+ }
241
+ async function update(body) {
242
+ try {
243
+ await githubRequest(api(), path, {
244
+ method: "PUT",
245
+ body
246
+ });
247
+ } catch (error) {
248
+ if (error instanceof GitApiError && (error.status === 403 || error.status === 404)) throw new DomainError("not-allowed", "This sign-in can't change the site's GitHub Pages settings.");
249
+ if (error instanceof GitApiError && (error.status === 400 || error.status === 422)) {
250
+ if (/taken|already/i.test(error.message)) throw new DomainError("taken", "Another GitHub Pages site already uses this domain.");
251
+ if ("https_enforced" in body) throw new DomainError("not-ready", "The domain's certificate isn't ready yet.");
252
+ throw new DomainError("invalid", "GitHub Pages doesn't accept this domain.");
253
+ }
254
+ throw error;
255
+ }
256
+ }
257
+ return {
258
+ async site() {
259
+ const found = await pages();
260
+ return found && {
261
+ url: found.html_url,
262
+ domain: found.cname ?? void 0
263
+ };
264
+ },
265
+ async connect(domain, options) {
266
+ await update({ cname: domain });
267
+ return githubPagesRecords(repo, domain, options);
268
+ },
269
+ async records(domain, options) {
270
+ return githubPagesRecords(repo, domain, options);
271
+ },
272
+ async status(domain) {
273
+ const found = await githubJson(api(), path);
274
+ const state = found.https_certificate?.state ?? "";
275
+ return {
276
+ verified: true,
277
+ certificate: READY.has(state) ? "ready" : FAILED.has(state) ? "failed" : "pending",
278
+ httpsOnly: found.https_enforced === true,
279
+ primary: found.cname === domain
280
+ };
281
+ },
282
+ async secure() {
283
+ await update({ https_enforced: true });
284
+ },
285
+ async disconnect() {
286
+ await update({ cname: null });
287
+ }
288
+ };
289
+ }
290
+ //#endregion
291
+ //#region src/updates.ts
292
+ /** The starters' GitHub Pages workflow, which runs `goodfellow update` each night and when it's dispatched. */
293
+ const WORKFLOW = "deploy.yml";
294
+ const WORKFLOW_FILE = `.github/workflows/${WORKFLOW}`;
295
+ /**
296
+ * The site's nightly update, which runs in its GitHub Pages workflow. Running
297
+ * it now dispatches the workflow, which needs GitHub's Actions permission
298
+ * with write access: `manage()` gives the owner token when one is in use.
299
+ */
300
+ function githubUpdates(read, manage, repo, branch, files) {
301
+ const path = `/repos/${repo}/actions/workflows/${WORKFLOW}`;
302
+ return {
303
+ async setup() {
304
+ return (await files.read(WORKFLOW_FILE))?.includes("update --publish") ? "ready" : "missing";
305
+ },
306
+ async lastRun() {
307
+ const response = await githubRequest(read, `${path}/runs?branch=${encodeURIComponent(branch)}&per_page=20`, { allow: [403, 404] });
308
+ if (!response.ok) return void 0;
309
+ const { workflow_runs: runs = [] } = await response.json();
310
+ for (const run of runs.filter((candidate) => ["schedule", "workflow_dispatch"].includes(candidate.event))) {
311
+ const base = {
312
+ date: run.created_at,
313
+ detailsUrl: run.html_url
314
+ };
315
+ if (run.status !== "completed") return {
316
+ state: "running",
317
+ ...base
318
+ };
319
+ const { jobs = [] } = await githubJson(read, `/repos/${repo}/actions/runs/${run.id}/jobs`);
320
+ const job = jobs.find((candidate) => candidate.name === "update");
321
+ if (!job || job.conclusion === "skipped") continue;
322
+ if (job.conclusion !== "success") return {
323
+ state: "failed",
324
+ ...base
325
+ };
326
+ const before = siteVersion(await files.readAt("package.json", run.head_sha) ?? "");
327
+ const now = siteVersion(await files.read("package.json") ?? "");
328
+ if (!before || !now || before === now) return {
329
+ state: "up-to-date",
330
+ ...base
331
+ };
332
+ const rollback = compareReleases(now, before) < 0;
333
+ return {
334
+ state: "updated",
335
+ ...base,
336
+ result: {
337
+ state: "updated",
338
+ from: before,
339
+ to: now,
340
+ ...rollback && { rollback }
341
+ }
342
+ };
343
+ }
344
+ },
345
+ async start(target) {
346
+ try {
347
+ await githubRequest(manage() ?? read, `${path}/dispatches`, {
348
+ method: "POST",
349
+ body: {
350
+ ref: branch,
351
+ inputs: { update: target }
352
+ }
353
+ });
354
+ } catch (error) {
355
+ if (error instanceof GitApiError && (error.status === 403 || error.status === 401)) throw new UpdatesError("not-allowed", "Running the update needs a token that can run the site's workflows.");
356
+ if (error instanceof GitApiError && (error.status === 404 || error.status === 422)) throw new UpdatesError("missing", "The site's GitHub Pages workflow doesn't update Goodfellow.");
357
+ throw error;
358
+ }
359
+ }
360
+ };
361
+ }
362
+ //#endregion
363
+ //#region src/backend.ts
364
+ const COMMIT_MUTATION = `mutation ($input: CreateCommitOnBranchInput!) {
365
+ createCommitOnBranch(input: $input) { commit { oid } }
366
+ }`;
367
+ /**
368
+ * The site's files on GitHub. Reads come from the commit `revision()` last
369
+ * returned; saves are single commits made with GraphQL's `createCommitOnBranch`,
370
+ * which fails if the branch has moved, so nothing is ever overwritten.
371
+ */
372
+ var GitHubBackend = class {
373
+ user;
374
+ pages;
375
+ editors;
376
+ ownerAccess;
377
+ updates;
378
+ api;
379
+ repo;
380
+ branch;
381
+ onSignOut;
382
+ pinned;
383
+ trees = /* @__PURE__ */ new Map();
384
+ blobs = /* @__PURE__ */ new Map();
385
+ constructor(options) {
386
+ this.api = options.api;
387
+ this.repo = repoPath(options.repo);
388
+ this.branch = options.branch;
389
+ this.user = options.user;
390
+ this.onSignOut = options.onSignOut;
391
+ const owner = githubOwnerAccess(this.api, this.repo, options.ownerTokenLink);
392
+ this.ownerAccess = owner.access;
393
+ this.pages = githubPages(() => owner.api() ?? this.api, this.repo);
394
+ this.editors = githubEditors(this.api, owner.api, this.repo, this.user.login);
395
+ this.updates = githubUpdates(this.api, owner.api, this.repo, this.branch, this);
396
+ }
397
+ async revision() {
398
+ const ref = await githubJson(this.api, `/repos/${this.repo}/git/ref/heads/${encodeURIComponent(this.branch)}`);
399
+ this.pinned = ref.object.sha;
400
+ return this.pinned;
401
+ }
402
+ /** Path → blob id for every file at a commit. Commits never change, so this is cached. */
403
+ tree(sha) {
404
+ let tree = this.trees.get(sha);
405
+ if (!tree) {
406
+ tree = githubJson(this.api, `/repos/${this.repo}/git/trees/${sha}?recursive=1`).then((response) => {
407
+ if (response.truncated) throw new GitApiError(413, "The repository has too many files for GitHub to list at once.");
408
+ return new Map(response.tree.filter((entry) => entry.type === "blob").map((entry) => [entry.path, entry.sha]));
409
+ });
410
+ tree.catch(() => this.trees.delete(sha));
411
+ this.trees.set(sha, tree);
412
+ }
413
+ return tree;
414
+ }
415
+ async currentTree() {
416
+ return this.tree(this.pinned ?? await this.revision());
417
+ }
418
+ async read(path) {
419
+ return this.readFrom(await this.currentTree(), path);
420
+ }
421
+ readFrom(tree, path) {
422
+ const sha = tree.get(path);
423
+ if (!sha) return Promise.resolve(void 0);
424
+ let blob = this.blobs.get(sha);
425
+ if (!blob) {
426
+ blob = githubRequest(this.api, `/repos/${this.repo}/git/blobs/${sha}`, { accept: "application/vnd.github.raw+json" }).then((response) => response.text());
427
+ blob.catch(() => this.blobs.delete(sha));
428
+ this.blobs.set(sha, blob);
429
+ }
430
+ return blob;
431
+ }
432
+ async readBytes(path) {
433
+ const sha = (await this.currentTree()).get(path);
434
+ if (!sha) return void 0;
435
+ const response = await githubRequest(this.api, `/repos/${this.repo}/git/blobs/${sha}`, { accept: "application/vnd.github.raw+json" });
436
+ return new Uint8Array(await response.arrayBuffer());
437
+ }
438
+ async list(dir) {
439
+ const prefix = `${dir}/`;
440
+ return [...(await this.currentTree()).keys()].filter((path) => path.startsWith(prefix)).sort();
441
+ }
442
+ async write(changes, { message, expectedRevision }) {
443
+ const existing = await this.tree(expectedRevision);
444
+ const additions = changes.flatMap((change) => "delete" in change ? [] : [{
445
+ path: change.path,
446
+ contents: "bytes" in change ? encodeBase64Bytes(change.bytes) : encodeBase64(change.content)
447
+ }]);
448
+ const deletions = changes.flatMap((change) => "delete" in change && existing.has(change.path) ? [{ path: change.path }] : []);
449
+ if (additions.length === 0 && deletions.length === 0) return { revision: expectedRevision };
450
+ const response = await githubJson(this.api, "/graphql", {
451
+ method: "POST",
452
+ body: {
453
+ query: COMMIT_MUTATION,
454
+ variables: { input: {
455
+ branch: {
456
+ repositoryNameWithOwner: decodeURIComponent(this.repo),
457
+ branchName: this.branch
458
+ },
459
+ message: { headline: message },
460
+ expectedHeadOid: expectedRevision,
461
+ fileChanges: {
462
+ additions,
463
+ deletions
464
+ }
465
+ } }
466
+ }
467
+ });
468
+ const oid = response.data?.createCommitOnBranch?.commit?.oid;
469
+ if (oid) {
470
+ this.pinned = oid;
471
+ return { revision: oid };
472
+ }
473
+ if (await this.revision() !== expectedRevision) throw new ConflictError();
474
+ const error = response.errors?.[0];
475
+ if (error?.type === "FORBIDDEN") throw new SignInError("cant-publish", "This GitHub account can't publish to the site.");
476
+ throw new GitApiError(422, `GitHub couldn't save the changes: ${error?.message ?? "unknown error"}`);
477
+ }
478
+ async history(path, { page = 1, perPage = 20 } = {}) {
479
+ const query = new URLSearchParams({
480
+ sha: this.pinned ?? await this.revision(),
481
+ path,
482
+ per_page: String(perPage),
483
+ page: String(page)
484
+ });
485
+ return (await githubJson(this.api, `/repos/${this.repo}/commits?${query}`)).map((commit) => ({
486
+ revision: commit.sha,
487
+ date: commit.commit.author?.date ?? "",
488
+ author: commit.commit.author?.name || commit.author?.login || void 0,
489
+ message: commit.commit.message.split("\n")[0] ?? ""
490
+ }));
491
+ }
492
+ async readAt(path, revision) {
493
+ return this.readFrom(await this.tree(revision), path);
494
+ }
495
+ async changedPaths(from, to) {
496
+ const comparison = await githubJson(this.api, `/repos/${this.repo}/compare/${from}...${to}`);
497
+ return [...new Set((comparison.files ?? []).flatMap((file) => [file.filename, file.previous_filename ?? []].flat()))];
498
+ }
499
+ /**
500
+ * Reads the deployment GitHub Pages or Vercel created for a commit, and for
501
+ * GitHub Actions, the workflow run that builds it: a build that fails never
502
+ * creates a deployment. Before either exists, the site counts as building
503
+ * if it has ever been deployed.
504
+ */
505
+ async deployStatus(revision) {
506
+ try {
507
+ const latest = (await githubJson(this.api, `/repos/${this.repo}/deployments?sha=${revision}&per_page=5`))[0];
508
+ if (latest) {
509
+ const [status] = await githubJson(this.api, `/repos/${this.repo}/deployments/${latest.id}/statuses?per_page=1`);
510
+ const detailsUrl = status?.log_url || status?.target_url || void 0;
511
+ switch (status?.state) {
512
+ case "success":
513
+ case "inactive": return {
514
+ state: "live",
515
+ detailsUrl
516
+ };
517
+ case "failure":
518
+ case "error": {
519
+ const run = await this.workflowRun(revision);
520
+ return {
521
+ state: "failed",
522
+ detailsUrl,
523
+ problem: run ? await this.runProblem(run) : { step: "deploy" }
524
+ };
525
+ }
526
+ default: return {
527
+ state: "building",
528
+ detailsUrl
529
+ };
530
+ }
531
+ }
532
+ const run = await this.workflowRun(revision);
533
+ if (run?.status === "completed" && run.conclusion !== "success" && run.conclusion !== "skipped") return {
534
+ state: "failed",
535
+ detailsUrl: run.html_url,
536
+ problem: await this.runProblem(run)
537
+ };
538
+ if (run) return {
539
+ state: "building",
540
+ detailsUrl: run.html_url
541
+ };
542
+ return { state: (await githubJson(this.api, `/repos/${this.repo}/deployments?per_page=1`)).length > 0 ? "building" : "unknown" };
543
+ } catch (error) {
544
+ if (error instanceof GitApiError) return { state: "unknown" };
545
+ throw error;
546
+ }
547
+ }
548
+ /** The site's deploy workflow run for a commit, or `undefined` if there's none or the token can't see Actions. */
549
+ async workflowRun(revision) {
550
+ const response = await githubRequest(this.api, `/repos/${this.repo}/actions/runs?head_sha=${revision}&per_page=20`, { allow: [403, 404] });
551
+ if (!response.ok) return void 0;
552
+ const { workflow_runs: runs = [] } = await response.json();
553
+ return runs.find((run) => /deploy|pages/i.test(`${run.path ?? ""} ${run.name ?? ""}`)) ?? runs[0];
554
+ }
555
+ /** Which step of a failed run failed. */
556
+ async runProblem(run) {
557
+ const response = await githubRequest(this.api, `/repos/${this.repo}/actions/runs/${run.id}/jobs?per_page=50`, { allow: [403, 404] });
558
+ const failed = (response.ok ? (await response.json()).jobs ?? [] : []).find((job) => job.conclusion === "failure");
559
+ if (!failed) return {
560
+ step: run.conclusion === "startup_failure" ? "not-started" : "host",
561
+ detail: run.conclusion ?? void 0
562
+ };
563
+ const step = failed.steps?.find((candidate) => candidate.conclusion === "failure");
564
+ if (!step) return {
565
+ step: "not-started",
566
+ detail: failed.name
567
+ };
568
+ return {
569
+ step: buildStepFor(step.name) ?? (/deploy/i.test(failed.name) ? "deploy" : "build"),
570
+ detail: `${failed.name}: ${step.name}`
571
+ };
572
+ }
573
+ signOut() {
574
+ this.ownerAccess.forget();
575
+ this.onSignOut();
576
+ }
577
+ };
578
+ //#endregion
579
+ //#region src/setup.ts
580
+ /** The branch the deploy setups build from. */
581
+ const MAIN = "main";
582
+ /**
583
+ * Links to GitHub's token pages with what creating a site needs: creating the
584
+ * repository (administration), writing its files and its build setup
585
+ * (contents and workflows), and turning on GitHub Pages (pages).
586
+ */
587
+ function githubSetupTokenLinks(webUrl = "https://github.com") {
588
+ const fineGrained = new URLSearchParams({
589
+ name: "Goodfellow site setup",
590
+ description: "Create a Goodfellow site. It can be deleted once the site is created.",
591
+ expires_in: "7",
592
+ administration: "write",
593
+ contents: "write",
594
+ workflows: "write",
595
+ pages: "write"
596
+ });
597
+ const classic = new URLSearchParams({
598
+ scopes: "repo,workflow",
599
+ description: "Goodfellow site setup"
600
+ });
601
+ return {
602
+ fineGrained: `${webUrl}/settings/personal-access-tokens/new?${fineGrained}`,
603
+ classic: `${webUrl}/settings/tokens/new?${classic}`
604
+ };
605
+ }
606
+ /** Signing in to GitHub to create a site's repository, with GitHub Pages if wanted. */
607
+ function githubSetup(options = {}) {
608
+ const apiUrl = (options.apiUrl ?? "https://api.github.com").replace(/\/$/, "");
609
+ const links = githubSetupTokenLinks(options.webUrl);
610
+ const fetcher = options.fetch ?? globalThis.fetch.bind(globalThis);
611
+ async function connect(token) {
612
+ const api = {
613
+ apiUrl,
614
+ token,
615
+ fetch: fetcher
616
+ };
617
+ const user = await githubJson(api, "/user");
618
+ async function owners() {
619
+ return [{
620
+ path: user.login,
621
+ name: user.name || user.login,
622
+ kind: "personal"
623
+ }, ...(await githubJson(api, "/user/orgs?per_page=100").catch(() => [])).map((org) => ({
624
+ path: org.login,
625
+ name: org.login,
626
+ kind: "organization"
627
+ }))];
628
+ }
629
+ async function isAvailable(owner, name) {
630
+ return (await githubRequest(api, `/repos/${repoPath(`${owner.path}/${name}`)}`, { allow: [404] })).status === 404;
631
+ }
632
+ async function createRepository(site) {
633
+ const path = site.owner.kind === "personal" ? "/user/repos" : `/orgs/${encodeURIComponent(site.owner.path)}/repos`;
634
+ if (!await isAvailable(site.owner, site.name)) throw new SetupError("name-taken", `There's already a repository called ${site.owner.path}/${site.name}.`);
635
+ try {
636
+ return await githubJson(api, path, {
637
+ method: "POST",
638
+ body: {
639
+ name: site.name,
640
+ private: site.private,
641
+ description: site.description,
642
+ auto_init: true
643
+ }
644
+ });
645
+ } catch (error) {
646
+ if (error instanceof GitApiError && error.status === 422) throw new SetupError("name-invalid", `GitHub can't use "${site.name}" as a repository name.`);
647
+ if (error instanceof GitApiError && (error.status === 403 || error.status === 404)) throw new SetupError("not-allowed", `This sign-in can't create repositories in ${site.owner.path}.`);
648
+ throw error;
649
+ }
650
+ }
651
+ /** Makes `main` the default branch, which the deploy setups build from. */
652
+ async function useMainBranch(repo) {
653
+ if (repo.default_branch === MAIN) return;
654
+ const base = `/repos/${repoPath(repo.full_name)}`;
655
+ const ref = await githubJson(api, `${base}/git/ref/heads/${repo.default_branch}`);
656
+ await githubRequest(api, `${base}/git/refs`, {
657
+ method: "POST",
658
+ body: {
659
+ ref: `refs/heads/${MAIN}`,
660
+ sha: ref.object.sha
661
+ }
662
+ });
663
+ await githubRequest(api, base, {
664
+ method: "PATCH",
665
+ body: { default_branch: MAIN }
666
+ });
667
+ await githubRequest(api, `${base}/git/refs/heads/${repo.default_branch}`, { method: "DELETE" });
668
+ }
669
+ /** Turns on GitHub Pages, published by the site's own workflow. Returns the site's address. */
670
+ async function turnOnPages(repo) {
671
+ try {
672
+ return (await githubJson(api, `/repos/${repoPath(repo.full_name)}/pages`, {
673
+ method: "POST",
674
+ body: { build_type: "workflow" }
675
+ })).html_url;
676
+ } catch (error) {
677
+ if (error instanceof GitApiError && [
678
+ 403,
679
+ 404,
680
+ 409,
681
+ 422
682
+ ].includes(error.status)) return void 0;
683
+ throw error;
684
+ }
685
+ }
686
+ /** Writes every file in one commit, which replaces the starting README rather than following it. */
687
+ async function writeFiles(repo, site) {
688
+ const base = `/repos/${repoPath(repo.full_name)}`;
689
+ const tree = [];
690
+ for (const file of site.files) {
691
+ if ("delete" in file) continue;
692
+ if ("content" in file) {
693
+ tree.push({
694
+ path: file.path,
695
+ mode: "100644",
696
+ type: "blob",
697
+ content: file.content
698
+ });
699
+ continue;
700
+ }
701
+ const blob = await githubJson(api, `${base}/git/blobs`, {
702
+ method: "POST",
703
+ body: {
704
+ content: encodeBase64Bytes(file.bytes),
705
+ encoding: "base64"
706
+ }
707
+ });
708
+ tree.push({
709
+ path: file.path,
710
+ mode: "100644",
711
+ type: "blob",
712
+ sha: blob.sha
713
+ });
714
+ }
715
+ try {
716
+ const created = await githubJson(api, `${base}/git/trees`, {
717
+ method: "POST",
718
+ body: { tree }
719
+ });
720
+ const commit = await githubJson(api, `${base}/git/commits`, {
721
+ method: "POST",
722
+ body: {
723
+ message: site.message,
724
+ tree: created.sha,
725
+ parents: []
726
+ }
727
+ });
728
+ await githubRequest(api, `${base}/git/refs/heads/${MAIN}`, {
729
+ method: "PATCH",
730
+ body: {
731
+ sha: commit.sha,
732
+ force: true
733
+ }
734
+ });
735
+ } catch (error) {
736
+ if (error instanceof GitApiError && (error.status === 403 || error.status === 404)) throw new SetupError("not-allowed", "This sign-in can't write the site's files, or its build setup.");
737
+ throw error;
738
+ }
739
+ }
740
+ /** Stops anyone rewriting or deleting the main branch's history. Not every plan allows it. */
741
+ async function protectMain(repo) {
742
+ return (await githubRequest(api, `/repos/${repoPath(repo.full_name)}/rulesets`, {
743
+ method: "POST",
744
+ body: {
745
+ name: "Protect the published history",
746
+ target: "branch",
747
+ enforcement: "active",
748
+ conditions: { ref_name: {
749
+ include: ["~DEFAULT_BRANCH"],
750
+ exclude: []
751
+ } },
752
+ rules: [{ type: "non_fast_forward" }, { type: "deletion" }]
753
+ },
754
+ allow: [
755
+ 403,
756
+ 404,
757
+ 422
758
+ ]
759
+ })).ok;
760
+ }
761
+ return {
762
+ user: {
763
+ login: user.login,
764
+ name: user.name ?? void 0,
765
+ avatarUrl: user.avatar_url
766
+ },
767
+ owners,
768
+ isAvailable,
769
+ async createSite(site, onStep) {
770
+ const warnings = [];
771
+ onStep?.("repository");
772
+ const repo = await createRepository(site);
773
+ await useMainBranch(repo);
774
+ let siteUrl;
775
+ if (site.pages) {
776
+ onStep?.("pages");
777
+ siteUrl = await turnOnPages(repo);
778
+ if (!siteUrl) warnings.push("pages-unavailable");
779
+ }
780
+ onStep?.("files");
781
+ await writeFiles(repo, site);
782
+ onStep?.("protection");
783
+ if (!await protectMain(repo)) warnings.push("protection-unavailable");
784
+ return {
785
+ repo: repo.full_name,
786
+ webUrl: repo.html_url,
787
+ siteUrl,
788
+ buildsUrl: `${repo.html_url}/actions`,
789
+ pagesUrl: site.pages ? `${repo.html_url}/settings/pages` : void 0,
790
+ warnings
791
+ };
792
+ }
793
+ };
794
+ }
795
+ return {
796
+ name: "GitHub",
797
+ canRedirect: false,
798
+ tokenLinks: [{
799
+ url: links.fineGrained,
800
+ label: "signIn.token.create"
801
+ }, {
802
+ url: links.classic,
803
+ label: "signIn.token.createBroad",
804
+ hint: "signIn.token.createBroadHint"
805
+ }],
806
+ async restore() {
807
+ return null;
808
+ },
809
+ async signInWithToken(token) {
810
+ const trimmed = token.trim();
811
+ if (!trimmed) throw new SignInError("invalid", "Paste a token to sign in.");
812
+ return connect(trimmed);
813
+ },
814
+ async startRedirect() {
815
+ throw new Error("Signing in by redirect needs a sign-in server, which isn't supported for GitHub yet.");
816
+ }
817
+ };
818
+ }
819
+ //#endregion
820
+ //#region src/index.ts
821
+ /**
822
+ * Links to GitHub's token page with everything filled in except the
823
+ * repository, which GitHub doesn't let links choose.
824
+ */
825
+ function githubTokenLinks(repo, webUrl = "https://github.com") {
826
+ const [repoOwner = ""] = repo.split("/");
827
+ const fineGrained = new URLSearchParams({
828
+ name: "Goodfellow admin",
829
+ description: `Edit ${repo} in its admin panel`,
830
+ target_name: repoOwner,
831
+ expires_in: "90",
832
+ contents: "write",
833
+ deployments: "read",
834
+ actions: "read"
835
+ });
836
+ const classic = new URLSearchParams({
837
+ scopes: "repo",
838
+ description: `Goodfellow admin for ${repo}`
839
+ });
840
+ const owner = new URLSearchParams({
841
+ name: "Goodfellow owner",
842
+ description: `Manage the editors, domain and updates of ${repo}`,
843
+ target_name: repoOwner,
844
+ expires_in: "1",
845
+ administration: "write",
846
+ pages: "write",
847
+ actions: "write"
848
+ });
849
+ return {
850
+ fineGrained: `${webUrl}/settings/personal-access-tokens/new?${fineGrained}`,
851
+ classic: `${webUrl}/settings/tokens/new?${classic}`,
852
+ owner: `${webUrl}/settings/personal-access-tokens/new?${owner}`
853
+ };
854
+ }
855
+ /** Sign-in and storage for a site whose repository is on GitHub. */
856
+ function github(options) {
857
+ if (!/^[\w.-]+\/[\w.-]+$/.test(options.repo)) throw new Error(`github({ repo }) must look like "owner/name" (got "${options.repo}").`);
858
+ const apiUrl = (options.apiUrl ?? "https://api.github.com").replace(/\/$/, "");
859
+ const links = githubTokenLinks(options.repo, options.webUrl);
860
+ const storage = credentialStorage(`goodfellow:github:${options.repo}`, options.storage);
861
+ async function connect(token) {
862
+ const api = {
863
+ apiUrl,
864
+ token,
865
+ fetch: options.fetch ?? globalThis.fetch.bind(globalThis)
866
+ };
867
+ const user = await githubJson(api, "/user");
868
+ let repo;
869
+ try {
870
+ repo = await githubJson(api, `/repos/${repoPath(options.repo)}`);
871
+ } catch (error) {
872
+ if (error instanceof GitApiError && (error.status === 404 || error.status === 403)) throw new SignInError("no-access", `The GitHub account ${user.login} can't see ${options.repo}.`);
873
+ throw error;
874
+ }
875
+ if (repo.permissions?.push === false) throw new SignInError("cant-publish", `The GitHub account ${user.login} can see ${options.repo} but can't publish to it.`);
876
+ return new GitHubBackend({
877
+ api,
878
+ repo: options.repo,
879
+ branch: options.branch ?? repo.default_branch,
880
+ user: {
881
+ login: user.login,
882
+ name: user.name ?? void 0,
883
+ avatarUrl: user.avatar_url
884
+ },
885
+ onSignOut: () => storage.clear(),
886
+ ownerTokenLink: {
887
+ url: links.owner,
888
+ label: "owner.token.create"
889
+ }
890
+ });
891
+ }
892
+ return {
893
+ name: "GitHub",
894
+ canRedirect: false,
895
+ tokenLinks: [{
896
+ url: links.fineGrained,
897
+ label: "signIn.token.create"
898
+ }, {
899
+ url: links.classic,
900
+ label: "signIn.token.createBroad",
901
+ hint: "signIn.token.createBroadHint"
902
+ }],
903
+ async restore() {
904
+ const token = storage.load();
905
+ if (!token) return null;
906
+ try {
907
+ return await connect(token);
908
+ } catch (error) {
909
+ if (error instanceof SignInError) storage.clear();
910
+ throw error;
911
+ }
912
+ },
913
+ async signInWithToken(token, remember) {
914
+ const trimmed = token.trim();
915
+ if (!trimmed) throw new SignInError("invalid", "Paste a token to sign in.");
916
+ const backend = await connect(trimmed);
917
+ storage.save(trimmed, remember);
918
+ return backend;
919
+ },
920
+ async startRedirect() {
921
+ throw new Error("Signing in by redirect needs a sign-in server, which isn't supported for GitHub yet.");
922
+ }
923
+ };
924
+ }
925
+ //#endregion
926
+ export { GitHubBackend, github, githubPagesRecords, githubSetup, githubSetupTokenLinks, githubTokenLinks };