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