@forge-glance/sdk 0.1.1 → 0.2.0

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