@aaronshaf/ger 3.0.1 → 4.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.
- package/docs/prd/commands.md +59 -1
- package/package.json +1 -1
- package/src/api/gerrit-types.ts +121 -0
- package/src/api/gerrit.ts +68 -102
- package/src/cli/commands/analyze.ts +284 -0
- package/src/cli/commands/cherry.ts +268 -0
- package/src/cli/commands/failures.ts +74 -0
- package/src/cli/commands/files.ts +86 -0
- package/src/cli/commands/list.ts +239 -0
- package/src/cli/commands/rebase.ts +5 -1
- package/src/cli/commands/retrigger.ts +91 -0
- package/src/cli/commands/reviewers.ts +95 -0
- package/src/cli/commands/setup.ts +19 -6
- package/src/cli/commands/tree-cleanup.ts +139 -0
- package/src/cli/commands/tree-rebase.ts +168 -0
- package/src/cli/commands/tree-setup.ts +202 -0
- package/src/cli/commands/trees.ts +107 -0
- package/src/cli/commands/update.ts +73 -0
- package/src/cli/register-analytics-commands.ts +90 -0
- package/src/cli/register-commands.ts +96 -40
- package/src/cli/register-list-commands.ts +105 -0
- package/src/cli/register-tree-commands.ts +128 -0
- package/src/schemas/config.ts +3 -0
- package/src/schemas/gerrit.ts +2 -0
- package/src/schemas/reviewer.ts +16 -0
- package/src/services/config.ts +15 -0
- package/tests/analyze.test.ts +197 -0
- package/tests/cherry.test.ts +208 -0
- package/tests/failures.test.ts +212 -0
- package/tests/files.test.ts +223 -0
- package/tests/helpers/config-mock.ts +4 -0
- package/tests/list.test.ts +220 -0
- package/tests/retrigger.test.ts +159 -0
- package/tests/reviewers.test.ts +259 -0
- package/tests/tree.test.ts +517 -0
- package/tests/update.test.ts +86 -0
|
@@ -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
|
+
})
|