@aaronshaf/ger 1.2.11 → 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 -196
  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,659 @@
1
+ import type { Command } from 'commander'
2
+ import { Effect } from 'effect'
3
+ import { GerritApiServiceLive } from '@/api/gerrit'
4
+ import { ConfigServiceLive } from '@/services/config'
5
+ import { ReviewStrategyServiceLive } from '@/services/review-strategy'
6
+ import { GitWorktreeServiceLive } from '@/services/git-worktree'
7
+ import { CommitHookServiceLive } from '@/services/commit-hook'
8
+ import { abandonCommand } from './commands/abandon'
9
+ import { restoreCommand } from './commands/restore'
10
+ import { rebaseCommand } from './commands/rebase'
11
+ import { submitCommand } from './commands/submit'
12
+ import { voteCommand } from './commands/vote'
13
+ import { projectsCommand } from './commands/projects'
14
+ import { buildStatusCommand, BUILD_STATUS_HELP_TEXT } from './commands/build-status'
15
+ import { checkoutCommand, CHECKOUT_HELP_TEXT } from './commands/checkout'
16
+ import { commentCommand } from './commands/comment'
17
+ import { commentsCommand } from './commands/comments'
18
+ import { diffCommand } from './commands/diff'
19
+ import { extractUrlCommand } from './commands/extract-url'
20
+ import { incomingCommand } from './commands/incoming'
21
+ import { mineCommand } from './commands/mine'
22
+ import { openCommand } from './commands/open'
23
+ import { pushCommand, PUSH_HELP_TEXT } from './commands/push'
24
+ import { reviewCommand } from './commands/review'
25
+ import { searchCommand, SEARCH_HELP_TEXT } from './commands/search'
26
+ import { setup } from './commands/setup'
27
+ import { showCommand, SHOW_HELP_TEXT } from './commands/show'
28
+ import { statusCommand } from './commands/status'
29
+ import { workspaceCommand } from './commands/workspace'
30
+ import { sanitizeCDATA } from '@/utils/shell-safety'
31
+ import { registerGroupCommands } from './register-group-commands'
32
+ import { registerReviewerCommands } from './register-reviewer-commands'
33
+
34
+ // Helper function to output error in plain text or XML format
35
+ function outputError(error: unknown, options: { xml?: boolean }, resultTag: string): void {
36
+ const errorMessage = error instanceof Error ? error.message : String(error)
37
+ if (options.xml) {
38
+ console.log(`<?xml version="1.0" encoding="UTF-8"?>`)
39
+ console.log(`<${resultTag}>`)
40
+ console.log(` <status>error</status>`)
41
+ console.log(` <error><![CDATA[${errorMessage}]]></error>`)
42
+ console.log(`</${resultTag}>`)
43
+ } else {
44
+ console.error('✗ Error:', errorMessage)
45
+ }
46
+ }
47
+
48
+ // Helper function to execute Effect with standard error handling
49
+ async function executeEffect<E>(
50
+ effect: Effect.Effect<void, E, never>,
51
+ options: { xml?: boolean },
52
+ resultTag: string,
53
+ ): Promise<void> {
54
+ try {
55
+ await Effect.runPromise(effect)
56
+ } catch (error) {
57
+ outputError(error, options, resultTag)
58
+ process.exit(1)
59
+ }
60
+ }
61
+
62
+ export function registerCommands(program: Command): void {
63
+ // setup command (new primary command)
64
+ program
65
+ .command('setup')
66
+ .description('Configure Gerrit credentials and AI tools')
67
+ .action(async () => {
68
+ await setup()
69
+ })
70
+
71
+ // init command (kept for backward compatibility, redirects to setup)
72
+ program
73
+ .command('init')
74
+ .description('Initialize Gerrit credentials (alias for setup)')
75
+ .action(async () => {
76
+ await setup()
77
+ })
78
+
79
+ // status command
80
+ program
81
+ .command('status')
82
+ .description('Check connection status')
83
+ .option('--xml', 'XML output for LLM consumption')
84
+ .action(async (options) => {
85
+ await executeEffect(
86
+ statusCommand(options).pipe(
87
+ Effect.provide(GerritApiServiceLive),
88
+ Effect.provide(ConfigServiceLive),
89
+ ),
90
+ options,
91
+ 'status_result',
92
+ )
93
+ })
94
+
95
+ // comment command
96
+ program
97
+ .command('comment <change-id>')
98
+ .description('Post a comment on a change (accepts change number or Change-ID)')
99
+ .option('-m, --message <message>', 'Comment message')
100
+ .option('--file <file>', 'File path for line-specific comment (relative to repo root)')
101
+ .option(
102
+ '--line <line>',
103
+ 'Line number in the NEW version of the file (not diff line numbers)',
104
+ parseInt,
105
+ )
106
+ .option('--unresolved', 'Mark comment as unresolved (requires human attention)')
107
+ .option('--batch', 'Read batch comments from stdin as JSON (see examples below)')
108
+ .option('--xml', 'XML output for LLM consumption')
109
+ .addHelpText(
110
+ 'after',
111
+ `
112
+ Examples:
113
+ # Post a general comment on a change (using change number)
114
+ $ ger comment 12345 -m "Looks good to me!"
115
+
116
+ # Post a comment using Change-ID
117
+ $ ger comment If5a3ae8cb5a107e187447802358417f311d0c4b1 -m "LGTM"
118
+
119
+ # Post a comment using piped input (useful for multi-line comments or scripts)
120
+ $ echo "This is a comment from stdin!" | ger comment 12345
121
+ $ cat review-notes.txt | ger comment 12345
122
+
123
+ # Post a line-specific comment (line number from NEW file version)
124
+ $ ger comment 12345 --file src/main.js --line 42 -m "Consider using const here"
125
+
126
+ # Post an unresolved comment requiring human attention
127
+ $ ger comment 12345 --file src/api.js --line 15 -m "Security concern" --unresolved
128
+
129
+ # Post multiple comments using batch mode
130
+ $ echo '{"message": "Review complete", "comments": [
131
+ {"file": "src/main.js", "line": 10, "message": "Good refactor"},
132
+ {"file": "src/api.js", "line": 25, "message": "Check error handling", "unresolved": true}
133
+ ]}' | ger comment 12345 --batch
134
+
135
+ Note:
136
+ - Both change number (e.g., 12345) and Change-ID (e.g., If5a3ae8...) formats are accepted
137
+ - Line numbers refer to the actual line numbers in the NEW version of the file,
138
+ NOT the line numbers shown in the diff view. To find the correct line number,
139
+ look at the file after all changes have been applied.`,
140
+ )
141
+ .action(async (changeId, options) => {
142
+ await executeEffect(
143
+ commentCommand(changeId, options).pipe(
144
+ Effect.provide(GerritApiServiceLive),
145
+ Effect.provide(ConfigServiceLive),
146
+ ),
147
+ options,
148
+ 'comment_result',
149
+ )
150
+ })
151
+
152
+ // diff command
153
+ program
154
+ .command('diff <change-id>')
155
+ .description('Get diff for a change (accepts change number or Change-ID)')
156
+ .option('--xml', 'XML output for LLM consumption')
157
+ .option('--file <file>', 'Specific file to diff')
158
+ .option('--files-only', 'List changed files only')
159
+ .option('--format <format>', 'Output format (unified, json, files)')
160
+ .action(async (changeId, options) => {
161
+ await executeEffect(
162
+ diffCommand(changeId, options).pipe(
163
+ Effect.provide(GerritApiServiceLive),
164
+ Effect.provide(ConfigServiceLive),
165
+ ),
166
+ options,
167
+ 'diff_result',
168
+ )
169
+ })
170
+
171
+ // mine command
172
+ program
173
+ .command('mine')
174
+ .description('Show your open changes')
175
+ .option('--xml', 'XML output for LLM consumption')
176
+ .action(async (options) => {
177
+ await executeEffect(
178
+ mineCommand(options).pipe(
179
+ Effect.provide(GerritApiServiceLive),
180
+ Effect.provide(ConfigServiceLive),
181
+ ),
182
+ options,
183
+ 'mine_result',
184
+ )
185
+ })
186
+
187
+ // search command
188
+ program
189
+ .command('search [query]')
190
+ .description('Search changes using Gerrit query syntax')
191
+ .option('--xml', 'XML output for LLM consumption')
192
+ .option('-n, --limit <number>', 'Limit results (default: 25)')
193
+ .addHelpText('after', SEARCH_HELP_TEXT)
194
+ .action(async (query, options) => {
195
+ const effect = searchCommand(query, options).pipe(
196
+ Effect.provide(GerritApiServiceLive),
197
+ Effect.provide(ConfigServiceLive),
198
+ )
199
+ await Effect.runPromise(effect).catch((error: unknown) => {
200
+ if (options.xml) {
201
+ console.log(`<?xml version="1.0" encoding="UTF-8"?>`)
202
+ console.log(`<search_result>`)
203
+ console.log(` <status>error</status>`)
204
+ console.log(
205
+ ` <error><![CDATA[${error instanceof Error ? error.message : String(error)}]]></error>`,
206
+ )
207
+ console.log(`</search_result>`)
208
+ } else {
209
+ console.error('✗ Error:', error instanceof Error ? error.message : String(error))
210
+ }
211
+ process.exit(1)
212
+ })
213
+ })
214
+
215
+ // workspace command
216
+ program
217
+ .command('workspace <change-id>')
218
+ .description(
219
+ 'Create or switch to a git worktree for a Gerrit change (accepts change number or Change-ID)',
220
+ )
221
+ .option('--xml', 'XML output for LLM consumption')
222
+ .action(async (changeId, options) => {
223
+ await executeEffect(
224
+ workspaceCommand(changeId, options).pipe(
225
+ Effect.provide(GerritApiServiceLive),
226
+ Effect.provide(ConfigServiceLive),
227
+ ),
228
+ options,
229
+ 'workspace_result',
230
+ )
231
+ })
232
+
233
+ // incoming command
234
+ program
235
+ .command('incoming')
236
+ .description('Show incoming changes for review (where you are a reviewer)')
237
+ .option('--xml', 'XML output for LLM consumption')
238
+ .option('-i, --interactive', 'Interactive mode with detailed view and diff')
239
+ .action(async (options) => {
240
+ await executeEffect(
241
+ incomingCommand(options).pipe(
242
+ Effect.provide(GerritApiServiceLive),
243
+ Effect.provide(ConfigServiceLive),
244
+ ),
245
+ options,
246
+ 'incoming_result',
247
+ )
248
+ })
249
+
250
+ // abandon command
251
+ program
252
+ .command('abandon [change-id]')
253
+ .description(
254
+ 'Abandon a change (interactive mode if no change-id provided; accepts change number or Change-ID)',
255
+ )
256
+ .option('-m, --message <message>', 'Abandon message')
257
+ .option('--xml', 'XML output for LLM consumption')
258
+ .action(async (changeId, options) => {
259
+ await executeEffect(
260
+ abandonCommand(changeId, options).pipe(
261
+ Effect.provide(GerritApiServiceLive),
262
+ Effect.provide(ConfigServiceLive),
263
+ ),
264
+ options,
265
+ 'abandon_result',
266
+ )
267
+ })
268
+
269
+ // restore command
270
+ program
271
+ .command('restore <change-id>')
272
+ .description('Restore an abandoned change (accepts change number or Change-ID)')
273
+ .option('-m, --message <message>', 'Restoration message')
274
+ .option('--xml', 'XML output for LLM consumption')
275
+ .action(async (changeId, options) => {
276
+ await executeEffect(
277
+ restoreCommand(changeId, options).pipe(
278
+ Effect.provide(GerritApiServiceLive),
279
+ Effect.provide(ConfigServiceLive),
280
+ ),
281
+ options,
282
+ 'restore_result',
283
+ )
284
+ })
285
+
286
+ // rebase command
287
+ program
288
+ .command('rebase <change-id>')
289
+ .description('Rebase a change onto target branch (accepts change number or Change-ID)')
290
+ .option('--base <ref>', 'Base revision to rebase onto (default: target branch HEAD)')
291
+ .option('--xml', 'XML output for LLM consumption')
292
+ .action(async (changeId, options) => {
293
+ await executeEffect(
294
+ rebaseCommand(changeId, options).pipe(
295
+ Effect.provide(GerritApiServiceLive),
296
+ Effect.provide(ConfigServiceLive),
297
+ ),
298
+ options,
299
+ 'rebase_result',
300
+ )
301
+ })
302
+
303
+ // submit command
304
+ program
305
+ .command('submit <change-id>')
306
+ .description('Submit a change for merging (accepts change number or Change-ID)')
307
+ .option('--xml', 'XML output for LLM consumption')
308
+ .action(async (changeId, options) => {
309
+ await executeEffect(
310
+ submitCommand(changeId, options).pipe(
311
+ Effect.provide(GerritApiServiceLive),
312
+ Effect.provide(ConfigServiceLive),
313
+ ),
314
+ options,
315
+ 'submit_result',
316
+ )
317
+ })
318
+
319
+ // vote command
320
+ program
321
+ .command('vote <change-id>')
322
+ .description('Cast votes on a change (accepts change number or Change-ID)')
323
+ .option('--code-review <value>', 'Code-Review vote (-2 to +2)', parseInt)
324
+ .option('--verified <value>', 'Verified vote (-1 to +1)', parseInt)
325
+ .option('--label <name> <value>', 'Custom label vote (can be used multiple times)')
326
+ .option('-m, --message <message>', 'Comment with vote')
327
+ .option('--xml', 'XML output for LLM consumption')
328
+ .action(async (changeId, options) => {
329
+ await executeEffect(
330
+ voteCommand(changeId, options).pipe(
331
+ Effect.provide(GerritApiServiceLive),
332
+ Effect.provide(ConfigServiceLive),
333
+ ),
334
+ options,
335
+ 'vote_result',
336
+ )
337
+ })
338
+
339
+ // Register all reviewer-related commands
340
+ registerReviewerCommands(program)
341
+
342
+ // projects command
343
+ program
344
+ .command('projects')
345
+ .description('List Gerrit projects')
346
+ .option('--pattern <regex>', 'Filter projects by name pattern')
347
+ .option('--xml', 'XML output for LLM consumption')
348
+ .action(async (options) => {
349
+ await executeEffect(
350
+ projectsCommand(options).pipe(
351
+ Effect.provide(GerritApiServiceLive),
352
+ Effect.provide(ConfigServiceLive),
353
+ ),
354
+ options,
355
+ 'projects_result',
356
+ )
357
+ })
358
+
359
+ // Register all group-related commands
360
+ registerGroupCommands(program)
361
+
362
+ // comments command
363
+ program
364
+ .command('comments <change-id>')
365
+ .description(
366
+ 'Show all comments on a change with diff context (accepts change number or Change-ID)',
367
+ )
368
+ .option('--xml', 'XML output for LLM consumption')
369
+ .action(async (changeId, options) => {
370
+ await executeEffect(
371
+ commentsCommand(changeId, options).pipe(
372
+ Effect.provide(GerritApiServiceLive),
373
+ Effect.provide(ConfigServiceLive),
374
+ ),
375
+ options,
376
+ 'comments_result',
377
+ )
378
+ })
379
+
380
+ // open command
381
+ program
382
+ .command('open <change-id>')
383
+ .description('Open a change in the browser (accepts change number or Change-ID)')
384
+ .action(async (changeId, options) => {
385
+ try {
386
+ const effect = openCommand(changeId, options).pipe(
387
+ Effect.provide(GerritApiServiceLive),
388
+ Effect.provide(ConfigServiceLive),
389
+ )
390
+ await Effect.runPromise(effect)
391
+ } catch (error) {
392
+ console.error('✗ Error:', error instanceof Error ? error.message : String(error))
393
+ process.exit(1)
394
+ }
395
+ })
396
+
397
+ // show command
398
+ program
399
+ .command('show [change-id]')
400
+ .description(
401
+ 'Show comprehensive change information (auto-detects from HEAD commit if not specified)',
402
+ )
403
+ .option('--xml', 'XML output for LLM consumption')
404
+ .option('--json', 'JSON output for programmatic consumption')
405
+ .addHelpText('after', SHOW_HELP_TEXT)
406
+ .action(async (changeId, options) => {
407
+ try {
408
+ const effect = showCommand(changeId, options).pipe(
409
+ Effect.provide(GerritApiServiceLive),
410
+ Effect.provide(ConfigServiceLive),
411
+ )
412
+ await Effect.runPromise(effect)
413
+ } catch (error) {
414
+ const errorMessage = error instanceof Error ? error.message : String(error)
415
+ if (options.json) {
416
+ console.log(JSON.stringify({ status: 'error', error: errorMessage }, null, 2))
417
+ } else if (options.xml) {
418
+ console.log(`<?xml version="1.0" encoding="UTF-8"?>`)
419
+ console.log(`<show_result>`)
420
+ console.log(` <status>error</status>`)
421
+ console.log(` <error><![CDATA[${sanitizeCDATA(errorMessage)}]]></error>`)
422
+ console.log(`</show_result>`)
423
+ } else {
424
+ console.error('✗ Error:', errorMessage)
425
+ }
426
+ process.exit(1)
427
+ }
428
+ })
429
+
430
+ // build-status command
431
+ program
432
+ .command('build-status [change-id]')
433
+ .description(
434
+ 'Check build status from Gerrit messages (auto-detects from HEAD commit if not specified)',
435
+ )
436
+ .option('--watch', 'Watch build status until completion (mimics gh run watch)')
437
+ .option('-i, --interval <seconds>', 'Refresh interval in seconds (default: 10)', '10')
438
+ .option('--timeout <seconds>', 'Maximum wait time in seconds (default: 1800 / 30min)', '1800')
439
+ .option('--exit-status', 'Exit with non-zero status if build fails')
440
+ .addHelpText('after', BUILD_STATUS_HELP_TEXT)
441
+ .action(async (changeId, cmdOptions) => {
442
+ try {
443
+ const effect = buildStatusCommand(changeId, {
444
+ watch: cmdOptions.watch,
445
+ interval: Number.parseInt(cmdOptions.interval, 10),
446
+ timeout: Number.parseInt(cmdOptions.timeout, 10),
447
+ exitStatus: cmdOptions.exitStatus,
448
+ }).pipe(Effect.provide(GerritApiServiceLive), Effect.provide(ConfigServiceLive))
449
+ await Effect.runPromise(effect)
450
+ } catch (error) {
451
+ // Errors are handled within the command itself
452
+ // This catch is just for any unexpected errors
453
+ if (error instanceof Error && error.message !== 'Process exited') {
454
+ console.error('✗ Unexpected error:', error.message)
455
+ process.exit(3)
456
+ }
457
+ }
458
+ })
459
+
460
+ // extract-url command
461
+ program
462
+ .command('extract-url <pattern> [change-id]')
463
+ .description(
464
+ 'Extract URLs from change messages and comments (auto-detects from HEAD commit if not specified)',
465
+ )
466
+ .option('--include-comments', 'Also search inline comments (default: messages only)')
467
+ .option('--regex', 'Treat pattern as regex instead of substring match')
468
+ .option('--xml', 'XML output for LLM consumption')
469
+ .option('--json', 'JSON output for programmatic consumption')
470
+ .addHelpText(
471
+ 'after',
472
+ `
473
+ Examples:
474
+ # Extract all Jenkins build-summary-report URLs (substring match)
475
+ $ ger extract-url "build-summary-report"
476
+
477
+ # Get the latest build URL using tail
478
+ $ ger extract-url "build-summary-report" | tail -1
479
+
480
+ # Get the first build URL using head
481
+ $ ger extract-url "jenkins.inst-ci.net" | head -1
482
+
483
+ # For a specific change (using change number)
484
+ $ ger extract-url "build-summary" 391831
485
+
486
+ # For a specific change (using Change-ID)
487
+ $ ger extract-url "jenkins" If5a3ae8cb5a107e187447802358417f311d0c4b1
488
+
489
+ # Use regex for precise matching
490
+ $ ger extract-url "job/MyProject/job/main/\\d+/" --regex
491
+
492
+ # Search both messages and inline comments
493
+ $ ger extract-url "github.com" --include-comments
494
+
495
+ # JSON output for scripting
496
+ $ ger extract-url "jenkins" --json | jq -r '.urls[-1]'
497
+
498
+ # XML output
499
+ $ ger extract-url "jenkins" --xml
500
+
501
+ Note:
502
+ - URLs are output in chronological order (oldest first)
503
+ - Use tail -1 to get the latest URL, head -1 for the oldest
504
+ - When no change-id is provided, it will be automatically extracted from the
505
+ Change-ID footer in your HEAD commit`,
506
+ )
507
+ .action(async (pattern, changeId, options) => {
508
+ try {
509
+ const effect = extractUrlCommand(pattern, changeId, options).pipe(
510
+ Effect.provide(GerritApiServiceLive),
511
+ Effect.provide(ConfigServiceLive),
512
+ )
513
+ await Effect.runPromise(effect)
514
+ } catch (error) {
515
+ const errorMessage = error instanceof Error ? error.message : String(error)
516
+ if (options.json) {
517
+ console.log(JSON.stringify({ status: 'error', error: errorMessage }, null, 2))
518
+ } else if (options.xml) {
519
+ console.log(`<?xml version="1.0" encoding="UTF-8"?>`)
520
+ console.log(`<extract_url_result>`)
521
+ console.log(` <status>error</status>`)
522
+ console.log(` <error><![CDATA[${sanitizeCDATA(errorMessage)}]]></error>`)
523
+ console.log(`</extract_url_result>`)
524
+ } else {
525
+ console.error('✗ Error:', errorMessage)
526
+ }
527
+ process.exit(1)
528
+ }
529
+ })
530
+
531
+ // push command
532
+ program
533
+ .command('push')
534
+ .description('Push commits to Gerrit for code review')
535
+ .option('-b, --branch <branch>', 'Target branch (default: auto-detect)')
536
+ .option('-t, --topic <topic>', 'Set change topic')
537
+ .option('-r, --reviewer <email...>', 'Add reviewer(s)')
538
+ .option('--cc <email...>', 'Add CC recipient(s)')
539
+ .option('--wip', 'Mark as work-in-progress')
540
+ .option('--ready', 'Mark as ready for review')
541
+ .option('--hashtag <tag...>', 'Add hashtag(s)')
542
+ .option('--private', 'Mark change as private')
543
+ .option('--draft', 'Alias for --wip')
544
+ .option('--dry-run', 'Show what would be pushed without pushing')
545
+ .addHelpText('after', PUSH_HELP_TEXT)
546
+ .action(async (options) => {
547
+ try {
548
+ const effect = pushCommand({
549
+ branch: options.branch,
550
+ topic: options.topic,
551
+ reviewer: options.reviewer,
552
+ cc: options.cc,
553
+ wip: options.wip,
554
+ ready: options.ready,
555
+ hashtag: options.hashtag,
556
+ private: options.private,
557
+ draft: options.draft,
558
+ dryRun: options.dryRun,
559
+ }).pipe(Effect.provide(CommitHookServiceLive), Effect.provide(ConfigServiceLive))
560
+ await Effect.runPromise(effect)
561
+ } catch (error) {
562
+ console.error('Error:', error instanceof Error ? error.message : String(error))
563
+ process.exit(1)
564
+ }
565
+ })
566
+
567
+ // checkout command
568
+ program
569
+ .command('checkout <change-id>')
570
+ .description('Fetch and checkout a Gerrit change')
571
+ .option('--detach', 'Checkout as detached HEAD without creating branch')
572
+ .option('--remote <name>', 'Use specific git remote (default: auto-detect)')
573
+ .addHelpText('after', CHECKOUT_HELP_TEXT)
574
+ .action(async (changeId, options) => {
575
+ try {
576
+ const effect = checkoutCommand(changeId, {
577
+ detach: options.detach,
578
+ remote: options.remote,
579
+ }).pipe(Effect.provide(GerritApiServiceLive), Effect.provide(ConfigServiceLive))
580
+ await Effect.runPromise(effect)
581
+ } catch (error) {
582
+ console.error('Error:', error instanceof Error ? error.message : String(error))
583
+ process.exit(1)
584
+ }
585
+ })
586
+
587
+ // review command
588
+ program
589
+ .command('review <change-id>')
590
+ .description(
591
+ 'AI-powered code review that analyzes changes and optionally posts comments (accepts change number or Change-ID)',
592
+ )
593
+ .option('--comment', 'Post the review as comments (prompts for confirmation)')
594
+ .option('-y, --yes', 'Skip confirmation prompts when posting comments')
595
+ .option('--debug', 'Show debug output including AI responses')
596
+ .option('--prompt <file>', 'Path to custom review prompt file (e.g., ~/prompts/review.md)')
597
+ .option('--tool <tool>', 'Preferred AI tool (claude, gemini, opencode)')
598
+ .option('--system-prompt <prompt>', 'Custom system prompt for the AI')
599
+ .addHelpText(
600
+ 'after',
601
+ `
602
+ This command uses AI (claude CLI, gemini CLI, or opencode CLI) to review a Gerrit change.
603
+ It performs a two-stage review process:
604
+
605
+ 1. Generates inline comments for specific code issues
606
+ 2. Generates an overall review comment
607
+
608
+ By default, the review is only displayed in the terminal.
609
+ Use --comment to post the review to Gerrit (with confirmation prompts).
610
+ Use --comment --yes to post without confirmation.
611
+
612
+ Requirements:
613
+ - One of these AI tools must be available: claude CLI, gemini CLI, or opencode CLI
614
+ - Gerrit credentials must be configured (run 'ger setup' first)
615
+
616
+ Examples:
617
+ # Review a change using change number (display only)
618
+ $ ger review 12345
619
+
620
+ # Review using Change-ID
621
+ $ ger review If5a3ae8cb5a107e187447802358417f311d0c4b1
622
+
623
+ # Review and prompt to post comments
624
+ $ ger review 12345 --comment
625
+
626
+ # Review and auto-post comments without prompting
627
+ $ ger review 12345 --comment --yes
628
+
629
+ # Use specific AI tool
630
+ $ ger review 12345 --tool gemini
631
+
632
+ # Show debug output to troubleshoot issues
633
+ $ ger review 12345 --debug
634
+
635
+ Note: Both change number (e.g., 12345) and Change-ID (e.g., If5a3ae8...) formats are accepted
636
+ `,
637
+ )
638
+ .action(async (changeId, options) => {
639
+ try {
640
+ const effect = reviewCommand(changeId, {
641
+ comment: options.comment,
642
+ yes: options.yes,
643
+ debug: options.debug,
644
+ prompt: options.prompt,
645
+ tool: options.tool,
646
+ systemPrompt: options.systemPrompt,
647
+ }).pipe(
648
+ Effect.provide(ReviewStrategyServiceLive),
649
+ Effect.provide(GerritApiServiceLive),
650
+ Effect.provide(ConfigServiceLive),
651
+ Effect.provide(GitWorktreeServiceLive),
652
+ )
653
+ await Effect.runPromise(effect)
654
+ } catch (error) {
655
+ console.error('✗ Error:', error instanceof Error ? error.message : String(error))
656
+ process.exit(1)
657
+ }
658
+ })
659
+ }