@vibedx/vibekit 0.6.4 → 0.7.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/assets/config.yml +3 -0
- package/index.js +2 -1
- package/package.json +1 -1
- package/src/commands/pr/index.js +259 -0
- package/src/commands/pr/index.test.js +139 -0
- package/src/commands/start/index.js +289 -177
- package/src/commands/start/index.test.js +19 -8
- package/src/commands/status/index.js +80 -0
- package/src/commands/status/index.test.js +49 -0
package/assets/config.yml
CHANGED
package/index.js
CHANGED
|
@@ -23,7 +23,8 @@ const __dirname = dirname(__filename);
|
|
|
23
23
|
// Available commands in VibeKit
|
|
24
24
|
const AVAILABLE_COMMANDS = [
|
|
25
25
|
'init', 'new', 'close', 'list', 'get-started',
|
|
26
|
-
'start', 'link', 'unlink', 'refine', 'lint', 'review', 'team', 'skills'
|
|
26
|
+
'start', 'link', 'unlink', 'refine', 'lint', 'review', 'team', 'skills',
|
|
27
|
+
'status', 'pr'
|
|
27
28
|
];
|
|
28
29
|
|
|
29
30
|
/**
|
package/package.json
CHANGED
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import yaml from 'js-yaml';
|
|
4
|
+
import { execSync } from 'child_process';
|
|
5
|
+
import { getTicketsDir, getConfig } from '../../utils/index.js';
|
|
6
|
+
import {
|
|
7
|
+
isGitRepository,
|
|
8
|
+
getCurrentBranch,
|
|
9
|
+
getDefaultBaseBranch,
|
|
10
|
+
listWorktrees,
|
|
11
|
+
getRepoRoot
|
|
12
|
+
} from '../../utils/git.js';
|
|
13
|
+
|
|
14
|
+
function parseArgs(args) {
|
|
15
|
+
const flags = {};
|
|
16
|
+
const ids = [];
|
|
17
|
+
|
|
18
|
+
let i = 0;
|
|
19
|
+
while (i < args.length) {
|
|
20
|
+
const arg = args[i];
|
|
21
|
+
if (arg === '--all' || arg === '-a') {
|
|
22
|
+
flags.all = true;
|
|
23
|
+
i++;
|
|
24
|
+
} else if (arg === '--base' && i + 1 < args.length) {
|
|
25
|
+
flags.baseBranch = args[i + 1];
|
|
26
|
+
i += 2;
|
|
27
|
+
} else if (arg === '--draft' || arg === '-d') {
|
|
28
|
+
flags.draft = true;
|
|
29
|
+
i++;
|
|
30
|
+
} else if (arg === '--dry-run') {
|
|
31
|
+
flags.dryRun = true;
|
|
32
|
+
i++;
|
|
33
|
+
} else if (!arg.startsWith('-')) {
|
|
34
|
+
ids.push(arg);
|
|
35
|
+
i++;
|
|
36
|
+
} else {
|
|
37
|
+
i++;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return { ids, flags };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function normalizeTicketId(input) {
|
|
44
|
+
if (input.startsWith('TKT-')) return input;
|
|
45
|
+
if (/^\d+$/.test(input)) return `TKT-${input.padStart(3, '0')}`;
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function findTicketForBranch(ticketsDir, branch) {
|
|
50
|
+
const files = fs.readdirSync(ticketsDir).filter(f => f.endsWith('.md'));
|
|
51
|
+
for (const file of files) {
|
|
52
|
+
const filePath = path.join(ticketsDir, file);
|
|
53
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
54
|
+
const match = content.match(/^---\n([\s\S]*?)\n---/);
|
|
55
|
+
if (match) {
|
|
56
|
+
const fm = yaml.load(match[1]);
|
|
57
|
+
const slug = fm.slug || '';
|
|
58
|
+
if (branch.includes(fm.id) || branch.includes(slug)) {
|
|
59
|
+
const body = content.split('---').slice(2).join('---').trim();
|
|
60
|
+
return { frontmatter: fm, body, filePath };
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function loadTicket(ticketsDir, ticketId) {
|
|
68
|
+
const files = fs.readdirSync(ticketsDir).filter(f => f.startsWith(`${ticketId}-`));
|
|
69
|
+
if (files.length === 0) return null;
|
|
70
|
+
const filePath = path.join(ticketsDir, files[0]);
|
|
71
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
72
|
+
const match = content.match(/^---\n([\s\S]*?)\n---/);
|
|
73
|
+
if (!match) return null;
|
|
74
|
+
const fm = yaml.load(match[1]);
|
|
75
|
+
const body = content.split('---').slice(2).join('---').trim();
|
|
76
|
+
return { frontmatter: fm, body, filePath };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function hasRemote() {
|
|
80
|
+
try {
|
|
81
|
+
execSync('git remote get-url origin', { stdio: 'ignore' });
|
|
82
|
+
return true;
|
|
83
|
+
} catch {
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function pushBranch(branch, cwd) {
|
|
89
|
+
try {
|
|
90
|
+
execSync(`git push -u origin ${branch}`, { cwd, stdio: 'pipe' });
|
|
91
|
+
return true;
|
|
92
|
+
} catch (error) {
|
|
93
|
+
console.error(` ❌ Failed to push ${branch}: ${error.message}`);
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function createPR(branch, baseBranch, title, body, draft, cwd) {
|
|
99
|
+
const draftFlag = draft ? ' --draft' : '';
|
|
100
|
+
const safeTitle = title.replace(/"/g, '\\"');
|
|
101
|
+
const safeBody = body.replace(/'/g, "'\\''");
|
|
102
|
+
try {
|
|
103
|
+
const result = execSync(
|
|
104
|
+
`gh pr create --head "${branch}" --base "${baseBranch}" --title "${safeTitle}"${draftFlag} --body '${safeBody}'`,
|
|
105
|
+
{ cwd, encoding: 'utf-8' }
|
|
106
|
+
);
|
|
107
|
+
return result.trim();
|
|
108
|
+
} catch (error) {
|
|
109
|
+
if (error.message.includes('already exists')) {
|
|
110
|
+
try {
|
|
111
|
+
const existing = execSync(`gh pr view "${branch}" --json url -q .url`, { cwd, encoding: 'utf-8' });
|
|
112
|
+
return existing.trim() + ' (already existed)';
|
|
113
|
+
} catch {
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
console.error(` ❌ Failed to create PR: ${error.message}`);
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function buildPRBody(ticket) {
|
|
123
|
+
if (!ticket) return 'No ticket context found.';
|
|
124
|
+
|
|
125
|
+
const fm = ticket.frontmatter;
|
|
126
|
+
const sections = [];
|
|
127
|
+
sections.push(`## ${fm.id}: ${fm.title}`);
|
|
128
|
+
if (fm.priority) sections.push(`**Priority:** ${fm.priority}`);
|
|
129
|
+
if (fm.assignee) sections.push(`**Assignee:** ${fm.assignee}`);
|
|
130
|
+
sections.push('');
|
|
131
|
+
sections.push(ticket.body);
|
|
132
|
+
return sections.join('\n');
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function getBranchCwd(worktrees, branch) {
|
|
136
|
+
const wt = worktrees.find(w => w.branch === branch);
|
|
137
|
+
return wt ? wt.path : null;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async function prCommand(args) {
|
|
141
|
+
if (!isGitRepository()) {
|
|
142
|
+
console.error('❌ Not in a git repository.');
|
|
143
|
+
process.exit(1);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (!hasRemote()) {
|
|
147
|
+
console.error('❌ No git remote "origin" found. Push your repo to GitHub first.');
|
|
148
|
+
process.exit(1);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
try {
|
|
152
|
+
execSync('gh --version', { stdio: 'ignore' });
|
|
153
|
+
} catch {
|
|
154
|
+
console.error('❌ GitHub CLI (gh) is required. Install it: https://cli.github.com');
|
|
155
|
+
process.exit(1);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const { ids, flags } = parseArgs(args);
|
|
159
|
+
const config = getConfig();
|
|
160
|
+
const ticketsDir = getTicketsDir();
|
|
161
|
+
const baseBranch = flags.baseBranch || config.git?.default_base || getDefaultBaseBranch();
|
|
162
|
+
const worktrees = listWorktrees();
|
|
163
|
+
const repoRoot = getRepoRoot();
|
|
164
|
+
|
|
165
|
+
let branches = [];
|
|
166
|
+
|
|
167
|
+
if (flags.all) {
|
|
168
|
+
// Gather all worktree branches (excluding the main worktree)
|
|
169
|
+
const mainRoot = repoRoot;
|
|
170
|
+
branches = worktrees
|
|
171
|
+
.filter(wt => wt.branch && wt.path !== mainRoot && wt.branch !== baseBranch)
|
|
172
|
+
.map(wt => ({ branch: wt.branch, cwd: wt.path }));
|
|
173
|
+
|
|
174
|
+
if (branches.length === 0) {
|
|
175
|
+
console.error('❌ No worktree branches found. Use `vibe plan` to create them.');
|
|
176
|
+
process.exit(1);
|
|
177
|
+
}
|
|
178
|
+
} else if (ids.length > 0) {
|
|
179
|
+
// Look up branches for specific ticket IDs
|
|
180
|
+
for (const id of ids) {
|
|
181
|
+
const ticketId = normalizeTicketId(id);
|
|
182
|
+
if (!ticketId) {
|
|
183
|
+
console.error(`❌ Invalid ticket ID: ${id}`);
|
|
184
|
+
process.exit(1);
|
|
185
|
+
}
|
|
186
|
+
const ticket = loadTicket(ticketsDir, ticketId);
|
|
187
|
+
if (!ticket) {
|
|
188
|
+
console.error(`❌ Ticket ${ticketId} not found.`);
|
|
189
|
+
process.exit(1);
|
|
190
|
+
}
|
|
191
|
+
const branchPrefix = config.git?.branch_prefix || '';
|
|
192
|
+
const slug = ticket.frontmatter.slug || ticket.frontmatter.id;
|
|
193
|
+
const branchName = slug.includes(ticket.frontmatter.id)
|
|
194
|
+
? `${branchPrefix}${slug}`
|
|
195
|
+
: `${branchPrefix}${ticket.frontmatter.id}-${slug}`;
|
|
196
|
+
|
|
197
|
+
const wtCwd = getBranchCwd(worktrees, branchName);
|
|
198
|
+
branches.push({ branch: branchName, cwd: wtCwd || repoRoot, ticketId });
|
|
199
|
+
}
|
|
200
|
+
} else {
|
|
201
|
+
// Current branch
|
|
202
|
+
const currentBranch = getCurrentBranch();
|
|
203
|
+
if (!currentBranch || currentBranch === baseBranch) {
|
|
204
|
+
console.error(`❌ You're on ${currentBranch || 'a detached HEAD'}. Switch to a feature branch or specify ticket IDs.`);
|
|
205
|
+
console.error('');
|
|
206
|
+
console.error('Usage: vibe pr [TKT-001 ...] [--all] [--draft] [--base main]');
|
|
207
|
+
process.exit(1);
|
|
208
|
+
}
|
|
209
|
+
branches.push({ branch: currentBranch, cwd: repoRoot });
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
console.log(`\n📝 Opening ${branches.length} PR(s) against ${baseBranch}:\n`);
|
|
213
|
+
|
|
214
|
+
if (flags.dryRun) {
|
|
215
|
+
for (const { branch } of branches) {
|
|
216
|
+
const ticket = findTicketForBranch(ticketsDir, branch);
|
|
217
|
+
const title = ticket ? `${ticket.frontmatter.id}: ${ticket.frontmatter.title}` : branch;
|
|
218
|
+
console.log(` ${branch} → "${title}"`);
|
|
219
|
+
}
|
|
220
|
+
console.log('\n🏁 Dry run complete. No PRs created.');
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const results = [];
|
|
225
|
+
for (const { branch, cwd } of branches) {
|
|
226
|
+
const ticket = findTicketForBranch(ticketsDir, branch);
|
|
227
|
+
const title = ticket ? `${ticket.frontmatter.id}: ${ticket.frontmatter.title}` : branch;
|
|
228
|
+
const body = buildPRBody(ticket);
|
|
229
|
+
|
|
230
|
+
console.log(` 📤 ${branch}`);
|
|
231
|
+
|
|
232
|
+
if (!pushBranch(branch, cwd)) continue;
|
|
233
|
+
|
|
234
|
+
const prUrl = createPR(branch, baseBranch, title, body, flags.draft, cwd);
|
|
235
|
+
if (prUrl) {
|
|
236
|
+
console.log(` ✅ ${prUrl}`);
|
|
237
|
+
results.push({ branch, url: prUrl });
|
|
238
|
+
|
|
239
|
+
// Update ticket status to review
|
|
240
|
+
if (ticket) {
|
|
241
|
+
try {
|
|
242
|
+
const currentContent = fs.readFileSync(ticket.filePath, 'utf-8');
|
|
243
|
+
const now = new Date().toISOString();
|
|
244
|
+
const updatedContent = currentContent
|
|
245
|
+
.replace(/^status: (.+)$/m, 'status: review')
|
|
246
|
+
.replace(/^updated_at: (.+)$/m, `updated_at: "${now}"`);
|
|
247
|
+
fs.writeFileSync(ticket.filePath, updatedContent, 'utf-8');
|
|
248
|
+
} catch {
|
|
249
|
+
// Non-critical — skip
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
console.log('');
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
console.log(`🏁 Done! ${results.length}/${branches.length} PR(s) opened.`);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export default prCommand;
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { jest } from '@jest/globals';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
|
|
5
|
+
jest.unstable_mockModule('child_process', () => ({
|
|
6
|
+
execSync: jest.fn()
|
|
7
|
+
}));
|
|
8
|
+
|
|
9
|
+
jest.unstable_mockModule('../../utils/index.js', () => ({
|
|
10
|
+
getTicketsDir: jest.fn(),
|
|
11
|
+
getConfig: jest.fn(() => ({ git: { branch_prefix: 'feature/', default_base: 'main' } }))
|
|
12
|
+
}));
|
|
13
|
+
|
|
14
|
+
jest.unstable_mockModule('../../utils/git.js', () => ({
|
|
15
|
+
isGitRepository: jest.fn(() => true),
|
|
16
|
+
getCurrentBranch: jest.fn(() => 'feature/TKT-001-add-login'),
|
|
17
|
+
getDefaultBaseBranch: jest.fn(() => 'main'),
|
|
18
|
+
listWorktrees: jest.fn(() => []),
|
|
19
|
+
getRepoRoot: jest.fn(() => '/tmp/test-repo')
|
|
20
|
+
}));
|
|
21
|
+
|
|
22
|
+
const { default: prCommand } = await import('./index.js');
|
|
23
|
+
const { getTicketsDir } = await import('../../utils/index.js');
|
|
24
|
+
const { isGitRepository, getCurrentBranch, listWorktrees, getRepoRoot } = await import('../../utils/git.js');
|
|
25
|
+
const childProcess = await import('child_process');
|
|
26
|
+
|
|
27
|
+
const TICKET_CONTENT = `---
|
|
28
|
+
id: TKT-001
|
|
29
|
+
title: Add login page
|
|
30
|
+
slug: TKT-001-add-login-page
|
|
31
|
+
status: in_progress
|
|
32
|
+
priority: high
|
|
33
|
+
assignee: dev1
|
|
34
|
+
author: pm
|
|
35
|
+
created_at: "2026-01-01T00:00:00Z"
|
|
36
|
+
updated_at: "2026-01-01T00:00:00Z"
|
|
37
|
+
---
|
|
38
|
+
|
|
39
|
+
## Description
|
|
40
|
+
Add a login page.
|
|
41
|
+
`;
|
|
42
|
+
|
|
43
|
+
describe('pr command', () => {
|
|
44
|
+
let tempDir;
|
|
45
|
+
let mockExit;
|
|
46
|
+
let mockConsoleLog;
|
|
47
|
+
let mockConsoleError;
|
|
48
|
+
|
|
49
|
+
beforeEach(() => {
|
|
50
|
+
tempDir = fs.mkdtempSync(path.join('/tmp', 'vibe-pr-test-'));
|
|
51
|
+
const ticketsDir = path.join(tempDir, '.vibe', 'tickets');
|
|
52
|
+
fs.mkdirSync(ticketsDir, { recursive: true });
|
|
53
|
+
fs.writeFileSync(path.join(ticketsDir, 'TKT-001-add-login-page.md'), TICKET_CONTENT);
|
|
54
|
+
|
|
55
|
+
getTicketsDir.mockReturnValue(ticketsDir);
|
|
56
|
+
mockExit = jest.spyOn(process, 'exit').mockImplementation(() => { throw new Error('process.exit'); });
|
|
57
|
+
mockConsoleLog = jest.spyOn(console, 'log').mockImplementation(() => {});
|
|
58
|
+
mockConsoleError = jest.spyOn(console, 'error').mockImplementation(() => {});
|
|
59
|
+
|
|
60
|
+
// Default: gh is installed, origin exists
|
|
61
|
+
childProcess.execSync.mockImplementation((cmd) => {
|
|
62
|
+
if (cmd === 'gh --version') return 'gh version 2.0.0';
|
|
63
|
+
if (cmd.includes('remote get-url')) return 'git@github.com:org/repo.git';
|
|
64
|
+
if (cmd.includes('git push')) return '';
|
|
65
|
+
if (cmd.includes('gh pr create')) return 'https://github.com/org/repo/pull/1';
|
|
66
|
+
return '';
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
afterEach(() => {
|
|
71
|
+
mockExit.mockRestore();
|
|
72
|
+
mockConsoleLog.mockRestore();
|
|
73
|
+
mockConsoleError.mockRestore();
|
|
74
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
75
|
+
jest.clearAllMocks();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('exits if not in git repo', async () => {
|
|
79
|
+
isGitRepository.mockReturnValueOnce(false);
|
|
80
|
+
await expect(prCommand([])).rejects.toThrow('process.exit');
|
|
81
|
+
expect(mockConsoleError).toHaveBeenCalledWith('❌ Not in a git repository.');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('exits if no remote', async () => {
|
|
85
|
+
childProcess.execSync.mockImplementation((cmd) => {
|
|
86
|
+
if (cmd.includes('remote get-url')) throw new Error('no remote');
|
|
87
|
+
return '';
|
|
88
|
+
});
|
|
89
|
+
await expect(prCommand([])).rejects.toThrow('process.exit');
|
|
90
|
+
expect(mockConsoleError).toHaveBeenCalledWith(expect.stringContaining('No git remote'));
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('exits if gh not installed', async () => {
|
|
94
|
+
childProcess.execSync.mockImplementation((cmd) => {
|
|
95
|
+
if (cmd === 'gh --version') throw new Error('not found');
|
|
96
|
+
if (cmd.includes('remote get-url')) return 'git@github.com:org/repo.git';
|
|
97
|
+
return '';
|
|
98
|
+
});
|
|
99
|
+
await expect(prCommand([])).rejects.toThrow('process.exit');
|
|
100
|
+
expect(mockConsoleError).toHaveBeenCalledWith(expect.stringContaining('GitHub CLI'));
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('creates PR for current branch', async () => {
|
|
104
|
+
await prCommand([]);
|
|
105
|
+
expect(childProcess.execSync).toHaveBeenCalledWith(
|
|
106
|
+
expect.stringContaining('gh pr create'),
|
|
107
|
+
expect.any(Object)
|
|
108
|
+
);
|
|
109
|
+
expect(mockConsoleLog).toHaveBeenCalledWith(expect.stringContaining('https://github.com'));
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test('dry run shows branches without creating PRs', async () => {
|
|
113
|
+
await prCommand(['--dry-run']);
|
|
114
|
+
expect(mockConsoleLog).toHaveBeenCalledWith(expect.stringContaining('Dry run complete'));
|
|
115
|
+
const prCreateCalls = childProcess.execSync.mock.calls.filter(([cmd]) =>
|
|
116
|
+
typeof cmd === 'string' && cmd.includes('gh pr create')
|
|
117
|
+
);
|
|
118
|
+
expect(prCreateCalls.length).toBe(0);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test('errors when on base branch with no args', async () => {
|
|
122
|
+
getCurrentBranch.mockReturnValueOnce('main');
|
|
123
|
+
await expect(prCommand([])).rejects.toThrow('process.exit');
|
|
124
|
+
expect(mockConsoleError).toHaveBeenCalledWith(expect.stringContaining("You're on main"));
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test('--all collects worktree branches', async () => {
|
|
128
|
+
listWorktrees.mockReturnValueOnce([
|
|
129
|
+
{ path: '/tmp/test-repo', branch: 'main' },
|
|
130
|
+
{ path: '/tmp/wt-1', branch: 'feature/TKT-001-login' },
|
|
131
|
+
{ path: '/tmp/wt-2', branch: 'feature/TKT-002-signup' }
|
|
132
|
+
]);
|
|
133
|
+
getRepoRoot.mockReturnValueOnce('/tmp/test-repo');
|
|
134
|
+
|
|
135
|
+
await prCommand(['--all']);
|
|
136
|
+
const prCalls = childProcess.execSync.mock.calls.filter(([cmd]) => cmd.includes('gh pr create'));
|
|
137
|
+
expect(prCalls.length).toBe(2);
|
|
138
|
+
});
|
|
139
|
+
});
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import fs from 'fs';
|
|
2
2
|
import path from 'path';
|
|
3
3
|
import yaml from 'js-yaml';
|
|
4
|
+
import { execSync, spawn } from 'child_process';
|
|
4
5
|
import { getTicketsDir, getConfig, createSlug } from '../../utils/index.js';
|
|
5
6
|
import {
|
|
6
7
|
isGitRepository,
|
|
@@ -13,230 +14,341 @@ import {
|
|
|
13
14
|
getRepoName,
|
|
14
15
|
getWorktreePath,
|
|
15
16
|
createWorktree,
|
|
16
|
-
createWorktreeExistingBranch
|
|
17
|
+
createWorktreeExistingBranch,
|
|
18
|
+
getRepoRoot,
|
|
19
|
+
getDefaultBaseBranch
|
|
17
20
|
} from '../../utils/git.js';
|
|
18
21
|
|
|
22
|
+
function parseTicketIds(args) {
|
|
23
|
+
const ids = [];
|
|
24
|
+
const flags = {};
|
|
25
|
+
let i = 0;
|
|
26
|
+
while (i < args.length) {
|
|
27
|
+
const arg = args[i];
|
|
28
|
+
if (arg === '--base' && i + 1 < args.length) {
|
|
29
|
+
flags.baseBranch = args[i + 1];
|
|
30
|
+
i += 2;
|
|
31
|
+
} else if (arg === '--dry-run') {
|
|
32
|
+
flags.dryRun = true;
|
|
33
|
+
i++;
|
|
34
|
+
} else if (arg === '--no-install') {
|
|
35
|
+
flags.noInstall = true;
|
|
36
|
+
i++;
|
|
37
|
+
} else if (arg === '--agent') {
|
|
38
|
+
flags.agent = true;
|
|
39
|
+
i++;
|
|
40
|
+
} else if (arg === '--worktree' || arg === '-w') {
|
|
41
|
+
flags.worktree = true;
|
|
42
|
+
i++;
|
|
43
|
+
} else if (arg === '--update-status' || arg === '-u') {
|
|
44
|
+
flags.updateStatus = true;
|
|
45
|
+
i++;
|
|
46
|
+
} else if (arg === '--prompt' && i + 1 < args.length) {
|
|
47
|
+
flags.prompt = args[i + 1];
|
|
48
|
+
i += 2;
|
|
49
|
+
} else if (arg.startsWith('--status=')) {
|
|
50
|
+
flags.statusFilter = arg.split('=')[1];
|
|
51
|
+
i++;
|
|
52
|
+
} else if (!arg.startsWith('-')) {
|
|
53
|
+
ids.push(arg);
|
|
54
|
+
i++;
|
|
55
|
+
} else {
|
|
56
|
+
i++;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return { ids, flags };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function normalizeTicketId(input) {
|
|
63
|
+
if (input.startsWith('TKT-')) return input;
|
|
64
|
+
if (/^\d+$/.test(input)) return `TKT-${input.padStart(3, '0')}`;
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function loadTicket(ticketsDir, ticketId) {
|
|
69
|
+
const files = fs.readdirSync(ticketsDir).filter(f => f.startsWith(`${ticketId}-`));
|
|
70
|
+
if (files.length === 0) return null;
|
|
71
|
+
const filePath = path.join(ticketsDir, files[0]);
|
|
72
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
73
|
+
const match = content.match(/^---\n([\s\S]*?)\n---/);
|
|
74
|
+
if (!match) return null;
|
|
75
|
+
const frontmatter = yaml.load(match[1]);
|
|
76
|
+
return { frontmatter, content, filePath, fileName: files[0] };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function loadTicketsByStatus(ticketsDir, status) {
|
|
80
|
+
const files = fs.readdirSync(ticketsDir).filter(f => f.endsWith('.md'));
|
|
81
|
+
const tickets = [];
|
|
82
|
+
for (const file of files) {
|
|
83
|
+
const filePath = path.join(ticketsDir, file);
|
|
84
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
85
|
+
const match = content.match(/^---\n([\s\S]*?)\n---/);
|
|
86
|
+
if (match) {
|
|
87
|
+
const frontmatter = yaml.load(match[1]);
|
|
88
|
+
if (frontmatter.status === status) {
|
|
89
|
+
tickets.push({ frontmatter, content, filePath, fileName: file });
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return tickets;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function getBranchName(ticket, config) {
|
|
97
|
+
const branchPrefix = config.git?.branch_prefix || '';
|
|
98
|
+
const slug = String(ticket.frontmatter.slug || ticket.frontmatter.id);
|
|
99
|
+
return slug.includes(ticket.frontmatter.id)
|
|
100
|
+
? `${branchPrefix}${slug}`
|
|
101
|
+
: `${branchPrefix}${ticket.frontmatter.id}-${slug}`;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function updateTicketStatus(ticket, worktreePath) {
|
|
105
|
+
const now = new Date().toISOString();
|
|
106
|
+
let updatedContent = ticket.content;
|
|
107
|
+
|
|
108
|
+
if (updatedContent.match(/^worktree_path: .+$/m)) {
|
|
109
|
+
updatedContent = updatedContent.replace(/^worktree_path: .+$/m, `worktree_path: "${worktreePath}"`);
|
|
110
|
+
} else {
|
|
111
|
+
updatedContent = updatedContent.replace(/^(updated_at: .+)$/m, `$1\nworktree_path: "${worktreePath}"`);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
updatedContent = updatedContent
|
|
115
|
+
.replace(/^status: (.+)$/m, 'status: in_progress')
|
|
116
|
+
.replace(/^updated_at: (.+)$/m, `updated_at: "${now}"`);
|
|
117
|
+
|
|
118
|
+
fs.writeFileSync(ticket.filePath, updatedContent, 'utf-8');
|
|
119
|
+
}
|
|
120
|
+
|
|
19
121
|
/**
|
|
20
|
-
* Start working on
|
|
122
|
+
* Start working on ticket(s) by checking out branches or creating worktrees
|
|
21
123
|
* @param {string[]} args Command arguments
|
|
22
124
|
*/
|
|
23
125
|
function startCommand(args) {
|
|
24
|
-
// Check if we're in a git repository
|
|
25
126
|
if (!isGitRepository()) {
|
|
26
|
-
console.error('❌ Not in a git repository.
|
|
127
|
+
console.error('❌ Not in a git repository.');
|
|
27
128
|
process.exit(1);
|
|
28
129
|
}
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
130
|
+
|
|
131
|
+
const { ids, flags } = parseTicketIds(args);
|
|
132
|
+
|
|
133
|
+
if (ids.length === 0 && !flags.statusFilter) {
|
|
134
|
+
console.error('❌ Usage: vibe start <TKT-001> [<TKT-002> ...] [-w] [--agent] [--base main] [--dry-run]');
|
|
135
|
+
console.error('');
|
|
136
|
+
console.error('Start working on tickets. Without -w, checks out branch locally. With -w, creates worktrees.');
|
|
137
|
+
console.error('');
|
|
138
|
+
console.error('Options:');
|
|
139
|
+
console.error(' -w, --worktree Create worktrees for tickets');
|
|
140
|
+
console.error(' --agent Spawn Claude agents to work on tickets');
|
|
141
|
+
console.error(' --status=<status> Start all tickets with this status (e.g. --status=open)');
|
|
142
|
+
console.error(' --base <branch> Base branch for new branches/worktrees (default: main)');
|
|
143
|
+
console.error(' --dry-run Show what would happen without doing it');
|
|
144
|
+
console.error(' --no-install Skip npm install in worktrees');
|
|
33
145
|
process.exit(1);
|
|
34
146
|
}
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
updateStatus = true;
|
|
49
|
-
} else if (args[i] === '--worktree' || args[i] === '-w') {
|
|
50
|
-
useWorktree = true;
|
|
147
|
+
|
|
148
|
+
const config = getConfig();
|
|
149
|
+
const ticketsDir = getTicketsDir();
|
|
150
|
+
const repoName = getRepoName();
|
|
151
|
+
const repoRoot = getRepoRoot();
|
|
152
|
+
|
|
153
|
+
let tickets = [];
|
|
154
|
+
|
|
155
|
+
if (flags.statusFilter) {
|
|
156
|
+
tickets = loadTicketsByStatus(ticketsDir, flags.statusFilter);
|
|
157
|
+
if (tickets.length === 0) {
|
|
158
|
+
console.error(`❌ No tickets found with status: ${flags.statusFilter}`);
|
|
159
|
+
process.exit(1);
|
|
51
160
|
}
|
|
161
|
+
console.log(`📋 Found ${tickets.length} ticket(s) with status "${flags.statusFilter}"`);
|
|
52
162
|
}
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
}
|
|
60
|
-
|
|
163
|
+
|
|
164
|
+
for (const id of ids) {
|
|
165
|
+
const ticketId = normalizeTicketId(id);
|
|
166
|
+
if (!ticketId) {
|
|
167
|
+
console.error(`❌ Invalid ticket ID: ${id}`);
|
|
168
|
+
process.exit(1);
|
|
169
|
+
}
|
|
170
|
+
const ticket = loadTicket(ticketsDir, ticketId);
|
|
171
|
+
if (!ticket) {
|
|
172
|
+
console.error(`❌ Ticket ${ticketId} not found.`);
|
|
61
173
|
process.exit(1);
|
|
62
174
|
}
|
|
175
|
+
if (!tickets.find(t => t.frontmatter.id === ticket.frontmatter.id)) {
|
|
176
|
+
tickets.push(ticket);
|
|
177
|
+
}
|
|
63
178
|
}
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
const ticketsDir = getTicketsDir();
|
|
68
|
-
|
|
69
|
-
// Check if the ticket exists
|
|
70
|
-
const ticketFiles = fs.readdirSync(ticketsDir).filter(file => file.startsWith(`${ticketId}-`));
|
|
71
|
-
|
|
72
|
-
if (ticketFiles.length === 0) {
|
|
73
|
-
console.error(`❌ Ticket ${ticketId} not found.`);
|
|
179
|
+
|
|
180
|
+
if (tickets.length === 0) {
|
|
181
|
+
console.error('❌ No tickets to start.');
|
|
74
182
|
process.exit(1);
|
|
75
183
|
}
|
|
76
|
-
|
|
77
|
-
const
|
|
78
|
-
const
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
const title = titleMatch ? titleMatch[1].trim() : '';
|
|
84
|
-
|
|
85
|
-
// Look for the slug in the frontmatter
|
|
86
|
-
const slugMatch = ticketContent.match(/slug: (.+)/);
|
|
87
|
-
let slug;
|
|
88
|
-
|
|
89
|
-
if (slugMatch && slugMatch[1].trim()) {
|
|
90
|
-
// Use the slug from the ticket file
|
|
91
|
-
slug = slugMatch[1].trim();
|
|
92
|
-
} else {
|
|
93
|
-
// Generate a slug from the title as fallback
|
|
94
|
-
slug = `${ticketId}-${createSlug(title)}`;
|
|
95
|
-
console.log(`⚠️ No slug found in ticket. Generated slug: ${slug}`);
|
|
96
|
-
|
|
97
|
-
// Update the ticket with the generated slug
|
|
98
|
-
try {
|
|
99
|
-
let updatedContent = ticketContent;
|
|
100
|
-
if (updatedContent.includes('slug:')) {
|
|
101
|
-
updatedContent = updatedContent.replace(/slug:.*/, `slug: ${slug}`);
|
|
102
|
-
} else {
|
|
103
|
-
updatedContent = updatedContent.replace(/---/, `---\nslug: ${slug}`);
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
fs.writeFileSync(ticketPath, updatedContent, 'utf-8');
|
|
107
|
-
console.log(`✅ Updated ticket with slug: ${slug}`);
|
|
108
|
-
} catch (error) {
|
|
109
|
-
console.error(`❌ Failed to update ticket with slug: ${error.message}`);
|
|
110
|
-
}
|
|
184
|
+
|
|
185
|
+
const useWorktree = flags.worktree;
|
|
186
|
+
const spawnAgent = flags.agent;
|
|
187
|
+
|
|
188
|
+
if (spawnAgent && !useWorktree && tickets.length > 1) {
|
|
189
|
+
console.error('❌ --agent without -w only supports a single ticket. Use -w for multiple tickets.');
|
|
190
|
+
process.exit(1);
|
|
111
191
|
}
|
|
112
|
-
|
|
113
|
-
// Get branch prefix from config or use default (empty)
|
|
114
|
-
const branchPrefix = config.git?.branch_prefix || '';
|
|
115
|
-
|
|
116
|
-
// Create branch name - if slug already contains the ticket ID, don't add it again
|
|
117
|
-
const branchName = slug.includes(ticketId)
|
|
118
|
-
? `${branchPrefix}${slug}`
|
|
119
|
-
: `${branchPrefix}${ticketId}-${slug}`;
|
|
120
|
-
|
|
121
|
-
if (useWorktree) {
|
|
122
|
-
const repoName = getRepoName();
|
|
123
|
-
const worktreePath = getWorktreePath(repoName, branchName);
|
|
124
192
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
193
|
+
console.log('');
|
|
194
|
+
console.log(`🚀 Starting ${tickets.length} ticket(s):\n`);
|
|
195
|
+
|
|
196
|
+
const worktreeInfos = [];
|
|
197
|
+
|
|
198
|
+
for (const ticket of tickets) {
|
|
199
|
+
const branchName = getBranchName(ticket, config);
|
|
200
|
+
const title = ticket.frontmatter.title || 'Untitled';
|
|
201
|
+
|
|
202
|
+
if (useWorktree) {
|
|
203
|
+
const worktreePath = getWorktreePath(repoName, branchName);
|
|
204
|
+
worktreeInfos.push({
|
|
205
|
+
ticket,
|
|
206
|
+
branchName,
|
|
207
|
+
worktreePath,
|
|
208
|
+
title,
|
|
209
|
+
alreadyExists: fs.existsSync(worktreePath)
|
|
210
|
+
});
|
|
211
|
+
const status = fs.existsSync(worktreePath) ? '(exists)' : '(new)';
|
|
212
|
+
console.log(` ${ticket.frontmatter.id} — ${title}`);
|
|
213
|
+
console.log(` 🌿 ${branchName} ${status}`);
|
|
214
|
+
console.log(` 📂 ${worktreePath}`);
|
|
128
215
|
} else {
|
|
129
|
-
|
|
130
|
-
|
|
216
|
+
console.log(` ${ticket.frontmatter.id} — ${title}`);
|
|
217
|
+
console.log(` 🌿 ${branchName}`);
|
|
218
|
+
}
|
|
219
|
+
console.log('');
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
if (flags.dryRun) {
|
|
223
|
+
console.log('🏁 Dry run complete. No changes made.');
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (useWorktree) {
|
|
228
|
+
for (const info of worktreeInfos) {
|
|
229
|
+
if (info.alreadyExists) {
|
|
230
|
+
console.log(`✅ ${info.ticket.frontmatter.id}: Worktree already exists`);
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
131
233
|
|
|
132
234
|
try {
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
createWorktreeExistingBranch(worktreePath, branchName);
|
|
235
|
+
const branchExists = branchExistsLocally(info.branchName) || branchExistsRemotely(info.branchName);
|
|
236
|
+
if (branchExists) {
|
|
237
|
+
createWorktreeExistingBranch(info.worktreePath, info.branchName);
|
|
136
238
|
} else {
|
|
137
|
-
|
|
138
|
-
createWorktree(worktreePath, branchName, baseBranch);
|
|
239
|
+
createWorktree(info.worktreePath, info.branchName, flags.baseBranch);
|
|
139
240
|
}
|
|
140
|
-
console.log(`✅ Worktree created
|
|
241
|
+
console.log(`✅ ${info.ticket.frontmatter.id}: Worktree created`);
|
|
141
242
|
} catch (error) {
|
|
142
|
-
console.error(`❌ Failed to create worktree
|
|
143
|
-
|
|
243
|
+
console.error(`❌ ${info.ticket.frontmatter.id}: Failed to create worktree — ${error.message}`);
|
|
244
|
+
continue;
|
|
144
245
|
}
|
|
145
|
-
}
|
|
146
246
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
const currentContent = fs.readFileSync(ticketPath, 'utf-8');
|
|
150
|
-
const now = new Date().toISOString();
|
|
151
|
-
let updatedContent = currentContent;
|
|
247
|
+
updateTicketStatus(info.ticket, info.worktreePath);
|
|
248
|
+
}
|
|
152
249
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
250
|
+
if (!flags.noInstall) {
|
|
251
|
+
const pkgExists = fs.existsSync(path.join(repoRoot, 'package.json'));
|
|
252
|
+
if (pkgExists) {
|
|
253
|
+
console.log('\n📦 Installing dependencies in worktrees...');
|
|
254
|
+
for (const info of worktreeInfos) {
|
|
255
|
+
if (fs.existsSync(path.join(info.worktreePath, 'package.json'))) {
|
|
256
|
+
try {
|
|
257
|
+
execSync('npm install --silent', { cwd: info.worktreePath, stdio: 'ignore' });
|
|
258
|
+
console.log(` ✅ ${info.ticket.frontmatter.id}: npm install done`);
|
|
259
|
+
} catch (error) {
|
|
260
|
+
console.warn(` ⚠️ ${info.ticket.frontmatter.id}: npm install failed — ${error.message}`);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
157
264
|
}
|
|
265
|
+
}
|
|
158
266
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
267
|
+
if (spawnAgent) {
|
|
268
|
+
const agentTimeout = config.worktree?.agent?.timeout || 900;
|
|
269
|
+
console.log('\n🤖 Spawning Claude agents...\n');
|
|
270
|
+
for (const info of worktreeInfos) {
|
|
271
|
+
const ticketContent = fs.readFileSync(info.ticket.filePath, 'utf-8');
|
|
272
|
+
const prompt = flags.prompt
|
|
273
|
+
? flags.prompt
|
|
274
|
+
: `You are working on ticket ${info.ticket.frontmatter.id}: ${info.title}\n\nHere is the full ticket:\n\n${ticketContent}\n\nImplement the ticket requirements. Follow the acceptance criteria. Commit your work when done. Update the ticket status to done when complete.`;
|
|
275
|
+
|
|
276
|
+
try {
|
|
277
|
+
const agentProcess = spawn('claude', ['-p', prompt, '--timeout', String(agentTimeout * 1000)], {
|
|
278
|
+
cwd: info.worktreePath,
|
|
279
|
+
stdio: 'ignore',
|
|
280
|
+
detached: true
|
|
281
|
+
});
|
|
164
282
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
283
|
+
agentProcess.unref();
|
|
284
|
+
console.log(` 🤖 ${info.ticket.frontmatter.id}: Agent spawned in ${info.worktreePath} (timeout: ${agentTimeout}s)`);
|
|
285
|
+
} catch (error) {
|
|
286
|
+
console.error(` ❌ ${info.ticket.frontmatter.id}: Failed to spawn agent — ${error.message}`);
|
|
287
|
+
}
|
|
168
288
|
}
|
|
169
|
-
} catch (error) {
|
|
170
|
-
console.error(`❌ Failed to update ticket: ${error.message}`);
|
|
171
|
-
}
|
|
172
289
|
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
console.log('💡 Run `npm install` in the worktree to install dependencies.');
|
|
182
|
-
} else {
|
|
183
|
-
// Check if there are uncommitted changes
|
|
184
|
-
const gitStatus = getGitStatus();
|
|
185
|
-
if (gitStatus) {
|
|
186
|
-
console.warn('⚠️ You have uncommitted changes. Stash or commit them before switching branches.');
|
|
290
|
+
console.log('\n🏁 All agents launched!\n');
|
|
291
|
+
console.log('Monitor progress:');
|
|
292
|
+
console.log(' vibe status # see active worktree work');
|
|
293
|
+
console.log(' vibe list --status=in_progress # see in-progress tickets');
|
|
294
|
+
} else {
|
|
295
|
+
console.log('\n🏁 Worktrees created!\n');
|
|
296
|
+
console.log('To work in a worktree:');
|
|
297
|
+
console.log(` cd ${worktreeInfos[0]?.worktreePath || '<worktree-path>'}`);
|
|
187
298
|
console.log('');
|
|
299
|
+
console.log('When done:');
|
|
300
|
+
console.log(' vibe pr --all # open PRs for all worktree branches');
|
|
188
301
|
}
|
|
302
|
+
} else {
|
|
303
|
+
for (const ticket of tickets) {
|
|
304
|
+
const branchName = getBranchName(ticket, config);
|
|
305
|
+
const branchExistsLocal = branchExistsLocally(branchName);
|
|
306
|
+
const branchExistsRemote = branchExistsRemotely(branchName);
|
|
189
307
|
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
const branchExistsRemote = branchExistsRemotely(branchName);
|
|
193
|
-
|
|
194
|
-
if (branchExistsLocal || branchExistsRemote) {
|
|
195
|
-
console.log(`🔍 Branch ${branchName} already exists.`);
|
|
196
|
-
|
|
197
|
-
if (checkoutBranch(branchName)) {
|
|
198
|
-
console.log(`✅ Switched to branch: ${branchName}`);
|
|
308
|
+
if (branchExistsLocal || branchExistsRemote) {
|
|
309
|
+
console.log(`🔍 ${ticket.frontmatter.id}: Branch exists`);
|
|
199
310
|
} else {
|
|
200
|
-
|
|
201
|
-
|
|
311
|
+
try {
|
|
312
|
+
createAndCheckoutBranch(branchName, flags.baseBranch);
|
|
313
|
+
console.log(`✅ ${ticket.frontmatter.id}: Created branch ${branchName}`);
|
|
314
|
+
} catch (error) {
|
|
315
|
+
console.error(`❌ ${ticket.frontmatter.id}: Failed to create branch — ${error.message}`);
|
|
316
|
+
}
|
|
202
317
|
}
|
|
203
|
-
} else {
|
|
204
|
-
console.log(`🔍 Creating new branch: ${branchName}`);
|
|
205
318
|
|
|
206
|
-
|
|
207
|
-
console.log(`✅ Created and switched to branch: ${branchName}`);
|
|
208
|
-
} else {
|
|
209
|
-
console.error(`❌ Failed to create branch: ${branchName}`);
|
|
210
|
-
process.exit(1);
|
|
211
|
-
}
|
|
319
|
+
updateTicketStatus(ticket, null);
|
|
212
320
|
}
|
|
213
321
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
.replace(/^status: (.+)$/m, 'status: in_progress')
|
|
222
|
-
.replace(/^updated_at: (.+)$/m, `updated_at: ${now}`);
|
|
322
|
+
if (spawnAgent) {
|
|
323
|
+
const ticket = tickets[0];
|
|
324
|
+
const agentTimeout = config.worktree?.agent?.timeout || 900;
|
|
325
|
+
const ticketContent = fs.readFileSync(ticket.filePath, 'utf-8');
|
|
326
|
+
const prompt = flags.prompt
|
|
327
|
+
? flags.prompt
|
|
328
|
+
: `You are working on ticket ${ticket.frontmatter.id}: ${ticket.frontmatter.title}\n\nHere is the full ticket:\n\n${ticketContent}\n\nImplement the ticket requirements. Follow the acceptance criteria. Commit your work when done. Update the ticket status to done when complete.`;
|
|
223
329
|
|
|
224
|
-
|
|
330
|
+
console.log('\n🤖 Spawning Claude agent...\n');
|
|
331
|
+
try {
|
|
332
|
+
const agentProcess = spawn('claude', ['-p', prompt, '--timeout', String(agentTimeout * 1000)], {
|
|
333
|
+
cwd: process.cwd(),
|
|
334
|
+
stdio: 'ignore',
|
|
335
|
+
detached: true
|
|
336
|
+
});
|
|
225
337
|
|
|
226
|
-
|
|
227
|
-
console.log(
|
|
338
|
+
agentProcess.unref();
|
|
339
|
+
console.log(` 🤖 ${ticket.frontmatter.id}: Agent spawned (timeout: ${agentTimeout}s)`);
|
|
340
|
+
console.log('\n🏁 Agent launched!\n');
|
|
341
|
+
console.log('Monitor progress:');
|
|
342
|
+
console.log(' vibe status # see ticket status');
|
|
343
|
+
console.log(' vibe list --status=in_progress # see in-progress tickets');
|
|
228
344
|
} catch (error) {
|
|
229
|
-
console.error(
|
|
345
|
+
console.error(` ❌ ${ticket.frontmatter.id}: Failed to spawn agent — ${error.message}`);
|
|
230
346
|
}
|
|
347
|
+
} else {
|
|
348
|
+
console.log('\n🏁 Branches ready!\n');
|
|
349
|
+
console.log('To switch to a branch:');
|
|
350
|
+
console.log(` git checkout ${getBranchName(tickets[0], config)}`);
|
|
231
351
|
}
|
|
232
|
-
|
|
233
|
-
// Summary
|
|
234
|
-
console.log('');
|
|
235
|
-
console.log(`🎯 Now working on: ${ticketId} - ${title}`);
|
|
236
|
-
console.log(`🌿 Branch: ${branchName}`);
|
|
237
|
-
console.log('');
|
|
238
|
-
console.log('To push this branch to remote:');
|
|
239
|
-
console.log(` git push -u origin ${branchName}`);
|
|
240
352
|
}
|
|
241
353
|
}
|
|
242
354
|
|
|
@@ -49,7 +49,7 @@ describe('start command', () => {
|
|
|
49
49
|
|
|
50
50
|
// Assert
|
|
51
51
|
expect(exitMock.exitCalls).toContain(1);
|
|
52
|
-
expect(consoleMock.logs.error).toContain('
|
|
52
|
+
expect(consoleMock.logs.error[0]).toContain('Usage: vibe start');
|
|
53
53
|
});
|
|
54
54
|
|
|
55
55
|
it('should show error for invalid ticket ID format', () => {
|
|
@@ -61,7 +61,7 @@ describe('start command', () => {
|
|
|
61
61
|
|
|
62
62
|
// Assert
|
|
63
63
|
expect(exitMock.exitCalls).toContain(1);
|
|
64
|
-
expect(consoleMock.logs.error).toContain('❌ Invalid ticket ID format
|
|
64
|
+
expect(consoleMock.logs.error).toContain('❌ Invalid ticket ID: invalid-format');
|
|
65
65
|
});
|
|
66
66
|
});
|
|
67
67
|
|
|
@@ -79,10 +79,21 @@ describe('start command', () => {
|
|
|
79
79
|
});
|
|
80
80
|
});
|
|
81
81
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
82
|
+
describe('--agent flag validation', () => {
|
|
83
|
+
it('should error when --agent used without -w for multiple tickets', () => {
|
|
84
|
+
// Arrange
|
|
85
|
+
createMockVibeProject(tempDir, {
|
|
86
|
+
withTickets: [
|
|
87
|
+
{ id: 'TKT-001', title: 'First', status: 'open' },
|
|
88
|
+
{ id: 'TKT-002', title: 'Second', status: 'open' }
|
|
89
|
+
]
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
// Act
|
|
93
|
+
expect(() => startCommand(['TKT-001', 'TKT-002', '--agent'])).toThrow('process.exit(1)');
|
|
94
|
+
|
|
95
|
+
// Assert
|
|
96
|
+
expect(consoleMock.logs.error[0]).toContain('--agent without -w only supports a single ticket');
|
|
97
|
+
});
|
|
98
|
+
});
|
|
88
99
|
});
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import yaml from 'js-yaml';
|
|
4
|
+
import { execSync } from 'child_process';
|
|
5
|
+
import { getTicketsDir, getConfig } from '../../utils/index.js';
|
|
6
|
+
import { isGitRepository, getRepoName } from '../../utils/git.js';
|
|
7
|
+
|
|
8
|
+
function getWorktreesStatus() {
|
|
9
|
+
try {
|
|
10
|
+
const output = execSync('git worktree list --porcelain', { encoding: 'utf-8' });
|
|
11
|
+
const lines = output.trim().split('\n').filter(l => l);
|
|
12
|
+
const worktrees = [];
|
|
13
|
+
for (const line of lines) {
|
|
14
|
+
const [path_, branch_] = line.split(' ');
|
|
15
|
+
if (path_ && path_ !== '.') {
|
|
16
|
+
const branch = branch_.replace('branch ', '').replace(/^refs\/heads\//, '');
|
|
17
|
+
worktrees.push({ path: path_, branch });
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
return worktrees;
|
|
21
|
+
} catch (error) {
|
|
22
|
+
return [];
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function loadTicketFromPath(ticketsDir, worktreePath) {
|
|
27
|
+
const files = fs.readdirSync(ticketsDir).filter(f => f.endsWith('.md'));
|
|
28
|
+
for (const file of files) {
|
|
29
|
+
const filePath = path.join(ticketsDir, file);
|
|
30
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
31
|
+
if (content.includes(`worktree_path: "${worktreePath}"`)) {
|
|
32
|
+
const match = content.match(/^---\n([\s\S]*?)\n---/);
|
|
33
|
+
if (match) {
|
|
34
|
+
const frontmatter = yaml.load(match[1]);
|
|
35
|
+
return { id: frontmatter.id, title: frontmatter.title, status: frontmatter.status, filePath };
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function statusCommand(args) {
|
|
43
|
+
if (!isGitRepository()) {
|
|
44
|
+
console.error('❌ Not in a git repository.');
|
|
45
|
+
process.exit(1);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const ticketsDir = getTicketsDir();
|
|
49
|
+
const worktrees = getWorktreesStatus();
|
|
50
|
+
|
|
51
|
+
if (worktrees.length === 0) {
|
|
52
|
+
console.log('📭 No active worktrees.\n');
|
|
53
|
+
console.log('Start a ticket with worktrees:');
|
|
54
|
+
console.log(' vibe start <TKT-001> -w');
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
console.log('\n🌳 Active Worktrees\n');
|
|
59
|
+
|
|
60
|
+
for (const wt of worktrees) {
|
|
61
|
+
const ticket = loadTicketFromPath(ticketsDir, wt.path);
|
|
62
|
+
if (ticket) {
|
|
63
|
+
console.log(` ${ticket.id} — ${ticket.title}`);
|
|
64
|
+
console.log(` 📂 ${wt.path}`);
|
|
65
|
+
console.log(` 🌿 ${wt.branch}`);
|
|
66
|
+
console.log(` ✓ ${ticket.status}`);
|
|
67
|
+
} else {
|
|
68
|
+
console.log(` ${wt.branch}`);
|
|
69
|
+
console.log(` 📂 ${wt.path}`);
|
|
70
|
+
}
|
|
71
|
+
console.log('');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
console.log('Commands:');
|
|
75
|
+
console.log(' cd <path> # Enter a worktree');
|
|
76
|
+
console.log(' vibe pr --all # Open PRs for all branches');
|
|
77
|
+
console.log(' git worktree remove # Remove a worktree when done');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export default statusCommand;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from '@jest/globals';
|
|
2
|
+
import {
|
|
3
|
+
createTempDir,
|
|
4
|
+
cleanupTempDir,
|
|
5
|
+
mockConsole,
|
|
6
|
+
mockProcessCwd,
|
|
7
|
+
createMockVibeProject
|
|
8
|
+
} from '../../utils/test-helpers.js';
|
|
9
|
+
import statusCommand from './index.js';
|
|
10
|
+
|
|
11
|
+
describe('status command', () => {
|
|
12
|
+
let tempDir;
|
|
13
|
+
let consoleMock;
|
|
14
|
+
let restoreCwd;
|
|
15
|
+
|
|
16
|
+
beforeEach(() => {
|
|
17
|
+
tempDir = createTempDir('status-test');
|
|
18
|
+
consoleMock = mockConsole();
|
|
19
|
+
restoreCwd = mockProcessCwd(tempDir);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
afterEach(() => {
|
|
23
|
+
consoleMock.restore();
|
|
24
|
+
restoreCwd();
|
|
25
|
+
cleanupTempDir(tempDir);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
describe('basic validation', () => {
|
|
29
|
+
it('should validate that status command exists and is callable', () => {
|
|
30
|
+
expect(typeof statusCommand).toBe('function');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('should accept arguments parameter', () => {
|
|
34
|
+
expect(statusCommand.length).toBe(1);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('should show message when no active worktrees', () => {
|
|
38
|
+
// Arrange
|
|
39
|
+
createMockVibeProject(tempDir);
|
|
40
|
+
|
|
41
|
+
// Act
|
|
42
|
+
statusCommand([]);
|
|
43
|
+
|
|
44
|
+
// Assert
|
|
45
|
+
const output = consoleMock.logs.log[0];
|
|
46
|
+
expect(output.includes('No active worktrees') || output.includes('Active Worktrees')).toBe(true);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
});
|