@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,486 @@
1
+ import { Effect, pipe, Schema } from 'effect'
2
+ import { ReviewStrategyService, ReviewStrategyError } from '@/services/review-strategy'
3
+ import { commentCommandWithInput } from './comment'
4
+ import { Console } from 'effect'
5
+ import { GerritApiService } from '@/api/gerrit'
6
+ import { buildEnhancedPrompt } from '@/utils/review-prompt-builder'
7
+ import * as fs from 'node:fs/promises'
8
+ import * as fsSync from 'node:fs'
9
+ import * as os from 'node:os'
10
+ import * as path from 'node:path'
11
+ import { fileURLToPath } from 'node:url'
12
+ import { dirname } from 'node:path'
13
+ import * as readline from 'node:readline'
14
+ import { GitWorktreeService } from '@/services/git-worktree'
15
+
16
+ // Get the directory of this module
17
+ const __filename = fileURLToPath(import.meta.url)
18
+ const __dirname = dirname(__filename)
19
+
20
+ // Effect-based file reading helper
21
+ const readFileEffect = (filePath: string): Effect.Effect<string, Error, never> =>
22
+ Effect.tryPromise({
23
+ try: () => fs.readFile(filePath, 'utf8'),
24
+ catch: (error) => new Error(`Failed to read file ${filePath}: ${error}`),
25
+ })
26
+
27
+ // Load default prompts from .md files using Effect
28
+ const loadDefaultPrompts = Effect.gen(function* () {
29
+ const defaultReviewPrompt = yield* readFileEffect(
30
+ path.join(__dirname, '../../prompts/default-review.md'),
31
+ )
32
+ const inlineReviewSystemPrompt = yield* readFileEffect(
33
+ path.join(__dirname, '../../prompts/system-inline-review.md'),
34
+ )
35
+ const overallReviewSystemPrompt = yield* readFileEffect(
36
+ path.join(__dirname, '../../prompts/system-overall-review.md'),
37
+ )
38
+
39
+ return {
40
+ defaultReviewPrompt,
41
+ inlineReviewSystemPrompt,
42
+ overallReviewSystemPrompt,
43
+ }
44
+ })
45
+
46
+ // Helper to expand tilde in file paths
47
+ const expandTilde = (filePath: string): string => {
48
+ if (filePath.startsWith('~/')) {
49
+ return path.join(os.homedir(), filePath.slice(2))
50
+ }
51
+ return filePath
52
+ }
53
+
54
+ // Helper to read prompt file using Effect
55
+ const readPromptFileEffect = (filePath: string): Effect.Effect<string | null, never, never> =>
56
+ Effect.gen(function* () {
57
+ const expanded = expandTilde(filePath)
58
+
59
+ // Check if file exists using sync method since Effect doesn't have a convenient async exists check
60
+ const exists = yield* Effect.try(() => fsSync.existsSync(expanded)).pipe(
61
+ Effect.catchAll(() => Effect.succeed(false)),
62
+ )
63
+
64
+ if (!exists) {
65
+ return null
66
+ }
67
+
68
+ // Read file using async Effect
69
+ const content = yield* readFileEffect(expanded).pipe(
70
+ Effect.catchAll(() => Effect.succeed(null)),
71
+ )
72
+
73
+ return content
74
+ })
75
+
76
+ interface ReviewOptions {
77
+ debug?: boolean
78
+ dryRun?: boolean
79
+ comment?: boolean
80
+ yes?: boolean
81
+ prompt?: string
82
+ tool?: string
83
+ systemPrompt?: string
84
+ }
85
+
86
+ // Schema for validating AI-generated inline comments
87
+ const InlineCommentSchema = Schema.Struct({
88
+ file: Schema.String,
89
+ message: Schema.String,
90
+ side: Schema.optional(Schema.String),
91
+ line: Schema.optional(Schema.Number),
92
+ range: Schema.optional(
93
+ Schema.Struct({
94
+ start_line: Schema.Number,
95
+ end_line: Schema.Number,
96
+ start_character: Schema.optional(Schema.Number),
97
+ end_character: Schema.optional(Schema.Number),
98
+ }),
99
+ ),
100
+ })
101
+
102
+ interface InlineComment extends Schema.Schema.Type<typeof InlineCommentSchema> {}
103
+
104
+ // Helper to validate and fix AI-generated inline comments
105
+ const validateAndFixInlineComments = (
106
+ rawComments: unknown[],
107
+ availableFiles: string[],
108
+ ): Effect.Effect<InlineComment[], never, never> =>
109
+ Effect.gen(function* () {
110
+ const validComments: InlineComment[] = []
111
+
112
+ for (const rawComment of rawComments) {
113
+ // Validate comment structure using Effect Schema
114
+ const parseResult = yield* Schema.decodeUnknown(InlineCommentSchema)(rawComment).pipe(
115
+ Effect.catchTag('ParseError', (_parseError) =>
116
+ Effect.gen(function* () {
117
+ yield* Console.warn('Skipping comment with invalid structure')
118
+ return yield* Effect.succeed(null)
119
+ }),
120
+ ),
121
+ )
122
+
123
+ if (!parseResult) {
124
+ continue
125
+ }
126
+
127
+ const comment = parseResult
128
+
129
+ // Skip comments with invalid line formats (like ":range")
130
+ if (!comment.line && !comment.range) {
131
+ yield* Console.warn('Skipping comment with invalid line format')
132
+ continue
133
+ }
134
+
135
+ // Try to find the correct file path
136
+ let correctFilePath = comment.file
137
+
138
+ // If the file path doesn't exist exactly, try to find a match
139
+ if (!availableFiles.includes(comment.file)) {
140
+ // Look for files that end with the provided path (secure path matching)
141
+ const matchingFiles = availableFiles.filter((file) => {
142
+ const normalizedFile = file.replace(/\\/g, '/')
143
+ const normalizedComment = comment.file.replace(/\\/g, '/')
144
+
145
+ // Only match if the comment path is a suffix of the file path with proper boundaries
146
+ return (
147
+ normalizedFile.endsWith(normalizedComment) &&
148
+ (normalizedFile === normalizedComment ||
149
+ normalizedFile.endsWith(`/${normalizedComment}`))
150
+ )
151
+ })
152
+
153
+ if (matchingFiles.length === 1) {
154
+ correctFilePath = matchingFiles[0]
155
+ yield* Console.log(`Fixed file path: ${comment.file} -> ${correctFilePath}`)
156
+ } else if (matchingFiles.length > 1) {
157
+ // Multiple matches, try to pick the most likely one (exact suffix match)
158
+ const exactMatch = matchingFiles.find((file) => file.endsWith(`/${comment.file}`))
159
+ if (exactMatch) {
160
+ correctFilePath = exactMatch
161
+ yield* Console.log(
162
+ `Fixed file path (exact match): ${comment.file} -> ${correctFilePath}`,
163
+ )
164
+ } else {
165
+ yield* Console.warn(`Multiple file matches for ${comment.file}. Skipping comment.`)
166
+ continue
167
+ }
168
+ } else {
169
+ yield* Console.warn(`File not found in change: ${comment.file}. Skipping comment.`)
170
+ continue
171
+ }
172
+ }
173
+
174
+ // Update the comment with the correct file path and add to valid comments
175
+ validComments.push({ ...comment, file: correctFilePath })
176
+ }
177
+
178
+ return validComments
179
+ })
180
+
181
+ // Helper function to prompt user for confirmation
182
+ const promptUser = (message: string): Effect.Effect<boolean, never> =>
183
+ Effect.async<boolean, never>((resume) => {
184
+ const rl = readline.createInterface({
185
+ input: process.stdin,
186
+ output: process.stdout,
187
+ })
188
+
189
+ rl.question(`${message} [y/N]: `, (answer: string) => {
190
+ rl.close()
191
+ resume(Effect.succeed(answer.toLowerCase() === 'y'))
192
+ })
193
+ })
194
+
195
+ export const reviewCommand = (
196
+ changeId: string,
197
+ options: ReviewOptions = {},
198
+ ): Effect.Effect<
199
+ void,
200
+ Error | ReviewStrategyError,
201
+ GerritApiService | ReviewStrategyService | GitWorktreeService
202
+ > =>
203
+ Effect.gen(function* () {
204
+ const reviewStrategy = yield* ReviewStrategyService
205
+ const gitService = yield* GitWorktreeService
206
+
207
+ // Load default prompts
208
+ const prompts = yield* loadDefaultPrompts
209
+
210
+ // Validate preconditions
211
+ yield* gitService.validatePreconditions()
212
+
213
+ // Check for available AI strategies
214
+ yield* Console.log('→ Checking AI tool availability...')
215
+ const availableStrategies = yield* reviewStrategy.getAvailableStrategies()
216
+
217
+ if (availableStrategies.length === 0) {
218
+ return yield* Effect.fail(
219
+ new Error('No AI tools available. Please install claude, gemini, or opencode CLI.'),
220
+ )
221
+ }
222
+
223
+ // Select strategy based on user preference
224
+ const selectedStrategy = yield* reviewStrategy.selectStrategy(options.tool)
225
+ yield* Console.log(`✓ Using AI tool: ${selectedStrategy.name}`)
226
+
227
+ // Load custom review prompt if provided
228
+ let userReviewPrompt = prompts.defaultReviewPrompt
229
+
230
+ if (options.prompt) {
231
+ const customPrompt = yield* readPromptFileEffect(options.prompt)
232
+ if (customPrompt) {
233
+ userReviewPrompt = customPrompt
234
+ yield* Console.log(`✓ Using custom review prompt from ${options.prompt}`)
235
+ } else {
236
+ yield* Console.log(`⚠ Could not read custom prompt file: ${options.prompt}`)
237
+ yield* Console.log('→ Using default review prompt')
238
+ }
239
+ }
240
+
241
+ // Use Effect's resource management for worktree lifecycle
242
+ yield* Effect.acquireUseRelease(
243
+ // Acquire: Create worktree and setup
244
+ Effect.gen(function* () {
245
+ const worktreeInfo = yield* gitService.createWorktree(changeId)
246
+ yield* gitService.fetchAndCheckoutPatchset(worktreeInfo)
247
+ return worktreeInfo
248
+ }),
249
+
250
+ // Use: Run the enhanced review process
251
+ (worktreeInfo) =>
252
+ Effect.gen(function* () {
253
+ // Switch to worktree directory
254
+ const originalCwd = process.cwd()
255
+ process.chdir(worktreeInfo.path)
256
+
257
+ try {
258
+ // Get changed files from git
259
+ const changedFiles = yield* gitService.getChangedFiles()
260
+
261
+ yield* Console.log(`→ Found ${changedFiles.length} changed files`)
262
+ if (options.debug) {
263
+ yield* Console.log(`[DEBUG] Changed files: ${changedFiles.join(', ')}`)
264
+ }
265
+
266
+ // Stage 1: Generate inline comments
267
+ yield* Console.log(`→ Generating inline comments for change ${changeId}...`)
268
+
269
+ const inlinePrompt = yield* buildEnhancedPrompt(
270
+ userReviewPrompt,
271
+ prompts.inlineReviewSystemPrompt,
272
+ changeId,
273
+ changedFiles,
274
+ )
275
+
276
+ // Run inline review using selected strategy
277
+ if (options.debug) {
278
+ yield* Console.log(`[DEBUG] Running inline review with ${selectedStrategy.name}`)
279
+ yield* Console.log(`[DEBUG] Working directory: ${worktreeInfo.path}`)
280
+ }
281
+
282
+ const inlineResponse = yield* reviewStrategy
283
+ .executeWithStrategy(selectedStrategy, inlinePrompt, {
284
+ cwd: worktreeInfo.path,
285
+ systemPrompt: options.systemPrompt || prompts.inlineReviewSystemPrompt,
286
+ })
287
+ .pipe(
288
+ Effect.catchTag('ReviewStrategyError', (error) =>
289
+ Effect.gen(function* () {
290
+ yield* Console.error(`✗ Inline review failed: ${error.message}`)
291
+ return yield* Effect.fail(new Error(error.message))
292
+ }),
293
+ ),
294
+ )
295
+
296
+ if (options.debug) {
297
+ yield* Console.log(`[DEBUG] Inline review completed`)
298
+ yield* Console.log(`[DEBUG] Response length: ${inlineResponse.length} chars`)
299
+ }
300
+
301
+ // Response content is ready for parsing
302
+ const extractedInlineResponse = inlineResponse.trim()
303
+
304
+ if (options.debug) {
305
+ yield* Console.log(
306
+ `[DEBUG] Extracted response for parsing:\n${extractedInlineResponse}`,
307
+ )
308
+ }
309
+
310
+ // Parse JSON array from response
311
+ const inlineCommentsArray = yield* Effect.tryPromise({
312
+ try: () => Promise.resolve(JSON.parse(extractedInlineResponse)),
313
+ catch: (error) => new Error(`Invalid JSON response: ${error}`),
314
+ }).pipe(
315
+ Effect.catchAll((error) =>
316
+ Effect.gen(function* () {
317
+ yield* Console.error(`✗ Failed to parse inline comments JSON: ${error}`)
318
+ yield* Console.error(`Raw extracted response: "${extractedInlineResponse}"`)
319
+ if (!options.debug) {
320
+ yield* Console.error('Run with --debug to see full AI output')
321
+ }
322
+ return yield* Effect.fail(error)
323
+ }),
324
+ ),
325
+ )
326
+
327
+ // Validate that the response is an array
328
+ if (!Array.isArray(inlineCommentsArray)) {
329
+ yield* Console.error('✗ AI response is not an array of comments')
330
+ return yield* Effect.fail(new Error('Invalid inline comments format'))
331
+ }
332
+
333
+ // Validate and fix inline comments
334
+ const originalCount = inlineCommentsArray.length
335
+ const inlineComments = yield* validateAndFixInlineComments(
336
+ inlineCommentsArray,
337
+ changedFiles,
338
+ )
339
+ const validCount = inlineComments.length
340
+
341
+ if (originalCount > validCount) {
342
+ yield* Console.log(
343
+ `→ Filtered ${originalCount - validCount} invalid comments, ${validCount} remain`,
344
+ )
345
+ }
346
+
347
+ // Handle inline comments output/posting
348
+ yield* handleInlineComments(inlineComments, changeId, options)
349
+
350
+ // Stage 2: Generate overall review comment
351
+ yield* Console.log(`→ Generating overall review comment for change ${changeId}...`)
352
+
353
+ const overallPrompt = yield* buildEnhancedPrompt(
354
+ userReviewPrompt,
355
+ prompts.overallReviewSystemPrompt,
356
+ changeId,
357
+ changedFiles,
358
+ )
359
+
360
+ // Run overall review using selected strategy
361
+ if (options.debug) {
362
+ yield* Console.log(`[DEBUG] Running overall review with ${selectedStrategy.name}`)
363
+ }
364
+
365
+ const overallResponse = yield* reviewStrategy
366
+ .executeWithStrategy(selectedStrategy, overallPrompt, {
367
+ cwd: worktreeInfo.path,
368
+ systemPrompt: options.systemPrompt || prompts.overallReviewSystemPrompt,
369
+ })
370
+ .pipe(
371
+ Effect.catchTag('ReviewStrategyError', (error) =>
372
+ Effect.gen(function* () {
373
+ yield* Console.error(`✗ Overall review failed: ${error.message}`)
374
+ return yield* Effect.fail(new Error(error.message))
375
+ }),
376
+ ),
377
+ )
378
+
379
+ if (options.debug) {
380
+ yield* Console.log(`[DEBUG] Overall review completed`)
381
+ yield* Console.log(`[DEBUG] Response length: ${overallResponse.length} chars`)
382
+ }
383
+
384
+ // Response content is ready for use
385
+ const extractedOverallResponse = overallResponse.trim()
386
+
387
+ // Handle overall review output/posting
388
+ yield* handleOverallReview(extractedOverallResponse, changeId, options)
389
+ } finally {
390
+ // Always restore original working directory
391
+ process.chdir(originalCwd)
392
+ }
393
+
394
+ yield* Console.log(`✓ Review complete for ${changeId}`)
395
+ }),
396
+
397
+ // Release: Always cleanup worktree
398
+ (worktreeInfo) => gitService.cleanup(worktreeInfo),
399
+ )
400
+ })
401
+
402
+ // Helper function to handle inline comments output/posting
403
+ const handleInlineComments = (
404
+ inlineComments: InlineComment[],
405
+ changeId: string,
406
+ options: ReviewOptions,
407
+ ): Effect.Effect<void, Error, GerritApiService> =>
408
+ Effect.gen(function* () {
409
+ if (!options.comment) {
410
+ // Display mode
411
+ if (inlineComments.length > 0) {
412
+ yield* Console.log('\n━━━━━━ INLINE COMMENTS ━━━━━━')
413
+ for (const comment of inlineComments) {
414
+ yield* Console.log(`\n📍 ${comment.file}${comment.line ? `:${comment.line}` : ''}`)
415
+ yield* Console.log(comment.message)
416
+ }
417
+ } else {
418
+ yield* Console.log('\n→ No inline comments')
419
+ }
420
+ } else {
421
+ // Comment posting mode
422
+ if (inlineComments.length > 0) {
423
+ yield* Console.log('\n━━━━━━ INLINE COMMENTS TO POST ━━━━━━')
424
+ for (const comment of inlineComments) {
425
+ yield* Console.log(`\n📍 ${comment.file}${comment.line ? `:${comment.line}` : ''}`)
426
+ yield* Console.log(comment.message)
427
+ }
428
+ yield* Console.log('\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━')
429
+
430
+ const shouldPost =
431
+ options.yes || (yield* promptUser('\nPost these inline comments to Gerrit?'))
432
+
433
+ if (shouldPost) {
434
+ yield* pipe(
435
+ commentCommandWithInput(changeId, JSON.stringify(inlineComments), { batch: true }),
436
+ Effect.catchAll((error) =>
437
+ Effect.gen(function* () {
438
+ yield* Console.error(`✗ Failed to post inline comments: ${error}`)
439
+ return yield* Effect.fail(error)
440
+ }),
441
+ ),
442
+ )
443
+ yield* Console.log(`✓ Inline comments posted for ${changeId}`)
444
+ } else {
445
+ yield* Console.log('→ Inline comments not posted')
446
+ }
447
+ }
448
+ }
449
+ })
450
+
451
+ // Helper function to handle overall review output/posting
452
+ const handleOverallReview = (
453
+ overallResponse: string,
454
+ changeId: string,
455
+ options: ReviewOptions,
456
+ ): Effect.Effect<void, Error, GerritApiService> =>
457
+ Effect.gen(function* () {
458
+ if (!options.comment) {
459
+ // Display mode
460
+ yield* Console.log('\n━━━━━━ OVERALL REVIEW ━━━━━━')
461
+ yield* Console.log(overallResponse)
462
+ yield* Console.log('\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━')
463
+ } else {
464
+ // Comment posting mode
465
+ yield* Console.log('\n━━━━━━ OVERALL REVIEW TO POST ━━━━━━')
466
+ yield* Console.log(overallResponse)
467
+ yield* Console.log('\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━')
468
+
469
+ const shouldPost = options.yes || (yield* promptUser('\nPost this overall review to Gerrit?'))
470
+
471
+ if (shouldPost) {
472
+ yield* pipe(
473
+ commentCommandWithInput(changeId, overallResponse, {}),
474
+ Effect.catchAll((error) =>
475
+ Effect.gen(function* () {
476
+ yield* Console.error(`✗ Failed to post review comment: ${error}`)
477
+ return yield* Effect.fail(error)
478
+ }),
479
+ ),
480
+ )
481
+ yield* Console.log(`✓ Overall review posted for ${changeId}`)
482
+ } else {
483
+ yield* Console.log('→ Overall review not posted')
484
+ }
485
+ }
486
+ })
@@ -0,0 +1,162 @@
1
+ import { Effect } from 'effect'
2
+ import { type ApiError, GerritApiService } from '@/api/gerrit'
3
+ import type { ChangeInfo } from '@/schemas/gerrit'
4
+ import { colors, formatDate } from '@/utils/formatters'
5
+ import { escapeXML, sanitizeCDATA } from '@/utils/shell-safety'
6
+ import { getStatusIndicators } from '@/utils/status-indicators'
7
+
8
+ export const SEARCH_HELP_TEXT = `
9
+ Examples:
10
+ # Search for all open changes (default)
11
+ $ ger search
12
+
13
+ # Search for your open changes
14
+ $ ger search "owner:self status:open"
15
+
16
+ # Search for changes by a specific user
17
+ $ ger search "owner:john@example.com"
18
+
19
+ # Search by project
20
+ $ ger search "project:my-project status:open"
21
+
22
+ # Search with date filters
23
+ $ ger search "owner:self after:2025-01-01"
24
+ $ ger search "status:merged age:7d"
25
+
26
+ # Combine filters
27
+ $ ger search "owner:self status:merged before:2025-06-01"
28
+
29
+ # Limit results
30
+ $ ger search "project:my-project" -n 10
31
+
32
+ Common query operators:
33
+ owner:USER Changes owned by USER (use 'self' for yourself)
34
+ status:STATE open, merged, abandoned, closed
35
+ project:NAME Changes in a specific project
36
+ branch:NAME Changes targeting a branch
37
+ age:TIME Time since last update (e.g., 1d, 2w, 1mon)
38
+ before:DATE Changes modified before date (YYYY-MM-DD)
39
+ after:DATE Changes modified after date (YYYY-MM-DD)
40
+ is:wip Work-in-progress changes
41
+ is:submittable Changes ready to submit
42
+ reviewer:USER Changes where USER is a reviewer
43
+ label:NAME=VALUE Filter by label (e.g., label:Code-Review+2)
44
+
45
+ Full query syntax: https://gerrit-review.googlesource.com/Documentation/user-search.html`
46
+
47
+ interface SearchOptions {
48
+ xml?: boolean
49
+ limit?: string
50
+ }
51
+
52
+ // Group changes by project for better organization
53
+ const groupChangesByProject = (changes: readonly ChangeInfo[]) => {
54
+ const grouped = new Map<string, ChangeInfo[]>()
55
+
56
+ for (const change of changes) {
57
+ const project = change.project
58
+ const existing = grouped.get(project) ?? []
59
+ existing.push(change)
60
+ grouped.set(project, existing)
61
+ }
62
+
63
+ // Sort projects alphabetically and changes by updated date
64
+ return Array.from(grouped.entries())
65
+ .sort(([a], [b]) => a.localeCompare(b))
66
+ .map(([project, projectChanges]) => ({
67
+ project,
68
+ changes: projectChanges.sort((a, b) => {
69
+ const dateA = a.updated ? new Date(a.updated).getTime() : 0
70
+ const dateB = b.updated ? new Date(b.updated).getTime() : 0
71
+ return dateB - dateA
72
+ }),
73
+ }))
74
+ }
75
+
76
+ export const searchCommand = (
77
+ query: string | undefined,
78
+ options: SearchOptions,
79
+ ): Effect.Effect<void, ApiError, GerritApiService> =>
80
+ Effect.gen(function* () {
81
+ const gerritApi = yield* GerritApiService
82
+
83
+ // Build the final query with limit if specified
84
+ let finalQuery = query || 'is:open'
85
+ const parsedLimit = options.limit ? parseInt(options.limit, 10) : 25
86
+ const limit = Number.isNaN(parsedLimit) || parsedLimit < 1 ? 25 : parsedLimit
87
+ if (!finalQuery.includes('limit:')) {
88
+ finalQuery = `${finalQuery} limit:${limit}`
89
+ }
90
+
91
+ const changes = yield* gerritApi.listChanges(finalQuery)
92
+
93
+ // Group changes by project (used by both output formats)
94
+ const groupedChanges = changes.length > 0 ? groupChangesByProject(changes) : []
95
+
96
+ if (options.xml) {
97
+ // XML output
98
+ const xmlOutput = [
99
+ '<?xml version="1.0" encoding="UTF-8"?>',
100
+ '<search_results>',
101
+ ` <query><![CDATA[${sanitizeCDATA(finalQuery)}]]></query>`,
102
+ ` <count>${changes.length}</count>`,
103
+ ]
104
+
105
+ if (changes.length > 0) {
106
+ xmlOutput.push(' <changes>')
107
+
108
+ for (const { project, changes: projectChanges } of groupedChanges) {
109
+ xmlOutput.push(` <project name="${escapeXML(project)}">`)
110
+ for (const change of projectChanges) {
111
+ xmlOutput.push(' <change>')
112
+ xmlOutput.push(` <number>${change._number}</number>`)
113
+ xmlOutput.push(
114
+ ` <subject><![CDATA[${sanitizeCDATA(change.subject)}]]></subject>`,
115
+ )
116
+ xmlOutput.push(` <status>${escapeXML(change.status)}</status>`)
117
+ xmlOutput.push(` <owner>${escapeXML(change.owner?.name ?? 'Unknown')}</owner>`)
118
+ xmlOutput.push(` <branch>${escapeXML(change.branch)}</branch>`)
119
+ if (change.updated && change.updated.trim() !== '') {
120
+ xmlOutput.push(` <updated>${escapeXML(change.updated)}</updated>`)
121
+ }
122
+ if (change.owner?.email) {
123
+ xmlOutput.push(` <owner_email>${escapeXML(change.owner.email)}</owner_email>`)
124
+ }
125
+ xmlOutput.push(' </change>')
126
+ }
127
+ xmlOutput.push(' </project>')
128
+ }
129
+
130
+ xmlOutput.push(' </changes>')
131
+ }
132
+
133
+ xmlOutput.push('</search_results>')
134
+ console.log(xmlOutput.join('\n'))
135
+ } else {
136
+ // Pretty output (default)
137
+ if (changes.length === 0) {
138
+ console.log(`${colors.yellow}No changes found${colors.reset}`)
139
+ return
140
+ }
141
+
142
+ console.log(`${colors.blue}Search results (${changes.length})${colors.reset}\n`)
143
+
144
+ for (const { project, changes: projectChanges } of groupedChanges) {
145
+ console.log(`${colors.gray}${project}${colors.reset}`)
146
+
147
+ for (const change of projectChanges) {
148
+ const indicators = getStatusIndicators(change)
149
+ const statusPart = indicators.length > 0 ? `${indicators.join(' ')} ` : ''
150
+ const dateStr = change.updated ? ` • ${formatDate(change.updated)}` : ''
151
+
152
+ console.log(
153
+ ` ${statusPart}${colors.yellow}#${change._number}${colors.reset} ${change.subject}`,
154
+ )
155
+ console.log(
156
+ ` ${colors.gray}by ${change.owner?.name ?? 'Unknown'} • ${change.status}${dateStr}${colors.reset}`,
157
+ )
158
+ }
159
+ console.log() // Empty line between projects
160
+ }
161
+ }
162
+ })