gitlab-mcp 1.2.0 → 1.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.
Files changed (42) hide show
  1. package/README.md +42 -28
  2. package/dist/config/dotenv.d.ts +2 -0
  3. package/dist/config/dotenv.js +44 -0
  4. package/dist/config/dotenv.js.map +1 -0
  5. package/dist/config/env.d.ts +3 -2
  6. package/dist/config/env.js +17 -2
  7. package/dist/config/env.js.map +1 -1
  8. package/dist/http-app.js +10 -3
  9. package/dist/http-app.js.map +1 -1
  10. package/dist/http.js +5 -4
  11. package/dist/http.js.map +1 -1
  12. package/dist/index.js +5 -4
  13. package/dist/index.js.map +1 -1
  14. package/dist/lib/auth-context.d.ts +2 -1
  15. package/dist/lib/auth-context.js.map +1 -1
  16. package/dist/lib/gitlab-client.d.ts +42 -8
  17. package/dist/lib/gitlab-client.js +380 -42
  18. package/dist/lib/gitlab-client.js.map +1 -1
  19. package/dist/lib/network.js +12 -6
  20. package/dist/lib/network.js.map +1 -1
  21. package/dist/lib/oauth-scopes.d.ts +2 -0
  22. package/dist/lib/oauth-scopes.js +16 -0
  23. package/dist/lib/oauth-scopes.js.map +1 -0
  24. package/dist/lib/regex.d.ts +5 -0
  25. package/dist/lib/regex.js +111 -0
  26. package/dist/lib/regex.js.map +1 -0
  27. package/dist/lib/request-runtime.js +24 -11
  28. package/dist/lib/request-runtime.js.map +1 -1
  29. package/dist/tools/gitlab.js +193 -3
  30. package/dist/tools/gitlab.js.map +1 -1
  31. package/dist/tools/mr-code-context.d.ts +1 -1
  32. package/dist/types/auth.d.ts +1 -0
  33. package/dist/types/auth.js +2 -0
  34. package/dist/types/auth.js.map +1 -0
  35. package/dist/types/context.d.ts +1 -0
  36. package/docs/architecture.md +11 -11
  37. package/docs/authentication.md +4 -1
  38. package/docs/configuration.md +29 -22
  39. package/docs/deployment.md +2 -0
  40. package/docs/mcp-integration-testing-best-practices.md +381 -730
  41. package/docs/tools.md +24 -14
  42. 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?: "authorization" | "private-token";
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?: "authorization" | "private-token";
25
+ authHeader?: GitLabAuthHeader;
24
26
  }
25
27
  export interface GitLabBeforeRequestResult {
26
28
  headers?: Headers;
27
29
  body?: BodyInit;
28
30
  token?: string;
29
- authHeader?: "authorization" | "private-token";
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 token = requestConfig.token;
710
- let authHeader = requestConfig.authHeader;
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: "GET",
877
+ method: options.method,
716
878
  headers,
879
+ body: requestBody,
717
880
  token,
718
- authHeader: requestConfig.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
- const response = await fetchImpl(url, {
735
- method: "GET",
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
- // graphql
763
- executeGraphql(query, variables, options = {}) {
764
- const requestConfig = this.resolveRequestConfig(options);
765
- const endpoint = buildGraphqlEndpoint(requestConfig.apiUrl);
766
- return this.rawRequest(endpoint, {
767
- method: "POST",
768
- headers: {
769
- "Content-Type": "application/json",
770
- ...(options.headers ?? {})
771
- },
772
- body: JSON.stringify({ query, variables }),
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