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/index.js
ADDED
|
@@ -0,0 +1,504 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { execSync } from 'node:child_process';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { query } from '@anthropic-ai/claude-agent-sdk';
|
|
5
|
+
import { CONFIG, REVIEW_OUTPUT_SCHEMA, isPro } from './config.js';
|
|
6
|
+
import { sleep, clamp, extractJsonFromOutput, shellEscape, extractNumber, loadEnv, createWorktree, removeWorktree, getDefaultBranch } from './lib/utils.js';
|
|
7
|
+
import { getProjectDir, getProjectNames, getBuildCommand } from './lib/projects.js';
|
|
8
|
+
import { fetchTicketsByStatus, fetchTicketDetails, writeReviewResults, writeExecutionResults, moveTicketStatus, writeFailure, addComment, } from './lib/notion.js';
|
|
9
|
+
import { PACKAGE_ROOT, CONFIG_DIR } from './lib/paths.js';
|
|
10
|
+
// Load .env.local from the user's working directory
|
|
11
|
+
loadEnv(join(CONFIG_DIR, '.env.local'));
|
|
12
|
+
// Allow running inside a Claude Code session (e.g. during development)
|
|
13
|
+
delete process.env.CLAUDECODE;
|
|
14
|
+
// -- Subcommand routing --
|
|
15
|
+
const subcommand = process.argv[2];
|
|
16
|
+
if (subcommand === 'init' || subcommand === 'doctor') {
|
|
17
|
+
const { runInit, runDoctor } = await import('./cli.js');
|
|
18
|
+
await (subcommand === 'init' ? runInit() : runDoctor());
|
|
19
|
+
process.exit(0);
|
|
20
|
+
}
|
|
21
|
+
// -- CLI flags --
|
|
22
|
+
const args = process.argv.slice(2);
|
|
23
|
+
const DRY_RUN = args.includes('--dry-run');
|
|
24
|
+
const ONCE = args.includes('--once');
|
|
25
|
+
// -- State --
|
|
26
|
+
const activeLocks = new Map();
|
|
27
|
+
let shuttingDown = false;
|
|
28
|
+
let activeAgentCount = 0;
|
|
29
|
+
// -- Logging --
|
|
30
|
+
const RESET = '\x1b[0m';
|
|
31
|
+
const DIM = '\x1b[2m';
|
|
32
|
+
const CYAN = '\x1b[36m';
|
|
33
|
+
const GREEN = '\x1b[32m';
|
|
34
|
+
const YELLOW = '\x1b[33m';
|
|
35
|
+
const RED = '\x1b[31m';
|
|
36
|
+
const MAGENTA = '\x1b[35m';
|
|
37
|
+
function ts() {
|
|
38
|
+
return DIM + new Date().toISOString().slice(11, 19) + RESET;
|
|
39
|
+
}
|
|
40
|
+
function log(color, label, msg) {
|
|
41
|
+
console.log(`${ts()} ${color}[${label}]${RESET} ${msg}`);
|
|
42
|
+
}
|
|
43
|
+
// -- Prompt loading (bundled with the package) --
|
|
44
|
+
const reviewPrompt = readFileSync(join(PACKAGE_ROOT, 'prompts', 'review.md'), 'utf-8');
|
|
45
|
+
const executePrompt = readFileSync(join(PACKAGE_ROOT, 'prompts', 'execute.md'), 'utf-8');
|
|
46
|
+
// -- Agent Runner --
|
|
47
|
+
async function runReviewAgent(ticket) {
|
|
48
|
+
const projectDir = getProjectDir(ticket.project);
|
|
49
|
+
if (!projectDir) {
|
|
50
|
+
throw new Error(`Unknown project: "${ticket.project}"`);
|
|
51
|
+
}
|
|
52
|
+
log(CYAN, 'REVIEW', `Starting review for "${ticket.title}" in ${ticket.project}`);
|
|
53
|
+
const startTime = Date.now();
|
|
54
|
+
const prompt = [
|
|
55
|
+
reviewPrompt,
|
|
56
|
+
'',
|
|
57
|
+
'## Ticket',
|
|
58
|
+
`**Title**: ${ticket.title}`,
|
|
59
|
+
'',
|
|
60
|
+
'**Description**:',
|
|
61
|
+
ticket.description,
|
|
62
|
+
'',
|
|
63
|
+
'**Page Content**:',
|
|
64
|
+
ticket.bodyBlocks,
|
|
65
|
+
].join('\n');
|
|
66
|
+
const messages = query({
|
|
67
|
+
prompt,
|
|
68
|
+
options: {
|
|
69
|
+
model: CONFIG.REVIEW_MODEL,
|
|
70
|
+
cwd: projectDir,
|
|
71
|
+
tools: ['Read', 'Glob', 'Grep', 'Task'],
|
|
72
|
+
allowedTools: ['Read', 'Glob', 'Grep', 'Task'],
|
|
73
|
+
maxTurns: CONFIG.REVIEW_MAX_TURNS,
|
|
74
|
+
maxBudgetUsd: CONFIG.REVIEW_BUDGET_USD,
|
|
75
|
+
permissionMode: 'bypassPermissions',
|
|
76
|
+
allowDangerouslySkipPermissions: true,
|
|
77
|
+
settingSources: ['project'],
|
|
78
|
+
systemPrompt: { type: 'preset', preset: 'claude_code' },
|
|
79
|
+
outputFormat: {
|
|
80
|
+
type: 'json_schema',
|
|
81
|
+
schema: REVIEW_OUTPUT_SCHEMA,
|
|
82
|
+
},
|
|
83
|
+
stderr: (data) => {
|
|
84
|
+
if (data.trim())
|
|
85
|
+
log(DIM, 'STDERR', data.trim());
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
});
|
|
89
|
+
let output = '';
|
|
90
|
+
let structuredOutput = undefined;
|
|
91
|
+
let cost = 0;
|
|
92
|
+
for await (const message of messages) {
|
|
93
|
+
if (message.type === 'assistant') {
|
|
94
|
+
const content = message.message?.content;
|
|
95
|
+
if (Array.isArray(content)) {
|
|
96
|
+
for (const block of content) {
|
|
97
|
+
if (block.type === 'text') {
|
|
98
|
+
output = block.text;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
else if (typeof content === 'string') {
|
|
103
|
+
output = content;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
if (message.type === 'result') {
|
|
107
|
+
cost = message.total_cost_usd ?? 0;
|
|
108
|
+
if (message.subtype !== 'success') {
|
|
109
|
+
throw new Error(`Review agent failed: ${message.subtype}`);
|
|
110
|
+
}
|
|
111
|
+
// Prefer structured_output from json_schema outputFormat
|
|
112
|
+
if ('structured_output' in message && message.structured_output != null) {
|
|
113
|
+
structuredOutput = message.structured_output;
|
|
114
|
+
}
|
|
115
|
+
if (message.result) {
|
|
116
|
+
output = message.result;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
// Prefer structured output (from outputFormat), fall back to JSON extraction from text
|
|
121
|
+
const parsed = structuredOutput ?? extractJsonFromOutput(output);
|
|
122
|
+
if (!parsed) {
|
|
123
|
+
throw new Error(`Review agent failed to return scores. The agent used all ${CONFIG.REVIEW_MAX_TURNS} turns without producing a result. Try simplifying the ticket description or increasing REVIEW_MAX_TURNS in config.ts.`);
|
|
124
|
+
}
|
|
125
|
+
const results = {
|
|
126
|
+
easeScore: clamp(Number(parsed.easeScore) || 5, 1, 10),
|
|
127
|
+
confidenceScore: clamp(Number(parsed.confidenceScore) || 5, 1, 10),
|
|
128
|
+
spec: String(parsed.spec ?? ''),
|
|
129
|
+
impactReport: String(parsed.impactReport ?? ''),
|
|
130
|
+
affectedFiles: Array.isArray(parsed.affectedFiles) ? parsed.affectedFiles.map(String) : [],
|
|
131
|
+
risks: parsed.risks ? String(parsed.risks) : undefined,
|
|
132
|
+
};
|
|
133
|
+
await writeReviewResults(ticket.id, results);
|
|
134
|
+
await moveTicketStatus(ticket.id, CONFIG.COLUMNS.SCORED);
|
|
135
|
+
const duration = Math.round((Date.now() - startTime) / 1000);
|
|
136
|
+
// Add audit trail comment
|
|
137
|
+
const comment = [
|
|
138
|
+
'🔍 Review Complete',
|
|
139
|
+
`Ease: ${results.easeScore}/10 | Confidence: ${results.confidenceScore}/10`,
|
|
140
|
+
`Files: ${results.affectedFiles.length} analyzed`,
|
|
141
|
+
`Cost: $${cost.toFixed(2)} | Duration: ${duration}s`,
|
|
142
|
+
].join('\n');
|
|
143
|
+
await addComment(ticket.id, comment);
|
|
144
|
+
log(GREEN, 'REVIEW', `Done: ease=${results.easeScore} confidence=${results.confidenceScore} cost=$${cost.toFixed(2)}`);
|
|
145
|
+
}
|
|
146
|
+
async function runExecuteAgent(ticket) {
|
|
147
|
+
const projectDir = getProjectDir(ticket.project);
|
|
148
|
+
if (!projectDir) {
|
|
149
|
+
throw new Error(`Unknown project: "${ticket.project}"`);
|
|
150
|
+
}
|
|
151
|
+
// Create branch name
|
|
152
|
+
const shortId = ticket.id.replace(/-/g, '').slice(0, 8);
|
|
153
|
+
const slug = ticket.title
|
|
154
|
+
.toLowerCase()
|
|
155
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
156
|
+
.replace(/^-|-$/g, '')
|
|
157
|
+
.slice(0, 40);
|
|
158
|
+
const branchName = `notion/${shortId}/${slug}`;
|
|
159
|
+
const worktreeDir = join(projectDir, '.worktrees', branchName.replace(/\//g, '_'));
|
|
160
|
+
log(MAGENTA, 'EXECUTE', `Starting execution for "${ticket.title}" on branch ${branchName}`);
|
|
161
|
+
const startTime = Date.now();
|
|
162
|
+
// Move to In Progress immediately
|
|
163
|
+
await moveTicketStatus(ticket.id, CONFIG.COLUMNS.IN_PROGRESS);
|
|
164
|
+
// Git: create isolated worktree
|
|
165
|
+
createWorktree(projectDir, branchName, worktreeDir);
|
|
166
|
+
let cost = 0;
|
|
167
|
+
let commitCount = 0;
|
|
168
|
+
try {
|
|
169
|
+
const prompt = [
|
|
170
|
+
executePrompt,
|
|
171
|
+
'',
|
|
172
|
+
'## Ticket',
|
|
173
|
+
`**Title**: ${ticket.title}`,
|
|
174
|
+
'',
|
|
175
|
+
'**Description**:',
|
|
176
|
+
ticket.description,
|
|
177
|
+
'',
|
|
178
|
+
'**Spec**:',
|
|
179
|
+
ticket.spec ?? '(no spec provided)',
|
|
180
|
+
'',
|
|
181
|
+
'**Impact Analysis**:',
|
|
182
|
+
ticket.impact ?? '(no impact analysis provided)',
|
|
183
|
+
'',
|
|
184
|
+
'**Page Content**:',
|
|
185
|
+
ticket.bodyBlocks,
|
|
186
|
+
].join('\n');
|
|
187
|
+
const messages = query({
|
|
188
|
+
prompt,
|
|
189
|
+
options: {
|
|
190
|
+
model: CONFIG.EXECUTE_MODEL,
|
|
191
|
+
cwd: worktreeDir,
|
|
192
|
+
allowedTools: [
|
|
193
|
+
'Read', 'Glob', 'Grep', 'Edit', 'Write', 'Task',
|
|
194
|
+
'Bash(git add:*)', 'Bash(git commit:*)', 'Bash(git status:*)',
|
|
195
|
+
'Bash(git diff:*)', 'Bash(git log:*)',
|
|
196
|
+
'Bash(npm run build:*)', 'Bash(npm test:*)', 'Bash(npx tsc:*)',
|
|
197
|
+
],
|
|
198
|
+
disallowedTools: ['WebFetch', 'WebSearch'],
|
|
199
|
+
maxTurns: CONFIG.EXECUTE_MAX_TURNS,
|
|
200
|
+
maxBudgetUsd: CONFIG.EXECUTE_BUDGET_USD,
|
|
201
|
+
permissionMode: 'acceptEdits',
|
|
202
|
+
settingSources: ['project'],
|
|
203
|
+
systemPrompt: { type: 'preset', preset: 'claude_code' },
|
|
204
|
+
stderr: (data) => {
|
|
205
|
+
if (data.trim())
|
|
206
|
+
log(DIM, 'STDERR', data.trim());
|
|
207
|
+
},
|
|
208
|
+
},
|
|
209
|
+
});
|
|
210
|
+
for await (const message of messages) {
|
|
211
|
+
if (message.type === 'result') {
|
|
212
|
+
cost = message.total_cost_usd ?? 0;
|
|
213
|
+
if (message.subtype !== 'success') {
|
|
214
|
+
throw new Error(`Execute agent failed: ${message.subtype}`);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
// Count commits made
|
|
219
|
+
const baseBranch = getDefaultBranch(projectDir);
|
|
220
|
+
try {
|
|
221
|
+
const commitLog = execSync(`git log ${shellEscape(baseBranch)}..${shellEscape(branchName)} --oneline`, { cwd: worktreeDir, stdio: 'pipe' });
|
|
222
|
+
commitCount = commitLog.toString().trim().split('\n').filter(Boolean).length;
|
|
223
|
+
}
|
|
224
|
+
catch {
|
|
225
|
+
// If branch doesn't exist or no commits, count is 0
|
|
226
|
+
commitCount = 0;
|
|
227
|
+
}
|
|
228
|
+
// Post-execution: validate build
|
|
229
|
+
const buildCmd = getBuildCommand(ticket.project);
|
|
230
|
+
let buildPassed = true;
|
|
231
|
+
if (buildCmd) {
|
|
232
|
+
log(YELLOW, 'VALIDATE', `Running: ${buildCmd}`);
|
|
233
|
+
try {
|
|
234
|
+
execSync(buildCmd, { cwd: worktreeDir, stdio: 'pipe', timeout: 120_000 });
|
|
235
|
+
log(GREEN, 'VALIDATE', 'Build passed');
|
|
236
|
+
}
|
|
237
|
+
catch (e) {
|
|
238
|
+
buildPassed = false;
|
|
239
|
+
let detail = '';
|
|
240
|
+
if (e && typeof e === 'object' && 'stderr' in e) {
|
|
241
|
+
detail = String(e.stderr).slice(0, 500);
|
|
242
|
+
}
|
|
243
|
+
if (!detail && e && typeof e === 'object' && 'stdout' in e) {
|
|
244
|
+
detail = String(e.stdout).slice(0, 500);
|
|
245
|
+
}
|
|
246
|
+
throw new Error(`Build validation failed.\nCommand: ${buildCmd}\nDirectory: ${worktreeDir}\n${detail ? `Output:\n${detail}` : (e instanceof Error ? e.message : String(e))}`);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
// Push branch
|
|
250
|
+
log(CYAN, 'PUSH', `Pushing ${branchName}`);
|
|
251
|
+
execSync(`git push -u origin ${shellEscape(branchName)}`, { cwd: worktreeDir, stdio: 'pipe' });
|
|
252
|
+
// Create PR
|
|
253
|
+
let prUrl = '';
|
|
254
|
+
try {
|
|
255
|
+
log(CYAN, 'PR', 'Creating pull request...');
|
|
256
|
+
const prBody = [
|
|
257
|
+
'## Summary',
|
|
258
|
+
'',
|
|
259
|
+
ticket.spec ?? ticket.description,
|
|
260
|
+
'',
|
|
261
|
+
'## Impact',
|
|
262
|
+
'',
|
|
263
|
+
ticket.impact ?? '_No impact analysis_',
|
|
264
|
+
'',
|
|
265
|
+
`## Notion Ticket`,
|
|
266
|
+
'',
|
|
267
|
+
`[View in Notion](https://www.notion.so/${ticket.id.replace(/-/g, '')})`,
|
|
268
|
+
'',
|
|
269
|
+
'---',
|
|
270
|
+
`Cost: $${cost.toFixed(2)} | Review: Ease ${extractNumber(ticket, 'ease')}/10, Confidence ${extractNumber(ticket, 'confidence')}/10`,
|
|
271
|
+
].join('\n');
|
|
272
|
+
const prResult = execSync(`gh pr create --title ${shellEscape(ticket.title)} --body ${shellEscape(prBody)} --base ${shellEscape(baseBranch)} --head ${branchName}`, { cwd: worktreeDir, stdio: 'pipe', timeout: 30_000 });
|
|
273
|
+
prUrl = prResult.toString().trim();
|
|
274
|
+
log(GREEN, 'PR', `Created: ${prUrl}`);
|
|
275
|
+
}
|
|
276
|
+
catch (e) {
|
|
277
|
+
// PR creation is best-effort — don't fail the ticket over it
|
|
278
|
+
log(YELLOW, 'PR', `Failed to create PR: ${e instanceof Error ? e.message : e}`);
|
|
279
|
+
}
|
|
280
|
+
// Update Notion
|
|
281
|
+
await writeExecutionResults(ticket.id, { branch: branchName, cost, prUrl });
|
|
282
|
+
await moveTicketStatus(ticket.id, CONFIG.COLUMNS.DONE);
|
|
283
|
+
const duration = Math.round((Date.now() - startTime) / 1000);
|
|
284
|
+
// Add success audit trail comment
|
|
285
|
+
const comment = [
|
|
286
|
+
'✅ Execute Complete',
|
|
287
|
+
`Branch: ${branchName}`,
|
|
288
|
+
prUrl ? `PR: ${prUrl}` : 'PR: Not created',
|
|
289
|
+
`Build: ${buildPassed ? 'PASS' : 'N/A'}`,
|
|
290
|
+
`Commits: ${commitCount}`,
|
|
291
|
+
`Cost: $${cost.toFixed(2)} | Duration: ${duration}s`,
|
|
292
|
+
].join('\n');
|
|
293
|
+
await addComment(ticket.id, comment);
|
|
294
|
+
log(GREEN, 'EXECUTE', `Done: branch=${branchName} cost=$${cost.toFixed(2)}${prUrl ? ` pr=${prUrl}` : ''}`);
|
|
295
|
+
}
|
|
296
|
+
catch (error) {
|
|
297
|
+
// On failure, add failure audit trail comment
|
|
298
|
+
const duration = Math.round((Date.now() - startTime) / 1000);
|
|
299
|
+
const errMsg = error instanceof Error ? error.message : String(error);
|
|
300
|
+
const comment = [
|
|
301
|
+
'❌ Execute Failed',
|
|
302
|
+
`Error: ${errMsg.slice(0, 500)}`,
|
|
303
|
+
`Phase: execution`,
|
|
304
|
+
`Cost: $${cost.toFixed(2)} | Duration: ${duration}s`,
|
|
305
|
+
].join('\n');
|
|
306
|
+
await addComment(ticket.id, comment);
|
|
307
|
+
throw error;
|
|
308
|
+
}
|
|
309
|
+
finally {
|
|
310
|
+
// Always clean up the worktree
|
|
311
|
+
removeWorktree(projectDir, worktreeDir);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
// -- Orchestration --
|
|
315
|
+
async function handleTicket(mode, ticket) {
|
|
316
|
+
const lockKey = ticket.id;
|
|
317
|
+
if (activeLocks.has(lockKey)) {
|
|
318
|
+
return; // Already being processed
|
|
319
|
+
}
|
|
320
|
+
// Check project mapping
|
|
321
|
+
if (!getProjectDir(ticket.project)) {
|
|
322
|
+
const known = getProjectNames();
|
|
323
|
+
log(RED, 'ERROR', `Unknown project "${ticket.project}" for ticket "${ticket.title}". Available projects: ${known.join(', ')}. Check that the Notion Project field matches projects.json exactly (case-sensitive).`);
|
|
324
|
+
await writeFailure(ticket.id, `Unknown project: "${ticket.project}". Available projects: ${known.join(', ')}. Check that the Notion Project field matches projects.json exactly (case-sensitive).`);
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
const lockEntry = { mode, startedAt: Date.now() };
|
|
328
|
+
activeLocks.set(lockKey, lockEntry);
|
|
329
|
+
activeAgentCount++;
|
|
330
|
+
try {
|
|
331
|
+
if (mode === 'review') {
|
|
332
|
+
await runReviewAgent(ticket);
|
|
333
|
+
}
|
|
334
|
+
else {
|
|
335
|
+
await runExecuteAgent(ticket);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
catch (error) {
|
|
339
|
+
const errMsg = error instanceof Error ? error.message : String(error);
|
|
340
|
+
log(RED, 'FAILED', `${mode} failed for "${ticket.title}": ${errMsg}`);
|
|
341
|
+
// Add failure comment for review (execute handles its own failure comments)
|
|
342
|
+
if (mode === 'review') {
|
|
343
|
+
const duration = Math.round((Date.now() - lockEntry.startedAt) / 1000);
|
|
344
|
+
const comment = [
|
|
345
|
+
'❌ Review Failed',
|
|
346
|
+
`Error: ${errMsg.slice(0, 500)}`,
|
|
347
|
+
`Phase: review`,
|
|
348
|
+
`Duration: ${duration}s`,
|
|
349
|
+
].join('\n');
|
|
350
|
+
await addComment(ticket.id, comment);
|
|
351
|
+
}
|
|
352
|
+
try {
|
|
353
|
+
await writeFailure(ticket.id, errMsg);
|
|
354
|
+
}
|
|
355
|
+
catch (notionErr) {
|
|
356
|
+
log(RED, 'NOTION', `Failed to write failure to Notion: ${notionErr}`);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
finally {
|
|
360
|
+
activeLocks.delete(lockKey);
|
|
361
|
+
activeAgentCount--;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
function clearStaleLocks() {
|
|
365
|
+
const now = Date.now();
|
|
366
|
+
for (const [id, lock] of activeLocks) {
|
|
367
|
+
if (now - lock.startedAt > CONFIG.STALE_LOCK_MS) {
|
|
368
|
+
log(YELLOW, 'STALE', `Releasing stale lock for ${id} (mode: ${lock.mode})`);
|
|
369
|
+
activeLocks.delete(id);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
async function poll() {
|
|
374
|
+
if (shuttingDown)
|
|
375
|
+
return;
|
|
376
|
+
log(DIM, 'POLL', `Checking Notion board...${DRY_RUN ? ' (dry-run)' : ''}`);
|
|
377
|
+
try {
|
|
378
|
+
// Clear stale locks
|
|
379
|
+
clearStaleLocks();
|
|
380
|
+
// Fetch tickets in Review and Execute columns
|
|
381
|
+
const [reviewTickets, executeTickets] = await Promise.all([
|
|
382
|
+
fetchTicketsByStatus(CONFIG.COLUMNS.REVIEW),
|
|
383
|
+
fetchTicketsByStatus(CONFIG.COLUMNS.EXECUTE),
|
|
384
|
+
]);
|
|
385
|
+
const pendingReview = reviewTickets.filter((t) => !activeLocks.has(t.id));
|
|
386
|
+
const pendingExecute = executeTickets.filter((t) => !activeLocks.has(t.id));
|
|
387
|
+
if (pendingReview.length > 0) {
|
|
388
|
+
log(CYAN, 'POLL', `Found ${pendingReview.length} ticket(s) to review`);
|
|
389
|
+
}
|
|
390
|
+
if (pendingExecute.length > 0) {
|
|
391
|
+
log(MAGENTA, 'POLL', `Found ${pendingExecute.length} ticket(s) to execute`);
|
|
392
|
+
}
|
|
393
|
+
if (pendingReview.length === 0 && pendingExecute.length === 0) {
|
|
394
|
+
log(DIM, 'POLL', 'No tickets to process');
|
|
395
|
+
}
|
|
396
|
+
if (DRY_RUN)
|
|
397
|
+
return;
|
|
398
|
+
// Collect all pending tickets (review first, then execute)
|
|
399
|
+
const allPendingTickets = [
|
|
400
|
+
...pendingReview.map((t) => ({ ticket: t, mode: 'review' })),
|
|
401
|
+
...pendingExecute.map((t) => ({ ticket: t, mode: 'execute' })),
|
|
402
|
+
];
|
|
403
|
+
// Launch agents up to concurrency limit
|
|
404
|
+
const availableSlots = CONFIG.MAX_CONCURRENT_AGENTS - activeLocks.size;
|
|
405
|
+
const ticketsToProcess = allPendingTickets.slice(0, Math.max(0, availableSlots));
|
|
406
|
+
if (ticketsToProcess.length > 0) {
|
|
407
|
+
log(CYAN, 'QUEUE', `Launching ${ticketsToProcess.length} agent(s) (${activeLocks.size} already running, ${CONFIG.MAX_CONCURRENT_AGENTS} max)`);
|
|
408
|
+
}
|
|
409
|
+
if (ticketsToProcess.length < allPendingTickets.length) {
|
|
410
|
+
const queued = allPendingTickets.length - ticketsToProcess.length;
|
|
411
|
+
log(YELLOW, 'QUEUE', `${queued} ticket(s) queued for next poll (concurrency limit reached)`);
|
|
412
|
+
}
|
|
413
|
+
// Fire and forget - runs in background, lock prevents duplicates
|
|
414
|
+
for (const { ticket, mode } of ticketsToProcess) {
|
|
415
|
+
if (shuttingDown)
|
|
416
|
+
break;
|
|
417
|
+
const details = await fetchTicketDetails(ticket.id);
|
|
418
|
+
handleTicket(mode, details).catch((err) => {
|
|
419
|
+
log(RED, 'UNHANDLED', `Unexpected error in ${mode} for "${details.title}": ${err instanceof Error ? err.message : err}`);
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
catch (error) {
|
|
424
|
+
const errMsg = error instanceof Error ? error.message : String(error);
|
|
425
|
+
log(RED, 'POLL', `Error during poll: ${errMsg}`);
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
// -- Graceful Shutdown --
|
|
429
|
+
function setupShutdown() {
|
|
430
|
+
const shutdown = async () => {
|
|
431
|
+
if (shuttingDown)
|
|
432
|
+
return;
|
|
433
|
+
shuttingDown = true;
|
|
434
|
+
log(YELLOW, 'SHUTDOWN', 'Received signal, waiting for active agents to finish...');
|
|
435
|
+
// Wait up to 5 minutes for active agents
|
|
436
|
+
const deadline = Date.now() + 5 * 60 * 1000;
|
|
437
|
+
while (activeAgentCount > 0 && Date.now() < deadline) {
|
|
438
|
+
log(YELLOW, 'SHUTDOWN', `${activeAgentCount} agent(s) still running...`);
|
|
439
|
+
await sleep(5_000);
|
|
440
|
+
}
|
|
441
|
+
if (activeAgentCount > 0) {
|
|
442
|
+
log(RED, 'SHUTDOWN', `Force exiting with ${activeAgentCount} agent(s) still running`);
|
|
443
|
+
}
|
|
444
|
+
else {
|
|
445
|
+
log(GREEN, 'SHUTDOWN', 'All agents finished. Exiting cleanly.');
|
|
446
|
+
}
|
|
447
|
+
process.exit(0);
|
|
448
|
+
};
|
|
449
|
+
process.on('SIGINT', shutdown);
|
|
450
|
+
process.on('SIGTERM', shutdown);
|
|
451
|
+
}
|
|
452
|
+
// -- Main --
|
|
453
|
+
async function main() {
|
|
454
|
+
// Validate environment
|
|
455
|
+
if (!process.env.NOTION_TOKEN) {
|
|
456
|
+
console.error("Missing NOTION_TOKEN. Run 'npx tsx index.ts init' to configure, or create .env.local manually.");
|
|
457
|
+
process.exit(1);
|
|
458
|
+
}
|
|
459
|
+
if (!process.env.NOTION_DATABASE_ID) {
|
|
460
|
+
console.error("Missing NOTION_DATABASE_ID. Run 'npx tsx index.ts init' to configure, or create .env.local manually.");
|
|
461
|
+
process.exit(1);
|
|
462
|
+
}
|
|
463
|
+
setupShutdown();
|
|
464
|
+
const pro = isPro();
|
|
465
|
+
const projectNames = getProjectNames();
|
|
466
|
+
// Free tier: enforce 1-project limit
|
|
467
|
+
if (!pro && projectNames.length > CONFIG.FREE_MAX_PROJECTS) {
|
|
468
|
+
console.error(`Free tier supports ${CONFIG.FREE_MAX_PROJECTS} project. You have ${projectNames.length} configured.` +
|
|
469
|
+
`\nRemove extra projects from projects.json, or add a LICENSE_KEY to .env.local to unlock unlimited projects.`);
|
|
470
|
+
process.exit(1);
|
|
471
|
+
}
|
|
472
|
+
console.log('');
|
|
473
|
+
log(GREEN, 'START', `TicketToPR ${pro ? '(Pro)' : '(Free)'}`);
|
|
474
|
+
log(DIM, 'CONFIG', `Poll interval: ${CONFIG.POLL_INTERVAL_MS / 1000}s`);
|
|
475
|
+
log(DIM, 'CONFIG', `Max concurrent agents: ${CONFIG.MAX_CONCURRENT_AGENTS}${pro ? '' : ' (upgrade to Pro for up to 10)'}`);
|
|
476
|
+
log(DIM, 'CONFIG', `Projects: ${projectNames.join(', ')}`);
|
|
477
|
+
log(DIM, 'CONFIG', `Review: ${CONFIG.REVIEW_MODEL} ($${CONFIG.REVIEW_BUDGET_USD} budget)`);
|
|
478
|
+
log(DIM, 'CONFIG', `Execute: ${CONFIG.EXECUTE_MODEL} ($${CONFIG.EXECUTE_BUDGET_USD} budget)`);
|
|
479
|
+
if (DRY_RUN)
|
|
480
|
+
log(YELLOW, 'CONFIG', 'DRY-RUN mode: polling only, no agents will run');
|
|
481
|
+
if (ONCE)
|
|
482
|
+
log(YELLOW, 'CONFIG', 'ONE-SHOT mode: will exit after first poll');
|
|
483
|
+
console.log('');
|
|
484
|
+
// First poll
|
|
485
|
+
await poll();
|
|
486
|
+
if (ONCE) {
|
|
487
|
+
// In one-shot mode, wait for any agents that were launched
|
|
488
|
+
while (activeAgentCount > 0) {
|
|
489
|
+
log(DIM, 'WAIT', `${activeAgentCount} agent(s) still running...`);
|
|
490
|
+
await sleep(5_000);
|
|
491
|
+
}
|
|
492
|
+
log(GREEN, 'DONE', 'One-shot complete');
|
|
493
|
+
process.exit(0);
|
|
494
|
+
}
|
|
495
|
+
// Poll loop
|
|
496
|
+
while (!shuttingDown) {
|
|
497
|
+
await sleep(CONFIG.POLL_INTERVAL_MS);
|
|
498
|
+
await poll();
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
main().catch((err) => {
|
|
502
|
+
console.error('Fatal error:', err);
|
|
503
|
+
process.exit(1);
|
|
504
|
+
});
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { PageObjectResponse, BlockObjectResponse, RichTextItemResponse } from '@notionhq/client/build/src/api-endpoints.js';
|
|
2
|
+
import { type NotionTicket, type TicketDetails, type ReviewOutput } from '../config.js';
|
|
3
|
+
export declare function extractPlainText(richText: RichTextItemResponse[]): string;
|
|
4
|
+
export declare function extractProjectName(page: PageObjectResponse): string;
|
|
5
|
+
export declare function pageToTicket(page: PageObjectResponse): NotionTicket;
|
|
6
|
+
export declare function blockToMarkdown(block: BlockObjectResponse): string;
|
|
7
|
+
/**
|
|
8
|
+
* Fetch all tickets with a given status from the Notion database.
|
|
9
|
+
*/
|
|
10
|
+
export declare function fetchTicketsByStatus(status: string): Promise<NotionTicket[]>;
|
|
11
|
+
/**
|
|
12
|
+
* Read full ticket details including page body blocks.
|
|
13
|
+
*/
|
|
14
|
+
export declare function fetchTicketDetails(pageId: string): Promise<TicketDetails>;
|
|
15
|
+
/**
|
|
16
|
+
* Write review results back to the ticket properties.
|
|
17
|
+
*/
|
|
18
|
+
export declare function writeReviewResults(pageId: string, results: ReviewOutput): Promise<void>;
|
|
19
|
+
/**
|
|
20
|
+
* Write execution results back to the ticket.
|
|
21
|
+
*/
|
|
22
|
+
export declare function writeExecutionResults(pageId: string, results: {
|
|
23
|
+
branch: string;
|
|
24
|
+
cost: number;
|
|
25
|
+
prUrl?: string;
|
|
26
|
+
}): Promise<void>;
|
|
27
|
+
/**
|
|
28
|
+
* Move a ticket to a new status column.
|
|
29
|
+
*/
|
|
30
|
+
export declare function moveTicketStatus(pageId: string, newStatus: string): Promise<void>;
|
|
31
|
+
/**
|
|
32
|
+
* Write error details and move ticket to Failed.
|
|
33
|
+
*/
|
|
34
|
+
export declare function writeFailure(pageId: string, error: string): Promise<void>;
|
|
35
|
+
/**
|
|
36
|
+
* Add a comment to a Notion page (best-effort).
|
|
37
|
+
* Used for agent audit trail - does not throw if it fails.
|
|
38
|
+
*/
|
|
39
|
+
export declare function addComment(pageId: string, text: string): Promise<void>;
|
|
40
|
+
export declare function truncate(str: string, maxLen: number): string;
|
|
41
|
+
/** Chunk text into Notion rich_text segments (each max 2000 chars). */
|
|
42
|
+
export declare function chunkRichText(str: string): Array<{
|
|
43
|
+
text: {
|
|
44
|
+
content: string;
|
|
45
|
+
};
|
|
46
|
+
}>;
|