@aaronshaf/ger 0.1.0 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bun.lock CHANGED
@@ -4,6 +4,7 @@
4
4
  "": {
5
5
  "name": "ger",
6
6
  "dependencies": {
7
+ "@anthropic-ai/claude-code": "^1.0.102",
7
8
  "@effect/platform": "^0.90.6",
8
9
  "@effect/platform-node": "^0.94.2",
9
10
  "@effect/schema": "^0.75.5",
@@ -11,18 +12,18 @@
11
12
  "chalk": "^5.6.0",
12
13
  "cli-table3": "^0.6.5",
13
14
  "commander": "^14.0.0",
14
- "effect": "^3.17.8",
15
+ "effect": "^3.17.9",
15
16
  "signal-exit": "3.0.7",
16
17
  },
17
18
  "devDependencies": {
18
- "@biomejs/biome": "^2.2.0",
19
+ "@biomejs/biome": "^2.2.2",
19
20
  "@types/node": "^24.3.0",
20
21
  "ast-grep": "^0.1.0",
21
- "bun-types": "^1.2.20",
22
+ "bun-types": "^1.2.21",
22
23
  "husky": "^9.1.7",
23
24
  "lint-staged": "^16.1.5",
24
25
  "msw": "^2.10.5",
25
- "oxlint": "^1.12.0",
26
+ "oxlint": "^1.13.0",
26
27
  "typescript": "^5.9.2",
27
28
  },
28
29
  "peerDependencies": {
@@ -31,6 +32,8 @@
31
32
  },
32
33
  },
