@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.
@@ -1,4 +1,4 @@
1
- import type { MRDetail, PullRequest, UserRef } from "./types.ts";
1
+ import type { BranchProtectionRule, CreatePullRequestInput, MergePullRequestInput, MRDetail, ProviderCapabilities, PullRequest, UpdatePullRequestInput, UserRef } from './types.ts';
2
2
  /**
3
3
  * Provider-agnostic interface for a Git hosting service.
4
4
  *
@@ -24,11 +24,99 @@ export interface GitProvider {
24
24
  * Used by SubscriptionManager for real-time update handling.
25
25
  */
26
26
  fetchSingleMR(projectPath: string, mrIid: number, currentUserNumericId: number | null): Promise<PullRequest | null>;
27
+ /**
28
+ * Fetch a single MR/PR by its source branch within a project.
29
+ * Returns null if no open MR/PR exists for that branch.
30
+ */
31
+ fetchPullRequestByBranch(projectPath: string, sourceBranch: string): Promise<PullRequest | null>;
32
+ /**
33
+ * Create a new merge request / pull request.
34
+ * Returns the created PullRequest.
35
+ */
36
+ createPullRequest(input: CreatePullRequestInput): Promise<PullRequest>;
37
+ /**
38
+ * Update an existing merge request / pull request.
39
+ * Returns the updated PullRequest.
40
+ */
41
+ updatePullRequest(projectPath: string, mrIid: number, input: UpdatePullRequestInput): Promise<PullRequest>;
42
+ /**
43
+ * Fetch branch protection rules for a repository.
44
+ * Returns an array of rules (one per protected branch/pattern).
45
+ */
46
+ fetchBranchProtectionRules(projectPath: string): Promise<BranchProtectionRule[]>;
47
+ /**
48
+ * Delete a branch from the repository.
49
+ * @throws if the branch doesn't exist or is protected.
50
+ */
51
+ deleteBranch(projectPath: string, branch: string): Promise<void>;
27
52
  /**
28
53
  * Fetch discussions (comments, threads) for a specific MR/PR.
29
54
  * Returns the MRDetail with discussions populated.
30
55
  */
31
56
  fetchMRDiscussions(repositoryId: string, mrIid: number): Promise<MRDetail>;
57
+ /**
58
+ * Reports which mutation operations this provider supports.
59
+ * Callers should check these flags to conditionally show/hide UI
60
+ * affordances without knowing which provider they're talking to.
61
+ */
62
+ readonly capabilities: ProviderCapabilities;
63
+ /**
64
+ * Merge (accept) a pull request / merge request.
65
+ * All input fields are optional — omitting them defers to the project's
66
+ * configured defaults (merge method, squash policy, delete-source-branch).
67
+ */
68
+ mergePullRequest(projectPath: string, mrIid: number, input?: MergePullRequestInput): Promise<PullRequest>;
69
+ /**
70
+ * Approve a pull request / merge request.
71
+ * On GitLab: POST /merge_requests/:iid/approve
72
+ * On GitHub: POST /pulls/:number/reviews with event "APPROVE"
73
+ */
74
+ approvePullRequest(projectPath: string, mrIid: number): Promise<void>;
75
+ /**
76
+ * Revoke an existing approval.
77
+ * GitLab-only — GitHub does not support unapproving via API.
78
+ * Check `capabilities.canUnapprove` before calling.
79
+ */
80
+ unapprovePullRequest(projectPath: string, mrIid: number): Promise<void>;
81
+ /**
82
+ * Rebase the MR source branch onto the target branch.
83
+ * GitLab-only — GitHub does not have a native rebase API.
84
+ * Check `capabilities.canRebase` before calling.
85
+ */
86
+ rebasePullRequest(projectPath: string, mrIid: number): Promise<void>;
87
+ /**
88
+ * Enable auto-merge: the MR will be merged automatically when the
89
+ * pipeline succeeds and all approval rules are met.
90
+ * GitLab-only — check `capabilities.canAutoMerge` before calling.
91
+ */
92
+ setAutoMerge(projectPath: string, mrIid: number): Promise<void>;
93
+ /**
94
+ * Cancel a previously enabled auto-merge.
95
+ * GitLab-only — check `capabilities.canAutoMerge` before calling.
96
+ */
97
+ cancelAutoMerge(projectPath: string, mrIid: number): Promise<void>;
98
+ /**
99
+ * Resolve a discussion thread on an MR.
100
+ * GitLab-only — check `capabilities.canResolveDiscussions` before calling.
101
+ */
102
+ resolveDiscussion(projectPath: string, mrIid: number, discussionId: string): Promise<void>;
103
+ /**
104
+ * Unresolve a previously resolved discussion thread.
105
+ * GitLab-only — check `capabilities.canResolveDiscussions` before calling.
106
+ */
107
+ unresolveDiscussion(projectPath: string, mrIid: number, discussionId: string): Promise<void>;
108
+ /**
109
+ * Retry a failed or canceled pipeline.
110
+ * On GitLab: POST /pipelines/:id/retry
111
+ * On GitHub: POST re-run for the workflow run.
112
+ */
113
+ retryPipeline(projectPath: string, pipelineId: number): Promise<void>;
114
+ /**
115
+ * Re-request review attention on an MR from its reviewers.
116
+ * If `reviewerUsernames` is provided, only those reviewers are pinged;
117
+ * otherwise all current reviewers are re-requested.
118
+ */
119
+ requestReReview(projectPath: string, mrIid: number, reviewerUsernames?: string[]): Promise<void>;
32
120
  /**
33
121
  * Make an authenticated REST API request to the provider.
34
122
  * Used for operations that don't have a typed method yet (job traces,
package/dist/index.d.ts CHANGED
@@ -11,17 +11,17 @@
11
11
  * const provider = new GitLabProvider('https://gitlab.com', token, { logger: console });
12
12
  * const prs = await provider.fetchPullRequests();
13
13
  */
