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