@forge-glance/sdk 0.1.1 → 0.2.1
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/dist/GitHubProvider.d.ts +14 -3
- package/dist/GitHubProvider.js +100 -3
- package/dist/GitLabProvider.d.ts +21 -4
- package/dist/GitLabProvider.js +194 -11
- package/dist/GitProvider.d.ts +64 -1
- package/dist/index.d.ts +14 -14
- package/dist/index.js +294 -14
- package/dist/providers.js +294 -14
- package/dist/types.d.ts +64 -1
- package/package.json +1 -1
- package/src/GitHubProvider.ts +431 -153
- package/src/GitLabProvider.ts +437 -87
- package/src/GitProvider.ts +113 -21
- package/src/index.ts +22 -15
- package/src/types.ts +70 -1
package/src/GitLabProvider.ts
CHANGED
|
@@ -1,17 +1,19 @@
|
|
|
1
|
-
import type { GitProvider } from
|
|
1
|
+
import type { GitProvider } from './GitProvider.ts';
|
|
2
2
|
import type {
|
|
3
3
|
BranchProtectionRule,
|
|
4
4
|
CreatePullRequestInput,
|
|
5
5
|
DiffStats,
|
|
6
|
+
MergePullRequestInput,
|
|
6
7
|
MRDetail,
|
|
7
8
|
Pipeline,
|
|
8
9
|
PipelineJob,
|
|
10
|
+
ProviderCapabilities,
|
|
9
11
|
PullRequest,
|
|
10
12
|
UpdatePullRequestInput,
|
|
11
|
-
UserRef
|
|
12
|
-
} from
|
|
13
|
-
import { type ForgeLogger, noopLogger } from
|
|
14
|
-
import { MRDetailFetcher } from
|
|
13
|
+
UserRef
|
|
14
|
+
} from './types.ts';
|
|
15
|
+
import { type ForgeLogger, noopLogger } from './logger.ts';
|
|
16
|
+
import { MRDetailFetcher } from './MRDetailFetcher.ts';
|
|
15
17
|
|
|
16
18
|
// ---------------------------------------------------------------------------
|
|
17
19
|
// Repository ID helpers
|
|
@@ -23,8 +25,8 @@ import { MRDetailFetcher } from "./MRDetailFetcher.ts";
|
|
|
23
25
|
* e.g. "gitlab:42" → 42
|
|
24
26
|
*/
|
|
25
27
|
export function parseGitLabRepoId(repositoryId: string): number {
|
|
26
|
-
const parts = repositoryId.split(
|
|
27
|
-
return parseInt(parts.at(-1) ??
|
|
28
|
+
const parts = repositoryId.split(':');
|
|
29
|
+
return parseInt(parts.at(-1) ?? '0', 10);
|
|
28
30
|
}
|
|
29
31
|
|
|
30
32
|
// ---------------------------------------------------------------------------
|
|
@@ -48,6 +50,10 @@ export const MR_DASHBOARD_FRAGMENT = `
|
|
|
48
50
|
approvalsLeft
|
|
49
51
|
resolvableDiscussionsCount
|
|
50
52
|
resolvedDiscussionsCount
|
|
53
|
+
autoMergeEnabled
|
|
54
|
+
autoMergeStrategy
|
|
55
|
+
mergeUser { id username name avatarUrl }
|
|
56
|
+
mergeAfter
|
|
51
57
|
headPipeline {
|
|
52
58
|
id iid status
|
|
53
59
|
createdAt
|
|
@@ -162,6 +168,10 @@ interface GQLMR {
|
|
|
162
168
|
approvalsLeft: number | null;
|
|
163
169
|
resolvableDiscussionsCount: number | null;
|
|
164
170
|
resolvedDiscussionsCount: number | null;
|
|
171
|
+
autoMergeEnabled: boolean;
|
|
172
|
+
autoMergeStrategy: string | null;
|
|
173
|
+
mergeUser: GQLUser | null;
|
|
174
|
+
mergeAfter: string | null;
|
|
165
175
|
headPipeline: GQLPipeline | null;
|
|
166
176
|
}
|
|
167
177
|
|
|
@@ -181,8 +191,8 @@ interface AssignedResponse {
|
|
|
181
191
|
|
|
182
192
|
/** Extract numeric ID from a GQL global ID like "gid://gitlab/MergeRequest/12345". */
|
|
183
193
|
function numericId(gid: string): number {
|
|
184
|
-
const parts = gid.split(
|
|
185
|
-
return parseInt(parts[parts.length - 1] ??
|
|
194
|
+
const parts = gid.split('/');
|
|
195
|
+
return parseInt(parts[parts.length - 1] ?? '0', 10);
|
|
186
196
|
}
|
|
187
197
|
|
|
188
198
|
/** Build a scoped domain ID from a GitLab numeric integer. */
|
|
@@ -195,29 +205,28 @@ function toUserRef(u: GQLUser): UserRef {
|
|
|
195
205
|
id: `gitlab:user:${numericId(u.id)}`,
|
|
196
206
|
username: u.username,
|
|
197
207
|
name: u.name,
|
|
198
|
-
avatarUrl: u.avatarUrl
|
|
208
|
+
avatarUrl: u.avatarUrl
|
|
199
209
|
};
|
|
200
210
|
}
|
|
201
211
|
|
|
202
|
-
|
|
203
212
|
function toPipeline(p: GQLPipeline, baseURL: string): Pipeline {
|
|
204
|
-
const allJobs: PipelineJob[] = p.stages.nodes.flatMap(
|
|
205
|
-
stage.jobs.nodes.map(
|
|
213
|
+
const allJobs: PipelineJob[] = p.stages.nodes.flatMap(stage =>
|
|
214
|
+
stage.jobs.nodes.map(job => ({
|
|
206
215
|
id: `gitlab:job:${numericId(job.id)}`,
|
|
207
216
|
name: job.name,
|
|
208
217
|
stage: job.stage.name,
|
|
209
218
|
status: job.status,
|
|
210
219
|
allowFailure: job.allowFailure,
|
|
211
|
-
webUrl: job.webPath ? `${baseURL}${job.webPath}` : null
|
|
212
|
-
}))
|
|
220
|
+
webUrl: job.webPath ? `${baseURL}${job.webPath}` : null
|
|
221
|
+
}))
|
|
213
222
|
);
|
|
214
223
|
|
|
215
224
|
return {
|
|
216
|
-
id: domainId(
|
|
225
|
+
id: domainId('pipeline', numericId(p.id)),
|
|
217
226
|
status: normalizePipelineStatus(p),
|
|
218
227
|
createdAt: p.createdAt,
|
|
219
228
|
webUrl: p.path ? `${baseURL}${p.path}` : null,
|
|
220
|
-
jobs: allJobs
|
|
229
|
+
jobs: allJobs
|
|
221
230
|
};
|
|
222
231
|
}
|
|
223
232
|
|
|
@@ -227,12 +236,12 @@ function toPipeline(p: GQLPipeline, baseURL: string): Pipeline {
|
|
|
227
236
|
* if any allow-failure job failed but overall status is success → "success_with_warnings"
|
|
228
237
|
*/
|
|
229
238
|
function normalizePipelineStatus(p: GQLPipeline): string {
|
|
230
|
-
const allJobs = p.stages.nodes.flatMap(
|
|
239
|
+
const allJobs = p.stages.nodes.flatMap(s => s.jobs.nodes);
|
|
231
240
|
const hasAllowFailFailed = allJobs.some(
|
|
232
|
-
|
|
241
|
+
j => j.allowFailure && j.status === 'failed'
|
|
233
242
|
);
|
|
234
|
-
if (p.status ===
|
|
235
|
-
return
|
|
243
|
+
if (p.status === 'success' && hasAllowFailFailed) {
|
|
244
|
+
return 'success_with_warnings';
|
|
236
245
|
}
|
|
237
246
|
return p.status;
|
|
238
247
|
}
|
|
@@ -246,7 +255,7 @@ function toMR(gql: GQLMR, role: string, baseURL: string): PullRequest {
|
|
|
246
255
|
? {
|
|
247
256
|
additions: gql.diffStatsSummary.additions,
|
|
248
257
|
deletions: gql.diffStatsSummary.deletions,
|
|
249
|
-
filesChanged: gql.diffStatsSummary.fileCount
|
|
258
|
+
filesChanged: gql.diffStatsSummary.fileCount
|
|
250
259
|
}
|
|
251
260
|
: null;
|
|
252
261
|
|
|
@@ -258,7 +267,7 @@ function toMR(gql: GQLMR, role: string, baseURL: string): PullRequest {
|
|
|
258
267
|
description: gql.description ?? null,
|
|
259
268
|
state: gql.state,
|
|
260
269
|
draft: gql.draft,
|
|
261
|
-
conflicts: gql.conflicts || gql.detailedMergeStatus ===
|
|
270
|
+
conflicts: gql.conflicts || gql.detailedMergeStatus === 'conflict',
|
|
262
271
|
webUrl: gql.webUrl,
|
|
263
272
|
sourceBranch: gql.sourceBranch,
|
|
264
273
|
targetBranch: gql.targetBranch,
|
|
@@ -276,6 +285,10 @@ function toMR(gql: GQLMR, role: string, baseURL: string): PullRequest {
|
|
|
276
285
|
approvedBy: gql.approvedBy.nodes.map(toUserRef),
|
|
277
286
|
diffStats,
|
|
278
287
|
detailedMergeStatus: gql.detailedMergeStatus ?? null,
|
|
288
|
+
autoMergeEnabled: gql.autoMergeEnabled ?? false,
|
|
289
|
+
autoMergeStrategy: gql.autoMergeStrategy ?? null,
|
|
290
|
+
mergeUser: gql.mergeUser ? toUserRef(gql.mergeUser) : null,
|
|
291
|
+
mergeAfter: gql.mergeAfter ?? null
|
|
279
292
|
};
|
|
280
293
|
}
|
|
281
294
|
|
|
@@ -303,29 +316,50 @@ interface MRDetailResponse {
|
|
|
303
316
|
// ---------------------------------------------------------------------------
|
|
304
317
|
|
|
305
318
|
export class GitLabProvider implements GitProvider {
|
|
306
|
-
readonly providerName =
|
|
319
|
+
readonly providerName = 'gitlab' as const;
|
|
307
320
|
readonly baseURL: string;
|
|
308
321
|
private readonly token: string;
|
|
309
322
|
private readonly log: ForgeLogger;
|
|
310
323
|
private readonly mrDetailFetcher: MRDetailFetcher;
|
|
311
324
|
|
|
312
|
-
constructor(
|
|
325
|
+
constructor(
|
|
326
|
+
baseURL: string,
|
|
327
|
+
token: string,
|
|
328
|
+
options: { logger?: ForgeLogger } = {}
|
|
329
|
+
) {
|
|
313
330
|
// Strip trailing slash for consistent URL building
|
|
314
|
-
this.baseURL = baseURL.replace(/\/$/,
|
|
331
|
+
this.baseURL = baseURL.replace(/\/$/, '');
|
|
315
332
|
this.token = token;
|
|
316
333
|
this.log = options.logger ?? noopLogger;
|
|
317
|
-
this.mrDetailFetcher = new MRDetailFetcher(this.baseURL, token, {
|
|
334
|
+
this.mrDetailFetcher = new MRDetailFetcher(this.baseURL, token, {
|
|
335
|
+
logger: this.log
|
|
336
|
+
});
|
|
318
337
|
}
|
|
319
338
|
|
|
339
|
+
// ── Capabilities ──────────────────────────────────────────────────────
|
|
340
|
+
|
|
341
|
+
readonly capabilities: ProviderCapabilities = {
|
|
342
|
+
canMerge: true,
|
|
343
|
+
canApprove: true,
|
|
344
|
+
canUnapprove: true,
|
|
345
|
+
canRebase: true,
|
|
346
|
+
canAutoMerge: true,
|
|
347
|
+
canResolveDiscussions: true,
|
|
348
|
+
canRetryPipeline: true,
|
|
349
|
+
canRequestReReview: true
|
|
350
|
+
};
|
|
351
|
+
|
|
320
352
|
// MARK: - GitProvider
|
|
321
353
|
|
|
322
354
|
async validateToken(): Promise<UserRef> {
|
|
323
355
|
const url = `${this.baseURL}/api/v4/user`;
|
|
324
356
|
const res = await fetch(url, {
|
|
325
|
-
headers: {
|
|
357
|
+
headers: { 'PRIVATE-TOKEN': this.token }
|
|
326
358
|
});
|
|
327
359
|
if (!res.ok) {
|
|
328
|
-
throw new Error(
|
|
360
|
+
throw new Error(
|
|
361
|
+
`Token validation failed: ${res.status} ${res.statusText}`
|
|
362
|
+
);
|
|
329
363
|
}
|
|
330
364
|
const user = (await res.json()) as {
|
|
331
365
|
id: number;
|
|
@@ -337,7 +371,7 @@ export class GitLabProvider implements GitProvider {
|
|
|
337
371
|
id: `gitlab:user:${user.id}`,
|
|
338
372
|
username: user.username,
|
|
339
373
|
name: user.name,
|
|
340
|
-
avatarUrl: user.avatar_url
|
|
374
|
+
avatarUrl: user.avatar_url
|
|
341
375
|
};
|
|
342
376
|
}
|
|
343
377
|
|
|
@@ -352,17 +386,17 @@ export class GitLabProvider implements GitProvider {
|
|
|
352
386
|
async fetchSingleMR(
|
|
353
387
|
projectPath: string,
|
|
354
388
|
mrIid: number,
|
|
355
|
-
currentUserNumericId: number | null
|
|
389
|
+
currentUserNumericId: number | null
|
|
356
390
|
): Promise<PullRequest | null> {
|
|
357
391
|
let resp: MRDetailResponse;
|
|
358
392
|
try {
|
|
359
393
|
resp = await this.runQuery<MRDetailResponse>(MR_DETAIL_QUERY, {
|
|
360
394
|
projectPath,
|
|
361
|
-
iid: String(mrIid)
|
|
395
|
+
iid: String(mrIid)
|
|
362
396
|
});
|
|
363
397
|
} catch (err) {
|
|
364
398
|
const message = err instanceof Error ? err.message : String(err);
|
|
365
|
-
this.log.warn(
|
|
399
|
+
this.log.warn('fetchSingleMR failed', { projectPath, mrIid, message });
|
|
366
400
|
return null;
|
|
367
401
|
}
|
|
368
402
|
|
|
@@ -373,13 +407,15 @@ export class GitLabProvider implements GitProvider {
|
|
|
373
407
|
const roles: string[] = [];
|
|
374
408
|
if (currentUserNumericId !== null) {
|
|
375
409
|
const userGqlId = `gid://gitlab/User/${currentUserNumericId}`;
|
|
376
|
-
if (gql.author.id === userGqlId) roles.push(
|
|
377
|
-
if (gql.assignees.nodes.some(
|
|
378
|
-
|
|
410
|
+
if (gql.author.id === userGqlId) roles.push('author');
|
|
411
|
+
if (gql.assignees.nodes.some(u => u.id === userGqlId))
|
|
412
|
+
roles.push('assignee');
|
|
413
|
+
if (gql.reviewers.nodes.some(u => u.id === userGqlId))
|
|
414
|
+
roles.push('reviewer');
|
|
379
415
|
}
|
|
380
416
|
|
|
381
417
|
// Use "author" as the primary role for toMR, then overwrite with full computed roles.
|
|
382
|
-
const pr = toMR(gql, roles[0] ??
|
|
418
|
+
const pr = toMR(gql, roles[0] ?? 'author', this.baseURL);
|
|
383
419
|
pr.roles = roles.length > 0 ? roles : pr.roles;
|
|
384
420
|
return pr;
|
|
385
421
|
}
|
|
@@ -388,7 +424,7 @@ export class GitLabProvider implements GitProvider {
|
|
|
388
424
|
const [authored, reviewing, assigned] = await Promise.all([
|
|
389
425
|
this.runQuery<AuthoredResponse>(AUTHORED_QUERY),
|
|
390
426
|
this.runQuery<ReviewingResponse>(REVIEWING_QUERY),
|
|
391
|
-
this.runQuery<AssignedResponse>(ASSIGNED_QUERY)
|
|
427
|
+
this.runQuery<AssignedResponse>(ASSIGNED_QUERY)
|
|
392
428
|
]);
|
|
393
429
|
|
|
394
430
|
// Merge all three sets, deduplicating by MR global ID.
|
|
@@ -408,28 +444,38 @@ export class GitLabProvider implements GitProvider {
|
|
|
408
444
|
}
|
|
409
445
|
};
|
|
410
446
|
|
|
411
|
-
addAll(authored.currentUser.authoredMergeRequests.nodes,
|
|
412
|
-
addAll(
|
|
413
|
-
|
|
447
|
+
addAll(authored.currentUser.authoredMergeRequests.nodes, 'author');
|
|
448
|
+
addAll(
|
|
449
|
+
reviewing.currentUser.reviewRequestedMergeRequests.nodes,
|
|
450
|
+
'reviewer'
|
|
451
|
+
);
|
|
452
|
+
addAll(assigned.currentUser.assignedMergeRequests.nodes, 'assignee');
|
|
414
453
|
|
|
415
454
|
const prs = [...byId.values()];
|
|
416
|
-
this.log.debug(
|
|
455
|
+
this.log.debug('fetchPullRequests', { count: prs.length });
|
|
417
456
|
return prs;
|
|
418
457
|
}
|
|
419
458
|
|
|
420
|
-
async fetchMRDiscussions(
|
|
459
|
+
async fetchMRDiscussions(
|
|
460
|
+
repositoryId: string,
|
|
461
|
+
mrIid: number
|
|
462
|
+
): Promise<MRDetail> {
|
|
421
463
|
const projectId = parseGitLabRepoId(repositoryId);
|
|
422
464
|
return this.mrDetailFetcher.fetchDetail(projectId, mrIid);
|
|
423
465
|
}
|
|
424
466
|
|
|
425
|
-
async fetchBranchProtectionRules(
|
|
467
|
+
async fetchBranchProtectionRules(
|
|
468
|
+
projectPath: string
|
|
469
|
+
): Promise<BranchProtectionRule[]> {
|
|
426
470
|
const encoded = encodeURIComponent(projectPath);
|
|
427
471
|
const res = await fetch(
|
|
428
472
|
`${this.baseURL}/api/v4/projects/${encoded}/protected_branches?per_page=100`,
|
|
429
|
-
{ headers: {
|
|
473
|
+
{ headers: { 'PRIVATE-TOKEN': this.token } }
|
|
430
474
|
);
|
|
431
475
|
if (!res.ok) {
|
|
432
|
-
throw new Error(
|
|
476
|
+
throw new Error(
|
|
477
|
+
`fetchBranchProtectionRules failed: ${res.status} ${await res.text()}`
|
|
478
|
+
);
|
|
433
479
|
}
|
|
434
480
|
const branches = (await res.json()) as Array<{
|
|
435
481
|
name: string;
|
|
@@ -438,13 +484,13 @@ export class GitLabProvider implements GitProvider {
|
|
|
438
484
|
merge_access_levels: Array<{ access_level: number }>;
|
|
439
485
|
code_owner_approval_required?: boolean;
|
|
440
486
|
}>;
|
|
441
|
-
return branches.map(
|
|
487
|
+
return branches.map(b => ({
|
|
442
488
|
pattern: b.name,
|
|
443
489
|
allowForcePush: b.allow_force_push,
|
|
444
490
|
allowDeletion: false,
|
|
445
491
|
requiredApprovals: 0,
|
|
446
492
|
requireStatusChecks: false,
|
|
447
|
-
raw: b as unknown as Record<string, unknown
|
|
493
|
+
raw: b as unknown as Record<string, unknown>
|
|
448
494
|
}));
|
|
449
495
|
}
|
|
450
496
|
|
|
@@ -452,7 +498,7 @@ export class GitLabProvider implements GitProvider {
|
|
|
452
498
|
const encoded = encodeURIComponent(projectPath);
|
|
453
499
|
const res = await fetch(
|
|
454
500
|
`${this.baseURL}/api/v4/projects/${encoded}/repository/branches/${encodeURIComponent(branch)}`,
|
|
455
|
-
{ method:
|
|
501
|
+
{ method: 'DELETE', headers: { 'PRIVATE-TOKEN': this.token } }
|
|
456
502
|
);
|
|
457
503
|
if (!res.ok) {
|
|
458
504
|
throw new Error(`deleteBranch failed: ${res.status} ${await res.text()}`);
|
|
@@ -461,15 +507,19 @@ export class GitLabProvider implements GitProvider {
|
|
|
461
507
|
|
|
462
508
|
async fetchPullRequestByBranch(
|
|
463
509
|
projectPath: string,
|
|
464
|
-
sourceBranch: string
|
|
510
|
+
sourceBranch: string
|
|
465
511
|
): Promise<PullRequest | null> {
|
|
466
512
|
const encoded = encodeURIComponent(projectPath);
|
|
467
513
|
const url = `${this.baseURL}/api/v4/projects/${encoded}/merge_requests?source_branch=${encodeURIComponent(sourceBranch)}&state=opened&per_page=1`;
|
|
468
514
|
const res = await fetch(url, {
|
|
469
|
-
headers: {
|
|
515
|
+
headers: { 'PRIVATE-TOKEN': this.token }
|
|
470
516
|
});
|
|
471
517
|
if (!res.ok) {
|
|
472
|
-
this.log.warn(
|
|
518
|
+
this.log.warn('fetchPullRequestByBranch failed', {
|
|
519
|
+
projectPath,
|
|
520
|
+
sourceBranch,
|
|
521
|
+
status: res.status
|
|
522
|
+
});
|
|
473
523
|
return null;
|
|
474
524
|
}
|
|
475
525
|
const mrs = (await res.json()) as Array<{ iid: number }>;
|
|
@@ -482,36 +532,41 @@ export class GitLabProvider implements GitProvider {
|
|
|
482
532
|
const body: Record<string, unknown> = {
|
|
483
533
|
source_branch: input.sourceBranch,
|
|
484
534
|
target_branch: input.targetBranch,
|
|
485
|
-
title: input.title
|
|
535
|
+
title: input.title
|
|
486
536
|
};
|
|
487
537
|
if (input.description != null) body.description = input.description;
|
|
488
538
|
if (input.draft != null) body.draft = input.draft;
|
|
489
|
-
if (input.labels?.length) body.labels = input.labels.join(
|
|
539
|
+
if (input.labels?.length) body.labels = input.labels.join(',');
|
|
490
540
|
if (input.assignees?.length) body.assignee_ids = input.assignees;
|
|
491
541
|
if (input.reviewers?.length) body.reviewer_ids = input.reviewers;
|
|
492
542
|
|
|
493
|
-
const res = await fetch(
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
543
|
+
const res = await fetch(
|
|
544
|
+
`${this.baseURL}/api/v4/projects/${encoded}/merge_requests`,
|
|
545
|
+
{
|
|
546
|
+
method: 'POST',
|
|
547
|
+
headers: {
|
|
548
|
+
'PRIVATE-TOKEN': this.token,
|
|
549
|
+
'Content-Type': 'application/json'
|
|
550
|
+
},
|
|
551
|
+
body: JSON.stringify(body)
|
|
552
|
+
}
|
|
553
|
+
);
|
|
501
554
|
if (!res.ok) {
|
|
502
555
|
const text = await res.text();
|
|
503
556
|
throw new Error(`createPullRequest failed: ${res.status} ${text}`);
|
|
504
557
|
}
|
|
505
558
|
const created = (await res.json()) as { iid: number };
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
559
|
+
return this.fetchSingleMRWithRetry(
|
|
560
|
+
input.projectPath,
|
|
561
|
+
created.iid,
|
|
562
|
+
'Created MR but failed to fetch it back'
|
|
563
|
+
);
|
|
509
564
|
}
|
|
510
565
|
|
|
511
566
|
async updatePullRequest(
|
|
512
567
|
projectPath: string,
|
|
513
568
|
mrIid: number,
|
|
514
|
-
input: UpdatePullRequestInput
|
|
569
|
+
input: UpdatePullRequestInput
|
|
515
570
|
): Promise<PullRequest> {
|
|
516
571
|
const encoded = encodeURIComponent(projectPath);
|
|
517
572
|
const body: Record<string, unknown> = {};
|
|
@@ -519,7 +574,7 @@ export class GitLabProvider implements GitProvider {
|
|
|
519
574
|
if (input.description != null) body.description = input.description;
|
|
520
575
|
if (input.draft != null) body.draft = input.draft;
|
|
521
576
|
if (input.targetBranch != null) body.target_branch = input.targetBranch;
|
|
522
|
-
if (input.labels) body.labels = input.labels.join(
|
|
577
|
+
if (input.labels) body.labels = input.labels.join(',');
|
|
523
578
|
if (input.assignees) body.assignee_ids = input.assignees;
|
|
524
579
|
if (input.reviewers) body.reviewer_ids = input.reviewers;
|
|
525
580
|
if (input.stateEvent) body.state_event = input.stateEvent;
|
|
@@ -527,54 +582,349 @@ export class GitLabProvider implements GitProvider {
|
|
|
527
582
|
const res = await fetch(
|
|
528
583
|
`${this.baseURL}/api/v4/projects/${encoded}/merge_requests/${mrIid}`,
|
|
529
584
|
{
|
|
530
|
-
method:
|
|
585
|
+
method: 'PUT',
|
|
531
586
|
headers: {
|
|
532
|
-
|
|
533
|
-
|
|
587
|
+
'PRIVATE-TOKEN': this.token,
|
|
588
|
+
'Content-Type': 'application/json'
|
|
534
589
|
},
|
|
535
|
-
body: JSON.stringify(body)
|
|
536
|
-
}
|
|
590
|
+
body: JSON.stringify(body)
|
|
591
|
+
}
|
|
537
592
|
);
|
|
538
593
|
if (!res.ok) {
|
|
539
594
|
const text = await res.text();
|
|
540
595
|
throw new Error(`updatePullRequest failed: ${res.status} ${text}`);
|
|
541
596
|
}
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
597
|
+
return this.fetchSingleMRWithRetry(
|
|
598
|
+
projectPath,
|
|
599
|
+
mrIid,
|
|
600
|
+
'Updated MR but failed to fetch it back'
|
|
601
|
+
);
|
|
545
602
|
}
|
|
546
603
|
|
|
547
|
-
async restRequest(
|
|
604
|
+
async restRequest(
|
|
605
|
+
method: string,
|
|
606
|
+
path: string,
|
|
607
|
+
body?: unknown
|
|
608
|
+
): Promise<Response> {
|
|
548
609
|
const url = `${this.baseURL}${path}`;
|
|
549
610
|
const headers: Record<string, string> = {
|
|
550
|
-
|
|
611
|
+
'PRIVATE-TOKEN': this.token
|
|
551
612
|
};
|
|
552
613
|
if (body !== undefined) {
|
|
553
|
-
headers[
|
|
614
|
+
headers['Content-Type'] = 'application/json';
|
|
554
615
|
}
|
|
555
616
|
return fetch(url, {
|
|
556
617
|
method,
|
|
557
618
|
headers,
|
|
558
|
-
body: body !== undefined ? JSON.stringify(body) : undefined
|
|
619
|
+
body: body !== undefined ? JSON.stringify(body) : undefined
|
|
559
620
|
});
|
|
560
621
|
}
|
|
561
622
|
|
|
623
|
+
// ── MR lifecycle mutations ──────────────────────────────────────────────
|
|
624
|
+
|
|
625
|
+
async mergePullRequest(
|
|
626
|
+
projectPath: string,
|
|
627
|
+
mrIid: number,
|
|
628
|
+
input?: MergePullRequestInput
|
|
629
|
+
): Promise<PullRequest> {
|
|
630
|
+
const encoded = encodeURIComponent(projectPath);
|
|
631
|
+
const body: Record<string, unknown> = {};
|
|
632
|
+
if (input?.commitMessage != null)
|
|
633
|
+
body.merge_commit_message = input.commitMessage;
|
|
634
|
+
if (input?.squashCommitMessage != null)
|
|
635
|
+
body.squash_commit_message = input.squashCommitMessage;
|
|
636
|
+
if (input?.squash != null) body.squash = input.squash;
|
|
637
|
+
if (input?.shouldRemoveSourceBranch != null)
|
|
638
|
+
body.should_remove_source_branch = input.shouldRemoveSourceBranch;
|
|
639
|
+
if (input?.sha != null) body.sha = input.sha;
|
|
640
|
+
// mergeMethod: GitLab uses the project's configured merge method by default.
|
|
641
|
+
// The merge REST API doesn't accept a merge_method param — it honours the project setting.
|
|
642
|
+
// If the caller passes mergeMethod: "squash", we set squash = true as a hint.
|
|
643
|
+
if (input?.mergeMethod === 'squash' && input?.squash == null)
|
|
644
|
+
body.squash = true;
|
|
645
|
+
|
|
646
|
+
const res = await fetch(
|
|
647
|
+
`${this.baseURL}/api/v4/projects/${encoded}/merge_requests/${mrIid}/merge`,
|
|
648
|
+
{
|
|
649
|
+
method: 'PUT',
|
|
650
|
+
headers: {
|
|
651
|
+
'PRIVATE-TOKEN': this.token,
|
|
652
|
+
'Content-Type': 'application/json'
|
|
653
|
+
},
|
|
654
|
+
body: JSON.stringify(body)
|
|
655
|
+
}
|
|
656
|
+
);
|
|
657
|
+
if (!res.ok) {
|
|
658
|
+
const text = await res.text();
|
|
659
|
+
throw new Error(`mergePullRequest failed: ${res.status} ${text}`);
|
|
660
|
+
}
|
|
661
|
+
return this.fetchSingleMRWithRetry(
|
|
662
|
+
projectPath,
|
|
663
|
+
mrIid,
|
|
664
|
+
'Merged MR but failed to fetch it back'
|
|
665
|
+
);
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
async approvePullRequest(projectPath: string, mrIid: number): Promise<void> {
|
|
669
|
+
const encoded = encodeURIComponent(projectPath);
|
|
670
|
+
const res = await fetch(
|
|
671
|
+
`${this.baseURL}/api/v4/projects/${encoded}/merge_requests/${mrIid}/approve`,
|
|
672
|
+
{
|
|
673
|
+
method: 'POST',
|
|
674
|
+
headers: { 'PRIVATE-TOKEN': this.token }
|
|
675
|
+
}
|
|
676
|
+
);
|
|
677
|
+
if (!res.ok) {
|
|
678
|
+
const text = await res.text().catch(() => '');
|
|
679
|
+
throw new Error(
|
|
680
|
+
`approvePullRequest failed: ${res.status} ${res.statusText}${text ? ` — ${text}` : ''}`
|
|
681
|
+
);
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
async unapprovePullRequest(
|
|
686
|
+
projectPath: string,
|
|
687
|
+
mrIid: number
|
|
688
|
+
): Promise<void> {
|
|
689
|
+
const encoded = encodeURIComponent(projectPath);
|
|
690
|
+
const res = await fetch(
|
|
691
|
+
`${this.baseURL}/api/v4/projects/${encoded}/merge_requests/${mrIid}/unapprove`,
|
|
692
|
+
{
|
|
693
|
+
method: 'POST',
|
|
694
|
+
headers: { 'PRIVATE-TOKEN': this.token }
|
|
695
|
+
}
|
|
696
|
+
);
|
|
697
|
+
if (!res.ok) {
|
|
698
|
+
const text = await res.text().catch(() => '');
|
|
699
|
+
throw new Error(
|
|
700
|
+
`unapprovePullRequest failed: ${res.status} ${res.statusText}${text ? ` — ${text}` : ''}`
|
|
701
|
+
);
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
async rebasePullRequest(projectPath: string, mrIid: number): Promise<void> {
|
|
706
|
+
const encoded = encodeURIComponent(projectPath);
|
|
707
|
+
const res = await fetch(
|
|
708
|
+
`${this.baseURL}/api/v4/projects/${encoded}/merge_requests/${mrIid}/rebase`,
|
|
709
|
+
{
|
|
710
|
+
method: 'PUT',
|
|
711
|
+
headers: { 'PRIVATE-TOKEN': this.token }
|
|
712
|
+
}
|
|
713
|
+
);
|
|
714
|
+
if (!res.ok) {
|
|
715
|
+
const text = await res.text().catch(() => '');
|
|
716
|
+
throw new Error(
|
|
717
|
+
`rebasePullRequest failed: ${res.status} ${res.statusText}${text ? ` — ${text}` : ''}`
|
|
718
|
+
);
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
async setAutoMerge(projectPath: string, mrIid: number): Promise<void> {
|
|
723
|
+
// REST: PUT merge with merge_when_pipeline_succeeds = true
|
|
724
|
+
// This tells GitLab to merge automatically once the head pipeline succeeds.
|
|
725
|
+
const encoded = encodeURIComponent(projectPath);
|
|
726
|
+
const res = await fetch(
|
|
727
|
+
`${this.baseURL}/api/v4/projects/${encoded}/merge_requests/${mrIid}/merge`,
|
|
728
|
+
{
|
|
729
|
+
method: 'PUT',
|
|
730
|
+
headers: {
|
|
731
|
+
'PRIVATE-TOKEN': this.token,
|
|
732
|
+
'Content-Type': 'application/json'
|
|
733
|
+
},
|
|
734
|
+
body: JSON.stringify({ merge_when_pipeline_succeeds: true })
|
|
735
|
+
}
|
|
736
|
+
);
|
|
737
|
+
if (!res.ok) {
|
|
738
|
+
const text = await res.text().catch(() => '');
|
|
739
|
+
throw new Error(
|
|
740
|
+
`setAutoMerge failed: ${res.status} ${res.statusText}${text ? ` — ${text}` : ''}`
|
|
741
|
+
);
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
async cancelAutoMerge(projectPath: string, mrIid: number): Promise<void> {
|
|
746
|
+
// REST: POST cancel_merge_when_pipeline_succeeds
|
|
747
|
+
const encoded = encodeURIComponent(projectPath);
|
|
748
|
+
const res = await fetch(
|
|
749
|
+
`${this.baseURL}/api/v4/projects/${encoded}/merge_requests/${mrIid}/cancel_merge_when_pipeline_succeeds`,
|
|
750
|
+
{
|
|
751
|
+
method: 'POST',
|
|
752
|
+
headers: { 'PRIVATE-TOKEN': this.token }
|
|
753
|
+
}
|
|
754
|
+
);
|
|
755
|
+
if (!res.ok) {
|
|
756
|
+
const text = await res.text().catch(() => '');
|
|
757
|
+
throw new Error(
|
|
758
|
+
`cancelAutoMerge failed: ${res.status} ${res.statusText}${text ? ` — ${text}` : ''}`
|
|
759
|
+
);
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
// ── Discussion mutations ────────────────────────────────────────────────
|
|
764
|
+
|
|
765
|
+
async resolveDiscussion(
|
|
766
|
+
projectPath: string,
|
|
767
|
+
mrIid: number,
|
|
768
|
+
discussionId: string
|
|
769
|
+
): Promise<void> {
|
|
770
|
+
const encoded = encodeURIComponent(projectPath);
|
|
771
|
+
const res = await fetch(
|
|
772
|
+
`${this.baseURL}/api/v4/projects/${encoded}/merge_requests/${mrIid}/discussions/${discussionId}`,
|
|
773
|
+
{
|
|
774
|
+
method: 'PUT',
|
|
775
|
+
headers: {
|
|
776
|
+
'PRIVATE-TOKEN': this.token,
|
|
777
|
+
'Content-Type': 'application/json'
|
|
778
|
+
},
|
|
779
|
+
body: JSON.stringify({ resolved: true })
|
|
780
|
+
}
|
|
781
|
+
);
|
|
782
|
+
if (!res.ok) {
|
|
783
|
+
const text = await res.text().catch(() => '');
|
|
784
|
+
throw new Error(
|
|
785
|
+
`resolveDiscussion failed: ${res.status} ${res.statusText}${text ? ` — ${text}` : ''}`
|
|
786
|
+
);
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
async unresolveDiscussion(
|
|
791
|
+
projectPath: string,
|
|
792
|
+
mrIid: number,
|
|
793
|
+
discussionId: string
|
|
794
|
+
): Promise<void> {
|
|
795
|
+
const encoded = encodeURIComponent(projectPath);
|
|
796
|
+
const res = await fetch(
|
|
797
|
+
`${this.baseURL}/api/v4/projects/${encoded}/merge_requests/${mrIid}/discussions/${discussionId}`,
|
|
798
|
+
{
|
|
799
|
+
method: 'PUT',
|
|
800
|
+
headers: {
|
|
801
|
+
'PRIVATE-TOKEN': this.token,
|
|
802
|
+
'Content-Type': 'application/json'
|
|
803
|
+
},
|
|
804
|
+
body: JSON.stringify({ resolved: false })
|
|
805
|
+
}
|
|
806
|
+
);
|
|
807
|
+
if (!res.ok) {
|
|
808
|
+
const text = await res.text().catch(() => '');
|
|
809
|
+
throw new Error(
|
|
810
|
+
`unresolveDiscussion failed: ${res.status} ${res.statusText}${text ? ` — ${text}` : ''}`
|
|
811
|
+
);
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
// ── Pipeline mutations ──────────────────────────────────────────────────
|
|
816
|
+
|
|
817
|
+
async retryPipeline(projectPath: string, pipelineId: number): Promise<void> {
|
|
818
|
+
const encoded = encodeURIComponent(projectPath);
|
|
819
|
+
const res = await fetch(
|
|
820
|
+
`${this.baseURL}/api/v4/projects/${encoded}/pipelines/${pipelineId}/retry`,
|
|
821
|
+
{
|
|
822
|
+
method: 'POST',
|
|
823
|
+
headers: { 'PRIVATE-TOKEN': this.token }
|
|
824
|
+
}
|
|
825
|
+
);
|
|
826
|
+
if (!res.ok) {
|
|
827
|
+
const text = await res.text().catch(() => '');
|
|
828
|
+
throw new Error(
|
|
829
|
+
`retryPipeline failed: ${res.status} ${res.statusText}${text ? ` — ${text}` : ''}`
|
|
830
|
+
);
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
// ── Review mutations ────────────────────────────────────────────────────
|
|
835
|
+
|
|
836
|
+
async requestReReview(
|
|
837
|
+
projectPath: string,
|
|
838
|
+
mrIid: number,
|
|
839
|
+
_reviewerUsernames?: string[]
|
|
840
|
+
): Promise<void> {
|
|
841
|
+
// GitLab does not have a dedicated "re-request review" endpoint.
|
|
842
|
+
// The approach: fetch the current MR to get reviewer IDs, then
|
|
843
|
+
// re-assign them via PUT to trigger review-requested notifications.
|
|
844
|
+
const encoded = encodeURIComponent(projectPath);
|
|
845
|
+
|
|
846
|
+
// Fetch current MR to get existing reviewer IDs
|
|
847
|
+
const mrRes = await fetch(
|
|
848
|
+
`${this.baseURL}/api/v4/projects/${encoded}/merge_requests/${mrIid}`,
|
|
849
|
+
{ headers: { 'PRIVATE-TOKEN': this.token } }
|
|
850
|
+
);
|
|
851
|
+
if (!mrRes.ok) {
|
|
852
|
+
const text = await mrRes.text().catch(() => '');
|
|
853
|
+
throw new Error(
|
|
854
|
+
`requestReReview: failed to fetch MR: ${mrRes.status}${text ? ` — ${text}` : ''}`
|
|
855
|
+
);
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
const mr = (await mrRes.json()) as {
|
|
859
|
+
reviewers?: Array<{ id: number }>;
|
|
860
|
+
};
|
|
861
|
+
const reviewerIds = mr.reviewers?.map(r => r.id) ?? [];
|
|
862
|
+
if (reviewerIds.length === 0) {
|
|
863
|
+
// No reviewers to re-request
|
|
864
|
+
return;
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
// Re-assign the same reviewers to trigger notifications
|
|
868
|
+
const res = await fetch(
|
|
869
|
+
`${this.baseURL}/api/v4/projects/${encoded}/merge_requests/${mrIid}`,
|
|
870
|
+
{
|
|
871
|
+
method: 'PUT',
|
|
872
|
+
headers: {
|
|
873
|
+
'PRIVATE-TOKEN': this.token,
|
|
874
|
+
'Content-Type': 'application/json'
|
|
875
|
+
},
|
|
876
|
+
body: JSON.stringify({ reviewer_ids: reviewerIds })
|
|
877
|
+
}
|
|
878
|
+
);
|
|
879
|
+
if (!res.ok) {
|
|
880
|
+
const text = await res.text().catch(() => '');
|
|
881
|
+
throw new Error(
|
|
882
|
+
`requestReReview failed: ${res.status} ${res.statusText}${text ? ` — ${text}` : ''}`
|
|
883
|
+
);
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
|
|
562
887
|
// MARK: - Private
|
|
563
888
|
|
|
564
|
-
|
|
889
|
+
/**
|
|
890
|
+
* Retry `fetchSingleMR` with exponential backoff to handle REST→GraphQL
|
|
891
|
+
* eventual consistency. GitLab's GraphQL may not immediately reflect
|
|
892
|
+
* changes made via REST. 3 attempts: 0ms, 300ms, 600ms delay.
|
|
893
|
+
*/
|
|
894
|
+
private async fetchSingleMRWithRetry(
|
|
895
|
+
projectPath: string,
|
|
896
|
+
mrIid: number,
|
|
897
|
+
errorMessage: string
|
|
898
|
+
): Promise<PullRequest> {
|
|
899
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
900
|
+
if (attempt > 0) {
|
|
901
|
+
await new Promise(r => setTimeout(r, attempt * 300));
|
|
902
|
+
}
|
|
903
|
+
const pr = await this.fetchSingleMR(projectPath, mrIid, null);
|
|
904
|
+
if (pr) return pr;
|
|
905
|
+
}
|
|
906
|
+
throw new Error(errorMessage);
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
private async runQuery<T>(
|
|
910
|
+
query: string,
|
|
911
|
+
variables?: Record<string, unknown>
|
|
912
|
+
): Promise<T> {
|
|
565
913
|
const url = `${this.baseURL}/api/graphql`;
|
|
566
914
|
const body = JSON.stringify({ query, variables: variables ?? {} });
|
|
567
915
|
const res = await fetch(url, {
|
|
568
|
-
method:
|
|
916
|
+
method: 'POST',
|
|
569
917
|
headers: {
|
|
570
|
-
|
|
571
|
-
Authorization: `Bearer ${this.token}
|
|
918
|
+
'Content-Type': 'application/json',
|
|
919
|
+
Authorization: `Bearer ${this.token}`
|
|
572
920
|
},
|
|
573
|
-
body
|
|
921
|
+
body
|
|
574
922
|
});
|
|
575
923
|
|
|
576
924
|
if (!res.ok) {
|
|
577
|
-
throw new Error(
|
|
925
|
+
throw new Error(
|
|
926
|
+
`GraphQL request failed: ${res.status} ${res.statusText}`
|
|
927
|
+
);
|
|
578
928
|
}
|
|
579
929
|
|
|
580
930
|
const envelope = (await res.json()) as {
|
|
@@ -583,12 +933,12 @@ export class GitLabProvider implements GitProvider {
|
|
|
583
933
|
};
|
|
584
934
|
|
|
585
935
|
if (envelope.errors?.length) {
|
|
586
|
-
const msg = envelope.errors.map(
|
|
936
|
+
const msg = envelope.errors.map(e => e.message).join('; ');
|
|
587
937
|
throw new Error(`GraphQL errors: ${msg}`);
|
|
588
938
|
}
|
|
589
939
|
|
|
590
940
|
if (!envelope.data) {
|
|
591
|
-
throw new Error(
|
|
941
|
+
throw new Error('GraphQL response missing data');
|
|
592
942
|
}
|
|
593
943
|
|
|
594
944
|
return envelope.data;
|