@aaronshaf/ger 2.0.1 → 2.0.3

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.
@@ -9,15 +9,17 @@ import { abandonCommand } from './commands/abandon'
9
9
  import { restoreCommand } from './commands/restore'
10
10
  import { rebaseCommand } from './commands/rebase'
11
11
  import { submitCommand } from './commands/submit'
12
+ import { topicCommand, TOPIC_HELP_TEXT } from './commands/topic'
12
13
  import { voteCommand } from './commands/vote'
13
14
  import { projectsCommand } from './commands/projects'
14
15
  import { buildStatusCommand, BUILD_STATUS_HELP_TEXT } from './commands/build-status'
15
16
  import { checkoutCommand, CHECKOUT_HELP_TEXT } from './commands/checkout'
16
- import { commentCommand } from './commands/comment'
17
+ import { commentCommand, COMMENT_HELP_TEXT } from './commands/comment'
17
18
  import { commentsCommand } from './commands/comments'
18
19
  import { diffCommand } from './commands/diff'
19
20
  import { extractUrlCommand } from './commands/extract-url'
20
21
  import { incomingCommand } from './commands/incoming'
22
+ import { installHookCommand } from './commands/install-hook'
21
23
  import { mineCommand } from './commands/mine'
22
24
  import { openCommand } from './commands/open'
23
25
  import { pushCommand, PUSH_HELP_TEXT } from './commands/push'
@@ -106,38 +108,7 @@ export function registerCommands(program: Command): void {
106
108
  .option('--unresolved', 'Mark comment as unresolved (requires human attention)')
107
109
  .option('--batch', 'Read batch comments from stdin as JSON (see examples below)')
108
110
  .option('--xml', 'XML output for LLM consumption')
109
- .addHelpText(
110
- 'after',
111
- `
112
- Examples:
113
- # Post a general comment on a change (using change number)
114
- $ ger comment 12345 -m "Looks good to me!"
115
-
116
- # Post a comment using Change-ID
117
- $ ger comment If5a3ae8cb5a107e187447802358417f311d0c4b1 -m "LGTM"
118
-
119
- # Post a comment using piped input (useful for multi-line comments or scripts)
120
- $ echo "This is a comment from stdin!" | ger comment 12345
121
- $ cat review-notes.txt | ger comment 12345
122
-
123
- # Post a line-specific comment (line number from NEW file version)
124
- $ ger comment 12345 --file src/main.js --line 42 -m "Consider using const here"
125
-
126
- # Post an unresolved comment requiring human attention
127
- $ ger comment 12345 --file src/api.js --line 15 -m "Security concern" --unresolved
128
-
129
- # Post multiple comments using batch mode
130
- $ echo '{"message": "Review complete", "comments": [
131
- {"file": "src/main.js", "line": 10, "message": "Good refactor"},
132
- {"file": "src/api.js", "line": 25, "message": "Check error handling", "unresolved": true}
133
- ]}' | ger comment 12345 --batch
134
-
135
- Note:
136
- - Both change number (e.g., 12345) and Change-ID (e.g., If5a3ae8...) formats are accepted
137
- - Line numbers refer to the actual line numbers in the NEW version of the file,
138
- NOT the line numbers shown in the diff view. To find the correct line number,
139
- look at the file after all changes have been applied.`,
140
- )
111
+ .addHelpText('after', COMMENT_HELP_TEXT)
141
112
  .action(async (changeId, options) => {
142
113
  await executeEffect(
143
114
  commentCommand(changeId, options).pipe(
@@ -316,6 +287,24 @@ Note:
316
287
  )
317
288
  })
318
289
 
290
+ // topic command
291
+ program
292
+ .command('topic [change-id] [topic]')
293
+ .description('Get, set, or remove topic for a change (auto-detects from HEAD if not specified)')
294
+ .option('--delete', 'Remove the topic from the change')
295
+ .option('--xml', 'XML output for LLM consumption')
296
+ .addHelpText('after', TOPIC_HELP_TEXT)
297
+ .action(async (changeId, topic, options) => {
298
+ await executeEffect(
299
+ topicCommand(changeId, topic, options).pipe(
300
+ Effect.provide(GerritApiServiceLive),
301
+ Effect.provide(ConfigServiceLive),
302
+ ),
303
+ options,
304
+ 'topic_result',
305
+ )
306
+ })
307
+
319
308
  // vote command
320
309
  program
321
310
  .command('vote <change-id>')
@@ -528,6 +517,39 @@ Note:
528
517
  }
529
518
  })
