@vibedx/vibekit 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/LICENSE +21 -0
- package/README.md +368 -0
- package/assets/config.yml +35 -0
- package/assets/default.md +47 -0
- package/assets/instructions/README.md +46 -0
- package/assets/instructions/claude.md +83 -0
- package/assets/instructions/codex.md +19 -0
- package/index.js +106 -0
- package/package.json +90 -0
- package/src/commands/close/index.js +66 -0
- package/src/commands/close/index.test.js +235 -0
- package/src/commands/get-started/index.js +138 -0
- package/src/commands/get-started/index.test.js +246 -0
- package/src/commands/init/index.js +51 -0
- package/src/commands/init/index.test.js +159 -0
- package/src/commands/link/index.js +395 -0
- package/src/commands/link/index.test.js +28 -0
- package/src/commands/lint/index.js +657 -0
- package/src/commands/lint/index.test.js +569 -0
- package/src/commands/list/index.js +131 -0
- package/src/commands/list/index.test.js +153 -0
- package/src/commands/new/index.js +305 -0
- package/src/commands/new/index.test.js +256 -0
- package/src/commands/refine/index.js +741 -0
- package/src/commands/refine/index.test.js +28 -0
- package/src/commands/review/index.js +957 -0
- package/src/commands/review/index.test.js +193 -0
- package/src/commands/start/index.js +180 -0
- package/src/commands/start/index.test.js +88 -0
- package/src/commands/unlink/index.js +123 -0
- package/src/commands/unlink/index.test.js +22 -0
- package/src/utils/arrow-select.js +233 -0
- package/src/utils/cli.js +489 -0
- package/src/utils/cli.test.js +9 -0
- package/src/utils/git.js +146 -0
- package/src/utils/git.test.js +330 -0
- package/src/utils/index.js +193 -0
- package/src/utils/index.test.js +375 -0
- package/src/utils/prompts.js +47 -0
- package/src/utils/prompts.test.js +165 -0
- package/src/utils/test-helpers.js +492 -0
- package/src/utils/ticket.js +423 -0
- package/src/utils/ticket.test.js +190 -0
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { describe, it, expect } from '@jest/globals';
|
|
2
|
+
|
|
3
|
+
describe('cli utilities', () => {
|
|
4
|
+
it('should be a placeholder test file', () => {
|
|
5
|
+
// This is a placeholder test to prevent Jest from failing
|
|
6
|
+
// due to empty test suite
|
|
7
|
+
expect(true).toBe(true);
|
|
8
|
+
});
|
|
9
|
+
});
|
package/src/utils/git.js
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { execSync } from 'child_process';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Check if the current directory is within a git repository
|
|
7
|
+
* @returns {boolean} True if in a git repository, false otherwise
|
|
8
|
+
*/
|
|
9
|
+
function isGitRepository() {
|
|
10
|
+
try {
|
|
11
|
+
execSync('git rev-parse --is-inside-work-tree', { stdio: 'ignore' });
|
|
12
|
+
return true;
|
|
13
|
+
} catch (error) {
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Get the current branch name
|
|
20
|
+
* @returns {string|null} The current branch name or null if not in a git repository
|
|
21
|
+
*/
|
|
22
|
+
function getCurrentBranch() {
|
|
23
|
+
try {
|
|
24
|
+
return execSync('git branch --show-current', { encoding: 'utf-8' }).trim();
|
|
25
|
+
} catch (error) {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Check if a branch exists locally
|
|
32
|
+
* @param {string} branchName The branch name to check
|
|
33
|
+
* @returns {boolean} True if the branch exists locally, false otherwise
|
|
34
|
+
*/
|
|
35
|
+
function branchExistsLocally(branchName) {
|
|
36
|
+
try {
|
|
37
|
+
const result = execSync(`git show-ref --verify --quiet refs/heads/${branchName}`);
|
|
38
|
+
return true;
|
|
39
|
+
} catch (error) {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Check if a branch exists remotely
|
|
46
|
+
* @param {string} branchName The branch name to check
|
|
47
|
+
* @returns {boolean} True if the branch exists remotely, false otherwise
|
|
48
|
+
*/
|
|
49
|
+
function branchExistsRemotely(branchName) {
|
|
50
|
+
try {
|
|
51
|
+
const result = execSync(`git ls-remote --heads origin ${branchName}`, { encoding: 'utf-8' });
|
|
52
|
+
return result.trim() !== '';
|
|
53
|
+
} catch (error) {
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Get the default base branch (usually main or master)
|
|
60
|
+
* @returns {string} The default base branch name
|
|
61
|
+
*/
|
|
62
|
+
function getDefaultBaseBranch() {
|
|
63
|
+
try {
|
|
64
|
+
// Try to determine if main or master is the default branch
|
|
65
|
+
const branches = execSync('git branch -r', { encoding: 'utf-8' }).trim().split('\n');
|
|
66
|
+
|
|
67
|
+
// Check if origin/main exists
|
|
68
|
+
if (branches.some(branch => branch.trim() === 'origin/main')) {
|
|
69
|
+
return 'main';
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Check if origin/master exists
|
|
73
|
+
if (branches.some(branch => branch.trim() === 'origin/master')) {
|
|
74
|
+
return 'master';
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Default to main if we can't determine
|
|
78
|
+
return 'main';
|
|
79
|
+
} catch (error) {
|
|
80
|
+
return 'main'; // Default to main if there's an error
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Create and checkout a new branch
|
|
86
|
+
* @param {string} branchName The name of the branch to create
|
|
87
|
+
* @param {string} baseBranch The base branch to create from (default: main)
|
|
88
|
+
* @returns {boolean} True if successful, false otherwise
|
|
89
|
+
*/
|
|
90
|
+
function createAndCheckoutBranch(branchName, baseBranch = null) {
|
|
91
|
+
try {
|
|
92
|
+
const base = baseBranch || getDefaultBaseBranch();
|
|
93
|
+
|
|
94
|
+
// Make sure we have the latest from the base branch
|
|
95
|
+
try {
|
|
96
|
+
execSync(`git fetch origin ${base}`, { stdio: 'ignore' });
|
|
97
|
+
} catch (error) {
|
|
98
|
+
// Ignore fetch errors, we'll try to create the branch anyway
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Create and checkout the new branch
|
|
102
|
+
execSync(`git checkout -b ${branchName} origin/${base} || git checkout -b ${branchName} ${base}`, { stdio: 'pipe' });
|
|
103
|
+
return true;
|
|
104
|
+
} catch (error) {
|
|
105
|
+
console.error(`❌ Failed to create branch: ${error.message}`);
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Checkout an existing branch
|
|
112
|
+
* @param {string} branchName The name of the branch to checkout
|
|
113
|
+
* @returns {boolean} True if successful, false otherwise
|
|
114
|
+
*/
|
|
115
|
+
function checkoutBranch(branchName) {
|
|
116
|
+
try {
|
|
117
|
+
execSync(`git checkout ${branchName}`, { stdio: 'pipe' });
|
|
118
|
+
return true;
|
|
119
|
+
} catch (error) {
|
|
120
|
+
console.error(`❌ Failed to checkout branch: ${error.message}`);
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Get the status of the working directory
|
|
127
|
+
* @returns {string} The git status output
|
|
128
|
+
*/
|
|
129
|
+
function getGitStatus() {
|
|
130
|
+
try {
|
|
131
|
+
return execSync('git status --porcelain', { encoding: 'utf-8' }).trim();
|
|
132
|
+
} catch (error) {
|
|
133
|
+
return '';
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export {
|
|
138
|
+
isGitRepository,
|
|
139
|
+
getCurrentBranch,
|
|
140
|
+
branchExistsLocally,
|
|
141
|
+
branchExistsRemotely,
|
|
142
|
+
getDefaultBaseBranch,
|
|
143
|
+
createAndCheckoutBranch,
|
|
144
|
+
checkoutBranch,
|
|
145
|
+
getGitStatus
|
|
146
|
+
};
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, jest } from '@jest/globals';
|
|
2
|
+
import {
|
|
3
|
+
createTempDir,
|
|
4
|
+
cleanupTempDir,
|
|
5
|
+
mockConsole,
|
|
6
|
+
createMockGitRepo
|
|
7
|
+
} from './test-helpers.js';
|
|
8
|
+
|
|
9
|
+
// Mock child_process
|
|
10
|
+
const mockExecSync = jest.fn();
|
|
11
|
+
jest.unstable_mockModule('child_process', () => ({
|
|
12
|
+
execSync: mockExecSync
|
|
13
|
+
}));
|
|
14
|
+
|
|
15
|
+
describe('git utilities', () => {
|
|
16
|
+
let tempDir;
|
|
17
|
+
let consoleMock;
|
|
18
|
+
let gitModule;
|
|
19
|
+
|
|
20
|
+
beforeEach(async () => {
|
|
21
|
+
tempDir = createTempDir('git-test');
|
|
22
|
+
consoleMock = mockConsole();
|
|
23
|
+
mockExecSync.mockReset();
|
|
24
|
+
|
|
25
|
+
// Import after mocking
|
|
26
|
+
gitModule = await import('./git.js');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
afterEach(() => {
|
|
30
|
+
consoleMock.restore();
|
|
31
|
+
cleanupTempDir(tempDir);
|
|
32
|
+
jest.clearAllMocks();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe('isGitRepository', () => {
|
|
36
|
+
it('should return true when in git repository', () => {
|
|
37
|
+
// Arrange
|
|
38
|
+
mockExecSync.mockReturnValue('true');
|
|
39
|
+
|
|
40
|
+
// Act
|
|
41
|
+
const result = gitModule.isGitRepository();
|
|
42
|
+
|
|
43
|
+
// Assert
|
|
44
|
+
expect(result).toBe(true);
|
|
45
|
+
expect(mockExecSync).toHaveBeenCalledWith('git rev-parse --is-inside-work-tree', { stdio: 'ignore' });
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('should return false when not in git repository', () => {
|
|
49
|
+
// Arrange
|
|
50
|
+
mockExecSync.mockImplementation(() => {
|
|
51
|
+
throw new Error('Not a git repository');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
// Act
|
|
55
|
+
const result = gitModule.isGitRepository();
|
|
56
|
+
|
|
57
|
+
// Assert
|
|
58
|
+
expect(result).toBe(false);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe('getCurrentBranch', () => {
|
|
63
|
+
it('should return current branch name', () => {
|
|
64
|
+
// Arrange
|
|
65
|
+
mockExecSync.mockReturnValue('feature/test-branch\n');
|
|
66
|
+
|
|
67
|
+
// Act
|
|
68
|
+
const result = gitModule.getCurrentBranch();
|
|
69
|
+
|
|
70
|
+
// Assert
|
|
71
|
+
expect(result).toBe('feature/test-branch');
|
|
72
|
+
expect(mockExecSync).toHaveBeenCalledWith('git branch --show-current', { encoding: 'utf-8' });
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('should return null when git command fails', () => {
|
|
76
|
+
// Arrange
|
|
77
|
+
mockExecSync.mockImplementation(() => {
|
|
78
|
+
throw new Error('Not a git repository');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
// Act
|
|
82
|
+
const result = gitModule.getCurrentBranch();
|
|
83
|
+
|
|
84
|
+
// Assert
|
|
85
|
+
expect(result).toBe(null);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('should handle empty branch name', () => {
|
|
89
|
+
// Arrange
|
|
90
|
+
mockExecSync.mockReturnValue('');
|
|
91
|
+
|
|
92
|
+
// Act
|
|
93
|
+
const result = gitModule.getCurrentBranch();
|
|
94
|
+
|
|
95
|
+
// Assert
|
|
96
|
+
expect(result).toBe('');
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe('branchExistsLocally', () => {
|
|
101
|
+
it('should return true when branch exists locally', () => {
|
|
102
|
+
// Arrange
|
|
103
|
+
mockExecSync.mockReturnValue('');
|
|
104
|
+
|
|
105
|
+
// Act
|
|
106
|
+
const result = gitModule.branchExistsLocally('feature/test');
|
|
107
|
+
|
|
108
|
+
// Assert
|
|
109
|
+
expect(result).toBe(true);
|
|
110
|
+
expect(mockExecSync).toHaveBeenCalledWith('git show-ref --verify --quiet refs/heads/feature/test');
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('should return false when branch does not exist locally', () => {
|
|
114
|
+
// Arrange
|
|
115
|
+
mockExecSync.mockImplementation(() => {
|
|
116
|
+
throw new Error('Branch not found');
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
// Act
|
|
120
|
+
const result = gitModule.branchExistsLocally('nonexistent');
|
|
121
|
+
|
|
122
|
+
// Assert
|
|
123
|
+
expect(result).toBe(false);
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
describe('branchExistsRemotely', () => {
|
|
128
|
+
it('should return true when branch exists remotely', () => {
|
|
129
|
+
// Arrange
|
|
130
|
+
mockExecSync.mockReturnValue('abc123\trefs/heads/feature/test\n');
|
|
131
|
+
|
|
132
|
+
// Act
|
|
133
|
+
const result = gitModule.branchExistsRemotely('feature/test');
|
|
134
|
+
|
|
135
|
+
// Assert
|
|
136
|
+
expect(result).toBe(true);
|
|
137
|
+
expect(mockExecSync).toHaveBeenCalledWith('git ls-remote --heads origin feature/test', { encoding: 'utf-8' });
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('should return false when branch does not exist remotely', () => {
|
|
141
|
+
// Arrange
|
|
142
|
+
mockExecSync.mockReturnValue('');
|
|
143
|
+
|
|
144
|
+
// Act
|
|
145
|
+
const result = gitModule.branchExistsRemotely('nonexistent');
|
|
146
|
+
|
|
147
|
+
// Assert
|
|
148
|
+
expect(result).toBe(false);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('should return false when git command fails', () => {
|
|
152
|
+
// Arrange
|
|
153
|
+
mockExecSync.mockImplementation(() => {
|
|
154
|
+
throw new Error('Network error');
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
// Act
|
|
158
|
+
const result = gitModule.branchExistsRemotely('test');
|
|
159
|
+
|
|
160
|
+
// Assert
|
|
161
|
+
expect(result).toBe(false);
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
describe('getDefaultBaseBranch', () => {
|
|
166
|
+
it('should return main when origin/main exists', () => {
|
|
167
|
+
// Arrange
|
|
168
|
+
mockExecSync.mockReturnValue(' origin/main\n origin/develop\n');
|
|
169
|
+
|
|
170
|
+
// Act
|
|
171
|
+
const result = gitModule.getDefaultBaseBranch();
|
|
172
|
+
|
|
173
|
+
// Assert
|
|
174
|
+
expect(result).toBe('main');
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('should return master when origin/master exists but not main', () => {
|
|
178
|
+
// Arrange
|
|
179
|
+
mockExecSync.mockReturnValue(' origin/master\n origin/develop\n');
|
|
180
|
+
|
|
181
|
+
// Act
|
|
182
|
+
const result = gitModule.getDefaultBaseBranch();
|
|
183
|
+
|
|
184
|
+
// Assert
|
|
185
|
+
expect(result).toBe('master');
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it('should default to main when neither exists', () => {
|
|
189
|
+
// Arrange
|
|
190
|
+
mockExecSync.mockReturnValue(' origin/develop\n origin/feature\n');
|
|
191
|
+
|
|
192
|
+
// Act
|
|
193
|
+
const result = gitModule.getDefaultBaseBranch();
|
|
194
|
+
|
|
195
|
+
// Assert
|
|
196
|
+
expect(result).toBe('main');
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it('should handle git command failure', () => {
|
|
200
|
+
// Arrange
|
|
201
|
+
mockExecSync.mockImplementation(() => {
|
|
202
|
+
throw new Error('Git error');
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
// Act
|
|
206
|
+
const result = gitModule.getDefaultBaseBranch();
|
|
207
|
+
|
|
208
|
+
// Assert
|
|
209
|
+
expect(result).toBe('main');
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
describe('createAndCheckoutBranch', () => {
|
|
214
|
+
it('should create and checkout new branch successfully', () => {
|
|
215
|
+
// Arrange
|
|
216
|
+
mockExecSync.mockImplementation((command) => {
|
|
217
|
+
if (command.includes('fetch')) return '';
|
|
218
|
+
if (command.includes('checkout -b')) return '';
|
|
219
|
+
return '';
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
// Act
|
|
223
|
+
const result = gitModule.createAndCheckoutBranch('feature/new-branch');
|
|
224
|
+
|
|
225
|
+
// Assert
|
|
226
|
+
expect(result).toBe(true);
|
|
227
|
+
expect(mockExecSync).toHaveBeenCalledWith(
|
|
228
|
+
expect.stringContaining('checkout -b feature/new-branch'),
|
|
229
|
+
{ stdio: 'pipe' }
|
|
230
|
+
);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it('should use custom base branch when provided', () => {
|
|
234
|
+
// Arrange
|
|
235
|
+
mockExecSync.mockImplementation(() => '');
|
|
236
|
+
|
|
237
|
+
// Act
|
|
238
|
+
const result = gitModule.createAndCheckoutBranch('feature/test', 'develop');
|
|
239
|
+
|
|
240
|
+
// Assert
|
|
241
|
+
expect(result).toBe(true);
|
|
242
|
+
expect(mockExecSync).toHaveBeenCalledWith(
|
|
243
|
+
expect.stringContaining('develop'),
|
|
244
|
+
expect.any(Object)
|
|
245
|
+
);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it('should return false when branch creation fails', () => {
|
|
249
|
+
// Arrange
|
|
250
|
+
mockExecSync.mockImplementation((command) => {
|
|
251
|
+
if (command.includes('checkout -b')) {
|
|
252
|
+
throw new Error('Branch creation failed');
|
|
253
|
+
}
|
|
254
|
+
return '';
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
// Act
|
|
258
|
+
const result = gitModule.createAndCheckoutBranch('invalid/branch');
|
|
259
|
+
|
|
260
|
+
// Assert
|
|
261
|
+
expect(result).toBe(false);
|
|
262
|
+
});
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
describe('checkoutBranch', () => {
|
|
266
|
+
it('should checkout existing branch successfully', () => {
|
|
267
|
+
// Arrange
|
|
268
|
+
mockExecSync.mockReturnValue('');
|
|
269
|
+
|
|
270
|
+
// Act
|
|
271
|
+
const result = gitModule.checkoutBranch('existing-branch');
|
|
272
|
+
|
|
273
|
+
// Assert
|
|
274
|
+
expect(result).toBe(true);
|
|
275
|
+
expect(mockExecSync).toHaveBeenCalledWith('git checkout existing-branch', { stdio: 'pipe' });
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it('should return false when checkout fails', () => {
|
|
279
|
+
// Arrange
|
|
280
|
+
mockExecSync.mockImplementation(() => {
|
|
281
|
+
throw new Error('Branch not found');
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
// Act
|
|
285
|
+
const result = gitModule.checkoutBranch('nonexistent');
|
|
286
|
+
|
|
287
|
+
// Assert
|
|
288
|
+
expect(result).toBe(false);
|
|
289
|
+
});
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
describe('getGitStatus', () => {
|
|
293
|
+
it('should return git status output', () => {
|
|
294
|
+
// Arrange
|
|
295
|
+
const statusOutput = 'M file1.js\nA file2.js\n';
|
|
296
|
+
mockExecSync.mockReturnValue(statusOutput);
|
|
297
|
+
|
|
298
|
+
// Act
|
|
299
|
+
const result = gitModule.getGitStatus();
|
|
300
|
+
|
|
301
|
+
// Assert
|
|
302
|
+
expect(result).toBe('M file1.js\nA file2.js');
|
|
303
|
+
expect(mockExecSync).toHaveBeenCalledWith('git status --porcelain', { encoding: 'utf-8' });
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it('should return empty string when no changes', () => {
|
|
307
|
+
// Arrange
|
|
308
|
+
mockExecSync.mockReturnValue('');
|
|
309
|
+
|
|
310
|
+
// Act
|
|
311
|
+
const result = gitModule.getGitStatus();
|
|
312
|
+
|
|
313
|
+
// Assert
|
|
314
|
+
expect(result).toBe('');
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
it('should return empty string when git command fails', () => {
|
|
318
|
+
// Arrange
|
|
319
|
+
mockExecSync.mockImplementation(() => {
|
|
320
|
+
throw new Error('Not a git repository');
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
// Act
|
|
324
|
+
const result = gitModule.getGitStatus();
|
|
325
|
+
|
|
326
|
+
// Assert
|
|
327
|
+
expect(result).toBe('');
|
|
328
|
+
});
|
|
329
|
+
});
|
|
330
|
+
});
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import yaml from 'js-yaml';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Get the path to the tickets directory from config or use default
|
|
7
|
+
* Reads the configuration file to determine the tickets directory path.
|
|
8
|
+
* Falls back to default path if config is missing or invalid.
|
|
9
|
+
* @returns {string} Absolute path to the tickets directory
|
|
10
|
+
* @throws {Error} Logs error but doesn't throw - returns default path
|
|
11
|
+
*/
|
|
12
|
+
function getTicketsDir() {
|
|
13
|
+
const configPath = path.join(process.cwd(), '.vibe', 'config.yml');
|
|
14
|
+
let ticketDir = path.join(process.cwd(), '.vibe', 'tickets');
|
|
15
|
+
|
|
16
|
+
try {
|
|
17
|
+
if (fs.existsSync(configPath)) {
|
|
18
|
+
const configContent = fs.readFileSync(configPath, 'utf-8');
|
|
19
|
+
const config = yaml.load(configContent) || {};
|
|
20
|
+
|
|
21
|
+
if (config.tickets?.path && typeof config.tickets.path === 'string') {
|
|
22
|
+
const customPath = path.resolve(process.cwd(), config.tickets.path);
|
|
23
|
+
ticketDir = customPath;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
} catch (error) {
|
|
27
|
+
console.error(`❌ Error reading config: ${error.message}`);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return ticketDir;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Get the configuration from .vibe/config.yml
|
|
35
|
+
* Loads and parses the YAML configuration file for VibeKit.
|
|
36
|
+
* Returns empty object if file doesn't exist or cannot be parsed.
|
|
37
|
+
* @returns {Object} The configuration object (empty if file missing/invalid)
|
|
38
|
+
* @example
|
|
39
|
+
* const config = getConfig();
|
|
40
|
+
* console.log(config.tickets?.path); // Access tickets path
|
|
41
|
+
*/
|
|
42
|
+
function getConfig() {
|
|
43
|
+
const configPath = path.join(process.cwd(), '.vibe', 'config.yml');
|
|
44
|
+
let config = {};
|
|
45
|
+
|
|
46
|
+
try {
|
|
47
|
+
if (fs.existsSync(configPath)) {
|
|
48
|
+
const configContent = fs.readFileSync(configPath, 'utf-8');
|
|
49
|
+
const parsedConfig = yaml.load(configContent);
|
|
50
|
+
|
|
51
|
+
// Ensure we have a valid object
|
|
52
|
+
if (parsedConfig && typeof parsedConfig === 'object') {
|
|
53
|
+
config = parsedConfig;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
} catch (error) {
|
|
57
|
+
console.error(`❌ Error reading config: ${error.message}`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return config;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Generate the next ticket ID based on existing tickets
|
|
65
|
+
* Scans the tickets directory for existing ticket files and generates
|
|
66
|
+
* the next sequential ID in the format TKT-XXX (zero-padded to 3 digits).
|
|
67
|
+
* @returns {string} The next ticket ID (e.g., "TKT-004")
|
|
68
|
+
* @example
|
|
69
|
+
* const nextId = getNextTicketId();
|
|
70
|
+
* console.log(nextId); // "TKT-005"
|
|
71
|
+
*/
|
|
72
|
+
function getNextTicketId() {
|
|
73
|
+
const ticketDir = getTicketsDir();
|
|
74
|
+
|
|
75
|
+
// If tickets directory doesn't exist, start with 001
|
|
76
|
+
if (!fs.existsSync(ticketDir)) {
|
|
77
|
+
return 'TKT-001';
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
try {
|
|
81
|
+
const files = fs.readdirSync(ticketDir);
|
|
82
|
+
const ticketNumbers = files
|
|
83
|
+
.filter(f => f.endsWith('.md')) // Only consider markdown files
|
|
84
|
+
.map(f => f.match(/^TKT-(\d+)/))
|
|
85
|
+
.filter(Boolean)
|
|
86
|
+
.map(match => parseInt(match[1], 10))
|
|
87
|
+
.filter(num => !isNaN(num) && num > 0); // Filter out invalid numbers
|
|
88
|
+
|
|
89
|
+
// Find the highest existing number and add 1
|
|
90
|
+
const nextId = Math.max(0, ...ticketNumbers) + 1;
|
|
91
|
+
const paddedId = String(nextId).padStart(3, '0');
|
|
92
|
+
|
|
93
|
+
return `TKT-${paddedId}`;
|
|
94
|
+
} catch (error) {
|
|
95
|
+
console.error(`❌ Error scanning tickets directory: ${error.message}`);
|
|
96
|
+
return 'TKT-001'; // Fallback to first ticket
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Create a slug from a title based on configuration
|
|
102
|
+
* Converts a human-readable title into a URL-friendly slug following
|
|
103
|
+
* configuration rules for maximum length and word limits.
|
|
104
|
+
* @param {string} title - The title to slugify
|
|
105
|
+
* @param {Object|null} config - Optional configuration object (defaults to getConfig())
|
|
106
|
+
* @returns {string} The slugified title (kebab-case, lowercase)
|
|
107
|
+
* @example
|
|
108
|
+
* createSlug('Fix User Authentication Bug'); // 'fix-user-authentication-bug'
|
|
109
|
+
* createSlug('Very Long Title That Exceeds Limits', { tickets: { slug: { max_length: 10 } } }); // 'very-long'
|
|
110
|
+
*/
|
|
111
|
+
function createSlug(title, config = null) {
|
|
112
|
+
if (!title || typeof title !== 'string') {
|
|
113
|
+
return '';
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Get configuration with fallback defaults
|
|
117
|
+
const cfg = config || getConfig();
|
|
118
|
+
const maxLength = cfg.tickets?.slug?.max_length || 30;
|
|
119
|
+
const wordLimit = cfg.tickets?.slug?.word_limit || 5;
|
|
120
|
+
|
|
121
|
+
// Validate configuration values
|
|
122
|
+
const safeMaxLength = Math.max(1, Math.min(100, maxLength)); // Clamp between 1-100
|
|
123
|
+
const safeWordLimit = Math.max(1, Math.min(20, wordLimit)); // Clamp between 1-20
|
|
124
|
+
|
|
125
|
+
// Split into words and limit the number of words
|
|
126
|
+
const words = title.trim().split(/\s+/).filter(Boolean);
|
|
127
|
+
const limitedWords = words.slice(0, safeWordLimit).join(' ');
|
|
128
|
+
|
|
129
|
+
// Create the basic slug
|
|
130
|
+
let slug = limitedWords.toLowerCase()
|
|
131
|
+
.replace(/[^a-z0-9]+/g, '-') // Replace non-alphanumeric chars with hyphens
|
|
132
|
+
.replace(/^-+|-+$/g, ''); // Remove leading/trailing hyphens
|
|
133
|
+
|
|
134
|
+
// Limit the length and ensure we don't cut in the middle of a word
|
|
135
|
+
if (slug.length > safeMaxLength) {
|
|
136
|
+
slug = slug.substring(0, safeMaxLength);
|
|
137
|
+
// Remove trailing partial words (anything after the last hyphen)
|
|
138
|
+
const lastHyphen = slug.lastIndexOf('-');
|
|
139
|
+
if (lastHyphen > 0) {
|
|
140
|
+
slug = slug.substring(0, lastHyphen);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return slug || 'untitled'; // Fallback if slug is empty
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Create a full slug with ticket ID prefix
|
|
149
|
+
* Combines a ticket ID with a descriptive slug to create a complete
|
|
150
|
+
* filename-ready identifier.
|
|
151
|
+
* @param {string} ticketId - The ticket ID (e.g., "TKT-009")
|
|
152
|
+
* @param {string} slugText - The descriptive slug text part
|
|
153
|
+
* @returns {string} Full slug with ticket ID prefix (e.g., "TKT-009-fix-auth-bug")
|
|
154
|
+
* @example
|
|
155
|
+
* createFullSlug('TKT-001', 'user-login-fix'); // 'TKT-001-user-login-fix'
|
|
156
|
+
*/
|
|
157
|
+
function createFullSlug(ticketId, slugText) {
|
|
158
|
+
// Validate inputs
|
|
159
|
+
if (!ticketId || typeof ticketId !== 'string') {
|
|
160
|
+
return '';
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (!slugText || typeof slugText !== 'string') {
|
|
164
|
+
return ticketId; // Return just the ticket ID if no slug text
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Clean inputs
|
|
168
|
+
const cleanTicketId = ticketId.trim();
|
|
169
|
+
const cleanSlugText = slugText.trim();
|
|
170
|
+
|
|
171
|
+
if (!cleanTicketId || !cleanSlugText) {
|
|
172
|
+
return cleanTicketId || '';
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return `${cleanTicketId}-${cleanSlugText}`;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Get the path to the config.yml file
|
|
180
|
+
* @returns {string} Absolute path to the config.yml file
|
|
181
|
+
*/
|
|
182
|
+
function getConfigPath() {
|
|
183
|
+
return path.join(process.cwd(), '.vibe', 'config.yml');
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export {
|
|
187
|
+
getTicketsDir,
|
|
188
|
+
getConfig,
|
|
189
|
+
getConfigPath,
|
|
190
|
+
getNextTicketId,
|
|
191
|
+
createSlug,
|
|
192
|
+
createFullSlug
|
|
193
|
+
};
|