@aaronshaf/ger 1.2.10 → 2.0.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 (180) hide show
  1. package/.ast-grep/rules/no-as-casting.yml +13 -0
  2. package/.claude-plugin/plugin.json +22 -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 +83 -0
  6. package/.github/workflows/claude.yml +50 -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 +105 -0
  16. package/DEVELOPMENT.md +361 -0
  17. package/EXAMPLES.md +457 -0
  18. package/README.md +831 -16
  19. package/bin/ger +3 -18
  20. package/biome.json +36 -0
  21. package/bun.lock +678 -0
  22. package/bunfig.toml +8 -0
  23. package/docs/adr/0001-use-effect-for-side-effects.md +65 -0
  24. package/docs/adr/0002-use-bun-runtime.md +64 -0
  25. package/docs/adr/0003-store-credentials-in-home-directory.md +75 -0
  26. package/docs/adr/0004-use-commander-for-cli.md +76 -0
  27. package/docs/adr/0005-use-effect-schema-for-validation.md +93 -0
  28. package/docs/adr/0006-use-msw-for-api-mocking.md +89 -0
  29. package/docs/adr/0007-git-hooks-for-quality.md +94 -0
  30. package/docs/adr/0008-no-as-typecasting.md +83 -0
  31. package/docs/adr/0009-file-size-limits.md +82 -0
  32. package/docs/adr/0010-llm-friendly-xml-output.md +93 -0
  33. package/docs/adr/0011-ai-tool-strategy-pattern.md +102 -0
  34. package/docs/adr/0012-build-status-message-parsing.md +94 -0
  35. package/docs/adr/0013-git-subprocess-integration.md +98 -0
  36. package/docs/adr/0014-group-management-support.md +95 -0
  37. package/docs/adr/0015-batch-comment-processing.md +111 -0
  38. package/docs/adr/0016-flexible-change-identifiers.md +94 -0
  39. package/docs/adr/0017-git-worktree-support.md +102 -0
  40. package/docs/adr/0018-auto-install-commit-hook.md +103 -0
  41. package/docs/adr/0019-sdk-package-exports.md +95 -0
  42. package/docs/adr/0020-code-coverage-enforcement.md +105 -0
  43. package/docs/adr/0021-typescript-isolated-declarations.md +83 -0
  44. package/docs/adr/0022-biome-oxlint-tooling.md +124 -0
  45. package/docs/adr/README.md +30 -0
  46. package/docs/prd/README.md +12 -0
  47. package/docs/prd/architecture.md +325 -0
  48. package/docs/prd/commands.md +425 -0
  49. package/docs/prd/data-model.md +349 -0
  50. package/docs/prd/overview.md +124 -0
  51. package/index.ts +219 -0
  52. package/oxlint.json +24 -0
  53. package/package.json +82 -15
  54. package/scripts/check-coverage.ts +69 -0
  55. package/scripts/check-file-size.ts +38 -0
  56. package/scripts/fix-test-mocks.ts +55 -0
  57. package/skills/gerrit-workflow/SKILL.md +247 -0
  58. package/skills/gerrit-workflow/examples.md +572 -0
  59. package/skills/gerrit-workflow/reference.md +728 -0
  60. package/src/api/gerrit.ts +696 -0
  61. package/src/cli/commands/abandon.ts +65 -0
  62. package/src/cli/commands/add-reviewer.ts +156 -0
  63. package/src/cli/commands/build-status.ts +282 -0
  64. package/src/cli/commands/checkout.ts +422 -0
  65. package/src/cli/commands/comment.ts +460 -0
  66. package/src/cli/commands/comments.ts +85 -0
  67. package/src/cli/commands/diff.ts +71 -0
  68. package/src/cli/commands/extract-url.ts +266 -0
  69. package/src/cli/commands/groups-members.ts +104 -0
  70. package/src/cli/commands/groups-show.ts +169 -0
  71. package/src/cli/commands/groups.ts +137 -0
  72. package/src/cli/commands/incoming.ts +226 -0
  73. package/src/cli/commands/init.ts +164 -0
  74. package/src/cli/commands/mine.ts +115 -0
  75. package/src/cli/commands/open.ts +57 -0
  76. package/src/cli/commands/projects.ts +68 -0
  77. package/src/cli/commands/push.ts +430 -0
  78. package/src/cli/commands/rebase.ts +52 -0
  79. package/src/cli/commands/remove-reviewer.ts +123 -0
  80. package/src/cli/commands/restore.ts +50 -0
  81. package/src/cli/commands/review.ts +486 -0
  82. package/src/cli/commands/search.ts +162 -0
  83. package/src/cli/commands/setup.ts +286 -0
  84. package/src/cli/commands/show.ts +491 -0
  85. package/src/cli/commands/status.ts +35 -0
  86. package/src/cli/commands/submit.ts +108 -0
  87. package/src/cli/commands/vote.ts +119 -0
  88. package/src/cli/commands/workspace.ts +200 -0
  89. package/src/cli/index.ts +53 -0
  90. package/src/cli/register-commands.ts +659 -0
  91. package/src/cli/register-group-commands.ts +88 -0
  92. package/src/cli/register-reviewer-commands.ts +97 -0
  93. package/src/prompts/default-review.md +86 -0
  94. package/src/prompts/system-inline-review.md +135 -0
  95. package/src/prompts/system-overall-review.md +206 -0
  96. package/src/schemas/config.test.ts +245 -0
  97. package/src/schemas/config.ts +84 -0
  98. package/src/schemas/gerrit.ts +681 -0
  99. package/src/services/commit-hook.ts +314 -0
  100. package/src/services/config.test.ts +150 -0
  101. package/src/services/config.ts +250 -0
  102. package/src/services/git-worktree.ts +342 -0
  103. package/src/services/review-strategy.ts +292 -0
  104. package/src/test-utils/mock-generator.ts +138 -0
  105. package/src/utils/change-id.test.ts +98 -0
  106. package/src/utils/change-id.ts +63 -0
  107. package/src/utils/comment-formatters.ts +153 -0
  108. package/src/utils/diff-context.ts +103 -0
  109. package/src/utils/diff-formatters.ts +141 -0
  110. package/src/utils/formatters.ts +85 -0
  111. package/src/utils/git-commit.test.ts +277 -0
  112. package/src/utils/git-commit.ts +122 -0
  113. package/src/utils/index.ts +55 -0
  114. package/src/utils/message-filters.ts +26 -0
  115. package/src/utils/review-formatters.ts +89 -0
  116. package/src/utils/review-prompt-builder.ts +110 -0
  117. package/src/utils/shell-safety.ts +117 -0
  118. package/src/utils/status-indicators.ts +100 -0
  119. package/src/utils/url-parser.test.ts +271 -0
  120. package/src/utils/url-parser.ts +118 -0
  121. package/tests/abandon.test.ts +230 -0
  122. package/tests/add-reviewer.test.ts +579 -0
  123. package/tests/build-status-watch.test.ts +344 -0
  124. package/tests/build-status.test.ts +789 -0
  125. package/tests/change-id-formats.test.ts +268 -0
  126. package/tests/checkout/integration.test.ts +653 -0
  127. package/tests/checkout/parse-input.test.ts +55 -0
  128. package/tests/checkout/validation.test.ts +178 -0
  129. package/tests/comment-batch-advanced.test.ts +431 -0
  130. package/tests/comment-gerrit-api-compliance.test.ts +414 -0
  131. package/tests/comment.test.ts +708 -0
  132. package/tests/comments.test.ts +323 -0
  133. package/tests/config-service-simple.test.ts +100 -0
  134. package/tests/diff.test.ts +419 -0
  135. package/tests/extract-url.test.ts +517 -0
  136. package/tests/groups-members.test.ts +256 -0
  137. package/tests/groups-show.test.ts +323 -0
  138. package/tests/groups.test.ts +334 -0
  139. package/tests/helpers/build-status-test-setup.ts +83 -0
  140. package/tests/helpers/config-mock.ts +27 -0
  141. package/tests/incoming.test.ts +357 -0
  142. package/tests/init.test.ts +70 -0
  143. package/tests/integration/commit-hook.test.ts +246 -0
  144. package/tests/interactive-incoming.test.ts +173 -0
  145. package/tests/mine.test.ts +285 -0
  146. package/tests/mocks/msw-handlers.ts +80 -0
  147. package/tests/open.test.ts +233 -0
  148. package/tests/projects.test.ts +259 -0
  149. package/tests/rebase.test.ts +271 -0
  150. package/tests/remove-reviewer.test.ts +357 -0
  151. package/tests/restore.test.ts +237 -0
  152. package/tests/review.test.ts +135 -0
  153. package/tests/search.test.ts +712 -0
  154. package/tests/setup.test.ts +63 -0
  155. package/tests/show-auto-detect.test.ts +324 -0
  156. package/tests/show.test.ts +813 -0
  157. package/tests/status.test.ts +145 -0
  158. package/tests/submit.test.ts +316 -0
  159. package/tests/unit/commands/push.test.ts +194 -0
  160. package/tests/unit/git-branch-detection.test.ts +82 -0
  161. package/tests/unit/git-worktree.test.ts +55 -0
  162. package/tests/unit/patterns/push-patterns.test.ts +148 -0
  163. package/tests/unit/schemas/gerrit.test.ts +85 -0
  164. package/tests/unit/services/commit-hook.test.ts +132 -0
  165. package/tests/unit/services/review-strategy.test.ts +349 -0
  166. package/tests/unit/test-utils/mock-generator.test.ts +154 -0
  167. package/tests/unit/utils/comment-formatters.test.ts +415 -0
  168. package/tests/unit/utils/diff-context.test.ts +171 -0
  169. package/tests/unit/utils/diff-formatters.test.ts +165 -0
  170. package/tests/unit/utils/formatters.test.ts +411 -0
  171. package/tests/unit/utils/message-filters.test.ts +227 -0
  172. package/tests/unit/utils/shell-safety.test.ts +230 -0
  173. package/tests/unit/utils/status-indicators.test.ts +137 -0
  174. package/tests/vote.test.ts +317 -0
  175. package/tests/workspace.test.ts +295 -0
  176. package/tsconfig.json +36 -5
  177. package/src/commands/branch.ts +0 -180
  178. package/src/ger.ts +0 -22
  179. package/src/types.d.ts +0 -35
  180. package/src/utils.ts +0 -130
