@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,707 @@
1
+ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, mock } from 'bun:test'
2
+ import { Effect, Layer } from 'effect'
3
+ import { HttpResponse, http } from 'msw'
4
+ import { setupServer } from 'msw/node'
5
+ import { GerritApiServiceLive } from '@/api/gerrit'
6
+ import { commentCommand } from '@/cli/commands/comment'
7
+ import { ConfigService } from '@/services/config'
8
+
9
+ import { createMockConfigService } from './helpers/config-mock'
10
+ // Create MSW server
11
+ const server = setupServer(
12
+ // Default handler for auth check
13
+ http.get('*/a/accounts/self', ({ request }) => {
14
+ const auth = request.headers.get('Authorization')
15
+ if (!auth || !auth.startsWith('Basic ')) {
16
+ return HttpResponse.text('Unauthorized', { status: 401 })
17
+ }
18
+ return HttpResponse.json({
19
+ _account_id: 1000,
20
+ name: 'Test User',
21
+ email: 'test@example.com',
22
+ })
23
+ }),
24
+ )
25
+
26
+ describe('comment command', () => {
27
+ let mockConsoleLog: ReturnType<typeof mock>
28
+ let mockConsoleError: ReturnType<typeof mock>
29
+ let mockProcessStdin: {
30
+ on: ReturnType<typeof mock>
31
+ emit: (data: string) => void
32
+ dataCallback?: (...args: unknown[]) => void
33
+ endCallback?: (...args: unknown[]) => void
34
+ }
35
+
36
+ beforeAll(() => {
37
+ server.listen({ onUnhandledRequest: 'bypass' })
38
+ })
39
+
40
+ afterAll(() => {
41
+ server.close()
42
+ })
43
+
44
+ beforeEach(() => {
45
+ server.resetHandlers()
46
+
47
+ mockConsoleLog = mock(() => {})
48
+ mockConsoleError = mock(() => {})
49
+ console.log = mockConsoleLog
50
+ console.error = mockConsoleError
51
+
52
+ // Mock process.stdin for batch tests
53
+ mockProcessStdin = {
54
+ on: mock((event: string, callback: (...args: unknown[]) => void) => {
55
+ if (event === 'data') {
56
+ mockProcessStdin.dataCallback = callback
57
+ } else if (event === 'end') {
58
+ mockProcessStdin.endCallback = callback
59
+ }
60
+ }),
61
+ emit: (data: string) => {
62
+ if (mockProcessStdin.dataCallback) {
63
+ mockProcessStdin.dataCallback(data)
64
+ }
65
+ if (mockProcessStdin.endCallback) {
66
+ mockProcessStdin.endCallback()
67
+ }
68
+ },
69
+ }
70
+ })
71
+
72
+ afterEach(() => {
73
+ server.resetHandlers()
74
+ })
75
+
76
+ it('should post an overall comment', async () => {
77
+ server.use(
78
+ http.get('*/a/changes/:changeId', () => {
79
+ return HttpResponse.text(`)]}'\n{
80
+ "id": "test-project~main~I123abc",
81
+ "_number": 12345,
82
+ "project": "test-project",
83
+ "branch": "main",
84
+ "change_id": "I123abc",
85
+ "subject": "Test change",
86
+ "status": "NEW",
87
+ "created": "2024-01-15 10:00:00.000000000",
88
+ "updated": "2024-01-15 10:00:00.000000000"
89
+ }`)
90
+ }),
91
+ http.post('*/a/changes/:changeId/revisions/current/review', async ({ request }) => {
92
+ const body = (await request.json()) as { message?: string; comments?: unknown }
93
+ expect(body.message).toBe('This is a test comment')
94
+ expect(body.comments).toBeUndefined()
95
+ return HttpResponse.json({})
96
+ }),
97
+ )
98
+
99
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
100
+
101
+ const program = commentCommand('12345', {
102
+ message: 'This is a test comment',
103
+ }).pipe(Effect.provide(GerritApiServiceLive), Effect.provide(mockConfigLayer))
104
+
105
+ await Effect.runPromise(program)
106
+
107
+ const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
108
+ expect(output).toContain('✓ Comment posted successfully!')
109
+ expect(output).toContain('Test change')
110
+ })
111
+
112
+ it('should post a line-specific comment', async () => {
113
+ server.use(
114
+ http.get('*/a/changes/:changeId', () => {
115
+ return HttpResponse.text(`)]}'\n{
116
+ "id": "test-project~main~I123abc",
117
+ "_number": 12345,
118
+ "project": "test-project",
119
+ "branch": "main",
120
+ "change_id": "I123abc",
121
+ "subject": "Test change",
122
+ "status": "NEW",
123
+ "created": "2024-01-15 10:00:00.000000000",
124
+ "updated": "2024-01-15 10:00:00.000000000"
125
+ }`)
126
+ }),
127
+ http.post('*/a/changes/:changeId/revisions/current/review', async ({ request }) => {
128
+ const body = (await request.json()) as {
129
+ message?: string
130
+ comments?: Record<string, Array<{ line: number; message: string; unresolved?: boolean }>>
131
+ }
132
+ expect(body.message).toBeUndefined()
133
+ expect(body.comments).toBeDefined()
134
+ expect(body.comments?.['src/main.js']).toBeDefined()
135
+ expect(body.comments?.['src/main.js']?.[0].line).toBe(42)
136
+ expect(body.comments?.['src/main.js']?.[0].message).toBe('Fix this issue')
137
+ expect(body.comments?.['src/main.js']?.[0].unresolved).toBe(true)
138
+ return HttpResponse.json({})
139
+ }),
140
+ )
141
+
142
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
143
+
144
+ const program = commentCommand('12345', {
145
+ message: 'Fix this issue',
146
+ file: 'src/main.js',
147
+ line: 42,
148
+ unresolved: true,
149
+ }).pipe(Effect.provide(GerritApiServiceLive), Effect.provide(mockConfigLayer))
150
+
151
+ await Effect.runPromise(program)
152
+
153
+ const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
154
+ expect(output).toContain('✓ Comment posted successfully!')
155
+ expect(output).toContain('File: src/main.js, Line: 42')
156
+ expect(output).toContain('Status: Unresolved')
157
+ })
158
+
159
+ it('should handle batch comments', async () => {
160
+ // Override process.stdin temporarily
161
+ const originalStdin = process.stdin
162
+ Object.defineProperty(process, 'stdin', {
163
+ value: mockProcessStdin,
164
+ configurable: true,
165
+ })
166
+
167
+ server.use(
168
+ http.get('*/a/changes/:changeId', () => {
169
+ return HttpResponse.text(`)]}'\n{
170
+ "id": "test-project~main~I123abc",
171
+ "_number": 12345,
172
+ "project": "test-project",
173
+ "branch": "main",
174
+ "change_id": "I123abc",
175
+ "subject": "Test change",
176
+ "status": "NEW",
177
+ "created": "2024-01-15 10:00:00.000000000",
178
+ "updated": "2024-01-15 10:00:00.000000000"
179
+ }`)
180
+ }),
181
+ http.post('*/a/changes/:changeId/revisions/current/review', async ({ request }) => {
182
+ const body = (await request.json()) as {
183
+ message?: string
184
+ comments?: Record<string, unknown[]>
185
+ }
186
+ // Array format doesn't include overall message
187
+ expect(body.message).toBeUndefined()
188
+ expect(body.comments).toBeDefined()
189
+ expect(body.comments?.['src/main.js']?.length).toBe(2)
190
+ expect(body.comments?.['src/utils.js']?.length).toBe(1)
191
+ return HttpResponse.json({})
192
+ }),
193
+ )
194
+
195
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
196
+
197
+ const program = commentCommand('12345', { batch: true }).pipe(
198
+ Effect.provide(GerritApiServiceLive),
199
+ Effect.provide(mockConfigLayer),
200
+ )
201
+
202
+ // Simulate stdin data (array format)
203
+ setTimeout(() => {
204
+ mockProcessStdin.emit(
205
+ JSON.stringify([
206
+ { file: 'src/main.js', line: 10, message: 'First comment' },
207
+ { file: 'src/main.js', line: 20, message: 'Second comment', unresolved: true },
208
+ { file: 'src/utils.js', line: 5, message: 'Utils comment' },
209
+ ]),
210
+ )
211
+ }, 10)
212
+
213
+ await Effect.runPromise(program)
214
+
215
+ const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
216
+ expect(output).toContain('✓ Comment posted successfully!')
217
+ expect(output).toContain('Posted 3 line comment(s)')
218
+
219
+ // Restore process.stdin
220
+ Object.defineProperty(process, 'stdin', {
221
+ value: originalStdin,
222
+ configurable: true,
223
+ })
224
+ })
225
+
226
+ it('should output XML format for line comments', async () => {
227
+ server.use(
228
+ http.get('*/a/changes/:changeId', () => {
229
+ return HttpResponse.text(`)]}'\n{
230
+ "id": "test-project~main~I123abc",
231
+ "_number": 12345,
232
+ "project": "test-project",
233
+ "branch": "main",
234
+ "change_id": "I123abc",
235
+ "subject": "Test change",
236
+ "status": "NEW",
237
+ "created": "2024-01-15 10:00:00.000000000",
238
+ "updated": "2024-01-15 10:00:00.000000000"
239
+ }`)
240
+ }),
241
+ http.post('*/a/changes/:changeId/revisions/current/review', async () => {
242
+ return HttpResponse.json({})
243
+ }),
244
+ )
245
+
246
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
247
+
248
+ const program = commentCommand('12345', {
249
+ message: 'Fix this',
250
+ file: 'test.js',
251
+ line: 10,
252
+ xml: true,
253
+ }).pipe(Effect.provide(GerritApiServiceLive), Effect.provide(mockConfigLayer))
254
+
255
+ await Effect.runPromise(program)
256
+
257
+ const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
258
+ expect(output).toContain('<?xml version="1.0" encoding="UTF-8"?>')
259
+ expect(output).toContain('<comment_result>')
260
+ expect(output).toContain('<status>success</status>')
261
+ expect(output).toContain('<file>test.js</file>')
262
+ expect(output).toContain('<line>10</line>')
263
+ expect(output).toContain('<message><![CDATA[Fix this]]></message>')
264
+ })
265
+
266
+ it('should provide detailed error for invalid JSON with input preview', async () => {
267
+ const originalStdin = process.stdin
268
+ Object.defineProperty(process, 'stdin', {
269
+ value: mockProcessStdin,
270
+ configurable: true,
271
+ })
272
+
273
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
274
+
275
+ const program = commentCommand('12345', { batch: true }).pipe(
276
+ Effect.provide(GerritApiServiceLive),
277
+ Effect.provide(mockConfigLayer),
278
+ )
279
+
280
+ const malformedJson = `[
281
+ {
282
+ "file": "src/main.js",
283
+ "line": 10,
284
+ "message": "This is an unterminated string
285
+ }
286
+ ]`
287
+
288
+ // Simulate invalid JSON input
289
+ setTimeout(() => {
290
+ mockProcessStdin.emit(malformedJson)
291
+ }, 10)
292
+
293
+ await expect(Effect.runPromise(program)).rejects.toThrow(
294
+ /Invalid JSON input: .*Unterminated string.*\nInput \(\d+ chars, \d+ lines\):\n.*src\/main\.js.*\nExpected format:/s,
295
+ )
296
+
297
+ // Restore process.stdin
298
+ Object.defineProperty(process, 'stdin', {
299
+ value: originalStdin,
300
+ configurable: true,
301
+ })
302
+ })
303
+
304
+ it('should reject invalid batch JSON', async () => {
305
+ const originalStdin = process.stdin
306
+ Object.defineProperty(process, 'stdin', {
307
+ value: mockProcessStdin,
308
+ configurable: true,
309
+ })
310
+
311
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
312
+
313
+ const program = commentCommand('12345', { batch: true }).pipe(
314
+ Effect.provide(GerritApiServiceLive),
315
+ Effect.provide(mockConfigLayer),
316
+ )
317
+
318
+ // Simulate invalid JSON
319
+ setTimeout(() => {
320
+ mockProcessStdin.emit('not valid json')
321
+ }, 10)
322
+
323
+ await expect(Effect.runPromise(program)).rejects.toThrow('Invalid batch input format')
324
+
325
+ // Restore process.stdin
326
+ Object.defineProperty(process, 'stdin', {
327
+ value: originalStdin,
328
+ configurable: true,
329
+ })
330
+ })
331
+
332
+ it('should reject invalid batch schema', async () => {
333
+ const originalStdin = process.stdin
334
+ Object.defineProperty(process, 'stdin', {
335
+ value: mockProcessStdin,
336
+ configurable: true,
337
+ })
338
+
339
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
340
+
341
+ const program = commentCommand('12345', { batch: true }).pipe(
342
+ Effect.provide(GerritApiServiceLive),
343
+ Effect.provide(mockConfigLayer),
344
+ )
345
+
346
+ // Simulate invalid schema (array format)
347
+ setTimeout(() => {
348
+ mockProcessStdin.emit(
349
+ JSON.stringify([
350
+ { message: 'Missing file path' }, // Invalid: missing file
351
+ ]),
352
+ )
353
+ }, 10)
354
+
355
+ await expect(Effect.runPromise(program)).rejects.toThrow('Invalid batch input format')
356
+
357
+ // Restore process.stdin
358
+ Object.defineProperty(process, 'stdin', {
359
+ value: originalStdin,
360
+ configurable: true,
361
+ })
362
+ })
363
+
364
+ it('should require message for line comments', async () => {
365
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
366
+
367
+ const program = commentCommand('12345', {
368
+ file: 'test.js',
369
+ line: 10,
370
+ // Missing message
371
+ }).pipe(Effect.provide(GerritApiServiceLive), Effect.provide(mockConfigLayer))
372
+
373
+ await expect(Effect.runPromise(program)).rejects.toThrow(
374
+ 'Message is required for line comments',
375
+ )
376
+ })
377
+
378
+ it('should require message for overall comments when stdin is empty', async () => {
379
+ const originalStdin = process.stdin
380
+ Object.defineProperty(process, 'stdin', {
381
+ value: mockProcessStdin,
382
+ configurable: true,
383
+ })
384
+
385
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
386
+
387
+ const program = commentCommand('12345', {}).pipe(
388
+ Effect.provide(GerritApiServiceLive),
389
+ Effect.provide(mockConfigLayer),
390
+ )
391
+
392
+ // Simulate empty stdin
393
+ setTimeout(() => {
394
+ mockProcessStdin.emit('')
395
+ }, 10)
396
+
397
+ await expect(Effect.runPromise(program)).rejects.toThrow(
398
+ 'Message is required. Use -m "your message" or pipe content to stdin',
399
+ )
400
+
401
+ // Restore process.stdin
402
+ Object.defineProperty(process, 'stdin', {
403
+ value: originalStdin,
404
+ configurable: true,
405
+ })
406
+ })
407
+
408
+ it('should handle API errors gracefully', async () => {
409
+ server.use(
410
+ http.get('*/a/changes/:changeId', () => {
411
+ return HttpResponse.text('Not found', { status: 404 })
412
+ }),
413
+ )
414
+
415
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
416
+
417
+ const program = commentCommand('12345', {
418
+ message: 'Test comment',
419
+ }).pipe(Effect.provide(GerritApiServiceLive), Effect.provide(mockConfigLayer))
420
+
421
+ await expect(Effect.runPromise(program)).rejects.toThrow('Failed to get change')
422
+ })
423
+
424
+ it('should handle post review API errors', async () => {
425
+ server.use(
426
+ http.get('*/a/changes/:changeId', () => {
427
+ return HttpResponse.text(`)]}'\n{
428
+ "id": "test-project~main~I123abc",
429
+ "_number": 12345,
430
+ "project": "test-project",
431
+ "branch": "main",
432
+ "change_id": "I123abc",
433
+ "subject": "Test change",
434
+ "status": "NEW",
435
+ "created": "2024-01-15 10:00:00.000000000",
436
+ "updated": "2024-01-15 10:00:00.000000000"
437
+ }`)
438
+ }),
439
+ http.post('*/a/changes/:changeId/revisions/current/review', () => {
440
+ return HttpResponse.text('Forbidden', { status: 403 })
441
+ }),
442
+ )
443
+
444
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
445
+
446
+ const program = commentCommand('12345', {
447
+ message: 'Test comment',
448
+ }).pipe(Effect.provide(GerritApiServiceLive), Effect.provide(mockConfigLayer))
449
+
450
+ await expect(Effect.runPromise(program)).rejects.toThrow('Failed to post comment')
451
+ })
452
+
453
+ it('should output XML for batch comments', async () => {
454
+ const originalStdin = process.stdin
455
+ Object.defineProperty(process, 'stdin', {
456
+ value: mockProcessStdin,
457
+ configurable: true,
458
+ })
459
+
460
+ server.use(
461
+ http.get('*/a/changes/:changeId', () => {
462
+ return HttpResponse.text(`)]}'\n{
463
+ "id": "test-project~main~I123abc",
464
+ "_number": 12345,
465
+ "project": "test-project",
466
+ "branch": "main",
467
+ "change_id": "I123abc",
468
+ "subject": "Test change",
469
+ "status": "NEW",
470
+ "created": "2024-01-15 10:00:00.000000000",
471
+ "updated": "2024-01-15 10:00:00.000000000"
472
+ }`)
473
+ }),
474
+ http.post('*/a/changes/:changeId/revisions/current/review', async () => {
475
+ return HttpResponse.json({})
476
+ }),
477
+ )
478
+
479
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
480
+
481
+ const program = commentCommand('12345', { batch: true, xml: true }).pipe(
482
+ Effect.provide(GerritApiServiceLive),
483
+ Effect.provide(mockConfigLayer),
484
+ )
485
+
486
+ // Simulate stdin data (array format)
487
+ setTimeout(() => {
488
+ mockProcessStdin.emit(
489
+ JSON.stringify([
490
+ { file: 'src/main.js', line: 10, message: 'First comment' },
491
+ { file: 'src/main.js', line: 20, message: 'Second comment', unresolved: true },
492
+ ]),
493
+ )
494
+ }, 10)
495
+
496
+ await Effect.runPromise(program)
497
+
498
+ const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
499
+ expect(output).toContain('<?xml version="1.0" encoding="UTF-8"?>')
500
+ expect(output).toContain('<comments>')
501
+ expect(output).toContain('<file>src/main.js</file>')
502
+ expect(output).toContain('<line>10</line>')
503
+ expect(output).toContain('<line>20</line>')
504
+ expect(output).toContain('<unresolved>true</unresolved>')
505
+ expect(output).toContain('</comments>')
506
+
507
+ // Restore process.stdin
508
+ Object.defineProperty(process, 'stdin', {
509
+ value: originalStdin,
510
+ configurable: true,
511
+ })
512
+ })
513
+
514
+ it('should accept piped input for overall comments', async () => {
515
+ const originalStdin = process.stdin
516
+ Object.defineProperty(process, 'stdin', {
517
+ value: mockProcessStdin,
518
+ configurable: true,
519
+ })
520
+
521
+ server.use(
522
+ http.get('*/a/changes/:changeId', () => {
523
+ return HttpResponse.text(`)]}'\n{
524
+ "id": "test-project~main~I123abc",
525
+ "_number": 12345,
526
+ "project": "test-project",
527
+ "branch": "main",
528
+ "change_id": "I123abc",
529
+ "subject": "Test change",
530
+ "status": "NEW",
531
+ "created": "2024-01-15 10:00:00.000000000",
532
+ "updated": "2024-01-15 10:00:00.000000000"
533
+ }`)
534
+ }),
535
+ http.post('*/a/changes/:changeId/revisions/current/review', async ({ request }) => {
536
+ const body = (await request.json()) as { message?: string }
537
+ expect(body.message).toBe('Piped comment message')
538
+ return HttpResponse.json({})
539
+ }),
540
+ )
541
+
542
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
543
+
544
+ // Test comment without message option (should read from stdin)
545
+ const program = commentCommand('12345', {}).pipe(
546
+ Effect.provide(GerritApiServiceLive),
547
+ Effect.provide(mockConfigLayer),
548
+ )
549
+
550
+ // Simulate piped input
551
+ setTimeout(() => {
552
+ mockProcessStdin.emit('Piped comment message')
553
+ }, 10)
554
+
555
+ await Effect.runPromise(program)
556
+
557
+ const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
558
+ expect(output).toContain('✓ Comment posted successfully!')
559
+ expect(output).toContain('Message: Piped comment message')
560
+
561
+ // Restore process.stdin
562
+ Object.defineProperty(process, 'stdin', {
563
+ value: originalStdin,
564
+ configurable: true,
565
+ })
566
+ })
567
+
568
+ it('should trim whitespace from piped input', async () => {
569
+ const originalStdin = process.stdin
570
+ Object.defineProperty(process, 'stdin', {
571
+ value: mockProcessStdin,
572
+ configurable: true,
573
+ })
574
+
575
+ server.use(
576
+ http.get('*/a/changes/:changeId', () => {
577
+ return HttpResponse.text(`)]}'\n{
578
+ "id": "test-project~main~I123abc",
579
+ "_number": 12345,
580
+ "project": "test-project",
581
+ "branch": "main",
582
+ "change_id": "I123abc",
583
+ "subject": "Test change",
584
+ "status": "NEW",
585
+ "created": "2024-01-15 10:00:00.000000000",
586
+ "updated": "2024-01-15 10:00:00.000000000"
587
+ }`)
588
+ }),
589
+ http.post('*/a/changes/:changeId/revisions/current/review', async ({ request }) => {
590
+ const body = (await request.json()) as { message?: string }
591
+ expect(body.message).toBe('Trimmed message')
592
+ return HttpResponse.json({})
593
+ }),
594
+ )
595
+
596
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
597
+
598
+ const program = commentCommand('12345', {}).pipe(
599
+ Effect.provide(GerritApiServiceLive),
600
+ Effect.provide(mockConfigLayer),
601
+ )
602
+
603
+ // Simulate piped input with whitespace
604
+ setTimeout(() => {
605
+ mockProcessStdin.emit(' \n Trimmed message \n ')
606
+ }, 10)
607
+
608
+ await Effect.runPromise(program)
609
+
610
+ // Restore process.stdin
611
+ Object.defineProperty(process, 'stdin', {
612
+ value: originalStdin,
613
+ configurable: true,
614
+ })
615
+ })
616
+
617
+ it('should provide detailed error context for batch comment failures', async () => {
618
+ const originalStdin = process.stdin
619
+ Object.defineProperty(process, 'stdin', {
620
+ value: mockProcessStdin,
621
+ configurable: true,
622
+ })
623
+
624
+ server.use(
625
+ http.get('*/a/changes/:changeId', () => {
626
+ return HttpResponse.text(`)]}'\n{
627
+ "id": "test-project~main~I123abc",
628
+ "_number": 12345,
629
+ "project": "test-project",
630
+ "branch": "main",
631
+ "change_id": "I123abc",
632
+ "subject": "Test change",
633
+ "status": "NEW"
634
+ }`)
635
+ }),
636
+ http.post('*/a/changes/:changeId/revisions/current/review', () => {
637
+ return HttpResponse.text(
638
+ 'file app/models/auto_grade_result.rb not found in revision 386823,6',
639
+ { status: 400 },
640
+ )
641
+ }),
642
+ )
643
+
644
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
645
+
646
+ const program = commentCommand('12345', { batch: true }).pipe(
647
+ Effect.provide(GerritApiServiceLive),
648
+ Effect.provide(mockConfigLayer),
649
+ )
650
+
651
+ // Simulate batch input
652
+ setTimeout(() => {
653
+ mockProcessStdin.emit(
654
+ JSON.stringify([
655
+ { file: 'app/models/auto_grade_result.rb', line: 23, message: 'This needs improvement' },
656
+ {
657
+ file: 'src/utils.js',
658
+ line: 45,
659
+ message:
660
+ 'This is a very long comment message that should be truncated in the error output to keep it readable',
661
+ },
662
+ ]),
663
+ )
664
+ }, 10)
665
+
666
+ await expect(Effect.runPromise(program)).rejects.toThrow(
667
+ /Failed to post comment: file app\/models\/auto_grade_result\.rb not found in revision 386823,6\nTried to post: app\/models\/auto_grade_result\.rb:23 "This needs improvement", src\/utils\.js:45 "This is a very long comment message that should be\.\.\."/,
668
+ )
669
+
670
+ // Restore process.stdin
671
+ Object.defineProperty(process, 'stdin', {
672
+ value: originalStdin,
673
+ configurable: true,
674
+ })
675
+ })
676
+
677
+ it('should provide detailed error context for line comment failures', async () => {
678
+ server.use(
679
+ http.get('*/a/changes/:changeId', () => {
680
+ return HttpResponse.text(`)]}'\n{
681
+ "id": "test-project~main~I123abc",
682
+ "_number": 12345,
683
+ "project": "test-project",
684
+ "branch": "main",
685
+ "change_id": "I123abc",
686
+ "subject": "Test change",
687
+ "status": "NEW"
688
+ }`)
689
+ }),
690
+ http.post('*/a/changes/:changeId/revisions/current/review', () => {
691
+ return HttpResponse.text('file not found', { status: 400 })
692
+ }),
693
+ )
694
+
695
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
696
+
697
+ const program = commentCommand('12345', {
698
+ file: 'missing-file.rb',
699
+ line: 42,
700
+ message: 'Test comment on missing file',
701
+ }).pipe(Effect.provide(GerritApiServiceLive), Effect.provide(mockConfigLayer))
702
+
703
+ await expect(Effect.runPromise(program)).rejects.toThrow(
704
+ 'Failed to post comment: file not found\nTried to post to missing-file.rb:42: "Test comment on missing file"',
705
+ )
706
+ })
707
+ })