@ttpears/gitlab-mcp-server 1.7.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.
@@ -0,0 +1,1036 @@
1
+ import { GraphQLClient, gql, ClientError } from 'graphql-request';
2
+ import { buildClientSchema, getIntrospectionQuery } from 'graphql';
3
+ export class GitLabAPIError extends Error {
4
+ code;
5
+ statusCode;
6
+ isRetryable;
7
+ isRateLimited;
8
+ retryAfter;
9
+ originalError;
10
+ constructor(message, options = {}) {
11
+ super(message);
12
+ this.name = 'GitLabAPIError';
13
+ this.code = options.code || 'UNKNOWN_ERROR';
14
+ this.statusCode = options.statusCode;
15
+ this.isRetryable = options.isRetryable ?? false;
16
+ this.isRateLimited = options.isRateLimited ?? false;
17
+ this.retryAfter = options.retryAfter;
18
+ this.originalError = options.originalError;
19
+ }
20
+ }
21
+ // Retry configuration
22
+ const RETRY_CONFIG = {
23
+ maxRetries: 3,
24
+ baseDelayMs: 1000,
25
+ maxDelayMs: 10000,
26
+ retryableStatusCodes: [408, 429, 500, 502, 503, 504],
27
+ retryableErrorCodes: ['ECONNRESET', 'ETIMEDOUT', 'ENOTFOUND', 'ECONNREFUSED', 'EAI_AGAIN'],
28
+ };
29
+ export class GitLabGraphQLClient {
30
+ baseClient = null;
31
+ config;
32
+ schema = null;
33
+ userClients = new Map();
34
+ constructor(config) {
35
+ this.config = config;
36
+ // Create base client for shared operations (if shared token provided)
37
+ if (config.sharedAccessToken) {
38
+ this.baseClient = this.createClient(config.gitlabUrl, config.sharedAccessToken);
39
+ }
40
+ }
41
+ createClient(gitlabUrl, accessToken) {
42
+ const endpoint = `${gitlabUrl.replace(/\/$/, '')}/api/graphql`;
43
+ const timeoutMs = this.config.defaultTimeout || 30000;
44
+ return new GraphQLClient(endpoint, {
45
+ headers: {
46
+ Authorization: `Bearer ${accessToken}`,
47
+ 'Content-Type': 'application/json',
48
+ },
49
+ // Configure fetch with timeout using AbortController
50
+ fetch: (url, options) => {
51
+ const controller = new AbortController();
52
+ const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
53
+ return fetch(url, {
54
+ ...options,
55
+ signal: controller.signal,
56
+ }).finally(() => clearTimeout(timeoutId));
57
+ },
58
+ });
59
+ }
60
+ /**
61
+ * Parse GraphQL/HTTP errors into structured GitLabAPIError
62
+ */
63
+ parseError(error) {
64
+ // Handle ClientError from graphql-request (contains response details)
65
+ if (error instanceof ClientError) {
66
+ const response = error.response;
67
+ const statusCode = response.status;
68
+ const errors = response.errors;
69
+ if (statusCode === 429) {
70
+ const headers = response.headers;
71
+ const retryAfterHeader = headers?.['retry-after'] || '60';
72
+ const retryAfter = parseInt(retryAfterHeader, 10);
73
+ return new GitLabAPIError(`Rate limited by GitLab. Retry after ${retryAfter}s`, { code: 'RATE_LIMITED', statusCode, isRetryable: true, isRateLimited: true, retryAfter, originalError: error });
74
+ }
75
+ // Check for authentication errors (401)
76
+ if (statusCode === 401) {
77
+ const message = errors?.[0]?.message || 'Invalid or expired token';
78
+ return new GitLabAPIError(`Authentication failed: ${message}. Ensure your token has 'read_api' or 'api' scope.`, { code: 'AUTH_FAILED', statusCode, isRetryable: false, originalError: error });
79
+ }
80
+ // Check for forbidden (403) - often scope issues
81
+ if (statusCode === 403) {
82
+ return new GitLabAPIError('Access forbidden. Your token may lack required scopes (need read_api for queries, api for mutations).', { code: 'FORBIDDEN', statusCode, isRetryable: false, originalError: error });
83
+ }
84
+ // Check for query complexity errors
85
+ const complexityError = errors?.find(e => e.message?.includes('complexity') || e.message?.includes('Query has complexity'));
86
+ if (complexityError) {
87
+ return new GitLabAPIError(`Query too complex: ${complexityError.message}. Try reducing the number of fields or pagination size.`, { code: 'COMPLEXITY_EXCEEDED', statusCode, isRetryable: false, originalError: error });
88
+ }
89
+ // Check for timeout errors
90
+ if (errors?.some(e => e.message?.includes('timeout') || e.message?.includes('Timeout'))) {
91
+ return new GitLabAPIError('Query timed out. Try reducing pagination size or query complexity.', { code: 'TIMEOUT', statusCode, isRetryable: true, originalError: error });
92
+ }
93
+ // Check for server errors (5xx) - retryable
94
+ if (statusCode >= 500) {
95
+ return new GitLabAPIError(`GitLab server error (${statusCode}): ${errors?.[0]?.message || 'Internal server error'}`, { code: 'SERVER_ERROR', statusCode, isRetryable: true, originalError: error });
96
+ }
97
+ // Generic GraphQL errors
98
+ if (errors?.length) {
99
+ const messages = errors.map(e => e.message).join('; ');
100
+ return new GitLabAPIError(`GraphQL error: ${messages}`, { code: 'GRAPHQL_ERROR', statusCode, isRetryable: false, originalError: error });
101
+ }
102
+ return new GitLabAPIError(`HTTP ${statusCode}: ${error.message}`, { code: 'HTTP_ERROR', statusCode, isRetryable: RETRY_CONFIG.retryableStatusCodes.includes(statusCode), originalError: error });
103
+ }
104
+ // Handle abort/timeout errors
105
+ if (error instanceof Error) {
106
+ if (error.name === 'AbortError') {
107
+ return new GitLabAPIError('Request timed out. GitLab has a 30s server limit; try reducing query complexity.', { code: 'TIMEOUT', isRetryable: true, originalError: error });
108
+ }
109
+ // Network errors
110
+ const errorCode = error.code;
111
+ if (errorCode && RETRY_CONFIG.retryableErrorCodes.includes(errorCode)) {
112
+ return new GitLabAPIError(`Network error: ${error.message}`, { code: errorCode, isRetryable: true, originalError: error });
113
+ }
114
+ return new GitLabAPIError(error.message, { code: 'UNKNOWN_ERROR', isRetryable: false, originalError: error });
115
+ }
116
+ return new GitLabAPIError(String(error), { code: 'UNKNOWN_ERROR', isRetryable: false });
117
+ }
118
+ /**
119
+ * Execute request with exponential backoff retry
120
+ */
121
+ async executeWithRetry(fn, context = 'GraphQL query') {
122
+ let lastError;
123
+ for (let attempt = 0; attempt <= RETRY_CONFIG.maxRetries; attempt++) {
124
+ try {
125
+ return await fn();
126
+ }
127
+ catch (error) {
128
+ lastError = this.parseError(error);
129
+ // Don't retry non-retryable errors
130
+ if (!lastError.isRetryable) {
131
+ throw lastError;
132
+ }
133
+ // Don't retry if we've exhausted attempts
134
+ if (attempt >= RETRY_CONFIG.maxRetries) {
135
+ throw new GitLabAPIError(`${context} failed after ${RETRY_CONFIG.maxRetries + 1} attempts: ${lastError.message}`, { ...lastError, code: 'MAX_RETRIES_EXCEEDED' });
136
+ }
137
+ // Calculate delay with exponential backoff and jitter
138
+ let delay;
139
+ if (lastError.isRateLimited && lastError.retryAfter) {
140
+ delay = lastError.retryAfter * 1000;
141
+ }
142
+ else {
143
+ delay = Math.min(RETRY_CONFIG.baseDelayMs * Math.pow(2, attempt) + Math.random() * 1000, RETRY_CONFIG.maxDelayMs);
144
+ }
145
+ console.error(`[GitLab] ${context} failed (attempt ${attempt + 1}/${RETRY_CONFIG.maxRetries + 1}): ${lastError.code}. Retrying in ${Math.round(delay / 1000)}s...`);
146
+ await new Promise(resolve => setTimeout(resolve, delay));
147
+ }
148
+ }
149
+ // TypeScript: should never reach here, but just in case
150
+ throw lastError || new GitLabAPIError('Unknown error during retry');
151
+ }
152
+ getUserClient(userConfig) {
153
+ const userKey = `${userConfig.gitlabUrl || this.config.gitlabUrl}:${userConfig.accessToken}`;
154
+ if (!this.userClients.has(userKey)) {
155
+ const client = this.createClient(userConfig.gitlabUrl || this.config.gitlabUrl, userConfig.accessToken);
156
+ this.userClients.set(userKey, client);
157
+ }
158
+ return this.userClients.get(userKey);
159
+ }
160
+ getClient(userConfig, requiresWrite = false) {
161
+ // If user config provided, use user-specific client
162
+ if (userConfig) {
163
+ return this.getUserClient(userConfig);
164
+ }
165
+ // If write operation required, user must provide credentials
166
+ if (requiresWrite) {
167
+ throw new Error('Write operations require user authentication. Please provide your GitLab credentials.');
168
+ }
169
+ // For read operations, try shared client first
170
+ if (this.baseClient && this.config.authMode !== 'per-user') {
171
+ return this.baseClient;
172
+ }
173
+ // If no shared client and hybrid/per-user mode, require user auth
174
+ if (this.config.authMode === 'per-user' || this.config.authMode === 'hybrid') {
175
+ throw new Error('This operation requires user authentication. Please provide your GitLab credentials.');
176
+ }
177
+ throw new Error('No authentication configured. Please provide GitLab credentials or configure a shared access token.');
178
+ }
179
+ async introspectSchema(userConfig) {
180
+ if (this.schema)
181
+ return;
182
+ try {
183
+ const client = this.getClient(userConfig);
184
+ const introspectionResult = await client.request(getIntrospectionQuery());
185
+ this.schema = buildClientSchema(introspectionResult);
186
+ }
187
+ catch (error) {
188
+ throw new Error(`Failed to introspect GitLab GraphQL schema: ${error}`);
189
+ }
190
+ }
191
+ async query(query, variables, userConfig, requiresWrite = false) {
192
+ const client = this.getClient(userConfig, requiresWrite);
193
+ return this.executeWithRetry(() => client.request(query, variables), 'GraphQL query');
194
+ }
195
+ async getCurrentUser(userConfig) {
196
+ const query = gql `
197
+ query getCurrentUser {
198
+ currentUser {
199
+ id
200
+ username
201
+ name
202
+ avatarUrl
203
+ webUrl
204
+ }
205
+ }
206
+ `;
207
+ return this.query(query, undefined, userConfig);
208
+ }
209
+ async getProject(fullPath, userConfig) {
210
+ const query = gql `
211
+ query getProject($fullPath: ID!) {
212
+ project(fullPath: $fullPath) {
213
+ id
214
+ name
215
+ description
216
+ fullPath
217
+ webUrl
218
+ createdAt
219
+ updatedAt
220
+ visibility
221
+ repository {
222
+ tree {
223
+ lastCommit {
224
+ sha
225
+ message
226
+ authoredDate
227
+ authorName
228
+ }
229
+ }
230
+ }
231
+ }
232
+ }
233
+ `;
234
+ return this.query(query, { fullPath }, userConfig);
235
+ }
236
+ async getProjects(first = 20, after, userConfig) {
237
+ const query = gql `
238
+ query getProjects($first: Int!, $after: String) {
239
+ projects(first: $first, after: $after) {
240
+ pageInfo { hasNextPage hasPreviousPage startCursor endCursor }
241
+ nodes {
242
+ id
243
+ name
244
+ fullPath
245
+ webUrl
246
+ visibility
247
+ lastActivityAt
248
+ }
249
+ }
250
+ }
251
+ `;
252
+ return this.query(query, { first: Math.min(first, 50), after }, userConfig);
253
+ }
254
+ async getIssues(projectPath, first = 20, after, userConfig) {
255
+ const query = gql `
256
+ query getIssues($projectPath: ID!, $first: Int!, $after: String) {
257
+ project(fullPath: $projectPath) {
258
+ issues(first: $first, after: $after) {
259
+ pageInfo { hasNextPage hasPreviousPage startCursor endCursor }
260
+ nodes {
261
+ id
262
+ iid
263
+ title
264
+ state
265
+ createdAt
266
+ updatedAt
267
+ webUrl
268
+ author { username name }
269
+ }
270
+ }
271
+ }
272
+ }
273
+ `;
274
+ return this.query(query, { projectPath, first: Math.min(first, 50), after }, userConfig);
275
+ }
276
+ async getMergeRequests(projectPath, first = 20, after, userConfig) {
277
+ const query = gql `
278
+ query getMergeRequests($projectPath: ID!, $first: Int!, $after: String) {
279
+ project(fullPath: $projectPath) {
280
+ mergeRequests(first: $first, after: $after) {
281
+ pageInfo { hasNextPage hasPreviousPage startCursor endCursor }
282
+ nodes {
283
+ id
284
+ iid
285
+ title
286
+ state
287
+ createdAt
288
+ updatedAt
289
+ mergedAt
290
+ webUrl
291
+ sourceBranch
292
+ targetBranch
293
+ author { username name }
294
+ }
295
+ }
296
+ }
297
+ }
298
+ `;
299
+ return this.query(query, { projectPath, first: Math.min(first, 50), after }, userConfig);
300
+ }
301
+ async createIssue(projectPath, title, description, userConfig) {
302
+ await this.introspectSchema(userConfig);
303
+ const mutationType = this.schema?.getMutationType();
304
+ const fields = mutationType ? mutationType.getFields() : {};
305
+ const fieldName = fields['createIssue'] ? 'createIssue' : (fields['issueCreate'] ? 'issueCreate' : null);
306
+ if (!fieldName) {
307
+ throw new Error('Neither createIssue nor issueCreate mutation is available on this GitLab instance');
308
+ }
309
+ const hasCreateInput = !!this.schema.getType('CreateIssueInput');
310
+ const hasLegacyInput = !!this.schema.getType('IssueCreateInput');
311
+ const inputType = hasCreateInput ? 'CreateIssueInput' : (hasLegacyInput ? 'IssueCreateInput' : null);
312
+ if (!inputType) {
313
+ throw new Error('Neither CreateIssueInput nor IssueCreateInput input type is available on this GitLab instance');
314
+ }
315
+ const mutation = gql `
316
+ mutation createIssue($input: ${inputType}!) {
317
+ ${fieldName}(input: $input) {
318
+ issue {
319
+ id
320
+ iid
321
+ title
322
+ description
323
+ webUrl
324
+ state
325
+ createdAt
326
+ }
327
+ errors
328
+ }
329
+ }
330
+ `;
331
+ const input = {
332
+ projectPath,
333
+ title,
334
+ description,
335
+ };
336
+ const result = await this.query(mutation, { input }, userConfig, true);
337
+ // Normalize payload to { createIssue: ... }
338
+ const payload = result[fieldName];
339
+ return { createIssue: payload };
340
+ }
341
+ async createMergeRequest(projectPath, title, sourceBranch, targetBranch, description, userConfig) {
342
+ await this.introspectSchema(userConfig);
343
+ const mutationType = this.schema?.getMutationType();
344
+ const fields = mutationType ? mutationType.getFields() : {};
345
+ const fieldName = fields['createMergeRequest'] ? 'createMergeRequest' : (fields['mergeRequestCreate'] ? 'mergeRequestCreate' : null);
346
+ if (!fieldName) {
347
+ throw new Error('Neither createMergeRequest nor mergeRequestCreate mutation is available on this GitLab instance');
348
+ }
349
+ const hasCreateInput = !!this.schema.getType('CreateMergeRequestInput');
350
+ const hasLegacyInput = !!this.schema.getType('MergeRequestCreateInput');
351
+ const inputType = hasCreateInput ? 'CreateMergeRequestInput' : (hasLegacyInput ? 'MergeRequestCreateInput' : null);
352
+ if (!inputType) {
353
+ throw new Error('Neither CreateMergeRequestInput nor MergeRequestCreateInput input type is available on this GitLab instance');
354
+ }
355
+ const mutation = gql `
356
+ mutation createMergeRequest($input: ${inputType}!) {
357
+ ${fieldName}(input: $input) {
358
+ mergeRequest {
359
+ id
360
+ iid
361
+ title
362
+ description
363
+ webUrl
364
+ state
365
+ sourceBranch
366
+ targetBranch
367
+ createdAt
368
+ }
369
+ errors
370
+ }
371
+ }
372
+ `;
373
+ const input = {
374
+ projectPath,
375
+ title,
376
+ sourceBranch,
377
+ targetBranch,
378
+ description,
379
+ };
380
+ const result = await this.query(mutation, { input }, userConfig, true);
381
+ // Normalize payload to { createMergeRequest: ... }
382
+ const payload = result[fieldName];
383
+ return { createMergeRequest: payload };
384
+ }
385
+ getSchema() {
386
+ return this.schema;
387
+ }
388
+ getAvailableQueries() {
389
+ if (!this.schema)
390
+ return [];
391
+ const queryType = this.schema.getQueryType();
392
+ if (!queryType)
393
+ return [];
394
+ return Object.keys(queryType.getFields());
395
+ }
396
+ getAvailableMutations() {
397
+ if (!this.schema)
398
+ return [];
399
+ const mutationType = this.schema.getMutationType();
400
+ if (!mutationType)
401
+ return [];
402
+ return Object.keys(mutationType.getFields());
403
+ }
404
+ // Helpers for updates
405
+ async getIssueId(projectPath, iid, userConfig) {
406
+ const query = gql `
407
+ query issueId($projectPath: ID!, $iid: String!) {
408
+ project(fullPath: $projectPath) {
409
+ issue(iid: $iid) { id }
410
+ }
411
+ }
412
+ `;
413
+ const result = await this.query(query, { projectPath, iid }, userConfig);
414
+ const id = result?.project?.issue?.id;
415
+ if (!id)
416
+ throw new Error('Issue not found');
417
+ return id;
418
+ }
419
+ async getUserIdsByUsernames(usernames, userConfig) {
420
+ const ids = {};
421
+ if (!usernames || usernames.length === 0)
422
+ return ids;
423
+ const query = gql `
424
+ query users($search: String!, $first: Int!) {
425
+ users(search: $search, first: $first) { nodes { id username } }
426
+ }
427
+ `;
428
+ for (const name of usernames) {
429
+ const res = await this.query(query, { search: name, first: 20 }, userConfig);
430
+ const node = res?.users?.nodes?.find((u) => u.username === name);
431
+ if (node?.id)
432
+ ids[name] = node.id;
433
+ }
434
+ return ids;
435
+ }
436
+ async getLabelIds(projectPath, labelNames, userConfig) {
437
+ const ids = {};
438
+ if (!labelNames || labelNames.length === 0)
439
+ return ids;
440
+ const query = gql `
441
+ query projLabels($projectPath: ID!, $search: String!, $first: Int!) {
442
+ project(fullPath: $projectPath) {
443
+ labels(search: $search, first: $first) { nodes { id title } }
444
+ }
445
+ }
446
+ `;
447
+ for (const title of labelNames) {
448
+ const res = await this.query(query, { projectPath, search: title, first: 50 }, userConfig);
449
+ const node = res?.project?.labels?.nodes?.find((l) => l.title === title);
450
+ if (node?.id)
451
+ ids[title] = node.id;
452
+ }
453
+ return ids;
454
+ }
455
+ async updateIssueComposite(projectPath, iid, options, userConfig) {
456
+ await this.introspectSchema(userConfig);
457
+ const mutationType = this.schema?.getMutationType();
458
+ const fields = mutationType ? mutationType.getFields() : {};
459
+ const issueId = await this.getIssueId(projectPath, iid, userConfig);
460
+ const assigneeIdsMap = await this.getUserIdsByUsernames(options.assigneeUsernames || [], userConfig);
461
+ const assigneeIds = Object.values(assigneeIdsMap);
462
+ const labelIdsMap = await this.getLabelIds(projectPath, options.labelNames || [], userConfig);
463
+ const labelIds = Object.values(labelIdsMap);
464
+ const results = { iid, projectPath };
465
+ // Title/description/dueDate via updateIssue if available
466
+ if (fields['updateIssue']) {
467
+ const mutation = gql `
468
+ mutation UpdateIssue($input: UpdateIssueInput!) {
469
+ updateIssue(input: $input) {
470
+ issue { id iid title description dueDate webUrl updatedAt }
471
+ errors
472
+ }
473
+ }
474
+ `;
475
+ const input = { projectPath, iid };
476
+ if (options.title)
477
+ input.title = options.title;
478
+ if (options.description)
479
+ input.description = options.description;
480
+ if (options.dueDate)
481
+ input.dueDate = options.dueDate;
482
+ if (labelIds.length > 0)
483
+ input.labelIds = labelIds;
484
+ if (assigneeIds.length > 0)
485
+ input.assigneeIds = assigneeIds;
486
+ const res = await this.query(mutation, { input }, userConfig, true);
487
+ results.updateIssue = res.updateIssue;
488
+ }
489
+ else {
490
+ // Fallback to granular mutations if present
491
+ if (assigneeIds.length > 0 && fields['issueSetAssignees']) {
492
+ const mutation = gql `
493
+ mutation SetAssignees($input: IssueSetAssigneesInput!) {
494
+ issueSetAssignees(input: $input) { issue { id iid assignees { nodes { username } } } errors }
495
+ }
496
+ `;
497
+ const res = await this.query(mutation, { input: { issueId, assigneeIds } }, userConfig, true);
498
+ results.issueSetAssignees = res.issueSetAssignees;
499
+ }
500
+ if (labelIds.length > 0 && fields['issueSetLabels']) {
501
+ const mutation = gql `
502
+ mutation SetLabels($input: IssueSetLabelsInput!) {
503
+ issueSetLabels(input: $input) { issue { id iid labels { nodes { title } } } errors }
504
+ }
505
+ `;
506
+ const res = await this.query(mutation, { input: { issueId, labelIds } }, userConfig, true);
507
+ results.issueSetLabels = res.issueSetLabels;
508
+ }
509
+ if (options.dueDate && fields['issueSetDueDate']) {
510
+ const mutation = gql `
511
+ mutation SetDueDate($input: IssueSetDueDateInput!) {
512
+ issueSetDueDate(input: $input) { issue { id iid dueDate } errors }
513
+ }
514
+ `;
515
+ const res = await this.query(mutation, { input: { issueId, dueDate: options.dueDate } }, userConfig, true);
516
+ results.issueSetDueDate = res.issueSetDueDate;
517
+ }
518
+ }
519
+ return results;
520
+ }
521
+ async getMergeRequestId(projectPath, iid, userConfig) {
522
+ const query = gql `
523
+ query mrId($projectPath: ID!, $iid: String!) {
524
+ project(fullPath: $projectPath) {
525
+ mergeRequest(iid: $iid) { id }
526
+ }
527
+ }
528
+ `;
529
+ const result = await this.query(query, { projectPath, iid }, userConfig);
530
+ const id = result?.project?.mergeRequest?.id;
531
+ if (!id)
532
+ throw new Error('Merge request not found');
533
+ return id;
534
+ }
535
+ async updateMergeRequestComposite(projectPath, iid, options, userConfig) {
536
+ await this.introspectSchema(userConfig);
537
+ const mutationType = this.schema?.getMutationType();
538
+ const fields = mutationType ? mutationType.getFields() : {};
539
+ const mrId = await this.getMergeRequestId(projectPath, iid, userConfig);
540
+ const assigneeIdsMap = await this.getUserIdsByUsernames(options.assigneeUsernames || [], userConfig);
541
+ const assigneeIds = Object.values(assigneeIdsMap);
542
+ const reviewerIdsMap = await this.getUserIdsByUsernames(options.reviewerUsernames || [], userConfig);
543
+ const reviewerIds = Object.values(reviewerIdsMap);
544
+ const labelIdsMap = await this.getLabelIds(projectPath, options.labelNames || [], userConfig);
545
+ const labelIds = Object.values(labelIdsMap);
546
+ const results = { iid, projectPath };
547
+ if (fields['updateMergeRequest']) {
548
+ const mutation = gql `
549
+ mutation UpdateMergeRequest($input: UpdateMergeRequestInput!) {
550
+ updateMergeRequest(input: $input) {
551
+ mergeRequest { id iid title description webUrl updatedAt labels { nodes { title } } assignees { nodes { username } } }
552
+ errors
553
+ }
554
+ }
555
+ `;
556
+ const input = { projectPath, iid };
557
+ if (options.title)
558
+ input.title = options.title;
559
+ if (options.description)
560
+ input.description = options.description;
561
+ if (labelIds.length > 0)
562
+ input.labelIds = labelIds;
563
+ if (assigneeIds.length > 0)
564
+ input.assigneeIds = assigneeIds;
565
+ const res = await this.query(mutation, { input }, userConfig, true);
566
+ results.updateMergeRequest = res.updateMergeRequest;
567
+ }
568
+ else {
569
+ if (assigneeIds.length > 0 && fields['mergeRequestSetAssignees']) {
570
+ const mutation = gql `
571
+ mutation SetMRAssignees($input: MergeRequestSetAssigneesInput!) {
572
+ mergeRequestSetAssignees(input: $input) { mergeRequest { id iid assignees { nodes { username } } } errors }
573
+ }
574
+ `;
575
+ const res = await this.query(mutation, { input: { mergeRequestId: mrId, assigneeIds } }, userConfig, true);
576
+ results.mergeRequestSetAssignees = res.mergeRequestSetAssignees;
577
+ }
578
+ if (labelIds.length > 0 && fields['mergeRequestSetLabels']) {
579
+ const mutation = gql `
580
+ mutation SetMRLabels($input: MergeRequestSetLabelsInput!) {
581
+ mergeRequestSetLabels(input: $input) { mergeRequest { id iid labels { nodes { title } } } errors }
582
+ }
583
+ `;
584
+ const res = await this.query(mutation, { input: { mergeRequestId: mrId, labelIds } }, userConfig, true);
585
+ results.mergeRequestSetLabels = res.mergeRequestSetLabels;
586
+ }
587
+ if (reviewerIds.length > 0 && fields['mergeRequestSetReviewers']) {
588
+ const mutation = gql `
589
+ mutation SetMRReviewers($input: MergeRequestSetReviewersInput!) {
590
+ mergeRequestSetReviewers(input: $input) { mergeRequest { id iid reviewers { nodes { username } } } errors }
591
+ }
592
+ `;
593
+ const res = await this.query(mutation, { input: { mergeRequestId: mrId, reviewerIds } }, userConfig, true);
594
+ results.mergeRequestSetReviewers = res.mergeRequestSetReviewers;
595
+ }
596
+ if (options.title || options.description) {
597
+ // Attempt legacy/update fallback if available
598
+ const legacyName = fields['mergeRequestUpdate'] ? 'mergeRequestUpdate' : undefined;
599
+ if (legacyName) {
600
+ const mutation = gql `
601
+ mutation LegacyMRUpdate($input: MergeRequestUpdateInput!) {
602
+ mergeRequestUpdate(input: $input) { mergeRequest { id iid title description } errors }
603
+ }
604
+ `;
605
+ const input = { mergeRequestId: mrId };
606
+ if (options.title)
607
+ input.title = options.title;
608
+ if (options.description)
609
+ input.description = options.description;
610
+ const res = await this.query(mutation, { input }, userConfig, true);
611
+ results.mergeRequestUpdate = res.mergeRequestUpdate;
612
+ }
613
+ }
614
+ }
615
+ return results;
616
+ }
617
+ getTypeFields(typeName) {
618
+ if (!this.schema)
619
+ return [];
620
+ const type = this.schema.getType(typeName);
621
+ if (!type || typeof type.getFields !== 'function')
622
+ return [];
623
+ const fields = type.getFields();
624
+ return Object.keys(fields);
625
+ }
626
+ async globalSearch(searchTerm, scope, userConfig) {
627
+ const query = gql `
628
+ query globalSearch($search: String, $first: Int!) {
629
+ projects(search: $search, first: $first) {
630
+ nodes {
631
+ id
632
+ name
633
+ fullPath
634
+ webUrl
635
+ visibility
636
+ }
637
+ }
638
+ issues(search: $search, first: $first) {
639
+ nodes {
640
+ id
641
+ iid
642
+ title
643
+ state
644
+ webUrl
645
+ createdAt
646
+ }
647
+ }
648
+ }
649
+ `;
650
+ return this.query(query, {
651
+ search: searchTerm || undefined,
652
+ first: Math.min(this.config.maxPageSize, 25)
653
+ }, userConfig);
654
+ }
655
+ async searchProjects(searchTerm, first = 20, after, userConfig) {
656
+ const query = gql `
657
+ query searchProjects($search: String!, $first: Int!, $after: String) {
658
+ projects(search: $search, first: $first, after: $after) {
659
+ pageInfo { hasNextPage hasPreviousPage startCursor endCursor }
660
+ nodes {
661
+ id
662
+ name
663
+ fullPath
664
+ description
665
+ webUrl
666
+ visibility
667
+ lastActivityAt
668
+ }
669
+ }
670
+ }
671
+ `;
672
+ return this.query(query, { search: searchTerm, first: Math.min(first, 50), after }, userConfig);
673
+ }
674
+ getTypeName(t) {
675
+ if (!t)
676
+ return undefined;
677
+ return t.name || (t.ofType ? this.getTypeName(t.ofType) : undefined);
678
+ }
679
+ getEnumValues(enumTypeName) {
680
+ if (!enumTypeName || !this.schema)
681
+ return [];
682
+ const enumType = this.schema.getType(enumTypeName);
683
+ const values = (enumType && typeof enumType.getValues === 'function') ? enumType.getValues() : [];
684
+ return Array.isArray(values) ? values.map((v) => v.name) : [];
685
+ }
686
+ async searchIssues(searchTerm, projectPath, state, first = 20, after, userConfig, assigneeUsernames, authorUsername, labelNames) {
687
+ await this.introspectSchema(userConfig);
688
+ const mappedState = state && state.toLowerCase() !== 'all' ? state.toUpperCase() : undefined;
689
+ if (projectPath) {
690
+ const projectType = this.schema.getType('Project');
691
+ const projFields = projectType?.getFields?.() || {};
692
+ const issuesField = projFields['issues'];
693
+ const stateArgType = issuesField?.args?.find((a) => a.name === 'state')?.type;
694
+ const stateEnum = this.getTypeName(stateArgType) || 'IssueState';
695
+ const allowed = this.getEnumValues(stateEnum).map(v => String(v));
696
+ const mapped = state
697
+ ? (allowed.find(v => v.toLowerCase() === state.toLowerCase()) || undefined)
698
+ : undefined;
699
+ const query = gql `
700
+ query searchIssuesProject($projectPath: ID!, $search: String, $state: ${stateEnum}, $first: Int!, $after: String, $assigneeUsernames: [String!], $authorUsername: String, $labelName: [String!]) {
701
+ project(fullPath: $projectPath) {
702
+ issues(search: $search, state: $state, first: $first, after: $after, assigneeUsernames: $assigneeUsernames, authorUsername: $authorUsername, labelName: $labelName) {
703
+ pageInfo { hasNextPage hasPreviousPage startCursor endCursor }
704
+ nodes {
705
+ id
706
+ iid
707
+ title
708
+ state
709
+ webUrl
710
+ createdAt
711
+ updatedAt
712
+ author { username name }
713
+ labels { nodes { title } }
714
+ }
715
+ }
716
+ }
717
+ }
718
+ `;
719
+ return this.query(query, {
720
+ projectPath,
721
+ search: searchTerm,
722
+ state: mapped,
723
+ first,
724
+ after,
725
+ assigneeUsernames,
726
+ authorUsername,
727
+ labelName: labelNames
728
+ }, userConfig);
729
+ }
730
+ else {
731
+ const queryType = this.schema.getQueryType();
732
+ const qFields = queryType?.getFields?.() || {};
733
+ const issuesField = qFields['issues'];
734
+ const stateArgType = issuesField?.args?.find((a) => a.name === 'state')?.type;
735
+ const stateEnum = this.getTypeName(stateArgType) || (this.schema.getType('IssuableState') ? 'IssuableState' : 'IssueState');
736
+ const allowed = this.getEnumValues(stateEnum).map(v => String(v));
737
+ const mapped = state
738
+ ? (allowed.find(v => v.toLowerCase() === state.toLowerCase()) || undefined)
739
+ : undefined;
740
+ // OPTIMIZATION: Reduce query complexity for global searches to prevent timeouts
741
+ // According to GitLab best practices, avoid fetching deeply nested collections
742
+ // We keep description for AI context but limit nested collections (assignees/labels)
743
+ // Note: Global Issue type doesn't have 'project' field - only project-scoped queries do
744
+ const query = gql `
745
+ query searchIssuesGlobal($search: String, $state: ${stateEnum}, $first: Int!, $after: String, $assigneeUsernames: [String!], $authorUsername: String, $labelName: [String!]) {
746
+ issues(search: $search, state: $state, first: $first, after: $after, assigneeUsernames: $assigneeUsernames, authorUsername: $authorUsername, labelName: $labelName) {
747
+ pageInfo { hasNextPage hasPreviousPage startCursor endCursor }
748
+ nodes {
749
+ id
750
+ iid
751
+ title
752
+ description
753
+ state
754
+ webUrl
755
+ createdAt
756
+ updatedAt
757
+ closedAt
758
+ author { username name }
759
+ }
760
+ }
761
+ }
762
+ `;
763
+ // Note: Global search returns streamlined fields (no assignees/labels/project) for performance.
764
+ // For full details and project attribution, search within a specific project using projectPath.
765
+ return this.query(query, {
766
+ search: searchTerm,
767
+ state: mapped,
768
+ first, // Respect user's requested limit - no forced cap
769
+ after,
770
+ assigneeUsernames,
771
+ authorUsername,
772
+ labelName: labelNames
773
+ }, userConfig);
774
+ }
775
+ }
776
+ async searchMergeRequests(searchTerm, projectPath, state, first = 20, after, userConfig) {
777
+ const mappedState = state && state.toLowerCase() !== 'all' ? state.toUpperCase() : undefined;
778
+ if (projectPath) {
779
+ const query = gql `
780
+ query searchMergeRequestsProject($projectPath: ID!, $search: String, $state: MergeRequestState, $first: Int!, $after: String) {
781
+ project(fullPath: $projectPath) {
782
+ mergeRequests(search: $search, state: $state, first: $first, after: $after) {
783
+ pageInfo { hasNextPage hasPreviousPage startCursor endCursor }
784
+ nodes {
785
+ id
786
+ iid
787
+ title
788
+ state
789
+ webUrl
790
+ createdAt
791
+ updatedAt
792
+ mergedAt
793
+ sourceBranch
794
+ targetBranch
795
+ author { username name }
796
+ }
797
+ }
798
+ }
799
+ }
800
+ `;
801
+ return this.query(query, {
802
+ projectPath,
803
+ search: searchTerm,
804
+ state: mappedState,
805
+ first: Math.min(first, 50),
806
+ after
807
+ }, userConfig);
808
+ }
809
+ else {
810
+ // GitLab GraphQL API does NOT support global MR search at root level
811
+ // Best approach: Try user-based search for username queries
812
+ // Supports: "username", "author:username", "assignee:username"
813
+ // Detect search type and extract username
814
+ let username = searchTerm;
815
+ let searchType = 'author'; // Default to author
816
+ if (searchTerm.includes(':')) {
817
+ const parts = searchTerm.split(':');
818
+ if (parts[0] === 'author') {
819
+ searchType = 'author';
820
+ username = parts[1].trim();
821
+ }
822
+ else if (parts[0] === 'assignee') {
823
+ searchType = 'assignee';
824
+ username = parts[1].trim();
825
+ }
826
+ }
827
+ const isValidUsername = /^[a-zA-Z0-9_-]+$/.test(username);
828
+ if (isValidUsername) {
829
+ const fieldName = searchType === 'author' ? 'authoredMergeRequests' : 'assignedMergeRequests';
830
+ const query = gql `
831
+ query searchUserMergeRequests($username: String!, $first: Int!, $after: String, $state: MergeRequestState) {
832
+ user(username: $username) {
833
+ ${fieldName}(first: $first, after: $after, state: $state) {
834
+ pageInfo { hasNextPage hasPreviousPage startCursor endCursor }
835
+ nodes {
836
+ id
837
+ iid
838
+ title
839
+ state
840
+ webUrl
841
+ createdAt
842
+ updatedAt
843
+ mergedAt
844
+ sourceBranch
845
+ targetBranch
846
+ author { username name }
847
+ project { fullPath }
848
+ }
849
+ }
850
+ }
851
+ }
852
+ `;
853
+ try {
854
+ const result = await this.query(query, {
855
+ username,
856
+ first: Math.min(first, 50),
857
+ after,
858
+ state: mappedState
859
+ }, userConfig);
860
+ if (result?.user?.[fieldName]) {
861
+ return result.user[fieldName];
862
+ }
863
+ }
864
+ catch (e) {
865
+ if (e instanceof GitLabAPIError && !e.isRetryable) {
866
+ throw e;
867
+ }
868
+ }
869
+ }
870
+ // If not a username search or user query failed, return helpful error
871
+ return {
872
+ pageInfo: { hasNextPage: false, hasPreviousPage: false },
873
+ nodes: [],
874
+ _note: `GitLab does not support global text searches for merge requests. Please either: (1) provide a projectPath for text search, or (2) search by username (e.g., "cdhanlon", "author:cdhanlon", "assignee:cdhanlon").`
875
+ };
876
+ }
877
+ }
878
+ async searchRepositoryFiles(projectPath, path, ref, userConfig) {
879
+ const query = gql `
880
+ query searchRepositoryFiles($projectPath: ID!, $path: String, $ref: String) {
881
+ project(fullPath: $projectPath) {
882
+ webUrl
883
+ repository {
884
+ tree(path: $path, ref: $ref, recursive: true) {
885
+ blobs {
886
+ nodes {
887
+ name
888
+ path
889
+ type
890
+ mode
891
+ }
892
+ }
893
+ trees {
894
+ nodes {
895
+ name
896
+ path
897
+ type
898
+ }
899
+ }
900
+ }
901
+ }
902
+ }
903
+ }
904
+ `;
905
+ return this.query(query, {
906
+ projectPath,
907
+ path: path || "",
908
+ ref: ref || "HEAD"
909
+ }, userConfig);
910
+ }
911
+ async resolvePath(fullPath, first = 20, after, userConfig) {
912
+ const query = gql `
913
+ query resolvePath($fullPath: ID!, $first: Int!, $after: String) {
914
+ project: project(fullPath: $fullPath) {
915
+ id
916
+ name
917
+ fullPath
918
+ webUrl
919
+ description
920
+ visibility
921
+ }
922
+ group: group(fullPath: $fullPath) {
923
+ id
924
+ name
925
+ fullPath
926
+ webUrl
927
+ description
928
+ projects(first: $first, after: $after) {
929
+ pageInfo {
930
+ hasNextPage
931
+ endCursor
932
+ }
933
+ nodes {
934
+ id
935
+ name
936
+ fullPath
937
+ webUrl
938
+ visibility
939
+ lastActivityAt
940
+ }
941
+ }
942
+ }
943
+ }
944
+ `;
945
+ return this.query(query, { fullPath, first, after }, userConfig);
946
+ }
947
+ async getGroup(fullPath, first = 20, after, searchTerm, userConfig) {
948
+ const query = gql `
949
+ query getGroup($fullPath: ID!, $first: Int!, $after: String, $search: String) {
950
+ group(fullPath: $fullPath) {
951
+ id
952
+ name
953
+ fullPath
954
+ webUrl
955
+ description
956
+ projects(first: $first, after: $after, search: $search) {
957
+ pageInfo {
958
+ hasNextPage
959
+ endCursor
960
+ }
961
+ nodes {
962
+ id
963
+ name
964
+ fullPath
965
+ webUrl
966
+ visibility
967
+ lastActivityAt
968
+ }
969
+ }
970
+ }
971
+ }
972
+ `;
973
+ return this.query(query, { fullPath, first, after, search: searchTerm }, userConfig);
974
+ }
975
+ async getFileContent(projectPath, filePath, ref, userConfig) {
976
+ const query = gql `
977
+ query getFileContent($projectPath: ID!, $path: String!, $ref: String) {
978
+ project(fullPath: $projectPath) {
979
+ webUrl
980
+ repository {
981
+ blobs(paths: [$path], ref: $ref) {
982
+ nodes {
983
+ name
984
+ path
985
+ rawBlob
986
+ size
987
+ lfsOid
988
+ }
989
+ }
990
+ }
991
+ }
992
+ }
993
+ `;
994
+ return this.query(query, {
995
+ projectPath,
996
+ path: filePath,
997
+ ref: ref || "HEAD"
998
+ }, userConfig);
999
+ }
1000
+ async searchUsers(searchTerm, first = 20, userConfig) {
1001
+ const query = gql `
1002
+ query searchUsers($search: String!, $first: Int!) {
1003
+ users(search: $search, first: $first) {
1004
+ nodes {
1005
+ id
1006
+ username
1007
+ name
1008
+ avatarUrl
1009
+ webUrl
1010
+ state
1011
+ }
1012
+ }
1013
+ }
1014
+ `;
1015
+ return this.query(query, { search: searchTerm, first: Math.min(first, 50) }, userConfig);
1016
+ }
1017
+ async searchGroups(searchTerm, first = 20, userConfig) {
1018
+ const query = gql `
1019
+ query searchGroups($search: String!, $first: Int!) {
1020
+ groups(search: $search, first: $first) {
1021
+ nodes {
1022
+ id
1023
+ name
1024
+ fullName
1025
+ fullPath
1026
+ description
1027
+ webUrl
1028
+ visibility
1029
+ }
1030
+ }
1031
+ }
1032
+ `;
1033
+ return this.query(query, { search: searchTerm, first: Math.min(first, 50) }, userConfig);
1034
+ }
1035
+ }
1036
+ //# sourceMappingURL=gitlab-client.js.map