crloop 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 +66 -0
- package/dist/client/assets/index-BY1o75E8.js +14 -0
- package/dist/client/assets/index-QcmHr2Xi.css +1 -0
- package/dist/client/favicon.svg +42 -0
- package/dist/client/index.html +17 -0
- package/dist/server/server/args.js +51 -0
- package/dist/server/server/assetPaths.js +17 -0
- package/dist/server/server/cli.js +377 -0
- package/dist/server/server/commentStore.js +190 -0
- package/dist/server/server/commentStore.test.js +34 -0
- package/dist/server/server/devServer.js +2 -0
- package/dist/server/server/diffParser.js +234 -0
- package/dist/server/server/diffParser.test.js +71 -0
- package/dist/server/server/errors.js +6 -0
- package/dist/server/server/exporter.js +4 -0
- package/dist/server/server/exporter.test.js +47 -0
- package/dist/server/server/git.js +11 -0
- package/dist/server/server/hash.js +4 -0
- package/dist/server/server/index.js +2 -0
- package/dist/server/server/platformPaths.js +12 -0
- package/dist/server/server/reviewService.js +202 -0
- package/dist/server/server/runServer.js +15 -0
- package/dist/server/server/server.js +235 -0
- package/dist/server/server/server.test.js +145 -0
- package/dist/server/server/testUtils.js +25 -0
- package/dist/server/shared/api.js +1 -0
- package/dist/server/shared/changePaths.js +3 -0
- package/dist/server/shared/export.js +26 -0
- package/dist/server/shared/types.js +1 -0
- package/package.json +48 -0
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
export const REVIEW_STORAGE_DIRECTORY = ".local-code-review";
|
|
5
|
+
export function getReviewSessionFileName(reviewBaseShortId) {
|
|
6
|
+
return `${reviewBaseShortId}.json`;
|
|
7
|
+
}
|
|
8
|
+
export class CommentStore {
|
|
9
|
+
sessionFileName;
|
|
10
|
+
storageDir;
|
|
11
|
+
constructor(repoPath, sessionFileName = getReviewSessionFileName("HEAD"), storageDir = path.join(repoPath, REVIEW_STORAGE_DIRECTORY)) {
|
|
12
|
+
this.sessionFileName = sessionFileName;
|
|
13
|
+
this.storageDir = storageDir;
|
|
14
|
+
}
|
|
15
|
+
async list() {
|
|
16
|
+
const storedComments = await this.read();
|
|
17
|
+
return flattenStoredComments(storedComments).map(toReviewComment);
|
|
18
|
+
}
|
|
19
|
+
async create(input) {
|
|
20
|
+
const storedComments = await this.read();
|
|
21
|
+
const commentsForPath = storedComments[input.path] ?? [];
|
|
22
|
+
const storedComment = {
|
|
23
|
+
id: createCommentId(),
|
|
24
|
+
side: input.side,
|
|
25
|
+
line: input.lineNumber,
|
|
26
|
+
body: input.body,
|
|
27
|
+
diffFingerprint: input.diffFingerprint
|
|
28
|
+
};
|
|
29
|
+
commentsForPath.push(storedComment);
|
|
30
|
+
storedComments[input.path] = commentsForPath;
|
|
31
|
+
await this.write(storedComments);
|
|
32
|
+
return toReviewComment({
|
|
33
|
+
path: input.path,
|
|
34
|
+
comment: storedComment
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
async update(commentId, body) {
|
|
38
|
+
const storedComments = await this.read();
|
|
39
|
+
const record = findStoredComment(storedComments, commentId);
|
|
40
|
+
if (!record) {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
const updated = {
|
|
44
|
+
...record.comment,
|
|
45
|
+
body
|
|
46
|
+
};
|
|
47
|
+
storedComments[record.path][record.index] = updated;
|
|
48
|
+
await this.write(storedComments);
|
|
49
|
+
return toReviewComment({
|
|
50
|
+
path: record.path,
|
|
51
|
+
comment: updated
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
async delete(commentId) {
|
|
55
|
+
const storedComments = await this.read();
|
|
56
|
+
const record = findStoredComment(storedComments, commentId);
|
|
57
|
+
if (!record) {
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
const nextComments = storedComments[record.path].filter((_comment, index) => index !== record.index);
|
|
61
|
+
if (nextComments.length === 0) {
|
|
62
|
+
delete storedComments[record.path];
|
|
63
|
+
}
|
|
64
|
+
else {
|
|
65
|
+
storedComments[record.path] = nextComments;
|
|
66
|
+
}
|
|
67
|
+
await this.write(storedComments);
|
|
68
|
+
return true;
|
|
69
|
+
}
|
|
70
|
+
async getSessionFilePath() {
|
|
71
|
+
await fs.mkdir(this.storageDir, { recursive: true });
|
|
72
|
+
return path.join(this.storageDir, this.sessionFileName);
|
|
73
|
+
}
|
|
74
|
+
async read() {
|
|
75
|
+
const sessionPath = await this.getSessionFilePath();
|
|
76
|
+
try {
|
|
77
|
+
const content = await fs.readFile(sessionPath, "utf8");
|
|
78
|
+
const normalized = normalizeStoredComments(JSON.parse(content));
|
|
79
|
+
if (normalized.didUpgrade) {
|
|
80
|
+
await this.write(normalized.comments);
|
|
81
|
+
}
|
|
82
|
+
return normalized.comments;
|
|
83
|
+
}
|
|
84
|
+
catch (error) {
|
|
85
|
+
if (error.code === "ENOENT") {
|
|
86
|
+
return {};
|
|
87
|
+
}
|
|
88
|
+
throw error;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
async write(storedComments) {
|
|
92
|
+
const sessionPath = await this.getSessionFilePath();
|
|
93
|
+
const sortedEntries = Object.entries(storedComments).sort(([left], [right]) => left.localeCompare(right));
|
|
94
|
+
const normalizedComments = Object.fromEntries(sortedEntries);
|
|
95
|
+
await fs.writeFile(sessionPath, `${JSON.stringify(normalizedComments, null, 2)}\n`, "utf8");
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
function flattenStoredComments(storedComments) {
|
|
99
|
+
return Object.entries(storedComments).flatMap(([path, comments]) => comments.map((comment) => ({
|
|
100
|
+
path,
|
|
101
|
+
comment
|
|
102
|
+
})));
|
|
103
|
+
}
|
|
104
|
+
function toReviewComment(entry) {
|
|
105
|
+
return {
|
|
106
|
+
commentId: entry.comment.id,
|
|
107
|
+
path: entry.path,
|
|
108
|
+
side: entry.comment.side,
|
|
109
|
+
lineNumber: entry.comment.line,
|
|
110
|
+
body: entry.comment.body,
|
|
111
|
+
diffFingerprint: entry.comment.diffFingerprint
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
function createCommentId() {
|
|
115
|
+
return randomUUID().replace(/-/g, "").slice(0, 12);
|
|
116
|
+
}
|
|
117
|
+
function findStoredComment(storedComments, commentId) {
|
|
118
|
+
for (const [filePath, comments] of Object.entries(storedComments)) {
|
|
119
|
+
for (const [index, comment] of comments.entries()) {
|
|
120
|
+
if (comment.id === commentId) {
|
|
121
|
+
return {
|
|
122
|
+
path: filePath,
|
|
123
|
+
index,
|
|
124
|
+
comment
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
function normalizeStoredComments(value) {
|
|
132
|
+
if (isLegacySessionFile(value)) {
|
|
133
|
+
return {
|
|
134
|
+
comments: value.comments.reduce((accumulator, comment) => {
|
|
135
|
+
const lineNumber = comment.side === "old" ? comment.oldLineNumber : comment.newLineNumber;
|
|
136
|
+
if (typeof lineNumber !== "number") {
|
|
137
|
+
return accumulator;
|
|
138
|
+
}
|
|
139
|
+
const commentsForPath = accumulator[comment.fileId] ?? [];
|
|
140
|
+
commentsForPath.push({
|
|
141
|
+
id: createCommentId(),
|
|
142
|
+
side: comment.side,
|
|
143
|
+
line: lineNumber,
|
|
144
|
+
body: comment.body,
|
|
145
|
+
diffFingerprint: comment.diffFingerprint
|
|
146
|
+
});
|
|
147
|
+
accumulator[comment.fileId] = commentsForPath;
|
|
148
|
+
return accumulator;
|
|
149
|
+
}, {}),
|
|
150
|
+
didUpgrade: true
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
if (!isStoredCommentsFile(value)) {
|
|
154
|
+
throw new Error("Invalid comments file");
|
|
155
|
+
}
|
|
156
|
+
let didUpgrade = false;
|
|
157
|
+
const comments = {};
|
|
158
|
+
for (const [filePath, storedComments] of Object.entries(value)) {
|
|
159
|
+
comments[filePath] = storedComments.map((comment) => {
|
|
160
|
+
const id = typeof comment.id === "string" && comment.id.length > 0 ? comment.id : createCommentId();
|
|
161
|
+
if (id !== comment.id) {
|
|
162
|
+
didUpgrade = true;
|
|
163
|
+
}
|
|
164
|
+
return {
|
|
165
|
+
...comment,
|
|
166
|
+
id
|
|
167
|
+
};
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
return {
|
|
171
|
+
comments,
|
|
172
|
+
didUpgrade
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
function isLegacySessionFile(value) {
|
|
176
|
+
return typeof value === "object" && value !== null && Array.isArray(value.comments);
|
|
177
|
+
}
|
|
178
|
+
function isStoredCommentsFile(value) {
|
|
179
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
180
|
+
return false;
|
|
181
|
+
}
|
|
182
|
+
return Object.values(value).every((comments) => Array.isArray(comments) &&
|
|
183
|
+
comments.every((comment) => typeof comment === "object" &&
|
|
184
|
+
comment !== null &&
|
|
185
|
+
(typeof comment.id === "string" || comment.id === undefined) &&
|
|
186
|
+
(comment.side === "old" || comment.side === "new") &&
|
|
187
|
+
typeof comment.line === "number" &&
|
|
188
|
+
typeof comment.body === "string" &&
|
|
189
|
+
typeof comment.diffFingerprint === "string"));
|
|
190
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
5
|
+
import { CommentStore } from "./commentStore.js";
|
|
6
|
+
const createdDirectories = [];
|
|
7
|
+
afterEach(async () => {
|
|
8
|
+
await Promise.all(createdDirectories.splice(0).map((directory) => fs.rm(directory, { recursive: true, force: true })));
|
|
9
|
+
});
|
|
10
|
+
describe("CommentStore", () => {
|
|
11
|
+
it("persists comments in a deterministic session file", async () => {
|
|
12
|
+
const storageDir = await fs.mkdtemp(path.join(os.tmpdir(), "comment-store-"));
|
|
13
|
+
createdDirectories.push(storageDir);
|
|
14
|
+
const store = new CommentStore("/tmp/repo", storageDir);
|
|
15
|
+
const created = await store.create({
|
|
16
|
+
fileId: "change-1",
|
|
17
|
+
side: "new",
|
|
18
|
+
oldLineNumber: null,
|
|
19
|
+
newLineNumber: 4,
|
|
20
|
+
hunkHeader: "@@ -1,1 +1,2 @@",
|
|
21
|
+
body: "Looks good",
|
|
22
|
+
diffFingerprint: "fingerprint-1"
|
|
23
|
+
});
|
|
24
|
+
const comments = await store.list();
|
|
25
|
+
const sessionFiles = await fs.readdir(storageDir);
|
|
26
|
+
expect(sessionFiles).toHaveLength(1);
|
|
27
|
+
expect(comments).toHaveLength(1);
|
|
28
|
+
expect(comments[0]).toMatchObject({
|
|
29
|
+
commentId: created.commentId,
|
|
30
|
+
body: "Looks good",
|
|
31
|
+
createdAt: created.createdAt
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
});
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import { getChangePath } from "../shared/changePaths.js";
|
|
2
|
+
import { sha256 } from "./hash.js";
|
|
3
|
+
const HUNK_RE = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(.*)$/;
|
|
4
|
+
export function parseTrackedDiff(patch) {
|
|
5
|
+
const state = {
|
|
6
|
+
current: null,
|
|
7
|
+
currentHunk: null,
|
|
8
|
+
files: []
|
|
9
|
+
};
|
|
10
|
+
for (const line of patch.split("\n")) {
|
|
11
|
+
if (line.startsWith("diff --git ")) {
|
|
12
|
+
finalizeHunk(state);
|
|
13
|
+
finalizeFile(state);
|
|
14
|
+
state.current = createFileFromHeader(line);
|
|
15
|
+
continue;
|
|
16
|
+
}
|
|
17
|
+
if (!state.current) {
|
|
18
|
+
continue;
|
|
19
|
+
}
|
|
20
|
+
if (line.startsWith("old mode ") || line.startsWith("new mode ") || line.startsWith("similarity index ")) {
|
|
21
|
+
appendFingerprintLine(state.current, line);
|
|
22
|
+
continue;
|
|
23
|
+
}
|
|
24
|
+
if (line.startsWith("rename from ")) {
|
|
25
|
+
state.current.changeType = "renamed";
|
|
26
|
+
state.current.oldPath = line.slice("rename from ".length);
|
|
27
|
+
appendFingerprintLine(state.current, line);
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
if (line.startsWith("rename to ")) {
|
|
31
|
+
state.current.changeType = "renamed";
|
|
32
|
+
state.current.newPath = line.slice("rename to ".length);
|
|
33
|
+
appendFingerprintLine(state.current, line);
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
if (line.startsWith("new file mode ")) {
|
|
37
|
+
state.current.changeType = "added";
|
|
38
|
+
appendFingerprintLine(state.current, line);
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
if (line.startsWith("deleted file mode ")) {
|
|
42
|
+
state.current.changeType = "deleted";
|
|
43
|
+
appendFingerprintLine(state.current, line);
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
if (line.startsWith("Binary files ") || line === "GIT binary patch") {
|
|
47
|
+
state.current.isBinary = true;
|
|
48
|
+
appendFingerprintLine(state.current, line);
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
if (line.startsWith("index ") || line.startsWith("--- ") || line.startsWith("+++ ")) {
|
|
52
|
+
updatePathsFromPatchLine(state.current, line);
|
|
53
|
+
appendFingerprintLine(state.current, line);
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
if (line.startsWith("@@ ")) {
|
|
57
|
+
finalizeHunk(state);
|
|
58
|
+
state.currentHunk = createHunk(line);
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
if (state.currentHunk && isDiffBodyLine(line)) {
|
|
62
|
+
const diffLine = parseDiffLine(line, state.currentHunk);
|
|
63
|
+
state.currentHunk.lines.push(diffLine);
|
|
64
|
+
if (diffLine.kind !== "context") {
|
|
65
|
+
appendFingerprintLine(state.current, normalizeDiffLine(diffLine));
|
|
66
|
+
}
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
if (line === "\") {
|
|
70
|
+
appendFingerprintLine(state.current, line);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
finalizeHunk(state);
|
|
74
|
+
finalizeFile(state);
|
|
75
|
+
return state.files
|
|
76
|
+
.map(({ rawFingerprintLines, ...file }) => ({
|
|
77
|
+
...file,
|
|
78
|
+
diffFingerprint: sha256(rawFingerprintLines.join("\n"))
|
|
79
|
+
}))
|
|
80
|
+
.sort((left, right) => getChangePath(left).localeCompare(getChangePath(right)));
|
|
81
|
+
}
|
|
82
|
+
export function createUntrackedChange(filePath, content) {
|
|
83
|
+
const lines = content.split(/\r?\n/);
|
|
84
|
+
const visibleLines = lines.at(-1) === "" ? lines.slice(0, -1) : lines;
|
|
85
|
+
const hunkHeader = `@@ -0,0 +1,${Math.max(visibleLines.length, 0)} @@`;
|
|
86
|
+
const diffLines = visibleLines.map((text, index) => ({
|
|
87
|
+
kind: "added",
|
|
88
|
+
oldLineNumber: null,
|
|
89
|
+
newLineNumber: index + 1,
|
|
90
|
+
text,
|
|
91
|
+
commentableSide: "new"
|
|
92
|
+
}));
|
|
93
|
+
const fingerprintSource = [`untracked:${filePath}`, hunkHeader, ...diffLines.map(normalizeDiffLine)].join("\n");
|
|
94
|
+
const changeId = sha256(`untracked:${filePath}`);
|
|
95
|
+
return {
|
|
96
|
+
changeId,
|
|
97
|
+
changeType: "untracked",
|
|
98
|
+
oldPath: null,
|
|
99
|
+
newPath: filePath,
|
|
100
|
+
isBinary: false,
|
|
101
|
+
diffFingerprint: sha256(fingerprintSource),
|
|
102
|
+
hunks: [
|
|
103
|
+
{
|
|
104
|
+
header: hunkHeader,
|
|
105
|
+
lines: diffLines
|
|
106
|
+
}
|
|
107
|
+
]
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
export function createBinaryUntrackedChange(filePath, content) {
|
|
111
|
+
return {
|
|
112
|
+
changeId: sha256(`untracked:${filePath}`),
|
|
113
|
+
changeType: "untracked",
|
|
114
|
+
oldPath: null,
|
|
115
|
+
newPath: filePath,
|
|
116
|
+
isBinary: true,
|
|
117
|
+
diffFingerprint: sha256(`untracked-binary:${filePath}:${content.toString("base64")}`),
|
|
118
|
+
hunks: []
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
function createFileFromHeader(line) {
|
|
122
|
+
const match = /^diff --git a\/(.*) b\/(.*)$/.exec(line);
|
|
123
|
+
const oldPath = match?.[1] ?? null;
|
|
124
|
+
const newPath = match?.[2] ?? null;
|
|
125
|
+
return {
|
|
126
|
+
changeId: sha256(`tracked:${oldPath ?? ""}:${newPath ?? ""}`),
|
|
127
|
+
changeType: inferInitialChangeType(oldPath, newPath),
|
|
128
|
+
oldPath,
|
|
129
|
+
newPath,
|
|
130
|
+
isBinary: false,
|
|
131
|
+
hunks: [],
|
|
132
|
+
rawFingerprintLines: [line]
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
function inferInitialChangeType(oldPath, newPath) {
|
|
136
|
+
if (!oldPath && newPath) {
|
|
137
|
+
return "added";
|
|
138
|
+
}
|
|
139
|
+
if (oldPath && !newPath) {
|
|
140
|
+
return "deleted";
|
|
141
|
+
}
|
|
142
|
+
return "modified";
|
|
143
|
+
}
|
|
144
|
+
function finalizeHunk(state) {
|
|
145
|
+
if (!state.current || !state.currentHunk || state.current.isBinary) {
|
|
146
|
+
state.currentHunk = null;
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
state.current.hunks.push({
|
|
150
|
+
header: state.currentHunk.header,
|
|
151
|
+
lines: state.currentHunk.lines
|
|
152
|
+
});
|
|
153
|
+
state.currentHunk = null;
|
|
154
|
+
}
|
|
155
|
+
function finalizeFile(state) {
|
|
156
|
+
if (!state.current) {
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
state.current.changeId = sha256(`${state.current.changeType}:${state.current.oldPath ?? ""}:${state.current.newPath ?? ""}`);
|
|
160
|
+
state.files.push(state.current);
|
|
161
|
+
state.current = null;
|
|
162
|
+
}
|
|
163
|
+
function appendFingerprintLine(file, line) {
|
|
164
|
+
file.rawFingerprintLines.push(line);
|
|
165
|
+
}
|
|
166
|
+
function updatePathsFromPatchLine(file, line) {
|
|
167
|
+
if (!line.startsWith("--- ") && !line.startsWith("+++ ")) {
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
const rawPath = line.slice(4).trim();
|
|
171
|
+
const parsed = rawPath === "/dev/null" ? null : stripPatchPrefix(rawPath);
|
|
172
|
+
if (line.startsWith("--- ")) {
|
|
173
|
+
file.oldPath = parsed;
|
|
174
|
+
}
|
|
175
|
+
else {
|
|
176
|
+
file.newPath = parsed;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
function stripPatchPrefix(value) {
|
|
180
|
+
return value.replace(/^[ab]\//, "");
|
|
181
|
+
}
|
|
182
|
+
function createHunk(headerLine) {
|
|
183
|
+
const match = HUNK_RE.exec(headerLine);
|
|
184
|
+
if (!match) {
|
|
185
|
+
throw new Error(`Invalid hunk header: ${headerLine}`);
|
|
186
|
+
}
|
|
187
|
+
return {
|
|
188
|
+
header: `@@ -${match[1]}${match[2] ? `,${match[2]}` : ""} +${match[3]}${match[4] ? `,${match[4]}` : ""} @@${match[5]}`,
|
|
189
|
+
lines: [],
|
|
190
|
+
oldLineNumber: Number(match[1]),
|
|
191
|
+
newLineNumber: Number(match[3])
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
function isDiffBodyLine(line) {
|
|
195
|
+
return line.startsWith(" ") || line.startsWith("+") || line.startsWith("-");
|
|
196
|
+
}
|
|
197
|
+
function parseDiffLine(line, hunk) {
|
|
198
|
+
const text = line.slice(1);
|
|
199
|
+
if (line.startsWith(" ")) {
|
|
200
|
+
const diffLine = {
|
|
201
|
+
kind: "context",
|
|
202
|
+
oldLineNumber: hunk.oldLineNumber,
|
|
203
|
+
newLineNumber: hunk.newLineNumber,
|
|
204
|
+
text,
|
|
205
|
+
commentableSide: null
|
|
206
|
+
};
|
|
207
|
+
hunk.oldLineNumber += 1;
|
|
208
|
+
hunk.newLineNumber += 1;
|
|
209
|
+
return diffLine;
|
|
210
|
+
}
|
|
211
|
+
if (line.startsWith("+")) {
|
|
212
|
+
const diffLine = {
|
|
213
|
+
kind: "added",
|
|
214
|
+
oldLineNumber: null,
|
|
215
|
+
newLineNumber: hunk.newLineNumber,
|
|
216
|
+
text,
|
|
217
|
+
commentableSide: "new"
|
|
218
|
+
};
|
|
219
|
+
hunk.newLineNumber += 1;
|
|
220
|
+
return diffLine;
|
|
221
|
+
}
|
|
222
|
+
const diffLine = {
|
|
223
|
+
kind: "removed",
|
|
224
|
+
oldLineNumber: hunk.oldLineNumber,
|
|
225
|
+
newLineNumber: null,
|
|
226
|
+
text,
|
|
227
|
+
commentableSide: "old"
|
|
228
|
+
};
|
|
229
|
+
hunk.oldLineNumber += 1;
|
|
230
|
+
return diffLine;
|
|
231
|
+
}
|
|
232
|
+
function normalizeDiffLine(line) {
|
|
233
|
+
return [line.kind, line.oldLineNumber ?? "-", line.newLineNumber ?? "-", line.text].join("|");
|
|
234
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { createBinaryUntrackedChange, createUntrackedChange, parseTrackedDiff } from "./diffParser.js";
|
|
3
|
+
describe("parseTrackedDiff", () => {
|
|
4
|
+
it("parses tracked text diffs with stable line numbers and commentable sides", () => {
|
|
5
|
+
const patch = [
|
|
6
|
+
"diff --git a/tracked.txt b/tracked.txt",
|
|
7
|
+
"index 83db48f..bf8a6f4 100644",
|
|
8
|
+
"--- a/tracked.txt",
|
|
9
|
+
"+++ b/tracked.txt",
|
|
10
|
+
"@@ -1,2 +1,2 @@",
|
|
11
|
+
"-before",
|
|
12
|
+
"+after",
|
|
13
|
+
" stay"
|
|
14
|
+
].join("\n");
|
|
15
|
+
const [change] = parseTrackedDiff(patch);
|
|
16
|
+
expect(change.changeType).toBe("modified");
|
|
17
|
+
expect(change.hunks).toHaveLength(1);
|
|
18
|
+
expect(change.hunks[0].lines).toEqual([
|
|
19
|
+
{
|
|
20
|
+
kind: "removed",
|
|
21
|
+
oldLineNumber: 1,
|
|
22
|
+
newLineNumber: null,
|
|
23
|
+
text: "before",
|
|
24
|
+
commentableSide: "old"
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
kind: "added",
|
|
28
|
+
oldLineNumber: null,
|
|
29
|
+
newLineNumber: 1,
|
|
30
|
+
text: "after",
|
|
31
|
+
commentableSide: "new"
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
kind: "context",
|
|
35
|
+
oldLineNumber: 2,
|
|
36
|
+
newLineNumber: 2,
|
|
37
|
+
text: "stay",
|
|
38
|
+
commentableSide: null
|
|
39
|
+
}
|
|
40
|
+
]);
|
|
41
|
+
});
|
|
42
|
+
it("marks binary tracked diffs as non-commentable", () => {
|
|
43
|
+
const patch = [
|
|
44
|
+
"diff --git a/image.png b/image.png",
|
|
45
|
+
"index 1111111..2222222 100644",
|
|
46
|
+
"Binary files a/image.png and b/image.png differ"
|
|
47
|
+
].join("\n");
|
|
48
|
+
const [change] = parseTrackedDiff(patch);
|
|
49
|
+
expect(change.isBinary).toBe(true);
|
|
50
|
+
expect(change.hunks).toEqual([]);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
describe("untracked changes", () => {
|
|
54
|
+
it("creates text hunks for untracked files", () => {
|
|
55
|
+
const change = createUntrackedChange("notes.txt", "first\nsecond\n");
|
|
56
|
+
expect(change.changeType).toBe("untracked");
|
|
57
|
+
expect(change.hunks[0].header).toBe("@@ -0,0 +1,2 @@");
|
|
58
|
+
expect(change.hunks[0].lines[1]).toEqual({
|
|
59
|
+
kind: "added",
|
|
60
|
+
oldLineNumber: null,
|
|
61
|
+
newLineNumber: 2,
|
|
62
|
+
text: "second",
|
|
63
|
+
commentableSide: "new"
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
it("creates non-commentable binary untracked changes", () => {
|
|
67
|
+
const change = createBinaryUntrackedChange("blob.bin", Buffer.from([0, 1, 2]));
|
|
68
|
+
expect(change.isBinary).toBe(true);
|
|
69
|
+
expect(change.hunks).toEqual([]);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { renderCommentsMarkdown } from "./exporter.js";
|
|
3
|
+
describe("renderCommentsMarkdown", () => {
|
|
4
|
+
it("renders comments in stable file and line order", () => {
|
|
5
|
+
const markdown = renderCommentsMarkdown("/repo", [
|
|
6
|
+
{
|
|
7
|
+
change: null,
|
|
8
|
+
path: "b.ts",
|
|
9
|
+
current: [
|
|
10
|
+
{
|
|
11
|
+
commentId: "2",
|
|
12
|
+
fileId: "b",
|
|
13
|
+
side: "new",
|
|
14
|
+
oldLineNumber: null,
|
|
15
|
+
newLineNumber: 10,
|
|
16
|
+
hunkHeader: "@@ -1,1 +1,1 @@",
|
|
17
|
+
body: "Second",
|
|
18
|
+
createdAt: "2026-03-10T10:00:00.000Z",
|
|
19
|
+
diffFingerprint: "fp"
|
|
20
|
+
}
|
|
21
|
+
],
|
|
22
|
+
outdated: []
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
change: null,
|
|
26
|
+
path: "a.ts",
|
|
27
|
+
current: [
|
|
28
|
+
{
|
|
29
|
+
commentId: "1",
|
|
30
|
+
fileId: "a",
|
|
31
|
+
side: "old",
|
|
32
|
+
oldLineNumber: 3,
|
|
33
|
+
newLineNumber: null,
|
|
34
|
+
hunkHeader: "@@ -3,1 +3,0 @@",
|
|
35
|
+
body: "First",
|
|
36
|
+
createdAt: "2026-03-10T09:00:00.000Z",
|
|
37
|
+
diffFingerprint: "fp"
|
|
38
|
+
}
|
|
39
|
+
],
|
|
40
|
+
outdated: []
|
|
41
|
+
}
|
|
42
|
+
]);
|
|
43
|
+
expect(markdown.indexOf("## File: a.ts")).toBeLessThan(markdown.indexOf("## File: b.ts"));
|
|
44
|
+
expect(markdown).toContain("Status: current");
|
|
45
|
+
expect(markdown).toContain("Body:\nFirst");
|
|
46
|
+
});
|
|
47
|
+
});
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
const execFileAsync = promisify(execFile);
|
|
4
|
+
export async function runGit(repoPath, args) {
|
|
5
|
+
const { stdout } = await execFileAsync("git", args, {
|
|
6
|
+
cwd: repoPath,
|
|
7
|
+
encoding: "utf8",
|
|
8
|
+
maxBuffer: 20 * 1024 * 1024
|
|
9
|
+
});
|
|
10
|
+
return stdout;
|
|
11
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import os from "node:os";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
export function getAppDataDirectory() {
|
|
4
|
+
const home = os.homedir();
|
|
5
|
+
if (process.platform === "darwin") {
|
|
6
|
+
return path.join(home, "Library", "Application Support", "local-review-tool");
|
|
7
|
+
}
|
|
8
|
+
if (process.platform === "win32") {
|
|
9
|
+
return path.join(process.env.APPDATA ?? path.join(home, "AppData", "Roaming"), "local-review-tool");
|
|
10
|
+
}
|
|
11
|
+
return path.join(process.env.XDG_DATA_HOME ?? path.join(home, ".local", "share"), "local-review-tool");
|
|
12
|
+
}
|