ticket-to-pr 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 +647 -0
- package/bin/ticket-to-pr.js +2 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +515 -0
- package/dist/config.d.ts +76 -0
- package/dist/config.js +80 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +504 -0
- package/dist/lib/notion.d.ts +46 -0
- package/dist/lib/notion.js +235 -0
- package/dist/lib/paths.d.ts +4 -0
- package/dist/lib/paths.js +23 -0
- package/dist/lib/projects.d.ts +6 -0
- package/dist/lib/projects.js +38 -0
- package/dist/lib/utils.d.ts +21 -0
- package/dist/lib/utils.js +254 -0
- package/package.json +39 -0
- package/projects.example.json +8 -0
- package/prompts/execute.md +19 -0
- package/prompts/review.md +43 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,515 @@
|
|
|
1
|
+
import { createInterface } from 'node:readline';
|
|
2
|
+
import { execSync } from 'node:child_process';
|
|
3
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { mask, shellEscape, writeEnvFile, updateProjectsFile } from './lib/utils.js';
|
|
6
|
+
import { getProjectNames, getProjectDir } from './lib/projects.js';
|
|
7
|
+
import { CONFIG_DIR } from './lib/paths.js';
|
|
8
|
+
// -- Colors --
|
|
9
|
+
const RESET = '\x1b[0m';
|
|
10
|
+
const DIM = '\x1b[2m';
|
|
11
|
+
const GREEN = '\x1b[32m';
|
|
12
|
+
const YELLOW = '\x1b[33m';
|
|
13
|
+
const RED = '\x1b[31m';
|
|
14
|
+
const BOLD = '\x1b[1m';
|
|
15
|
+
// -- Shared utilities --
|
|
16
|
+
function printStatus(ok, label, detail) {
|
|
17
|
+
const icon = ok === true ? `${GREEN}✓${RESET}` : ok === false ? `${RED}✗${RESET}` : `${YELLOW}○${RESET}`;
|
|
18
|
+
const line = detail ? `${icon} ${label} ${DIM}${detail}${RESET}` : `${icon} ${label}`;
|
|
19
|
+
console.log(` ${line}`);
|
|
20
|
+
}
|
|
21
|
+
function checkCommand(cmd) {
|
|
22
|
+
try {
|
|
23
|
+
const output = execSync(cmd, { stdio: 'pipe', timeout: 10_000 }).toString().trim();
|
|
24
|
+
return { ok: true, output };
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return { ok: false, output: '' };
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
function ask(rl, question, opts) {
|
|
31
|
+
return new Promise((resolve) => {
|
|
32
|
+
const suffix = opts?.defaultValue ? ` ${DIM}(${opts.defaultValue})${RESET}` : '';
|
|
33
|
+
rl.question(` ${question}${suffix}: `, (answer) => {
|
|
34
|
+
const value = answer.trim() || opts?.defaultValue || '';
|
|
35
|
+
if (opts?.validate) {
|
|
36
|
+
const error = opts.validate(value);
|
|
37
|
+
if (error) {
|
|
38
|
+
console.log(` ${RED}${error}${RESET}`);
|
|
39
|
+
resolve(ask(rl, question, opts));
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
resolve(value);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
// -- Doctor --
|
|
48
|
+
export async function runDoctor() {
|
|
49
|
+
console.log(`\n${BOLD}TicketToPR Doctor${RESET}\n`);
|
|
50
|
+
let passed = 0;
|
|
51
|
+
let warnings = 0;
|
|
52
|
+
let failed = 0;
|
|
53
|
+
function track(ok) {
|
|
54
|
+
if (ok === true)
|
|
55
|
+
passed++;
|
|
56
|
+
else if (ok === false)
|
|
57
|
+
failed++;
|
|
58
|
+
else
|
|
59
|
+
warnings++;
|
|
60
|
+
}
|
|
61
|
+
// Environment
|
|
62
|
+
console.log(`${BOLD}Environment:${RESET}`);
|
|
63
|
+
const envPath = join(CONFIG_DIR, '.env.local');
|
|
64
|
+
const envExists = existsSync(envPath);
|
|
65
|
+
printStatus(envExists, '.env.local exists');
|
|
66
|
+
track(envExists);
|
|
67
|
+
let envVars = {};
|
|
68
|
+
if (envExists) {
|
|
69
|
+
try {
|
|
70
|
+
const content = readFileSync(envPath, 'utf-8');
|
|
71
|
+
for (const line of content.split('\n')) {
|
|
72
|
+
const trimmed = line.trim();
|
|
73
|
+
if (!trimmed || trimmed.startsWith('#'))
|
|
74
|
+
continue;
|
|
75
|
+
const eqIndex = trimmed.indexOf('=');
|
|
76
|
+
if (eqIndex === -1)
|
|
77
|
+
continue;
|
|
78
|
+
envVars[trimmed.slice(0, eqIndex).trim()] = trimmed.slice(eqIndex + 1).trim();
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
// ignore
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
const notionToken = envVars.NOTION_TOKEN || process.env.NOTION_TOKEN || '';
|
|
86
|
+
const hasToken = notionToken.length > 0;
|
|
87
|
+
printStatus(hasToken, 'NOTION_TOKEN set', hasToken ? mask(notionToken) : undefined);
|
|
88
|
+
track(hasToken);
|
|
89
|
+
const dbId = envVars.NOTION_DATABASE_ID || process.env.NOTION_DATABASE_ID || '';
|
|
90
|
+
const hasDbId = dbId.length > 0;
|
|
91
|
+
printStatus(hasDbId, 'NOTION_DATABASE_ID set', hasDbId ? mask(dbId) : undefined);
|
|
92
|
+
track(hasDbId);
|
|
93
|
+
const licenseKey = envVars.LICENSE_KEY || process.env.LICENSE_KEY || '';
|
|
94
|
+
if (licenseKey) {
|
|
95
|
+
// Dynamically import config to check isPro
|
|
96
|
+
const { isPro } = await import('./config.js');
|
|
97
|
+
// Temporarily set env for check
|
|
98
|
+
const prev = process.env.LICENSE_KEY;
|
|
99
|
+
process.env.LICENSE_KEY = licenseKey;
|
|
100
|
+
const pro = isPro();
|
|
101
|
+
process.env.LICENSE_KEY = prev;
|
|
102
|
+
printStatus(pro, 'LICENSE_KEY', pro ? 'Pro' : 'Invalid key');
|
|
103
|
+
track(pro ? true : null);
|
|
104
|
+
}
|
|
105
|
+
else {
|
|
106
|
+
printStatus(null, 'LICENSE_KEY', 'Free tier');
|
|
107
|
+
track(null);
|
|
108
|
+
}
|
|
109
|
+
// Models
|
|
110
|
+
console.log(`\n${BOLD}Models:${RESET}`);
|
|
111
|
+
const { CONFIG } = await import('./config.js');
|
|
112
|
+
printStatus(true, 'Review model', CONFIG.REVIEW_MODEL);
|
|
113
|
+
printStatus(true, 'Execute model', CONFIG.EXECUTE_MODEL);
|
|
114
|
+
// Notion connectivity
|
|
115
|
+
console.log(`\n${BOLD}Notion:${RESET}`);
|
|
116
|
+
if (hasToken) {
|
|
117
|
+
try {
|
|
118
|
+
const { Client } = await import('@notionhq/client');
|
|
119
|
+
const client = new Client({ auth: notionToken });
|
|
120
|
+
try {
|
|
121
|
+
await client.users.me({});
|
|
122
|
+
printStatus(true, 'Token valid', 'connected to workspace');
|
|
123
|
+
track(true);
|
|
124
|
+
}
|
|
125
|
+
catch (e) {
|
|
126
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
127
|
+
printStatus(false, 'Token valid', msg);
|
|
128
|
+
track(false);
|
|
129
|
+
}
|
|
130
|
+
if (hasDbId) {
|
|
131
|
+
try {
|
|
132
|
+
const db = await client.databases.retrieve({ database_id: dbId });
|
|
133
|
+
printStatus(true, 'Database accessible');
|
|
134
|
+
track(true);
|
|
135
|
+
// Schema validation
|
|
136
|
+
console.log(`\n${BOLD}Database Schema:${RESET}`);
|
|
137
|
+
const requiredProps = [
|
|
138
|
+
{ name: 'Name', altName: 'Title', expectedTypes: ['title'] },
|
|
139
|
+
{ name: 'Status', expectedTypes: ['status'] },
|
|
140
|
+
{ name: 'Project', expectedTypes: ['select', 'rich_text'] },
|
|
141
|
+
{ name: 'Ease', expectedTypes: ['number'] },
|
|
142
|
+
{ name: 'Confidence', expectedTypes: ['number'] },
|
|
143
|
+
{ name: 'Spec', expectedTypes: ['rich_text'] },
|
|
144
|
+
{ name: 'Impact', expectedTypes: ['rich_text'] },
|
|
145
|
+
{ name: 'Branch', expectedTypes: ['rich_text'] },
|
|
146
|
+
{ name: 'Cost', expectedTypes: ['rich_text'] },
|
|
147
|
+
{ name: 'PR URL', expectedTypes: ['url', 'rich_text'] },
|
|
148
|
+
];
|
|
149
|
+
let schemaOk = 0;
|
|
150
|
+
let schemaMissing = 0;
|
|
151
|
+
for (const req of requiredProps) {
|
|
152
|
+
const prop = db.properties[req.name] || (req.altName ? db.properties[req.altName] : undefined);
|
|
153
|
+
if (prop && req.expectedTypes.includes(prop.type)) {
|
|
154
|
+
schemaOk++;
|
|
155
|
+
}
|
|
156
|
+
else if (prop) {
|
|
157
|
+
printStatus(false, `Property "${req.name}"`, `found as ${prop.type}, expected ${req.expectedTypes.join(' or ')}`);
|
|
158
|
+
schemaMissing++;
|
|
159
|
+
track(false);
|
|
160
|
+
}
|
|
161
|
+
else {
|
|
162
|
+
printStatus(false, `Missing property: "${req.name}"`, `(${req.expectedTypes.join(' or ')})`);
|
|
163
|
+
schemaMissing++;
|
|
164
|
+
track(false);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
if (schemaMissing === 0) {
|
|
168
|
+
printStatus(true, `All ${requiredProps.length} required properties found`);
|
|
169
|
+
track(true);
|
|
170
|
+
}
|
|
171
|
+
else {
|
|
172
|
+
printStatus(false, `${schemaMissing} properties missing or misconfigured`);
|
|
173
|
+
}
|
|
174
|
+
// Check Project select options vs projects.json
|
|
175
|
+
const projectProp = db.properties.Project;
|
|
176
|
+
if (projectProp?.type === 'select' && projectProp.select) {
|
|
177
|
+
const notionOptions = projectProp.select.options.map((o) => o.name);
|
|
178
|
+
const configProjects = getProjectNames();
|
|
179
|
+
const inNotionNotConfig = notionOptions.filter((n) => !configProjects.includes(n));
|
|
180
|
+
const inConfigNotNotion = configProjects.filter((n) => !notionOptions.includes(n));
|
|
181
|
+
if (inNotionNotConfig.length > 0) {
|
|
182
|
+
printStatus(null, 'Notion has projects not in projects.json', inNotionNotConfig.join(', '));
|
|
183
|
+
track(null);
|
|
184
|
+
}
|
|
185
|
+
if (inConfigNotNotion.length > 0) {
|
|
186
|
+
printStatus(null, 'projects.json has projects not in Notion', inConfigNotNotion.join(', '));
|
|
187
|
+
track(null);
|
|
188
|
+
}
|
|
189
|
+
if (inNotionNotConfig.length === 0 && inConfigNotNotion.length === 0 && configProjects.length > 0) {
|
|
190
|
+
printStatus(true, 'Project options match projects.json');
|
|
191
|
+
track(true);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
catch (e) {
|
|
196
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
197
|
+
printStatus(false, 'Database accessible', msg);
|
|
198
|
+
track(false);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
else {
|
|
202
|
+
printStatus(false, 'Database accessible', 'no database ID configured');
|
|
203
|
+
track(false);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
catch {
|
|
207
|
+
printStatus(false, 'Notion client', 'failed to load @notionhq/client');
|
|
208
|
+
track(false);
|
|
209
|
+
printStatus(false, 'Database accessible', 'skipped');
|
|
210
|
+
track(false);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
else {
|
|
214
|
+
printStatus(false, 'Token valid', 'no token configured');
|
|
215
|
+
track(false);
|
|
216
|
+
printStatus(false, 'Database accessible', 'skipped');
|
|
217
|
+
track(false);
|
|
218
|
+
}
|
|
219
|
+
// Tools
|
|
220
|
+
console.log(`\n${BOLD}Tools:${RESET}`);
|
|
221
|
+
const gh = checkCommand('gh --version');
|
|
222
|
+
if (gh.ok) {
|
|
223
|
+
printStatus(true, 'gh installed', gh.output.split('\n')[0]);
|
|
224
|
+
track(true);
|
|
225
|
+
}
|
|
226
|
+
else {
|
|
227
|
+
printStatus(null, 'gh not found', 'Install: brew install gh && gh auth login (required for automatic PR creation)');
|
|
228
|
+
track(null);
|
|
229
|
+
}
|
|
230
|
+
if (gh.ok) {
|
|
231
|
+
const ghAuth = checkCommand('gh auth status');
|
|
232
|
+
printStatus(ghAuth.ok, 'gh authenticated');
|
|
233
|
+
track(ghAuth.ok);
|
|
234
|
+
}
|
|
235
|
+
else {
|
|
236
|
+
printStatus(null, 'gh authenticated', 'skipped — gh not installed');
|
|
237
|
+
track(null);
|
|
238
|
+
}
|
|
239
|
+
const claude = checkCommand('claude --version');
|
|
240
|
+
if (claude.ok) {
|
|
241
|
+
printStatus(true, 'claude installed', claude.output.split('\n')[0]);
|
|
242
|
+
track(true);
|
|
243
|
+
}
|
|
244
|
+
else {
|
|
245
|
+
printStatus(false, 'claude not found', 'Install: npm i -g @anthropic-ai/claude-code — required, agents cannot run without it');
|
|
246
|
+
track(false);
|
|
247
|
+
}
|
|
248
|
+
// Projects
|
|
249
|
+
console.log(`\n${BOLD}Projects:${RESET}`);
|
|
250
|
+
const projectNames = getProjectNames();
|
|
251
|
+
if (projectNames.length === 0) {
|
|
252
|
+
printStatus(null, 'No projects configured', 'add projects to projects.json or run init');
|
|
253
|
+
track(null);
|
|
254
|
+
}
|
|
255
|
+
else {
|
|
256
|
+
for (const name of projectNames) {
|
|
257
|
+
const dir = getProjectDir(name);
|
|
258
|
+
const dirExists = existsSync(dir);
|
|
259
|
+
if (!dirExists) {
|
|
260
|
+
printStatus(false, `${name}`, `${dir} (directory not found)`);
|
|
261
|
+
track(false);
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
const gitExists = existsSync(join(dir, '.git'));
|
|
265
|
+
if (!gitExists) {
|
|
266
|
+
printStatus(false, `${name}`, `${dir} (not a git repo)`);
|
|
267
|
+
track(false);
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
270
|
+
const origin = checkCommand(`git -C ${shellEscape(dir)} remote get-url origin`);
|
|
271
|
+
if (!origin.ok) {
|
|
272
|
+
printStatus(false, `${name}`, `${dir} (no origin remote)`);
|
|
273
|
+
track(false);
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
printStatus(true, `${name}`, `${dir}`);
|
|
277
|
+
track(true);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
// Summary
|
|
281
|
+
console.log(`\n${BOLD}Summary:${RESET} ${GREEN}${passed} passed${RESET}, ${YELLOW}${warnings} warnings${RESET}, ${RED}${failed} failed${RESET}`);
|
|
282
|
+
console.log(`${DIM}Docs: https://www.tickettopr.com${RESET}\n`);
|
|
283
|
+
process.exitCode = failed > 0 ? 1 : 0;
|
|
284
|
+
}
|
|
285
|
+
// -- Init --
|
|
286
|
+
export async function runInit() {
|
|
287
|
+
console.log(`\n${BOLD}TicketToPR Setup${RESET}\n`);
|
|
288
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
289
|
+
const envPath = join(CONFIG_DIR, '.env.local');
|
|
290
|
+
const projectsPath = join(CONFIG_DIR, 'projects.json');
|
|
291
|
+
// Load existing env values for defaults
|
|
292
|
+
let existingEnv = {};
|
|
293
|
+
try {
|
|
294
|
+
const content = readFileSync(envPath, 'utf-8');
|
|
295
|
+
for (const line of content.split('\n')) {
|
|
296
|
+
const trimmed = line.trim();
|
|
297
|
+
if (!trimmed || trimmed.startsWith('#'))
|
|
298
|
+
continue;
|
|
299
|
+
const eqIndex = trimmed.indexOf('=');
|
|
300
|
+
if (eqIndex === -1)
|
|
301
|
+
continue;
|
|
302
|
+
existingEnv[trimmed.slice(0, eqIndex).trim()] = trimmed.slice(eqIndex + 1).trim();
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
catch {
|
|
306
|
+
// No existing file
|
|
307
|
+
}
|
|
308
|
+
// Re-run detection
|
|
309
|
+
const envExists = existsSync(envPath);
|
|
310
|
+
const projectsExists = existsSync(projectsPath);
|
|
311
|
+
if (envExists && projectsExists) {
|
|
312
|
+
console.log(` ${YELLOW}Existing configuration detected${RESET}`);
|
|
313
|
+
const mode = await ask(rl, 'Update existing config or start fresh?', {
|
|
314
|
+
defaultValue: 'update',
|
|
315
|
+
validate: (v) => {
|
|
316
|
+
const lower = v.toLowerCase();
|
|
317
|
+
return lower === 'update' || lower === 'fresh' ? null : 'Choose: update / fresh';
|
|
318
|
+
},
|
|
319
|
+
});
|
|
320
|
+
if (mode.toLowerCase() === 'fresh') {
|
|
321
|
+
existingEnv = {};
|
|
322
|
+
console.log(` ${DIM}Starting from scratch${RESET}`);
|
|
323
|
+
}
|
|
324
|
+
else {
|
|
325
|
+
console.log(` ${DIM}Pre-filling from existing config${RESET}`);
|
|
326
|
+
}
|
|
327
|
+
console.log('');
|
|
328
|
+
}
|
|
329
|
+
try {
|
|
330
|
+
// Step 1: Notion
|
|
331
|
+
console.log(`${BOLD}Step 1: Notion${RESET}`);
|
|
332
|
+
// -- Notion token with validation loop --
|
|
333
|
+
let notionToken = '';
|
|
334
|
+
let notionClient = null;
|
|
335
|
+
const { Client } = await import('@notionhq/client');
|
|
336
|
+
while (true) {
|
|
337
|
+
const tokenDefault = existingEnv.NOTION_TOKEN ? mask(existingEnv.NOTION_TOKEN) : undefined;
|
|
338
|
+
let tokenInput = await ask(rl, 'Notion token', {
|
|
339
|
+
defaultValue: tokenDefault,
|
|
340
|
+
validate: (v) => (!v ? 'Token is required' : null),
|
|
341
|
+
});
|
|
342
|
+
// If user accepted the masked default, use the actual stored value
|
|
343
|
+
if (tokenInput === tokenDefault && existingEnv.NOTION_TOKEN) {
|
|
344
|
+
tokenInput = existingEnv.NOTION_TOKEN;
|
|
345
|
+
}
|
|
346
|
+
try {
|
|
347
|
+
const client = new Client({ auth: tokenInput });
|
|
348
|
+
const me = await client.users.me({});
|
|
349
|
+
const workspaceName = me.bot?.workspace_name || 'connected';
|
|
350
|
+
printStatus(true, 'Token valid', workspaceName);
|
|
351
|
+
notionToken = tokenInput;
|
|
352
|
+
notionClient = client;
|
|
353
|
+
break;
|
|
354
|
+
}
|
|
355
|
+
catch {
|
|
356
|
+
printStatus(false, 'Token invalid — check your integration token and try again');
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
// -- Database ID with validation loop --
|
|
360
|
+
let databaseId = '';
|
|
361
|
+
while (true) {
|
|
362
|
+
const dbDefault = existingEnv.NOTION_DATABASE_ID ? mask(existingEnv.NOTION_DATABASE_ID) : undefined;
|
|
363
|
+
let dbInput = await ask(rl, 'Database ID', {
|
|
364
|
+
defaultValue: dbDefault,
|
|
365
|
+
validate: (v) => (!v ? 'Database ID is required' : null),
|
|
366
|
+
});
|
|
367
|
+
if (dbInput === dbDefault && existingEnv.NOTION_DATABASE_ID) {
|
|
368
|
+
dbInput = existingEnv.NOTION_DATABASE_ID;
|
|
369
|
+
}
|
|
370
|
+
try {
|
|
371
|
+
const db = await notionClient.databases.retrieve({ database_id: dbInput });
|
|
372
|
+
const dbTitle = db.title?.map((t) => t.plain_text).join('') || 'untitled';
|
|
373
|
+
printStatus(true, 'Database accessible', dbTitle);
|
|
374
|
+
databaseId = dbInput;
|
|
375
|
+
break;
|
|
376
|
+
}
|
|
377
|
+
catch {
|
|
378
|
+
printStatus(false, 'Database not accessible — check the ID and make sure the integration is connected to this database');
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
console.log('');
|
|
382
|
+
// Step 2: Tools
|
|
383
|
+
console.log(`${BOLD}Step 2: Tools${RESET}`);
|
|
384
|
+
const claude = checkCommand('claude --version');
|
|
385
|
+
if (claude.ok) {
|
|
386
|
+
printStatus(true, 'claude', claude.output.split('\n')[0]);
|
|
387
|
+
}
|
|
388
|
+
else {
|
|
389
|
+
printStatus(false, 'Claude Code CLI not found');
|
|
390
|
+
console.log(` ${DIM}Install: npm i -g @anthropic-ai/claude-code${RESET}`);
|
|
391
|
+
console.log(` ${DIM}Then authenticate: claude (follow the prompts)${RESET}`);
|
|
392
|
+
console.log(` ${RED}This is required — agents cannot run without it.${RESET}`);
|
|
393
|
+
}
|
|
394
|
+
const gh = checkCommand('gh --version');
|
|
395
|
+
if (gh.ok) {
|
|
396
|
+
printStatus(true, 'gh', gh.output.split('\n')[0]);
|
|
397
|
+
const ghAuth = checkCommand('gh auth status');
|
|
398
|
+
printStatus(ghAuth.ok, 'gh authenticated', ghAuth.ok ? undefined : 'run: gh auth login');
|
|
399
|
+
}
|
|
400
|
+
else {
|
|
401
|
+
printStatus(null, 'GitHub CLI not found');
|
|
402
|
+
console.log(` ${DIM}Install: brew install gh && gh auth login${RESET}`);
|
|
403
|
+
console.log(` ${DIM}Required for automatic PR creation. Review/Execute still work without it.${RESET}`);
|
|
404
|
+
}
|
|
405
|
+
console.log('');
|
|
406
|
+
// Step 3: Models
|
|
407
|
+
console.log(`${BOLD}Step 3: Models${RESET}`);
|
|
408
|
+
console.log(` ${DIM}Choose which Claude model each agent uses.${RESET}`);
|
|
409
|
+
console.log(` ${DIM}Sonnet = fast/cheap, Opus = best quality, Haiku = fastest/cheapest${RESET}\n`);
|
|
410
|
+
const modelChoices = [
|
|
411
|
+
{ label: 'sonnet', id: 'claude-sonnet-4-5-20250929' },
|
|
412
|
+
{ label: 'opus', id: 'claude-opus-4-6' },
|
|
413
|
+
{ label: 'haiku', id: 'claude-haiku-4-5-20251001' },
|
|
414
|
+
];
|
|
415
|
+
const modelLabels = modelChoices.map((m) => m.label).join('/');
|
|
416
|
+
const reviewModelDefault = existingEnv.REVIEW_MODEL
|
|
417
|
+
? modelChoices.find((m) => m.id === existingEnv.REVIEW_MODEL)?.label ?? existingEnv.REVIEW_MODEL
|
|
418
|
+
: 'sonnet';
|
|
419
|
+
const reviewModelInput = await ask(rl, `Review model (${modelLabels})`, {
|
|
420
|
+
defaultValue: reviewModelDefault,
|
|
421
|
+
validate: (v) => (modelChoices.some((m) => m.label === v || m.id === v) ? null : `Choose: ${modelLabels}`),
|
|
422
|
+
});
|
|
423
|
+
const reviewModel = modelChoices.find((m) => m.label === reviewModelInput || m.id === reviewModelInput)?.id ?? reviewModelInput;
|
|
424
|
+
const executeModelDefault = existingEnv.EXECUTE_MODEL
|
|
425
|
+
? modelChoices.find((m) => m.id === existingEnv.EXECUTE_MODEL)?.label ?? existingEnv.EXECUTE_MODEL
|
|
426
|
+
: 'opus';
|
|
427
|
+
const executeModelInput = await ask(rl, `Execute model (${modelLabels})`, {
|
|
428
|
+
defaultValue: executeModelDefault,
|
|
429
|
+
validate: (v) => (modelChoices.some((m) => m.label === v || m.id === v) ? null : `Choose: ${modelLabels}`),
|
|
430
|
+
});
|
|
431
|
+
const executeModel = modelChoices.find((m) => m.label === executeModelInput || m.id === executeModelInput)?.id ?? executeModelInput;
|
|
432
|
+
printStatus(true, 'Review model', reviewModel);
|
|
433
|
+
printStatus(true, 'Execute model', executeModel);
|
|
434
|
+
console.log('');
|
|
435
|
+
// Step 4: Projects
|
|
436
|
+
console.log(`${BOLD}Step 4: Projects${RESET}`);
|
|
437
|
+
const projects = [];
|
|
438
|
+
let addMore = true;
|
|
439
|
+
while (addMore) {
|
|
440
|
+
const name = await ask(rl, 'Project name', {
|
|
441
|
+
validate: (v) => (!v ? 'Project name is required' : null),
|
|
442
|
+
});
|
|
443
|
+
const dir = await ask(rl, 'Directory', {
|
|
444
|
+
validate: (v) => {
|
|
445
|
+
if (!v)
|
|
446
|
+
return 'Directory is required';
|
|
447
|
+
if (!existsSync(v))
|
|
448
|
+
return `Directory not found: ${v}`;
|
|
449
|
+
return null;
|
|
450
|
+
},
|
|
451
|
+
});
|
|
452
|
+
// Validate git repo
|
|
453
|
+
const gitExists = existsSync(join(dir, '.git'));
|
|
454
|
+
if (gitExists) {
|
|
455
|
+
const origin = checkCommand(`git -C ${shellEscape(dir)} remote get-url origin`);
|
|
456
|
+
if (origin.ok) {
|
|
457
|
+
printStatus(true, 'Git repo', origin.output);
|
|
458
|
+
}
|
|
459
|
+
else {
|
|
460
|
+
printStatus(null, 'Git repo found but no origin remote', `run: git -C ${dir} remote add origin <url>`);
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
else {
|
|
464
|
+
printStatus(null, 'Not a git repo', `${dir} — you can init git later`);
|
|
465
|
+
}
|
|
466
|
+
const buildCmd = await ask(rl, 'Build command (optional)');
|
|
467
|
+
projects.push({ name, dir, buildCmd: buildCmd || undefined });
|
|
468
|
+
console.log('');
|
|
469
|
+
const another = await ask(rl, 'Add another project?', { defaultValue: 'N' });
|
|
470
|
+
addMore = another.toLowerCase() === 'y' || another.toLowerCase() === 'yes';
|
|
471
|
+
if (addMore)
|
|
472
|
+
console.log('');
|
|
473
|
+
}
|
|
474
|
+
// Free tier guard
|
|
475
|
+
if (projects.length > 1) {
|
|
476
|
+
const { isPro } = await import('./config.js');
|
|
477
|
+
if (!isPro()) {
|
|
478
|
+
console.log('');
|
|
479
|
+
console.log(` ${YELLOW}Free tier supports 1 project. You configured ${projects.length}.${RESET}`);
|
|
480
|
+
console.log(` ${DIM}Upgrade to Pro for unlimited projects, or remove extras.${RESET}`);
|
|
481
|
+
const keepAll = await ask(rl, 'Keep only the first project?', { defaultValue: 'Y' });
|
|
482
|
+
if (keepAll.toLowerCase() === 'y' || keepAll.toLowerCase() === 'yes') {
|
|
483
|
+
const removed = projects.splice(1);
|
|
484
|
+
console.log(` ${DIM}Kept "${projects[0].name}", removed: ${removed.map((p) => p.name).join(', ')}${RESET}`);
|
|
485
|
+
}
|
|
486
|
+
else {
|
|
487
|
+
console.log(` ${DIM}Keeping all ${projects.length} projects — startup will fail without a Pro license.${RESET}`);
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
console.log('');
|
|
492
|
+
// Step 5: Save
|
|
493
|
+
console.log(`${BOLD}Step 5: Save${RESET}`);
|
|
494
|
+
// Write .env.local
|
|
495
|
+
const envUpdates = {
|
|
496
|
+
NOTION_TOKEN: notionToken,
|
|
497
|
+
NOTION_DATABASE_ID: databaseId,
|
|
498
|
+
REVIEW_MODEL: reviewModel,
|
|
499
|
+
EXECUTE_MODEL: executeModel,
|
|
500
|
+
};
|
|
501
|
+
writeEnvFile(envPath, envUpdates);
|
|
502
|
+
printStatus(true, 'Wrote .env.local');
|
|
503
|
+
// Update projects.json
|
|
504
|
+
if (projects.length > 0) {
|
|
505
|
+
updateProjectsFile(projectsPath, projects);
|
|
506
|
+
printStatus(true, 'Updated projects.json');
|
|
507
|
+
}
|
|
508
|
+
console.log(`\n${BOLD}Ready!${RESET}`);
|
|
509
|
+
console.log(` Test: ${DIM}npx tsx index.ts doctor${RESET}`);
|
|
510
|
+
console.log(` Docs: ${DIM}https://www.tickettopr.com${RESET}\n`);
|
|
511
|
+
}
|
|
512
|
+
finally {
|
|
513
|
+
rl.close();
|
|
514
|
+
}
|
|
515
|
+
}
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
export declare function isPro(): boolean;
|
|
2
|
+
export declare const CONFIG: {
|
|
3
|
+
readonly POLL_INTERVAL_MS: 30000;
|
|
4
|
+
readonly COLUMNS: {
|
|
5
|
+
readonly REVIEW: "Review";
|
|
6
|
+
readonly SCORED: "Scored";
|
|
7
|
+
readonly EXECUTE: "Execute";
|
|
8
|
+
readonly IN_PROGRESS: "In Progress";
|
|
9
|
+
readonly DONE: "PR Ready";
|
|
10
|
+
readonly FAILED: "Failed";
|
|
11
|
+
};
|
|
12
|
+
readonly REVIEW_BUDGET_USD: 2;
|
|
13
|
+
readonly EXECUTE_BUDGET_USD: 15;
|
|
14
|
+
readonly REVIEW_MODEL: string;
|
|
15
|
+
readonly EXECUTE_MODEL: string;
|
|
16
|
+
readonly REVIEW_MAX_TURNS: 25;
|
|
17
|
+
readonly EXECUTE_MAX_TURNS: 50;
|
|
18
|
+
readonly STALE_LOCK_MS: number;
|
|
19
|
+
readonly MAX_CONCURRENT_AGENTS: number;
|
|
20
|
+
readonly FREE_MAX_PROJECTS: 1;
|
|
21
|
+
};
|
|
22
|
+
export declare const REVIEW_OUTPUT_SCHEMA: {
|
|
23
|
+
readonly type: "object";
|
|
24
|
+
readonly properties: {
|
|
25
|
+
readonly easeScore: {
|
|
26
|
+
readonly type: "number";
|
|
27
|
+
readonly minimum: 1;
|
|
28
|
+
readonly maximum: 10;
|
|
29
|
+
};
|
|
30
|
+
readonly confidenceScore: {
|
|
31
|
+
readonly type: "number";
|
|
32
|
+
readonly minimum: 1;
|
|
33
|
+
readonly maximum: 10;
|
|
34
|
+
};
|
|
35
|
+
readonly spec: {
|
|
36
|
+
readonly type: "string";
|
|
37
|
+
};
|
|
38
|
+
readonly impactReport: {
|
|
39
|
+
readonly type: "string";
|
|
40
|
+
};
|
|
41
|
+
readonly affectedFiles: {
|
|
42
|
+
readonly type: "array";
|
|
43
|
+
readonly items: {
|
|
44
|
+
readonly type: "string";
|
|
45
|
+
};
|
|
46
|
+
};
|
|
47
|
+
readonly risks: {
|
|
48
|
+
readonly type: "string";
|
|
49
|
+
};
|
|
50
|
+
};
|
|
51
|
+
readonly required: readonly ["easeScore", "confidenceScore", "spec", "impactReport", "affectedFiles"];
|
|
52
|
+
};
|
|
53
|
+
export interface NotionTicket {
|
|
54
|
+
id: string;
|
|
55
|
+
title: string;
|
|
56
|
+
project: string;
|
|
57
|
+
status: string;
|
|
58
|
+
}
|
|
59
|
+
export interface TicketDetails extends NotionTicket {
|
|
60
|
+
description: string;
|
|
61
|
+
bodyBlocks: string;
|
|
62
|
+
spec?: string;
|
|
63
|
+
impact?: string;
|
|
64
|
+
}
|
|
65
|
+
export interface ReviewOutput {
|
|
66
|
+
easeScore: number;
|
|
67
|
+
confidenceScore: number;
|
|
68
|
+
spec: string;
|
|
69
|
+
impactReport: string;
|
|
70
|
+
affectedFiles: string[];
|
|
71
|
+
risks?: string;
|
|
72
|
+
}
|
|
73
|
+
export interface LockEntry {
|
|
74
|
+
mode: 'review' | 'execute';
|
|
75
|
+
startedAt: number;
|
|
76
|
+
}
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// -- License --
|
|
2
|
+
import { verify } from 'node:crypto';
|
|
3
|
+
const LICENSE_PUBLIC_KEY = `-----BEGIN PUBLIC KEY-----
|
|
4
|
+
MCowBQYDK2VwAyEAGtiFnwyCAHWl1b1yzm2wY14LiY8e0xfsXhQULcRaStM=
|
|
5
|
+
-----END PUBLIC KEY-----`;
|
|
6
|
+
let _proCache = null;
|
|
7
|
+
export function isPro() {
|
|
8
|
+
if (_proCache !== null)
|
|
9
|
+
return _proCache;
|
|
10
|
+
const key = process.env.LICENSE_KEY;
|
|
11
|
+
if (!key?.startsWith('ncb_pro_')) {
|
|
12
|
+
_proCache = false;
|
|
13
|
+
return false;
|
|
14
|
+
}
|
|
15
|
+
const rest = key.slice('ncb_pro_'.length);
|
|
16
|
+
const dotIndex = rest.indexOf('.');
|
|
17
|
+
if (dotIndex === -1) {
|
|
18
|
+
_proCache = false;
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
try {
|
|
22
|
+
const buyerId = Buffer.from(rest.slice(0, dotIndex), 'base64url');
|
|
23
|
+
const signature = Buffer.from(rest.slice(dotIndex + 1), 'base64url');
|
|
24
|
+
_proCache = verify(null, buyerId, LICENSE_PUBLIC_KEY, signature);
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
_proCache = false;
|
|
28
|
+
}
|
|
29
|
+
return _proCache;
|
|
30
|
+
}
|
|
31
|
+
const FREE_MAX_PROJECTS = 1;
|
|
32
|
+
const FREE_MAX_CONCURRENT = 1;
|
|
33
|
+
const PRO_MAX_CONCURRENT = 10;
|
|
34
|
+
export const CONFIG = {
|
|
35
|
+
// Polling
|
|
36
|
+
POLL_INTERVAL_MS: 30_000,
|
|
37
|
+
// Notion column names -> agent modes
|
|
38
|
+
COLUMNS: {
|
|
39
|
+
REVIEW: 'Review',
|
|
40
|
+
SCORED: 'Scored',
|
|
41
|
+
EXECUTE: 'Execute',
|
|
42
|
+
IN_PROGRESS: 'In Progress',
|
|
43
|
+
DONE: 'PR Ready',
|
|
44
|
+
FAILED: 'Failed',
|
|
45
|
+
},
|
|
46
|
+
// Agent budgets
|
|
47
|
+
REVIEW_BUDGET_USD: 2.00,
|
|
48
|
+
EXECUTE_BUDGET_USD: 15.00,
|
|
49
|
+
// Agent models (env override → default)
|
|
50
|
+
get REVIEW_MODEL() {
|
|
51
|
+
return process.env.REVIEW_MODEL || 'claude-sonnet-4-5-20250929';
|
|
52
|
+
},
|
|
53
|
+
get EXECUTE_MODEL() {
|
|
54
|
+
return process.env.EXECUTE_MODEL || 'claude-opus-4-6';
|
|
55
|
+
},
|
|
56
|
+
// Agent limits
|
|
57
|
+
REVIEW_MAX_TURNS: 25,
|
|
58
|
+
EXECUTE_MAX_TURNS: 50,
|
|
59
|
+
// Stale lock timeout (30 minutes)
|
|
60
|
+
STALE_LOCK_MS: 30 * 60 * 1000,
|
|
61
|
+
// Maximum concurrent agents (review + execute combined)
|
|
62
|
+
get MAX_CONCURRENT_AGENTS() {
|
|
63
|
+
return isPro() ? PRO_MAX_CONCURRENT : FREE_MAX_CONCURRENT;
|
|
64
|
+
},
|
|
65
|
+
// Free tier project limit
|
|
66
|
+
FREE_MAX_PROJECTS,
|
|
67
|
+
};
|
|
68
|
+
// JSON schema for review agent structured output
|
|
69
|
+
export const REVIEW_OUTPUT_SCHEMA = {
|
|
70
|
+
type: 'object',
|
|
71
|
+
properties: {
|
|
72
|
+
easeScore: { type: 'number', minimum: 1, maximum: 10 },
|
|
73
|
+
confidenceScore: { type: 'number', minimum: 1, maximum: 10 },
|
|
74
|
+
spec: { type: 'string' },
|
|
75
|
+
impactReport: { type: 'string' },
|
|
76
|
+
affectedFiles: { type: 'array', items: { type: 'string' } },
|
|
77
|
+
risks: { type: 'string' },
|
|
78
|
+
},
|
|
79
|
+
required: ['easeScore', 'confidenceScore', 'spec', 'impactReport', 'affectedFiles'],
|
|
80
|
+
};
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|