@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,669 @@
1
+ import { describe, test, expect, beforeEach, afterEach, mock, spyOn } from 'bun:test'
2
+ import { Effect, Layer } from 'effect'
3
+ import { reviewCommand } from '@/cli/commands/review'
4
+ import { AiService, NoAiToolFoundError } from '@/services/ai'
5
+ import { GerritApiService } from '@/api/gerrit'
6
+ import { ConfigService } from '@/services/config'
7
+ import { createMockConfigService } from './helpers/config-mock'
8
+ import type { ChangeInfo, CommentInfo, MessageInfo } from '@/schemas/gerrit'
9
+
10
+ describe('Review Command', () => {
11
+ let consoleSpy: any
12
+ let mockAiService: any
13
+ let mockApiService: any
14
+
15
+ beforeEach(() => {
16
+ consoleSpy = {
17
+ log: spyOn(console, 'log').mockImplementation(() => {}),
18
+ error: spyOn(console, 'error').mockImplementation(() => {}),
19
+ }
20
+
21
+ // Mock AI Service
22
+ mockAiService = {
23
+ detectAiTool: () => Effect.succeed('claude'),
24
+ extractResponseTag: (output: string) => Effect.succeed(output),
25
+ runPrompt: (prompt: string, input: string) => {
26
+ if (
27
+ prompt.includes('JSON Structure for Inline Comments') ||
28
+ prompt.includes('Priority Guidelines for Inline Comments')
29
+ ) {
30
+ // Return mock inline comments
31
+ return Effect.succeed(
32
+ JSON.stringify([
33
+ {
34
+ file: 'src/main.ts',
35
+ line: 10,
36
+ message: '🤖 Consider adding error handling here',
37
+ },
38
+ ]),
39
+ )
40
+ } else {
41
+ // Return mock overall review
42
+ return Effect.succeed(
43
+ '🤖 Code Review\n\nOVERALL ASSESSMENT\n\nThe code looks good overall.',
44
+ )
45
+ }
46
+ },
47
+ }
48
+
49
+ // Mock Gerrit API Service
50
+ const mockChange: ChangeInfo = {
51
+ id: 'project~master~I123',
52
+ _number: 12345,
53
+ change_id: 'I123',
54
+ project: 'test-project',
55
+ branch: 'master',
56
+ subject: 'Test change',
57
+ status: 'NEW',
58
+ created: '2024-01-01 10:00:00.000000000',
59
+ updated: '2024-01-01 12:00:00.000000000',
60
+ owner: {
61
+ _account_id: 1000,
62
+ name: 'Test User',
63
+ email: 'test@example.com',
64
+ },
65
+ }
66
+
67
+ mockApiService = {
68
+ getChange: () => Effect.succeed(mockChange),
69
+ getDiff: () => Effect.succeed('diff --git a/src/main.ts b/src/main.ts\n+console.log("test")'),
70
+ getComments: () => Effect.succeed({} as Record<string, CommentInfo[]>),
71
+ getMessages: () => Effect.succeed([] as MessageInfo[]),
72
+ listChanges: () => Effect.fail({ _tag: 'ApiError' as const, message: 'Not implemented' }),
73
+ postReview: mock(() => Effect.succeed(undefined as void)),
74
+ abandonChange: () => Effect.fail({ _tag: 'ApiError' as const, message: 'Not implemented' }),
75
+ testConnection: Effect.succeed(true),
76
+ getRevision: () => Effect.fail({ _tag: 'ApiError' as const, message: 'Not implemented' }),
77
+ getFiles: () =>
78
+ Effect.succeed({
79
+ 'src/main.ts': { status: 'M' as const },
80
+ 'app/controllers/users_controller.rb': { status: 'M' as const },
81
+ 'lib/utils/helper.rb': { status: 'A' as const },
82
+ }),
83
+ getFileDiff: () => Effect.fail({ _tag: 'ApiError' as const, message: 'Not implemented' }),
84
+ getFileContent: () => Effect.fail({ _tag: 'ApiError' as const, message: 'Not implemented' }),
85
+ getPatch: () => Effect.fail({ _tag: 'ApiError' as const, message: 'Not implemented' }),
86
+ }
87
+ })
88
+
89
+ afterEach(() => {
90
+ consoleSpy.log.mockRestore()
91
+ consoleSpy.error.mockRestore()
92
+ })
93
+
94
+ test('should detect AI tool and perform review', async () => {
95
+ const result = await Effect.runPromise(
96
+ Effect.either(
97
+ reviewCommand('12345', { debug: false }).pipe(
98
+ Effect.provide(Layer.succeed(AiService, mockAiService)),
99
+ Effect.provide(Layer.succeed(GerritApiService, mockApiService)),
100
+ Effect.provide(Layer.succeed(ConfigService, createMockConfigService())),
101
+ ),
102
+ ),
103
+ )
104
+
105
+ expect(result._tag).toBe('Right')
106
+
107
+ // Check that AI tool detection was logged
108
+ expect(consoleSpy.log).toHaveBeenCalledWith('→ Checking for AI tool availability...')
109
+ expect(consoleSpy.log).toHaveBeenCalledWith('✓ Found AI tool: claude')
110
+
111
+ // Check that review stages were executed
112
+ expect(consoleSpy.log).toHaveBeenCalledWith('→ Generating inline comments for change 12345...')
113
+ expect(consoleSpy.log).toHaveBeenCalledWith(
114
+ '→ Generating overall review comment for change 12345...',
115
+ )
116
+ expect(consoleSpy.log).toHaveBeenCalledWith('✓ Review complete for 12345')
117
+ })
118
+
119
+ test('should handle comment mode with auto-yes', async () => {
120
+ const result = await Effect.runPromise(
121
+ Effect.either(
122
+ reviewCommand('12345', { comment: true, yes: true }).pipe(
123
+ Effect.provide(Layer.succeed(AiService, mockAiService)),
124
+ Effect.provide(Layer.succeed(GerritApiService, mockApiService)),
125
+ Effect.provide(Layer.succeed(ConfigService, createMockConfigService())),
126
+ ),
127
+ ),
128
+ )
129
+
130
+ expect(result._tag).toBe('Right')
131
+
132
+ // Check that comments were posted without prompts
133
+ expect(consoleSpy.log).toHaveBeenCalledWith('✓ Inline comments posted for 12345')
134
+ expect(consoleSpy.log).toHaveBeenCalledWith('✓ Overall review posted for 12345')
135
+ })
136
+
137
+ test('should show debug output when debug flag is set', async () => {
138
+ const result = await Effect.runPromise(
139
+ Effect.either(
140
+ reviewCommand('12345', { debug: true }).pipe(
141
+ Effect.provide(Layer.succeed(AiService, mockAiService)),
142
+ Effect.provide(Layer.succeed(GerritApiService, mockApiService)),
143
+ Effect.provide(Layer.succeed(ConfigService, createMockConfigService())),
144
+ ),
145
+ ),
146
+ )
147
+
148
+ expect(result._tag).toBe('Right')
149
+
150
+ // Check that debug messages were shown
151
+ expect(consoleSpy.log).toHaveBeenCalledWith('[DEBUG] Running AI for inline comments...')
152
+ expect(consoleSpy.log).toHaveBeenCalledWith('[DEBUG] Running AI for overall review...')
153
+ })
154
+
155
+ test('should fail when no AI tool is available', async () => {
156
+ const noToolService = {
157
+ detectAiTool: () => Effect.fail(new NoAiToolFoundError({ message: 'No AI tool found' })),
158
+ extractResponseTag: mockAiService.extractResponseTag,
159
+ runPrompt: mockAiService.runPrompt,
160
+ }
161
+
162
+ const result = await Effect.runPromise(
163
+ Effect.either(
164
+ reviewCommand('12345', {}).pipe(
165
+ Effect.provide(Layer.succeed(AiService, noToolService)),
166
+ Effect.provide(Layer.succeed(GerritApiService, mockApiService)),
167
+ Effect.provide(Layer.succeed(ConfigService, createMockConfigService())),
168
+ ),
169
+ ),
170
+ )
171
+
172
+ expect(result._tag).toBe('Left')
173
+ if (result._tag === 'Left') {
174
+ expect(result.left).toBeInstanceOf(Error)
175
+ expect(result.left.message).toContain('No AI tool found')
176
+ }
177
+ })
178
+
179
+ test('should handle invalid JSON response for inline comments', async () => {
180
+ const badJsonService = {
181
+ detectAiTool: () => Effect.succeed('claude'),
182
+ extractResponseTag: (output: string) => Effect.succeed(output),
183
+ runPrompt: (prompt: string, input: string) => {
184
+ if (
185
+ prompt.includes('INLINE_REVIEW_SYSTEM_PROMPT') ||
186
+ prompt.includes('Example Output (THIS IS THE ONLY ACCEPTABLE FORMAT)')
187
+ ) {
188
+ // Return invalid JSON
189
+ return Effect.succeed('not valid json')
190
+ } else {
191
+ return Effect.succeed(
192
+ '🤖 Claude Code\n\nOVERALL ASSESSMENT\n\nThe code looks good overall.',
193
+ )
194
+ }
195
+ },
196
+ }
197
+
198
+ const result = await Effect.runPromise(
199
+ Effect.either(
200
+ reviewCommand('12345', {}).pipe(
201
+ Effect.provide(Layer.succeed(AiService, badJsonService)),
202
+ Effect.provide(Layer.succeed(GerritApiService, mockApiService)),
203
+ Effect.provide(Layer.succeed(ConfigService, createMockConfigService())),
204
+ ),
205
+ ),
206
+ )
207
+
208
+ expect(result._tag).toBe('Left')
209
+ expect(consoleSpy.error).toHaveBeenCalledWith(
210
+ expect.stringContaining('Failed to parse inline comments JSON'),
211
+ )
212
+ })
213
+
214
+ test('should handle empty inline comments array', async () => {
215
+ const emptyCommentsService = {
216
+ detectAiTool: () => Effect.succeed('claude'),
217
+ extractResponseTag: (output: string) => Effect.succeed(output),
218
+ runPrompt: (prompt: string, input: string) => {
219
+ if (
220
+ prompt.includes('JSON Structure for Inline Comments') ||
221
+ prompt.includes('Priority Guidelines for Inline Comments')
222
+ ) {
223
+ // Return empty array
224
+ return Effect.succeed('[]')
225
+ } else {
226
+ return Effect.succeed('🤖 Code Review\n\nOVERALL ASSESSMENT\n\nNo issues found.')
227
+ }
228
+ },
229
+ }
230
+
231
+ const result = await Effect.runPromise(
232
+ Effect.either(
233
+ reviewCommand('12345', {}).pipe(
234
+ Effect.provide(Layer.succeed(AiService, emptyCommentsService)),
235
+ Effect.provide(Layer.succeed(GerritApiService, mockApiService)),
236
+ Effect.provide(Layer.succeed(ConfigService, createMockConfigService())),
237
+ ),
238
+ ),
239
+ )
240
+
241
+ expect(result._tag).toBe('Right')
242
+ expect(consoleSpy.log).toHaveBeenCalledWith('\n→ No inline comments')
243
+ })
244
+
245
+ test('should format change data as XML for inline review', async () => {
246
+ let capturedXmlData: string | undefined
247
+
248
+ const captureService = {
249
+ detectAiTool: () => Effect.succeed('claude'),
250
+ extractResponseTag: (output: string) => Effect.succeed(output),
251
+ runPrompt: (prompt: string, input: string) => {
252
+ // Check if this is the inline review prompt (which gets XML data)
253
+ // Inline prompt contains "JSON Structure for Inline Comments" in its system prompt
254
+ if (prompt.includes('JSON Structure for Inline Comments')) {
255
+ capturedXmlData = input
256
+ return Effect.succeed('[]')
257
+ } else {
258
+ return Effect.succeed('🤖 Code Review\n\nOVERALL ASSESSMENT\n\nLooks good.')
259
+ }
260
+ },
261
+ }
262
+
263
+ await Effect.runPromise(
264
+ reviewCommand('12345', {}).pipe(
265
+ Effect.provide(Layer.succeed(AiService, captureService)),
266
+ Effect.provide(Layer.succeed(GerritApiService, mockApiService)),
267
+ Effect.provide(Layer.succeed(ConfigService, createMockConfigService())),
268
+ ),
269
+ )
270
+
271
+ expect(capturedXmlData).toBeDefined()
272
+ expect(capturedXmlData).toContain('<?xml version="1.0" encoding="UTF-8"?>')
273
+ expect(capturedXmlData).toContain('<show_result>')
274
+ expect(capturedXmlData).toContain('<change>')
275
+ expect(capturedXmlData).toContain('<id>I123</id>')
276
+ expect(capturedXmlData).toContain('<number>12345</number>')
277
+ })
278
+
279
+ test('should display review without posting when --comment flag is not present', async () => {
280
+ const result = await Effect.runPromise(
281
+ Effect.either(
282
+ reviewCommand('12345', {}).pipe(
283
+ Effect.provide(Layer.succeed(AiService, mockAiService)),
284
+ Effect.provide(Layer.succeed(GerritApiService, mockApiService)),
285
+ Effect.provide(Layer.succeed(ConfigService, createMockConfigService())),
286
+ ),
287
+ ),
288
+ )
289
+
290
+ expect(result._tag).toBe('Right')
291
+
292
+ // Check that it displays the reviews but doesn't post
293
+ expect(consoleSpy.log).toHaveBeenCalledWith(
294
+ expect.stringContaining('━━━━━━ INLINE COMMENTS ━━━━━━'),
295
+ )
296
+ expect(consoleSpy.log).toHaveBeenCalledWith(
297
+ expect.stringContaining('━━━━━━ OVERALL REVIEW ━━━━━━'),
298
+ )
299
+
300
+ // Verify it doesn't post
301
+ expect(consoleSpy.log).not.toHaveBeenCalledWith('✓ Inline comments posted for 12345')
302
+ expect(consoleSpy.log).not.toHaveBeenCalledWith('✓ Overall review posted for 12345')
303
+ })
304
+
305
+ test('should handle error during comment posting', async () => {
306
+ // Create a failing API service
307
+ const failingApiService = {
308
+ ...mockApiService,
309
+ postReview: () => Effect.fail({ _tag: 'ApiError' as const, message: 'Network error' }),
310
+ }
311
+
312
+ const result = await Effect.runPromise(
313
+ Effect.either(
314
+ reviewCommand('12345', { comment: true, yes: true }).pipe(
315
+ Effect.provide(Layer.succeed(AiService, mockAiService)),
316
+ Effect.provide(Layer.succeed(GerritApiService, failingApiService)),
317
+ Effect.provide(Layer.succeed(ConfigService, createMockConfigService())),
318
+ ),
319
+ ),
320
+ )
321
+
322
+ expect(result._tag).toBe('Left')
323
+ expect(consoleSpy.error).toHaveBeenCalledWith(
324
+ expect.stringContaining('Failed to post inline comments'),
325
+ )
326
+ })
327
+
328
+ test('should format change data as pretty text for overall review', async () => {
329
+ let capturedPrettyData: string | undefined
330
+
331
+ const captureService = {
332
+ detectAiTool: () => Effect.succeed('claude'),
333
+ extractResponseTag: (output: string) => Effect.succeed(output),
334
+ runPrompt: (prompt: string, input: string) => {
335
+ // Check if this is the inline review prompt (which gets XML) or overall (which gets pretty text)
336
+ if (
337
+ prompt.includes('JSON Structure for Inline Comments') ||
338
+ prompt.includes('Priority Guidelines for Inline Comments')
339
+ ) {
340
+ // This is inline review with XML data
341
+ return Effect.succeed('[]')
342
+ } else if (prompt.includes('Review Structure and Formatting')) {
343
+ // This is overall review with pretty text data
344
+ capturedPrettyData = input
345
+ return Effect.succeed('🤖 Code Review\n\nOVERALL ASSESSMENT\n\nLooks good.')
346
+ } else {
347
+ return Effect.succeed('🤖 Code Review\n\nOVERALL ASSESSMENT\n\nLooks good.')
348
+ }
349
+ },
350
+ }
351
+
352
+ await Effect.runPromise(
353
+ reviewCommand('12345', {}).pipe(
354
+ Effect.provide(Layer.succeed(AiService, captureService)),
355
+ Effect.provide(Layer.succeed(GerritApiService, mockApiService)),
356
+ Effect.provide(Layer.succeed(ConfigService, createMockConfigService())),
357
+ ),
358
+ )
359
+
360
+ expect(capturedPrettyData).toBeDefined()
361
+ expect(capturedPrettyData).toContain('📋 Change 12345: Test change')
362
+ expect(capturedPrettyData).toContain('Project: test-project')
363
+ expect(capturedPrettyData).toContain('Branch: master')
364
+ expect(capturedPrettyData).toContain('Status: NEW')
365
+ })
366
+
367
+ test('should use custom prompt file when --prompt option is provided', async () => {
368
+ const customPromptContent = 'Custom prompt for testing\n\nSpecial instructions here.'
369
+ const tempDir = require('os').tmpdir()
370
+ const tempFile = require('path').join(tempDir, `test-prompt-${Date.now()}.md`)
371
+
372
+ // Create temporary prompt file
373
+ require('fs').writeFileSync(tempFile, customPromptContent, 'utf8')
374
+
375
+ let capturedPrompt: string | undefined
376
+
377
+ const captureService = {
378
+ detectAiTool: () => Effect.succeed('claude'),
379
+ extractResponseTag: (output: string) => Effect.succeed(output),
380
+ runPrompt: (prompt: string, input: string) => {
381
+ capturedPrompt = prompt
382
+ if (prompt.includes('JSON Structure for Inline Comments')) {
383
+ return Effect.succeed('[]')
384
+ } else {
385
+ return Effect.succeed('🤖 Custom Review\n\nOVERALL ASSESSMENT\n\nUsed custom prompt.')
386
+ }
387
+ },
388
+ }
389
+
390
+ try {
391
+ const result = await Effect.runPromise(
392
+ Effect.either(
393
+ reviewCommand('12345', { prompt: tempFile }).pipe(
394
+ Effect.provide(Layer.succeed(AiService, captureService)),
395
+ Effect.provide(Layer.succeed(GerritApiService, mockApiService)),
396
+ Effect.provide(Layer.succeed(ConfigService, createMockConfigService())),
397
+ ),
398
+ ),
399
+ )
400
+
401
+ expect(result._tag).toBe('Right')
402
+
403
+ // Check that custom prompt was loaded
404
+ expect(consoleSpy.log).toHaveBeenCalledWith(`✓ Using custom review prompt from ${tempFile}`)
405
+
406
+ // Verify the custom prompt content was used
407
+ expect(capturedPrompt).toContain(customPromptContent)
408
+ } finally {
409
+ // Clean up temporary file
410
+ require('fs').unlinkSync(tempFile)
411
+ }
412
+ })
413
+
414
+ test('should expand tilde (~/) in prompt file paths', async () => {
415
+ const customPromptContent = 'Home directory prompt test'
416
+ const homeDir = require('os').homedir()
417
+ const relativeFileName = `.test-prompt-${Date.now()}.md`
418
+ const absolutePath = require('path').join(homeDir, relativeFileName)
419
+ const tildePath = `~/${relativeFileName}`
420
+
421
+ // Create prompt file in home directory
422
+ require('fs').writeFileSync(absolutePath, customPromptContent, 'utf8')
423
+
424
+ let capturedPrompt: string | undefined
425
+
426
+ const captureService = {
427
+ detectAiTool: () => Effect.succeed('claude'),
428
+ extractResponseTag: (output: string) => Effect.succeed(output),
429
+ runPrompt: (prompt: string, input: string) => {
430
+ capturedPrompt = prompt
431
+ if (prompt.includes('JSON Structure for Inline Comments')) {
432
+ return Effect.succeed('[]')
433
+ } else {
434
+ return Effect.succeed('🤖 Home Prompt Review')
435
+ }
436
+ },
437
+ }
438
+
439
+ try {
440
+ const result = await Effect.runPromise(
441
+ Effect.either(
442
+ reviewCommand('12345', { prompt: tildePath }).pipe(
443
+ Effect.provide(Layer.succeed(AiService, captureService)),
444
+ Effect.provide(Layer.succeed(GerritApiService, mockApiService)),
445
+ Effect.provide(Layer.succeed(ConfigService, createMockConfigService())),
446
+ ),
447
+ ),
448
+ )
449
+
450
+ expect(result._tag).toBe('Right')
451
+
452
+ // Check that tilde path was correctly expanded and file was loaded
453
+ expect(consoleSpy.log).toHaveBeenCalledWith(`✓ Using custom review prompt from ${tildePath}`)
454
+ expect(capturedPrompt).toContain(customPromptContent)
455
+ } finally {
456
+ // Clean up
457
+ require('fs').unlinkSync(absolutePath)
458
+ }
459
+ })
460
+
461
+ test('should fallback to default prompt when custom prompt file is missing', async () => {
462
+ const nonExistentFile = '/tmp/does-not-exist-prompt.md'
463
+
464
+ let capturedPrompt: string | undefined
465
+
466
+ const captureService = {
467
+ detectAiTool: () => Effect.succeed('claude'),
468
+ extractResponseTag: (output: string) => Effect.succeed(output),
469
+ runPrompt: (prompt: string, input: string) => {
470
+ capturedPrompt = prompt
471
+ if (prompt.includes('JSON Structure for Inline Comments')) {
472
+ return Effect.succeed('[]')
473
+ } else {
474
+ return Effect.succeed('🤖 Default Review')
475
+ }
476
+ },
477
+ }
478
+
479
+ const result = await Effect.runPromise(
480
+ Effect.either(
481
+ reviewCommand('12345', { prompt: nonExistentFile }).pipe(
482
+ Effect.provide(Layer.succeed(AiService, captureService)),
483
+ Effect.provide(Layer.succeed(GerritApiService, mockApiService)),
484
+ Effect.provide(Layer.succeed(ConfigService, createMockConfigService())),
485
+ ),
486
+ ),
487
+ )
488
+
489
+ expect(result._tag).toBe('Right')
490
+
491
+ // Check that error was logged but execution continued
492
+ expect(consoleSpy.log).toHaveBeenCalledWith(
493
+ `⚠ Could not read custom prompt file: ${nonExistentFile}`,
494
+ )
495
+ expect(consoleSpy.log).toHaveBeenCalledWith('→ Using default review prompt')
496
+
497
+ // Verify default prompt was used (should not contain custom content)
498
+ expect(capturedPrompt).toContain('Code Review Guidelines')
499
+ })
500
+
501
+ test('should handle permission errors gracefully for custom prompt files', async () => {
502
+ const restrictedDir = '/root' // Directory that typically has restricted permissions
503
+ const restrictedFile = `${restrictedDir}/test-prompt.md`
504
+
505
+ let capturedPrompt: string | undefined
506
+
507
+ const captureService = {
508
+ detectAiTool: () => Effect.succeed('claude'),
509
+ extractResponseTag: (output: string) => Effect.succeed(output),
510
+ runPrompt: (prompt: string, input: string) => {
511
+ capturedPrompt = prompt
512
+ if (prompt.includes('JSON Structure for Inline Comments')) {
513
+ return Effect.succeed('[]')
514
+ } else {
515
+ return Effect.succeed('🤖 Default Review')
516
+ }
517
+ },
518
+ }
519
+
520
+ const result = await Effect.runPromise(
521
+ Effect.either(
522
+ reviewCommand('12345', { prompt: restrictedFile }).pipe(
523
+ Effect.provide(Layer.succeed(AiService, captureService)),
524
+ Effect.provide(Layer.succeed(GerritApiService, mockApiService)),
525
+ Effect.provide(Layer.succeed(ConfigService, createMockConfigService())),
526
+ ),
527
+ ),
528
+ )
529
+
530
+ expect(result._tag).toBe('Right')
531
+
532
+ // Should fallback gracefully without crashing
533
+ expect(consoleSpy.log).toHaveBeenCalledWith(
534
+ `⚠ Could not read custom prompt file: ${restrictedFile}`,
535
+ )
536
+ expect(consoleSpy.log).toHaveBeenCalledWith('→ Using default review prompt')
537
+ })
538
+
539
+ test('should work normally without --prompt option (default behavior)', async () => {
540
+ let capturedPrompt: string | undefined
541
+
542
+ const captureService = {
543
+ detectAiTool: () => Effect.succeed('claude'),
544
+ extractResponseTag: (output: string) => Effect.succeed(output),
545
+ runPrompt: (prompt: string, input: string) => {
546
+ capturedPrompt = prompt
547
+ if (prompt.includes('JSON Structure for Inline Comments')) {
548
+ return Effect.succeed('[]')
549
+ } else {
550
+ return Effect.succeed('🤖 Default Review')
551
+ }
552
+ },
553
+ }
554
+
555
+ const result = await Effect.runPromise(
556
+ Effect.either(
557
+ reviewCommand('12345', {}).pipe(
558
+ Effect.provide(Layer.succeed(AiService, captureService)),
559
+ Effect.provide(Layer.succeed(GerritApiService, mockApiService)),
560
+ Effect.provide(Layer.succeed(ConfigService, createMockConfigService())),
561
+ ),
562
+ ),
563
+ )
564
+
565
+ expect(result._tag).toBe('Right')
566
+
567
+ // Should not show any custom prompt messages
568
+ expect(consoleSpy.log).not.toHaveBeenCalledWith(
569
+ expect.stringContaining('Using custom review prompt'),
570
+ )
571
+ expect(consoleSpy.log).not.toHaveBeenCalledWith(
572
+ expect.stringContaining('Could not read custom prompt file'),
573
+ )
574
+
575
+ // Should use default review prompt
576
+ expect(capturedPrompt).toContain('Code Review Guidelines')
577
+ })
578
+
579
+ test('should validate and fix inline comment file paths', async () => {
580
+ const aiService = {
581
+ detectAiTool: () => Effect.succeed('claude'),
582
+ extractResponseTag: (output: string) => Effect.succeed(output),
583
+ runPrompt: (prompt: string, input: string) => {
584
+ if (prompt.includes('JSON Structure for Inline Comments')) {
585
+ // Return comments with incomplete file paths that need fixing
586
+ return Effect.succeed(
587
+ JSON.stringify([
588
+ {
589
+ file: 'users_controller.rb',
590
+ line: 10,
591
+ message: '🤖 Test comment with incomplete path',
592
+ },
593
+ {
594
+ file: 'app/controllers/users_controller.rb',
595
+ line: 20,
596
+ message: '🤖 Test comment with complete path',
597
+ },
598
+ { file: 'nonexistent.rb', line: 30, message: '🤖 Test comment for nonexistent file' },
599
+ { file: 'helper.rb', line: 40, message: '🤖 Test comment with partial path' },
600
+ ]),
601
+ )
602
+ }
603
+ return Effect.succeed('Overall review comment')
604
+ },
605
+ }
606
+
607
+ const result = await Effect.runPromiseExit(
608
+ reviewCommand('12345', { comment: true, yes: true }).pipe(
609
+ Effect.provide(Layer.succeed(AiService, aiService)),
610
+ Effect.provide(Layer.succeed(GerritApiService, mockApiService)),
611
+ Effect.provide(Layer.succeed(ConfigService, createMockConfigService())),
612
+ ),
613
+ )
614
+
615
+ expect(result._tag).toBe('Success')
616
+
617
+ // Verify that the postReview was called (meaning some comments were valid after validation)
618
+ expect(mockApiService.postReview).toHaveBeenCalled()
619
+ })
620
+
621
+ test('should combine custom prompt with system prompts correctly', async () => {
622
+ const customPromptContent = 'CUSTOM: Focus on security issues\nAnd performance concerns'
623
+ const tempDir = require('os').tmpdir()
624
+ const tempFile = require('path').join(tempDir, `test-combine-prompt-${Date.now()}.md`)
625
+
626
+ require('fs').writeFileSync(tempFile, customPromptContent, 'utf8')
627
+
628
+ let inlinePromptCaptured: string | undefined
629
+ let overallPromptCaptured: string | undefined
630
+
631
+ const captureService = {
632
+ detectAiTool: () => Effect.succeed('claude'),
633
+ extractResponseTag: (output: string) => Effect.succeed(output),
634
+ runPrompt: (prompt: string, input: string) => {
635
+ if (prompt.includes('JSON Structure for Inline Comments')) {
636
+ inlinePromptCaptured = prompt
637
+ return Effect.succeed('[]')
638
+ } else {
639
+ overallPromptCaptured = prompt
640
+ return Effect.succeed('🤖 Combined Review')
641
+ }
642
+ },
643
+ }
644
+
645
+ try {
646
+ const result = await Effect.runPromise(
647
+ Effect.either(
648
+ reviewCommand('12345', { prompt: tempFile }).pipe(
649
+ Effect.provide(Layer.succeed(AiService, captureService)),
650
+ Effect.provide(Layer.succeed(GerritApiService, mockApiService)),
651
+ Effect.provide(Layer.succeed(ConfigService, createMockConfigService())),
652
+ ),
653
+ ),
654
+ )
655
+
656
+ expect(result._tag).toBe('Right')
657
+
658
+ // Both inline and overall prompts should contain custom content
659
+ expect(inlinePromptCaptured).toContain(customPromptContent)
660
+ expect(overallPromptCaptured).toContain(customPromptContent)
661
+
662
+ // Both should also contain their respective system prompts
663
+ expect(inlinePromptCaptured).toContain('JSON Structure for Inline Comments')
664
+ expect(overallPromptCaptured).toContain('Review Structure and Formatting')
665
+ } finally {
666
+ require('fs').unlinkSync(tempFile)
667
+ }
668
+ })
669
+ })
package/tests/setup.ts ADDED
@@ -0,0 +1,13 @@
1
+ import { afterEach, beforeAll } from 'bun:test'
2
+ import { setupFetchMock } from './mocks/fetch-mock'
3
+
4
+ // Setup Bun's native fetch mocking before all tests
5
+ beforeAll(() => {
6
+ setupFetchMock()
7
+ })
8
+
9
+ // Clean up after each test (Bun automatically restores mocks)
10
+ afterEach(() => {
11
+ // Bun automatically handles mock cleanup
12
+ // But we can reset if needed
13
+ })