reslop 0.1.3 → 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 CHANGED
@@ -2,6 +2,14 @@
2
2
 
3
3
  ## [Unreleased][unreleased]
4
4
 
5
+ ## [0.1.4][] - 2026-09-13
6
+
7
+ - Support a GitLab MR by URL and import review comments
8
+ - Support Windows: npm.cmd, clip, terminal resize, installer
9
+ - Added `-r` read-only mode that blocks stage, unstage, revert, npm
10
+ - Fixed `npm test` so it finds tests on Windows, macOS, and Node 18
11
+ - Added Testing CI on Linux, macOS, and Windows
12
+
5
13
  ## [0.1.3][] - 2026-09-12
6
14
 
7
15
  - Showed added/removed and staged/total counts in the file list
@@ -33,7 +41,8 @@
33
41
  - Feedback and todos that produce a repair plan for an agent
34
42
  - Intra-line highlighting in unified, mixed, and side-by-side layouts
35
43
 
36
- [unreleased]: https://github.com/tshemsedinov/reslop/compare/v0.1.3...HEAD
44
+ [unreleased]: https://github.com/tshemsedinov/reslop/compare/v0.1.4...HEAD
45
+ [0.1.4]: https://github.com/tshemsedinov/reslop/compare/v0.1.3...v0.1.4
37
46
  [0.1.3]: https://github.com/tshemsedinov/reslop/compare/v0.1.2...v0.1.3
38
47
  [0.1.2]: https://github.com/tshemsedinov/reslop/compare/v0.1.1...v0.1.2
39
48
  [0.1.1]: https://github.com/tshemsedinov/reslop/compare/v0.1.0...v0.1.1
