@aaronshaf/ger 2.0.10 → 3.0.2

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.
@@ -0,0 +1,76 @@
1
+ import { Effect } from 'effect'
2
+ import { type ApiError, GerritApiService } from '@/api/gerrit'
3
+ import { sanitizeCDATA } from '@/utils/shell-safety'
4
+
5
+ interface SetReadyOptions {
6
+ message?: string
7
+ xml?: boolean
8
+ json?: boolean
9
+ }
10
+
11
+ export const setReadyCommand = (
12
+ changeId?: string,
13
+ options: SetReadyOptions = {},
14
+ ): Effect.Effect<void, ApiError, GerritApiService> =>
15
+ Effect.gen(function* () {
16
+ const gerritApi = yield* GerritApiService
17
+
18
+ if (!changeId) {
19
+ console.error('✗ Change ID is required')
20
+ console.error(' Usage: ger set-ready <change-id>')
21
+ return
22
+ }
23
+
24
+ // Try to fetch change details for richer output, but don't let it block the mutation
25
+ let changeNumber: number | undefined
26
+ let subject: string | undefined
27
+ try {
28
+ const change = yield* gerritApi.getChange(changeId)
29
+ changeNumber = change._number
30
+ subject = change.subject
31
+ } catch {
32
+ // Proceed without change details
33
+ }
34
+
35
+ yield* gerritApi.setReady(changeId, options.message)
36
+
37
+ if (options.json) {
38
+ console.log(
39
+ JSON.stringify(
40
+ {
41
+ status: 'success',
42
+ ...(changeNumber !== undefined
43
+ ? { change_number: changeNumber }
44
+ : { change_id: changeId }),
45
+ ...(subject !== undefined ? { subject } : {}),
46
+ ...(options.message ? { message: options.message } : {}),
47
+ },
48
+ null,
49
+ 2,
50
+ ),
51
+ )
52
+ } else if (options.xml) {
53
+ console.log(`<?xml version="1.0" encoding="UTF-8"?>`)
54
+ console.log(`<set_ready_result>`)
55
+ console.log(` <status>success</status>`)
56
+ if (changeNumber !== undefined) {
57
+ console.log(` <change_number>${changeNumber}</change_number>`)
58
+ } else {
59
+ console.log(` <change_id>${changeId}</change_id>`)
60
+ }
61
+ if (subject !== undefined) {
62
+ console.log(` <subject><![CDATA[${sanitizeCDATA(subject)}]]></subject>`)
63
+ }
64
+ if (options.message) {
65
+ console.log(` <message><![CDATA[${sanitizeCDATA(options.message)}]]></message>`)
66
+ }
67
+ console.log(`</set_ready_result>`)
68
+ } else {
69
+ const label = changeNumber !== undefined ? `${changeNumber}` : changeId
70
+ const suffix = subject !== undefined ? `: ${subject}` : ''
71
+ console.log(`✓ Marked change ${label} as ready for review${suffix}`)
72
+ if (options.message) {
73
+ console.log(` Message: ${options.message}`)
74
+ }
75
+ }
76
+ })
@@ -0,0 +1,76 @@
1
+ import { Effect } from 'effect'
2
+ import { type ApiError, GerritApiService } from '@/api/gerrit'
3
+ import { sanitizeCDATA } from '@/utils/shell-safety'
4
+
5
+ interface SetWipOptions {
6
+ message?: string
7
+ xml?: boolean
8
+ json?: boolean
9
+ }
10
+
11
+ export const setWipCommand = (
12
+ changeId?: string,
13
+ options: SetWipOptions = {},
14
+ ): Effect.Effect<void, ApiError, GerritApiService> =>
15
+ Effect.gen(function* () {
16
+ const gerritApi = yield* GerritApiService
17
+
18
+ if (!changeId) {
19
+ console.error('✗ Change ID is required')
20
+ console.error(' Usage: ger set-wip <change-id>')
21
+ return
22
+ }
23
+
24
+ // Try to fetch change details for richer output, but don't let it block the mutation
25
+ let changeNumber: number | undefined
26
+ let subject: string | undefined
27
+ try {
28
+ const change = yield* gerritApi.getChange(changeId)
29
+ changeNumber = change._number
30
+ subject = change.subject
31
+ } catch {
32
+ // Proceed without change details
33
+ }
34
+
35
+ yield* gerritApi.setWip(changeId, options.message)
36
+
37
+ if (options.json) {
38
+ console.log(
39
+ JSON.stringify(
40
+ {
41
+ status: 'success',
42
+ ...(changeNumber !== undefined
43
+ ? { change_number: changeNumber }
44
+ : { change_id: changeId }),
45
+ ...(subject !== undefined ? { subject } : {}),
46
+ ...(options.message ? { message: options.message } : {}),
47
+ },
48
+ null,
49
+ 2,
50
+ ),
51
+ )
52
+ } else if (options.xml) {
53
+ console.log(`<?xml version="1.0" encoding="UTF-8"?>`)
54
+ console.log(`<set_wip_result>`)
55
+ console.log(` <status>success</status>`)
56
+ if (changeNumber !== undefined) {
57
+ console.log(` <change_number>${changeNumber}</change_number>`)
58
+ } else {
59
+ console.log(` <change_id>${changeId}</change_id>`)
60
+ }
61
+ if (subject !== undefined) {
62
+ console.log(` <subject><![CDATA[${sanitizeCDATA(subject)}]]></subject>`)
63
+ }
64
+ if (options.message) {
65
+ console.log(` <message><![CDATA[${sanitizeCDATA(options.message)}]]></message>`)
66
+ }
67
+ console.log(`</set_wip_result>`)
68
+ } else {
69
+ const label = changeNumber !== undefined ? `${changeNumber}` : changeId
70
+ const suffix = subject !== undefined ? `: ${subject}` : ''
71
+ console.log(`✓ Marked change ${label} as work-in-progress${suffix}`)
72
+ if (options.message) {
73
+ console.log(` Message: ${options.message}`)
74
+ }
75
+ }
76
+ })
@@ -260,7 +260,6 @@ const setupEffect = (configService: ConfigServiceImpl) =>
260
260
  Effect.tap(() => Console.log('You can now use:')),