530
519
 
520
+ // install-hook command
521
+ program
522
+ .command('install-hook')
523
+ .description('Install the Gerrit commit-msg hook for automatic Change-Id generation')
524
+ .option('--force', 'Overwrite existing hook')
525
+ .option('--xml', 'XML output for LLM consumption')
526
+ .addHelpText(
527
+ 'after',
528
+ `
529
+ Examples:
530
+ # Install the commit-msg hook
531
+ $ ger install-hook
532
+
533
+ # Force reinstall (overwrite existing)
534
+ $ ger install-hook --force
535
+
536
+ Note:
537
+ - Downloads hook from your configured Gerrit server
538
+ - Installs to .git/hooks/commit-msg
539
+ - Makes hook executable (chmod +x)
540
+ - Required for commits to have Change-Id footers`,
541
+ )
542
+ .action(async (options) => {
543
+ await executeEffect(
544
+ installHookCommand(options).pipe(
545
+ Effect.provide(CommitHookServiceLive),
546
+ Effect.provide(ConfigServiceLive),
547
+ ),
548
+ options,
549
+ 'install_hook_result',
550
+ )
551
+ })
552
+
531
553
  // push command
532
554
  program
533
555
  .command('push')
@@ -1,6 +1,5 @@
1
1
  import { Schema } from '@effect/schema'
2
2
 
3
- // Authentication schemas
4
3
  export const GerritCredentials: Schema.Schema<{
5
4
  readonly host: string
6
5
  readonly username: string
@@ -21,7 +20,6 @@ export const GerritCredentials: Schema.Schema<{
21
20
  })
22
21
  export type GerritCredentials = Schema.Schema.Type<typeof GerritCredentials>
23
22
 
