@aaronshaf/ger 1.2.11 → 2.0.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 (180) hide show
  1. package/.ast-grep/rules/no-as-casting.yml +13 -0
  2. package/.claude-plugin/plugin.json +22 -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 +83 -0
  6. package/.github/workflows/claude.yml +50 -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 +105 -0
  16. package/DEVELOPMENT.md +361 -0
  17. package/EXAMPLES.md +457 -0
  18. package/README.md +831 -16
  19. package/bin/ger +3 -18
  20. package/biome.json +36 -0
  21. package/bun.lock +678 -0
  22. package/bunfig.toml +8 -0
  23. package/docs/adr/0001-use-effect-for-side-effects.md +65 -0
  24. package/docs/adr/0002-use-bun-runtime.md +64 -0
  25. package/docs/adr/0003-store-credentials-in-home-directory.md +75 -0
  26. package/docs/adr/0004-use-commander-for-cli.md +76 -0
  27. package/docs/adr/0005-use-effect-schema-for-validation.md +93 -0
  28. package/docs/adr/0006-use-msw-for-api-mocking.md +89 -0
  29. package/docs/adr/0007-git-hooks-for-quality.md +94 -0
  30. package/docs/adr/0008-no-as-typecasting.md +83 -0
  31. package/docs/adr/0009-file-size-limits.md +82 -0
  32. package/docs/adr/0010-llm-friendly-xml-output.md +93 -0
  33. package/docs/adr/0011-ai-tool-strategy-pattern.md +102 -0
  34. package/docs/adr/0012-build-status-message-parsing.md +94 -0
  35. package/docs/adr/0013-git-subprocess-integration.md +98 -0
  36. package/docs/adr/0014-group-management-support.md +95 -0
  37. package/docs/adr/0015-batch-comment-processing.md +111 -0
  38. package/docs/adr/0016-flexible-change-identifiers.md +94 -0
  39. package/docs/adr/0017-git-worktree-support.md +102 -0
  40. package/docs/adr/0018-auto-install-commit-hook.md +103 -0
  41. package/docs/adr/0019-sdk-package-exports.md +95 -0
  42. package/docs/adr/0020-code-coverage-enforcement.md +105 -0
  43. package/docs/adr/0021-typescript-isolated-declarations.md +83 -0
  44. package/docs/adr/0022-biome-oxlint-tooling.md +124 -0
  45. package/docs/adr/README.md +30 -0
  46. package/docs/prd/README.md +12 -0
  47. package/docs/prd/architecture.md +325 -0
  48. package/docs/prd/commands.md +425 -0
  49. package/docs/prd/data-model.md +349 -0
  50. package/docs/prd/overview.md +124 -0
  51. package/index.ts +219 -0
  52. package/oxlint.json +24 -0
  53. package/package.json +82 -15
  54. package/scripts/check-coverage.ts +69 -0
  55. package/scripts/check-file-size.ts +38 -0
  56. package/scripts/fix-test-mocks.ts +55 -0
  57. package/skills/gerrit-workflow/SKILL.md +247 -0
  58. package/skills/gerrit-workflow/examples.md +572 -0
  59. package/skills/gerrit-workflow/reference.md +728 -0
  60. package/src/api/gerrit.ts +696 -0
  61. package/src/cli/commands/abandon.ts +65 -0
  62. package/src/cli/commands/add-reviewer.ts +156 -0
  63. package/src/cli/commands/build-status.ts +282 -0
  64. package/src/cli/commands/checkout.ts +422 -0
  65. package/src/cli/commands/comment.ts +460 -0
  66. package/src/cli/commands/comments.ts +85 -0
  67. package/src/cli/commands/diff.ts +71 -0
  68. package/src/cli/commands/extract-url.ts +266 -0
  69. package/src/cli/commands/groups-members.ts +104 -0
  70. package/src/cli/commands/groups-show.ts +169 -0
  71. package/src/cli/commands/groups.ts +137 -0
  72. package/src/cli/commands/incoming.ts +226 -0
  73. package/src/cli/commands/init.ts +164 -0
  74. package/src/cli/commands/mine.ts +115 -0
  75. package/src/cli/commands/open.ts +57 -0
  76. package/src/cli/commands/projects.ts +68 -0
  77. package/src/cli/commands/push.ts +430 -0
  78. package/src/cli/commands/rebase.ts +52 -0
  79. package/src/cli/commands/remove-reviewer.ts +123 -0
  80. package/src/cli/commands/restore.ts +50 -0
  81. package/src/cli/commands/review.ts +486 -0
  82. package/src/cli/commands/search.ts +162 -0
  83. package/src/cli/commands/setup.ts +286 -0
  84. package/src/cli/commands/show.ts +491 -0
  85. package/src/cli/commands/status.ts +35 -0
  86. package/src/cli/commands/submit.ts +108 -0
  87. package/src/cli/commands/vote.ts +119 -0
  88. package/src/cli/commands/workspace.ts +200 -0
  89. package/src/cli/index.ts +53 -0
  90. package/src/cli/register-commands.ts +659 -0
  91. package/src/cli/register-group-commands.ts +88 -0
  92. package/src/cli/register-reviewer-commands.ts +97 -0
  93. package/src/prompts/default-review.md +86 -0
  94. package/src/prompts/system-inline-review.md +135 -0
  95. package/src/prompts/system-overall-review.md +206 -0
  96. package/src/schemas/config.test.ts +245 -0
  97. package/src/schemas/config.ts +84 -0
  98. package/src/schemas/gerrit.ts +681 -0
  99. package/src/services/commit-hook.ts +314 -0
  100. package/src/services/config.test.ts +150 -0
  101. package/src/services/config.ts +250 -0
  102. package/src/services/git-worktree.ts +342 -0
  103. package/src/services/review-strategy.ts +292 -0
  104. package/src/test-utils/mock-generator.ts +138 -0
  105. package/src/utils/change-id.test.ts +98 -0
  106. package/src/utils/change-id.ts +63 -0
  107. package/src/utils/comment-formatters.ts +153 -0
  108. package/src/utils/diff-context.ts +103 -0
  109. package/src/utils/diff-formatters.ts +141 -0
  110. package/src/utils/formatters.ts +85 -0
  111. package/src/utils/git-commit.test.ts +277 -0
  112. package/src/utils/git-commit.ts +122 -0
  113. package/src/utils/index.ts +55 -0
  114. package/src/utils/message-filters.ts +26 -0
  115. package/src/utils/review-formatters.ts +89 -0
  116. package/src/utils/review-prompt-builder.ts +110 -0
  117. package/src/utils/shell-safety.ts +117 -0
  118. package/src/utils/status-indicators.ts +100 -0
  119. package/src/utils/url-parser.test.ts +271 -0
  120. package/src/utils/url-parser.ts +118 -0
  121. package/tests/abandon.test.ts +230 -0
  122. package/tests/add-reviewer.test.ts +579 -0
  123. package/tests/build-status-watch.test.ts +344 -0
  124. package/tests/build-status.test.ts +789 -0
  125. package/tests/change-id-formats.test.ts +268 -0
  126. package/tests/checkout/integration.test.ts +653 -0
  127. package/tests/checkout/parse-input.test.ts +55 -0
  128. package/tests/checkout/validation.test.ts +178 -0
  129. package/tests/comment-batch-advanced.test.ts +431 -0
  130. package/tests/comment-gerrit-api-compliance.test.ts +414 -0
  131. package/tests/comment.test.ts +708 -0
  132. package/tests/comments.test.ts +323 -0
  133. package/tests/config-service-simple.test.ts +100 -0
  134. package/tests/diff.test.ts +419 -0
  135. package/tests/extract-url.test.ts +517 -0
  136. package/tests/groups-members.test.ts +256 -0
  137. package/tests/groups-show.test.ts +323 -0
  138. package/tests/groups.test.ts +334 -0
  139. package/tests/helpers/build-status-test-setup.ts +83 -0
  140. package/tests/helpers/config-mock.ts +27 -0
  141. package/tests/incoming.test.ts +357 -0
  142. package/tests/init.test.ts +70 -0
  143. package/tests/integration/commit-hook.test.ts +246 -0
  144. package/tests/interactive-incoming.test.ts +173 -0
  145. package/tests/mine.test.ts +285 -0
  146. package/tests/mocks/msw-handlers.ts +80 -0
  147. package/tests/open.test.ts +233 -0
  148. package/tests/projects.test.ts +259 -0
  149. package/tests/rebase.test.ts +271 -0
  150. package/tests/remove-reviewer.test.ts +357 -0
  151. package/tests/restore.test.ts +237 -0
  152. package/tests/review.test.ts +135 -0
  153. package/tests/search.test.ts +712 -0
  154. package/tests/setup.test.ts +63 -0
  155. package/tests/show-auto-detect.test.ts +324 -0
  156. package/tests/show.test.ts +813 -0
  157. package/tests/status.test.ts +145 -0
  158. package/tests/submit.test.ts +316 -0
  159. package/tests/unit/commands/push.test.ts +194 -0
  160. package/tests/unit/git-branch-detection.test.ts +82 -0
  161. package/tests/unit/git-worktree.test.ts +55 -0
  162. package/tests/unit/patterns/push-patterns.test.ts +148 -0
  163. package/tests/unit/schemas/gerrit.test.ts +85 -0
  164. package/tests/unit/services/commit-hook.test.ts +132 -0
  165. package/tests/unit/services/review-strategy.test.ts +349 -0
  166. package/tests/unit/test-utils/mock-generator.test.ts +154 -0
  167. package/tests/unit/utils/comment-formatters.test.ts +415 -0
  168. package/tests/unit/utils/diff-context.test.ts +171 -0
  169. package/tests/unit/utils/diff-formatters.test.ts +165 -0
  170. package/tests/unit/utils/formatters.test.ts +411 -0
  171. package/tests/unit/utils/message-filters.test.ts +227 -0
  172. package/tests/unit/utils/shell-safety.test.ts +230 -0
  173. package/tests/unit/utils/status-indicators.test.ts +137 -0
  174. package/tests/vote.test.ts +317 -0
  175. package/tests/workspace.test.ts +295 -0
  176. package/tsconfig.json +36 -5
  177. package/src/commands/branch.ts +0 -196
  178. package/src/ger.ts +0 -22
  179. package/src/types.d.ts +0 -35
  180. package/src/utils.ts +0 -130