33
34
  "packages": {
35
+ "@anthropic-ai/claude-code": ["@anthropic-ai/claude-code@1.0.102", "", { "optionalDependencies": { "@img/sharp-darwin-arm64": "^0.33.5", "@img/sharp-darwin-x64": "^0.33.5", "@img/sharp-linux-arm": "^0.33.5", "@img/sharp-linux-arm64": "^0.33.5", "@img/sharp-linux-x64": "^0.33.5", "@img/sharp-win32-x64": "^0.33.5" }, "bin": { "claude": "cli.js" } }, "sha512-UIC6qNgKNZi1nLTf1bQvxNfd74xIAqJjIx6vggh3bJOMtuXBiFwrfPk1Pdf9CayYgwZYXgSmxYYaASt6i6ficQ=="],
36
+
34
37
  "@babel/code-frame": ["@babel/code-frame@7.0.0-beta.37", "", { "dependencies": { "chalk": "^2.0.0", "esutils": "^2.0.2", "js-tokens": "^3.0.0" } }, "sha512-LIpcKm+2otOOvOvhCbD6wkNYi8aUwHk73uWR+hxBdW2EFht5D0QX89n4me8nyeNGWr5zC3Pvmjq+9MvUof+jkg=="],
35
38
 
36
39
  "@babel/generator": ["@babel/generator@7.28.3", "", { "dependencies": { "@babel/parser": "^7.28.3", "@babel/types": "^7.28.2", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-3lSpxGgvnmZznmBkCRnVREPUFJv2wrv9iAoFDvADJc0ypmdOxdUtcLeBgBJ6zE0PMeTKnxeQzyk0xTBq4Ep7zw=="],
@@ -93,6 +96,28 @@
93
96
 
94
97
  "@effect/workflow": ["@effect/workflow@0.8.3", "", { "peerDependencies": { "@effect/platform": "^0.90.0", "@effect/rpc": "^0.68.3", "effect": "^3.17.6" } }, "sha512-8X5IOemCb6I66GMd84w6NSmaQ+Ya3oXwItCUMelQAEuRtGzwqsw8PNNunQgK/poSRkmpszlsKOb6kEVNjSdFiQ=="],
95
98
 
99
+ "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.0.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ=="],
100
+
101
+ "@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.0.4" }, "os": "darwin", "cpu": "x64" }, "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q=="],
102
+
103
+ "@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.0.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg=="],
104
+
105
+ "@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.0.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ=="],
106
+
107
+ "@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.0.5", "", { "os": "linux", "cpu": "arm" }, "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g=="],
108
+
109
+ "@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.0.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA=="],
110
+
111
+ "@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.0.4", "", { "os": "linux", "cpu": "x64" }, "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw=="],
112
+
113
+ "@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.0.5" }, "os": "linux", "cpu": "arm" }, "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ=="],
114
+
115
+ "@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.0.4" }, "os": "linux", "cpu": "arm64" }, "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA=="],
116
+
117
+ "@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.0.4" }, "os": "linux", "cpu": "x64" }, "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA=="],
118
+
119
+ "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.33.5", "", { "os": "win32", "cpu": "x64" }, "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg=="],
120
+
96
121
  "@inquirer/checkbox": ["@inquirer/checkbox@4.2.2", "", { "dependencies": { "@inquirer/core": "^10.2.0", "@inquirer/figures": "^1.0.13", "@inquirer/type": "^3.0.8", "ansi-escapes": "^4.3.2", "yoctocolors-cjs": "^2.1.2" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-E+KExNurKcUJJdxmjglTl141EwxWyAHplvsYJQgSwXf8qiNWkTxTuCCqmhFEmbIXd4zLaGMfQFJ6WrZ7fSeV3g=="],
97
122
 
98
123
  "@inquirer/confirm": ["@inquirer/confirm@5.1.16", "", { "dependencies": { "@inquirer/core": "^10.2.0", "@inquirer/type": "^3.0.8" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-j1a5VstaK5KQy8Mu8cHmuQvN1Zc62TbLhjJxwHvKPPKEoowSF6h/0UdOpA9DNdWZ+9Inq73+puRq1df6OJ8Sag=="],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aaronshaf/ger",
3
- "version": "0.1.0",
3
+ "version": "0.1.3",
4
4
  "module": "index.ts",
5
5
  "type": "module",
6
6
  "bin": {
@@ -29,6 +29,7 @@
29
29
  "typescript": "^5.0.0"
30
30
  },
31
31
  "dependencies": {
32
+ "@anthropic-ai/claude-code": "^1.0.102",
32
33
  "@effect/platform": "^0.90.6",
33
34
  "@effect/platform-node": "^0.94.2",
34
35
  "@effect/schema": "^0.75.5",
@@ -1,5 +1,5 @@
1
- import { Effect, pipe, Schema } from 'effect'
2
- import { AiService } from '@/services/ai'
1
+ import { Effect, pipe, Schema, Layer } from 'effect'
2
+ import { ReviewStrategyService, type ReviewStrategy } from '@/services/review-strategy'
3
3
  import { commentCommandWithInput } from './comment'
4
4
  import { Console } from 'effect'
5
5
  import { type ApiError, GerritApiService } from '@/api/gerrit'
@@ -7,6 +7,13 @@ import type { CommentInfo } from '@/schemas/gerrit'
7
7
  import { sanitizeCDATA, escapeXML } from '@/utils/shell-safety'
8
8
  import { formatDiffPretty } from '@/utils/diff-formatters'
9
9
  import { formatDate } from '@/utils/formatters'
10
+ import {
11
+ formatChangeAsXML,
12
+ formatCommentsAsXML,
13
+ formatMessagesAsXML,
14
+ flattenComments,
15
+ } from '@/utils/review-formatters'
16
+ import { buildEnhancedPrompt } from '@/utils/review-prompt-builder'
10
17
  import * as fs from 'node:fs/promises'
11
18
  import * as fsSync from 'node:fs'
12
19
  import * as os from 'node:os'
@@ -14,6 +21,7 @@ import * as path from 'node:path'
14
21
  import { fileURLToPath } from 'node:url'
15
22
  import { dirname } from 'node:path'
16
23
  import * as readline from 'node:readline'
24
+ import { GitWorktreeService, GitWorktreeServiceLive } from '@/services/git-worktree'
17
25
 
18
26
  // Get the directory of this module
19
27
  const __filename = fileURLToPath(import.meta.url)
@@ -81,6 +89,8 @@ interface ReviewOptions {
81
89
  comment?: boolean
82
90
  yes?: boolean
83
91
  prompt?: string
92
+ provider?: string
93
+ systemPrompt?: string
84
94
  }
85
95
 
86
96
  // Schema for validating AI-generated inline comments
@@ -178,7 +188,7 @@ const validateAndFixInlineComments = (
178
188
  return validComments
179
189
  })
180
190
 
181
- // Helper to get change data and format as XML string
191
+ // Legacy helper for backward compatibility (will be removed)
182
192
  const getChangeDataAsXml = (changeId: string): Effect.Effect<string, ApiError, GerritApiService> =>
183
193
  Effect.gen(function* () {
184
194
  const gerritApi = yield* GerritApiService
@@ -190,83 +200,17 @@ const getChangeDataAsXml = (changeId: string): Effect.Effect<string, ApiError, G
190
200
  const commentsMap = yield* gerritApi.getComments(changeId)
191
201
  const messages = yield* gerritApi.getMessages(changeId)
192
202
 
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
- }
203
+ const comments = flattenComments(commentsMap)
200
204
 
201
- // Build XML string
205
+ // Build XML string using helper functions
202
206
  const xmlLines: string[] = []
203
207
  xmlLines.push(`<?xml version="1.0" encoding="UTF-8"?>`)
204
208
  xmlLines.push(`<show_result>`)
205
209
  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>`)
210
+ xmlLines.push(...formatChangeAsXML(change))
224
211
  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>`)
212
+ xmlLines.push(...formatCommentsAsXML(comments))
213
+ xmlLines.push(...formatMessagesAsXML(messages))
270
214
  xmlLines.push(`</show_result>`)
271
215
 
272
216
  return xmlLines.join('\n')
@@ -286,13 +230,7 @@ const getChangeDataAsPretty = (
286
230
  const commentsMap = yield* gerritApi.getComments(changeId)
287
231
  const messages = yield* gerritApi.getMessages(changeId)
288
232
 
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
- }
233
+ const comments = flattenComments(commentsMap)
296
234
 
297
235
  // Build pretty string
298
236
  const lines: string[] = []
@@ -378,19 +316,30 @@ const promptUser = (message: string): Effect.Effect<boolean, never> =>
378
316
 
379
317
  export const reviewCommand = (changeId: string, options: ReviewOptions = {}) =>
380
318
  Effect.gen(function* () {
381
- const aiService = yield* AiService
319
+ const reviewStrategy = yield* ReviewStrategyService
320
+ const gitService = yield* GitWorktreeService
382
321
 
383
- // Load default prompts first
322
+ // Load default prompts
384
323
  const prompts = yield* loadDefaultPrompts
385
324
 
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}`)
325
+ // Validate preconditions
326
+ yield* gitService.validatePreconditions()
392
327
 
393
- // Load custom review prompt if provided via --prompt option
328
+ // Check for available AI strategies
329
+ yield* Console.log('→ Checking AI tool availability...')
330
+ const availableStrategies = yield* reviewStrategy.getAvailableStrategies()
331
+
332
+ if (availableStrategies.length === 0) {
333
+ return yield* Effect.fail(
334
+ new Error('No AI tools available. Please install claude, gemini, or codex CLI.'),
335
+ )
336
+ }
337
+
338
+ // Select strategy based on user preference
339
+ const selectedStrategy = yield* reviewStrategy.selectStrategy(options.provider)
340
+ yield* Console.log(`✓ Using AI tool: ${selectedStrategy.name}`)
341
+
342
+ // Load custom review prompt if provided
394
343
  let userReviewPrompt = prompts.defaultReviewPrompt
395
344
 
396
345
  if (options.prompt) {
@@ -404,81 +353,176 @@ export const reviewCommand = (changeId: string, options: ReviewOptions = {}) =>
404
353
  }
405
354
  }
406
355
 
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}`
356
+ // Use Effect's resource management for worktree lifecycle
357
+ yield* Effect.acquireUseRelease(
358
+ // Acquire: Create worktree and setup
359
+ Effect.gen(function* () {
360
+ const worktreeInfo = yield* gitService.createWorktree(changeId)
361
+ yield* gitService.fetchAndCheckoutPatchset(worktreeInfo)
362
+ return worktreeInfo
363
+ }),
364
+
365
+ // Use: Run the enhanced review process
366
+ (worktreeInfo) =>
367
+ Effect.gen(function* () {
368
+ // Switch to worktree directory
369
+ const originalCwd = process.cwd()
370
+ process.chdir(worktreeInfo.path)
371
+
372
+ try {
373
+ // Get changed files from git
374
+ const changedFiles = yield* gitService.getChangedFiles()
375
+
376
+ yield* Console.log(`→ Found ${changedFiles.length} changed files`)
377
+ if (options.debug) {
378
+ yield* Console.log(`[DEBUG] Changed files: ${changedFiles.join(', ')}`)
379
+ }
380
+
381
+ // Stage 1: Generate inline comments
382
+ yield* Console.log(`→ Generating inline comments for change ${changeId}...`)
383
+
384
+ const inlinePrompt = yield* buildEnhancedPrompt(
385
+ userReviewPrompt,
386
+ prompts.inlineReviewSystemPrompt,
387
+ changeId,
388
+ changedFiles,
389
+ )
410
390
 
411
- yield* Console.log(`→ Fetching change data for ${changeId}...`)
391
+ // Run inline review using selected strategy
392
+ if (options.debug) {
393
+ yield* Console.log(`[DEBUG] Running inline review with ${selectedStrategy.name}`)
394
+ yield* Console.log(`[DEBUG] Working directory: ${worktreeInfo.path}`)
395
+ }
396
+
397
+ const inlineResponse = yield* reviewStrategy
398
+ .executeWithStrategy(selectedStrategy, inlinePrompt, {
399
+ cwd: worktreeInfo.path,
400
+ systemPrompt: options.systemPrompt || prompts.inlineReviewSystemPrompt,
401
+ })
402
+ .pipe(
403
+ Effect.catchTag('ReviewStrategyError', (error) =>
404
+ Effect.gen(function* () {
405
+ yield* Console.error(`✗ Inline review failed: ${error.message}`)
406
+ return yield* Effect.fail(new Error(error.message))
407
+ }),
408
+ ),
409
+ )
410
+
411
+ if (options.debug) {
412
+ yield* Console.log(`[DEBUG] Inline review completed`)
413
+ yield* Console.log(`[DEBUG] Response length: ${inlineResponse.length} chars`)
414
+ }
415
+
416
+ // Response content is ready for parsing
417
+ const extractedInlineResponse = inlineResponse.trim()
418
+
419
+ if (options.debug) {
420
+ yield* Console.log(
421
+ `[DEBUG] Extracted response for parsing:\n${extractedInlineResponse}`,
422
+ )
423
+ }
424
+
425
+ // Parse JSON array from response
426
+ const inlineCommentsArray = yield* Effect.tryPromise({
427
+ try: () => Promise.resolve(JSON.parse(extractedInlineResponse)),
428
+ catch: (error) => new Error(`Invalid JSON response: ${error}`),
429
+ }).pipe(
430
+ Effect.catchAll((error) =>
431
+ Effect.gen(function* () {
432
+ yield* Console.error(`✗ Failed to parse inline comments JSON: ${error}`)
433
+ yield* Console.error(`Raw extracted response: "${extractedInlineResponse}"`)
434
+ if (!options.debug) {
435
+ yield* Console.error('Run with --debug to see full AI output')
436
+ }
437
+ return yield* Effect.fail(error)
438
+ }),
439
+ ),
440
+ )
412
441
 
413
- // Stage 1: Generate inline comments
414
- yield* Console.log(`→ Generating inline comments for change ${changeId}...`)
442
+ // Validate that the response is an array
443
+ if (!Array.isArray(inlineCommentsArray)) {
444
+ yield* Console.error('✗ AI response is not an array of comments')
445
+ return yield* Effect.fail(new Error('Invalid inline comments format'))
446
+ }
447
+
448
+ // Validate and fix inline comments
449
+ const originalCount = inlineCommentsArray.length
450
+ const inlineComments = yield* validateAndFixInlineComments(
451
+ inlineCommentsArray,
452
+ changedFiles,
453
+ )
454
+ const validCount = inlineComments.length
415
455
 
416
- // Get change data in XML format for inline review
417
- const xmlData = yield* getChangeDataAsXml(changeId)
456
+ if (originalCount > validCount) {
457
+ yield* Console.log(
458
+ `→ Filtered ${originalCount - validCount} invalid comments, ${validCount} remain`,
459
+ )
460
+ }
418
461
 
419
- if (options.debug) {
420
- yield* Console.log('[DEBUG] Running AI for inline comments...')
421
- }
462
+ // Handle inline comments output/posting
463
+ yield* handleInlineComments(inlineComments, changeId, options)
422
464
 
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
- )
465
+ // Stage 2: Generate overall review comment
466
+ yield* Console.log(`→ Generating overall review comment for change ${changeId}...`)
437
467
 
438
- if (options.debug) {
439
- yield* Console.log(`[DEBUG] Inline response:\n${inlineResponse}`)
440
- }
468
+ const overallPrompt = yield* buildEnhancedPrompt(
469
+ userReviewPrompt,
470
+ prompts.overallReviewSystemPrompt,
471
+ changeId,
472
+ changedFiles,
473
+ )
441
474
 
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')
475
+ // Run overall review using selected strategy
476
+ if (options.debug) {
477
+ yield* Console.log(`[DEBUG] Running overall review with ${selectedStrategy.name}`)
478
+ }
479
+
480
+ const overallResponse = yield* reviewStrategy
481
+ .executeWithStrategy(selectedStrategy, overallPrompt, {
482
+ cwd: worktreeInfo.path,
483
+ systemPrompt: options.systemPrompt || prompts.overallReviewSystemPrompt,
484
+ })
485
+ .pipe(
486
+ Effect.catchTag('ReviewStrategyError', (error) =>
487
+ Effect.gen(function* () {
488
+ yield* Console.error(`✗ Overall review failed: ${error.message}`)
489
+ return yield* Effect.fail(new Error(error.message))
490
+ }),
491
+ ),
492
+ )
493
+
494
+ if (options.debug) {
495
+ yield* Console.log(`[DEBUG] Overall review completed`)
496
+ yield* Console.log(`[DEBUG] Response length: ${overallResponse.length} chars`)
497
+ }
498
+
499
+ // Response content is ready for use
500
+ const extractedOverallResponse = overallResponse.trim()
501
+
502
+ // Handle overall review output/posting
503
+ yield* handleOverallReview(extractedOverallResponse, changeId, options)
504
+ } finally {
505
+ // Always restore original working directory
506
+ process.chdir(originalCwd)
452
507
  }
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
508
 
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
509
+ yield* Console.log(`✓ Review complete for ${changeId}`)
510
+ }),
473
511
 
474
- if (originalCount > validCount) {
475
- yield* Console.log(
476
- `→ Filtered ${originalCount - validCount} invalid comments, ${validCount} remain`,
477
- )
478
- }
512
+ // Release: Always cleanup worktree
513
+ (worktreeInfo) => gitService.cleanup(worktreeInfo),
514
+ )
515
+ })
479
516
 
480
- // If not in comment mode, just output the inline comments
517
+ // Helper function to handle inline comments output/posting
518
+ const handleInlineComments = (
519
+ inlineComments: InlineComment[],
520
+ changeId: string,
521
+ options: ReviewOptions,
522
+ ): Effect.Effect<void, Error, GerritApiService> =>
523
+ Effect.gen(function* () {
481
524
  if (!options.comment) {
525
+ // Display mode
482
526
  if (inlineComments.length > 0) {
483
527
  yield* Console.log('\n━━━━━━ INLINE COMMENTS ━━━━━━')
484
528
  for (const comment of inlineComments) {
@@ -489,7 +533,7 @@ export const reviewCommand = (changeId: string, options: ReviewOptions = {}) =>
489
533
  yield* Console.log('\n→ No inline comments')
490
534
  }
491
535
  } else {
492
- // In comment mode, handle posting
536
+ // Comment posting mode
493
537
  if (inlineComments.length > 0) {
494
538
  yield* Console.log('\n━━━━━━ INLINE COMMENTS TO POST ━━━━━━')
495
539
  for (const comment of inlineComments) {
@@ -498,82 +542,48 @@ export const reviewCommand = (changeId: string, options: ReviewOptions = {}) =>
498
542
  }
499
543
  yield* Console.log('\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━')
500
544
 
501
- // Ask for confirmation unless --yes is provided
502
- const shouldPost = options.yes
503
- ? true
504
- : yield* promptUser('\nPost these inline comments to Gerrit?')
545
+ const shouldPost =
546
+ options.yes || (yield* promptUser('\nPost these inline comments to Gerrit?'))
505
547
 
506
548
  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
- }
549
+ yield* pipe(
550
+ commentCommandWithInput(changeId, JSON.stringify(inlineComments), { batch: true }),
551
+ Effect.catchAll((error) =>
552
+ Effect.gen(function* () {
553
+ yield* Console.error(`✗ Failed to post inline comments: ${error}`)
554
+ return yield* Effect.fail(error)
555
+ }),
556
+ ),
557
+ )
558
+ yield* Console.log(`✓ Inline comments posted for ${changeId}`)
522
559
  } else {
523
560
  yield* Console.log('→ Inline comments not posted')
524
561
  }
525
- } else {
526
- yield* Console.log('\n→ No valid inline comments to post')
527
562
  }
528
563
  }
564
+ })
529
565
 
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
566
+ // Helper function to handle overall review output/posting
567
+ const handleOverallReview = (
568
+ overallResponse: string,
569
+ changeId: string,
570
+ options: ReviewOptions,
571
+ ): Effect.Effect<void, Error, GerritApiService> =>
572
+ Effect.gen(function* () {
560
573
  if (!options.comment) {
574
+ // Display mode
561
575
  yield* Console.log('\n━━━━━━ OVERALL REVIEW ━━━━━━')
562
576
  yield* Console.log(overallResponse)
563
577
  yield* Console.log('\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━')
564
578
  } else {
565
- // In comment mode, handle posting
579
+ // Comment posting mode
566
580
  yield* Console.log('\n━━━━━━ OVERALL REVIEW TO POST ━━━━━━')
567
581
  yield* Console.log(overallResponse)
568
582
  yield* Console.log('\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━')
569
583
 
570
- // Ask for confirmation unless --yes is provided
571
- const shouldPost = options.yes
572
- ? true
573
- : yield* promptUser('\nPost this overall review to Gerrit?')
584
+ const shouldPost = options.yes || (yield* promptUser('\nPost this overall review to Gerrit?'))
574
585
 
575
586
  if (shouldPost) {
576
- // Post overall comment using the new direct input method
577
587
  yield* pipe(
578
588
  commentCommandWithInput(changeId, overallResponse, {}),
579
589
  Effect.catchAll((error) =>
@@ -588,6 +598,4 @@ export const reviewCommand = (changeId: string, options: ReviewOptions = {}) =>
588
598
  yield* Console.log('→ Overall review not posted')
589
599
  }
590
600
  }
591
-
592
- yield* Console.log(`✓ Review complete for ${changeId}`)
593
601
  })