@aaronshaf/ger 3.0.1 → 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.
@@ -28,7 +28,65 @@ ger show # Auto-detect from HEAD
28
28
  - Full diff
29
29
  - All comments with context
30
30
 
31
- Reviewer listing for a specific change is provided by `show` (there is no separate `list-reviewers` command).
31
+ ### files
32
+
33
+ List files changed in a change.
34
+
35
+ ```bash
36
+ ger files [change-id]
37
+ ger files 12345
38
+ ger files # Auto-detect from HEAD
39
+ ger files 12345 --json
40
+ ger files 12345 --xml
41
+ ```
42
+
43
+ | Option | Description |
44
+ |--------|-------------|
45
+ | `--json` | Output as JSON |
46
+ | `--xml` | Output as XML for LLM consumption |
47
+
48
+ **Output:** One file per line with status prefix (`M` modified, `A` added, `D` deleted, `R` renamed). Magic files (`/COMMIT_MSG`, `/MERGE_LIST`, `/PATCHSET_LEVEL`) are filtered out.
49
+
50
+ **JSON output:**
51
+ ```json
52
+ {
53
+ "status": "success",
54
+ "change_id": "12345",
55
+ "files": [
56
+ { "path": "src/foo.ts", "status": "M", "lines_inserted": 10, "lines_deleted": 2 }
57
+ ]
58
+ }
59
+ ```
60
+
61
+ ### reviewers
62
+
63
+ List reviewers on a change.
64
+
65
+ ```bash
66
+ ger reviewers [change-id]
67
+ ger reviewers 12345
68
+ ger reviewers # Auto-detect from HEAD
69
+ ger reviewers 12345 --json
70
+ ger reviewers 12345 --xml
71
+ ```
72
+
73
+ | Option | Description |
74
+ |--------|-------------|
75
+ | `--json` | Output as JSON |
76
+ | `--xml` | Output as XML for LLM consumption |
77
+
78
+ **Output:** One reviewer per line in `Name <email>` format (or email alone for email-only accounts).
79
+
80
+ **JSON output:**
81
+ ```json
82
+ {
83
+ "status": "success",
84
+ "change_id": "12345",
85
+ "reviewers": [
86
+ { "account_id": 1001, "name": "Alice Smith", "email": "alice@example.com", "username": "alice" }
87
+ ]
88
+ }
89
+ ```
32
90
 
33
91
  ### diff
34
92
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aaronshaf/ger",
3
- "version": "3.0.1",
3
+ "version": "3.0.2",
4
4
  "description": "Gerrit CLI and SDK - A modern CLI tool and TypeScript SDK for Gerrit Code Review, built with Effect-TS",
5
5
  "keywords": [
6
6
  "gerrit",
package/src/api/gerrit.ts CHANGED
@@ -18,6 +18,7 @@ import {
18
18
  GroupDetailInfo,
19
19
  AccountInfo,
20
20
  } from '@/schemas/gerrit'
21
+ import { ReviewerListItem } from '@/schemas/reviewer'
21
22
  import { filterMeaningfulMessages } from '@/utils/message-filters'
22
23
  import { convertToUnifiedDiff } from '@/utils/diff-formatters'
23
24
  import { ConfigService } from '@/services/config'
@@ -86,6 +87,7 @@ export interface GerritApiServiceImpl {
86
87
  readonly getGroup: (groupId: string) => Effect.Effect<GroupInfo, ApiError>
87
88
  readonly getGroupDetail: (groupId: string) => Effect.Effect<GroupDetailInfo, ApiError>
88
89
  readonly getGroupMembers: (groupId: string) => Effect.Effect<readonly AccountInfo[], ApiError>
90
+ readonly getReviewers: (changeId: string) => Effect.Effect<readonly ReviewerListItem[], ApiError>
89
91
  readonly removeReviewer: (
90
92
  changeId: string,
91
93
  accountId: string,
@@ -227,13 +229,8 @@ export const GerritApiServiceLive: Layer.Layer<GerritApiService, never, ConfigSe
227
229
  const { credentials, authHeader } = yield* getCredentialsAndAuth
228
230
  let url = `${credentials.host}/a/projects/`
229
231
  if (options?.pattern) url += `?p=${encodeURIComponent(options.pattern)}`
230
- const projectsRecord = yield* makeRequest(
231
- url,
232
- authHeader,
233
- 'GET',
234
- undefined,
235
- Schema.Record({ key: Schema.String, value: ProjectInfo }),
236
- )
232
+ const schema = Schema.Record({ key: Schema.String, value: ProjectInfo })
233
+ const projectsRecord = yield* makeRequest(url, authHeader, 'GET', undefined, schema)
237
234
  return Object.values(projectsRecord).sort((a, b) => a.name.localeCompare(b.name))
238
235
  })
239
236
 
@@ -581,6 +578,15 @@ export const GerritApiServiceLive: Layer.Layer<GerritApiService, never, ConfigSe
581
578
  return yield* makeRequest(url, authHeader, 'GET', undefined, Schema.Array(AccountInfo))
582
579
  })