@@ -0,0 +1,813 @@
1
+ import { describe, test, expect, beforeAll, afterAll, afterEach, mock } from 'bun:test'
2
+ import { setupServer } from 'msw/node'
3
+ import { http, HttpResponse } from 'msw'
4
+ import { Effect, Layer } from 'effect'
5
+ import { showCommand } from '@/cli/commands/show'
6
+ import { GerritApiServiceLive } from '@/api/gerrit'
7
+ import { ConfigService } from '@/services/config'
8
+ import { generateMockChange } from '@/test-utils/mock-generator'
9
+ import type { MessageInfo } from '@/schemas/gerrit'
10
+
11
+ import { createMockConfigService } from './helpers/config-mock'
12
+ const server = setupServer(
13
+ // Default handler for auth check
14
+ http.get('*/a/accounts/self', ({ request }) => {
15
+ const auth = request.headers.get('Authorization')
16
+ if (!auth || !auth.startsWith('Basic ')) {
17
+ return HttpResponse.text('Unauthorized', { status: 401 })
18
+ }
19
+ return HttpResponse.json({
20
+ _account_id: 1000,
21
+ name: 'Test User',
22
+ email: 'test@example.com',
23
+ })
24
+ }),
25
+ )
26
+
27
+ // Store captured output
28
+ let capturedLogs: string[] = []
29
+ let capturedErrors: string[] = []
30
+ let capturedStdout: string[] = []
31
+
32
+ // Mock console.log and console.error
33
+ const mockConsoleLog = mock((...args: any[]) => {
34
+ capturedLogs.push(args.join(' '))
35
+ })
36
+ const mockConsoleError = mock((...args: any[]) => {
37
+ capturedErrors.push(args.join(' '))
38
+ })
39
+
40
+ // Mock process.stdout.write to capture JSON output and handle callbacks
41
+ const mockStdoutWrite = mock((chunk: any, callback?: any) => {
42
+ capturedStdout.push(String(chunk))
43
+ // Call the callback synchronously if provided
44
+ if (typeof callback === 'function') {
45
+ callback()
46
+ }
47
+ return true
48
+ })
49
+
50
+ // Store original methods
51
+ const originalConsoleLog = console.log
52
+ const originalConsoleError = console.error
53
+ const originalStdoutWrite = process.stdout.write
54
+
55
+ beforeAll(() => {
56
+ server.listen({ onUnhandledRequest: 'bypass' })
57
+ // @ts-ignore
58
+ console.log = mockConsoleLog
59
+ // @ts-ignore
60
+ console.error = mockConsoleError
61
+ // @ts-ignore
62
+ process.stdout.write = mockStdoutWrite
63
+ })
64
+
65
+ afterAll(() => {
66
+ server.close()
67
+ console.log = originalConsoleLog
68
+ console.error = originalConsoleError
69
+ // @ts-ignore
70
+ process.stdout.write = originalStdoutWrite
71
+ })
72
+
73
+ afterEach(() => {
74
+ server.resetHandlers()
75
+ mockConsoleLog.mockClear()
76
+ mockConsoleError.mockClear()
77
+ mockStdoutWrite.mockClear()
78
+ capturedLogs = []
79
+ capturedErrors = []
80
+ capturedStdout = []
81
+ })
82
+
83
+ describe('show command', () => {
84
+ const mockChange = generateMockChange({
85
+ _number: 12345,
86
+ change_id: 'I123abc456def',
87
+ subject: 'Fix authentication bug',
88
+ status: 'NEW',
89
+ project: 'test-project',
90
+ branch: 'main',
91
+ created: '2024-01-15 10:00:00.000000000',
92
+ updated: '2024-01-15 12:00:00.000000000',
93
+ owner: {
94
+ _account_id: 1001,
95
+ name: 'John Doe',
96
+ email: 'john@example.com',
97
+ },
98
+ })
99
+
100
+ const mockDiff = `--- a/src/auth.js
101
+ +++ b/src/auth.js
102
+ @@ -10,7 +10,8 @@ function authenticate(user) {
103
+ if (!user) {
104
+ - return false
105
+ + throw new Error('User required')
106
+ }
107
+ + // Added validation
108
+ return validateUser(user)
109
+ }`
110
+
111
+ const mockComments = {
112
+ 'src/auth.js': [
113
+ {
114
+ id: 'comment1',
115
+ path: 'src/auth.js',
116
+ line: 12,
117
+ message: 'Good improvement!',
118
+ author: {
119
+ name: 'Jane Reviewer',
120
+ email: 'jane@example.com',
121
+ },
122
+ updated: '2024-01-15 11:30:00.000000000',
123
+ unresolved: false,
124
+ },
125
+ {
126
+ id: 'comment2',
127
+ path: 'src/auth.js',
128
+ line: 14,
129
+ message: 'Consider adding JSDoc',
130
+ author: {
131
+ name: 'Bob Reviewer',
132
+ email: 'bob@example.com',
133
+ },
134
+ updated: '2024-01-15 11:45:00.000000000',
135
+ unresolved: true,
136
+ },
137
+ ],
138
+ '/COMMIT_MSG': [
139
+ {
140
+ id: 'comment3',
141
+ path: '/COMMIT_MSG',
142
+ line: 1,
143
+ message: 'Clear commit message',
144
+ author: {
145
+ name: 'Alice Lead',
146
+ email: 'alice@example.com',
147
+ },
148
+ updated: '2024-01-15 11:00:00.000000000',
149
+ unresolved: false,
150
+ },
151
+ ],
152
+ }
153
+
154
+ const setupMockHandlers = () => {
155
+ server.use(
156
+ // Get change details
157
+ http.get('*/a/changes/:changeId', () => {
158
+ return HttpResponse.text(`)]}'\n${JSON.stringify(mockChange)}`)
159
+ }),
160
+ // Get diff (returns base64-encoded content)
161
+ http.get('*/a/changes/:changeId/revisions/current/patch', () => {
162
+ return HttpResponse.text(btoa(mockDiff))
163
+ }),
164
+ // Get comments
165
+ http.get('*/a/changes/:changeId/revisions/current/comments', () => {
166
+ return HttpResponse.text(`)]}'\n${JSON.stringify(mockComments)}`)
167
+ }),
168
+ // Get file diff for context (optional, may fail gracefully)
169
+ http.get('*/a/changes/:changeId/revisions/current/files/:fileName/diff', () => {
170
+ return HttpResponse.text(mockDiff)
171
+ }),
172
+ )
173
+ }
174
+
175
+ const createMockConfigLayer = () => Layer.succeed(ConfigService, createMockConfigService())
176
+
177
+ test('should display comprehensive change information in pretty format', async () => {
178
+ setupMockHandlers()
179
+
180
+ const mockConfigLayer = createMockConfigLayer()
181
+ const program = showCommand('12345', {}).pipe(
182
+ Effect.provide(GerritApiServiceLive),
183
+ Effect.provide(mockConfigLayer),
184
+ )
185
+
186
+ await Effect.runPromise(program)
187
+
188
+ const output = capturedLogs.join('\n')
189
+
190
+ // Check that all sections are present
191
+ expect(output).toContain('📋 Change 12345: Fix authentication bug')
192
+ expect(output).toContain('📝 Details:')
193
+ expect(output).toContain('Project: test-project')
194
+ expect(output).toContain('Branch: main')
195
+ expect(output).toContain('Status: NEW')
196
+ expect(output).toContain('Owner: John Doe')
197
+ expect(output).toContain('Change-Id: I123abc456def')
198
+ expect(output).toContain('🔍 Diff:')
199
+ expect(output).toContain('💬 Inline Comments:')
200
+
201
+ // Check diff content is included
202
+ expect(output).toContain('src/auth.js')
203
+ expect(output).toContain('authenticate(user)')
204
+
205
+ // Check comments are included
206
+ expect(output).toContain('Good improvement!')
207
+ expect(output).toContain('Consider adding JSDoc')
208
+ expect(output).toContain('Clear commit message')
209
+ })
210
+
211
+ test('should output XML format when --xml flag is used', async () => {
212
+ setupMockHandlers()
213
+
214
+ const mockConfigLayer = createMockConfigLayer()
215
+ const program = showCommand('12345', { xml: true }).pipe(
216
+ Effect.provide(GerritApiServiceLive),
217
+ Effect.provide(mockConfigLayer),
218
+ )
219
+
220
+ await Effect.runPromise(program)
221
+
222
+ const output = capturedStdout.join('')
223
+
224
+ expect(output).toContain('<?xml version="1.0" encoding="UTF-8"?>')
225
+ expect(output).toContain('<show_result>')
226
+ expect(output).toContain('<status>success</status>')
227
+ expect(output).toContain('<change>')
228
+ expect(output).toContain('<id>I123abc456def</id>')
229
+ expect(output).toContain('<number>12345</number>')
230
+ expect(output).toContain('<subject><![CDATA[Fix authentication bug]]></subject>')
231
+ expect(output).toContain('<status>NEW</status>')
232
+ expect(output).toContain('<project>test-project</project>')
233
+ expect(output).toContain('<branch>main</branch>')
234
+ expect(output).toContain('<owner>')
235
+ expect(output).toContain('<name><![CDATA[John Doe]]></name>')
236
+ expect(output).toContain('<email>john@example.com</email>')
237
+ expect(output).toContain('<diff><![CDATA[')
238
+ expect(output).toContain('<comments>')
239
+ expect(output).toContain('<count>3</count>')
240
+ expect(output).toContain('</show_result>')
241
+ })
242
+
243
+ test('should handle API errors gracefully in pretty format', async () => {
244
+ server.use(
245
+ http.get('*/a/changes/:changeId', () => {
246
+ return HttpResponse.json({ error: 'Change not found' }, { status: 404 })
247
+ }),
248
+ )
249
+
250
+ const mockConfigLayer = createMockConfigLayer()
251
+ const program = showCommand('12345', {}).pipe(
252
+ Effect.provide(GerritApiServiceLive),
253
+ Effect.provide(mockConfigLayer),
254
+ )
255
+
256
+ await Effect.runPromise(program)
257
+
258
+ const output = capturedErrors.join('\n')
259
+ expect(output).toContain('✗ Error:')
260
+ // The error message will be from the network layer
261
+ expect(output.length).toBeGreaterThan(0)
262
+ })
263
+
264
+ test('should handle API errors gracefully in XML format', async () => {
265
+ server.use(
266
+ http.get('*/a/changes/:changeId', () => {
267
+ return HttpResponse.json({ error: 'Change not found' }, { status: 404 })
268
+ }),
269
+ )
270
+
271
+ const mockConfigLayer = createMockConfigLayer()
272
+ const program = showCommand('12345', { xml: true }).pipe(
273
+ Effect.provide(GerritApiServiceLive),
274
+ Effect.provide(mockConfigLayer),
275
+ )
276
+
277
+ await Effect.runPromise(program)
278
+
279
+ const output = capturedStdout.join('')
280
+
281
+ expect(output).toContain('<?xml version="1.0" encoding="UTF-8"?>')
282
+ expect(output).toContain('<show_result>')
283
+ expect(output).toContain('<status>error</status>')
284
+ expect(output).toContain('<error><![CDATA[')
285
+ expect(output).toContain('</show_result>')
286
+ })
287
+
288
+ test('should properly escape XML special characters', async () => {
289
+ const changeWithSpecialChars = generateMockChange({
290
+ _number: 12345,
291
+ change_id: 'I123abc456def',
292
+ subject: 'Fix "quotes" & <tags> in auth',
293
+ project: 'test-project',
294
+ branch: 'feature/fix&improve',
295
+ owner: {
296
+ _account_id: 1002,
297
+ name: 'User <with> & "special" chars',
298
+ email: 'user@example.com',
299
+ },
300
+ })
301
+
302
+ server.use(
303
+ http.get('*/a/changes/:changeId', () => {
304
+ return HttpResponse.text(`)]}'\n${JSON.stringify(changeWithSpecialChars)}`)
305
+ }),
306
+ http.get('*/a/changes/:changeId/revisions/current/patch', () => {
307
+ return HttpResponse.text('diff content')
308
+ }),
309
+ http.get('*/a/changes/:changeId/revisions/current/comments', () => {
310
+ return HttpResponse.text(`)]}'\n{}`)
311
+ }),
312
+ )
313
+
314
+ const mockConfigLayer = createMockConfigLayer()
315
+ const program = showCommand('12345', { xml: true }).pipe(
316
+ Effect.provide(GerritApiServiceLive),
317
+ Effect.provide(mockConfigLayer),
318
+ )
319
+
320
+ await Effect.runPromise(program)
321
+
322
+ const output = capturedStdout.join('')
323
+
324
+ expect(output).toContain('<subject><![CDATA[Fix "quotes" & <tags> in auth]]></subject>')
325
+ expect(output).toContain('<branch>feature/fix&amp;improve</branch>')
326
+ expect(output).toContain('<name><![CDATA[User <with> & "special" chars]]></name>')
327
+ })
328
+
329
+ test('should handle mixed file and commit message comments', async () => {
330
+ setupMockHandlers()
331
+
332
+ const mockConfigLayer = createMockConfigLayer()
333
+ const program = showCommand('12345', {}).pipe(
334
+ Effect.provide(GerritApiServiceLive),
335
+ Effect.provide(mockConfigLayer),
336
+ )
337
+
338
+ await Effect.runPromise(program)
339
+
340
+ const output = capturedLogs.join('\n')
341
+
342
+ // Should show comments from both files and commit message
343
+ expect(output).toContain('Good improvement!')
344
+ expect(output).toContain('Consider adding JSDoc')
345
+ expect(output).toContain('Clear commit message')
346
+
347
+ // Commit message path should be renamed
348
+ expect(output).toContain('Commit Message')
349
+ expect(output).not.toContain('/COMMIT_MSG')
350
+ })
351
+
352
+ test('should handle changes with missing optional fields', async () => {
353
+ const minimalChange = generateMockChange({
354
+ _number: 12345,
355
+ change_id: 'I123abc456def',
356
+ subject: 'Minimal change',
357
+ status: 'NEW',
358
+ project: 'test-project',
359
+ branch: 'main',
360
+ owner: {
361
+ _account_id: 1003,
362
+ email: 'user@example.com',
363
+ },
364
+ })
365
+
366
+ server.use(
367
+ http.get('*/a/changes/:changeId', () => {
368
+ return HttpResponse.text(`)]}'\n${JSON.stringify(minimalChange)}`)
369
+ }),
370
+ http.get('*/a/changes/:changeId/revisions/current/patch', () => {
371
+ return HttpResponse.text('minimal diff')
372
+ }),
373
+ http.get('*/a/changes/:changeId/revisions/current/comments', () => {
374
+ return HttpResponse.text(`)]}'\n{}`)
375
+ }),
376
+ )
377
+
378
+ const mockConfigLayer = createMockConfigLayer()
379
+ const program = showCommand('12345', {}).pipe(
380
+ Effect.provide(GerritApiServiceLive),
381
+ Effect.provide(mockConfigLayer),
382
+ )
383
+
384
+ await Effect.runPromise(program)
385
+
386
+ const output = capturedLogs.join('\n')
387
+
388
+ expect(output).toContain('📋 Change 12345: Minimal change')
389
+ expect(output).toContain('Owner: user@example.com') // Should fallback to email
390
+ })
391
+
392
+ test('should display review activity messages', async () => {
393
+ const mockChange = generateMockChange({
394
+ _number: 12345,
395
+ subject: 'Fix authentication bug',
396
+ })
397
+
398
+ const mockMessages: MessageInfo[] = [
399
+ {
400
+ id: 'msg1',
401
+ message: 'Patch Set 2: Code-Review+2',
402
+ author: { _account_id: 1001, name: 'Jane Reviewer' },
403
+ date: '2024-01-15 11:30:00.000000000',
404
+ _revision_number: 2,
405
+ },
406
+ {
407
+ id: 'msg2',
408
+ message: 'Patch Set 2: Verified+1\\n\\nBuild Successful',
409
+ author: { _account_id: 1002, name: 'Jenkins Bot' },
410
+ date: '2024-01-15 11:31:00.000000000',
411
+ _revision_number: 2,
412
+ },
413
+ {
414
+ id: 'msg3',
415
+ message: 'Uploaded patch set 1.',
416
+ author: { _account_id: 1000, name: 'Author' },
417
+ date: '2024-01-15 11:29:00.000000000',
418
+ tag: 'autogenerated:gerrit:newPatchSet',
419
+ _revision_number: 1,
420
+ },
421
+ ]
422
+
423
+ server.use(
424
+ http.get('*/a/changes/:changeId', ({ request }) => {
425
+ const url = new URL(request.url)
426
+ if (url.searchParams.get('o') === 'MESSAGES') {
427
+ return HttpResponse.text(`)]}'\n${JSON.stringify({ messages: mockMessages })}`)
428
+ }
429
+ return HttpResponse.text(`)]}'\n${JSON.stringify(mockChange)}`)
430
+ }),
431
+ http.get('*/a/changes/:changeId/revisions/current/patch', () => {
432
+ return HttpResponse.text('diff content')
433
+ }),
434
+ http.get('*/a/changes/:changeId/revisions/current/comments', () => {
435
+ return HttpResponse.text(`)]}'\n{}`)
436
+ }),
437
+ )
438
+
439
+ const mockConfigLayer = createMockConfigLayer()
440
+ const program = showCommand('12345', {}).pipe(
441
+ Effect.provide(GerritApiServiceLive),
442
+ Effect.provide(mockConfigLayer),
443
+ )
444
+
445
+ await Effect.runPromise(program)
446
+
447
+ const output = capturedLogs.join('\n')
448
+
449
+ // Should display review activity section
450
+ expect(output).toContain('📝 Review Activity:')
451
+ expect(output).toContain('Jane Reviewer')
452
+ expect(output).toContain('Code-Review+2')
453
+ expect(output).toContain('Jenkins Bot')
454
+ expect(output).toContain('Build Successful')
455
+
456
+ // Should filter out autogenerated messages
457
+ expect(output).not.toContain('Uploaded patch set')
458
+ })
459
+
460
+ test('should output JSON format when --json flag is used', async () => {
461
+ setupMockHandlers()
462
+
463
+ const mockConfigLayer = createMockConfigLayer()
464
+ const program = showCommand('12345', { json: true }).pipe(
465
+ Effect.provide(GerritApiServiceLive),
466
+ Effect.provide(mockConfigLayer),
467
+ )
468
+
469
+ await Effect.runPromise(program)
470
+
471
+ const output = capturedStdout.join('')
472
+
473
+ // Parse JSON to verify it's valid
474
+ const parsed = JSON.parse(output)
475
+
476
+ expect(parsed.status).toBe('success')
477
+ expect(parsed.change.id).toBe('I123abc456def')
478
+ expect(parsed.change.number).toBe(12345)
479
+ expect(parsed.change.subject).toBe('Fix authentication bug')
480
+ expect(parsed.change.status).toBe('NEW')
481
+ expect(parsed.change.project).toBe('test-project')
482
+ expect(parsed.change.branch).toBe('main')
483
+ expect(parsed.change.owner.name).toBe('John Doe')
484
+ expect(parsed.change.owner.email).toBe('john@example.com')
485
+
486
+ // Check diff is present
487
+ expect(parsed.diff).toContain('src/auth.js')
488
+ expect(parsed.diff).toContain('authenticate(user)')
489
+
490
+ // Check comments array
491
+ expect(Array.isArray(parsed.comments)).toBe(true)
492
+ expect(parsed.comments.length).toBe(3)
493
+ expect(parsed.comments[0].message).toContain('Clear commit message')
494
+ expect(parsed.comments[1].message).toBe('Good improvement!')
495
+ expect(parsed.comments[2].message).toBe('Consider adding JSDoc')
496
+
497
+ // Check messages array (should be empty for this test)
498
+ expect(Array.isArray(parsed.messages)).toBe(true)
499
+ })
500
+
501
+ test('should handle API errors gracefully in JSON format', async () => {
502
+ server.use(
503
+ http.get('*/a/changes/:changeId', () => {
504
+ return HttpResponse.json({ error: 'Change not found' }, { status: 404 })
505
+ }),
506
+ )
507
+
508
+ const mockConfigLayer = createMockConfigLayer()
509
+ const program = showCommand('12345', { json: true }).pipe(
510
+ Effect.provide(GerritApiServiceLive),
511
+ Effect.provide(mockConfigLayer),
512
+ )
513
+
514
+ await Effect.runPromise(program)
515
+
516
+ const output = capturedStdout.join('')
517
+
518
+ // Parse JSON to verify it's valid
519
+ const parsed = JSON.parse(output)
520
+
521
+ expect(parsed.status).toBe('error')
522
+ expect(parsed.error).toBeDefined()
523
+ expect(typeof parsed.error).toBe('string')
524
+ })
525
+
526
+ test('should sort comments by date in ascending order in XML output', async () => {
527
+ setupMockHandlers()
528
+
529
+ const mockConfigLayer = createMockConfigLayer()
530
+ const program = showCommand('12345', { xml: true }).pipe(
531
+ Effect.provide(GerritApiServiceLive),
532
+ Effect.provide(mockConfigLayer),
533
+ )
534
+
535
+ await Effect.runPromise(program)
536
+
537
+ const output = capturedStdout.join('')
538
+
539
+ // Extract comment sections to verify order
540
+ const commentMatches = output.matchAll(
541
+ /<comment>[\s\S]*?<updated>(.*?)<\/updated>[\s\S]*?<message><!\[CDATA\[(.*?)\]\]><\/message>[\s\S]*?<\/comment>/g,
542
+ )
543
+ const comments = Array.from(commentMatches).map((match) => ({
544
+ updated: match[1],
545
+ message: match[2],
546
+ }))
547
+
548
+ // Should have 3 comments
549
+ expect(comments.length).toBe(3)
550
+
551
+ // Comments should be in ascending date order (oldest first)
552
+ expect(comments[0].updated).toBe('2024-01-15 11:00:00.000000000')
553
+ expect(comments[0].message).toBe('Clear commit message')
554
+
555
+ expect(comments[1].updated).toBe('2024-01-15 11:30:00.000000000')
556
+ expect(comments[1].message).toBe('Good improvement!')
557
+
558
+ expect(comments[2].updated).toBe('2024-01-15 11:45:00.000000000')
559
+ expect(comments[2].message).toBe('Consider adding JSDoc')
560
+ })
561
+
562
+ test('should include messages in JSON output', async () => {
563
+ const mockChange = generateMockChange({
564
+ _number: 12345,
565
+ subject: 'Fix authentication bug',
566
+ })
567
+
568
+ const mockMessages: MessageInfo[] = [
569
+ {
570
+ id: 'msg1',
571
+ message: 'Patch Set 2: Verified-1\\n\\nBuild Failed https://jenkins.example.com/job/123',
572
+ author: { _account_id: 1001, name: 'Jenkins Bot' },
573
+ date: '2024-01-15 11:30:00.000000000',
574
+ _revision_number: 2,
575
+ },
576
+ ]
577
+
578
+ server.use(
579
+ http.get('*/a/changes/:changeId', ({ request }) => {
580
+ const url = new URL(request.url)
581
+ if (url.searchParams.get('o') === 'MESSAGES') {
582
+ return HttpResponse.text(`)]}'\n${JSON.stringify({ messages: mockMessages })}`)
583
+ }
584
+ return HttpResponse.text(`)]}'\n${JSON.stringify(mockChange)}`)
585
+ }),
586
+ http.get('*/a/changes/:changeId/revisions/current/patch', () => {
587
+ return HttpResponse.text('diff content')
588
+ }),
589
+ http.get('*/a/changes/:changeId/revisions/current/comments', () => {
590
+ return HttpResponse.text(`)]}'\n{}`)
591
+ }),
592
+ )
593
+
594
+ const mockConfigLayer = createMockConfigLayer()
595
+ const program = showCommand('12345', { json: true }).pipe(
596
+ Effect.provide(GerritApiServiceLive),
597
+ Effect.provide(mockConfigLayer),
598
+ )
599
+
600
+ await Effect.runPromise(program)
601
+
602
+ const output = capturedStdout.join('')
603
+ const parsed = JSON.parse(output)
604
+
605
+ expect(parsed.messages).toBeDefined()
606
+ expect(Array.isArray(parsed.messages)).toBe(true)
607
+ expect(parsed.messages.length).toBe(1)
608
+ expect(parsed.messages[0].message).toContain('Build Failed')
609
+ expect(parsed.messages[0].message).toContain('https://jenkins.example.com')
610
+ expect(parsed.messages[0].author.name).toBe('Jenkins Bot')
611
+ expect(parsed.messages[0].revision).toBe(2)
612
+ })
613
+
614
+ test('should handle large JSON output without truncation', async () => {
615
+ // Create a large diff to simulate output > 64KB
616
+ const largeDiff = '--- a/large-file.js\n+++ b/large-file.js\n' + 'x'.repeat(100000)
617
+
618
+ const mockChange = generateMockChange({
619
+ _number: 12345,
620
+ subject: 'Large change with extensive diff',
621
+ })
622
+
623
+ // Create many comments to increase JSON size
624
+ const manyComments: Record<string, any[]> = {
625
+ 'src/file.js': Array.from({ length: 100 }, (_, i) => ({
626
+ id: `comment${i}`,
627
+ path: 'src/file.js',
628
+ line: i + 1,
629
+ message: `Comment ${i}: ${'a'.repeat(500)}`, // Make comments substantial
630
+ author: {
631
+ name: 'Reviewer',
632
+ email: 'reviewer@example.com',
633
+ },
634
+ updated: '2024-01-15 11:30:00.000000000',
635
+ unresolved: false,
636
+ })),
637
+ }
638
+
639
+ server.use(
640
+ http.get('*/a/changes/:changeId', () => {
641
+ return HttpResponse.text(`)]}'\n${JSON.stringify(mockChange)}`)
642
+ }),
643
+ http.get('*/a/changes/:changeId/revisions/current/patch', () => {
644
+ return HttpResponse.text(btoa(largeDiff))
645
+ }),
646
+ http.get('*/a/changes/:changeId/revisions/current/comments', () => {
647
+ return HttpResponse.text(`)]}'\n${JSON.stringify(manyComments)}`)
648
+ }),
649
+ http.get('*/a/changes/:changeId/revisions/current/files/:fileName/diff', () => {
650
+ return HttpResponse.text('context')
651
+ }),
652
+ )
653
+
654
+ const mockConfigLayer = createMockConfigLayer()
655
+ const program = showCommand('12345', { json: true }).pipe(
656
+ Effect.provide(GerritApiServiceLive),
657
+ Effect.provide(mockConfigLayer),
658
+ )
659
+
660
+ await Effect.runPromise(program)
661
+
662
+ const output = capturedStdout.join('')
663
+
664
+ // Verify output is larger than 64KB (the previous truncation point)
665
+ expect(output.length).toBeGreaterThan(65536)
666
+
667
+ // Verify JSON is valid and complete
668
+ const parsed = JSON.parse(output)
669
+ expect(parsed.status).toBe('success')
670
+ expect(parsed.diff).toContain('x'.repeat(100000))
671
+ expect(parsed.comments.length).toBe(100)
672
+
673
+ // Verify last comment is present (proves no truncation)
674
+ const lastComment = parsed.comments[parsed.comments.length - 1]
675
+ expect(lastComment.message).toContain('Comment 99')
676
+ })
677
+
678
+ test('should handle stdout drain event when buffer is full', async () => {
679
+ setupMockHandlers()
680
+
681
+ // Store original stdout.write
682
+ const originalStdoutWrite = process.stdout.write
683
+
684
+ let drainCallback: (() => void) | null = null
685
+ let errorCallback: ((err: Error) => void) | null = null
686
+ let writeCallbackFn: ((err?: Error) => void) | null = null
687
+
688
+ // Mock stdout.write to simulate full buffer
689
+ const mockWrite = mock((chunk: any, callback?: any) => {
690
+ capturedStdout.push(String(chunk))
691
+ writeCallbackFn = callback
692
+ // Return false to simulate full buffer
693
+ return false
694
+ })
695
+
696
+ // Mock stdout.once to capture drain and error listeners
697
+ const mockOnce = mock((event: string, callback: any) => {
698
+ if (event === 'drain') {
699
+ drainCallback = callback
700
+ // Simulate drain event after a short delay
701
+ setTimeout(() => {
702
+ if (drainCallback) {
703
+ drainCallback()
704
+ if (writeCallbackFn) {
705
+ writeCallbackFn()
706
+ }
707
+ }
708
+ }, 10)
709
+ } else if (event === 'error') {
710
+ errorCallback = callback
711
+ }
712
+ return process.stdout
713
+ })
714
+
715
+ // Apply mocks
716
+ // @ts-ignore
717
+ process.stdout.write = mockWrite
718
+ // @ts-ignore
719
+ process.stdout.once = mockOnce
720
+
721
+ const mockConfigLayer = createMockConfigLayer()
722
+ const program = showCommand('12345', { json: true }).pipe(
723
+ Effect.provide(GerritApiServiceLive),
724
+ Effect.provide(mockConfigLayer),
725
+ )
726
+
727
+ await Effect.runPromise(program)
728
+
729
+ // Restore original stdout.write
730
+ // @ts-ignore
731
+ process.stdout.write = originalStdoutWrite
732
+
733
+ // Verify that write returned false (buffer full)
734
+ expect(mockWrite).toHaveBeenCalled()
735
+
736
+ // Verify that drain listener was registered
737
+ expect(mockOnce).toHaveBeenCalledWith('drain', expect.any(Function))
738
+
739
+ // Verify that error listener was registered for robustness
740
+ expect(mockOnce).toHaveBeenCalledWith('error', expect.any(Function))
741
+
742
+ // Verify output is still valid JSON despite drain handling
743
+ const output = capturedStdout.join('')
744
+ const parsed = JSON.parse(output)
745
+ expect(parsed.status).toBe('success')
746
+ expect(parsed.change.id).toBe('I123abc456def')
747
+ })
748
+
749
+ test('should handle large XML output without truncation', async () => {
750
+ // Create a large diff to simulate output > 64KB
751
+ const largeDiff = '--- a/large-file.js\n+++ b/large-file.js\n' + 'x'.repeat(100000)
752
+
753
+ const mockChange = generateMockChange({
754
+ _number: 12345,
755
+ subject: 'Large change with extensive diff',
756
+ })
757
+
758
+ // Create many comments to increase XML size
759
+ const manyComments: Record<string, any[]> = {
760
+ 'src/file.js': Array.from({ length: 100 }, (_, i) => ({
761
+ id: `comment${i}`,
762
+ path: 'src/file.js',
763
+ line: i + 1,
764
+ message: `Comment ${i}: ${'a'.repeat(500)}`,
765
+ author: {
766
+ name: 'Reviewer',
767
+ email: 'reviewer@example.com',
768
+ },
769
+ updated: '2024-01-15 11:30:00.000000000',
770
+ unresolved: false,
771
+ })),
772
+ }
773
+
774
+ server.use(
775
+ http.get('*/a/changes/:changeId', () => {
776
+ return HttpResponse.text(`)]}'\n${JSON.stringify(mockChange)}`)
777
+ }),
778
+ http.get('*/a/changes/:changeId/revisions/current/patch', () => {
779
+ return HttpResponse.text(btoa(largeDiff))
780
+ }),
781
+ http.get('*/a/changes/:changeId/revisions/current/comments', () => {
782
+ return HttpResponse.text(`)]}'\n${JSON.stringify(manyComments)}`)
783
+ }),
784
+ http.get('*/a/changes/:changeId/revisions/current/files/:fileName/diff', () => {
785
+ return HttpResponse.text('context')
786
+ }),
787
+ )
788
+
789
+ const mockConfigLayer = createMockConfigLayer()
790
+ const program = showCommand('12345', { xml: true }).pipe(
791
+ Effect.provide(GerritApiServiceLive),
792
+ Effect.provide(mockConfigLayer),
793
+ )
794
+
795
+ await Effect.runPromise(program)
796
+
797
+ const output = capturedStdout.join('')
798
+
799
+ // Verify output is larger than 64KB
800
+ expect(output.length).toBeGreaterThan(65536)
801
+
802
+ // Verify XML is valid and complete
803
+ expect(output).toContain('<?xml version="1.0" encoding="UTF-8"?>')
804
+ expect(output).toContain('<show_result>')
805
+ expect(output).toContain('<status>success</status>')
806
+ expect(output).toContain('x'.repeat(100000))
807
+ expect(output).toContain('<count>100</count>')
808
+ expect(output).toContain('</show_result>')
809
+
810
+ // Verify last comment is present (proves no truncation)
811
+ expect(output).toContain('Comment 99')
812
+ })
813
+ })