@goodfellow-cms/github 0.5.0 → 0.7.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/README.md CHANGED
@@ -8,7 +8,7 @@ import { github } from "@goodfellow-cms/github";
8
8
  export default defineConfig({ blocks, backend: github({ repo: "your-name/your-site" }) });
9
9
  ```
10
10
 
11
- [Goodfellow](https://github.com/goodfellow-cms/goodfellow-cms) is a website builder for people who aren't developers, with no server or database: pages are files in your own GitHub or GitLab repository, edited in a visual editor built on [Puck](https://puckeditor.com). To start a new site, run `npm create goodfellow@latest my-site`. See the [documentation](https://goodfellow-cms.github.io/goodfellow-cms/) for everything else.
11
+ [Goodfellow](https://github.com/goodfellow-cms/goodfellow-cms) is a website builder for people who aren't developers, with no server or database: pages are files in your own GitHub, GitLab or Bitbucket repository, edited in a visual editor built on [Puck](https://puckeditor.com). To start a new site, run `npm create goodfellow@latest my-site`. See the [documentation](https://goodfellow-cms.github.io/goodfellow-cms/) for everything else.
12
12
 
13
13
  More in the docs: [Hosts and business sites](https://goodfellow-cms.github.io/goodfellow-cms/docs/hosts).
14
14
 
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { ConnectDomainOptions, DeployStatus, DnsRecord, FileChange, FileVersion, GitBackend, GitHost, GitUser, HistoryOptions, OwnerAccess, PagesDomains, 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, 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 {
@@ -20,6 +20,15 @@ interface ApiOptions {
20
20
  fetch: typeof fetch;
21
21
  }
22
22
  //#endregion
23
+ //#region src/tasks.d.ts
24
+ interface GitHubTasksOptions {
25
+ /** How often to look for the answer, and how long to wait for it. */
26
+ pollMs?: number;
27
+ timeoutMs?: number;
28
+ /** Waits between looks; tests pass one that doesn't. */
29
+ wait?: (ms: number) => Promise<void>;
30
+ }
31
+ //#endregion
23
32
  //#region src/backend.d.ts
24
33
  interface GitHubBackendOptions {
25
34
  api: ApiOptions;
@@ -42,6 +51,8 @@ interface GitHubBackendOptions {
42
51
  };
43
52
  /** The commit reads see until `revision()` is next called, as a full-stack site's server reads what a browser loaded. */
44
53
  revision?: string;
54
+ /** How plugins' tasks wait for their answers; tests pass short waits. */
55
+ tasks?: GitHubTasksOptions;
45
56
  /** The repository holds only the site's content, with its code in a repository of its own (`GitBackend.contentOnly`). */
46
57
  contentOnly?: boolean;
47
58
  /**
@@ -68,6 +79,7 @@ export declare class GitHubBackend implements GitBackend {
68
79
  readonly updates?: SiteUpdates;
69
80
  readonly drafts: SiteDrafts;
70
81
  readonly scheduledBuilds: ScheduledBuilds;
82
+ readonly tasks: PluginTasks;
71
83
  readonly contentOnly: boolean;
72
84
  private readonly options;
73
85
  private readonly api;
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { ConflictError, DomainError, DraftError, EditorsError, GitApiError, NIGHTLY_VARIABLE, REBUILD_JOB, SetupError, SignInError, UpdatesError, assertContentChanges, branchDrafts, buildStepFor, compareReleases, credentialStorage, decodeBase64, encodeBase64, encodeBase64Bytes, hostSource, nightlyMode, siteVersion } from "@goodfellow-cms/core";
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";
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 = {}) {
@@ -349,6 +349,71 @@ function githubScheduledBuilds(read) {
349
349
  } };
350
350
  }
351
351
  //#endregion
352
+ //#region src/tasks.ts
353
+ /** GitHub's own pages for an API address: `github.com` for its API, the host itself for GitHub Enterprise's `/api/v3`. */
354
+ function webBase(apiUrl) {
355
+ const url = new URL(apiUrl);
356
+ if (url.hostname === "api.github.com") return "https://github.com";
357
+ return `${url.origin}${url.pathname.replace(/\/api\/v3\/?$/, "")}`.replace(/\/+$/, "");
358
+ }
359
+ /**
360
+ * Plugins' tasks on GitHub, through the starters' `plugin-task.yml`: a
361
+ * `repository_dispatch` event starts it, which editors' tokens can send with
362
+ * the Contents permission they publish with, and its `answer` job leaves the
363
+ * sealed answer on a branch of its own, `goodfellow-task-<request>`, which is
364
+ * read here and deleted. No Actions permission is needed.
365
+ */
366
+ function githubTasks(api, repo, branch, options = {}) {
367
+ const wait = options.wait ?? ((ms) => new Promise((done) => setTimeout(done, ms)));
368
+ const contents = (path, ref) => githubRequest(api, `/repos/${repo}/contents/${path}?ref=${encodeURIComponent(ref)}`, { allow: [404] });
369
+ const supported = async () => (await contents(`.github/workflows/${TASK_WORKFLOW}`, branch)).ok;
370
+ return {
371
+ supported,
372
+ secretsPlace: async () => ({
373
+ kind: "github",
374
+ url: `${webBase(api.apiUrl)}/${repo}/settings/secrets/actions/new`
375
+ }),
376
+ async run(request) {
377
+ const problem = taskRequestProblem(request);
378
+ if (problem) throw new PluginTaskError("failed", problem);
379
+ if (!await supported()) throw new PluginTaskError("missing", "The site's setup files don't run plugins' tasks: it needs plugin-task.yml.");
380
+ try {
381
+ await githubRequest(api, `/repos/${repo}/dispatches`, {
382
+ method: "POST",
383
+ body: {
384
+ event_type: TASK_EVENT,
385
+ client_payload: {
386
+ request: request.id,
387
+ plugin: request.plugin,
388
+ task: request.task,
389
+ input: request.input,
390
+ key: request.key
391
+ }
392
+ }
393
+ });
394
+ } catch (error) {
395
+ if (error instanceof GitApiError && (error.status === 403 || error.status === 404)) throw new PluginTaskError("not-allowed", "Running plugins' tasks needs a sign-in that can publish to the site.");
396
+ throw error;
397
+ }
398
+ const answerBranch = `${TASK_BRANCH_PREFIX}${request.id}`;
399
+ const deadline = Date.now() + (options.timeoutMs ?? TASK_TIMEOUT_MS);
400
+ for (;;) {
401
+ const found = await contents(TASK_RESULT_FILE, answerBranch);
402
+ if (found.ok) {
403
+ const { content = "" } = await found.json();
404
+ await githubRequest(api, `/repos/${repo}/git/refs/heads/${encodeURIComponent(answerBranch)}`, {
405
+ method: "DELETE",
406
+ allow: [404, 422]
407
+ }).catch(() => {});
408
+ return new TextDecoder().decode(decodeBase64(content.replace(/\s/g, "")));
409
+ }
410
+ if (Date.now() >= deadline) throw new PluginTaskError("timeout", "The site's build service didn't answer in time.");
411
+ await wait(options.pollMs ?? TASK_POLL_MS);
412
+ }
413
+ }
414
+ };
415
+ }
416
+ //#endregion
352
417
  //#region src/updates.ts
353
418
  /** The starters' GitHub Pages workflow, which runs `goodfellow update` each night and when it's dispatched. */
354
419
  const WORKFLOW = "deploy.yml";
@@ -581,6 +646,7 @@ var GitHubBackend = class GitHubBackend {
581
646
  updates;
582
647
  drafts;
583
648
  scheduledBuilds;
649
+ tasks;
584
650
  contentOnly;
585
651
  options;
586
652
  api;
@@ -626,6 +692,7 @@ var GitHubBackend = class GitHubBackend {
626
692
  });
627
693
  this.drafts = branchDrafts(this, this.draftHost());
628
694
  this.scheduledBuilds = githubScheduledBuilds((path) => this.read(path));
695
+ this.tasks = githubTasks(this.api, this.repo, this.branch, options.tasks);
629
696
  }
630
697
  async repositoryVisibility() {
631
698
  const found = await githubJson(this.api, `/repos/${this.repo}`);
@@ -636,6 +703,14 @@ var GitHubBackend = class GitHubBackend {
636
703
  draftHost() {
637
704
  const base = `/repos/${this.repo}`;
638
705
  const ref = (branch) => `${base}/git/refs/heads/${encodeURIComponent(branch)}`;
706
+ const openPulls = (branch) => {
707
+ const owner = decodeURIComponent(this.repo).split("/")[0];
708
+ const query = new URLSearchParams({
709
+ head: `${owner}:${branch}`,
710
+ state: "open"
711
+ });
712
+ return githubJson(this.api, `${base}/pulls?${query}`);
713
+ };
639
714
  return {
640
715
  liveBranch: this.branch,
641
716
  branchStore: (branch) => new GitHubBackend({
@@ -666,7 +741,7 @@ var GitHubBackend = class GitHubBackend {
666
741
  listBranches: async (prefix) => {
667
742
  return (await githubJson(this.api, `${base}/git/matching-refs/heads/${encodeURIComponent(prefix)}`)).map((found) => found.ref.replace(/^refs\/heads\//, ""));
668
743
  },
669
- openReview: async ({ branch, title, description }) => {
744
+ openReview: async ({ branch, title, description, reviewers = [] }) => {
670
745
  const response = await githubRequest(this.api, `${base}/pulls`, {
671
746
  method: "POST",
672
747
  body: {
@@ -681,16 +756,46 @@ var GitHubBackend = class GitHubBackend {
681
756
  422
682
757
  ]
683
758
  });
684
- if (response.ok) return { url: (await response.json()).html_url };
685
- if (response.status !== 422) throw new DraftError("no-reviews", "This sign-in can't open pull requests on the site's repository.");
686
- const owner = decodeURIComponent(this.repo).split("/")[0];
687
- const query = new URLSearchParams({
688
- head: `${owner}:${branch}`,
689
- state: "open"
759
+ let review;
760
+ if (response.ok) review = await response.json();
761
+ else if (response.status !== 422) throw new DraftError("no-reviews", "This sign-in can't open pull requests on the site's repository.");
762
+ else {
763
+ [review] = await openPulls(branch);
764
+ if (!review) throw new GitApiError(422, "GitHub couldn't open the pull request.");
765
+ }
766
+ for (const reviewer of reviewers) await githubRequest(this.api, `${base}/pulls/${review.number}/requested_reviewers`, {
767
+ method: "POST",
768
+ body: { reviewers: [reviewer] },
769
+ allow: [
770
+ 403,
771
+ 404,
772
+ 422
773
+ ]
774
+ });
775
+ return { url: review.html_url };
776
+ },
777
+ listReviews: async (prefix) => {
778
+ const pulls = await githubJson(this.api, `${base}/pulls?${new URLSearchParams({
779
+ state: "open",
780
+ per_page: "100"
781
+ })}`);
782
+ const repo = decodeURIComponent(this.repo).toLowerCase();
783
+ return pulls.filter((pull) => pull.head.ref.startsWith(prefix) && (pull.head.repo?.full_name ?? repo).toLowerCase() === repo).map((pull) => ({
784
+ branch: pull.head.ref,
785
+ url: pull.html_url,
786
+ title: pull.title
787
+ }));
788
+ },
789
+ closeReview: async (branch) => {
790
+ for (const pull of await openPulls(branch)) await githubRequest(this.api, `${base}/pulls/${pull.number}`, {
791
+ method: "PATCH",
792
+ body: { state: "closed" },
793
+ allow: [
794
+ 403,
795
+ 404,
796
+ 422
797
+ ]
690
798
  });
691
- const [open] = await githubJson(this.api, `${base}/pulls?${query}`);
692
- if (!open) throw new GitApiError(422, "GitHub couldn't open the pull request.");
693
- return { url: open.html_url };
694
799
  }
695
800
  };
696
801
  }
@@ -1753,7 +1858,7 @@ function github(options) {
1753
1858
  const links = githubTokenLinks(options.repo, options.webUrl);
1754
1859
  const storage = credentialStorage(`goodfellow:github:${options.repo}`, options.storage);
1755
1860
  const pending = credentialStorage(`goodfellow:github:pending:${options.repo}`, options.storage);
1756
- const fetcher = options.fetch ?? ((input, init) => globalThis.fetch(input, init));
1861
+ const fetcher = withRateLimits(options.fetch ?? ((input, init) => globalThis.fetch(input, init)), { host: "GitHub" });
1757
1862
  const location = options.location ?? browserLocation();
1758
1863
  const signInUrl = options.signIn;
1759
1864
  const service = signInUrl && location && !isLocalAddress(location.href()) ? new SignInService(signInUrl, fetcher) : void 0;
package/dist/testing.d.ts CHANGED
@@ -103,6 +103,21 @@ export interface FakeGitHubOptions {
103
103
  apps?: FakeGitHubApp[];
104
104
  /** Whether the site's repository is private. Public by default. */
105
105
  private?: boolean;
106
+ /**
107
+ * Answers plugins' tasks, as the site's `plugin-task.yml` would: given each
108
+ * `repository_dispatch` event's payload, it gives the text the workflow's
109
+ * `answer` job leaves on the request's branch, or nothing for a workflow
110
+ * that never answers. Without it, events are only recorded.
111
+ */
112
+ tasks?: (payload: FakeTaskPayload) => string | undefined | Promise<string | undefined>;
113
+ }
114
+ /** A plugin task's `repository_dispatch` payload, as the admin panel sends it. */
115
+ export interface FakeTaskPayload {
116
+ request: string;
117
+ plugin: string;
118
+ task: string;
119
+ input: string;
120
+ key: string;
106
121
  }
107
122
  /** A GitHub App, as the sign-in worker uses one: its JWTs, installations, OAuth sign-in and tokens. */
108
123
  export interface FakeGitHubApp {
@@ -208,6 +223,11 @@ export declare function fakeGitHub(options: FakeGitHubOptions): {
208
223
  failCloudflare: (sha: string) => void;
209
224
  /** GitHub Actions workflow runs, which tests add with `failBuild()` and `cancelBuild()`. */
210
225
  runs: FakeRun[];
226
+ /** `repository_dispatch` events sent, such as plugins' tasks. */
227
+ dispatches: {
228
+ event_type: string;
229
+ client_payload: FakeTaskPayload;
230
+ }[];
211
231
  /** The repository's Actions variables, such as `GOODFELLOW_NIGHTLY`. */
212
232
  variables: Map<string, string>;
213
233
  failBuild: (sha: string, step?: string, job?: string) => FakeRun;
package/dist/testing.js CHANGED
@@ -55,6 +55,8 @@ function fakeGitHub(options) {
55
55
  const deployments = [];
56
56
  const cloudflareBuilds = /* @__PURE__ */ new Map();
57
57
  const runs = [];
58
+ /** `repository_dispatch` events, in the order they were sent. */
59
+ const dispatches = [];
58
60
  /** The repository's Actions variables, by name. */
59
61
  const variables = /* @__PURE__ */ new Map();
60
62
  const requests = [];
@@ -811,7 +813,9 @@ function fakeGitHub(options) {
811
813
  const contents = rest.match(/^\/contents\/(.+)$/);
812
814
  if (contents && request.method === "GET") {
813
815
  if (user.bot && !user.permissions?.contents) return json({ message: "Resource not accessible by integration" }, 403);
814
- const file = repo.files(url.searchParams.get("ref") ?? repo.defaultBranch).get(decodeURIComponent(contents[1] ?? ""));
816
+ const ref = url.searchParams.get("ref") ?? repo.defaultBranch;
817
+ if (!repo.branches.has(ref) && !repo.commitAt(ref)) return json({ message: `No commit found for the ref ${ref}` }, 404);
818
+ const file = repo.files(ref).get(decodeURIComponent(contents[1] ?? ""));
815
819
  if (file === void 0) return json({ message: "Not Found" }, 404);
816
820
  return request.headers.get("accept")?.includes("raw") ? new Response(fakeFileBytes(file)) : json({
817
821
  content: encodeBase64Bytes(fakeFileBytes(file)),
@@ -866,9 +870,33 @@ function fakeGitHub(options) {
866
870
  const head = url.searchParams.get("head")?.split(":")[1];
867
871
  return json(repo.reviews.filter((review) => review.state === "open" && (!head || review.branch === head)).map((review) => ({
868
872
  number: review.number,
869
- html_url: `https://github.com/${options.repo}/pull/${review.number}`
873
+ title: review.title,
874
+ html_url: `https://github.com/${options.repo}/pull/${review.number}`,
875
+ head: {
876
+ ref: review.branch,
877
+ repo: { full_name: options.repo }
878
+ }
870
879
  })));
