git-reviewer 2.0.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/server.js ADDED
@@ -0,0 +1,421 @@
1
+ 'use strict';
2
+
3
+ const express = require('express');
4
+ const cors = require('cors');
5
+ const simpleGit = require('simple-git');
6
+ const fs = require('fs').promises;
7
+ const path = require('path');
8
+
9
+ const { parseDiff } = require('./lib/diff');
10
+ const { resolveRepoFile, PathEscapeError } = require('./lib/paths');
11
+ const { collectWorkingChanges, collectCommitChanges } = require('./lib/changes');
12
+ const { SessionStore } = require('./lib/sessions');
13
+ const { normalizeComments } = require('./lib/comments');
14
+ const { formatReview, reviewFilename, commentsFilename } = require('./lib/review');
15
+ const { buildReviewDocument } = require('./lib/agent');
16
+ const { readSavedComments, ReviewOwnershipError } = require('./lib/store');
17
+
18
+ const DEFAULT_PORT = 4500;
19
+ const DEFAULT_HOST = '127.0.0.1';
20
+
21
+ /** Where comment state and submitted reviews are written. */
22
+ const REVIEWS_DIR = path.join(__dirname, 'reviews');
23
+
24
+ /** A path inside the repository that names nothing, on disk or at HEAD. */
25
+ class FileNotFoundError extends Error {
26
+ /** @param {string} filePath */
27
+ constructor(filePath) {
28
+ super('File not found in this repository');
29
+ this.name = 'FileNotFoundError';
30
+ this.filePath = filePath;
31
+ }
32
+ }
33
+
34
+ /**
35
+ * Build the review server.
36
+ *
37
+ * Everything the server keeps — open sessions, where reviews are written — is
38
+ * passed in rather than reached for, so a test can point one instance at a
39
+ * temporary directory without disturbing another.
40
+ *
41
+ * @param {object} [options]
42
+ * @param {string} [options.reviewsDir] where comment and review files are written
43
+ * @param {SessionStore} [options.sessions]
44
+ * @param {(path: string) => import('simple-git').SimpleGit} [options.git] git factory, injectable for tests
45
+ * @returns {import('express').Express}
46
+ */
47
+ function createApp(options = {}) {
48
+ const {
49
+ reviewsDir = REVIEWS_DIR,
50
+ sessions = new SessionStore(),
51
+ git: gitFactory = simpleGit
52
+ } = options;
53
+
54
+ const app = express();
55
+
56
+ app.use(cors());
57
+ app.use(express.json({ limit: '10mb' }));
58
+ app.use(express.static(path.join(__dirname, 'public')));
59
+
60
+ app.locals.sessions = sessions;
61
+ app.locals.reviewsDir = reviewsDir;
62
+
63
+ /**
64
+ * Look up the session for a request, or answer 400.
65
+ *
66
+ * @param {import('express').Response} res
67
+ * @param {string} repoId
68
+ * @returns {{repoPath: string, mode: import('./lib/sessions').ReviewMode}|null}
69
+ */
70
+ function requireSession(res, repoId) {
71
+ const session = sessions.get(repoId);
72
+ if (!session) {
73
+ res.status(400).json({ error: 'Invalid repository ID' });
74
+ return null;
75
+ }
76
+ return session;
77
+ }
78
+
79
+ /**
80
+ * Read one file as of the session's review mode.
81
+ *
82
+ * In `lastCommit` mode the committed text is authoritative; a file added by
83
+ * that commit is not in its parent, so the working copy is the fallback.
84
+ *
85
+ * In `working` mode the working copy is authoritative — except for a file
86
+ * the review is about precisely because it was deleted, which is no longer
87
+ * on disk. Its content at HEAD is what the reviewer needs to see.
88
+ *
89
+ * @param {{repoPath: string, mode: string}} session
90
+ * @param {string} filePath repo-relative, already confined by the caller
91
+ * @returns {Promise<string>}
92
+ */
93
+ async function readFileForMode(session, filePath) {
94
+ const git = gitFactory(session.repoPath);
95
+ const absolutePath = resolveRepoFile(session.repoPath, filePath);
96
+
97
+ if (session.mode === 'lastCommit') {
98
+ try {
99
+ return await git.show([`HEAD:${filePath}`]);
100
+ } catch {
101
+ return await fs.readFile(absolutePath, 'utf-8').catch(() => '');
102
+ }
103
+ }
104
+
105
+ try {
106
+ return await fs.readFile(absolutePath, 'utf-8');
107
+ } catch (error) {
108
+ if (error.code !== 'ENOENT') throw error;
109
+ // Missing from disk means deleted — if HEAD has it. If HEAD does not
110
+ // either, the path simply names nothing and saying so beats answering
111
+ // with an empty file.
112
+ try {
113
+ return await git.show([`HEAD:${filePath}`]);
114
+ } catch {
115
+ throw new FileNotFoundError(filePath);
116
+ }
117
+ }
118
+ }
119
+
120
+ /**
121
+ * Whether the repository has any commit yet.
122
+ *
123
+ * A freshly `git init`ed repository has an unborn HEAD, and every command
124
+ * that names `HEAD` fails against it.
125
+ *
126
+ * @param {import('simple-git').SimpleGit} git
127
+ * @returns {Promise<boolean>}
128
+ */
129
+ async function hasCommits(git) {
130
+ try {
131
+ await git.raw(['rev-parse', '--verify', 'HEAD']);
132
+ return true;
133
+ } catch {
134
+ return false;
135
+ }
136
+ }
137
+
138
+ /**
139
+ * Diff text for one file, as of the session's review mode.
140
+ *
141
+ * With no commit to compare against there is nothing to diff, and an empty
142
+ * diff is what tells the parser to present the file as wholly new — which
143
+ * is exactly what it is.
144
+ *
145
+ * @param {import('simple-git').SimpleGit} git
146
+ * @param {{mode: string}} session
147
+ * @param {string} filePath
148
+ * @returns {Promise<string>}
149
+ */
150
+ async function diffForFile(git, session, filePath) {
151
+ if (session.mode === 'lastCommit') {
152
+ return git.diff(['HEAD~1', 'HEAD', '--', filePath]);
153
+ }
154
+ if (!(await hasCommits(git))) {
155
+ return '';
156
+ }
157
+ return git.diff(['HEAD', '--', filePath]);
158
+ }
159
+
160
+ /** Liveness probe, and a count of how many repositories are open. */
161
+ app.get('/api/health', (req, res) => {
162
+ res.json({ status: 'ok', sessions: sessions.size });
163
+ });
164
+
165
+ /** Open a repository and list what changed. */
166
+ app.post('/api/load-repo', async (req, res) => {
167
+ try {
168
+ const { repoPath } = req.body ?? {};
169
+
170
+ if (!repoPath) {
171
+ return res.status(400).json({ error: 'Repository path is required' });
172
+ }
173
+
174
+ try {
175
+ await fs.access(repoPath);
176
+ } catch {
177
+ return res.status(400).json({ error: 'Path does not exist' });
178
+ }
179
+
180
+ const git = gitFactory(repoPath);
181
+ if (!(await git.checkIsRepo())) {
182
+ return res.status(400).json({ error: 'Not a valid git repository' });
183
+ }
184
+
185
+ let files = collectWorkingChanges(await git.status());
186
+ let mode = 'working';
187
+ let message = `Found ${files.length} changed file(s)`;
188
+
189
+ // A clean tree has nothing to review, so fall back to the last commit.
190
+ if (files.length === 0) {
191
+ try {
192
+ const commitFiles = collectCommitChanges(await git.diffSummary(['HEAD~1', 'HEAD']));
193
+ if (commitFiles.length > 0) {
194
+ files = commitFiles;
195
+ mode = 'lastCommit';
196
+ message = `No working directory changes. Loaded last commit with ${files.length} changed file(s)`;
197
+ }
198
+ } catch (error) {
199
+ // A repository with no commits, or only one, has no parent to diff
200
+ // against. An empty file list is the correct answer there.
201
+ console.error('Error loading last commit:', error.message);
202
+ }
203
+ }
204
+
205
+ const repoId = sessions.create(repoPath, mode);
206
+ console.log(`Loaded repository: ${repoPath} (mode: ${mode})`);
207
+
208
+ res.json({ repoId, files, repoPath, mode, message });
209
+ } catch (error) {
210
+ console.error('Load repo error:', error);
211
+ res.status(500).json({ error: error.message });
212
+ }
213
+ });
214
+
215
+ /** Diff of one file, as structured lines. */
216
+ app.get('/api/file/:repoId/:filePath(*)', async (req, res) => {
217
+ try {
218
+ const { repoId, filePath } = req.params;
219
+ const session = requireSession(res, repoId);
220
+ if (!session) return;
221
+
222
+ const git = gitFactory(session.repoPath);
223
+ const content = await readFileForMode(session, filePath);
224
+ const diffText = await diffForFile(git, session, filePath);
225
+
226
+ res.json({ filePath, diffLines: parseDiff(diffText, content) });
227
+ } catch (error) {
228
+ if (error instanceof PathEscapeError) {
229
+ return res.status(400).json({ error: error.message });
230
+ }
231
+ if (error instanceof FileNotFoundError) {
232
+ return res.status(404).json({ error: error.message });
233
+ }
234
+ console.error('File read error:', error);
235
+ res.status(500).json({ error: error.message });
236
+ }
237
+ });
238
+
239
+ /** Whole file, for showing context around a comment. */
240
+ app.get('/api/file-full/:repoId/:filePath(*)', async (req, res) => {
241
+ try {
242
+ const { repoId, filePath } = req.params;
243
+ const session = requireSession(res, repoId);
244
+ if (!session) return;
245
+
246
+ const content = await readFileForMode(session, filePath);
247
+ res.json({ filePath, lines: content.split('\n') });
248
+ } catch (error) {
249
+ if (error instanceof PathEscapeError) {
250
+ return res.status(400).json({ error: error.message });
251
+ }
252
+ if (error instanceof FileNotFoundError) {
253
+ return res.status(404).json({ error: error.message });
254
+ }
255
+ console.error('File read error:', error);
256
+ res.status(500).json({ error: error.message });
257
+ }
258
+ });
259
+
260
+ /** Persist the current comments. Called on every edit. */
261
+ app.post('/api/save-comments', async (req, res) => {
262
+ try {
263
+ const { repoId, comments } = req.body ?? {};
264
+ const session = requireSession(res, repoId);
265
+ if (!session) return;
266
+
267
+ let normalized;
268
+ try {
269
+ normalized = normalizeComments(comments);
270
+ } catch (error) {
271
+ return res.status(400).json({ error: error.message });
272
+ }
273
+
274
+ await fs.mkdir(reviewsDir, { recursive: true });
275
+
276
+ const data = {
277
+ repoPath: session.repoPath,
278
+ lastUpdated: new Date().toISOString(),
279
+ comments: normalized
280
+ };
281
+
282
+ const target = path.join(reviewsDir, commentsFilename(session.repoPath));
283
+ await fs.writeFile(target, `${JSON.stringify(data, null, 2)}\n`);
284
+
285
+ res.json({ message: 'Comments saved successfully' });
286
+ } catch (error) {
287
+ console.error('Save comments error:', error);
288
+ res.status(500).json({ error: error.message });
289
+ }
290
+ });
291
+
292
+ /** Restore comments saved by an earlier session. */
293
+ app.get('/api/load-comments/:repoId', async (req, res) => {
294
+ try {
295
+ const session = requireSession(res, req.params.repoId);
296
+ if (!session) return;
297
+
298
+ const { comments } = await readSavedComments(reviewsDir, session.repoPath);
299
+ res.json({ comments });
300
+ } catch (error) {
301
+ if (error instanceof ReviewOwnershipError) {
302
+ return res.status(409).json({ error: error.message });
303
+ }
304
+ console.error('Load comments error:', error);
305
+ res.status(500).json({ error: error.message });
306
+ }
307
+ });
308
+
309
+ /** Render the saved comments into a timestamped review file. */
310
+ app.post('/api/submit-review', async (req, res) => {
311
+ try {
312
+ const { repoId } = req.body ?? {};
313
+ const session = requireSession(res, repoId);
314
+ if (!session) return;
315
+
316
+ // The JSON file is the source of truth, not whatever the client holds:
317
+ // submitting renders exactly what was last saved.
318
+ const { comments } = await readSavedComments(reviewsDir, session.repoPath);
319
+
320
+ if (comments.length === 0) {
321
+ return res.status(400).json({
322
+ error: 'No comments found. Please add comments before submitting review.'
323
+ });
324
+ }
325
+
326
+ const generatedAt = new Date();
327
+ const reviewContent = formatReview({ repoPath: session.repoPath, comments, generatedAt });
328
+ const filename = reviewFilename(session.repoPath, generatedAt);
329
+
330
+ // The commit is worth recording for anything acting on the review later:
331
+ // it is how a consumer tells whether the tree has moved on since.
332
+ const git = gitFactory(session.repoPath);
333
+ const head = await git.revparse(['HEAD']).then(sha => sha.trim()).catch(() => null);
334
+ const branch = await git.revparse(['--abbrev-ref', 'HEAD']).then(name => name.trim()).catch(() => null);
335
+
336
+ const document = buildReviewDocument({
337
+ repoPath: session.repoPath,
338
+ comments,
339
+ generatedAt,
340
+ head,
341
+ branch,
342
+ mode: session.mode
343
+ });
344
+ const documentFilename = filename.replace(/\.txt$/, '.json');
345
+
346
+ await fs.mkdir(reviewsDir, { recursive: true });
347
+ await fs.writeFile(path.join(reviewsDir, filename), reviewContent);
348
+ // Written alongside the .txt so a review is available to a tool without
349
+ // anyone having to re-run anything.
350
+ await fs.writeFile(
351
+ path.join(reviewsDir, documentFilename),
352
+ `${JSON.stringify(document, null, 2)}\n`
353
+ );
354
+
355
+ console.log(`Review generated: ${filename} (${comments.length} comments)`);
356
+
357
+ res.json({
358
+ message: 'Review submitted successfully',
359
+ reviewContent,
360
+ filename,
361
+ documentFilename,
362
+ review: document,
363
+ totalComments: comments.length
364
+ });
365
+ } catch (error) {
366
+ if (error instanceof ReviewOwnershipError) {
367
+ return res.status(409).json({ error: error.message });
368
+ }
369
+ console.error('Submit error:', error);
370
+ res.status(500).json({ error: error.message });
371
+ }
372
+ });
373
+
374
+ /** Drop a session. Saved comments on disk are left alone. */
375
+ app.delete('/api/cleanup/:repoId', (req, res) => {
376
+ sessions.delete(req.params.repoId);
377
+ res.json({ message: 'Session cleaned up' });
378
+ });
379
+
380
+ return app;
381
+ }
382
+
383
+ /**
384
+ * Start the server.
385
+ *
386
+ * Binds to loopback unless told otherwise. The server reads any file in the
387
+ * repository under review, so reaching it should require being on the machine
388
+ * running it; set `HOST=0.0.0.0` to opt out deliberately.
389
+ *
390
+ * @param {object} [options] forwarded to {@link createApp}, plus `port` and `host`
391
+ * @returns {Promise<import('http').Server>} a listening server
392
+ */
393
+ function startServer(options = {}) {
394
+ const {
395
+ port = process.env.PORT || DEFAULT_PORT,
396
+ host = process.env.HOST || DEFAULT_HOST,
397
+ silent = false,
398
+ ...appOptions
399
+ } = options;
400
+ const app = createApp(appOptions);
401
+
402
+ return new Promise((resolve, reject) => {
403
+ const server = app.listen(port, host, () => {
404
+ if (!silent) {
405
+ console.log(`Code Reviewer server running on http://${host}:${server.address().port}`);
406
+ }
407
+ resolve(server);
408
+ });
409
+ server.on('error', reject);
410
+ });
411
+ }
412
+
413
+ // Only listen when run directly; `require`ing this file just builds the app.
414
+ if (require.main === module) {
415
+ startServer().catch(error => {
416
+ console.error('Failed to start server:', error);
417
+ process.exit(1);
418
+ });
419
+ }
420
+
421
+ module.exports = { createApp, startServer, DEFAULT_PORT, DEFAULT_HOST, REVIEWS_DIR };