261
261
  Effect.tap(() => Console.log(' • "ger mine" to view your changes')),
262
262
  Effect.tap(() => Console.log(' • "ger show <change-id>" to view change details')),
263
- Effect.tap(() => Console.log(' • "ger review <change-id>" to review with AI')),
264
263
  Effect.catchAll((error) =>
265
264
  pipe(
266
265
  Console.error(
package/src/cli/index.ts CHANGED
@@ -75,6 +75,7 @@ COMMON LLM WORKFLOWS
75
75
  Review a change: ger show <id> → ger diff <id> → ger comments <id>
76
76
  Post a review: ger comment <id> -m "..." → ger vote <id> <label> <score>
77
77
  Manage changes: ger push, ger checkout <id>, ger abandon <id>, ger submit <id>
78
+ WIP toggle: ger set-wip <id>, ger set-ready <id> [-m "message"]
78
79
  Check CI: ger build-status <id> --exit-status
79
80
 
80
81
  EXIT CODES
@@ -2,11 +2,8 @@ import type { Command } from 'commander'
2
2
  import { Effect } from 'effect'
3
3
  import { GerritApiServiceLive } from '@/api/gerrit'
4
4
  import { ConfigServiceLive } from '@/services/config'
5
- import { ReviewStrategyServiceLive } from '@/services/review-strategy'
6
- import { GitWorktreeServiceLive } from '@/services/git-worktree'
7
5
  import { CommitHookServiceLive } from '@/services/commit-hook'
8
- import { abandonCommand } from './commands/abandon'
9
- import { restoreCommand } from './commands/restore'
6
+ import { registerStateCommands } from './register-state-commands'
10
7
  import { rebaseCommand } from './commands/rebase'
11
8
  import { submitCommand } from './commands/submit'
12
9
  import { topicCommand, TOPIC_HELP_TEXT } from './commands/topic'
@@ -23,7 +20,6 @@ import { installHookCommand } from './commands/install-hook'
23
20
  import { mineCommand } from './commands/mine'
24
21
  import { openCommand } from './commands/open'
25
22
  import { pushCommand, PUSH_HELP_TEXT } from './commands/push'
26
- import { reviewCommand } from './commands/review'
27
23
  import { searchCommand, SEARCH_HELP_TEXT } from './commands/search'
28
24
  import { setup } from './commands/setup'
29
25
  import { showCommand, SHOW_HELP_TEXT } from './commands/show'
@@ -32,6 +28,8 @@ import { workspaceCommand } from './commands/workspace'
32
28
  import { sanitizeCDATA } from '@/utils/shell-safety'
33
29
  import { registerGroupCommands } from './register-group-commands'
34
30
  import { registerReviewerCommands } from './register-reviewer-commands'
31
+ import { filesCommand } from './commands/files'
32
+ import { reviewersCommand } from './commands/reviewers'
35
33
 
36
34
  // Helper function to output error in plain text, JSON, or XML format
37
35
  function outputError(
@@ -240,43 +238,8 @@ export function registerCommands(program: Command): void {
240
238
  )
241
239
  })
242
240
 
243
- // abandon command
244
- program
245
- .command('abandon [change-id]')
246
- .description(
247
- 'Abandon a change (interactive mode if no change-id provided; accepts change number or Change-ID)',
248
- )
249
- .option('-m, --message <message>', 'Abandon message')
250
- .option('--xml', 'XML output for LLM consumption')
251
- .option('--json', 'JSON output for programmatic consumption')
252
- .action(async (changeId, options) => {
253
- await executeEffect(
254
- abandonCommand(changeId, options).pipe(
255
- Effect.provide(GerritApiServiceLive),
256
- Effect.provide(ConfigServiceLive),
257
- ),
258
- options,
259
- 'abandon_result',
260
- )
261
- })
262
-
263
- // restore command
264
- program
265
- .command('restore <change-id>')
266
- .description('Restore an abandoned change (accepts change number or Change-ID)')
267
- .option('-m, --message <message>', 'Restoration message')
268
- .option('--xml', 'XML output for LLM consumption')
269
- .option('--json', 'JSON output for programmatic consumption')
270
- .action(async (changeId, options) => {
271
- await executeEffect(
272
- restoreCommand(changeId, options).pipe(
273
- Effect.provide(GerritApiServiceLive),
274
- Effect.provide(ConfigServiceLive),
275
- ),
276
- options,
277
- 'restore_result',
278
- )
279
- })
241
+ // abandon / restore / set-ready / set-wip commands
242
+ registerStateCommands(program)
280
243
 
281
244
  // rebase command
282
245
  program
@@ -617,6 +580,44 @@ Note:
617
580
  }
618
581
  })
