gitlab-mcp 1.2.1 → 1.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/README.md +36 -29
- package/dist/config/dotenv.js +6 -2
- package/dist/config/dotenv.js.map +1 -1
- package/dist/config/env.d.ts +5 -2
- package/dist/config/env.js +27 -1
- package/dist/config/env.js.map +1 -1
- package/dist/http-app.js +10 -3
- package/dist/http-app.js.map +1 -1
- package/dist/http.js +6 -4
- package/dist/http.js.map +1 -1
- package/dist/index.js +6 -4
- package/dist/index.js.map +1 -1
- package/dist/lib/auth-context.d.ts +2 -1
- package/dist/lib/auth-context.js.map +1 -1
- package/dist/lib/gitlab-client.d.ts +42 -8
- package/dist/lib/gitlab-client.js +380 -42
- package/dist/lib/gitlab-client.js.map +1 -1
- package/dist/lib/network.js +12 -6
- package/dist/lib/network.js.map +1 -1
- package/dist/lib/oauth-scopes.d.ts +2 -0
- package/dist/lib/oauth-scopes.js +16 -0
- package/dist/lib/oauth-scopes.js.map +1 -0
- package/dist/lib/policy.d.ts +5 -1
- package/dist/lib/policy.js +11 -1
- package/dist/lib/policy.js.map +1 -1
- package/dist/lib/regex.d.ts +5 -0
- package/dist/lib/regex.js +111 -0
- package/dist/lib/regex.js.map +1 -0
- package/dist/lib/request-runtime.js +24 -11
- package/dist/lib/request-runtime.js.map +1 -1
- package/dist/lib/tool-capabilities.d.ts +3 -0
- package/dist/lib/tool-capabilities.js +3 -0
- package/dist/lib/tool-capabilities.js.map +1 -0
- package/dist/lib/tool-schema.d.ts +14 -0
- package/dist/lib/tool-schema.js +52 -0
- package/dist/lib/tool-schema.js.map +1 -0
- package/dist/tools/gitlab.js +496 -299
- package/dist/tools/gitlab.js.map +1 -1
- package/dist/tools/mr-code-context.d.ts +1 -1
- package/dist/tools/mr-code-context.js +2 -1
- package/dist/tools/mr-code-context.js.map +1 -1
- package/dist/types/auth.d.ts +1 -0
- package/dist/types/auth.js +2 -0
- package/dist/types/auth.js.map +1 -0
- package/dist/types/context.d.ts +1 -0
- package/docs/architecture.md +7 -6
- package/docs/authentication.md +4 -1
- package/docs/configuration.md +25 -22
- package/docs/deployment.md +9 -1
- package/docs/mcp-integration-testing-best-practices.md +381 -730
- package/docs/tools.md +76 -66
- package/package.json +1 -1
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { type SessionAuth } from "./auth-context.js";
|
|
2
|
+
import type { GitLabAuthHeader } from "../types/auth.js";
|
|
2
3
|
export interface GitLabClientOptions {
|
|
3
4
|
timeoutMs?: number;
|
|
4
5
|
apiUrls?: string[];
|
|
5
6
|
maxAttachmentBytes?: number;
|
|
7
|
+
maxLocalFileBytes?: number;
|
|
6
8
|
maxResponseBodyBytes?: number;
|
|
7
9
|
beforeRequest?: (context: GitLabBeforeRequestContext) => Promise<GitLabBeforeRequestResult | void>;
|
|
8
10
|
}
|
|
@@ -12,7 +14,7 @@ export interface GitLabRequestOptions {
|
|
|
12
14
|
headers?: HeadersInit;
|
|
13
15
|
token?: string;
|
|
14
16
|
apiUrl?: string;
|
|
15
|
-
authHeader?:
|
|
17
|
+
authHeader?: GitLabAuthHeader;
|
|
16
18
|
}
|
|
17
19
|
export interface GitLabBeforeRequestContext {
|
|
18
20
|
url: URL;
|
|
@@ -20,13 +22,13 @@ export interface GitLabBeforeRequestContext {
|
|
|
20
22
|
headers: Headers;
|
|
21
23
|
body?: BodyInit;
|
|
22
24
|
token?: string;
|
|
23
|
-
authHeader?:
|
|
25
|
+
authHeader?: GitLabAuthHeader;
|
|
24
26
|
}
|
|
25
27
|
export interface GitLabBeforeRequestResult {
|
|
26
28
|
headers?: Headers;
|
|
27
29
|
body?: BodyInit;
|
|
28
30
|
token?: string;
|
|
29
|
-
authHeader?:
|
|
31
|
+
authHeader?: GitLabAuthHeader;
|
|
30
32
|
fetchImpl?: typeof fetch;
|
|
31
33
|
}
|
|
32
34
|
export interface GitLabProject {
|
|
@@ -56,6 +58,24 @@ export interface MergeRequestCodeContextFile {
|
|
|
56
58
|
deleted_file: boolean;
|
|
57
59
|
diff: string;
|
|
58
60
|
}
|
|
61
|
+
export interface GitLabDownloadedFile {
|
|
62
|
+
fileName: string;
|
|
63
|
+
contentType: string;
|
|
64
|
+
base64: string;
|
|
65
|
+
}
|
|
66
|
+
export interface GitLabSavedFile {
|
|
67
|
+
filePath: string;
|
|
68
|
+
fileName: string;
|
|
69
|
+
contentType: string;
|
|
70
|
+
size: number;
|
|
71
|
+
}
|
|
72
|
+
export interface GitLabArtifactFileContent {
|
|
73
|
+
fileName: string;
|
|
74
|
+
contentType: string;
|
|
75
|
+
encoding: "utf8" | "base64";
|
|
76
|
+
content: string;
|
|
77
|
+
}
|
|
78
|
+
export type GitLabPipelineInputValue = string | number | boolean | Array<string | number | boolean>;
|
|
59
79
|
export declare class GitLabApiError extends Error {
|
|
60
80
|
readonly status: number;
|
|
61
81
|
readonly details?: unknown | undefined;
|
|
@@ -63,6 +83,7 @@ export declare class GitLabApiError extends Error {
|
|
|
63
83
|
}
|
|
64
84
|
export declare class GitLabClient {
|
|
65
85
|
private static readonly DEFAULT_MAX_ATTACHMENT_BYTES;
|
|
86
|
+
private static readonly DEFAULT_MAX_LOCAL_FILE_BYTES;
|
|
66
87
|
private static readonly DEFAULT_MAX_RESPONSE_BODY_BYTES;
|
|
67
88
|
private readonly baseApiUrl;
|
|
68
89
|
private readonly apiUrls;
|
|
@@ -70,6 +91,7 @@ export declare class GitLabClient {
|
|
|
70
91
|
private readonly defaultToken?;
|
|
71
92
|
private readonly timeoutMs;
|
|
72
93
|
private readonly maxAttachmentBytes;
|
|
94
|
+
private readonly maxLocalFileBytes;
|
|
73
95
|
private readonly maxResponseBodyBytes;
|
|
74
96
|
private readonly beforeRequest?;
|
|
75
97
|
constructor(baseApiUrl: string, defaultToken?: string, options?: GitLabClientOptions);
|
|
@@ -158,6 +180,7 @@ export declare class GitLabClient {
|
|
|
158
180
|
approveMergeRequest(projectId: string, mergeRequestIid: string, payload?: Record<string, unknown>, options?: GitLabRequestOptions): Promise<unknown>;
|
|
159
181
|
unapproveMergeRequest(projectId: string, mergeRequestIid: string, options?: GitLabRequestOptions): Promise<unknown>;
|
|
160
182
|
getMergeRequestApprovalState(projectId: string, mergeRequestIid: string, options?: GitLabRequestOptions): Promise<unknown>;
|
|
183
|
+
getMergeRequestConflicts(projectId: string, mergeRequestIid: string, options?: GitLabRequestOptions): Promise<unknown>;
|
|
161
184
|
listMergeRequestDiscussions(projectId: string, mergeRequestIid: string, options?: GitLabRequestOptions): Promise<unknown>;
|
|
162
185
|
createMergeRequestDiscussionNote(projectId: string, mergeRequestIid: string, discussionId: string, payload: {
|
|
163
186
|
body: string;
|
|
@@ -257,10 +280,19 @@ export declare class GitLabClient {
|
|
|
257
280
|
deleteWikiPage(projectId: string, slug: string, options?: GitLabRequestOptions): Promise<unknown>;
|
|
258
281
|
listPipelines(projectId: string, options?: GitLabRequestOptions): Promise<unknown>;
|
|
259
282
|
getPipeline(projectId: string, pipelineId: string, options?: GitLabRequestOptions): Promise<unknown>;
|
|
283
|
+
listDeployments(projectId: string, options?: GitLabRequestOptions): Promise<unknown>;
|
|
284
|
+
getDeployment(projectId: string, deploymentId: string, options?: GitLabRequestOptions): Promise<unknown>;
|
|
285
|
+
listEnvironments(projectId: string, options?: GitLabRequestOptions): Promise<unknown>;
|
|
286
|
+
getEnvironment(projectId: string, environmentId: string, options?: GitLabRequestOptions): Promise<unknown>;
|
|
260
287
|
listPipelineJobs(projectId: string, pipelineId: string, options?: GitLabRequestOptions): Promise<unknown>;
|
|
261
288
|
listPipelineTriggerJobs(projectId: string, pipelineId: string, options?: GitLabRequestOptions): Promise<unknown>;
|
|
262
289
|
getPipelineJob(projectId: string, jobId: string, options?: GitLabRequestOptions): Promise<unknown>;
|
|
263
290
|
getPipelineJobOutput(projectId: string, jobId: string, options?: GitLabRequestOptions): Promise<unknown>;
|
|
291
|
+
listJobArtifacts(projectId: string, jobId: string, options?: GitLabRequestOptions): Promise<unknown>;
|
|
292
|
+
downloadJobArtifacts(projectId: string, jobId: string, options?: GitLabRequestOptions): Promise<GitLabDownloadedFile>;
|
|
293
|
+
saveJobArtifacts(projectId: string, jobId: string, localPath?: string, options?: GitLabRequestOptions): Promise<GitLabSavedFile>;
|
|
294
|
+
getJobArtifactFile(projectId: string, jobId: string, artifactPath: string, options?: GitLabRequestOptions): Promise<GitLabArtifactFileContent>;
|
|
295
|
+
saveJobArtifactFile(projectId: string, jobId: string, artifactPath: string, localPath?: string, options?: GitLabRequestOptions): Promise<GitLabSavedFile>;
|
|
264
296
|
createPipeline(projectId: string, payload: {
|
|
265
297
|
ref: string;
|
|
266
298
|
variables?: Array<{
|
|
@@ -268,6 +300,7 @@ export declare class GitLabClient {
|
|
|
268
300
|
value: string;
|
|
269
301
|
variable_type?: "env_var" | "file";
|
|
270
302
|
}>;
|
|
303
|
+
inputs?: Record<string, GitLabPipelineInputValue>;
|
|
271
304
|
}, options?: GitLabRequestOptions): Promise<unknown>;
|
|
272
305
|
retryPipeline(projectId: string, pipelineId: string, options?: GitLabRequestOptions): Promise<unknown>;
|
|
273
306
|
cancelPipeline(projectId: string, pipelineId: string, options?: GitLabRequestOptions): Promise<unknown>;
|
|
@@ -309,12 +342,13 @@ export declare class GitLabClient {
|
|
|
309
342
|
getProjectEvents(projectId: string, options?: GitLabRequestOptions): Promise<unknown>;
|
|
310
343
|
uploadMarkdown(projectId: string, content: string, filename: string, options?: GitLabRequestOptions): Promise<unknown>;
|
|
311
344
|
uploadMarkdownFile(projectId: string, filePath: string, options?: GitLabRequestOptions): Promise<unknown>;
|
|
312
|
-
downloadAttachment(urlOrPath: string, options?: GitLabRequestOptions): Promise<
|
|
313
|
-
fileName: string;
|
|
314
|
-
contentType: string;
|
|
315
|
-
base64: string;
|
|
316
|
-
}>;
|
|
345
|
+
downloadAttachment(urlOrPath: string, options?: GitLabRequestOptions): Promise<GitLabDownloadedFile>;
|
|
317
346
|
executeGraphql(query: string, variables: Record<string, unknown> | undefined, options?: GitLabRequestOptions): Promise<unknown>;
|
|
347
|
+
private downloadFile;
|
|
348
|
+
private saveDownloadedFile;
|
|
349
|
+
private downloadFileContent;
|
|
350
|
+
private fetchRawResponse;
|
|
351
|
+
private toDownloadError;
|
|
318
352
|
get(path: string, options?: GitLabRequestOptions): Promise<unknown>;
|
|
319
353
|
post(path: string, options?: GitLabRequestOptions): Promise<unknown>;
|
|
320
354
|
put(path: string, options?: GitLabRequestOptions): Promise<unknown>;
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { constants as fsConstants } from "node:fs";
|
|
1
3
|
import * as fs from "node:fs/promises";
|
|
2
4
|
import * as path from "node:path";
|
|
3
5
|
import { getSessionAuth } from "./auth-context.js";
|
|
@@ -13,6 +15,7 @@ export class GitLabApiError extends Error {
|
|
|
13
15
|
}
|
|
14
16
|
export class GitLabClient {
|
|
15
17
|
static DEFAULT_MAX_ATTACHMENT_BYTES = 25 * 1024 * 1024;
|
|
18
|
+
static DEFAULT_MAX_LOCAL_FILE_BYTES = 250 * 1024 * 1024;
|
|
16
19
|
static DEFAULT_MAX_RESPONSE_BODY_BYTES = 25 * 1024 * 1024;
|
|
17
20
|
baseApiUrl;
|
|
18
21
|
apiUrls;
|
|
@@ -20,6 +23,7 @@ export class GitLabClient {
|
|
|
20
23
|
defaultToken;
|
|
21
24
|
timeoutMs;
|
|
22
25
|
maxAttachmentBytes;
|
|
26
|
+
maxLocalFileBytes;
|
|
23
27
|
maxResponseBodyBytes;
|
|
24
28
|
beforeRequest;
|
|
25
29
|
constructor(baseApiUrl, defaultToken, options = {}) {
|
|
@@ -32,6 +36,7 @@ export class GitLabClient {
|
|
|
32
36
|
this.timeoutMs = options.timeoutMs ?? 20_000;
|
|
33
37
|
this.maxAttachmentBytes =
|
|
34
38
|
options.maxAttachmentBytes ?? GitLabClient.DEFAULT_MAX_ATTACHMENT_BYTES;
|
|
39
|
+
this.maxLocalFileBytes = options.maxLocalFileBytes ?? GitLabClient.DEFAULT_MAX_LOCAL_FILE_BYTES;
|
|
35
40
|
this.maxResponseBodyBytes =
|
|
36
41
|
options.maxResponseBodyBytes ?? GitLabClient.DEFAULT_MAX_RESPONSE_BODY_BYTES;
|
|
37
42
|
this.beforeRequest = options.beforeRequest;
|
|
@@ -229,6 +234,9 @@ export class GitLabClient {
|
|
|
229
234
|
getMergeRequestApprovalState(projectId, mergeRequestIid, options = {}) {
|
|
230
235
|
return this.get(`/projects/${encode(projectId)}/merge_requests/${encode(mergeRequestIid)}/approval_state`, options);
|
|
231
236
|
}
|
|
237
|
+
getMergeRequestConflicts(projectId, mergeRequestIid, options = {}) {
|
|
238
|
+
return this.get(`/projects/${encode(projectId)}/merge_requests/${encode(mergeRequestIid)}/conflicts`, options);
|
|
239
|
+
}
|
|
232
240
|
listMergeRequestDiscussions(projectId, mergeRequestIid, options = {}) {
|
|
233
241
|
return this.get(`/projects/${encode(projectId)}/merge_requests/${encode(mergeRequestIid)}/discussions`, options);
|
|
234
242
|
}
|
|
@@ -501,6 +509,18 @@ export class GitLabClient {
|
|
|
501
509
|
getPipeline(projectId, pipelineId, options = {}) {
|
|
502
510
|
return this.get(`/projects/${encode(projectId)}/pipelines/${encode(pipelineId)}`, options);
|
|
503
511
|
}
|
|
512
|
+
listDeployments(projectId, options = {}) {
|
|
513
|
+
return this.get(`/projects/${encode(projectId)}/deployments`, options);
|
|
514
|
+
}
|
|
515
|
+
getDeployment(projectId, deploymentId, options = {}) {
|
|
516
|
+
return this.get(`/projects/${encode(projectId)}/deployments/${encode(deploymentId)}`, options);
|
|
517
|
+
}
|
|
518
|
+
listEnvironments(projectId, options = {}) {
|
|
519
|
+
return this.get(`/projects/${encode(projectId)}/environments`, options);
|
|
520
|
+
}
|
|
521
|
+
getEnvironment(projectId, environmentId, options = {}) {
|
|
522
|
+
return this.get(`/projects/${encode(projectId)}/environments/${encode(environmentId)}`, options);
|
|
523
|
+
}
|
|
504
524
|
listPipelineJobs(projectId, pipelineId, options = {}) {
|
|
505
525
|
return this.get(`/projects/${encode(projectId)}/pipelines/${encode(pipelineId)}/jobs`, options);
|
|
506
526
|
}
|
|
@@ -513,6 +533,47 @@ export class GitLabClient {
|
|
|
513
533
|
getPipelineJobOutput(projectId, jobId, options = {}) {
|
|
514
534
|
return this.get(`/projects/${encode(projectId)}/jobs/${encode(jobId)}/trace`, options);
|
|
515
535
|
}
|
|
536
|
+
listJobArtifacts(projectId, jobId, options = {}) {
|
|
537
|
+
return this.get(`/projects/${encode(projectId)}/jobs/${encode(jobId)}/artifacts/tree`, options);
|
|
538
|
+
}
|
|
539
|
+
async downloadJobArtifacts(projectId, jobId, options = {}) {
|
|
540
|
+
const requestConfig = this.resolveRequestConfig(options);
|
|
541
|
+
const url = new URL(`projects/${encode(projectId)}/jobs/${encode(jobId)}/artifacts`, `${requestConfig.apiUrl}/`);
|
|
542
|
+
return this.downloadFile(url, {
|
|
543
|
+
headers: options.headers,
|
|
544
|
+
token: requestConfig.token,
|
|
545
|
+
authHeader: requestConfig.authHeader
|
|
546
|
+
}, "Job artifacts", `artifacts-job-${jobId}.zip`);
|
|
547
|
+
}
|
|
548
|
+
async saveJobArtifacts(projectId, jobId, localPath, options = {}) {
|
|
549
|
+
const requestConfig = this.resolveRequestConfig(options);
|
|
550
|
+
const url = new URL(`projects/${encode(projectId)}/jobs/${encode(jobId)}/artifacts`, `${requestConfig.apiUrl}/`);
|
|
551
|
+
return this.saveDownloadedFile(url, {
|
|
552
|
+
headers: options.headers,
|
|
553
|
+
token: requestConfig.token,
|
|
554
|
+
authHeader: requestConfig.authHeader
|
|
555
|
+
}, "Job artifacts", `artifacts-job-${jobId}.zip`, localPath);
|
|
556
|
+
}
|
|
557
|
+
async getJobArtifactFile(projectId, jobId, artifactPath, options = {}) {
|
|
558
|
+
const requestConfig = this.resolveRequestConfig(options);
|
|
559
|
+
const encodedArtifactPath = encodeSlashPath(artifactPath);
|
|
560
|
+
const url = new URL(`projects/${encode(projectId)}/jobs/${encode(jobId)}/artifacts/${encodedArtifactPath}`, `${requestConfig.apiUrl}/`);
|
|
561
|
+
return this.downloadFileContent(url, {
|
|
562
|
+
headers: options.headers,
|
|
563
|
+
token: requestConfig.token,
|
|
564
|
+
authHeader: requestConfig.authHeader
|
|
565
|
+
}, "Job artifact file", path.basename(artifactPath) || `artifact-${jobId}`);
|
|
566
|
+
}
|
|
567
|
+
async saveJobArtifactFile(projectId, jobId, artifactPath, localPath, options = {}) {
|
|
568
|
+
const requestConfig = this.resolveRequestConfig(options);
|
|
569
|
+
const encodedArtifactPath = encodeSlashPath(artifactPath);
|
|
570
|
+
const url = new URL(`projects/${encode(projectId)}/jobs/${encode(jobId)}/artifacts/${encodedArtifactPath}`, `${requestConfig.apiUrl}/`);
|
|
571
|
+
return this.saveDownloadedFile(url, {
|
|
572
|
+
headers: options.headers,
|
|
573
|
+
token: requestConfig.token,
|
|
574
|
+
authHeader: requestConfig.authHeader
|
|
575
|
+
}, "Job artifact file", path.basename(artifactPath) || `artifact-${jobId}`, localPath);
|
|
576
|
+
}
|
|
516
577
|
createPipeline(projectId, payload, options = {}) {
|
|
517
578
|
return this.post(`/projects/${encode(projectId)}/pipeline`, {
|
|
518
579
|
...options,
|
|
@@ -705,21 +766,126 @@ export class GitLabClient {
|
|
|
705
766
|
async downloadAttachment(urlOrPath, options = {}) {
|
|
706
767
|
const requestConfig = this.resolveRequestConfig(options);
|
|
707
768
|
const url = this.resolveAttachmentUrl(urlOrPath, requestConfig.apiUrl);
|
|
769
|
+
return this.downloadFile(url, {
|
|
770
|
+
headers: options.headers,
|
|
771
|
+
token: requestConfig.token,
|
|
772
|
+
authHeader: requestConfig.authHeader
|
|
773
|
+
}, "Attachment", `attachment-${Date.now()}`);
|
|
774
|
+
}
|
|
775
|
+
// graphql
|
|
776
|
+
executeGraphql(query, variables, options = {}) {
|
|
777
|
+
const requestConfig = this.resolveRequestConfig(options);
|
|
778
|
+
const endpoint = buildGraphqlEndpoint(requestConfig.apiUrl);
|
|
779
|
+
return this.rawRequest(endpoint, {
|
|
780
|
+
method: "POST",
|
|
781
|
+
headers: {
|
|
782
|
+
"Content-Type": "application/json",
|
|
783
|
+
...(options.headers ?? {})
|
|
784
|
+
},
|
|
785
|
+
body: JSON.stringify({ query, variables }),
|
|
786
|
+
token: requestConfig.token,
|
|
787
|
+
authHeader: requestConfig.authHeader
|
|
788
|
+
});
|
|
789
|
+
}
|
|
790
|
+
async downloadFile(url, options, label, fallbackFileName) {
|
|
791
|
+
const response = await this.fetchRawResponse(url, {
|
|
792
|
+
method: "GET",
|
|
793
|
+
headers: options.headers,
|
|
794
|
+
token: options.token,
|
|
795
|
+
authHeader: options.authHeader
|
|
796
|
+
});
|
|
797
|
+
if (!response.ok) {
|
|
798
|
+
throw await this.toDownloadError(response, label);
|
|
799
|
+
}
|
|
800
|
+
assertContentLengthWithinLimit(response, this.maxAttachmentBytes, label);
|
|
801
|
+
const contentType = response.headers.get("content-type") ?? "application/octet-stream";
|
|
802
|
+
const disposition = response.headers.get("content-disposition") ?? "";
|
|
803
|
+
const fileName = extractFileName(disposition) ?? fallbackFileName;
|
|
804
|
+
const bytes = await readResponseBytesWithLimit(response, this.maxAttachmentBytes, label);
|
|
805
|
+
return {
|
|
806
|
+
fileName,
|
|
807
|
+
contentType,
|
|
808
|
+
base64: bytes.toString("base64")
|
|
809
|
+
};
|
|
810
|
+
}
|
|
811
|
+
async saveDownloadedFile(url, options, label, fallbackFileName, localPath) {
|
|
812
|
+
const response = await this.fetchRawResponse(url, {
|
|
813
|
+
method: "GET",
|
|
814
|
+
headers: options.headers,
|
|
815
|
+
token: options.token,
|
|
816
|
+
authHeader: options.authHeader
|
|
817
|
+
});
|
|
818
|
+
if (!response.ok) {
|
|
819
|
+
throw await this.toDownloadError(response, label);
|
|
820
|
+
}
|
|
821
|
+
assertContentLengthWithinLimit(response, this.maxLocalFileBytes, label);
|
|
822
|
+
const contentType = response.headers.get("content-type") ?? "application/octet-stream";
|
|
823
|
+
const disposition = response.headers.get("content-disposition") ?? "";
|
|
824
|
+
const resolvedFileName = resolveDownloadedFileName(disposition, fallbackFileName);
|
|
825
|
+
const baseDirectory = localPath ? path.resolve(localPath) : process.cwd();
|
|
826
|
+
const tempFilePath = buildTemporaryDownloadPath(baseDirectory, resolvedFileName);
|
|
827
|
+
await fs.mkdir(baseDirectory, { recursive: true });
|
|
828
|
+
const size = await writeResponseToFileWithLimit(response, tempFilePath, this.maxLocalFileBytes, label);
|
|
829
|
+
const filePath = await commitDownloadedFile(tempFilePath, baseDirectory, resolvedFileName);
|
|
830
|
+
const fileName = path.basename(filePath);
|
|
831
|
+
return {
|
|
832
|
+
filePath,
|
|
833
|
+
fileName,
|
|
834
|
+
contentType,
|
|
835
|
+
size
|
|
836
|
+
};
|
|
837
|
+
}
|
|
838
|
+
async downloadFileContent(url, options, label, fallbackFileName) {
|
|
839
|
+
const response = await this.fetchRawResponse(url, {
|
|
840
|
+
method: "GET",
|
|
841
|
+
headers: options.headers,
|
|
842
|
+
token: options.token,
|
|
843
|
+
authHeader: options.authHeader
|
|
844
|
+
});
|
|
845
|
+
if (!response.ok) {
|
|
846
|
+
throw await this.toDownloadError(response, label);
|
|
847
|
+
}
|
|
848
|
+
assertContentLengthWithinLimit(response, this.maxAttachmentBytes, label);
|
|
849
|
+
const contentType = response.headers.get("content-type") ?? "application/octet-stream";
|
|
850
|
+
const disposition = response.headers.get("content-disposition") ?? "";
|
|
851
|
+
const fileName = extractFileName(disposition) ?? fallbackFileName;
|
|
852
|
+
const bytes = await readResponseBytesWithLimit(response, this.maxAttachmentBytes, label);
|
|
853
|
+
if (isTextLikeContent(contentType, fileName)) {
|
|
854
|
+
return {
|
|
855
|
+
fileName,
|
|
856
|
+
contentType,
|
|
857
|
+
encoding: "utf8",
|
|
858
|
+
content: bytes.toString("utf8")
|
|
859
|
+
};
|
|
860
|
+
}
|
|
861
|
+
return {
|
|
862
|
+
fileName,
|
|
863
|
+
contentType,
|
|
864
|
+
encoding: "base64",
|
|
865
|
+
content: bytes.toString("base64")
|
|
866
|
+
};
|
|
867
|
+
}
|
|
868
|
+
async fetchRawResponse(url, options) {
|
|
708
869
|
let headers = new Headers(options.headers);
|
|
709
|
-
let
|
|
710
|
-
let
|
|
870
|
+
let requestBody = options.body;
|
|
871
|
+
let token = options.token;
|
|
872
|
+
let authHeader = options.authHeader;
|
|
711
873
|
let fetchImpl = fetch;
|
|
712
874
|
if (this.beforeRequest) {
|
|
713
875
|
const override = await this.beforeRequest({
|
|
714
876
|
url,
|
|
715
|
-
method:
|
|
877
|
+
method: options.method,
|
|
716
878
|
headers,
|
|
879
|
+
body: requestBody,
|
|
717
880
|
token,
|
|
718
|
-
authHeader:
|
|
881
|
+
authHeader: options.authHeader
|
|
719
882
|
});
|
|
720
883
|
if (override?.headers) {
|
|
721
884
|
headers = override.headers;
|
|
722
885
|
}
|
|
886
|
+
if (override?.body !== undefined) {
|
|
887
|
+
requestBody = override.body;
|
|
888
|
+
}
|
|
723
889
|
if (override?.token !== undefined) {
|
|
724
890
|
token = override.token;
|
|
725
891
|
}
|
|
@@ -731,48 +897,24 @@ export class GitLabClient {
|
|
|
731
897
|
}
|
|
732
898
|
}
|
|
733
899
|
this.attachAuth(headers, token, authHeader);
|
|
734
|
-
|
|
735
|
-
method:
|
|
900
|
+
return fetchImpl(url, {
|
|
901
|
+
method: options.method,
|
|
902
|
+
body: requestBody,
|
|
736
903
|
headers,
|
|
737
904
|
signal: AbortSignal.timeout(this.timeoutMs)
|
|
738
905
|
});
|
|
739
|
-
if (!response.ok) {
|
|
740
|
-
let details;
|
|
741
|
-
try {
|
|
742
|
-
details = await this.parseResponseBody(response);
|
|
743
|
-
}
|
|
744
|
-
catch (error) {
|
|
745
|
-
details = {
|
|
746
|
-
message: error instanceof Error ? error.message : "Failed to read GitLab error response"
|
|
747
|
-
};
|
|
748
|
-
}
|
|
749
|
-
throw new GitLabApiError(`GitLab attachment download failed: ${response.status} ${response.statusText}`, response.status, details);
|
|
750
|
-
}
|
|
751
|
-
assertContentLengthWithinLimit(response, this.maxAttachmentBytes, "Attachment");
|
|
752
|
-
const contentType = response.headers.get("content-type") ?? "application/octet-stream";
|
|
753
|
-
const disposition = response.headers.get("content-disposition") ?? "";
|
|
754
|
-
const fileName = extractFileName(disposition) ?? `attachment-${Date.now()}`;
|
|
755
|
-
const bytes = await readResponseBytesWithLimit(response, this.maxAttachmentBytes, "Attachment");
|
|
756
|
-
return {
|
|
757
|
-
fileName,
|
|
758
|
-
contentType,
|
|
759
|
-
base64: bytes.toString("base64")
|
|
760
|
-
};
|
|
761
906
|
}
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
token: requestConfig.token,
|
|
774
|
-
authHeader: requestConfig.authHeader
|
|
775
|
-
});
|
|
907
|
+
async toDownloadError(response, label) {
|
|
908
|
+
let details;
|
|
909
|
+
try {
|
|
910
|
+
details = await this.parseResponseBody(response);
|
|
911
|
+
}
|
|
912
|
+
catch (error) {
|
|
913
|
+
details = {
|
|
914
|
+
message: error instanceof Error ? error.message : "Failed to read GitLab error response"
|
|
915
|
+
};
|
|
916
|
+
}
|
|
917
|
+
return new GitLabApiError(`GitLab ${label.toLowerCase()} download failed: ${response.status} ${response.statusText}`, response.status, details);
|
|
776
918
|
}
|
|
777
919
|
// generic methods
|
|
778
920
|
get(path, options = {}) {
|
|
@@ -920,6 +1062,10 @@ export class GitLabClient {
|
|
|
920
1062
|
headers.set("Authorization", `Bearer ${token}`);
|
|
921
1063
|
return;
|
|
922
1064
|
}
|
|
1065
|
+
if (authHeader === "job-token") {
|
|
1066
|
+
headers.set("JOB-TOKEN", token);
|
|
1067
|
+
return;
|
|
1068
|
+
}
|
|
923
1069
|
headers.set("PRIVATE-TOKEN", token);
|
|
924
1070
|
}
|
|
925
1071
|
}
|
|
@@ -969,6 +1115,149 @@ function extractFileName(contentDisposition) {
|
|
|
969
1115
|
}
|
|
970
1116
|
return decodeURIComponent(quoted[1] ?? "");
|
|
971
1117
|
}
|
|
1118
|
+
function resolveDownloadedFileName(contentDisposition, fallbackFileName) {
|
|
1119
|
+
const extracted = extractFileName(contentDisposition);
|
|
1120
|
+
const sanitized = sanitizeDownloadedFileName(extracted);
|
|
1121
|
+
return sanitized ?? sanitizeDownloadedFileName(fallbackFileName) ?? "downloaded-file";
|
|
1122
|
+
}
|
|
1123
|
+
function sanitizeDownloadedFileName(fileName) {
|
|
1124
|
+
if (!fileName) {
|
|
1125
|
+
return undefined;
|
|
1126
|
+
}
|
|
1127
|
+
const normalized = fileName.replace(/\\/g, "/");
|
|
1128
|
+
const basename = path.posix.basename(normalized).trim();
|
|
1129
|
+
if (!basename || basename === "." || basename === "..") {
|
|
1130
|
+
return undefined;
|
|
1131
|
+
}
|
|
1132
|
+
return basename;
|
|
1133
|
+
}
|
|
1134
|
+
async function commitDownloadedFile(tempFilePath, baseDirectory, fileName) {
|
|
1135
|
+
const parsed = path.parse(fileName);
|
|
1136
|
+
try {
|
|
1137
|
+
for (let suffix = 0; suffix < 10_000; suffix += 1) {
|
|
1138
|
+
const candidateName = suffix === 0 ? fileName : `${parsed.name || "downloaded-file"}-${suffix}${parsed.ext}`;
|
|
1139
|
+
const candidatePath = path.join(baseDirectory, candidateName);
|
|
1140
|
+
try {
|
|
1141
|
+
await fs.link(tempFilePath, candidatePath);
|
|
1142
|
+
return candidatePath;
|
|
1143
|
+
}
|
|
1144
|
+
catch (error) {
|
|
1145
|
+
if (isFileExistsError(error)) {
|
|
1146
|
+
continue;
|
|
1147
|
+
}
|
|
1148
|
+
if (!supportsAtomicLinkFallback(error)) {
|
|
1149
|
+
throw error;
|
|
1150
|
+
}
|
|
1151
|
+
try {
|
|
1152
|
+
await fs.copyFile(tempFilePath, candidatePath, fsConstants.COPYFILE_EXCL);
|
|
1153
|
+
return candidatePath;
|
|
1154
|
+
}
|
|
1155
|
+
catch (copyError) {
|
|
1156
|
+
if (isFileExistsError(copyError)) {
|
|
1157
|
+
continue;
|
|
1158
|
+
}
|
|
1159
|
+
throw copyError;
|
|
1160
|
+
}
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1163
|
+
throw new Error(`Unable to find an available local file name for '${fileName}'`);
|
|
1164
|
+
}
|
|
1165
|
+
finally {
|
|
1166
|
+
await fs.rm(tempFilePath, { force: true });
|
|
1167
|
+
}
|
|
1168
|
+
}
|
|
1169
|
+
function buildTemporaryDownloadPath(baseDirectory, fileName) {
|
|
1170
|
+
return path.join(baseDirectory, `.${fileName}.${randomUUID()}.tmp`);
|
|
1171
|
+
}
|
|
1172
|
+
function isFileExistsError(error) {
|
|
1173
|
+
return (typeof error === "object" &&
|
|
1174
|
+
error !== null &&
|
|
1175
|
+
"code" in error &&
|
|
1176
|
+
error.code === "EEXIST");
|
|
1177
|
+
}
|
|
1178
|
+
function supportsAtomicLinkFallback(error) {
|
|
1179
|
+
if (typeof error !== "object" || error === null || !("code" in error)) {
|
|
1180
|
+
return false;
|
|
1181
|
+
}
|
|
1182
|
+
return ["EMLINK", "ENOTSUP", "EPERM"].includes(String(error.code));
|
|
1183
|
+
}
|
|
1184
|
+
const TEXT_CONTENT_TYPE_HINTS = [
|
|
1185
|
+
"application/json",
|
|
1186
|
+
"application/ld+json",
|
|
1187
|
+
"application/problem+json",
|
|
1188
|
+
"application/graphql",
|
|
1189
|
+
"application/javascript",
|
|
1190
|
+
"application/typescript",
|
|
1191
|
+
"application/xml",
|
|
1192
|
+
"application/xhtml+xml",
|
|
1193
|
+
"application/yaml",
|
|
1194
|
+
"application/x-yaml",
|
|
1195
|
+
"application/toml",
|
|
1196
|
+
"application/csv",
|
|
1197
|
+
"application/sql"
|
|
1198
|
+
];
|
|
1199
|
+
const TEXT_FILE_EXTENSIONS = new Set([
|
|
1200
|
+
".c",
|
|
1201
|
+
".cc",
|
|
1202
|
+
".cfg",
|
|
1203
|
+
".conf",
|
|
1204
|
+
".cpp",
|
|
1205
|
+
".cs",
|
|
1206
|
+
".css",
|
|
1207
|
+
".csv",
|
|
1208
|
+
".dockerfile",
|
|
1209
|
+
".env",
|
|
1210
|
+
".go",
|
|
1211
|
+
".graphql",
|
|
1212
|
+
".h",
|
|
1213
|
+
".hpp",
|
|
1214
|
+
".html",
|
|
1215
|
+
".ini",
|
|
1216
|
+
".java",
|
|
1217
|
+
".js",
|
|
1218
|
+
".json",
|
|
1219
|
+
".jsx",
|
|
1220
|
+
".kt",
|
|
1221
|
+
".kts",
|
|
1222
|
+
".log",
|
|
1223
|
+
".md",
|
|
1224
|
+
".mjs",
|
|
1225
|
+
".php",
|
|
1226
|
+
".properties",
|
|
1227
|
+
".py",
|
|
1228
|
+
".rb",
|
|
1229
|
+
".rs",
|
|
1230
|
+
".scss",
|
|
1231
|
+
".sh",
|
|
1232
|
+
".sql",
|
|
1233
|
+
".svg",
|
|
1234
|
+
".swift",
|
|
1235
|
+
".toml",
|
|
1236
|
+
".ts",
|
|
1237
|
+
".tsx",
|
|
1238
|
+
".txt",
|
|
1239
|
+
".xml",
|
|
1240
|
+
".yaml",
|
|
1241
|
+
".yml"
|
|
1242
|
+
]);
|
|
1243
|
+
function isTextLikeContent(contentType, fileName) {
|
|
1244
|
+
const normalizedContentType = contentType.split(";")[0]?.trim().toLowerCase() ?? "";
|
|
1245
|
+
if (normalizedContentType.startsWith("text/")) {
|
|
1246
|
+
return true;
|
|
1247
|
+
}
|
|
1248
|
+
if (TEXT_CONTENT_TYPE_HINTS.includes(normalizedContentType)) {
|
|
1249
|
+
return true;
|
|
1250
|
+
}
|
|
1251
|
+
return isTextLikeFileName(fileName);
|
|
1252
|
+
}
|
|
1253
|
+
function isTextLikeFileName(fileName) {
|
|
1254
|
+
const normalizedFileName = fileName.trim().toLowerCase();
|
|
1255
|
+
if (normalizedFileName === "dockerfile" || normalizedFileName.endsWith(".gitignore")) {
|
|
1256
|
+
return true;
|
|
1257
|
+
}
|
|
1258
|
+
const extension = path.extname(normalizedFileName);
|
|
1259
|
+
return extension.length > 0 && TEXT_FILE_EXTENSIONS.has(extension);
|
|
1260
|
+
}
|
|
972
1261
|
function parseContentLength(value) {
|
|
973
1262
|
if (!value) {
|
|
974
1263
|
return undefined;
|
|
@@ -1022,4 +1311,53 @@ async function readResponseBytesWithLimit(response, maxBytes, label) {
|
|
|
1022
1311
|
}
|
|
1023
1312
|
return Buffer.concat(chunks, total);
|
|
1024
1313
|
}
|
|
1314
|
+
async function writeResponseToFileWithLimit(response, filePath, maxBytes, label) {
|
|
1315
|
+
if (!response.body) {
|
|
1316
|
+
const bytes = Buffer.from(await response.arrayBuffer());
|
|
1317
|
+
if (bytes.length > maxBytes) {
|
|
1318
|
+
throw new Error(`${label} size ${bytes.length} bytes exceeds limit ${maxBytes} bytes`);
|
|
1319
|
+
}
|
|
1320
|
+
await fs.writeFile(filePath, bytes);
|
|
1321
|
+
return bytes.length;
|
|
1322
|
+
}
|
|
1323
|
+
const fileHandle = await fs.open(filePath, "w");
|
|
1324
|
+
const reader = response.body.getReader();
|
|
1325
|
+
let total = 0;
|
|
1326
|
+
let success = false;
|
|
1327
|
+
try {
|
|
1328
|
+
while (true) {
|
|
1329
|
+
const { done, value } = await reader.read();
|
|
1330
|
+
if (done) {
|
|
1331
|
+
success = true;
|
|
1332
|
+
return total;
|
|
1333
|
+
}
|
|
1334
|
+
if (!value) {
|
|
1335
|
+
continue;
|
|
1336
|
+
}
|
|
1337
|
+
total += value.byteLength;
|
|
1338
|
+
if (total > maxBytes) {
|
|
1339
|
+
await reader.cancel();
|
|
1340
|
+
throw new Error(`${label} size ${total} bytes exceeds limit ${maxBytes} bytes`);
|
|
1341
|
+
}
|
|
1342
|
+
await writeBufferToFile(fileHandle, Buffer.from(value), total - value.byteLength);
|
|
1343
|
+
}
|
|
1344
|
+
}
|
|
1345
|
+
finally {
|
|
1346
|
+
reader.releaseLock();
|
|
1347
|
+
await fileHandle.close();
|
|
1348
|
+
if (!success) {
|
|
1349
|
+
await fs.rm(filePath, { force: true });
|
|
1350
|
+
}
|
|
1351
|
+
}
|
|
1352
|
+
}
|
|
1353
|
+
async function writeBufferToFile(fileHandle, buffer, startPosition) {
|
|
1354
|
+
let offset = 0;
|
|
1355
|
+
while (offset < buffer.length) {
|
|
1356
|
+
const { bytesWritten } = await fileHandle.write(buffer, offset, buffer.length - offset, startPosition + offset);
|
|
1357
|
+
if (bytesWritten <= 0) {
|
|
1358
|
+
throw new Error("Failed to write download chunk");
|
|
1359
|
+
}
|
|
1360
|
+
offset += bytesWritten;
|
|
1361
|
+
}
|
|
1362
|
+
}
|
|
1025
1363
|
//# sourceMappingURL=gitlab-client.js.map
|