@goodfellow-cms/github 0.7.0 → 0.9.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.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { ConnectDomainOptions, DeployStatus, DnsRecord, FileChange, FileVersion, GitBackend, GitHost, GitUser, HistoryOptions, OwnerAccess, PagesDomains, PluginTasks, RedirectOptions, RepositoryVisibility, RevisionFiles, ScheduledBuilds, SetupHost, SignInError, SiteDrafts, SiteEditors, SiteUpdates, StorageLike, TokenLink, WriteOptions } from "@goodfellow-cms/core";
1
+ import { ConnectDomainOptions, DeployStatus, DnsRecord, FileChange, FileVersion, GitBackend, GitHost, GitUser, HistoryOptions, OwnerAccess, PagesDomains, PluginTasks, RedirectOptions, RepositoryVisibility, RevisionFiles, ScheduledBuilds, SetupHost, SignInError, SiteComments, SiteDrafts, SiteEditors, SiteUpdates, StorageLike, TokenLink, WriteOptions } from "@goodfellow-cms/core";
2
2
  //#region src/sign-in.d.ts
3
3
  /** The page's address and how to change it. Replaceable in tests. */
4
4
  interface BrowserLocation {
@@ -38,6 +38,8 @@ interface GitHubBackendOptions {
38
38
  onSignOut: () => void;
39
39
  /** Where to create the owner token, which can change editors and GitHub Pages settings. */
40
40
  ownerTokenLink: TokenLink;
41
+ /** Where to create a token for the comments' repository, where the sign-in can't reach it. */
42
+ commentsTokenLink?: TokenLink;
41
43
  /** Signs in as an owner through the sign-in worker instead of with a token, where it's available. */
42
44
  ownerRedirect?: (options?: RedirectOptions) => Promise<void>;
43
45
  /**
@@ -80,6 +82,7 @@ export declare class GitHubBackend implements GitBackend {
80
82
  readonly drafts: SiteDrafts;
81
83
  readonly scheduledBuilds: ScheduledBuilds;
82
84
  readonly tasks: PluginTasks;
85
+ readonly comments: SiteComments;
83
86
  readonly contentOnly: boolean;
84
87
  private readonly options;
85
88
  private readonly api;
@@ -131,19 +134,19 @@ export declare class GitHubBackend implements GitBackend {
131
134
  readAt(path: string, revision: string): Promise<string | undefined>;
132
135
  changedPaths(from: string, to: string): Promise<string[]>;
133
136
  /**
134
- * Reads the deployment GitHub Pages or Vercel created for a commit, or what
135
- * Cloudflare Pages reports on it, and for GitHub Actions, the workflow run
137
+ * Reads the deployment GitHub Pages, Vercel or Netlify created for a commit,
138
+ * or what Cloudflare Pages or Netlify reports on it, and for GitHub Actions, the workflow run
136
139
  * that builds it: a build that fails never creates a deployment. Before any
137
140
  * exists, the site counts as building if it has ever been deployed, or if
138
141
  * Cloudflare reported on the commit before.
139
142
  */
140
143
  deployStatus(revision: string): Promise<DeployStatus>;
141
144
  /**
142
- * What Cloudflare Pages reports on a commit: a check run of its GitHub App,
143
- * or a commit status, which fine-grained tokens can read where check runs
144
- * need a GitHub App's permission. `undefined` if it reported nothing.
145
+ * What Cloudflare Pages or Netlify reports on a commit: a check run of its
146
+ * GitHub App, or a commit status, which fine-grained tokens can read where
147
+ * check runs need a GitHub App's permission. `undefined` if it reported nothing.
145
148
  */
146
- private cloudflareBuild;
149
+ private hostBuild;
147
150
  /** The commit a commit was made on top of, if the token can see it. */
148
151
  private parentOf;
149
152
  /** The site's deploy workflow run for a commit, or `undefined` if there's none or the token can't see Actions. */
@@ -233,6 +236,7 @@ export declare function githubTokenLinks(repo: string, webUrl?: string): {
233
236
  fineGrained: string;
234
237
  classic: string;
235
238
  owner: string;
239
+ comments: string;
236
240
  };
237
241
  /** Sign-in and storage for a site whose repository is on GitHub. */
238
242
  export declare function github(options: GitHubOptions): GitHost;
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { ConflictError, DomainError, DraftError, EditorsError, GitApiError, NIGHTLY_VARIABLE, PluginTaskError, REBUILD_JOB, SetupError, SignInError, TASK_BRANCH_PREFIX, TASK_EVENT, TASK_POLL_MS, TASK_RESULT_FILE, TASK_TIMEOUT_MS, TASK_WORKFLOW, UpdatesError, assertContentChanges, branchDrafts, buildStepFor, compareReleases, credentialStorage, decodeBase64, encodeBase64, encodeBase64Bytes, hostSource, nightlyMode, siteVersion, taskRequestProblem, withRateLimits } from "@goodfellow-cms/core";
1
+ import { CommentsError, ConflictError, DomainError, DraftError, EditorsError, GISCUS_APP_URL, GitApiError, NIGHTLY_VARIABLE, PluginTaskError, REBUILD_JOB, SetupError, SignInError, TASK_BRANCH_PREFIX, TASK_EVENT, TASK_POLL_MS, TASK_RESULT_FILE, TASK_TIMEOUT_MS, TASK_WORKFLOW, UpdatesError, assertContentChanges, branchDrafts, buildStepFor, compareReleases, credentialStorage, decodeBase64, encodeBase64, encodeBase64Bytes, hostSource, nightlyMode, siteVersion, taskRequestProblem, withRateLimits } from "@goodfellow-cms/core";
2
2
  //#region src/api.ts
3
3
  /** Calls the GitHub REST or GraphQL API. Treats 401 as an expired sign-in; other errors become `GitApiError`. */
4
4
  async function githubRequest({ apiUrl, token, fetch }, path, init = {}) {
@@ -30,6 +30,242 @@ function repoPath(repo) {
30
30
  return repo.split("/").map((part) => encodeURIComponent(part)).join("/");
31
31
  }
32
32
  //#endregion
33
+ //#region src/comments.ts
34
+ /**
35
+ * Comments on GitHub: the repository Giscus keeps them in, made or found
36
+ * with REST, and its discussions read and moderated with GraphQL. Uses the
37
+ * comments token where one is in use, and otherwise the site's own sign-in,
38
+ * which reaches the repository where it's a token for every repository.
39
+ */
40
+ /** How many pages' discussions one call lists, and how many comments and replies each brings. */
41
+ const THREADS_PER_PAGE = 20;
42
+ const COMMENTS_PER_THREAD = 50;
43
+ const REPLIES_PER_COMMENT = 50;
44
+ const CLASSIFIERS = {
45
+ spam: "SPAM",
46
+ abuse: "ABUSE",
47
+ "off-topic": "OFF_TOPIC"
48
+ };
49
+ /** The category Giscus recommends, where only it and the repository's owners can start discussions. */
50
+ const ANNOUNCEMENTS = "announcements";
51
+ const COMMENT_FIELDS = `id url bodyText createdAt isMinimized minimizedReason author { login url avatarUrl }`;
52
+ const THREADS_QUERY = `query ($owner: String!, $name: String!, $category: ID!, $after: String) {
53
+ repository(owner: $owner, name: $name) {
54
+ discussions(first: ${THREADS_PER_PAGE}, after: $after, categoryId: $category, orderBy: { field: UPDATED_AT, direction: DESC }) {
55
+ pageInfo { hasNextPage endCursor }
56
+ nodes {
57
+ id title url locked updatedAt
58
+ comments(first: ${COMMENTS_PER_THREAD}) {
59
+ nodes { ${COMMENT_FIELDS} replies(first: ${REPLIES_PER_COMMENT}) { nodes { ${COMMENT_FIELDS} } } }
60
+ }
61
+ }
62
+ }
63
+ }
64
+ }`;
65
+ const CATEGORIES_QUERY = `query ($owner: String!, $name: String!) {
66
+ repository(owner: $owner, name: $name) {
67
+ discussionCategories(first: 25) { nodes { id name slug } }
68
+ }
69
+ }`;
70
+ function comment(node) {
71
+ return {
72
+ id: node.id,
73
+ author: {
74
+ login: node.author?.login ?? "ghost",
75
+ url: node.author?.url ?? "https://github.com/ghost",
76
+ ...node.author?.avatarUrl && { avatarUrl: node.author.avatarUrl }
77
+ },
78
+ text: node.bodyText,
79
+ createdAt: node.createdAt,
80
+ url: node.url,
81
+ hidden: node.isMinimized,
82
+ ...node.isMinimized && node.minimizedReason && { hiddenReason: node.minimizedReason.toLowerCase() },
83
+ replies: (node.replies?.nodes ?? []).map(comment)
84
+ };
85
+ }
86
+ /** Giscus's install page, for the account the repository is in. */
87
+ function installUrl(ownerId) {
88
+ return `${GISCUS_APP_URL}/installations/new/permissions?target_id=${ownerId}`;
89
+ }
90
+ function isRefused(error) {
91
+ return error instanceof SignInError || error instanceof GitApiError && [403, 404].includes(error.status);
92
+ }
93
+ /**
94
+ * Where to create a token for comments: a classic one with \`public_repo\`,
95
+ * which can create a public repository and moderate its discussions. A
96
+ * fine-grained token can't reach a repository that doesn't exist yet.
97
+ */
98
+ function commentsTokenLink(webUrl, siteRepo) {
99
+ return {
100
+ url: `${webUrl}/settings/tokens/new?${new URLSearchParams({
101
+ scopes: "public_repo",
102
+ description: `Goodfellow comments for ${siteRepo}`
103
+ })}`,
104
+ label: "comments.token.create"
105
+ };
106
+ }
107
+ function githubComments(base, siteRepo, tokenLink) {
108
+ let own;
109
+ const api = () => own ?? base;
110
+ async function graphql(query, variables) {
111
+ const answer = await githubJson(api(), "/graphql", {
112
+ method: "POST",
113
+ body: {
114
+ query,
115
+ variables
116
+ }
117
+ }).catch((error) => {
118
+ if (isRefused(error)) throw new CommentsError("no-access", "GitHub refused the sign-in for comments.");
119
+ throw error;
120
+ });
121
+ const error = answer.errors?.[0];
122
+ if (error) {
123
+ if (error.type === "FORBIDDEN" || error.type === "NOT_FOUND" || /not have permission|resource not accessible/i.test(error.message)) throw new CommentsError("no-access", error.message);
124
+ throw new Error(error.message);
125
+ }
126
+ return answer.data;
127
+ }
128
+ /** The repository's category for announcements, or else its first, as Giscus needs. */
129
+ async function placeOf(found) {
130
+ const [owner = "", name = ""] = found.full_name.split("/");
131
+ let categories = [];
132
+ for (let attempt = 0; attempt < 3; attempt++) {
133
+ categories = (await graphql(CATEGORIES_QUERY, {
134
+ owner,
135
+ name
136
+ })).repository?.discussionCategories.nodes ?? [];
137
+ if (categories.length > 0) break;
138
+ await new Promise((resolve) => setTimeout(resolve, 500 * (attempt + 1)));
139
+ }
140
+ const category = categories.find((candidate) => candidate.slug === ANNOUNCEMENTS) ?? categories[0];
141
+ if (!category) throw new CommentsError("no-category", "The repository's Discussions have no categories yet.", `${found.html_url}/discussions`);
142
+ return {
143
+ repo: found.full_name,
144
+ repoId: found.node_id,
145
+ category: category.name,
146
+ categoryId: category.id,
147
+ webUrl: found.html_url,
148
+ installUrl: installUrl(found.owner.id)
149
+ };
150
+ }
151
+ /** Turns Discussions on where they're off, which needs the repository's admin. */
152
+ async function withDiscussions(found) {
153
+ if (found.has_discussions) return found;
154
+ if (found.permissions?.admin === false) throw new CommentsError("no-discussions", "Discussions are off.", `${found.html_url}/settings`);
155
+ return githubJson(api(), `/repos/${repoPath(found.full_name)}`, {
156
+ method: "PATCH",
157
+ body: { has_discussions: true }
158
+ }).catch((error) => {
159
+ if (isRefused(error)) throw new CommentsError("no-discussions", "Discussions are off.", `${found.html_url}/settings`);
160
+ throw error;
161
+ });
162
+ }
163
+ async function mutate(mutation, input) {
164
+ await graphql(`mutation ($input: ${mutation}Input!) { ${mutation[0]?.toLowerCase()}${mutation.slice(1)}(input: $input) { clientMutationId } }`, { input });
165
+ }
166
+ return {
167
+ access: {
168
+ tokenLink,
169
+ get active() {
170
+ return own !== void 0;
171
+ },
172
+ async applyToken(token) {
173
+ const candidate = {
174
+ ...base,
175
+ token: token.trim()
176
+ };
177
+ try {
178
+ await githubJson(candidate, "/user");
179
+ } catch (error) {
180
+ if (isRefused(error)) throw new CommentsError("no-access", "GitHub didn't accept this token.");
181
+ throw error;
182
+ }
183
+ own = candidate;
184
+ },
185
+ forget() {
186
+ own = void 0;
187
+ }
188
+ },
189
+ async create(name, description) {
190
+ const wanted = name.trim();
191
+ if (!/^[\w.-]+$/.test(wanted) || /^\.+$/.test(wanted)) throw new CommentsError("name-invalid", "That can't be a repository's name.");
192
+ const user = await githubJson(api(), "/user").catch((error) => {
193
+ if (isRefused(error)) throw new CommentsError("no-access", "GitHub refused the sign-in for comments.");
194
+ throw error;
195
+ });
196
+ const siteOwner = siteRepo.split("/")[0] ?? "";
197
+ const path = siteOwner.toLowerCase() === user.login.toLowerCase() ? "/user/repos" : `/orgs/${encodeURIComponent(siteOwner)}/repos`;
198
+ return placeOf(await withDiscussions(await githubJson(api(), path, {
199
+ method: "POST",
200
+ body: {
201
+ name: wanted,
202
+ description,
203
+ private: false,
204
+ has_discussions: true,
205
+ has_issues: false,
206
+ has_projects: false,
207
+ has_wiki: false,
208
+ auto_init: true
209
+ }
210
+ }).catch((error) => {
211
+ if (error instanceof GitApiError && error.status === 422) throw new CommentsError("name-taken", "There's already a repository with that name.");
212
+ if (isRefused(error)) throw new CommentsError("no-access", "This sign-in can't create repositories there.");
213
+ throw error;
214
+ })));
215
+ },
216
+ async find(repo) {
217
+ const wanted = repo.trim().replace(/^https:\/\/github\.com\//i, "").replace(/\/+$/, "");
218
+ if (!/^[\w.-]+\/[\w.-]+$/.test(wanted)) throw new CommentsError("not-found", "That isn't a repository's name.");
219
+ const found = await githubJson(api(), `/repos/${repoPath(wanted)}`).catch((error) => {
220
+ if (isRefused(error)) throw new CommentsError("not-found", "GitHub has no such repository for this sign-in.");
221
+ throw error;
222
+ });
223
+ if (found.private) throw new CommentsError("not-public", "The repository is private.", `${found.html_url}/settings`);
224
+ return placeOf(await withDiscussions(found));
225
+ },
226
+ async threads(place, next) {
227
+ const [owner = "", name = ""] = place.repo.split("/");
228
+ const data = await graphql(THREADS_QUERY, {
229
+ owner,
230
+ name,
231
+ category: place.categoryId,
232
+ after: next ?? null
233
+ });
234
+ if (!data.repository) throw new CommentsError("not-found", "GitHub has no such repository for this sign-in.");
235
+ const { pageInfo, nodes } = data.repository.discussions;
236
+ return {
237
+ threads: nodes.map((node) => ({
238
+ id: node.id,
239
+ title: node.title,
240
+ url: node.url,
241
+ locked: node.locked,
242
+ updatedAt: node.updatedAt,
243
+ comments: node.comments.nodes.map(comment)
244
+ })),
245
+ ...pageInfo.hasNextPage && pageInfo.endCursor && { next: pageInfo.endCursor }
246
+ };
247
+ },
248
+ async hide(commentId, reason) {
249
+ await mutate("MinimizeComment", {
250
+ subjectId: commentId,
251
+ classifier: CLASSIFIERS[reason]
252
+ });
253
+ },
254
+ async show(commentId) {
255
+ await mutate("UnminimizeComment", { subjectId: commentId });
256
+ },
257
+ async remove(commentId) {
258
+ await mutate("DeleteDiscussionComment", { id: commentId });
259
+ },
260
+ async lock(threadId) {
261
+ await mutate("LockLockable", { lockableId: threadId });
262
+ },
263
+ async unlock(threadId) {
264
+ await mutate("UnlockLockable", { lockableId: threadId });
265
+ }
266
+ };
267
+ }
268
+ //#endregion
33
269
  //#region src/editors.ts
34
270
  /** GitHub account names: letters, digits and single hyphens, up to 39 characters. */
35
271
  const USERNAME = /^[a-z\d](?:[a-z\d]|-(?=[a-z\d])){0,38}$/i;
@@ -66,15 +302,19 @@ function githubEditors(read, manage, repo, login) {
66
302
  throw error;
67
303
  }
68
304
  }
69
- async function collaborators(api) {
305
+ /** Every page of a list, 100 at a time. */
306
+ async function allPages(api, list) {
70
307
  const all = [];
71
308
  for (let page = 1; page <= 10; page++) {
72
- const found = await githubJson(api, `${path}/collaborators?per_page=100&page=${page}`);
309
+ const found = await githubJson(api, `${path}/${list}?per_page=100&page=${page}`);
73
310
  all.push(...found);
74
311
  if (found.length < 100) break;
75
312
  }
76
313
  return all;
77
314
  }
315
+ function collaborators(api) {
316
+ return allPages(api, "collaborators");
317
+ }
78
318
  return {
79
319
  invitesByEmail: false,
80
320
  async list() {
@@ -82,7 +322,7 @@ function githubEditors(read, manage, repo, login) {
82
322
  const people = await collaborators(api);
83
323
  let invitations;
84
324
  try {
85
- invitations = await githubJson(api, `${path}/invitations?per_page=100`);
325
+ invitations = await allPages(api, "invitations");
86
326
  } catch (error) {
87
327
  if (!isNotAllowed(error)) throw error;
88
328
  }
@@ -620,10 +860,20 @@ function githubCodeUpdates(options) {
620
860
  }
621
861
  //#endregion
622
862
  //#region src/backend.ts
623
- /** Cloudflare Pages' check runs (its GitHub App is "Cloudflare Workers and Pages") and commit statuses. */
624
- const CLOUDFLARE = /cloudflare/i;
863
+ /**
864
+ * The check runs and commit statuses of hosts that build the site themselves
865
+ * and report to GitHub: Cloudflare Pages (its GitHub App is "Cloudflare Workers
866
+ * and Pages") and Netlify's deploys (`netlify/<site>/deploy…`), but not its
867
+ * other checks, such as "Header rules", which finish before the deploy does.
868
+ * Netlify's GitHub App also creates a deployment, which `deployStatus()` reads first.
869
+ */
870
+ const HOST_BUILDS = /cloudflare|netlify\/\S+\/deploy/i;
625
871
  /** How many files one GraphQL request reads. */
626
872
  const GRAPHQL_BATCH = 100;
873
+ /** The most files GitHub's compare lists. */
874
+ const COMPARE_FILES_LIMIT = 300;
875
+ /** How many pages of open pull requests are read for drafts' reviews: a thousand. */
876
+ const REVIEW_PAGES = 10;
627
877
  const COMMIT_MUTATION = `mutation ($input: CreateCommitOnBranchInput!) {
628
878
  createCommitOnBranch(input: $input) { commit { oid } }
629
879
  }`;
@@ -647,6 +897,7 @@ var GitHubBackend = class GitHubBackend {
647
897
  drafts;
648
898
  scheduledBuilds;
649
899
  tasks;
900
+ comments;
650
901
  contentOnly;
651
902
  options;
652
903
  api;
@@ -693,6 +944,7 @@ var GitHubBackend = class GitHubBackend {
693
944
  this.drafts = branchDrafts(this, this.draftHost());
694
945
  this.scheduledBuilds = githubScheduledBuilds((path) => this.read(path));
695
946
  this.tasks = githubTasks(this.api, this.repo, this.branch, options.tasks);
947
+ this.comments = githubComments(this.api, options.repo, options.commentsTokenLink ?? commentsTokenLink("https://github.com", options.repo));
696
948
  }
697
949
  async repositoryVisibility() {
698
950
  const found = await githubJson(this.api, `/repos/${this.repo}`);
@@ -775,10 +1027,17 @@ var GitHubBackend = class GitHubBackend {
775
1027
  return { url: review.html_url };
776
1028
  },
777
1029
  listReviews: async (prefix) => {
778
- const pulls = await githubJson(this.api, `${base}/pulls?${new URLSearchParams({
779
- state: "open",
780
- per_page: "100"
781
- })}`);
1030
+ const pulls = [];
1031
+ for (let page = 1; page <= REVIEW_PAGES; page++) {
1032
+ const query = new URLSearchParams({
1033
+ state: "open",
1034
+ per_page: "100",
1035
+ page: String(page)
1036
+ });
1037
+ const found = await githubJson(this.api, `${base}/pulls?${query}`);
1038
+ pulls.push(...found);
1039
+ if (found.length < 100) break;
1040
+ }
782
1041
  const repo = decodeURIComponent(this.repo).toLowerCase();
783
1042
  return pulls.filter((pull) => pull.head.ref.startsWith(prefix) && (pull.head.repo?.full_name ?? repo).toLowerCase() === repo).map((pull) => ({
784
1043
  branch: pull.head.ref,
@@ -1043,12 +1302,13 @@ var GitHubBackend = class GitHubBackend {
1043
1302
  return this.readFrom(await this.tree(revision), path);
1044
1303
  }
1045
1304
  async changedPaths(from, to) {
1046
- const comparison = await githubJson(this.api, `/repos/${this.repo}/compare/${from}...${to}`);
1047
- return [...new Set((comparison.files ?? []).flatMap((file) => [file.filename, file.previous_filename ?? []].flat()))];
1305
+ const files = (await githubJson(this.api, `/repos/${this.repo}/compare/${from}...${to}`)).files ?? [];
1306
+ if (files.length >= COMPARE_FILES_LIMIT) throw new GitApiError(0, `More files changed between ${from} and ${to} than GitHub lists.`);
1307
+ return [...new Set(files.flatMap((file) => [file.filename, file.previous_filename ?? []].flat()))];
1048
1308
  }
1049
1309
  /**
1050
- * Reads the deployment GitHub Pages or Vercel created for a commit, or what
1051
- * Cloudflare Pages reports on it, and for GitHub Actions, the workflow run
1310
+ * Reads the deployment GitHub Pages, Vercel or Netlify created for a commit,
1311
+ * or what Cloudflare Pages or Netlify reports on it, and for GitHub Actions, the workflow run
1052
1312
  * that builds it: a build that fails never creates a deployment. Before any
1053
1313
  * exists, the site counts as building if it has ever been deployed, or if
1054
1314
  * Cloudflare reported on the commit before.
@@ -1080,8 +1340,8 @@ var GitHubBackend = class GitHubBackend {
1080
1340
  };
1081
1341
  }
1082
1342
  }
1083
- const cloudflare = await this.cloudflareBuild(revision);
1084
- if (cloudflare) return cloudflare;
1343
+ const hosted = await this.hostBuild(revision);
1344
+ if (hosted) return hosted;
1085
1345
  const run = await this.workflowRun(revision);
1086
1346
  if (run?.status === "completed" && run.conclusion === "cancelled") {
1087
1347
  const head = await this.head();
@@ -1098,22 +1358,22 @@ var GitHubBackend = class GitHubBackend {
1098
1358
  };
1099
1359
  if ((await githubJson(this.api, `/repos/${this.repo}/deployments?per_page=1`)).length > 0) return { state: "building" };
1100
1360
  const parent = await this.parentOf(revision);
1101
- return { state: parent && await this.cloudflareBuild(parent) ? "building" : "unknown" };
1361
+ return { state: parent && await this.hostBuild(parent) ? "building" : "unknown" };
1102
1362
  } catch (error) {
1103
1363
  if (error instanceof GitApiError) return { state: "unknown" };
1104
1364
  throw error;
1105
1365
  }
1106
1366
  }
1107
1367
  /**
1108
- * What Cloudflare Pages reports on a commit: a check run of its GitHub App,
1109
- * or a commit status, which fine-grained tokens can read where check runs
1110
- * need a GitHub App's permission. `undefined` if it reported nothing.
1368
+ * What Cloudflare Pages or Netlify reports on a commit: a check run of its
1369
+ * GitHub App, or a commit status, which fine-grained tokens can read where
1370
+ * check runs need a GitHub App's permission. `undefined` if it reported nothing.
1111
1371
  */
1112
- async cloudflareBuild(revision) {
1372
+ async hostBuild(revision) {
1113
1373
  const checks = await githubRequest(this.api, `/repos/${this.repo}/commits/${revision}/check-runs?per_page=50`, { allow: [403, 404] });
1114
1374
  if (checks.ok) {
1115
1375
  const { check_runs: found = [] } = await checks.json();
1116
- const run = found.find((candidate) => CLOUDFLARE.test(`${candidate.app?.slug ?? ""} ${candidate.name}`));
1376
+ const run = found.find((candidate) => HOST_BUILDS.test(`${candidate.app?.slug ?? ""} ${candidate.name}`));
1117
1377
  if (run) {
1118
1378
  const detailsUrl = run.details_url ?? run.html_url ?? void 0;
1119
1379
  if (run.status !== "completed") return {
@@ -1136,7 +1396,7 @@ var GitHubBackend = class GitHubBackend {
1136
1396
  }
1137
1397
  const statuses = await githubRequest(this.api, `/repos/${this.repo}/commits/${revision}/statuses?per_page=50`, { allow: [403, 404] });
1138
1398
  if (!statuses.ok) return void 0;
1139
- const status = (await statuses.json()).find((candidate) => CLOUDFLARE.test(candidate.context));
1399
+ const status = (await statuses.json()).find((candidate) => HOST_BUILDS.test(candidate.context));
1140
1400
  if (!status) return void 0;
1141
1401
  const detailsUrl = status.target_url ?? void 0;
1142
1402
  if (status.state === "success") return {
@@ -1194,6 +1454,7 @@ var GitHubBackend = class GitHubBackend {
1194
1454
  }
1195
1455
  signOut() {
1196
1456
  this.ownerAccess.forget();
1457
+ this.comments.access.forget();
1197
1458
  this.onSignOut();
1198
1459
  }
1199
1460
  };
@@ -1636,11 +1897,20 @@ async function tokenCreator(api) {
1636
1897
  avatarUrl: user.avatar_url
1637
1898
  },
1638
1899
  async owners() {
1639
- return [{
1900
+ const own = {
1640
1901
  path: user.login,
1641
1902
  name: user.name || user.login,
1642
1903
  kind: "personal"
1643
- }, ...(await githubJson(api, "/user/orgs?per_page=100").catch(() => [])).map((org) => ({
1904
+ };
1905
+ const orgs = [];
1906
+ try {
1907
+ for (let page = 1; page <= 10; page++) {
1908
+ const found = await githubJson(api, `/user/orgs?per_page=100&page=${page}`);
1909
+ orgs.push(...found);
1910
+ if (found.length < 100) break;
1911
+ }
1912
+ } catch {}
1913
+ return [own, ...orgs.map((org) => ({
1644
1914
  path: org.login,
1645
1915
  name: org.login,
1646
1916
  kind: "organization"
@@ -1840,7 +2110,8 @@ function githubTokenLinks(repo, webUrl = "https://github.com") {
1840
2110
  return {
1841
2111
  fineGrained: `${webUrl}/settings/personal-access-tokens/new?${fineGrained}`,
1842
2112
  classic: `${webUrl}/settings/tokens/new?${classic}`,
1843
- owner: `${webUrl}/settings/personal-access-tokens/new?${owner}`
2113
+ owner: `${webUrl}/settings/personal-access-tokens/new?${owner}`,
2114
+ comments: commentsTokenLink(webUrl, repo).url
1844
2115
  };
1845
2116
  }
1846
2117
  /** Sign-in and storage for a site whose repository is on GitHub. */
@@ -1945,6 +2216,10 @@ function github(options) {
1945
2216
  url: links.owner,
1946
2217
  label: "owner.token.create"
1947
2218
  },
2219
+ commentsTokenLink: {
2220
+ url: links.comments,
2221
+ label: "comments.token.create"
2222
+ },
1948
2223
  ownerRedirect: (redirectOptions) => redirect("owner", false, redirectOptions)
1949
2224
  });
1950
2225
  }
@@ -2041,6 +2316,10 @@ function github(options) {
2041
2316
  url: links.owner,
2042
2317
  label: "owner.token.create"
2043
2318
  },
2319
+ commentsTokenLink: {
2320
+ url: links.comments,
2321
+ label: "comments.token.create"
2322
+ },
2044
2323
  ...service && { ownerRedirect: (redirectOptions) => redirect("owner", false, redirectOptions) }
2045
2324
  });
2046
2325
  }
@@ -2073,6 +2352,10 @@ function github(options) {
2073
2352
  url: links.owner,
2074
2353
  label: "owner.token.create"
2075
2354
  },
2355
+ commentsTokenLink: {
2356
+ url: links.comments,
2357
+ label: "comments.token.create"
2358
+ },
2076
2359
  ...revision && { revision },
2077
2360
  ...author && { author }
2078
2361
  });
package/dist/testing.d.ts CHANGED
@@ -24,6 +24,8 @@ export interface FakeGitHubUser {
24
24
  variables?: boolean;
25
25
  /** Whether the token may set Actions secrets and add deploy keys. Defaults to true. */
26
26
  secrets?: boolean;
27
+ /** A fine-grained token's repositories, which it alone reaches. Others reach every repository the user can. */
28
+ repositories?: string[];
27
29
  }
28
30
  /** A GitHub Actions workflow run, with its jobs and their steps. */
29
31
  export interface FakeRun {
@@ -85,6 +87,14 @@ export interface FakeGitHubOptions {
85
87
  afterChecks: number;
86
88
  report?: "check-runs" | "statuses";
87
89
  };
90
+ /**
91
+ * Simulates Netlify, as `cloudflare` does Cloudflare Pages: its GitHub App's
92
+ * "Header rules" check run, which finishes at once, and a commit status for
93
+ * the deploy, which reports success after being checked this many times.
94
+ */
95
+ netlify?: {
96
+ afterChecks: number;
97
+ };
88
98
  /** Answers 403 to reading check runs, as GitHub does for fine-grained tokens, which can't have the permission. */
89
99
  checkRunsForbidden?: boolean;
90
100
  /** Organizations the users belong to, which new repositories can be created in. */
@@ -97,6 +107,8 @@ export interface FakeGitHubOptions {
97
107
  pages?: boolean;
98
108
  /** Domains another GitHub Pages site already uses. */
99
109
  takenDomains?: string[];
110
+ /** Turning on Discussions makes no categories yet, as GitHub sometimes doesn't until someone opens them. */
111
+ noDiscussionCategories?: boolean;
100
112
  /** Other GitHub accounts that exist, which can be invited. Every token's user exists too. */
101
113
  accounts?: string[];
102
114
  /** GitHub Apps, which the sign-in worker signs people in with. */
@@ -174,6 +186,33 @@ export interface FakeCreatedRepo {
174
186
  secretKey?: CryptoKeyPair;
175
187
  /** People's permission on it, by login, as `collaborators/{login}/permission` says; others have none. */
176
188
  collaborators?: Map<string, "admin" | "write" | "read">;
189
+ /** Whether Discussions are on, and their categories and discussions, as the Comments block keeps them. */
190
+ discussions?: boolean;
191
+ categories: FakeDiscussionCategory[];
192
+ threads: FakeDiscussion[];
193
+ }
194
+ export interface FakeDiscussionCategory {
195
+ id: string;
196
+ name: string;
197
+ slug: string;
198
+ }
199
+ /** A visitor's comment on a page's discussion, or a reply to one. */
200
+ export interface FakeDiscussionComment {
201
+ id: string;
202
+ login: string;
203
+ text: string;
204
+ createdAt: string;
205
+ minimized?: string;
206
+ replies: FakeDiscussionComment[];
207
+ }
208
+ /** A page's discussion, as Giscus starts one. */
209
+ export interface FakeDiscussion {
210
+ id: string;
211
+ title: string;
212
+ categoryId: string;
213
+ locked: boolean;
214
+ updatedAt: string;
215
+ comments: FakeDiscussionComment[];
177
216
  }
178
217
  interface Deployment {
179
218
  id: number;
@@ -208,7 +247,22 @@ export declare function fakeGitHub(options: FakeGitHubOptions): {
208
247
  files?: Record<string, string>;
209
248
  private?: boolean;
210
249
  collaborators?: Record<string, "admin" | "write" | "read">;
250
+ /** Turns Discussions on, with GitHub's usual categories. */
251
+ discussions?: boolean;
211
252
  }) => FakeCreatedRepo;
253
+ /** Starts a page's discussion in a repository with Discussions, as Giscus does. */
254
+ addDiscussion: (fullName: string, discussion: {
255
+ title: string;
256
+ category?: string;
257
+ comments: Array<{
258
+ login: string;
259
+ text: string;
260
+ replies?: Array<{
261
+ login: string;
262
+ text: string;
263
+ }>;
264
+ }>;
265
+ }) => FakeDiscussion;
212
266
  /** The site's GitHub Pages settings, which tests can change, such as to issue the certificate. */
213
267
  pages: {
214
268
  current?: FakePages;
package/dist/testing.js CHANGED
@@ -29,6 +29,15 @@ function decodeJwtPart(part) {
29
29
  return;
30
30
  }
31
31
  }
32
+ /** The categories GitHub makes as Discussions are turned on. */
33
+ const DEFAULT_CATEGORIES = [
34
+ "Announcements",
35
+ "General",
36
+ "Ideas",
37
+ "Polls",
38
+ "Q&A",
39
+ "Show and tell"
40
+ ];
32
41
  function json(body, status = 200) {
33
42
  return new Response(JSON.stringify(body), {
34
43
  status,
@@ -225,18 +234,167 @@ function fakeGitHub(options) {
225
234
  rulesets: [],
226
235
  deployKeys: [],
227
236
  secrets: /* @__PURE__ */ new Map(),
228
- collaborators: new Map(Object.entries(repoOptions.collaborators ?? {}))
237
+ collaborators: new Map(Object.entries(repoOptions.collaborators ?? {})),
238
+ categories: [],
239
+ threads: []
229
240
  };
241
+ if (repoOptions.discussions) enableDiscussions(added);
230
242
  created.set(fullName, added);
231
243
  return added;
232
244
  }
245
+ let nextNode = 1;
246
+ function enableDiscussions(target) {
247
+ target.discussions = true;
248
+ if (target.categories.length > 0 || options.noDiscussionCategories) return;
249
+ target.categories = DEFAULT_CATEGORIES.map((category) => ({
250
+ id: `DIC_${target.id}_${nextNode++}`,
251
+ name: category,
252
+ slug: category.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "")
253
+ }));
254
+ }
255
+ /** Starts a page's discussion with comments, as Giscus does when visitors write them. */
256
+ function addDiscussion(fullName, discussion) {
257
+ const target = created.get(fullName);
258
+ if (!target?.discussions) throw new Error(`${fullName} has no Discussions.`);
259
+ const category = target.categories.find((candidate) => candidate.name === (discussion.category ?? "Announcements")) ?? target.categories[0];
260
+ const at = new Date(Date.UTC(2026, 9, 1, 12, nextNode)).toISOString();
261
+ const made = (comment) => ({
262
+ id: `DC_${nextNode++}`,
263
+ login: comment.login,
264
+ text: comment.text,
265
+ createdAt: at,
266
+ replies: (comment.replies ?? []).map((reply) => made(reply))
267
+ });
268
+ const added = {
269
+ id: `D_${nextNode++}`,
270
+ title: discussion.title,
271
+ categoryId: category?.id ?? "",
272
+ locked: false,
273
+ updatedAt: at,
274
+ comments: discussion.comments.map(made)
275
+ };
276
+ target.threads.unshift(added);
277
+ return added;
278
+ }
279
+ /** GitHub's GraphQL for Discussions, as the admin panel's comments screen asks it. */
280
+ function handleDiscussions(body, principal) {
281
+ const variables = body.variables ?? {};
282
+ const query = body.query.trimStart();
283
+ if (query.startsWith("query") && /discussionCategories|discussions\(/.test(query)) {
284
+ const fullName = `${variables.owner}/${variables.name}`;
285
+ const target = created.get(fullName);
286
+ if (!target || principal.bot || !canSee(principal, fullName)) return json({
287
+ data: { repository: null },
288
+ errors: [{
289
+ type: "NOT_FOUND",
290
+ message: "Could not resolve to a Repository"
291
+ }]
292
+ });
293
+ if (query.includes("discussionCategories")) return json({ data: { repository: { discussionCategories: { nodes: target.categories } } } });
294
+ const comment = (item) => ({
295
+ id: item.id,
296
+ url: `https://github.com/${fullName}/discussions#${item.id}`,
297
+ bodyText: item.text,
298
+ createdAt: item.createdAt,
299
+ isMinimized: item.minimized !== void 0,
300
+ minimizedReason: item.minimized ?? null,
301
+ author: {
302
+ login: item.login,
303
+ url: `https://github.com/${item.login}`,
304
+ avatarUrl: `https://avatars.example/${item.login}`
305
+ },
306
+ replies: { nodes: item.replies.map(comment) }
307
+ });
308
+ const inCategory = target.threads.filter((thread) => thread.categoryId === variables.category);
309
+ const start = typeof variables.after === "string" ? Number(variables.after) : 0;
310
+ const more = start + 20 < inCategory.length;
311
+ return json({ data: { repository: { discussions: {
312
+ pageInfo: {
313
+ hasNextPage: more,
314
+ endCursor: more ? String(start + 20) : null
315
+ },
316
+ nodes: inCategory.slice(start, start + 20).map((thread) => ({
317
+ id: thread.id,
318
+ title: thread.title,
319
+ url: `https://github.com/${fullName}/discussions/${thread.id}`,
320
+ locked: thread.locked,
321
+ updatedAt: thread.updatedAt,
322
+ comments: { nodes: thread.comments.map(comment) }
323
+ }))
324
+ } } } });
325
+ }
326
+ const mutation = query.match(/\b(minimizeComment|unminimizeComment|deleteDiscussionComment|lockLockable|unlockLockable)\(/)?.[1];
327
+ if (!mutation || !query.startsWith("mutation")) return void 0;
328
+ const input = variables.input ?? {};
329
+ const id = input.subjectId ?? input.id ?? input.lockableId ?? "";
330
+ const find = (items) => {
331
+ for (const item of items) {
332
+ if (item.id === id) return {
333
+ list: items,
334
+ item
335
+ };
336
+ const inner = find(item.replies);
337
+ if (inner) return inner;
338
+ }
339
+ };
340
+ for (const target of created.values()) {
341
+ const thread = target.threads.find((candidate) => candidate.id === id);
342
+ const found = target.threads.map((candidate) => find(candidate.comments)).find(Boolean);
343
+ if (!thread && !found) continue;
344
+ const role = target.collaborators?.get(principal.login);
345
+ if (!(!principal.bot && canSee(principal, target.fullName) && (target.collaborators?.size ? role === "admin" || role === "write" : true))) return json({
346
+ data: null,
347
+ errors: [{
348
+ type: "FORBIDDEN",
349
+ message: "Resource not accessible by personal access token"
350
+ }]
351
+ });
352
+ if (thread) thread.locked = mutation === "lockLockable";
353
+ else if (found) {
354
+ if (mutation === "deleteDiscussionComment") found.list.splice(found.list.indexOf(found.item), 1);
355
+ if (mutation === "minimizeComment") found.item.minimized = (input.classifier ?? "").toLowerCase().replace(/_/g, "-");
356
+ if (mutation === "unminimizeComment") delete found.item.minimized;
357
+ }
358
+ return json({ data: { [mutation]: { clientMutationId: null } } });
359
+ }
360
+ return json({
361
+ data: null,
362
+ errors: [{
363
+ type: "NOT_FOUND",
364
+ message: `Could not resolve to a node with the global id of '${id}'`
365
+ }]
366
+ });
367
+ }
368
+ /** A repository as GitHub's REST API describes it. */
369
+ function repositoryJson(site, principal) {
370
+ const [ownerLogin = ""] = site.fullName.split("/");
371
+ const role = site.collaborators?.get(principal.login);
372
+ const admin = site.collaborators?.size ? role === "admin" : true;
373
+ return {
374
+ id: site.id,
375
+ node_id: `R_${site.id}`,
376
+ full_name: site.fullName,
377
+ html_url: `https://github.com/${site.fullName}`,
378
+ private: site.private,
379
+ default_branch: site.repo.defaultBranch,
380
+ has_discussions: site.discussions === true,
381
+ owner: {
382
+ id: accountId(ownerLogin),
383
+ login: ownerLogin
384
+ },
385
+ permissions: {
386
+ push: admin || role === "write",
387
+ admin
388
+ }
389
+ };
390
+ }
233
391
  /** Repository creation and the Git data API, for repositories created through the fake. */
234
392
  async function handleCreated(request, path, principal) {
235
393
  const login = principal.login;
236
394
  if (path === "/user/orgs") return json((options.orgs ?? []).map((org) => ({ login: org })));
237
395
  const create = path === "/user/repos" ? login : path.match(/^\/orgs\/([^/]+)\/repos$/)?.[1];
238
396
  if (create !== void 0 && request.method === "POST") {
239
- if (principal.bot || principal.app && principal.permissions?.administration !== "write") return json({ message: "Resource not accessible by integration" }, 403);
397
+ if (principal.bot || principal.repositories || principal.app && principal.permissions?.administration !== "write") return json({ message: "Resource not accessible by integration" }, 403);
240
398
  if (path !== "/user/repos" && !options.orgs?.includes(create)) return json({ message: "Not Found" }, 404);
241
399
  const body = await request.json();
242
400
  if (!/^[\w.-]+$/.test(body.name)) return json({ message: "Repository creation failed." }, 422);
@@ -255,14 +413,13 @@ function fakeGitHub(options) {
255
413
  repo,
256
414
  rulesets: [],
257
415
  deployKeys: [],
258
- secrets: /* @__PURE__ */ new Map()
416
+ secrets: /* @__PURE__ */ new Map(),
417
+ categories: [],
418
+ threads: []
259
419
  });
260
- return json({
261
- id,
262
- full_name: fullName,
263
- html_url: `https://github.com/${fullName}`,
264
- default_branch: repo.defaultBranch
265
- }, 201);
420
+ const made = created.get(fullName);
421
+ if (made && body.has_discussions) enableDiscussions(made);
422
+ return json(made ? repositoryJson(made, principal) : {}, 201);
266
423
  }
267
424
  const match = path.match(/^\/repos\/([^/]+\/[^/]+)(\/.*)?$/);
268
425
  const site = created.get(decodeURIComponent(match?.[1] ?? ""));
@@ -271,16 +428,7 @@ function fakeGitHub(options) {
271
428
  const { repo } = site;
272
429
  const rest = match[2] ?? "";
273
430
  const method = request.method;
274
- if (rest === "" && method === "GET") return json({
275
- id: site.id,
276
- full_name: site.fullName,
277
- private: site.private,
278
- default_branch: repo.defaultBranch,
279
- permissions: {
280
- push: true,
281
- admin: true
282
- }
283
- });
431
+ if (rest === "" && method === "GET") return json(repositoryJson(site, principal));
284
432
  const tree = rest.match(/^\/git\/trees\/([0-9a-f]{40})$/);
285
433
  if (tree && method === "GET" && repo.commitAt(tree[1] ?? "")) {
286
434
  const entries = [...repo.files(tree[1] ?? "")].map(([file, content]) => ({
@@ -318,14 +466,14 @@ function fakeGitHub(options) {
318
466
  }
319
467
  if (rest === "" && method === "PATCH") {
320
468
  const body = await request.json();
469
+ if (!repositoryJson(site, principal).permissions.admin) return json({ message: "Must have admin rights to Repository." }, 403);
321
470
  if (body.default_branch) {
322
471
  if (!repo.branches.has(body.default_branch)) return json({ message: "Not Found" }, 422);
323
472
  repo.defaultBranch = body.default_branch;
324
473
  }
325
- return json({
326
- full_name: site.fullName,
327
- default_branch: repo.defaultBranch
328
- });
474
+ if (body.has_discussions) enableDiscussions(site);
475
+ if (body.has_discussions === false) site.discussions = false;
476
+ return json(repositoryJson(site, principal));
329
477
  }
330
478
  const ref = rest.match(/^\/git\/refs?\/heads\/(.+)$/);
331
479
  if (ref && method === "GET") {
@@ -475,7 +623,7 @@ function fakeGitHub(options) {
475
623
  }
476
624
  }
477
625
  function deploy(sha) {
478
- if (options.cloudflare) cloudflareBuilds.set(sha, { checks: 0 });
626
+ if (options.cloudflare || options.netlify) cloudflareBuilds.set(sha, { checks: 0 });
479
627
  if (options.deployAfterChecks === void 0) return;
480
628
  deployments.unshift({
481
629
  id: nextDeployment++,
@@ -490,7 +638,7 @@ function fakeGitHub(options) {
490
638
  if (!build) return void 0;
491
639
  build.checks += 1;
492
640
  if (build.failed) return "failure";
493
- return build.checks > (options.cloudflare?.afterChecks ?? 0) ? "success" : "building";
641
+ return build.checks > (options.cloudflare?.afterChecks ?? options.netlify?.afterChecks ?? 0) ? "success" : "building";
494
642
  }
495
643
  function failCloudflare(sha) {
496
644
  cloudflareBuilds.set(sha, {
@@ -752,6 +900,8 @@ function fakeGitHub(options) {
752
900
  return accounts.has(login) ? json({ login }) : json({ message: "Not Found" }, 404);
753
901
  }
754
902
  if (path === "/graphql" && request.method === "POST") {
903
+ const discussions = handleDiscussions(await request.clone().json(), user);
904
+ if (discussions) return discussions;
755
905
  const body = await request.clone().json();
756
906
  if (body.query.trimStart().startsWith("query")) {
757
907
  if (`${body.variables?.owner}/${body.variables?.name}` !== options.repo) return json({
@@ -868,7 +1018,10 @@ function fakeGitHub(options) {
868
1018
  }
869
1019
  if (rest === "/pulls" && request.method === "GET") {
870
1020
  const head = url.searchParams.get("head")?.split(":")[1];
871
- return json(repo.reviews.filter((review) => review.state === "open" && (!head || review.branch === head)).map((review) => ({
1021
+ const found = repo.reviews.filter((review) => review.state === "open" && (!head || review.branch === head));
1022
+ const perPage = Number(url.searchParams.get("per_page") ?? 30);
1023
+ const page = Number(url.searchParams.get("page") ?? 1);
1024
+ return json(found.slice((page - 1) * perPage, page * perPage).map((review) => ({
872
1025
  number: review.number,
873
1026
  title: review.title,
874
1027
  html_url: `https://github.com/${options.repo}/pull/${review.number}`,
@@ -1108,6 +1261,19 @@ function fakeGitHub(options) {
1108
1261
  const checkRunsSha = rest.match(/^\/commits\/([0-9a-f]{40})\/check-runs$/)?.[1];
1109
1262
  if (checkRunsSha) {
1110
1263
  if (options.checkRunsForbidden) return json({ message: "Resource not accessible by personal access token" }, 403);
1264
+ if (options.netlify) {
1265
+ const known = cloudflareBuilds.has(checkRunsSha);
1266
+ return json({
1267
+ total_count: known ? 1 : 0,
1268
+ check_runs: known ? [{
1269
+ id: 1,
1270
+ name: "Header rules - parish",
1271
+ app: { slug: "netlify" },
1272
+ status: "completed",
1273
+ conclusion: "neutral"
1274
+ }] : []
1275
+ });
1276
+ }
1111
1277
  const state = options.cloudflare?.report === "statuses" ? void 0 : cloudflareState(checkRunsSha);
1112
1278
  const runsFound = state ? [{
1113
1279
  id: 1,
@@ -1125,6 +1291,15 @@ function fakeGitHub(options) {
1125
1291
  }
1126
1292
  const statusesSha = rest.match(/^\/commits\/([0-9a-f]{40})\/statuses$/)?.[1];
1127
1293
  if (statusesSha) {
1294
+ if (options.netlify) {
1295
+ const deploy = cloudflareState(statusesSha);
1296
+ return json(deploy ? [{
1297
+ context: "netlify/parish/deploy-preview",
1298
+ state: deploy === "building" ? "pending" : deploy,
1299
+ target_url: `https://app.netlify.com/sites/parish/deploys/${statusesSha}`,
1300
+ description: deploy === "failure" ? "Deploy failed" : "Deploy is ready!"
1301
+ }] : []);
1302
+ }
1128
1303
  const state = options.cloudflare?.report === "statuses" ? cloudflareState(statusesSha) : void 0;
1129
1304
  return json(state ? [{
1130
1305
  context: "Cloudflare Pages",
@@ -1168,6 +1343,8 @@ function fakeGitHub(options) {
1168
1343
  /** Repositories created through the API, or added with `addRepo()`, by `owner/name`. */
1169
1344
  created,
1170
1345
  addRepo,
1346
+ /** Starts a page's discussion in a repository with Discussions, as Giscus does. */
1347
+ addDiscussion,
1171
1348
  /** The site's GitHub Pages settings, which tests can change, such as to issue the certificate. */
1172
1349
  pages,
1173
1350
  /** The repository's collaborators, by login, and invitations waiting to be accepted. */
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@goodfellow-cms/github",
3
- "version": "0.7.0",
4
- "description": "Edit a Goodfellow site stored on GitHub, straight from the browser.",
3
+ "version": "0.9.0",
4
+ "description": "Edit a Goodfellow CMS site stored on GitHub, straight from the browser.",
5
5
  "keywords": [
6
6
  "goodfellow",
7
7
  "cms",
@@ -12,6 +12,10 @@
12
12
  ],
13
13
  "homepage": "https://goodfellow-cms.github.io/goodfellow-cms/docs/hosts",
14
14
  "bugs": "https://github.com/goodfellow-cms/goodfellow-cms/issues",
15
+ "funding": {
16
+ "type": "github",
17
+ "url": "https://github.com/sponsors/goodfellow-cms"
18
+ },
15
19
  "license": "MIT",
16
20
  "repository": {
17
21
  "type": "git",
@@ -35,7 +39,7 @@
35
39
  "dist"
36
40
  ],
37
41
  "dependencies": {
38
- "@goodfellow-cms/core": "0.7.0",
42
+ "@goodfellow-cms/core": "0.9.0",
39
43
  "@noble/ciphers": "2.4.0",
40
44
  "@noble/hashes": "2.4.0"
41
45
  },