@aaronshaf/ger 2.0.3 → 2.0.5

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.
@@ -33,10 +33,16 @@ import { sanitizeCDATA } from '@/utils/shell-safety'
33
33
  import { registerGroupCommands } from './register-group-commands'
34
34
  import { registerReviewerCommands } from './register-reviewer-commands'
35
35
 
36
- // Helper function to output error in plain text or XML format
37
- function outputError(error: unknown, options: { xml?: boolean }, resultTag: string): void {
36
+ // Helper function to output error in plain text, JSON, or XML format
37
+ function outputError(
38
+ error: unknown,
39
+ options: { xml?: boolean; json?: boolean },
40
+ resultTag: string,
41
+ ): void {
38
42
  const errorMessage = error instanceof Error ? error.message : String(error)
39
- if (options.xml) {
43
+ if (options.json) {
44
+ console.log(JSON.stringify({ status: 'error', error: errorMessage }, null, 2))
45
+ } else if (options.xml) {
40
46
  console.log(`<?xml version="1.0" encoding="UTF-8"?>`)
41
47
  console.log(`<${resultTag}>`)
42
48
  console.log(` <status>error</status>`)
@@ -50,9 +56,13 @@ function outputError(error: unknown, options: { xml?: boolean }, resultTag: stri
50
56
  // Helper function to execute Effect with standard error handling
51
57
  async function executeEffect<E>(
52
58
  effect: Effect.Effect<void, E, never>,
53
- options: { xml?: boolean },
59
+ options: { xml?: boolean; json?: boolean },
54
60
  resultTag: string,
55
61
  ): Promise<void> {
62
+ if (options.xml && options.json) {
63
+ outputError(new Error('--xml and --json are mutually exclusive'), options, resultTag)
64
+ process.exit(1)
65
+ }
56
66
  try {
57
67
  await Effect.runPromise(effect)
58
68
  } catch (error) {
@@ -83,6 +93,7 @@ export function registerCommands(program: Command): void {
83
93
  .command('status')
84
94
  .description('Check connection status')
85
95
  .option('--xml', 'XML output for LLM consumption')
96
+ .option('--json', 'JSON output for programmatic consumption')
86
97
  .action(async (options) => {
87
98
  await executeEffect(
88
99
  statusCommand(options).pipe(
@@ -105,9 +116,14 @@ export function registerCommands(program: Command): void {
105
116
  'Line number in the NEW version of the file (not diff line numbers)',
106
117
  parseInt,
107
118
  )
119
+ .option(
120
+ '--reply-to <comment-id>',
121
+ 'Reply to a comment thread (requires --file and --line; resolves thread by default)',
122
+ )
108
123
  .option('--unresolved', 'Mark comment as unresolved (requires human attention)')
109
124
  .option('--batch', 'Read batch comments from stdin as JSON (see examples below)')
110
125
  .option('--xml', 'XML output for LLM consumption')
126
+ .option('--json', 'JSON output for programmatic consumption')
111
127
  .addHelpText('after', COMMENT_HELP_TEXT)
112
128
  .action(async (changeId, options) => {
113
129
  await executeEffect(
@@ -125,6 +141,7 @@ export function registerCommands(program: Command): void {
125
141
  .command('diff <change-id>')
126
142
  .description('Get diff for a change (accepts change number or Change-ID)')
127
143
  .option('--xml', 'XML output for LLM consumption')
144
+ .option('--json', 'JSON output for programmatic consumption')
128
145
  .option('--file <file>', 'Specific file to diff')
129
146
  .option('--files-only', 'List changed files only')
130
147
  .option('--format <format>', 'Output format (unified, json, files)')
@@ -144,6 +161,7 @@ export function registerCommands(program: Command): void {
144
161
  .command('mine')
145
162
  .description('Show your open changes')
146
163
  .option('--xml', 'XML output for LLM consumption')
164
+ .option('--json', 'JSON output for programmatic consumption')
147
165
  .action(async (options) => {
148
166
  await executeEffect(
149
167
  mineCommand(options).pipe(
@@ -160,6 +178,7 @@ export function registerCommands(program: Command): void {
160
178
  .command('search [query]')
161
179
  .description('Search changes using Gerrit query syntax')
162
180
  .option('--xml', 'XML output for LLM consumption')
181
+ .option('--json', 'JSON output for programmatic consumption')
163
182
  .option('-n, --limit <number>', 'Limit results (default: 25)')
164
183
  .addHelpText('after', SEARCH_HELP_TEXT)
165
184
  .action(async (query, options) => {
@@ -168,16 +187,17 @@ export function registerCommands(program: Command): void {
168
187
  Effect.provide(ConfigServiceLive),
169
188
  )
170
189
  await Effect.runPromise(effect).catch((error: unknown) => {
171
- if (options.xml) {
190
+ const errorMessage = error instanceof Error ? error.message : String(error)
191
+ if (options.json) {
192
+ console.log(JSON.stringify({ status: 'error', error: errorMessage }, null, 2))
193
+ } else if (options.xml) {
172
194
  console.log(`<?xml version="1.0" encoding="UTF-8"?>`)
173
195
  console.log(`<search_result>`)
174
196
  console.log(` <status>error</status>`)
175
- console.log(
176
- ` <error><![CDATA[${error instanceof Error ? error.message : String(error)}]]></error>`,
177
- )
197
+ console.log(` <error><![CDATA[${errorMessage}]]></error>`)
178
198
  console.log(`</search_result>`)
179
199
  } else {
180
- console.error('✗ Error:', error instanceof Error ? error.message : String(error))
200
+ console.error('✗ Error:', errorMessage)
181
201
  }
182
202
  process.exit(1)
183
203
  })
@@ -190,6 +210,7 @@ export function registerCommands(program: Command): void {
190
210
  'Create or switch to a git worktree for a Gerrit change (accepts change number or Change-ID)',
191
211
  )
192
212
  .option('--xml', 'XML output for LLM consumption')
213
+ .option('--json', 'JSON output for programmatic consumption')
193
214
  .action(async (changeId, options) => {
194
215
  await executeEffect(
195
216
  workspaceCommand(changeId, options).pipe(
@@ -206,6 +227,7 @@ export function registerCommands(program: Command): void {
206
227
  .command('incoming')
207
228
  .description('Show incoming changes for review (where you are a reviewer)')
208
229
  .option('--xml', 'XML output for LLM consumption')
230
+ .option('--json', 'JSON output for programmatic consumption')
209
231
  .option('-i, --interactive', 'Interactive mode with detailed view and diff')
210
232
  .action(async (options) => {
211
233
  await executeEffect(
@@ -226,6 +248,7 @@ export function registerCommands(program: Command): void {
226
248
  )
227
249
  .option('-m, --message <message>', 'Abandon message')
228
250
  .option('--xml', 'XML output for LLM consumption')
251
+ .option('--json', 'JSON output for programmatic consumption')
229
252
  .action(async (changeId, options) => {
230
253
  await executeEffect(
231
254
  abandonCommand(changeId, options).pipe(
@@ -243,6 +266,7 @@ export function registerCommands(program: Command): void {
243
266
  .description('Restore an abandoned change (accepts change number or Change-ID)')
244
267
  .option('-m, --message <message>', 'Restoration message')
245
268
  .option('--xml', 'XML output for LLM consumption')
269
+ .option('--json', 'JSON output for programmatic consumption')
246
270
  .action(async (changeId, options) => {
247
271
  await executeEffect(
248
272
  restoreCommand(changeId, options).pipe(
@@ -260,6 +284,7 @@ export function registerCommands(program: Command): void {
260
284
  .description('Rebase a change onto target branch (auto-detects from HEAD if not provided)')
261
285
  .option('--base <ref>', 'Base revision to rebase onto (default: target branch HEAD)')
262
286
  .option('--xml', 'XML output for LLM consumption')
287
+ .option('--json', 'JSON output for programmatic consumption')
263
288
  .action(async (changeId, options) => {
264
289
  await executeEffect(
265
290
  rebaseCommand(changeId, options).pipe(
@@ -276,6 +301,7 @@ export function registerCommands(program: Command): void {
276
301
  .command('submit <change-id>')
277
302
  .description('Submit a change for merging (accepts change number or Change-ID)')
278
303
  .option('--xml', 'XML output for LLM consumption')
304
+ .option('--json', 'JSON output for programmatic consumption')
279
305
  .action(async (changeId, options) => {
280
306
  await executeEffect(
281
307
  submitCommand(changeId, options).pipe(
@@ -293,6 +319,7 @@ export function registerCommands(program: Command): void {
293
319
  .description('Get, set, or remove topic for a change (auto-detects from HEAD if not specified)')
294
320
  .option('--delete', 'Remove the topic from the change')
295
321
  .option('--xml', 'XML output for LLM consumption')
322
+ .option('--json', 'JSON output for programmatic consumption')
296
323
  .addHelpText('after', TOPIC_HELP_TEXT)
297
324
  .action(async (changeId, topic, options) => {
298
325
  await executeEffect(
@@ -314,6 +341,7 @@ export function registerCommands(program: Command): void {
314
341
  .option('--label <name> <value>', 'Custom label vote (can be used multiple times)')
315
342
  .option('-m, --message <message>', 'Comment with vote')
316
343
  .option('--xml', 'XML output for LLM consumption')
344
+ .option('--json', 'JSON output for programmatic consumption')
317
345
  .action(async (changeId, options) => {
318
346
  await executeEffect(
319
347
  voteCommand(changeId, options).pipe(
@@ -334,6 +362,7 @@ export function registerCommands(program: Command): void {
334
362
  .description('List Gerrit projects')
335
363
  .option('--pattern <regex>', 'Filter projects by name pattern')
336
364
  .option('--xml', 'XML output for LLM consumption')
365
+ .option('--json', 'JSON output for programmatic consumption')
337
366
  .action(async (options) => {
338
367
  await executeEffect(
339
368
  projectsCommand(options).pipe(
@@ -355,6 +384,7 @@ export function registerCommands(program: Command): void {
355
384
  'Show all comments on a change with diff context (accepts change number or Change-ID)',
356
385
  )
357
386
  .option('--xml', 'XML output for LLM consumption')
387
+ .option('--json', 'JSON output for programmatic consumption')
358
388
  .action(async (changeId, options) => {
359
389
  await executeEffect(
360
390
  commentsCommand(changeId, options).pipe(
@@ -523,6 +553,7 @@ Note:
523
553
  .description('Install the Gerrit commit-msg hook for automatic Change-Id generation')
524
554
  .option('--force', 'Overwrite existing hook')
525
555
  .option('--xml', 'XML output for LLM consumption')
556
+ .option('--json', 'JSON output for programmatic consumption')
526
557
  .addHelpText(
527
558
  'after',
528
559
  `
@@ -623,10 +654,8 @@ Note:
623
654
  `
624
655
  This command uses AI (claude CLI, gemini CLI, or opencode CLI) to review a Gerrit change.
625
656
  It performs a two-stage review process:
626
-
627
657
  1. Generates inline comments for specific code issues
628
658
  2. Generates an overall review comment
629
-
630
659
  By default, the review is only displayed in the terminal.
631
660
  Use --comment to post the review to Gerrit (with confirmation prompts).
632
661
  Use --comment --yes to post without confirmation.
@@ -636,22 +665,11 @@ Requirements:
636
665
  - Gerrit credentials must be configured (run 'ger setup' first)
637
666
 
638
667
  Examples:
639
- # Review a change using change number (display only)
640
668
  $ ger review 12345
641
-
642
- # Review using Change-ID
643
669
  $ ger review If5a3ae8cb5a107e187447802358417f311d0c4b1
644
-
645
- # Review and prompt to post comments
646
670
  $ ger review 12345 --comment
647
-
648
- # Review and auto-post comments without prompting
649
671
  $ ger review 12345 --comment --yes
650
-
651
- # Use specific AI tool
652
672
  $ ger review 12345 --tool gemini
653
-
654
- # Show debug output to troubleshoot issues
655
673
  $ ger review 12345 --debug
656
674
 
657
675
  Note: Both change number (e.g., 12345) and Change-ID (e.g., If5a3ae8...) formats are accepted
@@ -9,14 +9,26 @@ import { groupsMembersCommand } from './commands/groups-members'
9
9
  // Helper function to execute Effect with standard error handling
10
10
  async function executeEffect<E>(
11
11
  effect: Effect.Effect<void, E, never>,
12
- options: { xml?: boolean },
12
+ options: { xml?: boolean; json?: boolean },
13
13
  resultTag: string,
14
14
  ): Promise<void> {
15
+ if (options.xml && options.json) {
16
+ console.log(
17
+ JSON.stringify(
18
+ { status: 'error', error: '--xml and --json are mutually exclusive' },
19
+ null,
20
+ 2,
21
+ ),
22
+ )
23
+ process.exit(1)
24
+ }
15
25
  try {
16
26
  await Effect.runPromise(effect)
17
27
  } catch (error) {
18
28
  const errorMessage = error instanceof Error ? error.message : String(error)
19
- if (options.xml) {
29
+ if (options.json) {
30
+ console.log(JSON.stringify({ status: 'error', error: errorMessage }, null, 2))
31
+ } else if (options.xml) {
20
32
  console.log(`<?xml version="1.0" encoding="UTF-8"?>`)
21
33
  console.log(`<${resultTag}>`)
22
34
  console.log(` <status>error</status>`)
@@ -43,6 +55,7 @@ export function registerGroupCommands(program: Command): void {
43
55
  .option('--user <account>', 'Show groups a user belongs to')
44
56
  .option('--limit <n>', 'Maximum number of results (default: 25)')
45
57
  .option('--xml', 'XML output for LLM consumption')
58
+ .option('--json', 'JSON output for programmatic consumption')
46
59
  .action(async (options) => {
47
60
  await executeEffect(
48
61
  groupsCommand(options).pipe(
@@ -59,6 +72,7 @@ export function registerGroupCommands(program: Command): void {
59
72
  .command('groups-show <group-id>')
60
73
  .description('Show detailed information about a Gerrit group')
61
74
  .option('--xml', 'XML output for LLM consumption')
75
+ .option('--json', 'JSON output for programmatic consumption')
62
76
  .action(async (groupId, options) => {
63
77
  await executeEffect(
64
78
  groupsShowCommand(groupId, options).pipe(
@@ -75,6 +89,7 @@ export function registerGroupCommands(program: Command): void {
75
89
  .command('groups-members <group-id>')
76
90
  .description('List all members of a Gerrit group')
77
91
  .option('--xml', 'XML output for LLM consumption')
92
+ .option('--json', 'JSON output for programmatic consumption')
78
93
  .action(async (groupId, options) => {
79
94
  await executeEffect(
80
95
  groupsMembersCommand(groupId, options).pipe(
@@ -8,14 +8,26 @@ import { removeReviewerCommand } from './commands/remove-reviewer'
8
8
  // Helper function to execute Effect with standard error handling
9
9
  async function executeEffect<E>(
10
10
  effect: Effect.Effect<void, E, never>,
11
- options: { xml?: boolean },
11
+ options: { xml?: boolean; json?: boolean },
12
12
  resultTag: string,
13
13
  ): Promise<void> {
14
+ if (options.xml && options.json) {
15
+ console.log(
16
+ JSON.stringify(
17
+ { status: 'error', error: '--xml and --json are mutually exclusive' },
18
+ null,
19
+ 2,
20
+ ),
21
+ )
22
+ process.exit(1)
23
+ }
14
24
  try {
15
25
  await Effect.runPromise(effect)
16
26
  } catch (error) {
17
27
  const errorMessage = error instanceof Error ? error.message : String(error)
18
- if (options.xml) {
28
+ if (options.json) {
29
+ console.log(JSON.stringify({ status: 'error', error: errorMessage }, null, 2))
30
+ } else if (options.xml) {
19
31
  console.log(`<?xml version="1.0" encoding="UTF-8"?>`)
20
32
  console.log(`<${resultTag}>`)
21
33
  console.log(` <status>error</status>`)
@@ -44,6 +56,7 @@ export function registerReviewerCommands(program: Command): void {
44
56
  'Notification level: none, owner, owner_reviewers, all (default: all)',
45
57
  )
46
58
  .option('--xml', 'XML output for LLM consumption')
59
+ .option('--json', 'JSON output for programmatic consumption')
47
60
  .addHelpText(
48
61
  'after',
49
62
  `
@@ -76,6 +89,7 @@ Examples:
76
89
  'Notification level: none, owner, owner_reviewers, all (default: all)',
77
90
  )
78
91
  .option('--xml', 'XML output for LLM consumption')
92
+ .option('--json', 'JSON output for programmatic consumption')
79
93
  .addHelpText(
80
94
  'after',
81
95
  `
@@ -308,6 +308,7 @@ export const ReviewInput: Schema.Schema<{
308
308
  readonly message: string
309
309
  readonly side?: 'PARENT' | 'REVISION'
310
310
  readonly unresolved?: boolean
311
+ readonly in_reply_to?: string
311
312
  }>
312
313
  >
313
314
  }> = Schema.Struct({
@@ -330,6 +331,7 @@ export const ReviewInput: Schema.Schema<{
330
331
  message: Schema.String,
331
332
  side: Schema.optional(Schema.Literal('PARENT', 'REVISION')),
332
333
  unresolved: Schema.optional(Schema.Boolean),
334
+ in_reply_to: Schema.optional(Schema.String),
333
335
  }),
334
336
  ),
335
337
  }),
@@ -519,17 +521,14 @@ export type DiffOptions = Schema.Schema.Type<typeof DiffOptions>
519
521
  // Command options schemas
520
522
  export const DiffCommandOptions: Schema.Schema<{
521
523
  readonly xml?: boolean
524
+ readonly json?: boolean
522
525
  readonly file?: string
523
526
  readonly filesOnly?: boolean
524
527
  readonly format?: 'unified' | 'json' | 'files'
525
528
  }> = Schema.Struct({
526
529
  xml: Schema.optional(Schema.Boolean),
527
- file: Schema.optional(
528
- Schema.String.pipe(
529
- Schema.minLength(1),
530
- Schema.annotations({ description: 'File path for diff (relative to repo root)' }),
531
- ),
532
- ),
530
+ json: Schema.optional(Schema.Boolean),
531
+ file: Schema.optional(Schema.String.pipe(Schema.minLength(1))),
533
532
  filesOnly: Schema.optional(Schema.Boolean),
534
533
  format: Schema.optional(DiffFormat),
535
534
  })
@@ -135,11 +135,9 @@ export const getHooksDir = (): string => {
135
135
  export interface CommitHookServiceImpl {
136
136
  readonly hasHook: () => Effect.Effect<boolean, NotGitRepoError>
137
137
  readonly hasChangeId: (commit?: string) => Effect.Effect<boolean, NotGitRepoError>
138
- readonly installHook: () => Effect.Effect<
139
- void,
140
- HookInstallError | NotGitRepoError,
141
- ConfigServiceImpl
142
- >
138
+ readonly installHook: (
139
+ quiet?: boolean,
140
+ ) => Effect.Effect<void, HookInstallError | NotGitRepoError, ConfigServiceImpl>
143
141
  readonly ensureChangeId: () => Effect.Effect<
144
142
  void,
145
143
  HookInstallError | MissingChangeIdError | NotGitRepoError,
@@ -161,7 +159,7 @@ const CommitHookServiceImplLive: CommitHookServiceImpl = {
161
159
  catch: () => new NotGitRepoError({ message: 'Not in a git repository' }),
162
160
  }),
163
161
 
164
- installHook: () =>
162
+ installHook: (quiet = false) =>
165
163
  Effect.gen(function* () {
166
164
  const configService = yield* ConfigService
167
165
 
@@ -177,7 +175,7 @@ const CommitHookServiceImplLive: CommitHookServiceImpl = {
177
175
  const normalizedHost = config.host.replace(/\/$/, '')
178
176
  const hookUrl = `${normalizedHost}/tools/hooks/commit-msg`
179
177
 
180
- yield* Console.log(`Installing commit-msg hook from ${config.host}...`)
178
+ if (!quiet) yield* Console.log(`Installing commit-msg hook from ${config.host}...`)
181
179
 
182
180
  const hookContent = yield* Effect.tryPromise({
183
181
  try: async () => {
@@ -236,7 +234,7 @@ const CommitHookServiceImplLive: CommitHookServiceImpl = {
236
234
  }),
237
235
  })
238
236
 
239
- yield* Console.log('commit-msg hook installed successfully')
237
+ if (!quiet) yield* Console.log('commit-msg hook installed successfully')
240
238
  }),
241
239
 
242
240
  ensureChangeId: () =>
@@ -125,6 +125,62 @@ describe('mine command', () => {
125
125
  expect(output).toContain('</changes>')
126
126
  })
127
127
 
128
+ test('should include labels in --json output', async () => {
129
+ const mockChanges: ChangeInfo[] = [
130
+ generateMockChange({
131
+ _number: 12345,
132
+ subject: 'Labeled change',
133
+ labels: {
134
+ 'Code-Review': { value: 2 },
135
+ Verified: { value: -1 },
136
+ },
137
+ }),
138
+ ]
139
+
140
+ server.use(
141
+ http.get('*/a/changes/', () => {
142
+ return HttpResponse.text(`)]}'\n${JSON.stringify(mockChanges)}`)
143
+ }),
144
+ )
145
+
146
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
147
+ await Effect.runPromise(
148
+ mineCommand({ json: true }).pipe(
149
+ Effect.provide(GerritApiServiceLive),
150
+ Effect.provide(mockConfigLayer),
151
+ ),
152
+ )
153
+
154
+ const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
155
+ const parsed = JSON.parse(output)
156
+ const change = parsed.changes[0]
157
+ expect(change.labels).toBeDefined()
158
+ expect(change.labels['Code-Review'].value).toBe(2)
159
+ expect(change.labels['Verified'].value).toBe(-1)
160
+ })
161
+
162
+ test('should omit labels key in --json output when change has no labels', async () => {
163
+ const mockChanges: ChangeInfo[] = [generateMockChange({ _number: 12345 })]
164
+
165
+ server.use(
166
+ http.get('*/a/changes/', () => {
167
+ return HttpResponse.text(`)]}'\n${JSON.stringify(mockChanges)}`)
168
+ }),
169
+ )
170
+
171
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
172
+ await Effect.runPromise(
173
+ mineCommand({ json: true }).pipe(
174
+ Effect.provide(GerritApiServiceLive),
175
+ Effect.provide(mockConfigLayer),
176
+ ),
177
+ )
178
+
179
+ const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
180
+ const parsed = JSON.parse(output)
181
+ expect(parsed.changes[0].labels).toBeUndefined()
182
+ })
183
+
128
184
  test('should handle no changes gracefully', async () => {
129
185
  server.use(
130
186
  http.get('*/a/changes/', () => {