@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,460 @@
|
|
|
1
|
+
import { Schema, ParseResult, TreeFormatter } from '@effect/schema'
|
|
2
|
+
import { Effect, pipe } from 'effect'
|
|
3
|
+
import { type ApiError, GerritApiService } from '@/api/gerrit'
|
|
4
|
+
import type { ChangeInfo, ReviewInput } from '@/schemas/gerrit'
|
|
5
|
+
|
|
6
|
+
interface CommentOptions {
|
|
7
|
+
message?: string
|
|
8
|
+
xml?: boolean
|
|
9
|
+
file?: string
|
|
10
|
+
line?: number
|
|
11
|
+
unresolved?: boolean
|
|
12
|
+
batch?: boolean
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Schema for batch input validation - array of comments
|
|
16
|
+
const BatchCommentSchema = Schema.Array(
|
|
17
|
+
Schema.Struct({
|
|
18
|
+
file: Schema.String,
|
|
19
|
+
line: Schema.optional(Schema.Number), // Optional when using range
|
|
20
|
+
range: Schema.optional(
|
|
21
|
+
Schema.Struct({
|
|
22
|
+
start_line: Schema.Number,
|
|
23
|
+
end_line: Schema.Number,
|
|
24
|
+
start_character: Schema.optional(Schema.Number),
|
|
25
|
+
end_character: Schema.optional(Schema.Number),
|
|
26
|
+
}),
|
|
27
|
+
),
|
|
28
|
+
message: Schema.String,
|
|
29
|
+
path: Schema.optional(Schema.String), // Support both 'file' and 'path' for flexibility
|
|
30
|
+
side: Schema.optional(Schema.Literal('PARENT', 'REVISION')),
|
|
31
|
+
unresolved: Schema.optional(Schema.Boolean),
|
|
32
|
+
}),
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
type BatchCommentInput = Schema.Schema.Type<typeof BatchCommentSchema>
|
|
36
|
+
|
|
37
|
+
// Effect-ified stdin reader
|
|
38
|
+
const readStdin = Effect.async<string, Error>((callback) => {
|
|
39
|
+
let data = ''
|
|
40
|
+
|
|
41
|
+
const onData = (chunk: Buffer | string) => {
|
|
42
|
+
data += chunk
|
|
43
|
+
}
|
|
44
|
+
const onEnd = () => callback(Effect.succeed(data))
|
|
45
|
+
const onError = (error: Error) =>
|
|
46
|
+
callback(Effect.fail(new Error(`Failed to read stdin: ${error.message}`)))
|
|
47
|
+
|
|
48
|
+
process.stdin.on('data', onData)
|
|
49
|
+
process.stdin.on('end', onEnd)
|
|
50
|
+
process.stdin.on('error', onError)
|
|
51
|
+
|
|
52
|
+
// Cleanup function
|
|
53
|
+
return Effect.sync(() => {
|
|
54
|
+
process.stdin.removeListener('data', onData)
|
|
55
|
+
process.stdin.removeListener('end', onEnd)
|
|
56
|
+
process.stdin.removeListener('error', onError)
|
|
57
|
+
})
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
// Helper to parse JSON with better error handling
|
|
61
|
+
const parseJson = (data: string): Effect.Effect<unknown, Error> =>
|
|
62
|
+
Effect.try({
|
|
63
|
+
try: () => JSON.parse(data),
|
|
64
|
+
catch: (error) => {
|
|
65
|
+
const errorMsg = error instanceof Error ? error.message : 'parse error'
|
|
66
|
+
const lines = data.split('\n')
|
|
67
|
+
const lineCount = lines.length
|
|
68
|
+
|
|
69
|
+
// Show first few lines to help identify the issue
|
|
70
|
+
const preview = lines.slice(0, 10).join('\n')
|
|
71
|
+
const truncated = lineCount > 10 ? `\n... (${lineCount - 10} more lines)` : ''
|
|
72
|
+
|
|
73
|
+
return new Error(
|
|
74
|
+
`Invalid JSON input: ${errorMsg}\n` +
|
|
75
|
+
`Input (${data.length} chars, ${lineCount} lines):\n` +
|
|
76
|
+
`${preview}${truncated}\n\n` +
|
|
77
|
+
`Expected format: [{"file": "path/to/file", "line": 123, "message": "comment text"}]`,
|
|
78
|
+
)
|
|
79
|
+
},
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
// Helper to build ReviewInput from batch data
|
|
83
|
+
const buildBatchReview = (batchInput: BatchCommentInput): ReviewInput => {
|
|
84
|
+
const commentsByFile = batchInput.reduce<
|
|
85
|
+
Record<
|
|
86
|
+
string,
|
|
87
|
+
Array<{
|
|
88
|
+
line?: number
|
|
89
|
+
range?: {
|
|
90
|
+
start_line: number
|
|
91
|
+
end_line: number
|
|
92
|
+
start_character?: number
|
|
93
|
+
end_character?: number
|
|
94
|
+
}
|
|
95
|
+
message: string
|
|
96
|
+
side?: 'PARENT' | 'REVISION'
|
|
97
|
+
unresolved?: boolean
|
|
98
|
+
}>
|
|
99
|
+
>
|
|
100
|
+
>((acc, comment) => {
|
|
101
|
+
// Support both 'file' and 'path' properties
|
|
102
|
+
const filePath = comment.file || comment.path || ''
|
|
103
|
+
if (filePath && !acc[filePath]) {
|
|
104
|
+
acc[filePath] = []
|
|
105
|
+
}
|
|
106
|
+
if (filePath) {
|
|
107
|
+
// When range is present, don't include line (Gerrit API preference)
|
|
108
|
+
const commentObj: any = {
|
|
109
|
+
message: comment.message,
|
|
110
|
+
side: comment.side,
|
|
111
|
+
unresolved: comment.unresolved,
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (comment.range) {
|
|
115
|
+
commentObj.range = comment.range
|
|
116
|
+
} else if (comment.line) {
|
|
117
|
+
commentObj.line = comment.line
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
acc[filePath].push(commentObj)
|
|
121
|
+
}
|
|
122
|
+
return acc
|
|
123
|
+
}, {})
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
comments: commentsByFile,
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Create ReviewInput based on options
|
|
131
|
+
export const createReviewInputFromString = (
|
|
132
|
+
content: string,
|
|
133
|
+
options: CommentOptions,
|
|
134
|
+
): Effect.Effect<ReviewInput, Error> => {
|
|
135
|
+
// Batch mode with provided content
|
|
136
|
+
if (options.batch) {
|
|
137
|
+
return pipe(
|
|
138
|
+
parseJson(content),
|
|
139
|
+
Effect.flatMap(
|
|
140
|
+
Schema.decodeUnknown(BatchCommentSchema, {
|
|
141
|
+
errors: 'all',
|
|
142
|
+
onExcessProperty: 'ignore',
|
|
143
|
+
}),
|
|
144
|
+
),
|
|
145
|
+
Effect.mapError((error) => {
|
|
146
|
+
let errorMessage = 'Invalid batch input format.\n'
|
|
147
|
+
if (ParseResult.isParseError(error)) {
|
|
148
|
+
errorMessage += TreeFormatter.formatErrorSync(error)
|
|
149
|
+
errorMessage += '\n\nExpected format: [{"file": "...", "line": ..., "message": "..."}]'
|
|
150
|
+
} else if (error instanceof Error) {
|
|
151
|
+
errorMessage += error.message
|
|
152
|
+
} else {
|
|
153
|
+
errorMessage +=
|
|
154
|
+
'Expected: [{"file": "...", "line": ..., "message": "...", "side"?: "PARENT|REVISION", "range"?: {...}}]'
|
|
155
|
+
}
|
|
156
|
+
return new Error(errorMessage)
|
|
157
|
+
}),
|
|
158
|
+
Effect.map(buildBatchReview),
|
|
159
|
+
)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Overall comment with provided content
|
|
163
|
+
const message = content.trim()
|
|
164
|
+
return message.length > 0
|
|
165
|
+
? Effect.succeed({ message })
|
|
166
|
+
: Effect.fail(new Error('Message is required'))
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const createReviewInput = (options: CommentOptions): Effect.Effect<ReviewInput, Error> => {
|
|
170
|
+
// Batch mode
|
|
171
|
+
if (options.batch) {
|
|
172
|
+
return pipe(
|
|
173
|
+
readStdin,
|
|
174
|
+
Effect.flatMap(parseJson),
|
|
175
|
+
Effect.flatMap(
|
|
176
|
+
Schema.decodeUnknown(BatchCommentSchema, {
|
|
177
|
+
errors: 'all',
|
|
178
|
+
onExcessProperty: 'ignore',
|
|
179
|
+
}),
|
|
180
|
+
),
|
|
181
|
+
Effect.mapError((error) => {
|
|
182
|
+
// Extract the actual schema validation errors
|
|
183
|
+
let errorMessage = 'Invalid batch input format.\n'
|
|
184
|
+
|
|
185
|
+
if (ParseResult.isParseError(error)) {
|
|
186
|
+
// Format the parse error with details
|
|
187
|
+
errorMessage += TreeFormatter.formatErrorSync(error)
|
|
188
|
+
errorMessage += '\n\nExpected format: [{"file": "...", "line": ..., "message": "..."}]'
|
|
189
|
+
} else if (error instanceof Error) {
|
|
190
|
+
errorMessage += error.message
|
|
191
|
+
} else {
|
|
192
|
+
errorMessage +=
|
|
193
|
+
'Expected: [{"file": "...", "line": ..., "message": "...", "side"?: "PARENT|REVISION", "range"?: {...}}]'
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return new Error(errorMessage)
|
|
197
|
+
}),
|
|
198
|
+
Effect.map(buildBatchReview),
|
|
199
|
+
)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Line comment mode
|
|
203
|
+
if (options.file && options.line) {
|
|
204
|
+
return options.message
|
|
205
|
+
? Effect.succeed({
|
|
206
|
+
comments: {
|
|
207
|
+
[options.file]: [
|
|
208
|
+
{
|
|
209
|
+
line: options.line,
|
|
210
|
+
message: options.message,
|
|
211
|
+
unresolved: options.unresolved,
|
|
212
|
+
},
|
|
213
|
+
],
|
|
214
|
+
},
|
|
215
|
+
})
|
|
216
|
+
: Effect.fail(new Error('Message is required for line comments. Use -m "your message"'))
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Overall comment mode
|
|
220
|
+
if (options.message) {
|
|
221
|
+
return Effect.succeed({ message: options.message })
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// If no message provided, read from stdin (for piping support)
|
|
225
|
+
return pipe(
|
|
226
|
+
readStdin,
|
|
227
|
+
Effect.map((stdinContent) => stdinContent.trim()),
|
|
228
|
+
Effect.flatMap((message) =>
|
|
229
|
+
message.length > 0
|
|
230
|
+
? Effect.succeed({ message })
|
|
231
|
+
: Effect.fail(
|
|
232
|
+
new Error('Message is required. Use -m "your message" or pipe content to stdin'),
|
|
233
|
+
),
|
|
234
|
+
),
|
|
235
|
+
)
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Export a version that accepts direct input instead of reading stdin
|
|
239
|
+
export const commentCommandWithInput = (
|
|
240
|
+
changeId: string,
|
|
241
|
+
input: string,
|
|
242
|
+
options: CommentOptions,
|
|
243
|
+
): Effect.Effect<void, ApiError | Error, GerritApiService> =>
|
|
244
|
+
Effect.gen(function* () {
|
|
245
|
+
const apiService = yield* GerritApiService
|
|
246
|
+
|
|
247
|
+
// Build the review input from provided string
|
|
248
|
+
const review = yield* createReviewInputFromString(input, options)
|
|
249
|
+
|
|
250
|
+
// Execute the API calls in sequence
|
|
251
|
+
const change = yield* pipe(
|
|
252
|
+
apiService.getChange(changeId),
|
|
253
|
+
Effect.mapError((error) =>
|
|
254
|
+
error._tag === 'ApiError' ? new Error(`Failed to get change: ${error.message}`) : error,
|
|
255
|
+
),
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
yield* pipe(
|
|
259
|
+
apiService.postReview(changeId, review),
|
|
260
|
+
Effect.mapError((error) => {
|
|
261
|
+
if (error._tag === 'ApiError') {
|
|
262
|
+
// Build detailed error context for batch comments
|
|
263
|
+
if (options.batch && review.comments) {
|
|
264
|
+
const commentDetails = Object.entries(review.comments)
|
|
265
|
+
.flatMap(([file, comments]) =>
|
|
266
|
+
comments.map((comment) => {
|
|
267
|
+
const parts = [`${file}:${comment.line || 'range'}`]
|
|
268
|
+
if (comment.message?.length > 50) {
|
|
269
|
+
parts.push(`"${comment.message.slice(0, 50)}..."`)
|
|
270
|
+
} else {
|
|
271
|
+
parts.push(`"${comment.message}"`)
|
|
272
|
+
}
|
|
273
|
+
return parts.join(' ')
|
|
274
|
+
}),
|
|
275
|
+
)
|
|
276
|
+
.join(', ')
|
|
277
|
+
|
|
278
|
+
return new Error(
|
|
279
|
+
`Failed to post comment: ${error.message}\nTried to post: ${commentDetails}`,
|
|
280
|
+
)
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// Single line comment context
|
|
284
|
+
if (options.file && options.line) {
|
|
285
|
+
return new Error(
|
|
286
|
+
`Failed to post comment: ${error.message}\nTried to post to ${options.file}:${options.line}: "${options.message}"`,
|
|
287
|
+
)
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// Overall comment context
|
|
291
|
+
if (options.message) {
|
|
292
|
+
const msg =
|
|
293
|
+
options.message.length > 50 ? `${options.message.slice(0, 50)}...` : options.message
|
|
294
|
+
return new Error(
|
|
295
|
+
`Failed to post comment: ${error.message}\nTried to post overall comment: "${msg}"`,
|
|
296
|
+
)
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
return new Error(`Failed to post comment: ${error.message}`)
|
|
300
|
+
}
|
|
301
|
+
return error
|
|
302
|
+
}),
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
// Format and display output
|
|
306
|
+
yield* formatOutput(change, review, options, changeId)
|
|
307
|
+
})
|
|
308
|
+
|
|
309
|
+
export const commentCommand = (
|
|
310
|
+
changeId: string,
|
|
311
|
+
options: CommentOptions,
|
|
312
|
+
): Effect.Effect<void, ApiError | Error, GerritApiService> =>
|
|
313
|
+
Effect.gen(function* () {
|
|
314
|
+
const apiService = yield* GerritApiService
|
|
315
|
+
|
|
316
|
+
// Build the review input
|
|
317
|
+
const review = yield* createReviewInput(options)
|
|
318
|
+
|
|
319
|
+
// Execute the API calls in sequence
|
|
320
|
+
const change = yield* pipe(
|
|
321
|
+
apiService.getChange(changeId),
|
|
322
|
+
Effect.mapError((error) =>
|
|
323
|
+
error._tag === 'ApiError' ? new Error(`Failed to get change: ${error.message}`) : error,
|
|
324
|
+
),
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
yield* pipe(
|
|
328
|
+
apiService.postReview(changeId, review),
|
|
329
|
+
Effect.mapError((error) => {
|
|
330
|
+
if (error._tag === 'ApiError') {
|
|
331
|
+
// Build detailed error context for batch comments
|
|
332
|
+
if (options.batch && review.comments) {
|
|
333
|
+
const commentDetails = Object.entries(review.comments)
|
|
334
|
+
.flatMap(([file, comments]) =>
|
|
335
|
+
comments.map((comment) => {
|
|
336
|
+
const parts = [`${file}:${comment.line || 'range'}`]
|
|
337
|
+
if (comment.message?.length > 50) {
|
|
338
|
+
parts.push(`"${comment.message.slice(0, 50)}..."`)
|
|
339
|
+
} else {
|
|
340
|
+
parts.push(`"${comment.message}"`)
|
|
341
|
+
}
|
|
342
|
+
return parts.join(' ')
|
|
343
|
+
}),
|
|
344
|
+
)
|
|
345
|
+
.join(', ')
|
|
346
|
+
|
|
347
|
+
return new Error(
|
|
348
|
+
`Failed to post comment: ${error.message}\nTried to post: ${commentDetails}`,
|
|
349
|
+
)
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// Single line comment context
|
|
353
|
+
if (options.file && options.line) {
|
|
354
|
+
return new Error(
|
|
355
|
+
`Failed to post comment: ${error.message}\nTried to post to ${options.file}:${options.line}: "${options.message}"`,
|
|
356
|
+
)
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// Overall comment context
|
|
360
|
+
if (options.message) {
|
|
361
|
+
const msg =
|
|
362
|
+
options.message.length > 50 ? `${options.message.slice(0, 50)}...` : options.message
|
|
363
|
+
return new Error(
|
|
364
|
+
`Failed to post comment: ${error.message}\nTried to post overall comment: "${msg}"`,
|
|
365
|
+
)
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
return new Error(`Failed to post comment: ${error.message}`)
|
|
369
|
+
}
|
|
370
|
+
return error
|
|
371
|
+
}),
|
|
372
|
+
)
|
|
373
|
+
|
|
374
|
+
// Format and display output
|
|
375
|
+
yield* formatOutput(change, review, options, changeId)
|
|
376
|
+
})
|
|
377
|
+
|
|
378
|
+
// Helper to format XML output
|
|
379
|
+
const formatXmlOutput = (
|
|
380
|
+
change: ChangeInfo,
|
|
381
|
+
review: ReviewInput,
|
|
382
|
+
options: CommentOptions,
|
|
383
|
+
changeId: string,
|
|
384
|
+
): Effect.Effect<void> =>
|
|
385
|
+
Effect.sync(() => {
|
|
386
|
+
const lines: string[] = [
|
|
387
|
+
`<?xml version="1.0" encoding="UTF-8"?>`,
|
|
388
|
+
`<comment_result>`,
|
|
389
|
+
` <status>success</status>`,
|
|
390
|
+
` <change_id>${changeId}</change_id>`,
|
|
391
|
+
` <change_number>${change._number}</change_number>`,
|
|
392
|
+
` <change_subject><![CDATA[${change.subject}]]></change_subject>`,
|
|
393
|
+
` <change_status>${change.status}</change_status>`,
|
|
394
|
+
]
|
|
395
|
+
|
|
396
|
+
if (options.batch && review.comments) {
|
|
397
|
+
lines.push(` <comments>`)
|
|
398
|
+
for (const [file, comments] of Object.entries(review.comments)) {
|
|
399
|
+
for (const comment of comments) {
|
|
400
|
+
lines.push(` <comment>`)
|
|
401
|
+
lines.push(` <file>${file}</file>`)
|
|
402
|
+
if (comment.line) lines.push(` <line>${comment.line}</line>`)
|
|
403
|
+
lines.push(` <message><![CDATA[${comment.message}]]></message>`)
|
|
404
|
+
if (comment.unresolved) lines.push(` <unresolved>true</unresolved>`)
|
|
405
|
+
lines.push(` </comment>`)
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
lines.push(` </comments>`)
|
|
409
|
+
} else if (options.file && options.line) {
|
|
410
|
+
lines.push(` <comment>`)
|
|
411
|
+
lines.push(` <file>${options.file}</file>`)
|
|
412
|
+
lines.push(` <line>${options.line}</line>`)
|
|
413
|
+
lines.push(` <message><![CDATA[${options.message}]]></message>`)
|
|
414
|
+
if (options.unresolved) lines.push(` <unresolved>true</unresolved>`)
|
|
415
|
+
lines.push(` </comment>`)
|
|
416
|
+
} else {
|
|
417
|
+
lines.push(` <message><![CDATA[${options.message}]]></message>`)
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
lines.push(`</comment_result>`)
|
|
421
|
+
for (const line of lines) {
|
|
422
|
+
console.log(line)
|
|
423
|
+
}
|
|
424
|
+
})
|
|
425
|
+
|
|
426
|
+
// Helper to format human-readable output
|
|
427
|
+
const formatHumanOutput = (
|
|
428
|
+
change: ChangeInfo,
|
|
429
|
+
review: ReviewInput,
|
|
430
|
+
options: CommentOptions,
|
|
431
|
+
): Effect.Effect<void> =>
|
|
432
|
+
Effect.sync(() => {
|
|
433
|
+
console.log(`✓ Comment posted successfully!`)
|
|
434
|
+
console.log(`Change: ${change.subject} (${change.status})`)
|
|
435
|
+
|
|
436
|
+
if (options.batch && review.comments) {
|
|
437
|
+
const totalComments = Object.values(review.comments).reduce(
|
|
438
|
+
(sum, comments) => sum + comments.length,
|
|
439
|
+
0,
|
|
440
|
+
)
|
|
441
|
+
console.log(`Posted ${totalComments} line comment(s)`)
|
|
442
|
+
} else if (options.file && options.line) {
|
|
443
|
+
console.log(`File: ${options.file}, Line: ${options.line}`)
|
|
444
|
+
console.log(`Message: ${options.message}`)
|
|
445
|
+
if (options.unresolved) console.log(`Status: Unresolved`)
|
|
446
|
+
} else {
|
|
447
|
+
console.log(`Message: ${review.message}`)
|
|
448
|
+
}
|
|
449
|
+
})
|
|
450
|
+
|
|
451
|
+
// Main output formatter
|
|
452
|
+
const formatOutput = (
|
|
453
|
+
change: ChangeInfo,
|
|
454
|
+
review: ReviewInput,
|
|
455
|
+
options: CommentOptions,
|
|
456
|
+
changeId: string,
|
|
457
|
+
): Effect.Effect<void> =>
|
|
458
|
+
options.xml
|
|
459
|
+
? formatXmlOutput(change, review, options, changeId)
|
|
460
|
+
: formatHumanOutput(change, review, options)
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { Effect } from 'effect'
|
|
2
|
+
import { type ApiError, GerritApiService } from '@/api/gerrit'
|
|
3
|
+
import type { CommentInfo } from '@/schemas/gerrit'
|
|
4
|
+
import { formatCommentsPretty, formatCommentsXml } from '@/utils/comment-formatters'
|
|
5
|
+
import { getDiffContext } from '@/utils/diff-context'
|
|
6
|
+
|
|
7
|
+
interface CommentsOptions {
|
|
8
|
+
xml?: boolean
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const getCommentsForChange = (
|
|
12
|
+
changeId: string,
|
|
13
|
+
): Effect.Effect<CommentInfo[], ApiError, GerritApiService> =>
|
|
14
|
+
Effect.gen(function* () {
|
|
15
|
+
const gerritApi = yield* GerritApiService
|
|
16
|
+
|
|
17
|
+
// Get all comments for the change - let errors propagate for proper handling
|
|
18
|
+
const comments = yield* gerritApi.getComments(changeId)
|
|
19
|
+
|
|
20
|
+
// Flatten all comments from all files
|
|
21
|
+
const allComments: CommentInfo[] = []
|
|
22
|
+
for (const [path, fileComments] of Object.entries(comments)) {
|
|
23
|
+
for (const comment of fileComments) {
|
|
24
|
+
allComments.push({
|
|
25
|
+
...comment,
|
|
26
|
+
path: path === '/COMMIT_MSG' ? 'Commit Message' : path,
|
|
27
|
+
})
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Sort by path and then by line number
|
|
32
|
+
allComments.sort((a, b) => {
|
|
33
|
+
const pathCompare = (a.path || '').localeCompare(b.path || '')
|
|
34
|
+
if (pathCompare !== 0) return pathCompare
|
|
35
|
+
return (a.line || 0) - (b.line || 0)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
return allComments
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
export const commentsCommand = (
|
|
42
|
+
changeId: string,
|
|
43
|
+
options: CommentsOptions,
|
|
44
|
+
): Effect.Effect<void, ApiError | Error, GerritApiService> =>
|
|
45
|
+
Effect.gen(function* () {
|
|
46
|
+
// Get all comments
|
|
47
|
+
const comments = yield* getCommentsForChange(changeId)
|
|
48
|
+
|
|
49
|
+
// Get context for each comment using concurrent requests with unbounded concurrency
|
|
50
|
+
const contextEffects = comments.map((comment) =>
|
|
51
|
+
comment.path && comment.line
|
|
52
|
+
? getDiffContext(changeId, comment.path, comment.line).pipe(
|
|
53
|
+
Effect.map((context) => ({ comment, context })),
|
|
54
|
+
// Graceful degradation for diff fetch failures
|
|
55
|
+
Effect.catchAll(() => Effect.succeed({ comment, context: undefined })),
|
|
56
|
+
)
|
|
57
|
+
: Effect.succeed({ comment, context: undefined }),
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
// Execute all context fetches concurrently with unbounded concurrency
|
|
61
|
+
const commentsWithContext = yield* Effect.all(contextEffects, {
|
|
62
|
+
concurrency: 'unbounded',
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
// Format output
|
|
66
|
+
if (options.xml) {
|
|
67
|
+
formatCommentsXml(changeId, commentsWithContext)
|
|
68
|
+
} else {
|
|
69
|
+
formatCommentsPretty(commentsWithContext)
|
|
70
|
+
}
|
|
71
|
+
}).pipe(
|
|
72
|
+
// Regional error boundary for the entire command
|
|
73
|
+
Effect.catchTag('ApiError', (error) => {
|
|
74
|
+
if (options.xml) {
|
|
75
|
+
console.log(`<?xml version="1.0" encoding="UTF-8"?>`)
|
|
76
|
+
console.log(`<comments_result>`)
|
|
77
|
+
console.log(` <status>error</status>`)
|
|
78
|
+
console.log(` <error><![CDATA[${error.message}]]></error>`)
|
|
79
|
+
console.log(`</comments_result>`)
|
|
80
|
+
} else {
|
|
81
|
+
console.error(`✗ Failed to fetch comments: ${error.message}`)
|
|
82
|
+
}
|
|
83
|
+
return Effect.succeed(undefined)
|
|
84
|
+
}),
|
|
85
|
+
)
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { Schema } from '@effect/schema'
|
|
2
|
+
import { Effect, pipe } from 'effect'
|
|
3
|
+
import { type ApiError, GerritApiService } from '@/api/gerrit'
|
|
4
|
+
import type { DiffOptions, DiffCommandOptions } from '@/schemas/gerrit'
|
|
5
|
+
import { DiffCommandOptions as DiffCommandOptionsSchema } from '@/schemas/gerrit'
|
|
6
|
+
import { formatDiffPretty, formatFilesList } from '@/utils/diff-formatters'
|
|
7
|
+
import { sanitizeCDATA, escapeXML } from '@/utils/shell-safety'
|
|
8
|
+
|
|
9
|
+
export const diffCommand = (
|
|
10
|
+
changeId: string,
|
|
11
|
+
options: DiffCommandOptions,
|
|
12
|
+
): Effect.Effect<void, ApiError | Error, GerritApiService> =>
|
|
13
|
+
Effect.gen(function* () {
|
|
14
|
+
// Validate input options using Effect Schema
|
|
15
|
+
const validatedOptions = yield* pipe(
|
|
16
|
+
options,
|
|
17
|
+
Schema.decodeUnknown(DiffCommandOptionsSchema, {
|
|
18
|
+
errors: 'all',
|
|
19
|
+
onExcessProperty: 'ignore',
|
|
20
|
+
}),
|
|
21
|
+
Effect.mapError(() => new Error('Invalid diff command options')),
|
|
22
|
+
)
|
|
23
|
+
const apiService = yield* GerritApiService
|
|
24
|
+
|
|
25
|
+
const diffOptions: DiffOptions = {
|
|
26
|
+
format: validatedOptions.filesOnly ? 'files' : validatedOptions.format || 'unified',
|
|
27
|
+
file: validatedOptions.file,
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const diff = yield* apiService
|
|
31
|
+
.getDiff(changeId, diffOptions)
|
|
32
|
+
.pipe(
|
|
33
|
+
Effect.catchTag('ApiError', (error) =>
|
|
34
|
+
Effect.fail(new Error(`Failed to get diff: ${error.message}`)),
|
|
35
|
+
),
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
if (validatedOptions.xml) {
|
|
39
|
+
// XML output for LLM consumption
|
|
40
|
+
console.log(`<?xml version="1.0" encoding="UTF-8"?>`)
|
|
41
|
+
console.log(`<diff_result>`)
|
|
42
|
+
console.log(` <status>success</status>`)
|
|
43
|
+
console.log(` <change_id>${escapeXML(changeId)}</change_id>`)
|
|
44
|
+
|
|
45
|
+
if (Array.isArray(diff)) {
|
|
46
|
+
console.log(` <files>`)
|
|
47
|
+
diff.forEach((file) => {
|
|
48
|
+
console.log(` <file>${escapeXML(file)}</file>`)
|
|
49
|
+
})
|
|
50
|
+
console.log(` </files>`)
|
|
51
|
+
} else if (typeof diff === 'string') {
|
|
52
|
+
console.log(` <content><![CDATA[${sanitizeCDATA(diff)}]]></content>`)
|
|
53
|
+
} else {
|
|
54
|
+
console.log(
|
|
55
|
+
` <content><![CDATA[${sanitizeCDATA(JSON.stringify(diff, null, 2))}]]></content>`,
|
|
56
|
+
)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
console.log(`</diff_result>`)
|
|
60
|
+
} else {
|
|
61
|
+
// Human-readable output (default) - pretty formatted
|
|
62
|
+
if (Array.isArray(diff)) {
|
|
63
|
+
console.log(formatFilesList(diff))
|
|
64
|
+
} else if (typeof diff === 'string') {
|
|
65
|
+
console.log(formatDiffPretty(diff))
|
|
66
|
+
} else {
|
|
67
|
+
// JSON data - format as pretty JSON for readability
|
|
68
|
+
console.log(JSON.stringify(diff, null, 2))
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
})
|