git-devflow 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/README.md +179 -0
- package/dist/commands/branch.js +113 -0
- package/dist/commands/commit.js +95 -0
- package/dist/commands/config.js +135 -0
- package/dist/commands/pr.js +167 -0
- package/dist/index.js +45 -0
- package/dist/services/config.js +105 -0
- package/dist/services/copilot.js +456 -0
- package/dist/services/git.js +133 -0
- package/dist/services/github.js +160 -0
- package/dist/utils/spinner.js +42 -0
- package/package.json +53 -0
|
@@ -0,0 +1,456 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.CopilotService = void 0;
|
|
7
|
+
const child_process_1 = require("child_process");
|
|
8
|
+
const util_1 = require("util");
|
|
9
|
+
const chalk_1 = __importDefault(require("chalk"));
|
|
10
|
+
const config_1 = require("./config");
|
|
11
|
+
const execAsync = (0, util_1.promisify)(child_process_1.exec);
|
|
12
|
+
const execFileAsync = (0, util_1.promisify)(child_process_1.execFile);
|
|
13
|
+
/** Models that are included with paid plans and don't consume premium requests. */
|
|
14
|
+
const FREE_MODELS = ['gpt-4.1', 'gpt-5-mini'];
|
|
15
|
+
const FREE_FALLBACK_MODEL = FREE_MODELS[0];
|
|
16
|
+
/** Patterns that indicate the user has exceeded their premium request quota. */
|
|
17
|
+
const QUOTA_ERROR_PATTERNS = [
|
|
18
|
+
/premium request/i,
|
|
19
|
+
/rate limit/i,
|
|
20
|
+
/quota/i,
|
|
21
|
+
/exceeded.*allowance/i,
|
|
22
|
+
/budget.*reached/i,
|
|
23
|
+
/limit.*reached/i,
|
|
24
|
+
/too many requests/i,
|
|
25
|
+
/429/,
|
|
26
|
+
];
|
|
27
|
+
const MODEL_CATALOG = {
|
|
28
|
+
// Free / included models
|
|
29
|
+
'gpt-4.1': { tag: 'Free', description: 'Included with your plan, no extra cost', multiplier: 0 },
|
|
30
|
+
'gpt-5-mini': { tag: 'Free', description: 'Included with your plan, fast and lightweight', multiplier: 0 },
|
|
31
|
+
// Cheap & fast
|
|
32
|
+
'claude-haiku-4.5': { tag: 'Cheap', description: 'Fastest responses, very low cost', multiplier: 0.33 },
|
|
33
|
+
'gpt-5.1-codex-mini': { tag: 'Cheap', description: 'Fast code generation, very low cost', multiplier: 0.33 },
|
|
34
|
+
// Balanced
|
|
35
|
+
'claude-sonnet-4': { tag: 'Balanced', description: 'Good quality, standard cost', multiplier: 1 },
|
|
36
|
+
'claude-sonnet-4.5': { tag: 'Balanced', description: 'Great quality and speed, recommended', multiplier: 1 },
|
|
37
|
+
'gpt-5': { tag: 'Balanced', description: 'Solid all-rounder, standard cost', multiplier: 1 },
|
|
38
|
+
'gpt-5.1': { tag: 'Balanced', description: 'Latest GPT, good quality, standard cost', multiplier: 1 },
|
|
39
|
+
'gpt-5.1-codex': { tag: 'Balanced', description: 'Optimized for code, standard cost', multiplier: 1 },
|
|
40
|
+
'gpt-5.1-codex-max': { tag: 'Balanced', description: 'Max context for code, standard cost', multiplier: 1 },
|
|
41
|
+
'gpt-5.2': { tag: 'Balanced', description: 'Newest GPT, standard cost', multiplier: 1 },
|
|
42
|
+
'gpt-5.2-codex': { tag: 'Balanced', description: 'Newest GPT for code, standard cost', multiplier: 1 },
|
|
43
|
+
'gemini-3-pro-preview': { tag: 'Balanced', description: 'Google model, good for general tasks', multiplier: 1 },
|
|
44
|
+
// Expensive / highest quality
|
|
45
|
+
'claude-opus-4.5': { tag: 'Expensive', description: 'Best quality, slowest, costs 3x per prompt', multiplier: 3 },
|
|
46
|
+
};
|
|
47
|
+
class CopilotService {
|
|
48
|
+
/**
|
|
49
|
+
* Fetches available models from the Copilot CLI by parsing `copilot --help` output,
|
|
50
|
+
* enriched with cost/multiplier metadata from GitHub's published rates.
|
|
51
|
+
* Falls back to a minimal hardcoded list if the CLI is unavailable or parsing fails.
|
|
52
|
+
*/
|
|
53
|
+
async fetchAvailableModels() {
|
|
54
|
+
let modelIds;
|
|
55
|
+
try {
|
|
56
|
+
const { stdout } = await execAsync('copilot --help', {
|
|
57
|
+
timeout: 10000,
|
|
58
|
+
shell: '/bin/bash'
|
|
59
|
+
});
|
|
60
|
+
// Extract model choices from the --model flag description
|
|
61
|
+
// Format: --model <model> Set the AI model to use (choices: "model1", "model2", ...)
|
|
62
|
+
const modelSection = stdout.match(/--model\s+<model>\s+[\s\S]*?\(choices:\s*([\s\S]*?)\)/);
|
|
63
|
+
if (modelSection) {
|
|
64
|
+
const parsed = modelSection[1]
|
|
65
|
+
.match(/"([^"]+)"/g)
|
|
66
|
+
?.map(m => m.replace(/"/g, ''));
|
|
67
|
+
modelIds = parsed && parsed.length > 0 ? parsed : this.getFallbackModelIds();
|
|
68
|
+
}
|
|
69
|
+
else {
|
|
70
|
+
modelIds = this.getFallbackModelIds();
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
modelIds = this.getFallbackModelIds();
|
|
75
|
+
}
|
|
76
|
+
return modelIds.map(id => {
|
|
77
|
+
const info = MODEL_CATALOG[id];
|
|
78
|
+
return {
|
|
79
|
+
id,
|
|
80
|
+
tag: info?.tag ?? 'New',
|
|
81
|
+
description: info?.description ?? 'Recently added model',
|
|
82
|
+
};
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
getFallbackModelIds() {
|
|
86
|
+
return [
|
|
87
|
+
'claude-sonnet-4.5',
|
|
88
|
+
'claude-haiku-4.5',
|
|
89
|
+
'gpt-4.1'
|
|
90
|
+
];
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Prints the model name and its cost before a Copilot call.
|
|
94
|
+
*/
|
|
95
|
+
printModelCost(model) {
|
|
96
|
+
const meta = MODEL_CATALOG[model];
|
|
97
|
+
const tag = meta?.tag ?? 'Unknown';
|
|
98
|
+
const multiplier = meta?.multiplier;
|
|
99
|
+
let costStr;
|
|
100
|
+
if (multiplier === 0)
|
|
101
|
+
costStr = chalk_1.default.green('free, no premium requests used');
|
|
102
|
+
else if (multiplier !== undefined)
|
|
103
|
+
costStr = chalk_1.default.yellow(`${multiplier} premium request(s) per prompt`);
|
|
104
|
+
else
|
|
105
|
+
costStr = chalk_1.default.dim('unknown cost');
|
|
106
|
+
const tagColor = tag === 'Free' ? chalk_1.default.green(tag) :
|
|
107
|
+
tag === 'Cheap' ? chalk_1.default.cyan(tag) :
|
|
108
|
+
tag === 'Balanced' ? chalk_1.default.yellow(tag) :
|
|
109
|
+
tag === 'Expensive' ? chalk_1.default.red(tag) :
|
|
110
|
+
chalk_1.default.dim(tag);
|
|
111
|
+
console.log(`🤖 Model: ${chalk_1.default.bold(model)} ${tagColor} · ${costStr}`);
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Checks whether an error from the Copilot CLI looks like a quota/rate-limit issue.
|
|
115
|
+
*/
|
|
116
|
+
isQuotaError(error) {
|
|
117
|
+
const msg = error.message || '';
|
|
118
|
+
return QUOTA_ERROR_PATTERNS.some(p => p.test(msg));
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Prints a user-friendly warning when quota is exceeded and we're retrying
|
|
122
|
+
* with a free model.
|
|
123
|
+
*/
|
|
124
|
+
printQuotaWarning(currentModel) {
|
|
125
|
+
console.log('');
|
|
126
|
+
console.log(chalk_1.default.yellow('⚠️ Premium request limit reached for ') + chalk_1.default.bold(currentModel));
|
|
127
|
+
console.log(chalk_1.default.dim(' Your monthly quota may be exhausted or the model is rate-limited.'));
|
|
128
|
+
console.log(chalk_1.default.cyan(` Retrying with ${FREE_FALLBACK_MODEL} (free, no premium requests)...\n`));
|
|
129
|
+
this.printModelCost(FREE_FALLBACK_MODEL);
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Prints a tip after a successful free-model retry so the user knows
|
|
133
|
+
* how to permanently switch.
|
|
134
|
+
*/
|
|
135
|
+
printSwitchTip() {
|
|
136
|
+
console.log(chalk_1.default.dim(`\n Tip: To avoid this, switch your default model:`));
|
|
137
|
+
console.log(chalk_1.default.dim(` Run: `) + chalk_1.default.cyan('devflow config setup') + chalk_1.default.dim(' and pick a Free model.\n'));
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Runs a prompt against the Copilot CLI with the given model.
|
|
141
|
+
* Returns stdout on success, or throws on failure.
|
|
142
|
+
*/
|
|
143
|
+
async execCopilotPrompt(prompt, model) {
|
|
144
|
+
try {
|
|
145
|
+
// Build args array — passed directly to the binary, no shell interpretation.
|
|
146
|
+
// This avoids issues with $, backticks, quotes etc. in diff content.
|
|
147
|
+
const args = [];
|
|
148
|
+
if (model) {
|
|
149
|
+
args.push('--model', model);
|
|
150
|
+
}
|
|
151
|
+
args.push('-s', '--prompt', prompt);
|
|
152
|
+
const { stdout } = await execFileAsync('copilot', args, {
|
|
153
|
+
maxBuffer: 1024 * 1024,
|
|
154
|
+
timeout: 60000,
|
|
155
|
+
});
|
|
156
|
+
return stdout;
|
|
157
|
+
}
|
|
158
|
+
catch (error) {
|
|
159
|
+
// Build a safe error message without leaking sensitive data
|
|
160
|
+
const stderr = (error.stderr || '').trim();
|
|
161
|
+
const code = error.code;
|
|
162
|
+
const exitCode = error.status ?? error.code;
|
|
163
|
+
if (code === 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER') {
|
|
164
|
+
throw new Error('Copilot response exceeded buffer size');
|
|
165
|
+
}
|
|
166
|
+
if (error.killed) {
|
|
167
|
+
throw new Error('Copilot request timed out (60s). Try a faster model like gpt-4.1 or claude-haiku-4.5');
|
|
168
|
+
}
|
|
169
|
+
// Pass through stderr if it's short and doesn't contain secrets
|
|
170
|
+
const safeStderr = stderr.length > 0 && stderr.length < 200 && !/token|auth|key|secret|password/i.test(stderr)
|
|
171
|
+
? stderr
|
|
172
|
+
: `exit code ${exitCode || 'unknown'}`;
|
|
173
|
+
throw new Error(`Copilot CLI error: ${safeStderr}`);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Runs a prompt with the user's configured model.
|
|
178
|
+
* Shows cost info before the call.
|
|
179
|
+
* If a quota/rate-limit error is detected, automatically retries with a free model.
|
|
180
|
+
*/
|
|
181
|
+
async execWithQuotaRetry(prompt) {
|
|
182
|
+
const configService = new config_1.ConfigService();
|
|
183
|
+
const model = configService.getCopilotModel();
|
|
184
|
+
const isFreeModel = FREE_MODELS.includes(model);
|
|
185
|
+
// Show model + cost before making the call
|
|
186
|
+
this.printModelCost(model);
|
|
187
|
+
console.log('');
|
|
188
|
+
try {
|
|
189
|
+
return await this.execCopilotPrompt(prompt, model);
|
|
190
|
+
}
|
|
191
|
+
catch (error) {
|
|
192
|
+
// If already on a free model, no point retrying — just throw
|
|
193
|
+
if (isFreeModel)
|
|
194
|
+
throw error;
|
|
195
|
+
// If it looks like a quota issue, retry with the free model
|
|
196
|
+
if (this.isQuotaError(error)) {
|
|
197
|
+
this.printQuotaWarning(model);
|
|
198
|
+
const result = await this.execCopilotPrompt(prompt, FREE_FALLBACK_MODEL);
|
|
199
|
+
this.printSwitchTip();
|
|
200
|
+
return result;
|
|
201
|
+
}
|
|
202
|
+
// Some other error — let it bubble up
|
|
203
|
+
throw error;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
async generateCommitMessage(diff) {
|
|
207
|
+
try {
|
|
208
|
+
const prompt = `Generate 3 commit messages for these git changes. Use conventional commits format (feat/fix/chore/etc).
|
|
209
|
+
|
|
210
|
+
Changes:
|
|
211
|
+
${diff.substring(0, 2000)}
|
|
212
|
+
|
|
213
|
+
Respond with 3 options:
|
|
214
|
+
1. [first message]
|
|
215
|
+
2. [second message]
|
|
216
|
+
3. [third message]
|
|
217
|
+
|
|
218
|
+
Keep each under 72 characters.`;
|
|
219
|
+
const stdout = await this.execWithQuotaRetry(prompt);
|
|
220
|
+
// Parse messages
|
|
221
|
+
const messages = this.parseCommitMessages(stdout);
|
|
222
|
+
if (messages.length > 0) {
|
|
223
|
+
return messages;
|
|
224
|
+
}
|
|
225
|
+
return this.generateFallbackMessages(diff);
|
|
226
|
+
}
|
|
227
|
+
catch (error) {
|
|
228
|
+
const reason = error instanceof Error ? error.message : 'unknown error';
|
|
229
|
+
console.log(chalk_1.default.yellow(`⚠️ ${reason}`));
|
|
230
|
+
console.log(chalk_1.default.dim(' Using fallback suggestions\n'));
|
|
231
|
+
return this.generateFallbackMessages(diff);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
stripMarkdown(text) {
|
|
235
|
+
return text
|
|
236
|
+
.replace(/\*\*/g, '') // Remove bold **
|
|
237
|
+
.replace(/\*/g, '') // Remove italic *
|
|
238
|
+
.replace(/`/g, '') // Remove code `
|
|
239
|
+
.replace(/^[-•]\s+/, '') // Remove list markers
|
|
240
|
+
.trim();
|
|
241
|
+
}
|
|
242
|
+
parseCommitMessages(response) {
|
|
243
|
+
const messages = [];
|
|
244
|
+
// Split by lines
|
|
245
|
+
const lines = response
|
|
246
|
+
.split('\n')
|
|
247
|
+
.map(l => l.trim())
|
|
248
|
+
.filter(l => l.length > 0);
|
|
249
|
+
for (const line of lines) {
|
|
250
|
+
// Try multiple patterns
|
|
251
|
+
// Pattern 1: "1. message" or "1) message"
|
|
252
|
+
let match = line.match(/^\d+[\.)]\s*(.+)$/);
|
|
253
|
+
if (match) {
|
|
254
|
+
messages.push(this.stripMarkdown(match[1].trim()));
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
// Pattern 2: Direct conventional commit format
|
|
258
|
+
match = line.match(/^(feat|fix|docs|style|refactor|test|chore|perf|ci|build)(\(.+?\))?: .+/i);
|
|
259
|
+
if (match) {
|
|
260
|
+
messages.push(this.stripMarkdown(line));
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
// Pattern 3: "- message" (markdown list)
|
|
264
|
+
match = line.match(/^[-*]\s+(.+)$/);
|
|
265
|
+
if (match && match[1].match(/^(feat|fix|docs|style|refactor|test|chore)/i)) {
|
|
266
|
+
messages.push(this.stripMarkdown(match[1].trim()));
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
return messages.slice(0, 3);
|
|
271
|
+
}
|
|
272
|
+
generateFallbackMessages(diff) {
|
|
273
|
+
const lines = diff.split('\n');
|
|
274
|
+
const added = lines.filter(l => l.startsWith('+')).length;
|
|
275
|
+
const removed = lines.filter(l => l.startsWith('-')).length;
|
|
276
|
+
return [
|
|
277
|
+
`feat: update files (+${added} -${removed})`,
|
|
278
|
+
`fix: improve code quality`,
|
|
279
|
+
`chore: update documentation`
|
|
280
|
+
];
|
|
281
|
+
}
|
|
282
|
+
async generatePRDescription(commits, issueContext = '') {
|
|
283
|
+
try {
|
|
284
|
+
const commitList = commits.map(c => `- ${c.message}`).join('\n');
|
|
285
|
+
const prompt = `Generate a PR title and description for these commits:\n${commitList}\n\nFormat:\nTITLE: [title]\nBODY:\n[description]`;
|
|
286
|
+
const stdout = await this.execWithQuotaRetry(prompt);
|
|
287
|
+
// Parse response
|
|
288
|
+
const parsed = this.parsePRDescription(stdout);
|
|
289
|
+
if (parsed.title && parsed.body && parsed.body.length > 50) {
|
|
290
|
+
return parsed;
|
|
291
|
+
}
|
|
292
|
+
// If parsing gave weak results, use fallback
|
|
293
|
+
console.log('⚠️ Weak response, using fallback\n');
|
|
294
|
+
return this.generateSmartFallback(commits, issueContext);
|
|
295
|
+
}
|
|
296
|
+
catch (error) {
|
|
297
|
+
const reason = error instanceof Error ? error.message : 'unknown error';
|
|
298
|
+
console.log(chalk_1.default.yellow(`⚠️ ${reason}`));
|
|
299
|
+
console.log(chalk_1.default.dim(' Using fallback PR description\n'));
|
|
300
|
+
return this.generateSmartFallback(commits, issueContext);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
generateSmartFallback(commits, issueContext) {
|
|
304
|
+
// Use first commit as title if it's good
|
|
305
|
+
const title = commits[0]?.message || 'Update';
|
|
306
|
+
// Group commits by type
|
|
307
|
+
const features = commits.filter(c => c.message.startsWith('feat'));
|
|
308
|
+
const fixes = commits.filter(c => c.message.startsWith('fix'));
|
|
309
|
+
const chores = commits.filter(c => c.message.startsWith('chore'));
|
|
310
|
+
const others = commits.filter(c => !c.message.match(/^(feat|fix|chore)/));
|
|
311
|
+
let body = '## Summary\n\n';
|
|
312
|
+
if (commits.length === 1) {
|
|
313
|
+
body += commits[0].message;
|
|
314
|
+
}
|
|
315
|
+
else {
|
|
316
|
+
body += `This PR includes ${commits.length} commits with `;
|
|
317
|
+
const types = [];
|
|
318
|
+
if (features.length)
|
|
319
|
+
types.push(`${features.length} feature(s)`);
|
|
320
|
+
if (fixes.length)
|
|
321
|
+
types.push(`${fixes.length} fix(es)`);
|
|
322
|
+
if (chores.length)
|
|
323
|
+
types.push(`${chores.length} chore(s)`);
|
|
324
|
+
body += types.join(', ') || 'various changes';
|
|
325
|
+
body += '.';
|
|
326
|
+
}
|
|
327
|
+
body += '\n\n## Changes\n\n';
|
|
328
|
+
if (features.length) {
|
|
329
|
+
body += '### Features\n';
|
|
330
|
+
features.forEach(c => body += `- ${c.message}\n`);
|
|
331
|
+
body += '\n';
|
|
332
|
+
}
|
|
333
|
+
if (fixes.length) {
|
|
334
|
+
body += '### Fixes\n';
|
|
335
|
+
fixes.forEach(c => body += `- ${c.message}\n`);
|
|
336
|
+
body += '\n';
|
|
337
|
+
}
|
|
338
|
+
if (chores.length) {
|
|
339
|
+
body += '### Chores\n';
|
|
340
|
+
chores.forEach(c => body += `- ${c.message}\n`);
|
|
341
|
+
body += '\n';
|
|
342
|
+
}
|
|
343
|
+
if (others.length) {
|
|
344
|
+
body += '### Other Changes\n';
|
|
345
|
+
others.forEach(c => body += `- ${c.message}\n`);
|
|
346
|
+
body += '\n';
|
|
347
|
+
}
|
|
348
|
+
if (issueContext) {
|
|
349
|
+
body += `\n${issueContext}\n`;
|
|
350
|
+
}
|
|
351
|
+
body += '\n## Testing\n\nPlease test these changes in your environment.\n';
|
|
352
|
+
return { title, body };
|
|
353
|
+
}
|
|
354
|
+
parsePRDescription(response) {
|
|
355
|
+
// Try to extract title and body
|
|
356
|
+
const titleMatch = response.match(/TITLE:\s*(.+)/i);
|
|
357
|
+
const bodyMatch = response.match(/BODY:\s*([\s\S]+)/i);
|
|
358
|
+
if (titleMatch && bodyMatch) {
|
|
359
|
+
return {
|
|
360
|
+
title: this.stripMarkdown(titleMatch[1].trim()),
|
|
361
|
+
body: bodyMatch[1].trim()
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
// If parsing fails, use first line as title, rest as body
|
|
365
|
+
const lines = response.split('\n').filter(l => l.trim().length > 0);
|
|
366
|
+
return {
|
|
367
|
+
title: this.stripMarkdown(lines[0] || 'Update'),
|
|
368
|
+
body: lines.slice(1).join('\n') || 'Pull request description'
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
generateFallbackPR(commits, issueContext) {
|
|
372
|
+
const title = commits[0]?.message || 'Update code';
|
|
373
|
+
const body = `## Summary
|
|
374
|
+
This PR includes ${commits.length} commit(s).
|
|
375
|
+
|
|
376
|
+
## Changes
|
|
377
|
+
${commits.map(c => `- ${c.message}`).join('\n')}
|
|
378
|
+
|
|
379
|
+
${issueContext ? `## Related Issues\n${issueContext}` : ''}
|
|
380
|
+
|
|
381
|
+
## Testing
|
|
382
|
+
Please test the changes thoroughly.`;
|
|
383
|
+
return { title, body };
|
|
384
|
+
}
|
|
385
|
+
async generateBranchName(description, type = 'feature', issueNumber) {
|
|
386
|
+
try {
|
|
387
|
+
const issuePrefix = issueNumber ? `${issueNumber}-` : '';
|
|
388
|
+
const prompt = `Generate a git branch name from this description: "${description}"
|
|
389
|
+
|
|
390
|
+
Branch type: ${type}
|
|
391
|
+
${issueNumber ? `Issue number: ${issueNumber}` : ''}
|
|
392
|
+
|
|
393
|
+
Rules:
|
|
394
|
+
- Use format: ${type}/${issuePrefix}[descriptive-name]
|
|
395
|
+
- Use kebab-case (lowercase with hyphens)
|
|
396
|
+
- Keep it concise (max 50 chars)
|
|
397
|
+
- Be descriptive but brief
|
|
398
|
+
- Only alphanumeric and hyphens
|
|
399
|
+
|
|
400
|
+
Example: feature/123-add-user-auth
|
|
401
|
+
|
|
402
|
+
Respond with ONLY the branch name, nothing else.`;
|
|
403
|
+
const stdout = await this.execWithQuotaRetry(prompt);
|
|
404
|
+
// Parse the branch name from response
|
|
405
|
+
let branchName = stdout.trim().split('\n')[0].trim();
|
|
406
|
+
// Remove any markdown formatting
|
|
407
|
+
branchName = branchName.replace(/```/g, '').replace(/`/g, '').trim();
|
|
408
|
+
// Remove quotes if present
|
|
409
|
+
branchName = branchName.replace(/^["']|["']$/g, '');
|
|
410
|
+
// Validate and sanitize
|
|
411
|
+
branchName = this.sanitizeBranchName(branchName, type, issuePrefix);
|
|
412
|
+
return branchName;
|
|
413
|
+
}
|
|
414
|
+
catch (error) {
|
|
415
|
+
const reason = error instanceof Error ? error.message : 'unknown error';
|
|
416
|
+
console.log(chalk_1.default.yellow(`⚠️ ${reason}`));
|
|
417
|
+
console.log(chalk_1.default.dim(' Using fallback branch name\n'));
|
|
418
|
+
return this.generateFallbackBranchName(description, type, issueNumber);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
// Sanitize branch name
|
|
422
|
+
sanitizeBranchName(name, type, issuePrefix) {
|
|
423
|
+
// Remove type prefix if Copilot added it
|
|
424
|
+
name = name.replace(new RegExp(`^${type}/`, 'i'), '');
|
|
425
|
+
// Convert to lowercase and replace spaces/special chars with hyphens
|
|
426
|
+
name = name
|
|
427
|
+
.toLowerCase()
|
|
428
|
+
.replace(/[^a-z0-9-]/g, '-')
|
|
429
|
+
.replace(/-+/g, '-')
|
|
430
|
+
.replace(/^-|-$/g, '');
|
|
431
|
+
// Limit length
|
|
432
|
+
const maxLength = 50 - type.length - issuePrefix.length - 1;
|
|
433
|
+
if (name.length > maxLength) {
|
|
434
|
+
name = name.substring(0, maxLength).replace(/-$/, '');
|
|
435
|
+
}
|
|
436
|
+
return `${type}/${issuePrefix}${name}`;
|
|
437
|
+
}
|
|
438
|
+
// Fallback branch name generation
|
|
439
|
+
generateFallbackBranchName(description, type, issueNumber) {
|
|
440
|
+
const issuePrefix = issueNumber ? `${issueNumber}-` : '';
|
|
441
|
+
// Convert description to kebab-case
|
|
442
|
+
let name = description
|
|
443
|
+
.toLowerCase()
|
|
444
|
+
.replace(/[^a-z0-9\s-]/g, '')
|
|
445
|
+
.replace(/\s+/g, '-')
|
|
446
|
+
.replace(/-+/g, '-')
|
|
447
|
+
.replace(/^-|-$/g, '');
|
|
448
|
+
// Limit length
|
|
449
|
+
const maxLength = 50 - type.length - issuePrefix.length - 1;
|
|
450
|
+
if (name.length > maxLength) {
|
|
451
|
+
name = name.substring(0, maxLength).replace(/-$/, '');
|
|
452
|
+
}
|
|
453
|
+
return `${type}/${issuePrefix}${name}`;
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
exports.CopilotService = CopilotService;
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.GitService = void 0;
|
|
7
|
+
const simple_git_1 = __importDefault(require("simple-git"));
|
|
8
|
+
class GitService {
|
|
9
|
+
constructor() {
|
|
10
|
+
this.git = (0, simple_git_1.default)();
|
|
11
|
+
}
|
|
12
|
+
// Check if we're in a git repository
|
|
13
|
+
async isGitRepo() {
|
|
14
|
+
try {
|
|
15
|
+
await this.git.status();
|
|
16
|
+
return true;
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
// Get staged changes (git diff --cached)
|
|
23
|
+
async getStagedDiff() {
|
|
24
|
+
try {
|
|
25
|
+
const diff = await this.git.diff(['--cached']);
|
|
26
|
+
return diff;
|
|
27
|
+
}
|
|
28
|
+
catch (error) {
|
|
29
|
+
throw new Error('Failed to get staged changes');
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
// Get status
|
|
33
|
+
async getStatus() {
|
|
34
|
+
return await this.git.status();
|
|
35
|
+
}
|
|
36
|
+
// Stage all changes
|
|
37
|
+
async stageAll() {
|
|
38
|
+
await this.git.add('.');
|
|
39
|
+
}
|
|
40
|
+
// Create commit
|
|
41
|
+
async commit(message) {
|
|
42
|
+
await this.git.commit(message);
|
|
43
|
+
}
|
|
44
|
+
// Check if there are staged changes
|
|
45
|
+
async hasStagedChanges() {
|
|
46
|
+
const status = await this.getStatus();
|
|
47
|
+
return status.staged.length > 0;
|
|
48
|
+
}
|
|
49
|
+
// Get current branch name
|
|
50
|
+
async getCurrentBranch() {
|
|
51
|
+
const status = await this.git.status();
|
|
52
|
+
return status.current || 'main';
|
|
53
|
+
}
|
|
54
|
+
// Get base branch (usually main or master)
|
|
55
|
+
async getBaseBranch() {
|
|
56
|
+
try {
|
|
57
|
+
// Try to find main first
|
|
58
|
+
await this.git.raw(['rev-parse', '--verify', 'main']);
|
|
59
|
+
return 'main';
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
try {
|
|
63
|
+
// Fall back to master
|
|
64
|
+
await this.git.raw(['rev-parse', '--verify', 'master']);
|
|
65
|
+
return 'master';
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return 'main'; // Default to main
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
// Get commits in current branch (not in base branch)
|
|
73
|
+
async getCommitsSinceBase() {
|
|
74
|
+
const currentBranch = await this.getCurrentBranch();
|
|
75
|
+
const baseBranch = await this.getBaseBranch();
|
|
76
|
+
try {
|
|
77
|
+
const log = await this.git.log({
|
|
78
|
+
from: baseBranch,
|
|
79
|
+
to: currentBranch
|
|
80
|
+
});
|
|
81
|
+
return log.all.map(commit => ({
|
|
82
|
+
hash: commit.hash.substring(0, 7),
|
|
83
|
+
message: commit.message,
|
|
84
|
+
author: commit.author_name,
|
|
85
|
+
date: commit.date
|
|
86
|
+
}));
|
|
87
|
+
}
|
|
88
|
+
catch (error) {
|
|
89
|
+
throw new Error('Failed to get commit history');
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
// Extract issue number from branch name or commits
|
|
93
|
+
extractIssueNumber() {
|
|
94
|
+
// Try to extract from branch name (e.g., feature/add-auth-123)
|
|
95
|
+
const branchMatch = this.git.branch().then(b => b.current?.match(/-(\d+)$/));
|
|
96
|
+
// For now, return null (we'll enhance this later)
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
// Check if current branch exists on remote
|
|
100
|
+
async isBranchPushed(branchName) {
|
|
101
|
+
try {
|
|
102
|
+
const result = await this.git.raw(['ls-remote', '--heads', 'origin', branchName]);
|
|
103
|
+
return result.trim().length > 0;
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
// Push current branch to remote
|
|
110
|
+
async pushBranch(branchName) {
|
|
111
|
+
await this.git.push('origin', branchName, ['--set-upstream']);
|
|
112
|
+
}
|
|
113
|
+
// Create and checkout a new branch
|
|
114
|
+
async createAndCheckoutBranch(branchName) {
|
|
115
|
+
try {
|
|
116
|
+
await this.git.checkoutLocalBranch(branchName);
|
|
117
|
+
}
|
|
118
|
+
catch (error) {
|
|
119
|
+
throw new Error(`Failed to create branch: ${error.message}`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
// Check if branch already exists
|
|
123
|
+
async branchExists(branchName) {
|
|
124
|
+
try {
|
|
125
|
+
const branches = await this.git.branchLocal();
|
|
126
|
+
return branches.all.includes(branchName);
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
exports.GitService = GitService;
|