@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 +5 -1
- package/dist/index.js +335 -20
- package/dist/testing.d.ts +46 -0
- package/dist/testing.js +204 -25
- 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 } 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}`);
|
|
@@ -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
|
-
|
|
752
|
-
if (response.
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
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
|
|
1009
|
-
|
|
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
|
-
|
|
1894
|
+
const own = {
|
|
1602
1895
|
path: user.login,
|
|
1603
1896
|
name: user.name || user.login,
|
|
1604
1897
|
kind: "personal"
|
|
1605
|
-
}
|
|
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
|
-
|
|
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,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
|
-
|
|
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,
|
|
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.
|
|
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
|
},
|