@aaronshaf/ger 0.2.5 → 0.3.2

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.2",
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'
@@ -20,7 +37,8 @@ const VERIFIED_PLUS_PATTERN = /Verified\s*[+]\s*1/
20
37
  const VERIFIED_MINUS_PATTERN = /Verified\s*[-]\s*1/
21
38
 
22
39
  /**
23
- * Parse messages to determine build status based on "Build Started" and verification messages
40
+ * Parse messages to determine build status based on "Build Started" and verification messages.
41
+ * Only considers verification messages for the same patchset as the latest build.
24
42
  */
25
43
  const parseBuildStatus = (messages: readonly MessageInfo[]): BuildStatus => {
26
44
  // Empty messages means change exists but has no activity yet - return pending
@@ -28,11 +46,13 @@ const parseBuildStatus = (messages: readonly MessageInfo[]): BuildStatus => {
28
46
  return { state: 'pending' }
29
47
  }
30
48
 
31
- // Find the most recent "Build Started" message
49
+ // Find the most recent "Build Started" message and its revision number
32
50
  let lastBuildDate: string | null = null
51
+ let lastBuildRevision: number | undefined = undefined
33
52
  for (const msg of messages) {
34
53
  if (BUILD_STARTED_PATTERN.test(msg.message)) {
35
54
  lastBuildDate = msg.date
55
+ lastBuildRevision = msg._revision_number
36
56
  }
37
57
  }
38
58
 
@@ -41,12 +61,18 @@ const parseBuildStatus = (messages: readonly MessageInfo[]): BuildStatus => {
41
61
  return { state: 'pending' }
42
62
  }
43
63
 
44
- // Check for verification messages after the build started
64
+ // Check for verification messages after the build started AND for the same revision
45
65
  for (const msg of messages) {
46
66
  const date = msg.date
47
67
  // Gerrit timestamps are ISO 8601 strings (lexicographically sortable)
48
68
  if (date <= lastBuildDate) continue
49
69
 
70
+ // Only consider verification messages for the same patchset
71
+ // If revision numbers are available, they must match
72
+ if (lastBuildRevision !== undefined && msg._revision_number !== undefined) {
73
+ if (msg._revision_number !== lastBuildRevision) continue
74
+ }
75
+
50
76
  if (VERIFIED_PLUS_PATTERN.test(msg.message)) {
51
77
  return { state: 'success' }
52
78
  } else if (VERIFIED_MINUS_PATTERN.test(msg.message)) {
@@ -71,38 +97,129 @@ const getMessagesForChange = (
71
97
  })
72
98
 
73
99
  /**
74
- * Build status command - determines build status from Gerrit messages
100
+ * Poll build status until terminal state or timeout
101
+ * Outputs JSON status on each iteration (mimics gh run watch)
102
+ */
103
+ const pollBuildStatus = (
104
+ changeId: string,
105
+ options: WatchOptions,
106
+ ): Effect.Effect<BuildStatus, ApiError | TimeoutError, GerritApiService> =>
107
+ Effect.gen(function* () {
108
+ const startTime = Date.now()
109
+ const timeoutMs = options.timeout * 1000
110
+
111
+ while (true) {
112
+ // Check timeout
113
+ const elapsed = Date.now() - startTime
114
+ if (elapsed > timeoutMs) {
115
+ yield* Effect.sync(() => {
116
+ console.error(`Timeout: Build status check exceeded ${options.timeout}s`)
117
+ })
118
+ yield* Effect.fail(
119
+ new TimeoutError(`Build status check timed out after ${options.timeout}s`),
120
+ )
121
+ }
122
+
123
+ // Fetch and parse status
124
+ const messages = yield* getMessagesForChange(changeId)
125
+ const status = parseBuildStatus(messages)
126
+
127
+ // Check timeout again after API call (in case it took longer than expected)
128
+ const elapsedAfterFetch = Date.now() - startTime
129
+ if (elapsedAfterFetch > timeoutMs) {
130
+ yield* Effect.sync(() => {
131
+ console.error(`Timeout: Build status check exceeded ${options.timeout}s`)
132
+ })
133
+ yield* Effect.fail(
134
+ new TimeoutError(`Build status check timed out after ${options.timeout}s`),
135
+ )
136
+ }
137
+
138
+ // Output current status to stdout (JSON, like single-check mode)
139
+ yield* Effect.sync(() => {
140
+ process.stdout.write(JSON.stringify(status) + '\n')
141
+ })
142
+
143
+ // Terminal states - wait for interval before returning to allow logs to be written
144
+ if (status.state === 'success' || status.state === 'not_found') {
145
+ return status
146
+ }
147
+
148
+ if (status.state === 'failure') {
149
+ // Wait for interval seconds to allow build failure logs to be fully written
150
+ yield* Effect.sleep(options.interval * 1000)
151
+ return status
152
+ }
153
+
154
+ // Non-terminal states - sleep for interval duration
155
+ yield* Effect.sleep(options.interval * 1000)
156
+ }
157
+ })
158
+
159
+ /**
160
+ * Build status command with optional watch mode (mimics gh run watch)
75
161
  */
76
162
  export const buildStatusCommand = (
77
163
  changeId: string | undefined,
78
- ): Effect.Effect<void, ApiError | Error | GitError | NoChangeIdError, GerritApiService> =>
164
+ options: Partial<WatchOptions> = {},
165
+ ): Effect.Effect<
166
+ void,
167
+ ApiError | Error | GitError | NoChangeIdError | TimeoutError,
168
+ GerritApiService
169
+ > =>
79
170
  Effect.gen(function* () {
80
171
  // Auto-detect Change-ID from HEAD commit if not provided
81
172
  const resolvedChangeId = changeId || (yield* getChangeIdFromHead())
82
173
 
83
- // Fetch messages
84
- const messages = yield* getMessagesForChange(resolvedChangeId)
174
+ // Set defaults (matching gh run watch patterns)
175
+ const watchOpts: WatchOptions = {
176
+ watch: options.watch ?? false,
177
+ interval: Math.max(1, options.interval ?? 10), // Min 1 second
178
+ timeout: Math.max(1, options.timeout ?? 1800), // Min 1 second, default 30 minutes
179
+ exitStatus: options.exitStatus ?? false,
180
+ }
181
+
182
+ let status: BuildStatus
85
183
 
86
- // Parse build status
87
- const status = parseBuildStatus(messages)
184
+ if (watchOpts.watch) {
185
+ // Polling mode - outputs JSON on each iteration
186
+ status = yield* pollBuildStatus(resolvedChangeId, watchOpts)
187
+ } else {
188
+ // Single check mode (existing behavior)
189
+ const messages = yield* getMessagesForChange(resolvedChangeId)
190
+ status = parseBuildStatus(messages)
88
191
 
89
- // Output JSON to stdout
90
- const jsonOutput = JSON.stringify(status) + '\n'
91
- yield* Effect.sync(() => {
92
- process.stdout.write(jsonOutput)
93
- })
192
+ // Output JSON to stdout
193
+ yield* Effect.sync(() => {
194
+ process.stdout.write(JSON.stringify(status) + '\n')
195
+ })
196
+ }
197
+
198
+ // Handle exit codes (only non-zero when explicitly requested)
199
+ if (watchOpts.exitStatus && status.state === 'failure') {
200
+ yield* Effect.sync(() => process.exit(1))
201
+ }
202
+
203
+ // Default: exit 0 for all states (success, failure, pending, etc.)
94
204
  }).pipe(
95
- // Error handling - return not_found for API errors (e.g., change not found)
96
205
  Effect.catchAll((error) => {
206
+ // Timeout error
207
+ if (error instanceof TimeoutError) {
208
+ return Effect.sync(() => {
209
+ console.error(`Error: ${error.message}`)
210
+ process.exit(2)
211
+ })
212
+ }
213
+
214
+ // 404 - change not found
97
215
  if (error && typeof error === 'object' && 'status' in error && error.status === 404) {
98
- // Change not found - output not_found state and exit successfully
99
216
  const status: BuildStatus = { state: 'not_found' }
100
217
  return Effect.sync(() => {
101
218
  process.stdout.write(JSON.stringify(status) + '\n')
102
219
  })
103
220
  }
104
221
 
105
- // For other errors, write to stderr and exit with error
222
+ // Other errors - exit 3
106
223
  const errorMessage =
107
224
  error instanceof GitError || error instanceof NoChangeIdError || error instanceof Error
108
225
  ? error.message
@@ -110,7 +227,7 @@ export const buildStatusCommand = (
110
227
 
111
228
  return Effect.sync(() => {
112
229
  console.error(`Error: ${errorMessage}`)
113
- process.exit(1)
230
+ process.exit(3)
114
231
  })
115
232
  }),
116
233
  )
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,344 @@
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
+ // Minimalistic output: no stderr messages except on timeout/error
96
+ expect(capturedErrors.length).toBe(0)
97
+ })
98
+
99
+ test('polls until failure state is reached', async () => {
100
+ let callCount = 0
101
+
102
+ server.use(
103
+ http.get('*/a/changes/12345', ({ request }) => {
104
+ const url = new URL(request.url)
105
+ if (url.searchParams.get('o') === 'MESSAGES') {
106
+ callCount++
107
+
108
+ let messages: MessageInfo[]
109
+ if (callCount === 1) {
110
+ messages = [
111
+ {
112
+ id: 'msg1',
113
+ message: 'Build Started',
114
+ date: '2024-01-15 10:00:00.000000000',
115
+ author: { _account_id: 9999, name: 'CI Bot' },
116
+ },
117
+ ]
118
+ } else {
119
+ messages = [
120
+ {
121
+ id: 'msg1',
122
+ message: 'Build Started',
123
+ date: '2024-01-15 10:00:00.000000000',
124
+ author: { _account_id: 9999, name: 'CI Bot' },
125
+ },
126
+ {
127
+ id: 'msg2',
128
+ message: 'Patch Set 1: Verified-1',
129
+ date: '2024-01-15 10:05:00.000000000',
130
+ author: { _account_id: 9999, name: 'CI Bot' },
131
+ },
132
+ ]
133
+ }
134
+
135
+ return HttpResponse.json(
136
+ { messages },
137
+ { headers: { 'Content-Type': 'application/json' } },
138
+ )
139
+ }
140
+ return HttpResponse.text('Not Found', { status: 404 })
141
+ }),
142
+ )
143
+
144
+ const effect = buildStatusCommand('12345', {
145
+ watch: true,
146
+ interval: 0.1,
147
+ timeout: 10,
148
+ }).pipe(Effect.provide(GerritApiServiceLive), Effect.provide(createMockConfigLayer()))
149
+
150
+ await Effect.runPromise(effect)
151
+
152
+ expect(capturedStdout.length).toBeGreaterThanOrEqual(2)
153
+ expect(JSON.parse(capturedStdout[capturedStdout.length - 1])).toEqual({ state: 'failure' })
154
+
155
+ // Minimalistic output: no stderr messages except on timeout/error
156
+ expect(capturedErrors.length).toBe(0)
157
+ })
158
+
159
+ test('times out after specified duration', async () => {
160
+ server.use(
161
+ http.get('*/a/changes/12345', ({ request }) => {
162
+ const url = new URL(request.url)
163
+ if (url.searchParams.get('o') === 'MESSAGES') {
164
+ // Always return running state
165
+ return HttpResponse.json(
166
+ {
167
+ messages: [
168
+ {
169
+ id: 'msg1',
170
+ message: 'Build Started',
171
+ date: '2024-01-15 10:00:00.000000000',
172
+ author: { _account_id: 9999, name: 'CI Bot' },
173
+ },
174
+ ],
175
+ },
176
+ { headers: { 'Content-Type': 'application/json' } },
177
+ )
178
+ }
179
+ return HttpResponse.text('Not Found', { status: 404 })
180
+ }),
181
+ )
182
+
183
+ const effect = buildStatusCommand('12345', {
184
+ watch: true,
185
+ interval: 0.1,
186
+ timeout: 0.5, // Very short timeout
187
+ }).pipe(Effect.provide(GerritApiServiceLive), Effect.provide(createMockConfigLayer()))
188
+
189
+ try {
190
+ await Effect.runPromise(effect)
191
+ } catch {
192
+ // Should exit with code 2 for timeout
193
+ expect(mockProcessExit).toHaveBeenCalledWith(2)
194
+ expect(capturedErrors.some((e: string) => e.includes('Timeout'))).toBe(true)
195
+ }
196
+ })
197
+
198
+ test('exit-status flag causes exit 1 on failure', async () => {
199
+ server.use(
200
+ http.get('*/a/changes/12345', ({ request }) => {
201
+ const url = new URL(request.url)
202
+ if (url.searchParams.get('o') === 'MESSAGES') {
203
+ return HttpResponse.json(
204
+ {
205
+ messages: [
206
+ {
207
+ id: 'msg1',
208
+ message: 'Build Started',
209
+ date: '2024-01-15 10:00:00.000000000',
210
+ author: { _account_id: 9999, name: 'CI Bot' },
211
+ },
212
+ {
213
+ id: 'msg2',
214
+ message: 'Patch Set 1: Verified-1',
215
+ date: '2024-01-15 10:05:00.000000000',
216
+ author: { _account_id: 9999, name: 'CI Bot' },
217
+ },
218
+ ],
219
+ },
220
+ { headers: { 'Content-Type': 'application/json' } },
221
+ )
222
+ }
223
+ return HttpResponse.text('Not Found', { status: 404 })
224
+ }),
225
+ )
226
+
227
+ const effect = buildStatusCommand('12345', {
228
+ watch: true,
229
+ interval: 0.1,
230
+ timeout: 10,
231
+ exitStatus: true,
232
+ }).pipe(Effect.provide(GerritApiServiceLive), Effect.provide(createMockConfigLayer()))
233
+
234
+ try {
235
+ await Effect.runPromise(effect)
236
+ } catch {
237
+ // Should exit with code 1 for failure when --exit-status is used
238
+ expect(mockProcessExit).toHaveBeenCalledWith(1)
239
+ }
240
+ })
241
+
242
+ test('exit-status flag does not affect success state', async () => {
243
+ server.use(
244
+ http.get('*/a/changes/12345', ({ request }) => {
245
+ const url = new URL(request.url)
246
+ if (url.searchParams.get('o') === 'MESSAGES') {
247
+ return HttpResponse.json(
248
+ {
249
+ messages: [
250
+ {
251
+ id: 'msg1',
252
+ message: 'Build Started',
253
+ date: '2024-01-15 10:00:00.000000000',
254
+ author: { _account_id: 9999, name: 'CI Bot' },
255
+ },
256
+ {
257
+ id: 'msg2',
258
+ message: 'Patch Set 1: Verified+1',
259
+ date: '2024-01-15 10:05:00.000000000',
260
+ author: { _account_id: 9999, name: 'CI Bot' },
261
+ },
262
+ ],
263
+ },
264
+ { headers: { 'Content-Type': 'application/json' } },
265
+ )
266
+ }
267
+ return HttpResponse.text('Not Found', { status: 404 })
268
+ }),
269
+ )
270
+
271
+ const effect = buildStatusCommand('12345', {
272
+ watch: true,
273
+ interval: 0.1,
274
+ timeout: 10,
275
+ exitStatus: true,
276
+ }).pipe(Effect.provide(GerritApiServiceLive), Effect.provide(createMockConfigLayer()))
277
+
278
+ await Effect.runPromise(effect)
279
+
280
+ // Should not call process.exit for success state
281
+ expect(mockProcessExit).not.toHaveBeenCalled()
282
+ expect(JSON.parse(capturedStdout[0])).toEqual({ state: 'success' })
283
+ })
284
+
285
+ test('watch mode handles not_found state', async () => {
286
+ server.use(
287
+ http.get('*/a/changes/99999', () => {
288
+ return HttpResponse.text('Not Found', { status: 404 })
289
+ }),
290
+ )
291
+
292
+ const effect = buildStatusCommand('99999', {
293
+ watch: true,
294
+ interval: 0.1,
295
+ timeout: 10,
296
+ }).pipe(Effect.provide(GerritApiServiceLive), Effect.provide(createMockConfigLayer()))
297
+
298
+ await Effect.runPromise(effect)
299
+
300
+ expect(capturedStdout.length).toBe(1)
301
+ expect(JSON.parse(capturedStdout[0])).toEqual({ state: 'not_found' })
302
+
303
+ // 404 errors bypass pollBuildStatus and are handled in error handler
304
+ // Minimalistic output: no stderr messages for not_found state
305
+ expect(capturedErrors.length).toBe(0)
306
+ })
307
+
308
+ test('without watch flag, behaves as single check', async () => {
309
+ server.use(
310
+ http.get('*/a/changes/12345', ({ request }) => {
311
+ const url = new URL(request.url)
312
+ if (url.searchParams.get('o') === 'MESSAGES') {
313
+ return HttpResponse.json(
314
+ {
315
+ messages: [
316
+ {
317
+ id: 'msg1',
318
+ message: 'Build Started',
319
+ date: '2024-01-15 10:00:00.000000000',
320
+ author: { _account_id: 9999, name: 'CI Bot' },
321
+ },
322
+ ],
323
+ },
324
+ { headers: { 'Content-Type': 'application/json' } },
325
+ )
326
+ }
327
+ return HttpResponse.text('Not Found', { status: 404 })
328
+ }),
329
+ )
330
+
331
+ const effect = buildStatusCommand('12345', {
332
+ watch: false,
333
+ }).pipe(Effect.provide(GerritApiServiceLive), Effect.provide(createMockConfigLayer()))
334
+
335
+ await Effect.runPromise(effect)
336
+
337
+ // Should only have one output (no polling)
338
+ expect(capturedStdout.length).toBe(1)
339
+ expect(JSON.parse(capturedStdout[0])).toEqual({ state: 'running' })
340
+
341
+ // Should not have watch mode messages in stderr
342
+ expect(capturedErrors.some((e: string) => e.includes('Watching build status'))).toBe(false)
343
+ })
344
+ })
@@ -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
  })