14
- export type { PullRequest, PullRequestsSnapshot, Pipeline, PipelineJob, UserRef, DiffStats, Discussion, Note, NoteAuthor, NotePosition, MRDetail, FeedEvent, FeedSnapshot, ServerNotification, } from "./types.ts";
15
- export type { GitProvider } from "./GitProvider.ts";
16
- export { parseRepoId, repoIdProvider } from "./GitProvider.ts";
17
- export type { ForgeLogger } from "./logger.ts";
18
- export { noopLogger } from "./logger.ts";
19
- export { GitLabProvider, parseGitLabRepoId, MR_DASHBOARD_FRAGMENT } from "./GitLabProvider.ts";
20
- export { GitHubProvider } from "./GitHubProvider.ts";
21
- export { createProvider, SUPPORTED_PROVIDERS } from "./providers.ts";
22
- export type { ProviderSlug } from "./providers.ts";
23
- export { ActionCableClient } from "./ActionCableClient.ts";
24
- export type { ActionCableCallbacks } from "./ActionCableClient.ts";
25
- export { MRDetailFetcher } from "./MRDetailFetcher.ts";
26
- export { NoteMutator } from "./NoteMutator.ts";
27
- export type { CreatedNote } from "./NoteMutator.ts";
14
+ export type { PullRequest, PullRequestsSnapshot, CreatePullRequestInput, UpdatePullRequestInput, MergePullRequestInput, MergeMethod, ProviderCapabilities, BranchProtectionRule, Pipeline, PipelineJob, UserRef, DiffStats, Discussion, Note, NoteAuthor, NotePosition, MRDetail, FeedEvent, FeedSnapshot, ServerNotification } from './types.ts';
15
+ export type { GitProvider } from './GitProvider.ts';
16
+ export { parseRepoId, repoIdProvider } from './GitProvider.ts';
17
+ export type { ForgeLogger } from './logger.ts';
18
+ export { noopLogger } from './logger.ts';
19
+ export { GitLabProvider, parseGitLabRepoId, MR_DASHBOARD_FRAGMENT } from './GitLabProvider.ts';
20
+ export { GitHubProvider } from './GitHubProvider.ts';
21
+ export { createProvider, SUPPORTED_PROVIDERS } from './providers.ts';
22
+ export type { ProviderSlug } from './providers.ts';
23
+ export { ActionCableClient } from './ActionCableClient.ts';
24
+ export type { ActionCableCallbacks } from './ActionCableClient.ts';
25
+ export { MRDetailFetcher } from './MRDetailFetcher.ts';
26
+ export { NoteMutator } from './NoteMutator.ts';
27
+ export type { CreatedNote } from './NoteMutator.ts';
package/dist/index.js CHANGED
@@ -245,8 +245,20 @@ class GitLabProvider {
245
245
  this.baseURL = baseURL.replace(/\/$/, "");
246
246
  this.token = token;
247
247
  this.log = options.logger ?? noopLogger;
248
- this.mrDetailFetcher = new MRDetailFetcher(this.baseURL, token, { logger: this.log });
248
+ this.mrDetailFetcher = new MRDetailFetcher(this.baseURL, token, {
249
+ logger: this.log
250
+ });
249
251
  }
