@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,593 @@
|
|
|
1
|
+
import { Effect, pipe, Schema } from 'effect'
|
|
2
|
+
import { AiService } from '@/services/ai'
|
|
3
|
+
import { commentCommandWithInput } from './comment'
|
|
4
|
+
import { Console } from 'effect'
|
|
5
|
+
import { type ApiError, GerritApiService } from '@/api/gerrit'
|
|
6
|
+
import type { CommentInfo } from '@/schemas/gerrit'
|
|
7
|
+
import { sanitizeCDATA, escapeXML } from '@/utils/shell-safety'
|
|
8
|
+
import { formatDiffPretty } from '@/utils/diff-formatters'
|
|
9
|
+
import { formatDate } from '@/utils/formatters'
|
|
10
|
+
import * as fs from 'node:fs/promises'
|
|
11
|
+
import * as fsSync from 'node:fs'
|
|
12
|
+
import * as os from 'node:os'
|
|
13
|
+
import * as path from 'node:path'
|
|
14
|
+
import { fileURLToPath } from 'node:url'
|
|
15
|
+
import { dirname } from 'node:path'
|
|
16
|
+
import * as readline from 'node:readline'
|
|
17
|
+
|
|
18
|
+
// Get the directory of this module
|
|
19
|
+
const __filename = fileURLToPath(import.meta.url)
|
|
20
|
+
const __dirname = dirname(__filename)
|
|
21
|
+
|
|
22
|
+
// Effect-based file reading helper
|
|
23
|
+
const readFileEffect = (filePath: string): Effect.Effect<string, Error, never> =>
|
|
24
|
+
Effect.tryPromise({
|
|
25
|
+
try: () => fs.readFile(filePath, 'utf8'),
|
|
26
|
+
catch: (error) => new Error(`Failed to read file ${filePath}: ${error}`),
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
// Load default prompts from .md files using Effect
|
|
30
|
+
const loadDefaultPrompts = Effect.gen(function* () {
|
|
31
|
+
const defaultReviewPrompt = yield* readFileEffect(
|
|
32
|
+
path.join(__dirname, '../../prompts/default-review.md'),
|
|
33
|
+
)
|
|
34
|
+
const inlineReviewSystemPrompt = yield* readFileEffect(
|
|
35
|
+
path.join(__dirname, '../../prompts/system-inline-review.md'),
|
|
36
|
+
)
|
|
37
|
+
const overallReviewSystemPrompt = yield* readFileEffect(
|
|
38
|
+
path.join(__dirname, '../../prompts/system-overall-review.md'),
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
defaultReviewPrompt,
|
|
43
|
+
inlineReviewSystemPrompt,
|
|
44
|
+
overallReviewSystemPrompt,
|
|
45
|
+
}
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
// Helper to expand tilde in file paths
|
|
49
|
+
const expandTilde = (filePath: string): string => {
|
|
50
|
+
if (filePath.startsWith('~/')) {
|
|
51
|
+
return path.join(os.homedir(), filePath.slice(2))
|
|
52
|
+
}
|
|
53
|
+
return filePath
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Helper to read prompt file using Effect
|
|
57
|
+
const readPromptFileEffect = (filePath: string): Effect.Effect<string | null, never, never> =>
|
|
58
|
+
Effect.gen(function* () {
|
|
59
|
+
const expanded = expandTilde(filePath)
|
|
60
|
+
|
|
61
|
+
// Check if file exists using sync method since Effect doesn't have a convenient async exists check
|
|
62
|
+
const exists = yield* Effect.try(() => fsSync.existsSync(expanded)).pipe(
|
|
63
|
+
Effect.catchAll(() => Effect.succeed(false)),
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
if (!exists) {
|
|
67
|
+
return null
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Read file using async Effect
|
|
71
|
+
const content = yield* readFileEffect(expanded).pipe(
|
|
72
|
+
Effect.catchAll(() => Effect.succeed(null)),
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
return content
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
interface ReviewOptions {
|
|
79
|
+
debug?: boolean
|
|
80
|
+
dryRun?: boolean
|
|
81
|
+
comment?: boolean
|
|
82
|
+
yes?: boolean
|
|
83
|
+
prompt?: string
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Schema for validating AI-generated inline comments
|
|
87
|
+
const InlineCommentSchema = Schema.Struct({
|
|
88
|
+
file: Schema.String,
|
|
89
|
+
message: Schema.String,
|
|
90
|
+
side: Schema.optional(Schema.String),
|
|
91
|
+
line: Schema.optional(Schema.Number),
|
|
92
|
+
range: Schema.optional(
|
|
93
|
+
Schema.Struct({
|
|
94
|
+
start_line: Schema.Number,
|
|
95
|
+
end_line: Schema.Number,
|
|
96
|
+
start_character: Schema.optional(Schema.Number),
|
|
97
|
+
end_character: Schema.optional(Schema.Number),
|
|
98
|
+
}),
|
|
99
|
+
),
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
interface InlineComment extends Schema.Schema.Type<typeof InlineCommentSchema> {}
|
|
103
|
+
|
|
104
|
+
// Helper to validate and fix AI-generated inline comments
|
|
105
|
+
const validateAndFixInlineComments = (
|
|
106
|
+
rawComments: unknown[],
|
|
107
|
+
availableFiles: string[],
|
|
108
|
+
): Effect.Effect<InlineComment[], never, never> =>
|
|
109
|
+
Effect.gen(function* () {
|
|
110
|
+
const validComments: InlineComment[] = []
|
|
111
|
+
|
|
112
|
+
for (const rawComment of rawComments) {
|
|
113
|
+
// Validate comment structure using Effect Schema
|
|
114
|
+
const parseResult = yield* Schema.decodeUnknown(InlineCommentSchema)(rawComment).pipe(
|
|
115
|
+
Effect.catchTag('ParseError', (parseError) =>
|
|
116
|
+
Effect.gen(function* () {
|
|
117
|
+
yield* Console.warn('Skipping comment with invalid structure')
|
|
118
|
+
return yield* Effect.succeed(null)
|
|
119
|
+
}),
|
|
120
|
+
),
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
if (!parseResult) {
|
|
124
|
+
continue
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const comment = parseResult
|
|
128
|
+
|
|
129
|
+
// Skip comments with invalid line formats (like ":range")
|
|
130
|
+
if (!comment.line && !comment.range) {
|
|
131
|
+
yield* Console.warn('Skipping comment with invalid line format')
|
|
132
|
+
continue
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Try to find the correct file path
|
|
136
|
+
let correctFilePath = comment.file
|
|
137
|
+
|
|
138
|
+
// If the file path doesn't exist exactly, try to find a match
|
|
139
|
+
if (!availableFiles.includes(comment.file)) {
|
|
140
|
+
// Look for files that end with the provided path (secure path matching)
|
|
141
|
+
const matchingFiles = availableFiles.filter((file) => {
|
|
142
|
+
const normalizedFile = file.replace(/\\/g, '/')
|
|
143
|
+
const normalizedComment = comment.file.replace(/\\/g, '/')
|
|
144
|
+
|
|
145
|
+
// Only match if the comment path is a suffix of the file path with proper boundaries
|
|
146
|
+
return (
|
|
147
|
+
normalizedFile.endsWith(normalizedComment) &&
|
|
148
|
+
(normalizedFile === normalizedComment ||
|
|
149
|
+
normalizedFile.endsWith(`/${normalizedComment}`))
|
|
150
|
+
)
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
if (matchingFiles.length === 1) {
|
|
154
|
+
correctFilePath = matchingFiles[0]
|
|
155
|
+
yield* Console.log(`Fixed file path: ${comment.file} -> ${correctFilePath}`)
|
|
156
|
+
} else if (matchingFiles.length > 1) {
|
|
157
|
+
// Multiple matches, try to pick the most likely one (exact suffix match)
|
|
158
|
+
const exactMatch = matchingFiles.find((file) => file.endsWith(`/${comment.file}`))
|
|
159
|
+
if (exactMatch) {
|
|
160
|
+
correctFilePath = exactMatch
|
|
161
|
+
yield* Console.log(
|
|
162
|
+
`Fixed file path (exact match): ${comment.file} -> ${correctFilePath}`,
|
|
163
|
+
)
|
|
164
|
+
} else {
|
|
165
|
+
yield* Console.warn(`Multiple file matches for ${comment.file}. Skipping comment.`)
|
|
166
|
+
continue
|
|
167
|
+
}
|
|
168
|
+
} else {
|
|
169
|
+
yield* Console.warn(`File not found in change: ${comment.file}. Skipping comment.`)
|
|
170
|
+
continue
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Update the comment with the correct file path and add to valid comments
|
|
175
|
+
validComments.push({ ...comment, file: correctFilePath })
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return validComments
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
// Helper to get change data and format as XML string
|
|
182
|
+
const getChangeDataAsXml = (changeId: string): Effect.Effect<string, ApiError, GerritApiService> =>
|
|
183
|
+
Effect.gen(function* () {
|
|
184
|
+
const gerritApi = yield* GerritApiService
|
|
185
|
+
|
|
186
|
+
// Fetch all data
|
|
187
|
+
const change = yield* gerritApi.getChange(changeId)
|
|
188
|
+
const diffResult = yield* gerritApi.getDiff(changeId)
|
|
189
|
+
const diff = typeof diffResult === 'string' ? diffResult : JSON.stringify(diffResult)
|
|
190
|
+
const commentsMap = yield* gerritApi.getComments(changeId)
|
|
191
|
+
const messages = yield* gerritApi.getMessages(changeId)
|
|
192
|
+
|
|
193
|
+
// Flatten comments from all files
|
|
194
|
+
const comments: CommentInfo[] = []
|
|
195
|
+
for (const [path, fileComments] of Object.entries(commentsMap)) {
|
|
196
|
+
for (const comment of fileComments) {
|
|
197
|
+
comments.push({ ...comment, path })
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Build XML string
|
|
202
|
+
const xmlLines: string[] = []
|
|
203
|
+
xmlLines.push(`<?xml version="1.0" encoding="UTF-8"?>`)
|
|
204
|
+
xmlLines.push(`<show_result>`)
|
|
205
|
+
xmlLines.push(` <status>success</status>`)
|
|
206
|
+
xmlLines.push(` <change>`)
|
|
207
|
+
xmlLines.push(` <id>${escapeXML(change.change_id)}</id>`)
|
|
208
|
+
xmlLines.push(` <number>${change._number}</number>`)
|
|
209
|
+
xmlLines.push(` <subject><![CDATA[${sanitizeCDATA(change.subject)}]]></subject>`)
|
|
210
|
+
xmlLines.push(` <status>${escapeXML(change.status)}</status>`)
|
|
211
|
+
xmlLines.push(` <project>${escapeXML(change.project)}</project>`)
|
|
212
|
+
xmlLines.push(` <branch>${escapeXML(change.branch)}</branch>`)
|
|
213
|
+
xmlLines.push(` <owner>`)
|
|
214
|
+
if (change.owner?.name) {
|
|
215
|
+
xmlLines.push(` <name><![CDATA[${sanitizeCDATA(change.owner.name)}]]></name>`)
|
|
216
|
+
}
|
|
217
|
+
if (change.owner?.email) {
|
|
218
|
+
xmlLines.push(` <email>${escapeXML(change.owner.email)}</email>`)
|
|
219
|
+
}
|
|
220
|
+
xmlLines.push(` </owner>`)
|
|
221
|
+
xmlLines.push(` <created>${escapeXML(change.created || '')}</created>`)
|
|
222
|
+
xmlLines.push(` <updated>${escapeXML(change.updated || '')}</updated>`)
|
|
223
|
+
xmlLines.push(` </change>`)
|
|
224
|
+
xmlLines.push(` <diff><![CDATA[${sanitizeCDATA(diff)}]]></diff>`)
|
|
225
|
+
|
|
226
|
+
// Comments section
|
|
227
|
+
xmlLines.push(` <comments>`)
|
|
228
|
+
xmlLines.push(` <count>${comments.length}</count>`)
|
|
229
|
+
for (const comment of comments) {
|
|
230
|
+
xmlLines.push(` <comment>`)
|
|
231
|
+
if (comment.id) xmlLines.push(` <id>${escapeXML(comment.id)}</id>`)
|
|
232
|
+
if (comment.path)
|
|
233
|
+
xmlLines.push(` <path><![CDATA[${sanitizeCDATA(comment.path)}]]></path>`)
|
|
234
|
+
if (comment.line) xmlLines.push(` <line>${comment.line}</line>`)
|
|
235
|
+
if (comment.author?.name) {
|
|
236
|
+
xmlLines.push(` <author><![CDATA[${sanitizeCDATA(comment.author.name)}]]></author>`)
|
|
237
|
+
}
|
|
238
|
+
if (comment.updated) xmlLines.push(` <updated>${escapeXML(comment.updated)}</updated>`)
|
|
239
|
+
if (comment.message) {
|
|
240
|
+
xmlLines.push(` <message><![CDATA[${sanitizeCDATA(comment.message)}]]></message>`)
|
|
241
|
+
}
|
|
242
|
+
if (comment.unresolved) xmlLines.push(` <unresolved>true</unresolved>`)
|
|
243
|
+
xmlLines.push(` </comment>`)
|
|
244
|
+
}
|
|
245
|
+
xmlLines.push(` </comments>`)
|
|
246
|
+
|
|
247
|
+
// Messages section
|
|
248
|
+
xmlLines.push(` <messages>`)
|
|
249
|
+
xmlLines.push(` <count>${messages.length}</count>`)
|
|
250
|
+
for (const message of messages) {
|
|
251
|
+
xmlLines.push(` <message>`)
|
|
252
|
+
xmlLines.push(` <id>${escapeXML(message.id)}</id>`)
|
|
253
|
+
if (message.author?.name) {
|
|
254
|
+
xmlLines.push(` <author><![CDATA[${sanitizeCDATA(message.author.name)}]]></author>`)
|
|
255
|
+
}
|
|
256
|
+
if (message.author?._account_id) {
|
|
257
|
+
xmlLines.push(` <author_id>${message.author._account_id}</author_id>`)
|
|
258
|
+
}
|
|
259
|
+
xmlLines.push(` <date>${escapeXML(message.date)}</date>`)
|
|
260
|
+
if (message._revision_number) {
|
|
261
|
+
xmlLines.push(` <revision>${message._revision_number}</revision>`)
|
|
262
|
+
}
|
|
263
|
+
if (message.tag) {
|
|
264
|
+
xmlLines.push(` <tag>${escapeXML(message.tag)}</tag>`)
|
|
265
|
+
}
|
|
266
|
+
xmlLines.push(` <message><![CDATA[${sanitizeCDATA(message.message)}]]></message>`)
|
|
267
|
+
xmlLines.push(` </message>`)
|
|
268
|
+
}
|
|
269
|
+
xmlLines.push(` </messages>`)
|
|
270
|
+
xmlLines.push(`</show_result>`)
|
|
271
|
+
|
|
272
|
+
return xmlLines.join('\n')
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
// Helper to get change data and format as pretty string
|
|
276
|
+
const getChangeDataAsPretty = (
|
|
277
|
+
changeId: string,
|
|
278
|
+
): Effect.Effect<string, ApiError, GerritApiService> =>
|
|
279
|
+
Effect.gen(function* () {
|
|
280
|
+
const gerritApi = yield* GerritApiService
|
|
281
|
+
|
|
282
|
+
// Fetch all data
|
|
283
|
+
const change = yield* gerritApi.getChange(changeId)
|
|
284
|
+
const diffResult = yield* gerritApi.getDiff(changeId)
|
|
285
|
+
const diff = typeof diffResult === 'string' ? diffResult : JSON.stringify(diffResult)
|
|
286
|
+
const commentsMap = yield* gerritApi.getComments(changeId)
|
|
287
|
+
const messages = yield* gerritApi.getMessages(changeId)
|
|
288
|
+
|
|
289
|
+
// Flatten comments from all files
|
|
290
|
+
const comments: CommentInfo[] = []
|
|
291
|
+
for (const [path, fileComments] of Object.entries(commentsMap)) {
|
|
292
|
+
for (const comment of fileComments) {
|
|
293
|
+
comments.push({ ...comment, path })
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Build pretty string
|
|
298
|
+
const lines: string[] = []
|
|
299
|
+
|
|
300
|
+
// Change details header
|
|
301
|
+
lines.push('━'.repeat(80))
|
|
302
|
+
lines.push(`📋 Change ${change._number}: ${change.subject}`)
|
|
303
|
+
lines.push('━'.repeat(80))
|
|
304
|
+
lines.push('')
|
|
305
|
+
|
|
306
|
+
// Metadata
|
|
307
|
+
lines.push('📝 Details:')
|
|
308
|
+
lines.push(` Project: ${change.project}`)
|
|
309
|
+
lines.push(` Branch: ${change.branch}`)
|
|
310
|
+
lines.push(` Status: ${change.status}`)
|
|
311
|
+
lines.push(` Owner: ${change.owner?.name || change.owner?.email || 'Unknown'}`)
|
|
312
|
+
lines.push(` Created: ${change.created ? formatDate(change.created) : 'Unknown'}`)
|
|
313
|
+
lines.push(` Updated: ${change.updated ? formatDate(change.updated) : 'Unknown'}`)
|
|
314
|
+
lines.push(` Change-Id: ${change.change_id}`)
|
|
315
|
+
lines.push('')
|
|
316
|
+
|
|
317
|
+
// Diff section
|
|
318
|
+
lines.push('🔍 Diff:')
|
|
319
|
+
lines.push('─'.repeat(40))
|
|
320
|
+
lines.push(formatDiffPretty(diff))
|
|
321
|
+
lines.push('')
|
|
322
|
+
|
|
323
|
+
// Comments section
|
|
324
|
+
if (comments.length > 0) {
|
|
325
|
+
lines.push('💬 Inline Comments:')
|
|
326
|
+
lines.push('─'.repeat(40))
|
|
327
|
+
for (const comment of comments) {
|
|
328
|
+
const author = comment.author?.name || 'Unknown'
|
|
329
|
+
const date = comment.updated ? formatDate(comment.updated) : 'Unknown'
|
|
330
|
+
lines.push(`📅 ${date} - ${author}`)
|
|
331
|
+
if (comment.path) lines.push(` File: ${comment.path}`)
|
|
332
|
+
if (comment.line) lines.push(` Line: ${comment.line}`)
|
|
333
|
+
lines.push(` ${comment.message}`)
|
|
334
|
+
if (comment.unresolved) lines.push(` ⚠️ Unresolved`)
|
|
335
|
+
lines.push('')
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// Messages section
|
|
340
|
+
if (messages.length > 0) {
|
|
341
|
+
lines.push('📝 Review Activity:')
|
|
342
|
+
lines.push('─'.repeat(40))
|
|
343
|
+
for (const message of messages) {
|
|
344
|
+
const author = message.author?.name || 'Unknown'
|
|
345
|
+
const date = formatDate(message.date)
|
|
346
|
+
const cleanMessage = message.message.trim()
|
|
347
|
+
|
|
348
|
+
// Skip very short automated messages
|
|
349
|
+
if (
|
|
350
|
+
cleanMessage.length < 10 &&
|
|
351
|
+
(cleanMessage.includes('Build') || cleanMessage.includes('Patch'))
|
|
352
|
+
) {
|
|
353
|
+
continue
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
lines.push(`📅 ${date} - ${author}`)
|
|
357
|
+
lines.push(` ${cleanMessage}`)
|
|
358
|
+
lines.push('')
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
return lines.join('\n')
|
|
363
|
+
})
|
|
364
|
+
|
|
365
|
+
// Helper function to prompt user for confirmation
|
|
366
|
+
const promptUser = (message: string): Effect.Effect<boolean, never> =>
|
|
367
|
+
Effect.async<boolean, never>((resume) => {
|
|
368
|
+
const rl = readline.createInterface({
|
|
369
|
+
input: process.stdin,
|
|
370
|
+
output: process.stdout,
|
|
371
|
+
})
|
|
372
|
+
|
|
373
|
+
rl.question(`${message} [y/N]: `, (answer: string) => {
|
|
374
|
+
rl.close()
|
|
375
|
+
resume(Effect.succeed(answer.toLowerCase() === 'y'))
|
|
376
|
+
})
|
|
377
|
+
})
|
|
378
|
+
|
|
379
|
+
export const reviewCommand = (changeId: string, options: ReviewOptions = {}) =>
|
|
380
|
+
Effect.gen(function* () {
|
|
381
|
+
const aiService = yield* AiService
|
|
382
|
+
|
|
383
|
+
// Load default prompts first
|
|
384
|
+
const prompts = yield* loadDefaultPrompts
|
|
385
|
+
|
|
386
|
+
// Check for AI tool availability first
|
|
387
|
+
yield* Console.log('→ Checking for AI tool availability...')
|
|
388
|
+
const aiTool = yield* aiService
|
|
389
|
+
.detectAiTool()
|
|
390
|
+
.pipe(Effect.catchTag('NoAiToolFoundError', (error) => Effect.fail(new Error(error.message))))
|
|
391
|
+
yield* Console.log(`✓ Found AI tool: ${aiTool}`)
|
|
392
|
+
|
|
393
|
+
// Load custom review prompt if provided via --prompt option
|
|
394
|
+
let userReviewPrompt = prompts.defaultReviewPrompt
|
|
395
|
+
|
|
396
|
+
if (options.prompt) {
|
|
397
|
+
const customPrompt = yield* readPromptFileEffect(options.prompt)
|
|
398
|
+
if (customPrompt) {
|
|
399
|
+
userReviewPrompt = customPrompt
|
|
400
|
+
yield* Console.log(`✓ Using custom review prompt from ${options.prompt}`)
|
|
401
|
+
} else {
|
|
402
|
+
yield* Console.log(`⚠ Could not read custom prompt file: ${options.prompt}`)
|
|
403
|
+
yield* Console.log('→ Using default review prompt')
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// Combine user prompt with system prompts for each stage
|
|
408
|
+
const inlinePrompt = `${userReviewPrompt}\n\n${prompts.inlineReviewSystemPrompt}`
|
|
409
|
+
const overallPrompt = `${userReviewPrompt}\n\n${prompts.overallReviewSystemPrompt}`
|
|
410
|
+
|
|
411
|
+
yield* Console.log(`→ Fetching change data for ${changeId}...`)
|
|
412
|
+
|
|
413
|
+
// Stage 1: Generate inline comments
|
|
414
|
+
yield* Console.log(`→ Generating inline comments for change ${changeId}...`)
|
|
415
|
+
|
|
416
|
+
// Get change data in XML format for inline review
|
|
417
|
+
const xmlData = yield* getChangeDataAsXml(changeId)
|
|
418
|
+
|
|
419
|
+
if (options.debug) {
|
|
420
|
+
yield* Console.log('[DEBUG] Running AI for inline comments...')
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// Run inline review
|
|
424
|
+
const inlineResponse = yield* aiService.runPrompt(inlinePrompt, xmlData).pipe(
|
|
425
|
+
Effect.catchTag('AiResponseParseError', (error) =>
|
|
426
|
+
Effect.gen(function* () {
|
|
427
|
+
if (options.debug) {
|
|
428
|
+
yield* Console.error(`[DEBUG] AI output:\n${error.rawOutput}`)
|
|
429
|
+
}
|
|
430
|
+
return yield* Effect.fail(error)
|
|
431
|
+
}),
|
|
432
|
+
),
|
|
433
|
+
Effect.catchTag('AiServiceError', (error) =>
|
|
434
|
+
Effect.die(new Error(`AI service error: ${error.message}`)),
|
|
435
|
+
),
|
|
436
|
+
)
|
|
437
|
+
|
|
438
|
+
if (options.debug) {
|
|
439
|
+
yield* Console.log(`[DEBUG] Inline response:\n${inlineResponse}`)
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// Parse JSON array from response using Effect
|
|
443
|
+
const inlineCommentsArray = yield* Effect.tryPromise({
|
|
444
|
+
try: () => Promise.resolve(JSON.parse(inlineResponse)),
|
|
445
|
+
catch: (error) => new Error(`Invalid JSON response: ${error}`),
|
|
446
|
+
}).pipe(
|
|
447
|
+
Effect.catchAll((error) =>
|
|
448
|
+
Effect.gen(function* () {
|
|
449
|
+
yield* Console.error(`✗ Failed to parse inline comments JSON: ${error}`)
|
|
450
|
+
if (!options.debug) {
|
|
451
|
+
yield* Console.error('Run with --debug to see raw AI output')
|
|
452
|
+
}
|
|
453
|
+
return yield* Effect.fail(error)
|
|
454
|
+
}),
|
|
455
|
+
),
|
|
456
|
+
)
|
|
457
|
+
|
|
458
|
+
// Validate that the response is an array
|
|
459
|
+
if (!Array.isArray(inlineCommentsArray)) {
|
|
460
|
+
yield* Console.error('✗ AI response is not an array of comments')
|
|
461
|
+
return yield* Effect.fail(new Error('Invalid inline comments format'))
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// Get available files for validation
|
|
465
|
+
const gerritApi = yield* GerritApiService
|
|
466
|
+
const files = yield* gerritApi.getFiles(changeId)
|
|
467
|
+
const availableFiles = Object.keys(files)
|
|
468
|
+
|
|
469
|
+
// Validate and fix inline comments
|
|
470
|
+
const originalCount = inlineCommentsArray.length
|
|
471
|
+
const inlineComments = yield* validateAndFixInlineComments(inlineCommentsArray, availableFiles)
|
|
472
|
+
const validCount = inlineComments.length
|
|
473
|
+
|
|
474
|
+
if (originalCount > validCount) {
|
|
475
|
+
yield* Console.log(
|
|
476
|
+
`→ Filtered ${originalCount - validCount} invalid comments, ${validCount} remain`,
|
|
477
|
+
)
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// If not in comment mode, just output the inline comments
|
|
481
|
+
if (!options.comment) {
|
|
482
|
+
if (inlineComments.length > 0) {
|
|
483
|
+
yield* Console.log('\n━━━━━━ INLINE COMMENTS ━━━━━━')
|
|
484
|
+
for (const comment of inlineComments) {
|
|
485
|
+
yield* Console.log(`\n📍 ${comment.file}${comment.line ? `:${comment.line}` : ''}`)
|
|
486
|
+
yield* Console.log(comment.message)
|
|
487
|
+
}
|
|
488
|
+
} else {
|
|
489
|
+
yield* Console.log('\n→ No inline comments')
|
|
490
|
+
}
|
|
491
|
+
} else {
|
|
492
|
+
// In comment mode, handle posting
|
|
493
|
+
if (inlineComments.length > 0) {
|
|
494
|
+
yield* Console.log('\n━━━━━━ INLINE COMMENTS TO POST ━━━━━━')
|
|
495
|
+
for (const comment of inlineComments) {
|
|
496
|
+
yield* Console.log(`\n📍 ${comment.file}${comment.line ? `:${comment.line}` : ''}`)
|
|
497
|
+
yield* Console.log(comment.message)
|
|
498
|
+
}
|
|
499
|
+
yield* Console.log('\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━')
|
|
500
|
+
|
|
501
|
+
// Ask for confirmation unless --yes is provided
|
|
502
|
+
const shouldPost = options.yes
|
|
503
|
+
? true
|
|
504
|
+
: yield* promptUser('\nPost these inline comments to Gerrit?')
|
|
505
|
+
|
|
506
|
+
if (shouldPost) {
|
|
507
|
+
if (inlineComments.length === 0) {
|
|
508
|
+
yield* Console.log('→ No valid comments to post after validation')
|
|
509
|
+
} else {
|
|
510
|
+
// Post inline comments using the new direct input method
|
|
511
|
+
yield* pipe(
|
|
512
|
+
commentCommandWithInput(changeId, JSON.stringify(inlineComments), { batch: true }),
|
|
513
|
+
Effect.catchAll((error) =>
|
|
514
|
+
Effect.gen(function* () {
|
|
515
|
+
yield* Console.error(`✗ Failed to post inline comments: ${error}`)
|
|
516
|
+
return yield* Effect.fail(error)
|
|
517
|
+
}),
|
|
518
|
+
),
|
|
519
|
+
)
|
|
520
|
+
yield* Console.log(`✓ Inline comments posted for ${changeId}`)
|
|
521
|
+
}
|
|
522
|
+
} else {
|
|
523
|
+
yield* Console.log('→ Inline comments not posted')
|
|
524
|
+
}
|
|
525
|
+
} else {
|
|
526
|
+
yield* Console.log('\n→ No valid inline comments to post')
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
// Stage 2: Generate overall review comment
|
|
531
|
+
yield* Console.log(`→ Generating overall review comment for change ${changeId}...`)
|
|
532
|
+
|
|
533
|
+
// Get change data in regular format for overall review
|
|
534
|
+
const prettyData = yield* getChangeDataAsPretty(changeId)
|
|
535
|
+
|
|
536
|
+
if (options.debug) {
|
|
537
|
+
yield* Console.log('[DEBUG] Running AI for overall review...')
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
// Run overall review
|
|
541
|
+
const overallResponse = yield* aiService.runPrompt(overallPrompt, prettyData).pipe(
|
|
542
|
+
Effect.catchTag('AiResponseParseError', (error) =>
|
|
543
|
+
Effect.gen(function* () {
|
|
544
|
+
if (options.debug) {
|
|
545
|
+
yield* Console.error(`[DEBUG] AI output:\n${error.rawOutput}`)
|
|
546
|
+
}
|
|
547
|
+
return yield* Effect.fail(error)
|
|
548
|
+
}),
|
|
549
|
+
),
|
|
550
|
+
Effect.catchTag('AiServiceError', (error) =>
|
|
551
|
+
Effect.die(new Error(`AI service error: ${error.message}`)),
|
|
552
|
+
),
|
|
553
|
+
)
|
|
554
|
+
|
|
555
|
+
if (options.debug) {
|
|
556
|
+
yield* Console.log(`[DEBUG] Overall response:\n${overallResponse}`)
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
// If not in comment mode, just output the review
|
|
560
|
+
if (!options.comment) {
|
|
561
|
+
yield* Console.log('\n━━━━━━ OVERALL REVIEW ━━━━━━')
|
|
562
|
+
yield* Console.log(overallResponse)
|
|
563
|
+
yield* Console.log('\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━')
|
|
564
|
+
} else {
|
|
565
|
+
// In comment mode, handle posting
|
|
566
|
+
yield* Console.log('\n━━━━━━ OVERALL REVIEW TO POST ━━━━━━')
|
|
567
|
+
yield* Console.log(overallResponse)
|
|
568
|
+
yield* Console.log('\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━')
|
|
569
|
+
|
|
570
|
+
// Ask for confirmation unless --yes is provided
|
|
571
|
+
const shouldPost = options.yes
|
|
572
|
+
? true
|
|
573
|
+
: yield* promptUser('\nPost this overall review to Gerrit?')
|
|
574
|
+
|
|
575
|
+
if (shouldPost) {
|
|
576
|
+
// Post overall comment using the new direct input method
|
|
577
|
+
yield* pipe(
|
|
578
|
+
commentCommandWithInput(changeId, overallResponse, {}),
|
|
579
|
+
Effect.catchAll((error) =>
|
|
580
|
+
Effect.gen(function* () {
|
|
581
|
+
yield* Console.error(`✗ Failed to post review comment: ${error}`)
|
|
582
|
+
return yield* Effect.fail(error)
|
|
583
|
+
}),
|
|
584
|
+
),
|
|
585
|
+
)
|
|
586
|
+
yield* Console.log(`✓ Overall review posted for ${changeId}`)
|
|
587
|
+
} else {
|
|
588
|
+
yield* Console.log('→ Overall review not posted')
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
yield* Console.log(`✓ Review complete for ${changeId}`)
|
|
593
|
+
})
|