@aaronshaf/ger 0.1.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 (91) hide show
  1. package/.ast-grep/rules/no-as-casting.yml +13 -0
  2. package/.eslintrc.js +12 -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 +78 -0
  6. package/.github/workflows/claude.yml +64 -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 +103 -0
  16. package/DEVELOPMENT.md +361 -0
  17. package/LICENSE +21 -0
  18. package/README.md +325 -0
  19. package/bin/ger +3 -0
  20. package/biome.json +36 -0
  21. package/bun.lock +688 -0
  22. package/bunfig.toml +8 -0
  23. package/oxlint.json +24 -0
  24. package/package.json +55 -0
  25. package/scripts/check-coverage.ts +69 -0
  26. package/scripts/check-file-size.ts +38 -0
  27. package/scripts/fix-test-mocks.ts +55 -0
  28. package/src/api/gerrit.ts +466 -0
  29. package/src/cli/commands/abandon.ts +65 -0
  30. package/src/cli/commands/comment.ts +460 -0
  31. package/src/cli/commands/comments.ts +85 -0
  32. package/src/cli/commands/diff.ts +71 -0
  33. package/src/cli/commands/incoming.ts +226 -0
  34. package/src/cli/commands/init.ts +164 -0
  35. package/src/cli/commands/mine.ts +115 -0
  36. package/src/cli/commands/open.ts +57 -0
  37. package/src/cli/commands/review.ts +593 -0
  38. package/src/cli/commands/setup.ts +230 -0
  39. package/src/cli/commands/show.ts +303 -0
  40. package/src/cli/commands/status.ts +35 -0
  41. package/src/cli/commands/workspace.ts +200 -0
  42. package/src/cli/index.ts +420 -0
  43. package/src/prompts/default-review.md +80 -0
  44. package/src/prompts/system-inline-review.md +88 -0
  45. package/src/prompts/system-overall-review.md +152 -0
  46. package/src/schemas/config.test.ts +245 -0
  47. package/src/schemas/config.ts +75 -0
  48. package/src/schemas/gerrit.ts +455 -0
  49. package/src/services/ai-enhanced.ts +167 -0
  50. package/src/services/ai.ts +182 -0
  51. package/src/services/config.test.ts +414 -0
  52. package/src/services/config.ts +206 -0
  53. package/src/test-utils/mock-generator.ts +73 -0
  54. package/src/utils/comment-formatters.ts +153 -0
  55. package/src/utils/diff-context.ts +103 -0
  56. package/src/utils/diff-formatters.ts +141 -0
  57. package/src/utils/formatters.ts +85 -0
  58. package/src/utils/message-filters.ts +26 -0
  59. package/src/utils/shell-safety.ts +117 -0
  60. package/src/utils/status-indicators.ts +100 -0
  61. package/src/utils/url-parser.test.ts +123 -0
  62. package/src/utils/url-parser.ts +91 -0
  63. package/tests/abandon.test.ts +163 -0
  64. package/tests/ai-service.test.ts +489 -0
  65. package/tests/comment-batch-advanced.test.ts +431 -0
  66. package/tests/comment-gerrit-api-compliance.test.ts +414 -0
  67. package/tests/comment.test.ts +707 -0
  68. package/tests/comments.test.ts +323 -0
  69. package/tests/config-service-simple.test.ts +100 -0
  70. package/tests/diff.test.ts +419 -0
  71. package/tests/helpers/config-mock.ts +27 -0
  72. package/tests/incoming.test.ts +357 -0
  73. package/tests/interactive-incoming.test.ts +173 -0
  74. package/tests/mine.test.ts +318 -0
  75. package/tests/mocks/fetch-mock.ts +139 -0
  76. package/tests/mocks/msw-handlers.ts +80 -0
  77. package/tests/open.test.ts +233 -0
  78. package/tests/review.test.ts +669 -0
  79. package/tests/setup.ts +13 -0
  80. package/tests/show.test.ts +439 -0
  81. package/tests/unit/schemas/gerrit.test.ts +85 -0
  82. package/tests/unit/test-utils/mock-generator.test.ts +154 -0
  83. package/tests/unit/utils/comment-formatters.test.ts +415 -0
  84. package/tests/unit/utils/diff-context.test.ts +171 -0
  85. package/tests/unit/utils/diff-formatters.test.ts +165 -0
  86. package/tests/unit/utils/formatters.test.ts +411 -0
  87. package/tests/unit/utils/message-filters.test.ts +227 -0
  88. package/tests/unit/utils/prompt-helpers.test.ts +175 -0
  89. package/tests/unit/utils/shell-safety.test.ts +230 -0
  90. package/tests/unit/utils/status-indicators.test.ts +137 -0
  91. package/tsconfig.json +40 -0
