@sanurb/ringi 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,1782 @@
1
+ import * as HttpApiSchema from '@effect/platform/HttpApiSchema';
2
+ import * as Schema3 from 'effect/Schema';
3
+ import { randomUUID } from 'node:crypto';
4
+ import * as Effect4 from 'effect/Effect';
5
+ import * as Option from 'effect/Option';
6
+ import { mkdirSync } from 'node:fs';
7
+ import { dirname, join, relative } from 'node:path';
8
+ import { DatabaseSync } from 'node:sqlite';
9
+ import * as Config from 'effect/Config';
10
+ import * as Exit from 'effect/Exit';
11
+ import { execFile } from 'node:child_process';
12
+ import { readFile } from 'node:fs/promises';
13
+ import * as Layer from 'effect/Layer';
14
+ import 'effect/ManagedRuntime';
15
+ import { platform } from 'node:os';
16
+ import chokidar from 'chokidar';
17
+ import * as Queue from 'effect/Queue';
18
+ import * as Runtime from 'effect/Runtime';
19
+ import * as Stream from 'effect/Stream';
20
+
21
+ // src/api/schemas/review.ts
22
+ var ReviewId = Schema3.String.pipe(Schema3.brand("ReviewId"));
23
+ var ReviewStatus = Schema3.Literal(
24
+ "in_progress",
25
+ "approved",
26
+ "changes_requested"
27
+ );
28
+ var ReviewSourceType = Schema3.Literal("staged", "branch", "commits");
29
+ Schema3.Struct({
30
+ baseRef: Schema3.NullOr(Schema3.String),
31
+ createdAt: Schema3.String,
32
+ id: ReviewId,
33
+ repositoryPath: Schema3.String,
34
+ snapshotData: Schema3.String,
35
+ sourceRef: Schema3.NullOr(Schema3.String),
36
+ sourceType: ReviewSourceType,
37
+ status: ReviewStatus,
38
+ updatedAt: Schema3.String
39
+ });
40
+ Schema3.Struct({
41
+ sourceRef: Schema3.optionalWith(Schema3.NullOr(Schema3.String), {
42
+ default: () => null
43
+ }),
44
+ sourceType: Schema3.optionalWith(ReviewSourceType, {
45
+ default: () => "staged"
46
+ })
47
+ });
48
+ Schema3.Struct({
49
+ status: Schema3.optionalWith(ReviewStatus, { as: "Option" })
50
+ });
51
+ var ReviewNotFound = class extends Schema3.TaggedError()(
52
+ "ReviewNotFound",
53
+ { id: ReviewId },
54
+ HttpApiSchema.annotations({ status: 404 })
55
+ ) {
56
+ };
57
+ var TodoId = Schema3.String.pipe(Schema3.brand("TodoId"));
58
+ Schema3.Struct({
59
+ completed: Schema3.Boolean,
60
+ content: Schema3.String,
61
+ createdAt: Schema3.String,
62
+ id: TodoId,
63
+ position: Schema3.Number,
64
+ reviewId: Schema3.NullOr(ReviewId),
65
+ updatedAt: Schema3.String
66
+ });
67
+ Schema3.Struct({
68
+ content: Schema3.String.pipe(Schema3.minLength(1)),
69
+ reviewId: Schema3.optionalWith(Schema3.NullOr(ReviewId), {
70
+ default: () => null
71
+ })
72
+ });
73
+ Schema3.Struct({
74
+ completed: Schema3.optionalWith(Schema3.Boolean, { as: "Option" }),
75
+ content: Schema3.optionalWith(Schema3.String.pipe(Schema3.minLength(1)), {
76
+ as: "Option"
77
+ })
78
+ });
79
+ var TodoNotFound = class extends Schema3.TaggedError()(
80
+ "TodoNotFound",
81
+ { id: TodoId },
82
+ HttpApiSchema.annotations({ status: 404 })
83
+ ) {
84
+ };
85
+ var CommentId = Schema3.String.pipe(Schema3.brand("CommentId"));
86
+ var LineType = Schema3.Literal("added", "removed", "context");
87
+ Schema3.Struct({
88
+ content: Schema3.String,
89
+ createdAt: Schema3.String,
90
+ filePath: Schema3.String,
91
+ id: CommentId,
92
+ lineNumber: Schema3.NullOr(Schema3.Number),
93
+ lineType: Schema3.NullOr(LineType),
94
+ resolved: Schema3.Boolean,
95
+ reviewId: ReviewId,
96
+ suggestion: Schema3.NullOr(Schema3.String),
97
+ updatedAt: Schema3.String
98
+ });
99
+ Schema3.Struct({
100
+ content: Schema3.String.pipe(Schema3.minLength(1)),
101
+ filePath: Schema3.String.pipe(Schema3.minLength(1)),
102
+ lineNumber: Schema3.optionalWith(Schema3.NullOr(Schema3.Number), {
103
+ default: () => null
104
+ }),
105
+ lineType: Schema3.optionalWith(Schema3.NullOr(LineType), {
106
+ default: () => null
107
+ }),
108
+ suggestion: Schema3.optionalWith(Schema3.NullOr(Schema3.String), {
109
+ default: () => null
110
+ })
111
+ });
112
+ Schema3.Struct({
113
+ content: Schema3.optionalWith(Schema3.String.pipe(Schema3.minLength(1)), {
114
+ as: "Option"
115
+ }),
116
+ suggestion: Schema3.optionalWith(Schema3.NullOr(Schema3.String), {
117
+ as: "Option"
118
+ })
119
+ });
120
+ var CommentNotFound = class extends Schema3.TaggedError()(
121
+ "CommentNotFound",
122
+ { id: CommentId },
123
+ HttpApiSchema.annotations({ status: 404 })
124
+ ) {
125
+ };
126
+
127
+ // src/core/db/migrations.ts
128
+ var migrations = [
129
+ // v1: reviews table
130
+ `CREATE TABLE IF NOT EXISTS reviews (
131
+ id TEXT PRIMARY KEY,
132
+ repository_path TEXT NOT NULL,
133
+ base_ref TEXT,
134
+ snapshot_data TEXT NOT NULL,
135
+ status TEXT DEFAULT 'in_progress',
136
+ created_at TEXT DEFAULT (datetime('now')),
137
+ updated_at TEXT DEFAULT (datetime('now'))
138
+ ) STRICT`,
139
+ // v2: comments table
140
+ `CREATE TABLE IF NOT EXISTS comments (
141
+ id TEXT PRIMARY KEY,
142
+ review_id TEXT NOT NULL REFERENCES reviews(id) ON DELETE CASCADE,
143
+ file_path TEXT NOT NULL,
144
+ line_number INTEGER,
145
+ line_type TEXT,
146
+ content TEXT NOT NULL,
147
+ suggestion TEXT,
148
+ resolved INTEGER DEFAULT 0,
149
+ created_at TEXT DEFAULT (datetime('now')),
150
+ updated_at TEXT DEFAULT (datetime('now'))
151
+ ) STRICT`,
152
+ // v3: add source_type and source_ref to reviews
153
+ `ALTER TABLE reviews ADD COLUMN source_type TEXT DEFAULT 'staged';
154
+ ALTER TABLE reviews ADD COLUMN source_ref TEXT`,
155
+ // v4: todos table
156
+ `CREATE TABLE IF NOT EXISTS todos (
157
+ id TEXT PRIMARY KEY,
158
+ content TEXT NOT NULL,
159
+ completed INTEGER DEFAULT 0,
160
+ review_id TEXT REFERENCES reviews(id) ON DELETE CASCADE,
161
+ created_at TEXT DEFAULT (datetime('now')),
162
+ updated_at TEXT DEFAULT (datetime('now'))
163
+ ) STRICT`,
164
+ // v5: add position to todos
165
+ `ALTER TABLE todos ADD COLUMN position INTEGER DEFAULT 0`,
166
+ // v6: review_files table
167
+ `CREATE TABLE IF NOT EXISTS review_files (
168
+ id TEXT PRIMARY KEY,
169
+ review_id TEXT NOT NULL REFERENCES reviews(id) ON DELETE CASCADE,
170
+ file_path TEXT NOT NULL,
171
+ old_path TEXT,
172
+ status TEXT NOT NULL,
173
+ additions INTEGER NOT NULL DEFAULT 0,
174
+ deletions INTEGER NOT NULL DEFAULT 0,
175
+ hunks_data TEXT,
176
+ created_at TEXT DEFAULT (datetime('now'))
177
+ ) STRICT`
178
+ ];
179
+ var runMigrations = (db) => {
180
+ const currentVersion = db.prepare("PRAGMA user_version").get().user_version;
181
+ for (let i = currentVersion; i < migrations.length; i++) {
182
+ const statements = migrations[i].split(";").map((s) => s.trim()).filter(Boolean);
183
+ for (const sql of statements) {
184
+ db.exec(sql);
185
+ }
186
+ db.exec(`PRAGMA user_version = ${i + 1}`);
187
+ }
188
+ };
189
+
190
+ // src/core/db/database.ts
191
+ var withTransaction = (db, body) => Effect4.acquireUseRelease(
192
+ Effect4.sync(() => db.exec("BEGIN")),
193
+ () => body,
194
+ (_, exit) => Effect4.sync(() => {
195
+ if (Exit.isSuccess(exit)) {
196
+ db.exec("COMMIT");
197
+ } else {
198
+ db.exec("ROLLBACK");
199
+ }
200
+ })
201
+ );
202
+ var SqliteService = class extends Effect4.Service()(
203
+ "@ringi/SqliteService",
204
+ {
205
+ effect: Effect4.gen(function* effect() {
206
+ const dbPath = yield* Config.string("DB_PATH").pipe(
207
+ Config.withDefault(".ringi/reviews.db")
208
+ );
209
+ mkdirSync(dirname(dbPath), { recursive: true });
210
+ const db = new DatabaseSync(dbPath);
211
+ db.exec("PRAGMA journal_mode=WAL");
212
+ db.exec("PRAGMA foreign_keys=ON");
213
+ runMigrations(db);
214
+ return { db };
215
+ })
216
+ }
217
+ ) {
218
+ };
219
+
220
+ // src/core/repos/comment.repo.ts
221
+ var rowToComment = (row) => ({
222
+ content: row.content,
223
+ createdAt: row.created_at,
224
+ filePath: row.file_path,
225
+ id: row.id,
226
+ lineNumber: row.line_number,
227
+ lineType: row.line_type,
228
+ resolved: row.resolved === 1,
229
+ reviewId: row.review_id,
230
+ suggestion: row.suggestion,
231
+ updatedAt: row.updated_at
232
+ });
233
+ var CommentRepo = class extends Effect4.Service()(
234
+ "@ringi/CommentRepo",
235
+ {
236
+ dependencies: [SqliteService.Default],
237
+ effect: Effect4.gen(function* effect() {
238
+ const { db } = yield* SqliteService;
239
+ const stmtFindById = db.prepare("SELECT * FROM comments WHERE id = ?");
240
+ const stmtFindByReview = db.prepare(
241
+ "SELECT * FROM comments WHERE review_id = ? ORDER BY created_at ASC"
242
+ );
243
+ const stmtFindByFile = db.prepare(
244
+ "SELECT * FROM comments WHERE review_id = ? AND file_path = ? ORDER BY line_number ASC, created_at ASC"
245
+ );
246
+ const stmtInsert = db.prepare(
247
+ `INSERT INTO comments (id, review_id, file_path, line_number, line_type, content, suggestion, resolved, created_at, updated_at)
248
+ VALUES (?, ?, ?, ?, ?, ?, ?, 0, datetime('now'), datetime('now'))`
249
+ );
250
+ const stmtDelete = db.prepare("DELETE FROM comments WHERE id = ?");
251
+ const stmtDeleteByReview = db.prepare(
252
+ "DELETE FROM comments WHERE review_id = ?"
253
+ );
254
+ const stmtSetResolved = db.prepare(
255
+ "UPDATE comments SET resolved = ?, updated_at = datetime('now') WHERE id = ?"
256
+ );
257
+ const stmtCountByReview = db.prepare(
258
+ `SELECT
259
+ COUNT(*) as total,
260
+ SUM(CASE WHEN resolved = 1 THEN 1 ELSE 0 END) as resolved,
261
+ SUM(CASE WHEN resolved = 0 THEN 1 ELSE 0 END) as unresolved,
262
+ SUM(CASE WHEN suggestion IS NOT NULL THEN 1 ELSE 0 END) as with_suggestions
263
+ FROM comments WHERE review_id = ?`
264
+ );
265
+ const findById = (id) => Effect4.sync(() => {
266
+ const row = stmtFindById.get(id);
267
+ return row ? rowToComment(row) : null;
268
+ });
269
+ const findByReview = (reviewId) => Effect4.sync(() => {
270
+ const rows = stmtFindByReview.all(
271
+ reviewId
272
+ );
273
+ return rows.map(rowToComment);
274
+ });
275
+ const findByFile = (reviewId, filePath) => Effect4.sync(() => {
276
+ const rows = stmtFindByFile.all(
277
+ reviewId,
278
+ filePath
279
+ );
280
+ return rows.map(rowToComment);
281
+ });
282
+ const create = (input) => Effect4.sync(() => {
283
+ stmtInsert.run(
284
+ input.id,
285
+ input.reviewId,
286
+ input.filePath,
287
+ input.lineNumber,
288
+ input.lineType,
289
+ input.content,
290
+ input.suggestion
291
+ );
292
+ return rowToComment(
293
+ stmtFindById.get(input.id)
294
+ );
295
+ });
296
+ const update = (id, updates) => Effect4.sync(() => {
297
+ const setClauses = [];
298
+ const params = [];
299
+ if (updates.content !== void 0) {
300
+ setClauses.push("content = ?");
301
+ params.push(updates.content);
302
+ }
303
+ if (updates.suggestion !== void 0) {
304
+ setClauses.push("suggestion = ?");
305
+ params.push(updates.suggestion);
306
+ }
307
+ if (setClauses.length === 0) {
308
+ const row2 = stmtFindById.get(id);
309
+ return row2 ? rowToComment(row2) : null;
310
+ }
311
+ setClauses.push("updated_at = datetime('now')");
312
+ params.push(id);
313
+ db.prepare(
314
+ `UPDATE comments SET ${setClauses.join(", ")} WHERE id = ?`
315
+ ).run(...params);
316
+ const row = stmtFindById.get(id);
317
+ return row ? rowToComment(row) : null;
318
+ });
319
+ const setResolved = (id, resolved) => Effect4.sync(() => {
320
+ stmtSetResolved.run(resolved ? 1 : 0, id);
321
+ const row = stmtFindById.get(id);
322
+ return row ? rowToComment(row) : null;
323
+ });
324
+ const remove = (id) => Effect4.sync(() => {
325
+ const result = stmtDelete.run(id);
326
+ return Number(result.changes) > 0;
327
+ });
328
+ const removeByReview = (reviewId) => Effect4.sync(() => {
329
+ const result = stmtDeleteByReview.run(reviewId);
330
+ return Number(result.changes);
331
+ });
332
+ const countByReview = (reviewId) => Effect4.sync(() => {
333
+ const row = stmtCountByReview.get(reviewId);
334
+ return {
335
+ resolved: row.resolved,
336
+ total: row.total,
337
+ unresolved: row.unresolved,
338
+ withSuggestions: row.with_suggestions
339
+ };
340
+ });
341
+ return {
342
+ countByReview,
343
+ create,
344
+ findByFile,
345
+ findById,
346
+ findByReview,
347
+ remove,
348
+ removeByReview,
349
+ setResolved,
350
+ update
351
+ };
352
+ })
353
+ }
354
+ ) {
355
+ };
356
+
357
+ // src/core/services/comment.service.ts
358
+ var CommentService = class extends Effect4.Service()(
359
+ "@ringi/CommentService",
360
+ {
361
+ dependencies: [CommentRepo.Default],
362
+ effect: Effect4.sync(() => {
363
+ const create = Effect4.fn("CommentService.create")(function* create2(reviewId, input) {
364
+ const repo = yield* CommentRepo;
365
+ const id = randomUUID();
366
+ return yield* repo.create({
367
+ content: input.content,
368
+ filePath: input.filePath,
369
+ id,
370
+ lineNumber: input.lineNumber,
371
+ lineType: input.lineType,
372
+ reviewId,
373
+ suggestion: input.suggestion
374
+ });
375
+ });
376
+ const getById = Effect4.fn("CommentService.getById")(function* getById2(id) {
377
+ const repo = yield* CommentRepo;
378
+ const comment = yield* repo.findById(id);
379
+ if (!comment) {
380
+ return yield* new CommentNotFound({ id });
381
+ }
382
+ return comment;
383
+ });
384
+ const getByReview = Effect4.fn("CommentService.getByReview")(
385
+ function* getByReview2(reviewId) {
386
+ const repo = yield* CommentRepo;
387
+ return yield* repo.findByReview(reviewId);
388
+ }
389
+ );
390
+ const getByFile = Effect4.fn("CommentService.getByFile")(
391
+ function* getByFile2(reviewId, filePath) {
392
+ const repo = yield* CommentRepo;
393
+ return yield* repo.findByFile(reviewId, filePath);
394
+ }
395
+ );
396
+ const update = Effect4.fn("CommentService.update")(function* update2(id, input) {
397
+ const repo = yield* CommentRepo;
398
+ const updates = {};
399
+ if (Option.isSome(input.content)) {
400
+ updates.content = input.content.value;
401
+ }
402
+ if (Option.isSome(input.suggestion)) {
403
+ updates.suggestion = input.suggestion.value;
404
+ }
405
+ const comment = yield* repo.update(id, updates);
406
+ if (!comment) {
407
+ return yield* new CommentNotFound({ id });
408
+ }
409
+ return comment;
410
+ });
411
+ const resolve = Effect4.fn("CommentService.resolve")(function* resolve2(id) {
412
+ const repo = yield* CommentRepo;
413
+ const comment = yield* repo.setResolved(id, true);
414
+ if (!comment) {
415
+ return yield* new CommentNotFound({ id });
416
+ }
417
+ return comment;
418
+ });
419
+ const unresolve = Effect4.fn("CommentService.unresolve")(
420
+ function* unresolve2(id) {
421
+ const repo = yield* CommentRepo;
422
+ const comment = yield* repo.setResolved(id, false);
423
+ if (!comment) {
424
+ return yield* new CommentNotFound({ id });
425
+ }
426
+ return comment;
427
+ }
428
+ );
429
+ const remove = Effect4.fn("CommentService.remove")(function* remove2(id) {
430
+ const repo = yield* CommentRepo;
431
+ const existed = yield* repo.remove(id);
432
+ if (!existed) {
433
+ return yield* new CommentNotFound({ id });
434
+ }
435
+ return { success: true };
436
+ });
437
+ const getStats = Effect4.fn("CommentService.getStats")(function* getStats2(reviewId) {
438
+ const repo = yield* CommentRepo;
439
+ return yield* repo.countByReview(reviewId);
440
+ });
441
+ return {
442
+ create,
443
+ getByFile,
444
+ getById,
445
+ getByReview,
446
+ getStats,
447
+ remove,
448
+ resolve,
449
+ unresolve,
450
+ update
451
+ };
452
+ })
453
+ }
454
+ ) {
455
+ };
456
+
457
+ // src/core/services/diff.service.ts
458
+ var HUNK_HEADER = /@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/;
459
+ var splitIntoFiles = (diffText) => {
460
+ const files = [];
461
+ const lines2 = diffText.split("\n");
462
+ let current = [];
463
+ for (const line of lines2) {
464
+ if (line.startsWith("diff --git")) {
465
+ if (current.length > 0) {
466
+ files.push(current.join("\n"));
467
+ }
468
+ current = [line];
469
+ } else {
470
+ current.push(line);
471
+ }
472
+ }
473
+ if (current.length > 0) {
474
+ files.push(current.join("\n"));
475
+ }
476
+ return files;
477
+ };
478
+ var parseHunks = (lines2) => {
479
+ const hunks = [];
480
+ let currentHunk = null;
481
+ let oldLineNum = 0;
482
+ let newLineNum = 0;
483
+ for (const line of lines2) {
484
+ const match = line.match(HUNK_HEADER);
485
+ if (match) {
486
+ if (currentHunk) {
487
+ hunks.push(currentHunk);
488
+ }
489
+ const oldStart = Number.parseInt(match[1], 10);
490
+ const oldLines = Number.parseInt(match[2] ?? "1", 10);
491
+ const newStart = Number.parseInt(match[3], 10);
492
+ const newLines = Number.parseInt(match[4] ?? "1", 10);
493
+ currentHunk = { lines: [], newLines, newStart, oldLines, oldStart };
494
+ oldLineNum = oldStart;
495
+ newLineNum = newStart;
496
+ continue;
497
+ }
498
+ if (!currentHunk) {
499
+ continue;
500
+ }
501
+ if (line.startsWith("+") && !line.startsWith("+++")) {
502
+ currentHunk.lines.push({
503
+ content: line.slice(1),
504
+ newLineNumber: newLineNum++,
505
+ oldLineNumber: null,
506
+ type: "added"
507
+ });
508
+ } else if (line.startsWith("-") && !line.startsWith("---")) {
509
+ currentHunk.lines.push({
510
+ content: line.slice(1),
511
+ newLineNumber: null,
512
+ oldLineNumber: oldLineNum++,
513
+ type: "removed"
514
+ });
515
+ } else if (line.startsWith(" ")) {
516
+ currentHunk.lines.push({
517
+ content: line.slice(1),
518
+ newLineNumber: newLineNum++,
519
+ oldLineNumber: oldLineNum++,
520
+ type: "context"
521
+ });
522
+ }
523
+ }
524
+ if (currentHunk) {
525
+ hunks.push(currentHunk);
526
+ }
527
+ return hunks;
528
+ };
529
+ var parseFileDiff = (fileDiff) => {
530
+ const lines2 = fileDiff.split("\n");
531
+ const diffLine = lines2.find((l) => l.startsWith("diff --git"));
532
+ if (!diffLine) {
533
+ return null;
534
+ }
535
+ const pathMatch = diffLine.match(/diff --git a\/(.+) b\/(.+)/);
536
+ if (!pathMatch) {
537
+ return null;
538
+ }
539
+ const oldPath = pathMatch[1];
540
+ const newPath = pathMatch[2];
541
+ let status = "modified";
542
+ if (lines2.some((l) => l.startsWith("deleted file mode"))) {
543
+ status = "deleted";
544
+ } else if (lines2.some((l) => l.startsWith("new file mode"))) {
545
+ status = "added";
546
+ } else if (lines2.some((l) => l.startsWith("rename from")) || oldPath !== newPath) {
547
+ status = "renamed";
548
+ }
549
+ const hunks = parseHunks(lines2);
550
+ let additions = 0;
551
+ let deletions = 0;
552
+ for (const hunk of hunks) {
553
+ for (const line of hunk.lines) {
554
+ if (line.type === "added") {
555
+ additions++;
556
+ } else if (line.type === "removed") {
557
+ deletions++;
558
+ }
559
+ }
560
+ }
561
+ return { additions, deletions, hunks, newPath, oldPath, status };
562
+ };
563
+ var parseDiff = (diffText) => {
564
+ if (!diffText.trim()) {
565
+ return [];
566
+ }
567
+ const blocks = splitIntoFiles(diffText);
568
+ const files = [];
569
+ for (const block of blocks) {
570
+ const parsed = parseFileDiff(block);
571
+ if (parsed) {
572
+ files.push(parsed);
573
+ }
574
+ }
575
+ return files;
576
+ };
577
+ var getDiffSummary = (files) => {
578
+ let totalAdditions = 0;
579
+ let totalDeletions = 0;
580
+ for (const file of files) {
581
+ totalAdditions += file.additions;
582
+ totalDeletions += file.deletions;
583
+ }
584
+ return {
585
+ filesAdded: files.filter((f) => f.status === "added").length,
586
+ filesDeleted: files.filter((f) => f.status === "deleted").length,
587
+ filesModified: files.filter((f) => f.status === "modified").length,
588
+ filesRenamed: files.filter((f) => f.status === "renamed").length,
589
+ totalAdditions,
590
+ totalDeletions,
591
+ totalFiles: files.length
592
+ };
593
+ };
594
+ var GitError = class extends Schema3.TaggedError()(
595
+ "GitError",
596
+ { message: Schema3.String },
597
+ HttpApiSchema.annotations({ status: 500 })
598
+ ) {
599
+ };
600
+ var execGit = (args, repoPath) => Effect4.tryPromise({
601
+ catch: (error) => new GitError({ message: String(error) }),
602
+ try: () => new Promise((resolve, reject) => {
603
+ execFile(
604
+ "git",
605
+ [...args],
606
+ { cwd: repoPath, maxBuffer: 50 * 1024 * 1024 },
607
+ (err, stdout) => {
608
+ if (err) {
609
+ reject(err);
610
+ } else {
611
+ resolve(stdout);
612
+ }
613
+ }
614
+ );
615
+ })
616
+ });
617
+ var lines = (output) => output.trim().split("\n").filter(Boolean);
618
+ var parseNameStatus = (output) => lines(output).map((line) => {
619
+ const [status, ...rest] = line.split(" ");
620
+ return { path: rest.join(" "), status };
621
+ });
622
+ var GitService = class extends Effect4.Service()(
623
+ "@ringi/GitService",
624
+ {
625
+ effect: Effect4.gen(function* effect() {
626
+ const repoPath = yield* Config.string("REPOSITORY_PATH").pipe(
627
+ Config.withDefault(process.cwd())
628
+ );
629
+ const hasCommits = execGit(["rev-parse", "HEAD"], repoPath).pipe(
630
+ Effect4.as(true),
631
+ Effect4.catchTag("GitError", () => Effect4.succeed(false)),
632
+ Effect4.withSpan("GitService.hasCommits")
633
+ );
634
+ const getRepositoryInfo = Effect4.gen(function* getRepositoryInfo2() {
635
+ const name = yield* execGit(
636
+ ["rev-parse", "--show-toplevel"],
637
+ repoPath
638
+ ).pipe(Effect4.map((s) => s.trim().split("/").pop() ?? "unknown"));
639
+ const branch = yield* execGit(
640
+ ["rev-parse", "--abbrev-ref", "HEAD"],
641
+ repoPath
642
+ ).pipe(Effect4.map((s) => s.trim()));
643
+ const remote = yield* execGit(
644
+ ["config", "--get", "remote.origin.url"],
645
+ repoPath
646
+ ).pipe(
647
+ Effect4.map((s) => s.trim() || null),
648
+ Effect4.catchTag("GitError", () => Effect4.succeed(null))
649
+ );
650
+ return { branch, name, path: repoPath, remote };
651
+ }).pipe(Effect4.withSpan("GitService.getRepositoryInfo"));
652
+ const getStagedDiff = execGit(
653
+ ["diff", "--cached", "--no-color", "--unified=3"],
654
+ repoPath
655
+ ).pipe(Effect4.withSpan("GitService.getStagedDiff"));
656
+ const getUncommittedDiff = hasCommits.pipe(
657
+ Effect4.flatMap(
658
+ (has) => has ? execGit(["diff", "HEAD", "--no-color", "--unified=3"], repoPath) : Effect4.succeed("")
659
+ ),
660
+ Effect4.withSpan("GitService.getUncommittedDiff")
661
+ );
662
+ const getUnstagedDiff = execGit(
663
+ ["diff", "--no-color", "--unified=3"],
664
+ repoPath
665
+ ).pipe(Effect4.withSpan("GitService.getUnstagedDiff"));
666
+ const getLastCommitDiff = hasCommits.pipe(
667
+ Effect4.flatMap(
668
+ (has) => has ? execGit(
669
+ ["show", "HEAD", "--format=", "--no-color", "--unified=3"],
670
+ repoPath
671
+ ) : Effect4.succeed("")
672
+ ),
673
+ Effect4.withSpan("GitService.getLastCommitDiff")
674
+ );
675
+ const getBranchDiff = Effect4.fn("GitService.getBranchDiff")(
676
+ function* getBranchDiff2(branch) {
677
+ return yield* execGit(
678
+ ["diff", `${branch}...HEAD`, "--no-color", "--unified=3"],
679
+ repoPath
680
+ );
681
+ }
682
+ );
683
+ const getCommitDiff = Effect4.fn("GitService.getCommitDiff")(
684
+ function* getCommitDiff2(shas) {
685
+ if (shas.length === 1) {
686
+ return yield* execGit(
687
+ ["show", shas[0], "--format=", "--no-color", "--unified=3"],
688
+ repoPath
689
+ );
690
+ }
691
+ const first = shas.at(-1);
692
+ const last = shas[0];
693
+ return yield* execGit(
694
+ ["diff", `${first}~1..${last}`, "--no-color", "--unified=3"],
695
+ repoPath
696
+ );
697
+ }
698
+ );
699
+ const getStagedFiles = execGit(
700
+ ["diff", "--cached", "--name-status"],
701
+ repoPath
702
+ ).pipe(
703
+ Effect4.map(parseNameStatus),
704
+ Effect4.withSpan("GitService.getStagedFiles")
705
+ );
706
+ const getUncommittedFiles = hasCommits.pipe(
707
+ Effect4.flatMap(
708
+ (has) => has ? execGit(["diff", "HEAD", "--name-status"], repoPath).pipe(
709
+ Effect4.map(parseNameStatus)
710
+ ) : Effect4.succeed([])
711
+ ),
712
+ Effect4.withSpan("GitService.getUncommittedFiles")
713
+ );
714
+ const getUnstagedFiles = execGit(
715
+ ["diff", "--name-status"],
716
+ repoPath
717
+ ).pipe(
718
+ Effect4.map(parseNameStatus),
719
+ Effect4.withSpan("GitService.getUnstagedFiles")
720
+ );
721
+ const getLastCommitFiles = hasCommits.pipe(
722
+ Effect4.flatMap(
723
+ (has) => has ? execGit(
724
+ ["show", "HEAD", "--format=", "--name-status"],
725
+ repoPath
726
+ ).pipe(Effect4.map(parseNameStatus)) : Effect4.succeed([])
727
+ ),
728
+ Effect4.withSpan("GitService.getLastCommitFiles")
729
+ );
730
+ const getFileContent = Effect4.fn("GitService.getFileContent")(
731
+ function* getFileContent2(filePath, version) {
732
+ switch (version) {
733
+ case "staged": {
734
+ return yield* execGit(["show", `:${filePath}`], repoPath);
735
+ }
736
+ case "head": {
737
+ return yield* execGit(["show", `HEAD:${filePath}`], repoPath);
738
+ }
739
+ case "working":
740
+ default: {
741
+ return yield* Effect4.tryPromise({
742
+ catch: (error) => new GitError({
743
+ message: `Failed to read ${filePath}: ${String(error)}`
744
+ }),
745
+ try: () => readFile(join(repoPath, filePath), "utf8")
746
+ });
747
+ }
748
+ }
749
+ }
750
+ );
751
+ const getFileTree = Effect4.fn("GitService.getFileTree")(
752
+ function* getFileTree2(ref) {
753
+ return yield* execGit(
754
+ ["ls-tree", "-r", "--name-only", ref],
755
+ repoPath
756
+ ).pipe(Effect4.map(lines));
757
+ }
758
+ );
759
+ const getBranches = execGit(
760
+ ["branch", "--format=%(refname:short) %(HEAD)"],
761
+ repoPath
762
+ ).pipe(
763
+ Effect4.map(
764
+ (output) => lines(output).map((line) => {
765
+ const [name, head] = line.split(" ");
766
+ return { current: head === "*", name };
767
+ })
768
+ ),
769
+ Effect4.withSpan("GitService.getBranches")
770
+ );
771
+ const getCommits = Effect4.fn("GitService.getCommits")(
772
+ function* getCommits2(opts) {
773
+ const limit = (opts.limit ?? 20) + 1;
774
+ const args = [
775
+ "log",
776
+ `--max-count=${limit}`,
777
+ `--skip=${opts.offset ?? 0}`,
778
+ "--format=%H %s %an %aI"
779
+ ];
780
+ if (opts.search) {
781
+ args.push(`--grep=${opts.search}`, "-i");
782
+ }
783
+ const output = yield* execGit(args, repoPath);
784
+ const rows = lines(output);
785
+ const hasMore = rows.length === limit;
786
+ const commits = (hasMore ? rows.slice(0, -1) : rows).map((line) => {
787
+ const [hash, message, author, date] = line.split(" ");
788
+ return {
789
+ author,
790
+ date,
791
+ hash,
792
+ message
793
+ };
794
+ });
795
+ return { commits, hasMore };
796
+ }
797
+ );
798
+ const stageFiles = Effect4.fn("GitService.stageFiles")(
799
+ function* stageFiles2(files) {
800
+ return yield* execGit(["add", "--", ...files], repoPath).pipe(
801
+ Effect4.as(files)
802
+ );
803
+ }
804
+ );
805
+ const stageAll = execGit(["add", "-A"], repoPath).pipe(
806
+ Effect4.flatMap(() => getStagedFiles),
807
+ Effect4.map((files) => files.map((f) => f.path)),
808
+ Effect4.withSpan("GitService.stageAll")
809
+ );
810
+ const unstageFiles = Effect4.fn("GitService.unstageFiles")(
811
+ function* unstageFiles2(files) {
812
+ return yield* execGit(
813
+ ["reset", "HEAD", "--", ...files],
814
+ repoPath
815
+ ).pipe(Effect4.as(files));
816
+ }
817
+ );
818
+ const getRepositoryPath = execGit(
819
+ ["rev-parse", "--show-toplevel"],
820
+ repoPath
821
+ ).pipe(
822
+ Effect4.map((s) => s.trim()),
823
+ Effect4.withSpan("GitService.getRepositoryPath")
824
+ );
825
+ return {
826
+ getBranchDiff,
827
+ getBranches,
828
+ getCommitDiff,
829
+ getCommits,
830
+ getFileContent,
831
+ getFileTree,
832
+ getLastCommitDiff,
833
+ getLastCommitFiles,
834
+ getRepositoryInfo,
835
+ getRepositoryPath,
836
+ getStagedDiff,
837
+ getStagedFiles,
838
+ getUncommittedDiff,
839
+ getUncommittedFiles,
840
+ getUnstagedDiff,
841
+ getUnstagedFiles,
842
+ hasCommits,
843
+ stageAll,
844
+ stageFiles,
845
+ unstageFiles
846
+ };
847
+ })
848
+ }
849
+ ) {
850
+ };
851
+ var parseHunks2 = (hunksData) => hunksData == null ? Effect4.succeed([]) : Effect4.try(() => JSON.parse(hunksData)).pipe(
852
+ Effect4.orElseSucceed(() => [])
853
+ );
854
+ var serializeHunks = (hunks) => JSON.stringify(hunks);
855
+ var ReviewFileRepo = class extends Effect4.Service()(
856
+ "@ringi/ReviewFileRepo",
857
+ {
858
+ dependencies: [SqliteService.Default],
859
+ effect: Effect4.gen(function* effect() {
860
+ const { db } = yield* SqliteService;
861
+ const stmtFindByReview = db.prepare(
862
+ `SELECT id, review_id, file_path, old_path, status, additions, deletions, created_at
863
+ FROM review_files WHERE review_id = ? ORDER BY file_path`
864
+ );
865
+ const stmtFindByReviewAndPath = db.prepare(
866
+ "SELECT * FROM review_files WHERE review_id = ? AND file_path = ?"
867
+ );
868
+ const stmtInsert = db.prepare(
869
+ `INSERT INTO review_files (id, review_id, file_path, old_path, status, additions, deletions, hunks_data, created_at)
870
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))`
871
+ );
872
+ const stmtDeleteByReview = db.prepare(
873
+ "DELETE FROM review_files WHERE review_id = ?"
874
+ );
875
+ const stmtCountByReview = db.prepare(
876
+ "SELECT COUNT(*) as count FROM review_files WHERE review_id = ?"
877
+ );
878
+ const findByReview = (reviewId) => Effect4.sync(
879
+ () => stmtFindByReview.all(reviewId)
880
+ );
881
+ const findByReviewAndPath = (reviewId, filePath) => Effect4.sync(() => {
882
+ const row = stmtFindByReviewAndPath.get(
883
+ reviewId,
884
+ filePath
885
+ );
886
+ return row ?? null;
887
+ });
888
+ const createBulk = (files) => withTransaction(
889
+ db,
890
+ Effect4.sync(() => {
891
+ for (const f of files) {
892
+ stmtInsert.run(
893
+ randomUUID(),
894
+ f.reviewId,
895
+ f.filePath,
896
+ f.oldPath,
897
+ f.status,
898
+ f.additions,
899
+ f.deletions,
900
+ f.hunksData
901
+ );
902
+ }
903
+ })
904
+ );
905
+ const deleteByReview = (reviewId) => Effect4.sync(() => {
906
+ const result = stmtDeleteByReview.run(reviewId);
907
+ return Number(result.changes);
908
+ });
909
+ const countByReview = (reviewId) => Effect4.sync(() => {
910
+ const row = stmtCountByReview.get(reviewId);
911
+ return row.count;
912
+ });
913
+ return {
914
+ countByReview,
915
+ createBulk,
916
+ deleteByReview,
917
+ findByReview,
918
+ findByReviewAndPath
919
+ };
920
+ })
921
+ }
922
+ ) {
923
+ };
924
+ var rowToReview = (row) => ({
925
+ baseRef: row.base_ref,
926
+ createdAt: row.created_at,
927
+ id: row.id,
928
+ repositoryPath: row.repository_path,
929
+ snapshotData: row.snapshot_data,
930
+ sourceRef: row.source_ref,
931
+ sourceType: row.source_type,
932
+ status: row.status,
933
+ updatedAt: row.updated_at
934
+ });
935
+ var ReviewRepo = class extends Effect4.Service()(
936
+ "@ringi/ReviewRepo",
937
+ {
938
+ dependencies: [SqliteService.Default],
939
+ effect: Effect4.gen(function* effect() {
940
+ const { db } = yield* SqliteService;
941
+ const stmtFindById = db.prepare("SELECT * FROM reviews WHERE id = ?");
942
+ const stmtInsert = db.prepare(
943
+ `INSERT INTO reviews (id, repository_path, base_ref, source_type, source_ref, snapshot_data, status, created_at, updated_at)
944
+ VALUES (?, ?, ?, ?, ?, ?, ?, datetime('now'), datetime('now'))`
945
+ );
946
+ const stmtUpdate = db.prepare(
947
+ `UPDATE reviews SET status = COALESCE(?, status), updated_at = datetime('now') WHERE id = ?`
948
+ );
949
+ const stmtDelete = db.prepare("DELETE FROM reviews WHERE id = ?");
950
+ const stmtCountAll = db.prepare("SELECT COUNT(*) as count FROM reviews");
951
+ const stmtCountByStatus = db.prepare(
952
+ "SELECT COUNT(*) as count FROM reviews WHERE status = ?"
953
+ );
954
+ const findById = (id) => Effect4.sync(() => {
955
+ const row = stmtFindById.get(id);
956
+ return row ? rowToReview(row) : null;
957
+ });
958
+ const findAll = (opts = {}) => Effect4.sync(() => {
959
+ const conditions = [];
960
+ const params = [];
961
+ if (opts.status != null) {
962
+ conditions.push("status = ?");
963
+ params.push(opts.status);
964
+ }
965
+ if (opts.repositoryPath != null) {
966
+ conditions.push("repository_path = ?");
967
+ params.push(opts.repositoryPath);
968
+ }
969
+ if (opts.sourceType != null) {
970
+ conditions.push("source_type = ?");
971
+ params.push(opts.sourceType);
972
+ }
973
+ const where = conditions.length > 0 ? ` WHERE ${conditions.join(" AND ")}` : "";
974
+ const page = opts.page ?? 1;
975
+ const pageSize = opts.pageSize ?? 20;
976
+ const offset = (page - 1) * pageSize;
977
+ const totalRow = db.prepare(`SELECT COUNT(*) as count FROM reviews${where}`).get(
978
+ ...params
979
+ );
980
+ const rows = db.prepare(
981
+ `SELECT * FROM reviews${where} ORDER BY created_at DESC LIMIT ? OFFSET ?`
982
+ ).all(
983
+ ...params,
984
+ pageSize,
985
+ offset
986
+ );
987
+ return { data: rows.map(rowToReview), total: totalRow.count };
988
+ });
989
+ const create = (input) => Effect4.sync(() => {
990
+ stmtInsert.run(
991
+ input.id,
992
+ input.repositoryPath,
993
+ input.baseRef,
994
+ input.sourceType,
995
+ input.sourceRef,
996
+ input.snapshotData,
997
+ input.status
998
+ );
999
+ return rowToReview(
1000
+ stmtFindById.get(input.id)
1001
+ );
1002
+ });
1003
+ const update = (id, status) => Effect4.sync(() => {
1004
+ stmtUpdate.run(status, id);
1005
+ const row = stmtFindById.get(id);
1006
+ return row ? rowToReview(row) : null;
1007
+ });
1008
+ const remove = (id) => Effect4.sync(() => {
1009
+ const result = stmtDelete.run(id);
1010
+ return Number(result.changes) > 0;
1011
+ });
1012
+ const countAll = () => Effect4.sync(() => {
1013
+ const row = stmtCountAll.get();
1014
+ return row.count;
1015
+ });
1016
+ const countByStatus = (status) => Effect4.sync(() => {
1017
+ const row = stmtCountByStatus.get(status);
1018
+ return row.count;
1019
+ });
1020
+ return {
1021
+ countAll,
1022
+ countByStatus,
1023
+ create,
1024
+ findAll,
1025
+ findById,
1026
+ remove,
1027
+ update
1028
+ };
1029
+ })
1030
+ }
1031
+ ) {
1032
+ };
1033
+
1034
+ // src/core/services/review.service.ts
1035
+ var ReviewError = class extends Schema3.TaggedError()(
1036
+ "ReviewError",
1037
+ { code: Schema3.String, message: Schema3.String },
1038
+ HttpApiSchema.annotations({ status: 400 })
1039
+ ) {
1040
+ };
1041
+ var getHeadSha = (repoPath) => Effect4.tryPromise({
1042
+ catch: () => new ReviewError({ code: "GIT_ERROR", message: "Failed to get HEAD" }),
1043
+ try: () => new Promise((resolve, reject) => {
1044
+ execFile(
1045
+ "git",
1046
+ ["rev-parse", "HEAD"],
1047
+ { cwd: repoPath },
1048
+ (err, stdout) => {
1049
+ if (err) {
1050
+ reject(err);
1051
+ } else {
1052
+ resolve(stdout.trim());
1053
+ }
1054
+ }
1055
+ );
1056
+ })
1057
+ });
1058
+ var parseSnapshotData = (s) => Effect4.try(() => JSON.parse(s)).pipe(
1059
+ Effect4.orElseSucceed(() => ({}))
1060
+ );
1061
+ var ReviewService = class extends Effect4.Service()(
1062
+ "@ringi/ReviewService",
1063
+ {
1064
+ dependencies: [
1065
+ ReviewRepo.Default,
1066
+ ReviewFileRepo.Default,
1067
+ GitService.Default
1068
+ ],
1069
+ effect: Effect4.sync(() => {
1070
+ const create = Effect4.fn("ReviewService.create")(function* create2(input) {
1071
+ const git = yield* GitService;
1072
+ const repo = yield* ReviewRepo;
1073
+ const fileRepo = yield* ReviewFileRepo;
1074
+ const repoPath = yield* git.getRepositoryPath;
1075
+ const hasCommitsResult = yield* git.hasCommits;
1076
+ if (!hasCommitsResult) {
1077
+ return yield* new ReviewError({
1078
+ code: "NO_COMMITS",
1079
+ message: "Repository has no commits"
1080
+ });
1081
+ }
1082
+ let diffText;
1083
+ let baseRef = null;
1084
+ const { sourceType, sourceRef } = input;
1085
+ switch (sourceType) {
1086
+ case "staged": {
1087
+ diffText = yield* git.getStagedDiff;
1088
+ if (!diffText.trim()) {
1089
+ return yield* new ReviewError({
1090
+ code: "NO_STAGED_CHANGES",
1091
+ message: "No staged changes"
1092
+ });
1093
+ }
1094
+ baseRef = yield* getHeadSha(repoPath);
1095
+ break;
1096
+ }
1097
+ case "branch": {
1098
+ if (!sourceRef) {
1099
+ return yield* new ReviewError({
1100
+ code: "INVALID_SOURCE",
1101
+ message: "Branch name required"
1102
+ });
1103
+ }
1104
+ diffText = yield* git.getBranchDiff(sourceRef);
1105
+ baseRef = sourceRef;
1106
+ break;
1107
+ }
1108
+ case "commits": {
1109
+ if (!sourceRef) {
1110
+ return yield* new ReviewError({
1111
+ code: "INVALID_SOURCE",
1112
+ message: "Commit SHAs required"
1113
+ });
1114
+ }
1115
+ const shas = sourceRef.split(",").map((s) => s.trim()).filter(Boolean);
1116
+ if (shas.length === 0) {
1117
+ return yield* new ReviewError({
1118
+ code: "INVALID_SOURCE",
1119
+ message: "No valid commit SHAs"
1120
+ });
1121
+ }
1122
+ diffText = yield* git.getCommitDiff(shas);
1123
+ baseRef = shas.at(-1) ?? null;
1124
+ break;
1125
+ }
1126
+ default: {
1127
+ return yield* new ReviewError({
1128
+ code: "INVALID_SOURCE",
1129
+ message: "Unsupported review source"
1130
+ });
1131
+ }
1132
+ }
1133
+ const files = parseDiff(diffText);
1134
+ if (files.length === 0) {
1135
+ return yield* new ReviewError({
1136
+ code: "NO_CHANGES",
1137
+ message: "No changes found"
1138
+ });
1139
+ }
1140
+ const repoInfo = yield* git.getRepositoryInfo;
1141
+ const reviewId = crypto.randomUUID();
1142
+ const storeHunks = sourceType === "staged";
1143
+ const fileInputs = files.map((f) => ({
1144
+ additions: f.additions,
1145
+ deletions: f.deletions,
1146
+ filePath: f.newPath,
1147
+ hunksData: storeHunks ? serializeHunks(f.hunks) : null,
1148
+ oldPath: f.oldPath !== f.newPath ? f.oldPath : null,
1149
+ reviewId,
1150
+ status: f.status
1151
+ }));
1152
+ const snapshotData = JSON.stringify({
1153
+ repository: repoInfo,
1154
+ version: 2
1155
+ });
1156
+ const review = yield* repo.create({
1157
+ baseRef,
1158
+ id: reviewId,
1159
+ repositoryPath: repoPath,
1160
+ snapshotData,
1161
+ sourceRef: sourceRef ?? null,
1162
+ sourceType,
1163
+ status: "in_progress"
1164
+ });
1165
+ yield* fileRepo.createBulk(fileInputs);
1166
+ return review;
1167
+ });
1168
+ const list = Effect4.fn("ReviewService.list")(function* list2(opts) {
1169
+ const repo = yield* ReviewRepo;
1170
+ const fileRepo = yield* ReviewFileRepo;
1171
+ const page = opts.page ?? 1;
1172
+ const pageSize = opts.pageSize ?? 20;
1173
+ const result = yield* repo.findAll({
1174
+ page,
1175
+ pageSize,
1176
+ repositoryPath: opts.repositoryPath,
1177
+ sourceType: opts.sourceType,
1178
+ status: opts.status
1179
+ });
1180
+ const reviews = [];
1181
+ for (const review of result.data) {
1182
+ const fileCount = yield* fileRepo.countByReview(review.id);
1183
+ const snapshot = yield* parseSnapshotData(review.snapshotData);
1184
+ reviews.push({
1185
+ ...review,
1186
+ fileCount,
1187
+ repository: snapshot.repository ?? null
1188
+ });
1189
+ }
1190
+ return {
1191
+ hasMore: page * pageSize < result.total,
1192
+ page,
1193
+ pageSize,
1194
+ reviews,
1195
+ total: result.total
1196
+ };
1197
+ });
1198
+ const getById = Effect4.fn("ReviewService.getById")(function* getById2(id) {
1199
+ const repo = yield* ReviewRepo;
1200
+ const fileRepo = yield* ReviewFileRepo;
1201
+ const review = yield* repo.findById(id);
1202
+ if (!review) {
1203
+ return yield* new ReviewNotFound({ id });
1204
+ }
1205
+ const fileRows = yield* fileRepo.findByReview(id);
1206
+ const files = fileRows.map((r) => ({
1207
+ additions: r.additions,
1208
+ deletions: r.deletions,
1209
+ filePath: r.file_path,
1210
+ id: r.id,
1211
+ oldPath: r.old_path,
1212
+ status: r.status
1213
+ }));
1214
+ const snapshot = yield* parseSnapshotData(review.snapshotData);
1215
+ const summary = getDiffSummary(
1216
+ files.map((f) => ({
1217
+ additions: f.additions,
1218
+ deletions: f.deletions,
1219
+ hunks: [],
1220
+ newPath: f.filePath,
1221
+ oldPath: f.oldPath ?? f.filePath,
1222
+ status: f.status
1223
+ }))
1224
+ );
1225
+ return {
1226
+ ...review,
1227
+ files,
1228
+ repository: snapshot.repository ?? null,
1229
+ summary
1230
+ };
1231
+ });
1232
+ const getFileHunks = Effect4.fn("ReviewService.getFileHunks")(
1233
+ function* getFileHunks2(reviewId, filePath) {
1234
+ const repo = yield* ReviewRepo;
1235
+ const fileRepo = yield* ReviewFileRepo;
1236
+ const git = yield* GitService;
1237
+ const review = yield* repo.findById(reviewId);
1238
+ if (!review) {
1239
+ return yield* new ReviewNotFound({ id: reviewId });
1240
+ }
1241
+ const fileRecord = yield* fileRepo.findByReviewAndPath(
1242
+ reviewId,
1243
+ filePath
1244
+ );
1245
+ if (fileRecord?.hunks_data) {
1246
+ return yield* parseHunks2(fileRecord.hunks_data);
1247
+ }
1248
+ if (review.sourceType === "branch" && review.sourceRef) {
1249
+ const diff = yield* git.getBranchDiff(review.sourceRef);
1250
+ const diffFiles = parseDiff(diff);
1251
+ const file = diffFiles.find((f) => f.newPath === filePath);
1252
+ return file?.hunks ?? [];
1253
+ }
1254
+ if (review.sourceType === "commits" && review.sourceRef) {
1255
+ const shas = review.sourceRef.split(",").map((s) => s.trim());
1256
+ const diff = yield* git.getCommitDiff(shas);
1257
+ const diffFiles = parseDiff(diff);
1258
+ const file = diffFiles.find((f) => f.newPath === filePath);
1259
+ return file?.hunks ?? [];
1260
+ }
1261
+ const snapshot = yield* parseSnapshotData(review.snapshotData);
1262
+ if (snapshot.files) {
1263
+ const legacyFile = snapshot.files.find(
1264
+ (f) => f.newPath === filePath
1265
+ );
1266
+ return legacyFile?.hunks ?? [];
1267
+ }
1268
+ return [];
1269
+ }
1270
+ );
1271
+ const update = Effect4.fn("ReviewService.update")(function* update2(id, input) {
1272
+ const repo = yield* ReviewRepo;
1273
+ const existing = yield* repo.findById(id);
1274
+ if (!existing) {
1275
+ return yield* new ReviewNotFound({ id });
1276
+ }
1277
+ const status = Option.getOrNull(input.status);
1278
+ const review = yield* repo.update(id, status);
1279
+ if (!review) {
1280
+ return yield* new ReviewNotFound({ id });
1281
+ }
1282
+ return review;
1283
+ });
1284
+ const remove = Effect4.fn("ReviewService.remove")(function* remove2(id) {
1285
+ const repo = yield* ReviewRepo;
1286
+ const fileRepo = yield* ReviewFileRepo;
1287
+ const existing = yield* repo.findById(id);
1288
+ if (!existing) {
1289
+ return yield* new ReviewNotFound({ id });
1290
+ }
1291
+ yield* fileRepo.deleteByReview(id);
1292
+ yield* repo.remove(id);
1293
+ return { success: true };
1294
+ });
1295
+ const getStats = Effect4.fn("ReviewService.getStats")(
1296
+ function* getStats2() {
1297
+ const repo = yield* ReviewRepo;
1298
+ const total = yield* repo.countAll();
1299
+ const inProgress = yield* repo.countByStatus("in_progress");
1300
+ const approved = yield* repo.countByStatus("approved");
1301
+ const changesRequested = yield* repo.countByStatus("changes_requested");
1302
+ return { approved, changesRequested, inProgress, total };
1303
+ }
1304
+ );
1305
+ return {
1306
+ create,
1307
+ getById,
1308
+ getFileHunks,
1309
+ getStats,
1310
+ list,
1311
+ remove,
1312
+ update
1313
+ };
1314
+ })
1315
+ }
1316
+ ) {
1317
+ };
1318
+ var rowToTodo = (row) => ({
1319
+ completed: row.completed === 1,
1320
+ content: row.content,
1321
+ createdAt: row.created_at,
1322
+ id: row.id,
1323
+ position: row.position,
1324
+ reviewId: row.review_id,
1325
+ updatedAt: row.updated_at
1326
+ });
1327
+ var TodoRepo = class extends Effect4.Service()("@ringi/TodoRepo", {
1328
+ dependencies: [SqliteService.Default],
1329
+ effect: Effect4.gen(function* effect() {
1330
+ const { db } = yield* SqliteService;
1331
+ const stmtFindById = db.prepare("SELECT * FROM todos WHERE id = ?");
1332
+ const stmtInsert = db.prepare(
1333
+ `INSERT INTO todos (id, content, completed, review_id, position, created_at, updated_at)
1334
+ VALUES (?, ?, 0, ?, ?, datetime('now'), datetime('now'))`
1335
+ );
1336
+ const stmtDelete = db.prepare("DELETE FROM todos WHERE id = ?");
1337
+ const stmtDeleteCompleted = db.prepare(
1338
+ "DELETE FROM todos WHERE completed = 1"
1339
+ );
1340
+ const stmtNextPosition = db.prepare(
1341
+ "SELECT COALESCE(MAX(position), -1) + 1 AS next_pos FROM todos"
1342
+ );
1343
+ const stmtCountAll = db.prepare("SELECT COUNT(*) as count FROM todos");
1344
+ const stmtCountCompleted = db.prepare(
1345
+ "SELECT COUNT(*) as count FROM todos WHERE completed = 1"
1346
+ );
1347
+ const stmtCountPending = db.prepare(
1348
+ "SELECT COUNT(*) as count FROM todos WHERE completed = 0"
1349
+ );
1350
+ const findById = (id) => Effect4.sync(() => {
1351
+ const row = stmtFindById.get(id);
1352
+ return row ? rowToTodo(row) : null;
1353
+ });
1354
+ const findAll = (opts = {}) => Effect4.sync(() => {
1355
+ const conditions = [];
1356
+ const params = [];
1357
+ if (opts.reviewId != null) {
1358
+ conditions.push("review_id = ?");
1359
+ params.push(opts.reviewId);
1360
+ }
1361
+ if (opts.completed != null) {
1362
+ conditions.push("completed = ?");
1363
+ params.push(opts.completed ? 1 : 0);
1364
+ }
1365
+ const where = conditions.length > 0 ? ` WHERE ${conditions.join(" AND ")}` : "";
1366
+ const totalRow = db.prepare(`SELECT COUNT(*) as count FROM todos${where}`).get(
1367
+ ...params
1368
+ );
1369
+ const limitClause = opts.limit != null ? ` LIMIT ? OFFSET ?` : "";
1370
+ const queryParams = opts.limit != null ? [...params, opts.limit, opts.offset ?? 0] : params;
1371
+ const rows = db.prepare(
1372
+ `SELECT * FROM todos${where} ORDER BY position ASC${limitClause}`
1373
+ ).all(
1374
+ ...queryParams
1375
+ );
1376
+ return { data: rows.map(rowToTodo), total: totalRow.count };
1377
+ });
1378
+ const create = (input) => Effect4.sync(() => {
1379
+ const { next_pos } = stmtNextPosition.get();
1380
+ stmtInsert.run(input.id, input.content, input.reviewId, next_pos);
1381
+ return rowToTodo(stmtFindById.get(input.id));
1382
+ });
1383
+ const update = (id, updates) => Effect4.sync(() => {
1384
+ const sets = [];
1385
+ const params = [];
1386
+ if (updates.content != null) {
1387
+ sets.push("content = ?");
1388
+ params.push(updates.content);
1389
+ }
1390
+ if (updates.completed != null) {
1391
+ sets.push("completed = ?");
1392
+ params.push(updates.completed ? 1 : 0);
1393
+ }
1394
+ if (sets.length === 0) {
1395
+ const row2 = stmtFindById.get(id);
1396
+ return row2 ? rowToTodo(row2) : null;
1397
+ }
1398
+ sets.push("updated_at = datetime('now')");
1399
+ params.push(id);
1400
+ db.prepare(`UPDATE todos SET ${sets.join(", ")} WHERE id = ?`).run(
1401
+ ...params
1402
+ );
1403
+ const row = stmtFindById.get(id);
1404
+ return row ? rowToTodo(row) : null;
1405
+ });
1406
+ const toggle = (id) => Effect4.sync(() => {
1407
+ const row = stmtFindById.get(id);
1408
+ if (!row) {
1409
+ return null;
1410
+ }
1411
+ const newCompleted = row.completed === 1 ? 0 : 1;
1412
+ db.prepare(
1413
+ "UPDATE todos SET completed = ?, updated_at = datetime('now') WHERE id = ?"
1414
+ ).run(newCompleted, id);
1415
+ return rowToTodo(stmtFindById.get(id));
1416
+ });
1417
+ const remove = (id) => Effect4.sync(() => {
1418
+ const result = stmtDelete.run(id);
1419
+ return Number(result.changes) > 0;
1420
+ });
1421
+ const removeCompleted = () => Effect4.sync(() => {
1422
+ const result = stmtDeleteCompleted.run();
1423
+ return Number(result.changes);
1424
+ });
1425
+ const reorder = (orderedIds) => withTransaction(
1426
+ db,
1427
+ Effect4.sync(() => {
1428
+ const stmt = db.prepare(
1429
+ "UPDATE todos SET position = ?, updated_at = datetime('now') WHERE id = ?"
1430
+ );
1431
+ let updated = 0;
1432
+ for (let i = 0; i < orderedIds.length; i++) {
1433
+ const result = stmt.run(i, orderedIds[i]);
1434
+ updated += Number(result.changes);
1435
+ }
1436
+ return updated;
1437
+ })
1438
+ );
1439
+ const move = (id, newPosition) => Effect4.gen(function* move2() {
1440
+ const row = stmtFindById.get(id);
1441
+ if (!row) {
1442
+ return null;
1443
+ }
1444
+ const oldPosition = row.position;
1445
+ yield* withTransaction(
1446
+ db,
1447
+ Effect4.sync(() => {
1448
+ if (newPosition < oldPosition) {
1449
+ db.prepare(
1450
+ "UPDATE todos SET position = position + 1, updated_at = datetime('now') WHERE position >= ? AND position < ? AND id != ?"
1451
+ ).run(newPosition, oldPosition, id);
1452
+ } else if (newPosition > oldPosition) {
1453
+ db.prepare(
1454
+ "UPDATE todos SET position = position - 1, updated_at = datetime('now') WHERE position > ? AND position <= ? AND id != ?"
1455
+ ).run(oldPosition, newPosition, id);
1456
+ }
1457
+ db.prepare(
1458
+ "UPDATE todos SET position = ?, updated_at = datetime('now') WHERE id = ?"
1459
+ ).run(newPosition, id);
1460
+ })
1461
+ );
1462
+ return rowToTodo(stmtFindById.get(id));
1463
+ });
1464
+ const countAll = () => Effect4.sync(() => {
1465
+ const row = stmtCountAll.get();
1466
+ return row.count;
1467
+ });
1468
+ const countCompleted = () => Effect4.sync(() => {
1469
+ const row = stmtCountCompleted.get();
1470
+ return row.count;
1471
+ });
1472
+ const countPending = () => Effect4.sync(() => {
1473
+ const row = stmtCountPending.get();
1474
+ return row.count;
1475
+ });
1476
+ return {
1477
+ countAll,
1478
+ countCompleted,
1479
+ countPending,
1480
+ create,
1481
+ findAll,
1482
+ findById,
1483
+ move,
1484
+ remove,
1485
+ removeCompleted,
1486
+ reorder,
1487
+ toggle,
1488
+ update
1489
+ };
1490
+ })
1491
+ }) {
1492
+ };
1493
+
1494
+ // src/core/services/todo.service.ts
1495
+ var TodoService = class extends Effect4.Service()(
1496
+ "@ringi/TodoService",
1497
+ {
1498
+ dependencies: [TodoRepo.Default],
1499
+ effect: Effect4.sync(() => {
1500
+ const create = Effect4.fn("TodoService.create")(function* create2(input) {
1501
+ const repo = yield* TodoRepo;
1502
+ const id = randomUUID();
1503
+ return yield* repo.create({
1504
+ content: input.content,
1505
+ id,
1506
+ reviewId: input.reviewId
1507
+ });
1508
+ });
1509
+ const getById = Effect4.fn("TodoService.getById")(function* getById2(id) {
1510
+ const repo = yield* TodoRepo;
1511
+ const todo = yield* repo.findById(id);
1512
+ if (!todo) {
1513
+ return yield* new TodoNotFound({ id });
1514
+ }
1515
+ return todo;
1516
+ });
1517
+ const list = Effect4.fn("TodoService.list")(function* list2(opts = {}) {
1518
+ const repo = yield* TodoRepo;
1519
+ const result = yield* repo.findAll(opts);
1520
+ return {
1521
+ data: result.data,
1522
+ limit: opts.limit ?? null,
1523
+ offset: opts.offset ?? 0,
1524
+ total: result.total
1525
+ };
1526
+ });
1527
+ const update = Effect4.fn("TodoService.update")(function* update2(id, input) {
1528
+ const repo = yield* TodoRepo;
1529
+ const existing = yield* repo.findById(id);
1530
+ if (!existing) {
1531
+ return yield* new TodoNotFound({ id });
1532
+ }
1533
+ const updates = {};
1534
+ if (Option.isSome(input.content)) {
1535
+ updates.content = input.content.value;
1536
+ }
1537
+ if (Option.isSome(input.completed)) {
1538
+ updates.completed = input.completed.value;
1539
+ }
1540
+ const todo = yield* repo.update(id, updates);
1541
+ if (!todo) {
1542
+ return yield* new TodoNotFound({ id });
1543
+ }
1544
+ return todo;
1545
+ });
1546
+ const toggle = Effect4.fn("TodoService.toggle")(function* toggle2(id) {
1547
+ const repo = yield* TodoRepo;
1548
+ const todo = yield* repo.toggle(id);
1549
+ if (!todo) {
1550
+ return yield* new TodoNotFound({ id });
1551
+ }
1552
+ return todo;
1553
+ });
1554
+ const remove = Effect4.fn("TodoService.remove")(function* remove2(id) {
1555
+ const repo = yield* TodoRepo;
1556
+ const existing = yield* repo.findById(id);
1557
+ if (!existing) {
1558
+ return yield* new TodoNotFound({ id });
1559
+ }
1560
+ yield* repo.remove(id);
1561
+ return { success: true };
1562
+ });
1563
+ const removeCompleted = Effect4.fn("TodoService.removeCompleted")(
1564
+ function* removeCompleted2() {
1565
+ const repo = yield* TodoRepo;
1566
+ const deleted = yield* repo.removeCompleted();
1567
+ return { deleted };
1568
+ }
1569
+ );
1570
+ const reorder = Effect4.fn("TodoService.reorder")(function* reorder2(orderedIds) {
1571
+ const repo = yield* TodoRepo;
1572
+ const updated = yield* repo.reorder(orderedIds);
1573
+ return { updated };
1574
+ });
1575
+ const move = Effect4.fn("TodoService.move")(function* move2(id, position) {
1576
+ const repo = yield* TodoRepo;
1577
+ const todo = yield* repo.move(id, position);
1578
+ if (!todo) {
1579
+ return yield* new TodoNotFound({ id });
1580
+ }
1581
+ return todo;
1582
+ });
1583
+ const getStats = Effect4.fn("TodoService.getStats")(function* getStats2() {
1584
+ const repo = yield* TodoRepo;
1585
+ const total = yield* repo.countAll();
1586
+ const completed = yield* repo.countCompleted();
1587
+ const pending = yield* repo.countPending();
1588
+ return { completed, pending, total };
1589
+ });
1590
+ return {
1591
+ create,
1592
+ getById,
1593
+ getStats,
1594
+ list,
1595
+ move,
1596
+ remove,
1597
+ removeCompleted,
1598
+ reorder,
1599
+ toggle,
1600
+ update
1601
+ };
1602
+ })
1603
+ }
1604
+ ) {
1605
+ };
1606
+ var ExportService = class extends Effect4.Service()(
1607
+ "@ringi/ExportService",
1608
+ {
1609
+ dependencies: [
1610
+ ReviewService.Default,
1611
+ CommentService.Default,
1612
+ TodoService.Default
1613
+ ],
1614
+ effect: Effect4.sync(() => {
1615
+ const exportReview = Effect4.fn("ExportService.exportReview")(
1616
+ function* exportReview2(reviewId) {
1617
+ const reviewSvc = yield* ReviewService;
1618
+ const commentSvc = yield* CommentService;
1619
+ const todoSvc = yield* TodoService;
1620
+ const review = yield* reviewSvc.getById(reviewId);
1621
+ const repo = review.repository;
1622
+ const repoName = repo?.name ?? "Unknown";
1623
+ const branch = repo?.branch ?? "unknown";
1624
+ const comments = yield* commentSvc.getByReview(reviewId);
1625
+ const commentStats = yield* commentSvc.getStats(reviewId);
1626
+ const todos = yield* todoSvc.list({ reviewId });
1627
+ const lines2 = [];
1628
+ lines2.push(`# Code Review: ${repoName}`);
1629
+ lines2.push("");
1630
+ lines2.push(`**Status:** ${review.status}`);
1631
+ lines2.push(`**Branch:** ${branch}`);
1632
+ lines2.push(`**Created:** ${review.createdAt}`);
1633
+ if (review.files && review.files.length > 0) {
1634
+ lines2.push("");
1635
+ lines2.push("## Files Changed");
1636
+ lines2.push("");
1637
+ lines2.push("| File | Status | Additions | Deletions |");
1638
+ lines2.push("|------|--------|-----------|-----------|");
1639
+ for (const f of review.files) {
1640
+ const statusLabel = f.status === "modified" ? "M" : f.status === "added" ? "A" : f.status === "deleted" ? "D" : f.status;
1641
+ lines2.push(
1642
+ `| ${f.filePath} | ${statusLabel} | +${f.additions} | -${f.deletions} |`
1643
+ );
1644
+ }
1645
+ }
1646
+ if (comments.length > 0) {
1647
+ lines2.push("");
1648
+ lines2.push(
1649
+ `## Comments (${commentStats.total} total, ${commentStats.resolved} resolved)`
1650
+ );
1651
+ const byFile = /* @__PURE__ */ new Map();
1652
+ for (const c of comments) {
1653
+ const key = c.filePath ?? "(general)";
1654
+ const arr = byFile.get(key) ?? [];
1655
+ arr.push(c);
1656
+ byFile.set(key, arr);
1657
+ }
1658
+ for (const [filePath, fileComments] of byFile) {
1659
+ lines2.push("");
1660
+ lines2.push(`### ${filePath}`);
1661
+ for (const c of fileComments) {
1662
+ lines2.push("");
1663
+ lines2.push(
1664
+ `**Line ${c.lineNumber ?? "\u2013"}** (${c.lineType ?? "context"})`
1665
+ );
1666
+ lines2.push(`> ${c.content}`);
1667
+ if (c.suggestion) {
1668
+ lines2.push("");
1669
+ lines2.push("```suggestion");
1670
+ lines2.push(c.suggestion);
1671
+ lines2.push("```");
1672
+ }
1673
+ }
1674
+ }
1675
+ }
1676
+ if (todos.data.length > 0) {
1677
+ const completed = todos.data.filter((t) => t.completed).length;
1678
+ lines2.push("");
1679
+ lines2.push("---");
1680
+ lines2.push("");
1681
+ lines2.push(
1682
+ `## Todos (${todos.total} total, ${completed} completed)`
1683
+ );
1684
+ lines2.push("");
1685
+ for (const t of todos.data) {
1686
+ const check = t.completed ? "x" : " ";
1687
+ lines2.push(`- [${check}] ${t.content}`);
1688
+ }
1689
+ }
1690
+ lines2.push("");
1691
+ return lines2.join("\n");
1692
+ }
1693
+ );
1694
+ return { exportReview };
1695
+ })
1696
+ }
1697
+ ) {
1698
+ };
1699
+ var EventService = class extends Effect4.Service()(
1700
+ "@ringi/EventService",
1701
+ {
1702
+ effect: Effect4.gen(function* effect() {
1703
+ const rt = yield* Effect4.runtime();
1704
+ const runFork2 = Runtime.runFork(rt);
1705
+ const subscribers = /* @__PURE__ */ new Set();
1706
+ const broadcast = Effect4.fn("EventService.broadcast")(function* broadcast2(type, data) {
1707
+ const event = { data, timestamp: Date.now(), type };
1708
+ for (const queue of subscribers) {
1709
+ yield* Queue.offer(queue, event);
1710
+ }
1711
+ });
1712
+ const subscribe = Effect4.fn("EventService.subscribe")(
1713
+ function* subscribe2() {
1714
+ const queue = yield* Queue.sliding(100);
1715
+ subscribers.add(queue);
1716
+ const stream = Stream.fromQueue(queue);
1717
+ const unsubscribe = Effect4.sync(() => {
1718
+ subscribers.delete(queue);
1719
+ }).pipe(Effect4.andThen(Queue.shutdown(queue)));
1720
+ return { stream, unsubscribe };
1721
+ }
1722
+ );
1723
+ const startFileWatcher = (repoPath) => Effect4.acquireRelease(
1724
+ Effect4.sync(() => {
1725
+ let debounceTimer = null;
1726
+ const watcher = chokidar.watch(repoPath, {
1727
+ ignoreInitial: true,
1728
+ ignored: [
1729
+ "**/node_modules/**",
1730
+ "**/.git/**",
1731
+ "**/.ringi/**",
1732
+ "**/dist/**"
1733
+ ],
1734
+ persistent: true,
1735
+ ...platform() === "darwin" ? { interval: 1e3, usePolling: true } : {}
1736
+ });
1737
+ const debouncedBroadcast = (filePath) => {
1738
+ if (debounceTimer) {
1739
+ clearTimeout(debounceTimer);
1740
+ }
1741
+ debounceTimer = setTimeout(() => {
1742
+ const rel = relative(repoPath, filePath);
1743
+ runFork2(broadcast("files", { path: rel }));
1744
+ }, 300);
1745
+ };
1746
+ watcher.on("add", debouncedBroadcast);
1747
+ watcher.on("change", debouncedBroadcast);
1748
+ watcher.on("unlink", debouncedBroadcast);
1749
+ return watcher;
1750
+ }),
1751
+ (watcher) => Effect4.promise(() => watcher.close())
1752
+ );
1753
+ const getClientCount = () => Effect4.sync(() => subscribers.size);
1754
+ return {
1755
+ broadcast,
1756
+ getClientCount,
1757
+ startFileWatcher,
1758
+ subscribe
1759
+ };
1760
+ })
1761
+ }
1762
+ ) {
1763
+ };
1764
+
1765
+ // src/core/runtime.ts
1766
+ var CoreLive = Layer.mergeAll(
1767
+ ReviewService.Default,
1768
+ ReviewRepo.Default,
1769
+ ReviewFileRepo.Default,
1770
+ CommentService.Default,
1771
+ CommentRepo.Default,
1772
+ TodoService.Default,
1773
+ TodoRepo.Default,
1774
+ GitService.Default,
1775
+ EventService.Default,
1776
+ ExportService.Default,
1777
+ SqliteService.Default
1778
+ );
1779
+
1780
+ export { CommentService, CoreLive, ExportService, GitService, ReviewId, ReviewNotFound, ReviewService, ReviewSourceType, TodoId, TodoNotFound, TodoService, getDiffSummary, parseDiff };
1781
+ //# sourceMappingURL=chunk-3JLVANJR.js.map
1782
+ //# sourceMappingURL=chunk-3JLVANJR.js.map