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/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,21 @@
|
|
|
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
|
+
|
|
13
|
+
## [0.1.3][] - 2026-09-12
|
|
14
|
+
|
|
15
|
+
- Showed added/removed and staged/total counts in the file list
|
|
16
|
+
- Highlighted JSX and TSX syntax, including
|
|
17
|
+
- Added vim-style j/k navigation and Ctrl scrolling
|
|
18
|
+
- Improved TUI/UX with background git and npm checks to avoid a freeze
|
|
19
|
+
|
|
5
20
|
## [0.1.2][] - 2026-09-11
|
|
6
21
|
|
|
7
22
|
- Review each dependency once across package.json and package-lock
|
|
@@ -26,7 +41,9 @@
|
|
|
26
41
|
- Feedback and todos that produce a repair plan for an agent
|
|
27
42
|
- Intra-line highlighting in unified, mixed, and side-by-side layouts
|
|
28
43
|
|
|
29
|
-
[unreleased]: https://github.com/tshemsedinov/reslop/compare/v0.1.
|
|
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
|
|
46
|
+
[0.1.3]: https://github.com/tshemsedinov/reslop/compare/v0.1.2...v0.1.3
|
|
30
47
|
[0.1.2]: https://github.com/tshemsedinov/reslop/compare/v0.1.1...v0.1.2
|
|
31
48
|
[0.1.1]: https://github.com/tshemsedinov/reslop/compare/v0.1.0...v0.1.1
|
|
32
49
|
[0.1.0]: https://github.com/tshemsedinov/reslop/releases/tag/v0.1.0
|
package/README.md
CHANGED
|
@@ -1,4 +1,11 @@
|
|
|
1
|
-
# reslop — Review diff and plan changes
|
|
1
|
+
# 👁️ reslop — Review diff and plan changes
|
|
2
|
+
|
|
3
|
+
[](https://github.com/tshemsedinov/reslop/actions?query=workflow%3A%22Testing+CI%22+branch%3Amain)
|
|
4
|
+
[](https://snyk.io/test/github/tshemsedinov/reslop)
|
|
5
|
+
[](https://badge.fury.io/js/reslop)
|
|
6
|
+
[](https://www.npmjs.com/package/reslop)
|
|
7
|
+
[](https://www.npmjs.com/package/reslop)
|
|
8
|
+
[](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
|
|
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
|
|
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
|
-
|
|
35
|
-
reslop
|
|
36
|
-
reslop -
|
|
37
|
-
reslop
|
|
38
|
-
reslop
|
|
39
|
-
reslop
|
|
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,25 +47,26 @@ 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
|
-
##
|
|
50
|
-
|
|
51
|
-
| Key
|
|
52
|
-
|
|
|
53
|
-
| `a`
|
|
54
|
-
| `u`
|
|
55
|
-
| `r`
|
|
56
|
-
| `
|
|
57
|
-
|
|
|
58
|
-
|
|
|
59
|
-
| `
|
|
60
|
-
|
|
|
61
|
-
| `
|
|
62
|
-
| `
|
|
63
|
-
| `
|
|
64
|
-
|
|
|
65
|
-
|
|
|
66
|
-
| `
|
|
67
|
-
|
|
|
50
|
+
## Hotkeys
|
|
51
|
+
|
|
52
|
+
| Key | Action |
|
|
53
|
+
| ------------------- | ---------------------------------------- |
|
|
54
|
+
| `a` | Stage this block (`git add`) |
|
|
55
|
+
| `u` | Unstage this block (keep worktree) |
|
|
56
|
+
| `r` | Restore this block to the last commit |
|
|
57
|
+
| `f` | Feedback on this diff block |
|
|
58
|
+
| `t` | New todo for this file |
|
|
59
|
+
| `l` | File list (git status of this scope) |
|
|
60
|
+
| `m` | Cycle unified / mixed / side-by-side |
|
|
61
|
+
| `⏎` | Open the selected file |
|
|
62
|
+
| `Esc` | Quit file list or a tool |
|
|
63
|
+
| `→` / `j` | Next remaining block / next file |
|
|
64
|
+
| `←` / `k` | Previous remaining block / previous file |
|
|
65
|
+
| `Ctrl-e` / `Ctrl-y` | Scroll one line down / up |
|
|
66
|
+
| `Ctrl-f` / `Ctrl-b` | Scroll page down / up |
|
|
67
|
+
| `Ctrl-d` / `Ctrl-u` | Scroll half-page down / up |
|
|
68
|
+
| `⌫` / `Del` | Remove the selected todo |
|
|
69
|
+
| `q` | Quit |
|
|
68
70
|
|
|
69
71
|
## License
|
|
70
72
|
|
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,13 +116,14 @@ 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
|
});
|
|
101
123
|
|
|
102
|
-
const startSession = (options) => {
|
|
124
|
+
const startSession = (options, extra = {}) => {
|
|
103
125
|
const session = new Session(options);
|
|
104
|
-
session.load();
|
|
126
|
+
if (extra.load) session.load();
|
|
105
127
|
return session;
|
|
106
128
|
};
|
|
107
129
|
|
|
@@ -110,20 +132,23 @@ const openLocalSession = (proc, deps, args, selected, env) => {
|
|
|
110
132
|
const repo = deps.repo ?? createGitRepo();
|
|
111
133
|
const statSync = deps.statSync ?? fs.statSync;
|
|
112
134
|
const scope = resolveScope(cwd, selected.paths, repo, statSync);
|
|
113
|
-
|
|
135
|
+
const session = startSession(
|
|
114
136
|
sessionOptions(proc, deps, args, env, {
|
|
115
137
|
repo,
|
|
116
138
|
paths: scope.paths,
|
|
117
139
|
rev: scope.rev,
|
|
118
140
|
}),
|
|
119
141
|
);
|
|
142
|
+
session.ensureRepo();
|
|
143
|
+
return session;
|
|
120
144
|
};
|
|
121
145
|
|
|
122
|
-
const
|
|
146
|
+
const openRemoteSession = async (proc, deps, args, selected, env) => {
|
|
147
|
+
const remote = REMOTE_CHANGE[selected.kind];
|
|
123
148
|
const cwd = deps.cwd ?? proc.cwd();
|
|
124
|
-
const loader = deps.
|
|
125
|
-
const token =
|
|
126
|
-
const loaded = await loader(selected
|
|
149
|
+
const loader = deps[remote.load] ?? remote.fallback;
|
|
150
|
+
const token = remote.token(env);
|
|
151
|
+
const loaded = await loader(remote.target(selected), {
|
|
127
152
|
cwd,
|
|
128
153
|
paths: selected.paths,
|
|
129
154
|
token,
|
|
@@ -140,18 +165,26 @@ const openPullRequestSession = async (proc, deps, args, selected, env) => {
|
|
|
140
165
|
sourceLabel: loaded.sourceLabel,
|
|
141
166
|
change,
|
|
142
167
|
}),
|
|
168
|
+
{ load: true },
|
|
143
169
|
);
|
|
144
170
|
};
|
|
145
171
|
|
|
146
172
|
const loadSession = async (proc, deps, args) => {
|
|
147
173
|
const env = proc.env ?? {};
|
|
148
174
|
const selected = selectChangeSource(args.paths);
|
|
149
|
-
if (selected.kind
|
|
150
|
-
return
|
|
175
|
+
if (REMOTE_CHANGE[selected.kind]) {
|
|
176
|
+
return openRemoteSession(proc, deps, args, selected, env);
|
|
151
177
|
}
|
|
152
178
|
return openLocalSession(proc, deps, args, selected, env);
|
|
153
179
|
};
|
|
154
180
|
|
|
181
|
+
const runUi = async (session, proc) => {
|
|
182
|
+
const code = await session.startUi();
|
|
183
|
+
if (session.loadError) return fail(proc.stderr, session.loadError);
|
|
184
|
+
if (session.emptyReview) proc.stdout.write('nothing to review\n');
|
|
185
|
+
return code;
|
|
186
|
+
};
|
|
187
|
+
|
|
155
188
|
const run = async (proc, deps = {}) => {
|
|
156
189
|
const stdout = proc.stdout;
|
|
157
190
|
const stderr = proc.stderr;
|
|
@@ -167,15 +200,19 @@ const run = async (proc, deps = {}) => {
|
|
|
167
200
|
} catch (error) {
|
|
168
201
|
return fail(stderr, error);
|
|
169
202
|
}
|
|
203
|
+
const tty = proc.stdin.isTTY === true && stdout.isTTY === true;
|
|
204
|
+
if (tty) return runUi(session, proc);
|
|
205
|
+
try {
|
|
206
|
+
await session.loadReady();
|
|
207
|
+
} catch (error) {
|
|
208
|
+
return fail(stderr, error);
|
|
209
|
+
}
|
|
170
210
|
if (!session.items.length) {
|
|
171
211
|
stdout.write('nothing to review\n');
|
|
172
212
|
return 0;
|
|
173
213
|
}
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
return 1;
|
|
177
|
-
}
|
|
178
|
-
return session.startUi();
|
|
214
|
+
stderr.write('reslop: interactive terminal required\n');
|
|
215
|
+
return 1;
|
|
179
216
|
};
|
|
180
217
|
|
|
181
218
|
module.exports = {
|
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(
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
|
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/detect.js
CHANGED
package/lib/files.js
CHANGED
|
@@ -33,26 +33,50 @@ const fileStatus = (origins) => {
|
|
|
33
33
|
return key;
|
|
34
34
|
};
|
|
35
35
|
|
|
36
|
+
const lineDelta = ({ hunk, blockId }) => {
|
|
37
|
+
const delta = { added: 0, removed: 0 };
|
|
38
|
+
if (!hunk) return delta;
|
|
39
|
+
const { lines } = hunk;
|
|
40
|
+
if (!lines) return delta;
|
|
41
|
+
const scoped = typeof blockId === 'number';
|
|
42
|
+
for (const { type, blockId: lineBlock } of lines) {
|
|
43
|
+
if (scoped && lineBlock !== blockId) continue;
|
|
44
|
+
if (type === 'add') delta.added += 1;
|
|
45
|
+
else if (type === 'del') delta.removed += 1;
|
|
46
|
+
}
|
|
47
|
+
return delta;
|
|
48
|
+
};
|
|
49
|
+
|
|
36
50
|
const fileEntries = (items) => {
|
|
37
51
|
const groups = new Map();
|
|
38
52
|
for (let i = 0; i < items.length; i++) {
|
|
39
53
|
const item = items[i];
|
|
40
54
|
const rel = itemPath(item);
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
55
|
+
if (!groups.has(rel)) {
|
|
56
|
+
groups.set(rel, {
|
|
57
|
+
path: rel,
|
|
58
|
+
origins: [],
|
|
59
|
+
firstIndex: i,
|
|
60
|
+
remaining: 0,
|
|
61
|
+
added: 0,
|
|
62
|
+
removed: 0,
|
|
63
|
+
staged: 0,
|
|
64
|
+
unstaged: 0,
|
|
65
|
+
});
|
|
45
66
|
}
|
|
46
|
-
group.
|
|
67
|
+
const group = groups.get(rel);
|
|
68
|
+
const { origin } = item;
|
|
69
|
+
group.origins.push(origin);
|
|
47
70
|
group.remaining += 1;
|
|
71
|
+
const delta = lineDelta(item);
|
|
72
|
+
group.added += delta.added;
|
|
73
|
+
group.removed += delta.removed;
|
|
74
|
+
if (origin === 'staged') group.staged += 1;
|
|
75
|
+
if (origin === 'unstaged') group.unstaged += 1;
|
|
48
76
|
}
|
|
49
|
-
const entries = [];
|
|
50
|
-
for (const group of
|
|
51
|
-
|
|
52
|
-
const status = fileStatus(group.origins);
|
|
53
|
-
const firstIndex = group.firstIndex;
|
|
54
|
-
const remaining = group.remaining;
|
|
55
|
-
entries.push({ path, status, firstIndex, remaining });
|
|
77
|
+
const entries = [...groups.values()];
|
|
78
|
+
for (const group of entries) {
|
|
79
|
+
group.status = fileStatus(group.origins);
|
|
56
80
|
}
|
|
57
81
|
return entries;
|
|
58
82
|
};
|