24
- // Forward declare RevisionInfo type for use in ChangeInfo
25
23
  export interface RevisionInfoType {
26
24
  readonly kind?: string
27
25
  readonly _number: number
@@ -65,7 +63,22 @@ export interface RevisionInfoType {
65
63
  >
66
64
  }
67
65
 
68
- // Change schemas
66
+ type ChangeReviewerAccount = {
67
+ readonly _account_id?: number
68
+ readonly name?: string
69
+ readonly email?: string
70
+ readonly username?: string
71
+ }
72
+ const ChangeReviewerAccountInfo: Schema.Schema<ChangeReviewerAccount> = Schema.Struct({
73
+ _account_id: Schema.optional(Schema.Number),
74
+ name: Schema.optional(Schema.String),
75
+ email: Schema.optional(Schema.String),
76
+ username: Schema.optional(Schema.String),
77
+ })
78
+ type ChangeReviewerMap = Partial<
79
+ Record<'REVIEWER' | 'CC' | 'REMOVED', ReadonlyArray<ChangeReviewerAccount>>
80
+ >
81
+
69
82
  export const ChangeInfo: Schema.Schema<{
70
83
  readonly id: string
71
84
  readonly project: string
@@ -118,6 +131,8 @@ export const ChangeInfo: Schema.Schema<{
118
131
  readonly work_in_progress?: boolean
119
132
  readonly current_revision?: string
120
133
  readonly revisions?: Record<string, RevisionInfoType>
134
+ readonly topic?: string
135
+ readonly reviewers?: ChangeReviewerMap
121
136
  }> = Schema.Struct({
122
137
  id: Schema.String,
123
138
  project: Schema.String,
@@ -182,10 +197,17 @@ export const ChangeInfo: Schema.Schema<{
182
197
  work_in_progress: Schema.optional(Schema.Boolean),
183
198
  current_revision: Schema.optional(Schema.String),
184
199
  revisions: Schema.optional(Schema.Record({ key: Schema.String, value: Schema.Any })),
200
+ topic: Schema.optional(Schema.String),
201
+ reviewers: Schema.optional(
202
+ Schema.Struct({
203
+ REVIEWER: Schema.optional(Schema.Array(ChangeReviewerAccountInfo)),
204
+ CC: Schema.optional(Schema.Array(ChangeReviewerAccountInfo)),
205
+ REMOVED: Schema.optional(Schema.Array(ChangeReviewerAccountInfo)),
206
+ }),
207
+ ),
185
208
  })
186
209
  export type ChangeInfo = Schema.Schema.Type<typeof ChangeInfo>
187
210
 
188
- // Comment schemas
189
211
  export const CommentInput: Schema.Schema<{
190
212
  readonly message: string
191
213
  readonly unresolved?: boolean
@@ -198,7 +220,6 @@ export const CommentInput: Schema.Schema<{
198
220
  })
199
221
  export type CommentInput = Schema.Schema.Type<typeof CommentInput>
200
222
 
201
- // Comment info returned from API
202
223
  export const CommentInfo: Schema.Schema<{
203
224
  readonly id: string
204
225
  readonly path?: string
@@ -244,7 +265,6 @@ export const CommentInfo: Schema.Schema<{
244
265
  })
245
266
  export type CommentInfo = Schema.Schema.Type<typeof CommentInfo>
246
267
 
247
- // Message info for review messages
248
268
  export const MessageInfo: Schema.Schema<{
249
269
  readonly id: string
250
270
  readonly message: string
@@ -317,7 +337,6 @@ export const ReviewInput: Schema.Schema<{
317
337
  })
318
338
  export type ReviewInput = Schema.Schema.Type<typeof ReviewInput>
319
339
 
320
- // Project schema
321
340
  export const ProjectInfo: Schema.Schema<{
322
341
  readonly id: string
323
342
  readonly name: string
@@ -331,7 +350,6 @@ export const ProjectInfo: Schema.Schema<{
331
350
  })
332
351
  export type ProjectInfo = Schema.Schema.Type<typeof ProjectInfo>
333
352
 
334
- // File and diff schemas
335
353
  export const FileInfo: Schema.Schema<{
336
354
  readonly status?: 'A' | 'D' | 'R' | 'C' | 'M'
337
355
  readonly lines_inserted?: number
@@ -95,6 +95,27 @@ describe('show command', () => {
95
95
  name: 'John Doe',
96
96
  email: 'john@example.com',
97
97
  },
98
+ reviewers: {
99
+ REVIEWER: [
100
+ {
101
+ _account_id: 2001,
102
+ name: 'Jane Reviewer',
103
+ email: 'jane.reviewer@example.com',
104
+ username: 'jreviewer',
105
+ },
106
+ {
107
+ email: 'second.reviewer@example.com',
108
+ username: 'sreviewer',
109
+ },
110
+ ],
111
+ CC: [
112
+ {
113
+ _account_id: 2003,
114
+ name: 'Team Observer',
115
+ email: 'observer@example.com',
116
+ },
117
+ ],
118
+ },
98
119
  })
99
120
 
100
121
  const mockDiff = `--- a/src/auth.js
@@ -194,6 +215,9 @@ describe('show command', () => {
194
215
  expect(output).toContain('Branch: main')
195
216
  expect(output).toContain('Status: NEW')
196
217
  expect(output).toContain('Owner: John Doe')
218
+ expect(output).toContain('Reviewers: Jane Reviewer <jane.reviewer@example.com>')
219
+ expect(output).toContain('second.reviewer@example.com')
220
+ expect(output).toContain('CCs: Team Observer <observer@example.com>')
197
221
  expect(output).toContain('Change-Id: I123abc456def')
198
222
  expect(output).toContain('🔍 Diff:')
199
223
  expect(output).toContain('💬 Inline Comments:')
@@ -234,6 +258,13 @@ describe('show command', () => {
234
258
  expect(output).toContain('<owner>')
235
259
  expect(output).toContain('<name><![CDATA[John Doe]]></name>')
236
260
  expect(output).toContain('<email>john@example.com</email>')
261
+ expect(output).toContain('<reviewers>')
262
+ expect(output).toContain('<count>2</count>')
263
+ expect(output).toContain('<name><![CDATA[Jane Reviewer]]></name>')
264
+ expect(output).toContain('<ccs>')
265
+ expect(output).toContain('<count>1</count>')
266
+ expect(output).toContain('<name><![CDATA[Team Observer]]></name>')
267
+ expect(output).not.toContain('<account_id>undefined</account_id>')
237
268
  expect(output).toContain('<diff><![CDATA[')
238
269
  expect(output).toContain('<comments>')
239
270
  expect(output).toContain('<count>3</count>')
@@ -482,6 +513,14 @@ describe('show command', () => {
482
513
  expect(parsed.change.branch).toBe('main')
483
514
  expect(parsed.change.owner.name).toBe('John Doe')
484
515
  expect(parsed.change.owner.email).toBe('john@example.com')
516
+ expect(Array.isArray(parsed.change.reviewers)).toBe(true)
517
+ expect(parsed.change.reviewers.length).toBe(2)
518
+ expect(parsed.change.reviewers[0].name).toBe('Jane Reviewer')
519
+ expect(parsed.change.reviewers[1].email).toBe('second.reviewer@example.com')
520
+ expect(parsed.change.reviewers[1].account_id).toBeUndefined()
521
+ expect(Array.isArray(parsed.change.ccs)).toBe(true)
522
+ expect(parsed.change.ccs.length).toBe(1)
523
+ expect(parsed.change.ccs[0].name).toBe('Team Observer')
485
524
 
486
525
  // Check diff is present
487
526
  expect(parsed.diff).toContain('src/auth.js')
@@ -611,6 +650,93 @@ describe('show command', () => {
611
650
  expect(parsed.messages[0].revision).toBe(2)
612
651
  })
613
652
 
653
+ test('should fetch reviewers from listChanges when getChange lacks reviewer data', async () => {
654
+ let listChangesOptions: string[] = []
655
+ let listChangesQuery = ''
656
+
657
+ const changeWithoutReviewers = {
658
+ ...mockChange,
659
+ reviewers: undefined,
660
+ }
661
+
662
+ server.use(
663
+ http.get('*/a/changes/:changeId', ({ request }) => {
664
+ const url = new URL(request.url)
665
+ if (url.searchParams.get('o') === 'MESSAGES') {
666
+ return HttpResponse.text(`)]}'\n${JSON.stringify({ messages: [] })}`)
667
+ }
668
+ return HttpResponse.text(`)]}'\n${JSON.stringify(changeWithoutReviewers)}`)
669
+ }),
670
+ http.get('*/a/changes/', ({ request }) => {
671
+ const url = new URL(request.url)
672
+ listChangesOptions = url.searchParams.getAll('o')
673
+ listChangesQuery = url.searchParams.get('q') || ''
674
+ return HttpResponse.text(`)]}'\n${JSON.stringify([mockChange])}`)
675
+ }),
676
+ http.get('*/a/changes/:changeId/revisions/current/patch', () => {
677
+ return HttpResponse.text(btoa(mockDiff))
678
+ }),
679
+ http.get('*/a/changes/:changeId/revisions/current/comments', () => {
680
+ return HttpResponse.text(`)]}'\n${JSON.stringify(mockComments)}`)
681
+ }),
682
+ )
683
+
684
+ const mockConfigLayer = createMockConfigLayer()
685
+ const program = showCommand('12345', {}).pipe(
686
+ Effect.provide(GerritApiServiceLive),
687
+ Effect.provide(mockConfigLayer),
688
+ )
689
+
690
+ await Effect.runPromise(program)
691
+
692
+ expect(listChangesQuery).toBe('change:12345')
693
+ expect(listChangesOptions).toContain('LABELS')
694
+ expect(listChangesOptions).toContain('DETAILED_LABELS')
695
+ expect(listChangesOptions).toContain('DETAILED_ACCOUNTS')
696
+ })
697
+
698
+ test('should not fetch listChanges when reviewer data is explicitly present but empty', async () => {
699
+ let listChangesCalled = false
700
+
701
+ const changeWithEmptyReviewerLists = {
702
+ ...mockChange,
703
+ reviewers: {
704
+ REVIEWER: [],
705
+ CC: [],
706
+ },
707
+ }
708
+
709
+ server.use(
710
+ http.get('*/a/changes/:changeId', ({ request }) => {
711
+ const url = new URL(request.url)
712
+ if (url.searchParams.get('o') === 'MESSAGES') {
713
+ return HttpResponse.text(`)]}'\n${JSON.stringify({ messages: [] })}`)
714
+ }
715
+ return HttpResponse.text(`)]}'\n${JSON.stringify(changeWithEmptyReviewerLists)}`)
716
+ }),
717
+ http.get('*/a/changes/', () => {
718
+ listChangesCalled = true
719
+ return HttpResponse.text(`)]}'\n[]`)
720
+ }),
721
+ http.get('*/a/changes/:changeId/revisions/current/patch', () => {
722
+ return HttpResponse.text(btoa(mockDiff))
723
+ }),
724
+ http.get('*/a/changes/:changeId/revisions/current/comments', () => {
725
+ return HttpResponse.text(`)]}'\n${JSON.stringify(mockComments)}`)
726
+ }),
727
+ )
728
+
729
+ const mockConfigLayer = createMockConfigLayer()
730
+ const program = showCommand('12345', {}).pipe(
731
+ Effect.provide(GerritApiServiceLive),
732
+ Effect.provide(mockConfigLayer),
733
+ )
734
+
735
+ await Effect.runPromise(program)
736
+
737
+ expect(listChangesCalled).toBe(false)
738
+ })
739
+
614
740
  test('should handle large JSON output without truncation', async () => {
615
741
  // Create a large diff to simulate output > 64KB
616
742
  const largeDiff = '--- a/large-file.js\n+++ b/large-file.js\n' + 'x'.repeat(100000)
@@ -132,6 +132,34 @@ describe('submit command', () => {
132
132
  expect(output).toContain('Status: MERGED')
133
133
  })
134
134
 
135
+ it('should fetch change without detailed reviewer options', async () => {
136
+ let requestedOptions: string[] = []
137
+
138
+ server.use(
139
+ http.get('*/a/changes/12345', ({ request }) => {
140
+ const url = new URL(request.url)
141
+ requestedOptions = url.searchParams.getAll('o')
142
+ return HttpResponse.text(`)]}'\n${JSON.stringify(mockSubmittableChange)}`)
143
+ }),
144
+ http.post('*/a/changes/12345/submit', () => {
145
+ return HttpResponse.text(`)]}'\n${JSON.stringify(mockSubmitResponse)}`)
146
+ }),
147
+ )
148
+
149
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
150
+ const program = submitCommand('12345', {}).pipe(
151
+ Effect.provide(GerritApiServiceLive),
152
+ Effect.provide(mockConfigLayer),
153
+ )
154
+
155
+ await Effect.runPromise(program)
156
+
157
+ expect(requestedOptions).toContain('CURRENT_REVISION')
158
+ expect(requestedOptions).toContain('CURRENT_COMMIT')
159
+ expect(requestedOptions).not.toContain('DETAILED_LABELS')
160
+ expect(requestedOptions).not.toContain('DETAILED_ACCOUNTS')
161
+ })
162
+
135
163
  it('should output XML format when --xml flag is used', async () => {
136
164
  server.use(
137
165
  http.get('*/a/changes/12345', () => {