@forge-glance/sdk 0.1.0 → 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.
@@ -1,14 +1,19 @@
1
- import type { GitProvider } from "./GitProvider.ts";
1
+ import type { GitProvider } from './GitProvider.ts';
2
2
  import type {
3
+ BranchProtectionRule,
4
+ CreatePullRequestInput,
3
5
  DiffStats,
6
+ MergePullRequestInput,
4
7
  MRDetail,
5
8
  Pipeline,
6
9
  PipelineJob,
10
+ ProviderCapabilities,
7
11
  PullRequest,
8
- UserRef,
9
- } from "./types.ts";
10
- import { type ForgeLogger, noopLogger } from "./logger.ts";
11
- import { MRDetailFetcher } from "./MRDetailFetcher.ts";
12
+ UpdatePullRequestInput,
13
+ UserRef
14
+ } from './types.ts';
15
+ import { type ForgeLogger, noopLogger } from './logger.ts';
16
+ import { MRDetailFetcher } from './MRDetailFetcher.ts';
12
17
 
13
18
  // ---------------------------------------------------------------------------
14
19
  // Repository ID helpers
@@ -20,8 +25,8 @@ import { MRDetailFetcher } from "./MRDetailFetcher.ts";
20
25
  * e.g. "gitlab:42" → 42
21
26
  */
22
27
  export function parseGitLabRepoId(repositoryId: string): number {
23
- const parts = repositoryId.split(":");
24
- return parseInt(parts.at(-1) ?? "0", 10);
28
+ const parts = repositoryId.split(':');
29
+ return parseInt(parts.at(-1) ?? '0', 10);
25
30
  }
26
31
 
27
32
  // ---------------------------------------------------------------------------
@@ -178,8 +183,8 @@ interface AssignedResponse {
178
183
 
179
184
  /** Extract numeric ID from a GQL global ID like "gid://gitlab/MergeRequest/12345". */
180
185
  function numericId(gid: string): number {
181
- const parts = gid.split("/");
182
- return parseInt(parts[parts.length - 1] ?? "0", 10);
186
+ const parts = gid.split('/');
187
+ return parseInt(parts[parts.length - 1] ?? '0', 10);
183
188
  }
184
189
 
185
190
  /** Build a scoped domain ID from a GitLab numeric integer. */
@@ -192,29 +197,28 @@ function toUserRef(u: GQLUser): UserRef {
192
197
  id: `gitlab:user:${numericId(u.id)}`,
193
198
  username: u.username,
194
199
  name: u.name,
195
- avatarUrl: u.avatarUrl,
200
+ avatarUrl: u.avatarUrl
196
201
  };
197
202
  }
198
203
 
