@aaronshaf/ger 0.1.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 (91) hide show
  1. package/.ast-grep/rules/no-as-casting.yml +13 -0
  2. package/.eslintrc.js +12 -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 +78 -0
  6. package/.github/workflows/claude.yml +64 -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 +103 -0
  16. package/DEVELOPMENT.md +361 -0
  17. package/LICENSE +21 -0
  18. package/README.md +325 -0
  19. package/bin/ger +3 -0
  20. package/biome.json +36 -0
  21. package/bun.lock +688 -0
  22. package/bunfig.toml +8 -0
  23. package/oxlint.json +24 -0
  24. package/package.json +55 -0
  25. package/scripts/check-coverage.ts +69 -0
  26. package/scripts/check-file-size.ts +38 -0
  27. package/scripts/fix-test-mocks.ts +55 -0
  28. package/src/api/gerrit.ts +466 -0
  29. package/src/cli/commands/abandon.ts +65 -0
  30. package/src/cli/commands/comment.ts +460 -0
  31. package/src/cli/commands/comments.ts +85 -0
  32. package/src/cli/commands/diff.ts +71 -0
  33. package/src/cli/commands/incoming.ts +226 -0
  34. package/src/cli/commands/init.ts +164 -0
  35. package/src/cli/commands/mine.ts +115 -0
  36. package/src/cli/commands/open.ts +57 -0
  37. package/src/cli/commands/review.ts +593 -0
  38. package/src/cli/commands/setup.ts +230 -0
  39. package/src/cli/commands/show.ts +303 -0
  40. package/src/cli/commands/status.ts +35 -0
  41. package/src/cli/commands/workspace.ts +200 -0
  42. package/src/cli/index.ts +420 -0
  43. package/src/prompts/default-review.md +80 -0
  44. package/src/prompts/system-inline-review.md +88 -0
  45. package/src/prompts/system-overall-review.md +152 -0
  46. package/src/schemas/config.test.ts +245 -0
  47. package/src/schemas/config.ts +75 -0
  48. package/src/schemas/gerrit.ts +455 -0
  49. package/src/services/ai-enhanced.ts +167 -0
  50. package/src/services/ai.ts +182 -0
  51. package/src/services/config.test.ts +414 -0
  52. package/src/services/config.ts +206 -0
  53. package/src/test-utils/mock-generator.ts +73 -0
  54. package/src/utils/comment-formatters.ts +153 -0
  55. package/src/utils/diff-context.ts +103 -0
  56. package/src/utils/diff-formatters.ts +141 -0
  57. package/src/utils/formatters.ts +85 -0
  58. package/src/utils/message-filters.ts +26 -0
  59. package/src/utils/shell-safety.ts +117 -0
  60. package/src/utils/status-indicators.ts +100 -0
  61. package/src/utils/url-parser.test.ts +123 -0
  62. package/src/utils/url-parser.ts +91 -0
  63. package/tests/abandon.test.ts +163 -0
  64. package/tests/ai-service.test.ts +489 -0
  65. package/tests/comment-batch-advanced.test.ts +431 -0
  66. package/tests/comment-gerrit-api-compliance.test.ts +414 -0
  67. package/tests/comment.test.ts +707 -0
  68. package/tests/comments.test.ts +323 -0
  69. package/tests/config-service-simple.test.ts +100 -0
  70. package/tests/diff.test.ts +419 -0
  71. package/tests/helpers/config-mock.ts +27 -0
  72. package/tests/incoming.test.ts +357 -0
  73. package/tests/interactive-incoming.test.ts +173 -0
  74. package/tests/mine.test.ts +318 -0
  75. package/tests/mocks/fetch-mock.ts +139 -0
  76. package/tests/mocks/msw-handlers.ts +80 -0
  77. package/tests/open.test.ts +233 -0
  78. package/tests/review.test.ts +669 -0
  79. package/tests/setup.ts +13 -0
  80. package/tests/show.test.ts +439 -0
  81. package/tests/unit/schemas/gerrit.test.ts +85 -0
  82. package/tests/unit/test-utils/mock-generator.test.ts +154 -0
  83. package/tests/unit/utils/comment-formatters.test.ts +415 -0
  84. package/tests/unit/utils/diff-context.test.ts +171 -0
  85. package/tests/unit/utils/diff-formatters.test.ts +165 -0
  86. package/tests/unit/utils/formatters.test.ts +411 -0
  87. package/tests/unit/utils/message-filters.test.ts +227 -0
  88. package/tests/unit/utils/prompt-helpers.test.ts +175 -0
  89. package/tests/unit/utils/shell-safety.test.ts +230 -0
  90. package/tests/unit/utils/status-indicators.test.ts +137 -0
  91. package/tsconfig.json +40 -0
