@goodfellow-cms/gitlab 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { ConnectDomainOptions, DeployStatus, DnsRecord, FileChange, FileVersion, GitBackend, GitHost, GitUser, HistoryOptions, PagesDomains, SetupHost, SiteEditors, SiteUpdates, StorageLike, WriteOptions } from "@goodfellow-cms/core";
1
+ import { ConnectDomainOptions, DeployStatus, DnsRecord, FileChange, FileVersion, GitBackend, GitHost, GitUser, HistoryOptions, PagesDomains, SetupHost, SiteDrafts, SiteEditors, SiteUpdates, StorageLike, WriteOptions } from "@goodfellow-cms/core";
2
2
  //#region src/api.d.ts
3
3
  interface ApiOptions {
4
4
  /** The API root, such as `https://gitlab.com/api/v4`. */
@@ -19,6 +19,20 @@ interface GitLabBackendOptions {
19
19
  branch: string;
20
20
  user: GitUser;
21
21
  onSignOut: () => void;
22
+ /** Who commits name as their author, where that isn't the token's owner, as on a full-stack site's server. */
23
+ author?: {
24
+ name: string;
25
+ email: string;
26
+ };
27
+ /** The commit reads see until `revision()` is next called, as a full-stack site's server reads what a browser loaded. */
28
+ revision?: string;
29
+ /** The project holds only the site's content, with its code in a project of its own (`GitBackend.contentOnly`). */
30
+ contentOnly?: boolean;
31
+ /** The code project, for a project that holds only content, where the site's updates run. */
32
+ code?: {
33
+ project: string;
34
+ webUrl: string;
35
+ };
22
36
  }
23
37
  /**
24
38
  * The site's files on GitLab. Reads come from the commit `revision()` last
@@ -29,7 +43,11 @@ export declare class GitLabBackend implements GitBackend {
29
43
  readonly user: GitUser;
30
44
  readonly pages: PagesDomains;
31
45
  readonly editors: SiteEditors;
32
- readonly updates: SiteUpdates;
46
+ /** Absent where the project holds only content: the update job runs where the code is. */
47
+ readonly updates?: SiteUpdates;
48
+ readonly drafts: SiteDrafts;
49
+ readonly contentOnly: boolean;
50
+ private readonly options;
33
51
  private readonly api;
34
52
  private readonly project;
35
53
  private readonly branch;
@@ -38,6 +56,8 @@ export declare class GitLabBackend implements GitBackend {
38
56
  private readonly trees;
39
57
  private readonly blobs;
40
58
  constructor(options: GitLabBackendOptions);
59
+ /** Drafts on branches of the project, with merge requests for review. */
60
+ private draftHost;
41
61
  private repo;
42
62
  revision(): Promise<string>;
43
63
  /** Every file in one folder at a commit, page by page. */
@@ -46,8 +66,12 @@ export declare class GitLabBackend implements GitBackend {
46
66
  private tree;
47
67
  private currentTree;
48
68
  read(path: string): Promise<string | undefined>;
69
+ /** Reads many text files in a few GraphQL requests rather than one request each. */
70
+ readFiles(paths: string[]): Promise<Map<string, string>>;
49
71
  readBytes(path: string): Promise<Uint8Array | undefined>;
50
72
  list(dir: string): Promise<string[]>;
73
+ /** GitLab's tree doesn't say how large files are, so they're asked for with GraphQL, in batches. */
74
+ fileSizes(dir: string): Promise<Map<string, number>>;
51
75
  private lastCommitFor;
52
76
  write(changes: FileChange[], { message, expectedRevision }: WriteOptions): Promise<{
53
77
  revision: string;
@@ -122,6 +146,17 @@ export interface GitLabOptions {
122
146
  * one-click "Sign in with GitLab". Without it, people sign in with a token.
123
147
  */
124
148
  clientId?: string;
149
+ /**
150
+ * The project holds only the site's content (`content/` and
151
+ * `public/media/`), and its code is in a project of its own. The admin
152
+ * panel then leaves adding blocks, which changes code, to a developer.
153
+ */
154
+ contentOnly?: boolean;
155
+ /**
156
+ * With `contentOnly`, the project the site's code is kept in, where its
157
+ * updates run: the Updates screen follows them, for people who can see it.
158
+ */
159
+ code?: string;
125
160
  /** For tests. */
126
161
  fetch?: typeof fetch;
127
162
  /** For tests. */
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { ConflictError, DomainError, EDITABLE_FOLDERS, EditorsError, GitApiError, SetupError, SignInError, UpdatesError, buildStepFor, credentialStorage, encodeBase64Bytes, findBuildCause, findUpdateResult } from "@goodfellow-cms/core";
1
+ import { ConflictError, DomainError, DraftError, EDITABLE_FOLDERS, EditorsError, GitApiError, REBUILD_JOB, SetupError, SignInError, UpdatesError, assertContentChanges, branchDrafts, buildStepFor, credentialStorage, encodeBase64Bytes, findBuildCause, findUpdateResult } from "@goodfellow-cms/core";
2
2
  //#region src/api.ts
3
3
  /** Calls the GitLab API. A 401 refreshes the token once; after that it means the sign-in has expired. */
4
4
  async function gitlabRequest(api, path, init = {}) {
@@ -343,7 +343,7 @@ function notAllowed(error) {
343
343
  * It publishes with the job's own token, which GitLab only allows once the
344
344
  * project lets job tokens push.
345
345
  */
346
- function gitlabUpdates(api, project, branch, readAt) {
346
+ function gitlabUpdates(api, project, branch, readAt, jobName = "pages") {
347
347
  const base = `/projects/${encodeURIComponent(project)}`;
348
348
  async function schedules() {
349
349
  return gitlabJson(api, `${base}/pipeline_schedules`);
@@ -397,7 +397,7 @@ function gitlabUpdates(api, project, branch, readAt) {
397
397
  state: "running",
398
398
  ...run
399
399
  };
400
- const job = (await gitlabJson(api, `${base}/pipelines/${pipeline.id}/jobs?per_page=100`)).find((candidate) => candidate.name === "pages");
400
+ const job = (await gitlabJson(api, `${base}/pipelines/${pipeline.id}/jobs?per_page=100`)).find((candidate) => candidate.name === jobName);
401
401
  if (!job) continue;
402
402
  const response = await gitlabRequest(api, `${base}/jobs/${job.id}/trace`, { allow: [403, 404] });
403
403
  const result = response.ok ? findUpdateResult(await response.text()) : void 0;
@@ -424,9 +424,69 @@ function gitlabUpdates(api, project, branch, readAt) {
424
424
  }
425
425
  };
426
426
  }
427
+ function noAccess(error) {
428
+ return error instanceof GitApiError && (error.status === 403 || error.status === 404);
429
+ }
430
+ /**
431
+ * The nightly update of a site whose content is kept in a project of its own,
432
+ * which runs in the code project's `update` job. The sign-in needs to see the
433
+ * code project too; where it can't, `setup()` says `no-access`.
434
+ */
435
+ function gitlabCodeUpdates(options) {
436
+ const { api, project } = options;
437
+ let branch;
438
+ const liveBranch = () => {
439
+ branch ??= gitlabJson(api, `/projects/${encodeURIComponent(project)}`).then((found) => found.default_branch);
440
+ branch.catch(() => {
441
+ branch = void 0;
442
+ });
443
+ return branch;
444
+ };
445
+ /** The code project at its newest commit, read again each time, since updates change it. */
446
+ const files = async () => options.open(await liveBranch());
447
+ /** A file as it is now: its setup files are outside the folders backends list, so they're read at the newest commit. */
448
+ const readNow = async (path) => {
449
+ const backend = await files();
450
+ return backend.readAt(path, await backend.revision());
451
+ };
452
+ const updates = async () => gitlabUpdates(api, project, await liveBranch(), readNow, "update");
453
+ return {
454
+ code: {
455
+ repo: project,
456
+ webUrl: `${options.webUrl}/${project}`,
457
+ read: readNow,
458
+ history: async (path, history) => (await files()).history(path, history),
459
+ readAt: async (path, revision) => (await files()).readAt(path, revision)
460
+ },
461
+ async setup() {
462
+ try {
463
+ return await (await updates()).setup();
464
+ } catch (error) {
465
+ if (noAccess(error)) return "no-access";
466
+ throw error;
467
+ }
468
+ },
469
+ async enable() {
470
+ await (await updates()).enable?.();
471
+ },
472
+ async lastRun() {
473
+ try {
474
+ return await (await updates()).lastRun();
475
+ } catch (error) {
476
+ if (noAccess(error)) return void 0;
477
+ throw error;
478
+ }
479
+ },
480
+ async start(target) {
481
+ await (await updates()).start(target);
482
+ }
483
+ };
484
+ }
427
485
  //#endregion
428
486
  //#region src/backend.ts
429
487
  const PAGE_SIZE = 100;
488
+ /** How many files one GraphQL request reads. */
489
+ const GRAPHQL_BATCH = 50;
430
490
  /** The color and section codes in GitLab's job logs. */
431
491
  const ANSI_CODES = new RegExp(`${String.fromCharCode(27)}\\[[\\d;]*[A-Za-z]`, "g");
432
492
  /**
@@ -434,11 +494,15 @@ const ANSI_CODES = new RegExp(`${String.fromCharCode(27)}\\[[\\d;]*[A-Za-z]`, "g
434
494
  * returned. Saves are single commits; each changed file carries the commit it
435
495
  * was last changed in, so GitLab refuses to overwrite anyone else's edits.
436
496
  */
437
- var GitLabBackend = class {
497
+ var GitLabBackend = class GitLabBackend {
438
498
  user;
439
499
  pages;
440
500
  editors;
501
+ /** Absent where the project holds only content: the update job runs where the code is. */
441
502
  updates;
503
+ drafts;
504
+ contentOnly;
505
+ options;
442
506
  api;
443
507
  project;
444
508
  branch;
@@ -447,14 +511,93 @@ var GitLabBackend = class {
447
511
  trees = /* @__PURE__ */ new Map();
448
512
  blobs = /* @__PURE__ */ new Map();
449
513
  constructor(options) {
514
+ this.options = options;
450
515
  this.api = options.api;
451
516
  this.project = encodeURIComponent(options.project);
452
517
  this.branch = options.branch;
453
518
  this.user = options.user;
454
519
  this.onSignOut = options.onSignOut;
520
+ this.pinned = options.revision;
455
521
  this.pages = gitlabPages(this.api, options.project);
456
522
  this.editors = gitlabEditors(this.api, options.project, this.branch, this.user.login);
457
- this.updates = gitlabUpdates(this.api, options.project, this.branch, async (path) => this.readAt(path, this.pinned ?? await this.revision()));
523
+ this.contentOnly = options.contentOnly === true;
524
+ if (!this.contentOnly) this.updates = gitlabUpdates(this.api, options.project, this.branch, async (path) => this.readAt(path, this.pinned ?? await this.revision()));
525
+ else if (options.code) {
526
+ const code = options.code;
527
+ this.updates = gitlabCodeUpdates({
528
+ api: this.api,
529
+ project: code.project,
530
+ webUrl: code.webUrl,
531
+ open: (branch) => new GitLabBackend({
532
+ api: this.api,
533
+ project: code.project,
534
+ branch,
535
+ user: this.user,
536
+ onSignOut: () => {}
537
+ })
538
+ });
539
+ }
540
+ this.drafts = branchDrafts(this, this.draftHost());
541
+ }
542
+ /** Drafts on branches of the project, with merge requests for review. */
543
+ draftHost() {
544
+ const project = `/projects/${this.project}`;
545
+ return {
546
+ liveBranch: this.branch,
547
+ branchStore: (branch) => new GitLabBackend({
548
+ ...this.options,
549
+ branch
550
+ }),
551
+ createBranch: async (branch, revision) => {
552
+ if ((await gitlabRequest(this.api, this.repo(`/branches?${new URLSearchParams({
553
+ branch,
554
+ ref: revision
555
+ })}`), {
556
+ method: "POST",
557
+ allow: [400]
558
+ })).status === 400) throw new DraftError("exists", `There's already a draft called ${branch}.`);
559
+ },
560
+ deleteBranch: async (branch) => {
561
+ await gitlabRequest(this.api, this.repo(`/branches/${encodeURIComponent(branch)}`), {
562
+ method: "DELETE",
563
+ allow: [404]
564
+ });
565
+ },
566
+ mergeBase: async (branch) => {
567
+ const query = new URLSearchParams();
568
+ query.append("refs[]", this.branch);
569
+ query.append("refs[]", branch);
570
+ const response = await gitlabRequest(this.api, this.repo(`/merge_base?${query}`), { allow: [400, 404] });
571
+ if (!response.ok) throw new DraftError("not-found", `There's no draft ${branch}.`);
572
+ return (await response.json()).id;
573
+ },
574
+ openReview: async ({ branch, title, description }) => {
575
+ const response = await gitlabRequest(this.api, `${project}/merge_requests`, {
576
+ method: "POST",
577
+ body: {
578
+ source_branch: branch,
579
+ target_branch: this.branch,
580
+ title,
581
+ description,
582
+ remove_source_branch: true
583
+ },
584
+ allow: [
585
+ 403,
586
+ 404,
587
+ 409
588
+ ]
589
+ });
590
+ if (response.ok) return { url: (await response.json()).web_url };
591
+ if (response.status !== 409) throw new DraftError("no-reviews", "This sign-in can't open merge requests on the site's project.");
592
+ const query = new URLSearchParams({
593
+ source_branch: branch,
594
+ state: "opened"
595
+ });
596
+ const [open] = await gitlabJson(this.api, `${project}/merge_requests?${query}`);
597
+ if (!open) throw new GitApiError(409, "GitLab couldn't open the merge request.");
598
+ return { url: open.web_url };
599
+ }
600
+ };
458
601
  }
459
602
  repo(path) {
460
603
  return `/projects/${this.project}/repository${path}`;
@@ -506,6 +649,39 @@ var GitLabBackend = class {
506
649
  }
507
650
  return blob;
508
651
  }
652
+ /** Reads many text files in a few GraphQL requests rather than one request each. */
653
+ async readFiles(paths) {
654
+ const tree = await this.currentTree();
655
+ const wanted = paths.filter((path) => tree.has(path));
656
+ const fetch = wanted.filter((path) => !this.blobs.has(tree.get(path) ?? ""));
657
+ const ref = this.pinned ?? await this.revision();
658
+ for (let start = 0; start < fetch.length; start += GRAPHQL_BATCH) {
659
+ const batch = fetch.slice(start, start + GRAPHQL_BATCH);
660
+ const response = await gitlabJson(this.api, "/../graphql", {
661
+ method: "POST",
662
+ body: {
663
+ query: `query ($project: ID!, $ref: String!, $paths: [String!]!) {
664
+ project(fullPath: $project) { repository { blobs(ref: $ref, paths: $paths) { nodes { path rawTextBlob } } } }
665
+ }`,
666
+ variables: {
667
+ project: decodeURIComponent(this.project),
668
+ ref,
669
+ paths: batch
670
+ }
671
+ }
672
+ });
673
+ for (const node of response.data?.project?.repository?.blobs?.nodes ?? []) {
674
+ const sha = tree.get(node.path);
675
+ if (sha && typeof node.rawTextBlob === "string") this.blobs.set(sha, Promise.resolve(node.rawTextBlob));
676
+ }
677
+ }
678
+ const result = /* @__PURE__ */ new Map();
679
+ for (const path of wanted) {
680
+ const text = await this.read(path);
681
+ if (text !== void 0) result.set(path, text);
682
+ }
683
+ return result;
684
+ }
509
685
  async readBytes(path) {
510
686
  const sha = (await this.currentTree()).get(path);
511
687
  if (!sha) return void 0;
@@ -516,10 +692,37 @@ var GitLabBackend = class {
516
692
  const prefix = `${dir}/`;
517
693
  return [...(await this.currentTree()).keys()].filter((path) => path.startsWith(prefix)).sort();
518
694
  }
695
+ /** GitLab's tree doesn't say how large files are, so they're asked for with GraphQL, in batches. */
696
+ async fileSizes(dir) {
697
+ const paths = await this.list(dir);
698
+ const ref = this.pinned ?? await this.revision();
699
+ const sizes = /* @__PURE__ */ new Map();
700
+ for (let start = 0; start < paths.length; start += GRAPHQL_BATCH) {
701
+ const response = await gitlabJson(this.api, "/../graphql", {
702
+ method: "POST",
703
+ body: {
704
+ query: `query ($project: ID!, $ref: String!, $paths: [String!]!) {
705
+ project(fullPath: $project) { repository { blobs(ref: $ref, paths: $paths) { nodes { path size } } } }
706
+ }`,
707
+ variables: {
708
+ project: decodeURIComponent(this.project),
709
+ ref,
710
+ paths: paths.slice(start, start + GRAPHQL_BATCH)
711
+ }
712
+ }
713
+ });
714
+ for (const node of response.data?.project?.repository?.blobs?.nodes ?? []) {
715
+ const size = Number(node.size);
716
+ if (Number.isFinite(size)) sizes.set(node.path, size);
717
+ }
718
+ }
719
+ return sizes;
720
+ }
519
721
  async lastCommitFor(path, ref) {
520
722
  return (await gitlabJson(this.api, this.repo(`/files/${encodeURIComponent(path)}?ref=${ref}`))).last_commit_id;
521
723
  }
522
724
  async write(changes, { message, expectedRevision }) {
725
+ if (this.contentOnly) assertContentChanges(changes);
523
726
  if (await this.revision() !== expectedRevision) throw new ConflictError();
524
727
  const existing = await this.tree(expectedRevision);
525
728
  const toApply = (await Promise.all(changes.map(async (change) => {
@@ -551,7 +754,11 @@ var GitLabBackend = class {
551
754
  body: {
552
755
  branch: this.branch,
553
756
  commit_message: message,
554
- actions: toApply
757
+ actions: toApply,
758
+ ...this.options.author && {
759
+ author_name: this.options.author.name,
760
+ author_email: this.options.author.email
761
+ }
555
762
  }
556
763
  });
557
764
  this.pinned = commit.id;
@@ -595,7 +802,9 @@ var GitLabBackend = class {
595
802
  */
596
803
  async deployStatus(revision) {
597
804
  try {
598
- const statuses = await gitlabJson(this.api, this.repo(`/commits/${revision}/statuses`));
805
+ const all = await gitlabJson(this.api, this.repo(`/commits/${revision}/statuses`));
806
+ const statuses = all.filter((status) => status.name !== REBUILD_JOB);
807
+ if (all.length > 0 && statuses.length === 0) return { state: "unknown" };
599
808
  if (statuses.length === 0) return { state: (await gitlabJson(this.api, `/projects/${this.project}/pipelines?per_page=1`)).length > 0 ? "building" : "unknown" };
600
809
  const failed = statuses.find((status) => status.status === "failed" || status.status === "canceled");
601
810
  if (failed) {
@@ -738,6 +947,46 @@ function gitlabSetup(options = {}) {
738
947
  async function isAvailable(owner, name) {
739
948
  return (await gitlabRequest(api, `/projects/${encodeURIComponent(`${owner.path}/${name}`)}`, { allow: [404] })).status === 404;
740
949
  }
950
+ /**
951
+ * Lets each project's pipelines reach the other, as their setup files say:
952
+ * a job token is refused by a project whose job token allowlist doesn't
953
+ * name its project, even a public one. Also lets the code project's job
954
+ * token publish updates, and runs pipelines each night where they need it.
955
+ * Returns whether it worked.
956
+ */
957
+ async function connectProjects(link) {
958
+ const project = (path) => `/projects/${encodeURIComponent(path)}`;
959
+ try {
960
+ const ids = /* @__PURE__ */ new Map();
961
+ for (const which of ["code", "content"]) ids.set(which, (await gitlabJson(api, project(link[which]))).id);
962
+ for (const { reader, read } of link.links.reads) await gitlabRequest(api, `${project(link[read])}/job_token_scope/allowlist`, {
963
+ method: "POST",
964
+ body: { target_project_id: ids.get(reader) }
965
+ });
966
+ if (link.links.updates) await gitlabRequest(api, project(link[link.links.updates]), {
967
+ method: "PUT",
968
+ body: { ci_push_repository_for_job_token_allowed: true }
969
+ });
970
+ for (const which of link.links.nightly) await gitlabRequest(api, `${project(link[which])}/pipeline_schedules`, {
971
+ method: "POST",
972
+ body: {
973
+ description: which === link.links.updates ? "Rebuild the site and install Goodfellow's fixes each night" : "Rebuild the site each night",
974
+ ref: MAIN,
975
+ cron: NIGHTLY,
976
+ cron_timezone: "UTC",
977
+ active: true
978
+ }
979
+ });
980
+ return true;
981
+ } catch (error) {
982
+ if (error instanceof GitApiError && [
983
+ 400,
984
+ 403,
985
+ 404
986
+ ].includes(error.status)) return false;
987
+ throw error;
988
+ }
989
+ }
741
990
  return {
742
991
  user: {
743
992
  login: user.username,
@@ -775,6 +1024,11 @@ function gitlabSetup(options = {}) {
775
1024
  if (error instanceof GitApiError && (error.status === 403 || error.status === 404)) throw new SetupError("not-allowed", `This sign-in can't create projects in ${site.owner.path}.`);
776
1025
  throw error;
777
1026
  }
1027
+ const warnings = [];
1028
+ if (site.link) {
1029
+ onStep?.("connect");
1030
+ if (!await connectProjects(site.link)) warnings.push("connect-unavailable");
1031
+ }
778
1032
  onStep?.("files");
779
1033
  await gitlabRequest(api, `/projects/${project.id}/repository/commits`, {
780
1034
  method: "POST",
@@ -798,7 +1052,7 @@ function gitlabSetup(options = {}) {
798
1052
  webUrl: project.web_url,
799
1053
  buildsUrl: `${project.web_url}/-/pipelines`,
800
1054
  pagesUrl: site.pages ? `${project.web_url}/pages` : void 0,
801
- warnings: []
1055
+ warnings
802
1056
  };
803
1057
  }
804
1058
  };
@@ -931,6 +1185,11 @@ function gitlab(options) {
931
1185
  api,
932
1186
  project: options.project,
933
1187
  branch: options.branch ?? project.default_branch,
1188
+ ...options.contentOnly && { contentOnly: true },
1189
+ ...options.contentOnly && options.code && { code: {
1190
+ project: options.code,
1191
+ webUrl
1192
+ } },
934
1193
  user: {
935
1194
  login: user.username,
936
1195
  name: user.name,
@@ -967,9 +1226,47 @@ function gitlab(options) {
967
1226
  code_verifier: redirect.verifier
968
1227
  }), redirect.verifier), redirect.remember);
969
1228
  }
1229
+ /** The project through a token of the site's own, such as a project access token, on a full-stack site's server. */
1230
+ function server({ token }) {
1231
+ const api = {
1232
+ apiUrl,
1233
+ token: async () => typeof token === "string" ? token : token(),
1234
+ canRefresh: false,
1235
+ fetch: fetcher
1236
+ };
1237
+ let branch;
1238
+ const liveBranch = () => {
1239
+ branch ??= options.branch ? Promise.resolve(options.branch) : gitlabJson(api, `/projects/${encodeURIComponent(options.project)}`).then((project) => project.default_branch);
1240
+ branch.catch(() => {
1241
+ branch = void 0;
1242
+ });
1243
+ return branch;
1244
+ };
1245
+ const backend = async (revision, author) => new GitLabBackend({
1246
+ api,
1247
+ project: options.project,
1248
+ branch: await liveBranch(),
1249
+ ...options.contentOnly && { contentOnly: true },
1250
+ user: {
1251
+ login: author?.name ?? options.project,
1252
+ ...author && { name: author.name }
1253
+ },
1254
+ onSignOut: () => {},
1255
+ ...revision && { revision },
1256
+ ...author && { author }
1257
+ });
1258
+ return {
1259
+ ...options.contentOnly && { contentOnly: true },
1260
+ async revision() {
1261
+ return (await backend()).revision();
1262
+ },
1263
+ at: backend
1264
+ };
1265
+ }
970
1266
  return {
971
1267
  name: "GitLab",
972
1268
  canRedirect: Boolean(options.clientId),
1269
+ redirectAddressesListed: Boolean(options.clientId),
973
1270
  tokenLinks: [{
974
1271
  url: `${webUrl}/-/user_settings/personal_access_tokens?${new URLSearchParams({
975
1272
  name: "Goodfellow admin",
@@ -1019,7 +1316,8 @@ function gitlab(options) {
1019
1316
  code_challenge_method: "S256"
1020
1317
  });
1021
1318
  location().assign(`${webUrl}/oauth/authorize?${query}`);
1022
- }
1319
+ },
1320
+ server
1023
1321
  };
1024
1322
  }
1025
1323
  //#endregion
package/dist/testing.d.ts CHANGED
@@ -21,6 +21,10 @@ export interface FakeGitLabOptions {
21
21
  };
22
22
  /** Simulates a GitLab Pages pipeline that succeeds after being checked this many times. Off by default. */
23
23
  deployAfterChecks?: number;
24
+ /** The job each published commit's pipeline runs: `pages` by default. */
25
+ deployJob?: string;
26
+ /** The job the update runs in, which `addUpdateRun()` logs: `pages` by default, as in the starters, or a code project's `update`. */
27
+ updateJob?: string;
24
28
  /** Groups the users belong to, which new projects can be created in. */
25
29
  groups?: string[];
26
30
  /** Publishes the site with GitLab Pages. Off by default. */
@@ -63,8 +67,20 @@ export interface FakeCreatedProject {
63
67
  description?: string;
64
68
  pagesAccessLevel?: string;
65
69
  repo: FakeRepo;
70
+ /** The projects whose job tokens may reach this one (its job token allowlist), by id. */
71
+ jobTokenAllowlist: number[];
72
+ /** Whether its pipelines' job tokens may push to it. */
73
+ jobTokenPush: boolean;
74
+ schedules: Array<{
75
+ description: string;
76
+ ref: string;
77
+ cron: string;
78
+ active: boolean;
79
+ }>;
66
80
  }
67
81
  export declare function fakeGitLab(options: FakeGitLabOptions): {
82
+ /** How many GraphQL requests read files, as `readFiles()` makes. */
83
+ readonly graphqlReads: number;
68
84
  /** The project's members by user id, invitations by email, and protected branches, which tests can change. */
69
85
  members: Map<number, FakeMember>;
70
86
  invitations: Map<string, number>;
@@ -121,8 +137,13 @@ export declare function fakeGitLab(options: FakeGitLabOptions): {
121
137
  created: Map<string, FakeCreatedProject>;
122
138
  /** The site's GitLab Pages, if it has them, which tests can change. */
123
139
  pages: FakeGitLabPages | undefined;
140
+ /** Who commits that named their author did name, by commit. */
141
+ commitAuthors: Map<string, {
142
+ name: string;
143
+ email: string;
144
+ }>;
124
145
  requests: Request[];
125
- commit: (changes: FileChange[], message: string, author?: string) => string;
146
+ commit: (changes: FileChange[], message: string, author?: string, branch?: string) => string;
126
147
  authorize: (authorizeUrl: string) => string;
127
148
  handle: (request: Request) => Promise<Response>;
128
149
  fetch: typeof fetch;
package/dist/testing.js CHANGED
@@ -20,6 +20,8 @@ async function sha256Base64Url(text) {
20
20
  function fakeGitLab(options) {
21
21
  const webUrl = options.url ?? "https://gitlab.com";
22
22
  const repo = new FakeRepo(options.files);
23
+ /** Who each commit that named its author did name, by commit. */
24
+ const commitAuthors = /* @__PURE__ */ new Map();
23
25
  const tokens = new Map(Object.entries(options.tokens ?? { "test-token": {
24
26
  username: "editor",
25
27
  name: "Test Editor"
@@ -187,6 +189,7 @@ function fakeGitLab(options) {
187
189
  const pipelineId = (sha) => [...pipelines.keys()].indexOf(sha) + 1;
188
190
  const requests = [];
189
191
  let counter = 0;
192
+ let graphqlReads = 0;
190
193
  const created = /* @__PURE__ */ new Map();
191
194
  const pages = options.pages ? {
192
195
  uniqueDomain: options.pages.uniqueDomain ?? true,
@@ -296,7 +299,10 @@ function fakeGitLab(options) {
296
299
  visibility: body.visibility,
297
300
  description: body.description,
298
301
  pagesAccessLevel: body.pages_access_level,
299
- repo: new FakeRepo({})
302
+ repo: new FakeRepo({}),
303
+ jobTokenAllowlist: [],
304
+ jobTokenPush: false,
305
+ schedules: []
300
306
  };
301
307
  created.set(fullPath, project);
302
308
  return json({
@@ -310,11 +316,40 @@ function fakeGitLab(options) {
310
316
  const project = created.get(key) ?? [...created.values()].find((candidate) => String(candidate.id) === key);
311
317
  if (!match || !project) return void 0;
312
318
  const rest = match[2] ?? "";
313
- if (rest === "") return json({
319
+ if (rest === "" && request.method === "GET") return json({
314
320
  id: project.id,
315
321
  path_with_namespace: project.path,
316
- default_branch: "main"
322
+ default_branch: "main",
323
+ ci_push_repository_for_job_token_allowed: project.jobTokenPush
317
324
  });
325
+ if (rest === "" && request.method === "PUT") {
326
+ const body = await request.json();
327
+ if (body.ci_push_repository_for_job_token_allowed !== void 0) project.jobTokenPush = body.ci_push_repository_for_job_token_allowed;
328
+ return json({
329
+ id: project.id,
330
+ ci_push_repository_for_job_token_allowed: project.jobTokenPush
331
+ });
332
+ }
333
+ if (rest === "/job_token_scope/allowlist" && request.method === "POST") {
334
+ const body = await request.json();
335
+ if (![...created.values()].some((candidate) => candidate.id === body.target_project_id)) return json({ message: "404 Project Not Found" }, 404);
336
+ if (!project.jobTokenAllowlist.includes(body.target_project_id)) project.jobTokenAllowlist.push(body.target_project_id);
337
+ return json({
338
+ source_project_id: project.id,
339
+ target_project_id: body.target_project_id
340
+ }, 201);
341
+ }
342
+ if (rest === "/pipeline_schedules" && request.method === "POST") {
343
+ const body = await request.json();
344
+ project.schedules.push({
345
+ ...body,
346
+ active: body.active !== false
347
+ });
348
+ return json({
349
+ id: project.schedules.length,
350
+ ...body
351
+ }, 201);
352
+ }
318
353
  if (rest === "/repository/commits" && request.method === "POST") {
319
354
  const body = await request.json();
320
355
  return json({
@@ -330,9 +365,12 @@ function fakeGitLab(options) {
330
365
  }
331
366
  return json({ message: "404 Not Found" }, 404);
332
367
  }
333
- function commit(changes, message, author) {
334
- const sha = repo.commit(changes, message, { author });
335
- if (options.deployAfterChecks !== void 0) pipelines.set(sha, { checks: 0 });
368
+ function commit(changes, message, author, branch = repo.defaultBranch) {
369
+ const sha = repo.commit(changes, message, {
370
+ author,
371
+ branch
372
+ });
373
+ if (options.deployAfterChecks !== void 0 && branch === repo.defaultBranch) pipelines.set(sha, { checks: 0 });
336
374
  return sha;
337
375
  }
338
376
  function issueTokens(user) {
@@ -390,6 +428,22 @@ function fakeGitLab(options) {
390
428
  }
391
429
  return json({ error: "unsupported_grant_type" }, 400);
392
430
  }
431
+ if (url.pathname === "/api/graphql" && request.method === "POST") {
432
+ if (!tokens.has(request.headers.get("authorization")?.replace(/^Bearer /, "") ?? "")) return json({ message: "401 Unauthorized" }, 401);
433
+ const { variables } = await request.json();
434
+ if (variables.project !== options.project) return json({ data: { project: null } });
435
+ graphqlReads += 1;
436
+ const files = repo.files(variables.ref);
437
+ return json({ data: { project: { repository: { blobs: { nodes: variables.paths.flatMap((path) => {
438
+ const content = files.get(path);
439
+ if (content === void 0) return [];
440
+ return [{
441
+ path,
442
+ rawTextBlob: typeof content === "string" ? content : null,
443
+ size: String(fakeFileBytes(content).length)
444
+ }];
445
+ }) } } } } });
446
+ }
393
447
  if (!url.pathname.startsWith("/api/v4/")) return json({ message: "404 Not Found" }, 404);
394
448
  const user = tokens.get(request.headers.get("authorization")?.replace(/^Bearer /, "") ?? "");
395
449
  if (!user) return json({ message: "401 Unauthorized" }, 401);
@@ -401,6 +455,14 @@ function fakeGitLab(options) {
401
455
  });
402
456
  const setup = await handleCreated(request, path, user);
403
457
  if (setup) return setup;
458
+ if (path === "/projects" && request.method === "GET") {
459
+ const level = members.get(accounts.get(user.username) ?? 0)?.accessLevel ?? 0;
460
+ const min = Number(url.searchParams.get("min_access_level") ?? "0");
461
+ return json(level > 0 && level >= min ? [{
462
+ path_with_namespace: options.project,
463
+ default_branch: repo.defaultBranch
464
+ }] : []);
465
+ }
404
466
  if (path === "/users") {
405
467
  const name = url.searchParams.get("username") ?? "";
406
468
  const id = [...accounts].find(([username]) => username.toLowerCase() === name.toLowerCase())?.[1];
@@ -425,11 +487,55 @@ function fakeGitLab(options) {
425
487
  },
426
488
  ci_push_repository_for_job_token_allowed: updateSettings.jobTokenPush
427
489
  });
490
+ if (rest === "/repository/branches" && request.method === "POST") {
491
+ if (accessLevel < 30) return json({ message: "403 Forbidden" }, 403);
492
+ const name = url.searchParams.get("branch") ?? "";
493
+ const ref = url.searchParams.get("ref") ?? "";
494
+ if (repo.branches.has(name)) return json({ message: "Branch already exists" }, 400);
495
+ const sha = repo.branches.get(ref) ?? (repo.commitAt(ref) ? ref : void 0);
496
+ if (!sha) return json({ message: "Invalid reference name" }, 400);
497
+ repo.branches.set(name, sha);
498
+ return json({
499
+ name,
500
+ commit: { id: sha }
501
+ }, 201);
502
+ }
428
503
  const branch = rest.match(/^\/repository\/branches\/(.+)$/);
504
+ if (branch && request.method === "DELETE") {
505
+ if (accessLevel < 30) return json({ message: "403 Forbidden" }, 403);
506
+ const name = decodeURIComponent(branch[1] ?? "");
507
+ if (!repo.branches.has(name)) return json({ message: "404 Branch Not Found" }, 404);
508
+ repo.branches.delete(name);
509
+ return new Response(null, { status: 204 });
510
+ }
429
511
  if (branch) {
430
512
  const sha = repo.branches.get(decodeURIComponent(branch[1] ?? ""));
431
513
  return sha ? json({ commit: { id: sha } }) : json({ message: "404 Branch Not Found" }, 404);
432
514
  }
515
+ if (rest === "/repository/merge_base") {
516
+ const refs = url.searchParams.getAll("refs[]").map((ref) => repo.branches.get(ref) ?? ref);
517
+ if (refs.length !== 2 || refs.some((ref) => !repo.commitAt(ref))) return json({ message: "Could not find ref" }, 400);
518
+ const base = repo.mergeBase(refs[0] ?? "", refs[1] ?? "");
519
+ return base ? json({ id: base }) : json({ message: "404 Not Found" }, 404);
520
+ }
521
+ if (rest === "/merge_requests" && request.method === "POST") {
522
+ if (accessLevel < 30) return json({ message: "403 Forbidden" }, 403);
523
+ const body = await request.json();
524
+ if (!repo.branches.has(body.source_branch) || !repo.branches.has(body.target_branch)) return json({ message: ["Source branch does not exist"] }, 422);
525
+ if (repo.reviews.some((review) => review.branch === body.source_branch && review.state === "open")) return json({ message: ["Another open merge request already exists for this source branch"] }, 409);
526
+ const review = repo.openReview(body.source_branch, body.title, body.description ?? "", body.target_branch);
527
+ return json({
528
+ iid: review.number,
529
+ web_url: `${webUrl}/${options.project}/-/merge_requests/${review.number}`
530
+ }, 201);
531
+ }
532
+ if (rest === "/merge_requests" && request.method === "GET") {
533
+ const source = url.searchParams.get("source_branch");
534
+ return json(repo.reviews.filter((review) => review.state === "open" && (!source || review.branch === source)).map((review) => ({
535
+ iid: review.number,
536
+ web_url: `${webUrl}/${options.project}/-/merge_requests/${review.number}`
537
+ })));
538
+ }
433
539
  if (rest === "/repository/tree") {
434
540
  const ref = url.searchParams.get("ref") ?? repo.defaultBranch;
435
541
  const dir = url.searchParams.get("path") ?? "";
@@ -477,17 +583,22 @@ function fakeGitLab(options) {
477
583
  if ((action.action === "update" || action.action === "delete") && !files.has(action.file_path)) return json({ message: "A file with this name doesn't exist" }, 400);
478
584
  if (action.last_commit_id && repo.lastCommitFor(action.file_path, body.branch) !== action.last_commit_id) return json({ message: "You are attempting to update a file that has changed since you started editing it." }, 400);
479
585
  }
586
+ const sha = commit(body.actions.map((action) => action.action === "delete" ? {
587
+ path: action.file_path,
588
+ delete: true
589
+ } : action.encoding === "base64" ? {
590
+ path: action.file_path,
591
+ bytes: decodeBase64(action.content ?? "")
592
+ } : {
593
+ path: action.file_path,
594
+ content: action.content ?? ""
595
+ }), body.commit_message, body.author_name ?? user.name ?? user.username, body.branch);
596
+ if (body.author_name && body.author_email) commitAuthors.set(sha, {
597
+ name: body.author_name,
598
+ email: body.author_email
599
+ });
480
600
  return json({
481
- id: commit(body.actions.map((action) => action.action === "delete" ? {
482
- path: action.file_path,
483
- delete: true
484
- } : action.encoding === "base64" ? {
485
- path: action.file_path,
486
- bytes: decodeBase64(action.content ?? "")
487
- } : {
488
- path: action.file_path,
489
- content: action.content ?? ""
490
- }), body.commit_message, user.name ?? user.username),
601
+ id: sha,
491
602
  message: body.commit_message
492
603
  }, 201);
493
604
  }
@@ -524,9 +635,10 @@ function fakeGitLab(options) {
524
635
  }]);
525
636
  }
526
637
  pipeline.checks += 1;
638
+ const done = pipeline.checks > (options.deployAfterChecks ?? 0);
527
639
  return json([{
528
- name: "pages",
529
- status: pipeline.checks > (options.deployAfterChecks ?? 0) ? "success" : "running",
640
+ name: options.deployJob ?? "pages",
641
+ status: done ? "success" : "running",
530
642
  target_url: `${webUrl}/${options.project}/-/jobs/1`
531
643
  }]);
532
644
  }
@@ -575,7 +687,7 @@ function fakeGitLab(options) {
575
687
  const run = updateRuns.find((candidate) => candidate.id === Number(updateJobs[1]));
576
688
  return run ? json([{
577
689
  id: run.id,
578
- name: "pages",
690
+ name: options.updateJob ?? "pages",
579
691
  status: run.status
580
692
  }]) : json({ message: "404 Not Found" }, 404);
581
693
  }
@@ -617,6 +729,10 @@ function fakeGitLab(options) {
617
729
  return json({ message: "404 Not Found" }, 404);
618
730
  }
619
731
  return {
732
+ /** How many GraphQL requests read files, as `readFiles()` makes. */
733
+ get graphqlReads() {
734
+ return graphqlReads;
735
+ },
620
736
  /** The project's members by user id, invitations by email, and protected branches, which tests can change. */
621
737
  members,
622
738
  invitations,
@@ -631,6 +747,8 @@ function fakeGitLab(options) {
631
747
  created,
632
748
  /** The site's GitLab Pages, if it has them, which tests can change. */
633
749
  pages,
750
+ /** Who commits that named their author did name, by commit. */
751
+ commitAuthors,
634
752
  requests,
635
753
  commit,
636
754
  authorize,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodfellow-cms/gitlab",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "Edit a Goodfellow site stored on GitLab, 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.2.0"
38
+ "@goodfellow-cms/core": "0.4.0"
39
39
  },
40
40
  "devDependencies": {
41
41
  "@puckeditor/core": "0.23.0"