871
880
  }
881
+ const pull = rest.match(/^\/pulls\/(\d+)(\/requested_reviewers)?$/);
882
+ if (pull) {
883
+ const review = repo.reviews.find((found) => found.number === Number(pull[1]));
884
+ if (!review) return json({ message: "Not Found" }, 404);
885
+ if (user.bot && user.permissions?.pull_requests !== "write") return json({ message: "Resource not accessible by integration" }, 403);
886
+ if (pull[2] && request.method === "POST") {
887
+ const body = await request.json();
888
+ if ((body.reviewers ?? []).some((login) => login === user.login || login.startsWith("not-"))) return json({ message: "Reviews may only be requested from collaborators." }, 422);
889
+ review.reviewers.push(...body.reviewers ?? []);
890
+ return json({ number: review.number }, 201);
891
+ }
892
+ if (!pull[2] && request.method === "PATCH") {
893
+ if ((await request.json()).state === "closed") review.state = "closed";
894
+ return json({
895
+ number: review.number,
896
+ state: review.state
897
+ });
898
+ }
899
+ }
872
900
  if (rest === "/pages" && request.method === "GET") {
873
901
  const site = pages.current;
874
902
  if (!site) return json({ message: "Not Found" }, 404);
@@ -985,6 +1013,17 @@ function fakeGitHub(options) {
985
1013
  return new Response(null, { status: 204 });
986
1014
  }
987
1015
  }