@@ -0,0 +1,55 @@
1
+ import { describe, test, expect } from 'bun:test'
2
+ import { parseChangeInput } from '@/cli/commands/checkout'
3
+
4
+ /**
5
+ * Tests for parseChangeInput function
6
+ *
7
+ * Validates parsing of various input formats:
8
+ * - Plain change numbers
9
+ * - Change numbers with patchsets (12345/3)
10
+ * - Change-IDs (Iabc123...)
11
+ * - URLs with/without patchsets
12
+ */
13
+
14
+ describe('parseChangeInput', () => {
15
+ test('should parse plain change number', () => {
16
+ const result = parseChangeInput('12345')
17
+ expect(result).toEqual({ changeId: '12345' })
18
+ })
19
+
20
+ test('should parse change number with patchset', () => {
21
+ const result = parseChangeInput('12345/3')
22
+ expect(result).toEqual({ changeId: '12345', patchset: 3 })
23
+ })
24
+
25
+ test('should parse Change-ID', () => {
26
+ const result = parseChangeInput('Iabc123def456')
27
+ expect(result).toEqual({ changeId: 'Iabc123def456' })
28
+ })
29
+
30
+ test('should parse URL with change number', () => {
31
+ const result = parseChangeInput('https://gerrit.example.com/c/project/+/12345')
32
+ expect(result).toEqual({ changeId: '12345' })
33
+ })
34
+
35
+ test('should parse URL with change number and patchset', () => {
36
+ const result = parseChangeInput('https://gerrit.example.com/c/project/+/12345/3')
37
+ expect(result).toEqual({ changeId: '12345', patchset: 3 })
38
+ })
39
+
40
+ test('should parse URL with hash format', () => {
41
+ const result = parseChangeInput('https://gerrit.example.com/#/c/project/+/12345')
42
+ expect(result).toEqual({ changeId: '12345' })
43
+ })
44
+
45
+ test('should handle whitespace', () => {
46
+ const result = parseChangeInput(' 12345/2 ')
47
+ expect(result).toEqual({ changeId: '12345', patchset: 2 })
48
+ })
49
+
50
+ test('should handle invalid patchset gracefully', () => {
51
+ const result = parseChangeInput('12345/abc')
52
+ expect(result.changeId).toBe('12345')
53
+ expect(result.patchset).toBeUndefined()
54
+ })
55
+ })
@@ -0,0 +1,178 @@
1
+ import { describe, test, expect, beforeAll, afterAll, afterEach, mock, spyOn } from 'bun:test'
2
+ import { HttpResponse, http } from 'msw'
3
+ import { setupServer } from 'msw/node'
4
+ import { Effect, Layer } from 'effect'
5
+ import { checkoutCommand, InvalidInputError } from '@/cli/commands/checkout'
6
+ import { GerritApiServiceLive } from '@/api/gerrit'
7
+ import { ConfigService } from '@/services/config'
8
+ import { createMockConfigService } from '../helpers/config-mock'
9
+ import type { ChangeInfo, RevisionInfo } from '@/schemas/gerrit'
10
+ import * as childProcess from 'node:child_process'
11
+
12
+ /**
13
+ * Input validation and security tests
14
+ *
15
+ * Tests cover:
16
+ * - Shell injection prevention
17
+ * - Invalid input format rejection
18
+ * - Malicious remote/ref validation
19
+ */
20
+
21
+ const server = setupServer(
22
+ http.get('*/a/accounts/self', ({ request }) => {
23
+ const auth = request.headers.get('Authorization')
24
+ if (!auth || !auth.startsWith('Basic ')) {
25
+ return HttpResponse.text('Unauthorized', { status: 401 })
26
+ }
27
+ return HttpResponse.json({
28
+ _account_id: 1000,
29
+ name: 'Test User',
30
+ email: 'test@example.com',
31
+ })
32
+ }),
33
+ )
34
+
35
+ describe('Checkout Command - Input Validation', () => {
36
+ let mockConsoleLog: ReturnType<typeof mock>
37
+ let mockConsoleError: ReturnType<typeof mock>
38
+ let mockExecSync: ReturnType<typeof spyOn>
39
+
40
+ beforeAll(() => {
41
+ server.listen({ onUnhandledRequest: 'bypass' })
42
+ mockConsoleLog = mock(() => {})
43
+ mockConsoleError = mock(() => {})
44
+ console.log = mockConsoleLog
45
+ console.error = mockConsoleError
46
+ })
47
+
48
+ afterAll(() => {
49
+ server.close()
50
+ })
51
+
52
+ afterEach(() => {
53
+ server.resetHandlers()
54
+ mockConsoleLog.mockClear()
55
+ mockConsoleError.mockClear()
56
+ mockExecSync?.mockRestore()
57
+ })
58
+
59
+ test('should reject malicious remote name with shell injection', async () => {
60
+ mockExecSync = spyOn(childProcess, 'execSync').mockImplementation(((
61
+ command: string,
62
+ _options?: unknown,
63
+ ) => {
64
+ if (command === 'git rev-parse --git-dir') return Buffer.from('.git\n')
65
+ if (command === 'git symbolic-ref --short HEAD') return Buffer.from('main\n')
66
+ if (command === 'git remote -v') {
67
+ return Buffer.from('origin\thttps://test.gerrit.com/repo.git\t(push)\n')
68
+ }
69
+ return Buffer.from('')
70
+ }) as typeof childProcess.execSync)
71
+
72
+ const mockChange: ChangeInfo = {
73
+ id: 'test-project~main~Iabc123',
74
+ _number: 12345,
75
+ project: 'test-project',
76
+ branch: 'main',
77
+ change_id: 'Iabc123',
78
+ subject: 'Test change',
79
+ status: 'NEW',
80
+ created: '2024-01-15 10:00:00.000000000',
81
+ updated: '2024-01-15 10:00:00.000000000',
82
+ }
83
+
84
+ const mockRevision: RevisionInfo = {
85
+ _number: 1,
86
+ ref: 'refs/changes/45/12345/1',
87
+ created: '2024-01-15 10:00:00.000000000',
88
+ uploader: {
89
+ _account_id: 1000,
90
+ name: 'Test User',
91
+ email: 'test@example.com',
92
+ },
93
+ }
94
+
95
+ server.use(
96
+ http.get('*/a/changes/12345', () => {
97
+ return HttpResponse.text(`)]}'\n${JSON.stringify(mockChange)}`)
98
+ }),
99
+ http.get('*/a/changes/12345/revisions/current', () => {
100
+ return HttpResponse.text(`)]}'\n${JSON.stringify(mockRevision)}`)
101
+ }),
102
+ )
103
+
104
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
105
+
106
+ // Try to inject shell command in remote option
107
+ const program = checkoutCommand('12345', { remote: 'origin; rm -rf /' }).pipe(
108
+ Effect.provide(GerritApiServiceLive),
109
+ Effect.provide(mockConfigLayer),
110
+ )
111
+
112
+ const result = await Effect.runPromise(program.pipe(Effect.either))
113
+
114
+ // Should fail with InvalidInputError
115
+ expect(result._tag).toBe('Left')
116
+ if (result._tag === 'Left') {
117
+ expect(result.left).toBeInstanceOf(InvalidInputError)
118
+ }
119
+ })
120
+
121
+ test('should reject malicious ref with invalid format', async () => {
122
+ mockExecSync = spyOn(childProcess, 'execSync').mockImplementation(((
123
+ command: string,
124
+ _options?: unknown,
125
+ ) => {
126
+ if (command === 'git rev-parse --git-dir') return Buffer.from('.git\n')
127
+ return Buffer.from('')
128
+ }) as typeof childProcess.execSync)
129
+
130
+ const mockChange: ChangeInfo = {
131
+ id: 'test-project~main~Iabc123',
132
+ _number: 12345,
133
+ project: 'test-project',
134
+ branch: 'main',
135
+ change_id: 'Iabc123',
136
+ subject: 'Test change',
137
+ status: 'NEW',
138
+ created: '2024-01-15 10:00:00.000000000',
139
+ updated: '2024-01-15 10:00:00.000000000',
140
+ }
141
+
142
+ // Malicious ref that doesn't match Gerrit format
143
+ const mockRevision: RevisionInfo = {
144
+ _number: 1,
145
+ ref: '$(malicious command)',
146
+ created: '2024-01-15 10:00:00.000000000',
147
+ uploader: {
148
+ _account_id: 1000,
149
+ name: 'Test User',
150
+ email: 'test@example.com',
151
+ },
152
+ }
153
+
154
+ server.use(
155
+ http.get('*/a/changes/12345', () => {
156
+ return HttpResponse.text(`)]}'\n${JSON.stringify(mockChange)}`)
157
+ }),
158
+ http.get('*/a/changes/12345/revisions/current', () => {
159
+ return HttpResponse.text(`)]}'\n${JSON.stringify(mockRevision)}`)
160
+ }),
161
+ )
162
+
163
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
164
+
165
+ const program = checkoutCommand('12345', {}).pipe(
166
+ Effect.provide(GerritApiServiceLive),
167
+ Effect.provide(mockConfigLayer),
168
+ )
169
+
170
+ const result = await Effect.runPromise(program.pipe(Effect.either))
171
+
172
+ // Should fail with InvalidInputError due to invalid ref format
173
+ expect(result._tag).toBe('Left')
174
+ if (result._tag === 'Left') {
175
+ expect(result.left).toBeInstanceOf(InvalidInputError)
176
+ }
177
+ })
178
+ })
@@ -0,0 +1,431 @@
1
+ import { test, expect, describe, beforeAll, afterEach, afterAll } from 'bun:test'
2
+ import { http, HttpResponse } from 'msw'
3
+ import { setupServer } from 'msw/node'
4
+ import { Effect, Layer } from 'effect'
5
+ import { ConfigService } from '@/services/config'
6
+ import { GerritApiServiceLive } from '@/api/gerrit'
7
+ import { commentCommand } from '@/cli/commands/comment'
8
+ import { EventEmitter } from 'node:events'
9
+
10
+ import { createMockConfigService } from './helpers/config-mock'
11
+ // Create a mock process.stdin for testing
12
+ class MockProcessStdin extends EventEmitter {
13
+ isTTY = false
14
+ readable = true
15
+
16
+ emit(event: string, data?: any): boolean {
17
+ if (event === 'data') {
18
+ super.emit('data', Buffer.from(data))
19
+ // Automatically emit 'end' after data
20
+ setTimeout(() => super.emit('end'), 0)
21
+ return true
22
+ }
23
+ return super.emit(event, data)
24
+ }
25
+ }
26
+
27
+ const server = setupServer()
28
+
29
+ beforeAll(() => server.listen())
30
+ afterEach(() => server.resetHandlers())
31
+ afterAll(() => server.close())
32
+
33
+ describe('comment command - advanced batch features', () => {
34
+ const mockProcessStdin = new MockProcessStdin()
35
+
36
+ test('should handle batch comments with side parameter', async () => {
37
+ const originalStdin = process.stdin
38
+ Object.defineProperty(process, 'stdin', {
39
+ value: mockProcessStdin,
40
+ configurable: true,
41
+ })
42
+
43
+ server.use(
44
+ http.get('*/a/changes/:changeId', () => {
45
+ return HttpResponse.text(`)]}'\n{
46
+ "id": "test-project~main~I123abc",
47
+ "_number": 12345,
48
+ "project": "test-project",
49
+ "branch": "main",
50
+ "change_id": "I123abc",
51
+ "subject": "Test change",
52
+ "status": "NEW",
53
+ "created": "2024-01-15 10:00:00.000000000",
54
+ "updated": "2024-01-15 10:00:00.000000000"
55
+ }`)
56
+ }),
57
+ http.post('*/a/changes/:changeId/revisions/current/review', async ({ request }) => {
58
+ const body = (await request.json()) as {
59
+ message?: string
60
+ comments?: Record<string, unknown[]>
61
+ }
62
+ expect(body.comments).toBeDefined()
63
+
64
+ const fileComments = body.comments?.['src/main.js'] as Array<{
65
+ line?: number
66
+ side?: string
67
+ message: string
68
+ }>
69
+
70
+ expect(fileComments?.length).toBe(2)
71
+ expect(fileComments?.[0]).toMatchObject({
72
+ line: 10,
73
+ side: 'PARENT',
74
+ message: 'Why was this removed?',
75
+ })
76
+ expect(fileComments?.[1]).toMatchObject({
77
+ line: 10,
78
+ side: 'REVISION',
79
+ message: 'Good improvement',
80
+ })
81
+
82
+ return HttpResponse.json({})
83
+ }),
84
+ )
85
+
86
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
87
+
88
+ const program = commentCommand('12345', { batch: true }).pipe(
89
+ Effect.provide(GerritApiServiceLive),
90
+ Effect.provide(mockConfigLayer),
91
+ )
92
+
93
+ // Simulate stdin data with side parameter
94
+ setTimeout(() => {
95
+ mockProcessStdin.emit(
96
+ 'data',
97
+ JSON.stringify([
98
+ { file: 'src/main.js', line: 10, message: 'Why was this removed?', side: 'PARENT' },
99
+ { file: 'src/main.js', line: 10, message: 'Good improvement', side: 'REVISION' },
100
+ ]),
101
+ )
102
+ }, 10)
103
+
104
+ await Effect.runPromise(program)
105
+
106
+ // Restore process.stdin
107
+ Object.defineProperty(process, 'stdin', {
108
+ value: originalStdin,
109
+ configurable: true,
110
+ })
111
+ })
112
+
113
+ test('should handle batch comments with range parameter', async () => {
114
+ const originalStdin = process.stdin
115
+ Object.defineProperty(process, 'stdin', {
116
+ value: mockProcessStdin,
117
+ configurable: true,
118
+ })
119
+
120
+ server.use(
121
+ http.get('*/a/changes/:changeId', () => {
122
+ return HttpResponse.text(`)]}'\n{
123
+ "id": "test-project~main~I123abc",
124
+ "_number": 12345,
125
+ "project": "test-project",
126
+ "branch": "main",
127
+ "change_id": "I123abc",
128
+ "subject": "Test change",
129
+ "status": "NEW"
130
+ }`)
131
+ }),
132
+ http.post('*/a/changes/:changeId/revisions/current/review', async ({ request }) => {
133
+ const body = (await request.json()) as {
134
+ comments?: Record<string, unknown[]>
135
+ }
136
+
137
+ const fileComments = body.comments?.['src/Calculator.java'] as Array<{
138
+ range?: {
139
+ start_line: number
140
+ end_line: number
141
+ start_character?: number
142
+ end_character?: number
143
+ }
144
+ message: string
145
+ }>
146
+
147
+ expect(fileComments?.length).toBe(3)
148
+
149
+ // Multi-line range comment
150
+ expect(fileComments?.[0]).toMatchObject({
151
+ range: {
152
+ start_line: 50,
153
+ end_line: 55,
154
+ },
155
+ message: 'This block needs refactoring',
156
+ })
157
+
158
+ // Character-specific range
159
+ expect(fileComments?.[1]).toMatchObject({
160
+ range: {
161
+ start_line: 10,
162
+ start_character: 8,
163
+ end_line: 10,
164
+ end_character: 25,
165
+ },
166
+ message: 'Variable name is confusing',
167
+ })
168
+
169
+ // Mixed with regular line comment
170
+ expect(fileComments?.[2]).toMatchObject({
171
+ line: 42,
172
+ message: 'Add null check here',
173
+ })
174
+
175
+ return HttpResponse.json({})
176
+ }),
177
+ )
178
+
179
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
180
+
181
+ const program = commentCommand('12345', { batch: true }).pipe(
182
+ Effect.provide(GerritApiServiceLive),
183
+ Effect.provide(mockConfigLayer),
184
+ )
185
+
186
+ // Simulate stdin data with range parameter
187
+ setTimeout(() => {
188
+ mockProcessStdin.emit(
189
+ 'data',
190
+ JSON.stringify([
191
+ {
192
+ file: 'src/Calculator.java',
193
+ range: { start_line: 50, end_line: 55 },
194
+ message: 'This block needs refactoring',
195
+ },
196
+ {
197
+ file: 'src/Calculator.java',
198
+ range: { start_line: 10, start_character: 8, end_line: 10, end_character: 25 },
199
+ message: 'Variable name is confusing',
200
+ },
201
+ {
202
+ file: 'src/Calculator.java',
203
+ line: 42,
204
+ message: 'Add null check here',
205
+ },
206
+ ]),
207
+ )
208
+ }, 10)
209
+
210
+ await Effect.runPromise(program)
211
+
212
+ // Restore process.stdin
213
+ Object.defineProperty(process, 'stdin', {
214
+ value: originalStdin,
215
+ configurable: true,
216
+ })
217
+ })
218
+
219
+ test('should handle batch comments with both side and range', async () => {
220
+ const originalStdin = process.stdin
221
+ Object.defineProperty(process, 'stdin', {
222
+ value: mockProcessStdin,
223
+ configurable: true,
224
+ })
225
+
226
+ server.use(
227
+ http.get('*/a/changes/:changeId', () => {
228
+ return HttpResponse.text(`)]}'\n{
229
+ "id": "test-project~main~I123abc",
230
+ "_number": 12345,
231
+ "project": "test-project",
232
+ "branch": "main",
233
+ "change_id": "I123abc",
234
+ "subject": "Test change",
235
+ "status": "NEW"
236
+ }`)
237
+ }),
238
+ http.post('*/a/changes/:changeId/revisions/current/review', async ({ request }) => {
239
+ const body = (await request.json()) as {
240
+ comments?: Record<string, unknown[]>
241
+ }
242
+
243
+ const fileComments = body.comments?.['src/Service.java'] as Array<{
244
+ range?: {
245
+ start_line: number
246
+ end_line: number
247
+ }
248
+ side?: string
249
+ message: string
250
+ unresolved?: boolean
251
+ }>
252
+
253
+ expect(fileComments?.length).toBe(2)
254
+
255
+ // Range comment on PARENT side
256
+ expect(fileComments?.[0]).toMatchObject({
257
+ range: {
258
+ start_line: 20,
259
+ end_line: 35,
260
+ },
261
+ side: 'PARENT',
262
+ message: 'Why was this error handling removed?',
263
+ unresolved: true,
264
+ })
265
+
266
+ // Range comment on REVISION side
267
+ expect(fileComments?.[1]).toMatchObject({
268
+ range: {
269
+ start_line: 20,
270
+ end_line: 35,
271
+ },
272
+ side: 'REVISION',
273
+ message: 'New error handling looks good, but consider extracting',
274
+ })
275
+
276
+ return HttpResponse.json({})
277
+ }),
278
+ )
279
+
280
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
281
+
282
+ const program = commentCommand('12345', { batch: true }).pipe(
283
+ Effect.provide(GerritApiServiceLive),
284
+ Effect.provide(mockConfigLayer),
285
+ )
286
+
287
+ // Simulate stdin data with both range and side
288
+ setTimeout(() => {
289
+ mockProcessStdin.emit(
290
+ 'data',
291
+ JSON.stringify([
292
+ {
293
+ file: 'src/Service.java',
294
+ range: { start_line: 20, end_line: 35 },
295
+ side: 'PARENT',
296
+ message: 'Why was this error handling removed?',
297
+ unresolved: true,
298
+ },
299
+ {
300
+ file: 'src/Service.java',
301
+ range: { start_line: 20, end_line: 35 },
302
+ side: 'REVISION',
303
+ message: 'New error handling looks good, but consider extracting',
304
+ },
305
+ ]),
306
+ )
307
+ }, 10)
308
+
309
+ await Effect.runPromise(program)
310
+
311
+ // Restore process.stdin
312
+ Object.defineProperty(process, 'stdin', {
313
+ value: originalStdin,
314
+ configurable: true,
315
+ })
316
+ })
317
+
318
+ test('should validate side parameter values', async () => {
319
+ const originalStdin = process.stdin
320
+ Object.defineProperty(process, 'stdin', {
321
+ value: mockProcessStdin,
322
+ configurable: true,
323
+ })
324
+
325
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
326
+
327
+ const program = commentCommand('12345', { batch: true }).pipe(
328
+ Effect.provide(GerritApiServiceLive),
329
+ Effect.provide(mockConfigLayer),
330
+ )
331
+
332
+ // Simulate invalid side value
333
+ setTimeout(() => {
334
+ mockProcessStdin.emit(
335
+ 'data',
336
+ JSON.stringify([
337
+ {
338
+ file: 'src/main.js',
339
+ line: 10,
340
+ message: 'Test',
341
+ side: 'INVALID', // Invalid side value
342
+ },
343
+ ]),
344
+ )
345
+ }, 10)
346
+
347
+ await expect(Effect.runPromise(program)).rejects.toThrow('Invalid batch input format')
348
+
349
+ // Restore process.stdin
350
+ Object.defineProperty(process, 'stdin', {
351
+ value: originalStdin,
352
+ configurable: true,
353
+ })
354
+ })
355
+
356
+ test('should require either line or range but not both', async () => {
357
+ const originalStdin = process.stdin
358
+ Object.defineProperty(process, 'stdin', {
359
+ value: mockProcessStdin,
360
+ configurable: true,
361
+ })
362
+
363
+ server.use(
364
+ http.get('*/a/changes/:changeId', () => {
365
+ return HttpResponse.text(`)]}'\n{
366
+ "id": "test-project~main~I123abc",
367
+ "_number": 12345,
368
+ "project": "test-project",
369
+ "branch": "main",
370
+ "change_id": "I123abc",
371
+ "subject": "Test change",
372
+ "status": "NEW"
373
+ }`)
374
+ }),
375
+ http.post('*/a/changes/:changeId/revisions/current/review', async ({ request }) => {
376
+ const body = (await request.json()) as {
377
+ comments?: Record<string, unknown[]>
378
+ }
379
+
380
+ const fileComments = body.comments?.['src/main.js'] as Array<{
381
+ line?: number
382
+ range?: unknown
383
+ message: string
384
+ }>
385
+
386
+ // Should use range when both are provided (range takes precedence)
387
+ expect(fileComments?.[0]).toMatchObject({
388
+ range: {
389
+ start_line: 10,
390
+ end_line: 15,
391
+ },
392
+ message: 'Test comment',
393
+ })
394
+ // line should NOT be included when range is present (Gerrit API preference)
395
+ expect(fileComments?.[0].line).toBeUndefined()
396
+
397
+ return HttpResponse.json({})
398
+ }),
399
+ )
400
+
401
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
402
+
403
+ const program = commentCommand('12345', { batch: true }).pipe(
404
+ Effect.provide(GerritApiServiceLive),
405
+ Effect.provide(mockConfigLayer),
406
+ )
407
+
408
+ // Both line and range provided - should work
409
+ setTimeout(() => {
410
+ mockProcessStdin.emit(
411
+ 'data',
412
+ JSON.stringify([
413
+ {
414
+ file: 'src/main.js',
415
+ line: 10, // Will be included
416
+ range: { start_line: 10, end_line: 15 }, // Takes precedence
417
+ message: 'Test comment',
418
+ },
419
+ ]),
420
+ )
421
+ }, 10)
422
+
423
+ await Effect.runPromise(program)
424
+
425
+ // Restore process.stdin
426
+ Object.defineProperty(process, 'stdin', {
427
+ value: originalStdin,
428
+ configurable: true,
429
+ })
430
+ })
431
+ })