@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.
- package/.ast-grep/rules/no-as-casting.yml +13 -0
- package/.eslintrc.js +12 -0
- package/.github/workflows/ci-simple.yml +53 -0
- package/.github/workflows/ci.yml +171 -0
- package/.github/workflows/claude-code-review.yml +78 -0
- package/.github/workflows/claude.yml +64 -0
- package/.github/workflows/dependency-update.yml +84 -0
- package/.github/workflows/release.yml +166 -0
- package/.github/workflows/security-scan.yml +113 -0
- package/.github/workflows/security.yml +96 -0
- package/.husky/pre-commit +16 -0
- package/.husky/pre-push +25 -0
- package/.lintstagedrc.json +6 -0
- package/.tool-versions +1 -0
- package/CLAUDE.md +103 -0
- package/DEVELOPMENT.md +361 -0
- package/LICENSE +21 -0
- package/README.md +325 -0
- package/bin/ger +3 -0
- package/biome.json +36 -0
- package/bun.lock +688 -0
- package/bunfig.toml +8 -0
- package/oxlint.json +24 -0
- package/package.json +55 -0
- package/scripts/check-coverage.ts +69 -0
- package/scripts/check-file-size.ts +38 -0
- package/scripts/fix-test-mocks.ts +55 -0
- package/src/api/gerrit.ts +466 -0
- package/src/cli/commands/abandon.ts +65 -0
- package/src/cli/commands/comment.ts +460 -0
- package/src/cli/commands/comments.ts +85 -0
- package/src/cli/commands/diff.ts +71 -0
- package/src/cli/commands/incoming.ts +226 -0
- package/src/cli/commands/init.ts +164 -0
- package/src/cli/commands/mine.ts +115 -0
- package/src/cli/commands/open.ts +57 -0
- package/src/cli/commands/review.ts +593 -0
- package/src/cli/commands/setup.ts +230 -0
- package/src/cli/commands/show.ts +303 -0
- package/src/cli/commands/status.ts +35 -0
- package/src/cli/commands/workspace.ts +200 -0
- package/src/cli/index.ts +420 -0
- package/src/prompts/default-review.md +80 -0
- package/src/prompts/system-inline-review.md +88 -0
- package/src/prompts/system-overall-review.md +152 -0
- package/src/schemas/config.test.ts +245 -0
- package/src/schemas/config.ts +75 -0
- package/src/schemas/gerrit.ts +455 -0
- package/src/services/ai-enhanced.ts +167 -0
- package/src/services/ai.ts +182 -0
- package/src/services/config.test.ts +414 -0
- package/src/services/config.ts +206 -0
- package/src/test-utils/mock-generator.ts +73 -0
- package/src/utils/comment-formatters.ts +153 -0
- package/src/utils/diff-context.ts +103 -0
- package/src/utils/diff-formatters.ts +141 -0
- package/src/utils/formatters.ts +85 -0
- package/src/utils/message-filters.ts +26 -0
- package/src/utils/shell-safety.ts +117 -0
- package/src/utils/status-indicators.ts +100 -0
- package/src/utils/url-parser.test.ts +123 -0
- package/src/utils/url-parser.ts +91 -0
- package/tests/abandon.test.ts +163 -0
- package/tests/ai-service.test.ts +489 -0
- package/tests/comment-batch-advanced.test.ts +431 -0
- package/tests/comment-gerrit-api-compliance.test.ts +414 -0
- package/tests/comment.test.ts +707 -0
- package/tests/comments.test.ts +323 -0
- package/tests/config-service-simple.test.ts +100 -0
- package/tests/diff.test.ts +419 -0
- package/tests/helpers/config-mock.ts +27 -0
- package/tests/incoming.test.ts +357 -0
- package/tests/interactive-incoming.test.ts +173 -0
- package/tests/mine.test.ts +318 -0
- package/tests/mocks/fetch-mock.ts +139 -0
- package/tests/mocks/msw-handlers.ts +80 -0
- package/tests/open.test.ts +233 -0
- package/tests/review.test.ts +669 -0
- package/tests/setup.ts +13 -0
- package/tests/show.test.ts +439 -0
- package/tests/unit/schemas/gerrit.test.ts +85 -0
- package/tests/unit/test-utils/mock-generator.test.ts +154 -0
- package/tests/unit/utils/comment-formatters.test.ts +415 -0
- package/tests/unit/utils/diff-context.test.ts +171 -0
- package/tests/unit/utils/diff-formatters.test.ts +165 -0
- package/tests/unit/utils/formatters.test.ts +411 -0
- package/tests/unit/utils/message-filters.test.ts +227 -0
- package/tests/unit/utils/prompt-helpers.test.ts +175 -0
- package/tests/unit/utils/shell-safety.test.ts +230 -0
- package/tests/unit/utils/status-indicators.test.ts +137 -0
- 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
|
+
})
|