reslop 0.1.2 → 0.1.4
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/CHANGELOG.md +18 -1
- package/README.md +38 -36
- package/lib/api.js +3 -0
- package/lib/cli.js +53 -16
- package/lib/clipboard.js +11 -11
- package/lib/detect.js +2 -0
- package/lib/files.js +36 -12
- package/lib/git.js +335 -65
- package/lib/gitlab.js +520 -0
- package/lib/highlight.js +160 -5
- package/lib/keys.js +8 -2
- package/lib/render.js +87 -30
- package/lib/session.js +227 -44
- package/lib/source.js +5 -2
- package/lib/sys.js +59 -0
- package/package.json +2 -2
package/lib/gitlab.js
ADDED
|
@@ -0,0 +1,520 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { delay } = require('metautil');
|
|
4
|
+
|
|
5
|
+
const diff = require('./diff.js');
|
|
6
|
+
const { parseDiff, itemsFromFiles, isCtxType } = diff;
|
|
7
|
+
const { itemPath, isPathInScope } = require('./files.js');
|
|
8
|
+
const { foldDepItems } = require('./deps.js');
|
|
9
|
+
|
|
10
|
+
const USER_AGENT = 'reslop';
|
|
11
|
+
const JSON_ACCEPT = 'application/json';
|
|
12
|
+
const DIFF_ACCEPT = 'text/plain';
|
|
13
|
+
const RETRY_ATTEMPTS = 3;
|
|
14
|
+
const RETRY_DELAY_MS = 200;
|
|
15
|
+
const TRANSIENT_STATUS = [429, 502, 503, 504];
|
|
16
|
+
const MR_TODO_FILE = 'merge request';
|
|
17
|
+
const LIST_PER_PAGE = 100;
|
|
18
|
+
|
|
19
|
+
const GITLAB_MR_RE = new RegExp(
|
|
20
|
+
String.raw`^(?:(https?)://)?(?:www\.)?([^/?#]+)/` +
|
|
21
|
+
String.raw`(.+?)(?:/-)?/merge_requests/(\d+)` +
|
|
22
|
+
String.raw`(?:\.diff|\.patch)?` +
|
|
23
|
+
String.raw`(?:/(?:diffs|commits|pipelines|changes)?)?` +
|
|
24
|
+
String.raw`/?(?:[?#].*)?$`,
|
|
25
|
+
'i',
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
const isRecord = (value) =>
|
|
29
|
+
!!value && typeof value === 'object' && !Array.isArray(value);
|
|
30
|
+
|
|
31
|
+
const oneLine = (text) => {
|
|
32
|
+
const value = `${text ?? ''}`.trim();
|
|
33
|
+
if (!value) return '';
|
|
34
|
+
return value.split('\n')[0];
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const gitlabError = (code, message) => {
|
|
38
|
+
const error = new Error(message);
|
|
39
|
+
error.code = code;
|
|
40
|
+
return error;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const withStatus = (error, status) => {
|
|
44
|
+
error.status = status;
|
|
45
|
+
return error;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const parseGitlabMrUrl = (text) => {
|
|
49
|
+
if (typeof text !== 'string' || !text) return null;
|
|
50
|
+
const match = GITLAB_MR_RE.exec(text.trim());
|
|
51
|
+
if (!match) return null;
|
|
52
|
+
const scheme = (match[1] || 'https').toLowerCase();
|
|
53
|
+
const host = match[2];
|
|
54
|
+
const rawProject = match[3];
|
|
55
|
+
const project = rawProject.replace(/\.git$/i, '');
|
|
56
|
+
const number = parseInt(match[4], 10);
|
|
57
|
+
const hasNumber = Number.isFinite(number) && number >= 1;
|
|
58
|
+
if (!host || !project || !hasNumber) return null;
|
|
59
|
+
const origin = `${scheme}://${host}`;
|
|
60
|
+
return { host, project, number, origin };
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const gitlabToken = (env) => {
|
|
64
|
+
const from = env ?? process.env;
|
|
65
|
+
return from.GITLAB_TOKEN || from.GL_TOKEN || '';
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const parseApiMessage = (body) => {
|
|
69
|
+
const text = `${body ?? ''}`.trim();
|
|
70
|
+
if (!text) return '';
|
|
71
|
+
try {
|
|
72
|
+
const parsed = JSON.parse(text);
|
|
73
|
+
if (isRecord(parsed)) {
|
|
74
|
+
if (typeof parsed.message === 'string') return parsed.message;
|
|
75
|
+
if (typeof parsed.error === 'string') return parsed.error;
|
|
76
|
+
}
|
|
77
|
+
} catch {
|
|
78
|
+
// ignore: body is not JSON
|
|
79
|
+
}
|
|
80
|
+
return oneLine(text);
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const gitlabHttpError = (status, body, token) => {
|
|
84
|
+
const message = parseApiMessage(body);
|
|
85
|
+
if (status === 404) {
|
|
86
|
+
const hint = token ? '' : ' (set GITLAB_TOKEN for private repositories)';
|
|
87
|
+
const error = gitlabError(
|
|
88
|
+
'GITLAB_NOT_FOUND',
|
|
89
|
+
`GitLab merge request not found${hint}`,
|
|
90
|
+
);
|
|
91
|
+
return withStatus(error, status);
|
|
92
|
+
}
|
|
93
|
+
if (status === 401) {
|
|
94
|
+
const error = gitlabError('GITLAB_AUTH', 'GitLab authentication failed');
|
|
95
|
+
return withStatus(error, status);
|
|
96
|
+
}
|
|
97
|
+
if (status === 429) {
|
|
98
|
+
const error = gitlabError(
|
|
99
|
+
'GITLAB_RATE_LIMIT',
|
|
100
|
+
'GitLab API rate limit exceeded',
|
|
101
|
+
);
|
|
102
|
+
return withStatus(error, status);
|
|
103
|
+
}
|
|
104
|
+
if (status === 403 && /rate limit/i.test(message)) {
|
|
105
|
+
const error = gitlabError(
|
|
106
|
+
'GITLAB_RATE_LIMIT',
|
|
107
|
+
'GitLab API rate limit exceeded',
|
|
108
|
+
);
|
|
109
|
+
return withStatus(error, status);
|
|
110
|
+
}
|
|
111
|
+
const detail = message
|
|
112
|
+
? `GitLab API: ${oneLine(message)}`
|
|
113
|
+
: `GitLab API HTTP ${status}`;
|
|
114
|
+
return withStatus(gitlabError('GITLAB_HTTP', detail), status);
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const wrapNetworkError = (error) => {
|
|
118
|
+
const detail = oneLine(error && error.message);
|
|
119
|
+
const suffix = detail ? `: ${detail}` : '';
|
|
120
|
+
const wrapped = gitlabError(
|
|
121
|
+
'GITLAB_NETWORK',
|
|
122
|
+
`GitLab request failed${suffix}`,
|
|
123
|
+
);
|
|
124
|
+
wrapped.cause = error;
|
|
125
|
+
return wrapped;
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
const isRetryable = (error) => {
|
|
129
|
+
if (error.code === 'GITLAB_NETWORK') return true;
|
|
130
|
+
return TRANSIENT_STATUS.includes(error.status);
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
const retry = async (run, options) => {
|
|
134
|
+
const attempts = options.attempts ?? RETRY_ATTEMPTS;
|
|
135
|
+
const delayMs = options.delayMs ?? RETRY_DELAY_MS;
|
|
136
|
+
for (let i = 0; ; i++) {
|
|
137
|
+
try {
|
|
138
|
+
return await run();
|
|
139
|
+
} catch (error) {
|
|
140
|
+
const isLast = i === attempts - 1;
|
|
141
|
+
if (isLast || !isRetryable(error)) throw error;
|
|
142
|
+
await delay(delayMs * (i + 1));
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
const gitlabHeaders = (token, accept) => {
|
|
148
|
+
const headers = {
|
|
149
|
+
Accept: accept,
|
|
150
|
+
'User-Agent': USER_AGENT,
|
|
151
|
+
};
|
|
152
|
+
if (!token) return headers;
|
|
153
|
+
return { ...headers, 'PRIVATE-TOKEN': token };
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
const headerValue = (headers, name) => {
|
|
157
|
+
if (!headers) return '';
|
|
158
|
+
if (typeof headers.get === 'function') {
|
|
159
|
+
return headers.get(name) || '';
|
|
160
|
+
}
|
|
161
|
+
const wanted = name.toLowerCase();
|
|
162
|
+
for (const key of Object.keys(headers)) {
|
|
163
|
+
const value = headers[key];
|
|
164
|
+
if (key.toLowerCase() === wanted) return `${value}`;
|
|
165
|
+
}
|
|
166
|
+
return '';
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
const parseLinkNext = (headers) => {
|
|
170
|
+
const link = headerValue(headers, 'link');
|
|
171
|
+
if (!link) return '';
|
|
172
|
+
for (const part of link.split(',')) {
|
|
173
|
+
const match = /<([^>]+)>\s*;\s*rel="?next"?/i.exec(part.trim());
|
|
174
|
+
if (match) return match[1];
|
|
175
|
+
}
|
|
176
|
+
return '';
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
const parseNextPage = (headers, currentUrl) => {
|
|
180
|
+
const fromLink = parseLinkNext(headers);
|
|
181
|
+
if (fromLink) return fromLink;
|
|
182
|
+
const page = headerValue(headers, 'x-next-page');
|
|
183
|
+
if (!page) return '';
|
|
184
|
+
const url = new URL(currentUrl);
|
|
185
|
+
url.searchParams.set('page', page);
|
|
186
|
+
return url.toString();
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
const parseGitlabJson = (text, label) => {
|
|
190
|
+
let data;
|
|
191
|
+
try {
|
|
192
|
+
data = JSON.parse(text);
|
|
193
|
+
} catch {
|
|
194
|
+
throw gitlabError('GITLAB_PARSE', `GitLab API: invalid ${label}`);
|
|
195
|
+
}
|
|
196
|
+
return data;
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
const requestGitlab = async (url, options) => {
|
|
200
|
+
const fetchImpl = options.fetch;
|
|
201
|
+
if (typeof fetchImpl !== 'function') {
|
|
202
|
+
throw gitlabError('GITLAB_FETCH', 'fetch is not available');
|
|
203
|
+
}
|
|
204
|
+
const token = options.token || '';
|
|
205
|
+
const headers = gitlabHeaders(token, options.accept);
|
|
206
|
+
const retryOpts = options.retry ?? {};
|
|
207
|
+
const method = options.method || 'GET';
|
|
208
|
+
return retry(async () => {
|
|
209
|
+
let response;
|
|
210
|
+
try {
|
|
211
|
+
response = await fetchImpl(url, { method, headers });
|
|
212
|
+
} catch (error) {
|
|
213
|
+
throw wrapNetworkError(error);
|
|
214
|
+
}
|
|
215
|
+
const text = await response.text();
|
|
216
|
+
if (!response.ok) throw gitlabHttpError(response.status, text, token);
|
|
217
|
+
return { body: text, headers: response.headers };
|
|
218
|
+
}, retryOpts);
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
const requestGitlabList = async (url, options) => {
|
|
222
|
+
const items = [];
|
|
223
|
+
let next = url;
|
|
224
|
+
while (next) {
|
|
225
|
+
const page = await requestGitlab(next, {
|
|
226
|
+
...options,
|
|
227
|
+
accept: JSON_ACCEPT,
|
|
228
|
+
});
|
|
229
|
+
const data = parseGitlabJson(page.body, 'list payload');
|
|
230
|
+
if (!Array.isArray(data)) {
|
|
231
|
+
throw gitlabError('GITLAB_PARSE', 'GitLab API: invalid list payload');
|
|
232
|
+
}
|
|
233
|
+
for (const entry of data) items.push(entry);
|
|
234
|
+
next = parseNextPage(page.headers, next);
|
|
235
|
+
}
|
|
236
|
+
return items;
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
const changePath = (file) => file.newPath || file.oldPath || '';
|
|
240
|
+
|
|
241
|
+
const filterChangeFiles = (files, paths) => {
|
|
242
|
+
if (!paths.length) return files;
|
|
243
|
+
const kept = [];
|
|
244
|
+
for (const file of files) {
|
|
245
|
+
if (isPathInScope(changePath(file), paths)) kept.push(file);
|
|
246
|
+
}
|
|
247
|
+
return kept;
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
const mrApiUrl = (mr) => {
|
|
251
|
+
const project = encodeURIComponent(mr.project);
|
|
252
|
+
const base = `${mr.origin}/api/v4/projects/${project}`;
|
|
253
|
+
return `${base}/merge_requests/${mr.number}`;
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
const listUrl = (base) => {
|
|
257
|
+
const url = new URL(base);
|
|
258
|
+
url.searchParams.set('per_page', `${LIST_PER_PAGE}`);
|
|
259
|
+
return url.toString();
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
const readUsername = (user) => {
|
|
263
|
+
if (!isRecord(user)) return '';
|
|
264
|
+
return user.username || '';
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
const readProject = (data, mr) => {
|
|
268
|
+
const refs = isRecord(data.references) ? data.references : null;
|
|
269
|
+
const full = refs && refs.full;
|
|
270
|
+
if (typeof full === 'string') {
|
|
271
|
+
const cut = full.lastIndexOf('!');
|
|
272
|
+
if (cut > 0) return full.slice(0, cut);
|
|
273
|
+
}
|
|
274
|
+
return mr.project;
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
const pushChangeFile = (files, seen, rel) => {
|
|
278
|
+
if (!rel || seen.has(rel)) return;
|
|
279
|
+
seen.add(rel);
|
|
280
|
+
files.push(rel);
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
const changeFiles = (items) => {
|
|
284
|
+
const files = [];
|
|
285
|
+
const seen = new Set();
|
|
286
|
+
for (const item of items) {
|
|
287
|
+
if (item.dep) {
|
|
288
|
+
const depFiles = item.dep.files ?? [];
|
|
289
|
+
for (const rel of depFiles) pushChangeFile(files, seen, rel);
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
const rel = itemPath(item);
|
|
293
|
+
pushChangeFile(files, seen, rel);
|
|
294
|
+
}
|
|
295
|
+
return files;
|
|
296
|
+
};
|
|
297
|
+
|
|
298
|
+
const parseMergeRequestPayload = (text) => {
|
|
299
|
+
const data = parseGitlabJson(text, 'merge request payload');
|
|
300
|
+
if (!isRecord(data)) {
|
|
301
|
+
throw gitlabError(
|
|
302
|
+
'GITLAB_PARSE',
|
|
303
|
+
'GitLab API: invalid merge request payload',
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
return data;
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
const toChange = (mr, data, items) => {
|
|
310
|
+
const number = data.iid || mr.number;
|
|
311
|
+
const repository = readProject(data, mr);
|
|
312
|
+
const title = data.title || '';
|
|
313
|
+
const author = readUsername(data.author);
|
|
314
|
+
const files = changeFiles(items);
|
|
315
|
+
const base = data.target_branch || '';
|
|
316
|
+
const head = data.source_branch || '';
|
|
317
|
+
const fallbackUrl = `${mr.origin}/${repository}/-/merge_requests/${number}`;
|
|
318
|
+
const url = data.web_url || fallbackUrl;
|
|
319
|
+
const id = `${mr.host}/${repository}/merge_requests/${number}`;
|
|
320
|
+
return {
|
|
321
|
+
id,
|
|
322
|
+
source: 'gitlab-mr',
|
|
323
|
+
title,
|
|
324
|
+
author,
|
|
325
|
+
files,
|
|
326
|
+
status: 'reviewing',
|
|
327
|
+
base,
|
|
328
|
+
head,
|
|
329
|
+
url,
|
|
330
|
+
repository,
|
|
331
|
+
number,
|
|
332
|
+
};
|
|
333
|
+
};
|
|
334
|
+
|
|
335
|
+
const readCommentBody = (value) => `${value ?? ''}`.trim();
|
|
336
|
+
|
|
337
|
+
const reviewerOf = (record) => {
|
|
338
|
+
const login = readUsername(record && record.author);
|
|
339
|
+
return login || 'unknown';
|
|
340
|
+
};
|
|
341
|
+
|
|
342
|
+
const formatImportedText = (entry) => {
|
|
343
|
+
const reviewer = entry.reviewer || 'unknown';
|
|
344
|
+
const body = `${entry.body ?? ''}`.trim();
|
|
345
|
+
return `@${reviewer} review at gitlab: ${body}`;
|
|
346
|
+
};
|
|
347
|
+
|
|
348
|
+
const noteLine = (position) => {
|
|
349
|
+
if (!isRecord(position)) return { side: 'RIGHT', line: 0 };
|
|
350
|
+
const kind = position.position_type || 'text';
|
|
351
|
+
if (kind === 'file' || kind === 'image') {
|
|
352
|
+
return { side: 'RIGHT', line: 0 };
|
|
353
|
+
}
|
|
354
|
+
const newLine = Number(position.new_line);
|
|
355
|
+
if (Number.isFinite(newLine) && newLine >= 1) {
|
|
356
|
+
return { side: 'RIGHT', line: newLine };
|
|
357
|
+
}
|
|
358
|
+
const oldLine = Number(position.old_line);
|
|
359
|
+
if (Number.isFinite(oldLine) && oldLine >= 1) {
|
|
360
|
+
return { side: 'LEFT', line: oldLine };
|
|
361
|
+
}
|
|
362
|
+
return { side: 'RIGHT', line: 0 };
|
|
363
|
+
};
|
|
364
|
+
|
|
365
|
+
const sideLineNumber = (type, oldNo, newNo, side) => {
|
|
366
|
+
if (side === 'LEFT') {
|
|
367
|
+
if (type === 'add') return 0;
|
|
368
|
+
return oldNo;
|
|
369
|
+
}
|
|
370
|
+
if (type === 'del') return 0;
|
|
371
|
+
return newNo;
|
|
372
|
+
};
|
|
373
|
+
|
|
374
|
+
const locateLineInItem = (item, rel, line, side) => {
|
|
375
|
+
const path = itemPath(item);
|
|
376
|
+
if (path !== rel) return null;
|
|
377
|
+
const hunk = item.hunk;
|
|
378
|
+
if (!hunk || !line) return null;
|
|
379
|
+
let oldNo = hunk.oldStart;
|
|
380
|
+
let newNo = hunk.newStart;
|
|
381
|
+
for (const entry of hunk.lines) {
|
|
382
|
+
const at = sideLineNumber(entry.type, oldNo, newNo, side);
|
|
383
|
+
if (at === line) {
|
|
384
|
+
const inBlock = entry.blockId === item.blockId;
|
|
385
|
+
return { inBlock };
|
|
386
|
+
}
|
|
387
|
+
if (isCtxType(entry.type) || entry.type === 'del') {
|
|
388
|
+
oldNo += 1;
|
|
389
|
+
}
|
|
390
|
+
if (isCtxType(entry.type) || entry.type === 'add') {
|
|
391
|
+
newNo += 1;
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
return null;
|
|
395
|
+
};
|
|
396
|
+
|
|
397
|
+
const findItemForLine = (items, rel, line, side) => {
|
|
398
|
+
let fallback = null;
|
|
399
|
+
for (const item of items) {
|
|
400
|
+
const hit = locateLineInItem(item, rel, line, side);
|
|
401
|
+
if (!hit) continue;
|
|
402
|
+
if (hit.inBlock) return item;
|
|
403
|
+
if (!fallback) fallback = item;
|
|
404
|
+
}
|
|
405
|
+
return fallback;
|
|
406
|
+
};
|
|
407
|
+
|
|
408
|
+
const toImportedTodo = (file, entry) => ({
|
|
409
|
+
kind: 'todo',
|
|
410
|
+
file,
|
|
411
|
+
text: formatImportedText(entry),
|
|
412
|
+
done: entry.resolved === true,
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
const noteToImported = (note, items, paths) => {
|
|
416
|
+
if (!isRecord(note) || note.system === true) return null;
|
|
417
|
+
const body = readCommentBody(note.body);
|
|
418
|
+
if (!body) return null;
|
|
419
|
+
const resolved = note.resolved === true;
|
|
420
|
+
const entry = {
|
|
421
|
+
reviewer: reviewerOf(note),
|
|
422
|
+
body,
|
|
423
|
+
resolved,
|
|
424
|
+
};
|
|
425
|
+
if (note.type !== 'DiffNote') {
|
|
426
|
+
return toImportedTodo(MR_TODO_FILE, entry);
|
|
427
|
+
}
|
|
428
|
+
const position = isRecord(note.position) ? note.position : null;
|
|
429
|
+
const path = position ? position.new_path || position.old_path || '' : '';
|
|
430
|
+
if (path && !isPathInScope(path, paths)) return null;
|
|
431
|
+
const { side, line } = noteLine(position);
|
|
432
|
+
const isFile = !line;
|
|
433
|
+
if (!isFile && path) {
|
|
434
|
+
const item = findItemForLine(items, path, line, side);
|
|
435
|
+
if (item) {
|
|
436
|
+
const hunk = item.hunk;
|
|
437
|
+
return {
|
|
438
|
+
kind: 'feedback',
|
|
439
|
+
file: itemPath(item) || path,
|
|
440
|
+
oldStart: hunk ? hunk.oldStart : 0,
|
|
441
|
+
newStart: hunk ? hunk.newStart : 0,
|
|
442
|
+
blockId: item.blockId ?? 0,
|
|
443
|
+
origin: item.origin ?? 'pr',
|
|
444
|
+
header: hunk ? hunk.header : '',
|
|
445
|
+
text: formatImportedText(entry),
|
|
446
|
+
done: resolved,
|
|
447
|
+
};
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
if (path) return toImportedTodo(path, entry);
|
|
451
|
+
return toImportedTodo(MR_TODO_FILE, entry);
|
|
452
|
+
};
|
|
453
|
+
|
|
454
|
+
const discussionToNotes = (discussions, items, paths = []) => {
|
|
455
|
+
const feedback = [];
|
|
456
|
+
const todos = [];
|
|
457
|
+
const threads = discussions ?? [];
|
|
458
|
+
for (const discussion of threads) {
|
|
459
|
+
if (!isRecord(discussion)) continue;
|
|
460
|
+
const notes = Array.isArray(discussion.notes) ? discussion.notes : [];
|
|
461
|
+
for (const note of notes) {
|
|
462
|
+
const mapped = noteToImported(note, items, paths);
|
|
463
|
+
if (!mapped) continue;
|
|
464
|
+
if (mapped.kind === 'feedback') feedback.push(mapped);
|
|
465
|
+
else todos.push(mapped);
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
return { feedback, todos };
|
|
469
|
+
};
|
|
470
|
+
|
|
471
|
+
const loadDiscussion = async (mr, options) => {
|
|
472
|
+
const url = listUrl(`${mrApiUrl(mr)}/discussions`);
|
|
473
|
+
return requestGitlabList(url, options);
|
|
474
|
+
};
|
|
475
|
+
|
|
476
|
+
const loadImportedNotes = async (mr, items, paths, options) => {
|
|
477
|
+
try {
|
|
478
|
+
const discussions = await loadDiscussion(mr, options);
|
|
479
|
+
return discussionToNotes(discussions, items, paths);
|
|
480
|
+
} catch {
|
|
481
|
+
// ignore: discussion import is optional
|
|
482
|
+
return { feedback: [], todos: [] };
|
|
483
|
+
}
|
|
484
|
+
};
|
|
485
|
+
|
|
486
|
+
const loadMergeRequest = async (mr, options = {}) => {
|
|
487
|
+
const token = options.token ?? '';
|
|
488
|
+
const fetchImpl = options.fetch ?? globalThis.fetch;
|
|
489
|
+
const paths = options.paths ?? [];
|
|
490
|
+
const retryOpts = options.retry ?? {};
|
|
491
|
+
const url = mrApiUrl(mr);
|
|
492
|
+
const request = { fetch: fetchImpl, token, retry: retryOpts };
|
|
493
|
+
const jsonPage = await requestGitlab(url, {
|
|
494
|
+
...request,
|
|
495
|
+
accept: JSON_ACCEPT,
|
|
496
|
+
});
|
|
497
|
+
const data = parseMergeRequestPayload(jsonPage.body);
|
|
498
|
+
const diffPage = await requestGitlab(`${url}/raw_diffs`, {
|
|
499
|
+
...request,
|
|
500
|
+
accept: DIFF_ACCEPT,
|
|
501
|
+
});
|
|
502
|
+
const parsed = parseDiff(diffPage.body);
|
|
503
|
+
const files = filterChangeFiles(parsed, paths);
|
|
504
|
+
const parsedItems = itemsFromFiles(files, 'pr');
|
|
505
|
+
const items = foldDepItems(parsedItems);
|
|
506
|
+
const change = toChange(mr, data, items);
|
|
507
|
+
const sourceLabel = `!${change.number}`;
|
|
508
|
+
const imported = await loadImportedNotes(mr, items, paths, request);
|
|
509
|
+
return { top: options.cwd, items, sourceLabel, change, imported };
|
|
510
|
+
};
|
|
511
|
+
|
|
512
|
+
module.exports = {
|
|
513
|
+
parseGitlabMrUrl,
|
|
514
|
+
gitlabToken,
|
|
515
|
+
filterChangeFiles,
|
|
516
|
+
mrApiUrl,
|
|
517
|
+
formatImportedText,
|
|
518
|
+
discussionToNotes,
|
|
519
|
+
loadMergeRequest,
|
|
520
|
+
};
|
package/lib/highlight.js
CHANGED
|
@@ -546,7 +546,7 @@ const highlightTemplate = (source, i, highlightExpr) => {
|
|
|
546
546
|
emit(parts, 'interpolation', '${');
|
|
547
547
|
j += 2;
|
|
548
548
|
const interp = readTemplateInterp(source, j);
|
|
549
|
-
const inner = highlightExpr(interp.expr
|
|
549
|
+
const inner = highlightExpr(interp.expr);
|
|
550
550
|
for (const t of inner) parts.push(t);
|
|
551
551
|
j = interp.end;
|
|
552
552
|
if (source[j] === '}') {
|
|
@@ -562,7 +562,149 @@ const highlightTemplate = (source, i, highlightExpr) => {
|
|
|
562
562
|
return { tokens: parts, end: j };
|
|
563
563
|
};
|
|
564
564
|
|
|
565
|
-
const
|
|
565
|
+
const isJsxTagStart = (source, i) => {
|
|
566
|
+
if (source[i] !== '<') return false;
|
|
567
|
+
if (i > 0 && /[A-Za-z0-9_$]/.test(source[i - 1])) return false;
|
|
568
|
+
const next = source[i + 1];
|
|
569
|
+
if (next === '>' || next === '/') return true;
|
|
570
|
+
if (!/[A-Za-z_$]/.test(next || '')) return false;
|
|
571
|
+
let j = i + 1;
|
|
572
|
+
while (j < source.length && /[A-Za-z0-9_$.:-]/.test(source[j])) j += 1;
|
|
573
|
+
const after = source[skipWs(source, j)];
|
|
574
|
+
if (after === '>' || after === '/' || after === '{') return true;
|
|
575
|
+
return /[A-Za-z_$]/.test(after || '');
|
|
576
|
+
};
|
|
577
|
+
|
|
578
|
+
const jsxNameStyle = (name) => {
|
|
579
|
+
if (/^[A-Z]/.test(name) || name.includes('.')) return 'className';
|
|
580
|
+
return 'tag';
|
|
581
|
+
};
|
|
582
|
+
|
|
583
|
+
const readJsxName = (source, i) => {
|
|
584
|
+
let j = i;
|
|
585
|
+
while (j < source.length && /[A-Za-z0-9_$.:-]/.test(source[j])) j += 1;
|
|
586
|
+
return { text: source.slice(i, j), end: j };
|
|
587
|
+
};
|
|
588
|
+
|
|
589
|
+
const highlightJsxExpr = (source, i, inner) => {
|
|
590
|
+
const out = [];
|
|
591
|
+
emit(out, 'interpolation', '{');
|
|
592
|
+
const interp = readTemplateInterp(source, i + 1);
|
|
593
|
+
const tokens = inner(interp.expr);
|
|
594
|
+
for (const t of tokens) out.push(t);
|
|
595
|
+
let end = interp.end;
|
|
596
|
+
if (source[end] === '}') {
|
|
597
|
+
emit(out, 'interpolation', '}');
|
|
598
|
+
end += 1;
|
|
599
|
+
}
|
|
600
|
+
return { tokens: out, end };
|
|
601
|
+
};
|
|
602
|
+
|
|
603
|
+
const highlightJsxTagTail = (source, i, inner, out) => {
|
|
604
|
+
const n = source.length;
|
|
605
|
+
let selfClosing = false;
|
|
606
|
+
while (i < n) {
|
|
607
|
+
const ch = source[i];
|
|
608
|
+
if (ch === '>') {
|
|
609
|
+
emit(out, 'punct', '>');
|
|
610
|
+
i += 1;
|
|
611
|
+
break;
|
|
612
|
+
}
|
|
613
|
+
if (ch === '/' && source[i + 1] === '>') {
|
|
614
|
+
emit(out, 'punct', '/>');
|
|
615
|
+
i += 2;
|
|
616
|
+
selfClosing = true;
|
|
617
|
+
break;
|
|
618
|
+
}
|
|
619
|
+
if (/\s/.test(ch)) {
|
|
620
|
+
emit(out, 'plain', ch);
|
|
621
|
+
i += 1;
|
|
622
|
+
continue;
|
|
623
|
+
}
|
|
624
|
+
if (ch === '{') {
|
|
625
|
+
const expr = highlightJsxExpr(source, i, inner);
|
|
626
|
+
for (const t of expr.tokens) out.push(t);
|
|
627
|
+
i = expr.end;
|
|
628
|
+
continue;
|
|
629
|
+
}
|
|
630
|
+
if (QUOTES.includes(ch)) {
|
|
631
|
+
const { text, end } = readString(source, i);
|
|
632
|
+
emit(out, 'string', text);
|
|
633
|
+
i = end;
|
|
634
|
+
continue;
|
|
635
|
+
}
|
|
636
|
+
if (ch === '=') {
|
|
637
|
+
emit(out, 'punct', '=');
|
|
638
|
+
i += 1;
|
|
639
|
+
continue;
|
|
640
|
+
}
|
|
641
|
+
if (/[A-Za-z_$]/.test(ch)) {
|
|
642
|
+
const { text, end } = readJsxName(source, i);
|
|
643
|
+
emit(out, 'attr', text);
|
|
644
|
+
i = end;
|
|
645
|
+
continue;
|
|
646
|
+
}
|
|
647
|
+
emit(out, 'punct', ch);
|
|
648
|
+
i += 1;
|
|
649
|
+
}
|
|
650
|
+
return { end: i, selfClosing };
|
|
651
|
+
};
|
|
652
|
+
|
|
653
|
+
const highlightJsxElement = (source, i, inner) => {
|
|
654
|
+
const out = [];
|
|
655
|
+
const n = source.length;
|
|
656
|
+
emit(out, 'punct', '<');
|
|
657
|
+
i += 1;
|
|
658
|
+
let closing = false;
|
|
659
|
+
if (source[i] === '/') {
|
|
660
|
+
emit(out, 'punct', '/');
|
|
661
|
+
i += 1;
|
|
662
|
+
closing = true;
|
|
663
|
+
}
|
|
664
|
+
const nameRead = readJsxName(source, i);
|
|
665
|
+
const name = nameRead.text;
|
|
666
|
+
if (name) {
|
|
667
|
+
emit(out, jsxNameStyle(name), name);
|
|
668
|
+
i = nameRead.end;
|
|
669
|
+
}
|
|
670
|
+
const tail = highlightJsxTagTail(source, i, inner, out);
|
|
671
|
+
i = tail.end;
|
|
672
|
+
if (closing || tail.selfClosing) return { tokens: out, end: i };
|
|
673
|
+
while (i < n) {
|
|
674
|
+
if (source.startsWith('</', i)) {
|
|
675
|
+
const close = highlightJsxElement(source, i, inner);
|
|
676
|
+
for (const t of close.tokens) out.push(t);
|
|
677
|
+
return { tokens: out, end: close.end };
|
|
678
|
+
}
|
|
679
|
+
if (source[i] === '{') {
|
|
680
|
+
const expr = highlightJsxExpr(source, i, inner);
|
|
681
|
+
for (const t of expr.tokens) out.push(t);
|
|
682
|
+
i = expr.end;
|
|
683
|
+
continue;
|
|
684
|
+
}
|
|
685
|
+
if (source[i] === '<' && isJsxTagStart(source, i)) {
|
|
686
|
+
const child = highlightJsxElement(source, i, inner);
|
|
687
|
+
for (const t of child.tokens) out.push(t);
|
|
688
|
+
i = child.end;
|
|
689
|
+
continue;
|
|
690
|
+
}
|
|
691
|
+
let j = i;
|
|
692
|
+
while (j < n && source[j] !== '<' && source[j] !== '{') j += 1;
|
|
693
|
+
if (j === i) {
|
|
694
|
+
emit(out, 'plain', source[i]);
|
|
695
|
+
i += 1;
|
|
696
|
+
continue;
|
|
697
|
+
}
|
|
698
|
+
emit(out, 'plain', source.slice(i, j));
|
|
699
|
+
i = j;
|
|
700
|
+
}
|
|
701
|
+
return { tokens: out, end: i };
|
|
702
|
+
};
|
|
703
|
+
|
|
704
|
+
const highlightJsFamily = (source, options = {}) => {
|
|
705
|
+
const ts = options.ts === true;
|
|
706
|
+
const jsx = options.jsx === true;
|
|
707
|
+
const inner = (expr) => highlightJsFamily(expr, options);
|
|
566
708
|
const out = [];
|
|
567
709
|
let i = 0;
|
|
568
710
|
const n = source.length;
|
|
@@ -587,7 +729,7 @@ const highlightJsFamily = (source, ts = false) => {
|
|
|
587
729
|
}
|
|
588
730
|
|
|
589
731
|
if (ch === '`') {
|
|
590
|
-
const r = highlightTemplate(source, i,
|
|
732
|
+
const r = highlightTemplate(source, i, inner);
|
|
591
733
|
for (const t of r.tokens) out.push(t);
|
|
592
734
|
i = r.end;
|
|
593
735
|
continue;
|
|
@@ -636,6 +778,14 @@ const highlightJsFamily = (source, ts = false) => {
|
|
|
636
778
|
continue;
|
|
637
779
|
}
|
|
638
780
|
|
|
781
|
+
if (jsx && ch === '<' && isJsxTagStart(source, i)) {
|
|
782
|
+
const node = highlightJsxElement(source, i, inner);
|
|
783
|
+
for (const t of node.tokens) out.push(t);
|
|
784
|
+
i = node.end;
|
|
785
|
+
expectName = null;
|
|
786
|
+
continue;
|
|
787
|
+
}
|
|
788
|
+
|
|
639
789
|
if (/[=<>!+\-*/%&|^~?:]/.test(ch)) {
|
|
640
790
|
const { text, end } = readJsOperator(source, i);
|
|
641
791
|
emit(out, 'operator', text);
|
|
@@ -1136,13 +1286,18 @@ const highlightTxt = (source) => {
|
|
|
1136
1286
|
return out;
|
|
1137
1287
|
};
|
|
1138
1288
|
|
|
1139
|
-
const highlightJs = (source) => highlightJsFamily(source,
|
|
1140
|
-
const highlightTs = (source) => highlightJsFamily(source, true);
|
|
1289
|
+
const highlightJs = (source) => highlightJsFamily(source, {});
|
|
1290
|
+
const highlightTs = (source) => highlightJsFamily(source, { ts: true });
|
|
1291
|
+
const highlightJsx = (source) => highlightJsFamily(source, { jsx: true });
|
|
1292
|
+
const highlightTsx = (source) =>
|
|
1293
|
+
highlightJsFamily(source, { ts: true, jsx: true });
|
|
1141
1294
|
|
|
1142
1295
|
const HIGHLIGHTERS = {
|
|
1143
1296
|
js: highlightJs,
|
|
1144
1297
|
mjs: highlightJs,
|
|
1145
1298
|
ts: highlightTs,
|
|
1299
|
+
jsx: highlightJsx,
|
|
1300
|
+
tsx: highlightTsx,
|
|
1146
1301
|
json: highlightJson,
|
|
1147
1302
|
css: highlightCss,
|
|
1148
1303
|
html: highlightHtml,
|