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.
@@ -0,0 +1,202 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { getChangePath } from "../shared/changePaths.js";
4
+ import { CommentStore, REVIEW_STORAGE_DIRECTORY, getReviewSessionFileName } from "./commentStore.js";
5
+ import { createBinaryUntrackedChange, createUntrackedChange, parseTrackedDiff } from "./diffParser.js";
6
+ import { renderReviewCommentsText } from "../shared/export.js";
7
+ import { runGit } from "./git.js";
8
+ import { ClientError } from "./errors.js";
9
+ const SUMMARY_CONTEXT = "0";
10
+ const FULL_CONTEXT_LINES = 1_000_000;
11
+ export class ReviewService {
12
+ repoPath;
13
+ commentStore;
14
+ constructor(repoPath) {
15
+ this.repoPath = repoPath;
16
+ this.commentStore = new CommentStore(repoPath);
17
+ }
18
+ async validateRepository() {
19
+ await this.syncReviewSession();
20
+ }
21
+ async getRepoInfo() {
22
+ await this.syncReviewSession();
23
+ return {
24
+ path: this.repoPath,
25
+ baseRef: "HEAD"
26
+ };
27
+ }
28
+ async getChangeSummaries() {
29
+ await this.syncReviewSession();
30
+ const [changes, comments] = await Promise.all([this.getChanges(), this.commentStore.list()]);
31
+ return changes.map((change) => {
32
+ const classified = classifyCommentsForChange(comments, change);
33
+ return {
34
+ changeId: change.changeId,
35
+ changeType: change.changeType,
36
+ oldPath: change.oldPath,
37
+ newPath: change.newPath,
38
+ isBinary: change.isBinary,
39
+ commentCounts: {
40
+ current: classified.current.length,
41
+ outdated: classified.outdated.length
42
+ }
43
+ };
44
+ });
45
+ }
46
+ async getChange(changeId, context = SUMMARY_CONTEXT) {
47
+ await this.syncReviewSession();
48
+ const changes = await this.getChanges(context);
49
+ return changes.find((change) => change.changeId === changeId) ?? null;
50
+ }
51
+ async getComments(changeId) {
52
+ await this.syncReviewSession();
53
+ const [changes, comments] = await Promise.all([
54
+ this.getChanges(SUMMARY_CONTEXT),
55
+ this.commentStore.list()
56
+ ]);
57
+ const change = changes.find((c) => c.changeId === changeId);
58
+ if (!change) {
59
+ throw new ClientError("Unknown changeId");
60
+ }
61
+ return classifyCommentsForChange(comments, change);
62
+ }
63
+ async createComment(request) {
64
+ await this.syncReviewSession();
65
+ const changes = await this.getChanges("full");
66
+ const change = changes.find((c) => c.changeId === request.changeId);
67
+ if (!change) {
68
+ throw new ClientError("Unknown changeId");
69
+ }
70
+ const isValidAnchor = change.hunks.some((hunk) => hunk.lines.some((line) => (line.commentableSide === request.side || (line.kind === "context" && (request.side === "old" || request.side === "new"))) &&
71
+ getLineNumberForSide(line, request.side) === request.lineNumber));
72
+ if (!isValidAnchor) {
73
+ throw new ClientError("Comment anchor is not commentable in the current diff");
74
+ }
75
+ return this.commentStore.create({
76
+ path: getChangePath(change),
77
+ side: request.side,
78
+ lineNumber: request.lineNumber,
79
+ body: request.body.trim(),
80
+ diffFingerprint: change.diffFingerprint
81
+ });
82
+ }
83
+ async updateComment(commentId, body) {
84
+ await this.syncReviewSession();
85
+ return this.commentStore.update(commentId, body.trim());
86
+ }
87
+ async deleteComment(commentId) {
88
+ await this.syncReviewSession();
89
+ return this.commentStore.delete(commentId);
90
+ }
91
+ async exportComments() {
92
+ await this.syncReviewSession();
93
+ const [changes, comments] = await Promise.all([this.getChanges(), this.commentStore.list()]);
94
+ const matchedFileIds = new Set();
95
+ const files = changes
96
+ .map((change) => ({
97
+ path: getChangePath(change),
98
+ comments: comments
99
+ .filter((comment) => comment.path === getChangePath(change))
100
+ .map((comment) => ({
101
+ comment,
102
+ status: comment.diffFingerprint === change.diffFingerprint ? "current" : "outdated"
103
+ }))
104
+ }))
105
+ .filter((file) => {
106
+ const hasComments = file.comments.length > 0;
107
+ if (hasComments) {
108
+ matchedFileIds.add(file.path);
109
+ }
110
+ return hasComments;
111
+ });
112
+ const orphanedFiles = Array.from(groupCommentsByFilePath(comments).entries())
113
+ .filter(([fileId]) => !matchedFileIds.has(fileId))
114
+ .map(([fileId, groupedComments]) => ({
115
+ path: fileId,
116
+ comments: groupedComments.map((comment) => ({
117
+ comment,
118
+ status: "outdated"
119
+ }))
120
+ }));
121
+ return renderReviewCommentsText([...files, ...orphanedFiles], {
122
+ header: {
123
+ repoName: path.basename(this.repoPath) || this.repoPath,
124
+ baseRef: "HEAD",
125
+ date: new Date().toISOString().slice(0, 10)
126
+ }
127
+ });
128
+ }
129
+ async syncReviewSession() {
130
+ const topLevel = (await runGit(this.repoPath, ["rev-parse", "--show-toplevel"])).trim();
131
+ if (!topLevel) {
132
+ throw new Error("Invalid Git repository");
133
+ }
134
+ const headShortId = (await runGit(topLevel, ["rev-parse", "--short=12", "HEAD"])).trim();
135
+ if (!headShortId) {
136
+ throw new Error("Invalid Git repository");
137
+ }
138
+ this.repoPath = topLevel;
139
+ this.commentStore = new CommentStore(topLevel, getReviewSessionFileName(headShortId));
140
+ }
141
+ async getChanges(context = SUMMARY_CONTEXT) {
142
+ const trackedPatch = await runGit(this.repoPath, [
143
+ "diff",
144
+ `--unified=${getGitUnifiedContext(context)}`,
145
+ "HEAD",
146
+ "--find-renames",
147
+ "--patch",
148
+ "--binary",
149
+ "--no-color"
150
+ ]);
151
+ const tracked = parseTrackedDiff(trackedPatch).filter((change) => !isInternalReviewChange(change));
152
+ const untracked = await this.readUntrackedChanges();
153
+ return [...tracked, ...untracked].sort((left, right) => getChangePath(left).localeCompare(getChangePath(right)));
154
+ }
155
+ async readUntrackedChanges() {
156
+ const statusOutput = await runGit(this.repoPath, ["status", "--porcelain=v1", "-z", "--untracked-files=all"]);
157
+ const untrackedPaths = statusOutput
158
+ .split("\0")
159
+ .filter((entry) => entry.startsWith("?? "))
160
+ .map((entry) => entry.slice(3))
161
+ .filter((relativePath) => !isInternalReviewPath(relativePath));
162
+ return Promise.all(untrackedPaths.map(async (relativePath) => {
163
+ const absolutePath = path.join(this.repoPath, relativePath);
164
+ const content = await fs.readFile(absolutePath);
165
+ return isBinaryBuffer(content)
166
+ ? createBinaryUntrackedChange(relativePath, content)
167
+ : createUntrackedChange(relativePath, content.toString("utf8"));
168
+ }));
169
+ }
170
+ }
171
+ function getGitUnifiedContext(context) {
172
+ return context === "full" ? FULL_CONTEXT_LINES : Number(context);
173
+ }
174
+ function classifyCommentsForChange(comments, change) {
175
+ const filtered = comments.filter((comment) => comment.path === getChangePath(change));
176
+ return {
177
+ current: filtered.filter((comment) => comment.diffFingerprint === change.diffFingerprint),
178
+ outdated: filtered.filter((comment) => comment.diffFingerprint !== change.diffFingerprint)
179
+ };
180
+ }
181
+ function groupCommentsByFilePath(comments) {
182
+ const grouped = new Map();
183
+ for (const comment of comments) {
184
+ const list = grouped.get(comment.path) ?? [];
185
+ list.push(comment);
186
+ grouped.set(comment.path, list);
187
+ }
188
+ return grouped;
189
+ }
190
+ function isInternalReviewChange(change) {
191
+ return isInternalReviewPath(change.newPath) || isInternalReviewPath(change.oldPath);
192
+ }
193
+ function isInternalReviewPath(filePath) {
194
+ return typeof filePath === "string" && (filePath === REVIEW_STORAGE_DIRECTORY || filePath.startsWith(`${REVIEW_STORAGE_DIRECTORY}/`));
195
+ }
196
+ function getLineNumberForSide(line, side) {
197
+ return side === "old" ? line.oldLineNumber : line.newLineNumber;
198
+ }
199
+ function isBinaryBuffer(buffer) {
200
+ const sample = buffer.subarray(0, Math.min(buffer.length, 8000));
201
+ return sample.includes(0);
202
+ }
@@ -0,0 +1,15 @@
1
+ import { parseServerOptions } from "./args.js";
2
+ import { startServer } from "./server.js";
3
+ export async function runServer(options = {}) {
4
+ const parsedOptions = parseServerOptions(options.argv ?? process.argv.slice(2));
5
+ const { server, port } = await startServer({ repos: parsedOptions.repos, port: parsedOptions.port }, { dev: options.dev });
6
+ const label = options.dev ? "API" : "Review tool";
7
+ server.listen(port, "localhost", () => {
8
+ console.log(`${label} listening on http://localhost:${port}`);
9
+ });
10
+ }
11
+ export function logFatalError(error) {
12
+ const message = error instanceof Error ? error.message : String(error);
13
+ console.error(message);
14
+ process.exit(1);
15
+ }
@@ -0,0 +1,235 @@
1
+ import http from "node:http";
2
+ import path from "node:path";
3
+ import express from "express";
4
+ import { DIFF_CONTEXT_VALUES } from "../shared/api.js";
5
+ import { deriveRepoId } from "./args.js";
6
+ import { resolveClientDistDirectory } from "./assetPaths.js";
7
+ import { ClientError } from "./errors.js";
8
+ import { ReviewService } from "./reviewService.js";
9
+ function getRepoService(response) {
10
+ return response.locals.service;
11
+ }
12
+ function getRepoId(response) {
13
+ return response.locals.repoId;
14
+ }
15
+ export async function startServer({ repos, port }, options = {}) {
16
+ const services = new Map();
17
+ for (const repo of repos) {
18
+ const svc = new ReviewService(repo.path);
19
+ await svc.validateRepository();
20
+ services.set(repo.id, svc);
21
+ }
22
+ const app = express();
23
+ app.use(express.json());
24
+ // ── Flat repo-management endpoints ──────────────────────────
25
+ app.get("/api/repos", (_request, response) => {
26
+ const result = [...services.entries()].map(([id, svc]) => ({ id, path: svc.repoPath }));
27
+ response.json(result);
28
+ });
29
+ app.post("/api/repos", async (request, response, next) => {
30
+ try {
31
+ const body = request.body;
32
+ if (typeof body.path !== "string" || body.path.trim().length === 0) {
33
+ response.status(400).json({ error: "Missing path" });
34
+ return;
35
+ }
36
+ const resolvedPath = path.resolve(body.path);
37
+ const id = typeof body.id === "string" && body.id.trim().length > 0 ? body.id : deriveRepoId(resolvedPath);
38
+ if (services.has(id)) {
39
+ response.status(409).json({ error: `Repo id already in use: ${id}` });
40
+ return;
41
+ }
42
+ const svc = new ReviewService(resolvedPath);
43
+ try {
44
+ await svc.validateRepository();
45
+ }
46
+ catch {
47
+ response.status(400).json({ error: "Not a git repository" });
48
+ return;
49
+ }
50
+ services.set(id, svc);
51
+ response.status(201).json({ id, path: resolvedPath });
52
+ }
53
+ catch (error) {
54
+ next(error);
55
+ }
56
+ });
57
+ app.post("/api/server/stop", (_request, response) => {
58
+ response.status(204).send();
59
+ setImmediate(() => process.exit(0));
60
+ });
61
+ app.delete("/api/repos/:repoId", (request, response) => {
62
+ const { repoId } = request.params;
63
+ if (!services.has(repoId)) {
64
+ response.status(404).json({ error: `Repo not found: ${repoId}` });
65
+ return;
66
+ }
67
+ services.delete(repoId);
68
+ response.status(204).send();
69
+ });
70
+ // ── Per-repo router ──────────────────────────────────────────
71
+ const repoRouter = express.Router({ mergeParams: true });
72
+ repoRouter.use((request, response, next) => {
73
+ const repoId = request.params.repoId;
74
+ const svc = services.get(repoId);
75
+ if (!svc) {
76
+ response.status(404).json({ error: `Repo not found: ${repoId}` });
77
+ return;
78
+ }
79
+ response.locals.repoId = repoId;
80
+ response.locals.service = svc;
81
+ next();
82
+ });
83
+ repoRouter.get("/repo", async (_request, response, next) => {
84
+ try {
85
+ const svc = getRepoService(response);
86
+ const [info, changes] = await Promise.all([svc.getRepoInfo(), svc.getChangeSummaries()]);
87
+ response.json({
88
+ id: getRepoId(response),
89
+ path: info.path,
90
+ baseRef: info.baseRef,
91
+ changeCount: changes.length
92
+ });
93
+ }
94
+ catch (error) {
95
+ next(error);
96
+ }
97
+ });
98
+ repoRouter.get("/changes", async (_request, response, next) => {
99
+ try {
100
+ const svc = getRepoService(response);
101
+ response.json(await svc.getChangeSummaries());
102
+ }
103
+ catch (error) {
104
+ next(error);
105
+ }
106
+ });
107
+ repoRouter.get("/changes/:changeId", async (request, response, next) => {
108
+ try {
109
+ const svc = getRepoService(response);
110
+ const change = await svc.getChange(request.params.changeId, parseDiffContext(request.query.context));
111
+ if (!change) {
112
+ response.status(404).json({ error: "Change not found" });
113
+ return;
114
+ }
115
+ response.json(change);
116
+ }
117
+ catch (error) {
118
+ next(error);
119
+ }
120
+ });
121
+ repoRouter.get("/comments", async (request, response, next) => {
122
+ try {
123
+ const svc = getRepoService(response);
124
+ const changeId = request.query.changeId;
125
+ if (typeof changeId !== "string") {
126
+ response.status(400).json({ error: "Missing changeId" });
127
+ return;
128
+ }
129
+ response.json(await svc.getComments(changeId));
130
+ }
131
+ catch (error) {
132
+ next(error);
133
+ }
134
+ });
135
+ repoRouter.post("/comments", async (request, response, next) => {
136
+ try {
137
+ const svc = getRepoService(response);
138
+ const body = request.body;
139
+ if (typeof body.changeId !== "string" ||
140
+ (body.side !== "old" && body.side !== "new") ||
141
+ typeof body.lineNumber !== "number" ||
142
+ !Number.isInteger(body.lineNumber) ||
143
+ body.lineNumber <= 0 ||
144
+ typeof body.body !== "string" ||
145
+ body.body.trim().length === 0) {
146
+ response.status(400).json({ error: "Invalid comment payload" });
147
+ return;
148
+ }
149
+ response.status(201).json(await svc.createComment({
150
+ changeId: body.changeId,
151
+ side: body.side,
152
+ lineNumber: body.lineNumber,
153
+ body: body.body
154
+ }));
155
+ }
156
+ catch (error) {
157
+ next(error);
158
+ }
159
+ });
160
+ repoRouter.patch("/comments/:commentId", async (request, response, next) => {
161
+ try {
162
+ const svc = getRepoService(response);
163
+ const { commentId } = request.params;
164
+ const body = request.body;
165
+ if (typeof commentId !== "string" || typeof body.body !== "string" || body.body.trim().length === 0) {
166
+ response.status(400).json({ error: "Invalid comment payload" });
167
+ return;
168
+ }
169
+ const updated = await svc.updateComment(commentId, body.body);
170
+ if (!updated) {
171
+ response.status(404).json({ error: "Comment not found" });
172
+ return;
173
+ }
174
+ response.json(updated);
175
+ }
176
+ catch (error) {
177
+ next(error);
178
+ }
179
+ });
180
+ repoRouter.delete("/comments/:commentId", async (request, response, next) => {
181
+ try {
182
+ const svc = getRepoService(response);
183
+ const { commentId } = request.params;
184
+ if (typeof commentId !== "string") {
185
+ response.status(400).json({ error: "Missing commentId" });
186
+ return;
187
+ }
188
+ const deleted = await svc.deleteComment(commentId);
189
+ if (!deleted) {
190
+ response.status(404).json({ error: "Comment not found" });
191
+ return;
192
+ }
193
+ response.status(204).send();
194
+ }
195
+ catch (error) {
196
+ next(error);
197
+ }
198
+ });
199
+ repoRouter.get("/export/comments.txt", async (_request, response, next) => {
200
+ try {
201
+ const svc = getRepoService(response);
202
+ response.type("text/plain").send(await svc.exportComments());
203
+ }
204
+ catch (error) {
205
+ next(error);
206
+ }
207
+ });
208
+ app.use("/api/repos/:repoId", repoRouter);
209
+ app.use((error, _request, response, _next) => {
210
+ const status = error instanceof ClientError ? 400 : 500;
211
+ const message = error instanceof Error ? error.message : "Internal server error";
212
+ response.status(status).json({ error: message });
213
+ });
214
+ if (!options.dev) {
215
+ const clientDir = resolveClientDistDirectory(import.meta.url);
216
+ app.use(express.static(clientDir));
217
+ app.get("/{*path}", (_request, response) => {
218
+ response.sendFile(path.join(clientDir, "index.html"));
219
+ });
220
+ }
221
+ return {
222
+ app,
223
+ server: http.createServer(app),
224
+ port
225
+ };
226
+ }
227
+ function parseDiffContext(rawValue) {
228
+ if (rawValue === undefined) {
229
+ return "full";
230
+ }
231
+ if (typeof rawValue !== "string" || !DIFF_CONTEXT_VALUES.includes(rawValue)) {
232
+ throw new ClientError("Invalid diff context");
233
+ }
234
+ return rawValue;
235
+ }
@@ -0,0 +1,145 @@
1
+ import fs from "node:fs/promises";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
5
+ import { startServer } from "./server.js";
6
+ import { createTempGitRepo } from "./testUtils.js";
7
+ let homeDir;
8
+ let repoPath;
9
+ beforeEach(async () => {
10
+ homeDir = await fs.mkdtemp(path.join(os.tmpdir(), "review-home-"));
11
+ process.env.HOME = homeDir;
12
+ process.env.XDG_DATA_HOME = path.join(homeDir, ".local", "share");
13
+ repoPath = await createTempGitRepo();
14
+ });
15
+ afterEach(async () => {
16
+ await fs.rm(homeDir, { recursive: true, force: true });
17
+ await fs.rm(repoPath, { recursive: true, force: true });
18
+ });
19
+ describe("server API", () => {
20
+ it("lists tracked and untracked changes and persists comments", async () => {
21
+ const { app } = await startServer({ repoPath, port: 3000 }, { dev: true });
22
+ const changesResponse = await invokeRoute(app, "get", "/api/changes");
23
+ expect(changesResponse.statusCode).toBe(200);
24
+ expect(changesResponse.body.some((change) => change.changeType === "modified")).toBe(true);
25
+ expect(changesResponse.body.some((change) => change.newPath === "untracked.txt")).toBe(true);
26
+ const trackedChange = changesResponse.body.find((change) => change.newPath === "tracked.txt");
27
+ const detailResponse = await invokeRoute(app, "get", "/api/changes/:changeId", {
28
+ params: { changeId: trackedChange.changeId }
29
+ });
30
+ expect(detailResponse.statusCode).toBe(200);
31
+ const line = detailResponse.body.hunks[0].lines.find((entry) => entry.commentableSide === "new");
32
+ const createResponse = await invokeRoute(app, "post", "/api/comments", {
33
+ body: {
34
+ changeId: trackedChange.changeId,
35
+ side: "new",
36
+ oldLineNumber: line.oldLineNumber,
37
+ newLineNumber: line.newLineNumber,
38
+ hunkHeader: detailResponse.body.hunks[0].header,
39
+ body: "Check wording"
40
+ }
41
+ });
42
+ expect(createResponse.statusCode).toBe(201);
43
+ const commentsResponse = await invokeRoute(app, "get", "/api/comments", {
44
+ query: { changeId: trackedChange.changeId }
45
+ });
46
+ expect(commentsResponse.statusCode).toBe(200);
47
+ expect(commentsResponse.body.current).toHaveLength(1);
48
+ expect(commentsResponse.body.outdated).toHaveLength(0);
49
+ });
50
+ it("marks comments outdated when the diff fingerprint changes", async () => {
51
+ const { app } = await startServer({ repoPath, port: 3000 }, { dev: true });
52
+ const changesResponse = await invokeRoute(app, "get", "/api/changes");
53
+ const trackedChange = changesResponse.body.find((change) => change.newPath === "tracked.txt");
54
+ const detailResponse = await invokeRoute(app, "get", "/api/changes/:changeId", {
55
+ params: { changeId: trackedChange.changeId }
56
+ });
57
+ const line = detailResponse.body.hunks[0].lines.find((entry) => entry.commentableSide === "new");
58
+ const createResponse = await invokeRoute(app, "post", "/api/comments", {
59
+ body: {
60
+ changeId: trackedChange.changeId,
61
+ side: "new",
62
+ oldLineNumber: line.oldLineNumber,
63
+ newLineNumber: line.newLineNumber,
64
+ hunkHeader: detailResponse.body.hunks[0].header,
65
+ body: "Will go stale"
66
+ }
67
+ });
68
+ expect(createResponse.statusCode).toBe(201);
69
+ await fs.writeFile(path.join(repoPath, "tracked.txt"), "another\nstay\n", "utf8");
70
+ const staleResponse = await invokeRoute(app, "get", "/api/comments", {
71
+ query: { changeId: trackedChange.changeId }
72
+ });
73
+ expect(staleResponse.statusCode).toBe(200);
74
+ expect(staleResponse.body.current).toHaveLength(0);
75
+ expect(staleResponse.body.outdated).toHaveLength(1);
76
+ });
77
+ it("exports orphaned stale comments even after a file leaves the diff", async () => {
78
+ const { app } = await startServer({ repoPath, port: 3000 }, { dev: true });
79
+ const changesResponse = await invokeRoute(app, "get", "/api/changes");
80
+ const trackedChange = changesResponse.body.find((change) => change.newPath === "tracked.txt");
81
+ const detailResponse = await invokeRoute(app, "get", "/api/changes/:changeId", {
82
+ params: { changeId: trackedChange.changeId }
83
+ });
84
+ const line = detailResponse.body.hunks[0].lines.find((entry) => entry.commentableSide === "new");
85
+ await invokeRoute(app, "post", "/api/comments", {
86
+ body: {
87
+ changeId: trackedChange.changeId,
88
+ side: "new",
89
+ oldLineNumber: line.oldLineNumber,
90
+ newLineNumber: line.newLineNumber,
91
+ hunkHeader: detailResponse.body.hunks[0].header,
92
+ body: "Persist in export"
93
+ }
94
+ });
95
+ await fs.writeFile(path.join(repoPath, "tracked.txt"), "before\nstay\n", "utf8");
96
+ const exportResponse = await invokeRoute(app, "get", "/api/export/comments.md");
97
+ expect(exportResponse.headers["content-type"]).toBe("text/markdown");
98
+ expect(exportResponse.body).toContain("## File: tracked.txt");
99
+ expect(exportResponse.body).toContain("Status: outdated");
100
+ expect(exportResponse.body).toContain("Persist in export");
101
+ });
102
+ });
103
+ async function invokeRoute(app, method, pathPattern, options = {}) {
104
+ const layer = app.router.stack.find((entry) => entry.route?.path === pathPattern && entry.route.methods?.[method]);
105
+ if (!layer?.route) {
106
+ throw new Error(`Route not found: ${method.toUpperCase()} ${pathPattern}`);
107
+ }
108
+ let statusCode = 200;
109
+ let body;
110
+ const headers = {};
111
+ let nextError;
112
+ const response = {
113
+ status(code) {
114
+ statusCode = code;
115
+ return this;
116
+ },
117
+ json(payload) {
118
+ body = payload;
119
+ return this;
120
+ },
121
+ send(payload) {
122
+ body = payload;
123
+ return this;
124
+ },
125
+ type(value) {
126
+ headers["content-type"] = value;
127
+ return this;
128
+ }
129
+ };
130
+ await layer.route.stack[0].handle({
131
+ params: options.params ?? {},
132
+ query: options.query ?? {},
133
+ body: options.body ?? {}
134
+ }, response, (error) => {
135
+ nextError = error;
136
+ });
137
+ if (nextError) {
138
+ throw nextError;
139
+ }
140
+ return {
141
+ statusCode,
142
+ body,
143
+ headers
144
+ };
145
+ }
@@ -0,0 +1,25 @@
1
+ import fs from "node:fs/promises";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { execFileSync } from "node:child_process";
5
+ export async function createTempGitRepo() {
6
+ const repoPath = await fs.mkdtemp(path.join(os.tmpdir(), "review-tool-"));
7
+ runGit(repoPath, ["init"]);
8
+ runGit(repoPath, ["config", "user.name", "Test User"]);
9
+ runGit(repoPath, ["config", "user.email", "test@example.com"]);
10
+ await fs.writeFile(path.join(repoPath, "tracked.txt"), "before\nstay\n", "utf8");
11
+ await fs.writeFile(path.join(repoPath, "rename-me.txt"), "rename source\n", "utf8");
12
+ runGit(repoPath, ["add", "."]);
13
+ runGit(repoPath, ["commit", "-m", "initial"]);
14
+ await fs.writeFile(path.join(repoPath, "tracked.txt"), "after\nstay\n", "utf8");
15
+ await fs.rename(path.join(repoPath, "rename-me.txt"), path.join(repoPath, "renamed.txt"));
16
+ await fs.writeFile(path.join(repoPath, "untracked.txt"), "brand new\nline two\n", "utf8");
17
+ await fs.writeFile(path.join(repoPath, "binary.bin"), Buffer.from([0, 1, 2, 3, 4]));
18
+ return repoPath;
19
+ }
20
+ export function runGit(repoPath, args) {
21
+ return execFileSync("git", args, {
22
+ cwd: repoPath,
23
+ encoding: "utf8"
24
+ });
25
+ }
@@ -0,0 +1 @@
1
+ export const DIFF_CONTEXT_VALUES = ["0", "3", "20", "100", "full"];
@@ -0,0 +1,3 @@
1
+ export function getChangePath(change) {
2
+ return change.newPath ?? change.oldPath ?? "(unknown)";
3
+ }
@@ -0,0 +1,26 @@
1
+ export function renderReviewCommentsText(files, options = {}) {
2
+ const lines = [];
3
+ if (options.header) {
4
+ lines.push(`CODE REVIEW · ${options.header.repoName} · branch: ${options.header.baseRef} · ${options.header.date}`, "━".repeat(50), "");
5
+ }
6
+ for (const file of [...files].sort((left, right) => left.path.localeCompare(right.path))) {
7
+ const comments = [...file.comments].sort(compareCommentExportOrder);
8
+ if (comments.length === 0) {
9
+ continue;
10
+ }
11
+ lines.push(`REVIEW ${file.path}`);
12
+ lines.push("");
13
+ for (const { comment, status } of comments) {
14
+ lines.push(`NOTE ${comment.commentId} SIDE ${comment.side} LINE ${comment.lineNumber} STATUS ${status}`);
15
+ lines.push(comment.body);
16
+ lines.push("END NOTE");
17
+ lines.push("");
18
+ }
19
+ }
20
+ return lines.join("\n").trimEnd() + "\n";
21
+ }
22
+ function compareCommentExportOrder(left, right) {
23
+ return (left.comment.lineNumber - right.comment.lineNumber ||
24
+ left.comment.side.localeCompare(right.comment.side) ||
25
+ left.comment.commentId.localeCompare(right.comment.commentId));
26
+ }
@@ -0,0 +1 @@
1
+ export {};