@nimbus-dev/connectors 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,10 +1,23 @@
1
1
  import { z } from "zod";
2
+ import {
3
+ assertCombinedSize,
4
+ assertCommitFiles,
5
+ COMMIT_MAX_COMBINED_BYTES,
6
+ COMMIT_MAX_FILES,
7
+ commitFilePathSchema,
8
+ commitFilesSchema,
9
+ fullShaSchema,
10
+ notOverwritable,
11
+ } from "../../../shared/commit-files.ts";
2
12
  import {
3
13
  type ConsentServer,
14
+ createPrepareToolRegistrar,
4
15
  createWriteToolRegistrar,
5
16
  type WriteToolConfig,
6
17
  } from "../../../shared/consent-kit.ts";
18
+ import { type GithubFileRead, readGithubFilesAt } from "../../../shared/github-files.ts";
7
19
  import {
20
+ commitRefCandidates,
8
21
  ghFetch,
9
22
  ghQueryPath,
10
23
  ghRepoPath,
@@ -17,6 +30,7 @@ import {
17
30
  createRegisterSimpleTool,
18
31
  createZodToolRegistrar,
19
32
  mcpJsonResult as jsonResult,
33
+ mcpJsonResultIfOk,
20
34
  requireProcessEnv,
21
35
  type ZodObjectSchema,
22
36
  } from "../../../shared/mcp-tool-kit.ts";
@@ -44,11 +58,15 @@ export const GITHUB_TOOL_NAMES = [
44
58
  "github_ci_run_get",
45
59
  "github_branch_delete",
46
60
  "github_ref_get",
61
+ "github_commit_get",
47
62
  "github_branch_create",
48
63
  "github_pr_create",
49
64
  "github_pr_update",
50
65
  "github_tag_create",
51
66
  "github_commit_push",
67
+ "github_files_get",
68
+ "github_commit_prepare",
69
+ "github_file_commit",
52
70
  ] as const;
53
71
 
54
72
  export function registerGithubTools(
@@ -68,6 +86,16 @@ export function registerGithubTools(
68
86
  scopeKinds: ["repo"],
69
87
  });
70
88
 
89
+ /**
90
+ * Prepare tools (shared/prepare-tool-ids.ts) read the state a write is bound to, for the gateway to
91
+ * call BEFORE the write's approval prompt; they are neither reads nor writes to the kit.
92
+ */
93
+ const registerPrepareTool = createPrepareToolRegistrar(server, {
94
+ connector: "github",
95
+ scopeEnv: "NIMBUS_MCP_GITHUB_WRITE_SCOPE",
96
+ scopeKinds: ["repo"],
97
+ });
98
+
71
99
  /** Standard GitHub tool, read or write: token → ghFetch(buildPath[, buildInit]) → mcpJsonResultIfOk("GitHub"). */
72
100
  const githubRest = { tokenEnv: "GITHUB_PAT", serviceLabel: "GitHub", fetch: ghFetch } as const;
73
101
  const registerGithubTool = makeRestToolRegistrar({ registrar: reg, ...githubRest });
@@ -174,15 +202,20 @@ export function registerGithubTools(
174
202
  pullNumber: z.number().int().min(1),
175
203
  mergeMethod: z.enum(["merge", "squash", "rebase"]).optional(),
176
204
  commitTitle: z.string().max(500).optional(),
205
+ // Lowercase hex only: the sha is sent as given and compared by GitHub, and the preview shows it.
206
+ sha: z
207
+ .string()
208
+ .regex(/^[0-9a-f]{40}$/, "a full 40-character lowercase hex commit sha")
209
+ .optional(),
177
210
  });
178
211
 
