@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.
@@ -12,20 +12,25 @@
12
12
  * provides the instance URL and we append "/api/v3".
13
13
  */
14
14
 
15
- import type { GitProvider } from "./GitProvider.ts";
15
+ import type { GitProvider } from './GitProvider.ts';
16
16
  import type {
17
+ BranchProtectionRule,
18
+ CreatePullRequestInput,
17
19
  DiffStats,
18
20
  Discussion,
21
+ MergePullRequestInput,
19
22
  MRDetail,
20
23
  Note,
21
24
  NoteAuthor,
22
25
  NotePosition,
23
26
  Pipeline,
24
27
  PipelineJob,
28
+ ProviderCapabilities,
25
29
  PullRequest,
26
- UserRef,
27
- } from "./types.ts";
28
- import { type ForgeLogger, noopLogger } from "./logger.ts";
30
+ UpdatePullRequestInput,
31
+ UserRef
32
+ } from './types.ts';
33
+ import { type ForgeLogger, noopLogger } from './logger.ts';
29
34
 
30
35
  // ---------------------------------------------------------------------------
31
36
  // GitHub REST API response shapes (only fields we consume)
@@ -119,7 +124,7 @@ function toUserRef(u: GHUser): UserRef {
119
124
  id: `github:user:${u.id}`,
120
125
  username: u.login,
121
126
  name: u.name ?? u.login,
122
- avatarUrl: u.avatar_url,
127
+ avatarUrl: u.avatar_url
123
128
  };
124
129
  }
125
130
 
