@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.
- package/.ast-grep/rules/no-as-casting.yml +13 -0
- package/.eslintrc.js +12 -0
- package/.github/workflows/ci-simple.yml +53 -0
- package/.github/workflows/ci.yml +171 -0
- package/.github/workflows/claude-code-review.yml +78 -0
- package/.github/workflows/claude.yml +64 -0
- package/.github/workflows/dependency-update.yml +84 -0
- package/.github/workflows/release.yml +166 -0
- package/.github/workflows/security-scan.yml +113 -0
- package/.github/workflows/security.yml +96 -0
- package/.husky/pre-commit +16 -0
- package/.husky/pre-push +25 -0
- package/.lintstagedrc.json +6 -0
- package/.tool-versions +1 -0
- package/CLAUDE.md +103 -0
- package/DEVELOPMENT.md +361 -0
- package/LICENSE +21 -0
- package/README.md +325 -0
- package/bin/ger +3 -0
- package/biome.json +36 -0
- package/bun.lock +688 -0
- package/bunfig.toml +8 -0
- package/oxlint.json +24 -0
- package/package.json +55 -0
- package/scripts/check-coverage.ts +69 -0
- package/scripts/check-file-size.ts +38 -0
- package/scripts/fix-test-mocks.ts +55 -0
- package/src/api/gerrit.ts +466 -0
- package/src/cli/commands/abandon.ts +65 -0
- package/src/cli/commands/comment.ts +460 -0
- package/src/cli/commands/comments.ts +85 -0
- package/src/cli/commands/diff.ts +71 -0
- package/src/cli/commands/incoming.ts +226 -0
- package/src/cli/commands/init.ts +164 -0
- package/src/cli/commands/mine.ts +115 -0
- package/src/cli/commands/open.ts +57 -0
- package/src/cli/commands/review.ts +593 -0
- package/src/cli/commands/setup.ts +230 -0
- package/src/cli/commands/show.ts +303 -0
- package/src/cli/commands/status.ts +35 -0
- package/src/cli/commands/workspace.ts +200 -0
- package/src/cli/index.ts +420 -0
- package/src/prompts/default-review.md +80 -0
- package/src/prompts/system-inline-review.md +88 -0
- package/src/prompts/system-overall-review.md +152 -0
- package/src/schemas/config.test.ts +245 -0
- package/src/schemas/config.ts +75 -0
- package/src/schemas/gerrit.ts +455 -0
- package/src/services/ai-enhanced.ts +167 -0
- package/src/services/ai.ts +182 -0
- package/src/services/config.test.ts +414 -0
- package/src/services/config.ts +206 -0
- package/src/test-utils/mock-generator.ts +73 -0
- package/src/utils/comment-formatters.ts +153 -0
- package/src/utils/diff-context.ts +103 -0
- package/src/utils/diff-formatters.ts +141 -0
- package/src/utils/formatters.ts +85 -0
- package/src/utils/message-filters.ts +26 -0
- package/src/utils/shell-safety.ts +117 -0
- package/src/utils/status-indicators.ts +100 -0
- package/src/utils/url-parser.test.ts +123 -0
- package/src/utils/url-parser.ts +91 -0
- package/tests/abandon.test.ts +163 -0
- package/tests/ai-service.test.ts +489 -0
- package/tests/comment-batch-advanced.test.ts +431 -0
- package/tests/comment-gerrit-api-compliance.test.ts +414 -0
- package/tests/comment.test.ts +707 -0
- package/tests/comments.test.ts +323 -0
- package/tests/config-service-simple.test.ts +100 -0
- package/tests/diff.test.ts +419 -0
- package/tests/helpers/config-mock.ts +27 -0
- package/tests/incoming.test.ts +357 -0
- package/tests/interactive-incoming.test.ts +173 -0
- package/tests/mine.test.ts +318 -0
- package/tests/mocks/fetch-mock.ts +139 -0
- package/tests/mocks/msw-handlers.ts +80 -0
- package/tests/open.test.ts +233 -0
- package/tests/review.test.ts +669 -0
- package/tests/setup.ts +13 -0
- package/tests/show.test.ts +439 -0
- package/tests/unit/schemas/gerrit.test.ts +85 -0
- package/tests/unit/test-utils/mock-generator.test.ts +154 -0
- package/tests/unit/utils/comment-formatters.test.ts +415 -0
- package/tests/unit/utils/diff-context.test.ts +171 -0
- package/tests/unit/utils/diff-formatters.test.ts +165 -0
- package/tests/unit/utils/formatters.test.ts +411 -0
- package/tests/unit/utils/message-filters.test.ts +227 -0
- package/tests/unit/utils/prompt-helpers.test.ts +175 -0
- package/tests/unit/utils/shell-safety.test.ts +230 -0
- package/tests/unit/utils/status-indicators.test.ts +137 -0
- 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
|
+
})
|