@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/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
+ }