252
+ capabilities = {
253
+ canMerge: true,
254
+ canApprove: true,
255
+ canUnapprove: true,
256
+ canRebase: true,
257
+ canAutoMerge: true,
258
+ canResolveDiscussions: true,
259
+ canRetryPipeline: true,
260
+ canRequestReReview: true
261
+ };
250
262
  async validateToken() {
251
263
  const url = `${this.baseURL}/api/v4/user`;
252
264
  const res = await fetch(url, {
@@ -322,6 +334,113 @@ class GitLabProvider {
322
334
  const projectId = parseGitLabRepoId(repositoryId);
323
335
  return this.mrDetailFetcher.fetchDetail(projectId, mrIid);
324
336
  }
337
+ async fetchBranchProtectionRules(projectPath) {
338
+ const encoded = encodeURIComponent(projectPath);
339
+ const res = await fetch(`${this.baseURL}/api/v4/projects/${encoded}/protected_branches?per_page=100`, { headers: { "PRIVATE-TOKEN": this.token } });
340
+ if (!res.ok) {
341
+ throw new Error(`fetchBranchProtectionRules failed: ${res.status} ${await res.text()}`);
342
+ }
343
+ const branches = await res.json();
344
+ return branches.map((b) => ({
345
+ pattern: b.name,
346
+ allowForcePush: b.allow_force_push,
347
+ allowDeletion: false,
348
+ requiredApprovals: 0,
349
+ requireStatusChecks: false,
350
+ raw: b
351
+ }));
352
+ }
353
+ async deleteBranch(projectPath, branch) {
354
+ const encoded = encodeURIComponent(projectPath);
355
+ const res = await fetch(`${this.baseURL}/api/v4/projects/${encoded}/repository/branches/${encodeURIComponent(branch)}`, { method: "DELETE", headers: { "PRIVATE-TOKEN": this.token } });
356
+ if (!res.ok) {
357
+ throw new Error(`deleteBranch failed: ${res.status} ${await res.text()}`);
358
+ }
359
+ }
360
+ async fetchPullRequestByBranch(projectPath, sourceBranch) {
361
+ const encoded = encodeURIComponent(projectPath);
362
+ const url = `${this.baseURL}/api/v4/projects/${encoded}/merge_requests?source_branch=${encodeURIComponent(sourceBranch)}&state=opened&per_page=1`;
363
+ const res = await fetch(url, {
364
+ headers: { "PRIVATE-TOKEN": this.token }
365
+ });
366
+ if (!res.ok) {
367
+ this.log.warn("fetchPullRequestByBranch failed", {
368
+ projectPath,
369
+ sourceBranch,
370
+ status: res.status
371
+ });
372
+ return null;
373
+ }
374
+ const mrs = await res.json();
375
+ if (!mrs[0])
376
+ return null;
377
+ return this.fetchSingleMR(projectPath, mrs[0].iid, null);
378
+ }
379
+ async createPullRequest(input) {
380
+ const encoded = encodeURIComponent(input.projectPath);
381
+ const body = {
382
+ source_branch: input.sourceBranch,
383
+ target_branch: input.targetBranch,
384
+ title: input.title
385
+ };
386
+ if (input.description != null)
387
+ body.description = input.description;
388
+ if (input.draft != null)
389
+ body.draft = input.draft;
390
+ if (input.labels?.length)
391
+ body.labels = input.labels.join(",");
392
+ if (input.assignees?.length)
393
+ body.assignee_ids = input.assignees;
394
+ if (input.reviewers?.length)
395
+ body.reviewer_ids = input.reviewers;
396
+ const res = await fetch(`${this.baseURL}/api/v4/projects/${encoded}/merge_requests`, {
397
+ method: "POST",
398
+ headers: {
399
+ "PRIVATE-TOKEN": this.token,
400
+ "Content-Type": "application/json"
401
+ },
402
+ body: JSON.stringify(body)
403
+ });
404
+ if (!res.ok) {
405
+ const text = await res.text();
406
+ throw new Error(`createPullRequest failed: ${res.status} ${text}`);
407
+ }
408
+ const created = await res.json();
409
+ return this.fetchSingleMRWithRetry(input.projectPath, created.iid, "Created MR but failed to fetch it back");
410
+ }
411
+ async updatePullRequest(projectPath, mrIid, input) {
412
+ const encoded = encodeURIComponent(projectPath);
413
+ const body = {};
414
+ if (input.title != null)
415
+ body.title = input.title;
416
+ if (input.description != null)
417
+ body.description = input.description;
418
+ if (input.draft != null)
419
+ body.draft = input.draft;
420
+ if (input.targetBranch != null)
421
+ body.target_branch = input.targetBranch;
422
+ if (input.labels)
423
+ body.labels = input.labels.join(",");
424
+ if (input.assignees)
425
+ body.assignee_ids = input.assignees;
426
+ if (input.reviewers)
427
+ body.reviewer_ids = input.reviewers;
428
+ if (input.stateEvent)
429
+ body.state_event = input.stateEvent;
430
+ const res = await fetch(`${this.baseURL}/api/v4/projects/${encoded}/merge_requests/${mrIid}`, {
431
+ method: "PUT",
432
+ headers: {
433
+ "PRIVATE-TOKEN": this.token,
434
+ "Content-Type": "application/json"
435
+ },
436
+ body: JSON.stringify(body)
437
+ });
438
+ if (!res.ok) {
439
+ const text = await res.text();
440
+ throw new Error(`updatePullRequest failed: ${res.status} ${text}`);
441
+ }
442
+ return this.fetchSingleMRWithRetry(projectPath, mrIid, "Updated MR but failed to fetch it back");
443
+ }
325
444
  async restRequest(method, path, body) {
326
445
  const url = `${this.baseURL}${path}`;
327
446
  const headers = {
@@ -336,6 +455,171 @@ class GitLabProvider {
336
455
  body: body !== undefined ? JSON.stringify(body) : undefined
337
456
  });
338
457
  }
458
+ async mergePullRequest(projectPath, mrIid, input) {
459
+ const encoded = encodeURIComponent(projectPath);
460
+ const body = {};
461
+ if (input?.commitMessage != null)
462
+ body.merge_commit_message = input.commitMessage;
463
+ if (input?.squashCommitMessage != null)
464
+ body.squash_commit_message = input.squashCommitMessage;
465
+ if (input?.squash != null)
466
+ body.squash = input.squash;
467
+ if (input?.shouldRemoveSourceBranch != null)
468
+ body.should_remove_source_branch = input.shouldRemoveSourceBranch;
469
+ if (input?.sha != null)
470
+ body.sha = input.sha;
471
+ if (input?.mergeMethod === "squash" && input?.squash == null)
472
+ body.squash = true;
473
+ const res = await fetch(`${this.baseURL}/api/v4/projects/${encoded}/merge_requests/${mrIid}/merge`, {
474
+ method: "PUT",
475
+ headers: {
476
+ "PRIVATE-TOKEN": this.token,
477
+ "Content-Type": "application/json"
478
+ },
479
+ body: JSON.stringify(body)
480
+ });
481
+ if (!res.ok) {
482
+ const text = await res.text();
483
+ throw new Error(`mergePullRequest failed: ${res.status} ${text}`);
484
+ }
485
+ return this.fetchSingleMRWithRetry(projectPath, mrIid, "Merged MR but failed to fetch it back");
486
+ }
487
+ async approvePullRequest(projectPath, mrIid) {
488
+ const encoded = encodeURIComponent(projectPath);
489
+ const res = await fetch(`${this.baseURL}/api/v4/projects/${encoded}/merge_requests/${mrIid}/approve`, {
490
+ method: "POST",
491
+ headers: { "PRIVATE-TOKEN": this.token }
492
+ });
493
+ if (!res.ok) {
494
+ const text = await res.text().catch(() => "");
495
+ throw new Error(`approvePullRequest failed: ${res.status} ${res.statusText}${text ? ` — ${text}` : ""}`);
496
+ }
497
+ }
498
+ async unapprovePullRequest(projectPath, mrIid) {
499
+ const encoded = encodeURIComponent(projectPath);
500
+ const res = await fetch(`${this.baseURL}/api/v4/projects/${encoded}/merge_requests/${mrIid}/unapprove`, {
501
+ method: "POST",
502
+ headers: { "PRIVATE-TOKEN": this.token }
503
+ });
504
+ if (!res.ok) {
505
+ const text = await res.text().catch(() => "");
506
+ throw new Error(`unapprovePullRequest failed: ${res.status} ${res.statusText}${text ? ` — ${text}` : ""}`);
507
+ }
508
+ }
509
+ async rebasePullRequest(projectPath, mrIid) {
510
+ const encoded = encodeURIComponent(projectPath);
511
+ const res = await fetch(`${this.baseURL}/api/v4/projects/${encoded}/merge_requests/${mrIid}/rebase`, {
512
+ method: "PUT",
513
+ headers: { "PRIVATE-TOKEN": this.token }
514
+ });
515
+ if (!res.ok) {
516
+ const text = await res.text().catch(() => "");
517
+ throw new Error(`rebasePullRequest failed: ${res.status} ${res.statusText}${text ? ` — ${text}` : ""}`);
518
+ }
519
+ }
520
+ async setAutoMerge(projectPath, mrIid) {
521
+ const encoded = encodeURIComponent(projectPath);
522
+ const res = await fetch(`${this.baseURL}/api/v4/projects/${encoded}/merge_requests/${mrIid}/merge`, {
523
+ method: "PUT",
524
+ headers: {
525
+ "PRIVATE-TOKEN": this.token,
526
+ "Content-Type": "application/json"
527
+ },
528
+ body: JSON.stringify({ merge_when_pipeline_succeeds: true })
529
+ });
530
+ if (!res.ok) {
531
+ const text = await res.text().catch(() => "");
532
+ throw new Error(`setAutoMerge failed: ${res.status} ${res.statusText}${text ? ` — ${text}` : ""}`);
533
+ }
534
+ }
535
+ async cancelAutoMerge(projectPath, mrIid) {
536
+ const encoded = encodeURIComponent(projectPath);
537
+ const res = await fetch(`${this.baseURL}/api/v4/projects/${encoded}/merge_requests/${mrIid}/cancel_merge_when_pipeline_succeeds`, {
538
+ method: "POST",
539
+ headers: { "PRIVATE-TOKEN": this.token }
540
+ });
541
+ if (!res.ok) {
542
+ const text = await res.text().catch(() => "");
543
+ throw new Error(`cancelAutoMerge failed: ${res.status} ${res.statusText}${text ? ` — ${text}` : ""}`);
544
+ }
545
+ }
546
+ async resolveDiscussion(projectPath, mrIid, discussionId) {
547
+ const encoded = encodeURIComponent(projectPath);
548
+ const res = await fetch(`${this.baseURL}/api/v4/projects/${encoded}/merge_requests/${mrIid}/discussions/${discussionId}`, {
549
+ method: "PUT",
550
+ headers: {
551
+ "PRIVATE-TOKEN": this.token,
552
+ "Content-Type": "application/json"
553
+ },
554
+ body: JSON.stringify({ resolved: true })
555
+ });
556
+ if (!res.ok) {
557
+ const text = await res.text().catch(() => "");
558
+ throw new Error(`resolveDiscussion failed: ${res.status} ${res.statusText}${text ? ` — ${text}` : ""}`);
559
+ }
560
+ }
561
+ async unresolveDiscussion(projectPath, mrIid, discussionId) {
562
+ const encoded = encodeURIComponent(projectPath);
563
+ const res = await fetch(`${this.baseURL}/api/v4/projects/${encoded}/merge_requests/${mrIid}/discussions/${discussionId}`, {
564
+ method: "PUT",
565
+ headers: {
566
+ "PRIVATE-TOKEN": this.token,
567
+ "Content-Type": "application/json"
568
+ },
569
+ body: JSON.stringify({ resolved: false })
570
+ });
571
+ if (!res.ok) {
572
+ const text = await res.text().catch(() => "");
573
+ throw new Error(`unresolveDiscussion failed: ${res.status} ${res.statusText}${text ? ` — ${text}` : ""}`);
574
+ }
575
+ }
576
+ async retryPipeline(projectPath, pipelineId) {
577
+ const encoded = encodeURIComponent(projectPath);
578
+ const res = await fetch(`${this.baseURL}/api/v4/projects/${encoded}/pipelines/${pipelineId}/retry`, {
579
+ method: "POST",
580
+ headers: { "PRIVATE-TOKEN": this.token }
581
+ });
582
+ if (!res.ok) {
583
+ const text = await res.text().catch(() => "");
584
+ throw new Error(`retryPipeline failed: ${res.status} ${res.statusText}${text ? ` — ${text}` : ""}`);
585
+ }
586
+ }
587
+ async requestReReview(projectPath, mrIid, _reviewerUsernames) {
588
+ const encoded = encodeURIComponent(projectPath);
589
+ const mrRes = await fetch(`${this.baseURL}/api/v4/projects/${encoded}/merge_requests/${mrIid}`, { headers: { "PRIVATE-TOKEN": this.token } });
590
+ if (!mrRes.ok) {
591
+ const text = await mrRes.text().catch(() => "");
592
+ throw new Error(`requestReReview: failed to fetch MR: ${mrRes.status}${text ? ` — ${text}` : ""}`);
593
+ }
594
+ const mr = await mrRes.json();
595
+ const reviewerIds = mr.reviewers?.map((r) => r.id) ?? [];
596
+ if (reviewerIds.length === 0) {
597
+ return;
598
+ }
599
+ const res = await fetch(`${this.baseURL}/api/v4/projects/${encoded}/merge_requests/${mrIid}`, {
600
+ method: "PUT",
601
+ headers: {
602
+ "PRIVATE-TOKEN": this.token,
603
+ "Content-Type": "application/json"
604
+ },
605
+ body: JSON.stringify({ reviewer_ids: reviewerIds })
606
+ });
607
+ if (!res.ok) {
608
+ const text = await res.text().catch(() => "");
609
+ throw new Error(`requestReReview failed: ${res.status} ${res.statusText}${text ? ` — ${text}` : ""}`);
610
+ }
611
+ }
612
+ async fetchSingleMRWithRetry(projectPath, mrIid, errorMessage) {
613
+ for (let attempt = 0;attempt < 3; attempt++) {
614
+ if (attempt > 0) {
615
+ await new Promise((r) => setTimeout(r, attempt * 300));
616
+ }
617
+ const pr = await this.fetchSingleMR(projectPath, mrIid, null);
618
+ if (pr)
619
+ return pr;
620
+ }
621
+ throw new Error(errorMessage);
622
+ }
339
623
  async runQuery(query, variables) {
340
624
  const url = `${this.baseURL}/api/graphql`;
341
625
  const body = JSON.stringify({ query, variables: variables ?? {} });
@@ -451,6 +735,16 @@ class GitHubProvider {
451
735
  this.apiBase = `${this.baseURL}/api/v3`;
452
736
  }
453
737
  }
738
+ capabilities = {
739
+ canMerge: true,
740
+ canApprove: true,
741
+ canUnapprove: false,
742
+ canRebase: false,
743
+ canAutoMerge: false,
744
+ canResolveDiscussions: false,
745
+ canRetryPipeline: true,
746
+ canRequestReReview: true
747
+ };
454
748
  async validateToken() {
455
749
  const res = await this.api("GET", "/user");
456
750
  if (!res.ok) {
@@ -492,7 +786,9 @@ class GitHubProvider {
492
786
  ]);
493
787
  return this.toPullRequest(pr, prRoles, reviews, checkRuns);
494
788
  }));
495
- this.log.debug("GitHubProvider.fetchPullRequests", { count: results.length });
789
+ this.log.debug("GitHubProvider.fetchPullRequests", {
790
+ count: results.length
791
+ });
496
792
  return results;
497
793
  }
