@muhammad_zihad/prless 0.1.0
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/LICENSE +21 -0
- package/README.md +97 -0
- package/dist/server/cli.js +101 -0
- package/dist/server/comments.js +73 -0
- package/dist/server/comments.test.js +42 -0
- package/dist/server/export.js +47 -0
- package/dist/server/export.test.js +43 -0
- package/dist/server/git.js +46 -0
- package/dist/server/git.test.js +36 -0
- package/dist/server/index.js +31 -0
- package/dist/server/routes.js +59 -0
- package/dist/shared/types.js +1 -0
- package/dist/web/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
- package/dist/web/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
- package/dist/web/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
- package/dist/web/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
- package/dist/web/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
- package/dist/web/assets/index-BJamOYc8.js +63 -0
- package/dist/web/assets/index-BowQr5t6.css +1 -0
- package/dist/web/assets/jetbrains-mono-cyrillic-wght-normal-D73BlboJ.woff2 +0 -0
- package/dist/web/assets/jetbrains-mono-greek-wght-normal-Bw9x6K1M.woff2 +0 -0
- package/dist/web/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
- package/dist/web/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
- package/dist/web/assets/jetbrains-mono-vietnamese-wght-normal-Bt-aOZkq.woff2 +0 -0
- package/dist/web/index.html +13 -0
- package/package.json +69 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Muhammad AR Zihad
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# PRless
|
|
2
|
+
|
|
3
|
+
A local, GitHub-style code review tool with an agent-agnostic AI handoff. Review a local
|
|
4
|
+
git diff in the browser, leave inline comments anchored to specific lines, then export the
|
|
5
|
+
comments to a file that any CLI coding agent (Claude Code, Codex, …) can read and act on.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
Requires **Node.js 18+**. One command on every platform — macOS, Linux, and Windows:
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
npm install -g @muhammad_zihad/prless
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
That puts a `prless` command on your PATH (on Windows, npm generates the `.cmd`/PowerShell
|
|
16
|
+
shims automatically). Prefer not to install globally? Run it on demand with `npx`:
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
npx @muhammad_zihad/prless open .
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
### macOS / Linux via Homebrew
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
brew install muhammadZihad/tap/prless
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
`brew` installs Node for you. See [packaging/README.md](packaging/README.md) for publishing
|
|
29
|
+
the npm package and setting up the tap.
|
|
30
|
+
|
|
31
|
+
### From source
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
git clone https://github.com/muhammadZihad/prless.git && cd prless
|
|
35
|
+
npm install # builds automatically via the prepare script
|
|
36
|
+
npm link # makes `prless` available globally
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Use
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
prless open . # review the git repo in the current directory
|
|
43
|
+
prless open ~/projects/my-app # review a repo by path
|
|
44
|
+
prless open ./my-app --port 4200
|
|
45
|
+
prless help # show usage
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Works on macOS, Linux, and Windows. This starts a local server and opens the UI in your
|
|
49
|
+
browser. You can:
|
|
50
|
+
|
|
51
|
+
- **Pick a diff source** — working tree vs HEAD, staged vs HEAD, or compare two branches.
|
|
52
|
+
- **Comment inline** — click a line's gutter to leave a comment (⌘/Ctrl+Enter to submit).
|
|
53
|
+
- **Resolve / delete** comments; toggle split / unified view.
|
|
54
|
+
- **Switch the app theme** — light or dark (defaults to your OS setting, remembered per browser).
|
|
55
|
+
- **Choose a syntax theme** — Auto, GitHub Light/Dark, One Dark, Dracula, Nord, Monokai,
|
|
56
|
+
Solarized Light. The diff renders as a self-contained editor surface, so any code theme
|
|
57
|
+
looks right regardless of the app theme.
|
|
58
|
+
- **Export for AI** — writes open comments to `.prless/review.md`.
|
|
59
|
+
|
|
60
|
+
Typography uses Geist (UI) and JetBrains Mono (code), bundled locally so it works offline.
|
|
61
|
+
|
|
62
|
+
Comments persist in `.prless/comments.json` in the repo (gitignore the `.prless/` folder).
|
|
63
|
+
|
|
64
|
+
## Hand off to an agent
|
|
65
|
+
|
|
66
|
+
After exporting, run your agent in the repo and point it at the file:
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
claude # then: "address the comments in .prless/review.md"
|
|
70
|
+
# or
|
|
71
|
+
codex "address the comments in .prless/review.md"
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
`review.md` lists each open comment grouped by file, with the target line, side, and the
|
|
75
|
+
requested change — no API keys or MCP required.
|
|
76
|
+
|
|
77
|
+
## Develop
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
npm run dev # Vite UI on :5174 (proxying /api) + API on :4100 with reload
|
|
81
|
+
npm test # vitest (git, comments, export)
|
|
82
|
+
npm run typecheck
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## Layout
|
|
86
|
+
|
|
87
|
+
```
|
|
88
|
+
src/
|
|
89
|
+
shared/types.ts # types shared by server + web
|
|
90
|
+
server/ # Fastify API + CLI (git diff, comment store, review.md export)
|
|
91
|
+
web/ # React + Vite UI (react-diff-view)
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## Scope
|
|
95
|
+
|
|
96
|
+
Local, single-user. No GitHub PR ingestion, no auth/DB, no MCP — handoff is the exported
|
|
97
|
+
file. These are deliberate non-goals and can be layered on later.
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import open from 'open';
|
|
4
|
+
import { buildServer } from './index.js';
|
|
5
|
+
import { assertGitRepo, createGit, GitError } from './git.js';
|
|
6
|
+
const USAGE = `prless — local code review with agent-agnostic AI handoff
|
|
7
|
+
|
|
8
|
+
Usage:
|
|
9
|
+
prless open <repository> Review a git repository at the given path
|
|
10
|
+
prless open . Review the git repository in the current directory
|
|
11
|
+
prless help Show this help
|
|
12
|
+
|
|
13
|
+
Options (for "open"):
|
|
14
|
+
--port <n> Port to serve on (default 4100, or $PRLESS_PORT)
|
|
15
|
+
--no-open Do not launch a browser automatically
|
|
16
|
+
--dev Internal: run the API only (UI served by the Vite dev server)
|
|
17
|
+
|
|
18
|
+
Examples:
|
|
19
|
+
prless open .
|
|
20
|
+
prless open ~/projects/my-app
|
|
21
|
+
prless open ./my-app --port 4200
|
|
22
|
+
`;
|
|
23
|
+
function parseOpenArgs(rest) {
|
|
24
|
+
let repoArg;
|
|
25
|
+
let dev = false;
|
|
26
|
+
let noOpen = false;
|
|
27
|
+
let port = Number(process.env.PRLESS_PORT ?? 4100);
|
|
28
|
+
for (let i = 0; i < rest.length; i++) {
|
|
29
|
+
const a = rest[i];
|
|
30
|
+
if (a === '--dev')
|
|
31
|
+
dev = true;
|
|
32
|
+
else if (a === '--no-open')
|
|
33
|
+
noOpen = true;
|
|
34
|
+
else if (a === '--port')
|
|
35
|
+
port = Number(rest[++i]);
|
|
36
|
+
else if (!a.startsWith('-') && repoArg === undefined)
|
|
37
|
+
repoArg = a;
|
|
38
|
+
}
|
|
39
|
+
if (repoArg === undefined) {
|
|
40
|
+
console.error('prless: "open" requires a repository path (use "." for the current directory).\n');
|
|
41
|
+
console.error(USAGE);
|
|
42
|
+
process.exit(1);
|
|
43
|
+
}
|
|
44
|
+
return {
|
|
45
|
+
// path.resolve normalises against cwd on every platform (mac/linux/windows).
|
|
46
|
+
repoRoot: path.resolve(repoArg),
|
|
47
|
+
dev,
|
|
48
|
+
noOpen,
|
|
49
|
+
port,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
async function runOpen(opts) {
|
|
53
|
+
const git = createGit(opts.repoRoot);
|
|
54
|
+
try {
|
|
55
|
+
await assertGitRepo(git);
|
|
56
|
+
}
|
|
57
|
+
catch (err) {
|
|
58
|
+
if (err instanceof GitError) {
|
|
59
|
+
console.error(`prless: ${opts.repoRoot} is not a git repository.`);
|
|
60
|
+
process.exit(1);
|
|
61
|
+
}
|
|
62
|
+
throw err;
|
|
63
|
+
}
|
|
64
|
+
const app = await buildServer({ repoRoot: opts.repoRoot, dev: opts.dev });
|
|
65
|
+
await app.listen({ port: opts.port, host: '127.0.0.1' });
|
|
66
|
+
const url = opts.dev ? `http://localhost:5174` : `http://localhost:${opts.port}`;
|
|
67
|
+
console.log(`prless: reviewing ${opts.repoRoot}`);
|
|
68
|
+
console.log(`prless: serving ${opts.dev ? 'API' : 'UI'} on http://localhost:${opts.port}`);
|
|
69
|
+
if (opts.dev)
|
|
70
|
+
console.log(`prless: open the Vite dev server at ${url}`);
|
|
71
|
+
if (!opts.dev && !opts.noOpen) {
|
|
72
|
+
await open(url).catch(() => {
|
|
73
|
+
// Launching a browser is best-effort and varies by platform.
|
|
74
|
+
console.log(`prless: open ${url} in your browser`);
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
async function main() {
|
|
79
|
+
const argv = process.argv.slice(2);
|
|
80
|
+
const command = argv[0];
|
|
81
|
+
const rest = argv.slice(1);
|
|
82
|
+
switch (command) {
|
|
83
|
+
case 'open':
|
|
84
|
+
await runOpen(parseOpenArgs(rest));
|
|
85
|
+
break;
|
|
86
|
+
case 'help':
|
|
87
|
+
case '--help':
|
|
88
|
+
case '-h':
|
|
89
|
+
case undefined:
|
|
90
|
+
console.log(USAGE);
|
|
91
|
+
break;
|
|
92
|
+
default:
|
|
93
|
+
console.error(`prless: unknown command "${command}"\n`);
|
|
94
|
+
console.error(USAGE);
|
|
95
|
+
process.exit(1);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
main().catch((err) => {
|
|
99
|
+
console.error(err);
|
|
100
|
+
process.exit(1);
|
|
101
|
+
});
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
export class CommentStore {
|
|
5
|
+
dir;
|
|
6
|
+
file;
|
|
7
|
+
constructor(repoRoot) {
|
|
8
|
+
this.dir = path.join(repoRoot, '.prless');
|
|
9
|
+
this.file = path.join(this.dir, 'comments.json');
|
|
10
|
+
}
|
|
11
|
+
get filePath() {
|
|
12
|
+
return this.file;
|
|
13
|
+
}
|
|
14
|
+
async list() {
|
|
15
|
+
try {
|
|
16
|
+
const raw = await readFile(this.file, 'utf8');
|
|
17
|
+
const parsed = JSON.parse(raw);
|
|
18
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
19
|
+
}
|
|
20
|
+
catch (err) {
|
|
21
|
+
if (err.code === 'ENOENT') {
|
|
22
|
+
return [];
|
|
23
|
+
}
|
|
24
|
+
throw err;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
async add(input) {
|
|
28
|
+
const comments = await this.list();
|
|
29
|
+
const now = new Date().toISOString();
|
|
30
|
+
const comment = {
|
|
31
|
+
id: randomUUID(),
|
|
32
|
+
file: input.file,
|
|
33
|
+
line: input.line,
|
|
34
|
+
side: input.side,
|
|
35
|
+
snippet: input.snippet ?? '',
|
|
36
|
+
body: input.body,
|
|
37
|
+
status: 'open',
|
|
38
|
+
createdAt: now,
|
|
39
|
+
updatedAt: now,
|
|
40
|
+
};
|
|
41
|
+
comments.push(comment);
|
|
42
|
+
await this.save(comments);
|
|
43
|
+
return comment;
|
|
44
|
+
}
|
|
45
|
+
async patch(id, patch) {
|
|
46
|
+
const comments = await this.list();
|
|
47
|
+
const idx = comments.findIndex((c) => c.id === id);
|
|
48
|
+
if (idx === -1)
|
|
49
|
+
return null;
|
|
50
|
+
const existing = comments[idx];
|
|
51
|
+
const updated = {
|
|
52
|
+
...existing,
|
|
53
|
+
body: patch.body ?? existing.body,
|
|
54
|
+
status: patch.status ?? existing.status,
|
|
55
|
+
updatedAt: new Date().toISOString(),
|
|
56
|
+
};
|
|
57
|
+
comments[idx] = updated;
|
|
58
|
+
await this.save(comments);
|
|
59
|
+
return updated;
|
|
60
|
+
}
|
|
61
|
+
async remove(id) {
|
|
62
|
+
const comments = await this.list();
|
|
63
|
+
const next = comments.filter((c) => c.id !== id);
|
|
64
|
+
if (next.length === comments.length)
|
|
65
|
+
return false;
|
|
66
|
+
await this.save(next);
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
69
|
+
async save(comments) {
|
|
70
|
+
await mkdir(this.dir, { recursive: true });
|
|
71
|
+
await writeFile(this.file, JSON.stringify(comments, null, 2) + '\n', 'utf8');
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { mkdtemp, rm } from 'node:fs/promises';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
5
|
+
import { CommentStore } from './comments.js';
|
|
6
|
+
describe('CommentStore', () => {
|
|
7
|
+
let dir;
|
|
8
|
+
let store;
|
|
9
|
+
beforeEach(async () => {
|
|
10
|
+
dir = await mkdtemp(path.join(tmpdir(), 'prless-'));
|
|
11
|
+
store = new CommentStore(dir);
|
|
12
|
+
});
|
|
13
|
+
afterEach(async () => {
|
|
14
|
+
await rm(dir, { recursive: true, force: true });
|
|
15
|
+
});
|
|
16
|
+
it('returns an empty list before anything is written', async () => {
|
|
17
|
+
expect(await store.list()).toEqual([]);
|
|
18
|
+
});
|
|
19
|
+
it('adds, persists and round-trips a comment', async () => {
|
|
20
|
+
const created = await store.add({ file: 'a.ts', line: 3, side: 'new', body: 'hi', snippet: 'x' });
|
|
21
|
+
expect(created.id).toBeTruthy();
|
|
22
|
+
expect(created.status).toBe('open');
|
|
23
|
+
const reread = await new CommentStore(dir).list();
|
|
24
|
+
expect(reread).toHaveLength(1);
|
|
25
|
+
expect(reread[0].body).toBe('hi');
|
|
26
|
+
});
|
|
27
|
+
it('patches status and body', async () => {
|
|
28
|
+
const created = await store.add({ file: 'a.ts', line: 1, side: 'new', body: 'orig' });
|
|
29
|
+
const patched = await store.patch(created.id, { status: 'resolved', body: 'edited' });
|
|
30
|
+
expect(patched?.status).toBe('resolved');
|
|
31
|
+
expect(patched?.body).toBe('edited');
|
|
32
|
+
});
|
|
33
|
+
it('returns null when patching a missing id', async () => {
|
|
34
|
+
expect(await store.patch('nope', { status: 'resolved' })).toBeNull();
|
|
35
|
+
});
|
|
36
|
+
it('removes a comment', async () => {
|
|
37
|
+
const created = await store.add({ file: 'a.ts', line: 1, side: 'new', body: 'x' });
|
|
38
|
+
expect(await store.remove(created.id)).toBe(true);
|
|
39
|
+
expect(await store.list()).toEqual([]);
|
|
40
|
+
expect(await store.remove(created.id)).toBe(false);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { mkdir, writeFile } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
const HEADER = `# Code Review — action required
|
|
4
|
+
|
|
5
|
+
You are addressing review comments on this repository. For each comment below,
|
|
6
|
+
make the requested change in the referenced file, then briefly note what you changed.
|
|
7
|
+
Line numbers refer to the indicated side of the diff ("new" = current file contents,
|
|
8
|
+
"old" = the version before the change).
|
|
9
|
+
`;
|
|
10
|
+
/**
|
|
11
|
+
* Render open comments into a deterministic, agent-friendly markdown document,
|
|
12
|
+
* grouped by file and ordered by line. Pure function for easy testing.
|
|
13
|
+
*/
|
|
14
|
+
export function renderReviewMarkdown(comments) {
|
|
15
|
+
const open = comments.filter((c) => c.status === 'open');
|
|
16
|
+
if (open.length === 0) {
|
|
17
|
+
return `${HEADER}\n_No open comments._\n`;
|
|
18
|
+
}
|
|
19
|
+
const byFile = new Map();
|
|
20
|
+
for (const c of open) {
|
|
21
|
+
const bucket = byFile.get(c.file) ?? [];
|
|
22
|
+
bucket.push(c);
|
|
23
|
+
byFile.set(c.file, bucket);
|
|
24
|
+
}
|
|
25
|
+
const sections = [];
|
|
26
|
+
for (const file of [...byFile.keys()].sort()) {
|
|
27
|
+
const lines = byFile
|
|
28
|
+
.get(file)
|
|
29
|
+
.slice()
|
|
30
|
+
.sort((a, b) => a.line - b.line || a.createdAt.localeCompare(b.createdAt));
|
|
31
|
+
const items = lines.map((c) => {
|
|
32
|
+
const snippet = c.snippet.trim();
|
|
33
|
+
const context = snippet ? ` \`${snippet}\`` : '';
|
|
34
|
+
const body = c.body.trim().replace(/\n/g, '\n ');
|
|
35
|
+
return `- **Line ${c.line} (${c.side}):**${context}\n → ${body}`;
|
|
36
|
+
});
|
|
37
|
+
sections.push(`## ${file}\n\n${items.join('\n\n')}`);
|
|
38
|
+
}
|
|
39
|
+
return `${HEADER}\n${sections.join('\n\n')}\n`;
|
|
40
|
+
}
|
|
41
|
+
export async function exportReview(repoRoot, comments) {
|
|
42
|
+
const dir = path.join(repoRoot, '.prless');
|
|
43
|
+
const file = path.join(dir, 'review.md');
|
|
44
|
+
await mkdir(dir, { recursive: true });
|
|
45
|
+
await writeFile(file, renderReviewMarkdown(comments), 'utf8');
|
|
46
|
+
return { path: file, count: comments.filter((c) => c.status === 'open').length };
|
|
47
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { renderReviewMarkdown } from './export.js';
|
|
3
|
+
function comment(overrides) {
|
|
4
|
+
return {
|
|
5
|
+
id: 'id',
|
|
6
|
+
file: 'src/a.ts',
|
|
7
|
+
line: 1,
|
|
8
|
+
side: 'new',
|
|
9
|
+
snippet: '',
|
|
10
|
+
body: 'do something',
|
|
11
|
+
status: 'open',
|
|
12
|
+
createdAt: '2026-01-01T00:00:00.000Z',
|
|
13
|
+
updatedAt: '2026-01-01T00:00:00.000Z',
|
|
14
|
+
...overrides,
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
describe('renderReviewMarkdown', () => {
|
|
18
|
+
it('renders an empty placeholder when there are no open comments', () => {
|
|
19
|
+
const md = renderReviewMarkdown([comment({ status: 'resolved' })]);
|
|
20
|
+
expect(md).toContain('_No open comments._');
|
|
21
|
+
});
|
|
22
|
+
it('groups open comments by file, sorted by line, with line context', () => {
|
|
23
|
+
const md = renderReviewMarkdown([
|
|
24
|
+
comment({ id: '1', file: 'src/b.ts', line: 5, side: 'new', snippet: 'return x', body: 'guard null' }),
|
|
25
|
+
comment({ id: '2', file: 'src/a.ts', line: 10, side: 'old', snippet: 'let u', body: 'rename u' }),
|
|
26
|
+
comment({ id: '3', file: 'src/a.ts', line: 2, side: 'new', snippet: 'const z=1', body: 'inline this' }),
|
|
27
|
+
]);
|
|
28
|
+
// Files alphabetical: a.ts before b.ts
|
|
29
|
+
expect(md.indexOf('## src/a.ts')).toBeLessThan(md.indexOf('## src/b.ts'));
|
|
30
|
+
// Within a.ts, line 2 before line 10
|
|
31
|
+
expect(md.indexOf('Line 2 (new)')).toBeLessThan(md.indexOf('Line 10 (old)'));
|
|
32
|
+
expect(md).toContain('`return x`');
|
|
33
|
+
expect(md).toContain('→ guard null');
|
|
34
|
+
});
|
|
35
|
+
it('excludes resolved comments', () => {
|
|
36
|
+
const md = renderReviewMarkdown([
|
|
37
|
+
comment({ id: '1', body: 'keep me', status: 'open' }),
|
|
38
|
+
comment({ id: '2', body: 'drop me', status: 'resolved' }),
|
|
39
|
+
]);
|
|
40
|
+
expect(md).toContain('keep me');
|
|
41
|
+
expect(md).not.toContain('drop me');
|
|
42
|
+
});
|
|
43
|
+
});
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { simpleGit } from 'simple-git';
|
|
2
|
+
export class GitError extends Error {
|
|
3
|
+
}
|
|
4
|
+
export function createGit(repoRoot) {
|
|
5
|
+
return simpleGit({ baseDir: repoRoot });
|
|
6
|
+
}
|
|
7
|
+
export async function assertGitRepo(git) {
|
|
8
|
+
const isRepo = await git.checkIsRepo();
|
|
9
|
+
if (!isRepo) {
|
|
10
|
+
throw new GitError('Not a git repository');
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
export async function getRefs(git) {
|
|
14
|
+
const branchSummary = await git.branchLocal();
|
|
15
|
+
return {
|
|
16
|
+
current: branchSummary.current,
|
|
17
|
+
branches: branchSummary.all,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Produce a unified diff for the requested mode.
|
|
22
|
+
* - working: all uncommitted changes vs HEAD (staged + unstaged)
|
|
23
|
+
* - staged: staged changes only
|
|
24
|
+
* - compare: `git diff <base> <head>` (two-dot)
|
|
25
|
+
*/
|
|
26
|
+
export async function getDiff(git, mode, base, head) {
|
|
27
|
+
let args;
|
|
28
|
+
switch (mode) {
|
|
29
|
+
case 'staged':
|
|
30
|
+
args = ['--staged'];
|
|
31
|
+
break;
|
|
32
|
+
case 'compare':
|
|
33
|
+
if (!base || !head) {
|
|
34
|
+
throw new GitError('compare mode requires both base and head');
|
|
35
|
+
}
|
|
36
|
+
args = [base, head];
|
|
37
|
+
break;
|
|
38
|
+
case 'working':
|
|
39
|
+
default:
|
|
40
|
+
args = ['HEAD'];
|
|
41
|
+
break;
|
|
42
|
+
}
|
|
43
|
+
// Stable, machine-friendly diff output.
|
|
44
|
+
const raw = await git.diff(['--no-color', ...args]);
|
|
45
|
+
return { mode, base, head, raw };
|
|
46
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
5
|
+
import { assertGitRepo, createGit, getDiff, getRefs } from './git.js';
|
|
6
|
+
describe('git layer', () => {
|
|
7
|
+
let dir;
|
|
8
|
+
beforeEach(async () => {
|
|
9
|
+
dir = await mkdtemp(path.join(tmpdir(), 'prless-git-'));
|
|
10
|
+
const git = createGit(dir);
|
|
11
|
+
await git.init();
|
|
12
|
+
await git.addConfig('user.email', 'test@example.com');
|
|
13
|
+
await git.addConfig('user.name', 'Test');
|
|
14
|
+
await writeFile(path.join(dir, 'a.ts'), 'const a = 1;\n');
|
|
15
|
+
await git.add('.');
|
|
16
|
+
await git.commit('init');
|
|
17
|
+
});
|
|
18
|
+
afterEach(async () => {
|
|
19
|
+
await rm(dir, { recursive: true, force: true });
|
|
20
|
+
});
|
|
21
|
+
it('detects a git repo', async () => {
|
|
22
|
+
await expect(assertGitRepo(createGit(dir))).resolves.toBeUndefined();
|
|
23
|
+
});
|
|
24
|
+
it('lists refs including the current branch', async () => {
|
|
25
|
+
const refs = await getRefs(createGit(dir));
|
|
26
|
+
expect(refs.current).toBeTruthy();
|
|
27
|
+
expect(refs.branches).toContain(refs.current);
|
|
28
|
+
});
|
|
29
|
+
it('produces a working-tree diff for uncommitted changes', async () => {
|
|
30
|
+
await writeFile(path.join(dir, 'a.ts'), 'const a = 2;\n');
|
|
31
|
+
const res = await getDiff(createGit(dir), 'working');
|
|
32
|
+
expect(res.raw).toContain('a.ts');
|
|
33
|
+
expect(res.raw).toContain('-const a = 1;');
|
|
34
|
+
expect(res.raw).toContain('+const a = 2;');
|
|
35
|
+
});
|
|
36
|
+
});
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { fileURLToPath } from 'node:url';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { existsSync } from 'node:fs';
|
|
4
|
+
import Fastify from 'fastify';
|
|
5
|
+
import fastifyStatic from '@fastify/static';
|
|
6
|
+
import { CommentStore } from './comments.js';
|
|
7
|
+
import { createGit } from './git.js';
|
|
8
|
+
import { registerApiRoutes } from './routes.js';
|
|
9
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
10
|
+
export async function buildServer(opts) {
|
|
11
|
+
const app = Fastify({ logger: false });
|
|
12
|
+
const git = createGit(opts.repoRoot);
|
|
13
|
+
const store = new CommentStore(opts.repoRoot);
|
|
14
|
+
await registerApiRoutes(app, { repoRoot: opts.repoRoot, git, store });
|
|
15
|
+
// In dev, Vite serves the UI and proxies /api here. In a built install we
|
|
16
|
+
// serve the compiled web assets ourselves so the CLI is a single process.
|
|
17
|
+
if (!opts.dev) {
|
|
18
|
+
const webRoot = path.join(__dirname, '..', 'web');
|
|
19
|
+
if (existsSync(webRoot)) {
|
|
20
|
+
await app.register(fastifyStatic, { root: webRoot });
|
|
21
|
+
// SPA fallback for any non-API route.
|
|
22
|
+
app.setNotFoundHandler((request, reply) => {
|
|
23
|
+
if (request.url.startsWith('/api')) {
|
|
24
|
+
return reply.code(404).send({ error: 'not found' });
|
|
25
|
+
}
|
|
26
|
+
return reply.sendFile('index.html');
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return app;
|
|
31
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { exportReview } from './export.js';
|
|
2
|
+
import { getDiff, getRefs, GitError } from './git.js';
|
|
3
|
+
export async function registerApiRoutes(app, ctx) {
|
|
4
|
+
app.get('/api/refs', async () => {
|
|
5
|
+
return getRefs(ctx.git);
|
|
6
|
+
});
|
|
7
|
+
app.get('/api/diff', async (request, reply) => {
|
|
8
|
+
const q = request.query;
|
|
9
|
+
const mode = (q.mode ?? 'working');
|
|
10
|
+
try {
|
|
11
|
+
return await getDiff(ctx.git, mode, q.base, q.head);
|
|
12
|
+
}
|
|
13
|
+
catch (err) {
|
|
14
|
+
if (err instanceof GitError) {
|
|
15
|
+
return reply.code(400).send({ error: err.message });
|
|
16
|
+
}
|
|
17
|
+
throw err;
|
|
18
|
+
}
|
|
19
|
+
});
|
|
20
|
+
app.get('/api/comments', async () => {
|
|
21
|
+
return ctx.store.list();
|
|
22
|
+
});
|
|
23
|
+
app.post('/api/comments', async (request, reply) => {
|
|
24
|
+
const body = request.body;
|
|
25
|
+
if (!body || !body.file || typeof body.line !== 'number' || !body.side || !body.body) {
|
|
26
|
+
return reply.code(400).send({ error: 'file, line, side and body are required' });
|
|
27
|
+
}
|
|
28
|
+
const created = await ctx.store.add({
|
|
29
|
+
file: body.file,
|
|
30
|
+
line: body.line,
|
|
31
|
+
side: body.side,
|
|
32
|
+
body: body.body,
|
|
33
|
+
snippet: body.snippet,
|
|
34
|
+
});
|
|
35
|
+
return reply.code(201).send(created);
|
|
36
|
+
});
|
|
37
|
+
app.patch('/api/comments/:id', async (request, reply) => {
|
|
38
|
+
const { id } = request.params;
|
|
39
|
+
const patch = request.body;
|
|
40
|
+
const updated = await ctx.store.patch(id, patch);
|
|
41
|
+
if (!updated) {
|
|
42
|
+
return reply.code(404).send({ error: 'comment not found' });
|
|
43
|
+
}
|
|
44
|
+
return updated;
|
|
45
|
+
});
|
|
46
|
+
app.delete('/api/comments/:id', async (request, reply) => {
|
|
47
|
+
const { id } = request.params;
|
|
48
|
+
const ok = await ctx.store.remove(id);
|
|
49
|
+
if (!ok) {
|
|
50
|
+
return reply.code(404).send({ error: 'comment not found' });
|
|
51
|
+
}
|
|
52
|
+
return reply.code(204).send();
|
|
53
|
+
});
|
|
54
|
+
app.post('/api/export', async () => {
|
|
55
|
+
const comments = await ctx.store.list();
|
|
56
|
+
const result = await exportReview(ctx.repoRoot, comments);
|
|
57
|
+
return result;
|
|
58
|
+
});
|
|
59
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|