@goodfellow-cms/github 0.7.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 +5 -1
- package/dist/index.js +290 -13
- package/dist/testing.d.ts +46 -0
- package/dist/testing.js +179 -24
- package/package.json +2 -2
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, 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
|
-
|
|
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}
|
|
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
|
|
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}`);
|
|
@@ -775,10 +1021,17 @@ var GitHubBackend = class GitHubBackend {
|
|
|
775
1021
|
return { url: review.html_url };
|
|
776
1022
|
},
|
|
777
1023
|
listReviews: async (prefix) => {
|
|
778
|
-
const pulls =
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
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
|
+
}
|
|
782
1035
|
const repo = decodeURIComponent(this.repo).toLowerCase();
|
|
783
1036
|
return pulls.filter((pull) => pull.head.ref.startsWith(prefix) && (pull.head.repo?.full_name ?? repo).toLowerCase() === repo).map((pull) => ({
|
|
784
1037
|
branch: pull.head.ref,
|
|
@@ -1043,8 +1296,9 @@ var GitHubBackend = class GitHubBackend {
|
|
|
1043
1296
|
return this.readFrom(await this.tree(revision), path);
|
|
1044
1297
|
}
|
|
1045
1298
|
async changedPaths(from, to) {
|
|
1046
|
-
const
|
|
1047
|
-
|
|
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()))];
|
|
1048
1302
|
}
|
|
1049
1303
|
/**
|
|
1050
1304
|
* Reads the deployment GitHub Pages or Vercel created for a commit, or what
|
|
@@ -1194,6 +1448,7 @@ var GitHubBackend = class GitHubBackend {
|
|
|
1194
1448
|
}
|
|
1195
1449
|
signOut() {
|
|
1196
1450
|
this.ownerAccess.forget();
|
|
1451
|
+
this.comments.access.forget();
|
|
1197
1452
|
this.onSignOut();
|
|
1198
1453
|
}
|
|
1199
1454
|
};
|
|
@@ -1636,11 +1891,20 @@ async function tokenCreator(api) {
|
|
|
1636
1891
|
avatarUrl: user.avatar_url
|
|
1637
1892
|
},
|
|
1638
1893
|
async owners() {
|
|
1639
|
-
|
|
1894
|
+
const own = {
|
|
1640
1895
|
path: user.login,
|
|
1641
1896
|
name: user.name || user.login,
|
|
1642
1897
|
kind: "personal"
|
|
1643
|
-
}
|
|
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) => ({
|
|
1644
1908
|
path: org.login,
|
|
1645
1909
|
name: org.login,
|
|
1646
1910
|
kind: "organization"
|
|
@@ -1840,7 +2104,8 @@ function githubTokenLinks(repo, webUrl = "https://github.com") {
|
|
|
1840
2104
|
return {
|
|
1841
2105
|
fineGrained: `${webUrl}/settings/personal-access-tokens/new?${fineGrained}`,
|
|
1842
2106
|
classic: `${webUrl}/settings/tokens/new?${classic}`,
|
|
1843
|
-
owner: `${webUrl}/settings/personal-access-tokens/new?${owner}
|
|
2107
|
+
owner: `${webUrl}/settings/personal-access-tokens/new?${owner}`,
|
|
2108
|
+
comments: commentsTokenLink(webUrl, repo).url
|
|
1844
2109
|
};
|
|
1845
2110
|
}
|
|
1846
2111
|
/** Sign-in and storage for a site whose repository is on GitHub. */
|
|
@@ -1945,6 +2210,10 @@ function github(options) {
|
|
|
1945
2210
|
url: links.owner,
|
|
1946
2211
|
label: "owner.token.create"
|
|
1947
2212
|
},
|
|
2213
|
+
commentsTokenLink: {
|
|
2214
|
+
url: links.comments,
|
|
2215
|
+
label: "comments.token.create"
|
|
2216
|
+
},
|
|
1948
2217
|
ownerRedirect: (redirectOptions) => redirect("owner", false, redirectOptions)
|
|
1949
2218
|
});
|
|
1950
2219
|
}
|
|
@@ -2041,6 +2310,10 @@ function github(options) {
|
|
|
2041
2310
|
url: links.owner,
|
|
2042
2311
|
label: "owner.token.create"
|
|
2043
2312
|
},
|
|
2313
|
+
commentsTokenLink: {
|
|
2314
|
+
url: links.comments,
|
|
2315
|
+
label: "comments.token.create"
|
|
2316
|
+
},
|
|
2044
2317
|
...service && { ownerRedirect: (redirectOptions) => redirect("owner", false, redirectOptions) }
|
|
2045
2318
|
});
|
|
2046
2319
|
}
|
|
@@ -2073,6 +2346,10 @@ function github(options) {
|
|
|
2073
2346
|
url: links.owner,
|
|
2074
2347
|
label: "owner.token.create"
|
|
2075
2348
|
},
|
|
2349
|
+
commentsTokenLink: {
|
|
2350
|
+
url: links.comments,
|
|
2351
|
+
label: "comments.token.create"
|
|
2352
|
+
},
|
|
2076
2353
|
...revision && { revision },
|
|
2077
2354
|
...author && { author }
|
|
2078
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
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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
|
-
|
|
326
|
-
|
|
327
|
-
|
|
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,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
|
-
|
|
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}`,
|
|
@@ -1168,6 +1321,8 @@ function fakeGitHub(options) {
|
|
|
1168
1321
|
/** Repositories created through the API, or added with `addRepo()`, by `owner/name`. */
|
|
1169
1322
|
created,
|
|
1170
1323
|
addRepo,
|
|
1324
|
+
/** Starts a page's discussion in a repository with Discussions, as Giscus does. */
|
|
1325
|
+
addDiscussion,
|
|
1171
1326
|
/** The site's GitHub Pages settings, which tests can change, such as to issue the certificate. */
|
|
1172
1327
|
pages,
|
|
1173
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.
|
|
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.
|
|
38
|
+
"@goodfellow-cms/core": "0.8.0",
|
|
39
39
|
"@noble/ciphers": "2.4.0",
|
|
40
40
|
"@noble/hashes": "2.4.0"
|
|
41
41
|
},
|