@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.
- package/connectors/apple/src/adapters.ts +56 -1
- package/connectors/apple/src/caldav-core.ts +7 -0
- package/connectors/apple/src/calendar-tools.ts +34 -0
- package/connectors/apple/src/tools.ts +2 -1
- package/connectors/aws/src/tools.ts +110 -0
- package/connectors/azure/src/tools.ts +70 -0
- package/connectors/circleci/src/tools.ts +63 -1
- package/connectors/gcp/src/tools.ts +74 -16
- package/connectors/github/src/tools.ts +298 -6
- package/connectors/gitlab/src/gitlab-files.ts +213 -0
- package/connectors/gitlab/src/tools.ts +226 -0
- package/connectors/kubernetes/src/tools.ts +35 -15
- package/connectors/slack/src/tools.ts +56 -5
- package/connectors/snowflake/src/server.ts +69 -2
- package/connectors/teams/src/bot-send.ts +10 -2
- package/connectors/teams/src/tools.ts +18 -1
- package/package.json +1 -1
- package/shared/audit-chain.ts +5 -2
- package/shared/commit-files.ts +138 -0
- package/shared/consent-kit.ts +141 -31
- package/shared/github-files.ts +195 -0
- package/shared/github-rest.ts +12 -0
- package/shared/k8s-deployment-view.ts +68 -0
- package/shared/prepare-tool-ids.ts +21 -0
- package/shared/private-kubeconfig.ts +110 -0
|
@@ -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
|
-
|
|
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) =>
|
|
185
|
-
|
|
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
|
-
|
|
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
|
+
}
|