583
580
 
581
+ const getReviewers = (changeId: string) =>
582
+ Effect.gen(function* () {
583
+ const { credentials, authHeader } = yield* getCredentialsAndAuth
584
+ const normalized = yield* normalizeAndValidate(changeId)
585
+ const url = `${credentials.host}/a/changes/${encodeURIComponent(normalized)}/reviewers`
586
+ const schema = Schema.Array(ReviewerListItem)
587
+ return yield* makeRequest(url, authHeader, 'GET', undefined, schema)
588
+ })
589
+
584
590
  const removeReviewer = (
585
591
  changeId: string,
586
592
  accountId: string,
@@ -674,6 +680,7 @@ export const GerritApiServiceLive: Layer.Layer<GerritApiService, never, ConfigSe
674
680
  getComments,
675
681
  getMessages,
676
682
  addReviewer,
683
+ getReviewers,
677
684
  listGroups,
678
685
  getGroup,
679
686
  getGroupDetail,
@@ -0,0 +1,86 @@
1
+ import { Effect } from 'effect'
2
+ import { type ApiError, GerritApiService } from '@/api/gerrit'
3
+ import { GitError, NoChangeIdError, getChangeIdFromHead } from '@/utils/git-commit'
4
+ import { escapeXML, sanitizeCDATA } from '@/utils/shell-safety'
5
+
6
+ const MAGIC_FILES = new Set(['/COMMIT_MSG', '/MERGE_LIST', '/PATCHSET_LEVEL'])
7
+
8
+ interface FilesOptions {
9
+ xml?: boolean
10
+ json?: boolean
11
+ }
12
+
13
+ export const filesCommand = (
14
+ changeId?: string,
15
+ options: FilesOptions = {},
16
+ ): Effect.Effect<void, never, GerritApiService> =>
17
+ Effect.gen(function* () {
18
+ const gerritApi = yield* GerritApiService
19
+ const resolvedChangeId = changeId || (yield* getChangeIdFromHead())
20
+
21
+ const filesRecord = yield* gerritApi.getFiles(resolvedChangeId)
22
+ const files = Object.entries(filesRecord)
23
+ .filter(([path]) => !MAGIC_FILES.has(path))
24
+ .map(([path, info]) => ({
25
+ path,
26
+ status: info.status ?? 'M',
27
+ lines_inserted: info.lines_inserted ?? 0,
28
+ lines_deleted: info.lines_deleted ?? 0,
29
+ }))
30
+
31
+ if (options.json) {
32
+ console.log(
33
+ JSON.stringify(
34
+ {
35
+ status: 'success',
36
+ change_id: resolvedChangeId,
37
+ files,
38
+ },
39
+ null,
40
+ 2,
41
+ ),
42
+ )
43
+ } else if (options.xml) {
44
+ console.log(`<?xml version="1.0" encoding="UTF-8"?>`)
45
+ console.log(`<files_result>`)
46
+ console.log(` <status>success</status>`)
47
+ console.log(` <change_id><![CDATA[${sanitizeCDATA(resolvedChangeId)}]]></change_id>`)
48
+ console.log(` <files>`)
49
+ for (const file of files) {
50
+ console.log(` <file>`)
51
+ console.log(` <path><![CDATA[${sanitizeCDATA(file.path)}]]></path>`)
52
+ console.log(` <status>${escapeXML(file.status)}</status>`)
53
+ console.log(` <lines_inserted>${file.lines_inserted}</lines_inserted>`)
54
+ console.log(` <lines_deleted>${file.lines_deleted}</lines_deleted>`)
55
+ console.log(` </file>`)
56
+ }
57
+ console.log(` </files>`)
58
+ console.log(`</files_result>`)
59
+ } else {
60
+ for (const file of files) {
61
+ console.log(`${file.status} ${file.path}`)
62
+ }
63
+ }
64
+ }).pipe(
65
+ Effect.catchAll((error: ApiError | GitError | NoChangeIdError) =>
66
+ Effect.sync(() => {
67
+ const errorMessage =
68
+ error instanceof GitError || error instanceof NoChangeIdError || error instanceof Error
69
+ ? error.message
70
+ : String(error)
71
+
72
+ if (options.json) {
73
+ console.log(JSON.stringify({ status: 'error', error: errorMessage }, null, 2))
74
+ } else if (options.xml) {
75
+ console.log(`<?xml version="1.0" encoding="UTF-8"?>`)
76
+ console.log(`<files_result>`)
77
+ console.log(` <status>error</status>`)
78
+ console.log(` <error><![CDATA[${sanitizeCDATA(errorMessage)}]]></error>`)
79
+ console.log(`</files_result>`)
80
+ } else {
81
+ console.error(`✗ Error: ${errorMessage}`)
82
+ }
83
+ process.exit(1)
84
+ }),
85
+ ),
86
+ )
@@ -0,0 +1,95 @@
1
+ import { Effect } from 'effect'
2
+ import { type ApiError, GerritApiService } from '@/api/gerrit'
3
+ import { GitError, NoChangeIdError, getChangeIdFromHead } from '@/utils/git-commit'
4
+ import { sanitizeCDATA } from '@/utils/shell-safety'
5
+ import type { ReviewerListItem } from '@/schemas/reviewer'
6
+
7
+ interface ReviewersOptions {
8
+ xml?: boolean
9
+ json?: boolean
10
+ }
11
+
12
+ function formatReviewer(r: ReviewerListItem): string {
13
+ const name =
14
+ r.name ?? r.username ?? (r._account_id !== undefined ? `#${r._account_id}` : undefined)
15
+ if (name !== undefined) return r.email ? `${name} <${r.email}>` : name
16
+ return r.email ?? 'unknown'
17
+ }
18
+
19
+ export const reviewersCommand = (
20
+ changeId?: string,
21
+ options: ReviewersOptions = {},
22
+ ): Effect.Effect<void, never, GerritApiService> =>
23
+ Effect.gen(function* () {
24
+ const gerritApi = yield* GerritApiService
25
+ const resolvedChangeId = changeId || (yield* getChangeIdFromHead())
26
+
27
+ const reviewers = yield* gerritApi.getReviewers(resolvedChangeId)
28
+
29
+ if (options.json) {
30
+ console.log(
31
+ JSON.stringify(
32
+ {
33
+ status: 'success',
34
+ change_id: resolvedChangeId,
35
+ reviewers: reviewers.map((r) => ({
36
+ ...(r._account_id !== undefined ? { account_id: r._account_id } : {}),
37
+ name: r.name,
38
+ email: r.email,
39
+ username: r.username,
40
+ })),
41
+ },
42
+ null,
43
+ 2,
44
+ ),
45
+ )
46
+ } else if (options.xml) {
47
+ console.log(`<?xml version="1.0" encoding="UTF-8"?>`)
48
+ console.log(`<reviewers_result>`)
49
+ console.log(` <status>success</status>`)
50
+ console.log(` <change_id><![CDATA[${sanitizeCDATA(resolvedChangeId)}]]></change_id>`)
51
+ console.log(` <reviewers>`)
52
+ for (const r of reviewers) {
53
+ console.log(` <reviewer>`)
54
+ if (r._account_id !== undefined)
55
+ console.log(` <account_id>${r._account_id}</account_id>`)
56
+ if (r.name) console.log(` <name><![CDATA[${sanitizeCDATA(r.name)}]]></name>`)
57
+ if (r.email) console.log(` <email><![CDATA[${sanitizeCDATA(r.email)}]]></email>`)
58
+ if (r.username)
59
+ console.log(` <username><![CDATA[${sanitizeCDATA(r.username)}]]></username>`)
60
+ console.log(` </reviewer>`)
61
+ }
62
+ console.log(` </reviewers>`)
63
+ console.log(`</reviewers_result>`)
64
+ } else {
65
+ if (reviewers.length === 0) {
66
+ console.log('No reviewers')
67
+ } else {
68
+ for (const r of reviewers) {
69
+ console.log(formatReviewer(r))
70
+ }
71
+ }
72
+ }
73
+ }).pipe(
74
+ Effect.catchAll((error: ApiError | GitError | NoChangeIdError) =>
75
+ Effect.sync(() => {
76
+ const errorMessage =
77
+ error instanceof GitError || error instanceof NoChangeIdError || error instanceof Error
78
+ ? error.message
79
+ : String(error)
80
+
81
+ if (options.json) {
82
+ console.log(JSON.stringify({ status: 'error', error: errorMessage }, null, 2))
83
+ } else if (options.xml) {
84
+ console.log(`<?xml version="1.0" encoding="UTF-8"?>`)
85
+ console.log(`<reviewers_result>`)
86
+ console.log(` <status>error</status>`)
87
+ console.log(` <error><![CDATA[${sanitizeCDATA(errorMessage)}]]></error>`)
88
+ console.log(`</reviewers_result>`)
89
+ } else {
90
+ console.error(`✗ Error: ${errorMessage}`)
91
+ }
92
+ process.exit(1)
93
+ }),
94
+ ),
95
+ )
@@ -28,6 +28,8 @@ import { workspaceCommand } from './commands/workspace'
28
28
  import { sanitizeCDATA } from '@/utils/shell-safety'
29
29
  import { registerGroupCommands } from './register-group-commands'
30
30
  import { registerReviewerCommands } from './register-reviewer-commands'
31
+ import { filesCommand } from './commands/files'
32
+ import { reviewersCommand } from './commands/reviewers'
31
33
 
32
34
  // Helper function to output error in plain text, JSON, or XML format
33
35
  function outputError(
@@ -578,6 +580,44 @@ Note:
578
580
  }
579
581
  })
