@alien-protocol/cannon 2.2.2
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/.env.example +12 -0
- package/README.md +395 -0
- package/bin/cli.js +410 -0
- package/cannon.config.example.json +17 -0
- package/package.json +72 -0
- package/src/auth.js +188 -0
- package/src/cannon.js +401 -0
- package/src/config.js +131 -0
- package/src/github.js +167 -0
- package/src/index.js +5 -0
- package/src/loaders/csv.js +70 -0
- package/src/loaders/docx.js +45 -0
- package/src/loaders/index.js +51 -0
- package/src/loaders/json.js +10 -0
- package/src/loaders/mysql.js +30 -0
- package/src/loaders/pdf.js +62 -0
- package/src/loaders/postgres.js +32 -0
- package/src/loaders/sqlite.js +32 -0
package/src/cannon.js
ADDED
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { loadConfig } from './config.js';
|
|
4
|
+
import { loadIssues } from './loaders/index.js';
|
|
5
|
+
import { createIssue, verifyToken, ensureLabel } from './github.js';
|
|
6
|
+
|
|
7
|
+
// ── ANSI colours ──────────────────────────────────────────────────
|
|
8
|
+
const c = {
|
|
9
|
+
reset: '\x1b[0m',
|
|
10
|
+
bold: '\x1b[1m',
|
|
11
|
+
dim: '\x1b[2m',
|
|
12
|
+
green: '\x1b[32m',
|
|
13
|
+
yellow: '\x1b[33m',
|
|
14
|
+
red: '\x1b[31m',
|
|
15
|
+
cyan: '\x1b[36m',
|
|
16
|
+
magenta: '\x1b[35m',
|
|
17
|
+
blue: '\x1b[34m',
|
|
18
|
+
white: '\x1b[37m',
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const log = {
|
|
22
|
+
info: (m) => console.log(`${c.cyan}ℹ${c.reset} ${m}`),
|
|
23
|
+
success: (m) => console.log(`${c.green}✔${c.reset} ${m}`),
|
|
24
|
+
warn: (m) => console.log(`${c.yellow}⚠${c.reset} ${m}`),
|
|
25
|
+
error: (m) => console.log(`${c.red}✖${c.reset} ${m}`),
|
|
26
|
+
step: (m) => console.log(`\n${c.bold}${c.blue}${m}${c.reset}`),
|
|
27
|
+
dim: (m) => console.log(` ${c.dim}${m}${c.reset}`),
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export class IssueCannon {
|
|
31
|
+
/**
|
|
32
|
+
* @param {object} options — programmatic overrides (all optional)
|
|
33
|
+
* @param {string} [options.token]
|
|
34
|
+
* @param {boolean} [options.dryRun]
|
|
35
|
+
* @param {boolean} [options.safeMode] — true = random delays (default)
|
|
36
|
+
* @param {boolean} [options.resumable]
|
|
37
|
+
* @param {object} [options.delay] — { mode, minMs, maxMs, fixedMs }
|
|
38
|
+
* @param {boolean} [options.silent] — suppress all console output
|
|
39
|
+
*/
|
|
40
|
+
constructor(options = {}) {
|
|
41
|
+
this.config = loadConfig(options);
|
|
42
|
+
this.silent = options.silent ?? false;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async fire(sourceOpts = {}) {
|
|
46
|
+
const { config } = this;
|
|
47
|
+
|
|
48
|
+
// ── Auth check ────────────────────────────────────────────────
|
|
49
|
+
if (!config.github.token) {
|
|
50
|
+
log.error('No GitHub token found. To authenticate:\n');
|
|
51
|
+
log.info(` ${c.bold}Option A — OAuth login (recommended, no token needed):${c.reset}`);
|
|
52
|
+
log.info(` cannon auth login\n`);
|
|
53
|
+
log.info(` ${c.bold}Option B — Personal Access Token:${c.reset}`);
|
|
54
|
+
log.info(` echo "GITHUB_TOKEN=ghp_xxx" > .env`);
|
|
55
|
+
log.info(` Create token: https://github.com/settings/tokens/new`);
|
|
56
|
+
log.info(` Required scopes: repo (or public_repo for public repos only)\n`);
|
|
57
|
+
throw new Error('Missing GitHub token');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ── Resolve source from config if not passed programmatically ─
|
|
61
|
+
const effectiveSource = {
|
|
62
|
+
source: config.source?.type,
|
|
63
|
+
file: config.source?.file,
|
|
64
|
+
query: config.source?.query,
|
|
65
|
+
connectionString: config.source?.connectionString,
|
|
66
|
+
...sourceOpts, // programmatic overrides still win
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
// ── Mode banner ───────────────────────────────────────────────
|
|
70
|
+
if (config.mode.dryRun) {
|
|
71
|
+
this._log('warn', `${c.yellow}${c.bold}DRY RUN MODE${c.reset} — no issues will be created`);
|
|
72
|
+
}
|
|
73
|
+
if (!config.mode.safeMode && !config.mode.dryRun) {
|
|
74
|
+
this._log(
|
|
75
|
+
'warn',
|
|
76
|
+
`${c.red}${c.bold}UNSAFE MODE${c.reset} — issues will fire with NO delays (risk of GitHub spam flag)`
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ── Load issues ───────────────────────────────────────────────
|
|
81
|
+
this._log('step', '📦 Loading issues…');
|
|
82
|
+
const issues = await loadIssues(effectiveSource);
|
|
83
|
+
if (!issues.length) throw new Error('No issues loaded from source');
|
|
84
|
+
this._log(
|
|
85
|
+
'info',
|
|
86
|
+
`Loaded ${c.bold}${issues.length}${c.reset} issue(s) from ${c.cyan}${effectiveSource.source}${c.reset}`
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
const repoMap = issues.reduce((a, r) => {
|
|
90
|
+
a[r.repo] = (a[r.repo] || 0) + 1;
|
|
91
|
+
return a;
|
|
92
|
+
}, {});
|
|
93
|
+
for (const [repo, n] of Object.entries(repoMap)) {
|
|
94
|
+
this._log('dim', `${c.blue}${repo}${c.reset} → ${n} issue(s)`);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ── Verify token access per repo ──────────────────────────────
|
|
98
|
+
this._log('step', '🔑 Verifying GitHub access…');
|
|
99
|
+
const badRepos = new Set();
|
|
100
|
+
const goodRepos = new Set();
|
|
101
|
+
|
|
102
|
+
for (const repo of Object.keys(repoMap)) {
|
|
103
|
+
const status = await verifyToken(repo, config.github.token);
|
|
104
|
+
if (status) {
|
|
105
|
+
const reason =
|
|
106
|
+
status === 401
|
|
107
|
+
? 'token invalid or expired'
|
|
108
|
+
: status === 403
|
|
109
|
+
? 'token lacks required scope (need: repo)'
|
|
110
|
+
: status === 404
|
|
111
|
+
? 'repo not found or no permission'
|
|
112
|
+
: `HTTP ${status}`;
|
|
113
|
+
log.warn(`Skipping ${c.blue}${repo}${c.reset} ${c.dim}(${reason})${c.reset}`);
|
|
114
|
+
badRepos.add(repo);
|
|
115
|
+
} else {
|
|
116
|
+
this._log('success', repo);
|
|
117
|
+
goodRepos.add(repo);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (goodRepos.size === 0) {
|
|
122
|
+
log.error('No accessible repos. Check repo names and token permissions.');
|
|
123
|
+
log.info(`Run: ${c.bold}cannon auth login${c.reset} or set GITHUB_TOKEN in .env`);
|
|
124
|
+
throw new Error('No accessible repos');
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ── Auto-create labels if configured ──────────────────────────
|
|
128
|
+
if (config.labels?.autoCreate && Object.keys(config.labels?.colorMap || {}).length) {
|
|
129
|
+
this._log('step', '🏷 Auto-creating labels…');
|
|
130
|
+
for (const repo of goodRepos) {
|
|
131
|
+
for (const [label, color] of Object.entries(config.labels.colorMap)) {
|
|
132
|
+
await ensureLabel(repo, label, color, config.github.token);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ── Resume state ──────────────────────────────────────────────
|
|
138
|
+
const state = config.mode.resumable ? this._loadState() : { completed: [], failed: [] };
|
|
139
|
+
const done = new Set(state.completed);
|
|
140
|
+
if (done.size) this._log('warn', `Resuming — ${done.size} issue(s) already created, skipping`);
|
|
141
|
+
|
|
142
|
+
const pending = issues.filter((r) => !done.has(r.title) && !badRepos.has(r.repo));
|
|
143
|
+
|
|
144
|
+
// Pre-fail bad-repo issues
|
|
145
|
+
const results_prefail = [];
|
|
146
|
+
issues
|
|
147
|
+
.filter((r) => badRepos.has(r.repo))
|
|
148
|
+
.forEach((r) => {
|
|
149
|
+
const err = 'repo not found or no permission';
|
|
150
|
+
results_prefail.push({ repo: r.repo, title: r.title, error: err });
|
|
151
|
+
state.failed.push({ repo: r.repo, title: r.title, error: err });
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
if (!pending.length) {
|
|
155
|
+
this._log('success', 'All issues already created!');
|
|
156
|
+
return { created: [], failed: results_prefail };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ── Delay / timing info ───────────────────────────────────────
|
|
160
|
+
const safeMode = config.mode.safeMode;
|
|
161
|
+
let estLabel, estMin;
|
|
162
|
+
|
|
163
|
+
if (!safeMode) {
|
|
164
|
+
estLabel = `${c.red}${c.bold}IMMEDIATE${c.reset} (no delays — unsafe mode)`;
|
|
165
|
+
estMin = 0;
|
|
166
|
+
} else {
|
|
167
|
+
const mid = (config.delay.minMs + config.delay.maxMs) / 2;
|
|
168
|
+
const delayMs = config.delay.mode === 'fixed' ? config.delay.fixedMs : mid;
|
|
169
|
+
const totalMs = pending.length * delayMs;
|
|
170
|
+
estMin = Math.ceil(totalMs / 60_000);
|
|
171
|
+
const delayStr =
|
|
172
|
+
config.delay.mode === 'fixed'
|
|
173
|
+
? `${fmtDelay(config.delay.fixedMs)} fixed`
|
|
174
|
+
: `${fmtDelay(config.delay.minMs)}–${fmtDelay(config.delay.maxMs)} random`;
|
|
175
|
+
estLabel = `${c.yellow}${delayStr}${c.reset} · Est. total: ${c.yellow}~${estMin > 0 ? estMin + ' min' : Math.round(totalMs / 1000) + 's'}${c.reset}`;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
this._log('info', `To create: ${c.bold}${pending.length}${c.reset} · Delay: ${estLabel}\n`);
|
|
179
|
+
this._log('step', '🚀 Creating issues…\n');
|
|
180
|
+
|
|
181
|
+
const startTime = Date.now();
|
|
182
|
+
const results = { created: [], failed: [] };
|
|
183
|
+
|
|
184
|
+
// ── Progress bar ──────────────────────────────────────────────
|
|
185
|
+
const W = 36;
|
|
186
|
+
|
|
187
|
+
const drawBar = (done, total, status = '') => {
|
|
188
|
+
if (this.silent) return;
|
|
189
|
+
const f = Math.round((done / total) * W);
|
|
190
|
+
const bar = `${c.green}${'█'.repeat(f)}${c.dim}${'░'.repeat(W - f)}${c.reset}`;
|
|
191
|
+
const pct = String(Math.round((done / total) * 100)).padStart(3) + '%';
|
|
192
|
+
const cnt = `${c.bold}${done}/${total}${c.reset}`;
|
|
193
|
+
const stat = status ? ` ${status}` : '';
|
|
194
|
+
const padding = ' '.repeat(
|
|
195
|
+
Math.max(0, 30 - (status || '').replace(/\x1b\[[\d;]*m/g, '').length)
|
|
196
|
+
);
|
|
197
|
+
process.stdout.write(`\x1b[u\x1b[2K ${bar} ${pct} ${cnt}${stat}${padding}\x1b[1B\r`);
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
if (!this.silent) process.stdout.write(`\x1b[s\n`);
|
|
201
|
+
|
|
202
|
+
for (let i = 0; i < pending.length; i++) {
|
|
203
|
+
const issue = pending[i];
|
|
204
|
+
drawBar(
|
|
205
|
+
i,
|
|
206
|
+
pending.length,
|
|
207
|
+
`${c.yellow}creating…${c.reset} ${c.dim}${issue.title.slice(0, 35)}${c.reset}`
|
|
208
|
+
);
|
|
209
|
+
|
|
210
|
+
try {
|
|
211
|
+
const created = await createIssue(issue, config.github.token, config.mode.dryRun);
|
|
212
|
+
results.created.push({
|
|
213
|
+
repo: issue.repo,
|
|
214
|
+
title: issue.title,
|
|
215
|
+
url: created.html_url,
|
|
216
|
+
number: created.number,
|
|
217
|
+
});
|
|
218
|
+
state.completed.push(issue.title);
|
|
219
|
+
if (config.mode.resumable) this._saveState(state);
|
|
220
|
+
|
|
221
|
+
drawBar(i + 1, pending.length);
|
|
222
|
+
if (!this.silent)
|
|
223
|
+
process.stdout.write(
|
|
224
|
+
`\x1b[2K ${c.green}✔${c.reset} ${c.dim}#${created.number ?? i + 1}${c.reset} ` +
|
|
225
|
+
`${issue.title.slice(0, 48).padEnd(48)} ${c.dim}${created.html_url}${c.reset}\n`
|
|
226
|
+
);
|
|
227
|
+
} catch (err) {
|
|
228
|
+
drawBar(i + 1, pending.length, `${c.red}failed${c.reset}`);
|
|
229
|
+
if (!this.silent)
|
|
230
|
+
process.stdout.write(
|
|
231
|
+
`\x1b[2K ${c.red}✖${c.reset} ${issue.title.slice(0, 48).padEnd(48)} ` +
|
|
232
|
+
`${c.dim}${err.message.startsWith('DUPLICATE') ? 'already exists (skipped)' : err.message.slice(0, 40)}${c.reset}\n`
|
|
233
|
+
);
|
|
234
|
+
results.failed.push({ repo: issue.repo, title: issue.title, error: err.message });
|
|
235
|
+
state.failed.push({ repo: issue.repo, title: issue.title, error: err.message });
|
|
236
|
+
if (config.mode.resumable) this._saveState(state);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// ── Delay between issues ──────────────────────────────────
|
|
240
|
+
if (i < pending.length - 1) {
|
|
241
|
+
// Never sleep during a dry run / preview — it's pointless
|
|
242
|
+
if (safeMode && !config.mode.dryRun) {
|
|
243
|
+
const delay = this._pickDelay();
|
|
244
|
+
await liveCountdown(Math.round(delay / 1000), pending.length, i + 1, delay, drawBar);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Final bar
|
|
250
|
+
drawBar(pending.length, pending.length, `${c.green}done${c.reset}`);
|
|
251
|
+
if (!this.silent) process.stdout.write(`\n`);
|
|
252
|
+
|
|
253
|
+
results.failed.push(...results_prefail);
|
|
254
|
+
this._printSummary(results, startTime);
|
|
255
|
+
|
|
256
|
+
// ── Write log file ────────────────────────────────────────────
|
|
257
|
+
if (config.output?.logFile) {
|
|
258
|
+
const logPath = path.resolve(config.output.logFile);
|
|
259
|
+
const logData = {
|
|
260
|
+
timestamp: new Date().toISOString(),
|
|
261
|
+
dryRun: config.mode.dryRun,
|
|
262
|
+
safeMode: config.mode.safeMode,
|
|
263
|
+
created: results.created,
|
|
264
|
+
failed: results.failed,
|
|
265
|
+
};
|
|
266
|
+
fs.writeFileSync(logPath, JSON.stringify(logData, null, 2));
|
|
267
|
+
this._log('info', `Log written → ${c.dim}${logPath}${c.reset}`);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Clean up state if everything succeeded
|
|
271
|
+
if (!results.failed.length && config.mode.resumable) {
|
|
272
|
+
try {
|
|
273
|
+
fs.unlinkSync(config.stateFile);
|
|
274
|
+
} catch {}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
return results;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// ── Private helpers ─────────────────────────────────────────────
|
|
281
|
+
|
|
282
|
+
_pickDelay() {
|
|
283
|
+
const { delay } = this.config;
|
|
284
|
+
if (delay.mode === 'fixed') return delay.fixedMs;
|
|
285
|
+
return Math.floor(Math.random() * (delay.maxMs - delay.minMs + 1)) + delay.minMs;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
_loadState() {
|
|
289
|
+
try {
|
|
290
|
+
if (fs.existsSync(this.config.stateFile))
|
|
291
|
+
return JSON.parse(fs.readFileSync(this.config.stateFile, 'utf-8'));
|
|
292
|
+
} catch {}
|
|
293
|
+
return { completed: [], failed: [] };
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
_saveState(state) {
|
|
297
|
+
fs.writeFileSync(this.config.stateFile, JSON.stringify(state, null, 2));
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
_log(level, msg) {
|
|
301
|
+
if (this.silent) return;
|
|
302
|
+
if (log[level]) {
|
|
303
|
+
log[level](msg);
|
|
304
|
+
} else {
|
|
305
|
+
console.log(msg);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
_printSummary(results, startTime) {
|
|
310
|
+
if (!this.config.output?.showTable && !this.silent) return;
|
|
311
|
+
const elapsed = fmtDelay(Date.now() - (startTime || Date.now()));
|
|
312
|
+
console.log(`\n${c.bold}${c.blue}📊 Summary${c.reset}\n`);
|
|
313
|
+
|
|
314
|
+
const boxTable = (rows, headers, colors) => {
|
|
315
|
+
const colW = headers.map((h, i) =>
|
|
316
|
+
Math.min(50, Math.max(h.length, ...rows.map((r) => String(r[i] || '').length)))
|
|
317
|
+
);
|
|
318
|
+
const line = (l, m, r) => ` ${l}${colW.map((w) => '─'.repeat(w + 2)).join(m)}${r}`;
|
|
319
|
+
const fmtRow = (vals, rowColors) =>
|
|
320
|
+
` │${vals
|
|
321
|
+
.map(
|
|
322
|
+
(v, i) =>
|
|
323
|
+
` ${rowColors?.[i] || ''}${String(v || '')
|
|
324
|
+
.slice(0, colW[i])
|
|
325
|
+
.padEnd(colW[i])}${c.reset} `
|
|
326
|
+
)
|
|
327
|
+
.join('│')}│`;
|
|
328
|
+
|
|
329
|
+
console.log(`${c.dim}${line('┌', '┬', '┐')}${c.reset}`);
|
|
330
|
+
console.log(
|
|
331
|
+
`${c.dim}${fmtRow(
|
|
332
|
+
headers,
|
|
333
|
+
headers.map(() => c.dim + c.bold)
|
|
334
|
+
)}${c.reset}`
|
|
335
|
+
);
|
|
336
|
+
console.log(`${c.dim}${line('├', '┼', '┤')}${c.reset}`);
|
|
337
|
+
rows.forEach((r) => console.log(`${c.dim}${fmtRow(r, colors)}${c.reset}`));
|
|
338
|
+
console.log(`${c.dim}${line('└', '┴', '┘')}${c.reset}`);
|
|
339
|
+
};
|
|
340
|
+
|
|
341
|
+
if (results.created.length) {
|
|
342
|
+
console.log(`${c.green}${c.bold} ✔ Created: ${results.created.length}${c.reset}\n`);
|
|
343
|
+
boxTable(
|
|
344
|
+
results.created.map((r, i) => [`#${r.number ?? i + 1}`, r.repo, r.title, r.url || '']),
|
|
345
|
+
['#', 'Repo', 'Title', 'URL'],
|
|
346
|
+
[c.green, c.blue, c.reset, c.dim]
|
|
347
|
+
);
|
|
348
|
+
console.log('');
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
if (results.failed.length) {
|
|
352
|
+
console.log(`${c.red}${c.bold} ✖ Failed / Skipped: ${results.failed.length}${c.reset}\n`);
|
|
353
|
+
const shortReason = (err = '') => {
|
|
354
|
+
if (err.startsWith('DUPLICATE:')) return '⟳ already exists (skipped)';
|
|
355
|
+
if (err.includes('not found')) return '✕ repo not found';
|
|
356
|
+
if (err.includes('no permission')) return '✕ no permission';
|
|
357
|
+
if (err.includes('required scope')) return '✕ token missing scope';
|
|
358
|
+
if (err.includes('token invalid')) return '✕ token invalid';
|
|
359
|
+
if (err.includes('404')) return '✕ not found';
|
|
360
|
+
if (err.includes('403')) return '✕ forbidden';
|
|
361
|
+
if (err.includes('401')) return '✕ unauthorized';
|
|
362
|
+
return err.slice(0, 40);
|
|
363
|
+
};
|
|
364
|
+
boxTable(
|
|
365
|
+
results.failed.map((r) => [r.repo, r.title, shortReason(r.error)]),
|
|
366
|
+
['Repo', 'Title', 'Reason'],
|
|
367
|
+
[c.blue, c.red, c.yellow]
|
|
368
|
+
);
|
|
369
|
+
console.log('');
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
const total = results.created.length + results.failed.length;
|
|
373
|
+
console.log(
|
|
374
|
+
` ${c.dim}Total: ${total} · ` +
|
|
375
|
+
`Created: ${c.green}${results.created.length}${c.reset}${c.dim} · ` +
|
|
376
|
+
`Failed: ${c.red}${results.failed.length}${c.reset}${c.dim} · ` +
|
|
377
|
+
`Time: ${c.yellow}${elapsed}${c.reset}\n`
|
|
378
|
+
);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// ── Utilities ──────────────────────────────────────────────────────
|
|
383
|
+
|
|
384
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
385
|
+
|
|
386
|
+
async function liveCountdown(seconds, total, done, delayMs, drawBar) {
|
|
387
|
+
for (let s = seconds; s > 0; s--) {
|
|
388
|
+
const mm = String(Math.floor(s / 60)).padStart(2, '0');
|
|
389
|
+
const ss = String(s % 60).padStart(2, '0');
|
|
390
|
+
const est = fmtDelay((total - done) * delayMs + s * 1_000);
|
|
391
|
+
drawBar(done, total, `\x1b[33mnext in ${mm}:${ss}\x1b[0m \x1b[2m· ~${est} left\x1b[0m`);
|
|
392
|
+
await sleep(1_000);
|
|
393
|
+
}
|
|
394
|
+
drawBar(done, total);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function fmtDelay(ms) {
|
|
398
|
+
const s = Math.round(ms / 1_000);
|
|
399
|
+
const m = Math.floor(s / 60);
|
|
400
|
+
return m > 0 ? `${m}m ${s % 60}s` : `${s}s`;
|
|
401
|
+
}
|
package/src/config.js
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import dotenv from 'dotenv';
|
|
4
|
+
import { getSavedToken } from './auth.js';
|
|
5
|
+
|
|
6
|
+
const CWD = process.cwd();
|
|
7
|
+
|
|
8
|
+
// Load .env from project root
|
|
9
|
+
dotenv.config({ path: path.join(CWD, '.env') });
|
|
10
|
+
|
|
11
|
+
// ── Defaults (mirrors cannon.config.json structure) ──────────────
|
|
12
|
+
const DEFAULTS = {
|
|
13
|
+
github: {
|
|
14
|
+
token: '',
|
|
15
|
+
apiVersion: '2022-11-28',
|
|
16
|
+
},
|
|
17
|
+
source: {
|
|
18
|
+
type: 'csv',
|
|
19
|
+
file: '',
|
|
20
|
+
query: '',
|
|
21
|
+
connectionString: '',
|
|
22
|
+
},
|
|
23
|
+
mode: {
|
|
24
|
+
dryRun: false,
|
|
25
|
+
safeMode: true,
|
|
26
|
+
resumable: true,
|
|
27
|
+
},
|
|
28
|
+
delay: {
|
|
29
|
+
mode: 'random',
|
|
30
|
+
minMs: 240_000, // 4 min
|
|
31
|
+
maxMs: 480_000, // 8 min
|
|
32
|
+
fixedMs: 300_000, // 5 min
|
|
33
|
+
},
|
|
34
|
+
labels: {
|
|
35
|
+
autoCreate: false,
|
|
36
|
+
colorMap: {},
|
|
37
|
+
},
|
|
38
|
+
output: {
|
|
39
|
+
logFile: '',
|
|
40
|
+
showTable: true,
|
|
41
|
+
},
|
|
42
|
+
stateFile: path.join(CWD, '.cannon_state.json'),
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Load config from cannon.config.json, merge with defaults,
|
|
47
|
+
* then apply any programmatic overrides passed in.
|
|
48
|
+
*
|
|
49
|
+
* Token resolution order:
|
|
50
|
+
* 1. overrides.token (programmatic / code)
|
|
51
|
+
* 2. GITHUB_TOKEN shell env var (or .env file)
|
|
52
|
+
* 3. ~/.cannon/credentials.json (cannon auth login OAuth)
|
|
53
|
+
* 4. cannon.config.json github.token (not recommended)
|
|
54
|
+
*/
|
|
55
|
+
export function loadConfig(overrides = {}) {
|
|
56
|
+
let fileConfig = {};
|
|
57
|
+
const configPath = path.join(CWD, 'cannon.config.json');
|
|
58
|
+
|
|
59
|
+
if (fs.existsSync(configPath)) {
|
|
60
|
+
try {
|
|
61
|
+
const raw = fs.readFileSync(configPath, 'utf-8');
|
|
62
|
+
fileConfig = JSON.parse(raw);
|
|
63
|
+
} catch (e) {
|
|
64
|
+
throw new Error(`cannon.config.json has invalid JSON: ${e.message}`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Strip comment keys (_xxx) before merging
|
|
69
|
+
const cleaned = stripComments(fileConfig);
|
|
70
|
+
const merged = deepMerge(DEFAULTS, cleaned);
|
|
71
|
+
|
|
72
|
+
// Support new simple format: delay.min / delay.max in MINUTES
|
|
73
|
+
// Convert to ms so the rest of the codebase stays unchanged
|
|
74
|
+
if (cleaned.delay?.min !== undefined && cleaned.delay?.fixedMs === undefined) {
|
|
75
|
+
merged.delay.minMs = Math.round((cleaned.delay.min ?? 4) * 60_000);
|
|
76
|
+
merged.delay.maxMs = Math.round((cleaned.delay.max ?? 8) * 60_000);
|
|
77
|
+
merged.delay.mode = 'random';
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Token priority chain
|
|
81
|
+
const token =
|
|
82
|
+
overrides.token || process.env.GITHUB_TOKEN || getSavedToken() || merged.github?.token || '';
|
|
83
|
+
|
|
84
|
+
// Programmatic overrides win over file config for simple fields
|
|
85
|
+
const mode = {
|
|
86
|
+
...merged.mode,
|
|
87
|
+
...(overrides.dryRun !== undefined ? { dryRun: overrides.dryRun } : {}),
|
|
88
|
+
...(overrides.safeMode !== undefined ? { safeMode: overrides.safeMode } : {}),
|
|
89
|
+
...(overrides.resumable !== undefined ? { resumable: overrides.resumable } : {}),
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
// Allow old-style `delay` override object to still work
|
|
93
|
+
const delay = overrides.delay ? { ...merged.delay, ...overrides.delay } : merged.delay;
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
...merged,
|
|
97
|
+
github: { ...merged.github, token },
|
|
98
|
+
mode,
|
|
99
|
+
delay,
|
|
100
|
+
stateFile: overrides.stateFile ?? merged.stateFile ?? DEFAULTS.stateFile,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ── Helpers ──────────────────────────────────────────────────────
|
|
105
|
+
|
|
106
|
+
/** Remove all keys starting with "_" (comment keys) */
|
|
107
|
+
function stripComments(obj) {
|
|
108
|
+
if (typeof obj !== 'object' || obj === null || Array.isArray(obj)) return obj;
|
|
109
|
+
const out = {};
|
|
110
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
111
|
+
if (k.startsWith('_')) continue;
|
|
112
|
+
out[k] = stripComments(v);
|
|
113
|
+
}
|
|
114
|
+
return out;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function deepMerge(base, override) {
|
|
118
|
+
const result = { ...base };
|
|
119
|
+
for (const key of Object.keys(override ?? {})) {
|
|
120
|
+
if (
|
|
121
|
+
typeof override[key] === 'object' &&
|
|
122
|
+
override[key] !== null &&
|
|
123
|
+
!Array.isArray(override[key])
|
|
124
|
+
) {
|
|
125
|
+
result[key] = deepMerge(base[key] ?? {}, override[key]);
|
|
126
|
+
} else {
|
|
127
|
+
result[key] = override[key];
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return result;
|
|
131
|
+
}
|
package/src/github.js
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
const GITHUB_API = 'https://api.github.com';
|
|
2
|
+
|
|
3
|
+
export async function verifyToken(repo, token) {
|
|
4
|
+
const res = await fetch(`${GITHUB_API}/repos/${repo}`, {
|
|
5
|
+
headers: authHeaders(token),
|
|
6
|
+
});
|
|
7
|
+
return res.ok ? null : res.status;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const _milestoneCache = {};
|
|
11
|
+
|
|
12
|
+
export async function getOrCreateMilestone(repo, milestoneName, token) {
|
|
13
|
+
if (!milestoneName) return null;
|
|
14
|
+
const key = `${repo}::${milestoneName}`;
|
|
15
|
+
if (_milestoneCache[key] !== undefined) return _milestoneCache[key];
|
|
16
|
+
|
|
17
|
+
const listRes = await fetch(`${GITHUB_API}/repos/${repo}/milestones?state=all&per_page=50`, {
|
|
18
|
+
headers: authHeaders(token),
|
|
19
|
+
});
|
|
20
|
+
if (!listRes.ok) return (_milestoneCache[key] = null);
|
|
21
|
+
|
|
22
|
+
const milestones = await listRes.json();
|
|
23
|
+
const existing = milestones.find((m) => m.title === milestoneName);
|
|
24
|
+
if (existing) return (_milestoneCache[key] = existing.number);
|
|
25
|
+
|
|
26
|
+
const createRes = await fetch(`${GITHUB_API}/repos/${repo}/milestones`, {
|
|
27
|
+
method: 'POST',
|
|
28
|
+
headers: { ...authHeaders(token), 'Content-Type': 'application/json' },
|
|
29
|
+
body: JSON.stringify({ title: milestoneName }),
|
|
30
|
+
});
|
|
31
|
+
if (!createRes.ok) return (_milestoneCache[key] = null);
|
|
32
|
+
const created = await createRes.json();
|
|
33
|
+
return (_milestoneCache[key] = created.number);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// ── Label auto-creation ───────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
const _labelCache = {};
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Ensure a label exists in a repo.
|
|
42
|
+
* Creates it with the given hex color if it doesn't exist.
|
|
43
|
+
* @param {string} repo — "owner/repo"
|
|
44
|
+
* @param {string} name — label name
|
|
45
|
+
* @param {string} color — hex color WITHOUT #, e.g. "ee0701"
|
|
46
|
+
* @param {string} token — GitHub PAT
|
|
47
|
+
*/
|
|
48
|
+
export async function ensureLabel(repo, name, color, token) {
|
|
49
|
+
const key = `${repo}::${name}`;
|
|
50
|
+
if (_labelCache[key]) return;
|
|
51
|
+
|
|
52
|
+
// Check existing labels
|
|
53
|
+
const listRes = await fetch(`${GITHUB_API}/repos/${repo}/labels?per_page=100`, {
|
|
54
|
+
headers: authHeaders(token),
|
|
55
|
+
});
|
|
56
|
+
if (listRes.ok) {
|
|
57
|
+
const labels = await listRes.json();
|
|
58
|
+
if (labels.find((l) => l.name === name)) {
|
|
59
|
+
_labelCache[key] = true;
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Create label
|
|
65
|
+
const createRes = await fetch(`${GITHUB_API}/repos/${repo}/labels`, {
|
|
66
|
+
method: 'POST',
|
|
67
|
+
headers: { ...authHeaders(token), 'Content-Type': 'application/json' },
|
|
68
|
+
body: JSON.stringify({ name, color: color.replace('#', '') }),
|
|
69
|
+
});
|
|
70
|
+
_labelCache[key] = createRes.ok;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const _existingTitles = {};
|
|
74
|
+
|
|
75
|
+
async function fetchExistingTitles(repo, token) {
|
|
76
|
+
if (_existingTitles[repo]) return _existingTitles[repo];
|
|
77
|
+
const titles = new Set();
|
|
78
|
+
let page = 1;
|
|
79
|
+
while (true) {
|
|
80
|
+
const res = await fetch(
|
|
81
|
+
`${GITHUB_API}/repos/${repo}/issues?state=all&per_page=100&page=${page}`,
|
|
82
|
+
{ headers: authHeaders(token) }
|
|
83
|
+
);
|
|
84
|
+
if (!res.ok) break;
|
|
85
|
+
const items = await res.json();
|
|
86
|
+
if (!items.length) break;
|
|
87
|
+
items.forEach((i) => titles.add(i.title.trim().toLowerCase()));
|
|
88
|
+
if (items.length < 100) break;
|
|
89
|
+
page++;
|
|
90
|
+
}
|
|
91
|
+
_existingTitles[repo] = titles;
|
|
92
|
+
return titles;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Create a single GitHub issue.
|
|
97
|
+
* Skips duplicates (same title already exists in repo).
|
|
98
|
+
*
|
|
99
|
+
* @param {object} issue — { repo, title, body, labels, milestone }
|
|
100
|
+
* @param {string} token — GitHub PAT
|
|
101
|
+
* @param {boolean} dryRun — if true, returns a fake response without hitting the API
|
|
102
|
+
*/
|
|
103
|
+
export async function createIssue(issue, token, dryRun = false) {
|
|
104
|
+
const repo = issue.repo?.trim();
|
|
105
|
+
if (!repo) throw new Error(`Issue missing 'repo': "${issue.title}"`);
|
|
106
|
+
|
|
107
|
+
if (!dryRun) {
|
|
108
|
+
const existing = await fetchExistingTitles(repo, token);
|
|
109
|
+
if (existing.has(issue.title.trim().toLowerCase())) {
|
|
110
|
+
throw new Error(`DUPLICATE: issue already exists in ${repo}`);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const labels = normLabels(issue.labels);
|
|
115
|
+
const milestoneNumber = await getOrCreateMilestone(repo, issue.milestone, token);
|
|
116
|
+
|
|
117
|
+
const payload = {
|
|
118
|
+
title: issue.title?.trim(),
|
|
119
|
+
body: issue.body?.trim() ?? '',
|
|
120
|
+
labels,
|
|
121
|
+
...(milestoneNumber ? { milestone: milestoneNumber } : {}),
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
if (dryRun) {
|
|
125
|
+
return { html_url: `https://github.com/${repo}/issues/0`, number: 0, _dryRun: true };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const res = await fetch(`${GITHUB_API}/repos/${repo}/issues`, {
|
|
129
|
+
method: 'POST',
|
|
130
|
+
headers: { ...authHeaders(token), 'Content-Type': 'application/json' },
|
|
131
|
+
body: JSON.stringify(payload),
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
if (!res.ok) {
|
|
135
|
+
const text = await res.text();
|
|
136
|
+
const hint =
|
|
137
|
+
res.status === 403
|
|
138
|
+
? ' — check token has "repo" or "public_repo" scope'
|
|
139
|
+
: res.status === 401
|
|
140
|
+
? ' — token invalid or expired; run: cannon auth login'
|
|
141
|
+
: '';
|
|
142
|
+
throw new Error(`GitHub ${res.status} on ${repo}${hint}: ${text}`);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const created = await res.json();
|
|
146
|
+
if (_existingTitles[repo]) {
|
|
147
|
+
_existingTitles[repo].add(created.title.trim().toLowerCase());
|
|
148
|
+
}
|
|
149
|
+
return created;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function authHeaders(token) {
|
|
153
|
+
return {
|
|
154
|
+
Authorization: `Bearer ${token}`,
|
|
155
|
+
Accept: 'application/vnd.github+json',
|
|
156
|
+
'X-GitHub-Api-Version': '2022-11-28',
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function normLabels(raw) {
|
|
161
|
+
if (!raw) return [];
|
|
162
|
+
if (Array.isArray(raw)) return raw.map((l) => l.trim()).filter(Boolean);
|
|
163
|
+
return raw
|
|
164
|
+
.split(',')
|
|
165
|
+
.map((l) => l.trim())
|
|
166
|
+
.filter(Boolean);
|
|
167
|
+
}
|