reslop 0.1.3 → 0.1.5
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 +19 -1
- package/README.md +40 -38
- package/lib/api.js +3 -0
- package/lib/cli.js +31 -8
- package/lib/clipboard.js +11 -11
- package/lib/diff.js +71 -2
- package/lib/git.js +56 -16
- package/lib/gitlab.js +520 -0
- package/lib/keys.js +27 -6
- package/lib/render.js +154 -59
- package/lib/review.js +145 -3
- package/lib/select.js +7 -12
- package/lib/session.js +256 -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/keys.js
CHANGED
|
@@ -3,18 +3,36 @@
|
|
|
3
3
|
const ACTIONS = {
|
|
4
4
|
add: { keys: ['a'], label: 'Add' },
|
|
5
5
|
unstage: { keys: ['u'], label: 'Unstage' },
|
|
6
|
-
revert: { keys: ['
|
|
7
|
-
|
|
8
|
-
|
|
6
|
+
revert: { keys: ['d'], label: 'Drop' },
|
|
7
|
+
commit: { keys: ['c'], label: 'Commit' },
|
|
8
|
+
prev: { keys: ['left', 'p', 'k'], hot: '←', label: '←' },
|
|
9
|
+
next: { keys: ['right', 'n', 'j'], hot: '→', label: '→' },
|
|
9
10
|
layout: { keys: ['m'], label: 'Mode' },
|
|
10
|
-
files: { keys: ['l'], label: 'Files' },
|
|
11
11
|
feedback: { keys: ['f'], label: 'Feedback' },
|
|
12
12
|
todo: { keys: ['t'], label: 'Todo' },
|
|
13
|
-
|
|
13
|
+
code: { keys: ['e'], label: 'Edit' },
|
|
14
|
+
reload: { keys: ['r'], label: 'Reload' },
|
|
15
|
+
quit: { keys: ['q', 'ctrl-c'], label: 'Q' },
|
|
14
16
|
};
|
|
15
17
|
const ACTION_IDS = Object.keys(ACTIONS);
|
|
16
18
|
|
|
17
|
-
const
|
|
19
|
+
const DIFF_DISABLED = ['reload'];
|
|
20
|
+
const FILES_DISABLED = ['layout', 'feedback', 'code'];
|
|
21
|
+
const TODO_DISABLED = [
|
|
22
|
+
'add',
|
|
23
|
+
'unstage',
|
|
24
|
+
'revert',
|
|
25
|
+
'layout',
|
|
26
|
+
'feedback',
|
|
27
|
+
'code',
|
|
28
|
+
'reload',
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
const disabledActions = (pane, item) => {
|
|
32
|
+
if (pane === 'files') return FILES_DISABLED;
|
|
33
|
+
if (item && item.origin === 'todo') return TODO_DISABLED;
|
|
34
|
+
return DIFF_DISABLED;
|
|
35
|
+
};
|
|
18
36
|
|
|
19
37
|
const SCROLL = {
|
|
20
38
|
up: 'scrollUp',
|
|
@@ -197,7 +215,10 @@ const hitAction = (hits, x) => {
|
|
|
197
215
|
module.exports = {
|
|
198
216
|
ACTIONS,
|
|
199
217
|
ACTION_IDS,
|
|
218
|
+
DIFF_DISABLED,
|
|
200
219
|
FILES_DISABLED,
|
|
220
|
+
TODO_DISABLED,
|
|
221
|
+
disabledActions,
|
|
201
222
|
decodeChunk,
|
|
202
223
|
actionFromKey,
|
|
203
224
|
actionHot,
|