498
794
  async fetchSingleMR(projectPath, mrIid, _currentUserNumericId) {
@@ -563,9 +859,213 @@ class GitHubProvider {
563
859
  }
564
860
  return { mrIid, repositoryId, discussions };
565
861
  }
862
+ async fetchBranchProtectionRules(projectPath) {
863
+ const res = await this.api("GET", `/repos/${projectPath}/branches?protected=true&per_page=100`);
864
+ if (!res.ok) {
865
+ throw new Error(`fetchBranchProtectionRules failed: ${res.status} ${await res.text()}`);
866
+ }
867
+ const branches = await res.json();
868
+ const rules = [];
869
+ for (const b of branches) {
870
+ if (!b.protected)
871
+ continue;
872
+ const detailRes = await this.api("GET", `/repos/${projectPath}/branches/${encodeURIComponent(b.name)}/protection`);
873
+ if (!detailRes.ok) {
874
+ rules.push({
875
+ pattern: b.name,
876
+ allowForcePush: false,
877
+ allowDeletion: false,
878
+ requiredApprovals: 0,
879
+ requireStatusChecks: false
880
+ });
881
+ continue;
882
+ }
883
+ const detail = await detailRes.json();
884
+ rules.push({
885
+ pattern: b.name,
886
+ allowForcePush: detail.allow_force_pushes?.enabled ?? false,
887
+ allowDeletion: detail.allow_deletions?.enabled ?? false,
888
+ requiredApprovals: detail.required_pull_request_reviews?.required_approving_review_count ?? 0,
889
+ requireStatusChecks: detail.required_status_checks !== null && detail.required_status_checks !== undefined,
890
+ raw: detail
891
+ });
892
+ }
893
+ return rules;
894
+ }
895
+ async deleteBranch(projectPath, branch) {
896
+ const res = await this.api("DELETE", `/repos/${projectPath}/git/refs/heads/${encodeURIComponent(branch)}`);
897
+ if (!res.ok) {
898
+ throw new Error(`deleteBranch failed: ${res.status} ${await res.text()}`);
899
+ }
900
+ }
901
+ async fetchPullRequestByBranch(projectPath, sourceBranch) {
902
+ const res = await this.api("GET", `/repos/${projectPath}/pulls?head=${projectPath.split("/")[0]}:${encodeURIComponent(sourceBranch)}&state=open&per_page=1`);
903
+ if (!res.ok) {
904
+ this.log.warn("fetchPullRequestByBranch failed", {
905
+ projectPath,
906
+ sourceBranch,
907
+ status: res.status
908
+ });
909
+ return null;
910
+ }
911
+ const prs = await res.json();
912
+ if (!prs[0])
913
+ return null;
914
+ return this.fetchSingleMR(projectPath, prs[0].number, null);
915
+ }
916
+ async createPullRequest(input) {
917
+ const body = {
918
+ head: input.sourceBranch,
919
+ base: input.targetBranch,
920
+ title: input.title
921
+ };
922
+ if (input.description != null)
923
+ body.body = input.description;
924
+ if (input.draft != null)
925
+ body.draft = input.draft;
926
+ const res = await this.api("POST", `/repos/${input.projectPath}/pulls`, body);
927
+ if (!res.ok) {
928
+ const text = await res.text();
929
+ throw new Error(`createPullRequest failed: ${res.status} ${text}`);
930
+ }
931
+ const created = await res.json();
932
+ if (input.reviewers?.length) {
933
+ await this.api("POST", `/repos/${input.projectPath}/pulls/${created.number}/requested_reviewers`, {
934
+ reviewers: input.reviewers
935
+ });
936
+ }
937
+ if (input.assignees?.length) {
938
+ await this.api("POST", `/repos/${input.projectPath}/issues/${created.number}/assignees`, {
939
+ assignees: input.assignees
940
+ });
941
+ }
942
+ if (input.labels?.length) {
943
+ await this.api("POST", `/repos/${input.projectPath}/issues/${created.number}/labels`, {
944
+ labels: input.labels
945
+ });
946
+ }
947
+ const pr = await this.fetchSingleMR(input.projectPath, created.number, null);
948
+ if (!pr)
949
+ throw new Error("Created PR but failed to fetch it back");
950
+ return pr;
951
+ }
952
+ async updatePullRequest(projectPath, mrIid, input) {
953
+ const body = {};
954
+ if (input.title != null)
955
+ body.title = input.title;
956
+ if (input.description != null)
957
+ body.body = input.description;
958
+ if (input.draft != null)
959
+ body.draft = input.draft;
960
+ if (input.targetBranch != null)
961
+ body.base = input.targetBranch;
962
+ if (input.stateEvent)
963
+ body.state = input.stateEvent === "close" ? "closed" : "open";
964
+ const res = await this.api("PATCH", `/repos/${projectPath}/pulls/${mrIid}`, body);
965
+ if (!res.ok) {
966
+ const text = await res.text();
967
+ throw new Error(`updatePullRequest failed: ${res.status} ${text}`);
968
+ }
969
+ if (input.reviewers) {
970
+ await this.api("POST", `/repos/${projectPath}/pulls/${mrIid}/requested_reviewers`, {
971
+ reviewers: input.reviewers
972
+ });
973
+ }
974
+ if (input.assignees) {
975
+ await this.api("POST", `/repos/${projectPath}/issues/${mrIid}/assignees`, {
976
+ assignees: input.assignees
977
+ });
978
+ }
979
+ if (input.labels) {
980
+ await this.api("PUT", `/repos/${projectPath}/issues/${mrIid}/labels`, {
981
+ labels: input.labels
982
+ });
983
+ }
984
+ const pr = await this.fetchSingleMR(projectPath, mrIid, null);
985
+ if (!pr)
986
+ throw new Error("Updated PR but failed to fetch it back");
987
+ return pr;
988
+ }
566
989
  async restRequest(method, path, body) {
567
990
  return this.api(method, path, body);
568
991
  }