619
582
 
583
+ // files command
584
+ program
585
+ .command('files [change-id]')
586
+ .description(
587
+ 'List files changed in a Gerrit change (auto-detects from HEAD commit if not specified)',
588
+ )
589
+ .option('--xml', 'XML output for LLM consumption')
590
+ .option('--json', 'JSON output for programmatic consumption')
591
+ .action(async (changeId, options) => {
592
+ await executeEffect(
593
+ filesCommand(changeId, options).pipe(
594
+ Effect.provide(GerritApiServiceLive),
595
+ Effect.provide(ConfigServiceLive),
596
+ ),
597
+ options,
598
+ 'files_result',
599
+ )
600
+ })
601
+
602
+ // reviewers command
603
+ program
604
+ .command('reviewers [change-id]')
605
+ .description(
606
+ 'List reviewers on a Gerrit change (auto-detects from HEAD commit if not specified)',
607
+ )
608
+ .option('--xml', 'XML output for LLM consumption')
609
+ .option('--json', 'JSON output for programmatic consumption')
610
+ .action(async (changeId, options) => {
611
+ await executeEffect(
612
+ reviewersCommand(changeId, options).pipe(
613
+ Effect.provide(GerritApiServiceLive),
614
+ Effect.provide(ConfigServiceLive),
615
+ ),
616
+ options,
617
+ 'reviewers_result',
618
+ )
619
+ })
620
+
620
621
  // checkout command
621
622
  program
622
623
  .command('checkout <change-id>')
@@ -636,64 +637,4 @@ Note:
636
637
  process.exit(1)
637
638
  }
638
639
  })
