@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.
@@ -11,9 +11,9 @@
11
11
  * Base URL: "https://api.github.com" for github.com; for GHES, the user
12
12
  * provides the instance URL and we append "/api/v3".
13
13
  */
14
- import type { GitProvider } from "./GitProvider.ts";
15
- import type { BranchProtectionRule, CreatePullRequestInput, MRDetail, PullRequest, UpdatePullRequestInput, UserRef } from "./types.ts";
16
- import { type ForgeLogger } from "./logger.ts";
14
+ import type { GitProvider } from './GitProvider.ts';
15
+ import type { BranchProtectionRule, CreatePullRequestInput, MergePullRequestInput, MRDetail, ProviderCapabilities, PullRequest, UpdatePullRequestInput, UserRef } from './types.ts';
16
+ import { type ForgeLogger } from './logger.ts';
17
17
  export declare class GitHubProvider implements GitProvider {
18
18
  readonly providerName: "github";
19
19
  readonly baseURL: string;
@@ -29,6 +29,7 @@ export declare class GitHubProvider implements GitProvider {
29
29
  constructor(baseURL: string, token: string, options?: {
30
30
  logger?: ForgeLogger;
31
31
  });
32
+ readonly capabilities: ProviderCapabilities;
32
33
  validateToken(): Promise<UserRef>;
33
34
  fetchPullRequests(): Promise<PullRequest[]>;
34
35
  fetchSingleMR(projectPath: string, mrIid: number, _currentUserNumericId: number | null): Promise<PullRequest | null>;
@@ -39,6 +40,16 @@ export declare class GitHubProvider implements GitProvider {
39
40
  createPullRequest(input: CreatePullRequestInput): Promise<PullRequest>;
40
41
  updatePullRequest(projectPath: string, mrIid: number, input: UpdatePullRequestInput): Promise<PullRequest>;
41
42
  restRequest(method: string, path: string, body?: unknown): Promise<Response>;
43
+ mergePullRequest(projectPath: string, mrIid: number, input?: MergePullRequestInput): Promise<PullRequest>;
44
+ approvePullRequest(projectPath: string, mrIid: number): Promise<void>;
45
+ unapprovePullRequest(_projectPath: string, _mrIid: number): Promise<void>;
46
+ rebasePullRequest(_projectPath: string, _mrIid: number): Promise<void>;
47
+ setAutoMerge(_projectPath: string, _mrIid: number): Promise<void>;
48
+ cancelAutoMerge(_projectPath: string, _mrIid: number): Promise<void>;
49
+ resolveDiscussion(_projectPath: string, _mrIid: number, _discussionId: string): Promise<void>;
50
+ unresolveDiscussion(_projectPath: string, _mrIid: number, _discussionId: string): Promise<void>;
51
+ retryPipeline(projectPath: string, pipelineId: number): Promise<void>;
52
+ requestReReview(projectPath: string, mrIid: number, reviewerUsernames?: string[]): Promise<void>;
42
53
  private api;
43
54
  /**
44
55
  * Search for PRs using the GitHub search API.
@@ -95,6 +95,16 @@ class GitHubProvider {
95
95
  this.apiBase = `${this.baseURL}/api/v3`;
96
96
  }
97
97
  }
98
+ capabilities = {
99
+ canMerge: true,
100
+ canApprove: true,
101
+ canUnapprove: false,
102
+ canRebase: false,
103
+ canAutoMerge: false,
104
+ canResolveDiscussions: false,
105
+ canRetryPipeline: true,
106
+ canRequestReReview: true
107
+ };
98
108
  async validateToken() {
99
109
  const res = await this.api("GET", "/user");
100
110
  if (!res.ok) {
@@ -136,7 +146,9 @@ class GitHubProvider {
136
146
  ]);
137
147
  return this.toPullRequest(pr, prRoles, reviews, checkRuns);
138
148
  }));
139
- this.log.debug("GitHubProvider.fetchPullRequests", { count: results.length });
149
+ this.log.debug("GitHubProvider.fetchPullRequests", {
150
+ count: results.length
151
+ });
140
152
  return results;
141
153
  }
142
154
  async fetchSingleMR(projectPath, mrIid, _currentUserNumericId) {
@@ -249,7 +261,11 @@ class GitHubProvider {
249
261
  async fetchPullRequestByBranch(projectPath, sourceBranch) {
250
262
  const res = await this.api("GET", `/repos/${projectPath}/pulls?head=${projectPath.split("/")[0]}:${encodeURIComponent(sourceBranch)}&state=open&per_page=1`);
251
263
  if (!res.ok) {
252
- this.log.warn("fetchPullRequestByBranch failed", { projectPath, sourceBranch, status: res.status });
264
+ this.log.warn("fetchPullRequestByBranch failed", {
265
+ projectPath,
266
+ sourceBranch,
267
+ status: res.status
268
+ });
253
269
  return null;
254
270
  }
255
271
  const prs = await res.json();
@@ -333,6 +349,83 @@ class GitHubProvider {
333
349
  async restRequest(method, path, body) {
334
350
  return this.api(method, path, body);
335
351
  }
352
+ async mergePullRequest(projectPath, mrIid, input) {
353
+ const body = {};
354
+ if (input?.commitMessage != null)
355
+ body.commit_title = input.commitMessage;
356
+ if (input?.squashCommitMessage != null)
357
+ body.commit_title = input.squashCommitMessage;
358
+ if (input?.shouldRemoveSourceBranch != null)
359
+ body.delete_branch = input.shouldRemoveSourceBranch;
360
+ if (input?.sha != null)
361
+ body.sha = input.sha;
362
+ if (input?.mergeMethod) {
363
+ body.merge_method = input.mergeMethod;
364
+ } else if (input?.squash) {
365
+ body.merge_method = "squash";
366
+ }
367
+ const res = await this.api("PUT", `/repos/${projectPath}/pulls/${mrIid}/merge`, body);
368
+ if (!res.ok) {
369
+ const text = await res.text();
370
+ throw new Error(`mergePullRequest failed: ${res.status} ${text}`);
371
+ }
372
+ const pr = await this.fetchSingleMR(projectPath, mrIid, null);
373
+ if (!pr)
374
+ throw new Error("Merged PR but failed to fetch it back");
375
+ return pr;
376
+ }
377
+ async approvePullRequest(projectPath, mrIid) {
378
+ const res = await this.api("POST", `/repos/${projectPath}/pulls/${mrIid}/reviews`, {
379
+ event: "APPROVE"
380
+ });
381
+ if (!res.ok) {
382
+ const text = await res.text().catch(() => "");
383
+ throw new Error(`approvePullRequest failed: ${res.status} ${res.statusText}${text ? ` — ${text}` : ""}`);
384
+ }
385
+ }
386
+ async unapprovePullRequest(_projectPath, _mrIid) {
387
+ throw new Error("unapprovePullRequest is not supported by GitHub. " + "Check provider.capabilities.canUnapprove before calling.");
388
+ }
389
+ async rebasePullRequest(_projectPath, _mrIid) {
390
+ throw new Error("rebasePullRequest is not supported by GitHub. " + "Check provider.capabilities.canRebase before calling.");
391
+ }
392
+ async setAutoMerge(_projectPath, _mrIid) {
393
+ throw new Error("setAutoMerge is not supported by the GitHub REST API. " + "Check provider.capabilities.canAutoMerge before calling.");
394
+ }
395
+ async cancelAutoMerge(_projectPath, _mrIid) {
396
+ throw new Error("cancelAutoMerge is not supported by the GitHub REST API. " + "Check provider.capabilities.canAutoMerge before calling.");
397
+ }
398
+ async resolveDiscussion(_projectPath, _mrIid, _discussionId) {
399
+ throw new Error("resolveDiscussion is not supported by the GitHub REST API. " + "Check provider.capabilities.canResolveDiscussions before calling.");
400
+ }
401
+ async unresolveDiscussion(_projectPath, _mrIid, _discussionId) {
402
+ throw new Error("unresolveDiscussion is not supported by the GitHub REST API. " + "Check provider.capabilities.canResolveDiscussions before calling.");
403
+ }
404
+ async retryPipeline(projectPath, pipelineId) {
405
+ const res = await this.api("POST", `/repos/${projectPath}/actions/runs/${pipelineId}/rerun`);
406
+ if (!res.ok) {
407
+ const text = await res.text().catch(() => "");
408
+ throw new Error(`retryPipeline failed: ${res.status} ${res.statusText}${text ? ` — ${text}` : ""}`);
409
+ }
410
+ }
411
+ async requestReReview(projectPath, mrIid, reviewerUsernames) {
412
+ if (!reviewerUsernames?.length) {
413
+ const prRes = await this.api("GET", `/repos/${projectPath}/pulls/${mrIid}`);
414
+ if (!prRes.ok) {
415
+ throw new Error(`requestReReview: failed to fetch PR: ${prRes.status}`);
416
+ }
417
+ const pr = await prRes.json();
418
+ reviewerUsernames = pr.requested_reviewers.map((r) => r.login);
419
+ if (!reviewerUsernames.length) {
420
+ return;
421
+ }
422
+ }
423
+ const res = await this.api("POST", `/repos/${projectPath}/pulls/${mrIid}/requested_reviewers`, { reviewers: reviewerUsernames });
424
+ if (!res.ok) {
425
+ const text = await res.text().catch(() => "");
426
+ throw new Error(`requestReReview failed: ${res.status} ${res.statusText}${text ? ` — ${text}` : ""}`);
427
+ }
428
+ }
336
429
  async api(method, path, body) {
337
430
  const url = `${this.apiBase}${path}`;
338
431
  const headers = {
@@ -447,7 +540,11 @@ class GitHubProvider {
447
540
  approved: approvedBy.length > 0 && changesRequested === 0,
448
541
  approvedBy,
449
542
  diffStats,
450
- detailedMergeStatus: null
543
+ detailedMergeStatus: null,
544
+ autoMergeEnabled: pr.auto_merge != null,
545
+ autoMergeStrategy: pr.auto_merge?.merge_method ?? null,
546
+ mergeUser: pr.auto_merge ? toUserRef(pr.auto_merge.enabled_by) : null,
547
+ mergeAfter: null
451
548
  };
452
549
  }
453
550
  }
@@ -1,13 +1,13 @@
1
- import type { GitProvider } from "./GitProvider.ts";
2
- import type { BranchProtectionRule, CreatePullRequestInput, MRDetail, PullRequest, UpdatePullRequestInput, UserRef } from "./types.ts";
3
- import { type ForgeLogger } from "./logger.ts";
1
+ import type { GitProvider } from './GitProvider.ts';
2
+ import type { BranchProtectionRule, CreatePullRequestInput, MergePullRequestInput, MRDetail, ProviderCapabilities, PullRequest, UpdatePullRequestInput, UserRef } from './types.ts';
3
+ import { type ForgeLogger } from './logger.ts';
4
4
  /**
5
5
  * Strips the provider prefix from a scoped repositoryId and returns the
6
6
  * numeric GitLab project ID needed for REST API calls.
7
7
  * e.g. "gitlab:42" → 42
8
8
  */
9
9
  export declare function parseGitLabRepoId(repositoryId: string): number;
10
- export declare const MR_DASHBOARD_FRAGMENT = "\n fragment MRDashboardFields on MergeRequest {\n id iid projectId title description state draft\n sourceBranch targetBranch webUrl\n diffHeadSha\n updatedAt createdAt\n conflicts\n detailedMergeStatus\n approved\n diffStatsSummary { additions deletions fileCount }\n author { id username name avatarUrl }\n assignees(first: 20) { nodes { id username name avatarUrl } }\n reviewers(first: 20) { nodes { id username name avatarUrl } }\n approvedBy(first: 20) { nodes { id username name avatarUrl } }\n approvalsLeft\n resolvableDiscussionsCount\n resolvedDiscussionsCount\n headPipeline {\n id iid status\n createdAt\n path\n stages(first: 20) { nodes {\n name\n jobs(first: 50) { nodes {\n id name status\n allowFailure\n webPath\n stage { name }\n }}\n }}\n }\n }\n";
10
+ export declare const MR_DASHBOARD_FRAGMENT = "\n fragment MRDashboardFields on MergeRequest {\n id iid projectId title description state draft\n sourceBranch targetBranch webUrl\n diffHeadSha\n updatedAt createdAt\n conflicts\n detailedMergeStatus\n approved\n diffStatsSummary { additions deletions fileCount }\n author { id username name avatarUrl }\n assignees(first: 20) { nodes { id username name avatarUrl } }\n reviewers(first: 20) { nodes { id username name avatarUrl } }\n approvedBy(first: 20) { nodes { id username name avatarUrl } }\n approvalsLeft\n resolvableDiscussionsCount\n resolvedDiscussionsCount\n autoMergeEnabled\n autoMergeStrategy\n mergeUser { id username name avatarUrl }\n mergeAfter\n headPipeline {\n id iid status\n createdAt\n path\n stages(first: 20) { nodes {\n name\n jobs(first: 50) { nodes {\n id name status\n allowFailure\n webPath\n stage { name }\n }}\n }}\n }\n }\n";
11
11
  export declare class GitLabProvider implements GitProvider {
12
12
  readonly providerName: "gitlab";
13
13
  readonly baseURL: string;
@@ -17,6 +17,7 @@ export declare class GitLabProvider implements GitProvider {
17
17
  constructor(baseURL: string, token: string, options?: {
18
18
  logger?: ForgeLogger;
19
19
  });
20
+ readonly capabilities: ProviderCapabilities;
20
21
  validateToken(): Promise<UserRef>;
21
22
  /**
22
23
  * Fetch a single MR by project path and IID.
@@ -35,5 +36,21 @@ export declare class GitLabProvider implements GitProvider {
35
36
  createPullRequest(input: CreatePullRequestInput): Promise<PullRequest>;
36
37
  updatePullRequest(projectPath: string, mrIid: number, input: UpdatePullRequestInput): Promise<PullRequest>;
37
38
  restRequest(method: string, path: string, body?: unknown): Promise<Response>;
39
+ mergePullRequest(projectPath: string, mrIid: number, input?: MergePullRequestInput): Promise<PullRequest>;
40
+ approvePullRequest(projectPath: string, mrIid: number): Promise<void>;
41
+ unapprovePullRequest(projectPath: string, mrIid: number): Promise<void>;
42
+ rebasePullRequest(projectPath: string, mrIid: number): Promise<void>;
43
+ setAutoMerge(projectPath: string, mrIid: number): Promise<void>;
44
+ cancelAutoMerge(projectPath: string, mrIid: number): Promise<void>;
45
+ resolveDiscussion(projectPath: string, mrIid: number, discussionId: string): Promise<void>;
46
+ unresolveDiscussion(projectPath: string, mrIid: number, discussionId: string): Promise<void>;
47
+ retryPipeline(projectPath: string, pipelineId: number): Promise<void>;
48
+ requestReReview(projectPath: string, mrIid: number, _reviewerUsernames?: string[]): Promise<void>;
49
+ /**
50
+ * Retry `fetchSingleMR` with exponential backoff to handle REST→GraphQL
51
+ * eventual consistency. GitLab's GraphQL may not immediately reflect
52
+ * changes made via REST. 3 attempts: 0ms, 300ms, 600ms delay.
53
+ */
54
+ private fetchSingleMRWithRetry;
38
55
  private runQuery;
39
56
  }
@@ -92,6 +92,10 @@ var MR_DASHBOARD_FRAGMENT = `
92
92
  approvalsLeft
93
93
  resolvableDiscussionsCount
94
94
  resolvedDiscussionsCount
95
+ autoMergeEnabled
96
+ autoMergeStrategy
97
+ mergeUser { id username name avatarUrl }
98
+ mergeAfter
95
99
  headPipeline {
96
100
  id iid status
97
101
  createdAt
@@ -212,7 +216,11 @@ function toMR(gql, role, baseURL) {
212
216
  approved: gql.approved ?? false,
213
217
  approvedBy: gql.approvedBy.nodes.map(toUserRef),
214
218
  diffStats,
215
- detailedMergeStatus: gql.detailedMergeStatus ?? null
219
+ detailedMergeStatus: gql.detailedMergeStatus ?? null,
220
+ autoMergeEnabled: gql.autoMergeEnabled ?? false,
221
+ autoMergeStrategy: gql.autoMergeStrategy ?? null,
222
+ mergeUser: gql.mergeUser ? toUserRef(gql.mergeUser) : null,
223
+ mergeAfter: gql.mergeAfter ?? null
216
224
  };
217
225
  }
218
226
  var MR_DETAIL_QUERY = `
@@ -236,8 +244,20 @@ class GitLabProvider {
236
244
  this.baseURL = baseURL.replace(/\/$/, "");
237
245
  this.token = token;
238
246
  this.log = options.logger ?? noopLogger;
239
- this.mrDetailFetcher = new MRDetailFetcher(this.baseURL, token, { logger: this.log });
247
+ this.mrDetailFetcher = new MRDetailFetcher(this.baseURL, token, {
248
+ logger: this.log
249
+ });
240
250
  }
251
+ capabilities = {
252
+ canMerge: true,
253
+ canApprove: true,
254
+ canUnapprove: true,
255
+ canRebase: true,
256
+ canAutoMerge: true,
257
+ canResolveDiscussions: true,
258
+ canRetryPipeline: true,
259
+ canRequestReReview: true
260
+ };
241
261
  async validateToken() {
242
262
  const url = `${this.baseURL}/api/v4/user`;
243
263
  const res = await fetch(url, {
@@ -343,7 +363,11 @@ class GitLabProvider {
343
363
  headers: { "PRIVATE-TOKEN": this.token }
344
364
  });
345
365
  if (!res.ok) {
346
- this.log.warn("fetchPullRequestByBranch failed", { projectPath, sourceBranch, status: res.status });
366
+ this.log.warn("fetchPullRequestByBranch failed", {
367
+ projectPath,
368
+ sourceBranch,
369
+ status: res.status
370
+ });
347
371
  return null;
348
372
  }
349
373
  const mrs = await res.json();
@@ -381,10 +405,7 @@ class GitLabProvider {
381
405
  throw new Error(`createPullRequest failed: ${res.status} ${text}`);
382
406
  }
383
407
  const created = await res.json();
384
- const pr = await this.fetchSingleMR(input.projectPath, created.iid, null);
385
- if (!pr)
386
- throw new Error("Created MR but failed to fetch it back");
387
- return pr;
408
+ return this.fetchSingleMRWithRetry(input.projectPath, created.iid, "Created MR but failed to fetch it back");
388
409
  }
389
410
  async updatePullRequest(projectPath, mrIid, input) {
390
411
  const encoded = encodeURIComponent(projectPath);
@@ -417,10 +438,7 @@ class GitLabProvider {
417
438
  const text = await res.text();
418
439
  throw new Error(`updatePullRequest failed: ${res.status} ${text}`);
419
440
  }
420
- const pr = await this.fetchSingleMR(projectPath, mrIid, null);
421
- if (!pr)
422
- throw new Error("Updated MR but failed to fetch it back");
423
- return pr;
441
+ return this.fetchSingleMRWithRetry(projectPath, mrIid, "Updated MR but failed to fetch it back");
424
442
  }
425
443
  async restRequest(method, path, body) {
426
444
  const url = `${this.baseURL}${path}`;
@@ -436,6 +454,171 @@ class GitLabProvider {
436
454
  body: body !== undefined ? JSON.stringify(body) : undefined
437
455
  });
438
456
  }
457
+ async mergePullRequest(projectPath, mrIid, input) {
458
+ const encoded = encodeURIComponent(projectPath);
459
+ const body = {};
460
+ if (input?.commitMessage != null)
461
+ body.merge_commit_message = input.commitMessage;
462
+ if (input?.squashCommitMessage != null)
463
+ body.squash_commit_message = input.squashCommitMessage;
464
+ if (input?.squash != null)
465
+ body.squash = input.squash;
466
+ if (input?.shouldRemoveSourceBranch != null)
467
+ body.should_remove_source_branch = input.shouldRemoveSourceBranch;
468
+ if (input?.sha != null)
469
+ body.sha = input.sha;
470
+ if (input?.mergeMethod === "squash" && input?.squash == null)
471
+ body.squash = true;
472
+ const res = await fetch(`${this.baseURL}/api/v4/projects/${encoded}/merge_requests/${mrIid}/merge`, {
473
+ method: "PUT",
474
+ headers: {
475
+ "PRIVATE-TOKEN": this.token,
476
+ "Content-Type": "application/json"
477
+ },
478
+ body: JSON.stringify(body)
479
+ });
480
+ if (!res.ok) {
481
+ const text = await res.text();
482
+ throw new Error(`mergePullRequest failed: ${res.status} ${text}`);
483
+ }
484
+ return this.fetchSingleMRWithRetry(projectPath, mrIid, "Merged MR but failed to fetch it back");
485
+ }
486
+ async approvePullRequest(projectPath, mrIid) {
487
+ const encoded = encodeURIComponent(projectPath);
488
+ const res = await fetch(`${this.baseURL}/api/v4/projects/${encoded}/merge_requests/${mrIid}/approve`, {
489
+ method: "POST",
490
+ headers: { "PRIVATE-TOKEN": this.token }
491
+ });
492
+ if (!res.ok) {
493
+ const text = await res.text().catch(() => "");
494
+ throw new Error(`approvePullRequest failed: ${res.status} ${res.statusText}${text ? ` — ${text}` : ""}`);
495
+ }
496
+ }
497
+ async unapprovePullRequest(projectPath, mrIid) {
498
+ const encoded = encodeURIComponent(projectPath);
499
+ const res = await fetch(`${this.baseURL}/api/v4/projects/${encoded}/merge_requests/${mrIid}/unapprove`, {
500
+ method: "POST",
501
+ headers: { "PRIVATE-TOKEN": this.token }
502
+ });
503
+ if (!res.ok) {
504
+ const text = await res.text().catch(() => "");
505
+ throw new Error(`unapprovePullRequest failed: ${res.status} ${res.statusText}${text ? ` — ${text}` : ""}`);
506
+ }
507
+ }
508
+ async rebasePullRequest(projectPath, mrIid) {
509
+ const encoded = encodeURIComponent(projectPath);
510
+ const res = await fetch(`${this.baseURL}/api/v4/projects/${encoded}/merge_requests/${mrIid}/rebase`, {
511
+ method: "PUT",
512
+ headers: { "PRIVATE-TOKEN": this.token }
513
+ });
514
+ if (!res.ok) {
515
+ const text = await res.text().catch(() => "");
516
+ throw new Error(`rebasePullRequest failed: ${res.status} ${res.statusText}${text ? ` — ${text}` : ""}`);
517
+ }
518
+ }
519
+ async setAutoMerge(projectPath, mrIid) {
520
+ const encoded = encodeURIComponent(projectPath);
521
+ const res = await fetch(`${this.baseURL}/api/v4/projects/${encoded}/merge_requests/${mrIid}/merge`, {
522
+ method: "PUT",
523
+ headers: {
524
+ "PRIVATE-TOKEN": this.token,
525
+ "Content-Type": "application/json"
526
+ },
527
+ body: JSON.stringify({ merge_when_pipeline_succeeds: true })
528
+ });
529
+ if (!res.ok) {
530
+ const text = await res.text().catch(() => "");
531
+ throw new Error(`setAutoMerge failed: ${res.status} ${res.statusText}${text ? ` — ${text}` : ""}`);
532
+ }
533
+ }
534
+ async cancelAutoMerge(projectPath, mrIid) {
535
+ const encoded = encodeURIComponent(projectPath);
536
+ const res = await fetch(`${this.baseURL}/api/v4/projects/${encoded}/merge_requests/${mrIid}/cancel_merge_when_pipeline_succeeds`, {
537
+ method: "POST",
538
+ headers: { "PRIVATE-TOKEN": this.token }
539
+ });
540
+ if (!res.ok) {
541
+ const text = await res.text().catch(() => "");
542
+ throw new Error(`cancelAutoMerge failed: ${res.status} ${res.statusText}${text ? ` — ${text}` : ""}`);
543
+ }
544
+ }
545
+ async resolveDiscussion(projectPath, mrIid, discussionId) {
546
+ const encoded = encodeURIComponent(projectPath);
547
+ const res = await fetch(`${this.baseURL}/api/v4/projects/${encoded}/merge_requests/${mrIid}/discussions/${discussionId}`, {
548
+ method: "PUT",
549
+ headers: {
550
+ "PRIVATE-TOKEN": this.token,
551
+ "Content-Type": "application/json"
552
+ },
553
+ body: JSON.stringify({ resolved: true })
554
+ });
555
+ if (!res.ok) {
556
+ const text = await res.text().catch(() => "");
557
+ throw new Error(`resolveDiscussion failed: ${res.status} ${res.statusText}${text ? ` — ${text}` : ""}`);
558
+ }
559
+ }
560
+ async unresolveDiscussion(projectPath, mrIid, discussionId) {
561
+ const encoded = encodeURIComponent(projectPath);
562
+ const res = await fetch(`${this.baseURL}/api/v4/projects/${encoded}/merge_requests/${mrIid}/discussions/${discussionId}`, {
563
+ method: "PUT",
564
+ headers: {
565
+ "PRIVATE-TOKEN": this.token,
566
+ "Content-Type": "application/json"
567
+ },
568
+ body: JSON.stringify({ resolved: false })
569
+ });
570
+ if (!res.ok) {
571
+ const text = await res.text().catch(() => "");
572
+ throw new Error(`unresolveDiscussion failed: ${res.status} ${res.statusText}${text ? ` — ${text}` : ""}`);
573
+ }
574
+ }
575
+ async retryPipeline(projectPath, pipelineId) {
576
+ const encoded = encodeURIComponent(projectPath);
577
+ const res = await fetch(`${this.baseURL}/api/v4/projects/${encoded}/pipelines/${pipelineId}/retry`, {
578
+ method: "POST",
579
+ headers: { "PRIVATE-TOKEN": this.token }
580
+ });
581
+ if (!res.ok) {
582
+ const text = await res.text().catch(() => "");
583
+ throw new Error(`retryPipeline failed: ${res.status} ${res.statusText}${text ? ` — ${text}` : ""}`);
584
+ }
585
+ }
586
+ async requestReReview(projectPath, mrIid, _reviewerUsernames) {
587
+ const encoded = encodeURIComponent(projectPath);
588
+ const mrRes = await fetch(`${this.baseURL}/api/v4/projects/${encoded}/merge_requests/${mrIid}`, { headers: { "PRIVATE-TOKEN": this.token } });
589
+ if (!mrRes.ok) {
590
+ const text = await mrRes.text().catch(() => "");
591
+ throw new Error(`requestReReview: failed to fetch MR: ${mrRes.status}${text ? ` — ${text}` : ""}`);
592
+ }
593
+ const mr = await mrRes.json();
594
+ const reviewerIds = mr.reviewers?.map((r) => r.id) ?? [];
595
+ if (reviewerIds.length === 0) {
596
+ return;
597
+ }
598
+ const res = await fetch(`${this.baseURL}/api/v4/projects/${encoded}/merge_requests/${mrIid}`, {
599
+ method: "PUT",
600
+ headers: {
601
+ "PRIVATE-TOKEN": this.token,
602
+ "Content-Type": "application/json"
603
+ },
604
+ body: JSON.stringify({ reviewer_ids: reviewerIds })
605
+ });
606
+ if (!res.ok) {
607
+ const text = await res.text().catch(() => "");
608
+ throw new Error(`requestReReview failed: ${res.status} ${res.statusText}${text ? ` — ${text}` : ""}`);
609
+ }
610
+ }
611
+ async fetchSingleMRWithRetry(projectPath, mrIid, errorMessage) {
612
+ for (let attempt = 0;attempt < 3; attempt++) {
613
+ if (attempt > 0) {
614
+ await new Promise((r) => setTimeout(r, attempt * 300));
615
+ }
616
+ const pr = await this.fetchSingleMR(projectPath, mrIid, null);
617
+ if (pr)
618
+ return pr;
619
+ }
620
+ throw new Error(errorMessage);
621
+ }
439
622
  async runQuery(query, variables) {
440
623
  const url = `${this.baseURL}/api/graphql`;
441
624
  const body = JSON.stringify({ query, variables: variables ?? {} });
@@ -1,4 +1,4 @@
1
- import type { BranchProtectionRule, CreatePullRequestInput, MRDetail, PullRequest, UpdatePullRequestInput, UserRef } from "./types.ts";
1
+ import type { BranchProtectionRule, CreatePullRequestInput, MergePullRequestInput, MRDetail, ProviderCapabilities, PullRequest, UpdatePullRequestInput, UserRef } from './types.ts';
2
2
  /**
3
3
  * Provider-agnostic interface for a Git hosting service.
4
4
  *
@@ -54,6 +54,69 @@ export interface GitProvider {
54
54
  * Returns the MRDetail with discussions populated.
55
55
  */
56
56
  fetchMRDiscussions(repositoryId: string, mrIid: number): Promise<MRDetail>;
57
+ /**
58
+ * Reports which mutation operations this provider supports.
59
+ * Callers should check these flags to conditionally show/hide UI
60
+ * affordances without knowing which provider they're talking to.
61
+ */
62
+ readonly capabilities: ProviderCapabilities;
63
+ /**
64
+ * Merge (accept) a pull request / merge request.
65
+ * All input fields are optional — omitting them defers to the project's
66
+ * configured defaults (merge method, squash policy, delete-source-branch).
67
+ */
68
+ mergePullRequest(projectPath: string, mrIid: number, input?: MergePullRequestInput): Promise<PullRequest>;
69
+ /**
70
+ * Approve a pull request / merge request.
71
+ * On GitLab: POST /merge_requests/:iid/approve
72
+ * On GitHub: POST /pulls/:number/reviews with event "APPROVE"
73
+ */
74
+ approvePullRequest(projectPath: string, mrIid: number): Promise<void>;
75
+ /**
76
+ * Revoke an existing approval.
77
+ * GitLab-only — GitHub does not support unapproving via API.
78
+ * Check `capabilities.canUnapprove` before calling.
79
+ */
80
+ unapprovePullRequest(projectPath: string, mrIid: number): Promise<void>;
81
+ /**
82
+ * Rebase the MR source branch onto the target branch.
83
+ * GitLab-only — GitHub does not have a native rebase API.
84
+ * Check `capabilities.canRebase` before calling.
85
+ */
86
+ rebasePullRequest(projectPath: string, mrIid: number): Promise<void>;
87
+ /**
88
+ * Enable auto-merge: the MR will be merged automatically when the
89
+ * pipeline succeeds and all approval rules are met.
90
+ * GitLab-only — check `capabilities.canAutoMerge` before calling.
91
+ */
92
+ setAutoMerge(projectPath: string, mrIid: number): Promise<void>;
93
+ /**
94
+ * Cancel a previously enabled auto-merge.
95
+ * GitLab-only — check `capabilities.canAutoMerge` before calling.
96
+ */
97
+ cancelAutoMerge(projectPath: string, mrIid: number): Promise<void>;
98
+ /**
99
+ * Resolve a discussion thread on an MR.
100
+ * GitLab-only — check `capabilities.canResolveDiscussions` before calling.
101
+ */
102
+ resolveDiscussion(projectPath: string, mrIid: number, discussionId: string): Promise<void>;
103
+ /**
104
+ * Unresolve a previously resolved discussion thread.
105
+ * GitLab-only — check `capabilities.canResolveDiscussions` before calling.
106
+ */
107
+ unresolveDiscussion(projectPath: string, mrIid: number, discussionId: string): Promise<void>;
108
+ /**
109
+ * Retry a failed or canceled pipeline.
110
+ * On GitLab: POST /pipelines/:id/retry
111
+ * On GitHub: POST re-run for the workflow run.
112
+ */
113
+ retryPipeline(projectPath: string, pipelineId: number): Promise<void>;
114
+ /**
115
+ * Re-request review attention on an MR from its reviewers.
116
+ * If `reviewerUsernames` is provided, only those reviewers are pinged;
117
+ * otherwise all current reviewers are re-requested.
118
+ */
119
+ requestReReview(projectPath: string, mrIid: number, reviewerUsernames?: string[]): Promise<void>;
57
120
  /**
58
121
  * Make an authenticated REST API request to the provider.
59
122
  * Used for operations that don't have a typed method yet (job traces,
package/dist/index.d.ts CHANGED
@@ -11,17 +11,17 @@
11
11
  * const provider = new GitLabProvider('https://gitlab.com', token, { logger: console });
12
12
  * const prs = await provider.fetchPullRequests();
13
13
  */
14
- export type { PullRequest, PullRequestsSnapshot, CreatePullRequestInput, UpdatePullRequestInput, BranchProtectionRule, Pipeline, PipelineJob, UserRef, DiffStats, Discussion, Note, NoteAuthor, NotePosition, MRDetail, FeedEvent, FeedSnapshot, ServerNotification, } from "./types.ts";
15
- export type { GitProvider } from "./GitProvider.ts";
16
- export { parseRepoId, repoIdProvider } from "./GitProvider.ts";
17
- export type { ForgeLogger } from "./logger.ts";
18
- export { noopLogger } from "./logger.ts";
19
- export { GitLabProvider, parseGitLabRepoId, MR_DASHBOARD_FRAGMENT } from "./GitLabProvider.ts";
20
- export { GitHubProvider } from "./GitHubProvider.ts";
21
- export { createProvider, SUPPORTED_PROVIDERS } from "./providers.ts";
22
- export type { ProviderSlug } from "./providers.ts";
23
- export { ActionCableClient } from "./ActionCableClient.ts";
24
- export type { ActionCableCallbacks } from "./ActionCableClient.ts";
25
- export { MRDetailFetcher } from "./MRDetailFetcher.ts";
26
- export { NoteMutator } from "./NoteMutator.ts";
27
- export type { CreatedNote } from "./NoteMutator.ts";
14
+ export type { PullRequest, PullRequestsSnapshot, CreatePullRequestInput, UpdatePullRequestInput, MergePullRequestInput, MergeMethod, ProviderCapabilities, BranchProtectionRule, Pipeline, PipelineJob, UserRef, DiffStats, Discussion, Note, NoteAuthor, NotePosition, MRDetail, FeedEvent, FeedSnapshot, ServerNotification } from './types.ts';
15
+ export type { GitProvider } from './GitProvider.ts';
16
+ export { parseRepoId, repoIdProvider } from './GitProvider.ts';
17
+ export type { ForgeLogger } from './logger.ts';
18
+ export { noopLogger } from './logger.ts';
19
+ export { GitLabProvider, parseGitLabRepoId, MR_DASHBOARD_FRAGMENT } from './GitLabProvider.ts';
20
+ export { GitHubProvider } from './GitHubProvider.ts';
21
+ export { createProvider, SUPPORTED_PROVIDERS } from './providers.ts';
22
+ export type { ProviderSlug } from './providers.ts';
23
+ export { ActionCableClient } from './ActionCableClient.ts';
24
+ export type { ActionCableCallbacks } from './ActionCableClient.ts';
25
+ export { MRDetailFetcher } from './MRDetailFetcher.ts';
26
+ export { NoteMutator } from './NoteMutator.ts';
27
+ export type { CreatedNote } from './NoteMutator.ts';