@ksw8954/git-ai-commit 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +38 -0
- package/CRUSH.md +28 -0
- package/Makefile +32 -0
- package/README.md +145 -0
- package/dist/commands/ai.d.ts +35 -0
- package/dist/commands/ai.d.ts.map +1 -0
- package/dist/commands/ai.js +206 -0
- package/dist/commands/ai.js.map +1 -0
- package/dist/commands/commit.d.ts +17 -0
- package/dist/commands/commit.d.ts.map +1 -0
- package/dist/commands/commit.js +126 -0
- package/dist/commands/commit.js.map +1 -0
- package/dist/commands/config.d.ts +33 -0
- package/dist/commands/config.d.ts.map +1 -0
- package/dist/commands/config.js +141 -0
- package/dist/commands/config.js.map +1 -0
- package/dist/commands/configCommand.d.ts +20 -0
- package/dist/commands/configCommand.d.ts.map +1 -0
- package/dist/commands/configCommand.js +108 -0
- package/dist/commands/configCommand.js.map +1 -0
- package/dist/commands/git.d.ts +26 -0
- package/dist/commands/git.d.ts.map +1 -0
- package/dist/commands/git.js +150 -0
- package/dist/commands/git.js.map +1 -0
- package/dist/commands/loadEnv.d.ts +2 -0
- package/dist/commands/loadEnv.d.ts.map +1 -0
- package/dist/commands/loadEnv.js +11 -0
- package/dist/commands/loadEnv.js.map +1 -0
- package/dist/commands/prCommand.d.ts +16 -0
- package/dist/commands/prCommand.d.ts.map +1 -0
- package/dist/commands/prCommand.js +61 -0
- package/dist/commands/prCommand.js.map +1 -0
- package/dist/commands/tag.d.ts +17 -0
- package/dist/commands/tag.d.ts.map +1 -0
- package/dist/commands/tag.js +127 -0
- package/dist/commands/tag.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +23 -0
- package/dist/index.js.map +1 -0
- package/dist/prompts/commit.d.ts +3 -0
- package/dist/prompts/commit.d.ts.map +1 -0
- package/dist/prompts/commit.js +101 -0
- package/dist/prompts/commit.js.map +1 -0
- package/dist/prompts/pr.d.ts +3 -0
- package/dist/prompts/pr.d.ts.map +1 -0
- package/dist/prompts/pr.js +58 -0
- package/dist/prompts/pr.js.map +1 -0
- package/dist/prompts/tag.d.ts +3 -0
- package/dist/prompts/tag.d.ts.map +1 -0
- package/dist/prompts/tag.js +42 -0
- package/dist/prompts/tag.js.map +1 -0
- package/eslint.config.js +35 -0
- package/jest.config.js +16 -0
- package/package.json +51 -0
- package/src/__tests__/ai.test.ts +185 -0
- package/src/__tests__/commitCommand.test.ts +155 -0
- package/src/__tests__/config.test.ts +238 -0
- package/src/__tests__/git.test.ts +88 -0
- package/src/__tests__/integration.test.ts +138 -0
- package/src/__tests__/prCommand.test.ts +121 -0
- package/src/__tests__/tagCommand.test.ts +197 -0
- package/src/commands/ai.ts +266 -0
- package/src/commands/commit.ts +215 -0
- package/src/commands/config.ts +182 -0
- package/src/commands/configCommand.ts +139 -0
- package/src/commands/git.ts +174 -0
- package/src/commands/history.ts +82 -0
- package/src/commands/loadEnv.ts +5 -0
- package/src/commands/log.ts +71 -0
- package/src/commands/prCommand.ts +108 -0
- package/src/commands/tag.ts +230 -0
- package/src/index.ts +29 -0
- package/src/prompts/commit.ts +105 -0
- package/src/prompts/pr.ts +64 -0
- package/src/prompts/tag.ts +48 -0
- package/tsconfig.json +19 -0
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { AIService } from '../commands/ai';
|
|
2
|
+
import { generateCommitPrompt } from '../prompts/commit';
|
|
3
|
+
import { generatePullRequestPrompt } from '../prompts/pr';
|
|
4
|
+
import OpenAI from 'openai';
|
|
5
|
+
|
|
6
|
+
jest.mock('openai');
|
|
7
|
+
const MockedOpenAI = OpenAI as jest.MockedClass<typeof OpenAI>;
|
|
8
|
+
|
|
9
|
+
describe('AIService', () => {
|
|
10
|
+
let aiService: AIService;
|
|
11
|
+
let mockOpenai: jest.Mocked<OpenAI>;
|
|
12
|
+
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
jest.clearAllMocks();
|
|
15
|
+
|
|
16
|
+
mockOpenai = {
|
|
17
|
+
chat: {
|
|
18
|
+
completions: {
|
|
19
|
+
create: jest.fn()
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
} as any;
|
|
23
|
+
|
|
24
|
+
MockedOpenAI.mockImplementation(() => mockOpenai);
|
|
25
|
+
|
|
26
|
+
aiService = new AIService({
|
|
27
|
+
apiKey: 'test-api-key',
|
|
28
|
+
baseURL: 'https://api.test.com',
|
|
29
|
+
model: 'gpt-4'
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe('generateCommitMessage', () => {
|
|
34
|
+
it('should return success with commit message when API call succeeds', async () => {
|
|
35
|
+
const mockResponse = {
|
|
36
|
+
choices: [{
|
|
37
|
+
message: {
|
|
38
|
+
content: 'feat: add new feature'
|
|
39
|
+
}
|
|
40
|
+
}]
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
(mockOpenai.chat.completions.create as jest.Mock).mockResolvedValue(mockResponse);
|
|
44
|
+
|
|
45
|
+
const diff = 'diff --git a/file.txt b/file.txt\nnew file mode 100644';
|
|
46
|
+
const result = await aiService.generateCommitMessage(diff);
|
|
47
|
+
|
|
48
|
+
expect(result).toEqual({
|
|
49
|
+
success: true,
|
|
50
|
+
message: 'feat: add new feature'
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
expect(mockOpenai.chat.completions.create).toHaveBeenCalledWith({
|
|
54
|
+
model: 'gpt-4',
|
|
55
|
+
messages: [
|
|
56
|
+
{
|
|
57
|
+
role: 'system',
|
|
58
|
+
content: generateCommitPrompt(
|
|
59
|
+
'',
|
|
60
|
+
'Git diff will be provided separately in the user message.',
|
|
61
|
+
'ko'
|
|
62
|
+
)
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
role: 'user',
|
|
66
|
+
content: `Git diff:\n${diff}`
|
|
67
|
+
}
|
|
68
|
+
],
|
|
69
|
+
max_tokens: 3000,
|
|
70
|
+
temperature: 0.1
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('should return error when API returns no message', async () => {
|
|
75
|
+
const mockResponse = {
|
|
76
|
+
choices: [{
|
|
77
|
+
message: {
|
|
78
|
+
content: null
|
|
79
|
+
}
|
|
80
|
+
}]
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
(mockOpenai.chat.completions.create as jest.Mock).mockResolvedValue(mockResponse);
|
|
84
|
+
|
|
85
|
+
const diff = 'diff --git a/file.txt b/file.txt\nnew file mode 100644';
|
|
86
|
+
const result = await aiService.generateCommitMessage(diff);
|
|
87
|
+
|
|
88
|
+
expect(result).toEqual({
|
|
89
|
+
success: false,
|
|
90
|
+
error: 'No commit message generated'
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('should return error when API call fails', async () => {
|
|
95
|
+
const error = new Error('API call failed');
|
|
96
|
+
(mockOpenai.chat.completions.create as jest.Mock).mockRejectedValue(error);
|
|
97
|
+
|
|
98
|
+
const diff = 'diff --git a/file.txt b/file.txt\nnew file mode 100644';
|
|
99
|
+
const result = await aiService.generateCommitMessage(diff);
|
|
100
|
+
|
|
101
|
+
expect(result).toEqual({
|
|
102
|
+
success: false,
|
|
103
|
+
error: 'API call failed'
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('should use default model when not specified', () => {
|
|
108
|
+
const aiServiceWithDefaults = new AIService({
|
|
109
|
+
apiKey: 'test-api-key'
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
expect((aiServiceWithDefaults as any).model).toBe('zai-org/GLM-4.5-FP8');
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
describe('generatePullRequestMessage', () => {
|
|
117
|
+
it('should return success with PR message when API call succeeds', async () => {
|
|
118
|
+
const mockResponse = {
|
|
119
|
+
choices: [{
|
|
120
|
+
message: {
|
|
121
|
+
content: 'Add caching layer\n\n## Summary\n- cache expensive queries to reduce latency\n\n## Testing\n- npm run test'
|
|
122
|
+
}
|
|
123
|
+
}]
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
(mockOpenai.chat.completions.create as jest.Mock).mockResolvedValue(mockResponse);
|
|
127
|
+
|
|
128
|
+
const diff = 'diff --git a/app.ts b/app.ts\nindex 123..456 100644';
|
|
129
|
+
const result = await aiService.generatePullRequestMessage('main', 'feature/cache', diff);
|
|
130
|
+
|
|
131
|
+
expect(result).toEqual({
|
|
132
|
+
success: true,
|
|
133
|
+
message: mockResponse.choices[0].message.content
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
expect(mockOpenai.chat.completions.create).toHaveBeenCalledWith({
|
|
137
|
+
model: 'gpt-4',
|
|
138
|
+
messages: [
|
|
139
|
+
{
|
|
140
|
+
role: 'system',
|
|
141
|
+
content: generatePullRequestPrompt('main', 'feature/cache', '', 'ko')
|
|
142
|
+
},
|
|
143
|
+
{
|
|
144
|
+
role: 'user',
|
|
145
|
+
content: `Git diff between main and feature/cache:\n${diff}`
|
|
146
|
+
}
|
|
147
|
+
],
|
|
148
|
+
max_tokens: 4000,
|
|
149
|
+
temperature: 0.2
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('should return error when API returns no content', async () => {
|
|
154
|
+
const mockResponse = {
|
|
155
|
+
choices: [{
|
|
156
|
+
message: {
|
|
157
|
+
content: '',
|
|
158
|
+
reasoning_content: ''
|
|
159
|
+
}
|
|
160
|
+
}]
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
(mockOpenai.chat.completions.create as jest.Mock).mockResolvedValue(mockResponse);
|
|
164
|
+
|
|
165
|
+
const result = await aiService.generatePullRequestMessage('main', 'feature/cache', 'diff');
|
|
166
|
+
|
|
167
|
+
expect(result).toEqual({
|
|
168
|
+
success: false,
|
|
169
|
+
error: 'No pull request message generated'
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('should return error when API call fails', async () => {
|
|
174
|
+
const error = new Error('API failure');
|
|
175
|
+
(mockOpenai.chat.completions.create as jest.Mock).mockRejectedValue(error);
|
|
176
|
+
|
|
177
|
+
const result = await aiService.generatePullRequestMessage('main', 'feature/cache', 'diff');
|
|
178
|
+
|
|
179
|
+
expect(result).toEqual({
|
|
180
|
+
success: false,
|
|
181
|
+
error: 'API failure'
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
});
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { CommitCommand } from '../commands/commit';
|
|
2
|
+
import { GitService } from '../commands/git';
|
|
3
|
+
import { AIService } from '../commands/ai';
|
|
4
|
+
import { ConfigService } from '../commands/config';
|
|
5
|
+
|
|
6
|
+
const mockGenerateCommitMessage = jest.fn();
|
|
7
|
+
|
|
8
|
+
jest.mock('../commands/git', () => ({
|
|
9
|
+
GitService: {
|
|
10
|
+
getStagedDiff: jest.fn(),
|
|
11
|
+
createCommit: jest.fn(),
|
|
12
|
+
push: jest.fn()
|
|
13
|
+
}
|
|
14
|
+
}));
|
|
15
|
+
|
|
16
|
+
jest.mock('../commands/ai', () => ({
|
|
17
|
+
AIService: jest.fn().mockImplementation(() => ({
|
|
18
|
+
generateCommitMessage: mockGenerateCommitMessage
|
|
19
|
+
}))
|
|
20
|
+
}));
|
|
21
|
+
|
|
22
|
+
jest.mock('../commands/config', () => ({
|
|
23
|
+
ConfigService: {
|
|
24
|
+
getConfig: jest.fn(),
|
|
25
|
+
validateConfig: jest.fn()
|
|
26
|
+
}
|
|
27
|
+
}));
|
|
28
|
+
|
|
29
|
+
describe('CommitCommand', () => {
|
|
30
|
+
const exitSpy = jest.spyOn(process, 'exit').mockImplementation((() => undefined) as any);
|
|
31
|
+
|
|
32
|
+
beforeEach(() => {
|
|
33
|
+
jest.clearAllMocks();
|
|
34
|
+
|
|
35
|
+
(ConfigService.getConfig as jest.Mock).mockReturnValue({
|
|
36
|
+
apiKey: 'env-key',
|
|
37
|
+
baseURL: 'https://api.test',
|
|
38
|
+
model: 'test-model',
|
|
39
|
+
language: 'ko',
|
|
40
|
+
autoPush: false
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
(ConfigService.validateConfig as jest.Mock).mockReturnValue(undefined);
|
|
44
|
+
|
|
45
|
+
(GitService.getStagedDiff as jest.Mock).mockResolvedValue({
|
|
46
|
+
success: true,
|
|
47
|
+
diff: 'diff --git a/file b/file'
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
mockGenerateCommitMessage.mockResolvedValue({
|
|
51
|
+
success: true,
|
|
52
|
+
message: 'feat: test commit'
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
afterAll(() => {
|
|
57
|
+
exitSpy.mockRestore();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
const createCommand = () => new CommitCommand();
|
|
61
|
+
|
|
62
|
+
it('should create commit after user confirmation', async () => {
|
|
63
|
+
(GitService.createCommit as jest.Mock).mockResolvedValue(true);
|
|
64
|
+
|
|
65
|
+
const command = createCommand();
|
|
66
|
+
const confirmSpy = jest
|
|
67
|
+
.spyOn(command as any, 'confirmCommit')
|
|
68
|
+
.mockResolvedValue(true);
|
|
69
|
+
|
|
70
|
+
await (command as any).handleCommit({});
|
|
71
|
+
|
|
72
|
+
expect(confirmSpy).toHaveBeenCalled();
|
|
73
|
+
expect(GitService.createCommit).toHaveBeenCalledWith('feat: test commit');
|
|
74
|
+
expect(GitService.push).not.toHaveBeenCalled();
|
|
75
|
+
expect(exitSpy).not.toHaveBeenCalled();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('should skip commit when user declines confirmation', async () => {
|
|
79
|
+
const command = createCommand();
|
|
80
|
+
jest.spyOn(command as any, 'confirmCommit').mockResolvedValue(false);
|
|
81
|
+
|
|
82
|
+
await (command as any).handleCommit({});
|
|
83
|
+
|
|
84
|
+
expect(GitService.createCommit).not.toHaveBeenCalled();
|
|
85
|
+
expect(GitService.push).not.toHaveBeenCalled();
|
|
86
|
+
expect(exitSpy).not.toHaveBeenCalled();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('should output message only without git actions when message-only option is set', async () => {
|
|
90
|
+
const command = createCommand();
|
|
91
|
+
const confirmSpy = jest.spyOn(command as any, 'confirmCommit');
|
|
92
|
+
const logSpy = jest.spyOn(console, 'log').mockImplementation(() => undefined);
|
|
93
|
+
|
|
94
|
+
try {
|
|
95
|
+
await (command as any).handleCommit({ messageOnly: true });
|
|
96
|
+
|
|
97
|
+
expect(confirmSpy).not.toHaveBeenCalled();
|
|
98
|
+
expect(GitService.createCommit).not.toHaveBeenCalled();
|
|
99
|
+
expect(GitService.push).not.toHaveBeenCalled();
|
|
100
|
+
expect(logSpy).toHaveBeenCalledTimes(1);
|
|
101
|
+
expect(logSpy).toHaveBeenCalledWith('feat: test commit');
|
|
102
|
+
expect(exitSpy).not.toHaveBeenCalled();
|
|
103
|
+
} finally {
|
|
104
|
+
logSpy.mockRestore();
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('should commit and push when push option is provided', async () => {
|
|
109
|
+
(GitService.createCommit as jest.Mock).mockResolvedValue(true);
|
|
110
|
+
(GitService.push as jest.Mock).mockResolvedValue(true);
|
|
111
|
+
|
|
112
|
+
const command = createCommand();
|
|
113
|
+
jest.spyOn(command as any, 'confirmCommit').mockResolvedValue(true);
|
|
114
|
+
|
|
115
|
+
await (command as any).handleCommit({ push: true });
|
|
116
|
+
|
|
117
|
+
expect(GitService.createCommit).toHaveBeenCalledWith('feat: test commit');
|
|
118
|
+
expect(GitService.push).toHaveBeenCalled();
|
|
119
|
+
expect(exitSpy).not.toHaveBeenCalled();
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('should exit when commit creation fails', async () => {
|
|
123
|
+
(GitService.createCommit as jest.Mock).mockResolvedValue(false);
|
|
124
|
+
|
|
125
|
+
const command = createCommand();
|
|
126
|
+
jest.spyOn(command as any, 'confirmCommit').mockResolvedValue(true);
|
|
127
|
+
|
|
128
|
+
await (command as any).handleCommit({});
|
|
129
|
+
|
|
130
|
+
expect(GitService.createCommit).toHaveBeenCalledWith('feat: test commit');
|
|
131
|
+
expect(exitSpy).toHaveBeenCalledWith(1);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('should push automatically when autoPush is enabled in config', async () => {
|
|
135
|
+
(GitService.createCommit as jest.Mock).mockResolvedValue(true);
|
|
136
|
+
(GitService.push as jest.Mock).mockResolvedValue(true);
|
|
137
|
+
|
|
138
|
+
(ConfigService.getConfig as jest.Mock).mockReturnValueOnce({
|
|
139
|
+
apiKey: 'env-key',
|
|
140
|
+
baseURL: 'https://api.test',
|
|
141
|
+
model: 'test-model',
|
|
142
|
+
language: 'ko',
|
|
143
|
+
autoPush: true
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
const command = createCommand();
|
|
147
|
+
jest.spyOn(command as any, 'confirmCommit').mockResolvedValue(true);
|
|
148
|
+
|
|
149
|
+
await (command as any).handleCommit({});
|
|
150
|
+
|
|
151
|
+
expect(GitService.createCommit).toHaveBeenCalledWith('feat: test commit');
|
|
152
|
+
expect(GitService.push).toHaveBeenCalledTimes(1);
|
|
153
|
+
expect(exitSpy).not.toHaveBeenCalled();
|
|
154
|
+
});
|
|
155
|
+
});
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import os from 'os';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import { ConfigService } from '../commands/config';
|
|
5
|
+
|
|
6
|
+
describe('ConfigService', () => {
|
|
7
|
+
const originalEnv = process.env;
|
|
8
|
+
let tempDir: string;
|
|
9
|
+
let configPath: string;
|
|
10
|
+
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
jest.resetModules();
|
|
13
|
+
process.env = {
|
|
14
|
+
PATH: originalEnv.PATH,
|
|
15
|
+
NODE_ENV: 'test'
|
|
16
|
+
} as NodeJS.ProcessEnv;
|
|
17
|
+
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'git-ai-config-'));
|
|
18
|
+
configPath = path.join(tempDir, 'config.json');
|
|
19
|
+
process.env.GIT_AI_COMMIT_CONFIG_PATH = configPath;
|
|
20
|
+
if (fs.existsSync(configPath)) {
|
|
21
|
+
fs.rmSync(configPath, { force: true });
|
|
22
|
+
}
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
afterEach(() => {
|
|
26
|
+
if (fs.existsSync(configPath)) {
|
|
27
|
+
fs.rmSync(configPath, { force: true });
|
|
28
|
+
}
|
|
29
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
30
|
+
process.env = originalEnv;
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe('getConfig', () => {
|
|
34
|
+
|
|
35
|
+
it('uses OPENAI_API_KEY values when present', () => {
|
|
36
|
+
process.env.OPENAI_API_KEY = 'test-api-key';
|
|
37
|
+
process.env.OPENAI_BASE_URL = 'https://api.test.com';
|
|
38
|
+
process.env.OPENAI_MODEL = 'gpt-4';
|
|
39
|
+
|
|
40
|
+
const config = ConfigService.getConfig();
|
|
41
|
+
|
|
42
|
+
expect(config).toEqual({
|
|
43
|
+
apiKey: 'test-api-key',
|
|
44
|
+
baseURL: 'https://api.test.com',
|
|
45
|
+
model: 'gpt-4',
|
|
46
|
+
mode: 'custom',
|
|
47
|
+
language: 'ko',
|
|
48
|
+
autoPush: false
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('falls back to AI_API_KEY when others are missing', () => {
|
|
53
|
+
process.env.AI_API_KEY = 'fallback-api-key';
|
|
54
|
+
process.env.AI_BASE_URL = 'https://fallback.test.com';
|
|
55
|
+
process.env.AI_MODEL = 'claude-3';
|
|
56
|
+
|
|
57
|
+
const config = ConfigService.getConfig();
|
|
58
|
+
|
|
59
|
+
expect(config).toEqual({
|
|
60
|
+
apiKey: 'fallback-api-key',
|
|
61
|
+
baseURL: 'https://fallback.test.com',
|
|
62
|
+
model: 'claude-3',
|
|
63
|
+
mode: 'custom',
|
|
64
|
+
language: 'ko',
|
|
65
|
+
autoPush: false
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('prefers AI_* variables over others in custom mode', () => {
|
|
70
|
+
process.env.AI_API_KEY = 'ai-key';
|
|
71
|
+
process.env.OPENAI_API_KEY = 'openai-key';
|
|
72
|
+
process.env.AI_BASE_URL = 'ai-url';
|
|
73
|
+
process.env.OPENAI_BASE_URL = 'openai-url';
|
|
74
|
+
|
|
75
|
+
const config = ConfigService.getConfig();
|
|
76
|
+
|
|
77
|
+
expect(config).toEqual({
|
|
78
|
+
apiKey: 'ai-key',
|
|
79
|
+
baseURL: 'ai-url',
|
|
80
|
+
model: 'zai-org/GLM-4.5-FP8',
|
|
81
|
+
mode: 'custom',
|
|
82
|
+
language: 'ko',
|
|
83
|
+
autoPush: false
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('uses OPENAI_* variables when mode is openai', () => {
|
|
88
|
+
process.env.AI_MODE = 'openai';
|
|
89
|
+
process.env.OPENAI_API_KEY = 'openai-key';
|
|
90
|
+
process.env.AI_API_KEY = 'ai-key';
|
|
91
|
+
process.env.OPENAI_BASE_URL = 'openai-url';
|
|
92
|
+
process.env.AI_BASE_URL = 'ai-url';
|
|
93
|
+
process.env.OPENAI_MODEL = 'gpt-4';
|
|
94
|
+
process.env.AI_MODEL = 'claude-3';
|
|
95
|
+
|
|
96
|
+
const config = ConfigService.getConfig();
|
|
97
|
+
|
|
98
|
+
expect(config).toEqual({
|
|
99
|
+
apiKey: 'openai-key',
|
|
100
|
+
baseURL: 'openai-url',
|
|
101
|
+
model: 'gpt-4',
|
|
102
|
+
mode: 'openai',
|
|
103
|
+
language: 'ko',
|
|
104
|
+
autoPush: false
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('falls back to AI_* variables in openai mode when OPENAI_* missing', () => {
|
|
109
|
+
process.env.AI_MODE = 'openai';
|
|
110
|
+
process.env.AI_API_KEY = 'ai-key';
|
|
111
|
+
process.env.AI_BASE_URL = 'ai-url';
|
|
112
|
+
process.env.AI_MODEL = 'claude-3';
|
|
113
|
+
|
|
114
|
+
const config = ConfigService.getConfig();
|
|
115
|
+
|
|
116
|
+
expect(config).toEqual({
|
|
117
|
+
apiKey: 'ai-key',
|
|
118
|
+
baseURL: 'ai-url',
|
|
119
|
+
model: 'claude-3',
|
|
120
|
+
mode: 'openai',
|
|
121
|
+
language: 'ko',
|
|
122
|
+
autoPush: false
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('uses default model when none provided', () => {
|
|
127
|
+
process.env.OPENAI_API_KEY = 'test-key';
|
|
128
|
+
delete process.env.OPENAI_MODEL;
|
|
129
|
+
delete process.env.AI_MODEL;
|
|
130
|
+
|
|
131
|
+
const config = ConfigService.getConfig();
|
|
132
|
+
|
|
133
|
+
expect(config.model).toBe('zai-org/GLM-4.5-FP8');
|
|
134
|
+
expect(config.mode).toBe('custom');
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('allows configuration file to override environment values', async () => {
|
|
138
|
+
process.env.AI_API_KEY = 'env-key';
|
|
139
|
+
process.env.AI_BASE_URL = 'https://env.example';
|
|
140
|
+
process.env.AI_MODEL = 'env-model';
|
|
141
|
+
|
|
142
|
+
await ConfigService.updateConfig({
|
|
143
|
+
apiKey: 'file-key',
|
|
144
|
+
baseURL: 'https://file.example',
|
|
145
|
+
model: 'file-model',
|
|
146
|
+
language: 'en',
|
|
147
|
+
autoPush: true,
|
|
148
|
+
mode: 'openai'
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
const config = ConfigService.getConfig();
|
|
152
|
+
|
|
153
|
+
expect(config).toEqual({
|
|
154
|
+
apiKey: 'file-key',
|
|
155
|
+
baseURL: 'https://file.example',
|
|
156
|
+
model: 'file-model',
|
|
157
|
+
mode: 'openai',
|
|
158
|
+
language: 'en',
|
|
159
|
+
autoPush: true
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('persists mode and model updates from config file', async () => {
|
|
164
|
+
await ConfigService.updateConfig({
|
|
165
|
+
apiKey: 'file-key',
|
|
166
|
+
baseURL: 'https://file.example',
|
|
167
|
+
model: 'gpt-4o-mini',
|
|
168
|
+
mode: 'openai'
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
const config = ConfigService.getConfig();
|
|
172
|
+
|
|
173
|
+
expect(config.model).toBe('gpt-4o-mini');
|
|
174
|
+
expect(config.mode).toBe('openai');
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('normalizes invalid mode values to the default', async () => {
|
|
178
|
+
const filePath = process.env.GIT_AI_COMMIT_CONFIG_PATH!;
|
|
179
|
+
fs.writeFileSync(filePath, JSON.stringify({ mode: 'invalid-mode' }));
|
|
180
|
+
|
|
181
|
+
const config = ConfigService.getConfig();
|
|
182
|
+
|
|
183
|
+
expect(config.mode).toBe('custom');
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('removes stored mode when resetting to the default', async () => {
|
|
187
|
+
await ConfigService.updateConfig({ mode: 'openai' });
|
|
188
|
+
|
|
189
|
+
let raw = fs.readFileSync(process.env.GIT_AI_COMMIT_CONFIG_PATH!, 'utf-8');
|
|
190
|
+
let parsed = JSON.parse(raw);
|
|
191
|
+
expect(parsed.mode).toBe('openai');
|
|
192
|
+
|
|
193
|
+
await ConfigService.updateConfig({ mode: 'custom' });
|
|
194
|
+
|
|
195
|
+
raw = fs.readFileSync(process.env.GIT_AI_COMMIT_CONFIG_PATH!, 'utf-8');
|
|
196
|
+
parsed = JSON.parse(raw);
|
|
197
|
+
expect(parsed.mode).toBeUndefined();
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
describe('validateConfig', () => {
|
|
202
|
+
it('does not throw for valid configuration', () => {
|
|
203
|
+
const config = {
|
|
204
|
+
apiKey: 'test-key',
|
|
205
|
+
language: 'ko'
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
expect(() => ConfigService.validateConfig(config)).not.toThrow();
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it('throws when API key is missing', () => {
|
|
212
|
+
const config = {
|
|
213
|
+
apiKey: '',
|
|
214
|
+
language: 'en'
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
expect(() => ConfigService.validateConfig(config)).toThrow('API key is required');
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it('throws when language is invalid', () => {
|
|
221
|
+
const config = {
|
|
222
|
+
apiKey: 'test',
|
|
223
|
+
language: 'jp'
|
|
224
|
+
} as any;
|
|
225
|
+
|
|
226
|
+
expect(() => ConfigService.validateConfig(config)).toThrow('Unsupported language. Use "ko" or "en".');
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it('throws when merged configuration lacks API key', () => {
|
|
230
|
+
delete process.env.OPENAI_API_KEY;
|
|
231
|
+
delete process.env.AI_API_KEY;
|
|
232
|
+
|
|
233
|
+
const config = ConfigService.getConfig();
|
|
234
|
+
|
|
235
|
+
expect(() => ConfigService.validateConfig(config)).toThrow('API key is required');
|
|
236
|
+
});
|
|
237
|
+
});
|
|
238
|
+
});
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { GitService } from '../commands/git';
|
|
2
|
+
|
|
3
|
+
// Mock the entire module at the top level
|
|
4
|
+
jest.mock('../commands/git', () => {
|
|
5
|
+
const getStagedDiff = jest.fn().mockResolvedValue({
|
|
6
|
+
success: true,
|
|
7
|
+
diff: 'mock diff'
|
|
8
|
+
});
|
|
9
|
+
const createCommit = jest.fn().mockResolvedValue(true);
|
|
10
|
+
|
|
11
|
+
return {
|
|
12
|
+
GitService: {
|
|
13
|
+
getStagedDiff,
|
|
14
|
+
createCommit
|
|
15
|
+
}
|
|
16
|
+
};
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
describe('GitService', () => {
|
|
20
|
+
beforeEach(() => {
|
|
21
|
+
jest.clearAllMocks();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe('getStagedDiff', () => {
|
|
25
|
+
it('should return success with diff when staged changes exist', async () => {
|
|
26
|
+
const mockDiff = 'diff --git a/file.txt b/file.txt\nnew file mode 100644';
|
|
27
|
+
|
|
28
|
+
// Mock the implementation for this specific test
|
|
29
|
+
(GitService.getStagedDiff as jest.Mock).mockResolvedValueOnce({
|
|
30
|
+
success: true,
|
|
31
|
+
diff: mockDiff
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
const result = await GitService.getStagedDiff();
|
|
35
|
+
|
|
36
|
+
expect(result).toEqual({
|
|
37
|
+
success: true,
|
|
38
|
+
diff: mockDiff
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('should return error when no staged changes', async () => {
|
|
43
|
+
(GitService.getStagedDiff as jest.Mock).mockResolvedValueOnce({
|
|
44
|
+
success: false,
|
|
45
|
+
error: 'No staged changes found. Please stage your changes first.'
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const result = await GitService.getStagedDiff();
|
|
49
|
+
|
|
50
|
+
expect(result).toEqual({
|
|
51
|
+
success: false,
|
|
52
|
+
error: 'No staged changes found. Please stage your changes first.'
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('should return error when git command fails', async () => {
|
|
57
|
+
(GitService.getStagedDiff as jest.Mock).mockResolvedValueOnce({
|
|
58
|
+
success: false,
|
|
59
|
+
error: 'Git command failed'
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
const result = await GitService.getStagedDiff();
|
|
63
|
+
|
|
64
|
+
expect(result).toEqual({
|
|
65
|
+
success: false,
|
|
66
|
+
error: 'Git command failed'
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe('createCommit', () => {
|
|
72
|
+
it('should return true when commit is successful', async () => {
|
|
73
|
+
(GitService.createCommit as jest.Mock).mockResolvedValueOnce(true);
|
|
74
|
+
|
|
75
|
+
const result = await GitService.createCommit('feat: add new feature');
|
|
76
|
+
|
|
77
|
+
expect(result).toBe(true);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('should return false when commit fails', async () => {
|
|
81
|
+
(GitService.createCommit as jest.Mock).mockResolvedValueOnce(false);
|
|
82
|
+
|
|
83
|
+
const result = await GitService.createCommit('feat: add new feature');
|
|
84
|
+
|
|
85
|
+
expect(result).toBe(false);
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
});
|