1016
+ if (rest === "/dispatches" && request.method === "POST") {
1017
+ if (user.push === false || user.bot && user.permissions?.contents !== "write") return json({ message: "Resource not accessible by integration" }, 403);
1018
+ const body = await request.json();
1019
+ dispatches.push(body);
1020
+ const answer = await options.tasks?.(body.client_payload);
1021
+ if (answer !== void 0 && body.event_type === "goodfellow-plugin-task") {
1022
+ const sha = repo.addCommit(void 0, `The answer to plugin task ${body.client_payload.request}`, /* @__PURE__ */ new Map([["goodfellow-task-result.json", `${answer}\n`]]));
1023
+ repo.branches.set(`goodfellow-task-${body.client_payload.request}`, sha);
1024
+ }
1025
+ return new Response(null, { status: 204 });
1026
+ }
988
1027
  const workflowFile = rest.match(/^\/actions\/workflows\/([\w.-]+)\/(?:dispatches|runs)$/)?.[1];
989
1028
  const workflowPath = `.github/workflows/${workflowFile}`;
990
1029
  const ofWorkflow = (run) => (run.path ?? ".github/workflows/deploy.yml") === workflowPath;
@@ -1141,6 +1180,8 @@ function fakeGitHub(options) {
1141
1180
  failCloudflare,
1142
1181
  /** GitHub Actions workflow runs, which tests add with `failBuild()` and `cancelBuild()`. */
1143
1182
  runs,
1183
+ /** `repository_dispatch` events sent, such as plugins' tasks. */
1184
+ dispatches,
1144
1185
  /** The repository's Actions variables, such as `GOODFELLOW_NIGHTLY`. */
1145
1186
  variables,
1146
1187
  failBuild,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodfellow-cms/github",
3
- "version": "0.5.0",
3
+ "version": "0.7.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.5.0",
38
+ "@goodfellow-cms/core": "0.7.0",
39
39
  "@noble/ciphers": "2.4.0",
40
40
  "@noble/hashes": "2.4.0"
41
41
  },