@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.
Files changed (36) hide show
  1. package/docs/prd/commands.md +59 -1
  2. package/package.json +1 -1
  3. package/src/api/gerrit-types.ts +121 -0
  4. package/src/api/gerrit.ts +68 -102
  5. package/src/cli/commands/analyze.ts +284 -0
  6. package/src/cli/commands/cherry.ts +268 -0
  7. package/src/cli/commands/failures.ts +74 -0
  8. package/src/cli/commands/files.ts +86 -0
  9. package/src/cli/commands/list.ts +239 -0
  10. package/src/cli/commands/rebase.ts +5 -1
  11. package/src/cli/commands/retrigger.ts +91 -0
  12. package/src/cli/commands/reviewers.ts +95 -0
  13. package/src/cli/commands/setup.ts +19 -6
  14. package/src/cli/commands/tree-cleanup.ts +139 -0
  15. package/src/cli/commands/tree-rebase.ts +168 -0
  16. package/src/cli/commands/tree-setup.ts +202 -0
  17. package/src/cli/commands/trees.ts +107 -0
  18. package/src/cli/commands/update.ts +73 -0
  19. package/src/cli/register-analytics-commands.ts +90 -0
  20. package/src/cli/register-commands.ts +96 -40
  21. package/src/cli/register-list-commands.ts +105 -0
  22. package/src/cli/register-tree-commands.ts +128 -0
  23. package/src/schemas/config.ts +3 -0
  24. package/src/schemas/gerrit.ts +2 -0
  25. package/src/schemas/reviewer.ts +16 -0
  26. package/src/services/config.ts +15 -0
  27. package/tests/analyze.test.ts +197 -0
  28. package/tests/cherry.test.ts +208 -0
  29. package/tests/failures.test.ts +212 -0
  30. package/tests/files.test.ts +223 -0
  31. package/tests/helpers/config-mock.ts +4 -0
  32. package/tests/list.test.ts +220 -0
  33. package/tests/retrigger.test.ts +159 -0
  34. package/tests/reviewers.test.ts +259 -0
  35. package/tests/tree.test.ts +517 -0
  36. package/tests/update.test.ts +86 -0
@@ -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
+ })
@@ -10,6 +10,7 @@ export const createMockConfigService = (
10
10
  password: 'testpass',
11
11
  },
12
12
  aiConfig: AiConfig = { autoDetect: true },
13
+ retriggerComment?: string,
13
14
  ): ConfigServiceImpl => ({
14
15
  getCredentials: Effect.succeed(credentials),
15
16
  saveCredentials: () => Effect.succeed(undefined as void),
@@ -22,6 +23,9 @@ export const createMockConfigService = (
22
23
  password: credentials.password,
23
24
  aiTool: aiConfig.tool,
24
25
  aiAutoDetect: aiConfig.autoDetect ?? true,
26
+ retriggerComment,
25
27
  } as AppConfig),
26
28
  saveFullConfig: () => Effect.succeed(undefined as void),
29
+ getRetriggerComment: Effect.succeed(retriggerComment),
30
+ saveRetriggerComment: () => Effect.succeed(undefined as void),
27
31
  })
