openthrottle 0.0.1 → 0.1.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/index.mjs +346 -0
- package/init.mjs +479 -0
- package/package.json +21 -4
- package/templates/wake-sandbox.yml +150 -0
package/index.mjs
ADDED
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// =============================================================================
|
|
3
|
+
// openthrottle — CLI for Open Throttle.
|
|
4
|
+
//
|
|
5
|
+
// Usage: npx openthrottle <command>
|
|
6
|
+
//
|
|
7
|
+
// Commands:
|
|
8
|
+
// ship <file.md> [--base <branch>] Ship a prompt to a Daytona sandbox
|
|
9
|
+
// status Show running, queued, and completed tasks
|
|
10
|
+
// logs Show recent GitHub Actions workflow runs
|
|
11
|
+
// =============================================================================
|
|
12
|
+
|
|
13
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
14
|
+
import { execFileSync } from 'node:child_process';
|
|
15
|
+
import { join, resolve } from 'node:path';
|
|
16
|
+
import { fileURLToPath } from 'node:url';
|
|
17
|
+
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
// 1. Constants + helpers
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
const EXIT_OK = 0;
|
|
23
|
+
const EXIT_USER_ERROR = 1;
|
|
24
|
+
const EXIT_MISSING_DEP = 2;
|
|
25
|
+
|
|
26
|
+
function die(message, code = EXIT_USER_ERROR) {
|
|
27
|
+
console.error(`error: ${message}`);
|
|
28
|
+
process.exit(code);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function gh(args, { quiet = false } = {}) {
|
|
32
|
+
try {
|
|
33
|
+
return execFileSync('gh', args, {
|
|
34
|
+
encoding: 'utf8',
|
|
35
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
36
|
+
}).trim();
|
|
37
|
+
} catch (err) {
|
|
38
|
+
const stderr = err.stderr?.toString().trim() || '';
|
|
39
|
+
if (stderr.includes('auth login')) {
|
|
40
|
+
die('gh auth expired -- run: gh auth login', EXIT_MISSING_DEP);
|
|
41
|
+
}
|
|
42
|
+
if (quiet) {
|
|
43
|
+
// Exit code 1 with no stderr = no matching results (expected)
|
|
44
|
+
if (err.status === 1 && !stderr) return '';
|
|
45
|
+
// Real failure — warn but don't crash
|
|
46
|
+
console.error(`warning: gh ${args.slice(0, 2).join(' ')} failed: ${stderr || err.message}`);
|
|
47
|
+
return '';
|
|
48
|
+
}
|
|
49
|
+
throw err;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function preflight() {
|
|
54
|
+
try {
|
|
55
|
+
execFileSync('gh', ['auth', 'status'], { stdio: 'pipe' });
|
|
56
|
+
} catch {
|
|
57
|
+
die(
|
|
58
|
+
'gh CLI not found or not authenticated.\n Install: https://cli.github.com\n Auth: gh auth login',
|
|
59
|
+
EXIT_MISSING_DEP,
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function detectRepo() {
|
|
65
|
+
try {
|
|
66
|
+
const url = execFileSync('git', ['remote', 'get-url', 'origin'], {
|
|
67
|
+
encoding: 'utf8',
|
|
68
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
69
|
+
}).trim();
|
|
70
|
+
const match = url.match(/github\.com[:/](.+?\/.+?)(?:\.git)?$/);
|
|
71
|
+
if (match) return match[1];
|
|
72
|
+
} catch {}
|
|
73
|
+
die('Could not detect GitHub repo. Run from a git repo with a github.com remote.');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function readConfig() {
|
|
77
|
+
const configPath = join(process.cwd(), '.openthrottle.yml');
|
|
78
|
+
if (!existsSync(configPath)) {
|
|
79
|
+
return { baseBranch: 'main', snapshot: 'openthrottle' };
|
|
80
|
+
}
|
|
81
|
+
let content;
|
|
82
|
+
try {
|
|
83
|
+
content = readFileSync(configPath, 'utf8');
|
|
84
|
+
} catch (err) {
|
|
85
|
+
die(`Could not read .openthrottle.yml: ${err.message}`);
|
|
86
|
+
}
|
|
87
|
+
const get = (key) => {
|
|
88
|
+
const match = content.match(new RegExp(`^${key}:\\s*(.+)`, 'm'));
|
|
89
|
+
if (!match) return undefined;
|
|
90
|
+
return match[1].replace(/#.*$/, '').trim().replace(/^["']|["']$/g, '');
|
|
91
|
+
};
|
|
92
|
+
return {
|
|
93
|
+
baseBranch: get('base_branch') || 'main',
|
|
94
|
+
snapshot: get('snapshot') || 'openthrottle',
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
99
|
+
// 2. Command: ship
|
|
100
|
+
// ---------------------------------------------------------------------------
|
|
101
|
+
|
|
102
|
+
function cmdShip(args) {
|
|
103
|
+
let file = null;
|
|
104
|
+
let baseBranch = null;
|
|
105
|
+
|
|
106
|
+
for (let i = 0; i < args.length; i++) {
|
|
107
|
+
if (args[i] === '--base' && args[i + 1]) {
|
|
108
|
+
baseBranch = args[++i];
|
|
109
|
+
} else if (!file) {
|
|
110
|
+
file = args[i];
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (!file) die('Usage: openthrottle ship <file.md> [--base <branch>]');
|
|
115
|
+
|
|
116
|
+
file = resolve(file);
|
|
117
|
+
if (!existsSync(file)) die(`File not found: ${file}`);
|
|
118
|
+
if (!file.endsWith('.md')) die(`Expected a markdown file, got: ${file}`);
|
|
119
|
+
|
|
120
|
+
const config = readConfig();
|
|
121
|
+
const base = baseBranch || config.baseBranch;
|
|
122
|
+
const repo = detectRepo();
|
|
123
|
+
|
|
124
|
+
// Extract title from first markdown heading
|
|
125
|
+
const content = readFileSync(file, 'utf8');
|
|
126
|
+
const headingMatch = content.match(/^#{1,6}\s+(.+)/m);
|
|
127
|
+
let title = headingMatch ? headingMatch[1].trim() : file.replace(/\.md$/, '');
|
|
128
|
+
if (!title.startsWith('PRD:')) title = `PRD: ${title}`;
|
|
129
|
+
|
|
130
|
+
// Ensure labels exist (idempotent)
|
|
131
|
+
const labels = [
|
|
132
|
+
'prd-queued', 'prd-running', 'prd-complete', 'prd-failed',
|
|
133
|
+
'needs-review', 'reviewing',
|
|
134
|
+
'bug-queued', 'bug-running', 'bug-complete', 'bug-failed',
|
|
135
|
+
];
|
|
136
|
+
for (const label of labels) {
|
|
137
|
+
try {
|
|
138
|
+
gh(['label', 'create', label, '--repo', repo, '--force']);
|
|
139
|
+
} catch (err) {
|
|
140
|
+
const stderr = err.stderr?.toString().trim() || err.message;
|
|
141
|
+
console.error(`warning: failed to create label "${label}": ${stderr}`);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Build label list
|
|
146
|
+
let issueLabels = 'prd-queued';
|
|
147
|
+
if (base !== 'main') issueLabels += `,base:${base}`;
|
|
148
|
+
|
|
149
|
+
// Create the issue
|
|
150
|
+
let issueUrl;
|
|
151
|
+
try {
|
|
152
|
+
issueUrl = gh([
|
|
153
|
+
'issue', 'create',
|
|
154
|
+
'--repo', repo,
|
|
155
|
+
'--title', title,
|
|
156
|
+
'--body-file', file,
|
|
157
|
+
'--label', issueLabels,
|
|
158
|
+
]);
|
|
159
|
+
} catch (err) {
|
|
160
|
+
const msg = err.stderr?.toString().trim() || err.message;
|
|
161
|
+
die(`Failed to create issue: ${msg}`);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Show queue position
|
|
165
|
+
let queueCount = 0;
|
|
166
|
+
try {
|
|
167
|
+
const raw = gh([
|
|
168
|
+
'issue', 'list', '--repo', repo,
|
|
169
|
+
'--label', 'prd-queued', '--state', 'open',
|
|
170
|
+
'--json', 'number', '--jq', 'length',
|
|
171
|
+
]);
|
|
172
|
+
queueCount = parseInt(raw, 10) || 0;
|
|
173
|
+
} catch {}
|
|
174
|
+
|
|
175
|
+
let runningInfo = '';
|
|
176
|
+
try {
|
|
177
|
+
runningInfo = gh([
|
|
178
|
+
'issue', 'list', '--repo', repo,
|
|
179
|
+
'--label', 'prd-running', '--state', 'open',
|
|
180
|
+
'--json', 'number,title',
|
|
181
|
+
'--jq', '.[0] | "#\\(.number) -- \\(.title)"',
|
|
182
|
+
]);
|
|
183
|
+
} catch {}
|
|
184
|
+
|
|
185
|
+
console.log(`Shipped: ${issueUrl}`);
|
|
186
|
+
if (queueCount > 1) {
|
|
187
|
+
console.log(`Queue: ${queueCount} queued`);
|
|
188
|
+
} else {
|
|
189
|
+
console.log('Status: starting');
|
|
190
|
+
}
|
|
191
|
+
if (runningInfo) {
|
|
192
|
+
console.log(`Running: ${runningInfo}`);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// ---------------------------------------------------------------------------
|
|
197
|
+
// 3. Command: status
|
|
198
|
+
// ---------------------------------------------------------------------------
|
|
199
|
+
|
|
200
|
+
function cmdStatus() {
|
|
201
|
+
const repo = detectRepo();
|
|
202
|
+
|
|
203
|
+
console.log('RUNNING');
|
|
204
|
+
const running = gh([
|
|
205
|
+
'issue', 'list', '--repo', repo,
|
|
206
|
+
'--label', 'prd-running', '--state', 'open',
|
|
207
|
+
'--json', 'number,title',
|
|
208
|
+
'--jq', '.[] | " #\\(.number) -- \\(.title)"',
|
|
209
|
+
], { quiet: true });
|
|
210
|
+
console.log(running || ' (none)');
|
|
211
|
+
|
|
212
|
+
console.log('\nQUEUE');
|
|
213
|
+
const queued = gh([
|
|
214
|
+
'issue', 'list', '--repo', repo,
|
|
215
|
+
'--label', 'prd-queued', '--state', 'open',
|
|
216
|
+
'--json', 'number,title',
|
|
217
|
+
'--jq', '.[] | " #\\(.number) -- \\(.title)"',
|
|
218
|
+
], { quiet: true });
|
|
219
|
+
console.log(queued || ' (none)');
|
|
220
|
+
|
|
221
|
+
console.log('\nREVIEW');
|
|
222
|
+
const pending = gh([
|
|
223
|
+
'pr', 'list', '--repo', repo,
|
|
224
|
+
'--label', 'needs-review',
|
|
225
|
+
'--json', 'number,title',
|
|
226
|
+
'--jq', '.[] | " pending: #\\(.number) -- \\(.title)"',
|
|
227
|
+
], { quiet: true });
|
|
228
|
+
const reviewing = gh([
|
|
229
|
+
'pr', 'list', '--repo', repo,
|
|
230
|
+
'--label', 'reviewing',
|
|
231
|
+
'--json', 'number,title',
|
|
232
|
+
'--jq', '.[] | " active: #\\(.number) -- \\(.title)"',
|
|
233
|
+
], { quiet: true });
|
|
234
|
+
const fixes = gh([
|
|
235
|
+
'pr', 'list', '--repo', repo,
|
|
236
|
+
'--search', 'review:changes_requested',
|
|
237
|
+
'--json', 'number,title',
|
|
238
|
+
'--jq', '.[] | " fixes: #\\(.number) -- \\(.title)"',
|
|
239
|
+
], { quiet: true });
|
|
240
|
+
const reviewOutput = [pending, reviewing, fixes].filter(Boolean).join('\n');
|
|
241
|
+
console.log(reviewOutput || ' (none)');
|
|
242
|
+
|
|
243
|
+
console.log('\nCOMPLETED (recent)');
|
|
244
|
+
const completed = gh([
|
|
245
|
+
'issue', 'list', '--repo', repo,
|
|
246
|
+
'--label', 'prd-complete', '--state', 'closed',
|
|
247
|
+
'--limit', '5',
|
|
248
|
+
'--json', 'number,title',
|
|
249
|
+
'--jq', '.[] | " #\\(.number) -- \\(.title)"',
|
|
250
|
+
], { quiet: true });
|
|
251
|
+
console.log(completed || ' (none)');
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// ---------------------------------------------------------------------------
|
|
255
|
+
// 4. Command: logs
|
|
256
|
+
// ---------------------------------------------------------------------------
|
|
257
|
+
|
|
258
|
+
function cmdLogs() {
|
|
259
|
+
const repo = detectRepo();
|
|
260
|
+
|
|
261
|
+
let output;
|
|
262
|
+
try {
|
|
263
|
+
output = gh([
|
|
264
|
+
'run', 'list',
|
|
265
|
+
'--repo', repo,
|
|
266
|
+
'--workflow', 'Wake Sandbox',
|
|
267
|
+
'--limit', '10',
|
|
268
|
+
]);
|
|
269
|
+
} catch {
|
|
270
|
+
try {
|
|
271
|
+
output = gh([
|
|
272
|
+
'run', 'list',
|
|
273
|
+
'--repo', repo,
|
|
274
|
+
'--limit', '10',
|
|
275
|
+
]);
|
|
276
|
+
} catch (err) {
|
|
277
|
+
die(`Failed to list workflow runs: ${err.stderr?.toString().trim() || err.message}`);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
if (!output) {
|
|
282
|
+
console.log('No workflow runs found.');
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
console.log(output);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// ---------------------------------------------------------------------------
|
|
290
|
+
// 5. Main
|
|
291
|
+
// ---------------------------------------------------------------------------
|
|
292
|
+
|
|
293
|
+
const HELP = `Usage: openthrottle <command>
|
|
294
|
+
|
|
295
|
+
Commands:
|
|
296
|
+
init Set up Open Throttle in your project
|
|
297
|
+
ship <file.md> [--base <branch>] Create a GitHub issue to trigger a sandbox
|
|
298
|
+
status Show running, queued, and completed tasks
|
|
299
|
+
logs Show recent GitHub Actions workflow runs
|
|
300
|
+
|
|
301
|
+
Options:
|
|
302
|
+
--help, -h Show this help message
|
|
303
|
+
--version, -v Show version`;
|
|
304
|
+
|
|
305
|
+
async function main() {
|
|
306
|
+
const args = process.argv.slice(2);
|
|
307
|
+
const command = args[0];
|
|
308
|
+
|
|
309
|
+
if (!command || command === '--help' || command === '-h') {
|
|
310
|
+
console.log(HELP);
|
|
311
|
+
process.exit(EXIT_OK);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
if (command === '--version' || command === '-v') {
|
|
315
|
+
const pkg = JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf8'));
|
|
316
|
+
console.log(pkg.version);
|
|
317
|
+
process.exit(EXIT_OK);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
if (command === 'init') {
|
|
321
|
+
const { default: init } = await import('./init.mjs');
|
|
322
|
+
await init();
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
preflight();
|
|
327
|
+
|
|
328
|
+
switch (command) {
|
|
329
|
+
case 'ship':
|
|
330
|
+
cmdShip(args.slice(1));
|
|
331
|
+
break;
|
|
332
|
+
case 'status':
|
|
333
|
+
cmdStatus();
|
|
334
|
+
break;
|
|
335
|
+
case 'logs':
|
|
336
|
+
cmdLogs();
|
|
337
|
+
break;
|
|
338
|
+
default:
|
|
339
|
+
die(`Unknown command: ${command}\n Run "openthrottle --help" for usage.`);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
main().catch((err) => {
|
|
344
|
+
console.error(`error: ${err.message}`);
|
|
345
|
+
process.exit(1);
|
|
346
|
+
});
|
package/init.mjs
ADDED
|
@@ -0,0 +1,479 @@
|
|
|
1
|
+
// =============================================================================
|
|
2
|
+
// openthrottle init — Set up Open Throttle in any Node.js project.
|
|
3
|
+
//
|
|
4
|
+
// Detects the project, prompts for config, generates .openthrottle.yml +
|
|
5
|
+
// wake-sandbox.yml, creates a Daytona snapshot, and prints next steps.
|
|
6
|
+
// =============================================================================
|
|
7
|
+
|
|
8
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, statSync } from 'node:fs';
|
|
9
|
+
import { join, dirname, relative } from 'node:path';
|
|
10
|
+
import { execFileSync } from 'node:child_process';
|
|
11
|
+
import { fileURLToPath } from 'node:url';
|
|
12
|
+
import prompts from 'prompts';
|
|
13
|
+
import { stringify } from 'yaml';
|
|
14
|
+
|
|
15
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
16
|
+
const cwd = process.cwd();
|
|
17
|
+
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
// 1. Detect project
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
function detectProject() {
|
|
23
|
+
const pkgPath = join(cwd, 'package.json');
|
|
24
|
+
if (!existsSync(pkgPath)) {
|
|
25
|
+
console.error('No package.json found. openthrottle init currently supports Node.js projects only.');
|
|
26
|
+
process.exit(1);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
let pkg;
|
|
30
|
+
try {
|
|
31
|
+
pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
|
|
32
|
+
} catch {
|
|
33
|
+
console.error('Could not parse package.json. Is it valid JSON?');
|
|
34
|
+
process.exit(1);
|
|
35
|
+
}
|
|
36
|
+
const scripts = pkg.scripts || {};
|
|
37
|
+
const rawName = pkg.name?.replace(/^@[^/]+\//, '') || 'project';
|
|
38
|
+
const name = rawName.toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/-+/g, '-');
|
|
39
|
+
|
|
40
|
+
// Detect package manager
|
|
41
|
+
let pm = 'npm';
|
|
42
|
+
if (pkg.packageManager?.startsWith('pnpm')) pm = 'pnpm';
|
|
43
|
+
else if (pkg.packageManager?.startsWith('yarn')) pm = 'yarn';
|
|
44
|
+
else if (existsSync(join(cwd, 'pnpm-lock.yaml'))) pm = 'pnpm';
|
|
45
|
+
else if (existsSync(join(cwd, 'yarn.lock'))) pm = 'yarn';
|
|
46
|
+
else if (existsSync(join(cwd, 'package-lock.json'))) pm = 'npm';
|
|
47
|
+
|
|
48
|
+
// Detect base branch
|
|
49
|
+
let baseBranch = 'main';
|
|
50
|
+
try {
|
|
51
|
+
const head = execFileSync('git', ['remote', 'show', 'origin'], { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] });
|
|
52
|
+
const match = head.match(/HEAD branch:\s*(\S+)/);
|
|
53
|
+
if (match) baseBranch = match[1];
|
|
54
|
+
} catch {
|
|
55
|
+
// Not a git repo or no remote — default to main
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
name,
|
|
60
|
+
pm,
|
|
61
|
+
baseBranch,
|
|
62
|
+
test: scripts.test ? `${pm} test` : '',
|
|
63
|
+
build: scripts.build ? `${pm} build` : '',
|
|
64
|
+
lint: scripts.lint ? `${pm} lint` : '',
|
|
65
|
+
format: scripts.format ? `${pm} run format` : (pkg.devDependencies?.prettier ? 'npx prettier --write .' : ''),
|
|
66
|
+
dev: scripts.dev ? `${pm} dev --port 8080 --hostname 0.0.0.0` : '',
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ---------------------------------------------------------------------------
|
|
71
|
+
// 1b. Detect .env files and extract key names
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
|
|
74
|
+
function detectEnvFiles() {
|
|
75
|
+
const envFiles = {};
|
|
76
|
+
const seen = new Set();
|
|
77
|
+
|
|
78
|
+
function scan(dir) {
|
|
79
|
+
let entries;
|
|
80
|
+
try { entries = readdirSync(dir); } catch { return; }
|
|
81
|
+
for (const entry of entries) {
|
|
82
|
+
if (entry === 'node_modules' || entry === '.git' || entry === '.next' || entry === 'dist') continue;
|
|
83
|
+
const full = join(dir, entry);
|
|
84
|
+
let stat;
|
|
85
|
+
try { stat = statSync(full); } catch { continue; }
|
|
86
|
+
if (stat.isDirectory()) { scan(full); continue; }
|
|
87
|
+
if (!entry.startsWith('.env')) continue;
|
|
88
|
+
// Skip .env.example, .env.sample, .env.template
|
|
89
|
+
if (/\.(example|sample|template)$/i.test(entry)) continue;
|
|
90
|
+
|
|
91
|
+
const relPath = relative(cwd, full);
|
|
92
|
+
const keys = [];
|
|
93
|
+
try {
|
|
94
|
+
const content = readFileSync(full, 'utf8');
|
|
95
|
+
for (const line of content.split('\n')) {
|
|
96
|
+
const trimmed = line.trim();
|
|
97
|
+
if (!trimmed || trimmed.startsWith('#')) continue;
|
|
98
|
+
const match = trimmed.replace(/^export\s+/, '').match(/^([a-zA-Z_][a-zA-Z0-9_]*)=/);
|
|
99
|
+
if (match) keys.push(match[1]);
|
|
100
|
+
}
|
|
101
|
+
} catch (err) {
|
|
102
|
+
console.error(`warning: could not read ${relPath}: ${err.message}`);
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (keys.length > 0) {
|
|
107
|
+
envFiles[relPath] = keys;
|
|
108
|
+
keys.forEach(k => seen.add(k));
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
scan(cwd);
|
|
114
|
+
return { envFiles, allKeys: [...seen].sort() };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ---------------------------------------------------------------------------
|
|
118
|
+
// 2. Prompt for config
|
|
119
|
+
// ---------------------------------------------------------------------------
|
|
120
|
+
|
|
121
|
+
async function promptConfig(detected) {
|
|
122
|
+
console.log(`\n Detected: package.json (${detected.pm})\n`);
|
|
123
|
+
|
|
124
|
+
const response = await prompts([
|
|
125
|
+
{ type: 'text', name: 'baseBranch', message: 'Base branch', initial: detected.baseBranch },
|
|
126
|
+
{ type: 'text', name: 'test', message: 'Test command', initial: detected.test },
|
|
127
|
+
{ type: 'text', name: 'build', message: 'Build command', initial: detected.build },
|
|
128
|
+
{ type: 'text', name: 'lint', message: 'Lint command', initial: detected.lint },
|
|
129
|
+
{ type: 'text', name: 'format', message: 'Format command', initial: detected.format },
|
|
130
|
+
{ type: 'text', name: 'dev', message: 'Dev command', initial: detected.dev },
|
|
131
|
+
{ type: 'text', name: 'postBootstrap', message: 'Post-bootstrap command', initial: `${detected.pm} install` },
|
|
132
|
+
{
|
|
133
|
+
type: 'select', name: 'agent', message: 'Agent runtime',
|
|
134
|
+
choices: [
|
|
135
|
+
{ title: 'Claude', value: 'claude' },
|
|
136
|
+
{ title: 'Codex', value: 'codex' },
|
|
137
|
+
{ title: 'Aider', value: 'aider' },
|
|
138
|
+
],
|
|
139
|
+
initial: 0,
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
type: 'select', name: 'notifications', message: 'Notifications',
|
|
143
|
+
choices: [
|
|
144
|
+
{ title: 'Telegram', value: 'telegram' },
|
|
145
|
+
{ title: 'None', value: 'none' },
|
|
146
|
+
],
|
|
147
|
+
initial: 0,
|
|
148
|
+
},
|
|
149
|
+
{ type: 'number', name: 'maxTurns', message: 'Max turns per agent run', initial: 200, min: 1 },
|
|
150
|
+
{ type: 'number', name: 'maxBudgetUsd', message: 'Max budget per run in USD (API only)', initial: 5, min: 0 },
|
|
151
|
+
{ type: 'confirm', name: 'reviewEnabled', message: 'Enable automated PR review?', initial: true },
|
|
152
|
+
{
|
|
153
|
+
type: (prev) => prev ? 'number' : null,
|
|
154
|
+
name: 'maxRounds', message: 'Max review rounds', initial: 3, min: 1, max: 10,
|
|
155
|
+
},
|
|
156
|
+
{ type: 'text', name: 'snapshotName', message: 'Daytona snapshot name', initial: 'openthrottle' },
|
|
157
|
+
], { onCancel: () => { console.log('\nCancelled.'); process.exit(0); } });
|
|
158
|
+
|
|
159
|
+
return { ...detected, ...response };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ---------------------------------------------------------------------------
|
|
163
|
+
// 3. Generate .openthrottle.yml
|
|
164
|
+
// ---------------------------------------------------------------------------
|
|
165
|
+
|
|
166
|
+
function generateConfig(config) {
|
|
167
|
+
const doc = {
|
|
168
|
+
base_branch: config.baseBranch,
|
|
169
|
+
test: config.test || undefined,
|
|
170
|
+
dev: config.dev || undefined,
|
|
171
|
+
format: config.format || undefined,
|
|
172
|
+
lint: config.lint || undefined,
|
|
173
|
+
build: config.build || undefined,
|
|
174
|
+
notifications: config.notifications === 'none' ? undefined : config.notifications,
|
|
175
|
+
agent: config.agent,
|
|
176
|
+
snapshot: config.snapshotName || 'openthrottle',
|
|
177
|
+
post_bootstrap: [config.postBootstrap],
|
|
178
|
+
mcp_servers: {},
|
|
179
|
+
env_files: config.envFiles && Object.keys(config.envFiles).length > 0
|
|
180
|
+
? config.envFiles
|
|
181
|
+
: undefined,
|
|
182
|
+
limits: {
|
|
183
|
+
max_turns: config.maxTurns ?? 200,
|
|
184
|
+
max_budget_usd: config.maxBudgetUsd ?? 5,
|
|
185
|
+
},
|
|
186
|
+
review: {
|
|
187
|
+
enabled: config.reviewEnabled,
|
|
188
|
+
max_rounds: config.maxRounds ?? 3,
|
|
189
|
+
},
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
// Remove undefined fields
|
|
193
|
+
for (const key of Object.keys(doc)) {
|
|
194
|
+
if (doc[key] === undefined) delete doc[key];
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const header = [
|
|
198
|
+
'# openthrottle.yml — project config for Open Throttle (Daytona runtime)',
|
|
199
|
+
'# Generated by openthrottle init. Committed to the repo so the',
|
|
200
|
+
'# sandbox knows how to work with this project.',
|
|
201
|
+
'',
|
|
202
|
+
].join('\n');
|
|
203
|
+
|
|
204
|
+
return header + stringify(doc);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// ---------------------------------------------------------------------------
|
|
208
|
+
// 4. Copy wake-sandbox.yml
|
|
209
|
+
// ---------------------------------------------------------------------------
|
|
210
|
+
|
|
211
|
+
function copyWorkflow(config) {
|
|
212
|
+
const src = join(__dirname, 'templates', 'wake-sandbox.yml');
|
|
213
|
+
const destDir = join(cwd, '.github', 'workflows');
|
|
214
|
+
const dest = join(destDir, 'wake-sandbox.yml');
|
|
215
|
+
mkdirSync(destDir, { recursive: true });
|
|
216
|
+
|
|
217
|
+
let content = readFileSync(src, 'utf8');
|
|
218
|
+
|
|
219
|
+
// Inject project-specific secrets into the workflow
|
|
220
|
+
const allKeys = config.envAllKeys || [];
|
|
221
|
+
if (allKeys.length > 0) {
|
|
222
|
+
// Add env: entries for secrets
|
|
223
|
+
const envSecrets = allKeys
|
|
224
|
+
.map(k => ` ${k}: \${{ secrets.${k} }}`)
|
|
225
|
+
.join('\n');
|
|
226
|
+
content = content.replace(
|
|
227
|
+
/ # @@ENV_SECRETS@@ — scaffolder inserts project-specific secrets here/,
|
|
228
|
+
envSecrets
|
|
229
|
+
);
|
|
230
|
+
|
|
231
|
+
// Add --env flags for daytona create
|
|
232
|
+
const envFlags = allKeys
|
|
233
|
+
.map(k => ` --env ${k}=\${${k}} \\`)
|
|
234
|
+
.join('\n');
|
|
235
|
+
content = content.replace(
|
|
236
|
+
/ # @@ENV_FLAGS@@ — scaffolder inserts --env flags for project secrets here/,
|
|
237
|
+
envFlags
|
|
238
|
+
);
|
|
239
|
+
} else {
|
|
240
|
+
// No project secrets — remove the placeholder comments
|
|
241
|
+
content = content.replace(/ # @@ENV_SECRETS@@ — scaffolder inserts project-specific secrets here\n/, '');
|
|
242
|
+
content = content.replace(/ # @@ENV_FLAGS@@ — scaffolder inserts --env flags for project secrets here\n/, '');
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
writeFileSync(dest, content);
|
|
246
|
+
return dest;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// ---------------------------------------------------------------------------
|
|
250
|
+
// 5. Create Daytona snapshot from pre-built image
|
|
251
|
+
// ---------------------------------------------------------------------------
|
|
252
|
+
|
|
253
|
+
function setupDaytona(config) {
|
|
254
|
+
const snapshotName = config.snapshotName || 'openthrottle';
|
|
255
|
+
const image = 'knoxgraeme/openthrottle:v1';
|
|
256
|
+
|
|
257
|
+
// Check daytona CLI is available
|
|
258
|
+
try {
|
|
259
|
+
execFileSync('daytona', ['--version'], { stdio: 'pipe' });
|
|
260
|
+
} catch {
|
|
261
|
+
console.log(`\n daytona CLI not found. Install it, then run:`);
|
|
262
|
+
console.log(` daytona snapshot create ${snapshotName} --image ${image} --cpu 2 --memory 4 --disk 10\n`);
|
|
263
|
+
return { snapshotName, skipped: true };
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Create snapshot from pre-built image
|
|
267
|
+
try {
|
|
268
|
+
execFileSync('daytona', [
|
|
269
|
+
'snapshot', 'create', snapshotName,
|
|
270
|
+
'--image', image,
|
|
271
|
+
'--cpu', '2', '--memory', '4', '--disk', '10',
|
|
272
|
+
], { stdio: ['pipe', 'pipe', 'pipe'], encoding: 'utf8' });
|
|
273
|
+
console.log(` Created Daytona snapshot: ${snapshotName}`);
|
|
274
|
+
} catch (err) {
|
|
275
|
+
if (err.stderr?.toString().includes('already exists')) {
|
|
276
|
+
console.log(` Snapshot already exists: ${snapshotName}`);
|
|
277
|
+
} else {
|
|
278
|
+
console.log(` Snapshot creation failed. You can create it manually:`);
|
|
279
|
+
console.log(` daytona snapshot create ${snapshotName} --image ${image} --cpu 2 --memory 4 --disk 10`);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
return { snapshotName };
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// ---------------------------------------------------------------------------
|
|
287
|
+
// 6. Push .env secrets to GitHub repo secrets
|
|
288
|
+
// ---------------------------------------------------------------------------
|
|
289
|
+
|
|
290
|
+
async function pushSecrets(config) {
|
|
291
|
+
const envFiles = config.envFiles || {};
|
|
292
|
+
const paths = Object.keys(envFiles);
|
|
293
|
+
if (paths.length === 0) return;
|
|
294
|
+
|
|
295
|
+
// Check gh is available and authenticated
|
|
296
|
+
try {
|
|
297
|
+
execFileSync('gh', ['auth', 'status'], { stdio: 'pipe' });
|
|
298
|
+
} catch {
|
|
299
|
+
console.log('\n gh CLI not authenticated — skipping secret push.');
|
|
300
|
+
console.log(' Run "gh auth login" then set secrets manually.\n');
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Detect repo
|
|
305
|
+
let repo;
|
|
306
|
+
try {
|
|
307
|
+
repo = execFileSync('gh', ['repo', 'view', '--json', 'nameWithOwner', '--jq', '.nameWithOwner'], {
|
|
308
|
+
encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'],
|
|
309
|
+
}).trim();
|
|
310
|
+
} catch {
|
|
311
|
+
console.log('\n Could not detect GitHub repo — skipping secret push.\n');
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// Show what we'd push
|
|
316
|
+
console.log(`\n Push .env secrets to GitHub repo secrets? (${repo})`);
|
|
317
|
+
console.log(' Values are encrypted at rest — not readable after upload.\n');
|
|
318
|
+
for (const [path, keys] of Object.entries(envFiles)) {
|
|
319
|
+
console.log(` ${path} (${keys.length} keys): ${keys.join(', ')}`);
|
|
320
|
+
}
|
|
321
|
+
console.log('');
|
|
322
|
+
|
|
323
|
+
const { confirm } = await prompts({
|
|
324
|
+
type: 'confirm', name: 'confirm',
|
|
325
|
+
message: `Push ${config.envAllKeys.length} secret(s) to ${repo}?`, initial: false,
|
|
326
|
+
}, { onCancel: () => {} });
|
|
327
|
+
|
|
328
|
+
if (!confirm) {
|
|
329
|
+
console.log(' Skipped secret push.');
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
let pushed = 0;
|
|
334
|
+
let failed = 0;
|
|
335
|
+
for (const [path, keys] of Object.entries(envFiles)) {
|
|
336
|
+
const fullPath = join(cwd, path);
|
|
337
|
+
let content;
|
|
338
|
+
try {
|
|
339
|
+
content = readFileSync(fullPath, 'utf8');
|
|
340
|
+
} catch {
|
|
341
|
+
console.log(` Could not read ${path} — skipping`);
|
|
342
|
+
failed += keys.length;
|
|
343
|
+
continue;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// Parse key=value pairs
|
|
347
|
+
for (const line of content.split('\n')) {
|
|
348
|
+
const trimmed = line.trim();
|
|
349
|
+
if (!trimmed || trimmed.startsWith('#')) continue;
|
|
350
|
+
const cleaned = trimmed.replace(/^export\s+/, '');
|
|
351
|
+
const eqIdx = cleaned.indexOf('=');
|
|
352
|
+
if (eqIdx === -1) continue;
|
|
353
|
+
const key = cleaned.slice(0, eqIdx);
|
|
354
|
+
let value = cleaned.slice(eqIdx + 1);
|
|
355
|
+
// Strip surrounding quotes
|
|
356
|
+
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
|
357
|
+
value = value.slice(1, -1);
|
|
358
|
+
}
|
|
359
|
+
if (!keys.includes(key)) continue;
|
|
360
|
+
|
|
361
|
+
try {
|
|
362
|
+
execFileSync('gh', ['secret', 'set', key, '--repo', repo], {
|
|
363
|
+
input: value,
|
|
364
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
365
|
+
});
|
|
366
|
+
pushed++;
|
|
367
|
+
} catch (err) {
|
|
368
|
+
const stderr = err.stderr?.toString().trim() || err.message;
|
|
369
|
+
console.log(` Failed to set ${key}: ${stderr}`);
|
|
370
|
+
failed++;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
console.log(` Pushed ${pushed} secret(s) to ${repo}${failed > 0 ? ` (${failed} failed)` : ''}`);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// ---------------------------------------------------------------------------
|
|
379
|
+
// 7. Print next steps
|
|
380
|
+
// ---------------------------------------------------------------------------
|
|
381
|
+
|
|
382
|
+
function printNextSteps(config) {
|
|
383
|
+
const agentSecret =
|
|
384
|
+
config.agent === 'claude'
|
|
385
|
+
? ' ANTHROPIC_API_KEY <- option a: pay-per-use API key\n CLAUDE_CODE_OAUTH_TOKEN <- option b: subscription token (claude setup-token)'
|
|
386
|
+
: config.agent === 'codex'
|
|
387
|
+
? ' OPENAI_API_KEY <- required for Codex'
|
|
388
|
+
: ' OPENAI_API_KEY <- or ANTHROPIC_API_KEY (depends on your Aider model)';
|
|
389
|
+
const secrets = [
|
|
390
|
+
' DAYTONA_API_KEY <- required',
|
|
391
|
+
agentSecret,
|
|
392
|
+
];
|
|
393
|
+
|
|
394
|
+
// Project-specific secrets from env_files
|
|
395
|
+
const projectKeys = config.envAllKeys || [];
|
|
396
|
+
const projectSecrets = projectKeys.length > 0
|
|
397
|
+
? '\n\n Project secrets (from .env files):\n' +
|
|
398
|
+
projectKeys.map(k => ` ${k}`).join('\n')
|
|
399
|
+
: '';
|
|
400
|
+
|
|
401
|
+
console.log(`
|
|
402
|
+
Next steps:
|
|
403
|
+
|
|
404
|
+
1. Set GitHub repo secrets:
|
|
405
|
+
${secrets.join('\n')}
|
|
406
|
+
TELEGRAM_BOT_TOKEN <- optional (notifications)
|
|
407
|
+
TELEGRAM_CHAT_ID <- optional (notifications)${projectSecrets}
|
|
408
|
+
|
|
409
|
+
2. Commit and push:
|
|
410
|
+
git add .openthrottle.yml .github/workflows/wake-sandbox.yml
|
|
411
|
+
git commit -m "feat: add openthrottle config"
|
|
412
|
+
git push
|
|
413
|
+
|
|
414
|
+
3. Ship your first prompt:
|
|
415
|
+
npx openthrottle ship docs/prds/my-feature.md
|
|
416
|
+
`);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// ---------------------------------------------------------------------------
|
|
420
|
+
// Main (exported for use by index.mjs)
|
|
421
|
+
// ---------------------------------------------------------------------------
|
|
422
|
+
|
|
423
|
+
export default async function init() {
|
|
424
|
+
console.log('\n openthrottle init\n');
|
|
425
|
+
|
|
426
|
+
// Step 1: Detect
|
|
427
|
+
const detected = detectProject();
|
|
428
|
+
const { envFiles, allKeys: envAllKeys } = detectEnvFiles();
|
|
429
|
+
|
|
430
|
+
if (Object.keys(envFiles).length > 0) {
|
|
431
|
+
console.log(` Found ${Object.keys(envFiles).length} .env file(s):`);
|
|
432
|
+
for (const [path, keys] of Object.entries(envFiles)) {
|
|
433
|
+
console.log(` ${path} (${keys.length} keys)`);
|
|
434
|
+
}
|
|
435
|
+
console.log('');
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// Step 2: Prompt
|
|
439
|
+
const config = await promptConfig(detected);
|
|
440
|
+
config.envFiles = envFiles;
|
|
441
|
+
config.envAllKeys = envAllKeys;
|
|
442
|
+
|
|
443
|
+
// Step 3: Generate config
|
|
444
|
+
const configPath = join(cwd, '.openthrottle.yml');
|
|
445
|
+
if (existsSync(configPath)) {
|
|
446
|
+
const { overwrite } = await prompts({
|
|
447
|
+
type: 'confirm', name: 'overwrite',
|
|
448
|
+
message: '.openthrottle.yml already exists. Overwrite?', initial: false,
|
|
449
|
+
}, { onCancel: () => { console.log('\nCancelled.'); process.exit(0); } });
|
|
450
|
+
if (!overwrite) { console.log(' Skipped .openthrottle.yml'); }
|
|
451
|
+
else { writeFileSync(configPath, generateConfig(config)); console.log(' Generated .openthrottle.yml'); }
|
|
452
|
+
} else {
|
|
453
|
+
writeFileSync(configPath, generateConfig(config));
|
|
454
|
+
console.log(' Generated .openthrottle.yml');
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// Step 4: Copy workflow
|
|
458
|
+
const workflowPath = join(cwd, '.github', 'workflows', 'wake-sandbox.yml');
|
|
459
|
+
if (existsSync(workflowPath)) {
|
|
460
|
+
const { overwrite } = await prompts({
|
|
461
|
+
type: 'confirm', name: 'overwrite',
|
|
462
|
+
message: 'wake-sandbox.yml already exists. Overwrite?', initial: false,
|
|
463
|
+
}, { onCancel: () => { console.log('\nCancelled.'); process.exit(0); } });
|
|
464
|
+
if (!overwrite) { console.log(' Skipped wake-sandbox.yml'); }
|
|
465
|
+
else { copyWorkflow(config); console.log(' Copied .github/workflows/wake-sandbox.yml'); }
|
|
466
|
+
} else {
|
|
467
|
+
copyWorkflow(config);
|
|
468
|
+
console.log(' Copied .github/workflows/wake-sandbox.yml');
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// Step 5: Create Daytona snapshot
|
|
472
|
+
setupDaytona(config);
|
|
473
|
+
|
|
474
|
+
// Step 6: Push secrets
|
|
475
|
+
await pushSecrets(config);
|
|
476
|
+
|
|
477
|
+
// Step 7: Next steps
|
|
478
|
+
printNextSteps(config);
|
|
479
|
+
}
|
package/package.json
CHANGED
|
@@ -1,15 +1,32 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openthrottle",
|
|
3
|
-
"version": "0.0
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "CLI for Open Throttle — ship prompts to Daytona sandboxes.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"openthrottle": "./index.mjs"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"index.mjs",
|
|
11
|
+
"init.mjs",
|
|
12
|
+
"templates/"
|
|
13
|
+
],
|
|
14
|
+
"dependencies": {
|
|
15
|
+
"prompts": "^2.4.2",
|
|
16
|
+
"yaml": "^2.4.0"
|
|
17
|
+
},
|
|
18
|
+
"engines": {
|
|
19
|
+
"node": ">=18"
|
|
20
|
+
},
|
|
5
21
|
"license": "MIT",
|
|
6
22
|
"repository": {
|
|
7
23
|
"type": "git",
|
|
8
|
-
"url": "https://github.com/knoxgraeme/
|
|
24
|
+
"url": "https://github.com/knoxgraeme/openthrottle"
|
|
9
25
|
},
|
|
10
26
|
"keywords": [
|
|
11
27
|
"openthrottle",
|
|
12
28
|
"daytona",
|
|
13
|
-
"agent"
|
|
29
|
+
"agent",
|
|
30
|
+
"cli"
|
|
14
31
|
]
|
|
15
32
|
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
# Wake Daytona sandbox when work arrives on GitHub.
|
|
2
|
+
#
|
|
3
|
+
# Triggers:
|
|
4
|
+
# - Issue labeled prd-queued or bug-queued → builder sandbox
|
|
5
|
+
# - PR labeled needs-review → reviewer sandbox
|
|
6
|
+
# - PR review with changes_requested → review-fix sandbox
|
|
7
|
+
#
|
|
8
|
+
# Each trigger creates a fresh ephemeral sandbox — no polling, no long-lived state.
|
|
9
|
+
# Multiple triggers fire in parallel (one sandbox per task).
|
|
10
|
+
#
|
|
11
|
+
# Required repository secrets:
|
|
12
|
+
# DAYTONA_API_KEY — API key from daytona.io
|
|
13
|
+
# ANTHROPIC_API_KEY — (option a) or
|
|
14
|
+
# CLAUDE_CODE_OAUTH_TOKEN — (option b) for Claude Code auth
|
|
15
|
+
# TELEGRAM_BOT_TOKEN — optional, for notifications
|
|
16
|
+
# TELEGRAM_CHAT_ID — optional, for notifications
|
|
17
|
+
# SUPABASE_ACCESS_TOKEN — optional, for Supabase MCP
|
|
18
|
+
|
|
19
|
+
name: Wake Sandbox
|
|
20
|
+
|
|
21
|
+
on:
|
|
22
|
+
issues:
|
|
23
|
+
types: [labeled]
|
|
24
|
+
pull_request:
|
|
25
|
+
types: [labeled]
|
|
26
|
+
pull_request_review:
|
|
27
|
+
types: [submitted]
|
|
28
|
+
|
|
29
|
+
concurrency:
|
|
30
|
+
group: openthrottle-${{ github.event.issue.number || github.event.pull_request.number }}
|
|
31
|
+
cancel-in-progress: false
|
|
32
|
+
|
|
33
|
+
permissions:
|
|
34
|
+
contents: read
|
|
35
|
+
issues: write
|
|
36
|
+
pull-requests: write
|
|
37
|
+
|
|
38
|
+
jobs:
|
|
39
|
+
run-task:
|
|
40
|
+
if: >-
|
|
41
|
+
contains(fromJSON('["prd-queued", "bug-queued", "needs-review", "needs-investigation"]'), github.event.label.name) ||
|
|
42
|
+
(github.event.review.state == 'changes_requested')
|
|
43
|
+
runs-on: ubuntu-latest
|
|
44
|
+
steps:
|
|
45
|
+
- uses: actions/checkout@v4
|
|
46
|
+
|
|
47
|
+
- name: Resolve work item
|
|
48
|
+
id: work
|
|
49
|
+
env:
|
|
50
|
+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
51
|
+
EVENT_LABEL: ${{ github.event.label.name }}
|
|
52
|
+
EVENT_REVIEW_STATE: ${{ github.event.review.state }}
|
|
53
|
+
EVENT_ISSUE_NUM: ${{ github.event.issue.number }}
|
|
54
|
+
EVENT_PR_NUM: ${{ github.event.pull_request.number }}
|
|
55
|
+
run: |
|
|
56
|
+
# Determine work item number
|
|
57
|
+
WORK_ITEM="${EVENT_ISSUE_NUM}"
|
|
58
|
+
if [[ -z "$WORK_ITEM" ]]; then
|
|
59
|
+
WORK_ITEM="${EVENT_PR_NUM}"
|
|
60
|
+
fi
|
|
61
|
+
if [[ -z "$WORK_ITEM" ]]; then
|
|
62
|
+
echo "::error::Could not determine work item number from event payload"
|
|
63
|
+
exit 1
|
|
64
|
+
fi
|
|
65
|
+
echo "item=$WORK_ITEM" >> "$GITHUB_OUTPUT"
|
|
66
|
+
|
|
67
|
+
# Determine task type (using env vars, not inline ${{ }} expressions)
|
|
68
|
+
TASK_TYPE="prd"
|
|
69
|
+
if [[ "$EVENT_LABEL" == "bug-queued" ]]; then
|
|
70
|
+
TASK_TYPE="bug"
|
|
71
|
+
elif [[ "$EVENT_LABEL" == "needs-review" ]]; then
|
|
72
|
+
TASK_TYPE="review"
|
|
73
|
+
elif [[ "$EVENT_LABEL" == "needs-investigation" ]]; then
|
|
74
|
+
TASK_TYPE="investigation"
|
|
75
|
+
elif [[ "$EVENT_REVIEW_STATE" == "changes_requested" ]]; then
|
|
76
|
+
TASK_TYPE="review-fix"
|
|
77
|
+
fi
|
|
78
|
+
echo "task_type=$TASK_TYPE" >> "$GITHUB_OUTPUT"
|
|
79
|
+
|
|
80
|
+
# Extract session ID for review fixes
|
|
81
|
+
RESUME_SESSION=""
|
|
82
|
+
if [[ "$TASK_TYPE" == "review-fix" ]]; then
|
|
83
|
+
RESUME_SESSION=$(gh pr view "$EVENT_PR_NUM" --json comments \
|
|
84
|
+
--jq '[.comments[].body | capture("session-id: (?<id>[^ ]+)") | .id] | last // empty') || true
|
|
85
|
+
fi
|
|
86
|
+
echo "resume_session=$RESUME_SESSION" >> "$GITHUB_OUTPUT"
|
|
87
|
+
|
|
88
|
+
- name: Validate config
|
|
89
|
+
id: config
|
|
90
|
+
run: |
|
|
91
|
+
SNAPSHOT=$(yq '.snapshot // ""' .openthrottle.yml)
|
|
92
|
+
if [[ -z "$SNAPSHOT" || "$SNAPSHOT" == "null" ]]; then
|
|
93
|
+
echo "::error::Missing 'snapshot' key in .openthrottle.yml — cannot create sandbox"
|
|
94
|
+
exit 1
|
|
95
|
+
fi
|
|
96
|
+
echo "snapshot=$SNAPSHOT" >> "$GITHUB_OUTPUT"
|
|
97
|
+
|
|
98
|
+
- name: Activate snapshot (reactivates if idle >2 weeks)
|
|
99
|
+
env:
|
|
100
|
+
DAYTONA_API_KEY: ${{ secrets.DAYTONA_API_KEY }}
|
|
101
|
+
run: |
|
|
102
|
+
daytona snapshot activate "${{ steps.config.outputs.snapshot }}" 2>/dev/null || true
|
|
103
|
+
|
|
104
|
+
- name: Create and run sandbox
|
|
105
|
+
env:
|
|
106
|
+
DAYTONA_API_KEY: ${{ secrets.DAYTONA_API_KEY }}
|
|
107
|
+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
108
|
+
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
|
109
|
+
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
|
110
|
+
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
|
111
|
+
SUPABASE_ACCESS_TOKEN: ${{ secrets.SUPABASE_ACCESS_TOKEN }}
|
|
112
|
+
# @@ENV_SECRETS@@ — scaffolder inserts project-specific secrets here
|
|
113
|
+
run: |
|
|
114
|
+
# Create ephemeral sandbox
|
|
115
|
+
OUTPUT=$(daytona create \
|
|
116
|
+
--snapshot "${{ steps.config.outputs.snapshot }}" \
|
|
117
|
+
--auto-delete 0 \
|
|
118
|
+
--auto-stop 60 \
|
|
119
|
+
--cpu 2 --memory 4096 --disk 10 \
|
|
120
|
+
--label project=${{ github.event.repository.name }} \
|
|
121
|
+
--label task_type="${{ steps.work.outputs.task_type }}" \
|
|
122
|
+
--label issue="${{ steps.work.outputs.item }}" \
|
|
123
|
+
--volume openthrottle-${{ github.repository_id }}:/home/daytona/.claude \
|
|
124
|
+
--env GITHUB_TOKEN=${{ secrets.GITHUB_TOKEN }} \
|
|
125
|
+
--env GITHUB_REPO=${{ github.repository }} \
|
|
126
|
+
--env ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY} \
|
|
127
|
+
--env CLAUDE_CODE_OAUTH_TOKEN=${CLAUDE_CODE_OAUTH_TOKEN} \
|
|
128
|
+
--env OPENAI_API_KEY=${OPENAI_API_KEY} \
|
|
129
|
+
--env SUPABASE_ACCESS_TOKEN=${SUPABASE_ACCESS_TOKEN} \
|
|
130
|
+
--env TELEGRAM_BOT_TOKEN=${{ secrets.TELEGRAM_BOT_TOKEN }} \
|
|
131
|
+
--env TELEGRAM_CHAT_ID=${{ secrets.TELEGRAM_CHAT_ID }} \
|
|
132
|
+
--env WORK_ITEM="${{ steps.work.outputs.item }}" \
|
|
133
|
+
--env TASK_TYPE="${{ steps.work.outputs.task_type }}" \
|
|
134
|
+
--env RESUME_SESSION="${{ steps.work.outputs.resume_session }}" \
|
|
135
|
+
# @@ENV_FLAGS@@ — scaffolder inserts --env flags for project secrets here
|
|
136
|
+
2>&1) || {
|
|
137
|
+
# Redact secrets from error output
|
|
138
|
+
SAFE_OUTPUT=$(echo "$OUTPUT" | sed \
|
|
139
|
+
-e "s/${ANTHROPIC_API_KEY:-___}/[REDACTED]/g" \
|
|
140
|
+
-e "s/${CLAUDE_CODE_OAUTH_TOKEN:-___}/[REDACTED]/g" \
|
|
141
|
+
-e "s/${OPENAI_API_KEY:-___}/[REDACTED]/g" \
|
|
142
|
+
-e "s/${SUPABASE_ACCESS_TOKEN:-___}/[REDACTED]/g" \
|
|
143
|
+
-e "s/${GH_TOKEN:-___}/[REDACTED]/g")
|
|
144
|
+
echo "::error::Sandbox creation failed: $SAFE_OUTPUT"
|
|
145
|
+
exit 1
|
|
146
|
+
}
|
|
147
|
+
SANDBOX_ID="$OUTPUT"
|
|
148
|
+
|
|
149
|
+
echo "Sandbox created: $SANDBOX_ID"
|
|
150
|
+
echo "Task: ${{ steps.work.outputs.task_type }} #${{ steps.work.outputs.item }}"
|