@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,233 @@
1
+ import { afterAll, beforeAll, beforeEach, describe, expect, mock, test } from 'bun:test'
2
+ import { http, HttpResponse } from 'msw'
3
+ import { setupServer } from 'msw/node'
4
+ import { Layer } from 'effect'
5
+ import { Effect } from 'effect'
6
+ import { GerritApiServiceLive } from '@/api/gerrit'
7
+ import { openCommand } from '@/cli/commands/open'
8
+ import { ConfigService } from '@/services/config'
9
+
10
+ import { createMockConfigService } from './helpers/config-mock'
11
+ // Mock child_process.exec
12
+ const mockExec = mock()
13
+ mock.module('node:child_process', () => ({
14
+ exec: mockExec,
15
+ }))
16
+
17
+ const server = setupServer()
18
+
19
+ beforeAll(() => {
20
+ server.listen()
21
+ })
22
+
23
+ beforeEach(() => {
24
+ mockExec.mockClear()
25
+ })
26
+
27
+ afterAll(() => {
28
+ server.close()
29
+ })
30
+
31
+ describe('open command', () => {
32
+ test('should open change URL in browser', async () => {
33
+ // Mock the exec function to simulate successful browser opening
34
+ mockExec.mockImplementation((cmd: string, callback: (error: null) => void) => {
35
+ expect(cmd).toMatch(
36
+ /^(open|start|xdg-open) "https:\/\/gerrit\.example\.com\/c\/test-project\/\+\/12345"$/,
37
+ )
38
+ callback(null)
39
+ })
40
+
41
+ server.use(
42
+ http.get('*/a/changes/12345', () => {
43
+ return HttpResponse.text(
44
+ `)]}'\n${JSON.stringify({
45
+ id: 'test-project~main~I1234567890abcdef',
46
+ project: 'test-project',
47
+ branch: 'main',
48
+ change_id: 'I1234567890abcdef',
49
+ subject: 'Test change',
50
+ status: 'NEW',
51
+ _number: 12345,
52
+ owner: {
53
+ _account_id: 1000000,
54
+ name: 'Test User',
55
+ email: 'test@example.com',
56
+ },
57
+ })}`,
58
+ )
59
+ }),
60
+ )
61
+
62
+ const mockConfigLayer = Layer.succeed(
63
+ ConfigService,
64
+ createMockConfigService({
65
+ host: 'https://gerrit.example.com',
66
+ username: 'testuser',
67
+ password: 'testpass',
68
+ }),
69
+ )
70
+
71
+ const consoleSpy = mock(() => {})
72
+ const originalLog = console.log
73
+ console.log = consoleSpy
74
+
75
+ const program = openCommand('12345').pipe(
76
+ Effect.provide(GerritApiServiceLive),
77
+ Effect.provide(mockConfigLayer),
78
+ )
79
+
80
+ await Effect.runPromise(program)
81
+
82
+ console.log = originalLog
83
+
84
+ expect(mockExec).toHaveBeenCalledTimes(1)
85
+ expect(consoleSpy).toHaveBeenCalledWith(
86
+ 'Opened: https://gerrit.example.com/c/test-project/+/12345',
87
+ )
88
+ })
89
+
90
+ test('should handle URLs and extract change number', async () => {
91
+ mockExec.mockImplementation((cmd: string, callback: (error: null) => void) => {
92
+ expect(cmd).toMatch(
93
+ /^(open|start|xdg-open) "https:\/\/gerrit\.example\.com\/c\/test-project\/\+\/12345"$/,
94
+ )
95
+ callback(null)
96
+ })
97
+
98
+ server.use(
99
+ http.get('*/a/changes/12345', () => {
100
+ return HttpResponse.text(
101
+ `)]}'\n${JSON.stringify({
102
+ id: 'test-project~main~I1234567890abcdef',
103
+ project: 'test-project',
104
+ branch: 'main',
105
+ change_id: 'I1234567890abcdef',
106
+ subject: 'Test change',
107
+ status: 'NEW',
108
+ _number: 12345,
109
+ owner: {
110
+ _account_id: 1000000,
111
+ name: 'Test User',
112
+ },
113
+ })}`,
114
+ )
115
+ }),
116
+ )
117
+
118
+ const mockConfigLayer = Layer.succeed(
119
+ ConfigService,
120
+ createMockConfigService({
121
+ host: 'https://gerrit.example.com',
122
+ username: 'testuser',
123
+ password: 'testpass',
124
+ }),
125
+ )
126
+
127
+ const consoleSpy = mock(() => {})
128
+ const originalLog = console.log
129
+ console.log = consoleSpy
130
+
131
+ // Test with a full Gerrit URL
132
+ const program = openCommand('https://gerrit.example.com/c/test-project/+/12345').pipe(
133
+ Effect.provide(GerritApiServiceLive),
134
+ Effect.provide(mockConfigLayer),
135
+ )
136
+
137
+ await Effect.runPromise(program)
138
+
139
+ console.log = originalLog
140
+
141
+ expect(mockExec).toHaveBeenCalledTimes(1)
142
+ expect(consoleSpy).toHaveBeenCalledWith(
143
+ 'Opened: https://gerrit.example.com/c/test-project/+/12345',
144
+ )
145
+ })
146
+
147
+ test('should handle invalid change ID', async () => {
148
+ const mockConfigLayer = Layer.succeed(
149
+ ConfigService,
150
+ createMockConfigService({
151
+ host: 'https://gerrit.example.com',
152
+ username: 'testuser',
153
+ password: 'testpass',
154
+ }),
155
+ )
156
+
157
+ // Use an empty string which is truly invalid according to isValidChangeId
158
+ const program = openCommand('').pipe(
159
+ Effect.provide(GerritApiServiceLive),
160
+ Effect.provide(mockConfigLayer),
161
+ )
162
+
163
+ await expect(Effect.runPromise(program)).rejects.toThrow('Invalid change ID: ')
164
+ })
165
+
166
+ test('should handle API errors gracefully', async () => {
167
+ server.use(
168
+ http.get('*/a/changes/12345', () => {
169
+ return HttpResponse.json({ error: 'Change not found' }, { status: 404 })
170
+ }),
171
+ )
172
+
173
+ const mockConfigLayer = Layer.succeed(
174
+ ConfigService,
175
+ createMockConfigService({
176
+ host: 'https://gerrit.example.com',
177
+ username: 'testuser',
178
+ password: 'testpass',
179
+ }),
180
+ )
181
+
182
+ const program = openCommand('12345').pipe(
183
+ Effect.provide(GerritApiServiceLive),
184
+ Effect.provide(mockConfigLayer),
185
+ )
186
+
187
+ await expect(Effect.runPromise(program)).rejects.toThrow()
188
+ })
189
+
190
+ test('should handle browser opening errors', async () => {
191
+ mockExec.mockImplementation((cmd: string, callback: (error: Error) => void) => {
192
+ callback(new Error('Browser not found'))
193
+ })
194
+
195
+ server.use(
196
+ http.get('*/a/changes/12345', () => {
197
+ return HttpResponse.text(
198
+ `)]}'\n${JSON.stringify({
199
+ id: 'test-project~main~I1234567890abcdef',
200
+ project: 'test-project',
201
+ branch: 'main',
202
+ change_id: 'I1234567890abcdef',
203
+ subject: 'Test change',
204
+ status: 'NEW',
205
+ _number: 12345,
206
+ owner: {
207
+ _account_id: 1000000,
208
+ name: 'Test User',
209
+ },
210
+ })}`,
211
+ )
212
+ }),
213
+ )
214
+
215
+ const mockConfigLayer = Layer.succeed(
216
+ ConfigService,
217
+ createMockConfigService({
218
+ host: 'https://gerrit.example.com',
219
+ username: 'testuser',
220
+ password: 'testpass',
221
+ }),
222
+ )
223
+
224
+ const program = openCommand('12345').pipe(
225
+ Effect.provide(GerritApiServiceLive),
226
+ Effect.provide(mockConfigLayer),
227
+ )
228
+
229
+ await expect(Effect.runPromise(program)).rejects.toThrow(
230
+ 'Failed to open URL: Browser not found',
231
+ )
232
+ })
233
+ })
@@ -0,0 +1,259 @@
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 { projectsCommand } from '@/cli/commands/projects'
7
+ import { ConfigService } from '@/services/config'
8
+ import { createMockConfigService } from './helpers/config-mock'
9
+
10
+ const mockProjects = {
11
+ 'project-a': {
12
+ id: 'project-a',
13
+ name: 'project-a',
14
+ parent: 'All-Projects',
15
+ state: 'ACTIVE' as const,
16
+ },
17
+ 'project-b': {
18
+ id: 'project-b',
19
+ name: 'project-b',
20
+ parent: 'All-Projects',
21
+ state: 'ACTIVE' as const,
22
+ },
23
+ 'test-project': {
24
+ id: 'test-project',
25
+ name: 'test-project',
26
+ parent: 'All-Projects',
27
+ state: 'ACTIVE' as const,
28
+ },
29
+ }
30
+
31
+ // Create MSW server
32
+ const server = setupServer(
33
+ // Default handler for auth check
34
+ http.get('*/a/accounts/self', ({ request }) => {
35
+ const auth = request.headers.get('Authorization')
36
+ if (!auth || !auth.startsWith('Basic ')) {
37
+ return HttpResponse.text('Unauthorized', { status: 401 })
38
+ }
39
+ return HttpResponse.json({
40
+ _account_id: 1000,
41
+ name: 'Test User',
42
+ email: 'test@example.com',
43
+ })
44
+ }),
45
+ )
46
+
47
+ describe('projects command', () => {
48
+ let mockConsoleLog: ReturnType<typeof mock>
49
+ let mockConsoleError: ReturnType<typeof mock>
50
+
51
+ beforeAll(() => {
52
+ server.listen({ onUnhandledRequest: 'bypass' })
53
+ })
54
+
55
+ afterAll(() => {
56
+ server.close()
57
+ })
58
+
59
+ beforeEach(() => {
60
+ mockConsoleLog = mock(() => {})
61
+ mockConsoleError = mock(() => {})
62
+ console.log = mockConsoleLog
63
+ console.error = mockConsoleError
64
+ })
65
+
66
+ afterEach(() => {
67
+ server.resetHandlers()
68
+ })
69
+
70
+ it('should list all projects', async () => {
71
+ server.use(
72
+ http.get('*/a/projects/', () => {
73
+ return HttpResponse.text(`)]}'\n${JSON.stringify(mockProjects)}`)
74
+ }),
75
+ )
76
+
77
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
78
+ const program = projectsCommand({}).pipe(
79
+ Effect.provide(GerritApiServiceLive),
80
+ Effect.provide(mockConfigLayer),
81
+ )
82
+
83
+ await Effect.runPromise(program)
84
+
85
+ const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
86
+ // Projects should be sorted alphabetically
87
+ expect(output).toContain('project-a')
88
+ expect(output).toContain('project-b')
89
+ expect(output).toContain('test-project')
90
+
91
+ // Check that they appear in alphabetical order
92
+ const lines = output.split('\n').filter((line) => line.trim())
93
+ expect(lines[0]).toBe('project-a')
94
+ expect(lines[1]).toBe('project-b')
95
+ expect(lines[2]).toBe('test-project')
96
+ })
97
+
98
+ it('should list projects with pattern filter', async () => {
99
+ server.use(
100
+ http.get('*/a/projects/', ({ request }) => {
101
+ const url = new URL(request.url)
102
+ const pattern = url.searchParams.get('p')
103
+ expect(pattern).toBe('test-*')
104
+
105
+ // Return filtered projects
106
+ const filtered = {
107
+ 'test-project': mockProjects['test-project'],
108
+ }
109
+ return HttpResponse.text(`)]}'\n${JSON.stringify(filtered)}`)
110
+ }),
111
+ )
112
+
113
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
114
+ const program = projectsCommand({ pattern: 'test-*' }).pipe(
115
+ Effect.provide(GerritApiServiceLive),
116
+ Effect.provide(mockConfigLayer),
117
+ )
118
+
119
+ await Effect.runPromise(program)
120
+
121
+ const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
122
+ expect(output).toContain('test-project')
123
+ expect(output).not.toContain('project-a')
124
+ expect(output).not.toContain('project-b')
125
+ })
126
+
127
+ it('should output XML format when --xml flag is used', async () => {
128
+ server.use(
129
+ http.get('*/a/projects/', () => {
130
+ return HttpResponse.text(`)]}'\n${JSON.stringify(mockProjects)}`)
131
+ }),
132
+ )
133
+
134
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
135
+ const program = projectsCommand({ xml: true }).pipe(
136
+ Effect.provide(GerritApiServiceLive),
137
+ Effect.provide(mockConfigLayer),
138
+ )
139
+
140
+ await Effect.runPromise(program)
141
+
142
+ const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
143
+ expect(output).toContain('<?xml version="1.0" encoding="UTF-8"?>')
144
+ expect(output).toContain('<projects_result>')
145
+ expect(output).toContain('<status>success</status>')
146
+ expect(output).toContain('<projects>')
147
+ expect(output).toContain('<project>')
148
+ expect(output).toContain('<id>project-a</id>')
149
+ expect(output).toContain('<name>project-a</name>')
150
+ expect(output).toContain('<parent>All-Projects</parent>')
151
+ expect(output).toContain('<state>ACTIVE</state>')
152
+ expect(output).toContain('</project>')
153
+ expect(output).toContain('</projects>')
154
+ expect(output).toContain('</projects_result>')
155
+ })
156
+
157
+ it('should handle empty results', async () => {
158
+ server.use(
159
+ http.get('*/a/projects/', () => {
160
+ return HttpResponse.text(`)]}'\n${JSON.stringify({})}`)
161
+ }),
162
+ )
163
+
164
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
165
+ const program = projectsCommand({}).pipe(
166
+ Effect.provide(GerritApiServiceLive),
167
+ Effect.provide(mockConfigLayer),
168
+ )
169
+
170
+ await Effect.runPromise(program)
171
+
172
+ const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
173
+ expect(output).toContain('No projects found')
174
+ })
175
+
176
+ it('should handle empty results with XML format', async () => {
177
+ server.use(
178
+ http.get('*/a/projects/', () => {
179
+ return HttpResponse.text(`)]}'\n${JSON.stringify({})}`)
180
+ }),
181
+ )
182
+
183
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
184
+ const program = projectsCommand({ xml: true }).pipe(
185
+ Effect.provide(GerritApiServiceLive),
186
+ Effect.provide(mockConfigLayer),
187
+ )
188
+
189
+ await Effect.runPromise(program)
190
+
191
+ const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
192
+ expect(output).toContain('<projects_result>')
193
+ expect(output).toContain('<status>success</status>')
194
+ expect(output).toContain('<projects />')
195
+ })
196
+
197
+ it('should handle network errors gracefully', async () => {
198
+ server.use(
199
+ http.get('*/a/projects/', () => {
200
+ return HttpResponse.error()
201
+ }),
202
+ )
203
+
204
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
205
+ const program = projectsCommand({}).pipe(
206
+ Effect.provide(GerritApiServiceLive),
207
+ Effect.provide(mockConfigLayer),
208
+ )
209
+
210
+ // Should throw/fail
211
+ await expect(Effect.runPromise(program)).rejects.toThrow()
212
+ })
213
+
214
+ it('should handle API errors', async () => {
215
+ server.use(
216
+ http.get('*/a/projects/', () => {
217
+ return HttpResponse.text('Forbidden', { status: 403 })
218
+ }),
219
+ )
220
+
221
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
222
+ const program = projectsCommand({}).pipe(
223
+ Effect.provide(GerritApiServiceLive),
224
+ Effect.provide(mockConfigLayer),
225
+ )
226
+
227
+ // Should throw/fail
228
+ await expect(Effect.runPromise(program)).rejects.toThrow()
229
+ })
230
+
231
+ it('should handle projects without parent or state', async () => {
232
+ const minimalProjects = {
233
+ 'minimal-project': {
234
+ id: 'minimal-project',
235
+ name: 'minimal-project',
236
+ },
237
+ }
238
+
239
+ server.use(
240
+ http.get('*/a/projects/', () => {
241
+ return HttpResponse.text(`)]}'\n${JSON.stringify(minimalProjects)}`)
242
+ }),
243
+ )
244
+
245
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
246
+ const program = projectsCommand({ xml: true }).pipe(
247
+ Effect.provide(GerritApiServiceLive),
248
+ Effect.provide(mockConfigLayer),
249
+ )
250
+
251
+ await Effect.runPromise(program)
252
+
253
+ const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
254
+ expect(output).toContain('<id>minimal-project</id>')
255
+ expect(output).toContain('<name>minimal-project</name>')
256
+ expect(output).not.toContain('<parent>')
257
+ expect(output).not.toContain('<state>')
258
+ })
259
+ })