@aaronshaf/ger 0.2.5 → 0.3.1

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/README.md CHANGED
@@ -59,6 +59,10 @@ ger extract-url "build-summary-report" | tail -1
59
59
  ger build-status 12345 # Returns: pending, running, success, failure, or not_found
60
60
  ger build-status # Auto-detects from HEAD commit
61
61
 
62
+ # Watch build status until completion (like gh run watch)
63
+ ger build-status 12345 --watch
64
+ ger build-status --watch --exit-status && deploy.sh
65
+
62
66
  # AI-powered code review (requires claude, llm, or opencode CLI)
63
67
  ger review 12345
64
68
  ger review 12345 --dry-run # Preview without posting
@@ -257,21 +261,48 @@ ger extract-url "job/[^/]+/job/[^/]+/\d+/$" --regex
257
261
 
258
262
  Check the CI build status of a change by parsing Gerrit messages for build events and verification results:
259
263
 
264
+ #### Single Check (Snapshot)
260
265
  ```bash
261
266
  # Check build status for a specific change
262
267
  ger build-status 12345
268
+ # Output: {"state":"success"}
263
269
 
264
270
  # Auto-detect change from HEAD commit
265
271
  ger build-status
266
272
 
267
273
  # Use in scripts with jq
268
274
  ger build-status | jq -r '.state'
275
+ ```
276
+
277
+ #### Watch Mode (Poll Until Completion)
278
+ Like `gh run watch`, you can poll the build status until it reaches a terminal state:
279
+
280
+ ```bash
281
+ # Watch until completion (outputs JSON on each poll)
282
+ ger build-status 12345 --watch
283
+ # Output:
284
+ # {"state":"pending"}
285
+ # {"state":"running"}
286
+ # {"state":"running"}
287
+ # {"state":"success"}
288
+
289
+ # Auto-detect from HEAD commit
290
+ ger build-status --watch
269
291
 
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
292
+ # Custom polling interval (check every 5 seconds, default: 10)
293
+ ger build-status --watch --interval 5
294
+
295
+ # Custom timeout (60 minutes, default: 30 minutes)
296
+ ger build-status --watch --timeout 3600
297
+
298
+ # Exit with code 1 on build failure (for CI/CD pipelines)
299
+ ger build-status --watch --exit-status && deploy.sh
300
+
301
+ # Trigger notification when done (like gh run watch pattern)
302
+ ger build-status --watch && notify-send 'Build is done!'
303
+
304
+ # Extract final state in scripts
305
+ ger build-status --watch | tail -1 | jq -r '.state'
275
306
  ```
276
307
 
277
308
  #### Output format (JSON):
@@ -286,17 +317,24 @@ done
286
317
  - **`failure`**: Verified -1 after most recent "Build Started"
287
318
  - **`not_found`**: Change does not exist
288
319
 
320
+ #### Exit codes:
321
+ - **`0`**: Default for all states (like `gh run watch`)
322
+ - **`1`**: Only when `--exit-status` flag is used AND build fails
323
+ - **`2`**: Timeout reached in watch mode
324
+ - **`3`**: API/network errors
325
+
289
326
  #### How it works:
290
327
  1. Fetches all messages for the change
291
328
  2. Finds the most recent "Build Started" message
292
329
  3. Checks for "Verified +1" or "Verified -1" messages after the build started
293
330
  4. Returns the appropriate state
331
+ 5. In watch mode: polls every N seconds until terminal state or timeout
294
332
 
295
333
  #### Use cases:
296
334
  - **CI/CD integration**: Wait for builds before proceeding with deployment
297
335
  - **Automation**: Trigger actions based on build results
298
336
  - **Scripting**: Check build status in shell scripts
299
- - **Monitoring**: Poll build status for long-running builds
337
+ - **Monitoring**: Poll build status for long-running builds with watch mode
300
338
 
301
339
  ### Diff
302
340
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aaronshaf/ger",
3
- "version": "0.2.5",
3
+ "version": "0.3.1",
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",
@@ -6,6 +6,23 @@ import { getChangeIdFromHead, GitError, NoChangeIdError } from '@/utils/git-comm
6
6
  // Export types for external use
7
7
  export type BuildState = 'pending' | 'running' | 'success' | 'failure' | 'not_found'
8
8
 
9
+ // Watch options (matches gh run watch pattern)
10
+ export type WatchOptions = {
11
+ readonly watch: boolean
12
+ readonly interval: number // seconds
13
+ readonly timeout: number // seconds
14
+ readonly exitStatus: boolean
15
+ }
16
+
17
+ // Timeout error for watch mode
18
+ export class TimeoutError extends Error {
19
+ readonly _tag = 'TimeoutError'
20
+ constructor(message: string) {
21
+ super(message)
22
+ this.name = 'TimeoutError'
23
+ }
24
+ }
25
+
9
26
  // Effect Schema for BuildStatus (follows project patterns)