package/README.md CHANGED
@@ -1,4 +1,11 @@
1
- # reslop — Review diff and plan changes
1
+ # 👁️ reslop — Review diff and plan changes
2
+
3
+ [![ci status](https://github.com/tshemsedinov/reslop/workflows/Testing%20CI/badge.svg)](https://github.com/tshemsedinov/reslop/actions?query=workflow%3A%22Testing+CI%22+branch%3Amain)
4
+ [![snyk](https://snyk.io/test/github/tshemsedinov/reslop/badge.svg)](https://snyk.io/test/github/tshemsedinov/reslop)
5
+ [![npm version](https://badge.fury.io/js/reslop.svg)](https://badge.fury.io/js/reslop)
6
+ [![npm downloads/month](https://img.shields.io/npm/dm/reslop.svg)](https://www.npmjs.com/package/reslop)
7
+ [![npm downloads](https://img.shields.io/npm/dt/reslop.svg)](https://www.npmjs.com/package/reslop)
8
+ [![license](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/tshemsedinov/reslop/blob/main/LICENSE)
2
9
 
3
10
  > Turn generated changes into owned changes
4
11
 
@@ -9,35 +16,29 @@ for the agent.
9
16
  Review → Plan → Repair → Verify
10
17
  ```
11
18
 
12
- - Review uncommitted git diffs, a given commit, or a GitHub pull request.
19
+ - Review uncommitted git diffs, a given commit, a GitHub PR, or a GitLab MR.
13
20
  - Stage, unstage, or revert each contiguous block of diff lines.
14
21
  - Leave feedback and todos that become a repair plan for the agent.
15
- - Import GitHub pull request review comments into the local plan for AI.
22
+ - Import GitHub PR and GitLab MR review comments into the local plan for AI.
16
23
  - Review each npm dependency once across `package.json` and the lockfile.
17
24
  - Propose unused removals, npm audit fixes, and outdated updates as diffs.
18
25
 
19
- Commit and pull request review is read-only. Applying a dependency
20
- proposal stages the files and runs `npm i` or `npm uninstall`.
21
-
22
- Intra-line highlighting paints the words that actually changed in a
23
- stronger red/green. A close edit inside one word still marks only
24
- those characters.
25
-
26
26
  ## Install
27
27
 
28
28
  ```bash
29
29
  npm i -g reslop
30
30
  ```
31
31
 
32
+ Works on Linux, macOS, and Windows. On Windows use Windows Terminal.
33
+
32
34
  ## Usage
33
35
 
34
- ```bash
35
- reslop
36
- reslop -n
37
- reslop path/file
38
- reslop 7ac260c
39
- reslop https://github.com/metarhia/metacom/pull/555
40
- ```
36
+ - `reslop` uncommitted diffs in this repository
37
+ - `reslop path/file` show only that path or file
38
+ - `reslop 7ac260c` show that commit (read-only)
39
+ - `reslop https://github.com/metarhia/metacom/pull/555` GitHub/GitLab PR/MR
40
+ - `reslop -n` start a new review even if the latest is still editing
41
+ - `reslop -r` read-only: no stage, unstage, revert, or npm apply
41
42
 
42
43
  Reviews go in `.review/YYYY-MM-DD-NN.md` with frontmatter `status`:
43
44
 
@@ -46,7 +47,7 @@ Reviews go in `.review/YYYY-MM-DD-NN.md` with frontmatter `status`:
46
47
  - `partial`: AI started; some items remain
47
48
  - `done`: all items marked `[x]`
48
49
 
49
- ## Keys and buttons
50
+ ## Hotkeys
50
51
 
51
52
  | Key | Action |
52
53
  | ------------------- | ---------------------------------------- |
package/lib/api.js CHANGED
@@ -8,6 +8,7 @@ const render = require('./render.js');
8
8
  const highlight = require('./highlight.js');
9
9
  const detect = require('./detect.js');
10
10
  const github = require('./github.js');
11
+ const gitlab = require('./gitlab.js');
11
12
  const source = require('./source.js');
12
13
  const { Session } = require('./session.js');
13
14
  const cli = require('./cli.js');
@@ -25,6 +26,8 @@ module.exports = {
25
26
  createGitRepo: git.createGitRepo,
26
27
  parseGithubPrUrl: github.parseGithubPrUrl,
27
28
  loadPullRequest: github.loadPullRequest,
29
+ parseGitlabMrUrl: gitlab.parseGitlabMrUrl,
30
+ loadMergeRequest: gitlab.loadMergeRequest,
28
31
  selectChangeSource: source.selectChangeSource,
29
32
  decodeChunk: keys.decodeChunk,
30
33
  hitAction: keys.hitAction,
package/lib/cli.js CHANGED
@@ -5,6 +5,7 @@ const path = require('node:path');
5
5
 
6
6
  const { createGitRepo } = require('./git.js');
7
7
  const { githubToken, loadPullRequest } = require('./github.js');
8
+ const { gitlabToken, loadMergeRequest } = require('./gitlab.js');
8
9
  const { selectChangeSource, createLoadedSource } = require('./source.js');
9
10
  const { Session } = require('./session.js');
10
11
 
@@ -12,7 +13,22 @@ const HEX_REV = /^[0-9a-f]{4,40}$/i;
12
13
  const HEAD_REV = /^HEAD([~^].*)?$/i;
13
14
  const RANGE_REV = /[~^]/;
14
15
 
15
- const USAGE = 'Usage: reslop [-n] [path | commit | pr-url]';
16
+ const USAGE = 'Usage: reslop [-n] [-r] [path | commit | pr-url | mr-url]';
17
+
18
+ const REMOTE_CHANGE = {
19
+ 'github-pr': {
20
+ load: 'loadPullRequest',
21
+ fallback: loadPullRequest,
22
+ token: githubToken,
23
+ target: (selected) => selected.pr,
24
+ },
25
+ 'gitlab-mr': {
26
+ load: 'loadMergeRequest',
27
+ fallback: loadMergeRequest,
28
+ token: gitlabToken,
29
+ target: (selected) => selected.mr,
30
+ },
31
+ };
16
32
 
17
33
  const errorMessage = (reason) => {
18
34
  if (reason instanceof Error) return reason.message;
@@ -37,17 +53,22 @@ const failUsage = (stderr, reason) => {
37
53
  const parseArgv = (argv) => {
38
54
  const paths = [];
39
55
  let newReview = false;
56
+ let readOnly = false;
40
57
  for (const arg of argv) {
41
58
  if (arg === '-n') {
42
59
  newReview = true;
43
60
  continue;
44
61
  }
62
+ if (arg === '-r') {
63
+ readOnly = true;
64
+ continue;
65
+ }
45
66
  if (arg.startsWith('-')) {
46
67
  throw new Error(`unknown option ${arg}`);
47
68
  }
48
69
  paths.push(arg);
49
70
  }
50
- return { paths, newReview };
71
+ return { paths, newReview, readOnly };
51
72
  };
52
73
 
53
74
  const looksLikeRev = (spec) => {
@@ -95,6 +116,7 @@ const sessionOptions = (proc, deps, args, env, extra) => ({
95
116
  getSize: deps.getSize,
96
117
  startPane: 'files',
97
118
  newReview: args.newReview,
119
+ readOnly: args.readOnly,
98
120
  audit: true,
99
121
  ...extra,
100
122
  });
@@ -121,11 +143,12 @@ const openLocalSession = (proc, deps, args, selected, env) => {
121
143
  return session;
122
144
  };
123
145
 
124
- const openPullRequestSession = async (proc, deps, args, selected, env) => {
146
+ const openRemoteSession = async (proc, deps, args, selected, env) => {
147
+ const remote = REMOTE_CHANGE[selected.kind];
125
148
  const cwd = deps.cwd ?? proc.cwd();
126
- const loader = deps.loadPullRequest ?? loadPullRequest;
127
- const token = githubToken(env);
128
- const loaded = await loader(selected.pr, {
149
+ const loader = deps[remote.load] ?? remote.fallback;
150
+ const token = remote.token(env);
151
+ const loaded = await loader(remote.target(selected), {
129
152
  cwd,
130
153
  paths: selected.paths,
131
154
  token,
@@ -149,8 +172,8 @@ const openPullRequestSession = async (proc, deps, args, selected, env) => {
149
172
  const loadSession = async (proc, deps, args) => {
150
173
  const env = proc.env ?? {};
151
174
  const selected = selectChangeSource(args.paths);
152
- if (selected.kind === 'github-pr') {
153
- return openPullRequestSession(proc, deps, args, selected, env);
175
+ if (REMOTE_CHANGE[selected.kind]) {
176
+ return openRemoteSession(proc, deps, args, selected, env);
154
177
  }
155
178
  return openLocalSession(proc, deps, args, selected, env);
156
179
  };
package/lib/clipboard.js CHANGED
@@ -3,13 +3,9 @@
3
3
  const { spawnSync } = require('node:child_process');
4
4
 
5
5
  const { ESC } = require('./ansi.js');
6
+ const { clipTools, spawnBase } = require('./sys.js');
6
7
 
7
8
  const CLIP_TIMEOUT_MS = 1000;
8
- const CLIP_TOOLS = [
9
- { cmd: 'wl-copy', args: [] },
10
- { cmd: 'xclip', args: ['-selection', 'clipboard'] },
11
- { cmd: 'xsel', args: ['--clipboard', '--input'] },
12
- ];
13
9
 
14
10
  const osc52 = (text) => {
15
11
  const b64 = Buffer.from(text, 'utf8').toString('base64');
@@ -17,11 +13,15 @@ const osc52 = (text) => {
17
13
  };
18
14
 
19
15
  const runClip = (cmd, args, text) => {
20
- const result = spawnSync(cmd, args, {
21
- input: text,
22
- encoding: 'utf8',
23
- timeout: CLIP_TIMEOUT_MS,
24
- });
16
+ const result = spawnSync(
17
+ cmd,
18
+ args,
19
+ spawnBase({
20
+ input: text,
21
+ encoding: 'utf8',
22
+ timeout: CLIP_TIMEOUT_MS,
23
+ }),
24
+ );
25
25
  if (result.error) return false;
26
26
  return result.status === 0;
27
27
  };
@@ -33,7 +33,7 @@ const copyText = (text, stdout) => {
33
33
  stdout.write(osc52(text));
34
34
  ok = true;
35
35
  }
36
- for (const tool of CLIP_TOOLS) {
36
+ for (const tool of clipTools()) {
37
37
  if (runClip(tool.cmd, tool.args, text)) return true;
38
38
  }
39
39
  return ok;
package/lib/git.js CHANGED
@@ -7,12 +7,18 @@ const path = require('node:path');
7
7
  const diff = require('./diff.js');
8
8
  const { parseDiff, synthesizeNewFile, itemsFromFiles } = diff;
9
9
  const { itemPath, isPathInScope, REVIEW_DIR } = require('./files.js');
10
+ const { npmBin, spawnBase } = require('./sys.js');
10
11
  const deps = require('./deps.js');
11
12
  const { foldDepItems, mergeDepFile, depFileMeta } = deps;
12
13
  const { collectUsedNames, parseAuditReport, parseOutdatedReport } = deps;
13
14
  const { mergeProposedItems, MANIFEST, LOCKFILE } = deps;
14
15
 
15
- const GIT_CONFIG = ['-c', 'core.quotepath=false'];
16
+ const GIT_CONFIG = [
17
+ '-c',
18
+ 'core.quotepath=false',
19
+ '-c',
20
+ 'i18n.logOutputEncoding=utf-8',
21
+ ];
16
22
  const DIFF_OPTS = ['--no-color', '--no-ext-diff', '--no-renames', '-U3'];
17
23
  const GIT_ENCODING = 'utf8';
18
24
  const GIT_MAX_BUFFER = 32 * 1024 * 1024;
@@ -71,7 +77,7 @@ const runProc = (cmd, args, options = {}) =>
71
77
  let stderr = '';
72
78
  let settled = false;
73
79
  let timer = null;
74
- const child = spawn(cmd, args, { cwd, env });
80
+ const child = spawn(cmd, args, spawnBase({ cwd, env }));
75
81
  const finish = (result) => {
76
82
  if (settled) return;
77
83
  settled = true;
@@ -135,7 +141,7 @@ const runGit = (args, cwd, input) => {
135
141
  const env = { ...process.env, GIT_OPTIONAL_LOCKS: '0' };
136
142
  const encoding = GIT_ENCODING;
137
143
  const maxBuffer = GIT_MAX_BUFFER;
138
- const options = { cwd, encoding, input, maxBuffer, env };
144
+ const options = spawnBase({ cwd, encoding, input, maxBuffer, env });
139
145
  const result = spawnSync('git', args, options);
140
146
  return gitResult(result);
141
147
  };
@@ -275,18 +281,22 @@ const readDepSides = (top, rev, origin, rel) => {
275
281
  };
276
282
 
277
283
  const runNpmJson = (top, args, timeout) => {
278
- const result = spawnSync('npm', args, {
279
- cwd: top,
280
- encoding: GIT_ENCODING,
281
- maxBuffer: GIT_MAX_BUFFER,
282
- timeout,
283
- });
284
+ const result = spawnSync(
285
+ npmBin(),
286
+ args,
287
+ spawnBase({
288
+ cwd: top,
289
+ encoding: GIT_ENCODING,
290
+ maxBuffer: GIT_MAX_BUFFER,
291
+ timeout,
292
+ }),
293
+ );
284
294
  if (result.error) return '';
285
295
  return result.stdout || '';
286
296
  };
287
297
 
288
298
  const runNpmJsonAsync = async (top, args, timeout, signal) => {
289
- const result = await runProc('npm', args, {
299
+ const result = await runProc(npmBin(), args, {
290
300
  cwd: top,
291
301
  encoding: GIT_ENCODING,
292
302
  maxBuffer: GIT_MAX_BUFFER,
@@ -443,12 +453,16 @@ const applyDepWorktree = (top, rel, change, origin) => {
443
453
  };
444
454
 
445
455
  const runNpm = (cwd, args) =>
446
- spawnSync('npm', args, {
447
- cwd,
448
- encoding: GIT_ENCODING,
449
- maxBuffer: GIT_MAX_BUFFER,
450
- timeout: INSTALL_MS,
451
- });
456
+ spawnSync(
457
+ npmBin(),
458
+ args,
459
+ spawnBase({
460
+ cwd,
461
+ encoding: GIT_ENCODING,
462
+ maxBuffer: GIT_MAX_BUFFER,
463
+ timeout: INSTALL_MS,
464
+ }),
465
+ );
452
466
 
453
467
  const firstDepRel = (files, kind) => {
454
468
  for (const rel of files) {
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/render.js CHANGED
@@ -514,12 +514,15 @@ const paintButtons = (layout, width, color) => {
514
514
  return out;
515
515
  };
516
516
 
517
+ const remoteCounts = (kind, { counts, notes, sourceLabel }) => {
518
+ const n = counts.pr ?? 0;
519
+ const label = sourceLabel || kind;
520
+ return ` ${kind} ${label} ${n} ${notes}`;
521
+ };
522
+
517
523
  const COUNTS_KIND = {
518
- pr: ({ counts, notes, sourceLabel }) => {
519
- const n = counts.pr ?? 0;
520
- const label = sourceLabel || 'pr';
521
- return ` pr ${label} ${n} ${notes}`;
522
- },
524
+ pr: (args) => remoteCounts('pr', args),
525
+ mr: (args) => remoteCounts('mr', args),
523
526
  commit: ({ counts, notes, revShort }) => {
524
527
  const n = counts.commit ?? 0;
525
528
  return ` commit ${revShort} ${n} ${notes}`;
@@ -535,7 +538,7 @@ const COUNTS_KIND = {
535
538
  };
536
539
 
537
540
  const countsKind = (sourceKind, revShort) => {
538
- if (sourceKind === 'pr') return 'pr';
541
+ if (sourceKind === 'pr' || sourceKind === 'mr') return sourceKind;
539
542
  if (revShort) return 'commit';
540
543
  return 'worktree';
541
544
  };
package/lib/session.js CHANGED
@@ -9,6 +9,7 @@ const review = require('./review.js');
9
9
  const wrap = require('./wrap.js');
10
10
  const { extractText, isRange } = require('./select.js');
11
11
  const { copyText } = require('./clipboard.js');
12
+ const { watchResize } = require('./sys.js');
12
13
  const { fileEntries, itemPath } = require('./files.js');
13
14
  const { Editor } = require('./editor.js');
14
15
  const { refreshIndexPatches } = require('./diff.js');
@@ -29,6 +30,10 @@ const { prefixTemplates, TEMPLATE_SHOW, rememberTemplate } = review;
29
30
 
30
31
  const LAYOUTS = ['unified', 'mixed', 'side'];
31
32
  const READ_ONLY_ORIGINS = ['commit', 'pr'];
33
+ const SOURCE_KIND = {
34
+ 'github-pr': 'pr',
35
+ 'gitlab-mr': 'mr',
36
+ };
32
37
  const ENTER_TERM = '\x1b[?1049h\x1b[?25l\x1b[?7l\x1b[?1002h\x1b[?1006h';
33
38
  const LEAVE_TERM =
34
39
  '\x1b[?1006l\x1b[?1002l\x1b[?7h\x1b[0 q\x1b[?12l\x1b[?25h\x1b[?1049l';
@@ -197,6 +202,7 @@ class Session {
197
202
  this.mkdirSync = options.mkdirSync ?? fs.mkdirSync;
198
203
  this.now = options.now ?? systemNow;
199
204
  this.forceNewReview = options.newReview === true;
205
+ this.readOnly = options.readOnly === true;
200
206
  this.audit = options.audit === true;
201
207
  this.outdatedMap = options.outdatedMap;
202
208
  this.auditMap = options.auditMap;
@@ -538,7 +544,7 @@ class Session {
538
544
  view() {
539
545
  const templates = this.shownTemplates();
540
546
  const change = this.change;
541
- const isGithubPr = change && change.source === 'github-pr';
547
+ const sourceKind = (change && SOURCE_KIND[change.source]) || '';
542
548
  const repoName = this.repoName || path.basename(this.top || this.cwd || '');
543
549
  return {
544
550
  item: this.current(),
@@ -556,7 +562,7 @@ class Session {
556
562
  repoName,
557
563
  revShort: this.revShort || '',
558
564
  sourceLabel: this.sourceLabel || '',
559
- sourceKind: isGithubPr ? 'pr' : '',
565
+ sourceKind,
560
566
  mode: this.mode,
561
567
  compose: this.composeView(),
562
568
  noteText: this.idleNoteText(),
@@ -692,16 +698,20 @@ class Session {
692
698
  return items;
693
699
  }
694
700
 
695
- guardFileGit(items) {
696
- if (!items.length) return false;
697
- const first = items[0];
698
- if (isReadOnlyOrigin(first.origin)) {
701
+ guardWritable(item) {
702
+ if (!item) return false;
703
+ if (this.readOnly || isReadOnlyOrigin(item.origin)) {
699
704
  this.status = 'read only';
700
705
  return false;
701
706
  }
702
707
  return true;
703
708
  }
704
709
 
710
+ guardFileGit(items) {
711
+ if (!items.length) return false;
712
+ return this.guardWritable(items[0]);
713
+ }
714
+
705
715
  applyFileGit(items, label, write, afterItem) {
706
716
  try {
707
717
  for (const item of items) {
@@ -797,11 +807,7 @@ class Session {
797
807
  onAdd() {
798
808
  if (this.pane === 'files') return void this.onFileAdd();
799
809
  const item = this.activeDiffItem();
800
- if (!item) return;
801
- if (isReadOnlyOrigin(item.origin)) {
802
- this.status = 'read only';
803
- return;
804
- }
810
+ if (!this.guardWritable(item)) return;
805
811
  if (item.origin === 'staged') {
806
812
  this.status = 'already staged';
807
813
  return;
@@ -823,11 +829,7 @@ class Session {
823
829
  onUnstage() {
824
830
  if (this.pane === 'files') return void this.onFileUnstage();
825
831
  const item = this.activeDiffItem();
826
- if (!item) return;
827
- if (isReadOnlyOrigin(item.origin)) {
828
- this.status = 'read only';
829
- return;
830
- }
832
+ if (!this.guardWritable(item)) return;
831
833
  if (item.origin !== 'staged') {
832
834
  this.status = 'not staged';
833
835
  return;
@@ -844,11 +846,7 @@ class Session {
844
846
  onRevert() {
845
847
  if (this.pane === 'files') return void this.onFileRevert();
846
848
  const item = this.activeDiffItem();
847
- if (!item) return;
848
- if (isReadOnlyOrigin(item.origin)) {
849
- this.status = 'read only';
850
- return;
851
- }
849
+ if (!this.guardWritable(item)) return;
852
850
  this.applyGit(
853
851
  item,
854
852
  'reverted',
@@ -1653,7 +1651,7 @@ class Session {
1653
1651
  CURSOR_BLINK_MS,
1654
1652
  );
1655
1653
  this.stdin.on('data', (chunk) => onData(chunk), { signal });
1656
- process.on('SIGWINCH', () => this.draw(), { signal });
1654
+ watchResize(this.stdout, process, () => this.draw(), signal);
1657
1655
  });
1658
1656
  }
1659
1657
  }
package/lib/source.js CHANGED
@@ -1,13 +1,16 @@
1
1
  'use strict';
2
2
 
3
3
  const { parseGithubPrUrl } = require('./github.js');
4
+ const { parseGitlabMrUrl } = require('./gitlab.js');
4
5
 
5
6
  const selectChangeSource = (paths) => {
6
7
  if (!paths.length) return { kind: 'local', paths };
7
8
  const first = paths[0];
8
9
  const pr = parseGithubPrUrl(first);
9
- if (!pr) return { kind: 'local', paths };
10
- return { kind: 'github-pr', pr, paths: paths.slice(1) };
10
+ if (pr) return { kind: 'github-pr', pr, paths: paths.slice(1) };
11
+ const mr = parseGitlabMrUrl(first);
12
+ if (mr) return { kind: 'gitlab-mr', mr, paths: paths.slice(1) };
13
+ return { kind: 'local', paths };
11
14
  };
12
15
 
13
16
  const nop = () => {};
package/lib/sys.js ADDED
@@ -0,0 +1,59 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+
6
+ const IS_WIN = process.platform === 'win32';
7
+
8
+ const UNIX_CLIP = [
9
+ { cmd: 'wl-copy', args: [] },
10
+ { cmd: 'xclip', args: ['-selection', 'clipboard'] },
11
+ { cmd: 'xsel', args: ['--clipboard', '--input'] },
12
+ ];
13
+
14
+ const npmBin = (platform = process.platform) =>
15
+ platform === 'win32' ? 'npm.cmd' : 'npm';
16
+
17
+ const spawnBase = (extra = {}) => ({ windowsHide: true, ...extra });
18
+
19
+ const clipTools = (platform = process.platform) => {
20
+ if (platform === 'win32') return [{ cmd: 'clip', args: [] }];
21
+ if (platform === 'darwin') return [{ cmd: 'pbcopy', args: [] }];
22
+ return UNIX_CLIP;
23
+ };
24
+
25
+ const watchResize = (stdout, proc, onResize, signal) => {
26
+ if (stdout && typeof stdout.on === 'function') {
27
+ stdout.on('resize', onResize, { signal });
28
+ }
29
+ try {
30
+ proc.on('SIGWINCH', onResize, { signal });
31
+ } catch {
32
+ // SIGWINCH is not available on Windows
33
+ }
34
+ };
35
+
36
+ const canonPath = (value) => {
37
+ try {
38
+ return fs.realpathSync.native(value);
39
+ } catch {
40
+ return path.resolve(value);
41
+ }
42
+ };
43
+
44
+ const samePath = (left, right) => {
45
+ if (left === right) return true;
46
+ const a = canonPath(left);
47
+ const b = canonPath(right);
48
+ if (a === b) return true;
49
+ return IS_WIN && a.toLowerCase() === b.toLowerCase();
50
+ };
51
+
52
+ module.exports = {
53
+ IS_WIN,
54
+ npmBin,
55
+ spawnBase,
56
+ clipTools,
57
+ watchResize,
58
+ samePath,
59
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "reslop",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "description": "Interactive code review and repair planning for AI-assisted changes",
5
5
  "keywords": [
6
6
  "code-review",
@@ -53,7 +53,7 @@
53
53
  "disable": "node scripts/disable.js",
54
54
  "lint": "eslint . && prettier -c \"**/*.js\" \"**/*.json\" \"**/*.md\"",
55
55
  "fix": "eslint . --fix && prettier --write \"**/*.js\" \"**/*.json\" \"**/*.md\"",
56
- "test": "npm run -s lint && node --test test/*.test.js"
56
+ "test": "npm run -s lint && node --test"
57
57
  },
58
58
  "devDependencies": {
59
59
  "eslint": "^9.39.4",