199
-
200
204
  function toPipeline(p: GQLPipeline, baseURL: string): Pipeline {
201
- const allJobs: PipelineJob[] = p.stages.nodes.flatMap((stage) =>
202
- stage.jobs.nodes.map((job) => ({
205
+ const allJobs: PipelineJob[] = p.stages.nodes.flatMap(stage =>
206
+ stage.jobs.nodes.map(job => ({
203
207
  id: `gitlab:job:${numericId(job.id)}`,
204
208
  name: job.name,
205
209
  stage: job.stage.name,
206
210
  status: job.status,
207
211
  allowFailure: job.allowFailure,
208
- webUrl: job.webPath ? `${baseURL}${job.webPath}` : null,
209
- })),
212
+ webUrl: job.webPath ? `${baseURL}${job.webPath}` : null
213
+ }))
210
214
  );
211
215
 
212
216
  return {
213
- id: domainId("pipeline", numericId(p.id)),
217
+ id: domainId('pipeline', numericId(p.id)),
214
218
  status: normalizePipelineStatus(p),
215
219
  createdAt: p.createdAt,
216
220
  webUrl: p.path ? `${baseURL}${p.path}` : null,
217
- jobs: allJobs,
221
+ jobs: allJobs
218
222
  };
219
223
  }
220
224
 
@@ -224,12 +228,12 @@ function toPipeline(p: GQLPipeline, baseURL: string): Pipeline {
224
228
  * if any allow-failure job failed but overall status is success → "success_with_warnings"
225
229
  */
226
230
  function normalizePipelineStatus(p: GQLPipeline): string {
227
- const allJobs = p.stages.nodes.flatMap((s) => s.jobs.nodes);
231
+ const allJobs = p.stages.nodes.flatMap(s => s.jobs.nodes);
228
232
  const hasAllowFailFailed = allJobs.some(
229
- (j) => j.allowFailure && j.status === "failed",
233
+ j => j.allowFailure && j.status === 'failed'
230
234
  );
231
- if (p.status === "success" && hasAllowFailFailed) {
232
- return "success_with_warnings";
235
+ if (p.status === 'success' && hasAllowFailFailed) {
236
+ return 'success_with_warnings';
233
237
  }
234
238
  return p.status;
235
239
  }
@@ -243,7 +247,7 @@ function toMR(gql: GQLMR, role: string, baseURL: string): PullRequest {
243
247
  ? {
244
248
  additions: gql.diffStatsSummary.additions,
245
249
  deletions: gql.diffStatsSummary.deletions,
246
- filesChanged: gql.diffStatsSummary.fileCount,
250
+ filesChanged: gql.diffStatsSummary.fileCount
247
251
  }
248
252
  : null;
249
253
 
@@ -255,7 +259,7 @@ function toMR(gql: GQLMR, role: string, baseURL: string): PullRequest {
255
259
  description: gql.description ?? null,
256
260
  state: gql.state,
257
261
  draft: gql.draft,
258
- conflicts: gql.conflicts || gql.detailedMergeStatus === "conflict",
262
+ conflicts: gql.conflicts || gql.detailedMergeStatus === 'conflict',
259
263
  webUrl: gql.webUrl,
260
264
  sourceBranch: gql.sourceBranch,
261
265
  targetBranch: gql.targetBranch,
@@ -272,7 +276,7 @@ function toMR(gql: GQLMR, role: string, baseURL: string): PullRequest {
272
276
  approved: gql.approved ?? false,
273
277
  approvedBy: gql.approvedBy.nodes.map(toUserRef),
274
278
  diffStats,
275
- detailedMergeStatus: gql.detailedMergeStatus ?? null,
279
+ detailedMergeStatus: gql.detailedMergeStatus ?? null
276
280
  };
277
281
  }
278
282
 
@@ -300,29 +304,50 @@ interface MRDetailResponse {
300
304
  // ---------------------------------------------------------------------------
301
305
 
302
306
  export class GitLabProvider implements GitProvider {
303
- readonly providerName = "gitlab" as const;
307
+ readonly providerName = 'gitlab' as const;
304
308
  readonly baseURL: string;
305
309
  private readonly token: string;
306
310
  private readonly log: ForgeLogger;
307
311
  private readonly mrDetailFetcher: MRDetailFetcher;
308
312
 
309
- constructor(baseURL: string, token: string, options: { logger?: ForgeLogger } = {}) {
313
+ constructor(
314
+ baseURL: string,
315
+ token: string,
316
+ options: { logger?: ForgeLogger } = {}
317
+ ) {
310
318
  // Strip trailing slash for consistent URL building
311
- this.baseURL = baseURL.replace(/\/$/, "");
319
+ this.baseURL = baseURL.replace(/\/$/, '');
312
320
  this.token = token;
313
321
  this.log = options.logger ?? noopLogger;
314
- this.mrDetailFetcher = new MRDetailFetcher(this.baseURL, token, { logger: this.log });
322
+ this.mrDetailFetcher = new MRDetailFetcher(this.baseURL, token, {
323
+ logger: this.log
324
+ });
315
325
  }
316
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
+
317
340
  // MARK: - GitProvider
318
341
 
319
342
  async validateToken(): Promise<UserRef> {
320
343
  const url = `${this.baseURL}/api/v4/user`;
321
344
  const res = await fetch(url, {
322
- headers: { "PRIVATE-TOKEN": this.token },
345
+ headers: { 'PRIVATE-TOKEN': this.token }
323
346
  });
324
347
  if (!res.ok) {
325
- throw new Error(`Token validation failed: ${res.status} ${res.statusText}`);
348
+ throw new Error(
349
+ `Token validation failed: ${res.status} ${res.statusText}`
350
+ );
326
351
  }
327
352
  const user = (await res.json()) as {
328
353
  id: number;
@@ -334,7 +359,7 @@ export class GitLabProvider implements GitProvider {
334
359
  id: `gitlab:user:${user.id}`,
335
360
  username: user.username,
336
361
  name: user.name,
337
- avatarUrl: user.avatar_url,
362
+ avatarUrl: user.avatar_url
338
363
  };
339
364
  }
340
365
 
@@ -349,17 +374,17 @@ export class GitLabProvider implements GitProvider {
349
374
  async fetchSingleMR(
350
375
  projectPath: string,
351
376
  mrIid: number,
352
- currentUserNumericId: number | null,
377
+ currentUserNumericId: number | null
353
378
  ): Promise<PullRequest | null> {
354
379
  let resp: MRDetailResponse;
355
380
  try {
356
381
  resp = await this.runQuery<MRDetailResponse>(MR_DETAIL_QUERY, {
357
382
  projectPath,
358
- iid: String(mrIid),
383
+ iid: String(mrIid)
359
384
  });
360
385
  } catch (err) {
361
386
  const message = err instanceof Error ? err.message : String(err);
362
- this.log.warn("fetchSingleMR failed", { projectPath, mrIid, message });
387
+ this.log.warn('fetchSingleMR failed', { projectPath, mrIid, message });
363
388
  return null;
364
389
  }
365
390
 
@@ -370,13 +395,15 @@ export class GitLabProvider implements GitProvider {
370
395
  const roles: string[] = [];
371
396
  if (currentUserNumericId !== null) {
372
397
  const userGqlId = `gid://gitlab/User/${currentUserNumericId}`;
373
- if (gql.author.id === userGqlId) roles.push("author");
374
- if (gql.assignees.nodes.some((u) => u.id === userGqlId)) roles.push("assignee");
375
- if (gql.reviewers.nodes.some((u) => u.id === userGqlId)) roles.push("reviewer");
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');
376
403
  }
377
404
 
378
405
  // Use "author" as the primary role for toMR, then overwrite with full computed roles.
379
- const pr = toMR(gql, roles[0] ?? "author", this.baseURL);
406
+ const pr = toMR(gql, roles[0] ?? 'author', this.baseURL);
380
407
  pr.roles = roles.length > 0 ? roles : pr.roles;
381
408
  return pr;
382
409
  }
@@ -385,7 +412,7 @@ export class GitLabProvider implements GitProvider {
385
412
  const [authored, reviewing, assigned] = await Promise.all([
386
413
  this.runQuery<AuthoredResponse>(AUTHORED_QUERY),
387
414
  this.runQuery<ReviewingResponse>(REVIEWING_QUERY),
388
- this.runQuery<AssignedResponse>(ASSIGNED_QUERY),
415
+ this.runQuery<AssignedResponse>(ASSIGNED_QUERY)
389
416
  ]);
390
417
 
391
418
  // Merge all three sets, deduplicating by MR global ID.
@@ -405,51 +432,487 @@ export class GitLabProvider implements GitProvider {
405
432
  }
406
433
  };
407
434
 
408
- addAll(authored.currentUser.authoredMergeRequests.nodes, "author");
409
- addAll(reviewing.currentUser.reviewRequestedMergeRequests.nodes, "reviewer");
410
- addAll(assigned.currentUser.assignedMergeRequests.nodes, "assignee");
435
+ addAll(authored.currentUser.authoredMergeRequests.nodes, 'author');
436
+ addAll(
437
+ reviewing.currentUser.reviewRequestedMergeRequests.nodes,
438
+ 'reviewer'
439
+ );
440
+ addAll(assigned.currentUser.assignedMergeRequests.nodes, 'assignee');
411
441
 
412
442
  const prs = [...byId.values()];
413
- this.log.debug("fetchPullRequests", { count: prs.length });
443
+ this.log.debug('fetchPullRequests', { count: prs.length });
414
444
  return prs;
415
445
  }
416
446
 
417
- async fetchMRDiscussions(repositoryId: string, mrIid: number): Promise<MRDetail> {
447
+ async fetchMRDiscussions(
448
+ repositoryId: string,
449
+ mrIid: number
450
+ ): Promise<MRDetail> {
418
451
  const projectId = parseGitLabRepoId(repositoryId);
419
452
  return this.mrDetailFetcher.fetchDetail(projectId, mrIid);
420
453
  }
421
454
 
422
- async restRequest(method: string, path: string, body?: unknown): Promise<Response> {
455
+ async fetchBranchProtectionRules(
456
+ projectPath: string
457
+ ): Promise<BranchProtectionRule[]> {
458
+ const encoded = encodeURIComponent(projectPath);
459
+ const res = await fetch(
460
+ `${this.baseURL}/api/v4/projects/${encoded}/protected_branches?per_page=100`,
461
+ { headers: { 'PRIVATE-TOKEN': this.token } }
462
+ );
463
+ if (!res.ok) {
464
+ throw new Error(
465
+ `fetchBranchProtectionRules failed: ${res.status} ${await res.text()}`
466
+ );
467
+ }
468
+ const branches = (await res.json()) as Array<{
469
+ name: string;
470
+ allow_force_push: boolean;
471
+ push_access_levels: Array<{ access_level: number }>;
472
+ merge_access_levels: Array<{ access_level: number }>;
473
+ code_owner_approval_required?: boolean;
474
+ }>;
475
+ return branches.map(b => ({
476
+ pattern: b.name,
477
+ allowForcePush: b.allow_force_push,
478
+ allowDeletion: false,
479
+ requiredApprovals: 0,
480
+ requireStatusChecks: false,
481
+ raw: b as unknown as Record<string, unknown>
482
+ }));
483
+ }
484
+
485
+ async deleteBranch(projectPath: string, branch: string): Promise<void> {
486
+ const encoded = encodeURIComponent(projectPath);
487
+ const res = await fetch(
488
+ `${this.baseURL}/api/v4/projects/${encoded}/repository/branches/${encodeURIComponent(branch)}`,
489
+ { method: 'DELETE', headers: { 'PRIVATE-TOKEN': this.token } }
490
+ );
491
+ if (!res.ok) {
492
+ throw new Error(`deleteBranch failed: ${res.status} ${await res.text()}`);
493
+ }
494
+ }
495
+
496
+ async fetchPullRequestByBranch(
497
+ projectPath: string,
498
+ sourceBranch: string
499
+ ): Promise<PullRequest | null> {
500
+ const encoded = encodeURIComponent(projectPath);
501
+ const url = `${this.baseURL}/api/v4/projects/${encoded}/merge_requests?source_branch=${encodeURIComponent(sourceBranch)}&state=opened&per_page=1`;
502
+ const res = await fetch(url, {
503
+ headers: { 'PRIVATE-TOKEN': this.token }
504
+ });
505
+ if (!res.ok) {
506
+ this.log.warn('fetchPullRequestByBranch failed', {
507
+ projectPath,
508
+ sourceBranch,
509
+ status: res.status
510
+ });
511
+ return null;
512
+ }
513
+ const mrs = (await res.json()) as Array<{ iid: number }>;
514
+ if (!mrs[0]) return null;
515
+ return this.fetchSingleMR(projectPath, mrs[0].iid, null);
516
+ }
517
+
518
+ async createPullRequest(input: CreatePullRequestInput): Promise<PullRequest> {
519
+ const encoded = encodeURIComponent(input.projectPath);
520
+ const body: Record<string, unknown> = {
521
+ source_branch: input.sourceBranch,
522
+ target_branch: input.targetBranch,
523
+ title: input.title
524
+ };
525
+ if (input.description != null) body.description = input.description;
526
+ if (input.draft != null) body.draft = input.draft;
527
+ if (input.labels?.length) body.labels = input.labels.join(',');
528
+ if (input.assignees?.length) body.assignee_ids = input.assignees;
529
+ if (input.reviewers?.length) body.reviewer_ids = input.reviewers;
530
+
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
+ );
542
+ if (!res.ok) {
543
+ const text = await res.text();
544
+ throw new Error(`createPullRequest failed: ${res.status} ${text}`);
545
+ }
546
+ const created = (await res.json()) as { iid: number };
547
+ return this.fetchSingleMRWithRetry(
548
+ input.projectPath,
549
+ created.iid,
550
+ 'Created MR but failed to fetch it back'
551
+ );
552
+ }
553
+
554
+ async updatePullRequest(
555
+ projectPath: string,
556
+ mrIid: number,
557
+ input: UpdatePullRequestInput
558
+ ): Promise<PullRequest> {
559
+ const encoded = encodeURIComponent(projectPath);
560
+ const body: Record<string, unknown> = {};
561
+ if (input.title != null) body.title = input.title;
562
+ if (input.description != null) body.description = input.description;
563
+ if (input.draft != null) body.draft = input.draft;
564
+ if (input.targetBranch != null) body.target_branch = input.targetBranch;
565
+ if (input.labels) body.labels = input.labels.join(',');
566
+ if (input.assignees) body.assignee_ids = input.assignees;
567
+ if (input.reviewers) body.reviewer_ids = input.reviewers;
568
+ if (input.stateEvent) body.state_event = input.stateEvent;
569
+
570
+ const res = await fetch(
571
+ `${this.baseURL}/api/v4/projects/${encoded}/merge_requests/${mrIid}`,
572
+ {
573
+ method: 'PUT',
574
+ headers: {
575
+ 'PRIVATE-TOKEN': this.token,
576
+ 'Content-Type': 'application/json'
577
+ },
578
+ body: JSON.stringify(body)
579
+ }
580
+ );
581
+ if (!res.ok) {
582
+ const text = await res.text();
583
+ throw new Error(`updatePullRequest failed: ${res.status} ${text}`);
584
+ }
585
+ return this.fetchSingleMRWithRetry(
586
+ projectPath,
587
+ mrIid,
588
+ 'Updated MR but failed to fetch it back'
589
+ );
590
+ }
591
+
592
+ async restRequest(
593
+ method: string,
594
+ path: string,
595
+ body?: unknown
596
+ ): Promise<Response> {
423
597
  const url = `${this.baseURL}${path}`;
424
598
  const headers: Record<string, string> = {
425
- "PRIVATE-TOKEN": this.token,
599
+ 'PRIVATE-TOKEN': this.token
426
600
  };
427
601
  if (body !== undefined) {
428
- headers["Content-Type"] = "application/json";
602
+ headers['Content-Type'] = 'application/json';
429
603
  }
430
604
  return fetch(url, {
431
605
  method,
432
606
  headers,
433
- body: body !== undefined ? JSON.stringify(body) : undefined,
607
+ body: body !== undefined ? JSON.stringify(body) : undefined
434
608
  });
435
609
  }
436
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
+
437
875
  // MARK: - Private
438
876
 
439
- private async runQuery<T>(query: string, variables?: Record<string, unknown>): Promise<T> {
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> {
440
901
  const url = `${this.baseURL}/api/graphql`;
441
902
  const body = JSON.stringify({ query, variables: variables ?? {} });
442
903
  const res = await fetch(url, {
443
- method: "POST",
904
+ method: 'POST',
444
905
  headers: {
445
- "Content-Type": "application/json",
446
- Authorization: `Bearer ${this.token}`,
906
+ 'Content-Type': 'application/json',
907
+ Authorization: `Bearer ${this.token}`
447
908
  },
448
- body,
909
+ body
449
910
  });
450
911
 
451
912
  if (!res.ok) {
452
- throw new Error(`GraphQL request failed: ${res.status} ${res.statusText}`);
913
+ throw new Error(
914
+ `GraphQL request failed: ${res.status} ${res.statusText}`
915
+ );
453
916
  }
454
917
 
455
918
  const envelope = (await res.json()) as {
@@ -458,12 +921,12 @@ export class GitLabProvider implements GitProvider {
458
921
  };
459
922
 
460
923
  if (envelope.errors?.length) {
461
- const msg = envelope.errors.map((e) => e.message).join("; ");
924
+ const msg = envelope.errors.map(e => e.message).join('; ');
462
925
  throw new Error(`GraphQL errors: ${msg}`);
463
926
  }
464
927
 
465
928
  if (!envelope.data) {
466
- throw new Error("GraphQL response missing data");
929
+ throw new Error('GraphQL response missing data');
467
930
  }
468
931
 
469
932
  return envelope.data;