@@ -0,0 +1,220 @@
1
+ import { describe, test, expect, beforeAll, afterAll, afterEach } from 'bun:test'
2
+ import { HttpResponse, http } from 'msw'
3
+ import { setupServer } from 'msw/node'
4
+ import { Effect, Layer } from 'effect'
5
+ import { listCommand } from '@/cli/commands/list'
6
+ import { GerritApiServiceLive } from '@/api/gerrit'
7
+ import { ConfigService } from '@/services/config'
8
+ import { createMockConfigService } from './helpers/config-mock'
9
+ import type { ChangeInfo } from '@/schemas/gerrit'
10
+
11
+ const makeChange = (overrides: Partial<ChangeInfo> = {}): ChangeInfo => ({
12
+ id: 'project~main~I123',
13
+ _number: 12345,
14
+ project: 'my-project',
15
+ branch: 'main',
16
+ change_id: 'I123',
17
+ subject: 'Fix the important thing',
18
+ status: 'NEW',
19
+ created: '2025-01-15 10:00:00.000000000',
20
+ updated: '2025-01-15 12:00:00.000000000',
21
+ owner: { _account_id: 1, name: 'Alice', email: 'alice@x.com' },
22
+ ...overrides,
23
+ })
24
+
25
+ const mockChanges: ChangeInfo[] = [
26
+ makeChange({ _number: 1, subject: 'First change' }),
27
+ makeChange({
28
+ _number: 2,
29
+ subject: 'Second change with Code-Review',
30
+ labels: { 'Code-Review': { approved: { _account_id: 1 }, value: 2 } },
31
+ }),
32
+ makeChange({
33
+ _number: 3,
34
+ subject: 'Third change rejected',
35
+ labels: { 'Code-Review': { rejected: { _account_id: 2 }, value: -2 } },
36
+ }),
37
+ ]
38
+
39
+ const server = setupServer(
40
+ http.get('*/a/accounts/self', () =>
41
+ HttpResponse.json({ _account_id: 1, name: 'Alice', email: 'alice@x.com' }),
42
+ ),
43
+ http.get('*/a/changes/', () => HttpResponse.json(mockChanges)),
44
+ )
45
+
46
+ beforeAll(() => server.listen({ onUnhandledRequest: 'bypass' }))
47
+ afterAll(() => server.close())
48
+ afterEach(() => server.resetHandlers())
49
+
50
+ const mockConfig = createMockConfigService()
51
+
52
+ describe('list command', () => {
53
+ test('renders table with header and rows', async () => {
54
+ const logs: string[] = []
55
+ const origLog = console.log
56
+ console.log = (...args: unknown[]) => logs.push(String(args[0]))
57
+
58
+ try {
59
+ await Effect.runPromise(
60
+ listCommand({}).pipe(
61
+ Effect.provide(GerritApiServiceLive),
62
+ Effect.provide(Layer.succeed(ConfigService, mockConfig)),
63
+ ),
64
+ )
65
+ } finally {
66
+ console.log = origLog
67
+ }
68
+
69
+ const output = logs.join('\n')
70
+ expect(output).toContain('Change')
71
+ expect(output).toContain('Subject')
72
+ expect(output).toContain('CR')
73
+ expect(output).toContain('Verified')
74
+ expect(output).toContain('1')
75
+ expect(output).toContain('First change')
76
+ })
77
+
78
+ test('outputs JSON', async () => {
79
+ const logs: string[] = []
80
+ const origLog = console.log
81
+ console.log = (...args: unknown[]) => logs.push(String(args[0]))
82
+
83
+ try {
84
+ await Effect.runPromise(
85
+ listCommand({ json: true }).pipe(
86
+ Effect.provide(GerritApiServiceLive),
87
+ Effect.provide(Layer.succeed(ConfigService, mockConfig)),
88
+ ),
89
+ )
90
+ } finally {
91
+ console.log = origLog
92
+ }
93
+
94
+ const parsed = JSON.parse(logs.join('')) as { status: string; count: number }
95
+ expect(parsed.status).toBe('success')
96
+ expect(parsed.count).toBe(3)
97
+ })
98
+
99
+ test('outputs XML', async () => {
100
+ const logs: string[] = []
101
+ const origLog = console.log
102
+ console.log = (...args: unknown[]) => logs.push(String(args[0]))
103
+
104
+ try {
105
+ await Effect.runPromise(
106
+ listCommand({ xml: true }).pipe(
107
+ Effect.provide(GerritApiServiceLive),
108
+ Effect.provide(Layer.succeed(ConfigService, mockConfig)),
109
+ ),
110
+ )
111
+ } finally {
112
+ console.log = origLog
113
+ }
114
+
115
+ const output = logs.join('\n')
116
+ expect(output).toContain('<changes count="3">')
117
+ expect(output).toContain('<number>1</number>')
118
+ })
119
+
120
+ test('--detailed shows per-change info', async () => {
121
+ const logs: string[] = []
122
+ const origLog = console.log
123
+ console.log = (...args: unknown[]) => logs.push(String(args[0]))
124
+
125
+ try {
126
+ await Effect.runPromise(
127
+ listCommand({ detailed: true }).pipe(
128
+ Effect.provide(GerritApiServiceLive),
129
+ Effect.provide(Layer.succeed(ConfigService, mockConfig)),
130
+ ),
131
+ )
132
+ } finally {
133
+ console.log = origLog
134
+ }
135
+
136
+ const output = logs.join('\n')
137
+ expect(output).toContain('Change:')
138
+ expect(output).toContain('Subject:')
139
+ expect(output).toContain('Project:')
140
+ })
141
+
142
+ test('--limit caps results', async () => {
143
+ const logs: string[] = []
144
+ const origLog = console.log
145
+ console.log = (...args: unknown[]) => logs.push(String(args[0]))
146
+
147
+ try {
148
+ await Effect.runPromise(
149
+ listCommand({ limit: 1, json: true }).pipe(
150
+ Effect.provide(GerritApiServiceLive),
151
+ Effect.provide(Layer.succeed(ConfigService, mockConfig)),
152
+ ),
153
+ )
154
+ } finally {
155
+ console.log = origLog
156
+ }
157
+
158
+ const parsed = JSON.parse(logs.join('')) as { count: number }
159
+ expect(parsed.count).toBe(1)
160
+ })
161
+
162
+ test('--status passes query to API', async () => {
163
+ let capturedUrl = ''
164
+ server.use(
165
+ http.get('*/a/changes/', ({ request }) => {
166
+ capturedUrl = decodeURIComponent(request.url)
167
+ return HttpResponse.json([])
168
+ }),
169
+ )
170
+
171
+ await Effect.runPromise(
172
+ listCommand({ status: 'merged' }).pipe(
173
+ Effect.provide(GerritApiServiceLive),
174
+ Effect.provide(Layer.succeed(ConfigService, mockConfig)),
175
+ ),
176
+ )
177
+
178
+ expect(capturedUrl).toContain('status:merged')
179
+ })
180
+
181
+ test('--reviewer uses reviewer query', async () => {
182
+ let capturedUrl = ''
183
+ server.use(
184
+ http.get('*/a/changes/', ({ request }) => {
185
+ capturedUrl = decodeURIComponent(request.url)
186
+ return HttpResponse.json([])
187
+ }),
188
+ )
189
+
190
+ await Effect.runPromise(
191
+ listCommand({ reviewer: true }).pipe(
192
+ Effect.provide(GerritApiServiceLive),
193
+ Effect.provide(Layer.succeed(ConfigService, mockConfig)),
194
+ ),
195
+ )
196
+
197
+ expect(capturedUrl).toContain('reviewer:')
198
+ })
199
+
200
+ test('shows empty message when no changes', async () => {
201
+ server.use(http.get('*/a/changes/', () => HttpResponse.json([])))
202
+
203
+ const logs: string[] = []
204
+ const origLog = console.log
205
+ console.log = (...args: unknown[]) => logs.push(String(args[0]))
206
+
207
+ try {
208
+ await Effect.runPromise(
209
+ listCommand({}).pipe(
210
+ Effect.provide(GerritApiServiceLive),
211
+ Effect.provide(Layer.succeed(ConfigService, mockConfig)),
212
+ ),
213
+ )
214
+ } finally {
215
+ console.log = origLog
216
+ }
217
+
218
+ expect(logs.join('\n')).toContain('No changes found')
219
+ })
220
+ })
@@ -0,0 +1,159 @@
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 { ConfigService } from '@/services/config'
7
+ import { retriggerCommand } from '@/cli/commands/retrigger'
8
+ import { createMockConfigService } from './helpers/config-mock'
9
+
10
+ // Mock @inquirer/prompts so tests don't block on stdin
11
+ const mockInput = mock(async () => 'trigger-build')
12
+ mock.module('@inquirer/prompts', () => ({ input: mockInput }))
13
+
14
+ const server = setupServer(
15
+ http.get('*/a/accounts/self', () =>
16
+ HttpResponse.json({ _account_id: 1, name: 'User', email: 'u@example.com' }),
17
+ ),
18
+ http.post('*/a/changes/:changeId/revisions/current/review', () => HttpResponse.json({})),
19
+ )
20
+
21
+ beforeAll(() => server.listen({ onUnhandledRequest: 'bypass' }))
22
+ afterAll(() => server.close())
23
+
24
+ describe('retrigger command', () => {
25
+ let mockConsoleLog: ReturnType<typeof mock>
26
+ let mockConsoleError: ReturnType<typeof mock>
27
+
28
+ beforeEach(() => {
29
+ mockConsoleLog = mock()
30
+ mockConsoleError = mock()
31
+ console.log = mockConsoleLog
32
+ console.error = mockConsoleError
33
+ mockInput.mockReset()
34
+ mockInput.mockResolvedValue('trigger-build')
35
+ server.resetHandlers()
36
+ })
37
+
38
+ afterEach(() => {
39
+ server.resetHandlers()
40
+ })
41
+
42
+ it('posts the retrigger comment when change-id is explicit and comment is configured', async () => {
43
+ const mockConfig = createMockConfigService(undefined, undefined, '__TRIGGER__')
44
+
45
+ await Effect.runPromise(
46
+ retriggerCommand('12345', {}).pipe(
47
+ Effect.provide(GerritApiServiceLive),
48
+ Effect.provide(Layer.succeed(ConfigService, mockConfig)),
49
+ ),
50
+ )
51
+
52
+ // Should print success
53
+ expect(mockConsoleLog).toHaveBeenCalledWith(expect.stringContaining('✓'))
54
+ })
55
+
56
+ it('posts to the given change-id', async () => {
57
+ const mockConfig = createMockConfigService(undefined, undefined, '__TRIGGER__')
58
+
59
+ let postedChangeId = ''
60
+ server.use(
61
+ http.post('*/a/changes/:changeId/revisions/current/review', ({ params }) => {
62
+ postedChangeId = params.changeId as string
63
+ return HttpResponse.json({})
64
+ }),
65
+ )
66
+
67
+ await Effect.runPromise(
68
+ retriggerCommand('67890', {}).pipe(
69
+ Effect.provide(GerritApiServiceLive),
70
+ Effect.provide(Layer.succeed(ConfigService, mockConfig)),
71
+ ),
72
+ )
73
+
74
+ expect(postedChangeId).toBe('67890')
75
+ })
76
+
77
+ it('prompts for retrigger comment when not configured, then saves it', async () => {
78
+ let savedComment = ''
79
+ const mockConfig: ReturnType<typeof createMockConfigService> = {
80
+ ...createMockConfigService(),
81
+ getRetriggerComment: Effect.succeed(undefined),
82
+ saveRetriggerComment: (comment: string) => {
83
+ savedComment = comment
84
+ return Effect.succeed(undefined as void)
85
+ },
86
+ }
87
+
88
+ mockInput.mockResolvedValue('my-trigger-comment')
89
+
90
+ await Effect.runPromise(
91
+ retriggerCommand('12345', {}).pipe(
92
+ Effect.provide(GerritApiServiceLive),
93
+ Effect.provide(Layer.succeed(ConfigService, mockConfig)),
94
+ ),
95
+ )
96
+
97
+ expect(mockInput).toHaveBeenCalled()
98
+ expect(savedComment).toBe('my-trigger-comment')
99
+ })
100
+
101
+ it('throws when prompted comment is empty', async () => {
102
+ const mockConfig: ReturnType<typeof createMockConfigService> = {
103
+ ...createMockConfigService(),
104
+ getRetriggerComment: Effect.succeed(undefined),
105
+ saveRetriggerComment: () => Effect.succeed(undefined as void),
106
+ }
107
+
108
+ mockInput.mockResolvedValue(' ')
109
+
110
+ let threw = false
111
+ try {
112
+ await Effect.runPromise(
113
+ retriggerCommand('12345', {}).pipe(
114
+ Effect.provide(GerritApiServiceLive),
115
+ Effect.provide(Layer.succeed(ConfigService, mockConfig)),
116
+ ),
117
+ )
118
+ } catch (e) {
119
+ threw = true
120
+ expect(String(e)).toContain('cannot be empty')
121
+ }
122
+ expect(threw).toBe(true)
123
+ })
124
+
125
+ it('outputs JSON on success', async () => {
126
+ const mockConfig = createMockConfigService(undefined, undefined, '__TRIGGER__')
127
+
128
+ const logs: string[] = []
129
+ console.log = (msg: string) => logs.push(msg)
130
+
131
+ await Effect.runPromise(
132
+ retriggerCommand('12345', { json: true }).pipe(
133
+ Effect.provide(GerritApiServiceLive),
134
+ Effect.provide(Layer.succeed(ConfigService, mockConfig)),
135
+ ),
136
+ )
137
+
138
+ const parsed = JSON.parse(logs[0]) as { status: string; change_id: string }
139
+ expect(parsed.status).toBe('success')
140
+ expect(parsed.change_id).toBe('12345')
141
+ })
142
+
143
+ it('outputs XML on success', async () => {
144
+ const mockConfig = createMockConfigService(undefined, undefined, '__TRIGGER__')
145
+
146
+ const logs: string[] = []
147
+ console.log = (msg: string) => logs.push(msg)
148
+
149
+ await Effect.runPromise(
150
+ retriggerCommand('12345', { xml: true }).pipe(
151
+ Effect.provide(GerritApiServiceLive),
152
+ Effect.provide(Layer.succeed(ConfigService, mockConfig)),
153
+ ),
154
+ )
155
+
156
+ expect(logs.join('\n')).toContain('<retrigger>')
157
+ expect(logs.join('\n')).toContain('<status>success</status>')
158
+ })
159
+ })