@goodfellow-cms/gitlab 0.2.0 → 0.3.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`. */
@@ -30,6 +30,8 @@ export declare class GitLabBackend implements GitBackend {
30
30
  readonly pages: PagesDomains;
31
31
  readonly editors: SiteEditors;
32
32
  readonly updates: SiteUpdates;
33
+ readonly drafts: SiteDrafts;
34
+ private readonly options;
33
35
  private readonly api;
34
36
  private readonly project;
35
37
  private readonly branch;
@@ -38,6 +40,8 @@ export declare class GitLabBackend implements GitBackend {
38
40
  private readonly trees;
39
41
  private readonly blobs;
40
42
  constructor(options: GitLabBackendOptions);
43
+ /** Drafts on branches of the project, with merge requests for review. */
44
+ private draftHost;
41
45
  private repo;
42
46
  revision(): Promise<string>;
43
47
  /** Every file in one folder at a commit, page by page. */
@@ -46,8 +50,12 @@ export declare class GitLabBackend implements GitBackend {
46
50
  private tree;
47
51
  private currentTree;
48
52
  read(path: string): Promise<string | undefined>;
53
+ /** Reads many text files in a few GraphQL requests rather than one request each. */
54
+ readFiles(paths: string[]): Promise<Map<string, string>>;
49
55
  readBytes(path: string): Promise<Uint8Array | undefined>;
50
56
  list(dir: string): Promise<string[]>;
57
+ /** GitLab's tree doesn't say how large files are, so they're asked for with GraphQL, in batches. */
58
+ fileSizes(dir: string): Promise<Map<string, number>>;
51
59
  private lastCommitFor;
52
60
  write(changes: FileChange[], { message, expectedRevision }: WriteOptions): Promise<{
53
61
  revision: string;
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, SetupError, SignInError, UpdatesError, 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 = {}) {
@@ -427,6 +427,8 @@ function gitlabUpdates(api, project, branch, readAt) {
427
427
  //#endregion
428
428
  //#region src/backend.ts
429
429
  const PAGE_SIZE = 100;
430
+ /** How many files one GraphQL request reads. */
431
+ const GRAPHQL_BATCH = 50;
430
432
  /** The color and section codes in GitLab's job logs. */
431
433
  const ANSI_CODES = new RegExp(`${String.fromCharCode(27)}\\[[\\d;]*[A-Za-z]`, "g");
432
434
  /**
@@ -434,11 +436,13 @@ const ANSI_CODES = new RegExp(`${String.fromCharCode(27)}\\[[\\d;]*[A-Za-z]`, "g
434
436
  * returned. Saves are single commits; each changed file carries the commit it
435
437
  * was last changed in, so GitLab refuses to overwrite anyone else's edits.
436
438
  */
437
- var GitLabBackend = class {
439
+ var GitLabBackend = class GitLabBackend {
438
440
  user;
439
441
  pages;
440
442
  editors;
441
443
  updates;
444
+ drafts;
445
+ options;
442
446
  api;
443
447
  project;
444
448
  branch;
@@ -447,6 +451,7 @@ var GitLabBackend = class {
447
451
  trees = /* @__PURE__ */ new Map();
448
452
  blobs = /* @__PURE__ */ new Map();
449
453
  constructor(options) {
454
+ this.options = options;
450
455
  this.api = options.api;
451
456
  this.project = encodeURIComponent(options.project);
452
457
  this.branch = options.branch;
@@ -455,6 +460,67 @@ var GitLabBackend = class {
455
460
  this.pages = gitlabPages(this.api, options.project);
456
461
  this.editors = gitlabEditors(this.api, options.project, this.branch, this.user.login);
457
462
  this.updates = gitlabUpdates(this.api, options.project, this.branch, async (path) => this.readAt(path, this.pinned ?? await this.revision()));
463
+ this.drafts = branchDrafts(this, this.draftHost());
464
+ }
465
+ /** Drafts on branches of the project, with merge requests for review. */
466
+ draftHost() {
467
+ const project = `/projects/${this.project}`;
468
+ return {
469
+ liveBranch: this.branch,
470
+ branchStore: (branch) => new GitLabBackend({
471
+ ...this.options,
472
+ branch
473
+ }),
474
+ createBranch: async (branch, revision) => {
475
+ if ((await gitlabRequest(this.api, this.repo(`/branches?${new URLSearchParams({
476
+ branch,
477
+ ref: revision
478
+ })}`), {
479
+ method: "POST",
480
+ allow: [400]
481
+ })).status === 400) throw new DraftError("exists", `There's already a draft called ${branch}.`);
482
+ },
483
+ deleteBranch: async (branch) => {
484
+ await gitlabRequest(this.api, this.repo(`/branches/${encodeURIComponent(branch)}`), {
485
+ method: "DELETE",
486
+ allow: [404]
487
+ });
488
+ },
489
+ mergeBase: async (branch) => {
490
+ const query = new URLSearchParams();
491
+ query.append("refs[]", this.branch);
492
+ query.append("refs[]", branch);
493
+ const response = await gitlabRequest(this.api, this.repo(`/merge_base?${query}`), { allow: [400, 404] });
494
+ if (!response.ok) throw new DraftError("not-found", `There's no draft ${branch}.`);
495
+ return (await response.json()).id;
496
+ },
497
+ openReview: async ({ branch, title, description }) => {
498
+ const response = await gitlabRequest(this.api, `${project}/merge_requests`, {
499
+ method: "POST",
500
+ body: {
501
+ source_branch: branch,
502
+ target_branch: this.branch,
503
+ title,
504
+ description,
505
+ remove_source_branch: true
506
+ },
507
+ allow: [
508
+ 403,
509
+ 404,
510
+ 409
511
+ ]
512
+ });
513
+ if (response.ok) return { url: (await response.json()).web_url };
514
+ if (response.status !== 409) throw new DraftError("no-reviews", "This sign-in can't open merge requests on the site's project.");
515
+ const query = new URLSearchParams({
516
+ source_branch: branch,
517
+ state: "opened"
518
+ });
519
+ const [open] = await gitlabJson(this.api, `${project}/merge_requests?${query}`);
520
+ if (!open) throw new GitApiError(409, "GitLab couldn't open the merge request.");
521
+ return { url: open.web_url };
522
+ }
523
+ };
458
524
  }
