@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,318 @@
1
+ import { describe, test, expect, beforeEach } from 'bun:test'
2
+ import { Effect, Layer } from 'effect'
3
+ import { mineCommand } from '@/cli/commands/mine'
4
+ import { GerritApiService, type ApiError } from '@/api/gerrit'
5
+ import { generateMockChange } from '@/test-utils/mock-generator'
6
+ import type { ChangeInfo } from '@/schemas/gerrit'
7
+
8
+ // Mock console.log to capture output
9
+ const mockConsole = {
10
+ logs: [] as string[],
11
+ log: function (message: string) {
12
+ this.logs.push(message)
13
+ },
14
+ clear: function () {
15
+ this.logs = []
16
+ },
17
+ }
18
+
19
+ describe('mine command', () => {
20
+ beforeEach(() => {
21
+ mockConsole.clear()
22
+ // Replace console.log for tests
23
+ global.console.log = mockConsole.log.bind(mockConsole)
24
+ })
25
+
26
+ test('should fetch and display my changes in pretty format', async () => {
27
+ const mockChanges: ChangeInfo[] = [
28
+ generateMockChange({
29
+ _number: 12345,
30
+ subject: 'My test change',
31
+ project: 'test-project',
32
+ branch: 'main',
33
+ status: 'NEW',
34
+ }),
35
+ generateMockChange({
36
+ _number: 12346,
37
+ subject: 'Another change',
38
+ project: 'test-project-2',
39
+ branch: 'develop',
40
+ status: 'MERGED',
41
+ }),
42
+ ]
43
+
44
+ const mockApi = GerritApiService.of({
45
+ listChanges: (query?: string) => {
46
+ expect(query).toBe('owner:self status:open')
47
+ return Effect.succeed(mockChanges)
48
+ },
49
+ getChange: () => Effect.fail(new Error('Not implemented') as ApiError),
50
+ postReview: () => Effect.fail(new Error('Not implemented') as ApiError),
51
+ abandonChange: () => Effect.fail(new Error('Not implemented') as ApiError),
52
+ testConnection: Effect.fail(new Error('Not implemented') as ApiError),
53
+ getRevision: () => Effect.fail(new Error('Not implemented') as ApiError),
54
+ getFiles: () => Effect.fail(new Error('Not implemented') as ApiError),
55
+ getFileDiff: () => Effect.fail(new Error('Not implemented') as ApiError),
56
+ getFileContent: () => Effect.fail(new Error('Not implemented') as ApiError),
57
+ getPatch: () => Effect.fail(new Error('Not implemented') as ApiError),
58
+ getDiff: () => Effect.fail(new Error('Not implemented') as ApiError),
59
+ getComments: () => Effect.fail(new Error('Not implemented') as ApiError),
60
+ getMessages: () => Effect.fail(new Error('Not implemented') as ApiError),
61
+ })
62
+
63
+ await Effect.runPromise(
64
+ mineCommand({ xml: false }).pipe(Effect.provide(Layer.succeed(GerritApiService, mockApi))),
65
+ )
66
+
67
+ expect(mockConsole.logs.length).toBeGreaterThan(0)
68
+ expect(mockConsole.logs.some((log) => log.includes('My test change'))).toBe(true)
69
+ expect(mockConsole.logs.some((log) => log.includes('Another change'))).toBe(true)
70
+ })
71
+
72
+ test('should output XML format when --xml flag is used', async () => {
73
+ const mockChanges: ChangeInfo[] = [
74
+ generateMockChange({
75
+ _number: 12345,
76
+ subject: 'Test change',
77
+ project: 'test-project',
78
+ branch: 'main',
79
+ status: 'NEW',
80
+ }),
81
+ ]
82
+
83
+ const mockApi = GerritApiService.of({
84
+ listChanges: (query?: string) => {
85
+ expect(query).toBe('owner:self status:open')
86
+ return Effect.succeed(mockChanges)
87
+ },
88
+ getChange: () => Effect.fail(new Error('Not implemented') as ApiError),
89
+ postReview: () => Effect.fail(new Error('Not implemented') as ApiError),
90
+ abandonChange: () => Effect.fail(new Error('Not implemented') as ApiError),
91
+ testConnection: Effect.fail(new Error('Not implemented') as ApiError),
92
+ getRevision: () => Effect.fail(new Error('Not implemented') as ApiError),
93
+ getFiles: () => Effect.fail(new Error('Not implemented') as ApiError),
94
+ getFileDiff: () => Effect.fail(new Error('Not implemented') as ApiError),
95
+ getFileContent: () => Effect.fail(new Error('Not implemented') as ApiError),
96
+ getPatch: () => Effect.fail(new Error('Not implemented') as ApiError),
97
+ getDiff: () => Effect.fail(new Error('Not implemented') as ApiError),
98
+ getComments: () => Effect.fail(new Error('Not implemented') as ApiError),
99
+ getMessages: () => Effect.fail(new Error('Not implemented') as ApiError),
100
+ })
101
+
102
+ await Effect.runPromise(
103
+ mineCommand({ xml: true }).pipe(Effect.provide(Layer.succeed(GerritApiService, mockApi))),
104
+ )
105
+
106
+ const output = mockConsole.logs.join('\n')
107
+ expect(output).toContain('<?xml version="1.0" encoding="UTF-8"?>')
108
+ expect(output).toContain('<changes count="1">')
109
+ expect(output).toContain('<change>')
110
+ expect(output).toContain('<number>12345</number>')
111
+ expect(output).toContain('<subject><![CDATA[Test change]]></subject>')
112
+ expect(output).toContain('<project>test-project</project>')
113
+ expect(output).toContain('<branch>main</branch>')
114
+ expect(output).toContain('<status>NEW</status>')
115
+ expect(output).toContain('</change>')
116
+ expect(output).toContain('</changes>')
117
+ })
118
+
119
+ test('should handle no changes gracefully', async () => {
120
+ const mockApi = GerritApiService.of({
121
+ listChanges: () => Effect.succeed([]),
122
+ getChange: () => Effect.fail(new Error('Not implemented') as ApiError),
123
+ postReview: () => Effect.fail(new Error('Not implemented') as ApiError),
124
+ abandonChange: () => Effect.fail(new Error('Not implemented') as ApiError),
125
+ testConnection: Effect.fail(new Error('Not implemented') as ApiError),
126
+ getRevision: () => Effect.fail(new Error('Not implemented') as ApiError),
127
+ getFiles: () => Effect.fail(new Error('Not implemented') as ApiError),
128
+ getFileDiff: () => Effect.fail(new Error('Not implemented') as ApiError),
129
+ getFileContent: () => Effect.fail(new Error('Not implemented') as ApiError),
130
+ getPatch: () => Effect.fail(new Error('Not implemented') as ApiError),
131
+ getDiff: () => Effect.fail(new Error('Not implemented') as ApiError),
132
+ getComments: () => Effect.fail(new Error('Not implemented') as ApiError),
133
+ getMessages: () => Effect.fail(new Error('Not implemented') as ApiError),
134
+ })
135
+
136
+ await Effect.runPromise(
137
+ mineCommand({ xml: false }).pipe(Effect.provide(Layer.succeed(GerritApiService, mockApi))),
138
+ )
139
+
140
+ // Mine command returns early for empty results, so no output is expected
141
+ expect(mockConsole.logs).toEqual([])
142
+ })
143
+
144
+ test('should handle no changes gracefully in XML format', async () => {
145
+ const mockApi = GerritApiService.of({
146
+ listChanges: () => Effect.succeed([]),
147
+ getChange: () => Effect.fail(new Error('Not implemented') as ApiError),
148
+ postReview: () => Effect.fail(new Error('Not implemented') as ApiError),
149
+ abandonChange: () => Effect.fail(new Error('Not implemented') as ApiError),
150
+ testConnection: Effect.fail(new Error('Not implemented') as ApiError),
151
+ getRevision: () => Effect.fail(new Error('Not implemented') as ApiError),
152
+ getFiles: () => Effect.fail(new Error('Not implemented') as ApiError),
153
+ getFileDiff: () => Effect.fail(new Error('Not implemented') as ApiError),
154
+ getFileContent: () => Effect.fail(new Error('Not implemented') as ApiError),
155
+ getPatch: () => Effect.fail(new Error('Not implemented') as ApiError),
156
+ getDiff: () => Effect.fail(new Error('Not implemented') as ApiError),
157
+ getComments: () => Effect.fail(new Error('Not implemented') as ApiError),
158
+ getMessages: () => Effect.fail(new Error('Not implemented') as ApiError),
159
+ })
160
+
161
+ await Effect.runPromise(
162
+ mineCommand({ xml: true }).pipe(Effect.provide(Layer.succeed(GerritApiService, mockApi))),
163
+ )
164
+
165
+ const output = mockConsole.logs.join('\n')
166
+ expect(output).toContain('<?xml version="1.0" encoding="UTF-8"?>')
167
+ expect(output).toContain('<changes count="0">')
168
+ expect(output).toContain('</changes>')
169
+ })
170
+
171
+ test('should handle network failures gracefully', async () => {
172
+ const mockApi = GerritApiService.of({
173
+ listChanges: () => Effect.fail(new Error('Network error') as ApiError),
174
+ getChange: () => Effect.fail(new Error('Not implemented') as ApiError),
175
+ postReview: () => Effect.fail(new Error('Not implemented') as ApiError),
176
+ abandonChange: () => Effect.fail(new Error('Not implemented') as ApiError),
177
+ testConnection: Effect.fail(new Error('Not implemented') as ApiError),
178
+ getRevision: () => Effect.fail(new Error('Not implemented') as ApiError),
179
+ getFiles: () => Effect.fail(new Error('Not implemented') as ApiError),
180
+ getFileDiff: () => Effect.fail(new Error('Not implemented') as ApiError),
181
+ getFileContent: () => Effect.fail(new Error('Not implemented') as ApiError),
182
+ getPatch: () => Effect.fail(new Error('Not implemented') as ApiError),
183
+ getDiff: () => Effect.fail(new Error('Not implemented') as ApiError),
184
+ getComments: () => Effect.fail(new Error('Not implemented') as ApiError),
185
+ getMessages: () => Effect.fail(new Error('Not implemented') as ApiError),
186
+ })
187
+
188
+ const result = await Effect.runPromise(
189
+ Effect.either(
190
+ mineCommand({ xml: false }).pipe(Effect.provide(Layer.succeed(GerritApiService, mockApi))),
191
+ ),
192
+ )
193
+
194
+ expect(result._tag).toBe('Left')
195
+ if (result._tag === 'Left') {
196
+ expect(result.left.message).toBe('Network error')
197
+ }
198
+ })
199
+
200
+ test('should handle network failures gracefully in XML format', async () => {
201
+ const mockApi = GerritApiService.of({
202
+ listChanges: () => Effect.fail(new Error('API error') as ApiError),
203
+ getChange: () => Effect.fail(new Error('Not implemented') as ApiError),
204
+ postReview: () => Effect.fail(new Error('Not implemented') as ApiError),
205
+ abandonChange: () => Effect.fail(new Error('Not implemented') as ApiError),
206
+ testConnection: Effect.fail(new Error('Not implemented') as ApiError),
207
+ getRevision: () => Effect.fail(new Error('Not implemented') as ApiError),
208
+ getFiles: () => Effect.fail(new Error('Not implemented') as ApiError),
209
+ getFileDiff: () => Effect.fail(new Error('Not implemented') as ApiError),
210
+ getFileContent: () => Effect.fail(new Error('Not implemented') as ApiError),
211
+ getPatch: () => Effect.fail(new Error('Not implemented') as ApiError),
212
+ getDiff: () => Effect.fail(new Error('Not implemented') as ApiError),
213
+ getComments: () => Effect.fail(new Error('Not implemented') as ApiError),
214
+ getMessages: () => Effect.fail(new Error('Not implemented') as ApiError),
215
+ })
216
+
217
+ const result = await Effect.runPromise(
218
+ Effect.either(
219
+ mineCommand({ xml: true }).pipe(Effect.provide(Layer.succeed(GerritApiService, mockApi))),
220
+ ),
221
+ )
222
+
223
+ expect(result._tag).toBe('Left')
224
+ if (result._tag === 'Left') {
225
+ expect(result.left.message).toBe('API error')
226
+ }
227
+ })
228
+
229
+ test('should properly escape XML special characters', async () => {
230
+ const mockChanges: ChangeInfo[] = [
231
+ generateMockChange({
232
+ _number: 12345,
233
+ subject: 'Test with <special> & "characters"',
234
+ project: 'test-project',
235
+ branch: 'feature/test&update',
236
+ status: 'NEW',
237
+ }),
238
+ ]
239
+
240
+ const mockApi = GerritApiService.of({
241
+ listChanges: () => Effect.succeed(mockChanges),
242
+ getChange: () => Effect.fail(new Error('Not implemented') as ApiError),
243
+ postReview: () => Effect.fail(new Error('Not implemented') as ApiError),
244
+ abandonChange: () => Effect.fail(new Error('Not implemented') as ApiError),
245
+ testConnection: Effect.fail(new Error('Not implemented') as ApiError),
246
+ getRevision: () => Effect.fail(new Error('Not implemented') as ApiError),
247
+ getFiles: () => Effect.fail(new Error('Not implemented') as ApiError),
248
+ getFileDiff: () => Effect.fail(new Error('Not implemented') as ApiError),
249
+ getFileContent: () => Effect.fail(new Error('Not implemented') as ApiError),
250
+ getPatch: () => Effect.fail(new Error('Not implemented') as ApiError),
251
+ getDiff: () => Effect.fail(new Error('Not implemented') as ApiError),
252
+ getComments: () => Effect.fail(new Error('Not implemented') as ApiError),
253
+ getMessages: () => Effect.fail(new Error('Not implemented') as ApiError),
254
+ })
255
+
256
+ await Effect.runPromise(
257
+ mineCommand({ xml: true }).pipe(Effect.provide(Layer.succeed(GerritApiService, mockApi))),
258
+ )
259
+
260
+ const output = mockConsole.logs.join('\n')
261
+ // CDATA sections should preserve special characters
262
+ expect(output).toContain('<![CDATA[Test with <special> & "characters"]]>')
263
+ expect(output).toContain('<branch>feature/test&update</branch>')
264
+ })
265
+
266
+ test('should display changes with proper grouping by project', async () => {
267
+ const mockChanges: ChangeInfo[] = [
268
+ generateMockChange({
269
+ _number: 12345,
270
+ subject: 'Change in project A',
271
+ project: 'project-a',
272
+ branch: 'main',
273
+ status: 'NEW',
274
+ }),
275
+ generateMockChange({
276
+ _number: 12346,
277
+ subject: 'Change in project B',
278
+ project: 'project-b',
279
+ branch: 'main',
280
+ status: 'NEW',
281
+ }),
282
+ generateMockChange({
283
+ _number: 12347,
284
+ subject: 'Another change in project A',
285
+ project: 'project-a',
286
+ branch: 'develop',
287
+ status: 'MERGED',
288
+ }),
289
+ ]
290
+
291
+ const mockApi = GerritApiService.of({
292
+ listChanges: () => Effect.succeed(mockChanges),
293
+ getChange: () => Effect.fail(new Error('Not implemented') as ApiError),
294
+ postReview: () => Effect.fail(new Error('Not implemented') as ApiError),
295
+ abandonChange: () => Effect.fail(new Error('Not implemented') as ApiError),
296
+ testConnection: Effect.fail(new Error('Not implemented') as ApiError),
297
+ getRevision: () => Effect.fail(new Error('Not implemented') as ApiError),
298
+ getFiles: () => Effect.fail(new Error('Not implemented') as ApiError),
299
+ getFileDiff: () => Effect.fail(new Error('Not implemented') as ApiError),
300
+ getFileContent: () => Effect.fail(new Error('Not implemented') as ApiError),
301
+ getPatch: () => Effect.fail(new Error('Not implemented') as ApiError),
302
+ getDiff: () => Effect.fail(new Error('Not implemented') as ApiError),
303
+ getComments: () => Effect.fail(new Error('Not implemented') as ApiError),
304
+ getMessages: () => Effect.fail(new Error('Not implemented') as ApiError),
305
+ })
306
+
307
+ await Effect.runPromise(
308
+ mineCommand({ xml: false }).pipe(Effect.provide(Layer.succeed(GerritApiService, mockApi))),
309
+ )
310
+
311
+ const output = mockConsole.logs.join('\n')
312
+ expect(output).toContain('Change in project A')
313
+ expect(output).toContain('Change in project B')
314
+ expect(output).toContain('Another change in project A')
315
+ expect(output).toContain('project-a')
316
+ expect(output).toContain('project-b')
317
+ })
318
+ })
@@ -0,0 +1,139 @@
1
+ import { mock } from 'bun:test'
2
+ import { Schema } from '@effect/schema'
3
+ import { ChangeInfo } from '@/schemas/gerrit'
4
+ import {
5
+ generateMockAccount,
6
+ generateMockChange,
7
+ generateMockFileDiff,
8
+ generateMockFiles,
9
+ } from '@/test-utils/mock-generator'
10
+
11
+ // Generate consistent mock data using Effect Schema
12
+ const mockChange = generateMockChange()
13
+ const mockFiles = generateMockFiles()
14
+ const mockDiff = generateMockFileDiff()
15
+ const mockAccount = generateMockAccount()
16
+
17
+ // Keep the old mockChange definition for now as backup (disabled to fix unused variable)
18
+ // const _mockChange: Schema.Schema.Type<typeof ChangeInfo> = {
19
+ // id: 'myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940',
20
+ // project: 'myProject',
21
+ // branch: 'master',
22
+ // change_id: 'I8473b95934b5732ac55d26311a706c9c2bde9940',
23
+ // subject: 'Implementing new feature',
24
+ // status: 'NEW',
25
+ // created: '2023-12-01 10:00:00.000000000',
26
+ // updated: '2023-12-01 12:00:00.000000000',
27
+ // _number: 123456,
28
+ // owner: {
29
+ // _account_id: 1000000,
30
+ // name: 'John Doe',
31
+ // email: 'john.doe@example.com',
32
+ // },
33
+ // labels: {},
34
+ // permitted_labels: {},
35
+ // removable_reviewers: [],
36
+ // reviewers: {},
37
+ // requirements: [],
38
+ // }
39
+
40
+ export const setupFetchMock = () => {
41
+ return mock(async (url: string | URL, options?: RequestInit) => {
42
+ const urlStr = url.toString()
43
+ const method = options?.method || 'GET'
44
+
45
+ // Check authentication
46
+ const authHeader =
47
+ options?.headers && 'Authorization' in options.headers
48
+ ? (options.headers as Record<string, string>).Authorization
49
+ : undefined
50
+
51
+ if (!authHeader || !authHeader.startsWith('Basic ')) {
52
+ return new Response(`)]}'\n${JSON.stringify({ message: 'Unauthorized' })}`, { status: 401 })
53
+ }
54
+
55
+ // Authentication endpoint
56
+ if (urlStr.includes('/a/accounts/self')) {
57
+ return new Response(`)]}'\n${JSON.stringify(mockAccount)}`, { status: 200 })
58
+ }
59
+
60
+ // List changes endpoint (must come before get change endpoint)
61
+ if (urlStr.includes('/a/changes/?q=')) {
62
+ return new Response(`)]}'\n${JSON.stringify([mockChange])}`, { status: 200 })
63
+ }
64
+
65
+ // Get change endpoint
66
+ if (
67
+ urlStr.includes('/a/changes/') &&
68
+ method === 'GET' &&
69
+ !urlStr.includes('/files') &&
70
+ !urlStr.includes('/diff') &&
71
+ !urlStr.includes('/patch') &&
72
+ !urlStr.includes('/review')
73
+ ) {
74
+ if (urlStr.includes('notfound')) {
75
+ return new Response(`)]}'\n${JSON.stringify({ message: 'Not found' })}`, { status: 404 })
76
+ }
77
+
78
+ // Validate response against schema
79
+ const validated = Schema.decodeUnknownSync(ChangeInfo)(mockChange)
80
+ return new Response(`)]}'\n${JSON.stringify(validated)}`, { status: 200 })
81
+ }
82
+
83
+ // Get file diff endpoint - must be checked BEFORE other file endpoints
84
+ if (urlStr.includes('/files/') && urlStr.includes('/diff') && method === 'GET') {
85
+ return new Response(`)]}'\n${JSON.stringify(mockDiff)}`, { status: 200 })
86
+ }
87
+
88
+ // Get files endpoint (list of files)
89
+ if (
90
+ urlStr.includes('/files') &&
91
+ method === 'GET' &&
92
+ !urlStr.includes('/diff') &&
93
+ !urlStr.includes('/content')
94
+ ) {
95
+ return new Response(`)]}'\n${JSON.stringify(mockFiles)}`, { status: 200 })
96
+ }
97
+
98
+ // Get file content endpoint
99
+ if (urlStr.includes('/content') && method === 'GET' && !urlStr.includes('/diff')) {
100
+ const content =
101
+ 'function main() {\n console.log("Hello, world!")\n return process.exit(0)\n}'
102
+ const base64Content = btoa(content)
103
+ return new Response(base64Content, { status: 200 })
104
+ }
105
+
106
+ // Get patch endpoint
107
+ if (urlStr.includes('/patch') && method === 'GET') {
108
+ const patch = `--- a/src/main.ts
109
+ +++ b/src/main.ts
110
+ @@ -1,3 +1,3 @@
111
+ function main() {
112
+ console.log("Hello, world!")
113
+ - return 0
114
+ + return process.exit(0)
115
+ }`
116
+ const base64Patch = btoa(patch)
117
+ return new Response(base64Patch, { status: 200 })
118
+ }
119
+
120
+ // Post review endpoint
121
+ if (urlStr.includes('/review') && method === 'POST') {
122
+ return new Response(
123
+ ")]}'\n" +
124
+ JSON.stringify({
125
+ labels: {},
126
+ ready: true,
127
+ }),
128
+ { status: 200 },
129
+ )
130
+ }
131
+
132
+ // Default 404 for unhandled requests
133
+ return new Response(`)]}'\n${JSON.stringify({ message: 'Not found' })}`, { status: 404 })
134
+ })
135
+ }
136
+
137
+ export const restoreFetch: () => void = () => {
138
+ // Restore original fetch (Bun handles this automatically after tests)
139
+ }
@@ -0,0 +1,80 @@
1
+ import { HttpResponse, http } from 'msw'
2
+ import type { CommentInfo } from '@/schemas/gerrit'
3
+
4
+ export const commentHandlers = [
5
+ // Comments endpoint
6
+ http.get('*/a/changes/:changeId/revisions/:revisionId/comments', () => {
7
+ const mockComments: Record<string, CommentInfo[]> = {
8
+ '/COMMIT_MSG': [
9
+ {
10
+ id: 'comment1',
11
+ message: 'Please update the commit message',
12
+ author: {
13
+ name: 'Reviewer 1',
14
+ email: 'reviewer1@example.com',
15
+ _account_id: 1001,
16
+ },
17
+ updated: '2024-01-15 10:30:00.000000000',
18
+ unresolved: true,
19
+ line: 3,
20
+ },
21
+ ],
22
+ 'src/main.ts': [
23
+ {
24
+ id: 'comment2',
25
+ message: 'Consider using a more descriptive variable name',
26
+ author: {
27
+ name: 'Reviewer 2',
28
+ email: 'reviewer2@example.com',
29
+ _account_id: 1002,
30
+ },
31
+ updated: '2024-01-15 11:45:00.000000000',
32
+ unresolved: false,
33
+ line: 42,
34
+ },
35
+ {
36
+ id: 'comment3',
37
+ message: 'This could be simplified',
38
+ author: {
39
+ name: 'Reviewer 1',
40
+ _account_id: 1001,
41
+ },
42
+ updated: '2024-01-15 12:00:00.000000000',
43
+ line: 67,
44
+ },
45
+ ],
46
+ }
47
+
48
+ return HttpResponse.text(`)]}'\n${JSON.stringify(mockComments)}`)
49
+ }),
50
+
51
+ // File diff endpoint
52
+ http.get('*/a/changes/:changeId/revisions/:revisionId/files/:filePath/diff', () => {
53
+ const mockDiff = {
54
+ content: [
55
+ {
56
+ ab: ['function calculateTotal(items) {', ' let total = 0;'],
57
+ },
58
+ {
59
+ b: [
60
+ ' // TODO: Add validation',
61
+ ' for (const item of items) {',
62
+ ' total += item.price * item.quantity;',
63
+ ' }',
64
+ ],
65
+ },
66
+ {
67
+ ab: [' return total;', '}'],
68
+ },
69
+ ],
70
+ }
71
+
72
+ return HttpResponse.text(`)]}'\n${JSON.stringify(mockDiff)}`)
73
+ }),
74
+ ]
75
+
76
+ export const emptyCommentsHandlers = [
77
+ http.get('*/a/changes/:changeId/revisions/:revisionId/comments', () => {
78
+ return HttpResponse.text(`)]}'\n{}`)
79
+ }),
80
+ ]