10
27
  export const BuildStatus: Schema.Schema<{
11
28
  readonly state: 'pending' | 'running' | 'success' | 'failure' | 'not_found'
@@ -71,38 +88,143 @@ const getMessagesForChange = (
71
88
  })
72
89
 
73
90
  /**
74
- * Build status command - determines build status from Gerrit messages
91
+ * Poll build status until terminal state or timeout
92
+ * Outputs JSON status on each iteration (mimics gh run watch)
93
+ */
94
+ const pollBuildStatus = (
95
+ changeId: string,
96
+ options: WatchOptions,
97
+ ): Effect.Effect<BuildStatus, ApiError | TimeoutError, GerritApiService> =>
98
+ Effect.gen(function* () {
99
+ const startTime = Date.now()
100
+ const timeoutMs = options.timeout * 1000
101
+
102
+ // Initial message to stderr
103
+ yield* Effect.sync(() => {
104
+ console.error(
105
+ `Watching build status (polling every ${options.interval}s, timeout: ${options.timeout}s)...`,
106
+ )
107
+ })
108
+
109
+ while (true) {
110
+ // Check timeout
111
+ const elapsed = Date.now() - startTime
112
+ if (elapsed > timeoutMs) {
113
+ yield* Effect.sync(() => {
114
+ console.error(`Timeout: Build status check exceeded ${options.timeout}s`)
115
+ })
116
+ yield* Effect.fail(
117
+ new TimeoutError(`Build status check timed out after ${options.timeout}s`),
118
+ )
119
+ }
120
+
121
+ // Fetch and parse status
122
+ const messages = yield* getMessagesForChange(changeId)
123
+ const status = parseBuildStatus(messages)
124
+
125
+ // Check timeout again after API call (in case it took longer than expected)
126
+ const elapsedAfterFetch = Date.now() - startTime
127
+ if (elapsedAfterFetch > timeoutMs) {
128
+ yield* Effect.sync(() => {
129
+ console.error(`Timeout: Build status check exceeded ${options.timeout}s`)
130
+ })
131
+ yield* Effect.fail(
132
+ new TimeoutError(`Build status check timed out after ${options.timeout}s`),
133
+ )
134
+ }
135
+
136
+ // Output current status to stdout (JSON, like single-check mode)
137
+ yield* Effect.sync(() => {
138
+ process.stdout.write(JSON.stringify(status) + '\n')
139
+ })
140
+
141
+ // Terminal states - return immediately
142
+ if (
143
+ status.state === 'success' ||
144
+ status.state === 'failure' ||
145
+ status.state === 'not_found'
146
+ ) {
147
+ yield* Effect.sync(() => {
148
+ console.error(`Build completed with status: ${status.state}`)
149
+ })
150
+ return status
151
+ }
152
+
153
+ // Non-terminal states - log progress and wait
154
+ const elapsedSeconds = Math.floor(elapsed / 1000)
155
+ yield* Effect.sync(() => {
156
+ console.error(`[${elapsedSeconds}s elapsed] Build status: ${status.state}`)
157
+ })
158
+
159
+ // Sleep for interval duration
160
+ yield* Effect.sleep(options.interval * 1000)
161
+ }
162
+ })
163
+
164
+ /**
165
+ * Build status command with optional watch mode (mimics gh run watch)
75
166
  */
