@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
|
@@ -1,167 +0,0 @@
|
|
|
1
|
-
import { Effect, Layer } from 'effect'
|
|
2
|
-
import { AiService, AiServiceError, NoAiToolFoundError, AiResponseParseError } from './ai'
|
|
3
|
-
import { ConfigService, ConfigServiceLive } from './config'
|
|
4
|
-
import { exec } from 'node:child_process'
|
|
5
|
-
import { promisify } from 'node:util'
|
|
6
|
-
|
|
7
|
-
const execAsync = promisify(exec)
|
|
8
|
-
|
|
9
|
-
// Enhanced AI Service that uses configuration
|
|
10
|
-
export const AiServiceEnhanced = Layer.effect(
|
|
11
|
-
AiService,
|
|
12
|
-
Effect.gen(function* () {
|
|
13
|
-
const configService = yield* ConfigService
|
|
14
|
-
|
|
15
|
-
const detectAiTool = () =>
|
|
16
|
-
Effect.gen(function* () {
|
|
17
|
-
// First check configured preference
|
|
18
|
-
const aiConfig = yield* configService.getAiConfig.pipe(
|
|
19
|
-
Effect.orElseSucceed(() => ({ autoDetect: true })),
|
|
20
|
-
)
|
|
21
|
-
|
|
22
|
-
if ('tool' in aiConfig && aiConfig.tool && !aiConfig.autoDetect) {
|
|
23
|
-
// Check if configured tool is available
|
|
24
|
-
const result = yield* Effect.tryPromise({
|
|
25
|
-
try: () => execAsync(`which ${aiConfig.tool}`),
|
|
26
|
-
catch: () => null,
|
|
27
|
-
}).pipe(Effect.orElseSucceed(() => null))
|
|
28
|
-
|
|
29
|
-
if (result && result.stdout.trim()) {
|
|
30
|
-
return aiConfig.tool
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
// Configured tool not available, fall back to auto-detect
|
|
34
|
-
yield* Effect.logWarning(
|
|
35
|
-
`Configured AI tool '${aiConfig.tool}' not found, auto-detecting...`,
|
|
36
|
-
)
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
// Auto-detect available tools
|
|
40
|
-
const tools =
|
|
41
|
-
'tool' in aiConfig && aiConfig.tool
|
|
42
|
-
? [aiConfig.tool, 'claude', 'llm', 'opencode', 'gemini'].filter(
|
|
43
|
-
(v, i, a) => a.indexOf(v) === i,
|
|
44
|
-
)
|
|
45
|
-
: ['claude', 'llm', 'opencode', 'gemini']
|
|
46
|
-
|
|
47
|
-
for (const tool of tools) {
|
|
48
|
-
const result = yield* Effect.tryPromise({
|
|
49
|
-
try: () => execAsync(`which ${tool}`),
|
|
50
|
-
catch: () => null,
|
|
51
|
-
}).pipe(Effect.orElseSucceed(() => null))
|
|
52
|
-
|
|
53
|
-
if (result && result.stdout.trim()) {
|
|
54
|
-
return tool
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
return yield* Effect.fail(
|
|
59
|
-
new NoAiToolFoundError({
|
|
60
|
-
message: 'No AI tool found. Please install claude, llm, opencode, or gemini CLI.',
|
|
61
|
-
}),
|
|
62
|
-
)
|
|
63
|
-
})
|
|
64
|
-
|
|
65
|
-
const extractResponseTag = (output: string) =>
|
|
66
|
-
Effect.gen(function* () {
|
|
67
|
-
// Extract content between <response> tags
|
|
68
|
-
const responseMatch = output.match(/<response>([\s\S]*?)<\/response>/i)
|
|
69
|
-
|
|
70
|
-
if (!responseMatch || !responseMatch[1]) {
|
|
71
|
-
return yield* Effect.fail(
|
|
72
|
-
new AiResponseParseError({
|
|
73
|
-
message: 'No <response> tag found in AI output',
|
|
74
|
-
rawOutput: output,
|
|
75
|
-
}),
|
|
76
|
-
)
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
return responseMatch[1].trim()
|
|
80
|
-
})
|
|
81
|
-
|
|
82
|
-
const runPrompt = (prompt: string, input: string) =>
|
|
83
|
-
Effect.gen(function* () {
|
|
84
|
-
const tool = yield* detectAiTool()
|
|
85
|
-
|
|
86
|
-
// Prepare the command based on the tool
|
|
87
|
-
const fullInput = `${prompt}\n\n${input}`
|
|
88
|
-
let command: string
|
|
89
|
-
|
|
90
|
-
switch (tool) {
|
|
91
|
-
case 'claude':
|
|
92
|
-
// Claude CLI uses -p flag for piped input
|
|
93
|
-
command = 'claude -p'
|
|
94
|
-
break
|
|
95
|
-
case 'llm':
|
|
96
|
-
// LLM CLI syntax
|
|
97
|
-
command = 'llm'
|
|
98
|
-
break
|
|
99
|
-
case 'opencode':
|
|
100
|
-
// Opencode CLI syntax
|
|
101
|
-
command = 'opencode'
|
|
102
|
-
break
|
|
103
|
-
case 'gemini':
|
|
104
|
-
// Gemini CLI syntax (adjust as needed)
|
|
105
|
-
command = 'gemini'
|
|
106
|
-
break
|
|
107
|
-
default:
|
|
108
|
-
command = tool
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
// Run the AI tool with the prompt and input
|
|
112
|
-
const result = yield* Effect.tryPromise({
|
|
113
|
-
try: async () => {
|
|
114
|
-
const child = require('node:child_process').spawn(command, {
|
|
115
|
-
shell: true,
|
|
116
|
-
stdio: ['pipe', 'pipe', 'pipe'],
|
|
117
|
-
})
|
|
118
|
-
|
|
119
|
-
// Write input to stdin
|
|
120
|
-
child.stdin.write(fullInput)
|
|
121
|
-
child.stdin.end()
|
|
122
|
-
|
|
123
|
-
// Collect output
|
|
124
|
-
let stdout = ''
|
|
125
|
-
let stderr = ''
|
|
126
|
-
|
|
127
|
-
child.stdout.on('data', (data: Buffer) => {
|
|
128
|
-
stdout += data.toString()
|
|
129
|
-
})
|
|
130
|
-
|
|
131
|
-
child.stderr.on('data', (data: Buffer) => {
|
|
132
|
-
stderr += data.toString()
|
|
133
|
-
})
|
|
134
|
-
|
|
135
|
-
return new Promise<{ stdout: string; stderr: string }>((resolve, reject) => {
|
|
136
|
-
child.on('close', (code: number) => {
|
|
137
|
-
if (code !== 0) {
|
|
138
|
-
reject(new Error(`AI tool exited with code ${code}: ${stderr}`))
|
|
139
|
-
} else {
|
|
140
|
-
resolve({ stdout, stderr })
|
|
141
|
-
}
|
|
142
|
-
})
|
|
143
|
-
|
|
144
|
-
child.on('error', reject)
|
|
145
|
-
})
|
|
146
|
-
},
|
|
147
|
-
catch: (error) =>
|
|
148
|
-
new AiServiceError({
|
|
149
|
-
message: `Failed to run AI tool: ${error instanceof Error ? error.message : String(error)}`,
|
|
150
|
-
cause: error,
|
|
151
|
-
}),
|
|
152
|
-
})
|
|
153
|
-
|
|
154
|
-
// Extract response tag
|
|
155
|
-
return yield* extractResponseTag(result.stdout)
|
|
156
|
-
})
|
|
157
|
-
|
|
158
|
-
return AiService.of({
|
|
159
|
-
detectAiTool,
|
|
160
|
-
extractResponseTag,
|
|
161
|
-
runPrompt,
|
|
162
|
-
})
|
|
163
|
-
}),
|
|
164
|
-
).pipe(Layer.provide(ConfigServiceLive))
|
|
165
|
-
|
|
166
|
-
// Export a simpler Live layer for backward compatibility
|
|
167
|
-
export const AiServiceLive = AiServiceEnhanced
|
package/src/services/ai.ts
DELETED
|
@@ -1,182 +0,0 @@
|
|
|
1
|
-
import { Context, Data, Effect, Layer } from 'effect'
|
|
2
|
-
import { exec } from 'node:child_process'
|
|
3
|
-
import { promisify } from 'node:util'
|
|
4
|
-
|
|
5
|
-
const execAsync = promisify(exec)
|
|
6
|
-
|
|
7
|
-
// Error types
|
|
8
|
-
export class AiServiceError extends Data.TaggedError('AiServiceError')<{
|
|
9
|
-
message: string
|
|
10
|
-
cause?: unknown
|
|
11
|
-
}> {}
|
|
12
|
-
|
|
13
|
-
export class NoAiToolFoundError extends Data.TaggedError('NoAiToolFoundError')<{
|
|
14
|
-
message: string
|
|
15
|
-
}> {}
|
|
16
|
-
|
|
17
|
-
export class AiResponseParseError extends Data.TaggedError('AiResponseParseError')<{
|
|
18
|
-
message: string
|
|
19
|
-
rawOutput: string
|
|
20
|
-
}> {}
|
|
21
|
-
|
|
22
|
-
// Service interface
|
|
23
|
-
export class AiService extends Context.Tag('AiService')<
|
|
24
|
-
AiService,
|
|
25
|
-
{
|
|
26
|
-
readonly runPrompt: (
|
|
27
|
-
prompt: string,
|
|
28
|
-
input: string,
|
|
29
|
-
) => Effect.Effect<string, AiServiceError | NoAiToolFoundError | AiResponseParseError>
|
|
30
|
-
readonly detectAiTool: () => Effect.Effect<string, NoAiToolFoundError>
|
|
31
|
-
readonly extractResponseTag: (output: string) => Effect.Effect<string, AiResponseParseError>
|
|
32
|
-
}
|
|
33
|
-
>() {}
|
|
34
|
-
|
|
35
|
-
// Service implementation
|
|
36
|
-
export const AiServiceLive = Layer.succeed(
|
|
37
|
-
AiService,
|
|
38
|
-
AiService.of({
|
|
39
|
-
detectAiTool: () =>
|
|
40
|
-
Effect.gen(function* () {
|
|
41
|
-
// Try to detect available AI tools in order of preference
|
|
42
|
-
const tools = ['claude', 'llm', 'opencode', 'gemini']
|
|
43
|
-
|
|
44
|
-
for (const tool of tools) {
|
|
45
|
-
const result = yield* Effect.tryPromise({
|
|
46
|
-
try: () => execAsync(`which ${tool}`),
|
|
47
|
-
catch: () => null,
|
|
48
|
-
}).pipe(Effect.orElseSucceed(() => null))
|
|
49
|
-
|
|
50
|
-
if (result && result.stdout.trim()) {
|
|
51
|
-
return tool
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
return yield* Effect.fail(
|
|
56
|
-
new NoAiToolFoundError({
|
|
57
|
-
message: 'No AI tool found. Please install claude, llm, opencode, or gemini CLI.',
|
|
58
|
-
}),
|
|
59
|
-
)
|
|
60
|
-
}),
|
|
61
|
-
|
|
62
|
-
extractResponseTag: (output: string) =>
|
|
63
|
-
Effect.gen(function* () {
|
|
64
|
-
// Extract content between <response> tags
|
|
65
|
-
const responseMatch = output.match(/<response>([\s\S]*?)<\/response>/i)
|
|
66
|
-
|
|
67
|
-
if (!responseMatch || !responseMatch[1]) {
|
|
68
|
-
return yield* Effect.fail(
|
|
69
|
-
new AiResponseParseError({
|
|
70
|
-
message: 'No <response> tag found in AI output',
|
|
71
|
-
rawOutput: output,
|
|
72
|
-
}),
|
|
73
|
-
)
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
return responseMatch[1].trim()
|
|
77
|
-
}),
|
|
78
|
-
|
|
79
|
-
runPrompt: (prompt: string, input: string) =>
|
|
80
|
-
Effect.gen(function* () {
|
|
81
|
-
const tool = yield* Effect.gen(function* () {
|
|
82
|
-
// Try to detect available AI tools in order of preference
|
|
83
|
-
const tools = ['claude', 'llm', 'opencode', 'gemini']
|
|
84
|
-
|
|
85
|
-
for (const tool of tools) {
|
|
86
|
-
const result = yield* Effect.tryPromise({
|
|
87
|
-
try: () => execAsync(`which ${tool}`),
|
|
88
|
-
catch: () => null,
|
|
89
|
-
}).pipe(Effect.orElseSucceed(() => null))
|
|
90
|
-
|
|
91
|
-
if (result && result.stdout.trim()) {
|
|
92
|
-
return tool
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
return yield* Effect.fail(
|
|
97
|
-
new NoAiToolFoundError({
|
|
98
|
-
message: 'No AI tool found. Please install claude, llm, opencode, or gemini CLI.',
|
|
99
|
-
}),
|
|
100
|
-
)
|
|
101
|
-
})
|
|
102
|
-
|
|
103
|
-
// Prepare the command based on the tool
|
|
104
|
-
const fullInput = `${prompt}\n\n${input}`
|
|
105
|
-
let command: string
|
|
106
|
-
|
|
107
|
-
switch (tool) {
|
|
108
|
-
case 'claude':
|
|
109
|
-
// Claude CLI uses -p flag for piped input
|
|
110
|
-
command = 'claude -p'
|
|
111
|
-
break
|
|
112
|
-
case 'llm':
|
|
113
|
-
// LLM CLI syntax
|
|
114
|
-
command = 'llm'
|
|
115
|
-
break
|
|
116
|
-
case 'opencode':
|
|
117
|
-
// Opencode CLI syntax
|
|
118
|
-
command = 'opencode'
|
|
119
|
-
break
|
|
120
|
-
default:
|
|
121
|
-
command = tool
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
// Run the AI tool with the prompt and input
|
|
125
|
-
const result = yield* Effect.tryPromise({
|
|
126
|
-
try: async () => {
|
|
127
|
-
const child = require('node:child_process').spawn(command, {
|
|
128
|
-
shell: true,
|
|
129
|
-
stdio: ['pipe', 'pipe', 'pipe'],
|
|
130
|
-
})
|
|
131
|
-
|
|
132
|
-
// Write input to stdin
|
|
133
|
-
child.stdin.write(fullInput)
|
|
134
|
-
child.stdin.end()
|
|
135
|
-
|
|
136
|
-
// Collect output
|
|
137
|
-
let stdout = ''
|
|
138
|
-
let stderr = ''
|
|
139
|
-
|
|
140
|
-
child.stdout.on('data', (data: Buffer) => {
|
|
141
|
-
stdout += data.toString()
|
|
142
|
-
})
|
|
143
|
-
|
|
144
|
-
child.stderr.on('data', (data: Buffer) => {
|
|
145
|
-
stderr += data.toString()
|
|
146
|
-
})
|
|
147
|
-
|
|
148
|
-
return new Promise<{ stdout: string; stderr: string }>((resolve, reject) => {
|
|
149
|
-
child.on('close', (code: number) => {
|
|
150
|
-
if (code !== 0) {
|
|
151
|
-
reject(new Error(`AI tool exited with code ${code}: ${stderr}`))
|
|
152
|
-
} else {
|
|
153
|
-
resolve({ stdout, stderr })
|
|
154
|
-
}
|
|
155
|
-
})
|
|
156
|
-
|
|
157
|
-
child.on('error', reject)
|
|
158
|
-
})
|
|
159
|
-
},
|
|
160
|
-
catch: (error) =>
|
|
161
|
-
new AiServiceError({
|
|
162
|
-
message: `Failed to run AI tool: ${error instanceof Error ? error.message : String(error)}`,
|
|
163
|
-
cause: error,
|
|
164
|
-
}),
|
|
165
|
-
})
|
|
166
|
-
|
|
167
|
-
// Extract response tag
|
|
168
|
-
const responseMatch = result.stdout.match(/<response>([\s\S]*?)<\/response>/i)
|
|
169
|
-
|
|
170
|
-
if (!responseMatch || !responseMatch[1]) {
|
|
171
|
-
return yield* Effect.fail(
|
|
172
|
-
new AiResponseParseError({
|
|
173
|
-
message: 'No <response> tag found in AI output',
|
|
174
|
-
rawOutput: result.stdout,
|
|
175
|
-
}),
|
|
176
|
-
)
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
return responseMatch[1].trim()
|
|
180
|
-
}),
|
|
181
|
-
}),
|
|
182
|
-
)
|