@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.
@@ -1,17 +1,19 @@
1
- import type { GitProvider } from "./GitProvider.ts";
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 "./types.ts";
13
- import { type ForgeLogger, noopLogger } from "./logger.ts";
14
- import { MRDetailFetcher } from "./MRDetailFetcher.ts";
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) ?? "0", 10);
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] ?? "0", 10);
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((stage) =>
205
- stage.jobs.nodes.map((job) => ({
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("pipeline", numericId(p.id)),
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((s) => s.jobs.nodes);
231
+ const allJobs = p.stages.nodes.flatMap(s => s.jobs.nodes);
231
232
  const hasAllowFailFailed = allJobs.some(
232
- (j) => j.allowFailure && j.status === "failed",
233
+ j => j.allowFailure && j.status === 'failed'
233
234
  );
234
- if (p.status === "success" && hasAllowFailFailed) {
235
- return "success_with_warnings";
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 === "conflict",
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 = "gitlab" as const;
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(baseURL: string, token: string, options: { logger?: ForgeLogger } = {}) {
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, { logger: this.log });
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: { "PRIVATE-TOKEN": this.token },
345
+ headers: { 'PRIVATE-TOKEN': this.token }
326
346
  });
327
347
  if (!res.ok) {
328
- throw new Error(`Token validation failed: ${res.status} ${res.statusText}`);
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("fetchSingleMR failed", { projectPath, mrIid, message });
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("author");
377
- if (gql.assignees.nodes.some((u) => u.id === userGqlId)) roles.push("assignee");
378
- 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');
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] ?? "author", this.baseURL);
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, "author");
412
- addAll(reviewing.currentUser.reviewRequestedMergeRequests.nodes, "reviewer");
413
- 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');
414
441
 
415
442
  const prs = [...byId.values()];
416
- this.log.debug("fetchPullRequests", { count: prs.length });
443
+ this.log.debug('fetchPullRequests', { count: prs.length });
417
444
  return prs;
418
445
  }
419
446
 
420
- async fetchMRDiscussions(repositoryId: string, mrIid: number): Promise<MRDetail> {
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(projectPath: string): Promise<BranchProtectionRule[]> {
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: { "PRIVATE-TOKEN": this.token } },
461
+ { headers: { 'PRIVATE-TOKEN': this.token } }
430
462
  );
431
463
  if (!res.ok) {
432
- throw new Error(`fetchBranchProtectionRules failed: ${res.status} ${await res.text()}`);
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((b) => ({
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: "DELETE", headers: { "PRIVATE-TOKEN": this.token } },
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: { "PRIVATE-TOKEN": this.token },
503
+ headers: { 'PRIVATE-TOKEN': this.token }
470
504
  });
471
505
  if (!res.ok) {
472
- this.log.warn("fetchPullRequestByBranch failed", { projectPath, sourceBranch, status: res.status });
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(`${this.baseURL}/api/v4/projects/${encoded}/merge_requests`, {
494
- method: "POST",
495
- headers: {
496
- "PRIVATE-TOKEN": this.token,
497
- "Content-Type": "application/json",
498
- },
499
- body: JSON.stringify(body),
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
- const pr = await this.fetchSingleMR(input.projectPath, created.iid, null);
507
- if (!pr) throw new Error("Created MR but failed to fetch it back");
508
- return pr;
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: "PUT",
573
+ method: 'PUT',
531
574
  headers: {
532
- "PRIVATE-TOKEN": this.token,
533
- "Content-Type": "application/json",
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
- const pr = await this.fetchSingleMR(projectPath, mrIid, null);
543
- if (!pr) throw new Error("Updated MR but failed to fetch it back");
544
- return pr;
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(method: string, path: string, body?: unknown): Promise<Response> {
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
- "PRIVATE-TOKEN": this.token,
599
+ 'PRIVATE-TOKEN': this.token
551
600
  };
552
601
  if (body !== undefined) {
553
- headers["Content-Type"] = "application/json";
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
- 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> {
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: "POST",
904
+ method: 'POST',
569
905
  headers: {
570
- "Content-Type": "application/json",
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(`GraphQL request failed: ${res.status} ${res.statusText}`);
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((e) => e.message).join("; ");
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("GraphQL response missing data");
929
+ throw new Error('GraphQL response missing data');
592
930
  }
593
931
 
594
932
  return envelope.data;