@aaronshaf/ger 0.2.4 → 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/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,14 @@ 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
+
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
+
58
66
  # AI-powered code review (requires claude, llm, or opencode CLI)
59
67
  ger review 12345
60
68
  ger review 12345 --dry-run # Preview without posting
@@ -249,6 +257,85 @@ ger extract-url "github.com" --include-comments
249
257
  ger extract-url "job/[^/]+/job/[^/]+/\d+/$" --regex
250
258
  ```
251
259
 
260
+ ### Build Status
261
+
262
+ Check the CI build status of a change by parsing Gerrit messages for build events and verification results:
263
+
264
+ #### Single Check (Snapshot)
265
+ ```bash
266
+ # Check build status for a specific change
267
+ ger build-status 12345
268
+ # Output: {"state":"success"}
269
+
270
+ # Auto-detect change from HEAD commit
271
+ ger build-status
272
+
273
+ # Use in scripts with jq
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
291
+
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'
306
+ ```
307
+
308
+ #### Output format (JSON):
309
+ ```json
310
+ {"state": "success"}
311
+ ```
312
+
313
+ #### Build states:
314
+ - **`pending`**: No "Build Started" message found yet
315
+ - **`running`**: "Build Started" found, but no verification result yet
316
+ - **`success`**: Verified +1 after most recent "Build Started"
317
+ - **`failure`**: Verified -1 after most recent "Build Started"
318
+ - **`not_found`**: Change does not exist
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
+
326
+ #### How it works:
327
+ 1. Fetches all messages for the change
328
+ 2. Finds the most recent "Build Started" message
329
+ 3. Checks for "Verified +1" or "Verified -1" messages after the build started
330
+ 4. Returns the appropriate state
331
+ 5. In watch mode: polls every N seconds until terminal state or timeout
332
+
333
+ #### Use cases:
334
+ - **CI/CD integration**: Wait for builds before proceeding with deployment
335
+ - **Automation**: Trigger actions based on build results
336
+ - **Scripting**: Check build status in shell scripts
337
+ - **Monitoring**: Poll build status for long-running builds with watch mode
338
+
252
339
  ### Diff
253
340
  ```bash
254
341
  # 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.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",
