@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,579 @@
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 { addReviewerCommand } from '@/cli/commands/add-reviewer'
7
+ import { ConfigService } from '@/services/config'
8
+ import { createMockConfigService } from './helpers/config-mock'
9
+
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('add-reviewer command', () => {
27
+ let mockConsoleLog: ReturnType<typeof mock>
28
+ let mockConsoleError: ReturnType<typeof mock>
29
+
30
+ beforeAll(() => {
31
+ server.listen({ onUnhandledRequest: 'bypass' })
32
+ })
33
+
34
+ afterAll(() => {
35
+ server.close()
36
+ })
37
+
38
+ beforeEach(() => {
39
+ mockConsoleLog = mock(() => {})
40
+ mockConsoleError = mock(() => {})
41
+ console.log = mockConsoleLog
42
+ console.error = mockConsoleError
43
+ })
44
+
45
+ afterEach(() => {
46
+ server.resetHandlers()
47
+ })
48
+
49
+ it('should add a single reviewer successfully', async () => {
50
+ server.use(
51
+ http.post('*/a/changes/12345/reviewers', async ({ request }) => {
52
+ const body = (await request.json()) as { reviewer: string; state?: string }
53
+ expect(body.reviewer).toBe('reviewer@example.com')
54
+ expect(body.state).toBe('REVIEWER')
55
+ return HttpResponse.text(
56
+ `)]}'\n${JSON.stringify({
57
+ input: 'reviewer@example.com',
58
+ reviewers: [
59
+ {
60
+ _account_id: 2000,
61
+ name: 'Reviewer User',
62
+ email: 'reviewer@example.com',
63
+ },
64
+ ],
65
+ })}`,
66
+ )
67
+ }),
68
+ )
69
+
70
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
71
+ const program = addReviewerCommand(['reviewer@example.com'], {
72
+ change: '12345',
73
+ }).pipe(Effect.provide(GerritApiServiceLive), Effect.provide(mockConfigLayer))
74
+
75
+ await Effect.runPromise(program)
76
+
77
+ const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
78
+ expect(output).toContain('Added Reviewer User as reviewer')
79
+ })
80
+
81
+ it('should add multiple reviewers successfully', async () => {
82
+ let callCount = 0
83
+ server.use(
84
+ http.post('*/a/changes/12345/reviewers', async ({ request }) => {
85
+ const body = (await request.json()) as { reviewer: string }
86
+ callCount++
87
+ const reviewerName = callCount === 1 ? 'User One' : 'User Two'
88
+ return HttpResponse.text(
89
+ `)]}'\n${JSON.stringify({
90
+ input: body.reviewer,
91
+ reviewers: [
92
+ {
93
+ _account_id: 2000 + callCount,
94
+ name: reviewerName,
95
+ email: body.reviewer,
96
+ },
97
+ ],
98
+ })}`,
99
+ )
100
+ }),
101
+ )
102
+
103
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
104
+ const program = addReviewerCommand(['user1@example.com', 'user2@example.com'], {
105
+ change: '12345',
106
+ }).pipe(Effect.provide(GerritApiServiceLive), Effect.provide(mockConfigLayer))
107
+
108
+ await Effect.runPromise(program)
109
+
110
+ const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
111
+ expect(output).toContain('Added User One as reviewer')
112
+ expect(output).toContain('Added User Two as reviewer')
113
+ expect(callCount).toBe(2)
114
+ })
115
+
116
+ it('should add as CC when --cc flag is used', async () => {
117
+ server.use(
118
+ http.post('*/a/changes/12345/reviewers', async ({ request }) => {
119
+ const body = (await request.json()) as { reviewer: string; state?: string }
120
+ expect(body.reviewer).toBe('cc@example.com')
121
+ expect(body.state).toBe('CC')
122
+ return HttpResponse.text(
123
+ `)]}'\n${JSON.stringify({
124
+ input: 'cc@example.com',
125
+ ccs: [
126
+ {
127
+ _account_id: 2000,
128
+ name: 'CC User',
129
+ email: 'cc@example.com',
130
+ },
131
+ ],
132
+ })}`,
133
+ )
134
+ }),
135
+ )
136
+
137
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
138
+ const program = addReviewerCommand(['cc@example.com'], {
139
+ change: '12345',
140
+ cc: true,
141
+ }).pipe(Effect.provide(GerritApiServiceLive), Effect.provide(mockConfigLayer))
142
+
143
+ await Effect.runPromise(program)
144
+
145
+ const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
146
+ expect(output).toContain('Added CC User as cc')
147
+ })
148
+
149
+ it('should pass notify option to API', async () => {
150
+ server.use(
151
+ http.post('*/a/changes/12345/reviewers', async ({ request }) => {
152
+ const body = (await request.json()) as { reviewer: string; notify?: string }
153
+ expect(body.notify).toBe('NONE')
154
+ return HttpResponse.text(
155
+ `)]}'\n${JSON.stringify({
156
+ input: 'reviewer@example.com',
157
+ reviewers: [
158
+ {
159
+ _account_id: 2000,
160
+ name: 'Reviewer',
161
+ email: 'reviewer@example.com',
162
+ },
163
+ ],
164
+ })}`,
165
+ )
166
+ }),
167
+ )
168
+
169
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
170
+ const program = addReviewerCommand(['reviewer@example.com'], {
171
+ change: '12345',
172
+ notify: 'none',
173
+ }).pipe(Effect.provide(GerritApiServiceLive), Effect.provide(mockConfigLayer))
174
+
175
+ await Effect.runPromise(program)
176
+
177
+ const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
178
+ expect(output).toContain('Added Reviewer as reviewer')
179
+ })
180
+
181
+ it('should handle API error in result', async () => {
182
+ server.use(
183
+ http.post('*/a/changes/12345/reviewers', async () => {
184
+ return HttpResponse.text(
185
+ `)]}'\n${JSON.stringify({
186
+ input: 'nonexistent@example.com',
187
+ error: 'Account not found: nonexistent@example.com',
188
+ })}`,
189
+ )
190
+ }),
191
+ )
192
+
193
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
194
+ const program = addReviewerCommand(['nonexistent@example.com'], {
195
+ change: '12345',
196
+ }).pipe(Effect.provide(GerritApiServiceLive), Effect.provide(mockConfigLayer))
197
+
198
+ await Effect.runPromise(program)
199
+
200
+ const errorOutput = mockConsoleError.mock.calls.map((call) => call[0]).join('\n')
201
+ expect(errorOutput).toContain('Failed to add nonexistent@example.com')
202
+ expect(errorOutput).toContain('Account not found')
203
+ })
204
+
205
+ it('should show error when change ID is not provided', async () => {
206
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
207
+ const program = addReviewerCommand(['reviewer@example.com'], {}).pipe(
208
+ Effect.provide(GerritApiServiceLive),
209
+ Effect.provide(mockConfigLayer),
210
+ )
211
+
212
+ const result = await Effect.runPromiseExit(program)
213
+ expect(result._tag).toBe('Failure')
214
+
215
+ const errorOutput = mockConsoleError.mock.calls.map((call) => call[0]).join('\n')
216
+ expect(errorOutput).toContain('Change ID is required')
217
+ })
218
+
219
+ it('should show error when no reviewers are provided', async () => {
220
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
221
+ const program = addReviewerCommand([], {
222
+ change: '12345',
223
+ }).pipe(Effect.provide(GerritApiServiceLive), Effect.provide(mockConfigLayer))
224
+
225
+ const result = await Effect.runPromiseExit(program)
226
+ expect(result._tag).toBe('Failure')
227
+
228
+ const errorOutput = mockConsoleError.mock.calls.map((call) => call[0]).join('\n')
229
+ expect(errorOutput).toContain('At least one reviewer is required')
230
+ })
231
+
232
+ it('should output XML format when --xml flag is used', async () => {
233
+ server.use(
234
+ http.post('*/a/changes/12345/reviewers', async () => {
235
+ return HttpResponse.text(
236
+ `)]}'\n${JSON.stringify({
237
+ input: 'reviewer@example.com',
238
+ reviewers: [
239
+ {
240
+ _account_id: 2000,
241
+ name: 'Reviewer User',
242
+ email: 'reviewer@example.com',
243
+ },
244
+ ],
245
+ })}`,
246
+ )
247
+ }),
248
+ )
249
+
250
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
251
+ const program = addReviewerCommand(['reviewer@example.com'], {
252
+ change: '12345',
253
+ xml: true,
254
+ }).pipe(Effect.provide(GerritApiServiceLive), Effect.provide(mockConfigLayer))
255
+
256
+ await Effect.runPromise(program)
257
+
258
+ const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
259
+ expect(output).toContain('<?xml version="1.0" encoding="UTF-8"?>')
260
+ expect(output).toContain('<add_reviewer_result>')
261
+ expect(output).toContain('<change_id>12345</change_id>')
262
+ expect(output).toContain('<state>REVIEWER</state>')
263
+ expect(output).toContain('<entity_type>individual</entity_type>')
264
+ expect(output).toContain('<reviewer status="added">')
265
+ expect(output).toContain('<input>reviewer@example.com</input>')
266
+ expect(output).toContain('<name><![CDATA[Reviewer User]]></name>')
267
+ expect(output).toContain('<status>success</status>')
268
+ expect(output).toContain('</add_reviewer_result>')
269
+ })
270
+
271
+ it('should output XML format for errors when --xml flag is used', async () => {
272
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
273
+ const program = addReviewerCommand(['reviewer@example.com'], {
274
+ xml: true,
275
+ }).pipe(Effect.provide(GerritApiServiceLive), Effect.provide(mockConfigLayer))
276
+
277
+ const result = await Effect.runPromiseExit(program)
278
+ expect(result._tag).toBe('Failure')
279
+
280
+ const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
281
+ expect(output).toContain('<?xml version="1.0" encoding="UTF-8"?>')
282
+ expect(output).toContain('<add_reviewer_result>')
283
+ expect(output).toContain('<status>error</status>')
284
+ expect(output).toContain('<error><![CDATA[Change ID is required')
285
+ expect(output).toContain('</add_reviewer_result>')
286
+ })
287
+
288
+ it('should handle network errors gracefully', async () => {
289
+ server.use(
290
+ http.post('*/a/changes/12345/reviewers', () => {
291
+ return HttpResponse.text('Internal Server Error', { status: 500 })
292
+ }),
293
+ )
294
+
295
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
296
+ const program = addReviewerCommand(['reviewer@example.com'], {
297
+ change: '12345',
298
+ }).pipe(Effect.provide(GerritApiServiceLive), Effect.provide(mockConfigLayer))
299
+
300
+ await Effect.runPromise(program)
301
+
302
+ const errorOutput = mockConsoleError.mock.calls.map((call) => call[0]).join('\n')
303
+ expect(errorOutput).toContain('Failed to add reviewer@example.com')
304
+ })
305
+
306
+ it('should handle partial success with multiple reviewers', async () => {
307
+ let callCount = 0
308
+ server.use(
309
+ http.post('*/a/changes/12345/reviewers', async ({ request }) => {
310
+ const body = (await request.json()) as { reviewer: string }
311
+ callCount++
312
+ if (callCount === 1) {
313
+ return HttpResponse.text(
314
+ `)]}'\n${JSON.stringify({
315
+ input: body.reviewer,
316
+ reviewers: [
317
+ {
318
+ _account_id: 2001,
319
+ name: 'Valid User',
320
+ email: body.reviewer,
321
+ },
322
+ ],
323
+ })}`,
324
+ )
325
+ }
326
+ return HttpResponse.text(
327
+ `)]}'\n${JSON.stringify({
328
+ input: body.reviewer,
329
+ error: 'Account not found',
330
+ })}`,
331
+ )
332
+ }),
333
+ )
334
+
335
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
336
+ const program = addReviewerCommand(['valid@example.com', 'invalid@example.com'], {
337
+ change: '12345',
338
+ xml: true,
339
+ }).pipe(Effect.provide(GerritApiServiceLive), Effect.provide(mockConfigLayer))
340
+
341
+ await Effect.runPromise(program)
342
+
343
+ const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
344
+ expect(output).toContain('<status>partial_failure</status>')
345
+ expect(output).toContain('<reviewer status="added">')
346
+ expect(output).toContain('<reviewer status="failed">')
347
+ })
348
+
349
+ it('should reject invalid notify option', async () => {
350
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
351
+ const program = addReviewerCommand(['reviewer@example.com'], {
352
+ change: '12345',
353
+ notify: 'invalid',
354
+ }).pipe(Effect.provide(GerritApiServiceLive), Effect.provide(mockConfigLayer))
355
+
356
+ const result = await Effect.runPromiseExit(program)
357
+ expect(result._tag).toBe('Failure')
358
+
359
+ const errorOutput = mockConsoleError.mock.calls.map((call) => call[0]).join('\n')
360
+ expect(errorOutput).toContain('Invalid notify level: invalid')
361
+ expect(errorOutput).toContain('Valid values: none, owner, owner_reviewers, all')
362
+ })
363
+
364
+ it('should pass REVIEWER state by default (not CC)', async () => {
365
+ let receivedState: string | undefined
366
+ server.use(
367
+ http.post('*/a/changes/12345/reviewers', async ({ request }) => {
368
+ const body = (await request.json()) as { reviewer: string; state?: string }
369
+ receivedState = body.state
370
+ return HttpResponse.text(
371
+ `)]}'\n${JSON.stringify({
372
+ input: body.reviewer,
373
+ reviewers: [
374
+ {
375
+ _account_id: 2000,
376
+ name: 'Reviewer',
377
+ email: body.reviewer,
378
+ },
379
+ ],
380
+ })}`,
381
+ )
382
+ }),
383
+ )
384
+
385
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
386
+ const program = addReviewerCommand(['reviewer@example.com'], {
387
+ change: '12345',
388
+ }).pipe(Effect.provide(GerritApiServiceLive), Effect.provide(mockConfigLayer))
389
+
390
+ await Effect.runPromise(program)
391
+
392
+ expect(receivedState).toBe('REVIEWER')
393
+ })
394
+
395
+ it('should add a group as reviewer with --group flag', async () => {
396
+ server.use(
397
+ http.post('*/a/changes/12345/reviewers', async ({ request }) => {
398
+ const body = (await request.json()) as { reviewer: string; state?: string }
399
+ expect(body.reviewer).toBe('project-reviewers')
400
+ expect(body.state).toBe('REVIEWER')
401
+ return HttpResponse.text(
402
+ `)]}'\n${JSON.stringify({
403
+ input: 'project-reviewers',
404
+ reviewers: [
405
+ {
406
+ _account_id: 3001,
407
+ name: 'Alice Developer',
408
+ email: 'alice@example.com',
409
+ },
410
+ {
411
+ _account_id: 3002,
412
+ name: 'Bob Developer',
413
+ email: 'bob@example.com',
414
+ },
415
+ ],
416
+ })}`,
417
+ )
418
+ }),
419
+ )
420
+
421
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
422
+ const program = addReviewerCommand(['project-reviewers'], {
423
+ change: '12345',
424
+ group: true,
425
+ }).pipe(Effect.provide(GerritApiServiceLive), Effect.provide(mockConfigLayer))
426
+
427
+ await Effect.runPromise(program)
428
+
429
+ const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
430
+ expect(output).toContain('Added Alice Developer as group')
431
+ })
432
+
433
+ it('should add a group as CC with --group and --cc flags', async () => {
434
+ server.use(
435
+ http.post('*/a/changes/12345/reviewers', async ({ request }) => {
436
+ const body = (await request.json()) as { reviewer: string; state?: string }
437
+ expect(body.reviewer).toBe('administrators')
438
+ expect(body.state).toBe('CC')
439
+ return HttpResponse.text(
440
+ `)]}'\n${JSON.stringify({
441
+ input: 'administrators',
442
+ ccs: [
443
+ {
444
+ _account_id: 4001,
445
+ name: 'Admin User',
446
+ email: 'admin@example.com',
447
+ },
448
+ ],
449
+ })}`,
450
+ )
451
+ }),
452
+ )
453
+
454
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
455
+ const program = addReviewerCommand(['administrators'], {
456
+ change: '12345',
457
+ group: true,
458
+ cc: true,
459
+ }).pipe(Effect.provide(GerritApiServiceLive), Effect.provide(mockConfigLayer))
460
+
461
+ await Effect.runPromise(program)
462
+
463
+ const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
464
+ expect(output).toContain('Added Admin User as cc')
465
+ })
466
+
467
+ it('should show error when no groups provided with --group flag', async () => {
468
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
469
+ const program = addReviewerCommand([], {
470
+ change: '12345',
471
+ group: true,
472
+ }).pipe(Effect.provide(GerritApiServiceLive), Effect.provide(mockConfigLayer))
473
+
474
+ const result = await Effect.runPromiseExit(program)
475
+ expect(result._tag).toBe('Failure')
476
+
477
+ const errorOutput = mockConsoleError.mock.calls.map((call) => call[0]).join('\n')
478
+ expect(errorOutput).toContain('At least one group is required')
479
+ })
480
+
481
+ it('should output XML format with --group flag', async () => {
482
+ server.use(
483
+ http.post('*/a/changes/12345/reviewers', async () => {
484
+ return HttpResponse.text(
485
+ `)]}'\n${JSON.stringify({
486
+ input: 'project-reviewers',
487
+ reviewers: [
488
+ {
489
+ _account_id: 3001,
490
+ name: 'Alice Developer',
491
+ email: 'alice@example.com',
492
+ },
493
+ ],
494
+ })}`,
495
+ )
496
+ }),
497
+ )
498
+
499
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
500
+ const program = addReviewerCommand(['project-reviewers'], {
501
+ change: '12345',
502
+ group: true,
503
+ xml: true,
504
+ }).pipe(Effect.provide(GerritApiServiceLive), Effect.provide(mockConfigLayer))
505
+
506
+ await Effect.runPromise(program)
507
+
508
+ const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
509
+ expect(output).toContain('<?xml version="1.0" encoding="UTF-8"?>')
510
+ expect(output).toContain('<add_reviewer_result>')
511
+ expect(output).toContain('<change_id>12345</change_id>')
512
+ expect(output).toContain('<state>REVIEWER</state>')
513
+ expect(output).toContain('<entity_type>group</entity_type>')
514
+ expect(output).toContain('<reviewer status="added">')
515
+ expect(output).toContain('<input>project-reviewers</input>')
516
+ expect(output).toContain('<name><![CDATA[Alice Developer]]></name>')
517
+ expect(output).toContain('<status>success</status>')
518
+ })
519
+
520
+ it('should handle group not found error', async () => {
521
+ server.use(
522
+ http.post('*/a/changes/12345/reviewers', async () => {
523
+ return HttpResponse.text(
524
+ `)]}'\n${JSON.stringify({
525
+ input: 'nonexistent-group',
526
+ error: 'Group nonexistent-group not found',
527
+ })}`,
528
+ )
529
+ }),
530
+ )
531
+
532
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
533
+ const program = addReviewerCommand(['nonexistent-group'], {
534
+ change: '12345',
535
+ group: true,
536
+ }).pipe(Effect.provide(GerritApiServiceLive), Effect.provide(mockConfigLayer))
537
+
538
+ await Effect.runPromise(program)
539
+
540
+ const errorOutput = mockConsoleError.mock.calls.map((call) => call[0]).join('\n')
541
+ expect(errorOutput).toContain('Failed to add nonexistent-group')
542
+ expect(errorOutput).toContain('Group nonexistent-group not found')
543
+ })
544
+
545
+ it('should reject email-like input when --group flag is used', async () => {
546
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
547
+ const program = addReviewerCommand(['user@example.com'], {
548
+ change: '12345',
549
+ group: true,
550
+ }).pipe(Effect.provide(GerritApiServiceLive), Effect.provide(mockConfigLayer))
551
+
552
+ const result = await Effect.runPromiseExit(program)
553
+ expect(result._tag).toBe('Failure')
554
+
555
+ const errorOutput = mockConsoleError.mock.calls.map((call) => call[0]).join('\n')
556
+ expect(errorOutput).toContain('The --group flag expects group identifiers')
557
+ expect(errorOutput).toContain('user@example.com')
558
+ expect(errorOutput).toContain('Did you mean to omit --group?')
559
+ })
560
+
561
+ it('should reject email-like input in XML mode when --group flag is used', async () => {
562
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
563
+ const program = addReviewerCommand(['admin@example.com', 'test@example.com'], {
564
+ change: '12345',
565
+ group: true,
566
+ xml: true,
567
+ }).pipe(Effect.provide(GerritApiServiceLive), Effect.provide(mockConfigLayer))
568
+
569
+ const result = await Effect.runPromiseExit(program)
570
+ expect(result._tag).toBe('Failure')
571
+
572
+ const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
573
+ expect(output).toContain('<add_reviewer_result>')
574
+ expect(output).toContain('<status>error</status>')
575
+ expect(output).toContain('<error><![CDATA[')
576
+ expect(output).toContain('admin@example.com')
577
+ expect(output).toContain('test@example.com')
578
+ })
579
+ })