992
+ async mergePullRequest(projectPath, mrIid, input) {
993
+ const body = {};
994
+ if (input?.commitMessage != null)
995
+ body.commit_title = input.commitMessage;
996
+ if (input?.squashCommitMessage != null)
997
+ body.commit_title = input.squashCommitMessage;
998
+ if (input?.shouldRemoveSourceBranch != null)
999
+ body.delete_branch = input.shouldRemoveSourceBranch;
1000
+ if (input?.sha != null)
1001
+ body.sha = input.sha;
1002
+ if (input?.mergeMethod) {
1003
+ body.merge_method = input.mergeMethod;
1004
+ } else if (input?.squash) {
1005
+ body.merge_method = "squash";
1006
+ }
1007
+ const res = await this.api("PUT", `/repos/${projectPath}/pulls/${mrIid}/merge`, body);
1008
+ if (!res.ok) {
1009
+ const text = await res.text();
1010
+ throw new Error(`mergePullRequest failed: ${res.status} ${text}`);
1011
+ }
1012
+ const pr = await this.fetchSingleMR(projectPath, mrIid, null);
1013
+ if (!pr)
1014
+ throw new Error("Merged PR but failed to fetch it back");
1015
+ return pr;
1016
+ }
1017
+ async approvePullRequest(projectPath, mrIid) {
1018
+ const res = await this.api("POST", `/repos/${projectPath}/pulls/${mrIid}/reviews`, {
1019
+ event: "APPROVE"
1020
+ });
1021
+ if (!res.ok) {
1022
+ const text = await res.text().catch(() => "");
1023
+ throw new Error(`approvePullRequest failed: ${res.status} ${res.statusText}${text ? ` — ${text}` : ""}`);
1024
+ }
1025
+ }
1026
+ async unapprovePullRequest(_projectPath, _mrIid) {
1027
+ throw new Error("unapprovePullRequest is not supported by GitHub. " + "Check provider.capabilities.canUnapprove before calling.");
1028
+ }
1029
+ async rebasePullRequest(_projectPath, _mrIid) {
1030
+ throw new Error("rebasePullRequest is not supported by GitHub. " + "Check provider.capabilities.canRebase before calling.");
1031
+ }
1032
+ async setAutoMerge(_projectPath, _mrIid) {
1033
+ throw new Error("setAutoMerge is not supported by the GitHub REST API. " + "Check provider.capabilities.canAutoMerge before calling.");
1034
+ }
1035
+ async cancelAutoMerge(_projectPath, _mrIid) {
1036
+ throw new Error("cancelAutoMerge is not supported by the GitHub REST API. " + "Check provider.capabilities.canAutoMerge before calling.");
1037
+ }
1038
+ async resolveDiscussion(_projectPath, _mrIid, _discussionId) {
1039
+ throw new Error("resolveDiscussion is not supported by the GitHub REST API. " + "Check provider.capabilities.canResolveDiscussions before calling.");
1040
+ }
1041
+ async unresolveDiscussion(_projectPath, _mrIid, _discussionId) {
1042
+ throw new Error("unresolveDiscussion is not supported by the GitHub REST API. " + "Check provider.capabilities.canResolveDiscussions before calling.");
1043
+ }
1044
+ async retryPipeline(projectPath, pipelineId) {
1045
+ const res = await this.api("POST", `/repos/${projectPath}/actions/runs/${pipelineId}/rerun`);
1046
+ if (!res.ok) {
1047
+ const text = await res.text().catch(() => "");
1048
+ throw new Error(`retryPipeline failed: ${res.status} ${res.statusText}${text ? ` — ${text}` : ""}`);
1049
+ }
1050
+ }
1051
+ async requestReReview(projectPath, mrIid, reviewerUsernames) {
1052
+ if (!reviewerUsernames?.length) {
1053
+ const prRes = await this.api("GET", `/repos/${projectPath}/pulls/${mrIid}`);
1054
+ if (!prRes.ok) {
1055
+ throw new Error(`requestReReview: failed to fetch PR: ${prRes.status}`);
1056
+ }
1057
+ const pr = await prRes.json();
1058
+ reviewerUsernames = pr.requested_reviewers.map((r) => r.login);
1059
+ if (!reviewerUsernames.length) {
1060
+ return;
1061
+ }
1062
+ }
1063
+ const res = await this.api("POST", `/repos/${projectPath}/pulls/${mrIid}/requested_reviewers`, { reviewers: reviewerUsernames });
1064
+ if (!res.ok) {
1065
+ const text = await res.text().catch(() => "");
1066
+ throw new Error(`requestReReview failed: ${res.status} ${res.statusText}${text ? ` — ${text}` : ""}`);
1067
+ }
1068
+ }
569
1069
  async api(method, path, body) {
570
1070
  const url = `${this.apiBase}${path}`;
571
1071
  const headers = {