@aaronshaf/ger 0.2.4 → 0.2.5

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/CLAUDE.md CHANGED
@@ -25,10 +25,10 @@
25
25
  ### Testing & Coverage
26
26
  - **ENFORCE** minimum 80% code coverage
27
27
  - **RUN** all tests in pre-commit and pre-push hooks
28
- - **USE** Bun's native fetch mocking for all HTTP requests
28
+ - **USE** MSW (Mock Service Worker) for all HTTP request mocking
29
29
  - **REQUIRE** meaningful integration tests for all commands that simulate full workflows
30
30
  - **IMPLEMENT** both unit tests and integration tests for every command modification/addition
31
- - **ENSURE** integration tests use realistic Bun HTTP mocks that match Gerrit API responses
31
+ - **ENSURE** integration tests use realistic MSW handlers that match Gerrit API responses
32
32
  - **EXCLUDE** generated code and tmp/ from coverage
33
33
 
34
34
  ### Security
@@ -56,7 +56,7 @@
56
56
  ### Testing Requirements for Commands
57
57
  - **UNIT TESTS**: Test individual functions, schemas, and utilities
58
58
  - **INTEGRATION TESTS**: Test complete command flows with mocked HTTP requests
59
- - **HTTP MOCKING**: Use `global.fetch = mock(...)` pattern with Bun's native mocking
59
+ - **HTTP MOCKING**: Use MSW handlers with http.get/http.post patterns for mocking
60
60
  - **SCHEMA VALIDATION**: Ensure mocks return data that validates against Effect Schemas
61
61
  - **COMMAND COVERAGE**: Every command must have integration tests covering:
62
62
  - Happy path execution
package/README.md CHANGED
@@ -55,6 +55,10 @@ ger diff 12345
55
55
  # Extract URLs from messages (e.g., Jenkins build links)
56
56
  ger extract-url "build-summary-report" | tail -1
57
57
 
58
+ # Check CI build status (parses build messages)
59
+ ger build-status 12345 # Returns: pending, running, success, failure, or not_found
60
+ ger build-status # Auto-detects from HEAD commit
61
+
58
62
  # AI-powered code review (requires claude, llm, or opencode CLI)
59
63
  ger review 12345
60
64
  ger review 12345 --dry-run # Preview without posting
@@ -249,6 +253,51 @@ ger extract-url "github.com" --include-comments
249
253
  ger extract-url "job/[^/]+/job/[^/]+/\d+/$" --regex
250
254
  ```
251
255
 
256
+ ### Build Status
257
+
258
+ Check the CI build status of a change by parsing Gerrit messages for build events and verification results:
259
+
260
+ ```bash
261
+ # Check build status for a specific change
262
+ ger build-status 12345
263
+
264
+ # Auto-detect change from HEAD commit
265
+ ger build-status
266
+
267
+ # Use in scripts with jq
268
+ ger build-status | jq -r '.state'
269
+
270
+ # Wait for build completion in CI scripts
271
+ while [ "$(ger build-status | jq -r '.state')" = "running" ]; do
272
+ echo "Waiting for build..."
273
+ sleep 30
274
+ done
275
+ ```
276
+
277
+ #### Output format (JSON):
278
+ ```json
279
+ {"state": "success"}
280
+ ```
281
+
282
+ #### Build states:
283
+ - **`pending`**: No "Build Started" message found yet
284
+ - **`running`**: "Build Started" found, but no verification result yet
285
+ - **`success`**: Verified +1 after most recent "Build Started"
286
+ - **`failure`**: Verified -1 after most recent "Build Started"
287
+ - **`not_found`**: Change does not exist
288
+
289
+ #### How it works:
290
+ 1. Fetches all messages for the change
291
+ 2. Finds the most recent "Build Started" message
292
+ 3. Checks for "Verified +1" or "Verified -1" messages after the build started
293
+ 4. Returns the appropriate state
294
+
295
+ #### Use cases:
296
+ - **CI/CD integration**: Wait for builds before proceeding with deployment
297
+ - **Automation**: Trigger actions based on build results
298
+ - **Scripting**: Check build status in shell scripts
299
+ - **Monitoring**: Poll build status for long-running builds
300
+
252
301
  ### Diff
253
302
  ```bash
