@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
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
import { Context, Data, Effect, Layer } from 'effect'
|
|
2
|
+
import { Console } from 'effect'
|
|
3
|
+
import { exec } from 'node:child_process'
|
|
4
|
+
import { promisify } from 'node:util'
|
|
5
|
+
|
|
6
|
+
const execAsync = promisify(exec)
|
|
7
|
+
|
|
8
|
+
// Dynamic import for Claude SDK
|
|
9
|
+
const getClaudeSDK = () =>
|
|
10
|
+
Effect.tryPromise({
|
|
11
|
+
try: () => import('@anthropic-ai/claude-code'),
|
|
12
|
+
catch: () => null,
|
|
13
|
+
}).pipe(Effect.orElseSucceed(() => null))
|
|
14
|
+
|
|
15
|
+
// Simple strategy focused only on review needs
|
|
16
|
+
export class ReviewStrategyError extends Data.TaggedError('ReviewStrategyError')<{
|
|
17
|
+
message: string
|
|
18
|
+
cause?: unknown
|
|
19
|
+
}> {}
|
|
20
|
+
|
|
21
|
+
// Review strategy interface - focused on specific review patterns
|
|
22
|
+
export interface ReviewStrategy {
|
|
23
|
+
readonly name: string
|
|
24
|
+
readonly isAvailable: () => Effect.Effect<boolean, never>
|
|
25
|
+
readonly executeReview: (
|
|
26
|
+
prompt: string,
|
|
27
|
+
options?: { cwd?: string; systemPrompt?: string },
|
|
28
|
+
) => Effect.Effect<string, ReviewStrategyError>
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Strategy implementations for different AI tools
|
|
32
|
+
export const claudeCliStrategy: ReviewStrategy = {
|
|
33
|
+
name: 'Claude CLI',
|
|
34
|
+
isAvailable: () =>
|
|
35
|
+
Effect.gen(function* () {
|
|
36
|
+
const result = yield* Effect.tryPromise({
|
|
37
|
+
try: () => execAsync('which claude'),
|
|
38
|
+
catch: () => null,
|
|
39
|
+
}).pipe(Effect.orElseSucceed(() => null))
|
|
40
|
+
|
|
41
|
+
return Boolean(result && result.stdout.trim())
|
|
42
|
+
}),
|
|
43
|
+
executeReview: (prompt, options = {}) =>
|
|
44
|
+
Effect.gen(function* () {
|
|
45
|
+
const result = yield* Effect.tryPromise({
|
|
46
|
+
try: async () => {
|
|
47
|
+
const child = require('node:child_process').spawn('claude -p', {
|
|
48
|
+
shell: true,
|
|
49
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
50
|
+
cwd: options.cwd || process.cwd(),
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
child.stdin.write(prompt)
|
|
54
|
+
child.stdin.end()
|
|
55
|
+
|
|
56
|
+
let stdout = ''
|
|
57
|
+
let stderr = ''
|
|
58
|
+
|
|
59
|
+
child.stdout.on('data', (data: Buffer) => {
|
|
60
|
+
stdout += data.toString()
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
child.stderr.on('data', (data: Buffer) => {
|
|
64
|
+
stderr += data.toString()
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
return new Promise<{ stdout: string; stderr: string }>((resolve, reject) => {
|
|
68
|
+
child.on('close', (code: number) => {
|
|
69
|
+
if (code !== 0) {
|
|
70
|
+
reject(new Error(`Claude CLI exited with code ${code}: ${stderr}`))
|
|
71
|
+
} else {
|
|
72
|
+
resolve({ stdout, stderr })
|
|
73
|
+
}
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
child.on('error', reject)
|
|
77
|
+
})
|
|
78
|
+
},
|
|
79
|
+
catch: (error) =>
|
|
80
|
+
new ReviewStrategyError({
|
|
81
|
+
message: `Claude CLI failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
82
|
+
cause: error,
|
|
83
|
+
}),
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
// Extract response from <response> tags or use full output
|
|
87
|
+
const responseMatch = result.stdout.match(/<response>([\s\S]*?)<\/response>/i)
|
|
88
|
+
return responseMatch ? responseMatch[1].trim() : result.stdout.trim()
|
|
89
|
+
}),
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export const geminiCliStrategy: ReviewStrategy = {
|
|
93
|
+
name: 'Gemini CLI',
|
|
94
|
+
isAvailable: () =>
|
|
95
|
+
Effect.gen(function* () {
|
|
96
|
+
const result = yield* Effect.tryPromise({
|
|
97
|
+
try: () => execAsync('which gemini'),
|
|
98
|
+
catch: () => null,
|
|
99
|
+
}).pipe(Effect.orElseSucceed(() => null))
|
|
100
|
+
|
|
101
|
+
return Boolean(result && result.stdout.trim())
|
|
102
|
+
}),
|
|
103
|
+
executeReview: (prompt, options = {}) =>
|
|
104
|
+
Effect.gen(function* () {
|
|
105
|
+
const result = yield* Effect.tryPromise({
|
|
106
|
+
try: async () => {
|
|
107
|
+
const child = require('node:child_process').spawn('gemini -p', {
|
|
108
|
+
shell: true,
|
|
109
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
110
|
+
cwd: options.cwd || process.cwd(),
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
child.stdin.write(prompt)
|
|
114
|
+
child.stdin.end()
|
|
115
|
+
|
|
116
|
+
let stdout = ''
|
|
117
|
+
let stderr = ''
|
|
118
|
+
|
|
119
|
+
child.stdout.on('data', (data: Buffer) => {
|
|
120
|
+
stdout += data.toString()
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
child.stderr.on('data', (data: Buffer) => {
|
|
124
|
+
stderr += data.toString()
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
return new Promise<{ stdout: string; stderr: string }>((resolve, reject) => {
|
|
128
|
+
child.on('close', (code: number) => {
|
|
129
|
+
if (code !== 0) {
|
|
130
|
+
reject(new Error(`Gemini CLI exited with code ${code}: ${stderr}`))
|
|
131
|
+
} else {
|
|
132
|
+
resolve({ stdout, stderr })
|
|
133
|
+
}
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
child.on('error', reject)
|
|
137
|
+
})
|
|
138
|
+
},
|
|
139
|
+
catch: (error) =>
|
|
140
|
+
new ReviewStrategyError({
|
|
141
|
+
message: `Gemini CLI failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
142
|
+
cause: error,
|
|
143
|
+
}),
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
return result.stdout.trim()
|
|
147
|
+
}),
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export const openCodeCliStrategy: ReviewStrategy = {
|
|
151
|
+
name: 'OpenCode CLI',
|
|
152
|
+
isAvailable: () =>
|
|
153
|
+
Effect.gen(function* () {
|
|
154
|
+
const result = yield* Effect.tryPromise({
|
|
155
|
+
try: () => execAsync('which opencode'),
|
|
156
|
+
catch: () => null,
|
|
157
|
+
}).pipe(Effect.orElseSucceed(() => null))
|
|
158
|
+
|
|
159
|
+
return Boolean(result && result.stdout.trim())
|
|
160
|
+
}),
|
|
161
|
+
executeReview: (prompt, options = {}) =>
|
|
162
|
+
Effect.gen(function* () {
|
|
163
|
+
const result = yield* Effect.tryPromise({
|
|
164
|
+
try: async () => {
|
|
165
|
+
const child = require('node:child_process').spawn('opencode -p', {
|
|
166
|
+
shell: true,
|
|
167
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
168
|
+
cwd: options.cwd || process.cwd(),
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
child.stdin.write(prompt)
|
|
172
|
+
child.stdin.end()
|
|
173
|
+
|
|
174
|
+
let stdout = ''
|
|
175
|
+
let stderr = ''
|
|
176
|
+
|
|
177
|
+
child.stdout.on('data', (data: Buffer) => {
|
|
178
|
+
stdout += data.toString()
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
child.stderr.on('data', (data: Buffer) => {
|
|
182
|
+
stderr += data.toString()
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
return new Promise<{ stdout: string; stderr: string }>((resolve, reject) => {
|
|
186
|
+
child.on('close', (code: number) => {
|
|
187
|
+
if (code !== 0) {
|
|
188
|
+
reject(new Error(`OpenCode CLI exited with code ${code}: ${stderr}`))
|
|
189
|
+
} else {
|
|
190
|
+
resolve({ stdout, stderr })
|
|
191
|
+
}
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
child.on('error', reject)
|
|
195
|
+
})
|
|
196
|
+
},
|
|
197
|
+
catch: (error) =>
|
|
198
|
+
new ReviewStrategyError({
|
|
199
|
+
message: `OpenCode CLI failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
200
|
+
cause: error,
|
|
201
|
+
}),
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
return result.stdout.trim()
|
|
205
|
+
}),
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export const codexCliStrategy: ReviewStrategy = {
|
|
209
|
+
name: 'Codex CLI',
|
|
210
|
+
isAvailable: () =>
|
|
211
|
+
Effect.gen(function* () {
|
|
212
|
+
const result = yield* Effect.tryPromise({
|
|
213
|
+
try: () => execAsync('which codex'),
|
|
214
|
+
catch: () => null,
|
|
215
|
+
}).pipe(Effect.orElseSucceed(() => null))
|
|
216
|
+
|
|
217
|
+
return Boolean(result && result.stdout.trim())
|
|
218
|
+
}),
|
|
219
|
+
executeReview: (prompt, options = {}) =>
|
|
220
|
+
Effect.gen(function* () {
|
|
221
|
+
const command = `codex exec "${prompt.replace(/"/g, '\\"')}"`
|
|
222
|
+
|
|
223
|
+
const result = yield* Effect.tryPromise({
|
|
224
|
+
try: () => execAsync(command, { cwd: options.cwd }),
|
|
225
|
+
catch: (error) =>
|
|
226
|
+
new ReviewStrategyError({
|
|
227
|
+
message: `Codex CLI failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
228
|
+
cause: error,
|
|
229
|
+
}),
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
return result.stdout.trim()
|
|
233
|
+
}),
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export const claudeSDKStrategy: ReviewStrategy = {
|
|
237
|
+
name: 'Claude SDK',
|
|
238
|
+
isAvailable: () =>
|
|
239
|
+
Effect.gen(function* () {
|
|
240
|
+
const sdk = yield* getClaudeSDK()
|
|
241
|
+
|
|
242
|
+
// Check if we have ANTHROPIC_API_KEY
|
|
243
|
+
const hasApiKey = Boolean(process.env.ANTHROPIC_API_KEY)
|
|
244
|
+
|
|
245
|
+
return Boolean(sdk && hasApiKey)
|
|
246
|
+
}),
|
|
247
|
+
executeReview: (prompt, options = {}) =>
|
|
248
|
+
Effect.gen(function* () {
|
|
249
|
+
const sdk = yield* getClaudeSDK()
|
|
250
|
+
|
|
251
|
+
if (!sdk) {
|
|
252
|
+
return yield* Effect.fail(
|
|
253
|
+
new ReviewStrategyError({
|
|
254
|
+
message: 'Claude SDK not available',
|
|
255
|
+
}),
|
|
256
|
+
)
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const result = yield* Effect.tryPromise({
|
|
260
|
+
try: async () => {
|
|
261
|
+
const messages = []
|
|
262
|
+
|
|
263
|
+
for await (const message of sdk.query({
|
|
264
|
+
prompt,
|
|
265
|
+
options: {
|
|
266
|
+
maxTurns: 3,
|
|
267
|
+
customSystemPrompt:
|
|
268
|
+
options.systemPrompt ||
|
|
269
|
+
'You are a code review expert. Analyze code changes and provide constructive feedback.',
|
|
270
|
+
allowedTools: ['Read', 'Grep', 'Glob'],
|
|
271
|
+
cwd: options.cwd,
|
|
272
|
+
},
|
|
273
|
+
})) {
|
|
274
|
+
if (message.type === 'result' && message.subtype === 'success') {
|
|
275
|
+
return message.result
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
throw new Error('No result received from Claude SDK')
|
|
280
|
+
},
|
|
281
|
+
catch: (error) =>
|
|
282
|
+
new ReviewStrategyError({
|
|
283
|
+
message: `Claude SDK failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
284
|
+
cause: error,
|
|
285
|
+
}),
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
return result
|
|
289
|
+
}),
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// Review service using strategy pattern
|
|
293
|
+
export class ReviewStrategyService extends Context.Tag('ReviewStrategyService')<
|
|
294
|
+
ReviewStrategyService,
|
|
295
|
+
{
|
|
296
|
+
readonly getAvailableStrategies: () => Effect.Effect<ReviewStrategy[], never>
|
|
297
|
+
readonly selectStrategy: (
|
|
298
|
+
preferredName?: string,
|
|
299
|
+
) => Effect.Effect<ReviewStrategy, ReviewStrategyError>
|
|
300
|
+
readonly executeWithStrategy: (
|
|
301
|
+
strategy: ReviewStrategy,
|
|
302
|
+
prompt: string,
|
|
303
|
+
options?: { cwd?: string; systemPrompt?: string },
|
|
304
|
+
) => Effect.Effect<string, ReviewStrategyError>
|
|
305
|
+
}
|
|
306
|
+
>() {}
|
|
307
|
+
|
|
308
|
+
export const ReviewStrategyServiceLive = Layer.succeed(
|
|
309
|
+
ReviewStrategyService,
|
|
310
|
+
ReviewStrategyService.of({
|
|
311
|
+
getAvailableStrategies: () =>
|
|
312
|
+
Effect.gen(function* () {
|
|
313
|
+
const strategies = [
|
|
314
|
+
claudeSDKStrategy,
|
|
315
|
+
claudeCliStrategy,
|
|
316
|
+
geminiCliStrategy,
|
|
317
|
+
openCodeCliStrategy,
|
|
318
|
+
codexCliStrategy,
|
|
319
|
+
]
|
|
320
|
+
const available: ReviewStrategy[] = []
|
|
321
|
+
|
|
322
|
+
for (const strategy of strategies) {
|
|
323
|
+
const isAvailable = yield* strategy.isAvailable()
|
|
324
|
+
if (isAvailable) {
|
|
325
|
+
available.push(strategy)
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
return available
|
|
330
|
+
}),
|
|
331
|
+
|
|
332
|
+
selectStrategy: (preferredName?: string) =>
|
|
333
|
+
Effect.gen(function* () {
|
|
334
|
+
const strategies = [
|
|
335
|
+
claudeSDKStrategy,
|
|
336
|
+
claudeCliStrategy,
|
|
337
|
+
geminiCliStrategy,
|
|
338
|
+
openCodeCliStrategy,
|
|
339
|
+
codexCliStrategy,
|
|
340
|
+
]
|
|
341
|
+
const available: ReviewStrategy[] = []
|
|
342
|
+
|
|
343
|
+
for (const strategy of strategies) {
|
|
344
|
+
const isAvailable = yield* strategy.isAvailable()
|
|
345
|
+
if (isAvailable) {
|
|
346
|
+
available.push(strategy)
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
if (available.length === 0) {
|
|
351
|
+
return yield* Effect.fail(
|
|
352
|
+
new ReviewStrategyError({
|
|
353
|
+
message: 'No AI tools available. Please install claude, gemini, or codex CLI.',
|
|
354
|
+
}),
|
|
355
|
+
)
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
if (preferredName) {
|
|
359
|
+
const preferred = available.find((s: ReviewStrategy) =>
|
|
360
|
+
s.name.toLowerCase().includes(preferredName.toLowerCase()),
|
|
361
|
+
)
|
|
362
|
+
if (preferred) {
|
|
363
|
+
return preferred
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
return available[0] // Return first available
|
|
368
|
+
}),
|
|
369
|
+
|
|
370
|
+
executeWithStrategy: (strategy, prompt, options = {}) =>
|
|
371
|
+
strategy.executeReview(prompt, options),
|
|
372
|
+
}),
|
|
373
|
+
)
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import type { ChangeInfo, CommentInfo, MessageInfo } from '@/schemas/gerrit'
|
|
2
|
+
import { sanitizeCDATA, escapeXML } from '@/utils/shell-safety'
|
|
3
|
+
|
|
4
|
+
export const formatChangeAsXML = (change: ChangeInfo): string[] => {
|
|
5
|
+
const lines: string[] = []
|
|
6
|
+
lines.push(` <change>`)
|
|
7
|
+
lines.push(` <id>${escapeXML(change.change_id)}</id>`)
|
|
8
|
+
lines.push(` <number>${change._number}</number>`)
|
|
9
|
+
lines.push(` <subject><![CDATA[${sanitizeCDATA(change.subject)}]]></subject>`)
|
|
10
|
+
lines.push(` <status>${escapeXML(change.status)}</status>`)
|
|
11
|
+
lines.push(` <project>${escapeXML(change.project)}</project>`)
|
|
12
|
+
lines.push(` <branch>${escapeXML(change.branch)}</branch>`)
|
|
13
|
+
lines.push(` <owner>`)
|
|
14
|
+
if (change.owner?.name) {
|
|
15
|
+
lines.push(` <name><![CDATA[${sanitizeCDATA(change.owner.name)}]]></name>`)
|
|
16
|
+
}
|
|
17
|
+
if (change.owner?.email) {
|
|
18
|
+
lines.push(` <email>${escapeXML(change.owner.email)}</email>`)
|
|
19
|
+
}
|
|
20
|
+
lines.push(` </owner>`)
|
|
21
|
+
lines.push(` <created>${escapeXML(change.created || '')}</created>`)
|
|
22
|
+
lines.push(` <updated>${escapeXML(change.updated || '')}</updated>`)
|
|
23
|
+
lines.push(` </change>`)
|
|
24
|
+
return lines
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const formatCommentsAsXML = (comments: readonly CommentInfo[]): string[] => {
|
|
28
|
+
const lines: string[] = []
|
|
29
|
+
lines.push(` <comments>`)
|
|
30
|
+
lines.push(` <count>${comments.length}</count>`)
|
|
31
|
+
for (const comment of comments) {
|
|
32
|
+
lines.push(` <comment>`)
|
|
33
|
+
if (comment.id) lines.push(` <id>${escapeXML(comment.id)}</id>`)
|
|
34
|
+
if (comment.path) {
|
|
35
|
+
lines.push(` <path><![CDATA[${sanitizeCDATA(comment.path)}]]></path>`)
|
|
36
|
+
}
|
|
37
|
+
if (comment.line) lines.push(` <line>${comment.line}</line>`)
|
|
38
|
+
if (comment.author?.name) {
|
|
39
|
+
lines.push(` <author><![CDATA[${sanitizeCDATA(comment.author.name)}]]></author>`)
|
|
40
|
+
}
|
|
41
|
+
if (comment.updated) lines.push(` <updated>${escapeXML(comment.updated)}</updated>`)
|
|
42
|
+
if (comment.message) {
|
|
43
|
+
lines.push(` <message><![CDATA[${sanitizeCDATA(comment.message)}]]></message>`)
|
|
44
|
+
}
|
|
45
|
+
if (comment.unresolved) lines.push(` <unresolved>true</unresolved>`)
|
|
46
|
+
lines.push(` </comment>`)
|
|
47
|
+
}
|
|
48
|
+
lines.push(` </comments>`)
|
|
49
|
+
return lines
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export const formatMessagesAsXML = (messages: readonly MessageInfo[]): string[] => {
|
|
53
|
+
const lines: string[] = []
|
|
54
|
+
lines.push(` <messages>`)
|
|
55
|
+
lines.push(` <count>${messages.length}</count>`)
|
|
56
|
+
for (const message of messages) {
|
|
57
|
+
lines.push(` <message>`)
|
|
58
|
+
lines.push(` <id>${escapeXML(message.id)}</id>`)
|
|
59
|
+
if (message.author?.name) {
|
|
60
|
+
lines.push(` <author><![CDATA[${sanitizeCDATA(message.author.name)}]]></author>`)
|
|
61
|
+
}
|
|
62
|
+
if (message.author?._account_id) {
|
|
63
|
+
lines.push(` <author_id>${message.author._account_id}</author_id>`)
|
|
64
|
+
}
|
|
65
|
+
lines.push(` <date>${escapeXML(message.date)}</date>`)
|
|
66
|
+
if (message._revision_number) {
|
|
67
|
+
lines.push(` <revision>${message._revision_number}</revision>`)
|
|
68
|
+
}
|
|
69
|
+
if (message.tag) {
|
|
70
|
+
lines.push(` <tag>${escapeXML(message.tag)}</tag>`)
|
|
71
|
+
}
|
|
72
|
+
lines.push(` <message><![CDATA[${sanitizeCDATA(message.message)}]]></message>`)
|
|
73
|
+
lines.push(` </message>`)
|
|
74
|
+
}
|
|
75
|
+
lines.push(` </messages>`)
|
|
76
|
+
return lines
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export const flattenComments = (
|
|
80
|
+
commentsMap: Record<string, readonly CommentInfo[]>,
|
|
81
|
+
): CommentInfo[] => {
|
|
82
|
+
const comments: CommentInfo[] = []
|
|
83
|
+
for (const [path, fileComments] of Object.entries(commentsMap)) {
|
|
84
|
+
for (const comment of fileComments) {
|
|
85
|
+
comments.push({ ...comment, path })
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return comments
|
|
89
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { Effect } from 'effect'
|
|
2
|
+
import { type ApiError, GerritApiService } from '@/api/gerrit'
|
|
3
|
+
import type { CommentInfo, MessageInfo } from '@/schemas/gerrit'
|
|
4
|
+
import { flattenComments } from '@/utils/review-formatters'
|
|
5
|
+
|
|
6
|
+
export const buildEnhancedPrompt = (
|
|
7
|
+
userPrompt: string,
|
|
8
|
+
systemPrompt: string,
|
|
9
|
+
changeId: string,
|
|
10
|
+
changedFiles: string[],
|
|
11
|
+
): Effect.Effect<string, ApiError, GerritApiService> =>
|
|
12
|
+
Effect.gen(function* () {
|
|
13
|
+
const gerritApi = yield* GerritApiService
|
|
14
|
+
|
|
15
|
+
const change = yield* gerritApi.getChange(changeId)
|
|
16
|
+
const commentsMap = yield* gerritApi.getComments(changeId)
|
|
17
|
+
const messages = yield* gerritApi.getMessages(changeId)
|
|
18
|
+
|
|
19
|
+
const comments = flattenComments(commentsMap)
|
|
20
|
+
|
|
21
|
+
const promptLines: string[] = []
|
|
22
|
+
|
|
23
|
+
// System prompt FIRST - critical for response format instructions
|
|
24
|
+
promptLines.push(systemPrompt.trim())
|
|
25
|
+
promptLines.push('')
|
|
26
|
+
|
|
27
|
+
// User custom prompt (if provided)
|
|
28
|
+
if (userPrompt.trim()) {
|
|
29
|
+
promptLines.push('ADDITIONAL INSTRUCTIONS FROM USER:')
|
|
30
|
+
promptLines.push('===================================')
|
|
31
|
+
promptLines.push(userPrompt.trim())
|
|
32
|
+
promptLines.push('')
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Change metadata section
|
|
36
|
+
promptLines.push('CHANGE INFORMATION')
|
|
37
|
+
promptLines.push('==================')
|
|
38
|
+
promptLines.push(`Change ID: ${change.change_id}`)
|
|
39
|
+
promptLines.push(`Number: ${change._number}`)
|
|
40
|
+
promptLines.push(`Subject: ${change.subject}`)
|
|
41
|
+
promptLines.push(`Project: ${change.project}`)
|
|
42
|
+
promptLines.push(`Branch: ${change.branch}`)
|
|
43
|
+
promptLines.push(`Status: ${change.status}`)
|
|
44
|
+
if (change.owner?.name) {
|
|
45
|
+
promptLines.push(`Author: ${change.owner.name}`)
|
|
46
|
+
}
|
|
47
|
+
promptLines.push('')
|
|
48
|
+
|
|
49
|
+
// Existing comments section
|
|
50
|
+
if (comments.length > 0) {
|
|
51
|
+
promptLines.push('EXISTING COMMENTS')
|
|
52
|
+
promptLines.push('=================')
|
|
53
|
+
for (const comment of comments) {
|
|
54
|
+
const author = comment.author?.name || 'Unknown'
|
|
55
|
+
const date = comment.updated || 'Unknown date'
|
|
56
|
+
const location = comment.path
|
|
57
|
+
? `${comment.path}${comment.line ? `:${comment.line}` : ''}`
|
|
58
|
+
: 'General'
|
|
59
|
+
promptLines.push(`[${author}] on ${location} (${date}):`)
|
|
60
|
+
promptLines.push(` ${comment.message}`)
|
|
61
|
+
if (comment.unresolved) {
|
|
62
|
+
promptLines.push(' ⚠️ UNRESOLVED')
|
|
63
|
+
}
|
|
64
|
+
promptLines.push('')
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Review messages section
|
|
69
|
+
if (messages.length > 0) {
|
|
70
|
+
promptLines.push('REVIEW ACTIVITY')
|
|
71
|
+
promptLines.push('===============')
|
|
72
|
+
for (const message of messages) {
|
|
73
|
+
const author = message.author?.name || 'Unknown'
|
|
74
|
+
const cleanMessage = message.message.trim()
|
|
75
|
+
|
|
76
|
+
// Skip very short automated messages
|
|
77
|
+
if (
|
|
78
|
+
cleanMessage.length >= 10 &&
|
|
79
|
+
!cleanMessage.includes('Build') &&
|
|
80
|
+
!cleanMessage.includes('Patch')
|
|
81
|
+
) {
|
|
82
|
+
promptLines.push(`[${author}] ${message.date}:`)
|
|
83
|
+
promptLines.push(` ${cleanMessage}`)
|
|
84
|
+
promptLines.push('')
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Changed files section
|
|
90
|
+
promptLines.push('CHANGED FILES')
|
|
91
|
+
promptLines.push('=============')
|
|
92
|
+
for (const file of changedFiles) {
|
|
93
|
+
promptLines.push(`- ${file}`)
|
|
94
|
+
}
|
|
95
|
+
promptLines.push('')
|
|
96
|
+
|
|
97
|
+
// Git capabilities section
|
|
98
|
+
promptLines.push('GIT CAPABILITIES')
|
|
99
|
+
promptLines.push('================')
|
|
100
|
+
promptLines.push('You are running in a git repository with full access to:')
|
|
101
|
+
promptLines.push('- git diff, git show, git log for understanding changes')
|
|
102
|
+
promptLines.push('- git blame for code ownership context')
|
|
103
|
+
promptLines.push('- All project files for architectural understanding')
|
|
104
|
+
promptLines.push('- Use these tools to provide comprehensive review')
|
|
105
|
+
promptLines.push('')
|
|
106
|
+
|
|
107
|
+
promptLines.push('Focus your review on the changed files listed above, but feel free to')
|
|
108
|
+
promptLines.push('examine related files, tests, and project structure as needed.')
|
|
109
|
+
|
|
110
|
+
return promptLines.join('\n')
|
|
111
|
+
})
|