@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.
@@ -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
  // ---------------------------------------------------------------------------
@@ -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] ?? "0", 10);
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((stage) =>
205
- stage.jobs.nodes.map((job) => ({
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("pipeline", numericId(p.id)),
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((s) => s.jobs.nodes);
239
+ const allJobs = p.stages.nodes.flatMap(s => s.jobs.nodes);
231
240
  const hasAllowFailFailed = allJobs.some(
232
- (j) => j.allowFailure && j.status === "failed",
241
+ j => j.allowFailure && j.status === 'failed'
233
242
  );
234
- if (p.status === "success" && hasAllowFailFailed) {
235
- return "success_with_warnings";
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 === "conflict",
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 = "gitlab" as const;
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(baseURL: string, token: string, options: { logger?: ForgeLogger } = {}) {
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, { logger: this.log });
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: { "PRIVATE-TOKEN": this.token },
357
+ headers: { 'PRIVATE-TOKEN': this.token }
326
358
  });
327
359
  if (!res.ok) {
328
- throw new Error(`Token validation failed: ${res.status} ${res.statusText}`);
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("fetchSingleMR failed", { projectPath, mrIid, message });
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("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");
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] ?? "author", this.baseURL);
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, "author");
412
- addAll(reviewing.currentUser.reviewRequestedMergeRequests.nodes, "reviewer");
413
- addAll(assigned.currentUser.assignedMergeRequests.nodes, "assignee");
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("fetchPullRequests", { count: prs.length });
455
+ this.log.debug('fetchPullRequests', { count: prs.length });
417
456
  return prs;
418
457
  }
419
458
 
420
- async fetchMRDiscussions(repositoryId: string, mrIid: number): Promise<MRDetail> {
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(projectPath: string): Promise<BranchProtectionRule[]> {
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: { "PRIVATE-TOKEN": this.token } },
473
+ { headers: { 'PRIVATE-TOKEN': this.token } }
430
474
  );
431
475
  if (!res.ok) {
432
- throw new Error(`fetchBranchProtectionRules failed: ${res.status} ${await res.text()}`);
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((b) => ({
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: "DELETE", headers: { "PRIVATE-TOKEN": this.token } },
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: { "PRIVATE-TOKEN": this.token },
515
+ headers: { 'PRIVATE-TOKEN': this.token }
470
516
  });
471
517
  if (!res.ok) {
472
- this.log.warn("fetchPullRequestByBranch failed", { projectPath, sourceBranch, status: res.status });
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(`${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
- });
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
- 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;
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: "PUT",
585
+ method: 'PUT',
531
586
  headers: {
532
- "PRIVATE-TOKEN": this.token,
533
- "Content-Type": "application/json",
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
- 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;
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(method: string, path: string, body?: unknown): Promise<Response> {
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
- "PRIVATE-TOKEN": this.token,
611
+ 'PRIVATE-TOKEN': this.token
551
612
  };
552
613
  if (body !== undefined) {
553
- headers["Content-Type"] = "application/json";
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
- private async runQuery<T>(query: string, variables?: Record<string, unknown>): Promise<T> {
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: "POST",
916
+ method: 'POST',
569
917
  headers: {
570
- "Content-Type": "application/json",
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(`GraphQL request failed: ${res.status} ${res.statusText}`);
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((e) => e.message).join("; ");
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("GraphQL response missing data");
941
+ throw new Error('GraphQL response missing data');
592
942
  }
593
943
 
594
944
  return envelope.data;