@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/ActionCableClient.d.ts +41 -0
- package/dist/ActionCableClient.js +189 -0
- package/dist/GitHubProvider.d.ts +50 -0
- package/dist/GitHubProvider.js +361 -0
- package/dist/GitLabProvider.d.ts +34 -0
- package/dist/GitLabProvider.js +359 -0
- package/dist/GitProvider.d.ts +50 -0
- package/dist/GitProvider.js +12 -0
- package/dist/MRDetailFetcher.d.ts +18 -0
- package/dist/MRDetailFetcher.js +74 -0
- package/dist/NoteMutator.d.ts +37 -0
- package/dist/NoteMutator.js +54 -0
- package/dist/index.d.ts +27 -0
- package/dist/index.js +972 -0
- package/dist/logger.d.ts +20 -0
- package/dist/logger.js +10 -0
- package/dist/providers.d.ts +23 -0
- package/dist/providers.js +722 -0
- package/dist/types.d.ts +176 -0
- package/dist/types.js +0 -0
- package/package.json +38 -0
- package/src/ActionCableClient.ts +237 -0
- package/src/GitHubProvider.ts +639 -0
- package/src/GitLabProvider.ts +471 -0
- package/src/GitProvider.ts +77 -0
- package/src/MRDetailFetcher.ts +133 -0
- package/src/NoteMutator.ts +108 -0
- package/src/index.ts +54 -0
- package/src/logger.ts +26 -0
- package/src/providers.ts +40 -0
- package/src/types.ts +196 -0
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
|
+
};
|