@gitset-dev/cli 1.2.0 → 2.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/{LICENSE.md → LICENSE} +1 -1
- package/README.md +80 -159
- package/index.js +230 -0
- package/lib/ai/capabilities.js +81 -0
- package/lib/ai/errors.js +115 -0
- package/lib/ai/index.js +98 -0
- package/lib/ai/providers/anthropic.js +62 -0
- package/lib/ai/providers/gemini.js +77 -0
- package/lib/ai/providers/mock.js +41 -0
- package/lib/ai/providers/openai-compatible.js +72 -0
- package/lib/ai/retry.js +31 -0
- package/lib/cli-commit.js +143 -0
- package/lib/cli-config.js +207 -0
- package/lib/cli-issue.js +144 -0
- package/lib/cli-pr.js +168 -0
- package/lib/cli-readme.js +153 -0
- package/lib/commit-local.js +67 -0
- package/lib/config.js +134 -0
- package/lib/generate-local.js +59 -0
- package/lib/labels-ai.js +41 -0
- package/lib/prompts/defaults.js +191 -0
- package/lib/prompts/index.js +79 -0
- package/lib/setup-wizard.js +115 -0
- package/npm-shrinkwrap.json +566 -0
- package/package.json +35 -35
- package/src/commands/dependabot-resolver.js +314 -0
- package/src/commands/gitignore.js +110 -0
- package/src/commands/init.js +38 -0
- package/src/commands/labelspack.js +94 -0
- package/src/commands/license.js +208 -0
- package/src/commands/release.js +180 -0
- package/src/commands/repo.js +176 -0
- package/src/commands/status.js +56 -0
- package/src/commands/template.js +92 -0
- package/src/commands/tree.js +77 -0
- package/src/utils/dependabot-analyzer.js +101 -0
- package/src/utils/labels.js +102 -0
- package/src/utils/theme.js +70 -0
- package/src/utils/ui.js +173 -0
- package/.github/workflows/npm_publish.yml +0 -38
- package/src/index.js +0 -505
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `gitset config` — manage local BYOAI provider credentials.
|
|
5
|
+
* Keys are stored only on this machine (~/.gitset/config.json, chmod 600)
|
|
6
|
+
* and are sent directly to the chosen AI provider, never to Gitset.
|
|
7
|
+
*/
|
|
8
|
+
const config = require('./config');
|
|
9
|
+
const { SUPPORTED, isForbiddenModel } = require('./ai');
|
|
10
|
+
const theme = require('../src/utils/theme');
|
|
11
|
+
const { selectOption } = require('../src/utils/ui');
|
|
12
|
+
const { runQuickSetup } = require('./setup-wizard');
|
|
13
|
+
|
|
14
|
+
const ok = (s) => console.log(`${theme.success('✓')} ${s}`);
|
|
15
|
+
const err = (s) => console.error(`${theme.error('✗')} ${s}`);
|
|
16
|
+
|
|
17
|
+
function flag(argv, name) {
|
|
18
|
+
const i = argv.indexOf(name);
|
|
19
|
+
return i !== -1 && i + 1 < argv.length && !argv[i + 1].startsWith('--') ? argv[i + 1] : null;
|
|
20
|
+
}
|
|
21
|
+
const has = (argv, name) => argv.includes(name);
|
|
22
|
+
|
|
23
|
+
const PROVIDERS = [...SUPPORTED.filter((p) => p !== 'mock'), 'custom'];
|
|
24
|
+
|
|
25
|
+
function usage() {
|
|
26
|
+
console.log(`gitset config — BYOAI provider setup
|
|
27
|
+
|
|
28
|
+
${theme.accent('gitset config')} interactive setup (recommended)
|
|
29
|
+
gitset config set <provider> --key <api-key> [--model <m>] [--base-url <url>] [--default]
|
|
30
|
+
gitset config list
|
|
31
|
+
gitset config remove <provider>
|
|
32
|
+
gitset config theme <dark|light>
|
|
33
|
+
gitset config path
|
|
34
|
+
|
|
35
|
+
Providers: ${PROVIDERS.join(', ')}
|
|
36
|
+
Env override: ANTHROPIC_API_KEY, OPENAI_API_KEY, GEMINI_API_KEY, OPENROUTER_API_KEY, DEEPSEEK_API_KEY`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function printList() {
|
|
40
|
+
const rows = config.list();
|
|
41
|
+
if (rows.length === 0) {
|
|
42
|
+
console.log('No providers configured yet.');
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
for (const r of rows) {
|
|
46
|
+
const def = r.isDefault ? theme.success(' (default)') : '';
|
|
47
|
+
const key = r.keyLast4 ? `••••${r.keyLast4}` : theme.warn('no key');
|
|
48
|
+
console.log(`${theme.bold(r.provider)}${def}\n key: ${key}${r.model ? ` model: ${theme.accent(r.model)}` : ''}${r.baseUrl ? ` base: ${r.baseUrl}` : ''}`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Bare `gitset config` — an always-interactive control panel, not a one-shot
|
|
53
|
+
// printout. Nothing set up yet? Straight to the add-provider wizard. Already
|
|
54
|
+
// configured? A real menu (add / remove / default / theme), looping until
|
|
55
|
+
// the user picks "Done" — never a dead end that just prints and exits.
|
|
56
|
+
async function runInteractiveHub() {
|
|
57
|
+
for (;;) {
|
|
58
|
+
const rows = config.list();
|
|
59
|
+
if (rows.length === 0) {
|
|
60
|
+
return await runQuickSetup();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
console.log();
|
|
64
|
+
console.log(theme.bold('Configured providers:'));
|
|
65
|
+
printList();
|
|
66
|
+
|
|
67
|
+
let choice;
|
|
68
|
+
try {
|
|
69
|
+
choice = await selectOption('What do you want to do?', [
|
|
70
|
+
{ label: 'Add another provider', value: 'add' },
|
|
71
|
+
{ label: 'Set the default provider', value: 'default' },
|
|
72
|
+
{ label: 'Remove a provider', value: 'remove' },
|
|
73
|
+
{ label: `Switch theme (currently ${theme.mode()})`, value: 'theme' },
|
|
74
|
+
{ label: 'Done', value: 'done' },
|
|
75
|
+
]);
|
|
76
|
+
} catch {
|
|
77
|
+
return true; // e.g. Ctrl+D — leave quietly, nothing pending to save
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (choice === 'done') return true;
|
|
81
|
+
|
|
82
|
+
if (choice === 'add') { await runQuickSetup(); continue; }
|
|
83
|
+
|
|
84
|
+
if (choice === 'default') {
|
|
85
|
+
const provider = await selectOption(
|
|
86
|
+
'Make which provider the default?',
|
|
87
|
+
rows.map((r) => ({ label: r.provider + (r.isDefault ? ' (current default)' : ''), value: r.provider })),
|
|
88
|
+
);
|
|
89
|
+
const row = rows.find((r) => r.provider === provider);
|
|
90
|
+
config.setProvider(provider, { model: row.model || undefined, baseUrl: row.baseUrl || undefined, makeDefault: true });
|
|
91
|
+
ok(`${provider} is now the default.`);
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (choice === 'remove') {
|
|
96
|
+
const provider = await selectOption(
|
|
97
|
+
'Remove which provider?',
|
|
98
|
+
rows.map((r) => ({ label: r.provider + (r.isDefault ? ' (default)' : ''), value: r.provider })),
|
|
99
|
+
);
|
|
100
|
+
config.removeProvider(provider);
|
|
101
|
+
ok(`Removed ${provider}.`);
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (choice === 'theme') {
|
|
106
|
+
const mode = await selectOption('Pick a theme:', [
|
|
107
|
+
{ label: 'Dark terminal', value: 'dark' },
|
|
108
|
+
{ label: 'Light terminal', value: 'light' },
|
|
109
|
+
]);
|
|
110
|
+
config.setTheme(mode);
|
|
111
|
+
ok(`Theme set to ${mode}.`);
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function runConfigCommand(argv) {
|
|
118
|
+
const sub = argv[0];
|
|
119
|
+
|
|
120
|
+
if (!sub) {
|
|
121
|
+
if (!process.stdin.isTTY) {
|
|
122
|
+
if (config.list().length === 0) {
|
|
123
|
+
err('No AI provider configured. In a non-interactive shell, set one directly:');
|
|
124
|
+
console.log(' gitset config set anthropic --key <api-key> --default');
|
|
125
|
+
return 1;
|
|
126
|
+
}
|
|
127
|
+
printList();
|
|
128
|
+
return 0;
|
|
129
|
+
}
|
|
130
|
+
const ranOk = await runInteractiveHub();
|
|
131
|
+
return ranOk ? 0 : 1;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (sub === 'help' || sub === '--help') { usage(); return 0; }
|
|
135
|
+
|
|
136
|
+
if (sub === 'path') { console.log(config.FILE); return 0; }
|
|
137
|
+
|
|
138
|
+
if (sub === 'list') { printList(); return 0; }
|
|
139
|
+
|
|
140
|
+
if (sub === 'theme') {
|
|
141
|
+
const value = argv[1];
|
|
142
|
+
if (value !== 'dark' && value !== 'light') {
|
|
143
|
+
err('Usage: gitset config theme <dark|light>');
|
|
144
|
+
return 1;
|
|
145
|
+
}
|
|
146
|
+
config.setTheme(value);
|
|
147
|
+
ok(`Theme set to ${value}.`);
|
|
148
|
+
return 0;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (sub === 'set') {
|
|
152
|
+
const provider = argv[1] && !argv[1].startsWith('--') ? argv[1].toLowerCase() : null;
|
|
153
|
+
|
|
154
|
+
// `gitset config set` with no provider named — same wizard as bare
|
|
155
|
+
// `gitset config`, just reachable from the more discoverable verb.
|
|
156
|
+
if (!provider) {
|
|
157
|
+
if (!process.stdin.isTTY) {
|
|
158
|
+
err('Usage: gitset config set <provider> --key <api-key> [--model m] [--default]');
|
|
159
|
+
return 1;
|
|
160
|
+
}
|
|
161
|
+
const didSetUp = await runQuickSetup();
|
|
162
|
+
return didSetUp ? 0 : 1;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (!PROVIDERS.includes(provider)) {
|
|
166
|
+
err(`Unknown provider "${provider}". One of: ${PROVIDERS.join(', ')}`);
|
|
167
|
+
return 1;
|
|
168
|
+
}
|
|
169
|
+
const apiKey = flag(argv, '--key');
|
|
170
|
+
const model = flag(argv, '--model');
|
|
171
|
+
const baseUrl = flag(argv, '--base-url');
|
|
172
|
+
if (!apiKey && !model && !baseUrl) {
|
|
173
|
+
err('Nothing to set. Provide at least --key (or --model / --base-url).');
|
|
174
|
+
return 1;
|
|
175
|
+
}
|
|
176
|
+
if (isForbiddenModel(model)) {
|
|
177
|
+
err(`Model "${model}" can't be used with Gitset — choose a different model.`);
|
|
178
|
+
return 1;
|
|
179
|
+
}
|
|
180
|
+
if (provider === 'custom' && !baseUrl && !config.list().find((x) => x.provider === 'custom')) {
|
|
181
|
+
err('Provider "custom" requires --base-url (an OpenAI-compatible endpoint).');
|
|
182
|
+
return 1;
|
|
183
|
+
}
|
|
184
|
+
config.setProvider(provider, {
|
|
185
|
+
apiKey: apiKey || undefined,
|
|
186
|
+
model: model || undefined,
|
|
187
|
+
baseUrl: baseUrl || undefined,
|
|
188
|
+
makeDefault: has(argv, '--default'),
|
|
189
|
+
});
|
|
190
|
+
ok(`Saved ${provider}${has(argv, '--default') ? ' (default)' : ''}. Stored locally at ${config.FILE} (chmod 600).`);
|
|
191
|
+
return 0;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (sub === 'remove') {
|
|
195
|
+
const provider = argv[1] ? argv[1].toLowerCase() : null;
|
|
196
|
+
if (!provider) { err('Usage: gitset config remove <provider>'); return 1; }
|
|
197
|
+
if (config.removeProvider(provider)) { ok(`Removed ${provider}.`); return 0; }
|
|
198
|
+
err(`Provider "${provider}" was not configured.`);
|
|
199
|
+
return 1;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
err(`Unknown subcommand "${sub}".`);
|
|
203
|
+
usage();
|
|
204
|
+
return 1;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
module.exports = { runConfigCommand };
|
package/lib/cli-issue.js
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `gitset issue` — local BYOAI GitHub issue drafting.
|
|
5
|
+
* Generates a markdown issue from a description (+ optional repo context),
|
|
6
|
+
* optional interactive refine, optional `gh issue create` (arg-array, no shell).
|
|
7
|
+
*
|
|
8
|
+
* Flags: --title <t> --message/-m <desc> --provider --model
|
|
9
|
+
* --create (gh issue create) --yes --print --json
|
|
10
|
+
* Exit: 0 ok · 1 usage · 2 provider error.
|
|
11
|
+
*/
|
|
12
|
+
const { execFileSync } = require('child_process');
|
|
13
|
+
const readline = require('readline');
|
|
14
|
+
const genLocal = require('./generate-local');
|
|
15
|
+
const { suggestLabels } = require('./labels-ai');
|
|
16
|
+
const theme = require('../src/utils/theme');
|
|
17
|
+
|
|
18
|
+
const out = (s = '') => console.log(s);
|
|
19
|
+
const errln = (s) => console.error(`${theme.error('✗')} ${s}`);
|
|
20
|
+
|
|
21
|
+
function getFlag(argv, ...names) {
|
|
22
|
+
for (const n of names) {
|
|
23
|
+
const i = argv.indexOf(n);
|
|
24
|
+
if (i !== -1 && argv[i + 1] && !argv[i + 1].startsWith('-')) return argv[i + 1];
|
|
25
|
+
}
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
const has = (argv, ...n) => n.some((x) => argv.includes(x));
|
|
29
|
+
|
|
30
|
+
function ask(q) {
|
|
31
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
32
|
+
return new Promise((r) => rl.question(q, (a) => { rl.close(); r(a.trim()); }));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const GENERIC_HEADING = /^(summary|description|overview|context|details|scope|changes|testing|steps\b.*|acceptance\b.*)$/i;
|
|
36
|
+
function deriveTitle(markdown) {
|
|
37
|
+
const lines = String(markdown).split('\n').map((l) => l.trim());
|
|
38
|
+
const clean = (l) => l.replace(/^#+\s*/, '').replace(/^\*\*|\*\*$/g, '').trim();
|
|
39
|
+
for (const l of lines) {
|
|
40
|
+
if (/^#+\s/.test(l) && !GENERIC_HEADING.test(clean(l))) return clean(l).slice(0, 120);
|
|
41
|
+
}
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function runIssueCommand(argv) {
|
|
46
|
+
const json = has(argv, '--json');
|
|
47
|
+
const printOnly = has(argv, '--print');
|
|
48
|
+
const nonInteractive = has(argv, '--yes', '-y') || json || printOnly || !process.stdin.isTTY;
|
|
49
|
+
|
|
50
|
+
let description = getFlag(argv, '--message', '-m');
|
|
51
|
+
if (!description) {
|
|
52
|
+
if (nonInteractive) { errln('Provide an issue description with --message "<text>".'); return 1; }
|
|
53
|
+
description = await ask('Describe the issue: ');
|
|
54
|
+
}
|
|
55
|
+
if (!description) { errln('An issue description is required.'); return 1; }
|
|
56
|
+
|
|
57
|
+
let title = getFlag(argv, '--title');
|
|
58
|
+
|
|
59
|
+
async function gen(previous, instruction) {
|
|
60
|
+
if (!json && !printOnly) process.stderr.write(theme.dim('… generating issue\n'));
|
|
61
|
+
try {
|
|
62
|
+
return await genLocal.generate({
|
|
63
|
+
tool: 'issue',
|
|
64
|
+
ctx: { title: title || description.slice(0, 80), context: description, previous: previous || '', instruction: instruction || '' },
|
|
65
|
+
provider: getFlag(argv, '--provider'),
|
|
66
|
+
model: getFlag(argv, '--model'),
|
|
67
|
+
maxTokens: 2048,
|
|
68
|
+
interactive: !nonInteractive,
|
|
69
|
+
});
|
|
70
|
+
} catch (e) {
|
|
71
|
+
if (e instanceof genLocal.AIError) { errln(`AI provider error (${e.code}): ${e.message}`); throw { exit: 2 }; }
|
|
72
|
+
errln(e.message); throw { exit: /gitset config/.test(e.message || '') ? 1 : 2 };
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
let result;
|
|
77
|
+
try { result = await gen(); } catch (e) { return e.exit || 2; }
|
|
78
|
+
|
|
79
|
+
if (!title) title = deriveTitle(result.text) || description.slice(0, 80);
|
|
80
|
+
let selectedLabels = [];
|
|
81
|
+
|
|
82
|
+
if (json) { out(JSON.stringify({ title, body: result.text, provider: result.provider })); return 0; }
|
|
83
|
+
if (printOnly) { out(`# ${title}\n\n${result.text}`); return 0; }
|
|
84
|
+
|
|
85
|
+
function ensureLabel(l) {
|
|
86
|
+
try { execFileSync('gh', ['label', 'create', l.name, '--color', l.color || 'ededed', '--description', l.description || ''], { stdio: 'ignore' }); } catch { }
|
|
87
|
+
}
|
|
88
|
+
function create(body) {
|
|
89
|
+
const args = ['issue', 'create', '--title', title, '--body', body];
|
|
90
|
+
for (const l of selectedLabels) { ensureLabel(l); args.push('--label', l.name); }
|
|
91
|
+
try {
|
|
92
|
+
execFileSync('gh', args, { stdio: 'inherit' });
|
|
93
|
+
return 0;
|
|
94
|
+
} catch {
|
|
95
|
+
errln('gh issue create failed (is `gh` installed & authenticated?).');
|
|
96
|
+
return 1;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (nonInteractive) {
|
|
101
|
+
if (has(argv, '--create')) return create(result.text);
|
|
102
|
+
out(`# ${title}\n\n${result.text}`);
|
|
103
|
+
return 0;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
for (;;) {
|
|
107
|
+
out();
|
|
108
|
+
out(theme.bold(`Title: ${title}`));
|
|
109
|
+
out(theme.accent(result.text.slice(0, 1500) + (result.text.length > 1500 ? '\n… (truncated)' : '')));
|
|
110
|
+
out();
|
|
111
|
+
out(theme.dim(`tip: edit ~/.gitset/templates/issue.md (or run \`gitset template edit issue\`) to change the format`));
|
|
112
|
+
const ch = (await ask(
|
|
113
|
+
`${theme.key('c', 'reate')} ${theme.key('l', 'abels')} ${theme.key('r', 'efine')} ${theme.key('g', 'regenerate')} ${theme.key('t', 'itle')} ${theme.key('p', 'rint')} ${theme.key('q', 'uit')} > `,
|
|
114
|
+
)).toLowerCase();
|
|
115
|
+
if (ch === 'c') return create(result.text);
|
|
116
|
+
if (ch === 'l') {
|
|
117
|
+
try {
|
|
118
|
+
let existing = '';
|
|
119
|
+
try { existing = JSON.parse(execFileSync('gh', ['label', 'list', '--limit', '100', '--json', 'name'], { encoding: 'utf8', stdio: ['pipe', 'pipe', 'ignore'] })).map((x) => x.name).join(', '); } catch { }
|
|
120
|
+
process.stderr.write(theme.dim('… suggesting labels\n'));
|
|
121
|
+
const sug = await suggestLabels({ title, body: result.text, existing, provider: getFlag(argv, '--provider'), model: getFlag(argv, '--model') });
|
|
122
|
+
if (sug.length === 0) { out(theme.warn('No label suggestions.')); continue; }
|
|
123
|
+
out('Suggested: ' + sug.map((l, i) => `[${i + 1}] ${l.name}`).join(' '));
|
|
124
|
+
const pick = await ask('Apply which? (comma numbers, "all", or blank to skip): ');
|
|
125
|
+
if (pick.toLowerCase() === 'all') selectedLabels = sug;
|
|
126
|
+
else { const idx = pick.split(',').map((s) => parseInt(s.trim(), 10) - 1).filter((n) => n >= 0 && n < sug.length); selectedLabels = idx.map((i) => sug[i]); }
|
|
127
|
+
if (selectedLabels.length) out(theme.success(`✓ labels: ${selectedLabels.map((l) => l.name).join(', ')}`));
|
|
128
|
+
} catch (e) { errln(e.message); }
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
if (ch === 'q' || ch === '') { out('Aborted.'); return 0; }
|
|
132
|
+
if (ch === 't') { const t = await ask('New title: '); if (t) title = t; continue; }
|
|
133
|
+
if (ch === 'p') { out(`# ${title}\n\n${result.text}`); continue; }
|
|
134
|
+
if (ch === 'g') { try { result = await gen(); } catch (e) { return e.exit || 2; } continue; }
|
|
135
|
+
if (ch === 'r') {
|
|
136
|
+
const instr = await ask('Refinement instruction: ');
|
|
137
|
+
if (instr) { try { result = await gen(result.text, instr); } catch (e) { return e.exit || 2; } }
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
out(theme.warn('Unrecognized option.'));
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
module.exports = { runIssueCommand };
|
package/lib/cli-pr.js
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `gitset pr` — local BYOAI pull-request description from the branch diff.
|
|
5
|
+
* Generates from `git diff <base>...<head>` + commit log, optional refine,
|
|
6
|
+
* optional `gh pr create` (arg-array, no shell injection).
|
|
7
|
+
*
|
|
8
|
+
* Flags: --base <branch> --title <t> --provider --model
|
|
9
|
+
* --create --yes --print --json
|
|
10
|
+
* Exit: 0 ok · 1 usage/git · 2 provider error.
|
|
11
|
+
*/
|
|
12
|
+
const { execFileSync } = require('child_process');
|
|
13
|
+
const readline = require('readline');
|
|
14
|
+
const genLocal = require('./generate-local');
|
|
15
|
+
const { suggestLabels } = require('./labels-ai');
|
|
16
|
+
const theme = require('../src/utils/theme');
|
|
17
|
+
|
|
18
|
+
const out = (s = '') => console.log(s);
|
|
19
|
+
const errln = (s) => console.error(`${theme.error('✗')} ${s}`);
|
|
20
|
+
|
|
21
|
+
function getFlag(argv, ...names) {
|
|
22
|
+
for (const n of names) {
|
|
23
|
+
const i = argv.indexOf(n);
|
|
24
|
+
if (i !== -1 && argv[i + 1] && !argv[i + 1].startsWith('-')) return argv[i + 1];
|
|
25
|
+
}
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
const has = (argv, ...n) => n.some((x) => argv.includes(x));
|
|
29
|
+
function ask(q) {
|
|
30
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
31
|
+
return new Promise((r) => rl.question(q, (a) => { rl.close(); r(a.trim()); }));
|
|
32
|
+
}
|
|
33
|
+
function git(args) {
|
|
34
|
+
return execFileSync('git', args, { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 }).trim();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const GENERIC_HEADING = /^(summary|description|overview|context|details|scope|changes|testing|steps\b.*|acceptance\b.*)$/i;
|
|
38
|
+
function deriveTitle(md) {
|
|
39
|
+
const lines = String(md).split('\n').map((x) => x.trim());
|
|
40
|
+
const clean = (l) => l.replace(/^#+\s*/, '').replace(/^(title:|\*\*)\s*/i, '').replace(/\*\*$/, '').trim();
|
|
41
|
+
for (const l of lines) {
|
|
42
|
+
if (/^#+\s/.test(l) && !GENERIC_HEADING.test(clean(l))) return clean(l).slice(0, 120);
|
|
43
|
+
}
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function titleFromBranch(branch) {
|
|
48
|
+
const tail = String(branch || '').split('/').pop() || 'Pull request';
|
|
49
|
+
const words = tail.replace(/[-_]+/g, ' ').trim();
|
|
50
|
+
return words ? words.charAt(0).toUpperCase() + words.slice(1) : 'Pull request';
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function runPrCommand(argv) {
|
|
54
|
+
const json = has(argv, '--json');
|
|
55
|
+
const printOnly = has(argv, '--print');
|
|
56
|
+
const nonInteractive = has(argv, '--yes', '-y') || json || printOnly || !process.stdin.isTTY;
|
|
57
|
+
|
|
58
|
+
let head;
|
|
59
|
+
try { head = git(['rev-parse', '--abbrev-ref', 'HEAD']); }
|
|
60
|
+
catch { errln('Not a git repository.'); return 1; }
|
|
61
|
+
|
|
62
|
+
let base = getFlag(argv, '--base');
|
|
63
|
+
if (!base) {
|
|
64
|
+
for (const cand of ['main', 'master', 'develop']) {
|
|
65
|
+
try { git(['rev-parse', '--verify', cand]); base = cand; break; } catch { }
|
|
66
|
+
}
|
|
67
|
+
base = base || 'main';
|
|
68
|
+
}
|
|
69
|
+
if (base === head) { errln(`Base and head are both "${head}". Pass --base <branch>.`); return 1; }
|
|
70
|
+
|
|
71
|
+
let diff = '';
|
|
72
|
+
let commits = '';
|
|
73
|
+
try {
|
|
74
|
+
diff = git(['diff', `${base}...${head}`, '--no-color']);
|
|
75
|
+
commits = git(['log', `${base}..${head}`, '--pretty=format:- %h %s (%an)']);
|
|
76
|
+
} catch {
|
|
77
|
+
errln(`Could not diff ${base}...${head}. Does base "${base}" exist?`); return 1;
|
|
78
|
+
}
|
|
79
|
+
if (!diff.trim()) { errln(`No changes between ${base} and ${head}.`); return 1; }
|
|
80
|
+
|
|
81
|
+
let title = getFlag(argv, '--title');
|
|
82
|
+
|
|
83
|
+
async function gen(previous, instruction) {
|
|
84
|
+
if (!json && !printOnly) process.stderr.write(theme.dim(`… generating PR (${base}...${head})\n`));
|
|
85
|
+
try {
|
|
86
|
+
return await genLocal.generate({
|
|
87
|
+
tool: 'pr',
|
|
88
|
+
ctx: { diff, commits, previous: previous || '', instruction: instruction || '' },
|
|
89
|
+
provider: getFlag(argv, '--provider'),
|
|
90
|
+
model: getFlag(argv, '--model'),
|
|
91
|
+
maxTokens: 4096,
|
|
92
|
+
interactive: !nonInteractive,
|
|
93
|
+
});
|
|
94
|
+
} catch (e) {
|
|
95
|
+
if (e instanceof genLocal.AIError) { errln(`AI provider error (${e.code}): ${e.message}`); throw { exit: 2 }; }
|
|
96
|
+
errln(e.message); throw { exit: /gitset config/.test(e.message || '') ? 1 : 2 };
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
let result;
|
|
101
|
+
try { result = await gen(); } catch (e) { return e.exit || 2; }
|
|
102
|
+
|
|
103
|
+
if (!title) title = deriveTitle(result.text) || titleFromBranch(head);
|
|
104
|
+
let selectedLabels = [];
|
|
105
|
+
|
|
106
|
+
if (json) { out(JSON.stringify({ title, body: result.text, base, head, provider: result.provider })); return 0; }
|
|
107
|
+
if (printOnly) { out(`# ${title}\n\n${result.text}`); return 0; }
|
|
108
|
+
|
|
109
|
+
function ensureLabel(l) {
|
|
110
|
+
try { execFileSync('gh', ['label', 'create', l.name, '--color', l.color || 'ededed', '--description', l.description || ''], { stdio: 'ignore' }); } catch { }
|
|
111
|
+
}
|
|
112
|
+
function create(body) {
|
|
113
|
+
const args = ['pr', 'create', '--base', base, '--head', head, '--title', title, '--body', body];
|
|
114
|
+
for (const l of selectedLabels) { ensureLabel(l); args.push('--label', l.name); }
|
|
115
|
+
try {
|
|
116
|
+
execFileSync('gh', args, { stdio: 'inherit' });
|
|
117
|
+
return 0;
|
|
118
|
+
} catch {
|
|
119
|
+
errln('gh pr create failed (is `gh` installed & authenticated, branch pushed?).');
|
|
120
|
+
return 1;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (nonInteractive) {
|
|
125
|
+
if (has(argv, '--create')) return create(result.text);
|
|
126
|
+
out(`# ${title}\n\n${result.text}`);
|
|
127
|
+
return 0;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
for (;;) {
|
|
131
|
+
out();
|
|
132
|
+
out(theme.bold(`${title} ${theme.dim(`(${base} ← ${head})`)}`));
|
|
133
|
+
out(theme.accent(result.text.slice(0, 1500) + (result.text.length > 1500 ? '\n… (truncated)' : '')));
|
|
134
|
+
out();
|
|
135
|
+
out(theme.dim(`tip: edit ~/.gitset/templates/pr.md (or run \`gitset template edit pr\`) to change the format`));
|
|
136
|
+
const ch = (await ask(
|
|
137
|
+
`${theme.key('c', 'reate')} ${theme.key('l', 'abels')} ${theme.key('r', 'efine')} ${theme.key('g', 'regenerate')} ${theme.key('t', 'itle')} ${theme.key('p', 'rint')} ${theme.key('q', 'uit')} > `,
|
|
138
|
+
)).toLowerCase();
|
|
139
|
+
if (ch === 'c') return create(result.text);
|
|
140
|
+
if (ch === 'l') {
|
|
141
|
+
try {
|
|
142
|
+
let existing = '';
|
|
143
|
+
try { existing = JSON.parse(execFileSync('gh', ['label', 'list', '--limit', '100', '--json', 'name'], { encoding: 'utf8', stdio: ['pipe', 'pipe', 'ignore'] })).map((x) => x.name).join(', '); } catch { }
|
|
144
|
+
process.stderr.write(theme.dim('… suggesting labels\n'));
|
|
145
|
+
const sug = await suggestLabels({ title, body: result.text, existing, provider: getFlag(argv, '--provider'), model: getFlag(argv, '--model') });
|
|
146
|
+
if (sug.length === 0) { out(theme.warn('No label suggestions.')); continue; }
|
|
147
|
+
out('Suggested: ' + sug.map((l, i) => `[${i + 1}] ${l.name}`).join(' '));
|
|
148
|
+
const pick = await ask('Apply which? (comma numbers, "all", or blank to skip): ');
|
|
149
|
+
if (pick.toLowerCase() === 'all') selectedLabels = sug;
|
|
150
|
+
else { const idx = pick.split(',').map((s) => parseInt(s.trim(), 10) - 1).filter((n) => n >= 0 && n < sug.length); selectedLabels = idx.map((i) => sug[i]); }
|
|
151
|
+
if (selectedLabels.length) out(theme.success(`✓ labels: ${selectedLabels.map((l) => l.name).join(', ')}`));
|
|
152
|
+
} catch (e) { errln(e.message); }
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
if (ch === 'q' || ch === '') { out('Aborted.'); return 0; }
|
|
156
|
+
if (ch === 't') { const t = await ask('New title: '); if (t) title = t; continue; }
|
|
157
|
+
if (ch === 'p') { out(`# ${title}\n\n${result.text}`); continue; }
|
|
158
|
+
if (ch === 'g') { try { result = await gen(); } catch (e) { return e.exit || 2; } continue; }
|
|
159
|
+
if (ch === 'r') {
|
|
160
|
+
const instr = await ask('Refinement instruction: ');
|
|
161
|
+
if (instr) { try { result = await gen(result.text, instr); } catch (e) { return e.exit || 2; } }
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
out(theme.warn('Unrecognized option.'));
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
module.exports = { runPrCommand };
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `gitset readme` — local BYOAI README generation.
|
|
5
|
+
* Gathers repo context locally (git-tracked files + key file contents),
|
|
6
|
+
* generates via the user's own provider, no backend.
|
|
7
|
+
*
|
|
8
|
+
* Flags: --provider --model --output <file> --force --yes --print --json
|
|
9
|
+
* Exit: 0 ok · 1 usage/io · 2 provider error.
|
|
10
|
+
*/
|
|
11
|
+
const fs = require('fs');
|
|
12
|
+
const path = require('path');
|
|
13
|
+
const { execFileSync } = require('child_process');
|
|
14
|
+
const readline = require('readline');
|
|
15
|
+
const genLocal = require('./generate-local');
|
|
16
|
+
const theme = require('../src/utils/theme');
|
|
17
|
+
|
|
18
|
+
const out = (s = '') => console.log(s);
|
|
19
|
+
const errln = (s) => console.error(`${theme.error('✗')} ${s}`);
|
|
20
|
+
|
|
21
|
+
function getFlag(argv, n) {
|
|
22
|
+
const i = argv.indexOf(n);
|
|
23
|
+
return i !== -1 && argv[i + 1] && !argv[i + 1].startsWith('-') ? argv[i + 1] : null;
|
|
24
|
+
}
|
|
25
|
+
const has = (argv, ...n) => n.some((x) => argv.includes(x));
|
|
26
|
+
|
|
27
|
+
function ask(q) {
|
|
28
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
29
|
+
return new Promise((r) => rl.question(q, (a) => { rl.close(); r(a.trim()); }));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const KEY_FILES = [
|
|
33
|
+
'package.json', 'pyproject.toml', 'Cargo.toml', 'go.mod', 'pom.xml',
|
|
34
|
+
'composer.json', 'Gemfile', 'requirements.txt', 'tsconfig.json',
|
|
35
|
+
'src/index.ts', 'src/index.js', 'src/main.ts', 'src/main.py',
|
|
36
|
+
'index.js', 'main.go', 'main.py', 'app.py',
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
function gatherContext(cwd) {
|
|
40
|
+
let files = [];
|
|
41
|
+
try {
|
|
42
|
+
files = execFileSync('git', ['ls-files'], { cwd, encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 })
|
|
43
|
+
.split('\n').map((s) => s.trim()).filter(Boolean);
|
|
44
|
+
} catch {
|
|
45
|
+
errln('Not a git repository (run inside your repo).');
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
const tree = files.slice(0, 400).join('\n');
|
|
49
|
+
let ctx = `FILE TREE (${files.length} tracked files):\n${tree}\n`;
|
|
50
|
+
|
|
51
|
+
let budget = 16000;
|
|
52
|
+
for (const f of KEY_FILES) {
|
|
53
|
+
if (budget <= 0) break;
|
|
54
|
+
if (!files.includes(f)) continue;
|
|
55
|
+
try {
|
|
56
|
+
const body = fs.readFileSync(path.join(cwd, f), 'utf8').slice(0, Math.min(budget, 6000));
|
|
57
|
+
ctx += `\n--- ${f} ---\n${body}\n`;
|
|
58
|
+
budget -= body.length;
|
|
59
|
+
} catch { }
|
|
60
|
+
}
|
|
61
|
+
const projectName = (() => {
|
|
62
|
+
try {
|
|
63
|
+
if (files.includes('package.json')) {
|
|
64
|
+
return JSON.parse(fs.readFileSync(path.join(cwd, 'package.json'), 'utf8')).name || path.basename(cwd);
|
|
65
|
+
}
|
|
66
|
+
} catch { }
|
|
67
|
+
return path.basename(cwd);
|
|
68
|
+
})();
|
|
69
|
+
return { projectName, context: ctx };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function runReadmeCommand(argv) {
|
|
73
|
+
const cwd = process.cwd();
|
|
74
|
+
const json = has(argv, '--json');
|
|
75
|
+
const printOnly = has(argv, '--print');
|
|
76
|
+
const nonInteractive = has(argv, '--yes', '-y') || json || printOnly || !process.stdin.isTTY;
|
|
77
|
+
const outFile = path.join(cwd, getFlag(argv, '--output') || 'README.md');
|
|
78
|
+
|
|
79
|
+
const gathered = gatherContext(cwd);
|
|
80
|
+
if (!gathered) return 1;
|
|
81
|
+
|
|
82
|
+
let templateText = '';
|
|
83
|
+
const tmplPath = getFlag(argv, '--template');
|
|
84
|
+
if (tmplPath) {
|
|
85
|
+
try { templateText = fs.readFileSync(tmplPath, 'utf8'); }
|
|
86
|
+
catch { errln(`Template not found: ${tmplPath}`); return 1; }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function gen(previous, instruction) {
|
|
90
|
+
if (!json && !printOnly) process.stderr.write(theme.dim('… generating README\n'));
|
|
91
|
+
try {
|
|
92
|
+
const r = await genLocal.generate({
|
|
93
|
+
tool: 'readme',
|
|
94
|
+
ctx: { projectName: gathered.projectName, context: gathered.context, template: templateText, previous: previous || '', instruction: instruction || '' },
|
|
95
|
+
provider: getFlag(argv, '--provider'),
|
|
96
|
+
model: getFlag(argv, '--model'),
|
|
97
|
+
maxTokens: 8192,
|
|
98
|
+
interactive: !nonInteractive,
|
|
99
|
+
});
|
|
100
|
+
return r;
|
|
101
|
+
} catch (e) {
|
|
102
|
+
if (e instanceof genLocal.AIError) { errln(`AI provider error (${e.code}): ${e.message}`); throw { exit: 2 }; }
|
|
103
|
+
errln(e.message); throw { exit: e.message && /gitset config/.test(e.message) ? 1 : 2 };
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
let result;
|
|
108
|
+
try { result = await gen(); } catch (e) { return e.exit || 2; }
|
|
109
|
+
|
|
110
|
+
if (json) { out(JSON.stringify({ content: result.text, provider: result.provider, model: result.model })); return 0; }
|
|
111
|
+
if (printOnly) { out(result.text); return 0; }
|
|
112
|
+
|
|
113
|
+
function write(content) {
|
|
114
|
+
if (fs.existsSync(outFile) && !has(argv, '--force') && nonInteractive) {
|
|
115
|
+
errln(`${path.basename(outFile)} exists. Use --force to overwrite.`);
|
|
116
|
+
return 1;
|
|
117
|
+
}
|
|
118
|
+
fs.writeFileSync(outFile, content.endsWith('\n') ? content : content + '\n');
|
|
119
|
+
out(`${theme.success('✓')} Wrote ${path.relative(cwd, outFile)} (${content.length} bytes, via ${result.provider})`);
|
|
120
|
+
return 0;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (nonInteractive) return write(result.text);
|
|
124
|
+
|
|
125
|
+
for (;;) {
|
|
126
|
+
out();
|
|
127
|
+
out(theme.bold(`README preview (${result.text.length} chars, via ${result.provider}):`));
|
|
128
|
+
out(theme.dim(result.text.slice(0, 1200) + (result.text.length > 1200 ? '\n… (truncated preview)' : '')));
|
|
129
|
+
out();
|
|
130
|
+
if (!tmplPath) out(theme.dim(`tip: edit ~/.gitset/templates/readme.md (or run \`gitset template edit readme\`) to change the format`));
|
|
131
|
+
const ch = (await ask(
|
|
132
|
+
`${theme.key('w', 'rite')} ${theme.key('r', 'efine')} ${theme.key('g', 'regenerate')} ${theme.key('p', 'rint')} ${theme.key('q', 'uit')} > `,
|
|
133
|
+
)).toLowerCase();
|
|
134
|
+
if (ch === 'w' || ch === '') {
|
|
135
|
+
if (fs.existsSync(outFile) && !has(argv, '--force')) {
|
|
136
|
+
const ow = (await ask(`${path.basename(outFile)} exists. Overwrite? [y/N] `)).toLowerCase();
|
|
137
|
+
if (ow !== 'y') continue;
|
|
138
|
+
}
|
|
139
|
+
return write(result.text);
|
|
140
|
+
}
|
|
141
|
+
if (ch === 'q') { out('Aborted.'); return 0; }
|
|
142
|
+
if (ch === 'p') { out(result.text); continue; }
|
|
143
|
+
if (ch === 'g') { try { result = await gen(); } catch (e) { return e.exit || 2; } continue; }
|
|
144
|
+
if (ch === 'r') {
|
|
145
|
+
const instr = await ask('Refinement instruction: ');
|
|
146
|
+
if (instr) { try { result = await gen(result.text, instr); } catch (e) { return e.exit || 2; } }
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
out(theme.warn('Unrecognized option.'));
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
module.exports = { runReadmeCommand };
|