254
303
  # Full diff
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aaronshaf/ger",
3
- "version": "0.2.4",
3
+ "version": "0.2.5",
4
4
  "description": "Gerrit CLI and SDK - A modern CLI tool and TypeScript SDK for Gerrit Code Review, built with Effect-TS",
5
5
  "keywords": [
6
6
  "gerrit",
@@ -0,0 +1,116 @@
1
+ import { Effect, Schema } from 'effect'
2
+ import { type ApiError, GerritApiService } from '@/api/gerrit'
3
+ import type { MessageInfo } from '@/schemas/gerrit'
4
+ import { getChangeIdFromHead, GitError, NoChangeIdError } from '@/utils/git-commit'
5
+
6
+ // Export types for external use
7
+ export type BuildState = 'pending' | 'running' | 'success' | 'failure' | 'not_found'
8
+
9
+ // Effect Schema for BuildStatus (follows project patterns)
10
+ export const BuildStatus: Schema.Schema<{
11
+ readonly state: 'pending' | 'running' | 'success' | 'failure' | 'not_found'
12
+ }> = Schema.Struct({
13
+ state: Schema.Literal('pending', 'running', 'success', 'failure', 'not_found'),
14
+ })
15
+ export type BuildStatus = Schema.Schema.Type<typeof BuildStatus>
16
+
17
+ // Message patterns for precise matching
18
+ const BUILD_STARTED_PATTERN = /Build\s+Started/i
19
+ const VERIFIED_PLUS_PATTERN = /Verified\s*[+]\s*1/
20
+ const VERIFIED_MINUS_PATTERN = /Verified\s*[-]\s*1/
21
+
22
+ /**
23
+ * Parse messages to determine build status based on "Build Started" and verification messages
24
+ */
25
+ const parseBuildStatus = (messages: readonly MessageInfo[]): BuildStatus => {
26
+ // Empty messages means change exists but has no activity yet - return pending
27
+ if (messages.length === 0) {
28
+ return { state: 'pending' }
29
+ }
30
+
31
+ // Find the most recent "Build Started" message
32
+ let lastBuildDate: string | null = null
33
+ for (const msg of messages) {
34
+ if (BUILD_STARTED_PATTERN.test(msg.message)) {
35
+ lastBuildDate = msg.date
36
+ }
37
+ }
38
+
39
+ // If no build has started, state is "pending"
40
+ if (!lastBuildDate) {
41
+ return { state: 'pending' }
42
+ }
43
+
44
+ // Check for verification messages after the build started
45
+ for (const msg of messages) {
46
+ const date = msg.date
47
+ // Gerrit timestamps are ISO 8601 strings (lexicographically sortable)
48
+ if (date <= lastBuildDate) continue
49
+
50
+ if (VERIFIED_PLUS_PATTERN.test(msg.message)) {
51
+ return { state: 'success' }
52
+ } else if (VERIFIED_MINUS_PATTERN.test(msg.message)) {
53
+ return { state: 'failure' }
54
+ }
55
+ }
56
+
57
+ // Build started but no verification yet, state is "running"
58
+ return { state: 'running' }
59
+ }
60
+
61
+ /**
62
+ * Get messages for a change
63
+ */
64
+ const getMessagesForChange = (
65
+ changeId: string,
66
+ ): Effect.Effect<readonly MessageInfo[], ApiError, GerritApiService> =>
67
+ Effect.gen(function* () {
68
+ const gerritApi = yield* GerritApiService
69
+ const messages = yield* gerritApi.getMessages(changeId)
70
+ return messages
71
+ })
72
+
73
+ /**
74
+ * Build status command - determines build status from Gerrit messages
75
+ */
76
+ export const buildStatusCommand = (
77
+ changeId: string | undefined,
78
+ ): Effect.Effect<void, ApiError | Error | GitError | NoChangeIdError, GerritApiService> =>
79
+ Effect.gen(function* () {
80
+ // Auto-detect Change-ID from HEAD commit if not provided
81
+ const resolvedChangeId = changeId || (yield* getChangeIdFromHead())
82
+
83
+ // Fetch messages
84
+ const messages = yield* getMessagesForChange(resolvedChangeId)
85
+
86
+ // Parse build status
87
+ const status = parseBuildStatus(messages)
88
+
89
+ // Output JSON to stdout
90
+ const jsonOutput = JSON.stringify(status) + '\n'
91
+ yield* Effect.sync(() => {
92
+ process.stdout.write(jsonOutput)
93
+ })
94
+ }).pipe(
95
+ // Error handling - return not_found for API errors (e.g., change not found)
96
+ Effect.catchAll((error) => {
97
+ if (error && typeof error === 'object' && 'status' in error && error.status === 404) {
98
+ // Change not found - output not_found state and exit successfully
99
+ const status: BuildStatus = { state: 'not_found' }
100
+ return Effect.sync(() => {
101
+ process.stdout.write(JSON.stringify(status) + '\n')
102
+ })
103
+ }
104
+
105
+ // For other errors, write to stderr and exit with error
106
+ const errorMessage =
107
+ error instanceof GitError || error instanceof NoChangeIdError || error instanceof Error
108
+ ? error.message
109
+ : String(error)
110
+
111
+ return Effect.sync(() => {
112
+ console.error(`Error: ${errorMessage}`)
113
+ process.exit(1)
114
+ })
115
+ }),
116
+ )
package/src/cli/index.ts CHANGED
@@ -31,6 +31,7 @@ import { ConfigServiceLive } from '@/services/config'
31
31
  import { ReviewStrategyServiceLive } from '@/services/review-strategy'
32
32
  import { GitWorktreeServiceLive } from '@/services/git-worktree'
33
33
  import { abandonCommand } from './commands/abandon'
34
+ import { buildStatusCommand } from './commands/build-status'
34
35
  import { commentCommand } from './commands/comment'
35
36
  import { commentsCommand } from './commands/comments'
36
37
  import { diffCommand } from './commands/diff'
@@ -419,6 +420,63 @@ Note: When no change-id is provided, it will be automatically extracted from the
419
420
  }
