@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.
- package/LICENSE +21 -0
- package/README.md +685 -0
- package/dist/config.d.ts +35 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +29 -0
- package/dist/config.js.map +1 -0
- package/dist/gitlab-client.d.ts +96 -0
- package/dist/gitlab-client.d.ts.map +1 -0
- package/dist/gitlab-client.js +1036 -0
- package/dist/gitlab-client.js.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +600 -0
- package/dist/index.js.map +1 -0
- package/dist/tools.d.ts +29 -0
- package/dist/tools.d.ts.map +1 -0
- package/dist/tools.js +777 -0
- package/dist/tools.js.map +1 -0
- package/icon.svg +32 -0
- package/package.json +58 -0
|
@@ -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
|