@@ -0,0 +1,200 @@
1
+ import { execSync, spawnSync } from 'node:child_process'
2
+ import * as fs from 'node:fs'
3
+ import * as path from 'node:path'
4
+ import { Effect } from 'effect'
5
+ import { type ApiError, GerritApiService } from '@/api/gerrit'
6
+ import { type ConfigError, ConfigService } from '@/services/config'
7
+
8
+ interface WorkspaceOptions {
9
+ xml?: boolean
10
+ }
11
+
12
+ const parseChangeSpec = (changeSpec: string): { changeId: string; patchset?: string } => {
13
+ const parts = changeSpec.split(':')
14
+ return {
15
+ changeId: parts[0],
16
+ patchset: parts[1],
17
+ }
18
+ }
19
+
20
+ const getGitRemotes = (): Record<string, string> => {
21
+ try {
22
+ const output = execSync('git remote -v', { encoding: 'utf8' })
23
+ const remotes: Record<string, string> = {}
24
+
25
+ for (const line of output.split('\n')) {
26
+ const match = line.match(/^(\S+)\s+(\S+)\s+\(fetch\)$/)
27
+ if (match) {
28
+ remotes[match[1]] = match[2]
29
+ }
30
+ }
31
+
32
+ return remotes
33
+ } catch {
34
+ return {}
35
+ }
36
+ }
37
+
38
+ const findMatchingRemote = (gerritHost: string): string | null => {
39
+ const remotes = getGitRemotes()
40
+
41
+ // Parse gerrit host
42
+ const gerritUrl = new URL(gerritHost)
43
+ const gerritHostname = gerritUrl.hostname
44
+
45
+ // Check each remote
46
+ for (const [name, url] of Object.entries(remotes)) {
47
+ try {
48
+ // Handle both HTTP and SSH URLs
49
+ let remoteHostname: string
50
+
51
+ if (url.startsWith('git@') || url.includes('://')) {
52
+ if (url.startsWith('git@')) {
53
+ // SSH format: git@hostname:project
54
+ remoteHostname = url.split('@')[1].split(':')[0]
55
+ } else {
56
+ // HTTP format
57
+ const remoteUrl = new URL(url)
58
+ remoteHostname = remoteUrl.hostname
59
+ }
60
+
61
+ if (remoteHostname === gerritHostname) {
62
+ return name
63
+ }
64
+ }
65
+ } catch {
66
+ // Ignore malformed URLs
67
+ }
68
+ }
69
+
70
+ return null
71
+ }
72
+
73
+ const isInGitRepo = (): boolean => {
74
+ try {
75
+ execSync('git rev-parse --git-dir', { encoding: 'utf8' })
76
+ return true
77
+ } catch {
78
+ return false
79
+ }
80
+ }
81
+
82
+ const getRepoRoot = (): string => {
83
+ try {
84
+ return execSync('git rev-parse --show-toplevel', { encoding: 'utf8' }).trim()
85
+ } catch {
86
+ throw new Error('Not in a git repository')
87
+ }
88
+ }
89
+
90
+ export const workspaceCommand = (
91
+ changeSpec: string,
92
+ options: WorkspaceOptions,
93
+ ): Effect.Effect<void, ApiError | ConfigError | Error, GerritApiService | ConfigService> =>
94
+ Effect.gen(function* () {
95
+ // Check if we're in a git repo
96
+ if (!isInGitRepo()) {
97
+ throw new Error(
98
+ 'Not in a git repository. Please run this command from within a git repository.',
99
+ )
100
+ }
101
+
102
+ const repoRoot = getRepoRoot()
103
+ const { changeId, patchset } = parseChangeSpec(changeSpec)
104
+
105
+ // Get Gerrit credentials and find matching remote
106
+ const configService = yield* ConfigService
107
+ const credentials = yield* configService.getCredentials
108
+ const matchingRemote = findMatchingRemote(credentials.host)
109
+
110
+ if (!matchingRemote) {
111
+ throw new Error(`No git remote found matching Gerrit host: ${credentials.host}`)
112
+ }
113
+
114
+ // Get change details from Gerrit
115
+ const gerritApi = yield* GerritApiService
116
+ const change = yield* gerritApi.getChange(changeId)
117
+
118
+ // Determine patchset to use
119
+ const targetPatchset = patchset || 'current'
120
+ const revision = yield* gerritApi.getRevision(changeId, targetPatchset)
121
+
122
+ // Create workspace directory name - validate to prevent path traversal
123
+ const workspaceName = change._number.toString()
124
+ // Validate workspace name contains only digits
125
+ if (!/^\d+$/.test(workspaceName)) {
126
+ throw new Error(`Invalid change number: ${workspaceName}`)
127
+ }
128
+ const workspaceDir = path.join(repoRoot, '.ger', workspaceName)
129
+
130
+ // Check if worktree already exists
131
+ if (fs.existsSync(workspaceDir)) {
132
+ if (options.xml) {
133
+ console.log(`<?xml version="1.0" encoding="UTF-8"?>`)
134
+ console.log(`<workspace>`)
135
+ console.log(` <path>${workspaceDir}</path>`)
136
+ console.log(` <exists>true</exists>`)
137
+ console.log(`</workspace>`)
138
+ } else {
139
+ console.log(`✓ Workspace already exists at: ${workspaceDir}`)
140
+ console.log(` Run: cd ${workspaceDir}`)
141
+ }
142
+ return
143
+ }
144
+
145
+ // Ensure .ger directory exists
146
+ const gerDir = path.join(repoRoot, '.ger')
147
+ if (!fs.existsSync(gerDir)) {
148
+ fs.mkdirSync(gerDir, { recursive: true })
149
+ }
150
+
151
+ // Fetch the change ref
152
+ const changeRef = revision.ref
153
+ if (!options.xml) {
154
+ console.log(`Fetching change ${change._number}: ${change.subject}`)
155
+ }
156
+
157
+ try {
158
+ // Use spawnSync with array to prevent command injection
159
+ const fetchResult = spawnSync('git', ['fetch', matchingRemote, changeRef], {
160
+ encoding: 'utf8',
161
+ cwd: repoRoot,
162
+ })
163
+ if (fetchResult.status !== 0) {
164
+ throw new Error(fetchResult.stderr || 'Git fetch failed')
165
+ }
166
+ } catch (error) {
167
+ throw new Error(`Failed to fetch change: ${error}`)
168
+ }
169
+
170
+ // Create worktree
171
+ if (!options.xml) {
172
+ console.log(`Creating worktree at: ${workspaceDir}`)
173
+ }
174
+
175
+ try {
176
+ // Use spawnSync with array to prevent command injection
177
+ const worktreeResult = spawnSync('git', ['worktree', 'add', workspaceDir, 'FETCH_HEAD'], {
178
+ encoding: 'utf8',
179
+ cwd: repoRoot,
180
+ })
181
+ if (worktreeResult.status !== 0) {
182
+ throw new Error(worktreeResult.stderr || 'Git worktree add failed')
183
+ }
184
+ } catch (error) {
185
+ throw new Error(`Failed to create worktree: ${error}`)
186
+ }
187
+
188
+ if (options.xml) {
189
+ console.log(`<?xml version="1.0" encoding="UTF-8"?>`)
190
+ console.log(`<workspace>`)
191
+ console.log(` <path>${workspaceDir}</path>`)
192
+ console.log(` <change_number>${change._number}</change_number>`)
193
+ console.log(` <subject><![CDATA[${change.subject}]]></subject>`)
194
+ console.log(` <created>true</created>`)
195
+ console.log(`</workspace>`)
196
+ } else {
197
+ console.log(`✓ Workspace created successfully!`)
198
+ console.log(` Run: cd ${workspaceDir}`)
199
+ }
200
+ })
@@ -0,0 +1,420 @@
1
+ #!/usr/bin/env bun
2
+
3
+ // Check Bun version requirement
4
+ const MIN_BUN_VERSION = '1.2.0'
5
+ const bunVersion = Bun.version
6
+
7
+ function compareSemver(a: string, b: string): number {
8
+ const parseVersion = (v: string) => v.split('.').map((n) => parseInt(n, 10))
9
+ const [aMajor, aMinor = 0, aPatch = 0] = parseVersion(a)
10
+ const [bMajor, bMinor = 0, bPatch = 0] = parseVersion(b)
11
+
12
+ if (aMajor !== bMajor) return aMajor - bMajor
13
+ if (aMinor !== bMinor) return aMinor - bMinor
14
+ return aPatch - bPatch
15
+ }
16
+
17
+ if (compareSemver(bunVersion, MIN_BUN_VERSION) < 0) {
18
+ console.error(`✗ Error: Bun version ${MIN_BUN_VERSION} or higher is required`)
19
+ console.error(` Current version: ${bunVersion}`)
20
+ console.error(` Please upgrade Bun: bun upgrade`)
21
+ process.exit(1)
22
+ }
23
+
24
+ import { Command } from 'commander'
25
+ import { Effect } from 'effect'
26
+ import { GerritApiServiceLive } from '@/api/gerrit'
27
+ import { ConfigServiceLive } from '@/services/config'
28
+ import { AiServiceLive } from '@/services/ai-enhanced'
29
+ import { abandonCommand } from './commands/abandon'
30
+ import { commentCommand } from './commands/comment'
31
+ import { commentsCommand } from './commands/comments'
32
+ import { diffCommand } from './commands/diff'
33
+ import { incomingCommand } from './commands/incoming'
34
+ import { mineCommand } from './commands/mine'
35
+ import { openCommand } from './commands/open'
36
+ import { reviewCommand } from './commands/review'
37
+ import { setup } from './commands/setup'
38
+ import { showCommand } from './commands/show'
39
+ import { statusCommand } from './commands/status'
40
+ import { workspaceCommand } from './commands/workspace'
41
+
42
+ const program = new Command()
43
+
44
+ program.name('gi').description('LLM-centric Gerrit CLI tool').version('0.1.0')
45
+
46
+ // setup command (new primary command)
47
+ program
48
+ .command('setup')
49
+ .description('Configure Gerrit credentials and AI tools')
50
+ .action(async () => {
51
+ await setup()
52
+ })
53
+
54
+ // init command (kept for backward compatibility, redirects to setup)
55
+ program
56
+ .command('init')
57
+ .description('Initialize Gerrit credentials (alias for setup)')
58
+ .action(async () => {
59
+ await setup()
60
+ })
61
+
62
+ // status command
63
+ program
64
+ .command('status')
65
+ .description('Check connection status')
66
+ .option('--xml', 'XML output for LLM consumption')
67
+ .action(async (options) => {
68
+ try {
69
+ const effect = statusCommand(options).pipe(
70
+ Effect.provide(GerritApiServiceLive),
71
+ Effect.provide(ConfigServiceLive),
72
+ )
73
+ await Effect.runPromise(effect)
74
+ } catch (error) {
75
+ console.error('Error:', error instanceof Error ? error.message : String(error))
76
+ process.exit(1)
77
+ }
78
+ })
79
+
80
+ // comment command
81
+ program
82
+ .command('comment <change-id>')
83
+ .description('Post a comment on a change')
84
+ .option('-m, --message <message>', 'Comment message')
85
+ .option('--file <file>', 'File path for line-specific comment (relative to repo root)')
86
+ .option(
87
+ '--line <line>',
88
+ 'Line number in the NEW version of the file (not diff line numbers)',
89
+ parseInt,
90
+ )
91
+ .option('--unresolved', 'Mark comment as unresolved (requires human attention)')
92
+ .option('--batch', 'Read batch comments from stdin as JSON (see examples below)')
93
+ .option('--xml', 'XML output for LLM consumption')
94
+ .addHelpText(
95
+ 'after',
96
+ `
97
+ Examples:
98
+ # Post a general comment on a change
99
+ $ ger comment 12345 -m "Looks good to me!"
100
+
101
+ # Post a comment using piped input (useful for multi-line comments or scripts)
102
+ $ echo "This is a comment from stdin!" | ger comment 12345
103
+ $ cat review-notes.txt | ger comment 12345
104
+
105
+ # Post a line-specific comment (line number from NEW file version)
106
+ $ ger comment 12345 --file src/main.js --line 42 -m "Consider using const here"
107
+
108
+ # Post an unresolved comment requiring human attention
109
+ $ ger comment 12345 --file src/api.js --line 15 -m "Security concern" --unresolved
110
+
111
+ # Post multiple comments using batch mode
112
+ $ echo '{"message": "Review complete", "comments": [
113
+ {"file": "src/main.js", "line": 10, "message": "Good refactor"},
114
+ {"file": "src/api.js", "line": 25, "message": "Check error handling", "unresolved": true}
115
+ ]}' | ger comment 12345 --batch
116
+
117
+ Note: Line numbers refer to the actual line numbers in the NEW version of the file,
118
+ NOT the line numbers shown in the diff view. To find the correct line number,
119
+ look at the file after all changes have been applied.`,
120
+ )
121
+ .action(async (changeId, options) => {
122
+ try {
123
+ const effect = commentCommand(changeId, options).pipe(
124
+ Effect.provide(GerritApiServiceLive),
125
+ Effect.provide(ConfigServiceLive),
126
+ )
127
+ await Effect.runPromise(effect)
128
+ } catch (error) {
129
+ if (options.xml) {
130
+ console.log(`<?xml version="1.0" encoding="UTF-8"?>`)
131
+ console.log(`<comment_result>`)
132
+ console.log(` <status>error</status>`)
133
+ console.log(
134
+ ` <error><![CDATA[${error instanceof Error ? error.message : String(error)}]]></error>`,
135
+ )
136
+ console.log(`</comment_result>`)
137
+ } else {
138
+ console.error('✗ Error:', error instanceof Error ? error.message : String(error))
139
+ }
140
+ process.exit(1)
141
+ }
142
+ })
143
+
144
+ // diff command
145
+ program
146
+ .command('diff <change-id>')
147
+ .description('Get diff for a change')
148
+ .option('--xml', 'XML output for LLM consumption')
149
+ .option('--file <file>', 'Specific file to diff')
150
+ .option('--files-only', 'List changed files only')
151
+ .option('--format <format>', 'Output format (unified, json, files)')
152
+ .action(async (changeId, options) => {
153
+ try {
154
+ const effect = diffCommand(changeId, options).pipe(
155
+ Effect.provide(GerritApiServiceLive),
156
+ Effect.provide(ConfigServiceLive),
157
+ )
158
+ await Effect.runPromise(effect)
159
+ } catch (error) {
160
+ if (options.xml) {
161
+ console.log(`<?xml version="1.0" encoding="UTF-8"?>`)
162
+ console.log(`<diff_result>`)
163
+ console.log(` <status>error</status>`)
164
+ console.log(
165
+ ` <error><![CDATA[${error instanceof Error ? error.message : String(error)}]]></error>`,
166
+ )
167
+ console.log(`</diff_result>`)
168
+ } else {
169
+ console.error('✗ Error:', error instanceof Error ? error.message : String(error))
170
+ }
171
+ process.exit(1)
172
+ }
173
+ })
174
+
175
+ // mine command
176
+ program
177
+ .command('mine')
178
+ .description('Show your open changes')
179
+ .option('--xml', 'XML output for LLM consumption')
180
+ .action(async (options) => {
181
+ try {
182
+ const effect = mineCommand(options).pipe(
183
+ Effect.provide(GerritApiServiceLive),
184
+ Effect.provide(ConfigServiceLive),
185
+ )
186
+ await Effect.runPromise(effect)
187
+ } catch (error) {
188
+ if (options.xml) {
189
+ console.log(`<?xml version="1.0" encoding="UTF-8"?>`)
190
+ console.log(`<mine_result>`)
191
+ console.log(` <status>error</status>`)
192
+ console.log(
193
+ ` <error><![CDATA[${error instanceof Error ? error.message : String(error)}]]></error>`,
194
+ )
195
+ console.log(`</mine_result>`)
196
+ } else {
197
+ console.error('✗ Error:', error instanceof Error ? error.message : String(error))
198
+ }
199
+ process.exit(1)
200
+ }
201
+ })
202
+
203
+ // workspace command
204
+ program
205
+ .command('workspace <change-id>')
206
+ .description('Create or switch to a git worktree for a Gerrit change')
207
+ .option('--xml', 'XML output for LLM consumption')
208
+ .action(async (changeId, options) => {
209
+ try {
210
+ const effect = workspaceCommand(changeId, options).pipe(
211
+ Effect.provide(GerritApiServiceLive),
212
+ Effect.provide(ConfigServiceLive),
213
+ )
214
+ await Effect.runPromise(effect)
215
+ } catch (error) {
216
+ if (options.xml) {
217
+ console.log(`<?xml version="1.0" encoding="UTF-8"?>`)
218
+ console.log(`<workspace_result>`)
219
+ console.log(` <status>error</status>`)
220
+ console.log(
221
+ ` <error><![CDATA[${error instanceof Error ? error.message : String(error)}]]></error>`,
222
+ )
223
+ console.log(`</workspace_result>`)
224
+ } else {
225
+ console.error('✗ Error:', error instanceof Error ? error.message : String(error))
226
+ }
227
+ process.exit(1)
228
+ }
229
+ })
230
+
231
+ // incoming command
232
+ program
233
+ .command('incoming')
234
+ .description('Show incoming changes for review (where you are a reviewer)')
235
+ .option('--xml', 'XML output for LLM consumption')
236
+ .option('-i, --interactive', 'Interactive mode with detailed view and diff')
237
+ .action(async (options) => {
238
+ try {
239
+ const effect = incomingCommand(options).pipe(
240
+ Effect.provide(GerritApiServiceLive),
241
+ Effect.provide(ConfigServiceLive),
242
+ )
243
+ await Effect.runPromise(effect)
244
+ } catch (error) {
245
+ if (options.xml) {
246
+ console.log(`<?xml version="1.0" encoding="UTF-8"?>`)
247
+ console.log(`<incoming_result>`)
248
+ console.log(` <status>error</status>`)
249
+ console.log(
250
+ ` <error><![CDATA[${error instanceof Error ? error.message : String(error)}]]></error>`,
251
+ )
252
+ console.log(`</incoming_result>`)
253
+ } else {
254
+ console.error('✗ Error:', error instanceof Error ? error.message : String(error))
255
+ }
256
+ process.exit(1)
257
+ }
258
+ })
259
+
260
+ // abandon command
261
+ program
262
+ .command('abandon [change-id]')
263
+ .description('Abandon a change (interactive mode if no change-id provided)')
264
+ .option('-m, --message <message>', 'Abandon message')
265
+ .option('--xml', 'XML output for LLM consumption')
266
+ .action(async (changeId, options) => {
267
+ try {
268
+ const effect = abandonCommand(changeId, options).pipe(
269
+ Effect.provide(GerritApiServiceLive),
270
+ Effect.provide(ConfigServiceLive),
271
+ )
272
+ await Effect.runPromise(effect)
273
+ } catch (error) {
274
+ if (options.xml) {
275
+ console.log(`<?xml version="1.0" encoding="UTF-8"?>`)
276
+ console.log(`<abandon_result>`)
277
+ console.log(` <status>error</status>`)
278
+ console.log(
279
+ ` <error><![CDATA[${error instanceof Error ? error.message : String(error)}]]></error>`,
280
+ )
281
+ console.log(`</abandon_result>`)
282
+ } else {
283
+ console.error('✗ Error:', error instanceof Error ? error.message : String(error))
284
+ }
285
+ process.exit(1)
286
+ }
287
+ })
288
+
289
+ // comments command
290
+ program
291
+ .command('comments <change-id>')
292
+ .description('Show all comments on a change with diff context')
293
+ .option('--xml', 'XML output for LLM consumption')
294
+ .action(async (changeId, options) => {
295
+ try {
296
+ const effect = commentsCommand(changeId, options).pipe(
297
+ Effect.provide(GerritApiServiceLive),
298
+ Effect.provide(ConfigServiceLive),
299
+ )
300
+ await Effect.runPromise(effect)
301
+ } catch (error) {
302
+ if (options.xml) {
303
+ console.log(`<?xml version="1.0" encoding="UTF-8"?>`)
304
+ console.log(`<comments_result>`)
305
+ console.log(` <status>error</status>`)
306
+ console.log(
307
+ ` <error><![CDATA[${error instanceof Error ? error.message : String(error)}]]></error>`,
308
+ )
309
+ console.log(`</comments_result>`)
310
+ } else {
311
+ console.error('✗ Error:', error instanceof Error ? error.message : String(error))
312
+ }
313
+ process.exit(1)
314
+ }
315
+ })
316
+
317
+ // open command
318
+ program
319
+ .command('open <change-id>')
320
+ .description('Open a change in the browser')
321
+ .action(async (changeId, options) => {
322
+ try {
323
+ const effect = openCommand(changeId, options).pipe(
324
+ Effect.provide(GerritApiServiceLive),
325
+ Effect.provide(ConfigServiceLive),
326
+ )
327
+ await Effect.runPromise(effect)
328
+ } catch (error) {
329
+ console.error('✗ Error:', error instanceof Error ? error.message : String(error))
330
+ process.exit(1)
331
+ }
332
+ })
333
+
334
+ // show command
335
+ program
336
+ .command('show <change-id>')
337
+ .description('Show comprehensive change information including metadata, diff, and all comments')
338
+ .option('--xml', 'XML output for LLM consumption')
339
+ .action(async (changeId, options) => {
340
+ try {
341
+ const effect = showCommand(changeId, options).pipe(
342
+ Effect.provide(GerritApiServiceLive),
343
+ Effect.provide(ConfigServiceLive),
344
+ )
345
+ await Effect.runPromise(effect)
346
+ } catch (error) {
347
+ if (options.xml) {
348
+ console.log(`<?xml version="1.0" encoding="UTF-8"?>`)
349
+ console.log(`<show_result>`)
350
+ console.log(` <status>error</status>`)
351
+ console.log(
352
+ ` <error><![CDATA[${error instanceof Error ? error.message : String(error)}]]></error>`,
353
+ )
354
+ console.log(`</show_result>`)
355
+ } else {
356
+ console.error('✗ Error:', error instanceof Error ? error.message : String(error))
357
+ }
358
+ process.exit(1)
359
+ }
360
+ })
361
+
362
+ // review command
363
+ program
364
+ .command('review <change-id>')
365
+ .description('AI-powered code review that analyzes changes and optionally posts comments')
366
+ .option('--comment', 'Post the review as comments (prompts for confirmation)')
367
+ .option('-y, --yes', 'Skip confirmation prompts when posting comments')
368
+ .option('--debug', 'Show debug output including AI responses')
369
+ .option('--prompt <file>', 'Path to custom review prompt file (e.g., ~/prompts/review.md)')
370
+ .addHelpText(
371
+ 'after',
372
+ `
373
+ This command uses AI (claude, llm, or opencode CLI) to review a Gerrit change.
374
+ It performs a two-stage review process:
375
+
376
+ 1. Generates inline comments for specific code issues
377
+ 2. Generates an overall review comment
378
+
379
+ By default, the review is only displayed in the terminal.
380
+ Use --comment to post the review to Gerrit (with confirmation prompts).
381
+ Use --comment --yes to post without confirmation.
382
+
383
+ Requirements:
384
+ - One of these AI tools must be installed: claude, llm, or opencode
385
+ - Gerrit credentials must be configured (run 'ger setup' first)
386
+
387
+ Examples:
388
+ # Review a change (display only)
389
+ $ ger review 12345
390
+
391
+ # Review and prompt to post comments
392
+ $ ger review 12345 --comment
393
+
394
+ # Review and auto-post comments without prompting
395
+ $ ger review 12345 --comment --yes
396
+
397
+ # Show debug output to troubleshoot issues
398
+ $ ger review 12345 --debug
399
+ `,
400
+ )
401
+ .action(async (changeId, options) => {
402
+ try {
403
+ const effect = reviewCommand(changeId, {
404
+ comment: options.comment,
405
+ yes: options.yes,
406
+ debug: options.debug,
407
+ prompt: options.prompt,
408
+ }).pipe(
409
+ Effect.provide(AiServiceLive),
410
+ Effect.provide(GerritApiServiceLive),
411
+ Effect.provide(ConfigServiceLive),
412
+ )
413
+ await Effect.runPromise(effect)
414
+ } catch (error) {
415
+ console.error('✗ Error:', error instanceof Error ? error.message : String(error))
416
+ process.exit(1)
417
+ }
418
+ })
419
+
420
+ program.parse(process.argv)
@@ -0,0 +1,80 @@
1
+ # Code Review Guidelines
2
+
3
+ You are reviewing a Gerrit change set. Provide thorough, constructive feedback focused on technical excellence and maintainability.
4
+
5
+ ## Review Philosophy
6
+
7
+ 1. **Understand First, Critique Second**
8
+ - Fully comprehend the author's intent before identifying issues
9
+ - Read COMPLETE files, not just diffs
10
+ - Check if apparent issues are handled elsewhere in the change
11
+ - Consider the broader architectural context
12
+ - Verify you're reviewing the LATEST patchset version
13
+
14
+ 2. **Be Direct and Constructive**
15
+ - Focus on substantive technical concerns
16
+ - Explain WHY something is problematic, not just what
17
+ - Provide actionable suggestions when identifying issues
18
+ - Assume the author has domain expertise
19
+ - Ask clarifying questions when intent is unclear
20
+
21
+ ## Review Categories (Priority Order)
22
+
23
+ ### 1. CRITICAL ISSUES (Must Fix)
24
+ - **Correctness**: Logic errors, race conditions, data corruption risks
25
+ - **Security**: Authentication bypasses, injection vulnerabilities, data exposure
26
+ - **Data Loss**: Operations that could destroy or corrupt user data
27
+ - **Breaking Changes**: Incompatible API/schema changes without migration
28
+ - **Production Impact**: Issues that would cause outages or severe degradation
29
+
30
+ ### 2. SIGNIFICANT CONCERNS (Should Fix)
31
+ - **Performance**: Memory leaks, N+1 queries, inefficient algorithms
32
+ - **Error Handling**: Missing error cases, silent failures, poor recovery
33
+ - **Resource Management**: Unclosed connections, file handles, cleanup issues
34
+ - **Type Safety**: Unsafe casts, missing validation, schema mismatches
35
+ - **Concurrency**: Deadlock risks, thread safety issues, synchronization problems
36
+
37
+ ### 3. CODE QUALITY (Consider Fixing)
38
+ - **Architecture**: Design pattern violations, coupling issues, abstraction leaks
39
+ - **Maintainability**: Complex logic without justification, unclear naming
40
+ - **Testing**: Missing test coverage for critical paths, brittle test design
41
+ - **Documentation**: Misleading comments, missing API documentation
42
+ - **Best Practices**: Framework misuse, anti-patterns, deprecated APIs
43
+
44
+ ### 4. MINOR IMPROVEMENTS (Optional)
45
+ - **Consistency**: Deviations from established patterns without reason
46
+ - **Efficiency**: Minor optimization opportunities
47
+ - **Clarity**: Code that works but could be more readable
48
+ - **Future-Proofing**: Anticipating likely future requirements
49
+
50
+ ## What NOT to Review
51
+
52
+ - **Already Fixed**: Issues resolved in the current patchset
53
+ - **Style Preferences**: Formatting that doesn't impact readability
54
+ - **Micro-Optimizations**: Unless performance is a stated goal
55
+ - **Personal Preferences**: Unless they violate team standards
56
+ - **Out of Scope**: Issues in unchanged code (unless directly relevant)
57
+
58
+ ## Context Requirements
59
+
60
+ Before commenting, verify:
61
+ 1. The issue still exists in the current patchset
62
+ 2. The fix wouldn't break other functionality
63
+ 3. Your understanding of the code's purpose is correct
64
+ 4. The issue isn't intentional or documented
65
+ 5. The concern is worth the author's time to address
66
+
67
+ ## Inline Comment Guidelines
68
+
69
+ - Start each comment with "🤖 " (robot emoji with space)
70
+ - Be specific about file paths and line numbers
71
+ - Group related issues when they share a root cause
72
+ - Provide concrete examples or corrections when helpful
73
+ - Use questions for clarification, statements for clear issues
74
+
75
+ ## Remember
76
+
77
+ - The goal is to improve code quality while respecting the author's time
78
+ - Focus on issues that matter for correctness, security, and maintainability
79
+ - Your review should help ship better code, not perfect code
80
+ - When in doubt, phrase feedback as a question rather than a mandate