420
421
  })
421
422
 
423
+ // build-status command
424
+ program
425
+ .command('build-status [change-id]')
426
+ .description(
427
+ 'Check build status from Gerrit messages (auto-detects from HEAD commit if not specified)',
428
+ )
429
+ .addHelpText(
430
+ 'after',
431
+ `
432
+ This command parses Gerrit change messages to determine build status.
433
+ It looks for "Build Started" messages and subsequent verification labels.
434
+
435
+ Output is JSON with a "state" field that can be:
436
+ - pending: No build has started yet
437
+ - running: Build started but no verification yet
438
+ - success: Build completed with Verified+1
439
+ - failure: Build completed with Verified-1
440
+ - not_found: Change does not exist
441
+
442
+ Examples:
443
+ # Check build status for specific change (using change number)
444
+ $ ger build-status 392385
445
+ {"state":"success"}
446
+
447
+ # Check build status for specific change (using Change-ID)
448
+ $ ger build-status If5a3ae8cb5a107e187447802358417f311d0c4b1
449
+ {"state":"running"}
450
+
451
+ # Auto-detect from HEAD commit
452
+ $ ger build-status
453
+ {"state":"pending"}
454
+
455
+ # Use in scripts (exit code 0 on success, 1 on error)
456
+ $ if ger build-status | jq -e '.state == "success"' > /dev/null; then
457
+ echo "Build passed!"
458
+ fi
459
+
460
+ Note: When no change-id is provided, it will be automatically extracted from the
461
+ Change-ID footer in your HEAD commit.`,
462
+ )
463
+ .action(async (changeId) => {
464
+ try {
465
+ const effect = buildStatusCommand(changeId).pipe(
466
+ Effect.provide(GerritApiServiceLive),
467
+ Effect.provide(ConfigServiceLive),
468
+ )
469
+ await Effect.runPromise(effect)
470
+ } catch (error) {
471
+ // Errors are handled within the command itself
472
+ // This catch is just for any unexpected errors
473
+ if (error instanceof Error && error.message !== 'Process exited') {
474
+ console.error('✗ Unexpected error:', error.message)
475
+ process.exit(1)
476
+ }
477
+ }
478
+ })
479
+
422
480
  // extract-url command