76
167
  export const buildStatusCommand = (
77
168
  changeId: string | undefined,
78
- ): Effect.Effect<void, ApiError | Error | GitError | NoChangeIdError, GerritApiService> =>
169
+ options: Partial<WatchOptions> = {},
170
+ ): Effect.Effect<
171
+ void,
172
+ ApiError | Error | GitError | NoChangeIdError | TimeoutError,
173
+ GerritApiService
174
+ > =>
79
175
  Effect.gen(function* () {
80
176
  // Auto-detect Change-ID from HEAD commit if not provided
81
177
  const resolvedChangeId = changeId || (yield* getChangeIdFromHead())
82
178
 
83
- // Fetch messages
84
- const messages = yield* getMessagesForChange(resolvedChangeId)
179
+ // Set defaults (matching gh run watch patterns)
180
+ const watchOpts: WatchOptions = {
181
+ watch: options.watch ?? false,
182
+ interval: Math.max(1, options.interval ?? 10), // Min 1 second
183
+ timeout: Math.max(1, options.timeout ?? 1800), // Min 1 second, default 30 minutes
184
+ exitStatus: options.exitStatus ?? false,
185
+ }
85
186
 
86
- // Parse build status
87
- const status = parseBuildStatus(messages)
187
+ let status: BuildStatus
88
188
 
89
- // Output JSON to stdout
90
- const jsonOutput = JSON.stringify(status) + '\n'
91
- yield* Effect.sync(() => {
92
- process.stdout.write(jsonOutput)
93
- })
189
+ if (watchOpts.watch) {
190
+ // Polling mode - outputs JSON on each iteration
191
+ status = yield* pollBuildStatus(resolvedChangeId, watchOpts)
192
+ } else {
193
+ // Single check mode (existing behavior)
194
+ const messages = yield* getMessagesForChange(resolvedChangeId)
195
+ status = parseBuildStatus(messages)
196
+
197
+ // Output JSON to stdout
198
+ yield* Effect.sync(() => {
199
+ process.stdout.write(JSON.stringify(status) + '\n')
200
+ })
201
+ }
202
+
203
+ // Handle exit codes (only non-zero when explicitly requested)
204
+ if (watchOpts.exitStatus && status.state === 'failure') {
205
+ yield* Effect.sync(() => process.exit(1))
206
+ }
207
+
208
+ // Default: exit 0 for all states (success, failure, pending, etc.)
94
209
  }).pipe(
95
- // Error handling - return not_found for API errors (e.g., change not found)
96
210
  Effect.catchAll((error) => {
211
+ // Timeout error
212
+ if (error instanceof TimeoutError) {
213
+ return Effect.sync(() => {
214
+ console.error(`Error: ${error.message}`)
215
+ process.exit(2)
216
+ })
217
+ }
218
+
219
+ // 404 - change not found
97
220
  if (error && typeof error === 'object' && 'status' in error && error.status === 404) {
98
- // Change not found - output not_found state and exit successfully
99
221
  const status: BuildStatus = { state: 'not_found' }
100
222
  return Effect.sync(() => {
101
223
  process.stdout.write(JSON.stringify(status) + '\n')
102
224
  })
103
225
  }
104
226
 
105
- // For other errors, write to stderr and exit with error
227
+ // Other errors - exit 3
106
228
  const errorMessage =
107
229
  error instanceof GitError || error instanceof NoChangeIdError || error instanceof Error
108
230
  ? error.message
@@ -110,7 +232,7 @@ export const buildStatusCommand = (
110
232
 
111
233
  return Effect.sync(() => {
112
234
  console.error(`Error: ${errorMessage}`)
113
- process.exit(1)
235
+ process.exit(3)
114
236
  })
115
237
  }),
116
238
  )
package/src/cli/index.ts CHANGED
@@ -426,6 +426,10 @@ program
426
426
  .description(
427
427
  'Check build status from Gerrit messages (auto-detects from HEAD commit if not specified)',
428
428
  )
429
+ .option('--watch', 'Watch build status until completion (mimics gh run watch)')
430
+ .option('-i, --interval <seconds>', 'Refresh interval in seconds (default: 10)', '10')
431
+ .option('--timeout <seconds>', 'Maximum wait time in seconds (default: 1800 / 30min)', '1800')
432
+ .option('--exit-status', 'Exit with non-zero status if build fails')
429
433
  .addHelpText(
430
434
  'after',
431
435
  `
@@ -439,40 +443,58 @@ Output is JSON with a "state" field that can be:
439
443
  - failure: Build completed with Verified-1
440
444
  - not_found: Change does not exist
441
445
 
446
+ Exit codes:
447
+ - 0: Default for all states (like gh run watch)
448
+ - 1: Only when --exit-status is used AND build fails
449
+ - 2: Timeout reached in watch mode
450
+ - 3: API/network errors
451
+
442
452
  Examples:
443
- # Check build status for specific change (using change number)
453
+ # Single check (current behavior)
444
454
  $ ger build-status 392385
445
455
  {"state":"success"}
446
456
 
447
- # Check build status for specific change (using Change-ID)
448
- $ ger build-status If5a3ae8cb5a107e187447802358417f311d0c4b1
457
+ # Watch until completion (outputs JSON on each poll)
458
+ $ ger build-status 392385 --watch
459
+ {"state":"pending"}
460
+ {"state":"running"}
449
461
  {"state":"running"}
462
+ {"state":"success"}
450
463
 
451
- # Auto-detect from HEAD commit
452
- $ ger build-status
453
- {"state":"pending"}
464
+ # Watch with custom interval (check every 5 seconds)
465
+ $ ger build-status --watch --interval 5
466
+
467
+ # Watch with custom timeout (60 minutes)
468
+ $ ger build-status --watch --timeout 3600
454
469
 
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
470
+ # Exit with code 1 on failure (for CI/CD pipelines)
471
+ $ ger build-status --watch --exit-status && deploy.sh
472
+
473
+ # Trigger notification when done (like gh run watch pattern)
474
+ $ ger build-status --watch && notify-send 'Build is done!'
475
+
476
+ # Parse final state in scripts
477
+ $ ger build-status --watch | tail -1 | jq -r '.state'
478
+ success
459
479
 
460
480
  Note: When no change-id is provided, it will be automatically extracted from the
461
481
  Change-ID footer in your HEAD commit.`,
462
482
  )
463
- .action(async (changeId) => {
483
+ .action(async (changeId, cmdOptions) => {
464
484
  try {
465
- const effect = buildStatusCommand(changeId).pipe(
466
- Effect.provide(GerritApiServiceLive),
467
- Effect.provide(ConfigServiceLive),
468
- )
485
+ const effect = buildStatusCommand(changeId, {
486
+ watch: cmdOptions.watch,
487
+ interval: Number.parseInt(cmdOptions.interval, 10),
488
+ timeout: Number.parseInt(cmdOptions.timeout, 10),
489
+ exitStatus: cmdOptions.exitStatus,
490
+ }).pipe(Effect.provide(GerritApiServiceLive), Effect.provide(ConfigServiceLive))
469
491
  await Effect.runPromise(effect)
470
492
  } catch (error) {
471
493
  // Errors are handled within the command itself
472
494
  // This catch is just for any unexpected errors
473
495
  if (error instanceof Error && error.message !== 'Process exited') {
474
496
  console.error('✗ Unexpected error:', error.message)
475
- process.exit(1)
497
+ process.exit(3)
476
498
  }
477
499
  }
478
500
  })
@@ -0,0 +1,347 @@
1
+ import { describe, test, expect, beforeAll, afterAll, afterEach } from 'bun:test'
2
+ import { http, HttpResponse } from 'msw'
3
+ import { Effect } from 'effect'
4
+ import { buildStatusCommand } from '@/cli/commands/build-status'
5
+ import { GerritApiServiceLive } from '@/api/gerrit'
6
+ import type { MessageInfo } from '@/schemas/gerrit'
7
+ import {
8
+ server,
9
+ capturedStdout,
10
+ capturedErrors,
11
+ mockProcessExit,
12
+ setupBuildStatusTests,
13
+ teardownBuildStatusTests,
14
+ resetBuildStatusMocks,
15
+ createMockConfigLayer,
16
+ } from './helpers/build-status-test-setup'
17
+
18
+ beforeAll(() => {
19
+ setupBuildStatusTests()
20
+ })
21
+
22
+ afterAll(() => {
23
+ teardownBuildStatusTests()
24
+ })
25
+
26
+ afterEach(() => {
27
+ resetBuildStatusMocks()
28
+ })
29
+
30
+ describe('build-status command - watch mode', () => {
31
+ test('polls until success state is reached', async () => {
32
+ let callCount = 0
33
+
34
+ server.use(
35
+ http.get('*/a/changes/12345', ({ request }) => {
36
+ const url = new URL(request.url)
37
+ if (url.searchParams.get('o') === 'MESSAGES') {
38
+ callCount++
39
+
40
+ let messages: MessageInfo[]
41
+ if (callCount === 1) {
42
+ // First call: pending (no build started)
43
+ messages = []
44
+ } else if (callCount === 2) {
45
+ // Second call: running (build started, no verification)
46
+ messages = [
47
+ {
48
+ id: 'msg1',
49
+ message: 'Build Started',
50
+ date: '2024-01-15 10:00:00.000000000',
51
+ author: { _account_id: 9999, name: 'CI Bot' },
52
+ },
53
+ ]
54
+ } else {
55
+ // Third call: success (verified +1)
56
+ messages = [
57
+ {
58
+ id: 'msg1',
59
+ message: 'Build Started',
60
+ date: '2024-01-15 10:00:00.000000000',
61
+ author: { _account_id: 9999, name: 'CI Bot' },
62
+ },
63
+ {
64
+ id: 'msg2',
65
+ message: 'Patch Set 1: Verified+1',
66
+ date: '2024-01-15 10:05:00.000000000',
67
+ author: { _account_id: 9999, name: 'CI Bot' },
68
+ },
69
+ ]
70
+ }
71
+
72
+ return HttpResponse.json(
73
+ { messages },
74
+ { headers: { 'Content-Type': 'application/json' } },
75
+ )
76
+ }
77
+ return HttpResponse.text('Not Found', { status: 404 })
78
+ }),
79
+ )
80
+
81
+ const effect = buildStatusCommand('12345', {
82
+ watch: true,
83
+ interval: 0.1, // Fast polling for tests
84
+ timeout: 10,
85
+ }).pipe(Effect.provide(GerritApiServiceLive), Effect.provide(createMockConfigLayer()))
86
+
87
+ await Effect.runPromise(effect)
88
+
89
+ // Should have multiple outputs (one per poll)
90
+ expect(capturedStdout.length).toBeGreaterThanOrEqual(3)
91
+ expect(JSON.parse(capturedStdout[0])).toEqual({ state: 'pending' })
92
+ expect(JSON.parse(capturedStdout[1])).toEqual({ state: 'running' })
93
+ expect(JSON.parse(capturedStdout[2])).toEqual({ state: 'success' })
94
+
95
+ // Should have logged progress to stderr
96
+ expect(capturedErrors.length).toBeGreaterThan(0)
97
+ expect(capturedErrors.some((e: string) => e.includes('Watching build status'))).toBe(true)
98
+ expect(
99
+ capturedErrors.some((e: string) => e.includes('Build completed with status: success')),
100
+ ).toBe(true)
101
+ })
102
+
103
+ test('polls until failure state is reached', async () => {
104
+ let callCount = 0
105
+
106
+ server.use(
107
+ http.get('*/a/changes/12345', ({ request }) => {
108
+ const url = new URL(request.url)
109
+ if (url.searchParams.get('o') === 'MESSAGES') {
110
+ callCount++
111
+
112
+ let messages: MessageInfo[]
113
+ if (callCount === 1) {
114
+ messages = [
115
+ {
116
+ id: 'msg1',
117
+ message: 'Build Started',
118
+ date: '2024-01-15 10:00:00.000000000',
119
+ author: { _account_id: 9999, name: 'CI Bot' },
120
+ },
121
+ ]
122
+ } else {
123
+ messages = [
124
+ {
125
+ id: 'msg1',
126
+ message: 'Build Started',
127
+ date: '2024-01-15 10:00:00.000000000',
128
+ author: { _account_id: 9999, name: 'CI Bot' },
129
+ },
130
+ {
131
+ id: 'msg2',
132
+ message: 'Patch Set 1: Verified-1',
133
+ date: '2024-01-15 10:05:00.000000000',
134
+ author: { _account_id: 9999, name: 'CI Bot' },
135
+ },
136
+ ]
137
+ }
138
+
139
+ return HttpResponse.json(
140
+ { messages },
141
+ { headers: { 'Content-Type': 'application/json' } },
142
+ )
143
+ }
144
+ return HttpResponse.text('Not Found', { status: 404 })
145
+ }),
146
+ )
147
+
148
+ const effect = buildStatusCommand('12345', {
149
+ watch: true,
150
+ interval: 0.1,
151
+ timeout: 10,
152
+ }).pipe(Effect.provide(GerritApiServiceLive), Effect.provide(createMockConfigLayer()))
153
+
154
+ await Effect.runPromise(effect)
155
+
156
+ expect(capturedStdout.length).toBeGreaterThanOrEqual(2)
157
+ expect(JSON.parse(capturedStdout[capturedStdout.length - 1])).toEqual({ state: 'failure' })
158
+ expect(
159
+ capturedErrors.some((e: string) => e.includes('Build completed with status: failure')),
160
+ ).toBe(true)
161
+ })
162
+
163
+ test('times out after specified duration', async () => {
164
+ server.use(
165
+ http.get('*/a/changes/12345', ({ request }) => {
166
+ const url = new URL(request.url)
167
+ if (url.searchParams.get('o') === 'MESSAGES') {
168
+ // Always return running state
169
+ return HttpResponse.json(
170
+ {
171
+ messages: [
172
+ {
173
+ id: 'msg1',
174
+ message: 'Build Started',
175
+ date: '2024-01-15 10:00:00.000000000',
176
+ author: { _account_id: 9999, name: 'CI Bot' },
177
+ },
178
+ ],
179
+ },
180
+ { headers: { 'Content-Type': 'application/json' } },
181
+ )
182
+ }
183
+ return HttpResponse.text('Not Found', { status: 404 })
184
+ }),
185
+ )
186
+
187
+ const effect = buildStatusCommand('12345', {
188
+ watch: true,
189
+ interval: 0.1,
190
+ timeout: 0.5, // Very short timeout
191
+ }).pipe(Effect.provide(GerritApiServiceLive), Effect.provide(createMockConfigLayer()))
192
+
193
+ try {
194
+ await Effect.runPromise(effect)
195
+ } catch {
196
+ // Should exit with code 2 for timeout
197
+ expect(mockProcessExit).toHaveBeenCalledWith(2)
198
+ expect(capturedErrors.some((e: string) => e.includes('Timeout'))).toBe(true)
199
+ }
200
+ })
201
+
202
+ test('exit-status flag causes exit 1 on failure', async () => {
203
+ server.use(
204
+ http.get('*/a/changes/12345', ({ request }) => {
205
+ const url = new URL(request.url)
206
+ if (url.searchParams.get('o') === 'MESSAGES') {
207
+ return HttpResponse.json(
208
+ {
209
+ messages: [
210
+ {
211
+ id: 'msg1',
212
+ message: 'Build Started',
213
+ date: '2024-01-15 10:00:00.000000000',
214
+ author: { _account_id: 9999, name: 'CI Bot' },
215
+ },
216
+ {
217
+ id: 'msg2',
218
+ message: 'Patch Set 1: Verified-1',
219
+ date: '2024-01-15 10:05:00.000000000',
220
+ author: { _account_id: 9999, name: 'CI Bot' },
221
+ },
222
+ ],
223
+ },
224
+ { headers: { 'Content-Type': 'application/json' } },
225
+ )
226
+ }
227
+ return HttpResponse.text('Not Found', { status: 404 })
228
+ }),
229
+ )
230
+
231
+ const effect = buildStatusCommand('12345', {
232
+ watch: true,
233
+ interval: 0.1,
234
+ timeout: 10,
235
+ exitStatus: true,
236
+ }).pipe(Effect.provide(GerritApiServiceLive), Effect.provide(createMockConfigLayer()))
237
+
238
+ try {
239
+ await Effect.runPromise(effect)
240
+ } catch {
241
+ // Should exit with code 1 for failure when --exit-status is used
242
+ expect(mockProcessExit).toHaveBeenCalledWith(1)
243
+ }
244
+ })
245
+
246
+ test('exit-status flag does not affect success state', async () => {
247
+ server.use(
248
+ http.get('*/a/changes/12345', ({ request }) => {
249
+ const url = new URL(request.url)
250
+ if (url.searchParams.get('o') === 'MESSAGES') {
251
+ return HttpResponse.json(
252
+ {
253
+ messages: [
254
+ {
255
+ id: 'msg1',
256
+ message: 'Build Started',
257
+ date: '2024-01-15 10:00:00.000000000',
258
+ author: { _account_id: 9999, name: 'CI Bot' },
259
+ },
260
+ {
261
+ id: 'msg2',
262
+ message: 'Patch Set 1: Verified+1',
263
+ date: '2024-01-15 10:05:00.000000000',
264
+ author: { _account_id: 9999, name: 'CI Bot' },
265
+ },
266
+ ],
267
+ },
268
+ { headers: { 'Content-Type': 'application/json' } },
269
+ )
270
+ }
271
+ return HttpResponse.text('Not Found', { status: 404 })
272
+ }),
273
+ )
274
+
275
+ const effect = buildStatusCommand('12345', {
276
+ watch: true,
277
+ interval: 0.1,
278
+ timeout: 10,
279
+ exitStatus: true,
280
+ }).pipe(Effect.provide(GerritApiServiceLive), Effect.provide(createMockConfigLayer()))
281
+
282
+ await Effect.runPromise(effect)
283
+
284
+ // Should not call process.exit for success state
285
+ expect(mockProcessExit).not.toHaveBeenCalled()
286
+ expect(JSON.parse(capturedStdout[0])).toEqual({ state: 'success' })
287
+ })
288
+
289
+ test('watch mode handles not_found state', async () => {
290
+ server.use(
291
+ http.get('*/a/changes/99999', () => {
292
+ return HttpResponse.text('Not Found', { status: 404 })
293
+ }),
294
+ )
295
+
296
+ const effect = buildStatusCommand('99999', {
297
+ watch: true,
298
+ interval: 0.1,
299
+ timeout: 10,
300
+ }).pipe(Effect.provide(GerritApiServiceLive), Effect.provide(createMockConfigLayer()))
301
+
302
+ await Effect.runPromise(effect)
303
+
304
+ expect(capturedStdout.length).toBe(1)
305
+ expect(JSON.parse(capturedStdout[0])).toEqual({ state: 'not_found' })
306
+ // 404 errors bypass pollBuildStatus and are handled in error handler
307
+ // So we get "Watching build status" but not "Build completed" message
308
+ expect(capturedErrors.some((e: string) => e.includes('Watching build status'))).toBe(true)
309
+ })
310
+
311
+ test('without watch flag, behaves as single check', async () => {
312
+ server.use(
313
+ http.get('*/a/changes/12345', ({ request }) => {
314
+ const url = new URL(request.url)
315
+ if (url.searchParams.get('o') === 'MESSAGES') {
316
+ return HttpResponse.json(
317
+ {
318
+ messages: [
319
+ {
320
+ id: 'msg1',
321
+ message: 'Build Started',
322
+ date: '2024-01-15 10:00:00.000000000',
323
+ author: { _account_id: 9999, name: 'CI Bot' },
324
+ },
325
+ ],
326
+ },
327
+ { headers: { 'Content-Type': 'application/json' } },
328
+ )
329
+ }
330
+ return HttpResponse.text('Not Found', { status: 404 })
331
+ }),
332
+ )
333
+
334
+ const effect = buildStatusCommand('12345', {
335
+ watch: false,
336
+ }).pipe(Effect.provide(GerritApiServiceLive), Effect.provide(createMockConfigLayer()))
337
+
338
+ await Effect.runPromise(effect)
339
+
340
+ // Should only have one output (no polling)
341
+ expect(capturedStdout.length).toBe(1)
342
+ expect(JSON.parse(capturedStdout[0])).toEqual({ state: 'running' })
343
+
344
+ // Should not have watch mode messages in stderr
345
+ expect(capturedErrors.some((e: string) => e.includes('Watching build status'))).toBe(false)
346
+ })
347
+ })
@@ -1,84 +1,33 @@
1
- import { describe, test, expect, beforeAll, afterAll, afterEach, mock } from 'bun:test'
2
- import { setupServer } from 'msw/node'
1
+ import { describe, test, expect, beforeAll, afterAll, afterEach } from 'bun:test'
3
2
  import { http, HttpResponse } from 'msw'
4
- import { Effect, Layer } from 'effect'
3
+ import { Effect } from 'effect'
5
4
  import { buildStatusCommand } from '@/cli/commands/build-status'
6
5
  import { GerritApiServiceLive } from '@/api/gerrit'
7
- import { ConfigService } from '@/services/config'
8
6
  import type { MessageInfo } from '@/schemas/gerrit'
9
- import { createMockConfigService } from './helpers/config-mock'
10
-
11
- const server = setupServer(
12
- // Default handler for auth check
13
- http.get('*/a/accounts/self', ({ request }) => {
14
- const auth = request.headers.get('Authorization')
15
- if (!auth || !auth.startsWith('Basic ')) {
16
- return HttpResponse.text('Unauthorized', { status: 401 })
17
- }
18
- return HttpResponse.json({
19
- _account_id: 1000,
20
- name: 'Test User',
21
- email: 'test@example.com',
22
- })
23
- }),
24
- )
25
-
26
- // Store captured output
27
- let capturedStdout: string[] = []
28
- let capturedErrors: string[] = []
29
-
30
- // Mock process.stdout.write to capture JSON output
31
- const mockStdoutWrite = mock((chunk: any) => {
32
- capturedStdout.push(String(chunk))
33
- return true
34
- })
35
-
36
- // Mock console.error to capture errors
37
- const mockConsoleError = mock((...args: any[]) => {
38
- capturedErrors.push(args.join(' '))
39
- })
40
-
41
- // Mock process.exit to prevent test termination
42
- const mockProcessExit = mock((_code?: number) => {
43
- throw new Error('Process exited')
44
- })
45
-
46
- // Store original methods
47
- const originalStdoutWrite = process.stdout.write
48
- const originalConsoleError = console.error
49
- const originalProcessExit = process.exit
7
+ import {
8
+ server,
9
+ capturedStdout,
10
+ capturedErrors,
11
+ mockProcessExit,
12
+ setupBuildStatusTests,
13
+ teardownBuildStatusTests,
14
+ resetBuildStatusMocks,
15
+ createMockConfigLayer,
16
+ } from './helpers/build-status-test-setup'
50
17
 
51
18
  beforeAll(() => {
52
- server.listen({ onUnhandledRequest: 'bypass' })
53
- // @ts-ignore - Mocking stdout
54
- process.stdout.write = mockStdoutWrite
55
- // @ts-ignore - Mocking console
56
- console.error = mockConsoleError
57
- // @ts-ignore - Mocking process.exit
58
- process.exit = mockProcessExit
19
+ setupBuildStatusTests()
59
20
  })
60
21
 
61
22
  afterAll(() => {
62
- server.close()
63
- // @ts-ignore - Restoring stdout
64
- process.stdout.write = originalStdoutWrite
65
- console.error = originalConsoleError
66
- // @ts-ignore - Restoring process.exit
67
- process.exit = originalProcessExit
23
+ teardownBuildStatusTests()
68
24
  })
69
25
 
70
26
  afterEach(() => {
71
- server.resetHandlers()
72
- mockStdoutWrite.mockClear()
73
- mockConsoleError.mockClear()
74
- mockProcessExit.mockClear()
75
- capturedStdout = []
76
- capturedErrors = []
27
+ resetBuildStatusMocks()
77
28
  })
78
29
 
79
30
  describe('build-status command', () => {
80
- const createMockConfigLayer = () => Layer.succeed(ConfigService, createMockConfigService())
81
-
82
31
  test('returns pending when no Build Started message found', async () => {
83
32
  const messages: MessageInfo[] = [
84
33
  {
@@ -590,9 +539,9 @@ describe('build-status command', () => {
590
539
 
591
540
  try {
592
541
  await Effect.runPromise(effect)
593
- } catch (_error) {
594
- // Should throw error and call process.exit
595
- expect(mockProcessExit).toHaveBeenCalledWith(1)
542
+ } catch {
543
+ // Should throw error and call process.exit with code 3 for API errors
544
+ expect(mockProcessExit).toHaveBeenCalledWith(3)
596
545
  expect(capturedErrors.length).toBeGreaterThan(0)
597
546
  }
598
547
  })
@@ -0,0 +1,83 @@
1
+ import type { Mock } from 'bun:test'
2
+ import { mock } from 'bun:test'
3
+ import type { SetupServer } from 'msw/node'
4
+ import { setupServer } from 'msw/node'
5
+ import { http, HttpResponse } from 'msw'
6
+ import { Layer } from 'effect'
7
+ import { ConfigService } from '@/services/config'
8
+ import { createMockConfigService } from './config-mock'
9
+
10
+ export const server: SetupServer = setupServer(
11
+ // Default handler for auth check
12
+ http.get('*/a/accounts/self', ({ request }) => {
13
+ const auth = request.headers.get('Authorization')
14
+ if (!auth || !auth.startsWith('Basic ')) {
15
+ return HttpResponse.text('Unauthorized', { status: 401 })
16
+ }
17
+ return HttpResponse.json({
18
+ _account_id: 1000,
19
+ name: 'Test User',
20
+ email: 'test@example.com',
21
+ })
22
+ }),
23
+ )
24
+
25
+ // Store captured output
26
+ export let capturedStdout: string[] = []
27
+ export let capturedErrors: string[] = []
28
+
29
+ // Mock process.stdout.write to capture JSON output
30
+ export const mockStdoutWrite: Mock<(chunk: unknown) => boolean> = mock(
31
+ (chunk: unknown): boolean => {
32
+ capturedStdout.push(String(chunk))
33
+ return true
34
+ },
35
+ )
36
+
37
+ // Mock console.error to capture errors
38
+ export const mockConsoleError: Mock<(...args: unknown[]) => void> = mock(
39
+ (...args: unknown[]): void => {
40
+ capturedErrors.push(args.join(' '))
41
+ },
42
+ )
43
+
44
+ // Mock process.exit to prevent test termination
45
+ export const mockProcessExit: Mock<(code?: number) => never> = mock((_code?: number): never => {
46
+ throw new Error('Process exited')
47
+ })
48
+
49
+ // Store original methods
50
+ export const originalStdoutWrite: typeof process.stdout.write = process.stdout.write
51
+ export const originalConsoleError: typeof console.error = console.error
52
+ export const originalProcessExit: typeof process.exit = process.exit
53
+
54
+ export const setupBuildStatusTests = (): void => {
55
+ server.listen({ onUnhandledRequest: 'bypass' })
56
+ // @ts-ignore - Mocking stdout
57
+ process.stdout.write = mockStdoutWrite
58
+ // @ts-ignore - Mocking console
59
+ console.error = mockConsoleError
60
+ // @ts-ignore - Mocking process.exit
61
+ process.exit = mockProcessExit
62
+ }
63
+
64
+ export const teardownBuildStatusTests = (): void => {
65
+ server.close()
66
+ // @ts-ignore - Restoring stdout
67
+ process.stdout.write = originalStdoutWrite
68
+ console.error = originalConsoleError
69
+ // @ts-ignore - Restoring process.exit
70
+ process.exit = originalProcessExit
71
+ }
72
+
73
+ export const resetBuildStatusMocks = (): void => {
74
+ server.resetHandlers()
75
+ mockStdoutWrite.mockClear()
76
+ mockConsoleError.mockClear()
77
+ mockProcessExit.mockClear()
78
+ capturedStdout = []
79
+ capturedErrors = []
80
+ }
81
+
82
+ export const createMockConfigLayer = (): Layer.Layer<ConfigService> =>
83
+ Layer.succeed(ConfigService, createMockConfigService())