@aaronshaf/ger 1.2.10 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (180) hide show
  1. package/.ast-grep/rules/no-as-casting.yml +13 -0
  2. package/.claude-plugin/plugin.json +22 -0
  3. package/.github/workflows/ci-simple.yml +53 -0
  4. package/.github/workflows/ci.yml +171 -0
  5. package/.github/workflows/claude-code-review.yml +83 -0
  6. package/.github/workflows/claude.yml +50 -0
  7. package/.github/workflows/dependency-update.yml +84 -0
  8. package/.github/workflows/release.yml +166 -0
  9. package/.github/workflows/security-scan.yml +113 -0
  10. package/.github/workflows/security.yml +96 -0
  11. package/.husky/pre-commit +16 -0
  12. package/.husky/pre-push +25 -0
  13. package/.lintstagedrc.json +6 -0
  14. package/.tool-versions +1 -0
  15. package/CLAUDE.md +105 -0
  16. package/DEVELOPMENT.md +361 -0
  17. package/EXAMPLES.md +457 -0
  18. package/README.md +831 -16
  19. package/bin/ger +3 -18
  20. package/biome.json +36 -0
  21. package/bun.lock +678 -0
  22. package/bunfig.toml +8 -0
  23. package/docs/adr/0001-use-effect-for-side-effects.md +65 -0
  24. package/docs/adr/0002-use-bun-runtime.md +64 -0
  25. package/docs/adr/0003-store-credentials-in-home-directory.md +75 -0
  26. package/docs/adr/0004-use-commander-for-cli.md +76 -0
  27. package/docs/adr/0005-use-effect-schema-for-validation.md +93 -0
  28. package/docs/adr/0006-use-msw-for-api-mocking.md +89 -0
  29. package/docs/adr/0007-git-hooks-for-quality.md +94 -0
  30. package/docs/adr/0008-no-as-typecasting.md +83 -0
  31. package/docs/adr/0009-file-size-limits.md +82 -0
  32. package/docs/adr/0010-llm-friendly-xml-output.md +93 -0
  33. package/docs/adr/0011-ai-tool-strategy-pattern.md +102 -0
  34. package/docs/adr/0012-build-status-message-parsing.md +94 -0
  35. package/docs/adr/0013-git-subprocess-integration.md +98 -0
  36. package/docs/adr/0014-group-management-support.md +95 -0
  37. package/docs/adr/0015-batch-comment-processing.md +111 -0
  38. package/docs/adr/0016-flexible-change-identifiers.md +94 -0
  39. package/docs/adr/0017-git-worktree-support.md +102 -0
  40. package/docs/adr/0018-auto-install-commit-hook.md +103 -0
  41. package/docs/adr/0019-sdk-package-exports.md +95 -0
  42. package/docs/adr/0020-code-coverage-enforcement.md +105 -0
  43. package/docs/adr/0021-typescript-isolated-declarations.md +83 -0
  44. package/docs/adr/0022-biome-oxlint-tooling.md +124 -0
  45. package/docs/adr/README.md +30 -0
  46. package/docs/prd/README.md +12 -0
  47. package/docs/prd/architecture.md +325 -0
  48. package/docs/prd/commands.md +425 -0
  49. package/docs/prd/data-model.md +349 -0
  50. package/docs/prd/overview.md +124 -0
  51. package/index.ts +219 -0
  52. package/oxlint.json +24 -0
  53. package/package.json +82 -15
  54. package/scripts/check-coverage.ts +69 -0
  55. package/scripts/check-file-size.ts +38 -0
  56. package/scripts/fix-test-mocks.ts +55 -0
  57. package/skills/gerrit-workflow/SKILL.md +247 -0
  58. package/skills/gerrit-workflow/examples.md +572 -0
  59. package/skills/gerrit-workflow/reference.md +728 -0
  60. package/src/api/gerrit.ts +696 -0
  61. package/src/cli/commands/abandon.ts +65 -0
  62. package/src/cli/commands/add-reviewer.ts +156 -0
  63. package/src/cli/commands/build-status.ts +282 -0
  64. package/src/cli/commands/checkout.ts +422 -0
  65. package/src/cli/commands/comment.ts +460 -0
  66. package/src/cli/commands/comments.ts +85 -0
  67. package/src/cli/commands/diff.ts +71 -0
  68. package/src/cli/commands/extract-url.ts +266 -0
  69. package/src/cli/commands/groups-members.ts +104 -0
  70. package/src/cli/commands/groups-show.ts +169 -0
  71. package/src/cli/commands/groups.ts +137 -0
  72. package/src/cli/commands/incoming.ts +226 -0
  73. package/src/cli/commands/init.ts +164 -0
  74. package/src/cli/commands/mine.ts +115 -0
  75. package/src/cli/commands/open.ts +57 -0
  76. package/src/cli/commands/projects.ts +68 -0
  77. package/src/cli/commands/push.ts +430 -0
  78. package/src/cli/commands/rebase.ts +52 -0
  79. package/src/cli/commands/remove-reviewer.ts +123 -0
  80. package/src/cli/commands/restore.ts +50 -0
  81. package/src/cli/commands/review.ts +486 -0
  82. package/src/cli/commands/search.ts +162 -0
  83. package/src/cli/commands/setup.ts +286 -0
  84. package/src/cli/commands/show.ts +491 -0
  85. package/src/cli/commands/status.ts +35 -0
  86. package/src/cli/commands/submit.ts +108 -0
  87. package/src/cli/commands/vote.ts +119 -0
  88. package/src/cli/commands/workspace.ts +200 -0
  89. package/src/cli/index.ts +53 -0
  90. package/src/cli/register-commands.ts +659 -0
  91. package/src/cli/register-group-commands.ts +88 -0
  92. package/src/cli/register-reviewer-commands.ts +97 -0
  93. package/src/prompts/default-review.md +86 -0
  94. package/src/prompts/system-inline-review.md +135 -0
  95. package/src/prompts/system-overall-review.md +206 -0
  96. package/src/schemas/config.test.ts +245 -0
  97. package/src/schemas/config.ts +84 -0
  98. package/src/schemas/gerrit.ts +681 -0
  99. package/src/services/commit-hook.ts +314 -0
  100. package/src/services/config.test.ts +150 -0
  101. package/src/services/config.ts +250 -0
  102. package/src/services/git-worktree.ts +342 -0
  103. package/src/services/review-strategy.ts +292 -0
  104. package/src/test-utils/mock-generator.ts +138 -0
  105. package/src/utils/change-id.test.ts +98 -0
  106. package/src/utils/change-id.ts +63 -0
  107. package/src/utils/comment-formatters.ts +153 -0
  108. package/src/utils/diff-context.ts +103 -0
  109. package/src/utils/diff-formatters.ts +141 -0
  110. package/src/utils/formatters.ts +85 -0
  111. package/src/utils/git-commit.test.ts +277 -0
  112. package/src/utils/git-commit.ts +122 -0
  113. package/src/utils/index.ts +55 -0
  114. package/src/utils/message-filters.ts +26 -0
  115. package/src/utils/review-formatters.ts +89 -0
  116. package/src/utils/review-prompt-builder.ts +110 -0
  117. package/src/utils/shell-safety.ts +117 -0
  118. package/src/utils/status-indicators.ts +100 -0
  119. package/src/utils/url-parser.test.ts +271 -0
  120. package/src/utils/url-parser.ts +118 -0
  121. package/tests/abandon.test.ts +230 -0
  122. package/tests/add-reviewer.test.ts +579 -0
  123. package/tests/build-status-watch.test.ts +344 -0
  124. package/tests/build-status.test.ts +789 -0
  125. package/tests/change-id-formats.test.ts +268 -0
  126. package/tests/checkout/integration.test.ts +653 -0
  127. package/tests/checkout/parse-input.test.ts +55 -0
  128. package/tests/checkout/validation.test.ts +178 -0
  129. package/tests/comment-batch-advanced.test.ts +431 -0
  130. package/tests/comment-gerrit-api-compliance.test.ts +414 -0
  131. package/tests/comment.test.ts +708 -0
  132. package/tests/comments.test.ts +323 -0
  133. package/tests/config-service-simple.test.ts +100 -0
  134. package/tests/diff.test.ts +419 -0
  135. package/tests/extract-url.test.ts +517 -0
  136. package/tests/groups-members.test.ts +256 -0
  137. package/tests/groups-show.test.ts +323 -0
  138. package/tests/groups.test.ts +334 -0
  139. package/tests/helpers/build-status-test-setup.ts +83 -0
  140. package/tests/helpers/config-mock.ts +27 -0
  141. package/tests/incoming.test.ts +357 -0
  142. package/tests/init.test.ts +70 -0
  143. package/tests/integration/commit-hook.test.ts +246 -0
  144. package/tests/interactive-incoming.test.ts +173 -0
  145. package/tests/mine.test.ts +285 -0
  146. package/tests/mocks/msw-handlers.ts +80 -0
  147. package/tests/open.test.ts +233 -0
  148. package/tests/projects.test.ts +259 -0
  149. package/tests/rebase.test.ts +271 -0
  150. package/tests/remove-reviewer.test.ts +357 -0
  151. package/tests/restore.test.ts +237 -0
  152. package/tests/review.test.ts +135 -0
  153. package/tests/search.test.ts +712 -0
  154. package/tests/setup.test.ts +63 -0
  155. package/tests/show-auto-detect.test.ts +324 -0
  156. package/tests/show.test.ts +813 -0
  157. package/tests/status.test.ts +145 -0
  158. package/tests/submit.test.ts +316 -0
  159. package/tests/unit/commands/push.test.ts +194 -0
  160. package/tests/unit/git-branch-detection.test.ts +82 -0
  161. package/tests/unit/git-worktree.test.ts +55 -0
  162. package/tests/unit/patterns/push-patterns.test.ts +148 -0
  163. package/tests/unit/schemas/gerrit.test.ts +85 -0
  164. package/tests/unit/services/commit-hook.test.ts +132 -0
  165. package/tests/unit/services/review-strategy.test.ts +349 -0
  166. package/tests/unit/test-utils/mock-generator.test.ts +154 -0
  167. package/tests/unit/utils/comment-formatters.test.ts +415 -0
  168. package/tests/unit/utils/diff-context.test.ts +171 -0
  169. package/tests/unit/utils/diff-formatters.test.ts +165 -0
  170. package/tests/unit/utils/formatters.test.ts +411 -0
  171. package/tests/unit/utils/message-filters.test.ts +227 -0
  172. package/tests/unit/utils/shell-safety.test.ts +230 -0
  173. package/tests/unit/utils/status-indicators.test.ts +137 -0
  174. package/tests/vote.test.ts +317 -0
  175. package/tests/workspace.test.ts +295 -0
  176. package/tsconfig.json +36 -5
  177. package/src/commands/branch.ts +0 -180
  178. package/src/ger.ts +0 -22
  179. package/src/types.d.ts +0 -35
  180. package/src/utils.ts +0 -130