@@ -0,0 +1,323 @@
1
+ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, mock } from 'bun:test'
2
+ import { Effect, Layer } from 'effect'
3
+ import { delay, HttpResponse, http } from 'msw'
4
+ import { setupServer } from 'msw/node'
5
+ import { GerritApiServiceLive } from '@/api/gerrit'
6
+ import { commentsCommand } from '@/cli/commands/comments'
7
+ import { ConfigService } from '@/services/config'
8
+ import { commentHandlers, emptyCommentsHandlers } from './mocks/msw-handlers'
9
+
10
+ import { createMockConfigService } from './helpers/config-mock'
11
+ // Create MSW server
12
+ const server = setupServer(
13
+ // Default handler for auth check
14
+ http.get('*/a/accounts/self', ({ request }) => {
15
+ const auth = request.headers.get('Authorization')
16
+ if (!auth || !auth.startsWith('Basic ')) {
17
+ return HttpResponse.text('Unauthorized', { status: 401 })
18
+ }
19
+ return HttpResponse.json({
20
+ _account_id: 1000,
21
+ name: 'Test User',
22
+ email: 'test@example.com',
23
+ })
24
+ }),
25
+ )
26
+
27
+ describe('comments command', () => {
28
+ let mockConsoleLog: ReturnType<typeof mock>
29
+ let mockConsoleError: ReturnType<typeof mock>
30
+
31
+ beforeAll(() => {
32
+ // Start MSW server before all tests
33
+ server.listen({ onUnhandledRequest: 'bypass' })
34
+ })
35
+
36
+ afterAll(() => {
37
+ // Clean up after all tests
38
+ server.close()
39
+ })
40
+
41
+ beforeEach(() => {
42
+ // Reset handlers to defaults before each test
43
+ server.resetHandlers()
44
+
45
+ mockConsoleLog = mock(() => {})
46
+ mockConsoleError = mock(() => {})
47
+ console.log = mockConsoleLog
48
+ console.error = mockConsoleError
49
+ })
50
+
51
+ afterEach(() => {
52
+ // Clean up after each test
53
+ server.resetHandlers()
54
+ })
55
+
56
+ it('should fetch and display comments in pretty format', async () => {
57
+ // Add comment handlers for this test
58
+ server.use(...commentHandlers)
59
+
60
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
61
+
62
+ const program = commentsCommand('12345', {}).pipe(
63
+ Effect.provide(GerritApiServiceLive),
64
+ Effect.provide(mockConfigLayer),
65
+ )
66
+
67
+ await Effect.runPromise(program)
68
+
69
+ // Check that comments were displayed
70
+ const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
71
+ expect(output).toContain('Found 3 comments')
72
+ expect(output).toContain('Commit Message')
73
+ expect(output).toContain('Please update the commit message')
74
+ expect(output).toContain('src/main.ts')
75
+ expect(output).toContain('Consider using a more descriptive variable name')
76
+ expect(output).toContain('[UNRESOLVED]')
77
+ })
78
+
79
+ it('should output XML format when --xml flag is used', async () => {
80
+ // Add comment handlers for this test
81
+ server.use(...commentHandlers)
82
+
83
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
84
+
85
+ const program = commentsCommand('12345', { xml: true }).pipe(
86
+ Effect.provide(GerritApiServiceLive),
87
+ Effect.provide(mockConfigLayer),
88
+ )
89
+
90
+ await Effect.runPromise(program)
91
+
92
+ // Check XML output structure
93
+ const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
94
+ expect(output).toContain('<?xml version="1.0" encoding="UTF-8"?>')
95
+ expect(output).toContain('<comments_result>')
96
+ expect(output).toContain('<change_id>12345</change_id>')
97
+ expect(output).toContain('<comment_count>3</comment_count>')
98
+ expect(output).toContain('<message><![CDATA[Please update the commit message]]></message>')
99
+ expect(output).toContain('<unresolved>true</unresolved>')
100
+ expect(output).toContain('</comments_result>')
101
+
102
+ // Verify XML is well-formed
103
+ expect(output.match(/<comment>/g)?.length).toBe(3)
104
+ expect(output.match(/<\/comment>/g)?.length).toBe(3)
105
+ })
106
+
107
+ it('should handle no comments gracefully', async () => {
108
+ // Use empty comments handlers for this test
109
+ server.use(...emptyCommentsHandlers)
110
+
111
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
112
+
113
+ const program = commentsCommand('12345', {}).pipe(
114
+ Effect.provide(GerritApiServiceLive),
115
+ Effect.provide(mockConfigLayer),
116
+ )
117
+
118
+ await Effect.runPromise(program)
119
+
120
+ const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
121
+ expect(output).toContain('No comments found on this change')
122
+ })
123
+
124
+ it('should handle network failures gracefully', async () => {
125
+ // Configure server to return network error
126
+ server.use(
127
+ http.get('*/a/changes/:changeId/revisions/:revisionId/comments', () => {
128
+ return HttpResponse.error()
129
+ }),
130
+ )
131
+
132
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
133
+
134
+ const program = commentsCommand('12345', {}).pipe(
135
+ Effect.provide(GerritApiServiceLive),
136
+ Effect.provide(mockConfigLayer),
137
+ )
138
+
139
+ await Effect.runPromise(program)
140
+
141
+ const errorOutput = mockConsoleError.mock.calls.map((call) => call[0]).join('\n')
142
+ expect(errorOutput).toContain('Failed to fetch comments')
143
+ })
144
+
145
+ it('should handle network failures gracefully in XML mode', async () => {
146
+ // Configure server to return network error
147
+ server.use(
148
+ http.get('*/a/changes/:changeId/revisions/:revisionId/comments', () => {
149
+ return HttpResponse.error()
150
+ }),
151
+ )
152
+
153
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
154
+
155
+ const program = commentsCommand('12345', { xml: true }).pipe(
156
+ Effect.provide(GerritApiServiceLive),
157
+ Effect.provide(mockConfigLayer),
158
+ )
159
+
160
+ await Effect.runPromise(program)
161
+
162
+ const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
163
+ expect(output).toContain('<status>error</status>')
164
+ expect(output).toContain('<error><![CDATA[')
165
+ })
166
+
167
+ it('should handle diff fetch failures gracefully', async () => {
168
+ // Comments endpoint works
169
+ server.use(
170
+ http.get('*/a/changes/:changeId/revisions/:revisionId/comments', () => {
171
+ return HttpResponse.text(`)]}'\n{
172
+ "src/file.ts": [{
173
+ "id": "test1",
174
+ "message": "Test comment",
175
+ "line": 10,
176
+ "author": {"name": "Test User"}
177
+ }]
178
+ }`)
179
+ }),
180
+ // Diff endpoint fails
181
+ http.get('*/a/changes/:changeId/revisions/:revisionId/files/:filePath/diff', () => {
182
+ return HttpResponse.error()
183
+ }),
184
+ )
185
+
186
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
187
+
188
+ const program = commentsCommand('12345', {}).pipe(
189
+ Effect.provide(GerritApiServiceLive),
190
+ Effect.provide(mockConfigLayer),
191
+ )
192
+
193
+ await Effect.runPromise(program)
194
+
195
+ // Should still display comment without context
196
+ const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
197
+ expect(output).toContain('Test comment')
198
+ expect(output).toContain('src/file.ts')
199
+ })
200
+
201
+ it('should handle concurrent API calls efficiently', async () => {
202
+ let _commentCallTime: number | null = null
203
+ let diffCallCount = 0
204
+ const diffCallTimes: number[] = []
205
+
206
+ server.use(
207
+ http.get('*/a/changes/:changeId/revisions/:revisionId/comments', async () => {
208
+ _commentCallTime = Date.now()
209
+ await delay(50) // Simulate network delay
210
+ return HttpResponse.text(`)]}'\n{
211
+ "file1.ts": [{"id": "c1", "message": "Comment 1", "line": 10}],
212
+ "file2.ts": [{"id": "c2", "message": "Comment 2", "line": 20}],
213
+ "file3.ts": [{"id": "c3", "message": "Comment 3", "line": 30}]
214
+ }`)
215
+ }),
216
+ http.get('*/a/changes/:changeId/revisions/:revisionId/files/:filePath/diff', async () => {
217
+ diffCallCount++
218
+ diffCallTimes.push(Date.now())
219
+ await delay(100) // Simulate network delay
220
+ return HttpResponse.text(`)]}'\n{
221
+ "content": [{"ab": ["line 1", "line 2"]}]
222
+ }`)
223
+ }),
224
+ )
225
+
226
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
227
+
228
+ const startTime = Date.now()
229
+ const program = commentsCommand('12345', {}).pipe(
230
+ Effect.provide(GerritApiServiceLive),
231
+ Effect.provide(mockConfigLayer),
232
+ )
233
+
234
+ await Effect.runPromise(program)
235
+ const totalTime = Date.now() - startTime
236
+
237
+ // Verify concurrent execution
238
+ expect(diffCallCount).toBe(3) // 3 diff calls made
239
+
240
+ // All diff calls should start close together (within 100ms)
241
+ // indicating concurrent execution, not sequential
242
+ const firstDiffTime = diffCallTimes[0]
243
+ const lastDiffTime = diffCallTimes[diffCallTimes.length - 1]
244
+ expect(lastDiffTime - firstDiffTime).toBeLessThan(100)
245
+
246
+ // Total time should be less than sequential execution would take
247
+ // Sequential: 50ms (comments) + 3 * 100ms (diffs) = 350ms
248
+ // Concurrent: 50ms (comments) + 100ms (parallel diffs) = 150ms (plus overhead)
249
+ expect(totalTime).toBeLessThan(250)
250
+ })
251
+
252
+ it('should properly escape XML special characters', async () => {
253
+ server.use(
254
+ http.get('*/a/changes/:changeId/revisions/:revisionId/comments', () => {
255
+ return HttpResponse.text(`)]}'\n{
256
+ "test.xml": [{
257
+ "id": "xml-test",
258
+ "message": "Test <script>alert('XSS')</script> & entities",
259
+ "author": {
260
+ "name": "User <>&\\"'",
261
+ "email": "test@example.com"
262
+ }
263
+ }]
264
+ }`)
265
+ }),
266
+ )
267
+
268
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
269
+
270
+ const program = commentsCommand('12345', { xml: true }).pipe(
271
+ Effect.provide(GerritApiServiceLive),
272
+ Effect.provide(mockConfigLayer),
273
+ )
274
+
275
+ await Effect.runPromise(program)
276
+
277
+ const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
278
+ // Message should be in CDATA
279
+ expect(output).toContain(
280
+ "<message><![CDATA[Test <script>alert('XSS')</script> & entities]]></message>",
281
+ )
282
+ // Author name should be in CDATA
283
+ expect(output).toContain('<name><![CDATA[User <>&"\']]></name>')
284
+ // Email should be escaped
285
+ expect(output).toContain('<email>test@example.com</email>')
286
+ })
287
+
288
+ it('should handle comments with ranges correctly', async () => {
289
+ server.use(
290
+ http.get('*/a/changes/:changeId/revisions/:revisionId/comments', () => {
291
+ return HttpResponse.text(`)]}'\n{
292
+ "src/range.ts": [{
293
+ "id": "range-comment",
294
+ "message": "Multi-line comment",
295
+ "range": {
296
+ "start_line": 10,
297
+ "end_line": 15,
298
+ "start_character": 5,
299
+ "end_character": 20
300
+ }
301
+ }]
302
+ }`)
303
+ }),
304
+ )
305
+
306
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
307
+
308
+ const program = commentsCommand('12345', { xml: true }).pipe(
309
+ Effect.provide(GerritApiServiceLive),
310
+ Effect.provide(mockConfigLayer),
311
+ )
312
+
313
+ await Effect.runPromise(program)
314
+
315
+ const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
316
+ expect(output).toContain('<range>')
317
+ expect(output).toContain('<start_line>10</start_line>')
318
+ expect(output).toContain('<end_line>15</end_line>')
319
+ expect(output).toContain('<start_character>5</start_character>')
320
+ expect(output).toContain('<end_character>20</end_character>')
321
+ expect(output).toContain('</range>')
322
+ })
323
+ })
@@ -0,0 +1,100 @@
1
+ import { describe, test, expect } from 'bun:test'
2
+ import { Effect } from 'effect'
3
+ import { ConfigService, ConfigError, ConfigServiceLive } from '@/services/config'
4
+ import { GerritCredentials } from '@/schemas/gerrit'
5
+ import { AiConfig, AppConfig } from '@/schemas/config'
6
+
7
+ describe('Config Service Simple Tests', () => {
8
+ describe('ConfigError', () => {
9
+ test('should create ConfigError with message', () => {
10
+ const error = new ConfigError({ message: 'Test error' })
11
+ expect(error.message).toBe('Test error')
12
+ expect(error._tag).toBe('ConfigError')
13
+ })
14
+
15
+ test('should be throwable and catchable', () => {
16
+ const error = new ConfigError({ message: 'Test error' })
17
+ expect(() => {
18
+ throw error
19
+ }).toThrow('Test error')
20
+ })
21
+
22
+ test('should be instanceof ConfigError', () => {
23
+ const error = new ConfigError({ message: 'Test error' })
24
+ expect(error).toBeInstanceOf(ConfigError)
25
+ })
26
+ })
27
+
28
+ describe('ConfigServiceLive layer', () => {
29
+ test('should be able to create live service layer', () => {
30
+ expect(ConfigServiceLive).toBeDefined()
31
+ expect(typeof ConfigServiceLive).toBe('object')
32
+ })
33
+
34
+ test('should provide all required service methods', async () => {
35
+ const service = await Effect.runPromise(
36
+ Effect.gen(function* () {
37
+ return yield* ConfigService
38
+ }).pipe(Effect.provide(ConfigServiceLive)),
39
+ )
40
+
41
+ expect(typeof service.getCredentials).toBe('object') // Effect object
42
+ expect(typeof service.saveCredentials).toBe('function')
43
+ expect(typeof service.deleteCredentials).toBe('object') // Effect object
44
+ expect(typeof service.getAiConfig).toBe('object') // Effect object
45
+ expect(typeof service.saveAiConfig).toBe('function')
46
+ expect(typeof service.getFullConfig).toBe('object') // Effect object
47
+ expect(typeof service.saveFullConfig).toBe('function')
48
+ })
49
+ })
50
+
51
+ // Note: Config behavior tests removed as they depend on filesystem state
52
+ // which varies between test environments
53
+
54
+ describe('Schema validation', () => {
55
+ test('should validate valid credentials schema', () => {
56
+ const validCredentials: GerritCredentials = {
57
+ host: 'https://gerrit.example.com',
58
+ username: 'testuser',
59
+ password: 'testpass',
60
+ }
61
+
62
+ expect(validCredentials.host).toBe('https://gerrit.example.com')
63
+ expect(validCredentials.username).toBe('testuser')
64
+ expect(validCredentials.password).toBe('testpass')
65
+ })
66
+
67
+ test('should validate valid AI config schema', () => {
68
+ const validAiConfig: AiConfig = {
69
+ autoDetect: true,
70
+ }
71
+
72
+ expect(validAiConfig.autoDetect).toBe(true)
73
+ })
74
+
75
+ test('should validate AI config with tool', () => {
76
+ const validAiConfig: AiConfig = {
77
+ autoDetect: false,
78
+ tool: 'claude',
79
+ }
80
+
81
+ expect(validAiConfig.autoDetect).toBe(false)
82
+ expect(validAiConfig.tool).toBe('claude')
83
+ })
84
+
85
+ test('should validate full app config schema', () => {
86
+ const validAppConfig: AppConfig = {
87
+ host: 'https://gerrit.example.com',
88
+ username: 'testuser',
89
+ password: 'testpass',
90
+ aiAutoDetect: true,
91
+ aiTool: 'claude',
92
+ }
93
+
94
+ expect(validAppConfig.host).toBe('https://gerrit.example.com')
95
+ expect(validAppConfig.username).toBe('testuser')
96
+ expect(validAppConfig.aiAutoDetect).toBe(true)
97
+ expect(validAppConfig.aiTool).toBe('claude')
98
+ })
99
+ })
100
+ })