@aaronshaf/ger 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.
Files changed (91) hide show
  1. package/.ast-grep/rules/no-as-casting.yml +13 -0
  2. package/.eslintrc.js +12 -0
  3. package/.github/workflows/ci-simple.yml +53 -0
  4. package/.github/workflows/ci.yml +171 -0
  5. package/.github/workflows/claude-code-review.yml +78 -0
  6. package/.github/workflows/claude.yml +64 -0
  7. package/.github/workflows/dependency-update.yml +84 -0
  8. package/.github/workflows/release.yml +166 -0
  9. package/.github/workflows/security-scan.yml +113 -0
  10. package/.github/workflows/security.yml +96 -0
  11. package/.husky/pre-commit +16 -0
  12. package/.husky/pre-push +25 -0
  13. package/.lintstagedrc.json +6 -0
  14. package/.tool-versions +1 -0
  15. package/CLAUDE.md +103 -0
  16. package/DEVELOPMENT.md +361 -0
  17. package/LICENSE +21 -0
  18. package/README.md +325 -0
  19. package/bin/ger +3 -0
  20. package/biome.json +36 -0
  21. package/bun.lock +688 -0
  22. package/bunfig.toml +8 -0
  23. package/oxlint.json +24 -0
  24. package/package.json +55 -0
  25. package/scripts/check-coverage.ts +69 -0
  26. package/scripts/check-file-size.ts +38 -0
  27. package/scripts/fix-test-mocks.ts +55 -0
  28. package/src/api/gerrit.ts +466 -0
  29. package/src/cli/commands/abandon.ts +65 -0
  30. package/src/cli/commands/comment.ts +460 -0
  31. package/src/cli/commands/comments.ts +85 -0
  32. package/src/cli/commands/diff.ts +71 -0
  33. package/src/cli/commands/incoming.ts +226 -0
  34. package/src/cli/commands/init.ts +164 -0
  35. package/src/cli/commands/mine.ts +115 -0
  36. package/src/cli/commands/open.ts +57 -0
  37. package/src/cli/commands/review.ts +593 -0
  38. package/src/cli/commands/setup.ts +230 -0
  39. package/src/cli/commands/show.ts +303 -0
  40. package/src/cli/commands/status.ts +35 -0
  41. package/src/cli/commands/workspace.ts +200 -0
  42. package/src/cli/index.ts +420 -0
  43. package/src/prompts/default-review.md +80 -0
  44. package/src/prompts/system-inline-review.md +88 -0
  45. package/src/prompts/system-overall-review.md +152 -0
  46. package/src/schemas/config.test.ts +245 -0
  47. package/src/schemas/config.ts +75 -0
  48. package/src/schemas/gerrit.ts +455 -0
  49. package/src/services/ai-enhanced.ts +167 -0
  50. package/src/services/ai.ts +182 -0
  51. package/src/services/config.test.ts +414 -0
  52. package/src/services/config.ts +206 -0
  53. package/src/test-utils/mock-generator.ts +73 -0
  54. package/src/utils/comment-formatters.ts +153 -0
  55. package/src/utils/diff-context.ts +103 -0
  56. package/src/utils/diff-formatters.ts +141 -0
  57. package/src/utils/formatters.ts +85 -0
  58. package/src/utils/message-filters.ts +26 -0
  59. package/src/utils/shell-safety.ts +117 -0
  60. package/src/utils/status-indicators.ts +100 -0
  61. package/src/utils/url-parser.test.ts +123 -0
  62. package/src/utils/url-parser.ts +91 -0
  63. package/tests/abandon.test.ts +163 -0
  64. package/tests/ai-service.test.ts +489 -0
  65. package/tests/comment-batch-advanced.test.ts +431 -0
  66. package/tests/comment-gerrit-api-compliance.test.ts +414 -0
  67. package/tests/comment.test.ts +707 -0
  68. package/tests/comments.test.ts +323 -0
  69. package/tests/config-service-simple.test.ts +100 -0
  70. package/tests/diff.test.ts +419 -0
  71. package/tests/helpers/config-mock.ts +27 -0
  72. package/tests/incoming.test.ts +357 -0
  73. package/tests/interactive-incoming.test.ts +173 -0
  74. package/tests/mine.test.ts +318 -0
  75. package/tests/mocks/fetch-mock.ts +139 -0
  76. package/tests/mocks/msw-handlers.ts +80 -0
  77. package/tests/open.test.ts +233 -0
  78. package/tests/review.test.ts +669 -0
  79. package/tests/setup.ts +13 -0
  80. package/tests/show.test.ts +439 -0
  81. package/tests/unit/schemas/gerrit.test.ts +85 -0
  82. package/tests/unit/test-utils/mock-generator.test.ts +154 -0
  83. package/tests/unit/utils/comment-formatters.test.ts +415 -0
  84. package/tests/unit/utils/diff-context.test.ts +171 -0
  85. package/tests/unit/utils/diff-formatters.test.ts +165 -0
  86. package/tests/unit/utils/formatters.test.ts +411 -0
  87. package/tests/unit/utils/message-filters.test.ts +227 -0
  88. package/tests/unit/utils/prompt-helpers.test.ts +175 -0
  89. package/tests/unit/utils/shell-safety.test.ts +230 -0
  90. package/tests/unit/utils/status-indicators.test.ts +137 -0
  91. package/tsconfig.json +40 -0