@@ -128,9 +133,9 @@ function toUserRef(u: GHUser): UserRef {
128
133
  * GitHub only has "open" and "closed"; we check `merged_at` to distinguish merges.
129
134
  */
130
135
  function normalizePRState(pr: GHPullRequest): string {
131
- if (pr.merged_at) return "merged";
132
- if (pr.state === "open") return "opened";
133
- return "closed";
136
+ if (pr.merged_at) return 'merged';
137
+ if (pr.state === 'open') return 'opened';
138
+ return 'closed';
134
139
  }
135
140
 
136
141
  /**
@@ -139,32 +144,32 @@ function normalizePRState(pr: GHPullRequest): string {
139
144
  */
140
145
  function toPipeline(
141
146
  checkRuns: GHCheckRun[],
142
- prHtmlUrl: string,
147
+ prHtmlUrl: string
143
148
  ): Pipeline | null {
144
149
  if (checkRuns.length === 0) return null;
145
150
 
146
- const jobs: PipelineJob[] = checkRuns.map((cr) => ({
151
+ const jobs: PipelineJob[] = checkRuns.map(cr => ({
147
152
  id: `github:check:${cr.id}`,
148
153
  name: cr.name,
149
- stage: "checks", // GitHub doesn't have stages; use a flat stage name
154
+ stage: 'checks', // GitHub doesn't have stages; use a flat stage name
150
155
  status: normalizeCheckStatus(cr),
151
156
  allowFailure: false,
152
- webUrl: cr.html_url,
157
+ webUrl: cr.html_url
153
158
  }));
154
159
 
155
160
  // Derive overall pipeline status from individual check runs
156
- const statuses = jobs.map((j) => j.status);
161
+ const statuses = jobs.map(j => j.status);
157
162
  let overallStatus: string;
158
- if (statuses.some((s) => s === "failed")) {
159
- overallStatus = "failed";
160
- } else if (statuses.some((s) => s === "running")) {
161
- overallStatus = "running";
162
- } else if (statuses.some((s) => s === "pending")) {
163
- overallStatus = "pending";
164
- } else if (statuses.every((s) => s === "success" || s === "skipped")) {
165
- overallStatus = "success";
163
+ if (statuses.some(s => s === 'failed')) {
164
+ overallStatus = 'failed';
165
+ } else if (statuses.some(s => s === 'running')) {
166
+ overallStatus = 'running';
167
+ } else if (statuses.some(s => s === 'pending')) {
168
+ overallStatus = 'pending';
169
+ } else if (statuses.every(s => s === 'success' || s === 'skipped')) {
170
+ overallStatus = 'success';
166
171
  } else {
167
- overallStatus = "pending";
172
+ overallStatus = 'pending';
168
173
  }
169
174
 
170
175
  return {
@@ -172,32 +177,32 @@ function toPipeline(
172
177
  status: overallStatus,
173
178
  createdAt: null,
174
179
  webUrl: `${prHtmlUrl}/checks`,
175
- jobs,
180
+ jobs
176
181
  };
177
182
  }
178
183
 
179
184
  function normalizeCheckStatus(cr: GHCheckRun): string {
180
- if (cr.status === "completed") {
185
+ if (cr.status === 'completed') {
181
186
  switch (cr.conclusion) {
182
- case "success":
183
- return "success";
184
- case "failure":
185
- case "timed_out":
186
- return "failed";
187
- case "cancelled":
188
- return "canceled";
189
- case "skipped":
190
- return "skipped";
191
- case "neutral":
192
- return "success";
193
- case "action_required":
194
- return "manual";
187
+ case 'success':
188
+ return 'success';
189
+ case 'failure':
190
+ case 'timed_out':
191
+ return 'failed';
192
+ case 'cancelled':
193
+ return 'canceled';
194
+ case 'skipped':
195
+ return 'skipped';
196
+ case 'neutral':
197
+ return 'success';
198
+ case 'action_required':
199
+ return 'manual';
195
200
  default:
196
- return "pending";
201
+ return 'pending';
197
202
  }
198
203
  }
199
- if (cr.status === "in_progress") return "running";
200
- return "pending"; // "queued"
204
+ if (cr.status === 'in_progress') return 'running';
205
+ return 'pending'; // "queued"
201
206
  }
202
207
 
203
208
  // ---------------------------------------------------------------------------
@@ -205,7 +210,7 @@ function normalizeCheckStatus(cr: GHCheckRun): string {
205
210
  // ---------------------------------------------------------------------------
206
211
 
207
212
  export class GitHubProvider implements GitProvider {
208
- readonly providerName = "github" as const;
213
+ readonly providerName = 'github' as const;
209
214
  readonly baseURL: string;
210
215
  private readonly apiBase: string;
211
216
  private readonly token: string;
@@ -217,29 +222,46 @@ export class GitHubProvider implements GitProvider {
217
222
  * @param token — A GitHub PAT (classic or fine-grained) with `repo` scope.
218
223
  * @param options.logger — Optional logger; defaults to noop.
219
224
  */
220
- constructor(baseURL: string, token: string, options: { logger?: ForgeLogger } = {}) {
221
- this.baseURL = baseURL.replace(/\/$/, "");
225
+ constructor(
226
+ baseURL: string,
227
+ token: string,
228
+ options: { logger?: ForgeLogger } = {}
229
+ ) {
230
+ this.baseURL = baseURL.replace(/\/$/, '');
222
231
  this.token = token;
223
232
  this.log = options.logger ?? noopLogger;
224
233
 
225
234
  // API base: github.com uses api.github.com; GHES uses <host>/api/v3
226
235
  if (
227
- this.baseURL === "https://github.com" ||
228
- this.baseURL === "https://www.github.com"
236
+ this.baseURL === 'https://github.com' ||
237
+ this.baseURL === 'https://www.github.com'
229
238
  ) {
230
- this.apiBase = "https://api.github.com";
239
+ this.apiBase = 'https://api.github.com';
231
240
  } else {
232
241
  this.apiBase = `${this.baseURL}/api/v3`;
233
242
  }
234
243
  }
235
244
 
245
+ // ── Capabilities ──────────────────────────────────────────────────────
246
+
247
+ readonly capabilities: ProviderCapabilities = {
248
+ canMerge: true,
249
+ canApprove: true,
250
+ canUnapprove: false,
251
+ canRebase: false,
252
+ canAutoMerge: false,
253
+ canResolveDiscussions: false,
254
+ canRetryPipeline: true,
255
+ canRequestReReview: true
256
+ };
257
+
236
258
  // ── GitProvider interface ─────────────────────────────────────────────────
237
259
 
238
260
  async validateToken(): Promise<UserRef> {
239
- const res = await this.api("GET", "/user");
261
+ const res = await this.api('GET', '/user');
240
262
  if (!res.ok) {
241
263
  throw new Error(
242
- `GitHub token validation failed: ${res.status} ${res.statusText}`,
264
+ `GitHub token validation failed: ${res.status} ${res.statusText}`
243
265
  );
244
266
  }
245
267
  const user = (await res.json()) as GHUser;
@@ -252,9 +274,9 @@ export class GitHubProvider implements GitProvider {
252
274
  // review requests. We merge the results.
253
275
 
254
276
  const [authored, reviewRequested, assigned] = await Promise.all([
255
- this.searchPRs("is:open is:pr author:@me"),
256
- this.searchPRs("is:open is:pr review-requested:@me"),
257
- this.searchPRs("is:open is:pr assignee:@me"),
277
+ this.searchPRs('is:open is:pr author:@me'),
278
+ this.searchPRs('is:open is:pr review-requested:@me'),
279
+ this.searchPRs('is:open is:pr assignee:@me')
258
280
  ]);
259
281
 
260
282
  // Deduplicate by PR number+repo
@@ -274,65 +296,67 @@ export class GitHubProvider implements GitProvider {
274
296
  }
275
297
  };
276
298
 
277
- addAll(authored, "author");
278
- addAll(reviewRequested, "reviewer");
279
- addAll(assigned, "assignee");
299
+ addAll(authored, 'author');
300
+ addAll(reviewRequested, 'reviewer');
301
+ addAll(assigned, 'assignee');
280
302
 
281
303
  // For each unique PR, fetch check runs and reviews in parallel
282
304
  const entries = [...byKey.entries()];
283
305
  const results = await Promise.all(
284
306
  entries.map(async ([key, pr]) => {
285
- const prRoles = roles.get(key) ?? ["author"];
307
+ const prRoles = roles.get(key) ?? ['author'];
286
308
  const [reviews, checkRuns] = await Promise.all([
287
309
  this.fetchReviews(pr.base.repo.full_name, pr.number),
288
- this.fetchCheckRuns(pr.base.repo.full_name, pr.head.sha),
310
+ this.fetchCheckRuns(pr.base.repo.full_name, pr.head.sha)
289
311
  ]);
290
312
  return this.toPullRequest(pr, prRoles, reviews, checkRuns);
291
- }),
313
+ })
292
314
  );
293
315
 
294
- this.log.debug("GitHubProvider.fetchPullRequests", { count: results.length });
316
+ this.log.debug('GitHubProvider.fetchPullRequests', {
317
+ count: results.length
318
+ });
295
319
  return results;
296
320
  }
297
321
 
298
322
  async fetchSingleMR(
299
323
  projectPath: string,
300
324
  mrIid: number,
301
- _currentUserNumericId: number | null,
325
+ _currentUserNumericId: number | null
302
326
  ): Promise<PullRequest | null> {
303
327
  // projectPath for GitHub is "owner/repo"
304
328
  try {
305
- const res = await this.api("GET", `/repos/${projectPath}/pulls/${mrIid}`);
329
+ const res = await this.api('GET', `/repos/${projectPath}/pulls/${mrIid}`);
306
330
  if (!res.ok) return null;
307
331
 
308
332
  const pr = (await res.json()) as GHPullRequest;
309
333
  const [reviews, checkRuns] = await Promise.all([
310
334
  this.fetchReviews(projectPath, mrIid),
311
- this.fetchCheckRuns(projectPath, pr.head.sha),
335
+ this.fetchCheckRuns(projectPath, pr.head.sha)
312
336
  ]);
313
337
 
314
338
  // Determine roles from the current user
315
- const currentUser = await this.api("GET", "/user");
339
+ const currentUser = await this.api('GET', '/user');
316
340
  const currentUserData = (await currentUser.json()) as GHUser;
317
341
  const prRoles: string[] = [];
318
- if (pr.user.id === currentUserData.id) prRoles.push("author");
319
- if (pr.assignees.some((a) => a.id === currentUserData.id))
320
- prRoles.push("assignee");
321
- if (pr.requested_reviewers.some((r) => r.id === currentUserData.id))
322
- prRoles.push("reviewer");
342
+ if (pr.user.id === currentUserData.id) prRoles.push('author');
343
+ if (pr.assignees.some(a => a.id === currentUserData.id))
344
+ prRoles.push('assignee');
345
+ if (pr.requested_reviewers.some(r => r.id === currentUserData.id))
346
+ prRoles.push('reviewer');
323
347
 
324
348
  return this.toPullRequest(
325
349
  pr,
326
- prRoles.length > 0 ? prRoles : ["author"],
350
+ prRoles.length > 0 ? prRoles : ['author'],
327
351
  reviews,
328
- checkRuns,
352
+ checkRuns
329
353
  );
330
354
  } catch (err) {
331
355
  const message = err instanceof Error ? err.message : String(err);
332
- this.log.warn("GitHubProvider.fetchSingleMR failed", {
356
+ this.log.warn('GitHubProvider.fetchSingleMR failed', {
333
357
  projectPath,
334
358
  mrIid,
335
- message,
359
+ message
336
360
  });
337
361
  return null;
338
362
  }
@@ -340,11 +364,11 @@ export class GitHubProvider implements GitProvider {
340
364
 
341
365
  async fetchMRDiscussions(
342
366
  repositoryId: string,
343
- mrIid: number,
367
+ mrIid: number
344
368
  ): Promise<MRDetail> {
345
- const repoId = parseInt(repositoryId.split(":").pop() ?? "0", 10);
369
+ const repoId = parseInt(repositoryId.split(':').pop() ?? '0', 10);
346
370
  // We need the repo full_name. For now, look it up from the API.
347
- const repoRes = await this.api("GET", `/repositories/${repoId}`);
371
+ const repoRes = await this.api('GET', `/repositories/${repoId}`);
348
372
  if (!repoRes.ok) {
349
373
  throw new Error(`Failed to fetch repo: ${repoRes.status}`);
350
374
  }
@@ -353,11 +377,11 @@ export class GitHubProvider implements GitProvider {
353
377
  // Fetch review comments (diff-level) and issue comments (PR-level)
354
378
  const [reviewComments, issueComments] = await Promise.all([
355
379
  this.fetchAllPages<GHComment>(
356
- `/repos/${repo.full_name}/pulls/${mrIid}/comments?per_page=100`,
380
+ `/repos/${repo.full_name}/pulls/${mrIid}/comments?per_page=100`
357
381
  ),
358
382
  this.fetchAllPages<GHComment>(
359
- `/repos/${repo.full_name}/issues/${mrIid}/comments?per_page=100`,
360
- ),
383
+ `/repos/${repo.full_name}/issues/${mrIid}/comments?per_page=100`
384
+ )
361
385
  ]);
362
386
 
363
387
  // Group review comments into threads (by pull_request_review_id and in_reply_to_id)
@@ -369,7 +393,7 @@ export class GitHubProvider implements GitProvider {
369
393
  id: `gh-issue-comment-${c.id}`,
370
394
  resolvable: null,
371
395
  resolved: null,
372
- notes: [toNote(c)],
396
+ notes: [toNote(c)]
373
397
  });
374
398
  }
375
399
 
@@ -385,47 +409,444 @@ export class GitHubProvider implements GitProvider {
385
409
  for (const [rootId, comments] of threadMap) {
386
410
  comments.sort(
387
411
  (a, b) =>
388
- new Date(a.created_at).getTime() - new Date(b.created_at).getTime(),
412
+ new Date(a.created_at).getTime() - new Date(b.created_at).getTime()
389
413
  );
390
414
  discussions.push({
391
415
  id: `gh-review-thread-${rootId}`,
392
416
  resolvable: true,
393
417
  resolved: null, // GitHub doesn't have a native "resolved" state on review threads
394
- notes: comments.map(toNote),
418
+ notes: comments.map(toNote)
395
419
  });
396
420
  }
397
421
 
398
422
  return { mrIid, repositoryId, discussions };
399
423
  }
400
424
 
425
+ async fetchBranchProtectionRules(
426
+ projectPath: string
427
+ ): Promise<BranchProtectionRule[]> {
428
+ const res = await this.api(
429
+ 'GET',
430
+ `/repos/${projectPath}/branches?protected=true&per_page=100`
431
+ );
432
+ if (!res.ok) {
433
+ throw new Error(
434
+ `fetchBranchProtectionRules failed: ${res.status} ${await res.text()}`
435
+ );
436
+ }
437
+ const branches = (await res.json()) as Array<{
438
+ name: string;
439
+ protected: boolean;
440
+ protection?: {
441
+ enabled: boolean;
442
+ required_status_checks?: {
443
+ enforcement_level: string;
444
+ contexts: string[];
445
+ } | null;
446
+ };
447
+ }>;
448
+
449
+ const rules: BranchProtectionRule[] = [];
450
+ for (const b of branches) {
451
+ if (!b.protected) continue;
452
+ // Fetch detailed protection for each protected branch
453
+ const detailRes = await this.api(
454
+ 'GET',
455
+ `/repos/${projectPath}/branches/${encodeURIComponent(b.name)}/protection`
456
+ );
457
+ if (!detailRes.ok) {
458
+ rules.push({
459
+ pattern: b.name,
460
+ allowForcePush: false,
461
+ allowDeletion: false,
462
+ requiredApprovals: 0,
463
+ requireStatusChecks: false
464
+ });
465
+ continue;
466
+ }
467
+ const detail = (await detailRes.json()) as {
468
+ allow_force_pushes?: { enabled: boolean };
469
+ allow_deletions?: { enabled: boolean };
470
+ required_pull_request_reviews?: {
471
+ required_approving_review_count?: number;
472
+ } | null;
473
+ required_status_checks?: { strict: boolean; contexts: string[] } | null;
474
+ };
475
+ rules.push({
476
+ pattern: b.name,
477
+ allowForcePush: detail.allow_force_pushes?.enabled ?? false,
478
+ allowDeletion: detail.allow_deletions?.enabled ?? false,
479
+ requiredApprovals:
480
+ detail.required_pull_request_reviews
481
+ ?.required_approving_review_count ?? 0,
482
+ requireStatusChecks:
483
+ detail.required_status_checks !== null &&
484
+ detail.required_status_checks !== undefined,
485
+ raw: detail as unknown as Record<string, unknown>
486
+ });
487
+ }
488
+ return rules;
489
+ }
490
+
491
+ async deleteBranch(projectPath: string, branch: string): Promise<void> {
492
+ const res = await this.api(
493
+ 'DELETE',
494
+ `/repos/${projectPath}/git/refs/heads/${encodeURIComponent(branch)}`
495
+ );
496
+ if (!res.ok) {
497
+ throw new Error(`deleteBranch failed: ${res.status} ${await res.text()}`);
498
+ }
499
+ }
500
+
501
+ async fetchPullRequestByBranch(
502
+ projectPath: string,
503
+ sourceBranch: string
504
+ ): Promise<PullRequest | null> {
505
+ const res = await this.api(
506
+ 'GET',
507
+ `/repos/${projectPath}/pulls?head=${projectPath.split('/')[0]}:${encodeURIComponent(sourceBranch)}&state=open&per_page=1`
508
+ );
509
+ if (!res.ok) {
510
+ this.log.warn('fetchPullRequestByBranch failed', {
511
+ projectPath,
512
+ sourceBranch,
513
+ status: res.status
514
+ });
515
+ return null;
516
+ }
517
+ const prs = (await res.json()) as GHPullRequest[];
518
+ if (!prs[0]) return null;
519
+ return this.fetchSingleMR(projectPath, prs[0].number, null);
520
+ }
521
+
522
+ async createPullRequest(input: CreatePullRequestInput): Promise<PullRequest> {
523
+ const body: Record<string, unknown> = {
524
+ head: input.sourceBranch,
525
+ base: input.targetBranch,
526
+ title: input.title
527
+ };
528
+ if (input.description != null) body.body = input.description;
529
+ if (input.draft != null) body.draft = input.draft;
530
+
531
+ const res = await this.api(
532
+ 'POST',
533
+ `/repos/${input.projectPath}/pulls`,
534
+ body
535
+ );
536
+ if (!res.ok) {
537
+ const text = await res.text();
538
+ throw new Error(`createPullRequest failed: ${res.status} ${text}`);
539
+ }
540
+ const created = (await res.json()) as GHPullRequest;
541
+
542
+ // GitHub doesn't support reviewers/assignees/labels on create — add them separately
543
+ if (input.reviewers?.length) {
544
+ await this.api(
545
+ 'POST',
546
+ `/repos/${input.projectPath}/pulls/${created.number}/requested_reviewers`,
547
+ {
548
+ reviewers: input.reviewers
549
+ }
550
+ );
551
+ }
552
+ if (input.assignees?.length) {
553
+ await this.api(
554
+ 'POST',
555
+ `/repos/${input.projectPath}/issues/${created.number}/assignees`,
556
+ {
557
+ assignees: input.assignees
558
+ }
559
+ );
560
+ }
561
+ if (input.labels?.length) {
562
+ await this.api(
563
+ 'POST',
564
+ `/repos/${input.projectPath}/issues/${created.number}/labels`,
565
+ {
566
+ labels: input.labels
567
+ }
568
+ );
569
+ }
570
+
571
+ const pr = await this.fetchSingleMR(
572
+ input.projectPath,
573
+ created.number,
574
+ null
575
+ );
576
+ if (!pr) throw new Error('Created PR but failed to fetch it back');
577
+ return pr;
578
+ }
579
+
580
+ async updatePullRequest(
581
+ projectPath: string,
582
+ mrIid: number,
583
+ input: UpdatePullRequestInput
584
+ ): Promise<PullRequest> {
585
+ const body: Record<string, unknown> = {};
586
+ if (input.title != null) body.title = input.title;
587
+ if (input.description != null) body.body = input.description;
588
+ if (input.draft != null) body.draft = input.draft;
589
+ if (input.targetBranch != null) body.base = input.targetBranch;
590
+ if (input.stateEvent)
591
+ body.state = input.stateEvent === 'close' ? 'closed' : 'open';
592
+
593
+ const res = await this.api(
594
+ 'PATCH',
595
+ `/repos/${projectPath}/pulls/${mrIid}`,
596
+ body
597
+ );
598
+ if (!res.ok) {
599
+ const text = await res.text();
600
+ throw new Error(`updatePullRequest failed: ${res.status} ${text}`);
601
+ }
602
+
603
+ // Handle reviewers/assignees/labels replacement if provided
604
+ if (input.reviewers) {
605
+ await this.api(
606
+ 'POST',
607
+ `/repos/${projectPath}/pulls/${mrIid}/requested_reviewers`,
608
+ {
609
+ reviewers: input.reviewers
610
+ }
611
+ );
612
+ }
613
+ if (input.assignees) {
614
+ await this.api(
615
+ 'POST',
616
+ `/repos/${projectPath}/issues/${mrIid}/assignees`,
617
+ {
618
+ assignees: input.assignees
619
+ }
620
+ );
621
+ }
622
+ if (input.labels) {
623
+ await this.api('PUT', `/repos/${projectPath}/issues/${mrIid}/labels`, {
624
+ labels: input.labels
625
+ });
626
+ }
627
+
628
+ const pr = await this.fetchSingleMR(projectPath, mrIid, null);
629
+ if (!pr) throw new Error('Updated PR but failed to fetch it back');
630
+ return pr;
631
+ }
632
+
401
633
  async restRequest(
402
634
  method: string,
403
635
  path: string,
404
- body?: unknown,
636
+ body?: unknown
405
637
  ): Promise<Response> {
406
638
  return this.api(method, path, body);
407
639
  }
408
640
 
641
+ // ── MR lifecycle mutations ──────────────────────────────────────────────
642
+
643
+ async mergePullRequest(
644
+ projectPath: string,
645
+ mrIid: number,
646
+ input?: MergePullRequestInput
647
+ ): Promise<PullRequest> {
648
+ const body: Record<string, unknown> = {};
649
+ if (input?.commitMessage != null) body.commit_title = input.commitMessage;
650
+ if (input?.squashCommitMessage != null)
651
+ body.commit_title = input.squashCommitMessage;
652
+ if (input?.shouldRemoveSourceBranch != null)
653
+ body.delete_branch = input.shouldRemoveSourceBranch;
654
+ if (input?.sha != null) body.sha = input.sha;
655
+
656
+ // Map mergeMethod / squash to GitHub's merge_method parameter.
657
+ // GitHub accepts: "merge", "squash", "rebase".
658
+ if (input?.mergeMethod) {
659
+ body.merge_method = input.mergeMethod;
660
+ } else if (input?.squash) {
661
+ body.merge_method = 'squash';
662
+ }
663
+
664
+ const res = await this.api(
665
+ 'PUT',
666
+ `/repos/${projectPath}/pulls/${mrIid}/merge`,
667
+ body
668
+ );
669
+ if (!res.ok) {
670
+ const text = await res.text();
671
+ throw new Error(`mergePullRequest failed: ${res.status} ${text}`);
672
+ }
673
+ const pr = await this.fetchSingleMR(projectPath, mrIid, null);
674
+ if (!pr) throw new Error('Merged PR but failed to fetch it back');
675
+ return pr;
676
+ }
677
+
678
+ async approvePullRequest(projectPath: string, mrIid: number): Promise<void> {
679
+ const res = await this.api(
680
+ 'POST',
681
+ `/repos/${projectPath}/pulls/${mrIid}/reviews`,
682
+ {
683
+ event: 'APPROVE'
684
+ }
685
+ );
686
+ if (!res.ok) {
687
+ const text = await res.text().catch(() => '');
688
+ throw new Error(
689
+ `approvePullRequest failed: ${res.status} ${res.statusText}${text ? ` — ${text}` : ''}`
690
+ );
691
+ }
692
+ }
693
+
694
+ async unapprovePullRequest(
695
+ _projectPath: string,
696
+ _mrIid: number
697
+ ): Promise<void> {
698
+ // TODO: GitHub does not support unapproving via REST API.
699
+ // A possible workaround is to dismiss the review via
700
+ // PUT /repos/{owner}/{repo}/pulls/{pull_number}/reviews/{review_id}/dismissals
701
+ // but that requires knowing the review ID and is semantically different
702
+ // (dismissal vs. unapproval). Leave as stub until a use case emerges.
703
+ throw new Error(
704
+ 'unapprovePullRequest is not supported by GitHub. ' +
705
+ 'Check provider.capabilities.canUnapprove before calling.'
706
+ );
707
+ }
708
+
709
+ async rebasePullRequest(_projectPath: string, _mrIid: number): Promise<void> {
710
+ // TODO: GitHub has no native "rebase" API for pull requests.
711
+ // The closest equivalent is the update-branch API:
712
+ // PUT /repos/{owner}/{repo}/pulls/{pull_number}/update-branch
713
+ // which updates the PR branch with the latest from the base branch,
714
+ // but it's a merge (not a rebase). True rebase requires pushing
715
+ // locally rebased commits.
716
+ throw new Error(
717
+ 'rebasePullRequest is not supported by GitHub. ' +
718
+ 'Check provider.capabilities.canRebase before calling.'
719
+ );
720
+ }
721
+
722
+ async setAutoMerge(_projectPath: string, _mrIid: number): Promise<void> {
723
+ // TODO: GitHub supports auto-merge via GraphQL mutation:
724
+ // mutation { enablePullRequestAutoMerge(input: { pullRequestId: "..." }) { ... } }
725
+ // Requires the repository to have "Allow auto-merge" enabled in settings.
726
+ // The REST API does not support this — GraphQL only.
727
+ throw new Error(
728
+ 'setAutoMerge is not supported by the GitHub REST API. ' +
729
+ 'Check provider.capabilities.canAutoMerge before calling.'
730
+ );
731
+ }
732
+
733
+ async cancelAutoMerge(_projectPath: string, _mrIid: number): Promise<void> {
734
+ // TODO: GitHub GraphQL mutation:
735
+ // mutation { disablePullRequestAutoMerge(input: { pullRequestId: "..." }) { ... } }
736
+ // Same pre-requisites as setAutoMerge.
737
+ throw new Error(
738
+ 'cancelAutoMerge is not supported by the GitHub REST API. ' +
739
+ 'Check provider.capabilities.canAutoMerge before calling.'
740
+ );
741
+ }
742
+
743
+ // ── Discussion mutations ────────────────────────────────────────────────
744
+
745
+ async resolveDiscussion(
746
+ _projectPath: string,
747
+ _mrIid: number,
748
+ _discussionId: string
749
+ ): Promise<void> {
750
+ // TODO: GitHub GraphQL mutation:
751
+ // mutation { resolveReviewThread(input: { threadId: "..." }) { ... } }
752
+ // REST API does not support resolving review threads.
753
+ throw new Error(
754
+ 'resolveDiscussion is not supported by the GitHub REST API. ' +
755
+ 'Check provider.capabilities.canResolveDiscussions before calling.'
756
+ );
757
+ }
758
+
759
+ async unresolveDiscussion(
760
+ _projectPath: string,
761
+ _mrIid: number,
762
+ _discussionId: string
763
+ ): Promise<void> {
764
+ // TODO: GitHub GraphQL mutation:
765
+ // mutation { unresolveReviewThread(input: { threadId: "..." }) { ... } }
766
+ // REST API does not support unresolving review threads.
767
+ throw new Error(
768
+ 'unresolveDiscussion is not supported by the GitHub REST API. ' +
769
+ 'Check provider.capabilities.canResolveDiscussions before calling.'
770
+ );
771
+ }
772
+
773
+ // ── Pipeline mutations ──────────────────────────────────────────────────
774
+
775
+ async retryPipeline(projectPath: string, pipelineId: number): Promise<void> {
776
+ // GitHub Actions: re-run a workflow run.
777
+ // pipelineId maps to the workflow run ID.
778
+ const res = await this.api(
779
+ 'POST',
780
+ `/repos/${projectPath}/actions/runs/${pipelineId}/rerun`
781
+ );
782
+ if (!res.ok) {
783
+ const text = await res.text().catch(() => '');
784
+ throw new Error(
785
+ `retryPipeline failed: ${res.status} ${res.statusText}${text ? ` — ${text}` : ''}`
786
+ );
787
+ }
788
+ }
789
+
790
+ // ── Review mutations ────────────────────────────────────────────────────
791
+
792
+ async requestReReview(
793
+ projectPath: string,
794
+ mrIid: number,
795
+ reviewerUsernames?: string[]
796
+ ): Promise<void> {
797
+ // GitHub: POST /repos/{owner}/{repo}/pulls/{pull_number}/requested_reviewers
798
+ // If no usernames provided, we'd need to fetch the current PR to get
799
+ // the existing reviewer list. For now, require explicit usernames.
800
+ if (!reviewerUsernames?.length) {
801
+ // Fetch current reviewers from the PR
802
+ const prRes = await this.api(
803
+ 'GET',
804
+ `/repos/${projectPath}/pulls/${mrIid}`
805
+ );
806
+ if (!prRes.ok) {
807
+ throw new Error(`requestReReview: failed to fetch PR: ${prRes.status}`);
808
+ }
809
+ const pr = (await prRes.json()) as GHPullRequest;
810
+ reviewerUsernames = pr.requested_reviewers.map(r => r.login);
811
+ if (!reviewerUsernames.length) {
812
+ // Nothing to re-request
813
+ return;
814
+ }
815
+ }
816
+
817
+ const res = await this.api(
818
+ 'POST',
819
+ `/repos/${projectPath}/pulls/${mrIid}/requested_reviewers`,
820
+ { reviewers: reviewerUsernames }
821
+ );
822
+ if (!res.ok) {
823
+ const text = await res.text().catch(() => '');
824
+ throw new Error(
825
+ `requestReReview failed: ${res.status} ${res.statusText}${text ? ` — ${text}` : ''}`
826
+ );
827
+ }
828
+ }
829
+
409
830
  // ── Private helpers ─────────────────────────────────────────────────────
410
831
 
411
832
  private async api(
412
833
  method: string,
413
834
  path: string,
414
- body?: unknown,
835
+ body?: unknown
415
836
  ): Promise<Response> {
416
837
  const url = `${this.apiBase}${path}`;
417
838
  const headers: Record<string, string> = {
418
839
  Authorization: `Bearer ${this.token}`,
419
- Accept: "application/vnd.github+json",
420
- "X-GitHub-Api-Version": "2022-11-28",
840
+ Accept: 'application/vnd.github+json',
841
+ 'X-GitHub-Api-Version': '2022-11-28'
421
842
  };
422
843
  if (body !== undefined) {
423
- headers["Content-Type"] = "application/json";
844
+ headers['Content-Type'] = 'application/json';
424
845
  }
425
846
  return fetch(url, {
426
847
  method,
427
848
  headers,
428
- body: body !== undefined ? JSON.stringify(body) : undefined,
849
+ body: body !== undefined ? JSON.stringify(body) : undefined
429
850
  });
430
851
  }
431
852
 
@@ -436,11 +857,11 @@ export class GitHubProvider implements GitProvider {
436
857
  private async searchPRs(qualifiers: string): Promise<GHPullRequest[]> {
437
858
  const q = encodeURIComponent(qualifiers);
438
859
  const res = await this.api(
439
- "GET",
440
- `/search/issues?q=${q}&per_page=100&sort=updated`,
860
+ 'GET',
861
+ `/search/issues?q=${q}&per_page=100&sort=updated`
441
862
  );
442
863
  if (!res.ok) {
443
- this.log.warn("GitHub search failed", { status: res.status, qualifiers });
864
+ this.log.warn('GitHub search failed', { status: res.status, qualifiers });
444
865
  return [];
445
866
  }
446
867
 
@@ -454,16 +875,16 @@ export class GitHubProvider implements GitProvider {
454
875
 
455
876
  // The search API returns issue-shaped results; fetch full PR details
456
877
  const prPromises = data.items
457
- .filter((item) => item.pull_request) // Only PRs
458
- .map(async (item) => {
878
+ .filter(item => item.pull_request) // Only PRs
879
+ .map(async item => {
459
880
  // Extract owner/repo from repository_url
460
881
  const repoPath = item.repository_url.replace(
461
882
  `${this.apiBase}/repos/`,
462
- "",
883
+ ''
463
884
  );
464
885
  const res = await this.api(
465
- "GET",
466
- `/repos/${repoPath}/pulls/${item.number}`,
886
+ 'GET',
887
+ `/repos/${repoPath}/pulls/${item.number}`
467
888
  );
468
889
  if (!res.ok) return null;
469
890
  return (await res.json()) as GHPullRequest;
@@ -475,21 +896,21 @@ export class GitHubProvider implements GitProvider {
475
896
 
476
897
  private async fetchReviews(
477
898
  repoPath: string,
478
- prNumber: number,
899
+ prNumber: number
479
900
  ): Promise<GHReview[]> {
480
901
  return this.fetchAllPages<GHReview>(
481
- `/repos/${repoPath}/pulls/${prNumber}/reviews?per_page=100`,
902
+ `/repos/${repoPath}/pulls/${prNumber}/reviews?per_page=100`
482
903
  );
483
904
  }
484
905
 
485
906
  private async fetchCheckRuns(
486
907
  repoPath: string,
487
- sha: string,
908
+ sha: string
488
909
  ): Promise<GHCheckRun[]> {
489
910
  try {
490
911
  const res = await this.api(
491
- "GET",
492
- `/repos/${repoPath}/commits/${sha}/check-runs?per_page=100`,
912
+ 'GET',
913
+ `/repos/${repoPath}/commits/${sha}/check-runs?per_page=100`
493
914
  );
494
915
  if (!res.ok) return [];
495
916
  const data = (await res.json()) as GHCheckSuite;
@@ -504,17 +925,17 @@ export class GitHubProvider implements GitProvider {
504
925
  let url: string | null = path;
505
926
 
506
927
  while (url) {
507
- const res = await this.api("GET", url);
928
+ const res = await this.api('GET', url);
508
929
  if (!res.ok) break;
509
930
  const items = (await res.json()) as T[];
510
931
  results.push(...items);
511
932
 
512
933
  // Parse Link header for pagination
513
- const linkHeader = res.headers.get("Link");
934
+ const linkHeader = res.headers.get('Link');
514
935
  const nextMatch = linkHeader?.match(/<([^>]+)>;\s*rel="next"/);
515
936
  if (nextMatch) {
516
937
  // Strip apiBase prefix — `api()` will re-add it
517
- url = nextMatch[1]!.replace(this.apiBase, "");
938
+ url = nextMatch[1]!.replace(this.apiBase, '');
518
939
  } else {
519
940
  url = null;
520
941
  }
@@ -530,14 +951,13 @@ export class GitHubProvider implements GitProvider {
530
951
  pr: GHPullRequest,
531
952
  roles: string[],
532
953
  reviews: GHReview[],
533
- checkRuns: GHCheckRun[],
954
+ checkRuns: GHCheckRun[]
534
955
  ): PullRequest {
535
956
  // Compute approvals: latest review per user, count "APPROVED" ones
536
957
  const latestReviewByUser = new Map<number, GHReview>();
537
958
  for (const r of reviews.sort(
538
959
  (a, b) =>
539
- new Date(a.submitted_at).getTime() -
540
- new Date(b.submitted_at).getTime(),
960
+ new Date(a.submitted_at).getTime() - new Date(b.submitted_at).getTime()
541
961
  )) {
542
962
  latestReviewByUser.set(r.user.id, r);
543
963
  }
@@ -545,9 +965,9 @@ export class GitHubProvider implements GitProvider {
545
965
  const approvedBy: UserRef[] = [];
546
966
  let changesRequested = 0;
547
967
  for (const r of latestReviewByUser.values()) {
548
- if (r.state === "APPROVED") {
968
+ if (r.state === 'APPROVED') {
549
969
  approvedBy.push(toUserRef(r.user));
550
- } else if (r.state === "CHANGES_REQUESTED") {
970
+ } else if (r.state === 'CHANGES_REQUESTED') {
551
971
  changesRequested++;
552
972
  }
553
973
  }
@@ -561,13 +981,12 @@ export class GitHubProvider implements GitProvider {
561
981
  ? {
562
982
  additions: pr.additions!,
563
983
  deletions: pr.deletions ?? 0,
564
- filesChanged: pr.changed_files ?? 0,
984
+ filesChanged: pr.changed_files ?? 0
565
985
  }
566
986
  : null;
567
987
 
568
988
  // Conflicts: GitHub's mergeable_state "dirty" indicates conflicts
569
- const conflicts =
570
- pr.mergeable === false || pr.mergeable_state === "dirty";
989
+ const conflicts = pr.mergeable === false || pr.mergeable_state === 'dirty';
571
990
 
572
991
  const pipeline = toPipeline(checkRuns, pr.html_url);
573
992
 
@@ -596,7 +1015,7 @@ export class GitHubProvider implements GitProvider {
596
1015
  approved: approvedBy.length > 0 && changesRequested === 0,
597
1016
  approvedBy,
598
1017
  diffStats,
599
- detailedMergeStatus: null, // GitHub-specific status not applicable
1018
+ detailedMergeStatus: null // GitHub-specific status not applicable
600
1019
  };
601
1020
  }
602
1021
  }
@@ -612,7 +1031,7 @@ function toNote(c: GHComment): Note {
612
1031
  oldPath: c.path,
613
1032
  newLine: c.line ?? null,
614
1033
  oldLine: c.original_line ?? null,
615
- positionType: c.path ? "text" : null,
1034
+ positionType: c.path ? 'text' : null
616
1035
  }
617
1036
  : null;
618
1037
 
@@ -622,10 +1041,10 @@ function toNote(c: GHComment): Note {
622
1041
  author: toNoteAuthor(c.user),
623
1042
  createdAt: c.created_at,
624
1043
  system: false,
625
- type: c.path ? "DiffNote" : "DiscussionNote",
1044
+ type: c.path ? 'DiffNote' : 'DiscussionNote',
626
1045
  resolvable: c.path ? true : null,
627
1046
  resolved: null,
628
- position,
1047
+ position
629
1048
  };
630
1049
  }
631
1050
 
@@ -634,6 +1053,6 @@ function toNoteAuthor(u: GHUser): NoteAuthor {
634
1053
  id: `github:user:${u.id}`,
635
1054
  username: u.login,
636
1055
  name: u.name ?? u.login,
637
- avatarUrl: u.avatar_url,
1056
+ avatarUrl: u.avatar_url
638
1057
  };
639
1058
  }