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