179
- registerGithubWriteTool(
212
+ registerWriteTool(
180
213
  "github_pr_merge",
181
- { mutates: "github.pr.merge", recoverable: true },
182
- "Merge a pull request.",
214
+ { mutates: "github.pr.merge", recoverable: true, scopeTargetOf: repoScope },
215
+ "Merge a pull request. If sha (40 lowercase hex) is given it is sent as the API's sha parameter, the head SHA the pull request must match, and GitHub refuses the merge with a 409 when the head has moved (https://docs.github.com/en/rest/pulls/pulls#merge-a-pull-request).",
183
216
  githubPrMergeSchema,
184
- (parsed) => `${ghRepoPath(parsed.owner, parsed.repo)}/pulls/${String(parsed.pullNumber)}/merge`,
185
- (parsed) => {
217
+ async (parsed) => {
218
+ const token = requireProcessEnv("GITHUB_PAT");
186
219
  const body: Record<string, string> = {};
187
220
  if (parsed.mergeMethod !== undefined) {
188
221
  body["merge_method"] = parsed.mergeMethod;
@@ -190,7 +223,22 @@ export function registerGithubTools(
190
223
  if (parsed.commitTitle !== undefined && parsed.commitTitle !== "") {
191
224
  body["commit_title"] = parsed.commitTitle;
192
225
  }
193
- return jsonInit("PUT", body);
226
+ if (parsed.sha !== undefined) {
227
+ body["sha"] = parsed.sha;
228
+ }
229
+ const res = await ghFetch(
230
+ token,
231
+ `${ghRepoPath(parsed.owner, parsed.repo)}/pulls/${String(parsed.pullNumber)}/merge`,
232
+ jsonInit("PUT", body),
233
+ );
234
+ // The merge endpoint documents 409 as "Conflict if sha was provided and pull request head did
235
+ // not match". Without a sha a 409 is not that, so it keeps the generic error.
236
+ if (res.status === 409 && parsed.sha !== undefined) {
237
+ throw new Error(
238
+ `GitHub 409: the pull request head is no longer the approved ${parsed.sha}; nothing was merged. GitHub said: ${res.text.slice(0, 200)}`,
239
+ );
240
+ }
241
+ return mcpJsonResultIfOk("GitHub", res);
194
242
  },
195
243
  );
196
244
 
@@ -360,6 +408,67 @@ export function registerGithubTools(
360
408
  },
361
409
  );
362
410
 
411
+ /** `owner/repo` as one string: exactly two safe slugs. */
412
+ const repoFullSchema = z.string().refine((v) => {
413
+ const parts = v.split("/");
414
+ return parts.length === 2 && parts.every(isSafeGhSlug);
415
+ }, "not an owner/repo name");
416
+
417
+ const COMMIT_MESSAGE_LINE_MAX = 200;
418
+
419
+ /** The first line of a commit message, clipped to 200 characters (code points, not UTF-16 halves). */
420
+ function firstLine(message: unknown): string {
421
+ const line = typeof message === "string" ? (message.split("\n")[0] ?? "").trimEnd() : "";
422
+ return [...line].slice(0, COMMIT_MESSAGE_LINE_MAX).join("");
423
+ }
424
+
425
+ /**
426
+ * Only what a tag's preview needs of `GET /commits/{ref}`
427
+ * (https://docs.github.com/en/rest/commits/commits#get-a-commit): the sha, the first line of the
428
+ * message, the author's name and date, and the parents' shas. The file list and diff are dropped.
429
+ */
430
+ function projectCommit(raw: unknown): Record<string, unknown> {
431
+ const c = raw as {
432
+ sha?: unknown;
433
+ commit?: { message?: unknown; author?: { name?: unknown; date?: unknown } };
434
+ parents?: unknown;
435
+ } | null;
436
+ if (c === null || typeof c.sha !== "string") throw new Error("GitHub returned no commit");
437
+ const parents = Array.isArray(c.parents)
438
+ ? c.parents
439
+ .map((p) => (p as { sha?: unknown } | null)?.sha)
440
+ .filter((s): s is string => typeof s === "string")
441
+ : [];
442
+ return {
443
+ sha: c.sha,
444
+ message: firstLine(c.commit?.message),
445
+ authorName: c.commit?.author?.name,
446
+ authorDate: c.commit?.author?.date,
447
+ parents,
448
+ };
449
+ }
450
+
451
+ reg(
452
+ "github_commit_get",
453
+ "Get one commit by sha, branch or tag: {sha, message (first line, at most 200 characters), authorName, authorDate, parents:[sha]}. The before-state of github_tag_create. A bare name is tried as a branch, then a tag; an unknown ref is 'commit not found'.",
454
+ z.object({ repoFull: repoFullSchema, ref: gitRefSchema }),
455
+ async (parsed) => {
456
+ const token = requireProcessEnv("GITHUB_PAT");
457
+ const [owner = "", repo = ""] = parsed.repoFull.split("/");
458
+ for (const name of commitRefCandidates(parsed.ref)) {
459
+ const segments = name.split("/").map(encodeURIComponent).join("/");
460
+ const res = await ghFetch(token, `${ghRepoPath(owner, repo)}/commits/${segments}`); // NOSONAR S9382: candidates are tried in order and the first hit wins.
461
+ // A missing ref is a 404, or a 422 "No commit found for SHA" (the commits endpoint's reply
462
+ // for a name it cannot resolve); a 422 with any other message is a real error.
463
+ if (res.status === 404 || (res.status === 422 && /no commit found/i.test(res.text)))
464
+ continue;
465
+ if (!res.ok) throw new Error(`GitHub ${String(res.status)}: ${res.text.slice(0, 300)}`);
466
+ return jsonResult(projectCommit(res.json));
467
+ }
468
+ throw new Error("commit not found");
469
+ },
470
+ );
471
+
363
472
  const githubBranchCreateSchema = repoSlugArgs.extend({
364
473
  branch: gitRefSchema,
365
474
  fromRef: gitRefSchema,
@@ -488,4 +597,187 @@ export function registerGithubTools(
488
597
  }),
489
598
  ),
490
599
  );
600
+
601
+ // -------------------------------------------------------------------------------------------
602
+ // Multi-file commit: github_files_get (read), github_commit_prepare (prepare), github_file_commit
603
+ // -------------------------------------------------------------------------------------------
604
+
605
+ /** The commit a branch points at now. A bare name is NOT tried as a tag: the commit lands on a branch. */
606
+ async function branchHead(
607
+ token: string,
608
+ p: { owner: string; repo: string; branch: string },
609
+ ): Promise<string> {
610
+ return (await resolveCommitSha(token, p.owner, p.repo, `heads/${p.branch}`)).sha;
611
+ }
612
+
613
+ /** What a read tool reports for one file; `content` only for text. */
614
+ function fileView(f: GithubFileRead): Record<string, unknown> {
615
+ if (!f.exists) return { path: f.path, exists: false };
616
+ const base = { path: f.path, exists: true, mode: f.mode, sha: f.sha, size: f.size };
617
+ if (f.kind === "text") return { ...base, content: f.content };
618
+ return { ...base, withheld: f.kind };
619
+ }
620
+
621
+ const READ_LIMIT = COMMIT_MAX_COMBINED_BYTES;
622
+
623
+ reg(
624
+ "github_files_get",
625
+ 'Read up to 50 files at a branch, tag or commit: {ref, headSha, files:[{path, exists, mode, sha, size, content}]}. A binary, symlink, submodule or over-192-KiB file is reported with its sha and `withheld` instead of content, and the content of all the files together is capped at 192 KiB: the first file that does not fit, and every file after it, is reported with `withheld: "budget"` and no content.',
626
+ repoSlugArgs.extend({
627
+ ref: gitRefSchema,
628
+ paths: z.array(commitFilePathSchema).min(1).max(50),
629
+ }),
630
+ async (parsed) => {
631
+ const token = requireProcessEnv("GITHUB_PAT");
632
+ const { sha } = await resolveCommitSha(token, parsed.owner, parsed.repo, parsed.ref);
633
+ const { files } = await readGithubFilesAt(
634
+ token,
635
+ parsed.owner,
636
+ parsed.repo,
637
+ sha,
638
+ parsed.paths,
639
+ { withContent: true, maxReadBytes: READ_LIMIT, totalBudget: READ_LIMIT },
640
+ );
641
+ return jsonResult({ ref: parsed.ref, headSha: sha, files: files.map(fileView) });
642
+ },
643
+ );
644
+
645
+ const githubCommitInputSchema = repoSlugArgs.extend({
646
+ branch: gitRefSchema,
647
+ files: commitFilesSchema,
648
+ });
649
+
650
+ registerPrepareTool(
651
+ "github_commit_prepare",
652
+ { scopeTargetOf: repoScope },
653
+ `Read the state a github_file_commit is bound to: the branch head and, for each file, whether it exists, its mode, blob sha and current content. Refuses (no truncation) a commit that touches more than ${String(COMMIT_MAX_FILES)} files or whose old and new content together exceed 192 KiB, and any existing file that is not a text file. Takes the commit's own owner, repo, branch and files.`,
654
+ githubCommitInputSchema,
655
+ async (parsed) => {
656
+ assertCommitFiles(parsed.files);
657
+ const token = requireProcessEnv("GITHUB_PAT");
658
+ const head = await branchHead(token, parsed);
659
+ const { files: read } = await readGithubFilesAt(
660
+ token,
661
+ parsed.owner,
662
+ parsed.repo,
663
+ head,
664
+ parsed.files.map((f) => f.path),
665
+ { withContent: true, maxReadBytes: READ_LIMIT },
666
+ );
667
+ let oldBytes = 0;
668
+ const files = read.map((f) => {
669
+ if (!f.exists) return { path: f.path, exists: false, mode: "100644" };
670
+ if (f.kind !== "text") throw notOverwritable(f.path);
671
+ oldBytes += f.size;
672
+ return { path: f.path, exists: true, mode: f.mode, oldSha: f.sha, oldContent: f.content };
673
+ });
674
+ assertCombinedSize(oldBytes, parsed.files);
675
+ return jsonResult({ headSha: head, files });
676
+ },
677
+ );
678
+
679
+ registerWriteTool(
680
+ "github_file_commit",
681
+ {
682
+ mutates: "github.file.commit",
683
+ // A commit moves a branch; the inverse needs the head and the old blob shas, captured first.
684
+ recoverable: false,
685
+ capturePreState: async (parsed) => {
686
+ const token = requireProcessEnv("GITHUB_PAT");
687
+ const head = await branchHead(token, parsed);
688
+ const { files: read } = await readGithubFilesAt(
689
+ token,
690
+ parsed.owner,
691
+ parsed.repo,
692
+ head,
693
+ parsed.files.map((f) => f.path),
694
+ { withContent: false, maxReadBytes: READ_LIMIT },
695
+ );
696
+ return {
697
+ branch: parsed.branch,
698
+ headSha: head,
699
+ files: read.map((f) => ({ path: f.path, oldSha: f.exists ? f.sha : null })),
700
+ };
701
+ },
702
+ scopeTargetOf: repoScope,
703
+ },
704
+ "Commit one or more whole text files to a branch as ONE commit, but only if the branch head is still expectedHeadSha (the head the owner approved); never forced. Existing files keep their mode, new files are 100644. No deletes, no mode changes.",
705
+ githubCommitInputSchema.extend({
706
+ message: z.string().min(1).max(5000),
707
+ expectedHeadSha: fullShaSchema,
708
+ }),
709
+ async (parsed) => {
710
+ assertCommitFiles(parsed.files);
711
+ const token = requireProcessEnv("GITHUB_PAT");
712
+ const expected = parsed.expectedHeadSha.toLowerCase();
713
+ // 1. The head AGAIN: the approval was for this commit, not for whatever the branch became.
714
+ const head = await branchHead(token, parsed);
715
+ if (head !== expected) {
716
+ throw new Error(`branch head is ${head}, not the approved ${expected}`);
717
+ }
718
+ // 2. Existing modes and sizes from the tree at that head; the blob is read too, only to refuse a
719
+ // binary file (its content is not otherwise needed).
720
+ const { treeSha: baseTree, files: existing } = await readGithubFilesAt(
721
+ token,
722
+ parsed.owner,
723
+ parsed.repo,
724
+ head,
725
+ parsed.files.map((f) => f.path),
726
+ { withContent: true, maxReadBytes: READ_LIMIT },
727
+ );
728
+ let oldBytes = 0;
729
+ const modes = new Map<string, string>();
730
+ for (const f of existing) {
731
+ if (!f.exists) continue;
732
+ if (f.kind !== "text") throw notOverwritable(f.path);
733
+ oldBytes += f.size;
734
+ modes.set(f.path, f.mode);
735
+ }
736
+ assertCombinedSize(oldBytes, parsed.files);
737
+ const base = ghRepoPath(parsed.owner, parsed.repo);
738
+ const post = async (path: string, body: unknown): Promise<Record<string, unknown>> => {
739
+ const res = await ghFetch(token, `${base}${path}`, jsonInit("POST", body));
740
+ const json = res.json as Record<string, unknown> | null;
741
+ if (!res.ok || json === null || typeof json["sha"] !== "string") {
742
+ throw new Error(`GitHub ${String(res.status)}: ${res.text.slice(0, 300)}`);
743
+ }
744
+ return json;
745
+ };
746
+ // 3. One blob per file, then one tree on top of the head's tree, then one commit.
747
+ const treeEntries: { path: string; mode: string; type: "blob"; sha: string }[] = [];
748
+ for (const f of parsed.files) {
749
+ const blob = await post("/git/blobs", { content: f.content, encoding: "utf-8" }); // NOSONAR S9382: sequential on purpose; a failed blob stops the commit before any tree exists.
750
+ treeEntries.push({
751
+ path: f.path,
752
+ mode: modes.get(f.path) ?? "100644",
753
+ type: "blob",
754
+ sha: String(blob["sha"]),
755
+ });
756
+ }
757
+ const tree = await post("/git/trees", { base_tree: baseTree, tree: treeEntries });
758
+ const commit = await post("/git/commits", {
759
+ message: parsed.message,
760
+ tree: String(tree["sha"]),
761
+ parents: [head],
762
+ });
763
+ // 4. Move the branch, fast-forward only. A moved head or a refusal is a 409 or a 422.
764
+ const refPath = parsed.branch.split("/").map(encodeURIComponent).join("/");
765
+ const moved = await ghFetch(
766
+ token,
767
+ `${base}/git/refs/heads/${refPath}`,
768
+ jsonInit("PATCH", { sha: String(commit["sha"]), force: false }),
769
+ );
770
+ if (!moved.ok) {
771
+ const why =
772
+ moved.status === 409 || moved.status === 422 ? "head moved or ref update refused: " : "";
773
+ throw new Error(`GitHub ${String(moved.status)}: ${why}${moved.text.slice(0, 300)}`);
774
+ }
775
+ return jsonResult({
776
+ ok: true,
777
+ commitSha: String(commit["sha"]),
778
+ branch: parsed.branch,
779
+ parent: head,
780
+ });
781
+ },
782
+ );
491
783
  }
@@ -0,0 +1,213 @@
1
+ /**
2
+ * gitlab-files: read files at a commit through the Repository Files API, for the batch read
3
+ * (`gitlab_files_get`), the prepare tool (`gitlab_commit_prepare`) and the commit
4
+ * (`gitlab_commit_create`), so what the owner was shown and what is committed come from one reader.
5
+ *
6
+ * Sources (https://docs.gitlab.com/api/{repository_files,branches,commits}/):
7
+ * - `GET /projects/:id/repository/files/:file_path?ref=` returns `content` (base64), `blob_id`,
8
+ * `commit_id`, `last_commit_id`, `execute_filemode` and `size`; the file path is fully URL-encoded,
9
+ * slashes as `%2F` and, in the page's examples, dots as `%2E` (`lib%2Fclass%2Erb`).
10
+ * - `GET /projects/:id/repository/branches/:branch` returns `commit.id`.
11
+ * - `GET /projects/:id/repository/commits/:sha` accepts a hash, a branch or a tag name; `id` is the
12
+ * full hash.
13
+ */
14
+
15
+ import { decodeBase64Text, notOverwritable } from "../../../shared/commit-files.ts";
16
+ import type { RestFetchResult } from "../../../shared/rest-tool-kit.ts";
17
+
18
+ /** A GET against the GitLab API: a path relative to the API base, already carrying the token. */
19
+ export type GitlabGet = (path: string) => Promise<RestFetchResult>;
20
+
21
+ const FULL_SHA = /^[0-9a-f]{40}$/i;
22
+
23
+ export type GitlabFileRead =
24
+ | { readonly path: string; readonly exists: false }
25
+ | {
26
+ readonly path: string;
27
+ readonly exists: true;
28
+ readonly kind: "text";
29
+ readonly lastCommitId: string;
30
+ readonly executable: boolean;
31
+ readonly size: number;
32
+ readonly content: string;
33
+ }
34
+ | {
35
+ readonly path: string;
36
+ readonly exists: true;
37
+ readonly kind: "binary" | "too-large" | "budget";
38
+ readonly lastCommitId: string;
39
+ readonly executable: boolean;
40
+ readonly size: number;
41
+ };
42
+
43
+ /** A file path as GitLab's API wants it: `encodeURIComponent`, plus every `.` as `%2E`. */
44
+ export function encodeFilePath(path: string): string {
45
+ return encodeURIComponent(path).replaceAll(".", "%2E");
46
+ }
47
+
48
+ function fail(res: { status: number; text: string }): never {
49
+ throw new Error(`GitLab ${String(res.status)}: ${res.text.slice(0, 300)}`);
50
+ }
51
+
52
+ function isRecord(v: unknown): v is Record<string, unknown> {
53
+ return typeof v === "object" && v !== null && !Array.isArray(v);
54
+ }
55
+
56
+ /** The commit a BRANCH points at now. */
57
+ export async function branchHead(get: GitlabGet, project: string, branch: string): Promise<string> {
58
+ const res = await get(`/projects/${project}/repository/branches/${encodeURIComponent(branch)}`);
59
+ if (res.status === 404) throw new Error(`GitLab 404: branch "${branch}" not found`);
60
+ if (!res.ok) fail(res);
61
+ const commit = isRecord(res.json) && isRecord(res.json["commit"]) ? res.json["commit"] : {};
62
+ const id = commit["id"];
63
+ if (typeof id !== "string" || !FULL_SHA.test(id))
64
+ throw new Error("GitLab returned no branch head");
65
+ return id.toLowerCase();
66
+ }
67
+
68
+ /** The commit a branch, tag or sha names. */
69
+ export async function refCommit(get: GitlabGet, project: string, ref: string): Promise<string> {
70
+ const res = await get(`/projects/${project}/repository/commits/${encodeURIComponent(ref)}`);
71
+ if (res.status === 404) throw new Error(`GitLab 404: ref "${ref}" not found`);
72
+ if (!res.ok) fail(res);
73
+ const id = isRecord(res.json) ? res.json["id"] : undefined;
74
+ if (typeof id !== "string" || !FULL_SHA.test(id)) throw new Error("GitLab returned no commit id");
75
+ return id.toLowerCase();
76
+ }
77
+
78
+ /** Entries per tree page (the API's own maximum is not stated on its page; 100 is what it is asked for). */
79
+ const TREE_PAGE = 100;
80
+ /** A directory of more than this many pages (5,000 entries) is refused rather than guessed about. */
81
+ const TREE_MAX_PAGES = 50;
82
+
83
+ type TreeEntry = { readonly path: string; readonly type: string; readonly mode: string };
84
+
85
+ /**
86
+ * The entries of directory `dir` ("" for the root) at `sha`, paged until a short page.
87
+ * `GET /projects/:id/repository/tree?path=&ref=&per_page=&page=`; each entry has `id, name, path, mode,
88
+ * type` (https://docs.gitlab.com/api/repositories/). A 404 is a directory that does not exist.
89
+ */
90
+ async function listTree(
91
+ get: GitlabGet,
92
+ project: string,
93
+ sha: string,
94
+ dir: string,
95
+ ): Promise<TreeEntry[]> {
96
+ const out: TreeEntry[] = [];
97
+ for (let page = 1; page <= TREE_MAX_PAGES; page++) {
98
+ const q = new URLSearchParams();
99
+ if (dir !== "") q.set("path", dir);
100
+ q.set("ref", sha);
101
+ q.set("per_page", String(TREE_PAGE));
102
+ q.set("page", String(page));
103
+ const res = await get(`/projects/${project}/repository/tree?${q.toString()}`); // NOSONAR S9382: pages are read in order until a short one.
104
+ if (res.status === 404) return out;
105
+ if (!res.ok) fail(res);
106
+ const entries = Array.isArray(res.json) ? res.json : [];
107
+ for (const e of entries) {
108
+ if (isRecord(e) && typeof e["path"] === "string" && typeof e["type"] === "string") {
109
+ out.push({
110
+ path: e["path"],
111
+ type: e["type"],
112
+ mode: typeof e["mode"] === "string" ? e["mode"] : "",
113
+ });
114
+ }
115
+ }
116
+ if (entries.length < TREE_PAGE) return out;
117
+ }
118
+ throw new Error(`GitLab directory "${dir}" has too many entries to check`);
119
+ }
120
+
121
+ /**
122
+ * Refuse a target the commit tools must not write over. The Repository Files API reports no mode, so
123
+ * a symlink reads as a text file holding its target; the tree listing does report one. For each
124
+ * distinct parent directory (sorted, one listing per directory, paged) every target path must be
125
+ * either absent or a plain `blob` whose mode is not 120000 (symlink) or 160000 (submodule); a
126
+ * directory or a submodule where a file is expected is refused too, and so is an existing file the
127
+ * listing does not show (it cannot be verified). Fail closed, before any write.
128
+ */
129
+ export async function assertRegularTargets(
130
+ get: GitlabGet,
131
+ project: string,
132
+ sha: string,
133
+ files: readonly { readonly path: string; readonly exists: boolean }[],
134
+ ): Promise<void> {
135
+ const dirOf = (p: string): string => (p.includes("/") ? p.slice(0, p.lastIndexOf("/")) : "");
136
+ const dirs = [...new Set(files.map((f) => dirOf(f.path)))].sort((a, b) => a.localeCompare(b));
137
+ const byPath = new Map<string, TreeEntry>();
138
+ for (const dir of dirs) {
139
+ for (const entry of await listTree(get, project, sha, dir)) byPath.set(entry.path, entry); // NOSONAR S9382
140
+ }
141
+ for (const f of files) {
142
+ const entry = byPath.get(f.path);
143
+ if (entry === undefined) {
144
+ if (f.exists) throw notOverwritable(f.path);
145
+ continue;
146
+ }
147
+ if (entry.type !== "blob" || entry.mode === "120000" || entry.mode === "160000") {
148
+ throw notOverwritable(f.path);
149
+ }
150
+ }
151
+ }
152
+
153
+ /**
154
+ * Read `paths` at the commit `sha`; a 404 for a path is `exists: false`. With `totalBudget`, the
155
+ * content of all the files together is capped (the files API returns a file whole, so a withheld file
156
+ * has still been transferred; only its content is dropped from the result).
157
+ */
158
+ export async function readGitlabFilesAt(
159
+ get: GitlabGet,
160
+ project: string,
161
+ sha: string,
162
+ paths: readonly string[],
163
+ maxReadBytes: number,
164
+ totalBudget?: number,
165
+ ): Promise<GitlabFileRead[]> {
166
+ const out: GitlabFileRead[] = [];
167
+ let used = 0;
168
+ let exhausted = false;
169
+ for (const path of paths) {
170
+ const query = new URLSearchParams({ ref: sha }).toString();
171
+ const res = await get(`/projects/${project}/repository/files/${encodeFilePath(path)}?${query}`); // NOSONAR S9382: sequential on purpose; paths are few and an error stops the read.
172
+ if (res.status === 404) {
173
+ out.push({ path, exists: false });
174
+ continue;
175
+ }
176
+ if (!res.ok) fail(res);
177
+ const f = isRecord(res.json) ? res.json : {};
178
+ const lastCommitId = f["last_commit_id"];
179
+ const content = f["content"];
180
+ if (typeof lastCommitId !== "string" || typeof content !== "string") {
181
+ throw new Error(`GitLab returned an unreadable file for ${path}`);
182
+ }
183
+ const size = typeof f["size"] === "number" ? f["size"] : 0;
184
+ const executable = f["execute_filemode"] === true;
185
+ if (size > maxReadBytes) {
186
+ out.push({ path, exists: true, kind: "too-large", lastCommitId, executable, size });
187
+ continue;
188
+ }
189
+ // The combined content of all the files is capped. The first file that does not fit, and every
190
+ // one after it, is reported `budget` with its metadata and no content.
191
+ if (totalBudget !== undefined && (exhausted || used + size > totalBudget)) {
192
+ exhausted = true;
193
+ out.push({ path, exists: true, kind: "budget", lastCommitId, executable, size });
194
+ continue;
195
+ }
196
+ used += size;
197
+ const decoded = decodeBase64Text(content);
198
+ out.push(
199
+ "binary" in decoded
200
+ ? { path, exists: true, kind: "binary", lastCommitId, executable, size }
201
+ : {
202
+ path,
203
+ exists: true,
204
+ kind: "text",
205
+ lastCommitId,
206
+ executable,
207
+ size,
208
+ content: decoded.text,
209
+ },
210
+ );
211
+ }
212
+ return out;
213
+ }