reslop 0.1.0 → 0.1.2
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 +25 -9
- package/README.md +25 -40
- package/bin/reslop.js +2 -2
- package/lib/ansi.js +0 -1
- package/lib/api.js +5 -0
- package/lib/cli.js +84 -79
- package/lib/clipboard.js +1 -1
- package/lib/deps.js +1440 -0
- package/lib/detect.js +4 -4
- package/lib/diff.js +40 -63
- package/lib/editor.js +1 -1
- package/lib/files.js +23 -4
- package/lib/git.js +367 -18
- package/lib/github.js +625 -0
- package/lib/highlight.js +149 -166
- package/lib/keys.js +34 -41
- package/lib/render.js +218 -143
- package/lib/review.js +52 -45
- package/lib/select.js +21 -31
- package/lib/session.js +298 -235
- package/lib/source.js +30 -0
- package/package.json +37 -18
package/lib/github.js
ADDED
|
@@ -0,0 +1,625 @@
|
|
|
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 GITHUB_API = 'https://api.github.com';
|
|
11
|
+
const API_VERSION = '2022-11-28';
|
|
12
|
+
const USER_AGENT = 'reslop';
|
|
13
|
+
const JSON_ACCEPT = 'application/vnd.github+json';
|
|
14
|
+
const DIFF_ACCEPT = 'application/vnd.github.diff';
|
|
15
|
+
const RETRY_ATTEMPTS = 3;
|
|
16
|
+
const RETRY_DELAY_MS = 200;
|
|
17
|
+
const TRANSIENT_STATUS = [429, 502, 503, 504];
|
|
18
|
+
const PR_TODO_FILE = 'pull request';
|
|
19
|
+
const LIST_PER_PAGE = 100;
|
|
20
|
+
const GRAPHQL_URL = `${GITHUB_API}/graphql`;
|
|
21
|
+
const REVIEW_THREADS_QUERY =
|
|
22
|
+
'query($owner: String!, $name: String!, $number: Int!, $after: String) {' +
|
|
23
|
+
' repository(owner: $owner, name: $name) {' +
|
|
24
|
+
' pullRequest(number: $number) {' +
|
|
25
|
+
' reviewThreads(first: 100, after: $after) {' +
|
|
26
|
+
' pageInfo { hasNextPage endCursor }' +
|
|
27
|
+
' nodes { isResolved comments(first: 100) { nodes { databaseId } } }' +
|
|
28
|
+
' } } } }';
|
|
29
|
+
|
|
30
|
+
const GITHUB_PR_RE = new RegExp(
|
|
31
|
+
String.raw`^(?:https?://)?(?:www\.)?github\.com/` +
|
|
32
|
+
String.raw`([^/?#]+)/([^/?#]+)/pull/(\d+)` +
|
|
33
|
+
String.raw`(?:\.diff|\.patch)?` +
|
|
34
|
+
String.raw`(?:/(?:files|commits|checks|changes)?)?` +
|
|
35
|
+
String.raw`/?(?:[?#].*)?$`,
|
|
36
|
+
'i',
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
const isRecord = (value) =>
|
|
40
|
+
!!value && typeof value === 'object' && !Array.isArray(value);
|
|
41
|
+
|
|
42
|
+
const oneLine = (text) => {
|
|
43
|
+
const value = `${text ?? ''}`.trim();
|
|
44
|
+
if (!value) return '';
|
|
45
|
+
return value.split('\n')[0];
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const githubError = (code, message) => {
|
|
49
|
+
const error = new Error(message);
|
|
50
|
+
error.code = code;
|
|
51
|
+
return error;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const withStatus = (error, status) => {
|
|
55
|
+
error.status = status;
|
|
56
|
+
return error;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const parseGithubPrUrl = (text) => {
|
|
60
|
+
if (typeof text !== 'string' || !text) return null;
|
|
61
|
+
const match = GITHUB_PR_RE.exec(text.trim());
|
|
62
|
+
if (!match) return null;
|
|
63
|
+
const owner = match[1];
|
|
64
|
+
const rawRepo = match[2];
|
|
65
|
+
const repo = rawRepo.replace(/\.git$/i, '');
|
|
66
|
+
const number = parseInt(match[3], 10);
|
|
67
|
+
const hasNumber = Number.isFinite(number) && number >= 1;
|
|
68
|
+
if (!owner || !repo || !hasNumber) return null;
|
|
69
|
+
return { owner, repo, number };
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const githubToken = (env) => {
|
|
73
|
+
const from = env ?? process.env;
|
|
74
|
+
return from.GITHUB_TOKEN || from.GH_TOKEN || '';
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const parseApiMessage = (body) => {
|
|
78
|
+
const text = `${body ?? ''}`.trim();
|
|
79
|
+
if (!text) return '';
|
|
80
|
+
try {
|
|
81
|
+
const parsed = JSON.parse(text);
|
|
82
|
+
if (isRecord(parsed) && typeof parsed.message === 'string') {
|
|
83
|
+
return parsed.message;
|
|
84
|
+
}
|
|
85
|
+
} catch {
|
|
86
|
+
// ignore: body is not JSON
|
|
87
|
+
}
|
|
88
|
+
return oneLine(text);
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const githubHttpError = (status, body, token) => {
|
|
92
|
+
const message = parseApiMessage(body);
|
|
93
|
+
if (status === 404) {
|
|
94
|
+
const hint = token ? '' : ' (set GITHUB_TOKEN for private repositories)';
|
|
95
|
+
const error = githubError(
|
|
96
|
+
'GITHUB_NOT_FOUND',
|
|
97
|
+
`GitHub pull request not found${hint}`,
|
|
98
|
+
);
|
|
99
|
+
return withStatus(error, status);
|
|
100
|
+
}
|
|
101
|
+
if (status === 401) {
|
|
102
|
+
const error = githubError('GITHUB_AUTH', 'GitHub authentication failed');
|
|
103
|
+
return withStatus(error, status);
|
|
104
|
+
}
|
|
105
|
+
if (status === 403 && /rate limit/i.test(message)) {
|
|
106
|
+
const error = githubError(
|
|
107
|
+
'GITHUB_RATE_LIMIT',
|
|
108
|
+
'GitHub API rate limit exceeded',
|
|
109
|
+
);
|
|
110
|
+
return withStatus(error, status);
|
|
111
|
+
}
|
|
112
|
+
const detail = message
|
|
113
|
+
? `GitHub API: ${oneLine(message)}`
|
|
114
|
+
: `GitHub API HTTP ${status}`;
|
|
115
|
+
return withStatus(githubError('GITHUB_HTTP', detail), status);
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const wrapNetworkError = (error) => {
|
|
119
|
+
const detail = oneLine(error && error.message);
|
|
120
|
+
const suffix = detail ? `: ${detail}` : '';
|
|
121
|
+
const wrapped = githubError(
|
|
122
|
+
'GITHUB_NETWORK',
|
|
123
|
+
`GitHub request failed${suffix}`,
|
|
124
|
+
);
|
|
125
|
+
wrapped.cause = error;
|
|
126
|
+
return wrapped;
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
const isRetryable = (error) => {
|
|
130
|
+
if (error.code === 'GITHUB_NETWORK') return true;
|
|
131
|
+
return TRANSIENT_STATUS.includes(error.status);
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
const retry = async (run, options) => {
|
|
135
|
+
const attempts = options.attempts ?? RETRY_ATTEMPTS;
|
|
136
|
+
const delayMs = options.delayMs ?? RETRY_DELAY_MS;
|
|
137
|
+
for (let i = 0; ; i++) {
|
|
138
|
+
try {
|
|
139
|
+
return await run();
|
|
140
|
+
} catch (error) {
|
|
141
|
+
const isLast = i === attempts - 1;
|
|
142
|
+
if (isLast || !isRetryable(error)) throw error;
|
|
143
|
+
await delay(delayMs * (i + 1));
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
const githubHeaders = (token, accept) => {
|
|
149
|
+
const headers = {
|
|
150
|
+
Accept: accept,
|
|
151
|
+
'User-Agent': USER_AGENT,
|
|
152
|
+
'X-GitHub-Api-Version': API_VERSION,
|
|
153
|
+
};
|
|
154
|
+
if (!token) return headers;
|
|
155
|
+
return { ...headers, Authorization: `Bearer ${token}` };
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
const headerValue = (headers, name) => {
|
|
159
|
+
if (!headers) return '';
|
|
160
|
+
if (typeof headers.get === 'function') {
|
|
161
|
+
return headers.get(name) || '';
|
|
162
|
+
}
|
|
163
|
+
const wanted = name.toLowerCase();
|
|
164
|
+
for (const key of Object.keys(headers)) {
|
|
165
|
+
const value = headers[key];
|
|
166
|
+
if (key.toLowerCase() === wanted) return `${value}`;
|
|
167
|
+
}
|
|
168
|
+
return '';
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
const parseLinkNext = (headers) => {
|
|
172
|
+
const link = headerValue(headers, 'link');
|
|
173
|
+
if (!link) return '';
|
|
174
|
+
for (const part of link.split(',')) {
|
|
175
|
+
const match = /<([^>]+)>\s*;\s*rel="?next"?/i.exec(part.trim());
|
|
176
|
+
if (match) return match[1];
|
|
177
|
+
}
|
|
178
|
+
return '';
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
const parseGithubJson = (text, label) => {
|
|
182
|
+
let data;
|
|
183
|
+
try {
|
|
184
|
+
data = JSON.parse(text);
|
|
185
|
+
} catch {
|
|
186
|
+
throw githubError('GITHUB_PARSE', `GitHub API: invalid ${label}`);
|
|
187
|
+
}
|
|
188
|
+
return data;
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
const requestGithub = async (url, options) => {
|
|
192
|
+
const fetchImpl = options.fetch;
|
|
193
|
+
if (typeof fetchImpl !== 'function') {
|
|
194
|
+
throw githubError('GITHUB_FETCH', 'fetch is not available');
|
|
195
|
+
}
|
|
196
|
+
const token = options.token || '';
|
|
197
|
+
const headers = { ...githubHeaders(token, options.accept) };
|
|
198
|
+
if (options.contentType) headers['Content-Type'] = options.contentType;
|
|
199
|
+
const retryOpts = options.retry ?? {};
|
|
200
|
+
const method = options.method || 'GET';
|
|
201
|
+
const body = options.body;
|
|
202
|
+
return retry(async () => {
|
|
203
|
+
let response;
|
|
204
|
+
try {
|
|
205
|
+
const init = { method, headers };
|
|
206
|
+
if (body !== undefined) init.body = body;
|
|
207
|
+
response = await fetchImpl(url, init);
|
|
208
|
+
} catch (error) {
|
|
209
|
+
throw wrapNetworkError(error);
|
|
210
|
+
}
|
|
211
|
+
const text = await response.text();
|
|
212
|
+
if (!response.ok) throw githubHttpError(response.status, text, token);
|
|
213
|
+
return { body: text, headers: response.headers };
|
|
214
|
+
}, retryOpts);
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
const requestGithubList = async (url, options) => {
|
|
218
|
+
const items = [];
|
|
219
|
+
let next = url;
|
|
220
|
+
while (next) {
|
|
221
|
+
const page = await requestGithub(next, {
|
|
222
|
+
...options,
|
|
223
|
+
accept: JSON_ACCEPT,
|
|
224
|
+
});
|
|
225
|
+
const data = parseGithubJson(page.body, 'list payload');
|
|
226
|
+
if (!Array.isArray(data)) {
|
|
227
|
+
throw githubError('GITHUB_PARSE', 'GitHub API: invalid list payload');
|
|
228
|
+
}
|
|
229
|
+
for (const entry of data) items.push(entry);
|
|
230
|
+
next = parseLinkNext(page.headers);
|
|
231
|
+
}
|
|
232
|
+
return items;
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
const requestGraphql = async (query, variables, options) => {
|
|
236
|
+
const payload = JSON.stringify({ query, variables });
|
|
237
|
+
const page = await requestGithub(GRAPHQL_URL, {
|
|
238
|
+
...options,
|
|
239
|
+
method: 'POST',
|
|
240
|
+
accept: JSON_ACCEPT,
|
|
241
|
+
contentType: 'application/json',
|
|
242
|
+
body: payload,
|
|
243
|
+
});
|
|
244
|
+
const data = parseGithubJson(page.body, 'GraphQL payload');
|
|
245
|
+
if (!isRecord(data)) {
|
|
246
|
+
throw githubError('GITHUB_PARSE', 'GitHub API: invalid GraphQL payload');
|
|
247
|
+
}
|
|
248
|
+
return data;
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
const changePath = (file) => file.newPath || file.oldPath || '';
|
|
252
|
+
|
|
253
|
+
const filterChangeFiles = (files, paths) => {
|
|
254
|
+
if (!paths.length) return files;
|
|
255
|
+
const kept = [];
|
|
256
|
+
for (const file of files) {
|
|
257
|
+
if (isPathInScope(changePath(file), paths)) kept.push(file);
|
|
258
|
+
}
|
|
259
|
+
return kept;
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
const prApiUrl = (pr) => {
|
|
263
|
+
const owner = encodeURIComponent(pr.owner);
|
|
264
|
+
const repo = encodeURIComponent(pr.repo);
|
|
265
|
+
return `${GITHUB_API}/repos/${owner}/${repo}/pulls/${pr.number}`;
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
const listUrl = (base) => {
|
|
269
|
+
const url = new URL(base);
|
|
270
|
+
url.searchParams.set('per_page', `${LIST_PER_PAGE}`);
|
|
271
|
+
return url.toString();
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
const discussionUrls = (pr) => {
|
|
275
|
+
const pull = prApiUrl(pr);
|
|
276
|
+
const issue = pull.replace('/pulls/', '/issues/');
|
|
277
|
+
return {
|
|
278
|
+
reviewComments: listUrl(`${pull}/comments`),
|
|
279
|
+
reviews: listUrl(`${pull}/reviews`),
|
|
280
|
+
issueComments: listUrl(`${issue}/comments`),
|
|
281
|
+
};
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
const readLogin = (user) => {
|
|
285
|
+
if (!isRecord(user)) return '';
|
|
286
|
+
return user.login || '';
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
const readRef = (side) => {
|
|
290
|
+
if (!isRecord(side)) return '';
|
|
291
|
+
return side.ref || '';
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
const readRepoName = (data, pr) => {
|
|
295
|
+
const base = isRecord(data.base) ? data.base : null;
|
|
296
|
+
const repo = base && isRecord(base.repo) ? base.repo : null;
|
|
297
|
+
const fromBase = repo && repo['full_name'];
|
|
298
|
+
if (fromBase) return fromBase;
|
|
299
|
+
return `${pr.owner}/${pr.repo}`;
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
const pushChangeFile = (files, seen, rel) => {
|
|
303
|
+
if (!rel || seen.has(rel)) return;
|
|
304
|
+
seen.add(rel);
|
|
305
|
+
files.push(rel);
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
const changeFiles = (items) => {
|
|
309
|
+
const files = [];
|
|
310
|
+
const seen = new Set();
|
|
311
|
+
for (const item of items) {
|
|
312
|
+
if (item.dep) {
|
|
313
|
+
const depFiles = item.dep.files ?? [];
|
|
314
|
+
for (const rel of depFiles) pushChangeFile(files, seen, rel);
|
|
315
|
+
continue;
|
|
316
|
+
}
|
|
317
|
+
const rel = itemPath(item);
|
|
318
|
+
pushChangeFile(files, seen, rel);
|
|
319
|
+
}
|
|
320
|
+
return files;
|
|
321
|
+
};
|
|
322
|
+
|
|
323
|
+
const parsePullRequestPayload = (text) => {
|
|
324
|
+
const data = parseGithubJson(text, 'pull request payload');
|
|
325
|
+
if (!isRecord(data)) {
|
|
326
|
+
throw githubError(
|
|
327
|
+
'GITHUB_PARSE',
|
|
328
|
+
'GitHub API: invalid pull request payload',
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
return data;
|
|
332
|
+
};
|
|
333
|
+
|
|
334
|
+
const toChange = (pr, data, items) => {
|
|
335
|
+
const number = data.number || pr.number;
|
|
336
|
+
const repository = readRepoName(data, pr);
|
|
337
|
+
const title = data.title || '';
|
|
338
|
+
const author = readLogin(data.user);
|
|
339
|
+
const files = changeFiles(items);
|
|
340
|
+
const base = readRef(data.base);
|
|
341
|
+
const head = readRef(data.head);
|
|
342
|
+
const fallbackUrl = `https://github.com/${repository}/pull/${number}`;
|
|
343
|
+
const url = data['html_url'] || fallbackUrl;
|
|
344
|
+
const id = `github.com/${repository}/pull/${number}`;
|
|
345
|
+
return {
|
|
346
|
+
id,
|
|
347
|
+
source: 'github-pr',
|
|
348
|
+
title,
|
|
349
|
+
author,
|
|
350
|
+
files,
|
|
351
|
+
status: 'reviewing',
|
|
352
|
+
base,
|
|
353
|
+
head,
|
|
354
|
+
url,
|
|
355
|
+
repository,
|
|
356
|
+
number,
|
|
357
|
+
};
|
|
358
|
+
};
|
|
359
|
+
|
|
360
|
+
const readCommentBody = (value) => `${value ?? ''}`.trim();
|
|
361
|
+
|
|
362
|
+
const reviewerOf = (record) => {
|
|
363
|
+
const login = readLogin(record && record.user);
|
|
364
|
+
return login || 'unknown';
|
|
365
|
+
};
|
|
366
|
+
|
|
367
|
+
const formatImportedText = (entry) => {
|
|
368
|
+
const reviewer = entry.reviewer || 'unknown';
|
|
369
|
+
const body = `${entry.body ?? ''}`.trim();
|
|
370
|
+
return `@${reviewer} review at github: ${body}`;
|
|
371
|
+
};
|
|
372
|
+
|
|
373
|
+
const commentLine = (comment) => {
|
|
374
|
+
const side = comment.side === 'LEFT' ? 'LEFT' : 'RIGHT';
|
|
375
|
+
const raw = comment.line ?? comment['original_line'];
|
|
376
|
+
const line = Number(raw);
|
|
377
|
+
if (!Number.isFinite(line) || line < 1) return { side, line: 0 };
|
|
378
|
+
return { side, line };
|
|
379
|
+
};
|
|
380
|
+
|
|
381
|
+
const sideLineNumber = (type, oldNo, newNo, side) => {
|
|
382
|
+
if (side === 'LEFT') {
|
|
383
|
+
if (type === 'add') return 0;
|
|
384
|
+
return oldNo;
|
|
385
|
+
}
|
|
386
|
+
if (type === 'del') return 0;
|
|
387
|
+
return newNo;
|
|
388
|
+
};
|
|
389
|
+
|
|
390
|
+
const locateLineInItem = (item, rel, line, side) => {
|
|
391
|
+
const path = itemPath(item);
|
|
392
|
+
if (path !== rel) return null;
|
|
393
|
+
const hunk = item.hunk;
|
|
394
|
+
if (!hunk || !line) return null;
|
|
395
|
+
let oldNo = hunk.oldStart;
|
|
396
|
+
let newNo = hunk.newStart;
|
|
397
|
+
for (const entry of hunk.lines) {
|
|
398
|
+
const at = sideLineNumber(entry.type, oldNo, newNo, side);
|
|
399
|
+
if (at === line) {
|
|
400
|
+
const inBlock = entry.blockId === item.blockId;
|
|
401
|
+
return { inBlock };
|
|
402
|
+
}
|
|
403
|
+
if (isCtxType(entry.type) || entry.type === 'del') {
|
|
404
|
+
oldNo += 1;
|
|
405
|
+
}
|
|
406
|
+
if (isCtxType(entry.type) || entry.type === 'add') {
|
|
407
|
+
newNo += 1;
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
return null;
|
|
411
|
+
};
|
|
412
|
+
|
|
413
|
+
const findItemForLine = (items, rel, line, side) => {
|
|
414
|
+
let fallback = null;
|
|
415
|
+
for (const item of items) {
|
|
416
|
+
const hit = locateLineInItem(item, rel, line, side);
|
|
417
|
+
if (!hit) continue;
|
|
418
|
+
if (hit.inBlock) return item;
|
|
419
|
+
if (!fallback) fallback = item;
|
|
420
|
+
}
|
|
421
|
+
return fallback;
|
|
422
|
+
};
|
|
423
|
+
|
|
424
|
+
const resolvedOf = (resolvedById, id) => {
|
|
425
|
+
const commentId = Number(id);
|
|
426
|
+
if (!Number.isFinite(commentId) || !resolvedById.has(commentId)) {
|
|
427
|
+
return null;
|
|
428
|
+
}
|
|
429
|
+
return resolvedById.get(commentId);
|
|
430
|
+
};
|
|
431
|
+
|
|
432
|
+
const toImportedTodo = (file, entry) => ({
|
|
433
|
+
kind: 'todo',
|
|
434
|
+
file,
|
|
435
|
+
text: formatImportedText(entry),
|
|
436
|
+
done: entry.resolved === true,
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
const reviewCommentToNote = (comment, items, resolvedById, paths) => {
|
|
440
|
+
if (!isRecord(comment)) return null;
|
|
441
|
+
const body = readCommentBody(comment.body);
|
|
442
|
+
if (!body) return null;
|
|
443
|
+
const path = comment.path || '';
|
|
444
|
+
if (path && !isPathInScope(path, paths)) return null;
|
|
445
|
+
const { side, line } = commentLine(comment);
|
|
446
|
+
const resolved = resolvedOf(resolvedById, comment.id);
|
|
447
|
+
const entry = {
|
|
448
|
+
reviewer: reviewerOf(comment),
|
|
449
|
+
body,
|
|
450
|
+
resolved,
|
|
451
|
+
};
|
|
452
|
+
const subject = comment['subject_type'] || 'line';
|
|
453
|
+
const isFile = subject === 'file' || !line;
|
|
454
|
+
if (!isFile && path) {
|
|
455
|
+
const item = findItemForLine(items, path, line, side);
|
|
456
|
+
if (item) {
|
|
457
|
+
const hunk = item.hunk;
|
|
458
|
+
return {
|
|
459
|
+
kind: 'feedback',
|
|
460
|
+
file: itemPath(item) || path,
|
|
461
|
+
oldStart: hunk ? hunk.oldStart : 0,
|
|
462
|
+
newStart: hunk ? hunk.newStart : 0,
|
|
463
|
+
blockId: item.blockId ?? 0,
|
|
464
|
+
origin: item.origin ?? 'pr',
|
|
465
|
+
header: hunk ? hunk.header : '',
|
|
466
|
+
text: formatImportedText(entry),
|
|
467
|
+
done: resolved === true,
|
|
468
|
+
};
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
if (path) return toImportedTodo(path, entry);
|
|
472
|
+
return toImportedTodo(PR_TODO_FILE, entry);
|
|
473
|
+
};
|
|
474
|
+
|
|
475
|
+
const summaryToTodo = (record) => {
|
|
476
|
+
if (!isRecord(record)) return null;
|
|
477
|
+
const body = readCommentBody(record.body);
|
|
478
|
+
if (!body) return null;
|
|
479
|
+
return toImportedTodo(PR_TODO_FILE, {
|
|
480
|
+
reviewer: reviewerOf(record),
|
|
481
|
+
body,
|
|
482
|
+
});
|
|
483
|
+
};
|
|
484
|
+
|
|
485
|
+
const discussionToNotes = (discussion, items, paths = []) => {
|
|
486
|
+
const feedback = [];
|
|
487
|
+
const todos = [];
|
|
488
|
+
const resolvedById = discussion.resolvedById ?? new Map();
|
|
489
|
+
const reviewComments = discussion.reviewComments ?? [];
|
|
490
|
+
for (const comment of reviewComments) {
|
|
491
|
+
const note = reviewCommentToNote(comment, items, resolvedById, paths);
|
|
492
|
+
if (!note) continue;
|
|
493
|
+
if (note.kind === 'feedback') feedback.push(note);
|
|
494
|
+
else todos.push(note);
|
|
495
|
+
}
|
|
496
|
+
const reviews = discussion.reviews ?? [];
|
|
497
|
+
for (const review of reviews) {
|
|
498
|
+
const todo = summaryToTodo(review);
|
|
499
|
+
if (todo) todos.push(todo);
|
|
500
|
+
}
|
|
501
|
+
const issueComments = discussion.issueComments ?? [];
|
|
502
|
+
for (const comment of issueComments) {
|
|
503
|
+
const todo = summaryToTodo(comment);
|
|
504
|
+
if (todo) todos.push(todo);
|
|
505
|
+
}
|
|
506
|
+
return { feedback, todos };
|
|
507
|
+
};
|
|
508
|
+
|
|
509
|
+
const loadReviewThreads = async (pr, options) => {
|
|
510
|
+
const threads = [];
|
|
511
|
+
let after = null;
|
|
512
|
+
for (;;) {
|
|
513
|
+
const payload = await requestGraphql(
|
|
514
|
+
REVIEW_THREADS_QUERY,
|
|
515
|
+
{
|
|
516
|
+
owner: pr.owner,
|
|
517
|
+
name: pr.repo,
|
|
518
|
+
number: pr.number,
|
|
519
|
+
after,
|
|
520
|
+
},
|
|
521
|
+
options,
|
|
522
|
+
);
|
|
523
|
+
const data = isRecord(payload.data) ? payload.data : null;
|
|
524
|
+
if (!data) return threads;
|
|
525
|
+
const repository = isRecord(data.repository) ? data.repository : {};
|
|
526
|
+
const pullRequest = isRecord(repository.pullRequest)
|
|
527
|
+
? repository.pullRequest
|
|
528
|
+
: {};
|
|
529
|
+
const reviewThreads = isRecord(pullRequest.reviewThreads)
|
|
530
|
+
? pullRequest.reviewThreads
|
|
531
|
+
: {};
|
|
532
|
+
const nodes = Array.isArray(reviewThreads.nodes) ? reviewThreads.nodes : [];
|
|
533
|
+
for (const thread of nodes) threads.push(thread);
|
|
534
|
+
const pageInfo = isRecord(reviewThreads.pageInfo)
|
|
535
|
+
? reviewThreads.pageInfo
|
|
536
|
+
: {};
|
|
537
|
+
if (!pageInfo.hasNextPage) return threads;
|
|
538
|
+
const cursor = pageInfo.endCursor || null;
|
|
539
|
+
if (!cursor || cursor === after) return threads;
|
|
540
|
+
after = cursor;
|
|
541
|
+
}
|
|
542
|
+
};
|
|
543
|
+
|
|
544
|
+
const threadComments = (thread) => {
|
|
545
|
+
if (!isRecord(thread)) return [];
|
|
546
|
+
const comments = thread.comments;
|
|
547
|
+
if (isRecord(comments) && Array.isArray(comments.nodes)) {
|
|
548
|
+
return comments.nodes;
|
|
549
|
+
}
|
|
550
|
+
return [];
|
|
551
|
+
};
|
|
552
|
+
|
|
553
|
+
const loadResolvedById = async (pr, options) => {
|
|
554
|
+
const resolved = new Map();
|
|
555
|
+
try {
|
|
556
|
+
const threads = await loadReviewThreads(pr, options);
|
|
557
|
+
for (const thread of threads) {
|
|
558
|
+
const isResolved = thread.isResolved === true;
|
|
559
|
+
for (const comment of threadComments(thread)) {
|
|
560
|
+
if (!isRecord(comment)) continue;
|
|
561
|
+
const id = Number(comment.databaseId);
|
|
562
|
+
if (!Number.isFinite(id)) continue;
|
|
563
|
+
resolved.set(id, isResolved);
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
} catch {
|
|
567
|
+
// ignore: resolved state is optional
|
|
568
|
+
}
|
|
569
|
+
return resolved;
|
|
570
|
+
};
|
|
571
|
+
|
|
572
|
+
const loadDiscussion = async (pr, options) => {
|
|
573
|
+
const urls = discussionUrls(pr);
|
|
574
|
+
const reviewComments = await requestGithubList(urls.reviewComments, options);
|
|
575
|
+
const reviews = await requestGithubList(urls.reviews, options);
|
|
576
|
+
const issueComments = await requestGithubList(urls.issueComments, options);
|
|
577
|
+
const resolvedById = await loadResolvedById(pr, options);
|
|
578
|
+
return { reviewComments, reviews, issueComments, resolvedById };
|
|
579
|
+
};
|
|
580
|
+
|
|
581
|
+
const loadImportedNotes = async (pr, items, paths, options) => {
|
|
582
|
+
try {
|
|
583
|
+
const discussion = await loadDiscussion(pr, options);
|
|
584
|
+
return discussionToNotes(discussion, items, paths);
|
|
585
|
+
} catch {
|
|
586
|
+
// ignore: discussion import is optional
|
|
587
|
+
return { feedback: [], todos: [] };
|
|
588
|
+
}
|
|
589
|
+
};
|
|
590
|
+
|
|
591
|
+
const loadPullRequest = async (pr, options = {}) => {
|
|
592
|
+
const token = options.token ?? '';
|
|
593
|
+
const fetchImpl = options.fetch ?? globalThis.fetch;
|
|
594
|
+
const paths = options.paths ?? [];
|
|
595
|
+
const retryOpts = options.retry ?? {};
|
|
596
|
+
const url = prApiUrl(pr);
|
|
597
|
+
const request = { fetch: fetchImpl, token, retry: retryOpts };
|
|
598
|
+
const jsonPage = await requestGithub(url, {
|
|
599
|
+
...request,
|
|
600
|
+
accept: JSON_ACCEPT,
|
|
601
|
+
});
|
|
602
|
+
const data = parsePullRequestPayload(jsonPage.body);
|
|
603
|
+
const diffPage = await requestGithub(url, {
|
|
604
|
+
...request,
|
|
605
|
+
accept: DIFF_ACCEPT,
|
|
606
|
+
});
|
|
607
|
+
const parsed = parseDiff(diffPage.body);
|
|
608
|
+
const files = filterChangeFiles(parsed, paths);
|
|
609
|
+
const parsedItems = itemsFromFiles(files, 'pr');
|
|
610
|
+
const items = foldDepItems(parsedItems);
|
|
611
|
+
const change = toChange(pr, data, items);
|
|
612
|
+
const sourceLabel = `#${change.number}`;
|
|
613
|
+
const imported = await loadImportedNotes(pr, items, paths, request);
|
|
614
|
+
return { top: options.cwd, items, sourceLabel, change, imported };
|
|
615
|
+
};
|
|
616
|
+
|
|
617
|
+
module.exports = {
|
|
618
|
+
parseGithubPrUrl,
|
|
619
|
+
githubToken,
|
|
620
|
+
filterChangeFiles,
|
|
621
|
+
prApiUrl,
|
|
622
|
+
formatImportedText,
|
|
623
|
+
discussionToNotes,
|
|
624
|
+
loadPullRequest,
|
|
625
|
+
};
|