@@ -688,4 +637,153 @@ describe('build-status command', () => {
688
637
  // Regex should handle extra whitespace
689
638
  expect(output).toEqual({ state: 'running' })
690
639
  })
640
+
641
+ test('ignores verification from older patchset when newer patchset build is running', async () => {
642
+ // This test replicates the bug scenario:
643
+ // - PS 3 build started, then PS 4 build started
644
+ // - PS 3 verification (-1) comes AFTER PS 4 build started
645
+ // - Should return "running" because PS 4 has no verification yet
646
+ const messages: MessageInfo[] = [
647
+ {
648
+ id: 'msg1',
649
+ message: 'Build Started https://jenkins.example.com/job/123/',
650
+ date: '2024-01-15 11:12:00.000000000',
651
+ _revision_number: 2,
652
+ author: {
653
+ _account_id: 9999,
654
+ name: 'Service Cloud Jenkins',
655
+ },
656
+ },
657
+ {
658
+ id: 'msg2',
659
+ message: 'Patch Set 2: Verified -1\n\nBuild Failed',
660
+ date: '2024-01-15 11:23:00.000000000',
661
+ _revision_number: 2,
662
+ author: {
663
+ _account_id: 9999,
664
+ name: 'Service Cloud Jenkins',
665
+ },
666
+ },
667
+ {
668
+ id: 'msg3',
669
+ message: 'Build Started https://jenkins.example.com/job/456/',
670
+ date: '2024-01-15 13:57:00.000000000',
671
+ _revision_number: 3,
672
+ author: {
673
+ _account_id: 9999,
674
+ name: 'Service Cloud Jenkins',
675
+ },
676
+ },
677
+ {
678
+ id: 'msg4',
679
+ message: 'Build Started https://jenkins.example.com/job/789/',
680
+ date: '2024-01-15 14:02:00.000000000',
681
+ _revision_number: 4,
682
+ author: {
683
+ _account_id: 9999,
684
+ name: 'Service Cloud Jenkins',
685
+ },
686
+ },
687
+ {
688
+ id: 'msg5',
689
+ message: 'Patch Set 3: Verified -1\n\nBuild Failed : ABORTED',
690
+ date: '2024-01-15 14:03:00.000000000',
691
+ _revision_number: 3,
692
+ author: {
693
+ _account_id: 9999,
694
+ name: 'Service Cloud Jenkins',
695
+ },
696
+ },
697
+ ]
698
+
699
+ server.use(
700
+ http.get('*/a/changes/12345', ({ request }) => {
701
+ const url = new URL(request.url)
702
+ if (url.searchParams.get('o') === 'MESSAGES') {
703
+ return HttpResponse.json(
704
+ { messages },
705
+ {
706
+ headers: { 'Content-Type': 'application/json' },
707
+ },
708
+ )
709
+ }
710
+ return HttpResponse.text('Not Found', { status: 404 })
711
+ }),
712
+ )
713
+
714
+ const effect = buildStatusCommand('12345').pipe(
715
+ Effect.provide(GerritApiServiceLive),
716
+ Effect.provide(createMockConfigLayer()),
717
+ )
718
+
719
+ await Effect.runPromise(effect)
720
+
721
+ expect(capturedStdout.length).toBe(1)
722
+ const output = JSON.parse(capturedStdout[0])
723
+ // PS 4 build started at 14:02, PS 3 verification at 14:03 should be IGNORED
724
+ // because it's for a different revision. PS 4 build is still running.
725
+ expect(output).toEqual({ state: 'running' })
726
+ })
727
+
728
+ test('returns success when verification matches the latest patchset', async () => {
729
+ const messages: MessageInfo[] = [
730
+ {
731
+ id: 'msg1',
732
+ message: 'Build Started',
733
+ date: '2024-01-15 10:00:00.000000000',
734
+ _revision_number: 1,
735
+ author: {
736
+ _account_id: 9999,
737
+ name: 'CI Bot',
738
+ },
739
+ },
740
+ {
741
+ id: 'msg2',
742
+ message: 'Build Started',
743
+ date: '2024-01-15 11:00:00.000000000',
744
+ _revision_number: 2,
745
+ author: {
746
+ _account_id: 9999,
747
+ name: 'CI Bot',
748
+ },
749
+ },
750
+ {
751
+ id: 'msg3',
752
+ message: 'Patch Set 2: Verified+1',
753
+ date: '2024-01-15 11:15:00.000000000',
754
+ _revision_number: 2,
755
+ author: {
756
+ _account_id: 9999,
757
+ name: 'CI Bot',
758
+ },
759
+ },
760
+ ]
761
+
762
+ server.use(
763
+ http.get('*/a/changes/12345', ({ request }) => {
764
+ const url = new URL(request.url)
765
+ if (url.searchParams.get('o') === 'MESSAGES') {
766
+ return HttpResponse.json(
767
+ { messages },
768
+ {
769
+ headers: { 'Content-Type': 'application/json' },
770
+ },
771
+ )
772
+ }
773
+ return HttpResponse.text('Not Found', { status: 404 })
774
+ }),
775
+ )
776
+
777
+ const effect = buildStatusCommand('12345').pipe(
778
+ Effect.provide(GerritApiServiceLive),
779
+ Effect.provide(createMockConfigLayer()),
780
+ )
781
+
782
+ await Effect.runPromise(effect)
783
+
784
+ expect(capturedStdout.length).toBe(1)
785
+ const output = JSON.parse(capturedStdout[0])
786
+ // PS 2 build started at 11:00, PS 2 verification at 11:15 - same revision, success
787
+ expect(output).toEqual({ state: 'success' })
788
+ })
691
789
  })
@@ -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())