580
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
+
581
621
  // checkout command
582
622
  program
583
623
  .command('checkout <change-id>')
@@ -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>
@@ -0,0 +1,223 @@
1
+ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, mock } from 'bun:test'
2
+ import { Effect, Layer } from 'effect'
3
+ import { HttpResponse, http } from 'msw'
4
+ import { setupServer } from 'msw/node'
5
+ import { GerritApiServiceLive } from '@/api/gerrit'
6
+ import { filesCommand } from '@/cli/commands/files'
7
+ import { ConfigService } from '@/services/config'
8
+ import { createMockConfigService } from './helpers/config-mock'
9
+
10
+ const mockFilesResponse = {
11
+ '/COMMIT_MSG': { status: 'A' as const, lines_inserted: 10 },
12
+ 'src/foo.ts': { status: 'M' as const, lines_inserted: 5, lines_deleted: 2 },
13
+ 'src/bar.ts': { status: 'A' as const, lines_inserted: 20 },
14
+ 'src/old.ts': { status: 'D' as const, lines_deleted: 30 },
15
+ }
16
+
17
+ const server = setupServer(
18
+ http.get('*/a/accounts/self', ({ request }) => {
19
+ const auth = request.headers.get('Authorization')
20
+ if (!auth || !auth.startsWith('Basic ')) {
21
+ return HttpResponse.text('Unauthorized', { status: 401 })
22
+ }
23
+ return HttpResponse.json({ _account_id: 1000, name: 'Test User', email: 'test@example.com' })
24
+ }),
25
+ )
26
+
27
+ describe('files command', () => {
28
+ let mockConsoleLog: ReturnType<typeof mock>
29
+ let mockConsoleError: ReturnType<typeof mock>
30
+ let mockProcessExit: ReturnType<typeof mock>
31
+
32
+ beforeAll(() => {
33
+ server.listen({ onUnhandledRequest: 'bypass' })
34
+ })
35
+
36
+ afterAll(() => {
37
+ server.close()
38
+ })
39
+
40
+ beforeEach(() => {
41
+ mockConsoleLog = mock(() => {})
42
+ mockConsoleError = mock(() => {})
43
+ mockProcessExit = mock(() => {})
44
+ console.log = mockConsoleLog
45
+ console.error = mockConsoleError
46
+ process.exit = mockProcessExit as unknown as typeof process.exit
47
+ })
48
+
49
+ afterEach(() => {
50
+ server.resetHandlers()
51
+ })
52
+
53
+ it('should list changed files with plain output', async () => {
54
+ server.use(
55
+ http.get('*/a/changes/12345/revisions/current/files', () => {
56
+ return HttpResponse.text(`)]}'\n${JSON.stringify(mockFilesResponse)}`)
57
+ }),
58
+ )
59
+
60
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
61
+ const program = filesCommand('12345', {}).pipe(
62
+ Effect.provide(GerritApiServiceLive),
63
+ Effect.provide(mockConfigLayer),
64
+ )
65
+
66
+ await Effect.runPromise(program)
67
+
68
+ const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
69
+ expect(output).not.toContain('/COMMIT_MSG')
70
+ expect(output).toContain('M src/foo.ts')
71
+ expect(output).toContain('A src/bar.ts')
72
+ expect(output).toContain('D src/old.ts')
73
+ })
74
+
75
+ it('should output JSON format', async () => {
76
+ server.use(
77
+ http.get('*/a/changes/12345/revisions/current/files', () => {
78
+ return HttpResponse.text(`)]}'\n${JSON.stringify(mockFilesResponse)}`)
79
+ }),
80
+ )
81
+
82
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
83
+ const program = filesCommand('12345', { json: true }).pipe(
84
+ Effect.provide(GerritApiServiceLive),
85
+ Effect.provide(mockConfigLayer),
86
+ )
87
+
88
+ await Effect.runPromise(program)
89
+
90
+ const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
91
+ const parsed = JSON.parse(output) as { status: string; change_id: string; files: unknown[] }
92
+ expect(parsed.status).toBe('success')
93
+ expect(parsed.change_id).toBe('12345')
94
+ expect(parsed.files).toBeArray()
95
+ const paths = (parsed.files as Array<{ path: string }>).map((f) => f.path)
96
+ expect(paths).not.toContain('/COMMIT_MSG')
97
+ expect(paths).toContain('src/foo.ts')
98
+ })
99
+
100
+ it('should output XML format', async () => {
101
+ server.use(
102
+ http.get('*/a/changes/12345/revisions/current/files', () => {
103
+ return HttpResponse.text(`)]}'\n${JSON.stringify(mockFilesResponse)}`)
104
+ }),
105
+ )
106
+
107
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
108
+ const program = filesCommand('12345', { xml: true }).pipe(
109
+ Effect.provide(GerritApiServiceLive),
110
+ Effect.provide(mockConfigLayer),
111
+ )
112
+
113
+ await Effect.runPromise(program)
114
+
115
+ const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
116
+ expect(output).toContain('<?xml version="1.0" encoding="UTF-8"?>')
117
+ expect(output).toContain('<files_result>')
118
+ expect(output).toContain('<status>success</status>')
119
+ expect(output).toContain('<change_id><![CDATA[12345]]></change_id>')
120
+ expect(output).toContain('<path><![CDATA[src/foo.ts]]></path>')
121
+ expect(output).not.toContain('/COMMIT_MSG')
122
+ expect(output).toContain('</files_result>')
123
+ })
124
+
125
+ it('should handle empty files response (only magic files)', async () => {
126
+ server.use(
127
+ http.get('*/a/changes/12345/revisions/current/files', () => {
128
+ return HttpResponse.text(`)]}'\n${JSON.stringify({ '/COMMIT_MSG': { status: 'A' } })}`)
129
+ }),
130
+ )
131
+
132
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
133
+ const program = filesCommand('12345', {}).pipe(
134
+ Effect.provide(GerritApiServiceLive),
135
+ Effect.provide(mockConfigLayer),
136
+ )
137
+
138
+ await Effect.runPromise(program)
139
+
140
+ const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
141
+ expect(output).not.toContain('/COMMIT_MSG')
142
+ })
143
+
144
+ it('should exit 1 on API error', async () => {
145
+ server.use(
146
+ http.get('*/a/changes/99999/revisions/current/files', () => {
147
+ return HttpResponse.text('Not Found', { status: 404 })
148
+ }),
149
+ )
150
+
151
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
152
+ const program = filesCommand('99999', {}).pipe(
153
+ Effect.provide(GerritApiServiceLive),
154
+ Effect.provide(mockConfigLayer),
155
+ )
156
+
157
+ await Effect.runPromise(program)
158
+
159
+ const errorOutput = mockConsoleError.mock.calls.map((call) => call[0]).join('\n')
160
+ expect(errorOutput).toContain('Error:')
161
+ expect(mockProcessExit.mock.calls[0][0]).toBe(1)
162
+ })
163
+
164
+ it('should exit 1 and output XML on API error with --xml', async () => {
165
+ server.use(
166
+ http.get('*/a/changes/99999/revisions/current/files', () => {
167
+ return HttpResponse.text('Not Found', { status: 404 })
168
+ }),
169
+ )
170
+
171
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
172
+ const program = filesCommand('99999', { xml: true }).pipe(
173
+ Effect.provide(GerritApiServiceLive),
174
+ Effect.provide(mockConfigLayer),
175
+ )
176
+
177
+ await Effect.runPromise(program)
178
+
179
+ const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
180
+ expect(output).toContain('<files_result>')
181
+ expect(output).toContain('<status>error</status>')
182
+ expect(output).toContain('</files_result>')
183
+ expect(mockProcessExit.mock.calls[0][0]).toBe(1)
184
+ })
185
+
186
+ it('should exit 1 and output JSON on API error with --json', async () => {
187
+ server.use(
188
+ http.get('*/a/changes/99999/revisions/current/files', () => {
189
+ return HttpResponse.text('Not Found', { status: 404 })
190
+ }),
191
+ )
192
+
193
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
194
+ const program = filesCommand('99999', { json: true }).pipe(
195
+ Effect.provide(GerritApiServiceLive),
196
+ Effect.provide(mockConfigLayer),
197
+ )
198
+
199
+ await Effect.runPromise(program)
200
+
201
+ const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
202
+ const parsed = JSON.parse(output) as { status: string; error: string }
203
+ expect(parsed.status).toBe('error')
204
+ expect(typeof parsed.error).toBe('string')
205
+ expect(parsed.error.length).toBeGreaterThan(0)
206
+ expect(mockProcessExit.mock.calls[0][0]).toBe(1)
207
+ })
208
+
209
+ it('should exit 1 when no change-id and HEAD has no Change-Id', async () => {
210
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
211
+ const program = filesCommand(undefined, {}).pipe(
212
+ Effect.provide(GerritApiServiceLive),
213
+ Effect.provide(mockConfigLayer),
214
+ )
215
+
216
+ await Effect.runPromise(program)
217
+
218
+ const errorOutput = mockConsoleError.mock.calls.map((call) => call[0]).join('\n')
219
+ expect(errorOutput).toContain('Error:')
220
+ expect(errorOutput).toContain('No Change-ID found in HEAD commit')
221
+ expect(mockProcessExit.mock.calls[0][0]).toBe(1)
222
+ })
223
+ })
@@ -0,0 +1,259 @@
1
+ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, mock } from 'bun:test'
2
+ import { Effect, Layer } from 'effect'
3
+ import { HttpResponse, http } from 'msw'
4
+ import { setupServer } from 'msw/node'
5
+ import { GerritApiServiceLive } from '@/api/gerrit'
6
+ import { reviewersCommand } from '@/cli/commands/reviewers'
7
+ import { ConfigService } from '@/services/config'
8
+ import { createMockConfigService } from './helpers/config-mock'
9
+
10
+ const mockReviewersResponse = [
11
+ {
12
+ _account_id: 1001,
13
+ name: 'Alice Smith',
14
+ email: 'alice@example.com',
15
+ username: 'alice',
16
+ approvals: { 'Code-Review': '0' },
17
+ },
18
+ {
19
+ _account_id: 1002,
20
+ name: 'Bob Jones',
21
+ email: 'bob@example.com',
22
+ username: 'bob',
23
+ approvals: { 'Code-Review': '+1' },
24
+ },
25
+ ]
26
+
27
+ const server = setupServer(
28
+ http.get('*/a/accounts/self', ({ request }) => {
29
+ const auth = request.headers.get('Authorization')
30
+ if (!auth || !auth.startsWith('Basic ')) {
31
+ return HttpResponse.text('Unauthorized', { status: 401 })
32
+ }
33
+ return HttpResponse.json({ _account_id: 1000, name: 'Test User', email: 'test@example.com' })
34
+ }),
35
+ )
36
+
37
+ describe('reviewers command', () => {
38
+ let mockConsoleLog: ReturnType<typeof mock>
39
+ let mockConsoleError: ReturnType<typeof mock>
40
+ let mockProcessExit: ReturnType<typeof mock>
41
+
42
+ beforeAll(() => {
43
+ server.listen({ onUnhandledRequest: 'bypass' })
44
+ })
45
+
46
+ afterAll(() => {
47
+ server.close()
48
+ })
49
+
50
+ beforeEach(() => {
51
+ mockConsoleLog = mock(() => {})
52
+ mockConsoleError = mock(() => {})
53
+ mockProcessExit = mock(() => {})
54
+ console.log = mockConsoleLog
55
+ console.error = mockConsoleError
56
+ process.exit = mockProcessExit as unknown as typeof process.exit
57
+ })
58
+
59
+ afterEach(() => {
60
+ server.resetHandlers()
61
+ })
62
+
63
+ it('should list reviewers with plain output', async () => {
64
+ server.use(
65
+ http.get('*/a/changes/12345/reviewers', () => {
66
+ return HttpResponse.text(`)]}'\n${JSON.stringify(mockReviewersResponse)}`)
67
+ }),
68
+ )
69
+
70
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
71
+ const program = reviewersCommand('12345', {}).pipe(
72
+ Effect.provide(GerritApiServiceLive),
73
+ Effect.provide(mockConfigLayer),
74
+ )
75
+
76
+ await Effect.runPromise(program)
77
+
78
+ const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
79
+ expect(output).toContain('Alice Smith')
80
+ expect(output).toContain('alice@example.com')
81
+ expect(output).toContain('Bob Jones')
82
+ expect(output).toContain('bob@example.com')
83
+ })
84
+
85
+ it('should handle email-only reviewer (no _account_id, no name)', async () => {
86
+ const emailOnlyReviewer = [{ email: 'ext@external.com', approvals: {} }]
87
+ server.use(
88
+ http.get('*/a/changes/12345/reviewers', () => {
89
+ return HttpResponse.text(`)]}'\n${JSON.stringify(emailOnlyReviewer)}`)
90
+ }),
91
+ )
92
+
93
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
94
+ const program = reviewersCommand('12345', {}).pipe(
95
+ Effect.provide(GerritApiServiceLive),
96
+ Effect.provide(mockConfigLayer),
97
+ )
98
+
99
+ await Effect.runPromise(program)
100
+
101
+ const lines = mockConsoleLog.mock.calls.map((call) => call[0] as string)
102
+ expect(lines).toContain('ext@external.com')
103
+ // Must not produce "ext@external.com <ext@external.com>"
104
+ expect(lines.join('\n')).not.toContain('ext@external.com <ext@external.com>')
105
+ })
106
+
107
+ it('should output JSON format', async () => {
108
+ server.use(
109
+ http.get('*/a/changes/12345/reviewers', () => {
110
+ return HttpResponse.text(`)]}'\n${JSON.stringify(mockReviewersResponse)}`)
111
+ }),
112
+ )
113
+
114
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
115
+ const program = reviewersCommand('12345', { json: true }).pipe(
116
+ Effect.provide(GerritApiServiceLive),
117
+ Effect.provide(mockConfigLayer),
118
+ )
119
+
120
+ await Effect.runPromise(program)
121
+
122
+ const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
123
+ const parsed = JSON.parse(output) as {
124
+ status: string
125
+ change_id: string
126
+ reviewers: Array<{ account_id?: number; name?: string; email?: string }>
127
+ }
128
+ expect(parsed.status).toBe('success')
129
+ expect(parsed.change_id).toBe('12345')
130
+ expect(parsed.reviewers).toBeArray()
131
+ expect(parsed.reviewers.length).toBe(2)
132
+ expect(parsed.reviewers[0].name).toBe('Alice Smith')
133
+ expect(parsed.reviewers[1].email).toBe('bob@example.com')
134
+ })
135
+
136
+ it('should output XML format', async () => {
137
+ server.use(
138
+ http.get('*/a/changes/12345/reviewers', () => {
139
+ return HttpResponse.text(`)]}'\n${JSON.stringify(mockReviewersResponse)}`)
140
+ }),
141
+ )
142
+
143
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
144
+ const program = reviewersCommand('12345', { xml: true }).pipe(
145
+ Effect.provide(GerritApiServiceLive),
146
+ Effect.provide(mockConfigLayer),
147
+ )
148
+
149
+ await Effect.runPromise(program)
150
+
151
+ const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
152
+ expect(output).toContain('<?xml version="1.0" encoding="UTF-8"?>')
153
+ expect(output).toContain('<reviewers_result>')
154
+ expect(output).toContain('<status>success</status>')
155
+ expect(output).toContain('<change_id><![CDATA[12345]]></change_id>')
156
+ expect(output).toContain('<name><![CDATA[Alice Smith]]></name>')
157
+ expect(output).toContain('<email><![CDATA[bob@example.com]]></email>')
158
+ expect(output).toContain('</reviewers_result>')
159
+ })
160
+
161
+ it('should handle empty reviewers list', async () => {
162
+ server.use(
163
+ http.get('*/a/changes/12345/reviewers', () => {
164
+ return HttpResponse.text(`)]}'\n${JSON.stringify([])}`)
165
+ }),
166
+ )
167
+
168
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
169
+ const program = reviewersCommand('12345', {}).pipe(
170
+ Effect.provide(GerritApiServiceLive),
171
+ Effect.provide(mockConfigLayer),
172
+ )
173
+
174
+ await Effect.runPromise(program)
175
+
176
+ const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
177
+ expect(output).toContain('No reviewers')
178
+ })
179
+
180
+ it('should exit 1 on API error', async () => {
181
+ server.use(
182
+ http.get('*/a/changes/99999/reviewers', () => {
183
+ return HttpResponse.text('Not Found', { status: 404 })
184
+ }),
185
+ )
186
+
187
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
188
+ const program = reviewersCommand('99999', {}).pipe(
189
+ Effect.provide(GerritApiServiceLive),
190
+ Effect.provide(mockConfigLayer),
191
+ )
192
+
193
+ await Effect.runPromise(program)
194
+
195
+ const errorOutput = mockConsoleError.mock.calls.map((call) => call[0]).join('\n')
196
+ expect(errorOutput).toContain('Error:')
197
+ expect(mockProcessExit.mock.calls[0][0]).toBe(1)
198
+ })
199
+
200
+ it('should exit 1 and output XML on API error with --xml', async () => {
201
+ server.use(
202
+ http.get('*/a/changes/99999/reviewers', () => {
203
+ return HttpResponse.text('Forbidden', { status: 403 })
204
+ }),
205
+ )
206
+
207
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
208
+ const program = reviewersCommand('99999', { xml: true }).pipe(
209
+ Effect.provide(GerritApiServiceLive),
210
+ Effect.provide(mockConfigLayer),
211
+ )
212
+
213
+ await Effect.runPromise(program)
214
+
215
+ const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
216
+ expect(output).toContain('<reviewers_result>')
217
+ expect(output).toContain('<status>error</status>')
218
+ expect(output).toContain('</reviewers_result>')
219
+ expect(mockProcessExit.mock.calls[0][0]).toBe(1)
220
+ })
221
+
222
+ it('should exit 1 and output JSON on API error with --json', async () => {
223
+ server.use(
224
+ http.get('*/a/changes/99999/reviewers', () => {
225
+ return HttpResponse.text('Not Found', { status: 404 })
226
+ }),
227
+ )
228
+
229
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
230
+ const program = reviewersCommand('99999', { json: true }).pipe(
231
+ Effect.provide(GerritApiServiceLive),
232
+ Effect.provide(mockConfigLayer),
233
+ )
234
+
235
+ await Effect.runPromise(program)
236
+
237
+ const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
238
+ const parsed = JSON.parse(output) as { status: string; error: string }
239
+ expect(parsed.status).toBe('error')
240
+ expect(typeof parsed.error).toBe('string')
241
+ expect(parsed.error.length).toBeGreaterThan(0)
242
+ expect(mockProcessExit.mock.calls[0][0]).toBe(1)
243
+ })
244
+
245
+ it('should exit 1 when no change-id and HEAD has no Change-Id', async () => {
246
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
247
+ const program = reviewersCommand(undefined, {}).pipe(
248
+ Effect.provide(GerritApiServiceLive),
249
+ Effect.provide(mockConfigLayer),
250
+ )
251
+
252
+ await Effect.runPromise(program)
253
+
254
+ const errorOutput = mockConsoleError.mock.calls.map((call) => call[0]).join('\n')
255
+ expect(errorOutput).toContain('Error:')
256
+ expect(errorOutput).toContain('No Change-ID found in HEAD commit')
257
+ expect(mockProcessExit.mock.calls[0][0]).toBe(1)
258
+ })
259
+ })