@aaronshaf/ger 2.0.10 → 3.0.2

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.
@@ -0,0 +1,223 @@
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 { filesCommand } from '@/cli/commands/files'
7
+ import { ConfigService } from '@/services/config'
8
+ import { createMockConfigService } from './helpers/config-mock'
9
+
10
+ const mockFilesResponse = {
11
+ '/COMMIT_MSG': { status: 'A' as const, lines_inserted: 10 },
12
+ 'src/foo.ts': { status: 'M' as const, lines_inserted: 5, lines_deleted: 2 },
13
+ 'src/bar.ts': { status: 'A' as const, lines_inserted: 20 },
14
+ 'src/old.ts': { status: 'D' as const, lines_deleted: 30 },
15
+ }
16
+
17
+ const server = setupServer(
18
+ http.get('*/a/accounts/self', ({ request }) => {
19
+ const auth = request.headers.get('Authorization')
20
+ if (!auth || !auth.startsWith('Basic ')) {
21
+ return HttpResponse.text('Unauthorized', { status: 401 })
22
+ }
23
+ return HttpResponse.json({ _account_id: 1000, name: 'Test User', email: 'test@example.com' })
24
+ }),
25
+ )
26
+
27
+ describe('files command', () => {
28
+ let mockConsoleLog: ReturnType<typeof mock>
29
+ let mockConsoleError: ReturnType<typeof mock>
30
+ let mockProcessExit: ReturnType<typeof mock>
31
+
32
+ beforeAll(() => {
33
+ server.listen({ onUnhandledRequest: 'bypass' })
34
+ })
35
+
36
+ afterAll(() => {
37
+ server.close()
38
+ })
39
+
40
+ beforeEach(() => {
41
+ mockConsoleLog = mock(() => {})
42
+ mockConsoleError = mock(() => {})
43
+ mockProcessExit = mock(() => {})
44
+ console.log = mockConsoleLog
45
+ console.error = mockConsoleError
46
+ process.exit = mockProcessExit as unknown as typeof process.exit
47
+ })
48
+
49
+ afterEach(() => {
50
+ server.resetHandlers()
51
+ })
52
+
53
+ it('should list changed files with plain output', async () => {
54
+ server.use(
55
+ http.get('*/a/changes/12345/revisions/current/files', () => {
56
+ return HttpResponse.text(`)]}'\n${JSON.stringify(mockFilesResponse)}`)
57
+ }),
58
+ )
59
+
60
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
61
+ const program = filesCommand('12345', {}).pipe(
62
+ Effect.provide(GerritApiServiceLive),
63
+ Effect.provide(mockConfigLayer),
64
+ )
65
+
66
+ await Effect.runPromise(program)
67
+
68
+ const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
69
+ expect(output).not.toContain('/COMMIT_MSG')
70
+ expect(output).toContain('M src/foo.ts')
71
+ expect(output).toContain('A src/bar.ts')
72
+ expect(output).toContain('D src/old.ts')
73
+ })
74
+
75
+ it('should output JSON format', async () => {
76
+ server.use(
77
+ http.get('*/a/changes/12345/revisions/current/files', () => {
78
+ return HttpResponse.text(`)]}'\n${JSON.stringify(mockFilesResponse)}`)
79
+ }),
80
+ )
81
+
82
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
83
+ const program = filesCommand('12345', { json: true }).pipe(
84
+ Effect.provide(GerritApiServiceLive),
85
+ Effect.provide(mockConfigLayer),
86
+ )
87
+
88
+ await Effect.runPromise(program)
89
+
90
+ const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
91
+ const parsed = JSON.parse(output) as { status: string; change_id: string; files: unknown[] }
92
+ expect(parsed.status).toBe('success')
93
+ expect(parsed.change_id).toBe('12345')
94
+ expect(parsed.files).toBeArray()
95
+ const paths = (parsed.files as Array<{ path: string }>).map((f) => f.path)
96
+ expect(paths).not.toContain('/COMMIT_MSG')
97
+ expect(paths).toContain('src/foo.ts')
98
+ })
99
+
100
+ it('should output XML format', async () => {
101
+ server.use(
102
+ http.get('*/a/changes/12345/revisions/current/files', () => {
103
+ return HttpResponse.text(`)]}'\n${JSON.stringify(mockFilesResponse)}`)
104
+ }),
105
+ )
106
+
107
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
108
+ const program = filesCommand('12345', { xml: true }).pipe(
109
+ Effect.provide(GerritApiServiceLive),
110
+ Effect.provide(mockConfigLayer),
111
+ )
112
+
113
+ await Effect.runPromise(program)
114
+
115
+ const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
116
+ expect(output).toContain('<?xml version="1.0" encoding="UTF-8"?>')
117
+ expect(output).toContain('<files_result>')
118
+ expect(output).toContain('<status>success</status>')
119
+ expect(output).toContain('<change_id><![CDATA[12345]]></change_id>')
120
+ expect(output).toContain('<path><![CDATA[src/foo.ts]]></path>')
121
+ expect(output).not.toContain('/COMMIT_MSG')
122
+ expect(output).toContain('</files_result>')
123
+ })
124
+
125
+ it('should handle empty files response (only magic files)', async () => {
126
+ server.use(
127
+ http.get('*/a/changes/12345/revisions/current/files', () => {
128
+ return HttpResponse.text(`)]}'\n${JSON.stringify({ '/COMMIT_MSG': { status: 'A' } })}`)
129
+ }),
130
+ )
131
+
132
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
133
+ const program = filesCommand('12345', {}).pipe(
134
+ Effect.provide(GerritApiServiceLive),
135
+ Effect.provide(mockConfigLayer),
136
+ )
137
+
138
+ await Effect.runPromise(program)
139
+
140
+ const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
141
+ expect(output).not.toContain('/COMMIT_MSG')
142
+ })
143
+
144
+ it('should exit 1 on API error', async () => {
145
+ server.use(
146
+ http.get('*/a/changes/99999/revisions/current/files', () => {
147
+ return HttpResponse.text('Not Found', { status: 404 })
148
+ }),
149
+ )
150
+
151
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
152
+ const program = filesCommand('99999', {}).pipe(
153
+ Effect.provide(GerritApiServiceLive),
154
+ Effect.provide(mockConfigLayer),
155
+ )
156
+
157
+ await Effect.runPromise(program)
158
+
159
+ const errorOutput = mockConsoleError.mock.calls.map((call) => call[0]).join('\n')
160
+ expect(errorOutput).toContain('Error:')
161
+ expect(mockProcessExit.mock.calls[0][0]).toBe(1)
162
+ })
163
+
164
+ it('should exit 1 and output XML on API error with --xml', async () => {
165
+ server.use(
166
+ http.get('*/a/changes/99999/revisions/current/files', () => {
167
+ return HttpResponse.text('Not Found', { status: 404 })
168
+ }),
169
+ )
170
+
171
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
172
+ const program = filesCommand('99999', { xml: true }).pipe(
173
+ Effect.provide(GerritApiServiceLive),
174
+ Effect.provide(mockConfigLayer),
175
+ )
176
+
177
+ await Effect.runPromise(program)
178
+
179
+ const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
180
+ expect(output).toContain('<files_result>')
181
+ expect(output).toContain('<status>error</status>')
182
+ expect(output).toContain('</files_result>')
183
+ expect(mockProcessExit.mock.calls[0][0]).toBe(1)
184
+ })
185
+
186
+ it('should exit 1 and output JSON on API error with --json', async () => {
187
+ server.use(
188
+ http.get('*/a/changes/99999/revisions/current/files', () => {
189
+ return HttpResponse.text('Not Found', { status: 404 })
190
+ }),
191
+ )
192
+
193
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
194
+ const program = filesCommand('99999', { json: true }).pipe(
195
+ Effect.provide(GerritApiServiceLive),
196
+ Effect.provide(mockConfigLayer),
197
+ )
198
+
199
+ await Effect.runPromise(program)
200
+
201
+ const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
202
+ const parsed = JSON.parse(output) as { status: string; error: string }
203
+ expect(parsed.status).toBe('error')
204
+ expect(typeof parsed.error).toBe('string')
205
+ expect(parsed.error.length).toBeGreaterThan(0)
206
+ expect(mockProcessExit.mock.calls[0][0]).toBe(1)
207
+ })
208
+
209
+ it('should exit 1 when no change-id and HEAD has no Change-Id', async () => {
210
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
211
+ const program = filesCommand(undefined, {}).pipe(
212
+ Effect.provide(GerritApiServiceLive),
213
+ Effect.provide(mockConfigLayer),
214
+ )
215
+
216
+ await Effect.runPromise(program)
217
+
218
+ const errorOutput = mockConsoleError.mock.calls.map((call) => call[0]).join('\n')
219
+ expect(errorOutput).toContain('Error:')
220
+ expect(errorOutput).toContain('No Change-ID found in HEAD commit')
221
+ expect(mockProcessExit.mock.calls[0][0]).toBe(1)
222
+ })
223
+ })
@@ -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 { reviewersCommand } from '@/cli/commands/reviewers'
7
+ import { ConfigService } from '@/services/config'
8
+ import { createMockConfigService } from './helpers/config-mock'
9
+
10
+ const mockReviewersResponse = [
11
+ {
12
+ _account_id: 1001,
13
+ name: 'Alice Smith',
14
+ email: 'alice@example.com',
15
+ username: 'alice',
16
+ approvals: { 'Code-Review': '0' },
17
+ },
18
+ {
19
+ _account_id: 1002,
20
+ name: 'Bob Jones',
21
+ email: 'bob@example.com',
22
+ username: 'bob',
23
+ approvals: { 'Code-Review': '+1' },
24
+ },
25
+ ]
26
+
27
+ const server = setupServer(
28
+ http.get('*/a/accounts/self', ({ request }) => {
29
+ const auth = request.headers.get('Authorization')
30
+ if (!auth || !auth.startsWith('Basic ')) {
31
+ return HttpResponse.text('Unauthorized', { status: 401 })
32
+ }
33
+ return HttpResponse.json({ _account_id: 1000, name: 'Test User', email: 'test@example.com' })
34
+ }),
35
+ )
36
+
37
+ describe('reviewers command', () => {
38
+ let mockConsoleLog: ReturnType<typeof mock>
39
+ let mockConsoleError: ReturnType<typeof mock>
40
+ let mockProcessExit: ReturnType<typeof mock>
41
+
42
+ beforeAll(() => {
43
+ server.listen({ onUnhandledRequest: 'bypass' })
44
+ })
45
+
46
+ afterAll(() => {
47
+ server.close()
48
+ })
49
+
50
+ beforeEach(() => {
51
+ mockConsoleLog = mock(() => {})
52
+ mockConsoleError = mock(() => {})
53
+ mockProcessExit = mock(() => {})
54
+ console.log = mockConsoleLog
55
+ console.error = mockConsoleError
56
+ process.exit = mockProcessExit as unknown as typeof process.exit
57
+ })
58
+
59
+ afterEach(() => {
60
+ server.resetHandlers()
61
+ })
62
+
63
+ it('should list reviewers with plain output', async () => {
64
+ server.use(
65
+ http.get('*/a/changes/12345/reviewers', () => {
66
+ return HttpResponse.text(`)]}'\n${JSON.stringify(mockReviewersResponse)}`)
67
+ }),
68
+ )
69
+
70
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
71
+ const program = reviewersCommand('12345', {}).pipe(
72
+ Effect.provide(GerritApiServiceLive),
73
+ Effect.provide(mockConfigLayer),
74
+ )
75
+
76
+ await Effect.runPromise(program)
77
+
78
+ const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
79
+ expect(output).toContain('Alice Smith')
80
+ expect(output).toContain('alice@example.com')
81
+ expect(output).toContain('Bob Jones')
82
+ expect(output).toContain('bob@example.com')
83
+ })
84
+
85
+ it('should handle email-only reviewer (no _account_id, no name)', async () => {
86
+ const emailOnlyReviewer = [{ email: 'ext@external.com', approvals: {} }]
87
+ server.use(
88
+ http.get('*/a/changes/12345/reviewers', () => {
89
+ return HttpResponse.text(`)]}'\n${JSON.stringify(emailOnlyReviewer)}`)
90
+ }),
91
+ )
92
+
93
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
94
+ const program = reviewersCommand('12345', {}).pipe(
95
+ Effect.provide(GerritApiServiceLive),
96
+ Effect.provide(mockConfigLayer),
97
+ )
98
+
99
+ await Effect.runPromise(program)
100
+
101
+ const lines = mockConsoleLog.mock.calls.map((call) => call[0] as string)
102
+ expect(lines).toContain('ext@external.com')
103
+ // Must not produce "ext@external.com <ext@external.com>"
104
+ expect(lines.join('\n')).not.toContain('ext@external.com <ext@external.com>')
105
+ })
106
+
107
+ it('should output JSON format', async () => {
108
+ server.use(
109
+ http.get('*/a/changes/12345/reviewers', () => {
110
+ return HttpResponse.text(`)]}'\n${JSON.stringify(mockReviewersResponse)}`)
111
+ }),
112
+ )
113
+
114
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
115
+ const program = reviewersCommand('12345', { json: true }).pipe(
116
+ Effect.provide(GerritApiServiceLive),
117
+ Effect.provide(mockConfigLayer),
118
+ )
119
+
120
+ await Effect.runPromise(program)
121
+
122
+ const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
123
+ const parsed = JSON.parse(output) as {
124
+ status: string
125
+ change_id: string
126
+ reviewers: Array<{ account_id?: number; name?: string; email?: string }>
127
+ }
128
+ expect(parsed.status).toBe('success')
129
+ expect(parsed.change_id).toBe('12345')
130
+ expect(parsed.reviewers).toBeArray()
131
+ expect(parsed.reviewers.length).toBe(2)
132
+ expect(parsed.reviewers[0].name).toBe('Alice Smith')
133
+ expect(parsed.reviewers[1].email).toBe('bob@example.com')
134
+ })
135
+
136
+ it('should output XML format', async () => {
137
+ server.use(
138
+ http.get('*/a/changes/12345/reviewers', () => {
139
+ return HttpResponse.text(`)]}'\n${JSON.stringify(mockReviewersResponse)}`)
140
+ }),
141
+ )
142
+
143
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
144
+ const program = reviewersCommand('12345', { xml: true }).pipe(
145
+ Effect.provide(GerritApiServiceLive),
146
+ Effect.provide(mockConfigLayer),
147
+ )
148
+
149
+ await Effect.runPromise(program)
150
+
151
+ const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
152
+ expect(output).toContain('<?xml version="1.0" encoding="UTF-8"?>')
153
+ expect(output).toContain('<reviewers_result>')
154
+ expect(output).toContain('<status>success</status>')
155
+ expect(output).toContain('<change_id><![CDATA[12345]]></change_id>')
156
+ expect(output).toContain('<name><![CDATA[Alice Smith]]></name>')
157
+ expect(output).toContain('<email><![CDATA[bob@example.com]]></email>')
158
+ expect(output).toContain('</reviewers_result>')
159
+ })
160
+
161
+ it('should handle empty reviewers list', async () => {
162
+ server.use(
163
+ http.get('*/a/changes/12345/reviewers', () => {
164
+ return HttpResponse.text(`)]}'\n${JSON.stringify([])}`)
165
+ }),
166
+ )
167
+
168
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
169
+ const program = reviewersCommand('12345', {}).pipe(
170
+ Effect.provide(GerritApiServiceLive),
171
+ Effect.provide(mockConfigLayer),
172
+ )
173
+
174
+ await Effect.runPromise(program)
175
+
176
+ const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
177
+ expect(output).toContain('No reviewers')
178
+ })
179
+
180
+ it('should exit 1 on API error', async () => {
181
+ server.use(
182
+ http.get('*/a/changes/99999/reviewers', () => {
183
+ return HttpResponse.text('Not Found', { status: 404 })
184
+ }),
185
+ )
186
+
187
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
188
+ const program = reviewersCommand('99999', {}).pipe(
189
+ Effect.provide(GerritApiServiceLive),
190
+ Effect.provide(mockConfigLayer),
191
+ )
192
+
193
+ await Effect.runPromise(program)
194
+
195
+ const errorOutput = mockConsoleError.mock.calls.map((call) => call[0]).join('\n')
196
+ expect(errorOutput).toContain('Error:')
197
+ expect(mockProcessExit.mock.calls[0][0]).toBe(1)
198
+ })
199
+
200
+ it('should exit 1 and output XML on API error with --xml', async () => {
201
+ server.use(
202
+ http.get('*/a/changes/99999/reviewers', () => {
203
+ return HttpResponse.text('Forbidden', { status: 403 })
204
+ }),
205
+ )
206
+
207
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
208
+ const program = reviewersCommand('99999', { xml: true }).pipe(
209
+ Effect.provide(GerritApiServiceLive),
210
+ Effect.provide(mockConfigLayer),
211
+ )
212
+
213
+ await Effect.runPromise(program)
214
+
215
+ const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
216
+ expect(output).toContain('<reviewers_result>')
217
+ expect(output).toContain('<status>error</status>')
218
+ expect(output).toContain('</reviewers_result>')
219
+ expect(mockProcessExit.mock.calls[0][0]).toBe(1)
220
+ })
221
+
222
+ it('should exit 1 and output JSON on API error with --json', async () => {
223
+ server.use(
224
+ http.get('*/a/changes/99999/reviewers', () => {
225
+ return HttpResponse.text('Not Found', { status: 404 })
226
+ }),
227
+ )
228
+
229
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
230
+ const program = reviewersCommand('99999', { json: true }).pipe(
231
+ Effect.provide(GerritApiServiceLive),
232
+ Effect.provide(mockConfigLayer),
233
+ )
234
+
235
+ await Effect.runPromise(program)
236
+
237
+ const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
238
+ const parsed = JSON.parse(output) as { status: string; error: string }
239
+ expect(parsed.status).toBe('error')
240
+ expect(typeof parsed.error).toBe('string')
241
+ expect(parsed.error.length).toBeGreaterThan(0)
242
+ expect(mockProcessExit.mock.calls[0][0]).toBe(1)
243
+ })
244
+
245
+ it('should exit 1 when no change-id and HEAD has no Change-Id', async () => {
246
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
247
+ const program = reviewersCommand(undefined, {}).pipe(
248
+ Effect.provide(GerritApiServiceLive),
249
+ Effect.provide(mockConfigLayer),
250
+ )
251
+
252
+ await Effect.runPromise(program)
253
+
254
+ const errorOutput = mockConsoleError.mock.calls.map((call) => call[0]).join('\n')
255
+ expect(errorOutput).toContain('Error:')
256
+ expect(errorOutput).toContain('No Change-ID found in HEAD commit')
257
+ expect(mockProcessExit.mock.calls[0][0]).toBe(1)
258
+ })
259
+ })