459
525
  repo(path) {
460
526
  return `/projects/${this.project}/repository${path}`;
@@ -506,6 +572,39 @@ var GitLabBackend = class {
506
572
  }
507
573
  return blob;
508
574
  }
575
+ /** Reads many text files in a few GraphQL requests rather than one request each. */
576
+ async readFiles(paths) {
577
+ const tree = await this.currentTree();
578
+ const wanted = paths.filter((path) => tree.has(path));
579
+ const fetch = wanted.filter((path) => !this.blobs.has(tree.get(path) ?? ""));
580
+ const ref = this.pinned ?? await this.revision();
581
+ for (let start = 0; start < fetch.length; start += GRAPHQL_BATCH) {
582
+ const batch = fetch.slice(start, start + GRAPHQL_BATCH);
583
+ const response = await gitlabJson(this.api, "/../graphql", {
584
+ method: "POST",
585
+ body: {
586
+ query: `query ($project: ID!, $ref: String!, $paths: [String!]!) {
587
+ project(fullPath: $project) { repository { blobs(ref: $ref, paths: $paths) { nodes { path rawTextBlob } } } }
588
+ }`,
589
+ variables: {
590
+ project: decodeURIComponent(this.project),
591
+ ref,
592
+ paths: batch
593
+ }
594
+ }
595
+ });
596
+ for (const node of response.data?.project?.repository?.blobs?.nodes ?? []) {
597
+ const sha = tree.get(node.path);
598
+ if (sha && typeof node.rawTextBlob === "string") this.blobs.set(sha, Promise.resolve(node.rawTextBlob));
599
+ }
600
+ }
601
+ const result = /* @__PURE__ */ new Map();
602
+ for (const path of wanted) {
603
+ const text = await this.read(path);
604
+ if (text !== void 0) result.set(path, text);
605
+ }
606
+ return result;
607
+ }
509
608
  async readBytes(path) {
510
609
  const sha = (await this.currentTree()).get(path);
511
610
  if (!sha) return void 0;
@@ -516,6 +615,32 @@ var GitLabBackend = class {
516
615
  const prefix = `${dir}/`;
517
616
  return [...(await this.currentTree()).keys()].filter((path) => path.startsWith(prefix)).sort();
518
617
  }
618
+ /** GitLab's tree doesn't say how large files are, so they're asked for with GraphQL, in batches. */
619
+ async fileSizes(dir) {
620
+ const paths = await this.list(dir);
621
+ const ref = this.pinned ?? await this.revision();
622
+ const sizes = /* @__PURE__ */ new Map();
623
+ for (let start = 0; start < paths.length; start += GRAPHQL_BATCH) {
624
+ const response = await gitlabJson(this.api, "/../graphql", {
625
+ method: "POST",
626
+ body: {
627
+ query: `query ($project: ID!, $ref: String!, $paths: [String!]!) {
628
+ project(fullPath: $project) { repository { blobs(ref: $ref, paths: $paths) { nodes { path size } } } }
629
+ }`,
630
+ variables: {
631
+ project: decodeURIComponent(this.project),
632
+ ref,
633
+ paths: paths.slice(start, start + GRAPHQL_BATCH)
634
+ }
635
+ }
636
+ });
637
+ for (const node of response.data?.project?.repository?.blobs?.nodes ?? []) {
638
+ const size = Number(node.size);
639
+ if (Number.isFinite(size)) sizes.set(node.path, size);
640
+ }
641
+ }
642
+ return sizes;
643
+ }
519
644
  async lastCommitFor(path, ref) {
520
645
  return (await gitlabJson(this.api, this.repo(`/files/${encodeURIComponent(path)}?ref=${ref}`))).last_commit_id;
521
646
  }
@@ -970,6 +1095,7 @@ function gitlab(options) {
970
1095
  return {
971
1096
  name: "GitLab",
972
1097
  canRedirect: Boolean(options.clientId),
1098
+ redirectAddressesListed: Boolean(options.clientId),
973
1099
  tokenLinks: [{
974
1100
  url: `${webUrl}/-/user_settings/personal_access_tokens?${new URLSearchParams({
975
1101
  name: "Goodfellow admin",
package/dist/testing.d.ts CHANGED
@@ -65,6 +65,8 @@ export interface FakeCreatedProject {
65
65
  repo: FakeRepo;
66
66
  }
67
67
  export declare function fakeGitLab(options: FakeGitLabOptions): {
68
+ /** How many GraphQL requests read files, as `readFiles()` makes. */
69
+ readonly graphqlReads: number;
68
70
  /** The project's members by user id, invitations by email, and protected branches, which tests can change. */
69
71
  members: Map<number, FakeMember>;
70
72
  invitations: Map<string, number>;
@@ -122,7 +124,7 @@ export declare function fakeGitLab(options: FakeGitLabOptions): {
122
124
  /** The site's GitLab Pages, if it has them, which tests can change. */
123
125
  pages: FakeGitLabPages | undefined;
124
126
  requests: Request[];
125
- commit: (changes: FileChange[], message: string, author?: string) => string;
127
+ commit: (changes: FileChange[], message: string, author?: string, branch?: string) => string;
126
128
  authorize: (authorizeUrl: string) => string;
127
129
  handle: (request: Request) => Promise<Response>;
128
130
  fetch: typeof fetch;
package/dist/testing.js CHANGED
@@ -187,6 +187,7 @@ function fakeGitLab(options) {
187
187
  const pipelineId = (sha) => [...pipelines.keys()].indexOf(sha) + 1;
188
188
  const requests = [];
189
189
  let counter = 0;
190
+ let graphqlReads = 0;
190
191
  const created = /* @__PURE__ */ new Map();
191
192
  const pages = options.pages ? {
192
193
  uniqueDomain: options.pages.uniqueDomain ?? true,
@@ -330,9 +331,12 @@ function fakeGitLab(options) {
330
331
  }
331
332
  return json({ message: "404 Not Found" }, 404);
332
333
  }
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 });
334
+ function commit(changes, message, author, branch = repo.defaultBranch) {
335
+ const sha = repo.commit(changes, message, {
336
+ author,
337
+ branch
338
+ });
339
+ if (options.deployAfterChecks !== void 0 && branch === repo.defaultBranch) pipelines.set(sha, { checks: 0 });
336
340
  return sha;
337
341
  }
338
342
  function issueTokens(user) {
@@ -390,6 +394,22 @@ function fakeGitLab(options) {
390
394
  }
391
395
  return json({ error: "unsupported_grant_type" }, 400);
392
396
  }
397
+ if (url.pathname === "/api/graphql" && request.method === "POST") {
398
+ if (!tokens.has(request.headers.get("authorization")?.replace(/^Bearer /, "") ?? "")) return json({ message: "401 Unauthorized" }, 401);
399
+ const { variables } = await request.json();
400
+ if (variables.project !== options.project) return json({ data: { project: null } });
401
+ graphqlReads += 1;
402
+ const files = repo.files(variables.ref);
403
+ return json({ data: { project: { repository: { blobs: { nodes: variables.paths.flatMap((path) => {
404
+ const content = files.get(path);
405
+ if (content === void 0) return [];
406
+ return [{
407
+ path,
408
+ rawTextBlob: typeof content === "string" ? content : null,
409
+ size: String(fakeFileBytes(content).length)
410
+ }];
411
+ }) } } } } });
412
+ }
393
413
  if (!url.pathname.startsWith("/api/v4/")) return json({ message: "404 Not Found" }, 404);
394
414
  const user = tokens.get(request.headers.get("authorization")?.replace(/^Bearer /, "") ?? "");
395
415
  if (!user) return json({ message: "401 Unauthorized" }, 401);
@@ -401,6 +421,14 @@ function fakeGitLab(options) {
401
421
  });
402
422
  const setup = await handleCreated(request, path, user);
403
423
  if (setup) return setup;
424
+ if (path === "/projects" && request.method === "GET") {
425
+ const level = members.get(accounts.get(user.username) ?? 0)?.accessLevel ?? 0;
426
+ const min = Number(url.searchParams.get("min_access_level") ?? "0");
427
+ return json(level > 0 && level >= min ? [{
428
+ path_with_namespace: options.project,
429
+ default_branch: repo.defaultBranch
430
+ }] : []);
431
+ }
404
432
  if (path === "/users") {
405
433
  const name = url.searchParams.get("username") ?? "";
406
434
  const id = [...accounts].find(([username]) => username.toLowerCase() === name.toLowerCase())?.[1];
@@ -425,11 +453,55 @@ function fakeGitLab(options) {
425
453
  },
426
454
  ci_push_repository_for_job_token_allowed: updateSettings.jobTokenPush
427
455
  });
456
+ if (rest === "/repository/branches" && request.method === "POST") {
457
+ if (accessLevel < 30) return json({ message: "403 Forbidden" }, 403);
458
+ const name = url.searchParams.get("branch") ?? "";
459
+ const ref = url.searchParams.get("ref") ?? "";
460
+ if (repo.branches.has(name)) return json({ message: "Branch already exists" }, 400);
461
+ const sha = repo.branches.get(ref) ?? (repo.commitAt(ref) ? ref : void 0);
462
+ if (!sha) return json({ message: "Invalid reference name" }, 400);
463
+ repo.branches.set(name, sha);
464
+ return json({
465
+ name,
466
+ commit: { id: sha }
467
+ }, 201);
468
+ }
428
469
  const branch = rest.match(/^\/repository\/branches\/(.+)$/);
470
+ if (branch && request.method === "DELETE") {
471
+ if (accessLevel < 30) return json({ message: "403 Forbidden" }, 403);
472
+ const name = decodeURIComponent(branch[1] ?? "");
473
+ if (!repo.branches.has(name)) return json({ message: "404 Branch Not Found" }, 404);
474
+ repo.branches.delete(name);
475
+ return new Response(null, { status: 204 });
476
+ }
429
477
  if (branch) {
430
478
  const sha = repo.branches.get(decodeURIComponent(branch[1] ?? ""));
431
479
  return sha ? json({ commit: { id: sha } }) : json({ message: "404 Branch Not Found" }, 404);
432
480
  }
481
+ if (rest === "/repository/merge_base") {
482
+ const refs = url.searchParams.getAll("refs[]").map((ref) => repo.branches.get(ref) ?? ref);
483
+ if (refs.length !== 2 || refs.some((ref) => !repo.commitAt(ref))) return json({ message: "Could not find ref" }, 400);
484
+ const base = repo.mergeBase(refs[0] ?? "", refs[1] ?? "");
485
+ return base ? json({ id: base }) : json({ message: "404 Not Found" }, 404);
486
+ }
487
+ if (rest === "/merge_requests" && request.method === "POST") {
488
+ if (accessLevel < 30) return json({ message: "403 Forbidden" }, 403);
489
+ const body = await request.json();
490
+ if (!repo.branches.has(body.source_branch) || !repo.branches.has(body.target_branch)) return json({ message: ["Source branch does not exist"] }, 422);
491
+ 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);
492
+ const review = repo.openReview(body.source_branch, body.title, body.description ?? "", body.target_branch);
493
+ return json({
494
+ iid: review.number,
495
+ web_url: `${webUrl}/${options.project}/-/merge_requests/${review.number}`
496
+ }, 201);
497
+ }
498
+ if (rest === "/merge_requests" && request.method === "GET") {
499
+ const source = url.searchParams.get("source_branch");
500
+ return json(repo.reviews.filter((review) => review.state === "open" && (!source || review.branch === source)).map((review) => ({
501
+ iid: review.number,
502
+ web_url: `${webUrl}/${options.project}/-/merge_requests/${review.number}`
503
+ })));
504
+ }
433
505
  if (rest === "/repository/tree") {
434
506
  const ref = url.searchParams.get("ref") ?? repo.defaultBranch;
435
507
  const dir = url.searchParams.get("path") ?? "";
@@ -487,7 +559,7 @@ function fakeGitLab(options) {
487
559
  } : {
488
560
  path: action.file_path,
489
561
  content: action.content ?? ""
490
- }), body.commit_message, user.name ?? user.username),
562
+ }), body.commit_message, user.name ?? user.username, body.branch),
491
563
  message: body.commit_message
492
564
  }, 201);
493
565
  }
@@ -617,6 +689,10 @@ function fakeGitLab(options) {
617
689
  return json({ message: "404 Not Found" }, 404);
618
690
  }
619
691
  return {
692
+ /** How many GraphQL requests read files, as `readFiles()` makes. */
693
+ get graphqlReads() {
694
+ return graphqlReads;
695
+ },
620
696
  /** The project's members by user id, invitations by email, and protected branches, which tests can change. */
621
697
  members,
622
698
  invitations,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodfellow-cms/gitlab",
3
- "version": "0.2.0",
3
+ "version": "0.3.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.3.0"
39
39
  },
40
40
  "devDependencies": {
41
41
  "@puckeditor/core": "0.23.0"