@aaronshaf/ger 1.2.11 → 2.0.1

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 +433 -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 +71 -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 +368 -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,256 @@
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 { groupsMembersCommand } from '@/cli/commands/groups-members'
7
+ import { ConfigService } from '@/services/config'
8
+ import { createMockConfigService } from './helpers/config-mock'
9
+
10
+ const mockMembers = [
11
+ {
12
+ _account_id: 1000,
13
+ name: 'John Doe',
14
+ email: 'john@example.com',
15
+ username: 'jdoe',
16
+ },
17
+ {
18
+ _account_id: 1001,
19
+ name: 'Jane Smith',
20
+ email: 'jane@example.com',
21
+ username: 'jsmith',
22
+ },
23
+ {
24
+ _account_id: 1002,
25
+ name: 'Bob Johnson',
26
+ email: 'bob@example.com',
27
+ },
28
+ ]
29
+
30
+ const server = setupServer(
31
+ http.get('*/a/accounts/self', ({ request }) => {
32
+ const auth = request.headers.get('Authorization')
33
+ if (!auth || !auth.startsWith('Basic ')) {
34
+ return HttpResponse.text('Unauthorized', { status: 401 })
35
+ }
36
+ return HttpResponse.json({
37
+ _account_id: 1000,
38
+ name: 'Test User',
39
+ email: 'test@example.com',
40
+ })
41
+ }),
42
+ )
43
+
44
+ describe('groups-members command', () => {
45
+ let mockConsoleLog: ReturnType<typeof mock>
46
+ let mockConsoleError: ReturnType<typeof mock>
47
+
48
+ beforeAll(() => {
49
+ server.listen({ onUnhandledRequest: 'bypass' })
50
+ })
51
+
52
+ afterAll(() => {
53
+ server.close()
54
+ })
55
+
56
+ beforeEach(() => {
57
+ mockConsoleLog = mock(() => {})
58
+ mockConsoleError = mock(() => {})
59
+ console.log = mockConsoleLog
60
+ console.error = mockConsoleError
61
+ })
62
+
63
+ afterEach(() => {
64
+ server.resetHandlers()
65
+ })
66
+
67
+ it('should list all members of a group', async () => {
68
+ server.use(
69
+ http.get('*/a/groups/administrators/members/', () => {
70
+ return HttpResponse.text(`)]}'\n${JSON.stringify(mockMembers)}`)
71
+ }),
72
+ )
73
+
74
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
75
+ const program = groupsMembersCommand('administrators', {}).pipe(
76
+ Effect.provide(GerritApiServiceLive),
77
+ Effect.provide(mockConfigLayer),
78
+ )
79
+
80
+ await Effect.runPromise(program)
81
+
82
+ const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
83
+ expect(output).toContain('Members of "administrators" (3)')
84
+ expect(output).toContain('John Doe')
85
+ expect(output).toContain('Email: john@example.com')
86
+ expect(output).toContain('Username: jdoe')
87
+ expect(output).toContain('Account ID: 1000')
88
+ expect(output).toContain('Jane Smith')
89
+ expect(output).toContain('Bob Johnson')
90
+ })
91
+
92
+ it('should handle group with no members', async () => {
93
+ server.use(
94
+ http.get('*/a/groups/empty-group/members/', () => {
95
+ return HttpResponse.text(`)]}'\n${JSON.stringify([])}`)
96
+ }),
97
+ )
98
+
99
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
100
+ const program = groupsMembersCommand('empty-group', {}).pipe(
101
+ Effect.provide(GerritApiServiceLive),
102
+ Effect.provide(mockConfigLayer),
103
+ )
104
+
105
+ await Effect.runPromise(program)
106
+
107
+ const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
108
+ expect(output).toContain('Group "empty-group" has no members')
109
+ })
110
+
111
+ it('should output XML format when --xml flag is used', async () => {
112
+ server.use(
113
+ http.get('*/a/groups/administrators/members/', () => {
114
+ return HttpResponse.text(`)]}'\n${JSON.stringify(mockMembers)}`)
115
+ }),
116
+ )
117
+
118
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
119
+ const program = groupsMembersCommand('administrators', { xml: true }).pipe(
120
+ Effect.provide(GerritApiServiceLive),
121
+ Effect.provide(mockConfigLayer),
122
+ )
123
+
124
+ await Effect.runPromise(program)
125
+
126
+ const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
127
+ expect(output).toContain('<?xml version="1.0" encoding="UTF-8"?>')
128
+ expect(output).toContain('<group_members_result>')
129
+ expect(output).toContain('<status>success</status>')
130
+ expect(output).toContain('<group_id><![CDATA[administrators]]></group_id>')
131
+ expect(output).toContain('<count>3</count>')
132
+ expect(output).toContain('<members>')
133
+ expect(output).toContain('<member>')
134
+ expect(output).toContain('<account_id>1000</account_id>')
135
+ expect(output).toContain('<name><![CDATA[John Doe]]></name>')
136
+ expect(output).toContain('<email><![CDATA[john@example.com]]></email>')
137
+ expect(output).toContain('<username><![CDATA[jdoe]]></username>')
138
+ expect(output).toContain('</member>')
139
+ expect(output).toContain('</members>')
140
+ expect(output).toContain('</group_members_result>')
141
+ })
142
+
143
+ it('should handle empty members with XML format', async () => {
144
+ server.use(
145
+ http.get('*/a/groups/empty-group/members/', () => {
146
+ return HttpResponse.text(`)]}'\n${JSON.stringify([])}`)
147
+ }),
148
+ )
149
+
150
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
151
+ const program = groupsMembersCommand('empty-group', { xml: true }).pipe(
152
+ Effect.provide(GerritApiServiceLive),
153
+ Effect.provide(mockConfigLayer),
154
+ )
155
+
156
+ await Effect.runPromise(program)
157
+
158
+ const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
159
+ expect(output).toContain('<group_members_result>')
160
+ expect(output).toContain('<status>success</status>')
161
+ expect(output).toContain('<count>0</count>')
162
+ expect(output).toContain('<members />')
163
+ })
164
+
165
+ it('should handle group not found (404)', async () => {
166
+ server.use(
167
+ http.get('*/a/groups/nonexistent/members/', () => {
168
+ return HttpResponse.text('Not Found', { status: 404 })
169
+ }),
170
+ )
171
+
172
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
173
+ const program = groupsMembersCommand('nonexistent', {}).pipe(
174
+ Effect.provide(GerritApiServiceLive),
175
+ Effect.provide(mockConfigLayer),
176
+ )
177
+
178
+ await expect(Effect.runPromise(program)).rejects.toThrow()
179
+ })
180
+
181
+ it('should handle network errors gracefully', async () => {
182
+ server.use(
183
+ http.get('*/a/groups/administrators/members/', () => {
184
+ return HttpResponse.error()
185
+ }),
186
+ )
187
+
188
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
189
+ const program = groupsMembersCommand('administrators', {}).pipe(
190
+ Effect.provide(GerritApiServiceLive),
191
+ Effect.provide(mockConfigLayer),
192
+ )
193
+
194
+ await expect(Effect.runPromise(program)).rejects.toThrow()
195
+ })
196
+
197
+ it('should handle members without optional fields (email, name)', async () => {
198
+ const minimalMembers = [
199
+ {
200
+ _account_id: 1000,
201
+ },
202
+ {
203
+ _account_id: 1001,
204
+ username: 'jsmith',
205
+ },
206
+ ]
207
+
208
+ server.use(
209
+ http.get('*/a/groups/minimal-group/members/', () => {
210
+ return HttpResponse.text(`)]}'\n${JSON.stringify(minimalMembers)}`)
211
+ }),
212
+ )
213
+
214
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
215
+ const program = groupsMembersCommand('minimal-group', {}).pipe(
216
+ Effect.provide(GerritApiServiceLive),
217
+ Effect.provide(mockConfigLayer),
218
+ )
219
+
220
+ await Effect.runPromise(program)
221
+
222
+ const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
223
+ expect(output).toContain('Account 1000')
224
+ expect(output).toContain('Account ID: 1000')
225
+ expect(output).toContain('jsmith')
226
+ expect(output).toContain('Account ID: 1001')
227
+ })
228
+
229
+ it('should handle members without optional fields in XML', async () => {
230
+ const minimalMembers = [
231
+ {
232
+ _account_id: 1000,
233
+ },
234
+ ]
235
+
236
+ server.use(
237
+ http.get('*/a/groups/minimal-group/members/', () => {
238
+ return HttpResponse.text(`)]}'\n${JSON.stringify(minimalMembers)}`)
239
+ }),
240
+ )
241
+
242
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
243
+ const program = groupsMembersCommand('minimal-group', { xml: true }).pipe(
244
+ Effect.provide(GerritApiServiceLive),
245
+ Effect.provide(mockConfigLayer),
246
+ )
247
+
248
+ await Effect.runPromise(program)
249
+
250
+ const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
251
+ expect(output).toContain('<account_id>1000</account_id>')
252
+ expect(output).not.toContain('<name>')
253
+ expect(output).not.toContain('<email>')
254
+ expect(output).not.toContain('<username>')
255
+ })
256
+ })
@@ -0,0 +1,323 @@
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 { groupsShowCommand } from '@/cli/commands/groups-show'
7
+ import { ConfigService } from '@/services/config'
8
+ import { createMockConfigService } from './helpers/config-mock'
9
+
10
+ const mockGroupDetail = {
11
+ id: 'administrators',
12
+ name: 'Administrators',
13
+ description: 'Site administrators with full access',
14
+ owner: 'Administrators',
15
+ owner_id: 'administrators',
16
+ group_id: 1,
17
+ options: { visible_to_all: true },
18
+ created_on: '2024-01-01 10:00:00.000000000',
19
+ url: 'https://gerrit.example.com/admin/groups/uuid-administrators',
20
+ members: [
21
+ {
22
+ _account_id: 1000,
23
+ name: 'John Doe',
24
+ email: 'john@example.com',
25
+ username: 'jdoe',
26
+ },
27
+ {
28
+ _account_id: 1001,
29
+ name: 'Jane Smith',
30
+ email: 'jane@example.com',
31
+ username: 'jsmith',
32
+ },
33
+ ],
34
+ includes: [
35
+ {
36
+ id: 'project-admins',
37
+ name: 'Project Admins',
38
+ },
39
+ {
40
+ id: 'system-admins',
41
+ name: 'System Admins',
42
+ },
43
+ ],
44
+ }
45
+
46
+ const server = setupServer(
47
+ http.get('*/a/accounts/self', ({ request }) => {
48
+ const auth = request.headers.get('Authorization')
49
+ if (!auth || !auth.startsWith('Basic ')) {
50
+ return HttpResponse.text('Unauthorized', { status: 401 })
51
+ }
52
+ return HttpResponse.json({
53
+ _account_id: 1000,
54
+ name: 'Test User',
55
+ email: 'test@example.com',
56
+ })
57
+ }),
58
+ )
59
+
60
+ describe('groups-show command', () => {
61
+ let mockConsoleLog: ReturnType<typeof mock>
62
+ let mockConsoleError: ReturnType<typeof mock>
63
+
64
+ beforeAll(() => {
65
+ server.listen({ onUnhandledRequest: 'bypass' })
66
+ })
67
+
68
+ afterAll(() => {
69
+ server.close()
70
+ })
71
+
72
+ beforeEach(() => {
73
+ mockConsoleLog = mock(() => {})
74
+ mockConsoleError = mock(() => {})
75
+ console.log = mockConsoleLog
76
+ console.error = mockConsoleError
77
+ })
78
+
79
+ afterEach(() => {
80
+ server.resetHandlers()
81
+ })
82
+
83
+ it('should show group details with members and subgroups', async () => {
84
+ server.use(
85
+ http.get('*/a/groups/administrators/detail', () => {
86
+ return HttpResponse.text(`)]}'\n${JSON.stringify(mockGroupDetail)}`)
87
+ }),
88
+ )
89
+
90
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
91
+ const program = groupsShowCommand('administrators', {}).pipe(
92
+ Effect.provide(GerritApiServiceLive),
93
+ Effect.provide(mockConfigLayer),
94
+ )
95
+
96
+ await Effect.runPromise(program)
97
+
98
+ const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
99
+ expect(output).toContain('Group: Administrators')
100
+ expect(output).toContain('ID: administrators')
101
+ expect(output).toContain('Numeric ID: 1')
102
+ expect(output).toContain('Owner: Administrators')
103
+ expect(output).toContain('Description: Site administrators with full access')
104
+ expect(output).toContain('Visible to all: Yes')
105
+ expect(output).toContain('Members (2):')
106
+ expect(output).toContain('John Doe')
107
+ expect(output).toContain('john@example.com')
108
+ expect(output).toContain('Jane Smith')
109
+ expect(output).toContain('Subgroups (2):')
110
+ expect(output).toContain('Project Admins')
111
+ expect(output).toContain('System Admins')
112
+ })
113
+
114
+ it('should show group by numeric ID', async () => {
115
+ server.use(
116
+ http.get('*/a/groups/1/detail', () => {
117
+ return HttpResponse.text(`)]}'\n${JSON.stringify(mockGroupDetail)}`)
118
+ }),
119
+ )
120
+
121
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
122
+ const program = groupsShowCommand('1', {}).pipe(
123
+ Effect.provide(GerritApiServiceLive),
124
+ Effect.provide(mockConfigLayer),
125
+ )
126
+
127
+ await Effect.runPromise(program)
128
+
129
+ const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
130
+ expect(output).toContain('Group: Administrators')
131
+ })
132
+
133
+ it('should show group by UUID', async () => {
134
+ server.use(
135
+ http.get('*/a/groups/uuid-123456/detail', () => {
136
+ return HttpResponse.text(`)]}'\n${JSON.stringify(mockGroupDetail)}`)
137
+ }),
138
+ )
139
+
140
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
141
+ const program = groupsShowCommand('uuid-123456', {}).pipe(
142
+ Effect.provide(GerritApiServiceLive),
143
+ Effect.provide(mockConfigLayer),
144
+ )
145
+
146
+ await Effect.runPromise(program)
147
+
148
+ const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
149
+ expect(output).toContain('Group: Administrators')
150
+ })
151
+
152
+ it('should output XML format when --xml flag is used', async () => {
153
+ server.use(
154
+ http.get('*/a/groups/administrators/detail', () => {
155
+ return HttpResponse.text(`)]}'\n${JSON.stringify(mockGroupDetail)}`)
156
+ }),
157
+ )
158
+
159
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
160
+ const program = groupsShowCommand('administrators', { xml: true }).pipe(
161
+ Effect.provide(GerritApiServiceLive),
162
+ Effect.provide(mockConfigLayer),
163
+ )
164
+
165
+ await Effect.runPromise(program)
166
+
167
+ const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
168
+ expect(output).toContain('<?xml version="1.0" encoding="UTF-8"?>')
169
+ expect(output).toContain('<group_detail_result>')
170
+ expect(output).toContain('<status>success</status>')
171
+ expect(output).toContain('<group>')
172
+ expect(output).toContain('<id><![CDATA[administrators]]></id>')
173
+ expect(output).toContain('<name><![CDATA[Administrators]]></name>')
174
+ expect(output).toContain(
175
+ '<description><![CDATA[Site administrators with full access]]></description>',
176
+ )
177
+ expect(output).toContain('<members>')
178
+ expect(output).toContain('<member>')
179
+ expect(output).toContain('<account_id>1000</account_id>')
180
+ expect(output).toContain('<name><![CDATA[John Doe]]></name>')
181
+ expect(output).toContain('<email><![CDATA[john@example.com]]></email>')
182
+ expect(output).toContain('</member>')
183
+ expect(output).toContain('</members>')
184
+ expect(output).toContain('<subgroups>')
185
+ expect(output).toContain('<subgroup>')
186
+ expect(output).toContain('<id><![CDATA[project-admins]]></id>')
187
+ expect(output).toContain('</subgroup>')
188
+ expect(output).toContain('</subgroups>')
189
+ expect(output).toContain('</group>')
190
+ expect(output).toContain('</group_detail_result>')
191
+ })
192
+
193
+ it('should handle group not found (404)', async () => {
194
+ server.use(
195
+ http.get('*/a/groups/nonexistent/detail', () => {
196
+ return HttpResponse.text('Not Found', { status: 404 })
197
+ }),
198
+ )
199
+
200
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
201
+ const program = groupsShowCommand('nonexistent', {}).pipe(
202
+ Effect.provide(GerritApiServiceLive),
203
+ Effect.provide(mockConfigLayer),
204
+ )
205
+
206
+ await expect(Effect.runPromise(program)).rejects.toThrow()
207
+ })
208
+
209
+ it('should handle permission denied (403)', async () => {
210
+ server.use(
211
+ http.get('*/a/groups/secret-group/detail', () => {
212
+ return HttpResponse.text('Forbidden', { status: 403 })
213
+ }),
214
+ )
215
+
216
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
217
+ const program = groupsShowCommand('secret-group', {}).pipe(
218
+ Effect.provide(GerritApiServiceLive),
219
+ Effect.provide(mockConfigLayer),
220
+ )
221
+
222
+ await expect(Effect.runPromise(program)).rejects.toThrow()
223
+ })
224
+
225
+ it('should handle network errors gracefully', async () => {
226
+ server.use(
227
+ http.get('*/a/groups/administrators/detail', () => {
228
+ return HttpResponse.error()
229
+ }),
230
+ )
231
+
232
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
233
+ const program = groupsShowCommand('administrators', {}).pipe(
234
+ Effect.provide(GerritApiServiceLive),
235
+ Effect.provide(mockConfigLayer),
236
+ )
237
+
238
+ await expect(Effect.runPromise(program)).rejects.toThrow()
239
+ })
240
+
241
+ it('should handle groups without members', async () => {
242
+ const groupWithoutMembers = {
243
+ ...mockGroupDetail,
244
+ members: undefined,
245
+ }
246
+
247
+ server.use(
248
+ http.get('*/a/groups/empty-group/detail', () => {
249
+ return HttpResponse.text(`)]}'\n${JSON.stringify(groupWithoutMembers)}`)
250
+ }),
251
+ )
252
+
253
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
254
+ const program = groupsShowCommand('empty-group', {}).pipe(
255
+ Effect.provide(GerritApiServiceLive),
256
+ Effect.provide(mockConfigLayer),
257
+ )
258
+
259
+ await Effect.runPromise(program)
260
+
261
+ const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
262
+ expect(output).toContain('Members: None')
263
+ })
264
+
265
+ it('should handle groups without subgroups', async () => {
266
+ const groupWithoutSubgroups = {
267
+ ...mockGroupDetail,
268
+ includes: undefined,
269
+ }
270
+
271
+ server.use(
272
+ http.get('*/a/groups/no-subgroups/detail', () => {
273
+ return HttpResponse.text(`)]}'\n${JSON.stringify(groupWithoutSubgroups)}`)
274
+ }),
275
+ )
276
+
277
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
278
+ const program = groupsShowCommand('no-subgroups', {}).pipe(
279
+ Effect.provide(GerritApiServiceLive),
280
+ Effect.provide(mockConfigLayer),
281
+ )
282
+
283
+ await Effect.runPromise(program)
284
+
285
+ const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
286
+ expect(output).toContain('Subgroups: None')
287
+ })
288
+
289
+ it('should handle members without optional fields', async () => {
290
+ const groupWithMinimalMembers = {
291
+ ...mockGroupDetail,
292
+ members: [
293
+ {
294
+ _account_id: 1000,
295
+ },
296
+ ],
297
+ }
298
+
299
+ server.use(
300
+ http.get('*/a/groups/minimal-members/detail', () => {
301
+ return HttpResponse.text(`)]}'\n${JSON.stringify(groupWithMinimalMembers)}`)
302
+ }),
303
+ )
304
+
305
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
306
+ const program = groupsShowCommand('minimal-members', { xml: true }).pipe(
307
+ Effect.provide(GerritApiServiceLive),
308
+ Effect.provide(mockConfigLayer),
309
+ )
310
+
311
+ await Effect.runPromise(program)
312
+
313
+ const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
314
+ expect(output).toContain('<account_id>1000</account_id>')
315
+ // Extract just the member section to check optional fields aren't present
316
+ const memberMatch = output.match(/<member>[\s\S]*?<\/member>/)
317
+ expect(memberMatch).toBeTruthy()
318
+ const memberSection = memberMatch?.[0] || ''
319
+ expect(memberSection).not.toContain('<name>')
320
+ expect(memberSection).not.toContain('<email>')
321
+ expect(memberSection).not.toContain('<username>')
322
+ })
323
+ })