@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,430 @@
1
+ import { execSync, spawnSync } from 'node:child_process'
2
+ import { Console, Effect } from 'effect'
3
+ import chalk from 'chalk'
4
+ import { ConfigService, type ConfigError, type ConfigServiceImpl } from '@/services/config'
5
+ import {
6
+ CommitHookService,
7
+ NotGitRepoError,
8
+ type HookInstallError,
9
+ type MissingChangeIdError,
10
+ type CommitHookServiceImpl,
11
+ } from '@/services/commit-hook'
12
+
13
+ /** Help text for push command - exported to keep index.ts under line limit */
14
+ export const PUSH_HELP_TEXT = `
15
+ Examples:
16
+ # Basic push to auto-detected target branch
17
+ $ ger push
18
+
19
+ # Push to specific branch
20
+ $ ger push -b master
21
+ $ ger push --branch feature/foo
22
+
23
+ # With topic
24
+ $ ger push -t my-feature
25
+
26
+ # With reviewers (can be repeated)
27
+ $ ger push -r alice@example.com -r bob@example.com
28
+
29
+ # With CC
30
+ $ ger push --cc manager@example.com
31
+
32
+ # Work in progress
33
+ $ ger push --wip
34
+
35
+ # Mark ready for review
36
+ $ ger push --ready
37
+
38
+ # Add hashtag
39
+ $ ger push --hashtag bugfix
40
+
41
+ # Combine options
42
+ $ ger push -b master -t refactor-auth -r alice@example.com --wip
43
+
44
+ # Dry run (show what would be pushed)
45
+ $ ger push --dry-run
46
+
47
+ Note:
48
+ - Auto-installs commit-msg hook if missing
49
+ - Auto-detects target branch from tracking branch or defaults to main/master
50
+ - Supports all standard Gerrit push options`
51
+
52
+ export interface PushOptions {
53
+ branch?: string
54
+ topic?: string
55
+ reviewer?: string[]
56
+ cc?: string[]
57
+ wip?: boolean
58
+ ready?: boolean
59
+ hashtag?: string[]
60
+ private?: boolean
61
+ draft?: boolean
62
+ dryRun?: boolean
63
+ }
64
+
65
+ // Custom error for push-specific failures
66
+ export class PushError extends Error {
67
+ readonly _tag = 'PushError'
68
+ constructor(message: string) {
69
+ super(message)
70
+ this.name = 'PushError'
71
+ }
72
+ }
73
+
74
+ export type PushErrors =
75
+ | ConfigError
76
+ | HookInstallError
77
+ | MissingChangeIdError
78
+ | NotGitRepoError
79
+ | PushError
80
+
81
+ /** Basic email validation pattern */
82
+ const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
83
+
84
+ /** Validate email addresses for reviewer/cc options */
85
+ export const validateEmails = (emails: string[] | undefined, fieldName: string): void => {
86
+ if (!emails) return
87
+ for (const email of emails) {
88
+ if (!EMAIL_PATTERN.test(email)) {
89
+ throw new PushError(
90
+ `Invalid email address for ${fieldName}: "${email}"\n` + `Expected format: user@domain.com`,
91
+ )
92
+ }
93
+ }
94
+ }
95
+
96
+ // Get git remotes
97
+ const getGitRemotes = (): Record<string, string> => {
98
+ try {
99
+ const output = execSync('git remote -v', { encoding: 'utf8' })
100
+ const remotes: Record<string, string> = {}
101
+
102
+ for (const line of output.split('\n')) {
103
+ const match = line.match(/^(\S+)\s+(\S+)\s+\(push\)$/)
104
+ if (match) {
105
+ remotes[match[1]] = match[2]
106
+ }
107
+ }
108
+
109
+ return remotes
110
+ } catch {
111
+ return {}
112
+ }
113
+ }
114
+
115
+ // Find remote matching Gerrit host
116
+ const findMatchingRemote = (gerritHost: string): string | null => {
117
+ const remotes = getGitRemotes()
118
+
119
+ // Parse gerrit host
120
+ const gerritUrl = new URL(gerritHost)
121
+ const gerritHostname = gerritUrl.hostname
122
+
123
+ // Check each remote
124
+ for (const [name, url] of Object.entries(remotes)) {
125
+ try {
126
+ let remoteHostname: string
127
+
128
+ if (url.startsWith('git@') || url.includes('://')) {
129
+ if (url.startsWith('git@')) {
130
+ // SSH format: git@hostname:project
131
+ remoteHostname = url.split('@')[1].split(':')[0]
132
+ } else {
133
+ // HTTP format
134
+ const remoteUrl = new URL(url)
135
+ remoteHostname = remoteUrl.hostname
136
+ }
137
+
138
+ if (remoteHostname === gerritHostname) {
139
+ return name
140
+ }
141
+ }
142
+ } catch {
143
+ // Ignore malformed URLs
144
+ }
145
+ }
146
+
147
+ return null
148
+ }
149
+
150
+ // Check if we're in a git repo
151
+ const isInGitRepo = (): boolean => {
152
+ try {
153
+ execSync('git rev-parse --git-dir', { encoding: 'utf8' })
154
+ return true
155
+ } catch {
156
+ return false
157
+ }
158
+ }
159
+
160
+ // Get current branch name
161
+ const getCurrentBranch = (): string | null => {
162
+ try {
163
+ const branch = execSync('git symbolic-ref --short HEAD', { encoding: 'utf8' }).trim()
164
+ return branch || null
165
+ } catch {
166
+ return null
167
+ }
168
+ }
169
+
170
+ // Get tracking branch for current branch
171
+ const getTrackingBranch = (): string | null => {
172
+ try {
173
+ // Get the upstream branch reference
174
+ const upstream = execSync('git rev-parse --abbrev-ref @{upstream}', {
175
+ encoding: 'utf8',
176
+ stdio: ['pipe', 'pipe', 'pipe'],
177
+ }).trim()
178
+
179
+ // Extract branch name (remove remote prefix like "origin/")
180
+ const parts = upstream.split('/')
181
+ if (parts.length > 1) {
182
+ return parts.slice(1).join('/')
183
+ }
184
+ return upstream
185
+ } catch {
186
+ return null
187
+ }
188
+ }
189
+
190
+ // Check if a remote branch exists
191
+ const remoteBranchExists = (remote: string, branch: string): boolean => {
192
+ try {
193
+ execSync(`git rev-parse --verify ${remote}/${branch}`, {
194
+ encoding: 'utf8',
195
+ stdio: ['pipe', 'pipe', 'pipe'],
196
+ })
197
+ return true
198
+ } catch {
199
+ return false
200
+ }
201
+ }
202
+
203
+ // Detect target branch with fallback strategy
204
+ const detectTargetBranch = (remote: string): string => {
205
+ // 1. Try tracking branch
206
+ const tracking = getTrackingBranch()
207
+ if (tracking) {
208
+ return tracking
209
+ }
210
+
211
+ // 2. Check if origin/main exists
212
+ if (remoteBranchExists(remote, 'main')) {
213
+ return 'main'
214
+ }
215
+
216
+ // 3. Check if origin/master exists
217
+ if (remoteBranchExists(remote, 'master')) {
218
+ return 'master'
219
+ }
220
+
221
+ // 4. Final fallback
222
+ return 'master'
223
+ }
224
+
225
+ // Build Gerrit push refspec with options
226
+ export const buildPushRefspec = (branch: string, options: PushOptions): string => {
227
+ let refspec = `refs/for/${branch}`
228
+ const params: string[] = []
229
+
230
+ if (options.topic) {
231
+ params.push(`topic=${encodeURIComponent(options.topic)}`)
232
+ }
233
+
234
+ // --draft is an alias for --wip; both map to Gerrit's 'wip' push option
235
+ if (options.wip || options.draft) {
236
+ params.push('wip')
237
+ }
238
+
239
+ if (options.ready) {
240
+ params.push('ready')
241
+ }
242
+
243
+ if (options.private) {
244
+ params.push('private')
245
+ }
246
+
247
+ if (options.reviewer) {
248
+ for (const reviewer of options.reviewer) {
249
+ params.push(`r=${reviewer}`)
250
+ }
251
+ }
252
+
253
+ if (options.cc) {
254
+ for (const cc of options.cc) {
255
+ params.push(`cc=${cc}`)
256
+ }
257
+ }
258
+
259
+ if (options.hashtag) {
260
+ for (const tag of options.hashtag) {
261
+ params.push(`hashtag=${encodeURIComponent(tag)}`)
262
+ }
263
+ }
264
+
265
+ if (params.length > 0) {
266
+ refspec += '%' + params.join(',')
267
+ }
268
+
269
+ return refspec
270
+ }
271
+
272
+ // Parse push output to extract change URL
273
+ const extractChangeUrl = (output: string): string | null => {
274
+ // Gerrit push output format: "remote: https://gerrit.example.com/c/project/+/12345"
275
+ const urlMatch = output.match(/remote:\s+(https?:\/\/\S+\/c\/\S+\/\+\/\d+)/)
276
+ if (urlMatch) {
277
+ return urlMatch[1]
278
+ }
279
+
280
+ return null
281
+ }
282
+
283
+ export const pushCommand = (
284
+ options: PushOptions,
285
+ ): Effect.Effect<void, PushErrors, ConfigServiceImpl | CommitHookServiceImpl> =>
286
+ Effect.gen(function* () {
287
+ // Validate email addresses early
288
+ yield* Effect.try({
289
+ try: () => {
290
+ validateEmails(options.reviewer, 'reviewer')
291
+ validateEmails(options.cc, 'cc')
292
+ },
293
+ catch: (e) => (e instanceof PushError ? e : new PushError(String(e))),
294
+ })
295
+
296
+ // Check if we're in a git repo
297
+ if (!isInGitRepo()) {
298
+ return yield* Effect.fail(new NotGitRepoError({ message: 'Not in a git repository' }))
299
+ }
300
+
301
+ // Get config for Gerrit host
302
+ const configService = yield* ConfigService
303
+ const credentials = yield* configService.getCredentials
304
+
305
+ // Find matching remote
306
+ const remote = findMatchingRemote(credentials.host)
307
+ if (!remote) {
308
+ return yield* Effect.fail(
309
+ new PushError(
310
+ `No git remote found matching Gerrit host: ${credentials.host}\n` +
311
+ `Please ensure your git remote points to the Gerrit server.`,
312
+ ),
313
+ )
314
+ }
315
+
316
+ // Ensure commit has Change-Id (installs hook if needed)
317
+ const commitHookService = yield* CommitHookService
318
+ yield* commitHookService.ensureChangeId()
319
+
320
+ // Determine target branch
321
+ const targetBranch = options.branch || detectTargetBranch(remote)
322
+
323
+ // Build refspec
324
+ const refspec = buildPushRefspec(targetBranch, options)
325
+
326
+ // Current branch info
327
+ const currentBranch = getCurrentBranch() || 'HEAD'
328
+
329
+ // Display what we're doing
330
+ if (options.dryRun) {
331
+ yield* Console.log(chalk.yellow('Dry run mode - no changes will be pushed\n'))
332
+ }
333
+
334
+ yield* Console.log(chalk.bold('Pushing to Gerrit'))
335
+ yield* Console.log(` Remote: ${remote} (${credentials.host})`)
336
+ yield* Console.log(` Branch: ${currentBranch} -> ${targetBranch}`)
337
+
338
+ if (options.topic) {
339
+ yield* Console.log(` Topic: ${options.topic}`)
340
+ }
341
+ if (options.reviewer && options.reviewer.length > 0) {
342
+ yield* Console.log(` Reviewers: ${options.reviewer.join(', ')}`)
343
+ }
344
+ if (options.cc && options.cc.length > 0) {
345
+ yield* Console.log(` CC: ${options.cc.join(', ')}`)
346
+ }
347
+ if (options.wip || options.draft) {
348
+ yield* Console.log(` Status: ${chalk.yellow('Work-in-Progress')}`)
349
+ }
350
+ if (options.ready) {
351
+ yield* Console.log(` Status: ${chalk.green('Ready for Review')}`)
352
+ }
353
+ if (options.hashtag && options.hashtag.length > 0) {
354
+ yield* Console.log(` Hashtags: ${options.hashtag.join(', ')}`)
355
+ }
356
+
357
+ yield* Console.log('')
358
+
359
+ // Build git push command
360
+ const args = ['push']
361
+ if (options.dryRun) {
362
+ args.push('--dry-run')
363
+ }
364
+ args.push(remote)
365
+ args.push(`HEAD:${refspec}`)
366
+
367
+ // Execute push
368
+ const result = spawnSync('git', args, {
369
+ encoding: 'utf8',
370
+ stdio: ['inherit', 'pipe', 'pipe'],
371
+ })
372
+
373
+ // Combine stdout and stderr (git push writes to stderr)
374
+ const output = (result.stdout || '') + (result.stderr || '')
375
+
376
+ if (result.status !== 0) {
377
+ // Parse common errors
378
+ if (output.includes('no new changes')) {
379
+ yield* Console.log(chalk.yellow('No new changes to push'))
380
+ return
381
+ }
382
+
383
+ if (output.includes('Permission denied') || output.includes('authentication failed')) {
384
+ return yield* Effect.fail(
385
+ new PushError(
386
+ 'Authentication failed. Please check your credentials with: ger status\n' +
387
+ 'You may need to regenerate your HTTP password in Gerrit settings.',
388
+ ),
389
+ )
390
+ }
391
+
392
+ if (output.includes('prohibited by Gerrit')) {
393
+ return yield* Effect.fail(
394
+ new PushError(
395
+ 'Push rejected by Gerrit. Common causes:\n' +
396
+ ' - Missing permissions for the target branch\n' +
397
+ ' - Branch may be read-only\n' +
398
+ ' - Change-Id may be in use by another change',
399
+ ),
400
+ )
401
+ }
402
+
403
+ return yield* Effect.fail(new PushError(`Push failed:\n${output}`))
404
+ }
405
+
406
+ // Success - try to extract change URL
407
+ const changeUrl = extractChangeUrl(output)
408
+
409
+ yield* Console.log(chalk.green('Push successful!'))
410
+
411
+ if (changeUrl) {
412
+ yield* Console.log(`\n ${chalk.cyan(changeUrl)}`)
413
+ }
414
+
415
+ // Show the raw output for additional info
416
+ if (output.includes('remote:')) {
417
+ const remoteLines = output
418
+ .split('\n')
419
+ .filter((line) => line.startsWith('remote:'))
420
+ .map((line) => line.replace('remote:', '').trim())
421
+ .filter((line) => line.length > 0)
422
+
423
+ if (remoteLines.length > 0) {
424
+ yield* Console.log('\nGerrit response:')
425
+ for (const line of remoteLines) {
426
+ yield* Console.log(` ${line}`)
427
+ }
428
+ }
429
+ }
430
+ })
@@ -0,0 +1,52 @@
1
+ import { Effect } from 'effect'
2
+ import { type ApiError, GerritApiService } from '@/api/gerrit'
3
+
4
+ interface RebaseOptions {
5
+ base?: string
6
+ xml?: boolean
7
+ }
8
+
9
+ /**
10
+ * Rebases a Gerrit change onto the target branch or specified base.
11
+ *
12
+ * @param changeId - Change number or Change-ID to rebase
13
+ * @param options - Configuration options
14
+ * @param options.base - Optional base revision to rebase onto (default: target branch HEAD)
15
+ * @param options.xml - Whether to output in XML format for LLM consumption
16
+ * @returns Effect that completes when the change is rebased
17
+ */
18
+ export const rebaseCommand = (
19
+ changeId?: string,
20
+ options: RebaseOptions = {},
21
+ ): Effect.Effect<void, ApiError, GerritApiService> =>
22
+ Effect.gen(function* () {
23
+ const gerritApi = yield* GerritApiService
24
+
25
+ if (!changeId || changeId.trim() === '') {
26
+ console.error('✗ Change ID is required')
27
+ console.error(' Usage: ger rebase <change-id> [--base <ref>]')
28
+ return
29
+ }
30
+
31
+ // Perform the rebase - this returns the rebased change info
32
+ const change = yield* gerritApi.rebaseChange(changeId, { base: options.base })
33
+
34
+ if (options.xml) {
35
+ console.log(`<?xml version="1.0" encoding="UTF-8"?>`)
36
+ console.log(`<rebase_result>`)
37
+ console.log(` <status>success</status>`)
38
+ console.log(` <change_number>${change._number}</change_number>`)
39
+ console.log(` <subject><![CDATA[${change.subject}]]></subject>`)
40
+ console.log(` <branch>${change.branch}</branch>`)
41
+ if (options.base) {
42
+ console.log(` <base><![CDATA[${options.base}]]></base>`)
43
+ }
44
+ console.log(`</rebase_result>`)
45
+ } else {
46
+ console.log(`✓ Rebased change ${change._number}: ${change.subject}`)
47
+ console.log(` Branch: ${change.branch}`)
48
+ if (options.base) {
49
+ console.log(` Base: ${options.base}`)
50
+ }
51
+ }
52
+ })
@@ -0,0 +1,123 @@
1
+ import { Effect } from 'effect'
2
+ import { type ApiError, GerritApiService } from '@/api/gerrit'
3
+ import { escapeXML, sanitizeCDATA } from '@/utils/shell-safety'
4
+
5
+ interface RemoveReviewerOptions {
6
+ change?: string
7
+ notify?: string
8
+ xml?: boolean
9
+ }
10
+
11
+ type NotifyLevel = 'NONE' | 'OWNER' | 'OWNER_REVIEWERS' | 'ALL'
12
+
13
+ const VALID_NOTIFY_LEVELS: ReadonlyArray<NotifyLevel> = ['NONE', 'OWNER', 'OWNER_REVIEWERS', 'ALL']
14
+
15
+ const isValidNotifyLevel = (value: string): value is NotifyLevel =>
16
+ VALID_NOTIFY_LEVELS.some((level) => level === value)
17
+
18
+ const outputXmlError = (message: string): void => {
19
+ console.log(`<?xml version="1.0" encoding="UTF-8"?>`)
20
+ console.log(`<remove_reviewer_result>`)
21
+ console.log(` <status>error</status>`)
22
+ console.log(` <error><![CDATA[${sanitizeCDATA(message)}]]></error>`)
23
+ console.log(`</remove_reviewer_result>`)
24
+ }
25
+
26
+ class ValidationError extends Error {
27
+ readonly _tag = 'ValidationError'
28
+ }
29
+
30
+ export const removeReviewerCommand = (
31
+ reviewers: string[],
32
+ options: RemoveReviewerOptions = {},
33
+ ): Effect.Effect<void, ApiError | ValidationError, GerritApiService> =>
34
+ Effect.gen(function* () {
35
+ const gerritApi = yield* GerritApiService
36
+
37
+ const changeId = options.change
38
+
39
+ if (!changeId) {
40
+ const message =
41
+ 'Change ID is required. Use -c <change-id> or run from a branch with an active change.'
42
+ if (options.xml) {
43
+ outputXmlError(message)
44
+ } else {
45
+ console.error(`✗ ${message}`)
46
+ }
47
+ return yield* Effect.fail(new ValidationError(message))
48
+ }
49
+
50
+ if (reviewers.length === 0) {
51
+ const message = 'At least one reviewer is required.'
52
+ if (options.xml) {
53
+ outputXmlError(message)
54
+ } else {
55
+ console.error(`✗ ${message}`)
56
+ }
57
+ return yield* Effect.fail(new ValidationError(message))
58
+ }
59
+
60
+ let notify: NotifyLevel | undefined
61
+ if (options.notify) {
62
+ const upperNotify = options.notify.toUpperCase()
63
+ if (!isValidNotifyLevel(upperNotify)) {
64
+ const message = `Invalid notify level: ${options.notify}. Valid values: none, owner, owner_reviewers, all`
65
+ if (options.xml) {
66
+ outputXmlError(message)
67
+ } else {
68
+ console.error(`✗ ${message}`)
69
+ }
70
+ yield* Effect.fail(new ValidationError(message))
71
+ return
72
+ }
73
+ notify = upperNotify
74
+ }
75
+
76
+ const results: Array<{ reviewer: string; success: boolean; error?: string }> = []
77
+
78
+ for (const reviewer of reviewers) {
79
+ const result = yield* Effect.either(
80
+ gerritApi.removeReviewer(changeId, reviewer, notify ? { notify } : undefined),
81
+ )
82
+
83
+ if (result._tag === 'Left') {
84
+ const error = result.left
85
+ const message = 'message' in error ? String(error.message) : String(error)
86
+ results.push({ reviewer, success: false, error: message })
87
+ continue
88
+ }
89
+
90
+ results.push({ reviewer, success: true })
91
+ }
92
+
93
+ if (options.xml) {
94
+ console.log(`<?xml version="1.0" encoding="UTF-8"?>`)
95
+ console.log(`<remove_reviewer_result>`)
96
+ console.log(` <change_id>${escapeXML(changeId)}</change_id>`)
97
+ console.log(` <reviewers>`)
98
+ for (const r of results) {
99
+ if (r.success) {
100
+ console.log(` <reviewer status="removed">`)
101
+ console.log(` <input>${escapeXML(r.reviewer)}</input>`)
102
+ console.log(` </reviewer>`)
103
+ } else {
104
+ console.log(` <reviewer status="failed">`)
105
+ console.log(` <input>${escapeXML(r.reviewer)}</input>`)
106
+ console.log(` <error><![CDATA[${sanitizeCDATA(r.error ?? '')}]]></error>`)
107
+ console.log(` </reviewer>`)
108
+ }
109
+ }
110
+ console.log(` </reviewers>`)
111
+ const allSuccess = results.every((r) => r.success)
112
+ console.log(` <status>${allSuccess ? 'success' : 'partial_failure'}</status>`)
113
+ console.log(`</remove_reviewer_result>`)
114
+ } else {
115
+ for (const r of results) {
116
+ if (r.success) {
117
+ console.log(`✓ Removed ${r.reviewer}`)
118
+ } else {
119
+ console.error(`✗ Failed to remove ${r.reviewer}: ${r.error}`)
120
+ }
121
+ }
122
+ }
123
+ })
@@ -0,0 +1,50 @@
1
+ import { Effect } from 'effect'
2
+ import { type ApiError, GerritApiService } from '@/api/gerrit'
3
+
4
+ interface RestoreOptions {
5
+ message?: string
6
+ xml?: boolean
7
+ }
8
+
9
+ /**
10
+ * Restores an abandoned Gerrit change to NEW status.
11
+ *
12
+ * @param changeId - Change number or Change-ID to restore
13
+ * @param options - Configuration options
14
+ * @param options.message - Optional restoration message
15
+ * @param options.xml - Whether to output in XML format for LLM consumption
16
+ * @returns Effect that completes when the change is restored
17
+ */
18
+ export const restoreCommand = (
19
+ changeId?: string,
20
+ options: RestoreOptions = {},
21
+ ): Effect.Effect<void, ApiError, GerritApiService> =>
22
+ Effect.gen(function* () {
23
+ const gerritApi = yield* GerritApiService
24
+
25
+ if (!changeId || changeId.trim() === '') {
26
+ console.error('✗ Change ID is required')
27
+ console.error(' Usage: ger restore <change-id>')
28
+ return
29
+ }
30
+
31
+ // Perform the restore - this returns the restored change info
32
+ const change = yield* gerritApi.restoreChange(changeId, options.message)
33
+
34
+ if (options.xml) {
35
+ console.log(`<?xml version="1.0" encoding="UTF-8"?>`)
36
+ console.log(`<restore_result>`)
37
+ console.log(` <status>success</status>`)
38
+ console.log(` <change_number>${change._number}</change_number>`)
39
+ console.log(` <subject><![CDATA[${change.subject}]]></subject>`)
40
+ if (options.message) {
41
+ console.log(` <message><![CDATA[${options.message}]]></message>`)
42
+ }
43
+ console.log(`</restore_result>`)
44
+ } else {
45
+ console.log(`✓ Restored change ${change._number}: ${change.subject}`)
46
+ if (options.message) {
47
+ console.log(` Message: ${options.message}`)
48
+ }
49
+ }
50
+ })