@goodfellow-cms/github 0.6.0 → 0.8.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;
@@ -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 } 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
  }
@@ -624,6 +864,10 @@ function githubCodeUpdates(options) {
624
864
  const CLOUDFLARE = /cloudflare/i;
625
865
  /** How many files one GraphQL request reads. */
626
866
  const GRAPHQL_BATCH = 100;
867
+ /** The most files GitHub's compare lists. */
868
+ const COMPARE_FILES_LIMIT = 300;
869
+ /** How many pages of open pull requests are read for drafts' reviews: a thousand. */
870
+ const REVIEW_PAGES = 10;
627
871
  const COMMIT_MUTATION = `mutation ($input: CreateCommitOnBranchInput!) {
628
872
  createCommitOnBranch(input: $input) { commit { oid } }
629
873
  }`;
@@ -647,6 +891,7 @@ var GitHubBackend = class GitHubBackend {
647
891
  drafts;
648
892
  scheduledBuilds;
649
893
  tasks;
894
+ comments;
650
895
  contentOnly;
651
896
  options;
652
897
  api;
@@ -693,6 +938,7 @@ var GitHubBackend = class GitHubBackend {
693
938
  this.drafts = branchDrafts(this, this.draftHost());
694
939
  this.scheduledBuilds = githubScheduledBuilds((path) => this.read(path));
695
940
  this.tasks = githubTasks(this.api, this.repo, this.branch, options.tasks);
941
+ this.comments = githubComments(this.api, options.repo, options.commentsTokenLink ?? commentsTokenLink("https://github.com", options.repo));
696
942
  }
697
943
  async repositoryVisibility() {
698
944
  const found = await githubJson(this.api, `/repos/${this.repo}`);
@@ -703,6 +949,14 @@ var GitHubBackend = class GitHubBackend {
703
949
  draftHost() {
704
950
  const base = `/repos/${this.repo}`;
705
951
  const ref = (branch) => `${base}/git/refs/heads/${encodeURIComponent(branch)}`;
952
+ const openPulls = (branch) => {
953
+ const owner = decodeURIComponent(this.repo).split("/")[0];
954
+ const query = new URLSearchParams({
955
+ head: `${owner}:${branch}`,
956
+ state: "open"
957
+ });
958
+ return githubJson(this.api, `${base}/pulls?${query}`);
959
+ };
706
960
  return {
707
961
  liveBranch: this.branch,
708
962
  branchStore: (branch) => new GitHubBackend({
@@ -733,7 +987,7 @@ var GitHubBackend = class GitHubBackend {
733
987
  listBranches: async (prefix) => {
734
988
  return (await githubJson(this.api, `${base}/git/matching-refs/heads/${encodeURIComponent(prefix)}`)).map((found) => found.ref.replace(/^refs\/heads\//, ""));
735
989
  },
736
- openReview: async ({ branch, title, description }) => {
990
+ openReview: async ({ branch, title, description, reviewers = [] }) => {
737
991
  const response = await githubRequest(this.api, `${base}/pulls`, {
738
992
  method: "POST",
739
993
  body: {
@@ -748,16 +1002,53 @@ var GitHubBackend = class GitHubBackend {
748
1002
  422
749
1003
  ]
750
1004
  });
751
- if (response.ok) return { url: (await response.json()).html_url };
752
- if (response.status !== 422) throw new DraftError("no-reviews", "This sign-in can't open pull requests on the site's repository.");
753
- const owner = decodeURIComponent(this.repo).split("/")[0];
754
- const query = new URLSearchParams({
755
- head: `${owner}:${branch}`,
756
- state: "open"
1005
+ let review;
1006
+ if (response.ok) review = await response.json();
1007
+ else if (response.status !== 422) throw new DraftError("no-reviews", "This sign-in can't open pull requests on the site's repository.");
1008
+ else {
1009
+ [review] = await openPulls(branch);
1010
+ if (!review) throw new GitApiError(422, "GitHub couldn't open the pull request.");
1011
+ }
1012
+ for (const reviewer of reviewers) await githubRequest(this.api, `${base}/pulls/${review.number}/requested_reviewers`, {
1013
+ method: "POST",
1014
+ body: { reviewers: [reviewer] },
1015
+ allow: [
1016
+ 403,
1017
+ 404,
1018
+ 422
1019
+ ]
1020
+ });
1021
+ return { url: review.html_url };
1022
+ },
1023
+ listReviews: async (prefix) => {
1024
+ const pulls = [];
1025
+ for (let page = 1; page <= REVIEW_PAGES; page++) {
1026
+ const query = new URLSearchParams({
1027
+ state: "open",
1028
+ per_page: "100",
1029
+ page: String(page)
1030
+ });
1031
+ const found = await githubJson(this.api, `${base}/pulls?${query}`);
1032
+ pulls.push(...found);
1033
+ if (found.length < 100) break;
1034
+ }
1035
+ const repo = decodeURIComponent(this.repo).toLowerCase();
1036
+ return pulls.filter((pull) => pull.head.ref.startsWith(prefix) && (pull.head.repo?.full_name ?? repo).toLowerCase() === repo).map((pull) => ({
1037
+ branch: pull.head.ref,
1038
+ url: pull.html_url,
1039
+ title: pull.title
1040
+ }));
1041
+ },
1042
+ closeReview: async (branch) => {
1043
+ for (const pull of await openPulls(branch)) await githubRequest(this.api, `${base}/pulls/${pull.number}`, {
1044
+ method: "PATCH",
1045
+ body: { state: "closed" },
1046
+ allow: [
1047
+ 403,
1048
+ 404,
1049
+ 422
1050
+ ]
757
1051
  });
758
- const [open] = await githubJson(this.api, `${base}/pulls?${query}`);
759
- if (!open) throw new GitApiError(422, "GitHub couldn't open the pull request.");
760
- return { url: open.html_url };
761
1052
  }
762
1053
  };
763
1054
  }
@@ -1005,8 +1296,9 @@ var GitHubBackend = class GitHubBackend {
1005
1296
  return this.readFrom(await this.tree(revision), path);
1006
1297
  }
1007
1298
  async changedPaths(from, to) {
1008
- const comparison = await githubJson(this.api, `/repos/${this.repo}/compare/${from}...${to}`);
1009
- return [...new Set((comparison.files ?? []).flatMap((file) => [file.filename, file.previous_filename ?? []].flat()))];
1299
+ const files = (await githubJson(this.api, `/repos/${this.repo}/compare/${from}...${to}`)).files ?? [];
1300
+ if (files.length >= COMPARE_FILES_LIMIT) throw new GitApiError(0, `More files changed between ${from} and ${to} than GitHub lists.`);
1301
+ return [...new Set(files.flatMap((file) => [file.filename, file.previous_filename ?? []].flat()))];
1010
1302
  }
1011
1303
  /**
1012
1304
  * Reads the deployment GitHub Pages or Vercel created for a commit, or what
@@ -1156,6 +1448,7 @@ var GitHubBackend = class GitHubBackend {
1156
1448
  }
1157
1449
  signOut() {
1158
1450
  this.ownerAccess.forget();
1451
+ this.comments.access.forget();
1159
1452
  this.onSignOut();
1160
1453
  }
1161
1454
  };
@@ -1598,11 +1891,20 @@ async function tokenCreator(api) {
1598
1891
  avatarUrl: user.avatar_url
1599
1892
  },
1600
1893
  async owners() {
1601
- return [{
1894
+ const own = {
1602
1895
  path: user.login,
1603
1896
  name: user.name || user.login,
1604
1897
  kind: "personal"
1605
- }, ...(await githubJson(api, "/user/orgs?per_page=100").catch(() => [])).map((org) => ({
1898
+ };
1899
+ const orgs = [];
1900
+ try {
1901
+ for (let page = 1; page <= 10; page++) {
1902
+ const found = await githubJson(api, `/user/orgs?per_page=100&page=${page}`);
1903
+ orgs.push(...found);
1904
+ if (found.length < 100) break;
1905
+ }
1906
+ } catch {}
1907
+ return [own, ...orgs.map((org) => ({
1606
1908
  path: org.login,
1607
1909
  name: org.login,
1608
1910
  kind: "organization"
@@ -1802,7 +2104,8 @@ function githubTokenLinks(repo, webUrl = "https://github.com") {
1802
2104
  return {
1803
2105
  fineGrained: `${webUrl}/settings/personal-access-tokens/new?${fineGrained}`,
1804
2106
  classic: `${webUrl}/settings/tokens/new?${classic}`,
1805
- owner: `${webUrl}/settings/personal-access-tokens/new?${owner}`
2107
+ owner: `${webUrl}/settings/personal-access-tokens/new?${owner}`,
2108
+ comments: commentsTokenLink(webUrl, repo).url
1806
2109
  };
1807
2110
  }
1808
2111
  /** Sign-in and storage for a site whose repository is on GitHub. */
@@ -1820,7 +2123,7 @@ function github(options) {
1820
2123
  const links = githubTokenLinks(options.repo, options.webUrl);
1821
2124
  const storage = credentialStorage(`goodfellow:github:${options.repo}`, options.storage);
1822
2125
  const pending = credentialStorage(`goodfellow:github:pending:${options.repo}`, options.storage);
1823
- const fetcher = options.fetch ?? ((input, init) => globalThis.fetch(input, init));
2126
+ const fetcher = withRateLimits(options.fetch ?? ((input, init) => globalThis.fetch(input, init)), { host: "GitHub" });
1824
2127
  const location = options.location ?? browserLocation();
1825
2128
  const signInUrl = options.signIn;
1826
2129
  const service = signInUrl && location && !isLocalAddress(location.href()) ? new SignInService(signInUrl, fetcher) : void 0;
@@ -1907,6 +2210,10 @@ function github(options) {
1907
2210
  url: links.owner,
1908
2211
  label: "owner.token.create"
1909
2212
  },
2213
+ commentsTokenLink: {
2214
+ url: links.comments,
2215
+ label: "comments.token.create"
2216
+ },
1910
2217
  ownerRedirect: (redirectOptions) => redirect("owner", false, redirectOptions)
1911
2218
  });
1912
2219
  }
@@ -2003,6 +2310,10 @@ function github(options) {
2003
2310
  url: links.owner,
2004
2311
  label: "owner.token.create"
2005
2312
  },
2313
+ commentsTokenLink: {
2314
+ url: links.comments,
2315
+ label: "comments.token.create"
2316
+ },
2006
2317
  ...service && { ownerRedirect: (redirectOptions) => redirect("owner", false, redirectOptions) }
2007
2318
  });
2008
2319
  }
@@ -2035,6 +2346,10 @@ function github(options) {
2035
2346
  url: links.owner,
2036
2347
  label: "owner.token.create"
2037
2348
  },
2349
+ commentsTokenLink: {
2350
+ url: links.comments,
2351
+ label: "comments.token.create"
2352
+ },
2038
2353
  ...revision && { revision },
2039
2354
  ...author && { author }
2040
2355
  });
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 {
@@ -97,6 +99,8 @@ export interface FakeGitHubOptions {
97
99
  pages?: boolean;
98
100
  /** Domains another GitHub Pages site already uses. */
99
101
  takenDomains?: string[];
102
+ /** Turning on Discussions makes no categories yet, as GitHub sometimes doesn't until someone opens them. */
103
+ noDiscussionCategories?: boolean;
100
104
  /** Other GitHub accounts that exist, which can be invited. Every token's user exists too. */
101
105
  accounts?: string[];
102
106
  /** GitHub Apps, which the sign-in worker signs people in with. */
@@ -174,6 +178,33 @@ export interface FakeCreatedRepo {
174
178
  secretKey?: CryptoKeyPair;
175
179
  /** People's permission on it, by login, as `collaborators/{login}/permission` says; others have none. */
176
180
  collaborators?: Map<string, "admin" | "write" | "read">;
181
+ /** Whether Discussions are on, and their categories and discussions, as the Comments block keeps them. */
182
+ discussions?: boolean;
183
+ categories: FakeDiscussionCategory[];
184
+ threads: FakeDiscussion[];
185
+ }
186
+ export interface FakeDiscussionCategory {
187
+ id: string;
188
+ name: string;
189
+ slug: string;
190
+ }
191
+ /** A visitor's comment on a page's discussion, or a reply to one. */
192
+ export interface FakeDiscussionComment {
193
+ id: string;
194
+ login: string;
195
+ text: string;
196
+ createdAt: string;
197
+ minimized?: string;
198
+ replies: FakeDiscussionComment[];
199
+ }
200
+ /** A page's discussion, as Giscus starts one. */
201
+ export interface FakeDiscussion {
202
+ id: string;
203
+ title: string;
204
+ categoryId: string;
205
+ locked: boolean;
206
+ updatedAt: string;
207
+ comments: FakeDiscussionComment[];
177
208
  }
178
209
  interface Deployment {
179
210
  id: number;
@@ -208,7 +239,22 @@ export declare function fakeGitHub(options: FakeGitHubOptions): {
208
239
  files?: Record<string, string>;
209
240
  private?: boolean;
210
241
  collaborators?: Record<string, "admin" | "write" | "read">;
242
+ /** Turns Discussions on, with GitHub's usual categories. */
243
+ discussions?: boolean;
211
244
  }) => FakeCreatedRepo;
245
+ /** Starts a page's discussion in a repository with Discussions, as Giscus does. */
246
+ addDiscussion: (fullName: string, discussion: {
247
+ title: string;
248
+ category?: string;
249
+ comments: Array<{
250
+ login: string;
251
+ text: string;
252
+ replies?: Array<{
253
+ login: string;
254
+ text: string;
255
+ }>;
256
+ }>;
257
+ }) => FakeDiscussion;
212
258
  /** The site's GitHub Pages settings, which tests can change, such as to issue the certificate. */
213
259
  pages: {
214
260
  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") {
@@ -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,11 +1018,38 @@ 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
- html_url: `https://github.com/${options.repo}/pull/${review.number}`
1026
+ title: review.title,
1027
+ html_url: `https://github.com/${options.repo}/pull/${review.number}`,
1028
+ head: {
1029
+ ref: review.branch,
1030
+ repo: { full_name: options.repo }
1031
+ }
874
1032
  })));
875
1033
  }
1034
+ const pull = rest.match(/^\/pulls\/(\d+)(\/requested_reviewers)?$/);
1035
+ if (pull) {
1036
+ const review = repo.reviews.find((found) => found.number === Number(pull[1]));
1037
+ if (!review) return json({ message: "Not Found" }, 404);
1038
+ if (user.bot && user.permissions?.pull_requests !== "write") return json({ message: "Resource not accessible by integration" }, 403);
1039
+ if (pull[2] && request.method === "POST") {
1040
+ const body = await request.json();
1041
+ if ((body.reviewers ?? []).some((login) => login === user.login || login.startsWith("not-"))) return json({ message: "Reviews may only be requested from collaborators." }, 422);
1042
+ review.reviewers.push(...body.reviewers ?? []);
1043
+ return json({ number: review.number }, 201);
1044
+ }
1045
+ if (!pull[2] && request.method === "PATCH") {
1046
+ if ((await request.json()).state === "closed") review.state = "closed";
1047
+ return json({
1048
+ number: review.number,
1049
+ state: review.state
1050
+ });
1051
+ }
1052
+ }
876
1053
  if (rest === "/pages" && request.method === "GET") {
877
1054
  const site = pages.current;
878
1055
  if (!site) return json({ message: "Not Found" }, 404);
@@ -1144,6 +1321,8 @@ function fakeGitHub(options) {
1144
1321
  /** Repositories created through the API, or added with `addRepo()`, by `owner/name`. */
1145
1322
  created,
1146
1323
  addRepo,
1324
+ /** Starts a page's discussion in a repository with Discussions, as Giscus does. */
1325
+ addDiscussion,
1147
1326
  /** The site's GitHub Pages settings, which tests can change, such as to issue the certificate. */
1148
1327
  pages,
1149
1328
  /** The repository's collaborators, by login, and invitations waiting to be accepted. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodfellow-cms/github",
3
- "version": "0.6.0",
3
+ "version": "0.8.0",
4
4
  "description": "Edit a Goodfellow site stored on GitHub, straight from the browser.",
5
5
  "keywords": [
6
6
  "goodfellow",
@@ -35,7 +35,7 @@
35
35
  "dist"
36
36
  ],
37
37
  "dependencies": {
38
- "@goodfellow-cms/core": "0.6.0",
38
+ "@goodfellow-cms/core": "0.8.0",
39
39
  "@noble/ciphers": "2.4.0",
40
40
  "@noble/hashes": "2.4.0"
41
41
  },