@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.
Files changed (91) hide show
  1. package/.ast-grep/rules/no-as-casting.yml +13 -0
  2. package/.eslintrc.js +12 -0
  3. package/.github/workflows/ci-simple.yml +53 -0
  4. package/.github/workflows/ci.yml +171 -0
  5. package/.github/workflows/claude-code-review.yml +78 -0
  6. package/.github/workflows/claude.yml +64 -0
  7. package/.github/workflows/dependency-update.yml +84 -0
  8. package/.github/workflows/release.yml +166 -0
  9. package/.github/workflows/security-scan.yml +113 -0
  10. package/.github/workflows/security.yml +96 -0
  11. package/.husky/pre-commit +16 -0
  12. package/.husky/pre-push +25 -0
  13. package/.lintstagedrc.json +6 -0
  14. package/.tool-versions +1 -0
  15. package/CLAUDE.md +103 -0
  16. package/DEVELOPMENT.md +361 -0
  17. package/LICENSE +21 -0
  18. package/README.md +325 -0
  19. package/bin/ger +3 -0
  20. package/biome.json +36 -0
  21. package/bun.lock +688 -0
  22. package/bunfig.toml +8 -0
  23. package/oxlint.json +24 -0
  24. package/package.json +55 -0
  25. package/scripts/check-coverage.ts +69 -0
  26. package/scripts/check-file-size.ts +38 -0
  27. package/scripts/fix-test-mocks.ts +55 -0
  28. package/src/api/gerrit.ts +466 -0
  29. package/src/cli/commands/abandon.ts +65 -0
  30. package/src/cli/commands/comment.ts +460 -0
  31. package/src/cli/commands/comments.ts +85 -0
  32. package/src/cli/commands/diff.ts +71 -0
  33. package/src/cli/commands/incoming.ts +226 -0
  34. package/src/cli/commands/init.ts +164 -0
  35. package/src/cli/commands/mine.ts +115 -0
  36. package/src/cli/commands/open.ts +57 -0
  37. package/src/cli/commands/review.ts +593 -0
  38. package/src/cli/commands/setup.ts +230 -0
  39. package/src/cli/commands/show.ts +303 -0
  40. package/src/cli/commands/status.ts +35 -0
  41. package/src/cli/commands/workspace.ts +200 -0
  42. package/src/cli/index.ts +420 -0
  43. package/src/prompts/default-review.md +80 -0
  44. package/src/prompts/system-inline-review.md +88 -0
  45. package/src/prompts/system-overall-review.md +152 -0
  46. package/src/schemas/config.test.ts +245 -0
  47. package/src/schemas/config.ts +75 -0
  48. package/src/schemas/gerrit.ts +455 -0
  49. package/src/services/ai-enhanced.ts +167 -0
  50. package/src/services/ai.ts +182 -0
  51. package/src/services/config.test.ts +414 -0
  52. package/src/services/config.ts +206 -0
  53. package/src/test-utils/mock-generator.ts +73 -0
  54. package/src/utils/comment-formatters.ts +153 -0
  55. package/src/utils/diff-context.ts +103 -0
  56. package/src/utils/diff-formatters.ts +141 -0
  57. package/src/utils/formatters.ts +85 -0
  58. package/src/utils/message-filters.ts +26 -0
  59. package/src/utils/shell-safety.ts +117 -0
  60. package/src/utils/status-indicators.ts +100 -0
  61. package/src/utils/url-parser.test.ts +123 -0
  62. package/src/utils/url-parser.ts +91 -0
  63. package/tests/abandon.test.ts +163 -0
  64. package/tests/ai-service.test.ts +489 -0
  65. package/tests/comment-batch-advanced.test.ts +431 -0
  66. package/tests/comment-gerrit-api-compliance.test.ts +414 -0
  67. package/tests/comment.test.ts +707 -0
  68. package/tests/comments.test.ts +323 -0
  69. package/tests/config-service-simple.test.ts +100 -0
  70. package/tests/diff.test.ts +419 -0
  71. package/tests/helpers/config-mock.ts +27 -0
  72. package/tests/incoming.test.ts +357 -0
  73. package/tests/interactive-incoming.test.ts +173 -0
  74. package/tests/mine.test.ts +318 -0
  75. package/tests/mocks/fetch-mock.ts +139 -0
  76. package/tests/mocks/msw-handlers.ts +80 -0
  77. package/tests/open.test.ts +233 -0
  78. package/tests/review.test.ts +669 -0
  79. package/tests/setup.ts +13 -0
  80. package/tests/show.test.ts +439 -0
  81. package/tests/unit/schemas/gerrit.test.ts +85 -0
  82. package/tests/unit/test-utils/mock-generator.test.ts +154 -0
  83. package/tests/unit/utils/comment-formatters.test.ts +415 -0
  84. package/tests/unit/utils/diff-context.test.ts +171 -0
  85. package/tests/unit/utils/diff-formatters.test.ts +165 -0
  86. package/tests/unit/utils/formatters.test.ts +411 -0
  87. package/tests/unit/utils/message-filters.test.ts +227 -0
  88. package/tests/unit/utils/prompt-helpers.test.ts +175 -0
  89. package/tests/unit/utils/shell-safety.test.ts +230 -0
  90. package/tests/unit/utils/status-indicators.test.ts +137 -0
  91. 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
+ })