@@ -0,0 +1,238 @@
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
+ // 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
+
26
+ // Effect Schema for BuildStatus (follows project patterns)
27
+ export const BuildStatus: Schema.Schema<{
28
+ readonly state: 'pending' | 'running' | 'success' | 'failure' | 'not_found'
29
+ }> = Schema.Struct({
30
+ state: Schema.Literal('pending', 'running', 'success', 'failure', 'not_found'),
31
+ })
32
+ export type BuildStatus = Schema.Schema.Type<typeof BuildStatus>
33
+
34
+ // Message patterns for precise matching
35
+ const BUILD_STARTED_PATTERN = /Build\s+Started/i
36
+ const VERIFIED_PLUS_PATTERN = /Verified\s*[+]\s*1/
37
+ const VERIFIED_MINUS_PATTERN = /Verified\s*[-]\s*1/
38
+
39
+ /**
40
+ * Parse messages to determine build status based on "Build Started" and verification messages
41
+ */
42
+ const parseBuildStatus = (messages: readonly MessageInfo[]): BuildStatus => {
43
+ // Empty messages means change exists but has no activity yet - return pending
44
+ if (messages.length === 0) {
45
+ return { state: 'pending' }
46
+ }
47
+
48
+ // Find the most recent "Build Started" message
49
+ let lastBuildDate: string | null = null
50
+ for (const msg of messages) {
51
+ if (BUILD_STARTED_PATTERN.test(msg.message)) {
52
+ lastBuildDate = msg.date
53
+ }
54
+ }
55
+
56
+ // If no build has started, state is "pending"
57
+ if (!lastBuildDate) {
58
+ return { state: 'pending' }
59
+ }
60
+
61
+ // Check for verification messages after the build started
62
+ for (const msg of messages) {
63
+ const date = msg.date
64
+ // Gerrit timestamps are ISO 8601 strings (lexicographically sortable)
65
+ if (date <= lastBuildDate) continue
66
+
67
+ if (VERIFIED_PLUS_PATTERN.test(msg.message)) {
68
+ return { state: 'success' }
69
+ } else if (VERIFIED_MINUS_PATTERN.test(msg.message)) {
70
+ return { state: 'failure' }
71
+ }
72
+ }
73
+
74
+ // Build started but no verification yet, state is "running"
75
+ return { state: 'running' }
76
+ }
77
+
78
+ /**
79
+ * Get messages for a change
80
+ */
81
+ const getMessagesForChange = (
82
+ changeId: string,
83
+ ): Effect.Effect<readonly MessageInfo[], ApiError, GerritApiService> =>
84
+ Effect.gen(function* () {
85
+ const gerritApi = yield* GerritApiService
86
+ const messages = yield* gerritApi.getMessages(changeId)
87
+ return messages
88
+ })
89
+
90
+ /**
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)
166
+ */
167
+ export const buildStatusCommand = (
168
+ changeId: string | undefined,
169
+ options: Partial<WatchOptions> = {},
170
+ ): Effect.Effect<
171
+ void,
172
+ ApiError | Error | GitError | NoChangeIdError | TimeoutError,
173
+ GerritApiService
174
+ > =>
175
+ Effect.gen(function* () {
176
+ // Auto-detect Change-ID from HEAD commit if not provided
177
+ const resolvedChangeId = changeId || (yield* getChangeIdFromHead())
178
+
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
+ }
186
+
187
+ let status: BuildStatus
188
+
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.)
209
+ }).pipe(
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
220
+ if (error && typeof error === 'object' && 'status' in error && error.status === 404) {
221
+ const status: BuildStatus = { state: 'not_found' }
222
+ return Effect.sync(() => {
223
+ process.stdout.write(JSON.stringify(status) + '\n')
224
+ })
225
+ }
226
+
227
+ // Other errors - exit 3
228
+ const errorMessage =
229
+ error instanceof GitError || error instanceof NoChangeIdError || error instanceof Error
230
+ ? error.message
231
+ : String(error)
232
+
233
+ return Effect.sync(() => {
234
+ console.error(`Error: ${errorMessage}`)
235
+ process.exit(3)
236
+ })
237
+ }),
238
+ )
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,85 @@ 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
+ .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')
433
+ .addHelpText(
434
+ 'after',
435
+ `
436
+ This command parses Gerrit change messages to determine build status.
437
+ It looks for "Build Started" messages and subsequent verification labels.
438
+
439
+ Output is JSON with a "state" field that can be:
440
+ - pending: No build has started yet
441
+ - running: Build started but no verification yet
442
+ - success: Build completed with Verified+1
443
+ - failure: Build completed with Verified-1
444
+ - not_found: Change does not exist
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
+
452
+ Examples:
453
+ # Single check (current behavior)
454
+ $ ger build-status 392385
455
+ {"state":"success"}
456
+
457
+ # Watch until completion (outputs JSON on each poll)
458
+ $ ger build-status 392385 --watch
459
+ {"state":"pending"}
460
+ {"state":"running"}
461
+ {"state":"running"}
462
+ {"state":"success"}
463
+
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
469
+
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
479
+
480
+ Note: When no change-id is provided, it will be automatically extracted from the
481
+ Change-ID footer in your HEAD commit.`,
482
+ )
483
+ .action(async (changeId, cmdOptions) => {
484
+ try {
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))
491
+ await Effect.runPromise(effect)
492
+ } catch (error) {
493
+ // Errors are handled within the command itself
494
+ // This catch is just for any unexpected errors
495
+ if (error instanceof Error && error.message !== 'Process exited') {
496
+ console.error('✗ Unexpected error:', error.message)
497
+ process.exit(3)
498
+ }
499
+ }
500
+ })
501
+
422
502
  // extract-url command
423
503
  program
424
504
  .command('extract-url <pattern> [change-id]')