639
-
640
- // review command
641
- program
642
- .command('review <change-id>')
643
- .description(
644
- 'AI-powered code review that analyzes changes and optionally posts comments (accepts change number or Change-ID)',
645
- )
646
- .option('--comment', 'Post the review as comments (prompts for confirmation)')
647
- .option('-y, --yes', 'Skip confirmation prompts when posting comments')
648
- .option('--debug', 'Show debug output including AI responses')
649
- .option('--prompt <file>', 'Path to custom review prompt file (e.g., ~/prompts/review.md)')
650
- .option('--tool <tool>', 'Preferred AI tool (claude, gemini, opencode)')
651
- .option('--system-prompt <prompt>', 'Custom system prompt for the AI')
652
- .addHelpText(
653
- 'after',
654
- `
655
- This command uses AI (claude CLI, gemini CLI, or opencode CLI) to review a Gerrit change.
656
- It performs a two-stage review process:
657
- 1. Generates inline comments for specific code issues
658
- 2. Generates an overall review comment
659
- By default, the review is only displayed in the terminal.
660
- Use --comment to post the review to Gerrit (with confirmation prompts).
661
- Use --comment --yes to post without confirmation.
662
-
663
- Requirements:
664
- - One of these AI tools must be available: claude CLI, gemini CLI, or opencode CLI
665
- - Gerrit credentials must be configured (run 'ger setup' first)
666
-
667
- Examples:
668
- $ ger review 12345
669
- $ ger review If5a3ae8cb5a107e187447802358417f311d0c4b1
670
- $ ger review 12345 --comment
671
- $ ger review 12345 --comment --yes
672
- $ ger review 12345 --tool gemini
673
- $ ger review 12345 --debug
674
-
675
- Note: Both change number (e.g., 12345) and Change-ID (e.g., If5a3ae8...) formats are accepted
676
- `,
677
- )
678
- .action(async (changeId, options) => {
679
- try {
680
- const effect = reviewCommand(changeId, {
681
- comment: options.comment,
682
- yes: options.yes,
683
- debug: options.debug,
684
- prompt: options.prompt,
685
- tool: options.tool,
686
- systemPrompt: options.systemPrompt,
687
- }).pipe(
688
- Effect.provide(ReviewStrategyServiceLive),
689
- Effect.provide(GerritApiServiceLive),
690
- Effect.provide(ConfigServiceLive),
691
- Effect.provide(GitWorktreeServiceLive),
692
- )
693
- await Effect.runPromise(effect)
694
- } catch (error) {
695
- console.error('✗ Error:', error instanceof Error ? error.message : String(error))
696
- process.exit(1)
697
- }
698
- })
699
640
  }
@@ -0,0 +1,106 @@
1
+ import type { Command } from 'commander'
2
+ import { Effect } from 'effect'
3
+ import { GerritApiServiceLive } from '@/api/gerrit'
4
+ import { ConfigServiceLive } from '@/services/config'
5
+ import { abandonCommand } from './commands/abandon'
6
+ import { restoreCommand } from './commands/restore'
7
+ import { setReadyCommand } from './commands/set-ready'
8
+ import { setWipCommand } from './commands/set-wip'
9
+
10
+ type StateOptions = { message?: string; xml?: boolean; json?: boolean }
11
+
12
+ async function executeStateEffect(
13
+ effect: Effect.Effect<void, unknown, never>,
14
+ options: StateOptions,
15
+ resultTag: string,
16
+ ): Promise<void> {
17
+ try {
18
+ await Effect.runPromise(effect)
19
+ } catch (error) {
20
+ const errorMessage = error instanceof Error ? error.message : String(error)
21
+ if (options.xml) {
22
+ console.log(`<?xml version="1.0" encoding="UTF-8"?>`)
23
+ console.log(`<${resultTag}>`)
24
+ console.log(` <status>error</status>`)
25
+ console.log(` <error><![CDATA[${errorMessage}]]></error>`)
26
+ console.log(`</${resultTag}>`)
27
+ } else if (options.json) {
28
+ console.log(JSON.stringify({ status: 'error', error: errorMessage }, null, 2))
29
+ } else {
30
+ console.error('✗ Error:', errorMessage)
31
+ }
32
+ process.exit(1)
33
+ }
34
+ }
35
+
36
+ export function registerStateCommands(program: Command): void {
37
+ program
38
+ .command('abandon [change-id]')
39
+ .description(
40
+ 'Abandon a change (interactive mode if no change-id provided; accepts change number or Change-ID)',
41
+ )
42
+ .option('-m, --message <message>', 'Abandon message')
43
+ .option('--xml', 'XML output for LLM consumption')
44
+ .option('--json', 'JSON output for programmatic consumption')
45
+ .action(async (changeId, options) => {
46
+ await executeStateEffect(
47
+ abandonCommand(changeId, options).pipe(
48
+ Effect.provide(GerritApiServiceLive),
49
+ Effect.provide(ConfigServiceLive),
50
+ ),
51
+ options,
52
+ 'abandon_result',
53
+ )
54
+ })
55
+
56
+ program
57
+ .command('restore <change-id>')
58
+ .description('Restore an abandoned change (accepts change number or Change-ID)')
59
+ .option('-m, --message <message>', 'Restoration message')
60
+ .option('--xml', 'XML output for LLM consumption')
61
+ .option('--json', 'JSON output for programmatic consumption')
62
+ .action(async (changeId, options) => {
63
+ await executeStateEffect(
64
+ restoreCommand(changeId, options).pipe(
65
+ Effect.provide(GerritApiServiceLive),
66
+ Effect.provide(ConfigServiceLive),
67
+ ),
68
+ options,
69
+ 'restore_result',
70
+ )
71
+ })
72
+
73
+ program
74
+ .command('set-ready <change-id>')
75
+ .description('Mark a WIP change as ready for review (accepts change number or Change-ID)')
76
+ .option('-m, --message <message>', 'Message to include with the status change')
77
+ .option('--xml', 'XML output for LLM consumption')
78
+ .option('--json', 'JSON output for programmatic consumption')
79
+ .action(async (changeId, options) => {
80
+ await executeStateEffect(
81
+ setReadyCommand(changeId, options).pipe(
82
+ Effect.provide(GerritApiServiceLive),
83
+ Effect.provide(ConfigServiceLive),
84
+ ),
85
+ options,
86
+ 'set_ready_result',
87
+ )
88
+ })
89
+
90
+ program
91
+ .command('set-wip <change-id>')
92
+ .description('Mark a change as work-in-progress (accepts change number or Change-ID)')
93
+ .option('-m, --message <message>', 'Message to include with the status change')
94
+ .option('--xml', 'XML output for LLM consumption')
95
+ .option('--json', 'JSON output for programmatic consumption')
96
+ .action(async (changeId, options) => {
97
+ await executeStateEffect(
98
+ setWipCommand(changeId, options).pipe(
99
+ Effect.provide(GerritApiServiceLive),
100
+ Effect.provide(ConfigServiceLive),
101
+ ),
102
+ options,
103
+ 'set_wip_result',
104
+ )
105
+ })
106
+ }
@@ -570,6 +570,8 @@ const ReviewerAccountInfo = Schema.Struct({
570
570
  username: Schema.optional(Schema.String),
571
571
  })
