nitpiq 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,210 @@
1
+ import { mkdirSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
4
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
5
+ import { z } from "zod";
6
+ import { changes, kindSymbol, openRepoAt, readFile, stage, unstage, type Repo } from "../git/repo";
7
+ import { debug, error, initLog } from "../log/log";
8
+ import { relocateThreads } from "../review/anchor";
9
+ import { AuthorModel, ThreadOpen, ThreadResolved } from "../review/types";
10
+ import { Store } from "../store/store";
11
+
12
+ export function createServer(repo: Repo, store: Store): McpServer {
13
+ const server = new McpServer({ name: "nitpiq", version: "0.1.0" });
14
+
15
+ const activeSession = () => store.activeSession() ?? store.createSession(repo.root);
16
+
17
+ server.registerTool(
18
+ "review_list_changes",
19
+ {
20
+ description: "List uncommitted file changes in the repository.",
21
+ },
22
+ async () => ({
23
+ content: [
24
+ {
25
+ type: "text",
26
+ text: JSON.stringify(
27
+ changes(repo).map((change) => ({
28
+ path: change.path,
29
+ kind: change.kind,
30
+ symbol: kindSymbol(change.kind),
31
+ })),
32
+ null,
33
+ 2,
34
+ ),
35
+ },
36
+ ],
37
+ }),
38
+ );
39
+
40
+ server.registerTool(
41
+ "review_list_threads",
42
+ {
43
+ description: "List review threads, optionally filtered by file and status.",
44
+ inputSchema: {
45
+ file_path: z.string().optional(),
46
+ status: z.enum(["open", "resolved", "all"]).optional(),
47
+ },
48
+ },
49
+ async ({ file_path, status = "open" }) => {
50
+ const session = activeSession();
51
+ let threads = store.listThreads(session.id, file_path ?? "");
52
+
53
+ if (file_path) {
54
+ const content = readFile(repo, file_path);
55
+ threads = relocateThreads(threads, content.split("\n"));
56
+ for (const thread of threads) {
57
+ store.updateThreadLine(thread.id, thread.currentLine, thread.isOutdated);
58
+ }
59
+ }
60
+
61
+ const filtered = threads.filter((thread) => status === "all" || thread.status === status);
62
+ return {
63
+ content: [
64
+ {
65
+ type: "text",
66
+ text: JSON.stringify(
67
+ filtered.map((thread) => ({
68
+ id: thread.id,
69
+ file_path: thread.filePath,
70
+ line: thread.currentLine,
71
+ line_end: thread.lineEnd || undefined,
72
+ status: thread.status,
73
+ is_outdated: thread.isOutdated || undefined,
74
+ comment_count: thread.commentCount,
75
+ first_comment: thread.firstComment,
76
+ })),
77
+ null,
78
+ 2,
79
+ ),
80
+ },
81
+ ],
82
+ };
83
+ },
84
+ );
85
+
86
+ server.registerTool(
87
+ "review_reply_thread",
88
+ {
89
+ description: "Post a reply comment to an existing review thread.",
90
+ inputSchema: {
91
+ thread_id: z.string(),
92
+ body: z.string(),
93
+ },
94
+ },
95
+ async ({ thread_id, body }) => {
96
+ store.addComment({ threadId: thread_id, author: AuthorModel, body });
97
+ return textResult(`Reply added to thread ${thread_id}`);
98
+ },
99
+ );
100
+
101
+ server.registerTool(
102
+ "review_resolve_thread",
103
+ {
104
+ description: "Mark a review thread as resolved.",
105
+ inputSchema: { thread_id: z.string() },
106
+ },
107
+ async ({ thread_id }) => {
108
+ store.updateThreadStatus(thread_id, ThreadResolved);
109
+ return textResult(`Thread ${thread_id} resolved`);
110
+ },
111
+ );
112
+
113
+ server.registerTool(
114
+ "review_reopen_thread",
115
+ {
116
+ description: "Reopen a resolved review thread.",
117
+ inputSchema: { thread_id: z.string() },
118
+ },
119
+ async ({ thread_id }) => {
120
+ store.updateThreadStatus(thread_id, ThreadOpen);
121
+ return textResult(`Thread ${thread_id} reopened`);
122
+ },
123
+ );
124
+
125
+ server.registerTool(
126
+ "review_apply_edit",
127
+ {
128
+ description: "Write new content to a file in the repository.",
129
+ inputSchema: {
130
+ file_path: z.string(),
131
+ content: z.string(),
132
+ },
133
+ },
134
+ async ({ file_path, content }) => {
135
+ const absolutePath = path.join(repo.root, file_path);
136
+ mkdirSync(path.dirname(absolutePath), { recursive: true });
137
+ await Bun.write(absolutePath, content);
138
+ return textResult(`File ${file_path} updated (${content.length} bytes)`);
139
+ },
140
+ );
141
+
142
+ server.registerTool(
143
+ "review_stage_file",
144
+ {
145
+ description: "Stage a file with git add.",
146
+ inputSchema: { file_path: z.string() },
147
+ },
148
+ async ({ file_path }) => {
149
+ stage(repo, file_path);
150
+ return textResult(`File ${file_path} staged`);
151
+ },
152
+ );
153
+
154
+ server.registerTool(
155
+ "review_unstage_file",
156
+ {
157
+ description: "Unstage a file.",
158
+ inputSchema: { file_path: z.string() },
159
+ },
160
+ async ({ file_path }) => {
161
+ unstage(repo, file_path);
162
+ return textResult(`File ${file_path} unstaged`);
163
+ },
164
+ );
165
+
166
+ return server;
167
+ }
168
+
169
+ function textResult(text: string) {
170
+ return { content: [{ type: "text" as const, text }] };
171
+ }
172
+
173
+ export async function serveStdio(repoPath?: string): Promise<void> {
174
+ const repo = openRepoAt(repoPath);
175
+ const store = Store.open(repo.root);
176
+ initLog(repo.root);
177
+ debug("nitpiq-mcp server starting");
178
+
179
+ try {
180
+ const server = createServer(repo, store);
181
+ const transport = new StdioServerTransport();
182
+ let cleanedUp = false;
183
+ const cleanup = async () => {
184
+ if (cleanedUp) {
185
+ return;
186
+ }
187
+ cleanedUp = true;
188
+ store.close();
189
+ };
190
+ const waitForExit = new Promise<void>((resolve) => {
191
+ transport.onclose = () => {
192
+ void cleanup().finally(resolve);
193
+ };
194
+ process.once("SIGINT", () => {
195
+ void cleanup().finally(resolve);
196
+ });
197
+ process.once("SIGTERM", () => {
198
+ void cleanup().finally(resolve);
199
+ });
200
+ process.once("beforeExit", () => {
201
+ void cleanup().finally(resolve);
202
+ });
203
+ });
204
+ await server.connect(transport);
205
+ await waitForExit;
206
+ } catch (cause) {
207
+ error(String(cause));
208
+ throw cause;
209
+ }
210
+ }
@@ -0,0 +1,118 @@
1
+ import type { Thread } from "./types";
2
+
3
+ const RELOCATE_MAX_DELTA = 50;
4
+
5
+ export function relocateThreads(threads: Thread[], fileLines: string[]): Thread[] {
6
+ return threads.map((thread) => relocateThread(thread, fileLines));
7
+ }
8
+
9
+ function relocateThread(thread: Thread, lines: string[]): Thread {
10
+ if (thread.currentLine <= 0 || lines.length === 0) {
11
+ return thread;
12
+ }
13
+
14
+ const next = { ...thread };
15
+ const anchorLines = next.anchorContent.split("\n");
16
+ const originalIndex = next.currentLine - 1;
17
+ const rangeLength = next.lineEnd > next.currentLine ? next.lineEnd - next.currentLine : 0;
18
+
19
+ if (anchorMatchesAt(lines, originalIndex, anchorLines)) {
20
+ next.isOutdated = false;
21
+ return next;
22
+ }
23
+
24
+ for (let delta = 1; delta <= RELOCATE_MAX_DELTA; delta += 1) {
25
+ const up = originalIndex - delta;
26
+ if (up >= 0 && anchorMatchesAt(lines, up, anchorLines)) {
27
+ next.currentLine = up + 1;
28
+ next.lineEnd = rangeLength > 0 ? next.currentLine + rangeLength : next.lineEnd;
29
+ next.isOutdated = false;
30
+ return next;
31
+ }
32
+
33
+ const down = originalIndex + delta;
34
+ if (down >= 0 && anchorMatchesAt(lines, down, anchorLines)) {
35
+ next.currentLine = down + 1;
36
+ next.lineEnd = rangeLength > 0 ? next.currentLine + rangeLength : next.lineEnd;
37
+ next.isOutdated = false;
38
+ return next;
39
+ }
40
+ }
41
+
42
+ if (next.contextBefore || next.contextAfter) {
43
+ for (let index = 0; index < lines.length; index += 1) {
44
+ if (matchesContext(lines, index, next.contextBefore, next.contextAfter)) {
45
+ next.currentLine = index + 1;
46
+ next.lineEnd = rangeLength > 0 ? next.currentLine + rangeLength : next.lineEnd;
47
+ next.isOutdated = true;
48
+ return next;
49
+ }
50
+ }
51
+ }
52
+
53
+ next.isOutdated = true;
54
+ return next;
55
+ }
56
+
57
+ function anchorMatchesAt(lines: string[], index: number, anchorLines: string[]): boolean {
58
+ if (index < 0 || index + anchorLines.length > lines.length) {
59
+ return false;
60
+ }
61
+
62
+ for (let offset = 0; offset < anchorLines.length; offset += 1) {
63
+ if (lines[index + offset] !== anchorLines[offset]) {
64
+ return false;
65
+ }
66
+ }
67
+
68
+ return true;
69
+ }
70
+
71
+ function matchesContext(lines: string[], index: number, before: string, after: string): boolean {
72
+ if (before) {
73
+ const beforeLines = before.split("\n");
74
+ for (let offset = 0; offset < beforeLines.length; offset += 1) {
75
+ const pos = index - beforeLines.length + offset;
76
+ if (pos < 0 || pos >= lines.length || lines[pos] !== beforeLines[offset]) {
77
+ return false;
78
+ }
79
+ }
80
+ }
81
+
82
+ if (after) {
83
+ const afterLines = after.split("\n");
84
+ for (let offset = 0; offset < afterLines.length; offset += 1) {
85
+ const pos = index + 1 + offset;
86
+ if (pos >= lines.length || lines[pos] !== afterLines[offset]) {
87
+ return false;
88
+ }
89
+ }
90
+ }
91
+
92
+ return Boolean(before || after);
93
+ }
94
+
95
+ export function extractContext(fileContent: string, lineNumber: number) {
96
+ const lines = fileContent.split("\n");
97
+ const index = lineNumber - 1;
98
+
99
+ if (index < 0 || index >= lines.length) {
100
+ return { anchor: "", before: "", after: "" };
101
+ }
102
+
103
+ const beforeStart = Math.max(0, index - 3);
104
+ const afterEnd = Math.min(lines.length, index + 4);
105
+
106
+ return {
107
+ anchor: lines[index] ?? "",
108
+ before: lines.slice(beforeStart, index).join("\n"),
109
+ after: lines.slice(index + 1, afterEnd).join("\n"),
110
+ };
111
+ }
112
+
113
+ export function extractRangeAnchor(fileContent: string, startLine: number, endLine: number): string {
114
+ const lines = fileContent.split("\n");
115
+ const start = Math.max(0, startLine - 1);
116
+ const end = Math.min(lines.length, endLine);
117
+ return lines.slice(start, end).join("\n");
118
+ }
@@ -0,0 +1,64 @@
1
+ export type ThreadStatus = "open" | "resolved";
2
+
3
+ export const ThreadOpen: ThreadStatus = "open";
4
+ export const ThreadResolved: ThreadStatus = "resolved";
5
+
6
+ export type Author = "human" | "model";
7
+
8
+ export const AuthorHuman: Author = "human";
9
+ export const AuthorModel: Author = "model";
10
+
11
+ export interface ReviewSession {
12
+ id: string;
13
+ repoRoot: string;
14
+ active: boolean;
15
+ createdAt: Date;
16
+ updatedAt: Date;
17
+ }
18
+
19
+ export interface Thread {
20
+ id: string;
21
+ sessionId: string;
22
+ filePath: string;
23
+ side: string;
24
+ originalLine: number;
25
+ lineEnd: number;
26
+ currentLine: number;
27
+ anchorContent: string;
28
+ contextBefore: string;
29
+ contextAfter: string;
30
+ isOutdated: boolean;
31
+ status: ThreadStatus;
32
+ createdAt: Date;
33
+ updatedAt: Date;
34
+ commentCount: number;
35
+ firstComment: string;
36
+ }
37
+
38
+ export interface Comment {
39
+ id: string;
40
+ threadId: string;
41
+ author: Author;
42
+ body: string;
43
+ createdAt: Date;
44
+ }
45
+
46
+ export interface NewThread {
47
+ sessionId: string;
48
+ filePath: string;
49
+ side: string;
50
+ originalLine: number;
51
+ lineEnd: number;
52
+ currentLine: number;
53
+ anchorContent: string;
54
+ contextBefore: string;
55
+ contextAfter: string;
56
+ isOutdated?: boolean;
57
+ status?: ThreadStatus;
58
+ }
59
+
60
+ export interface NewComment {
61
+ threadId: string;
62
+ author: Author;
63
+ body: string;
64
+ }
@@ -0,0 +1,315 @@
1
+ import { mkdirSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { Database } from "bun:sqlite";
4
+ import { AuthorHuman, ThreadOpen, type Author, type Comment, type NewComment, type NewThread, type ReviewSession, type Thread, type ThreadStatus } from "../review/types";
5
+
6
+ const schema = `
7
+ CREATE TABLE IF NOT EXISTS sessions (
8
+ id TEXT PRIMARY KEY,
9
+ repo_root TEXT NOT NULL,
10
+ active INTEGER NOT NULL DEFAULT 1,
11
+ created_at TEXT NOT NULL,
12
+ updated_at TEXT NOT NULL
13
+ );
14
+
15
+ CREATE TABLE IF NOT EXISTS threads (
16
+ id TEXT PRIMARY KEY,
17
+ session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
18
+ file_path TEXT NOT NULL,
19
+ side TEXT NOT NULL DEFAULT 'new',
20
+ original_line INTEGER NOT NULL DEFAULT 0,
21
+ line_end INTEGER NOT NULL DEFAULT 0,
22
+ current_line INTEGER NOT NULL DEFAULT 0,
23
+ anchor_content TEXT NOT NULL DEFAULT '',
24
+ context_before TEXT NOT NULL DEFAULT '',
25
+ context_after TEXT NOT NULL DEFAULT '',
26
+ is_outdated INTEGER NOT NULL DEFAULT 0,
27
+ status TEXT NOT NULL DEFAULT 'open',
28
+ created_at TEXT NOT NULL,
29
+ updated_at TEXT NOT NULL
30
+ );
31
+
32
+ CREATE TABLE IF NOT EXISTS comments (
33
+ id TEXT PRIMARY KEY,
34
+ thread_id TEXT NOT NULL REFERENCES threads(id) ON DELETE CASCADE,
35
+ author TEXT NOT NULL,
36
+ body TEXT NOT NULL,
37
+ created_at TEXT NOT NULL
38
+ );
39
+
40
+ CREATE INDEX IF NOT EXISTS idx_threads_session ON threads(session_id);
41
+ CREATE INDEX IF NOT EXISTS idx_threads_file ON threads(file_path);
42
+ CREATE INDEX IF NOT EXISTS idx_comments_thread ON comments(thread_id);
43
+ `;
44
+
45
+ export class Store {
46
+ readonly db: Database;
47
+
48
+ private constructor(db: Database) {
49
+ this.db = db;
50
+ }
51
+
52
+ static open(repoRoot: string): Store {
53
+ const dir = path.join(repoRoot, ".git", "nitpiq");
54
+ mkdirSync(dir, { recursive: true });
55
+ const dbPath = path.join(dir, "review.db");
56
+ const db = new Database(dbPath, { create: true, strict: true });
57
+ db.exec("PRAGMA journal_mode = WAL;");
58
+ db.exec("PRAGMA foreign_keys = ON;");
59
+ db.exec(schema);
60
+ return new Store(db);
61
+ }
62
+
63
+ close(): void {
64
+ this.db.close();
65
+ }
66
+
67
+ createSession(repoRoot: string): ReviewSession {
68
+ const now = new Date();
69
+ const session: ReviewSession = {
70
+ id: crypto.randomUUID(),
71
+ repoRoot,
72
+ active: true,
73
+ createdAt: now,
74
+ updatedAt: now,
75
+ };
76
+
77
+ this.db.query("UPDATE sessions SET active = 0 WHERE active = 1").run();
78
+ this.db
79
+ .query(
80
+ "INSERT INTO sessions (id, repo_root, active, created_at, updated_at) VALUES (?, ?, 1, ?, ?)",
81
+ )
82
+ .run(session.id, session.repoRoot, now.toISOString(), now.toISOString());
83
+
84
+ return session;
85
+ }
86
+
87
+ activeSession(): ReviewSession | null {
88
+ const row = this.db
89
+ .query("SELECT id, repo_root, created_at, updated_at FROM sessions WHERE active = 1 ORDER BY created_at DESC LIMIT 1")
90
+ .get() as
91
+ | {
92
+ id: string;
93
+ repo_root: string;
94
+ created_at: string;
95
+ updated_at: string;
96
+ }
97
+ | null;
98
+
99
+ if (!row) {
100
+ return null;
101
+ }
102
+
103
+ return {
104
+ id: row.id,
105
+ repoRoot: row.repo_root,
106
+ active: true,
107
+ createdAt: new Date(row.created_at),
108
+ updatedAt: new Date(row.updated_at),
109
+ };
110
+ }
111
+
112
+ clearSession(id: string): void {
113
+ this.db.query("DELETE FROM sessions WHERE id = ?").run(id);
114
+ }
115
+
116
+ createThread(input: NewThread): Thread {
117
+ const now = new Date();
118
+ const thread: Thread = {
119
+ id: crypto.randomUUID(),
120
+ sessionId: input.sessionId,
121
+ filePath: input.filePath,
122
+ side: input.side,
123
+ originalLine: input.originalLine,
124
+ lineEnd: input.lineEnd,
125
+ currentLine: input.currentLine,
126
+ anchorContent: input.anchorContent,
127
+ contextBefore: input.contextBefore,
128
+ contextAfter: input.contextAfter,
129
+ isOutdated: input.isOutdated ?? false,
130
+ status: input.status ?? ThreadOpen,
131
+ createdAt: now,
132
+ updatedAt: now,
133
+ commentCount: 0,
134
+ firstComment: "",
135
+ };
136
+
137
+ this.db
138
+ .query(
139
+ `INSERT INTO threads (
140
+ id, session_id, file_path, side, original_line, line_end,
141
+ current_line, anchor_content, context_before, context_after,
142
+ is_outdated, status, created_at, updated_at
143
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
144
+ )
145
+ .run(
146
+ thread.id,
147
+ thread.sessionId,
148
+ thread.filePath,
149
+ thread.side,
150
+ thread.originalLine,
151
+ thread.lineEnd,
152
+ thread.currentLine,
153
+ thread.anchorContent,
154
+ thread.contextBefore,
155
+ thread.contextAfter,
156
+ thread.isOutdated ? 1 : 0,
157
+ thread.status,
158
+ now.toISOString(),
159
+ now.toISOString(),
160
+ );
161
+
162
+ return thread;
163
+ }
164
+
165
+ listThreads(sessionId: string, filePath = ""): Thread[] {
166
+ const query = `
167
+ SELECT t.id, t.session_id, t.file_path, t.side,
168
+ t.original_line, t.line_end, t.current_line,
169
+ t.anchor_content, t.context_before, t.context_after,
170
+ t.is_outdated, t.status, t.created_at, t.updated_at,
171
+ COALESCE((SELECT COUNT(*) FROM comments WHERE thread_id = t.id), 0) AS comment_count,
172
+ COALESCE((SELECT body FROM comments WHERE thread_id = t.id ORDER BY created_at LIMIT 1), '') AS first_comment
173
+ FROM threads t
174
+ WHERE t.session_id = ? ${filePath ? "AND t.file_path = ?" : ""}
175
+ ORDER BY t.file_path, t.original_line
176
+ `;
177
+
178
+ const rows = (filePath
179
+ ? this.db.query(query).all(sessionId, filePath)
180
+ : this.db.query(query).all(sessionId)) as ThreadRow[];
181
+
182
+ return rows.map(mapThread);
183
+ }
184
+
185
+ threadCountsByFile(sessionId: string): Record<string, number> {
186
+ const rows = this.db
187
+ .query("SELECT file_path, COUNT(*) AS count FROM threads WHERE session_id = ? AND status = 'open' GROUP BY file_path")
188
+ .all(sessionId) as Array<{ file_path: string; count: number }>;
189
+
190
+ return Object.fromEntries(rows.map((row) => [row.file_path, row.count]));
191
+ }
192
+
193
+ updateThreadLine(id: string, currentLine: number, outdated: boolean): void {
194
+ this.db
195
+ .query("UPDATE threads SET current_line = ?, is_outdated = ?, updated_at = ? WHERE id = ?")
196
+ .run(currentLine, outdated ? 1 : 0, new Date().toISOString(), id);
197
+ }
198
+
199
+ updateThreadStatus(id: string, status: ThreadStatus): void {
200
+ this.db
201
+ .query("UPDATE threads SET status = ?, updated_at = ? WHERE id = ?")
202
+ .run(status, new Date().toISOString(), id);
203
+ }
204
+
205
+ deleteThread(id: string): void {
206
+ this.db.query("DELETE FROM threads WHERE id = ?").run(id);
207
+ }
208
+
209
+ addComment(input: NewComment): Comment {
210
+ const comment: Comment = {
211
+ id: crypto.randomUUID(),
212
+ threadId: input.threadId,
213
+ author: input.author ?? AuthorHuman,
214
+ body: input.body,
215
+ createdAt: new Date(),
216
+ };
217
+
218
+ this.db
219
+ .query("INSERT INTO comments (id, thread_id, author, body, created_at) VALUES (?, ?, ?, ?, ?)")
220
+ .run(comment.id, comment.threadId, comment.author, comment.body, comment.createdAt.toISOString());
221
+
222
+ return comment;
223
+ }
224
+
225
+ listComments(threadId: string): Comment[] {
226
+ const rows = this.db
227
+ .query("SELECT id, thread_id, author, body, created_at FROM comments WHERE thread_id = ? ORDER BY created_at")
228
+ .all(threadId) as CommentRow[];
229
+
230
+ return rows.map(mapComment);
231
+ }
232
+
233
+ listCommentsForThreads(threadIds: string[]): Record<string, Comment[]> {
234
+ if (threadIds.length === 0) {
235
+ return {};
236
+ }
237
+
238
+ const placeholders = threadIds.map(() => "?").join(", ");
239
+ const rows = this.db
240
+ .query(
241
+ `SELECT id, thread_id, author, body, created_at FROM comments WHERE thread_id IN (${placeholders}) ORDER BY created_at`,
242
+ )
243
+ .all(...threadIds) as CommentRow[];
244
+
245
+ const result: Record<string, Comment[]> = {};
246
+ for (const row of rows) {
247
+ const comment = mapComment(row);
248
+ const existing = result[comment.threadId];
249
+ if (existing) {
250
+ existing.push(comment);
251
+ } else {
252
+ result[comment.threadId] = [comment];
253
+ }
254
+ }
255
+ return result;
256
+ }
257
+ }
258
+
259
+ interface ThreadRow {
260
+ id: string;
261
+ session_id: string;
262
+ file_path: string;
263
+ side: string;
264
+ original_line: number;
265
+ line_end: number;
266
+ current_line: number;
267
+ anchor_content: string;
268
+ context_before: string;
269
+ context_after: string;
270
+ is_outdated: number;
271
+ status: string;
272
+ created_at: string;
273
+ updated_at: string;
274
+ comment_count: number;
275
+ first_comment: string;
276
+ }
277
+
278
+ interface CommentRow {
279
+ id: string;
280
+ thread_id: string;
281
+ author: string;
282
+ body: string;
283
+ created_at: string;
284
+ }
285
+
286
+ function mapThread(row: ThreadRow): Thread {
287
+ return {
288
+ id: row.id,
289
+ sessionId: row.session_id,
290
+ filePath: row.file_path,
291
+ side: row.side,
292
+ originalLine: row.original_line,
293
+ lineEnd: row.line_end,
294
+ currentLine: row.current_line,
295
+ anchorContent: row.anchor_content,
296
+ contextBefore: row.context_before,
297
+ contextAfter: row.context_after,
298
+ isOutdated: row.is_outdated !== 0,
299
+ status: row.status as ThreadStatus,
300
+ createdAt: new Date(row.created_at),
301
+ updatedAt: new Date(row.updated_at),
302
+ commentCount: row.comment_count,
303
+ firstComment: row.first_comment,
304
+ };
305
+ }
306
+
307
+ function mapComment(row: CommentRow): Comment {
308
+ return {
309
+ id: row.id,
310
+ threadId: row.thread_id,
311
+ author: row.author as Author,
312
+ body: row.body,
313
+ createdAt: new Date(row.created_at),
314
+ };
315
+ }