@forge-glance/sdk 0.1.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.
package/dist/index.js ADDED
@@ -0,0 +1,972 @@
1
+ // src/GitProvider.ts
2
+ function parseRepoId(repositoryId) {
3
+ const parts = repositoryId.split(":");
4
+ return parseInt(parts.at(-1) ?? "0", 10);
5
+ }
6
+ function repoIdProvider(repositoryId) {
7
+ return repositoryId.split(":")[0] ?? "unknown";
8
+ }
9
+
10
+ // src/logger.ts
11
+ var noopLogger = {
12
+ debug() {},
13
+ info() {},
14
+ warn() {},
15
+ error() {}
16
+ };
17
+
18
+ // src/MRDetailFetcher.ts
19
+ class MRDetailFetcher {
20
+ baseURL;
21
+ token;
22
+ log;
23
+ constructor(baseURL, token, options = {}) {
24
+ this.baseURL = baseURL.replace(/\/$/, "");
25
+ this.token = token;
26
+ this.log = options.logger ?? noopLogger;
27
+ }
28
+ async fetchDetail(projectId, mrIid) {
29
+ const url = `${this.baseURL}/api/v4/projects/${projectId}/merge_requests/${mrIid}/discussions?per_page=100`;
30
+ const res = await fetch(url, {
31
+ headers: { "PRIVATE-TOKEN": this.token }
32
+ });
33
+ if (!res.ok) {
34
+ throw new Error(`MR discussions fetch failed: ${res.status} ${res.statusText}`);
35
+ }
36
+ const raw = await res.json();
37
+ const discussions = raw.map((d) => ({
38
+ id: d.id,
39
+ resolvable: null,
40
+ resolved: null,
41
+ notes: d.notes.map(toNote)
42
+ }));
43
+ this.log.debug("MRDetailFetcher.fetchDetail", {
44
+ projectId,
45
+ mrIid,
46
+ discussionCount: discussions.length
47
+ });
48
+ return { mrIid, repositoryId: `gitlab:${projectId}`, discussions };
49
+ }
50
+ }
51
+ function toNote(n) {
52
+ return {
53
+ id: n.id,
54
+ body: n.body,
55
+ author: toAuthor(n.author),
56
+ createdAt: n.created_at,
57
+ system: n.system,
58
+ type: n.type,
59
+ resolvable: n.resolvable ?? null,
60
+ resolved: n.resolved ?? null,
61
+ position: n.position ? toPosition(n.position) : null
62
+ };
63
+ }
64
+ function toAuthor(a) {
65
+ return {
66
+ id: `gitlab:user:${a.id}`,
67
+ username: a.username,
68
+ name: a.name,
69
+ avatarUrl: a.avatar_url
70
+ };
71
+ }
72
+ function toPosition(p) {
73
+ return {
74
+ newPath: p.new_path ?? null,
75
+ oldPath: p.old_path ?? null,
76
+ newLine: p.new_line ?? null,
77
+ oldLine: p.old_line ?? null,
78
+ positionType: p.position_type ?? null
79
+ };
80
+ }
81
+
82
+ // src/GitLabProvider.ts
83
+ function parseGitLabRepoId(repositoryId) {
84
+ const parts = repositoryId.split(":");
85
+ return parseInt(parts.at(-1) ?? "0", 10);
86
+ }
87
+ var MR_DASHBOARD_FRAGMENT = `
88
+ fragment MRDashboardFields on MergeRequest {
89
+ id iid projectId title description state draft
90
+ sourceBranch targetBranch webUrl
91
+ diffHeadSha
92
+ updatedAt createdAt
93
+ conflicts
94
+ detailedMergeStatus
95
+ approved
96
+ diffStatsSummary { additions deletions fileCount }
97
+ author { id username name avatarUrl }
98
+ assignees(first: 20) { nodes { id username name avatarUrl } }
99
+ reviewers(first: 20) { nodes { id username name avatarUrl } }
100
+ approvedBy(first: 20) { nodes { id username name avatarUrl } }
101
+ approvalsLeft
102
+ resolvableDiscussionsCount
103
+ resolvedDiscussionsCount
104
+ headPipeline {
105
+ id iid status
106
+ createdAt
107
+ path
108
+ stages(first: 20) { nodes {
109
+ name
110
+ jobs(first: 50) { nodes {
111
+ id name status
112
+ allowFailure
113
+ webPath
114
+ stage { name }
115
+ }}
116
+ }}
117
+ }
118
+ }
119
+ `;
120
+ var AUTHORED_QUERY = `
121
+ query GlanceDashboardAuthored {
122
+ currentUser {
123
+ authoredMergeRequests(state: opened, first: 100) {
124
+ nodes { ...MRDashboardFields }
125
+ }
126
+ }
127
+ }
128
+ ${MR_DASHBOARD_FRAGMENT}
129
+ `;
130
+ var REVIEWING_QUERY = `
131
+ query GlanceDashboardReviewing {
132
+ currentUser {
133
+ reviewRequestedMergeRequests(state: opened, first: 100) {
134
+ nodes { ...MRDashboardFields }
135
+ }
136
+ }
137
+ }
138
+ ${MR_DASHBOARD_FRAGMENT}
139
+ `;
140
+ var ASSIGNED_QUERY = `
141
+ query GlanceDashboardAssigned {
142
+ currentUser {
143
+ assignedMergeRequests(state: opened, first: 100) {
144
+ nodes { ...MRDashboardFields }
145
+ }
146
+ }
147
+ }
148
+ ${MR_DASHBOARD_FRAGMENT}
149
+ `;
150
+ function numericId(gid) {
151
+ const parts = gid.split("/");
152
+ return parseInt(parts[parts.length - 1] ?? "0", 10);
153
+ }
154
+ function domainId(type, id) {
155
+ return `gitlab:${type}:${id}`;
156
+ }
157
+ function toUserRef(u) {
158
+ return {
159
+ id: `gitlab:user:${numericId(u.id)}`,
160
+ username: u.username,
161
+ name: u.name,
162
+ avatarUrl: u.avatarUrl
163
+ };
164
+ }
165
+ function toPipeline(p, baseURL) {
166
+ const allJobs = p.stages.nodes.flatMap((stage) => stage.jobs.nodes.map((job) => ({
167
+ id: `gitlab:job:${numericId(job.id)}`,
168
+ name: job.name,
169
+ stage: job.stage.name,
170
+ status: job.status,
171
+ allowFailure: job.allowFailure,
172
+ webUrl: job.webPath ? `${baseURL}${job.webPath}` : null
173
+ })));
174
+ return {
175
+ id: domainId("pipeline", numericId(p.id)),
176
+ status: normalizePipelineStatus(p),
177
+ createdAt: p.createdAt,
178
+ webUrl: p.path ? `${baseURL}${p.path}` : null,
179
+ jobs: allJobs
180
+ };
181
+ }
182
+ function normalizePipelineStatus(p) {
183
+ const allJobs = p.stages.nodes.flatMap((s) => s.jobs.nodes);
184
+ const hasAllowFailFailed = allJobs.some((j) => j.allowFailure && j.status === "failed");
185
+ if (p.status === "success" && hasAllowFailFailed) {
186
+ return "success_with_warnings";
187
+ }
188
+ return p.status;
189
+ }
190
+ function toMR(gql, role, baseURL) {
191
+ const resolvable = gql.resolvableDiscussionsCount ?? 0;
192
+ const resolved = gql.resolvedDiscussionsCount ?? 0;
193
+ const unresolvedThreadCount = Math.max(0, resolvable - resolved);
194
+ const diffStats = gql.diffStatsSummary ? {
195
+ additions: gql.diffStatsSummary.additions,
196
+ deletions: gql.diffStatsSummary.deletions,
197
+ filesChanged: gql.diffStatsSummary.fileCount
198
+ } : null;
199
+ return {
200
+ id: `gitlab:mr:${numericId(gql.id)}`,
201
+ iid: parseInt(gql.iid, 10),
202
+ repositoryId: `gitlab:${gql.projectId}`,
203
+ title: gql.title,
204
+ description: gql.description ?? null,
205
+ state: gql.state,
206
+ draft: gql.draft,
207
+ conflicts: gql.conflicts || gql.detailedMergeStatus === "conflict",
208
+ webUrl: gql.webUrl,
209
+ sourceBranch: gql.sourceBranch,
210
+ targetBranch: gql.targetBranch,
211
+ createdAt: gql.createdAt,
212
+ updatedAt: gql.updatedAt,
213
+ sha: gql.diffHeadSha,
214
+ author: toUserRef(gql.author),
215
+ assignees: gql.assignees.nodes.map(toUserRef),
216
+ reviewers: gql.reviewers.nodes.map(toUserRef),
217
+ roles: [role],
218
+ pipeline: gql.headPipeline ? toPipeline(gql.headPipeline, baseURL) : null,
219
+ unresolvedThreadCount,
220
+ approvalsLeft: gql.approvalsLeft ?? 0,
221
+ approved: gql.approved ?? false,
222
+ approvedBy: gql.approvedBy.nodes.map(toUserRef),
223
+ diffStats,
224
+ detailedMergeStatus: gql.detailedMergeStatus ?? null
225
+ };
226
+ }
227
+ var MR_DETAIL_QUERY = `
228
+ query GlanceMRDetail($projectPath: ID!, $iid: String!) {
229
+ project(fullPath: $projectPath) {
230
+ mergeRequest(iid: $iid) {
231
+ ...MRDashboardFields
232
+ }
233
+ }
234
+ }
235
+ ${MR_DASHBOARD_FRAGMENT}
236
+ `;
237
+
238
+ class GitLabProvider {
239
+ providerName = "gitlab";
240
+ baseURL;
241
+ token;
242
+ log;
243
+ mrDetailFetcher;
244
+ constructor(baseURL, token, options = {}) {
245
+ this.baseURL = baseURL.replace(/\/$/, "");
246
+ this.token = token;
247
+ this.log = options.logger ?? noopLogger;
248
+ this.mrDetailFetcher = new MRDetailFetcher(this.baseURL, token, { logger: this.log });
249
+ }
250
+ async validateToken() {
251
+ const url = `${this.baseURL}/api/v4/user`;
252
+ const res = await fetch(url, {
253
+ headers: { "PRIVATE-TOKEN": this.token }
254
+ });
255
+ if (!res.ok) {
256
+ throw new Error(`Token validation failed: ${res.status} ${res.statusText}`);
257
+ }
258
+ const user = await res.json();
259
+ return {
260
+ id: `gitlab:user:${user.id}`,
261
+ username: user.username,
262
+ name: user.name,
263
+ avatarUrl: user.avatar_url
264
+ };
265
+ }
266
+ async fetchSingleMR(projectPath, mrIid, currentUserNumericId) {
267
+ let resp;
268
+ try {
269
+ resp = await this.runQuery(MR_DETAIL_QUERY, {
270
+ projectPath,
271
+ iid: String(mrIid)
272
+ });
273
+ } catch (err) {
274
+ const message = err instanceof Error ? err.message : String(err);
275
+ this.log.warn("fetchSingleMR failed", { projectPath, mrIid, message });
276
+ return null;
277
+ }
278
+ const gql = resp.project?.mergeRequest;
279
+ if (!gql)
280
+ return null;
281
+ const roles = [];
282
+ if (currentUserNumericId !== null) {
283
+ const userGqlId = `gid://gitlab/User/${currentUserNumericId}`;
284
+ if (gql.author.id === userGqlId)
285
+ roles.push("author");
286
+ if (gql.assignees.nodes.some((u) => u.id === userGqlId))
287
+ roles.push("assignee");
288
+ if (gql.reviewers.nodes.some((u) => u.id === userGqlId))
289
+ roles.push("reviewer");
290
+ }
291
+ const pr = toMR(gql, roles[0] ?? "author", this.baseURL);
292
+ pr.roles = roles.length > 0 ? roles : pr.roles;
293
+ return pr;
294
+ }
295
+ async fetchPullRequests() {
296
+ const [authored, reviewing, assigned] = await Promise.all([
297
+ this.runQuery(AUTHORED_QUERY),
298
+ this.runQuery(REVIEWING_QUERY),
299
+ this.runQuery(ASSIGNED_QUERY)
300
+ ]);
301
+ const byId = new Map;
302
+ const addAll = (mrs, role) => {
303
+ for (const gql of mrs) {
304
+ const existing = byId.get(gql.id);
305
+ if (existing) {
306
+ if (!existing.roles.includes(role)) {
307
+ existing.roles.push(role);
308
+ }
309
+ } else {
310
+ byId.set(gql.id, toMR(gql, role, this.baseURL));
311
+ }
312
+ }
313
+ };
314
+ addAll(authored.currentUser.authoredMergeRequests.nodes, "author");
315
+ addAll(reviewing.currentUser.reviewRequestedMergeRequests.nodes, "reviewer");
316
+ addAll(assigned.currentUser.assignedMergeRequests.nodes, "assignee");
317
+ const prs = [...byId.values()];
318
+ this.log.debug("fetchPullRequests", { count: prs.length });
319
+ return prs;
320
+ }
321
+ async fetchMRDiscussions(repositoryId, mrIid) {
322
+ const projectId = parseGitLabRepoId(repositoryId);
323
+ return this.mrDetailFetcher.fetchDetail(projectId, mrIid);
324
+ }
325
+ async restRequest(method, path, body) {
326
+ const url = `${this.baseURL}${path}`;
327
+ const headers = {
328
+ "PRIVATE-TOKEN": this.token
329
+ };
330
+ if (body !== undefined) {
331
+ headers["Content-Type"] = "application/json";
332
+ }
333
+ return fetch(url, {
334
+ method,
335
+ headers,
336
+ body: body !== undefined ? JSON.stringify(body) : undefined
337
+ });
338
+ }
339
+ async runQuery(query, variables) {
340
+ const url = `${this.baseURL}/api/graphql`;
341
+ const body = JSON.stringify({ query, variables: variables ?? {} });
342
+ const res = await fetch(url, {
343
+ method: "POST",
344
+ headers: {
345
+ "Content-Type": "application/json",
346
+ Authorization: `Bearer ${this.token}`
347
+ },
348
+ body
349
+ });
350
+ if (!res.ok) {
351
+ throw new Error(`GraphQL request failed: ${res.status} ${res.statusText}`);
352
+ }
353
+ const envelope = await res.json();
354
+ if (envelope.errors?.length) {
355
+ const msg = envelope.errors.map((e) => e.message).join("; ");
356
+ throw new Error(`GraphQL errors: ${msg}`);
357
+ }
358
+ if (!envelope.data) {
359
+ throw new Error("GraphQL response missing data");
360
+ }
361
+ return envelope.data;
362
+ }
363
+ }
364
+
365
+ // src/GitHubProvider.ts
366
+ function toUserRef2(u) {
367
+ return {
368
+ id: `github:user:${u.id}`,
369
+ username: u.login,
370
+ name: u.name ?? u.login,
371
+ avatarUrl: u.avatar_url
372
+ };
373
+ }
374
+ function normalizePRState(pr) {
375
+ if (pr.merged_at)
376
+ return "merged";
377
+ if (pr.state === "open")
378
+ return "opened";
379
+ return "closed";
380
+ }
381
+ function toPipeline2(checkRuns, prHtmlUrl) {
382
+ if (checkRuns.length === 0)
383
+ return null;
384
+ const jobs = checkRuns.map((cr) => ({
385
+ id: `github:check:${cr.id}`,
386
+ name: cr.name,
387
+ stage: "checks",
388
+ status: normalizeCheckStatus(cr),
389
+ allowFailure: false,
390
+ webUrl: cr.html_url
391
+ }));
392
+ const statuses = jobs.map((j) => j.status);
393
+ let overallStatus;
394
+ if (statuses.some((s) => s === "failed")) {
395
+ overallStatus = "failed";
396
+ } else if (statuses.some((s) => s === "running")) {
397
+ overallStatus = "running";
398
+ } else if (statuses.some((s) => s === "pending")) {
399
+ overallStatus = "pending";
400
+ } else if (statuses.every((s) => s === "success" || s === "skipped")) {
401
+ overallStatus = "success";
402
+ } else {
403
+ overallStatus = "pending";
404
+ }
405
+ return {
406
+ id: `github:checks:${prHtmlUrl}`,
407
+ status: overallStatus,
408
+ createdAt: null,
409
+ webUrl: `${prHtmlUrl}/checks`,
410
+ jobs
411
+ };
412
+ }
413
+ function normalizeCheckStatus(cr) {
414
+ if (cr.status === "completed") {
415
+ switch (cr.conclusion) {
416
+ case "success":
417
+ return "success";
418
+ case "failure":
419
+ case "timed_out":
420
+ return "failed";
421
+ case "cancelled":
422
+ return "canceled";
423
+ case "skipped":
424
+ return "skipped";
425
+ case "neutral":
426
+ return "success";
427
+ case "action_required":
428
+ return "manual";
429
+ default:
430
+ return "pending";
431
+ }
432
+ }
433
+ if (cr.status === "in_progress")
434
+ return "running";
435
+ return "pending";
436
+ }
437
+
438
+ class GitHubProvider {
439
+ providerName = "github";
440
+ baseURL;
441
+ apiBase;
442
+ token;
443
+ log;
444
+ constructor(baseURL, token, options = {}) {
445
+ this.baseURL = baseURL.replace(/\/$/, "");
446
+ this.token = token;
447
+ this.log = options.logger ?? noopLogger;
448
+ if (this.baseURL === "https://github.com" || this.baseURL === "https://www.github.com") {
449
+ this.apiBase = "https://api.github.com";
450
+ } else {
451
+ this.apiBase = `${this.baseURL}/api/v3`;
452
+ }
453
+ }
454
+ async validateToken() {
455
+ const res = await this.api("GET", "/user");
456
+ if (!res.ok) {
457
+ throw new Error(`GitHub token validation failed: ${res.status} ${res.statusText}`);
458
+ }
459
+ const user = await res.json();
460
+ return toUserRef2(user);
461
+ }
462
+ async fetchPullRequests() {
463
+ const [authored, reviewRequested, assigned] = await Promise.all([
464
+ this.searchPRs("is:open is:pr author:@me"),
465
+ this.searchPRs("is:open is:pr review-requested:@me"),
466
+ this.searchPRs("is:open is:pr assignee:@me")
467
+ ]);
468
+ const byKey = new Map;
469
+ const roles = new Map;
470
+ const addAll = (prs, role) => {
471
+ for (const pr of prs) {
472
+ const key = `${pr.base.repo.id}:${pr.number}`;
473
+ if (!byKey.has(key)) {
474
+ byKey.set(key, pr);
475
+ roles.set(key, [role]);
476
+ } else {
477
+ const existing = roles.get(key);
478
+ if (!existing.includes(role))
479
+ existing.push(role);
480
+ }
481
+ }
482
+ };
483
+ addAll(authored, "author");
484
+ addAll(reviewRequested, "reviewer");
485
+ addAll(assigned, "assignee");
486
+ const entries = [...byKey.entries()];
487
+ const results = await Promise.all(entries.map(async ([key, pr]) => {
488
+ const prRoles = roles.get(key) ?? ["author"];
489
+ const [reviews, checkRuns] = await Promise.all([
490
+ this.fetchReviews(pr.base.repo.full_name, pr.number),
491
+ this.fetchCheckRuns(pr.base.repo.full_name, pr.head.sha)
492
+ ]);
493
+ return this.toPullRequest(pr, prRoles, reviews, checkRuns);
494
+ }));
495
+ this.log.debug("GitHubProvider.fetchPullRequests", { count: results.length });
496
+ return results;
497
+ }
498
+ async fetchSingleMR(projectPath, mrIid, _currentUserNumericId) {
499
+ try {
500
+ const res = await this.api("GET", `/repos/${projectPath}/pulls/${mrIid}`);
501
+ if (!res.ok)
502
+ return null;
503
+ const pr = await res.json();
504
+ const [reviews, checkRuns] = await Promise.all([
505
+ this.fetchReviews(projectPath, mrIid),
506
+ this.fetchCheckRuns(projectPath, pr.head.sha)
507
+ ]);
508
+ const currentUser = await this.api("GET", "/user");
509
+ const currentUserData = await currentUser.json();
510
+ const prRoles = [];
511
+ if (pr.user.id === currentUserData.id)
512
+ prRoles.push("author");
513
+ if (pr.assignees.some((a) => a.id === currentUserData.id))
514
+ prRoles.push("assignee");
515
+ if (pr.requested_reviewers.some((r) => r.id === currentUserData.id))
516
+ prRoles.push("reviewer");
517
+ return this.toPullRequest(pr, prRoles.length > 0 ? prRoles : ["author"], reviews, checkRuns);
518
+ } catch (err) {
519
+ const message = err instanceof Error ? err.message : String(err);
520
+ this.log.warn("GitHubProvider.fetchSingleMR failed", {
521
+ projectPath,
522
+ mrIid,
523
+ message
524
+ });
525
+ return null;
526
+ }
527
+ }
528
+ async fetchMRDiscussions(repositoryId, mrIid) {
529
+ const repoId = parseInt(repositoryId.split(":").pop() ?? "0", 10);
530
+ const repoRes = await this.api("GET", `/repositories/${repoId}`);
531
+ if (!repoRes.ok) {
532
+ throw new Error(`Failed to fetch repo: ${repoRes.status}`);
533
+ }
534
+ const repo = await repoRes.json();
535
+ const [reviewComments, issueComments] = await Promise.all([
536
+ this.fetchAllPages(`/repos/${repo.full_name}/pulls/${mrIid}/comments?per_page=100`),
537
+ this.fetchAllPages(`/repos/${repo.full_name}/issues/${mrIid}/comments?per_page=100`)
538
+ ]);
539
+ const discussions = [];
540
+ for (const c of issueComments) {
541
+ discussions.push({
542
+ id: `gh-issue-comment-${c.id}`,
543
+ resolvable: null,
544
+ resolved: null,
545
+ notes: [toNote2(c)]
546
+ });
547
+ }
548
+ const threadMap = new Map;
549
+ for (const c of reviewComments) {
550
+ const rootId = c.in_reply_to_id ?? c.id;
551
+ const thread = threadMap.get(rootId) ?? [];
552
+ thread.push(c);
553
+ threadMap.set(rootId, thread);
554
+ }
555
+ for (const [rootId, comments] of threadMap) {
556
+ comments.sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime());
557
+ discussions.push({
558
+ id: `gh-review-thread-${rootId}`,
559
+ resolvable: true,
560
+ resolved: null,
561
+ notes: comments.map(toNote2)
562
+ });
563
+ }
564
+ return { mrIid, repositoryId, discussions };
565
+ }
566
+ async restRequest(method, path, body) {
567
+ return this.api(method, path, body);
568
+ }
569
+ async api(method, path, body) {
570
+ const url = `${this.apiBase}${path}`;
571
+ const headers = {
572
+ Authorization: `Bearer ${this.token}`,
573
+ Accept: "application/vnd.github+json",
574
+ "X-GitHub-Api-Version": "2022-11-28"
575
+ };
576
+ if (body !== undefined) {
577
+ headers["Content-Type"] = "application/json";
578
+ }
579
+ return fetch(url, {
580
+ method,
581
+ headers,
582
+ body: body !== undefined ? JSON.stringify(body) : undefined
583
+ });
584
+ }
585
+ async searchPRs(qualifiers) {
586
+ const q = encodeURIComponent(qualifiers);
587
+ const res = await this.api("GET", `/search/issues?q=${q}&per_page=100&sort=updated`);
588
+ if (!res.ok) {
589
+ this.log.warn("GitHub search failed", { status: res.status, qualifiers });
590
+ return [];
591
+ }
592
+ const data = await res.json();
593
+ const prPromises = data.items.filter((item) => item.pull_request).map(async (item) => {
594
+ const repoPath = item.repository_url.replace(`${this.apiBase}/repos/`, "");
595
+ const res2 = await this.api("GET", `/repos/${repoPath}/pulls/${item.number}`);
596
+ if (!res2.ok)
597
+ return null;
598
+ return await res2.json();
599
+ });
600
+ const results = await Promise.all(prPromises);
601
+ return results.filter((pr) => pr !== null);
602
+ }
603
+ async fetchReviews(repoPath, prNumber) {
604
+ return this.fetchAllPages(`/repos/${repoPath}/pulls/${prNumber}/reviews?per_page=100`);
605
+ }
606
+ async fetchCheckRuns(repoPath, sha) {
607
+ try {
608
+ const res = await this.api("GET", `/repos/${repoPath}/commits/${sha}/check-runs?per_page=100`);
609
+ if (!res.ok)
610
+ return [];
611
+ const data = await res.json();
612
+ return data.check_runs;
613
+ } catch {
614
+ return [];
615
+ }
616
+ }
617
+ async fetchAllPages(path) {
618
+ const results = [];
619
+ let url = path;
620
+ while (url) {
621
+ const res = await this.api("GET", url);
622
+ if (!res.ok)
623
+ break;
624
+ const items = await res.json();
625
+ results.push(...items);
626
+ const linkHeader = res.headers.get("Link");
627
+ const nextMatch = linkHeader?.match(/<([^>]+)>;\s*rel="next"/);
628
+ if (nextMatch) {
629
+ url = nextMatch[1].replace(this.apiBase, "");
630
+ } else {
631
+ url = null;
632
+ }
633
+ }
634
+ return results;
635
+ }
636
+ toPullRequest(pr, roles, reviews, checkRuns) {
637
+ const latestReviewByUser = new Map;
638
+ for (const r of reviews.sort((a, b) => new Date(a.submitted_at).getTime() - new Date(b.submitted_at).getTime())) {
639
+ latestReviewByUser.set(r.user.id, r);
640
+ }
641
+ const approvedBy = [];
642
+ let changesRequested = 0;
643
+ for (const r of latestReviewByUser.values()) {
644
+ if (r.state === "APPROVED") {
645
+ approvedBy.push(toUserRef2(r.user));
646
+ } else if (r.state === "CHANGES_REQUESTED") {
647
+ changesRequested++;
648
+ }
649
+ }
650
+ const approvalsLeft = changesRequested;
651
+ const diffStats = pr.additions !== undefined ? {
652
+ additions: pr.additions,
653
+ deletions: pr.deletions ?? 0,
654
+ filesChanged: pr.changed_files ?? 0
655
+ } : null;
656
+ const conflicts = pr.mergeable === false || pr.mergeable_state === "dirty";
657
+ const pipeline = toPipeline2(checkRuns, pr.html_url);
658
+ return {
659
+ id: `github:pr:${pr.id}`,
660
+ iid: pr.number,
661
+ repositoryId: `github:${pr.base.repo.id}`,
662
+ title: pr.title,
663
+ state: normalizePRState(pr),
664
+ draft: pr.draft,
665
+ conflicts,
666
+ webUrl: pr.html_url,
667
+ sourceBranch: pr.head.ref,
668
+ targetBranch: pr.base.ref,
669
+ createdAt: pr.created_at,
670
+ updatedAt: pr.updated_at,
671
+ sha: pr.head.sha,
672
+ author: toUserRef2(pr.user),
673
+ assignees: pr.assignees.map(toUserRef2),
674
+ reviewers: pr.requested_reviewers.map(toUserRef2),
675
+ roles,
676
+ pipeline,
677
+ description: pr.body ?? null,
678
+ unresolvedThreadCount: 0,
679
+ approvalsLeft,
680
+ approved: approvedBy.length > 0 && changesRequested === 0,
681
+ approvedBy,
682
+ diffStats,
683
+ detailedMergeStatus: null
684
+ };
685
+ }
686
+ }
687
+ function toNote2(c) {
688
+ const position = c.path ? {
689
+ newPath: c.path,
690
+ oldPath: c.path,
691
+ newLine: c.line ?? null,
692
+ oldLine: c.original_line ?? null,
693
+ positionType: c.path ? "text" : null
694
+ } : null;
695
+ return {
696
+ id: c.id,
697
+ body: c.body,
698
+ author: toNoteAuthor(c.user),
699
+ createdAt: c.created_at,
700
+ system: false,
701
+ type: c.path ? "DiffNote" : "DiscussionNote",
702
+ resolvable: c.path ? true : null,
703
+ resolved: null,
704
+ position
705
+ };
706
+ }
707
+ function toNoteAuthor(u) {
708
+ return {
709
+ id: `github:user:${u.id}`,
710
+ username: u.login,
711
+ name: u.name ?? u.login,
712
+ avatarUrl: u.avatar_url
713
+ };
714
+ }
715
+
716
+ // src/providers.ts
717
+ function createProvider(provider, baseURL, token, options = {}) {
718
+ switch (provider) {
719
+ case "gitlab":
720
+ return new GitLabProvider(baseURL, token, options);
721
+ case "github":
722
+ return new GitHubProvider(baseURL, token, options);
723
+ default:
724
+ throw new Error(`Unknown provider: ${provider}`);
725
+ }
726
+ }
727
+ var SUPPORTED_PROVIDERS = ["gitlab", "github"];
728
+
729
+ // src/ActionCableClient.ts
730
+ var BASE_RECONNECT_DELAY_MS = 1000;
731
+ var MAX_RECONNECT_DELAY_MS = 120000;
732
+ var MAX_RECONNECT_ATTEMPTS = 8;
733
+
734
+ class ActionCableClient {
735
+ token;
736
+ callbacks;
737
+ ws = null;
738
+ reconnectAttempt = 0;
739
+ intentionalDisconnect = false;
740
+ reconnectTimer = null;
741
+ wsUrl;
742
+ originUrl;
743
+ log;
744
+ logContext;
745
+ constructor(baseURL, token, callbacks, options = {}) {
746
+ this.token = token;
747
+ this.callbacks = callbacks;
748
+ this.log = options.logger ?? noopLogger;
749
+ this.logContext = options.logContext ?? "";
750
+ const stripped = baseURL.replace(/\/$/, "");
751
+ this.originUrl = stripped;
752
+ this.wsUrl = stripped.replace(/^https:\/\//, "wss://").replace(/^http:\/\//, "ws://") + "/-/cable";
753
+ }
754
+ connect() {
755
+ this.intentionalDisconnect = false;
756
+ this.reconnectAttempt = 0;
757
+ this.performConnect();
758
+ }
759
+ disconnect() {
760
+ this.intentionalDisconnect = true;
761
+ this.cleanup();
762
+ this.log.info("ActionCable intentionally disconnected", {
763
+ url: this.wsUrl,
764
+ ctx: this.logContext
765
+ });
766
+ }
767
+ subscribe(identifier) {
768
+ this.send({ command: "subscribe", identifier });
769
+ }
770
+ unsubscribe(identifier) {
771
+ this.send({ command: "unsubscribe", identifier });
772
+ }
773
+ performConnect() {
774
+ this.cleanup();
775
+ let ws;
776
+ try {
777
+ ws = new WebSocket(this.wsUrl, {
778
+ headers: {
779
+ Authorization: `Bearer ${this.token}`,
780
+ Origin: this.originUrl
781
+ }
782
+ });
783
+ } catch (err) {
784
+ const message = err instanceof Error ? err.message : String(err);
785
+ this.log.error("ActionCable failed to create WebSocket", {
786
+ url: this.wsUrl,
787
+ message,
788
+ ctx: this.logContext
789
+ });
790
+ this.scheduleReconnect();
791
+ return;
792
+ }
793
+ this.ws = ws;
794
+ ws.onmessage = (event) => {
795
+ const raw = typeof event.data === "string" ? event.data : String(event.data);
796
+ this.handleMessage(raw);
797
+ };
798
+ ws.onclose = (event) => {
799
+ if (this.intentionalDisconnect) {
800
+ this.callbacks.onDisconnected(true, "intentional disconnect");
801
+ } else {
802
+ const reason = event.reason || `code ${event.code}`;
803
+ this.log.warn("ActionCable disconnected", {
804
+ url: this.wsUrl,
805
+ reason,
806
+ ctx: this.logContext
807
+ });
808
+ this.callbacks.onDisconnected(false, reason);
809
+ this.scheduleReconnect();
810
+ }
811
+ };
812
+ ws.onerror = () => {
813
+ this.log.warn("ActionCable WebSocket error", { url: this.wsUrl, ctx: this.logContext });
814
+ };
815
+ this.log.info("ActionCable connecting", { url: this.wsUrl, ctx: this.logContext });
816
+ }
817
+ handleMessage(raw) {
818
+ let msg;
819
+ try {
820
+ msg = JSON.parse(raw);
821
+ } catch {
822
+ return;
823
+ }
824
+ if (!msg.type) {
825
+ if (typeof msg.identifier === "string" && msg.message !== undefined) {
826
+ this.callbacks.onMessage(msg.identifier, msg.message);
827
+ }
828
+ return;
829
+ }
830
+ switch (msg.type) {
831
+ case "welcome":
832
+ this.reconnectAttempt = 0;
833
+ this.log.info("ActionCable connected (welcome)", { url: this.wsUrl, ctx: this.logContext });
834
+ this.callbacks.onConnected();
835
+ break;
836
+ case "ping":
837
+ break;
838
+ case "confirm_subscription":
839
+ if (typeof msg.identifier === "string") {
840
+ this.log.debug("ActionCable subscription confirmed", { ctx: this.logContext });
841
+ this.callbacks.onConfirm(msg.identifier);
842
+ }
843
+ break;
844
+ case "reject_subscription":
845
+ if (typeof msg.identifier === "string") {
846
+ this.log.warn("ActionCable subscription rejected", { ctx: this.logContext });
847
+ this.callbacks.onReject(msg.identifier);
848
+ }
849
+ break;
850
+ case "disconnect": {
851
+ const shouldReconnect = msg.reconnect !== false;
852
+ this.log.info("ActionCable server disconnect", {
853
+ reason: msg.reason,
854
+ reconnect: shouldReconnect,
855
+ ctx: this.logContext
856
+ });
857
+ if (!shouldReconnect)
858
+ this.intentionalDisconnect = true;
859
+ this.callbacks.onDisconnected(!shouldReconnect, msg.reason ?? "server disconnect");
860
+ break;
861
+ }
862
+ }
863
+ }
864
+ send(obj) {
865
+ if (this.ws?.readyState === WebSocket.OPEN) {
866
+ this.ws.send(JSON.stringify(obj));
867
+ }
868
+ }
869
+ cleanup() {
870
+ if (this.reconnectTimer !== null) {
871
+ clearTimeout(this.reconnectTimer);
872
+ this.reconnectTimer = null;
873
+ }
874
+ if (this.ws !== null) {
875
+ this.ws.onmessage = null;
876
+ this.ws.onclose = null;
877
+ this.ws.onerror = null;
878
+ this.ws.close();
879
+ this.ws = null;
880
+ }
881
+ }
882
+ scheduleReconnect() {
883
+ if (this.reconnectAttempt >= MAX_RECONNECT_ATTEMPTS) {
884
+ this.log.error("ActionCable max reconnect attempts reached", {
885
+ url: this.wsUrl,
886
+ ctx: this.logContext
887
+ });
888
+ return;
889
+ }
890
+ const base = BASE_RECONNECT_DELAY_MS * Math.pow(2, this.reconnectAttempt);
891
+ const jitter = Math.random() * base * 0.3;
892
+ const delayMs = Math.min(base + jitter, MAX_RECONNECT_DELAY_MS);
893
+ this.reconnectAttempt++;
894
+ this.log.info("ActionCable scheduling reconnect", {
895
+ attempt: this.reconnectAttempt,
896
+ delayMs: Math.round(delayMs),
897
+ ctx: this.logContext
898
+ });
899
+ this.reconnectTimer = setTimeout(() => {
900
+ this.reconnectTimer = null;
901
+ if (!this.intentionalDisconnect) {
902
+ this.performConnect();
903
+ }
904
+ }, delayMs);
905
+ }
906
+ }
907
+
908
+ // src/NoteMutator.ts
909
+ class NoteMutator {
910
+ baseURL;
911
+ token;
912
+ constructor(baseURL, token) {
913
+ this.baseURL = baseURL.replace(/\/$/, "");
914
+ this.token = token;
915
+ }
916
+ async createNote(projectId, mrIid, body, discussionId) {
917
+ const url = discussionId ? `${this.baseURL}/api/v4/projects/${projectId}/merge_requests/${mrIid}/discussions/${discussionId}/notes` : `${this.baseURL}/api/v4/projects/${projectId}/merge_requests/${mrIid}/notes`;
918
+ const res = await fetch(url, {
919
+ method: "POST",
920
+ headers: {
921
+ "Content-Type": "application/json",
922
+ "PRIVATE-TOKEN": this.token
923
+ },
924
+ body: JSON.stringify({ body })
925
+ });
926
+ if (!res.ok) {
927
+ const text = await res.text().catch(() => "");
928
+ throw new Error(`createNote failed: ${res.status} ${res.statusText}${text ? ` — ${text}` : ""}`);
929
+ }
930
+ return await res.json();
931
+ }
932
+ async updateNote(projectId, mrIid, noteId, body) {
933
+ const url = `${this.baseURL}/api/v4/projects/${projectId}/merge_requests/${mrIid}/notes/${noteId}`;
934
+ const res = await fetch(url, {
935
+ method: "PUT",
936
+ headers: {
937
+ "Content-Type": "application/json",
938
+ "PRIVATE-TOKEN": this.token
939
+ },
940
+ body: JSON.stringify({ body })
941
+ });
942
+ if (!res.ok) {
943
+ const text = await res.text().catch(() => "");
944
+ throw new Error(`updateNote failed: ${res.status} ${res.statusText}${text ? ` — ${text}` : ""}`);
945
+ }
946
+ }
947
+ async deleteNote(projectId, mrIid, noteId) {
948
+ const url = `${this.baseURL}/api/v4/projects/${projectId}/merge_requests/${mrIid}/notes/${noteId}`;
949
+ const res = await fetch(url, {
950
+ method: "DELETE",
951
+ headers: { "PRIVATE-TOKEN": this.token }
952
+ });
953
+ if (!res.ok) {
954
+ const text = await res.text().catch(() => "");
955
+ throw new Error(`deleteNote failed: ${res.status} ${res.statusText}${text ? ` — ${text}` : ""}`);
956
+ }
957
+ }
958
+ }
959
+ export {
960
+ repoIdProvider,
961
+ parseRepoId,
962
+ parseGitLabRepoId,
963
+ noopLogger,
964
+ createProvider,
965
+ SUPPORTED_PROVIDERS,
966
+ NoteMutator,
967
+ MR_DASHBOARD_FRAGMENT,
968
+ MRDetailFetcher,
969
+ GitLabProvider,
970
+ GitHubProvider,
971
+ ActionCableClient
972
+ };