572
572
 
573
+ export type { ReviewerListItem } from './reviewer'
574
+
573
575
  export const ReviewerResult: Schema.Schema<{
574
576
  readonly input: string
575
577
  readonly reviewers?: ReadonlyArray<{
@@ -0,0 +1,16 @@
1
+ import { Schema } from '@effect/schema'
2
+
3
+ export const ReviewerListItem: Schema.Schema<{
4
+ readonly _account_id?: number
5
+ readonly name?: string
6
+ readonly email?: string
7
+ readonly username?: string
8
+ readonly approvals?: { readonly [x: string]: string }
9
+ }> = Schema.Struct({
10
+ _account_id: Schema.optional(Schema.Number),
11
+ name: Schema.optional(Schema.String),
12
+ email: Schema.optional(Schema.String),
13
+ username: Schema.optional(Schema.String),
14
+ approvals: Schema.optional(Schema.Record({ key: Schema.String, value: Schema.String })),
15
+ })
16
+ export type ReviewerListItem = Schema.Schema.Type<typeof ReviewerListItem>
@@ -1,4 +1,5 @@
1
1
  import { colors } from './formatters'
2
+ import type { FileDiffContent } from '@/schemas/gerrit'
2
3
 
3
4
  interface DiffStats {
4
5
  additions: number
@@ -139,3 +140,34 @@ export const extractDiffStats = (diffContent: string): DiffStats => {
139
140
 
140
141
  return { additions, deletions, files }
141
142
  }
143
+
144
+ export const convertToUnifiedDiff = (diff: FileDiffContent, filePath: string): string => {
145
+ const lines: string[] = []
146
+
147
+ if (diff.diff_header) {
148
+ lines.push(...diff.diff_header)
149
+ } else {
150
+ lines.push(`--- a/${filePath}`)
151
+ lines.push(`+++ b/${filePath}`)
152
+ }
153
+
154
+ for (const section of diff.content) {
155
+ if (section.ab) {
156
+ for (const line of section.ab) {
157
+ lines.push(` ${line}`)
158
+ }
159
+ }
160
+ if (section.a) {
161
+ for (const line of section.a) {
162
+ lines.push(`-${line}`)
163
+ }
164
+ }
165
+ if (section.b) {
166
+ for (const line of section.b) {
167
+ lines.push(`+${line}`)
168
+ }
169
+ }
170
+ }
171
+
172
+ return lines.join('\n')
173
+ }