@@ -0,0 +1,65 @@
1
+ import { Effect } from 'effect'
2
+ import { type ApiError, GerritApiService } from '@/api/gerrit'
3
+
4
+ interface AbandonOptions {
5
+ message?: string
6
+ xml?: boolean
7
+ }
8
+
9
+ export const abandonCommand = (
10
+ changeId?: string,
11
+ options: AbandonOptions = {},
12
+ ): Effect.Effect<void, ApiError, GerritApiService> =>
13
+ Effect.gen(function* () {
14
+ const gerritApi = yield* GerritApiService
15
+
16
+ if (!changeId) {
17
+ console.error('✗ Change ID is required')
18
+ console.error(' Usage: ger abandon <change-id>')
19
+ return
20
+ }
21
+
22
+ try {
23
+ // First get the change details to show what we're abandoning
24
+ const change = yield* gerritApi.getChange(changeId)
25
+
26
+ // Perform the abandon
27
+ yield* gerritApi.abandonChange(changeId, options.message)
28
+
29
+ if (options.xml) {
30
+ console.log(`<?xml version="1.0" encoding="UTF-8"?>`)
31
+ console.log(`<abandon_result>`)
32
+ console.log(` <status>success</status>`)
33
+ console.log(` <change_number>${change._number}</change_number>`)
34
+ console.log(` <subject><![CDATA[${change.subject}]]></subject>`)
35
+ if (options.message) {
36
+ console.log(` <message><![CDATA[${options.message}]]></message>`)
37
+ }
38
+ console.log(`</abandon_result>`)
39
+ } else {
40
+ console.log(`✓ Abandoned change ${change._number}: ${change.subject}`)
41
+ if (options.message) {
42
+ console.log(` Message: ${options.message}`)
43
+ }
44
+ }
45
+ } catch {
46
+ // If we can't get change details, still try to abandon with just the ID
47
+ yield* gerritApi.abandonChange(changeId, options.message)
48
+
49
+ if (options.xml) {
50
+ console.log(`<?xml version="1.0" encoding="UTF-8"?>`)
51
+ console.log(`<abandon_result>`)
52
+ console.log(` <status>success</status>`)
53
+ console.log(` <change_id>${changeId}</change_id>`)
54
+ if (options.message) {
55
+ console.log(` <message><![CDATA[${options.message}]]></message>`)
56
+ }
57
+ console.log(`</abandon_result>`)
58
+ } else {
59
+ console.log(`✓ Abandoned change ${changeId}`)
60
+ if (options.message) {
61
+ console.log(` Message: ${options.message}`)
62
+ }
63
+ }
64
+ }
65
+ })
@@ -0,0 +1,156 @@
1
+ import { Effect } from 'effect'
2
+ import { type ApiError, GerritApiService } from '@/api/gerrit'
3
+
4
+ interface AddReviewerOptions {
5
+ change?: string
6
+ cc?: boolean
7
+ notify?: string
8
+ xml?: boolean
9
+ group?: boolean
10
+ }
11
+
12
+ type NotifyLevel = 'NONE' | 'OWNER' | 'OWNER_REVIEWERS' | 'ALL'
13
+
14
+ const VALID_NOTIFY_LEVELS: ReadonlyArray<NotifyLevel> = ['NONE', 'OWNER', 'OWNER_REVIEWERS', 'ALL']
15
+
16
+ const escapeXml = (str: string): string =>
17
+ str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
18
+
19
+ const outputXmlError = (message: string): void => {
20
+ console.log(`<?xml version="1.0" encoding="UTF-8"?>`)
21
+ console.log(`<add_reviewer_result>`)
22
+ console.log(` <status>error</status>`)
23
+ console.log(` <error><![CDATA[${message}]]></error>`)
24
+ console.log(`</add_reviewer_result>`)
25
+ }
26
+
27
+ class ValidationError extends Error {
28
+ readonly _tag = 'ValidationError'
29
+ }
30
+
31
+ export const addReviewerCommand = (
32
+ reviewers: string[],
33
+ options: AddReviewerOptions = {},
34
+ ): Effect.Effect<void, ApiError | ValidationError, GerritApiService> =>
35
+ Effect.gen(function* () {
36
+ const gerritApi = yield* GerritApiService
37
+
38
+ const changeId = options.change
39
+
40
+ if (!changeId) {
41
+ const message =
42
+ 'Change ID is required. Use -c <change-id> or run from a branch with an active change.'
43
+ if (options.xml) {
44
+ outputXmlError(message)
45
+ } else {
46
+ console.error(`✗ ${message}`)
47
+ }
48
+ return yield* Effect.fail(new ValidationError(message))
49
+ }
50
+
51
+ if (reviewers.length === 0) {
52
+ const entityType = options.group ? 'group' : 'reviewer'
53
+ const message = `At least one ${entityType} is required.`
54
+ if (options.xml) {
55
+ outputXmlError(message)
56
+ } else {
57
+ console.error(`✗ ${message}`)
58
+ }
59
+ return yield* Effect.fail(new ValidationError(message))
60
+ }
61
+
62
+ // Validate that email-like inputs aren't used with --group flag
63
+ // Note: This uses a simple heuristic (presence of '@') to detect likely email addresses.
64
+ // While Gerrit group names could theoretically contain '@', this is rare in practice
65
+ // and the validation serves as a helpful UX guardrail against common mistakes.
66
+ if (options.group) {
67
+ const emailLikeInputs = reviewers.filter((r) => r.includes('@'))
68
+ if (emailLikeInputs.length > 0) {
69
+ const message = `The --group flag expects group identifiers, but received email-like input: ${emailLikeInputs.join(', ')}. Did you mean to omit --group?`
70
+ if (options.xml) {
71
+ outputXmlError(message)
72
+ } else {
73
+ console.error(`✗ ${message}`)
74
+ }
75
+ return yield* Effect.fail(new ValidationError(message))
76
+ }
77
+ }
78
+
79
+ const state: 'REVIEWER' | 'CC' = options.cc ? 'CC' : 'REVIEWER'
80
+ const entityType = options.group ? 'group' : 'individual'
81
+ const stateLabel = options.cc ? 'cc' : options.group ? 'group' : 'reviewer'
82
+
83
+ let notify: NotifyLevel | undefined
84
+ if (options.notify) {
85
+ const upperNotify = options.notify.toUpperCase()
86
+ if (!VALID_NOTIFY_LEVELS.includes(upperNotify as NotifyLevel)) {
87
+ const message = `Invalid notify level: ${options.notify}. Valid values: none, owner, owner_reviewers, all`
88
+ if (options.xml) {
89
+ outputXmlError(message)
90
+ } else {
91
+ console.error(`✗ ${message}`)
92
+ }
93
+ return yield* Effect.fail(new ValidationError(message))
94
+ }
95
+ notify = upperNotify as NotifyLevel
96
+ }
97
+
98
+ const results: Array<{ reviewer: string; success: boolean; name?: string; error?: string }> = []
99
+
100
+ for (const reviewer of reviewers) {
101
+ const result = yield* Effect.either(
102
+ gerritApi.addReviewer(changeId, reviewer, { state, notify }),
103
+ )
104
+
105
+ if (result._tag === 'Left') {
106
+ const error = result.left
107
+ const message = 'message' in error ? String(error.message) : String(error)
108
+ results.push({ reviewer, success: false, error: message })
109
+ continue
110
+ }
111
+
112
+ const apiResult = result.right
113
+
114
+ if (apiResult.error) {
115
+ results.push({ reviewer, success: false, error: apiResult.error })
116
+ } else {
117
+ const added = apiResult.reviewers?.[0] || apiResult.ccs?.[0]
118
+ const name = added?.name || added?.email || reviewer
119
+ results.push({ reviewer, success: true, name })
120
+ }
121
+ }
122
+
123
+ if (options.xml) {
124
+ console.log(`<?xml version="1.0" encoding="UTF-8"?>`)
125
+ console.log(`<add_reviewer_result>`)
126
+ console.log(` <change_id>${escapeXml(changeId)}</change_id>`)
127
+ console.log(` <state>${escapeXml(state)}</state>`)
128
+ console.log(` <entity_type>${escapeXml(entityType)}</entity_type>`)
129
+ console.log(` <reviewers>`)
130
+ for (const r of results) {
131
+ if (r.success) {
132
+ console.log(` <reviewer status="added">`)
133
+ console.log(` <input>${escapeXml(r.reviewer)}</input>`)
134
+ console.log(` <name><![CDATA[${r.name}]]></name>`)
135
+ console.log(` </reviewer>`)
136
+ } else {
137
+ console.log(` <reviewer status="failed">`)
138
+ console.log(` <input>${escapeXml(r.reviewer)}</input>`)
139
+ console.log(` <error><![CDATA[${r.error}]]></error>`)
140
+ console.log(` </reviewer>`)
141
+ }
142
+ }
143
+ console.log(` </reviewers>`)
144
+ const allSuccess = results.every((r) => r.success)
145
+ console.log(` <status>${allSuccess ? 'success' : 'partial_failure'}</status>`)
146
+ console.log(`</add_reviewer_result>`)
147
+ } else {
148
+ for (const r of results) {
149
+ if (r.success) {
150
+ console.log(`✓ Added ${r.name} as ${stateLabel}`)
151
+ } else {
152
+ console.error(`✗ Failed to add ${r.reviewer}: ${r.error}`)
153
+ }
154
+ }
155
+ }
156
+ })
@@ -0,0 +1,282 @@
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
+ /** Help text for build-status command - exported to keep index.ts under line limit */
7
+ export const BUILD_STATUS_HELP_TEXT = `
8
+ This command parses Gerrit change messages to determine build status.
9
+ It looks for "Build Started" messages and subsequent verification labels.
10
+
11
+ Output is JSON with a "state" field that can be:
12
+ - pending: No build has started yet
13
+ - running: Build started but no verification yet
14
+ - success: Build completed with Verified+1
15
+ - failure: Build completed with Verified-1
16
+ - not_found: Change does not exist
17
+
18
+ Exit codes:
19
+ - 0: Default for all states (like gh run watch)
20
+ - 1: Only when --exit-status is used AND build fails
21
+ - 2: Timeout reached in watch mode
22
+ - 3: API/network errors
23
+
24
+ Examples:
25
+ # Single check (current behavior)
26
+ $ ger build-status 392385
27
+ {"state":"success"}
28
+
29
+ # Watch until completion (outputs JSON on each poll)
30
+ $ ger build-status 392385 --watch
31
+ {"state":"pending"}
32
+ {"state":"running"}
33
+ {"state":"running"}
34
+ {"state":"success"}
35
+
36
+ # Watch with custom interval (check every 5 seconds)
37
+ $ ger build-status --watch --interval 5
38
+
39
+ # Watch with custom timeout (60 minutes)
40
+ $ ger build-status --watch --timeout 3600
41
+
42
+ # Exit with code 1 on failure (for CI/CD pipelines)
43
+ $ ger build-status --watch --exit-status && deploy.sh
44
+
45
+ # Trigger notification when done (like gh run watch pattern)
46
+ $ ger build-status --watch && notify-send 'Build is done!'
47
+
48
+ # Parse final state in scripts
49
+ $ ger build-status --watch | tail -1 | jq -r '.state'
50
+ success
51
+
52
+ Note: When no change-id is provided, it will be automatically extracted from the
53
+ Change-ID footer in your HEAD commit.`
54
+
55
+ // Export types for external use
56
+ export type BuildState = 'pending' | 'running' | 'success' | 'failure' | 'not_found'
57
+
58
+ // Watch options (matches gh run watch pattern)
59
+ export type WatchOptions = {
60
+ readonly watch: boolean
61
+ readonly interval: number // seconds
62
+ readonly timeout: number // seconds
63
+ readonly exitStatus: boolean
64
+ }
65
+
66
+ // Timeout error for watch mode
67
+ export class TimeoutError extends Error {
68
+ readonly _tag = 'TimeoutError'
69
+ constructor(message: string) {
70
+ super(message)
71
+ this.name = 'TimeoutError'
72
+ }
73
+ }
74
+
75
+ // Effect Schema for BuildStatus (follows project patterns)
76
+ export const BuildStatus: Schema.Schema<{
77
+ readonly state: 'pending' | 'running' | 'success' | 'failure' | 'not_found'
78
+ }> = Schema.Struct({
79
+ state: Schema.Literal('pending', 'running', 'success', 'failure', 'not_found'),
80
+ })
81
+ export type BuildStatus = Schema.Schema.Type<typeof BuildStatus>
82
+
83
+ // Message patterns for precise matching
84
+ const BUILD_STARTED_PATTERN = /Build\s+Started/i
85
+ const VERIFIED_PLUS_PATTERN = /Verified\s*[+]\s*1/
86
+ const VERIFIED_MINUS_PATTERN = /Verified\s*[-]\s*1/
87
+
88
+ /**
89
+ * Parse messages to determine build status based on "Build Started" and verification messages.
90
+ * Only considers verification messages for the same patchset as the latest build.
91
+ */
92
+ const parseBuildStatus = (messages: readonly MessageInfo[]): BuildStatus => {
93
+ // Empty messages means change exists but has no activity yet - return pending
94
+ if (messages.length === 0) {
95
+ return { state: 'pending' }
96
+ }
97
+
98
+ // Find the most recent "Build Started" message and its revision number
99
+ let lastBuildDate: string | null = null
100
+ let lastBuildRevision: number | undefined = undefined
101
+ for (const msg of messages) {
102
+ if (BUILD_STARTED_PATTERN.test(msg.message)) {
103
+ lastBuildDate = msg.date
104
+ lastBuildRevision = msg._revision_number
105
+ }
106
+ }
107
+
108
+ // If no build has started, state is "pending"
109
+ if (!lastBuildDate) {
110
+ return { state: 'pending' }
111
+ }
112
+
113
+ // Check for verification messages after the build started AND for the same revision
114
+ for (const msg of messages) {
115
+ const date = msg.date
116
+ // Gerrit timestamps are ISO 8601 strings (lexicographically sortable)
117
+ if (date <= lastBuildDate) continue
118
+
119
+ // Only consider verification messages for the same patchset
120
+ // If revision numbers are available, they must match
121
+ if (lastBuildRevision !== undefined && msg._revision_number !== undefined) {
122
+ if (msg._revision_number !== lastBuildRevision) continue
123
+ }
124
+
125
+ if (VERIFIED_PLUS_PATTERN.test(msg.message)) {
126
+ return { state: 'success' }
127
+ } else if (VERIFIED_MINUS_PATTERN.test(msg.message)) {
128
+ return { state: 'failure' }
129
+ }
130
+ }
131
+
132
+ // Build started but no verification yet, state is "running"
133
+ return { state: 'running' }
134
+ }
135
+
136
+ /**
137
+ * Get messages for a change
138
+ */
139
+ const getMessagesForChange = (
140
+ changeId: string,
141
+ ): Effect.Effect<readonly MessageInfo[], ApiError, GerritApiService> =>
142
+ Effect.gen(function* () {
143
+ const gerritApi = yield* GerritApiService
144
+ const messages = yield* gerritApi.getMessages(changeId)
145
+ return messages
146
+ })
147
+
148
+ /**
149
+ * Poll build status until terminal state or timeout
150
+ * Outputs JSON status on each iteration (mimics gh run watch)
151
+ */
152
+ const pollBuildStatus = (
153
+ changeId: string,
154
+ options: WatchOptions,
155
+ ): Effect.Effect<BuildStatus, ApiError | TimeoutError, GerritApiService> =>
156
+ Effect.gen(function* () {
157
+ const startTime = Date.now()
158
+ const timeoutMs = options.timeout * 1000
159
+
160
+ while (true) {
161
+ // Check timeout
162
+ const elapsed = Date.now() - startTime
163
+ if (elapsed > timeoutMs) {
164
+ yield* Effect.sync(() => {
165
+ console.error(`Timeout: Build status check exceeded ${options.timeout}s`)
166
+ })
167
+ yield* Effect.fail(
168
+ new TimeoutError(`Build status check timed out after ${options.timeout}s`),
169
+ )
170
+ }
171
+
172
+ // Fetch and parse status
173
+ const messages = yield* getMessagesForChange(changeId)
174
+ const status = parseBuildStatus(messages)
175
+
176
+ // Check timeout again after API call (in case it took longer than expected)
177
+ const elapsedAfterFetch = Date.now() - startTime
178
+ if (elapsedAfterFetch > timeoutMs) {
179
+ yield* Effect.sync(() => {
180
+ console.error(`Timeout: Build status check exceeded ${options.timeout}s`)
181
+ })
182
+ yield* Effect.fail(
183
+ new TimeoutError(`Build status check timed out after ${options.timeout}s`),
184
+ )
185
+ }
186
+
187
+ // Output current status to stdout (JSON, like single-check mode)
188
+ yield* Effect.sync(() => {
189
+ process.stdout.write(JSON.stringify(status) + '\n')
190
+ })
191
+
192
+ // Terminal states - wait for interval before returning to allow logs to be written
193
+ if (status.state === 'success' || status.state === 'not_found') {
194
+ return status
195
+ }
196
+
197
+ if (status.state === 'failure') {
198
+ // Wait for interval seconds to allow build failure logs to be fully written
199
+ yield* Effect.sleep(options.interval * 1000)
200
+ return status
201
+ }
202
+
203
+ // Non-terminal states - sleep for interval duration
204
+ yield* Effect.sleep(options.interval * 1000)
205
+ }
206
+ })
207
+
208
+ /**
209
+ * Build status command with optional watch mode (mimics gh run watch)
210
+ */
211
+ export const buildStatusCommand = (
212
+ changeId: string | undefined,
213
+ options: Partial<WatchOptions> = {},
214
+ ): Effect.Effect<
215
+ void,
216
+ ApiError | Error | GitError | NoChangeIdError | TimeoutError,
217
+ GerritApiService
218
+ > =>
219
+ Effect.gen(function* () {
220
+ // Auto-detect Change-ID from HEAD commit if not provided
221
+ const resolvedChangeId = changeId || (yield* getChangeIdFromHead())
222
+
223
+ // Set defaults (matching gh run watch patterns)
224
+ const watchOpts: WatchOptions = {
225
+ watch: options.watch ?? false,
226
+ interval: Math.max(1, options.interval ?? 10), // Min 1 second
227
+ timeout: Math.max(1, options.timeout ?? 1800), // Min 1 second, default 30 minutes
228
+ exitStatus: options.exitStatus ?? false,
229
+ }
230
+
231
+ let status: BuildStatus
232
+
233
+ if (watchOpts.watch) {
234
+ // Polling mode - outputs JSON on each iteration
235
+ status = yield* pollBuildStatus(resolvedChangeId, watchOpts)
236
+ } else {
237
+ // Single check mode (existing behavior)
238
+ const messages = yield* getMessagesForChange(resolvedChangeId)
239
+ status = parseBuildStatus(messages)
240
+
241
+ // Output JSON to stdout
242
+ yield* Effect.sync(() => {
243
+ process.stdout.write(JSON.stringify(status) + '\n')
244
+ })
245
+ }
246
+
247
+ // Handle exit codes (only non-zero when explicitly requested)
248
+ if (watchOpts.exitStatus && status.state === 'failure') {
249
+ yield* Effect.sync(() => process.exit(1))
250
+ }
251
+
252
+ // Default: exit 0 for all states (success, failure, pending, etc.)
253
+ }).pipe(
254
+ Effect.catchAll((error) => {
255
+ // Timeout error
256
+ if (error instanceof TimeoutError) {
257
+ return Effect.sync(() => {
258
+ console.error(`Error: ${error.message}`)
259
+ process.exit(2)
260
+ })
261
+ }
262
+
263
+ // 404 - change not found
264
+ if (error && typeof error === 'object' && 'status' in error && error.status === 404) {
265
+ const status: BuildStatus = { state: 'not_found' }
266
+ return Effect.sync(() => {
267
+ process.stdout.write(JSON.stringify(status) + '\n')
268
+ })
269
+ }
270
+
271
+ // Other errors - exit 3
272
+ const errorMessage =
273
+ error instanceof GitError || error instanceof NoChangeIdError || error instanceof Error
274
+ ? error.message
275
+ : String(error)
276
+
277
+ return Effect.sync(() => {
278
+ console.error(`Error: ${errorMessage}`)
279
+ process.exit(3)
280
+ })
281
+ }),
282
+ )