423
481
  program
424
482
  .command('extract-url <pattern> [change-id]')
@@ -1,4 +1,11 @@
1
- import { beforeEach, describe, expect, it, mock } from 'bun:test'
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 { abandonCommand } from '@/cli/commands/abandon'
7
+ import { ConfigService } from '@/services/config'
8
+ import { createMockConfigService } from './helpers/config-mock'
2
9
  import type { ChangeInfo } from '@/schemas/gerrit'
3
10
 
4
11
  const mockChange: ChangeInfo = {
@@ -28,136 +35,196 @@ const mockChange: ChangeInfo = {
28
35
  submittable: false,
29
36
  }
30
37
 
38
+ // Create MSW server
39
+ const server = setupServer(
40
+ // Default handler for auth check
41
+ http.get('*/a/accounts/self', ({ request }) => {
42
+ const auth = request.headers.get('Authorization')
43
+ if (!auth || !auth.startsWith('Basic ')) {
44
+ return HttpResponse.text('Unauthorized', { status: 401 })
45
+ }
46
+ return HttpResponse.json({
47
+ _account_id: 1000,
48
+ name: 'Test User',
49
+ email: 'test@example.com',
50
+ })
51
+ }),
52
+ )
53
+
31
54
  describe('abandon command', () => {
32
- let mockFetch: ReturnType<typeof mock>
55
+ let mockConsoleLog: ReturnType<typeof mock>
56
+ let mockConsoleError: ReturnType<typeof mock>
57
+
58
+ beforeAll(() => {
59
+ server.listen({ onUnhandledRequest: 'bypass' })
60
+ })
61
+
62
+ afterAll(() => {
63
+ server.close()
64
+ })
33
65
 
34
66
  beforeEach(() => {
35
- // Reset fetch mock for each test
36
- mockFetch = mock(() =>
37
- Promise.resolve({
38
- ok: true,
39
- status: 200,
40
- text: () => Promise.resolve(')]}\n{}'),
67
+ mockConsoleLog = mock(() => {})
68
+ mockConsoleError = mock(() => {})
69
+ console.log = mockConsoleLog
70
+ console.error = mockConsoleError
71
+ })
72
+
73
+ afterEach(() => {
74
+ server.resetHandlers()
75
+ })
76
+
77
+ it('should abandon a change with a message', async () => {
78
+ server.use(
79
+ http.get('*/a/changes/12345', () => {
80
+ return HttpResponse.text(`)]}'\n${JSON.stringify(mockChange)}`)
81
+ }),
82
+ http.post('*/a/changes/12345/abandon', async ({ request }) => {
83
+ const body = (await request.json()) as { message?: string }
84
+ expect(body.message).toBe('No longer needed')
85
+ return HttpResponse.text(")]}'\n{}")
41
86
  }),
42
87
  )
43
- global.fetch = mockFetch as unknown as typeof fetch
88
+
89
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
90
+ const program = abandonCommand('12345', {
91
+ message: 'No longer needed',
92
+ }).pipe(Effect.provide(GerritApiServiceLive), Effect.provide(mockConfigLayer))
93
+
94
+ await Effect.runPromise(program)
95
+
96
+ const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
97
+ expect(output).toContain('Abandoned change 12345')
98
+ expect(output).toContain('Test change to abandon')
99
+ expect(output).toContain('Message: No longer needed')
44
100
  })
45
101
 
46
- it('should call abandon API endpoint with correct parameters', async () => {
47
- // Mock successful responses
48
- mockFetch
49
- .mockResolvedValueOnce({
50
- ok: true,
51
- text: async () => `)]}'
52
- {
53
- "id": "test-project~master~I123",
54
- "project": "test-project",
55
- "branch": "master",
56
- "change_id": "I123",
57
- "subject": "Test change to abandon",
58
- "status": "NEW",
59
- "_number": 12345
60
- }`,
61
- })
62
- .mockResolvedValueOnce({
63
- ok: true,
64
- text: async () => ')]}\n{}',
65
- })
66
-
67
- // Note: This is a unit test demonstrating the API calls
68
- // Actual integration would require running the full command
69
- // which we avoid to prevent hitting production
70
-
71
- // Verify the mock setup
72
- const response = await mockFetch('https://test.gerrit.com/a/changes/12345')
73
- const text = await response.text()
74
- expect(text).toContain('Test change to abandon')
75
-
76
- // Verify abandon endpoint would be called
77
- const abandonResponse = await mockFetch('https://test.gerrit.com/a/changes/12345/abandon', {
78
- method: 'POST',
79
- body: JSON.stringify({ message: 'No longer needed' }),
80
- })
81
- expect(abandonResponse.ok).toBe(true)
102
+ it('should abandon a change without a message', async () => {
103
+ server.use(
104
+ http.get('*/a/changes/12345', () => {
105
+ return HttpResponse.text(`)]}'\n${JSON.stringify(mockChange)}`)
106
+ }),
107
+ http.post('*/a/changes/12345/abandon', async ({ request }) => {
108
+ const body = (await request.json()) as { message?: string }
109
+ expect(body.message).toBeUndefined()
110
+ return HttpResponse.text(")]}'\n{}")
111
+ }),
112
+ )
82
113
 
83
- // Verify calls were made
84
- expect(mockFetch).toHaveBeenCalledTimes(2)
114
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
115
+ const program = abandonCommand('12345', {}).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
+ expect(output).toContain('Abandoned change 12345')
124
+ expect(output).toContain('Test change to abandon')
125
+ expect(output).not.toContain('Message:')
85
126
  })
86
127
 
87
- it('should handle abandon without message', async () => {
88
- mockFetch.mockResolvedValueOnce({
89
- ok: true,
90
- text: async () => ')]}\n{}',
91
- })
128
+ it('should output XML format when --xml flag is used', async () => {
129
+ server.use(
130
+ http.get('*/a/changes/12345', () => {
131
+ return HttpResponse.text(`)]}'\n${JSON.stringify(mockChange)}`)
132
+ }),
133
+ http.post('*/a/changes/12345/abandon', async ({ request }) => {
134
+ const body = (await request.json()) as { message?: string }
135
+ expect(body.message).toBe('Abandoning for testing')
136
+ return HttpResponse.text(")]}'\n{}")
137
+ }),
138
+ )
92
139
 
93
- const response = await mockFetch('https://test.gerrit.com/a/changes/12345/abandon', {
94
- method: 'POST',
95
- body: JSON.stringify({}),
96
- })
140
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
141
+ const program = abandonCommand('12345', {
142
+ xml: true,
143
+ message: 'Abandoning for testing',
144
+ }).pipe(Effect.provide(GerritApiServiceLive), Effect.provide(mockConfigLayer))
145
+
146
+ await Effect.runPromise(program)
147
+
148
+ const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
149
+ expect(output).toContain('<?xml version="1.0" encoding="UTF-8"?>')
150
+ expect(output).toContain('<abandon_result>')
151
+ expect(output).toContain('<status>success</status>')
152
+ expect(output).toContain('<change_number>12345</change_number>')
153
+ expect(output).toContain('<subject><![CDATA[Test change to abandon]]></subject>')
154
+ expect(output).toContain('<message><![CDATA[Abandoning for testing]]></message>')
155
+ expect(output).toContain('</abandon_result>')
156
+ })
97
157
 
98
- expect(response.ok).toBe(true)
99
- expect(mockFetch).toHaveBeenCalledWith('https://test.gerrit.com/a/changes/12345/abandon', {
100
- method: 'POST',
101
- body: JSON.stringify({}),
102
- })
158
+ it('should output XML format without message when no message provided', async () => {
159
+ server.use(
160
+ http.get('*/a/changes/12345', () => {
161
+ return HttpResponse.text(`)]}'\n${JSON.stringify(mockChange)}`)
162
+ }),
163
+ http.post('*/a/changes/12345/abandon', async () => {
164
+ return HttpResponse.text(")]}'\n{}")
165
+ }),
166
+ )
167
+
168
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
169
+ const program = abandonCommand('12345', { xml: true }).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('<abandon_result>')
178
+ expect(output).toContain('<status>success</status>')
179
+ expect(output).not.toContain('<message>')
103
180
  })
104
181
 
105
- it('should handle API errors', async () => {
106
- mockFetch.mockResolvedValueOnce({
107
- ok: false,
108
- status: 404,
109
- text: async () => 'Change not found',
110
- })
182
+ it('should handle not found errors gracefully', async () => {
183
+ server.use(
184
+ http.get('*/a/changes/99999', () => {
185
+ return HttpResponse.text('Change not found', { status: 404 })
186
+ }),
187
+ )
111
188
 
112
- const response = await mockFetch('https://test.gerrit.com/a/changes/99999/abandon')
113
- expect(response.ok).toBe(false)
114
- expect(response.status).toBe(404)
189
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
190
+ const program = abandonCommand('99999', {
191
+ message: 'Test message',
192
+ }).pipe(Effect.provide(GerritApiServiceLive), Effect.provide(mockConfigLayer))
115
193
 
116
- const errorText = await response.text()
117
- expect(errorText).toBe('Change not found')
194
+ // Should fail when change is not found
195
+ await expect(Effect.runPromise(program)).rejects.toThrow()
118
196
  })
119
197
 
120
- it('should format message correctly in request body', () => {
121
- const testCases = [
122
- { input: undefined, expected: {} },
123
- { input: '', expected: {} },
124
- { input: 'Abandoning this change', expected: { message: 'Abandoning this change' } },
125
- { input: 'Multi\nline\nmessage', expected: { message: 'Multi\nline\nmessage' } },
126
- ]
127
-
128
- for (const testCase of testCases) {
129
- const body = testCase.input ? { message: testCase.input } : {}
130
- expect(body).toEqual(testCase.expected)
131
- }
198
+ it('should show error when change ID is not provided', async () => {
199
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
200
+ const program = abandonCommand(undefined, {}).pipe(
201
+ Effect.provide(GerritApiServiceLive),
202
+ Effect.provide(mockConfigLayer),
203
+ )
204
+
205
+ await Effect.runPromise(program)
206
+
207
+ const errorOutput = mockConsoleError.mock.calls.map((call) => call[0]).join('\n')
208
+ expect(errorOutput).toContain('Change ID is required')
209
+ expect(errorOutput).toContain('Usage: ger abandon <change-id>')
132
210
  })
133
211
 
134
- describe('interactive mode API patterns', () => {
135
- it('should fetch changes for interactive mode', async () => {
136
- mockFetch.mockResolvedValueOnce({
137
- ok: true,
138
- text: async () => `)]}'\n[${JSON.stringify(mockChange)}]`,
139
- })
140
-
141
- // Test the API call pattern for interactive mode
142
- const response = await mockFetch(
143
- 'https://test.gerrit.com/a/changes/?q=owner:self+status:open',
144
- )
145
- const text = await response.text()
146
- expect(text).toContain('Test change to abandon')
147
- })
212
+ it('should handle abandon API failure', async () => {
213
+ server.use(
214
+ http.get('*/a/changes/12345', () => {
215
+ return HttpResponse.text(`)]}'\n${JSON.stringify(mockChange)}`)
216
+ }),
217
+ http.post('*/a/changes/12345/abandon', () => {
218
+ return HttpResponse.text('Forbidden', { status: 403 })
219
+ }),
220
+ )
148
221
 
149
- it('should handle empty changes list response', async () => {
150
- mockFetch.mockResolvedValueOnce({
151
- ok: true,
152
- text: async () => ")]}'\n[]",
153
- })
154
-
155
- const response = await mockFetch(
156
- 'https://test.gerrit.com/a/changes/?q=owner:self+status:open',
157
- )
158
- const text = await response.text()
159
- const parsed = JSON.parse(text.replace(")]}'\n", ''))
160
- expect(parsed).toEqual([])
161
- })
222
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
223
+ const program = abandonCommand('12345', {
224
+ message: 'Test',
225
+ }).pipe(Effect.provide(GerritApiServiceLive), Effect.provide(mockConfigLayer))
226
+
227
+ // Should throw/fail
228
+ await expect(Effect.runPromise(program)).rejects.toThrow()
162
229
  })
163
230
  })