@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 +29 -4
- package/package.json +2 -1
- package/src/cli/commands/review.ts +221 -213
- package/src/cli/index.ts +13 -4
- package/src/prompts/default-review.md +45 -39
- package/src/prompts/system-inline-review.md +50 -25
- package/src/prompts/system-overall-review.md +33 -8
- package/src/services/git-worktree.ts +297 -0
- package/src/services/review-strategy.ts +373 -0
- package/src/utils/review-formatters.ts +89 -0
- package/src/utils/review-prompt-builder.ts +111 -0
- package/tests/review.test.ts +94 -628
- package/tests/unit/git-branch-detection.test.ts +83 -0
- package/tests/unit/git-worktree.test.ts +54 -0
- package/tests/unit/services/review-strategy.test.ts +494 -0
- package/src/services/ai-enhanced.ts +0 -167
- package/src/services/ai.ts +0 -182
- package/tests/ai-service.test.ts +0 -489
package/tests/review.test.ts
CHANGED
|
@@ -1,89 +1,15 @@
|
|
|
1
|
-
import { describe, test, expect, beforeEach, afterEach,
|
|
1
|
+
import { describe, test, expect, beforeEach, afterEach, spyOn } from 'bun:test'
|
|
2
2
|
import { Effect, Layer } from 'effect'
|
|
3
|
-
import {
|
|
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'
|
|
3
|
+
import { GitWorktreeService, WorktreeCreationError } from '@/services/git-worktree'
|
|
9
4
|
|
|
10
|
-
describe('Review Command', () => {
|
|
5
|
+
describe('Review Command - Focused Tests', () => {
|
|
11
6
|
let consoleSpy: any
|
|
12
|
-
let mockAiService: any
|
|
13
|
-
let mockApiService: any
|
|
14
7
|
|
|
15
8
|
beforeEach(() => {
|
|
16
9
|
consoleSpy = {
|
|
17
10
|
log: spyOn(console, 'log').mockImplementation(() => {}),
|
|
18
11
|
error: spyOn(console, 'error').mockImplementation(() => {}),
|
|
19
12
|
}
|
|
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
13
|
})
|
|
88
14
|
|
|
89
15
|
afterEach(() => {
|
|
@@ -91,579 +17,119 @@ describe('Review Command', () => {
|
|
|
91
17
|
consoleSpy.error.mockRestore()
|
|
92
18
|
})
|
|
93
19
|
|
|
94
|
-
test('should
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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' }),
|
|
20
|
+
test('should integrate GitWorktreeService with review workflow', async () => {
|
|
21
|
+
// Mock Git Worktree Service
|
|
22
|
+
const mockGitService = {
|
|
23
|
+
validatePreconditions: () => Effect.succeed(undefined),
|
|
24
|
+
createWorktree: (changeId: string) =>
|
|
25
|
+
Effect.succeed({
|
|
26
|
+
path: `/tmp/test-worktree-${changeId}`,
|
|
27
|
+
changeId,
|
|
28
|
+
originalCwd: '/test/current',
|
|
29
|
+
timestamp: Date.now(),
|
|
30
|
+
pid: process.pid,
|
|
31
|
+
}),
|
|
32
|
+
fetchAndCheckoutPatchset: () => Effect.succeed(undefined),
|
|
33
|
+
cleanup: () => Effect.succeed(undefined),
|
|
34
|
+
getChangedFiles: () => Effect.succeed(['src/main.ts', 'tests/main.test.ts']),
|
|
310
35
|
}
|
|
311
36
|
|
|
37
|
+
// Test the complete GitWorktreeService workflow
|
|
312
38
|
const result = await Effect.runPromise(
|
|
313
|
-
Effect.
|
|
314
|
-
|
|
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
|
-
}
|
|
39
|
+
Effect.gen(function* () {
|
|
40
|
+
const service = yield* GitWorktreeService
|
|
351
41
|
|
|
352
|
-
|
|
353
|
-
|
|
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
|
-
}
|
|
42
|
+
// Test precondition validation
|
|
43
|
+
yield* service.validatePreconditions()
|
|
389
44
|
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
Effect.provide(Layer.succeed(GerritApiService, mockApiService)),
|
|
396
|
-
Effect.provide(Layer.succeed(ConfigService, createMockConfigService())),
|
|
397
|
-
),
|
|
398
|
-
),
|
|
399
|
-
)
|
|
45
|
+
// Test worktree creation
|
|
46
|
+
const worktree = yield* service.createWorktree('12345')
|
|
47
|
+
expect(worktree.changeId).toBe('12345')
|
|
48
|
+
expect(worktree.path).toContain('12345')
|
|
49
|
+
expect(worktree.originalCwd).toBe('/test/current')
|
|
400
50
|
|
|
401
|
-
|
|
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
|
-
}
|
|
51
|
+
// Test patchset fetch
|
|
52
|
+
yield* service.fetchAndCheckoutPatchset(worktree)
|
|
478
53
|
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
Effect.provide(Layer.succeed(AiService, captureService)),
|
|
483
|
-
Effect.provide(Layer.succeed(GerritApiService, mockApiService)),
|
|
484
|
-
Effect.provide(Layer.succeed(ConfigService, createMockConfigService())),
|
|
485
|
-
),
|
|
486
|
-
),
|
|
487
|
-
)
|
|
54
|
+
// Test getting changed files
|
|
55
|
+
const files = yield* service.getChangedFiles()
|
|
56
|
+
expect(files).toEqual(['src/main.ts', 'tests/main.test.ts'])
|
|
488
57
|
|
|
489
|
-
|
|
58
|
+
// Test cleanup
|
|
59
|
+
yield* service.cleanup(worktree)
|
|
490
60
|
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
`⚠ Could not read custom prompt file: ${nonExistentFile}`,
|
|
61
|
+
return { success: true, worktree, files }
|
|
62
|
+
}).pipe(Effect.provide(Layer.succeed(GitWorktreeService, mockGitService))),
|
|
494
63
|
)
|
|
495
|
-
expect(consoleSpy.log).toHaveBeenCalledWith('→ Using default review prompt')
|
|
496
64
|
|
|
497
|
-
// Verify
|
|
498
|
-
expect(
|
|
65
|
+
// Verify the workflow completed successfully
|
|
66
|
+
expect(result.success).toBe(true)
|
|
67
|
+
expect(result.worktree.path).toContain('12345')
|
|
68
|
+
expect(result.files).toHaveLength(2)
|
|
69
|
+
expect(result.files).toEqual(['src/main.ts', 'tests/main.test.ts'])
|
|
499
70
|
})
|
|
500
71
|
|
|
501
|
-
test('should handle
|
|
502
|
-
const
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
Effect.provide(Layer.succeed(GerritApiService, mockApiService)),
|
|
525
|
-
Effect.provide(Layer.succeed(ConfigService, createMockConfigService())),
|
|
526
|
-
),
|
|
72
|
+
test('should handle concurrent worktree scenarios with unique paths', async () => {
|
|
73
|
+
const mockGitService = {
|
|
74
|
+
validatePreconditions: () => Effect.succeed(undefined),
|
|
75
|
+
createWorktree: (changeId: string) =>
|
|
76
|
+
Effect.succeed({
|
|
77
|
+
path: `/tmp/test-worktree-${changeId}-${Date.now()}-${process.pid}`,
|
|
78
|
+
changeId,
|
|
79
|
+
originalCwd: process.cwd(),
|
|
80
|
+
timestamp: Date.now(),
|
|
81
|
+
pid: process.pid,
|
|
82
|
+
}),
|
|
83
|
+
fetchAndCheckoutPatchset: () => Effect.succeed(undefined),
|
|
84
|
+
cleanup: () => Effect.succeed(undefined),
|
|
85
|
+
getChangedFiles: () => Effect.succeed(['test.ts']),
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Simulate concurrent worktree creation
|
|
89
|
+
const [result1, result2] = await Promise.all([
|
|
90
|
+
Effect.runPromise(
|
|
91
|
+
Effect.gen(function* () {
|
|
92
|
+
const service = yield* GitWorktreeService
|
|
93
|
+
return yield* service.createWorktree('change-1')
|
|
94
|
+
}).pipe(Effect.provide(Layer.succeed(GitWorktreeService, mockGitService))),
|
|
527
95
|
),
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
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
|
-
),
|
|
96
|
+
Effect.runPromise(
|
|
97
|
+
Effect.gen(function* () {
|
|
98
|
+
const service = yield* GitWorktreeService
|
|
99
|
+
return yield* service.createWorktree('change-2')
|
|
100
|
+
}).pipe(Effect.provide(Layer.succeed(GitWorktreeService, mockGitService))),
|
|
562
101
|
),
|
|
563
|
-
)
|
|
564
|
-
|
|
565
|
-
expect(result._tag).toBe('Right')
|
|
102
|
+
])
|
|
566
103
|
|
|
567
|
-
//
|
|
568
|
-
expect(
|
|
569
|
-
|
|
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')
|
|
104
|
+
// Verify both worktrees have unique paths
|
|
105
|
+
expect(result1.path).not.toBe(result2.path)
|
|
106
|
+
expect(result1.changeId).toBe('change-1')
|
|
107
|
+
expect(result2.changeId).toBe('change-2')
|
|
577
108
|
})
|
|
578
109
|
|
|
579
|
-
test('should
|
|
580
|
-
const
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
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
|
-
},
|
|
110
|
+
test('should handle error scenarios in worktree operations', async () => {
|
|
111
|
+
const failingGitService = {
|
|
112
|
+
validatePreconditions: () =>
|
|
113
|
+
Effect.fail(new WorktreeCreationError({ message: 'Git repository validation failed' })),
|
|
114
|
+
createWorktree: () =>
|
|
115
|
+
Effect.fail(new WorktreeCreationError({ message: 'Worktree creation failed' })),
|
|
116
|
+
fetchAndCheckoutPatchset: () =>
|
|
117
|
+
Effect.fail(new WorktreeCreationError({ message: 'Patchset fetch failed' })),
|
|
118
|
+
cleanup: () => Effect.succeed(undefined), // Cleanup should never fail
|
|
119
|
+
getChangedFiles: () => Effect.fail(new WorktreeCreationError({ message: 'Git diff failed' })),
|
|
605
120
|
}
|
|
606
121
|
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
),
|
|
122
|
+
// Test validation failure
|
|
123
|
+
const validationResult = await Effect.runPromiseExit(
|
|
124
|
+
Effect.gen(function* () {
|
|
125
|
+
const service = yield* GitWorktreeService
|
|
126
|
+
yield* service.validatePreconditions()
|
|
127
|
+
}).pipe(Effect.provide(Layer.succeed(GitWorktreeService, failingGitService))),
|
|
613
128
|
)
|
|
614
129
|
|
|
615
|
-
expect(
|
|
616
|
-
|
|
617
|
-
|
|
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)
|
|
130
|
+
expect(validationResult._tag).toBe('Failure')
|
|
131
|
+
if (validationResult._tag === 'Failure') {
|
|
132
|
+
expect(String(validationResult.cause)).toContain('Git repository validation failed')
|
|
667
133
|
}
|
|
668
134
|
})
|
|
669
135
|
})
|