@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,182 @@
1
+ import { Context, Data, Effect, Layer } from 'effect'
2
+ import { exec } from 'node:child_process'
3
+ import { promisify } from 'node:util'
4
+
5
+ const execAsync = promisify(exec)
6
+
7
+ // Error types
8
+ export class AiServiceError extends Data.TaggedError('AiServiceError')<{
9
+ message: string
10
+ cause?: unknown
11
+ }> {}
12
+
13
+ export class NoAiToolFoundError extends Data.TaggedError('NoAiToolFoundError')<{
14
+ message: string
15
+ }> {}
16
+
17
+ export class AiResponseParseError extends Data.TaggedError('AiResponseParseError')<{
18
+ message: string
19
+ rawOutput: string
20
+ }> {}
21
+
22
+ // Service interface
23
+ export class AiService extends Context.Tag('AiService')<
24
+ AiService,
25
+ {
26
+ readonly runPrompt: (
27
+ prompt: string,
28
+ input: string,
29
+ ) => Effect.Effect<string, AiServiceError | NoAiToolFoundError | AiResponseParseError>
30
+ readonly detectAiTool: () => Effect.Effect<string, NoAiToolFoundError>
31
+ readonly extractResponseTag: (output: string) => Effect.Effect<string, AiResponseParseError>
32
+ }
33
+ >() {}
34
+
35
+ // Service implementation
36
+ export const AiServiceLive = Layer.succeed(
37
+ AiService,
38
+ AiService.of({
39
+ detectAiTool: () =>
40
+ Effect.gen(function* () {
41
+ // Try to detect available AI tools in order of preference
42
+ const tools = ['claude', 'llm', 'opencode', 'gemini']
43
+
44
+ for (const tool of tools) {
45
+ const result = yield* Effect.tryPromise({
46
+ try: () => execAsync(`which ${tool}`),
47
+ catch: () => null,
48
+ }).pipe(Effect.orElseSucceed(() => null))
49
+
50
+ if (result && result.stdout.trim()) {
51
+ return tool
52
+ }
53
+ }
54
+
55
+ return yield* Effect.fail(
56
+ new NoAiToolFoundError({
57
+ message: 'No AI tool found. Please install claude, llm, opencode, or gemini CLI.',
58
+ }),
59
+ )
60
+ }),
61
+
62
+ extractResponseTag: (output: string) =>
63
+ Effect.gen(function* () {
64
+ // Extract content between <response> tags
65
+ const responseMatch = output.match(/<response>([\s\S]*?)<\/response>/i)
66
+
67
+ if (!responseMatch || !responseMatch[1]) {
68
+ return yield* Effect.fail(
69
+ new AiResponseParseError({
70
+ message: 'No <response> tag found in AI output',
71
+ rawOutput: output,
72
+ }),
73
+ )
74
+ }
75
+
76
+ return responseMatch[1].trim()
77
+ }),
78
+
79
+ runPrompt: (prompt: string, input: string) =>
80
+ Effect.gen(function* () {
81
+ const tool = yield* Effect.gen(function* () {
82
+ // Try to detect available AI tools in order of preference
83
+ const tools = ['claude', 'llm', 'opencode', 'gemini']
84
+
85
+ for (const tool of tools) {
86
+ const result = yield* Effect.tryPromise({
87
+ try: () => execAsync(`which ${tool}`),
88
+ catch: () => null,
89
+ }).pipe(Effect.orElseSucceed(() => null))
90
+
91
+ if (result && result.stdout.trim()) {
92
+ return tool
93
+ }
94
+ }
95
+
96
+ return yield* Effect.fail(
97
+ new NoAiToolFoundError({
98
+ message: 'No AI tool found. Please install claude, llm, opencode, or gemini CLI.',
99
+ }),
100
+ )
101
+ })
102
+
103
+ // Prepare the command based on the tool
104
+ const fullInput = `${prompt}\n\n${input}`
105
+ let command: string
106
+
107
+ switch (tool) {
108
+ case 'claude':
109
+ // Claude CLI uses -p flag for piped input
110
+ command = 'claude -p'
111
+ break
112
+ case 'llm':
113
+ // LLM CLI syntax
114
+ command = 'llm'
115
+ break
116
+ case 'opencode':
117
+ // Opencode CLI syntax
118
+ command = 'opencode'
119
+ break
120
+ default:
121
+ command = tool
122
+ }
123
+
124
+ // Run the AI tool with the prompt and input
125
+ const result = yield* Effect.tryPromise({
126
+ try: async () => {
127
+ const child = require('node:child_process').spawn(command, {
128
+ shell: true,
129
+ stdio: ['pipe', 'pipe', 'pipe'],
130
+ })
131
+
132
+ // Write input to stdin
133
+ child.stdin.write(fullInput)
134
+ child.stdin.end()
135
+
136
+ // Collect output
137
+ let stdout = ''
138
+ let stderr = ''
139
+
140
+ child.stdout.on('data', (data: Buffer) => {
141
+ stdout += data.toString()
142
+ })
143
+
144
+ child.stderr.on('data', (data: Buffer) => {
145
+ stderr += data.toString()
146
+ })
147
+
148
+ return new Promise<{ stdout: string; stderr: string }>((resolve, reject) => {
149
+ child.on('close', (code: number) => {
150
+ if (code !== 0) {
151
+ reject(new Error(`AI tool exited with code ${code}: ${stderr}`))
152
+ } else {
153
+ resolve({ stdout, stderr })
154
+ }
155
+ })
156
+
157
+ child.on('error', reject)
158
+ })
159
+ },
160
+ catch: (error) =>
161
+ new AiServiceError({
162
+ message: `Failed to run AI tool: ${error instanceof Error ? error.message : String(error)}`,
163
+ cause: error,
164
+ }),
165
+ })
166
+
167
+ // Extract response tag
168
+ const responseMatch = result.stdout.match(/<response>([\s\S]*?)<\/response>/i)
169
+
170
+ if (!responseMatch || !responseMatch[1]) {
171
+ return yield* Effect.fail(
172
+ new AiResponseParseError({
173
+ message: 'No <response> tag found in AI output',
174
+ rawOutput: result.stdout,
175
+ }),
176
+ )
177
+ }
178
+
179
+ return responseMatch[1].trim()
180
+ }),
181
+ }),
182
+ )
@@ -0,0 +1,414 @@
1
+ import { describe, expect, test, beforeEach, afterEach } from 'bun:test'
2
+ import * as fs from 'node:fs'
3
+ import * as os from 'node:os'
4
+ import * as path from 'node:path'
5
+ import type { GerritCredentials } from '@/schemas/gerrit'
6
+ import type { AiConfig, AppConfig } from '@/schemas/config'
7
+ import { migrateFromNestedConfig, aiConfigFromFlat } from '@/schemas/config'
8
+
9
+ // Use a temporary test directory
10
+ const TEST_HOME = path.join(
11
+ os.tmpdir(),
12
+ 'ger-test-config-' + Math.random().toString(36).substring(7),
13
+ )
14
+ const TEST_CONFIG_DIR = path.join(TEST_HOME, '.ger')
15
+ const TEST_CONFIG_FILE = path.join(TEST_CONFIG_DIR, 'config.json')
16
+
17
+ // Simple test service that mimics the config service functionality
18
+ class TestConfigService {
19
+ private configDir = TEST_CONFIG_DIR
20
+ private configFile = TEST_CONFIG_FILE
21
+
22
+ constructor() {
23
+ this.ensureConfigDir()
24
+ }
25
+
26
+ private ensureConfigDir() {
27
+ if (!fs.existsSync(this.configDir)) {
28
+ fs.mkdirSync(this.configDir, { recursive: true, mode: 0o700 })
29
+ }
30
+ }
31
+
32
+ private readFileConfig(): unknown | null {
33
+ try {
34
+ if (fs.existsSync(this.configFile)) {
35
+ const content = fs.readFileSync(this.configFile, 'utf8')
36
+ const parsed = JSON.parse(content)
37
+
38
+ // Check if this is the old nested format and migrate if needed
39
+ if (parsed && typeof parsed === 'object' && 'credentials' in parsed) {
40
+ // Migrate from nested format to flat format with validation
41
+ const migrated = migrateFromNestedConfig(parsed)
42
+
43
+ // Save the migrated config immediately
44
+ try {
45
+ this.writeFileConfig(migrated)
46
+ } catch {
47
+ // If write fails, still return the migrated config
48
+ }
49
+
50
+ return migrated
51
+ }
52
+
53
+ return parsed
54
+ }
55
+ } catch {
56
+ // Ignore errors
57
+ }
58
+ return null
59
+ }
60
+
61
+ private writeFileConfig(config: AppConfig): void {
62
+ fs.writeFileSync(this.configFile, JSON.stringify(config, null, 2), 'utf8')
63
+ fs.chmodSync(this.configFile, 0o600)
64
+ }
65
+
66
+ async getFullConfig(): Promise<AppConfig> {
67
+ const fileContent = this.readFileConfig()
68
+ if (!fileContent) {
69
+ throw new Error('Configuration not found. Run "ger setup" to set up your credentials.')
70
+ }
71
+
72
+ // Simple validation - would use Schema in real implementation
73
+ const config = fileContent as AppConfig
74
+ if (!config.host || !config.username || !config.password) {
75
+ throw new Error('Invalid configuration format')
76
+ }
77
+
78
+ return config
79
+ }
80
+
81
+ async saveFullConfig(config: AppConfig): Promise<void> {
82
+ // Simple validation - would use Schema in real implementation
83
+ if (!config.host || !config.username || !config.password) {
84
+ throw new Error('Invalid configuration format')
85
+ }
86
+
87
+ this.writeFileConfig(config)
88
+ }
89
+
90
+ async getCredentials(): Promise<GerritCredentials> {
91
+ const config = await this.getFullConfig()
92
+ return {
93
+ host: config.host,
94
+ username: config.username,
95
+ password: config.password,
96
+ }
97
+ }
98
+
99
+ async saveCredentials(credentials: GerritCredentials): Promise<void> {
100
+ // Get existing config or create new one
101
+ let existingConfig: AppConfig
102
+ try {
103
+ existingConfig = await this.getFullConfig()
104
+ } catch {
105
+ existingConfig = {
106
+ host: credentials.host,
107
+ username: credentials.username,
108
+ password: credentials.password,
109
+ aiAutoDetect: true,
110
+ }
111
+ }
112
+
113
+ // Update credentials in flat config
114
+ const updatedConfig: AppConfig = {
115
+ ...existingConfig,
116
+ host: credentials.host,
117
+ username: credentials.username,
118
+ password: credentials.password,
119
+ }
120
+
121
+ await this.saveFullConfig(updatedConfig)
122
+ }
123
+
124
+ async getAiConfig(): Promise<AiConfig> {
125
+ const config = await this.getFullConfig()
126
+ return aiConfigFromFlat(config)
127
+ }
128
+
129
+ async saveAiConfig(aiConfig: AiConfig): Promise<void> {
130
+ const existingConfig = await this.getFullConfig()
131
+
132
+ // Update AI config in flat structure
133
+ const updatedConfig: AppConfig = {
134
+ ...existingConfig,
135
+ aiTool: aiConfig.tool,
136
+ aiAutoDetect: aiConfig.autoDetect,
137
+ }
138
+
139
+ await this.saveFullConfig(updatedConfig)
140
+ }
141
+
142
+ cleanup() {
143
+ if (fs.existsSync(TEST_HOME)) {
144
+ fs.rmSync(TEST_HOME, { recursive: true, force: true })
145
+ }
146
+ }
147
+ }
148
+
149
+ describe('ConfigService Integration Tests', () => {
150
+ let configService: TestConfigService
151
+
152
+ beforeEach(() => {
153
+ configService = new TestConfigService()
154
+ })
155
+
156
+ afterEach(() => {
157
+ configService.cleanup()
158
+ })
159
+
160
+ describe('Flat Configuration', () => {
161
+ test('saves and loads flat config', async () => {
162
+ const testConfig: AppConfig = {
163
+ host: 'https://gerrit.example.com',
164
+ username: 'testuser',
165
+ password: 'testpass123',
166
+ aiTool: 'claude',
167
+ aiAutoDetect: true,
168
+ }
169
+
170
+ // Save config
171
+ await configService.saveFullConfig(testConfig)
172
+
173
+ // Verify file was created
174
+ expect(fs.existsSync(TEST_CONFIG_FILE)).toBe(true)
175
+
176
+ // Load config
177
+ const loadedConfig = await configService.getFullConfig()
178
+ expect(loadedConfig).toEqual(testConfig)
179
+ })
180
+
181
+ test('migrates nested config to flat config automatically', async () => {
182
+ const nestedConfig = {
183
+ credentials: {
184
+ host: 'https://gerrit.example.com',
185
+ username: 'testuser',
186
+ password: 'testpass123',
187
+ },
188
+ ai: {
189
+ tool: 'claude',
190
+ autoDetect: false,
191
+ },
192
+ }
193
+
194
+ const expectedFlatConfig: AppConfig = {
195
+ host: 'https://gerrit.example.com',
196
+ username: 'testuser',
197
+ password: 'testpass123',
198
+ aiTool: 'claude',
199
+ aiAutoDetect: false,
200
+ }
201
+
202
+ // Write nested config directly to file
203
+ fs.writeFileSync(TEST_CONFIG_FILE, JSON.stringify(nestedConfig, null, 2), 'utf8')
204
+
205
+ // Load config (should trigger migration)
206
+ const loadedConfig = await configService.getFullConfig()
207
+ expect(loadedConfig).toEqual(expectedFlatConfig)
208
+
209
+ // Verify migration was written back to file
210
+ const fileContent = JSON.parse(fs.readFileSync(TEST_CONFIG_FILE, 'utf8'))
211
+ expect(fileContent).toEqual(expectedFlatConfig)
212
+ })
213
+
214
+ test('handles migration from nested config without AI section', async () => {
215
+ const nestedConfig = {
216
+ credentials: {
217
+ host: 'https://gerrit.example.com',
218
+ username: 'testuser',
219
+ password: 'testpass123',
220
+ },
221
+ }
222
+
223
+ const expectedFlatConfig: AppConfig = {
224
+ host: 'https://gerrit.example.com',
225
+ username: 'testuser',
226
+ password: 'testpass123',
227
+ aiAutoDetect: true, // default value
228
+ }
229
+
230
+ // Write nested config directly to file
231
+ fs.writeFileSync(TEST_CONFIG_FILE, JSON.stringify(nestedConfig, null, 2), 'utf8')
232
+
233
+ const loadedConfig = await configService.getFullConfig()
234
+ expect(loadedConfig).toEqual(expectedFlatConfig)
235
+ })
236
+ })
237
+
238
+ describe('Credentials Management', () => {
239
+ test('extracts credentials from flat config', async () => {
240
+ const testConfig: AppConfig = {
241
+ host: 'https://gerrit.example.com',
242
+ username: 'testuser',
243
+ password: 'testpass123',
244
+ aiAutoDetect: true,
245
+ }
246
+
247
+ await configService.saveFullConfig(testConfig)
248
+ const credentials = await configService.getCredentials()
249
+
250
+ expect(credentials).toEqual({
251
+ host: 'https://gerrit.example.com',
252
+ username: 'testuser',
253
+ password: 'testpass123',
254
+ })
255
+ })
256
+
257
+ test('saves credentials by updating flat config', async () => {
258
+ const existingConfig: AppConfig = {
259
+ host: 'https://old-gerrit.com',
260
+ username: 'olduser',
261
+ password: 'oldpass',
262
+ aiTool: 'llm',
263
+ aiAutoDetect: false,
264
+ }
265
+
266
+ const newCredentials: GerritCredentials = {
267
+ host: 'https://new-gerrit.com',
268
+ username: 'newuser',
269
+ password: 'newpass',
270
+ }
271
+
272
+ const expectedConfig: AppConfig = {
273
+ host: 'https://new-gerrit.com',
274
+ username: 'newuser',
275
+ password: 'newpass',
276
+ aiTool: 'llm', // preserved
277
+ aiAutoDetect: false, // preserved
278
+ }
279
+
280
+ await configService.saveFullConfig(existingConfig)
281
+ await configService.saveCredentials(newCredentials)
282
+
283
+ const updatedConfig = await configService.getFullConfig()
284
+ expect(updatedConfig).toEqual(expectedConfig)
285
+ })
286
+
287
+ test('creates new flat config when saving credentials to empty config', async () => {
288
+ const newCredentials: GerritCredentials = {
289
+ host: 'https://gerrit.example.com',
290
+ username: 'testuser',
291
+ password: 'testpass',
292
+ }
293
+
294
+ const expectedConfig: AppConfig = {
295
+ host: 'https://gerrit.example.com',
296
+ username: 'testuser',
297
+ password: 'testpass',
298
+ aiAutoDetect: true, // default
299
+ }
300
+
301
+ await configService.saveCredentials(newCredentials)
302
+
303
+ const savedConfig = await configService.getFullConfig()
304
+ expect(savedConfig).toEqual(expectedConfig)
305
+ })
306
+ })
307
+
308
+ describe('AI Configuration Management', () => {
309
+ test('extracts AI config from flat config', async () => {
310
+ const testConfig: AppConfig = {
311
+ host: 'https://gerrit.example.com',
312
+ username: 'testuser',
313
+ password: 'testpass123',
314
+ aiTool: 'claude',
315
+ aiAutoDetect: false,
316
+ }
317
+
318
+ await configService.saveFullConfig(testConfig)
319
+ const aiConfig = await configService.getAiConfig()
320
+
321
+ expect(aiConfig).toEqual({
322
+ tool: 'claude',
323
+ autoDetect: false,
324
+ })
325
+ })
326
+
327
+ test('provides default AI config when fields are undefined', async () => {
328
+ const testConfig: AppConfig = {
329
+ host: 'https://gerrit.example.com',
330
+ username: 'testuser',
331
+ password: 'testpass123',
332
+ aiAutoDetect: true, // required field
333
+ // aiTool undefined
334
+ }
335
+
336
+ await configService.saveFullConfig(testConfig)
337
+ const aiConfig = await configService.getAiConfig()
338
+
339
+ expect(aiConfig).toEqual({
340
+ tool: undefined,
341
+ autoDetect: true,
342
+ })
343
+ })
344
+
345
+ test('saves AI config by updating flat config', async () => {
346
+ const existingConfig: AppConfig = {
347
+ host: 'https://gerrit.example.com',
348
+ username: 'testuser',
349
+ password: 'testpass123',
350
+ aiTool: 'claude',
351
+ aiAutoDetect: true,
352
+ }
353
+
354
+ const newAiConfig: AiConfig = {
355
+ tool: 'llm',
356
+ autoDetect: false,
357
+ }
358
+
359
+ const expectedConfig: AppConfig = {
360
+ host: 'https://gerrit.example.com',
361
+ username: 'testuser',
362
+ password: 'testpass123',
363
+ aiTool: 'llm',
364
+ aiAutoDetect: false,
365
+ }
366
+
367
+ await configService.saveFullConfig(existingConfig)
368
+ await configService.saveAiConfig(newAiConfig)
369
+
370
+ const updatedConfig = await configService.getFullConfig()
371
+ expect(updatedConfig).toEqual(expectedConfig)
372
+ })
373
+ })
374
+
375
+ describe('Error Handling', () => {
376
+ test('throws error when no config file exists', async () => {
377
+ await expect(configService.getFullConfig()).rejects.toThrow(
378
+ 'Configuration not found. Run "ger setup" to set up your credentials.',
379
+ )
380
+ })
381
+
382
+ test('throws error for invalid config schema', async () => {
383
+ const invalidConfig = {
384
+ host: 'https://gerrit.example.com',
385
+ // missing username and password
386
+ }
387
+
388
+ fs.writeFileSync(TEST_CONFIG_FILE, JSON.stringify(invalidConfig, null, 2), 'utf8')
389
+
390
+ await expect(configService.getFullConfig()).rejects.toThrow('Invalid configuration format')
391
+ })
392
+ })
393
+
394
+ describe('File System Operations', () => {
395
+ test('creates config directory with correct permissions', async () => {
396
+ const testConfig: AppConfig = {
397
+ host: 'https://gerrit.example.com',
398
+ username: 'testuser',
399
+ password: 'testpass123',
400
+ aiAutoDetect: true,
401
+ }
402
+
403
+ await configService.saveFullConfig(testConfig)
404
+
405
+ // Verify directory exists
406
+ expect(fs.existsSync(TEST_CONFIG_DIR)).toBe(true)
407
+
408
+ // Verify file has restrictive permissions (600)
409
+ const stats = fs.statSync(TEST_CONFIG_FILE)
410
+ const mode = stats.mode & parseInt('777', 8)
411
+ expect(mode).toBe(parseInt('600', 8))
412
+ })
413
+ })
414
+ })