@aaronshaf/ger 0.3.2 → 0.3.3
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/EXAMPLES.md +48 -0
- package/README.md +87 -0
- package/package.json +1 -1
- package/src/api/gerrit.ts +28 -0
- package/src/cli/commands/add-reviewer.ts +135 -0
- package/src/cli/commands/build-status.ts +49 -0
- package/src/cli/commands/push.ts +430 -0
- package/src/cli/commands/search.ts +162 -0
- package/src/cli/commands/show.ts +20 -0
- package/src/cli/index.ts +115 -74
- package/src/schemas/gerrit.ts +43 -0
- package/src/services/commit-hook.ts +314 -0
- package/tests/add-reviewer.test.ts +393 -0
- package/tests/integration/commit-hook.test.ts +246 -0
- package/tests/search.test.ts +712 -0
- package/tests/unit/commands/push.test.ts +194 -0
- package/tests/unit/patterns/push-patterns.test.ts +148 -0
- package/tests/unit/services/commit-hook.test.ts +132 -0
|
@@ -0,0 +1,712 @@
|
|
|
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 { searchCommand } from '@/cli/commands/search'
|
|
7
|
+
import { ConfigService } from '@/services/config'
|
|
8
|
+
import { generateMockChange } from '@/test-utils/mock-generator'
|
|
9
|
+
import type { ChangeInfo } from '@/schemas/gerrit'
|
|
10
|
+
import { createMockConfigService } from './helpers/config-mock'
|
|
11
|
+
|
|
12
|
+
// Create MSW server
|
|
13
|
+
const server = setupServer(
|
|
14
|
+
// Default handler for auth check
|
|
15
|
+
http.get('*/a/accounts/self', ({ request }) => {
|
|
16
|
+
const auth = request.headers.get('Authorization')
|
|
17
|
+
if (!auth || !auth.startsWith('Basic ')) {
|
|
18
|
+
return HttpResponse.text('Unauthorized', { status: 401 })
|
|
19
|
+
}
|
|
20
|
+
return HttpResponse.json({
|
|
21
|
+
_account_id: 1000,
|
|
22
|
+
name: 'Test User',
|
|
23
|
+
email: 'test@example.com',
|
|
24
|
+
})
|
|
25
|
+
}),
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
describe('search command', () => {
|
|
29
|
+
let mockConsoleLog: ReturnType<typeof mock>
|
|
30
|
+
let originalConsoleLog: typeof console.log
|
|
31
|
+
|
|
32
|
+
beforeAll(() => {
|
|
33
|
+
server.listen({ onUnhandledRequest: 'bypass' })
|
|
34
|
+
originalConsoleLog = console.log
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
afterAll(() => {
|
|
38
|
+
server.close()
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
beforeEach(() => {
|
|
42
|
+
mockConsoleLog = mock(() => {})
|
|
43
|
+
console.log = mockConsoleLog
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
afterEach(() => {
|
|
47
|
+
server.resetHandlers()
|
|
48
|
+
console.log = originalConsoleLog
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('should use default query "is:open" when no query provided', async () => {
|
|
52
|
+
const mockChanges: ChangeInfo[] = [
|
|
53
|
+
generateMockChange({
|
|
54
|
+
_number: 12345,
|
|
55
|
+
subject: 'Default query change',
|
|
56
|
+
project: 'test-project',
|
|
57
|
+
status: 'NEW',
|
|
58
|
+
}),
|
|
59
|
+
]
|
|
60
|
+
|
|
61
|
+
server.use(
|
|
62
|
+
http.get('*/a/changes/', ({ request }) => {
|
|
63
|
+
const url = new URL(request.url)
|
|
64
|
+
const query = url.searchParams.get('q')
|
|
65
|
+
// Default query with limit
|
|
66
|
+
expect(query).toBe('is:open limit:25')
|
|
67
|
+
return HttpResponse.text(`)]}'\n${JSON.stringify(mockChanges)}`)
|
|
68
|
+
}),
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
|
|
72
|
+
await Effect.runPromise(
|
|
73
|
+
searchCommand(undefined, {}).pipe(
|
|
74
|
+
Effect.provide(GerritApiServiceLive),
|
|
75
|
+
Effect.provide(mockConfigLayer),
|
|
76
|
+
),
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
|
|
80
|
+
expect(output).toContain('Default query change')
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('should pass custom query to Gerrit API', async () => {
|
|
84
|
+
const mockChanges: ChangeInfo[] = [
|
|
85
|
+
generateMockChange({
|
|
86
|
+
_number: 12345,
|
|
87
|
+
subject: "John's change",
|
|
88
|
+
project: 'canvas-lms',
|
|
89
|
+
status: 'NEW',
|
|
90
|
+
owner: {
|
|
91
|
+
_account_id: 2000,
|
|
92
|
+
name: 'John Doe',
|
|
93
|
+
email: 'john@example.com',
|
|
94
|
+
},
|
|
95
|
+
}),
|
|
96
|
+
]
|
|
97
|
+
|
|
98
|
+
server.use(
|
|
99
|
+
http.get('*/a/changes/', ({ request }) => {
|
|
100
|
+
const url = new URL(request.url)
|
|
101
|
+
const query = url.searchParams.get('q')
|
|
102
|
+
expect(query).toBe('owner:john@example.com status:open limit:25')
|
|
103
|
+
return HttpResponse.text(`)]}'\n${JSON.stringify(mockChanges)}`)
|
|
104
|
+
}),
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
|
|
108
|
+
await Effect.runPromise(
|
|
109
|
+
searchCommand('owner:john@example.com status:open', {}).pipe(
|
|
110
|
+
Effect.provide(GerritApiServiceLive),
|
|
111
|
+
Effect.provide(mockConfigLayer),
|
|
112
|
+
),
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
|
|
116
|
+
expect(output).toContain("John's change")
|
|
117
|
+
expect(output).toContain('by John Doe')
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it('should respect --limit option', async () => {
|
|
121
|
+
const mockChanges: ChangeInfo[] = [
|
|
122
|
+
generateMockChange({
|
|
123
|
+
_number: 12345,
|
|
124
|
+
subject: 'Limited change',
|
|
125
|
+
project: 'test-project',
|
|
126
|
+
status: 'NEW',
|
|
127
|
+
}),
|
|
128
|
+
]
|
|
129
|
+
|
|
130
|
+
server.use(
|
|
131
|
+
http.get('*/a/changes/', ({ request }) => {
|
|
132
|
+
const url = new URL(request.url)
|
|
133
|
+
const query = url.searchParams.get('q')
|
|
134
|
+
expect(query).toBe('is:open limit:10')
|
|
135
|
+
return HttpResponse.text(`)]}'\n${JSON.stringify(mockChanges)}`)
|
|
136
|
+
}),
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
|
|
140
|
+
await Effect.runPromise(
|
|
141
|
+
searchCommand(undefined, { limit: '10' }).pipe(
|
|
142
|
+
Effect.provide(GerritApiServiceLive),
|
|
143
|
+
Effect.provide(mockConfigLayer),
|
|
144
|
+
),
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
|
|
148
|
+
expect(output).toContain('Limited change')
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
it('should not add limit if query already contains limit', async () => {
|
|
152
|
+
const mockChanges: ChangeInfo[] = [
|
|
153
|
+
generateMockChange({
|
|
154
|
+
_number: 12345,
|
|
155
|
+
subject: 'Custom limit change',
|
|
156
|
+
project: 'test-project',
|
|
157
|
+
status: 'NEW',
|
|
158
|
+
}),
|
|
159
|
+
]
|
|
160
|
+
|
|
161
|
+
server.use(
|
|
162
|
+
http.get('*/a/changes/', ({ request }) => {
|
|
163
|
+
const url = new URL(request.url)
|
|
164
|
+
const query = url.searchParams.get('q')
|
|
165
|
+
// Should not add another limit
|
|
166
|
+
expect(query).toBe('is:open limit:5')
|
|
167
|
+
return HttpResponse.text(`)]}'\n${JSON.stringify(mockChanges)}`)
|
|
168
|
+
}),
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
|
|
172
|
+
await Effect.runPromise(
|
|
173
|
+
searchCommand('is:open limit:5', {}).pipe(
|
|
174
|
+
Effect.provide(GerritApiServiceLive),
|
|
175
|
+
Effect.provide(mockConfigLayer),
|
|
176
|
+
),
|
|
177
|
+
)
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
it('should use default limit when --limit is non-numeric', async () => {
|
|
181
|
+
const mockChanges: ChangeInfo[] = [
|
|
182
|
+
generateMockChange({
|
|
183
|
+
_number: 12345,
|
|
184
|
+
subject: 'Invalid limit change',
|
|
185
|
+
project: 'test-project',
|
|
186
|
+
status: 'NEW',
|
|
187
|
+
}),
|
|
188
|
+
]
|
|
189
|
+
|
|
190
|
+
server.use(
|
|
191
|
+
http.get('*/a/changes/', ({ request }) => {
|
|
192
|
+
const url = new URL(request.url)
|
|
193
|
+
const query = url.searchParams.get('q')
|
|
194
|
+
// Should fall back to default limit of 25
|
|
195
|
+
expect(query).toBe('is:open limit:25')
|
|
196
|
+
return HttpResponse.text(`)]}'\n${JSON.stringify(mockChanges)}`)
|
|
197
|
+
}),
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
|
|
201
|
+
await Effect.runPromise(
|
|
202
|
+
searchCommand(undefined, { limit: 'abc' }).pipe(
|
|
203
|
+
Effect.provide(GerritApiServiceLive),
|
|
204
|
+
Effect.provide(mockConfigLayer),
|
|
205
|
+
),
|
|
206
|
+
)
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
it('should use default limit when --limit is negative', async () => {
|
|
210
|
+
const mockChanges: ChangeInfo[] = [
|
|
211
|
+
generateMockChange({
|
|
212
|
+
_number: 12345,
|
|
213
|
+
subject: 'Negative limit change',
|
|
214
|
+
project: 'test-project',
|
|
215
|
+
status: 'NEW',
|
|
216
|
+
}),
|
|
217
|
+
]
|
|
218
|
+
|
|
219
|
+
server.use(
|
|
220
|
+
http.get('*/a/changes/', ({ request }) => {
|
|
221
|
+
const url = new URL(request.url)
|
|
222
|
+
const query = url.searchParams.get('q')
|
|
223
|
+
// Should fall back to default limit of 25
|
|
224
|
+
expect(query).toBe('is:open limit:25')
|
|
225
|
+
return HttpResponse.text(`)]}'\n${JSON.stringify(mockChanges)}`)
|
|
226
|
+
}),
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
|
|
230
|
+
await Effect.runPromise(
|
|
231
|
+
searchCommand(undefined, { limit: '-5' }).pipe(
|
|
232
|
+
Effect.provide(GerritApiServiceLive),
|
|
233
|
+
Effect.provide(mockConfigLayer),
|
|
234
|
+
),
|
|
235
|
+
)
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
it('should display changes grouped by project', async () => {
|
|
239
|
+
const mockChanges: ChangeInfo[] = [
|
|
240
|
+
generateMockChange({
|
|
241
|
+
_number: 12345,
|
|
242
|
+
subject: 'Change in project A',
|
|
243
|
+
project: 'project-a',
|
|
244
|
+
status: 'NEW',
|
|
245
|
+
owner: { _account_id: 1, name: 'Alice' },
|
|
246
|
+
}),
|
|
247
|
+
generateMockChange({
|
|
248
|
+
_number: 12346,
|
|
249
|
+
subject: 'Change in project B',
|
|
250
|
+
project: 'project-b',
|
|
251
|
+
status: 'NEW',
|
|
252
|
+
owner: { _account_id: 2, name: 'Bob' },
|
|
253
|
+
}),
|
|
254
|
+
generateMockChange({
|
|
255
|
+
_number: 12347,
|
|
256
|
+
subject: 'Another change in project A',
|
|
257
|
+
project: 'project-a',
|
|
258
|
+
status: 'MERGED',
|
|
259
|
+
owner: { _account_id: 3, name: 'Charlie' },
|
|
260
|
+
}),
|
|
261
|
+
]
|
|
262
|
+
|
|
263
|
+
server.use(
|
|
264
|
+
http.get('*/a/changes/', () => {
|
|
265
|
+
return HttpResponse.text(`)]}'\n${JSON.stringify(mockChanges)}`)
|
|
266
|
+
}),
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
|
|
270
|
+
await Effect.runPromise(
|
|
271
|
+
searchCommand('is:open', {}).pipe(
|
|
272
|
+
Effect.provide(GerritApiServiceLive),
|
|
273
|
+
Effect.provide(mockConfigLayer),
|
|
274
|
+
),
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
|
|
278
|
+
|
|
279
|
+
// Verify project headers appear
|
|
280
|
+
expect(output).toContain('project-a')
|
|
281
|
+
expect(output).toContain('project-b')
|
|
282
|
+
|
|
283
|
+
// Verify changes are shown
|
|
284
|
+
expect(output).toContain('Change in project A')
|
|
285
|
+
expect(output).toContain('Change in project B')
|
|
286
|
+
expect(output).toContain('Another change in project A')
|
|
287
|
+
|
|
288
|
+
// Verify owners are shown
|
|
289
|
+
expect(output).toContain('by Alice')
|
|
290
|
+
expect(output).toContain('by Bob')
|
|
291
|
+
expect(output).toContain('by Charlie')
|
|
292
|
+
|
|
293
|
+
// Verify alphabetical ordering of projects
|
|
294
|
+
const projectAPos = output.indexOf('project-a')
|
|
295
|
+
const projectBPos = output.indexOf('project-b')
|
|
296
|
+
expect(projectAPos).toBeLessThan(projectBPos)
|
|
297
|
+
})
|
|
298
|
+
|
|
299
|
+
it('should output XML format when --xml flag is used', async () => {
|
|
300
|
+
const mockChanges: ChangeInfo[] = [
|
|
301
|
+
generateMockChange({
|
|
302
|
+
_number: 12345,
|
|
303
|
+
subject: 'XML test change',
|
|
304
|
+
project: 'test-project',
|
|
305
|
+
branch: 'main',
|
|
306
|
+
status: 'NEW',
|
|
307
|
+
owner: { _account_id: 1, name: 'Test User', email: 'test@example.com' },
|
|
308
|
+
updated: '2025-01-15 10:30:00.000000000',
|
|
309
|
+
}),
|
|
310
|
+
]
|
|
311
|
+
|
|
312
|
+
server.use(
|
|
313
|
+
http.get('*/a/changes/', () => {
|
|
314
|
+
return HttpResponse.text(`)]}'\n${JSON.stringify(mockChanges)}`)
|
|
315
|
+
}),
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
|
|
319
|
+
await Effect.runPromise(
|
|
320
|
+
searchCommand('owner:self', { xml: true }).pipe(
|
|
321
|
+
Effect.provide(GerritApiServiceLive),
|
|
322
|
+
Effect.provide(mockConfigLayer),
|
|
323
|
+
),
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
|
|
327
|
+
expect(output).toContain('<?xml version="1.0" encoding="UTF-8"?>')
|
|
328
|
+
expect(output).toContain('<search_results>')
|
|
329
|
+
expect(output).toContain('<query><![CDATA[owner:self limit:25]]></query>')
|
|
330
|
+
expect(output).toContain('<count>1</count>')
|
|
331
|
+
expect(output).toContain('<changes>')
|
|
332
|
+
expect(output).toContain('<project name="test-project">')
|
|
333
|
+
expect(output).toContain('<change>')
|
|
334
|
+
expect(output).toContain('<number>12345</number>')
|
|
335
|
+
expect(output).toContain('<subject><![CDATA[XML test change]]></subject>')
|
|
336
|
+
expect(output).toContain('<status>NEW</status>')
|
|
337
|
+
expect(output).toContain('<owner>Test User</owner>')
|
|
338
|
+
expect(output).toContain('<branch>main</branch>')
|
|
339
|
+
expect(output).toContain('<owner_email>test@example.com</owner_email>')
|
|
340
|
+
expect(output).toContain('</change>')
|
|
341
|
+
expect(output).toContain('</project>')
|
|
342
|
+
expect(output).toContain('</changes>')
|
|
343
|
+
expect(output).toContain('</search_results>')
|
|
344
|
+
})
|
|
345
|
+
|
|
346
|
+
it('should respect --limit option with --xml flag', async () => {
|
|
347
|
+
const mockChanges: ChangeInfo[] = [
|
|
348
|
+
generateMockChange({
|
|
349
|
+
_number: 12345,
|
|
350
|
+
subject: 'Limited XML change',
|
|
351
|
+
project: 'test-project',
|
|
352
|
+
status: 'NEW',
|
|
353
|
+
owner: { _account_id: 1, name: 'Test User', email: 'test@example.com' },
|
|
354
|
+
}),
|
|
355
|
+
]
|
|
356
|
+
|
|
357
|
+
server.use(
|
|
358
|
+
http.get('*/a/changes/', ({ request }) => {
|
|
359
|
+
const url = new URL(request.url)
|
|
360
|
+
const query = url.searchParams.get('q')
|
|
361
|
+
expect(query).toBe('owner:self limit:5')
|
|
362
|
+
return HttpResponse.text(`)]}'\n${JSON.stringify(mockChanges)}`)
|
|
363
|
+
}),
|
|
364
|
+
)
|
|
365
|
+
|
|
366
|
+
const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
|
|
367
|
+
await Effect.runPromise(
|
|
368
|
+
searchCommand('owner:self', { xml: true, limit: '5' }).pipe(
|
|
369
|
+
Effect.provide(GerritApiServiceLive),
|
|
370
|
+
Effect.provide(mockConfigLayer),
|
|
371
|
+
),
|
|
372
|
+
)
|
|
373
|
+
|
|
374
|
+
const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
|
|
375
|
+
expect(output).toContain('<?xml version="1.0" encoding="UTF-8"?>')
|
|
376
|
+
expect(output).toContain('<query><![CDATA[owner:self limit:5]]></query>')
|
|
377
|
+
expect(output).toContain('<number>12345</number>')
|
|
378
|
+
expect(output).toContain('<subject><![CDATA[Limited XML change]]></subject>')
|
|
379
|
+
})
|
|
380
|
+
|
|
381
|
+
it('should handle no results gracefully', async () => {
|
|
382
|
+
server.use(
|
|
383
|
+
http.get('*/a/changes/', () => {
|
|
384
|
+
return HttpResponse.text(")]}'\n[]")
|
|
385
|
+
}),
|
|
386
|
+
)
|
|
387
|
+
|
|
388
|
+
const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
|
|
389
|
+
await Effect.runPromise(
|
|
390
|
+
searchCommand('owner:nonexistent@example.com', {}).pipe(
|
|
391
|
+
Effect.provide(GerritApiServiceLive),
|
|
392
|
+
Effect.provide(mockConfigLayer),
|
|
393
|
+
),
|
|
394
|
+
)
|
|
395
|
+
|
|
396
|
+
const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
|
|
397
|
+
expect(output).toContain('No changes found')
|
|
398
|
+
})
|
|
399
|
+
|
|
400
|
+
it('should handle no results in XML format', async () => {
|
|
401
|
+
server.use(
|
|
402
|
+
http.get('*/a/changes/', () => {
|
|
403
|
+
return HttpResponse.text(")]}'\n[]")
|
|
404
|
+
}),
|
|
405
|
+
)
|
|
406
|
+
|
|
407
|
+
const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
|
|
408
|
+
await Effect.runPromise(
|
|
409
|
+
searchCommand('owner:nonexistent@example.com', { xml: true }).pipe(
|
|
410
|
+
Effect.provide(GerritApiServiceLive),
|
|
411
|
+
Effect.provide(mockConfigLayer),
|
|
412
|
+
),
|
|
413
|
+
)
|
|
414
|
+
|
|
415
|
+
const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
|
|
416
|
+
expect(output).toContain('<?xml version="1.0" encoding="UTF-8"?>')
|
|
417
|
+
expect(output).toContain('<search_results>')
|
|
418
|
+
expect(output).toContain('<count>0</count>')
|
|
419
|
+
expect(output).toContain('</search_results>')
|
|
420
|
+
})
|
|
421
|
+
|
|
422
|
+
it('should handle network failures gracefully', async () => {
|
|
423
|
+
server.use(
|
|
424
|
+
http.get('*/a/changes/', () => {
|
|
425
|
+
return HttpResponse.text('Network error', { status: 500 })
|
|
426
|
+
}),
|
|
427
|
+
)
|
|
428
|
+
|
|
429
|
+
const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
|
|
430
|
+
const result = await Effect.runPromise(
|
|
431
|
+
Effect.either(
|
|
432
|
+
searchCommand('is:open', {}).pipe(
|
|
433
|
+
Effect.provide(GerritApiServiceLive),
|
|
434
|
+
Effect.provide(mockConfigLayer),
|
|
435
|
+
),
|
|
436
|
+
),
|
|
437
|
+
)
|
|
438
|
+
|
|
439
|
+
expect(result._tag).toBe('Left')
|
|
440
|
+
})
|
|
441
|
+
|
|
442
|
+
it('should handle authentication failures', async () => {
|
|
443
|
+
server.use(
|
|
444
|
+
http.get('*/a/changes/', () => {
|
|
445
|
+
return HttpResponse.text('Unauthorized', { status: 401 })
|
|
446
|
+
}),
|
|
447
|
+
)
|
|
448
|
+
|
|
449
|
+
const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
|
|
450
|
+
const result = await Effect.runPromise(
|
|
451
|
+
Effect.either(
|
|
452
|
+
searchCommand('is:open', {}).pipe(
|
|
453
|
+
Effect.provide(GerritApiServiceLive),
|
|
454
|
+
Effect.provide(mockConfigLayer),
|
|
455
|
+
),
|
|
456
|
+
),
|
|
457
|
+
)
|
|
458
|
+
|
|
459
|
+
expect(result._tag).toBe('Left')
|
|
460
|
+
})
|
|
461
|
+
|
|
462
|
+
it('should properly escape XML special characters', async () => {
|
|
463
|
+
const mockChanges: ChangeInfo[] = [
|
|
464
|
+
generateMockChange({
|
|
465
|
+
_number: 12345,
|
|
466
|
+
subject: 'Fix <script>alert("XSS")</script> & entities',
|
|
467
|
+
project: 'test<project>',
|
|
468
|
+
status: 'NEW',
|
|
469
|
+
owner: { _account_id: 1, name: 'User <>&"\'' },
|
|
470
|
+
}),
|
|
471
|
+
]
|
|
472
|
+
|
|
473
|
+
server.use(
|
|
474
|
+
http.get('*/a/changes/', () => {
|
|
475
|
+
return HttpResponse.text(`)]}'\n${JSON.stringify(mockChanges)}`)
|
|
476
|
+
}),
|
|
477
|
+
)
|
|
478
|
+
|
|
479
|
+
const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
|
|
480
|
+
await Effect.runPromise(
|
|
481
|
+
searchCommand('is:open', { xml: true }).pipe(
|
|
482
|
+
Effect.provide(GerritApiServiceLive),
|
|
483
|
+
Effect.provide(mockConfigLayer),
|
|
484
|
+
),
|
|
485
|
+
)
|
|
486
|
+
|
|
487
|
+
const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
|
|
488
|
+
// Subject should be in CDATA (special chars preserved)
|
|
489
|
+
expect(output).toContain(
|
|
490
|
+
'<subject><![CDATA[Fix <script>alert("XSS")</script> & entities]]></subject>',
|
|
491
|
+
)
|
|
492
|
+
// Project name attribute should be escaped
|
|
493
|
+
expect(output).toContain('<project name="test<project>">')
|
|
494
|
+
// Owner should be escaped (not CDATA)
|
|
495
|
+
expect(output).toContain('<owner>User <>&"'</owner>')
|
|
496
|
+
})
|
|
497
|
+
|
|
498
|
+
it('should sanitize CDATA content with ]]> sequences', async () => {
|
|
499
|
+
const mockChanges: ChangeInfo[] = [
|
|
500
|
+
generateMockChange({
|
|
501
|
+
_number: 12345,
|
|
502
|
+
subject: 'Subject with ]]> CDATA breaker',
|
|
503
|
+
project: 'test-project',
|
|
504
|
+
status: 'NEW',
|
|
505
|
+
owner: { _account_id: 1, name: 'Test User' },
|
|
506
|
+
}),
|
|
507
|
+
]
|
|
508
|
+
|
|
509
|
+
server.use(
|
|
510
|
+
http.get('*/a/changes/', () => {
|
|
511
|
+
return HttpResponse.text(`)]}'\n${JSON.stringify(mockChanges)}`)
|
|
512
|
+
}),
|
|
513
|
+
)
|
|
514
|
+
|
|
515
|
+
const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
|
|
516
|
+
await Effect.runPromise(
|
|
517
|
+
searchCommand('is:open', { xml: true }).pipe(
|
|
518
|
+
Effect.provide(GerritApiServiceLive),
|
|
519
|
+
Effect.provide(mockConfigLayer),
|
|
520
|
+
),
|
|
521
|
+
)
|
|
522
|
+
|
|
523
|
+
const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
|
|
524
|
+
// ]]> should be escaped to ]]> to prevent CDATA injection
|
|
525
|
+
expect(output).toContain('<subject><![CDATA[Subject with ]]> CDATA breaker]]></subject>')
|
|
526
|
+
})
|
|
527
|
+
|
|
528
|
+
it('should display status indicators for changes with labels', async () => {
|
|
529
|
+
const mockChanges: ChangeInfo[] = [
|
|
530
|
+
generateMockChange({
|
|
531
|
+
_number: 12345,
|
|
532
|
+
subject: 'Approved change',
|
|
533
|
+
project: 'test-project',
|
|
534
|
+
status: 'NEW',
|
|
535
|
+
owner: { _account_id: 1, name: 'Test User' },
|
|
536
|
+
labels: {
|
|
537
|
+
'Code-Review': {
|
|
538
|
+
approved: { _account_id: 2 },
|
|
539
|
+
value: 2,
|
|
540
|
+
},
|
|
541
|
+
},
|
|
542
|
+
}),
|
|
543
|
+
]
|
|
544
|
+
|
|
545
|
+
server.use(
|
|
546
|
+
http.get('*/a/changes/', () => {
|
|
547
|
+
return HttpResponse.text(`)]}'\n${JSON.stringify(mockChanges)}`)
|
|
548
|
+
}),
|
|
549
|
+
)
|
|
550
|
+
|
|
551
|
+
const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
|
|
552
|
+
await Effect.runPromise(
|
|
553
|
+
searchCommand('is:open', {}).pipe(
|
|
554
|
+
Effect.provide(GerritApiServiceLive),
|
|
555
|
+
Effect.provide(mockConfigLayer),
|
|
556
|
+
),
|
|
557
|
+
)
|
|
558
|
+
|
|
559
|
+
const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
|
|
560
|
+
// Should contain the checkmark indicator for approved
|
|
561
|
+
expect(output).toContain('✓')
|
|
562
|
+
expect(output).toContain('Approved change')
|
|
563
|
+
})
|
|
564
|
+
|
|
565
|
+
it('should not include owner_email when email is not present', async () => {
|
|
566
|
+
const mockChanges: ChangeInfo[] = [
|
|
567
|
+
generateMockChange({
|
|
568
|
+
_number: 12345,
|
|
569
|
+
subject: 'No email change',
|
|
570
|
+
project: 'test-project',
|
|
571
|
+
status: 'NEW',
|
|
572
|
+
owner: { _account_id: 1, name: 'Test User' }, // No email
|
|
573
|
+
}),
|
|
574
|
+
]
|
|
575
|
+
|
|
576
|
+
server.use(
|
|
577
|
+
http.get('*/a/changes/', () => {
|
|
578
|
+
return HttpResponse.text(`)]}'\n${JSON.stringify(mockChanges)}`)
|
|
579
|
+
}),
|
|
580
|
+
)
|
|
581
|
+
|
|
582
|
+
const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
|
|
583
|
+
await Effect.runPromise(
|
|
584
|
+
searchCommand('is:open', { xml: true }).pipe(
|
|
585
|
+
Effect.provide(GerritApiServiceLive),
|
|
586
|
+
Effect.provide(mockConfigLayer),
|
|
587
|
+
),
|
|
588
|
+
)
|
|
589
|
+
|
|
590
|
+
const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
|
|
591
|
+
expect(output).toContain('<owner>Test User</owner>')
|
|
592
|
+
expect(output).not.toContain('<owner_email>')
|
|
593
|
+
})
|
|
594
|
+
|
|
595
|
+
it('should not include updated when it is empty string', async () => {
|
|
596
|
+
const mockChanges: ChangeInfo[] = [
|
|
597
|
+
generateMockChange({
|
|
598
|
+
_number: 12345,
|
|
599
|
+
subject: 'Empty updated change',
|
|
600
|
+
project: 'test-project',
|
|
601
|
+
status: 'NEW',
|
|
602
|
+
owner: { _account_id: 1, name: 'Test User' },
|
|
603
|
+
updated: ' ', // Empty/whitespace
|
|
604
|
+
}),
|
|
605
|
+
]
|
|
606
|
+
|
|
607
|
+
server.use(
|
|
608
|
+
http.get('*/a/changes/', () => {
|
|
609
|
+
return HttpResponse.text(`)]}'\n${JSON.stringify(mockChanges)}`)
|
|
610
|
+
}),
|
|
611
|
+
)
|
|
612
|
+
|
|
613
|
+
const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
|
|
614
|
+
await Effect.runPromise(
|
|
615
|
+
searchCommand('is:open', { xml: true }).pipe(
|
|
616
|
+
Effect.provide(GerritApiServiceLive),
|
|
617
|
+
Effect.provide(mockConfigLayer),
|
|
618
|
+
),
|
|
619
|
+
)
|
|
620
|
+
|
|
621
|
+
const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
|
|
622
|
+
expect(output).toContain('<number>12345</number>')
|
|
623
|
+
expect(output).not.toContain('<updated>')
|
|
624
|
+
})
|
|
625
|
+
|
|
626
|
+
it('should display search results header with count', async () => {
|
|
627
|
+
const mockChanges: ChangeInfo[] = [
|
|
628
|
+
generateMockChange({ _number: 1, subject: 'Change 1', project: 'p1' }),
|
|
629
|
+
generateMockChange({ _number: 2, subject: 'Change 2', project: 'p2' }),
|
|
630
|
+
generateMockChange({ _number: 3, subject: 'Change 3', project: 'p3' }),
|
|
631
|
+
]
|
|
632
|
+
|
|
633
|
+
server.use(
|
|
634
|
+
http.get('*/a/changes/', () => {
|
|
635
|
+
return HttpResponse.text(`)]}'\n${JSON.stringify(mockChanges)}`)
|
|
636
|
+
}),
|
|
637
|
+
)
|
|
638
|
+
|
|
639
|
+
const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
|
|
640
|
+
await Effect.runPromise(
|
|
641
|
+
searchCommand('is:open', {}).pipe(
|
|
642
|
+
Effect.provide(GerritApiServiceLive),
|
|
643
|
+
Effect.provide(mockConfigLayer),
|
|
644
|
+
),
|
|
645
|
+
)
|
|
646
|
+
|
|
647
|
+
const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
|
|
648
|
+
expect(output).toContain('Search results (3)')
|
|
649
|
+
})
|
|
650
|
+
})
|
|
651
|
+
|
|
652
|
+
describe('search command CLI integration', () => {
|
|
653
|
+
it('should output XML error format when --xml flag is used and request fails', async () => {
|
|
654
|
+
// Use environment variables to configure an invalid host that will fail to connect
|
|
655
|
+
const proc = Bun.spawn(['bun', 'run', 'src/cli/index.ts', 'search', '--xml'], {
|
|
656
|
+
env: {
|
|
657
|
+
// Preserve PATH for bun to be found
|
|
658
|
+
PATH: process.env.PATH,
|
|
659
|
+
// Override with invalid host - connection will fail
|
|
660
|
+
GERRIT_HOST: 'http://localhost:59999',
|
|
661
|
+
GERRIT_USERNAME: 'test',
|
|
662
|
+
GERRIT_PASSWORD: 'test',
|
|
663
|
+
// Set HOME to temp dir to prevent reading real config
|
|
664
|
+
HOME: '/tmp',
|
|
665
|
+
},
|
|
666
|
+
stdout: 'pipe',
|
|
667
|
+
stderr: 'pipe',
|
|
668
|
+
})
|
|
669
|
+
|
|
670
|
+
const stdout = await new Response(proc.stdout).text()
|
|
671
|
+
const exitCode = await proc.exited
|
|
672
|
+
|
|
673
|
+
// Should exit with error code
|
|
674
|
+
expect(exitCode).toBe(1)
|
|
675
|
+
|
|
676
|
+
// Should output XML error format
|
|
677
|
+
expect(stdout).toContain('<?xml version="1.0" encoding="UTF-8"?>')
|
|
678
|
+
expect(stdout).toContain('<search_result>')
|
|
679
|
+
expect(stdout).toContain('<status>error</status>')
|
|
680
|
+
expect(stdout).toContain('<error><![CDATA[')
|
|
681
|
+
expect(stdout).toContain(']]></error>')
|
|
682
|
+
expect(stdout).toContain('</search_result>')
|
|
683
|
+
})
|
|
684
|
+
|
|
685
|
+
it('should output plain error format when request fails without --xml', async () => {
|
|
686
|
+
// Use environment variables to configure an invalid host that will fail to connect
|
|
687
|
+
const proc = Bun.spawn(['bun', 'run', 'src/cli/index.ts', 'search'], {
|
|
688
|
+
env: {
|
|
689
|
+
// Preserve PATH for bun to be found
|
|
690
|
+
PATH: process.env.PATH,
|
|
691
|
+
// Override with invalid host - connection will fail
|
|
692
|
+
GERRIT_HOST: 'http://localhost:59999',
|
|
693
|
+
GERRIT_USERNAME: 'test',
|
|
694
|
+
GERRIT_PASSWORD: 'test',
|
|
695
|
+
// Set HOME to temp dir to prevent reading real config
|
|
696
|
+
HOME: '/tmp',
|
|
697
|
+
},
|
|
698
|
+
stdout: 'pipe',
|
|
699
|
+
stderr: 'pipe',
|
|
700
|
+
})
|
|
701
|
+
|
|
702
|
+
const stderr = await new Response(proc.stderr).text()
|
|
703
|
+
const exitCode = await proc.exited
|
|
704
|
+
|
|
705
|
+
// Should exit with error code
|
|
706
|
+
expect(exitCode).toBe(1)
|
|
707
|
+
|
|
708
|
+
// Should output plain error (not XML)
|
|
709
|
+
expect(stderr).toContain('✗ Error:')
|
|
710
|
+
expect(stderr).not.toContain('<?xml')
|
|
711
|
+
})
|
|
712
|
+
})
|