@@ -0,0 +1,593 @@
1
+ import { Effect, pipe, Schema } from 'effect'
2
+ import { AiService } from '@/services/ai'
3
+ import { commentCommandWithInput } from './comment'
4
+ import { Console } from 'effect'
5
+ import { type ApiError, GerritApiService } from '@/api/gerrit'
6
+ import type { CommentInfo } from '@/schemas/gerrit'
7
+ import { sanitizeCDATA, escapeXML } from '@/utils/shell-safety'
8
+ import { formatDiffPretty } from '@/utils/diff-formatters'
9
+ import { formatDate } from '@/utils/formatters'
10
+ import * as fs from 'node:fs/promises'
11
+ import * as fsSync from 'node:fs'
12
+ import * as os from 'node:os'
13
+ import * as path from 'node:path'
14
+ import { fileURLToPath } from 'node:url'
15
+ import { dirname } from 'node:path'
16
+ import * as readline from 'node:readline'
17
+
18
+ // Get the directory of this module
19
+ const __filename = fileURLToPath(import.meta.url)
20
+ const __dirname = dirname(__filename)
21
+
22
+ // Effect-based file reading helper
23
+ const readFileEffect = (filePath: string): Effect.Effect<string, Error, never> =>
24
+ Effect.tryPromise({
25
+ try: () => fs.readFile(filePath, 'utf8'),
26
+ catch: (error) => new Error(`Failed to read file ${filePath}: ${error}`),
27
+ })
28
+
29
+ // Load default prompts from .md files using Effect
30
+ const loadDefaultPrompts = Effect.gen(function* () {
31
+ const defaultReviewPrompt = yield* readFileEffect(
32
+ path.join(__dirname, '../../prompts/default-review.md'),
33
+ )
34
+ const inlineReviewSystemPrompt = yield* readFileEffect(
35
+ path.join(__dirname, '../../prompts/system-inline-review.md'),
36
+ )
37
+ const overallReviewSystemPrompt = yield* readFileEffect(
38
+ path.join(__dirname, '../../prompts/system-overall-review.md'),
39
+ )
40
+
41
+ return {
42
+ defaultReviewPrompt,
43
+ inlineReviewSystemPrompt,
44
+ overallReviewSystemPrompt,
45
+ }
46
+ })
47
+
48
+ // Helper to expand tilde in file paths
49
+ const expandTilde = (filePath: string): string => {
50
+ if (filePath.startsWith('~/')) {
51
+ return path.join(os.homedir(), filePath.slice(2))
52
+ }
53
+ return filePath
54
+ }
55
+
56
+ // Helper to read prompt file using Effect
57
+ const readPromptFileEffect = (filePath: string): Effect.Effect<string | null, never, never> =>
58
+ Effect.gen(function* () {
59
+ const expanded = expandTilde(filePath)
60
+
61
+ // Check if file exists using sync method since Effect doesn't have a convenient async exists check
62
+ const exists = yield* Effect.try(() => fsSync.existsSync(expanded)).pipe(
63
+ Effect.catchAll(() => Effect.succeed(false)),
64
+ )
65
+
66
+ if (!exists) {
67
+ return null
68
+ }
69
+
70
+ // Read file using async Effect
71
+ const content = yield* readFileEffect(expanded).pipe(
72
+ Effect.catchAll(() => Effect.succeed(null)),
73
+ )
74
+
75
+ return content
76
+ })
77
+
78
+ interface ReviewOptions {
79
+ debug?: boolean
80
+ dryRun?: boolean
81
+ comment?: boolean
82
+ yes?: boolean
83
+ prompt?: string
84
+ }
85
+
86
+ // Schema for validating AI-generated inline comments
87
+ const InlineCommentSchema = Schema.Struct({
88
+ file: Schema.String,
89
+ message: Schema.String,
90
+ side: Schema.optional(Schema.String),
91
+ line: Schema.optional(Schema.Number),
92
+ range: Schema.optional(
93
+ Schema.Struct({
94
+ start_line: Schema.Number,
95
+ end_line: Schema.Number,
96
+ start_character: Schema.optional(Schema.Number),
97
+ end_character: Schema.optional(Schema.Number),
98
+ }),
99
+ ),
100
+ })
101
+
102
+ interface InlineComment extends Schema.Schema.Type<typeof InlineCommentSchema> {}
103
+
104
+ // Helper to validate and fix AI-generated inline comments
105
+ const validateAndFixInlineComments = (
106
+ rawComments: unknown[],
107
+ availableFiles: string[],
108
+ ): Effect.Effect<InlineComment[], never, never> =>
109
+ Effect.gen(function* () {
110
+ const validComments: InlineComment[] = []
111
+
112
+ for (const rawComment of rawComments) {
113
+ // Validate comment structure using Effect Schema
114
+ const parseResult = yield* Schema.decodeUnknown(InlineCommentSchema)(rawComment).pipe(
115
+ Effect.catchTag('ParseError', (parseError) =>
116
+ Effect.gen(function* () {
117
+ yield* Console.warn('Skipping comment with invalid structure')
118
+ return yield* Effect.succeed(null)
119
+ }),
120
+ ),
121
+ )
122
+
123
+ if (!parseResult) {
124
+ continue
125
+ }
126
+
127
+ const comment = parseResult
128
+
129
+ // Skip comments with invalid line formats (like ":range")
130
+ if (!comment.line && !comment.range) {
131
+ yield* Console.warn('Skipping comment with invalid line format')
132
+ continue
133
+ }
134
+
135
+ // Try to find the correct file path
136
+ let correctFilePath = comment.file
137
+
138
+ // If the file path doesn't exist exactly, try to find a match
139
+ if (!availableFiles.includes(comment.file)) {
140
+ // Look for files that end with the provided path (secure path matching)
141
+ const matchingFiles = availableFiles.filter((file) => {
142
+ const normalizedFile = file.replace(/\\/g, '/')
143
+ const normalizedComment = comment.file.replace(/\\/g, '/')
144
+
145
+ // Only match if the comment path is a suffix of the file path with proper boundaries
146
+ return (
147
+ normalizedFile.endsWith(normalizedComment) &&
148
+ (normalizedFile === normalizedComment ||
149
+ normalizedFile.endsWith(`/${normalizedComment}`))
150
+ )
151
+ })
152
+
153
+ if (matchingFiles.length === 1) {
154
+ correctFilePath = matchingFiles[0]
155
+ yield* Console.log(`Fixed file path: ${comment.file} -> ${correctFilePath}`)
156
+ } else if (matchingFiles.length > 1) {
157
+ // Multiple matches, try to pick the most likely one (exact suffix match)
158
+ const exactMatch = matchingFiles.find((file) => file.endsWith(`/${comment.file}`))
159
+ if (exactMatch) {
160
+ correctFilePath = exactMatch
161
+ yield* Console.log(
162
+ `Fixed file path (exact match): ${comment.file} -> ${correctFilePath}`,
163
+ )
164
+ } else {
165
+ yield* Console.warn(`Multiple file matches for ${comment.file}. Skipping comment.`)
166
+ continue
167
+ }
168
+ } else {
169
+ yield* Console.warn(`File not found in change: ${comment.file}. Skipping comment.`)
170
+ continue
171
+ }
172
+ }
173
+
174
+ // Update the comment with the correct file path and add to valid comments
175
+ validComments.push({ ...comment, file: correctFilePath })
176
+ }
177
+
178
+ return validComments
179
+ })
180
+
181
+ // Helper to get change data and format as XML string
182
+ const getChangeDataAsXml = (changeId: string): Effect.Effect<string, ApiError, GerritApiService> =>
183
+ Effect.gen(function* () {
184
+ const gerritApi = yield* GerritApiService
185
+
186
+ // Fetch all data
187
+ const change = yield* gerritApi.getChange(changeId)
188
+ const diffResult = yield* gerritApi.getDiff(changeId)
189
+ const diff = typeof diffResult === 'string' ? diffResult : JSON.stringify(diffResult)
190
+ const commentsMap = yield* gerritApi.getComments(changeId)
191
+ const messages = yield* gerritApi.getMessages(changeId)
192
+
193
+ // Flatten comments from all files
194
+ const comments: CommentInfo[] = []
195
+ for (const [path, fileComments] of Object.entries(commentsMap)) {
196
+ for (const comment of fileComments) {
197
+ comments.push({ ...comment, path })
198
+ }
199
+ }
200
+
201
+ // Build XML string
202
+ const xmlLines: string[] = []
203
+ xmlLines.push(`<?xml version="1.0" encoding="UTF-8"?>`)
204
+ xmlLines.push(`<show_result>`)
205
+ xmlLines.push(` <status>success</status>`)
206
+ xmlLines.push(` <change>`)
207
+ xmlLines.push(` <id>${escapeXML(change.change_id)}</id>`)
208
+ xmlLines.push(` <number>${change._number}</number>`)
209
+ xmlLines.push(` <subject><![CDATA[${sanitizeCDATA(change.subject)}]]></subject>`)
210
+ xmlLines.push(` <status>${escapeXML(change.status)}</status>`)
211
+ xmlLines.push(` <project>${escapeXML(change.project)}</project>`)
212
+ xmlLines.push(` <branch>${escapeXML(change.branch)}</branch>`)
213
+ xmlLines.push(` <owner>`)
214
+ if (change.owner?.name) {
215
+ xmlLines.push(` <name><![CDATA[${sanitizeCDATA(change.owner.name)}]]></name>`)
216
+ }
217
+ if (change.owner?.email) {
218
+ xmlLines.push(` <email>${escapeXML(change.owner.email)}</email>`)
219
+ }
220
+ xmlLines.push(` </owner>`)
221
+ xmlLines.push(` <created>${escapeXML(change.created || '')}</created>`)
222
+ xmlLines.push(` <updated>${escapeXML(change.updated || '')}</updated>`)
223
+ xmlLines.push(` </change>`)
224
+ xmlLines.push(` <diff><![CDATA[${sanitizeCDATA(diff)}]]></diff>`)
225
+
226
+ // Comments section
227
+ xmlLines.push(` <comments>`)
228
+ xmlLines.push(` <count>${comments.length}</count>`)
229
+ for (const comment of comments) {
230
+ xmlLines.push(` <comment>`)
231
+ if (comment.id) xmlLines.push(` <id>${escapeXML(comment.id)}</id>`)
232
+ if (comment.path)
233
+ xmlLines.push(` <path><![CDATA[${sanitizeCDATA(comment.path)}]]></path>`)
234
+ if (comment.line) xmlLines.push(` <line>${comment.line}</line>`)
235
+ if (comment.author?.name) {
236
+ xmlLines.push(` <author><![CDATA[${sanitizeCDATA(comment.author.name)}]]></author>`)
237
+ }
238
+ if (comment.updated) xmlLines.push(` <updated>${escapeXML(comment.updated)}</updated>`)
239
+ if (comment.message) {
240
+ xmlLines.push(` <message><![CDATA[${sanitizeCDATA(comment.message)}]]></message>`)
241
+ }
242
+ if (comment.unresolved) xmlLines.push(` <unresolved>true</unresolved>`)
243
+ xmlLines.push(` </comment>`)
244
+ }
245
+ xmlLines.push(` </comments>`)
246
+
247
+ // Messages section
248
+ xmlLines.push(` <messages>`)
249
+ xmlLines.push(` <count>${messages.length}</count>`)
250
+ for (const message of messages) {
251
+ xmlLines.push(` <message>`)
252
+ xmlLines.push(` <id>${escapeXML(message.id)}</id>`)
253
+ if (message.author?.name) {
254
+ xmlLines.push(` <author><![CDATA[${sanitizeCDATA(message.author.name)}]]></author>`)
255
+ }
256
+ if (message.author?._account_id) {
257
+ xmlLines.push(` <author_id>${message.author._account_id}</author_id>`)
258
+ }
259
+ xmlLines.push(` <date>${escapeXML(message.date)}</date>`)
260
+ if (message._revision_number) {
261
+ xmlLines.push(` <revision>${message._revision_number}</revision>`)
262
+ }
263
+ if (message.tag) {
264
+ xmlLines.push(` <tag>${escapeXML(message.tag)}</tag>`)
265
+ }
266
+ xmlLines.push(` <message><![CDATA[${sanitizeCDATA(message.message)}]]></message>`)
267
+ xmlLines.push(` </message>`)
268
+ }
269
+ xmlLines.push(` </messages>`)
270
+ xmlLines.push(`</show_result>`)
271
+
272
+ return xmlLines.join('\n')
273
+ })
274
+
275
+ // Helper to get change data and format as pretty string
276
+ const getChangeDataAsPretty = (
277
+ changeId: string,
278
+ ): Effect.Effect<string, ApiError, GerritApiService> =>
279
+ Effect.gen(function* () {
280
+ const gerritApi = yield* GerritApiService
281
+
282
+ // Fetch all data
283
+ const change = yield* gerritApi.getChange(changeId)
284
+ const diffResult = yield* gerritApi.getDiff(changeId)
285
+ const diff = typeof diffResult === 'string' ? diffResult : JSON.stringify(diffResult)
286
+ const commentsMap = yield* gerritApi.getComments(changeId)
287
+ const messages = yield* gerritApi.getMessages(changeId)
288
+
289
+ // Flatten comments from all files
290
+ const comments: CommentInfo[] = []
291
+ for (const [path, fileComments] of Object.entries(commentsMap)) {
292
+ for (const comment of fileComments) {
293
+ comments.push({ ...comment, path })
294
+ }
295
+ }
296
+
297
+ // Build pretty string
298
+ const lines: string[] = []
299
+
300
+ // Change details header
301
+ lines.push('━'.repeat(80))
302
+ lines.push(`📋 Change ${change._number}: ${change.subject}`)
303
+ lines.push('━'.repeat(80))
304
+ lines.push('')
305
+
306
+ // Metadata
307
+ lines.push('📝 Details:')
308
+ lines.push(` Project: ${change.project}`)
309
+ lines.push(` Branch: ${change.branch}`)
310
+ lines.push(` Status: ${change.status}`)
311
+ lines.push(` Owner: ${change.owner?.name || change.owner?.email || 'Unknown'}`)
312
+ lines.push(` Created: ${change.created ? formatDate(change.created) : 'Unknown'}`)
313
+ lines.push(` Updated: ${change.updated ? formatDate(change.updated) : 'Unknown'}`)
314
+ lines.push(` Change-Id: ${change.change_id}`)
315
+ lines.push('')
316
+
317
+ // Diff section
318
+ lines.push('🔍 Diff:')
319
+ lines.push('─'.repeat(40))
320
+ lines.push(formatDiffPretty(diff))
321
+ lines.push('')
322
+
323
+ // Comments section
324
+ if (comments.length > 0) {
325
+ lines.push('💬 Inline Comments:')
326
+ lines.push('─'.repeat(40))
327
+ for (const comment of comments) {
328
+ const author = comment.author?.name || 'Unknown'
329
+ const date = comment.updated ? formatDate(comment.updated) : 'Unknown'
330
+ lines.push(`📅 ${date} - ${author}`)
331
+ if (comment.path) lines.push(` File: ${comment.path}`)
332
+ if (comment.line) lines.push(` Line: ${comment.line}`)
333
+ lines.push(` ${comment.message}`)
334
+ if (comment.unresolved) lines.push(` ⚠️ Unresolved`)
335
+ lines.push('')
336
+ }
337
+ }
338
+
339
+ // Messages section
340
+ if (messages.length > 0) {
341
+ lines.push('📝 Review Activity:')
342
+ lines.push('─'.repeat(40))
343
+ for (const message of messages) {
344
+ const author = message.author?.name || 'Unknown'
345
+ const date = formatDate(message.date)
346
+ const cleanMessage = message.message.trim()
347
+
348
+ // Skip very short automated messages
349
+ if (
350
+ cleanMessage.length < 10 &&
351
+ (cleanMessage.includes('Build') || cleanMessage.includes('Patch'))
352
+ ) {
353
+ continue
354
+ }
355
+
356
+ lines.push(`📅 ${date} - ${author}`)
357
+ lines.push(` ${cleanMessage}`)
358
+ lines.push('')
359
+ }
360
+ }
361
+
362
+ return lines.join('\n')
363
+ })
364
+
365
+ // Helper function to prompt user for confirmation
366
+ const promptUser = (message: string): Effect.Effect<boolean, never> =>
367
+ Effect.async<boolean, never>((resume) => {
368
+ const rl = readline.createInterface({
369
+ input: process.stdin,
370
+ output: process.stdout,
371
+ })
372
+
373
+ rl.question(`${message} [y/N]: `, (answer: string) => {
374
+ rl.close()
375
+ resume(Effect.succeed(answer.toLowerCase() === 'y'))
376
+ })
377
+ })
378
+
379
+ export const reviewCommand = (changeId: string, options: ReviewOptions = {}) =>
380
+ Effect.gen(function* () {
381
+ const aiService = yield* AiService
382
+
383
+ // Load default prompts first
384
+ const prompts = yield* loadDefaultPrompts
385
+
386
+ // Check for AI tool availability first
387
+ yield* Console.log('→ Checking for AI tool availability...')
388
+ const aiTool = yield* aiService
389
+ .detectAiTool()
390
+ .pipe(Effect.catchTag('NoAiToolFoundError', (error) => Effect.fail(new Error(error.message))))
391
+ yield* Console.log(`✓ Found AI tool: ${aiTool}`)
392
+
393
+ // Load custom review prompt if provided via --prompt option
394
+ let userReviewPrompt = prompts.defaultReviewPrompt
395
+
396
+ if (options.prompt) {
397
+ const customPrompt = yield* readPromptFileEffect(options.prompt)
398
+ if (customPrompt) {
399
+ userReviewPrompt = customPrompt
400
+ yield* Console.log(`✓ Using custom review prompt from ${options.prompt}`)
401
+ } else {
402
+ yield* Console.log(`⚠ Could not read custom prompt file: ${options.prompt}`)
403
+ yield* Console.log('→ Using default review prompt')
404
+ }
405
+ }
406
+
407
+ // Combine user prompt with system prompts for each stage
408
+ const inlinePrompt = `${userReviewPrompt}\n\n${prompts.inlineReviewSystemPrompt}`
409
+ const overallPrompt = `${userReviewPrompt}\n\n${prompts.overallReviewSystemPrompt}`
410
+
411
+ yield* Console.log(`→ Fetching change data for ${changeId}...`)
412
+
413
+ // Stage 1: Generate inline comments
414
+ yield* Console.log(`→ Generating inline comments for change ${changeId}...`)
415
+
416
+ // Get change data in XML format for inline review
417
+ const xmlData = yield* getChangeDataAsXml(changeId)
418
+
419
+ if (options.debug) {
420
+ yield* Console.log('[DEBUG] Running AI for inline comments...')
421
+ }
422
+
423
+ // Run inline review
424
+ const inlineResponse = yield* aiService.runPrompt(inlinePrompt, xmlData).pipe(
425
+ Effect.catchTag('AiResponseParseError', (error) =>
426
+ Effect.gen(function* () {
427
+ if (options.debug) {
428
+ yield* Console.error(`[DEBUG] AI output:\n${error.rawOutput}`)
429
+ }
430
+ return yield* Effect.fail(error)
431
+ }),
432
+ ),
433
+ Effect.catchTag('AiServiceError', (error) =>
434
+ Effect.die(new Error(`AI service error: ${error.message}`)),
435
+ ),
436
+ )
437
+
438
+ if (options.debug) {
439
+ yield* Console.log(`[DEBUG] Inline response:\n${inlineResponse}`)
440
+ }
441
+
442
+ // Parse JSON array from response using Effect
443
+ const inlineCommentsArray = yield* Effect.tryPromise({
444
+ try: () => Promise.resolve(JSON.parse(inlineResponse)),
445
+ catch: (error) => new Error(`Invalid JSON response: ${error}`),
446
+ }).pipe(
447
+ Effect.catchAll((error) =>
448
+ Effect.gen(function* () {
449
+ yield* Console.error(`✗ Failed to parse inline comments JSON: ${error}`)
450
+ if (!options.debug) {
451
+ yield* Console.error('Run with --debug to see raw AI output')
452
+ }
453
+ return yield* Effect.fail(error)
454
+ }),
455
+ ),
456
+ )
457
+
458
+ // Validate that the response is an array
459
+ if (!Array.isArray(inlineCommentsArray)) {
460
+ yield* Console.error('✗ AI response is not an array of comments')
461
+ return yield* Effect.fail(new Error('Invalid inline comments format'))
462
+ }
463
+
464
+ // Get available files for validation
465
+ const gerritApi = yield* GerritApiService
466
+ const files = yield* gerritApi.getFiles(changeId)
467
+ const availableFiles = Object.keys(files)
468
+
469
+ // Validate and fix inline comments
470
+ const originalCount = inlineCommentsArray.length
471
+ const inlineComments = yield* validateAndFixInlineComments(inlineCommentsArray, availableFiles)
472
+ const validCount = inlineComments.length
473
+
474
+ if (originalCount > validCount) {
475
+ yield* Console.log(
476
+ `→ Filtered ${originalCount - validCount} invalid comments, ${validCount} remain`,
477
+ )
478
+ }
479
+
480
+ // If not in comment mode, just output the inline comments
481
+ if (!options.comment) {
482
+ if (inlineComments.length > 0) {
483
+ yield* Console.log('\n━━━━━━ INLINE COMMENTS ━━━━━━')
484
+ for (const comment of inlineComments) {
485
+ yield* Console.log(`\n📍 ${comment.file}${comment.line ? `:${comment.line}` : ''}`)
486
+ yield* Console.log(comment.message)
487
+ }
488
+ } else {
489
+ yield* Console.log('\n→ No inline comments')
490
+ }
491
+ } else {
492
+ // In comment mode, handle posting
493
+ if (inlineComments.length > 0) {
494
+ yield* Console.log('\n━━━━━━ INLINE COMMENTS TO POST ━━━━━━')
495
+ for (const comment of inlineComments) {
496
+ yield* Console.log(`\n📍 ${comment.file}${comment.line ? `:${comment.line}` : ''}`)
497
+ yield* Console.log(comment.message)
498
+ }
499
+ yield* Console.log('\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━')
500
+
501
+ // Ask for confirmation unless --yes is provided
502
+ const shouldPost = options.yes
503
+ ? true
504
+ : yield* promptUser('\nPost these inline comments to Gerrit?')
505
+
506
+ if (shouldPost) {
507
+ if (inlineComments.length === 0) {
508
+ yield* Console.log('→ No valid comments to post after validation')
509
+ } else {
510
+ // Post inline comments using the new direct input method
511
+ yield* pipe(
512
+ commentCommandWithInput(changeId, JSON.stringify(inlineComments), { batch: true }),
513
+ Effect.catchAll((error) =>
514
+ Effect.gen(function* () {
515
+ yield* Console.error(`✗ Failed to post inline comments: ${error}`)
516
+ return yield* Effect.fail(error)
517
+ }),
518
+ ),
519
+ )
520
+ yield* Console.log(`✓ Inline comments posted for ${changeId}`)
521
+ }
522
+ } else {
523
+ yield* Console.log('→ Inline comments not posted')
524
+ }
525
+ } else {
526
+ yield* Console.log('\n→ No valid inline comments to post')
527
+ }
528
+ }
529
+
530
+ // Stage 2: Generate overall review comment
531
+ yield* Console.log(`→ Generating overall review comment for change ${changeId}...`)
532
+
533
+ // Get change data in regular format for overall review
534
+ const prettyData = yield* getChangeDataAsPretty(changeId)
535
+
536
+ if (options.debug) {
537
+ yield* Console.log('[DEBUG] Running AI for overall review...')
538
+ }
539
+
540
+ // Run overall review
541
+ const overallResponse = yield* aiService.runPrompt(overallPrompt, prettyData).pipe(
542
+ Effect.catchTag('AiResponseParseError', (error) =>
543
+ Effect.gen(function* () {
544
+ if (options.debug) {
545
+ yield* Console.error(`[DEBUG] AI output:\n${error.rawOutput}`)
546
+ }
547
+ return yield* Effect.fail(error)
548
+ }),
549
+ ),
550
+ Effect.catchTag('AiServiceError', (error) =>
551
+ Effect.die(new Error(`AI service error: ${error.message}`)),
552
+ ),
553
+ )
554
+
555
+ if (options.debug) {
556
+ yield* Console.log(`[DEBUG] Overall response:\n${overallResponse}`)
557
+ }
558
+
559
+ // If not in comment mode, just output the review
560
+ if (!options.comment) {
561
+ yield* Console.log('\n━━━━━━ OVERALL REVIEW ━━━━━━')
562
+ yield* Console.log(overallResponse)
563
+ yield* Console.log('\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━')
564
+ } else {
565
+ // In comment mode, handle posting
566
+ yield* Console.log('\n━━━━━━ OVERALL REVIEW TO POST ━━━━━━')
567
+ yield* Console.log(overallResponse)
568
+ yield* Console.log('\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━')
569
+
570
+ // Ask for confirmation unless --yes is provided
571
+ const shouldPost = options.yes
572
+ ? true
573
+ : yield* promptUser('\nPost this overall review to Gerrit?')
574
+
575
+ if (shouldPost) {
576
+ // Post overall comment using the new direct input method
577
+ yield* pipe(
578
+ commentCommandWithInput(changeId, overallResponse, {}),
579
+ Effect.catchAll((error) =>
580
+ Effect.gen(function* () {
581
+ yield* Console.error(`✗ Failed to post review comment: ${error}`)
582
+ return yield* Effect.fail(error)
583
+ }),
584
+ ),
585
+ )
586
+ yield* Console.log(`✓ Overall review posted for ${changeId}`)
587
+ } else {
588
+ yield* Console.log('→ Overall review not posted')
589
+ }
590
+ }
591
+
592
+ yield* Console.log(`✓ Review complete for ${changeId}`)
593
+ })