@alien-protocol/cannon 2.2.2 → 2.3.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/.env.example +0 -8
- package/README.md +107 -52
- package/package.json +1 -1
- package/src/cannon.js +105 -46
- package/src/github.js +80 -18
- package/src/loaders/index.js +10 -2
package/.env.example
CHANGED
|
@@ -1,12 +1,4 @@
|
|
|
1
|
-
# ─────────────────────────────────────────────
|
|
2
|
-
# .env.example — copy to .env (never commit .env!)
|
|
3
|
-
# ─────────────────────────────────────────────
|
|
4
|
-
|
|
5
|
-
# Your GitHub Personal Access Token
|
|
6
|
-
# Create one at: https://github.com/settings/tokens/new
|
|
7
|
-
# Required scope: repo (or public_repo for public repos only)
|
|
8
1
|
GITHUB_TOKEN=ghp_your_token_here
|
|
9
2
|
|
|
10
|
-
# Database connection strings (only needed for DB sources)
|
|
11
3
|
POSTGRES_URL=postgres://user:password@localhost:5432/mydb
|
|
12
4
|
MYSQL_URL=mysql://user:password@localhost:3306/mydb
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# 🛸 @alien-protocol/cannon
|
|
2
2
|
|
|
3
|
-
> Bulk-create GitHub issues from **CSV, PDF, DOCX, JSON, or any SQL database** — one config file, safe delays, duplicate detection, and resume support.
|
|
3
|
+
> Bulk-create **and update** GitHub issues from **CSV, PDF, DOCX, JSON, or any SQL database** — one config file, safe delays, duplicate detection, and resume support.
|
|
4
4
|
|
|
5
5
|
## Installation
|
|
6
6
|
|
|
@@ -33,10 +33,10 @@ cannon init
|
|
|
33
33
|
# 2. Login with GitHub (no token needed)
|
|
34
34
|
cannon auth login
|
|
35
35
|
|
|
36
|
-
# 3. Preview — nothing gets created
|
|
36
|
+
# 3. Preview — nothing gets created or updated
|
|
37
37
|
cannon fire --preview
|
|
38
38
|
|
|
39
|
-
# 4.
|
|
39
|
+
# 4. Fire your issues
|
|
40
40
|
cannon fire
|
|
41
41
|
```
|
|
42
42
|
|
|
@@ -97,24 +97,24 @@ cannon fire
|
|
|
97
97
|
|
|
98
98
|
### Config Options
|
|
99
99
|
|
|
100
|
-
| Key | Default | Description
|
|
101
|
-
| ------------------------- | -------------- |
|
|
100
|
+
| Key | Default | Description |
|
|
101
|
+
| ------------------------- | -------------- | ------------------------------------------------------------------ |
|
|
102
102
|
| `source.type` | `csv` | `csv` · `json` · `pdf` · `docx` · `sqlite` · `postgres` · `mysql` |
|
|
103
|
-
| `source.file` | `./issues.csv` | Path to your issues file
|
|
104
|
-
| `source.query` | — | SQL query (database sources only)
|
|
105
|
-
| `source.connectionString` | — | DB connection URL — use `${ENV_VAR}`, never hardcode
|
|
106
|
-
| `mode.safeMode` | `true` | Random delays between issues — keeps you off GitHub's radar
|
|
107
|
-
| `mode.dryRun` | `false` | Preview only, nothing created
|
|
108
|
-
| `mode.resumable` | `true` | Saves progress so you can stop and restart safely
|
|
109
|
-
| `delay.min` | `4` | Minimum minutes between issues
|
|
110
|
-
| `delay.max` | `8` | Maximum minutes between issues
|
|
111
|
-
| `labels.autoCreate` | `false` | Auto-create missing labels in GitHub
|
|
112
|
-
| `labels.colors` | see above | Label name → hex color for auto-creation
|
|
113
|
-
| `output.logFile` | — | Save a JSON results log to this path
|
|
114
|
-
| `output.showTable` | `true` | Show a summary table after completion
|
|
115
|
-
| `notify.webhookUrl` | — | Slack / Discord / Teams webhook URL
|
|
116
|
-
| `notify.onSuccess` | `true` | Notify when batch completes successfully
|
|
117
|
-
| `notify.onFailure` | `true` | Notify when any issues fail
|
|
103
|
+
| `source.file` | `./issues.csv` | Path to your issues file |
|
|
104
|
+
| `source.query` | — | SQL query (database sources only) |
|
|
105
|
+
| `source.connectionString` | — | DB connection URL — use `${ENV_VAR}`, never hardcode |
|
|
106
|
+
| `mode.safeMode` | `true` | Random delays between issues — keeps you off GitHub's radar |
|
|
107
|
+
| `mode.dryRun` | `false` | Preview only, nothing created or updated |
|
|
108
|
+
| `mode.resumable` | `true` | Saves progress so you can stop and restart safely |
|
|
109
|
+
| `delay.min` | `4` | Minimum minutes between issues |
|
|
110
|
+
| `delay.max` | `8` | Maximum minutes between issues |
|
|
111
|
+
| `labels.autoCreate` | `false` | Auto-create missing labels in GitHub |
|
|
112
|
+
| `labels.colors` | see above | Label name → hex color for auto-creation |
|
|
113
|
+
| `output.logFile` | — | Save a JSON results log to this path |
|
|
114
|
+
| `output.showTable` | `true` | Show a summary table after completion |
|
|
115
|
+
| `notify.webhookUrl` | — | Slack / Discord / Teams webhook URL |
|
|
116
|
+
| `notify.onSuccess` | `true` | Notify when batch completes successfully |
|
|
117
|
+
| `notify.onFailure` | `true` | Notify when any issues fail |
|
|
118
118
|
|
|
119
119
|
---
|
|
120
120
|
|
|
@@ -159,11 +159,11 @@ Checks your config is valid JSON · token is present · source file exists · is
|
|
|
159
159
|
|
|
160
160
|
### `cannon fire`
|
|
161
161
|
|
|
162
|
-
Create your issues.
|
|
162
|
+
Create and/or update your issues.
|
|
163
163
|
|
|
164
164
|
```bash
|
|
165
165
|
cannon fire # run using cannon.config.json
|
|
166
|
-
cannon fire --preview # dry run — nothing created, no delays
|
|
166
|
+
cannon fire --preview # dry run — nothing created or updated, no delays
|
|
167
167
|
cannon fire --unsafe # no delays (fast but risky)
|
|
168
168
|
cannon fire --delay 2 # fixed 2-minute delay between issues
|
|
169
169
|
cannon fire --fresh # ignore saved progress, start over
|
|
@@ -175,15 +175,15 @@ cannon fire -s docx -f ./issues.docx --delay 1
|
|
|
175
175
|
cannon fire -s pdf -f ./issues.pdf --unsafe
|
|
176
176
|
```
|
|
177
177
|
|
|
178
|
-
| Flag | What it does
|
|
179
|
-
| ---------------- |
|
|
180
|
-
| `--preview` | Dry run — shows what would be created, skips all delays
|
|
181
|
-
| `--unsafe` | No delays at all — fast but GitHub may flag as spam
|
|
182
|
-
| `--delay <mins>` | Fixed delay in minutes, e.g. `--delay 2`
|
|
183
|
-
| `--fresh` | Ignore saved progress and start from the beginning
|
|
184
|
-
| `-s <type>` | Source type override
|
|
185
|
-
| `-f <path>` | Source file override
|
|
186
|
-
| `-q <sql>` | SQL query override (database sources)
|
|
178
|
+
| Flag | What it does |
|
|
179
|
+
| ---------------- | ----------------------------------------------------------------- |
|
|
180
|
+
| `--preview` | Dry run — shows what would be created/updated, skips all delays |
|
|
181
|
+
| `--unsafe` | No delays at all — fast but GitHub may flag as spam |
|
|
182
|
+
| `--delay <mins>` | Fixed delay in minutes, e.g. `--delay 2` |
|
|
183
|
+
| `--fresh` | Ignore saved progress and start from the beginning |
|
|
184
|
+
| `-s <type>` | Source type override |
|
|
185
|
+
| `-f <path>` | Source file override |
|
|
186
|
+
| `-q <sql>` | SQL query override (database sources) |
|
|
187
187
|
|
|
188
188
|
---
|
|
189
189
|
|
|
@@ -203,22 +203,36 @@ Safe mode is on by default. For large batches never turn it off.
|
|
|
203
203
|
|
|
204
204
|
Every source needs at minimum: `repo` and `title`.
|
|
205
205
|
|
|
206
|
-
| Field | Required | Description
|
|
207
|
-
| ----------- | -------- |
|
|
208
|
-
| `
|
|
209
|
-
| `
|
|
210
|
-
| `
|
|
211
|
-
| `
|
|
212
|
-
| `
|
|
213
|
-
| `
|
|
214
|
-
| `
|
|
206
|
+
| Field | Required | Description |
|
|
207
|
+
| ----------- | -------- | --------------------------------------------------------------- |
|
|
208
|
+
| `action` | — | `create` (default) or `update` — controls what cannon does |
|
|
209
|
+
| `repo` | ✅ | `owner/repo` |
|
|
210
|
+
| `title` | ✅ | Issue title — used as the lookup key for `update` |
|
|
211
|
+
| `body` | — | Description |
|
|
212
|
+
| `labels` | — | Comma-separated: `bug,auth` |
|
|
213
|
+
| `milestone` | — | Auto-created if it doesn't exist |
|
|
214
|
+
| `priority` | — | `HIGH` · `MED` · `LOW` (informational) |
|
|
215
|
+
| `track` | — | e.g. `auth`, `ui`, `docs` (informational) |
|
|
216
|
+
|
|
217
|
+
### The `action` column
|
|
218
|
+
|
|
219
|
+
Add an `action` column to any source file to mix creates and updates in a single run.
|
|
220
|
+
|
|
221
|
+
| Value | What cannon does |
|
|
222
|
+
| -------------- | ---------------------------------------------------------------------------------------------------------- |
|
|
223
|
+
| `create` | Opens a **new** issue. Skipped if an issue with the same title already exists (duplicate guard). |
|
|
224
|
+
| `update` | Finds the **existing** issue by exact title match, then patches its body, labels, and milestone. |
|
|
225
|
+
| *(blank/omit)* | Defaults to `create`. |
|
|
226
|
+
|
|
227
|
+
> **Note:** For `update`, the title is the stable lookup key — it is not changed by the patch. If no matching issue is found, the row fails with `UPDATE_NOT_FOUND` and is shown in the summary.
|
|
215
228
|
|
|
216
229
|
### CSV
|
|
217
230
|
|
|
218
231
|
```csv
|
|
219
|
-
repo,title,body,labels,milestone
|
|
220
|
-
owner/repo,Fix login bug,"Steps to reproduce...",bug,v1.0
|
|
221
|
-
owner/repo,
|
|
232
|
+
action,repo,title,body,labels,milestone
|
|
233
|
+
create,owner/repo,Fix login bug,"Steps to reproduce...",bug,v1.0
|
|
234
|
+
update,owner/repo,Fix login bug,"Updated repro steps and raised priority",bug high,v1.0
|
|
235
|
+
create,owner/repo,Add dark mode,"User request",enhancement,v1.1
|
|
222
236
|
```
|
|
223
237
|
|
|
224
238
|
```json
|
|
@@ -230,11 +244,19 @@ owner/repo,Add dark mode,"User request",enhancement,v1.1
|
|
|
230
244
|
```json
|
|
231
245
|
[
|
|
232
246
|
{
|
|
247
|
+
"action": "create",
|
|
233
248
|
"repo": "owner/repo",
|
|
234
249
|
"title": "Fix login bug",
|
|
235
250
|
"body": "Steps to reproduce...",
|
|
236
251
|
"labels": "bug,auth",
|
|
237
252
|
"milestone": "v1.0"
|
|
253
|
+
},
|
|
254
|
+
{
|
|
255
|
+
"action": "update",
|
|
256
|
+
"repo": "owner/repo",
|
|
257
|
+
"title": "Fix login bug",
|
|
258
|
+
"body": "Updated repro steps.",
|
|
259
|
+
"labels": "bug,auth,high"
|
|
238
260
|
}
|
|
239
261
|
]
|
|
240
262
|
```
|
|
@@ -272,9 +294,10 @@ owner/repo | Fix login bug | Steps | bug,auth
|
|
|
272
294
|
|
|
273
295
|
A table in your Word file. First row = headers.
|
|
274
296
|
|
|
275
|
-
| repo | title | body | labels |
|
|
276
|
-
| ---------- | ------------- | -------- | -------- |
|
|
277
|
-
| owner/repo | Fix login bug | Steps... | bug,auth |
|
|
297
|
+
| action | repo | title | body | labels |
|
|
298
|
+
| ------ | ---------- | ------------- | -------- | -------- |
|
|
299
|
+
| create | owner/repo | Fix login bug | Steps... | bug,auth |
|
|
300
|
+
| update | owner/repo | Fix login bug | Updated. | bug,high |
|
|
278
301
|
|
|
279
302
|
```json
|
|
280
303
|
"source": { "type": "docx", "file": "./issues.docx" }
|
|
@@ -291,7 +314,7 @@ POSTGRES_URL=postgres://user:password@localhost:5432/mydb
|
|
|
291
314
|
"source": {
|
|
292
315
|
"type": "postgres",
|
|
293
316
|
"connectionString": "${POSTGRES_URL}",
|
|
294
|
-
"query": "SELECT repo, title, body, labels, milestone FROM backlog WHERE exported = false"
|
|
317
|
+
"query": "SELECT action, repo, title, body, labels, milestone FROM backlog WHERE exported = false"
|
|
295
318
|
}
|
|
296
319
|
```
|
|
297
320
|
|
|
@@ -301,7 +324,7 @@ POSTGRES_URL=postgres://user:password@localhost:5432/mydb
|
|
|
301
324
|
"source": {
|
|
302
325
|
"type": "mysql",
|
|
303
326
|
"connectionString": "${MYSQL_URL}",
|
|
304
|
-
"query": "SELECT repo, title, body, labels FROM issues WHERE status = 'pending'"
|
|
327
|
+
"query": "SELECT action, repo, title, body, labels FROM issues WHERE status = 'pending'"
|
|
305
328
|
}
|
|
306
329
|
```
|
|
307
330
|
|
|
@@ -311,7 +334,7 @@ POSTGRES_URL=postgres://user:password@localhost:5432/mydb
|
|
|
311
334
|
"source": {
|
|
312
335
|
"type": "sqlite",
|
|
313
336
|
"file": "./backlog.db",
|
|
314
|
-
"query": "SELECT repo, title, body, labels FROM issues"
|
|
337
|
+
"query": "SELECT action, repo, title, body, labels FROM issues"
|
|
315
338
|
}
|
|
316
339
|
```
|
|
317
340
|
|
|
@@ -361,12 +384,44 @@ const cannon = new IssueCannon({
|
|
|
361
384
|
dryRun: false,
|
|
362
385
|
});
|
|
363
386
|
|
|
364
|
-
const { created, failed } = await cannon.fire({
|
|
387
|
+
const { created, updated, failed } = await cannon.fire({
|
|
365
388
|
source: 'csv',
|
|
366
389
|
file: './issues.csv',
|
|
367
390
|
});
|
|
368
391
|
|
|
369
|
-
console.log(`Created: ${created.length} Failed: ${failed.length}`);
|
|
392
|
+
console.log(`Created: ${created.length} Updated: ${updated.length} Failed: ${failed.length}`);
|
|
393
|
+
```
|
|
394
|
+
|
|
395
|
+
---
|
|
396
|
+
|
|
397
|
+
## Summary Output
|
|
398
|
+
|
|
399
|
+
After a run with mixed actions, cannon prints three sections:
|
|
400
|
+
|
|
401
|
+
```
|
|
402
|
+
✔ Created: 4
|
|
403
|
+
|
|
404
|
+
┌───┬──────────────┬──────────────────────────┬────────────────────────────────────┐
|
|
405
|
+
│ # │ Repo │ Title │ URL │
|
|
406
|
+
├───┼──────────────┼──────────────────────────┼────────────────────────────────────┤
|
|
407
|
+
│ 1 │ owner/repo │ Add dark mode │ https://github.com/owner/repo/i... │
|
|
408
|
+
└───┴──────────────┴──────────────────────────┴────────────────────────────────────┘
|
|
409
|
+
|
|
410
|
+
↑ Updated: 2
|
|
411
|
+
|
|
412
|
+
┌───┬──────────────┬──────────────────────────┬────────────────────────────────────┐
|
|
413
|
+
│ # │ Repo │ Title │ URL │
|
|
414
|
+
├───┼──────────────┼──────────────────────────┼────────────────────────────────────┤
|
|
415
|
+
│ 7 │ owner/repo │ Fix login bug │ https://github.com/owner/repo/i... │
|
|
416
|
+
└───┴──────────────┴──────────────────────────┴────────────────────────────────────┘
|
|
417
|
+
|
|
418
|
+
✖ Failed / Skipped: 1
|
|
419
|
+
|
|
420
|
+
┌────────┬──────────────┬──────────────────────┬──────────────────────────────┐
|
|
421
|
+
│ Action │ Repo │ Title │ Reason │
|
|
422
|
+
├────────┼──────────────┼──────────────────────┼──────────────────────────────┤
|
|
423
|
+
│ update │ owner/repo │ Nonexistent issue │ ✕ issue not found for update│
|
|
424
|
+
└────────┴──────────────┴──────────────────────┴──────────────────────────────┘
|
|
370
425
|
```
|
|
371
426
|
|
|
372
427
|
---
|
|
@@ -392,4 +447,4 @@ console.log(`Created: ${created.length} Failed: ${failed.length}`);
|
|
|
392
447
|
|
|
393
448
|
## License
|
|
394
449
|
|
|
395
|
-
MIT
|
|
450
|
+
MIT
|
package/package.json
CHANGED
package/src/cannon.js
CHANGED
|
@@ -2,7 +2,7 @@ import fs from 'fs';
|
|
|
2
2
|
import path from 'path';
|
|
3
3
|
import { loadConfig } from './config.js';
|
|
4
4
|
import { loadIssues } from './loaders/index.js';
|
|
5
|
-
import { createIssue, verifyToken, ensureLabel } from './github.js';
|
|
5
|
+
import { createIssue, updateIssue, verifyToken, ensureLabel } from './github.js';
|
|
6
6
|
|
|
7
7
|
// ── ANSI colours ──────────────────────────────────────────────────
|
|
8
8
|
const c = {
|
|
@@ -63,12 +63,12 @@ export class IssueCannon {
|
|
|
63
63
|
file: config.source?.file,
|
|
64
64
|
query: config.source?.query,
|
|
65
65
|
connectionString: config.source?.connectionString,
|
|
66
|
-
...sourceOpts,
|
|
66
|
+
...sourceOpts,
|
|
67
67
|
};
|
|
68
68
|
|
|
69
69
|
// ── Mode banner ───────────────────────────────────────────────
|
|
70
70
|
if (config.mode.dryRun) {
|
|
71
|
-
this._log('warn', `${c.yellow}${c.bold}DRY RUN MODE${c.reset} — no issues will be created`);
|
|
71
|
+
this._log('warn', `${c.yellow}${c.bold}DRY RUN MODE${c.reset} — no issues will be created or updated`);
|
|
72
72
|
}
|
|
73
73
|
if (!config.mode.safeMode && !config.mode.dryRun) {
|
|
74
74
|
this._log(
|
|
@@ -81,9 +81,15 @@ export class IssueCannon {
|
|
|
81
81
|
this._log('step', '📦 Loading issues…');
|
|
82
82
|
const issues = await loadIssues(effectiveSource);
|
|
83
83
|
if (!issues.length) throw new Error('No issues loaded from source');
|
|
84
|
+
|
|
85
|
+
// Split by action for reporting
|
|
86
|
+
const toCreate = issues.filter((r) => r.action === 'create');
|
|
87
|
+
const toUpdate = issues.filter((r) => r.action === 'update');
|
|
88
|
+
|
|
84
89
|
this._log(
|
|
85
90
|
'info',
|
|
86
|
-
`Loaded ${c.bold}${issues.length}${c.reset} issue(s) from ${c.cyan}${effectiveSource.source}${c.reset}`
|
|
91
|
+
`Loaded ${c.bold}${issues.length}${c.reset} issue(s) from ${c.cyan}${effectiveSource.source}${c.reset}` +
|
|
92
|
+
` ${c.dim}(${c.green}${toCreate.length} create${c.reset}${c.dim}, ${c.yellow}${toUpdate.length} update${c.reset}${c.dim})${c.reset}`
|
|
87
93
|
);
|
|
88
94
|
|
|
89
95
|
const repoMap = issues.reduce((a, r) => {
|
|
@@ -137,7 +143,7 @@ export class IssueCannon {
|
|
|
137
143
|
// ── Resume state ──────────────────────────────────────────────
|
|
138
144
|
const state = config.mode.resumable ? this._loadState() : { completed: [], failed: [] };
|
|
139
145
|
const done = new Set(state.completed);
|
|
140
|
-
if (done.size) this._log('warn', `Resuming — ${done.size} issue(s) already
|
|
146
|
+
if (done.size) this._log('warn', `Resuming — ${done.size} issue(s) already processed, skipping`);
|
|
141
147
|
|
|
142
148
|
const pending = issues.filter((r) => !done.has(r.title) && !badRepos.has(r.repo));
|
|
143
149
|
|
|
@@ -147,27 +153,26 @@ export class IssueCannon {
|
|
|
147
153
|
.filter((r) => badRepos.has(r.repo))
|
|
148
154
|
.forEach((r) => {
|
|
149
155
|
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 });
|
|
156
|
+
results_prefail.push({ action: r.action, repo: r.repo, title: r.title, error: err });
|
|
157
|
+
state.failed.push({ action: r.action, repo: r.repo, title: r.title, error: err });
|
|
152
158
|
});
|
|
153
159
|
|
|
154
160
|
if (!pending.length) {
|
|
155
|
-
this._log('success', 'All issues already
|
|
156
|
-
return { created: [], failed: results_prefail };
|
|
161
|
+
this._log('success', 'All issues already processed!');
|
|
162
|
+
return { created: [], updated: [], failed: results_prefail };
|
|
157
163
|
}
|
|
158
164
|
|
|
159
165
|
// ── Delay / timing info ───────────────────────────────────────
|
|
160
166
|
const safeMode = config.mode.safeMode;
|
|
161
|
-
let estLabel
|
|
167
|
+
let estLabel;
|
|
162
168
|
|
|
163
169
|
if (!safeMode) {
|
|
164
170
|
estLabel = `${c.red}${c.bold}IMMEDIATE${c.reset} (no delays — unsafe mode)`;
|
|
165
|
-
estMin = 0;
|
|
166
171
|
} else {
|
|
167
172
|
const mid = (config.delay.minMs + config.delay.maxMs) / 2;
|
|
168
173
|
const delayMs = config.delay.mode === 'fixed' ? config.delay.fixedMs : mid;
|
|
169
174
|
const totalMs = pending.length * delayMs;
|
|
170
|
-
estMin = Math.ceil(totalMs / 60_000);
|
|
175
|
+
const estMin = Math.ceil(totalMs / 60_000);
|
|
171
176
|
const delayStr =
|
|
172
177
|
config.delay.mode === 'fixed'
|
|
173
178
|
? `${fmtDelay(config.delay.fixedMs)} fixed`
|
|
@@ -175,11 +180,11 @@ export class IssueCannon {
|
|
|
175
180
|
estLabel = `${c.yellow}${delayStr}${c.reset} · Est. total: ${c.yellow}~${estMin > 0 ? estMin + ' min' : Math.round(totalMs / 1000) + 's'}${c.reset}`;
|
|
176
181
|
}
|
|
177
182
|
|
|
178
|
-
this._log('info', `To
|
|
179
|
-
this._log('step', '🚀
|
|
183
|
+
this._log('info', `To process: ${c.bold}${pending.length}${c.reset} · Delay: ${estLabel}\n`);
|
|
184
|
+
this._log('step', '🚀 Processing issues…\n');
|
|
180
185
|
|
|
181
186
|
const startTime = Date.now();
|
|
182
|
-
const results = { created: [], failed: [] };
|
|
187
|
+
const results = { created: [], updated: [], failed: [] };
|
|
183
188
|
|
|
184
189
|
// ── Progress bar ──────────────────────────────────────────────
|
|
185
190
|
const W = 36;
|
|
@@ -201,44 +206,85 @@ export class IssueCannon {
|
|
|
201
206
|
|
|
202
207
|
for (let i = 0; i < pending.length; i++) {
|
|
203
208
|
const issue = pending[i];
|
|
209
|
+
const actionLabel = issue.action === 'update'
|
|
210
|
+
? `${c.yellow}updating…${c.reset}`
|
|
211
|
+
: `${c.cyan}creating…${c.reset}`;
|
|
212
|
+
|
|
204
213
|
drawBar(
|
|
205
214
|
i,
|
|
206
215
|
pending.length,
|
|
207
|
-
`${
|
|
216
|
+
`${actionLabel} ${c.dim}${issue.title.slice(0, 35)}${c.reset}`
|
|
208
217
|
);
|
|
209
218
|
|
|
210
219
|
try {
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
)
|
|
220
|
+
let result;
|
|
221
|
+
|
|
222
|
+
if (issue.action === 'update') {
|
|
223
|
+
// ── UPDATE path ──────────────────────────────────────
|
|
224
|
+
result = await updateIssue(issue, config.github.token, config.mode.dryRun);
|
|
225
|
+
results.updated.push({
|
|
226
|
+
repo: issue.repo,
|
|
227
|
+
title: issue.title,
|
|
228
|
+
url: result.html_url,
|
|
229
|
+
number: result.number,
|
|
230
|
+
});
|
|
231
|
+
state.completed.push(issue.title);
|
|
232
|
+
if (config.mode.resumable) this._saveState(state);
|
|
233
|
+
|
|
234
|
+
drawBar(i + 1, pending.length);
|
|
235
|
+
if (!this.silent)
|
|
236
|
+
process.stdout.write(
|
|
237
|
+
`\x1b[2K ${c.yellow}↑${c.reset} ${c.dim}#${result.number ?? i + 1}${c.reset} ` +
|
|
238
|
+
`${issue.title.slice(0, 48).padEnd(48)} ${c.dim}${result.html_url}${c.reset}\n`
|
|
239
|
+
);
|
|
240
|
+
} else {
|
|
241
|
+
// ── CREATE path ──────────────────────────────────────
|
|
242
|
+
result = await createIssue(issue, config.github.token, config.mode.dryRun);
|
|
243
|
+
results.created.push({
|
|
244
|
+
repo: issue.repo,
|
|
245
|
+
title: issue.title,
|
|
246
|
+
url: result.html_url,
|
|
247
|
+
number: result.number,
|
|
248
|
+
});
|
|
249
|
+
state.completed.push(issue.title);
|
|
250
|
+
if (config.mode.resumable) this._saveState(state);
|
|
251
|
+
|
|
252
|
+
drawBar(i + 1, pending.length);
|
|
253
|
+
if (!this.silent)
|
|
254
|
+
process.stdout.write(
|
|
255
|
+
`\x1b[2K ${c.green}✔${c.reset} ${c.dim}#${result.number ?? i + 1}${c.reset} ` +
|
|
256
|
+
`${issue.title.slice(0, 48).padEnd(48)} ${c.dim}${result.html_url}${c.reset}\n`
|
|
257
|
+
);
|
|
258
|
+
}
|
|
227
259
|
} catch (err) {
|
|
228
260
|
drawBar(i + 1, pending.length, `${c.red}failed${c.reset}`);
|
|
229
261
|
if (!this.silent)
|
|
230
262
|
process.stdout.write(
|
|
231
263
|
`\x1b[2K ${c.red}✖${c.reset} ${issue.title.slice(0, 48).padEnd(48)} ` +
|
|
232
|
-
|
|
264
|
+
`${c.dim}${err.message.startsWith('DUPLICATE')
|
|
265
|
+
? 'already exists (skipped)'
|
|
266
|
+
: err.message.startsWith('UPDATE_NOT_FOUND')
|
|
267
|
+
? 'issue not found for update'
|
|
268
|
+
: err.message.slice(0, 40)
|
|
269
|
+
}${c.reset}\n`
|
|
233
270
|
);
|
|
234
|
-
results.failed.push({
|
|
235
|
-
|
|
271
|
+
results.failed.push({
|
|
272
|
+
action: issue.action,
|
|
273
|
+
repo: issue.repo,
|
|
274
|
+
title: issue.title,
|
|
275
|
+
error: err.message,
|
|
276
|
+
});
|
|
277
|
+
state.failed.push({
|
|
278
|
+
action: issue.action,
|
|
279
|
+
repo: issue.repo,
|
|
280
|
+
title: issue.title,
|
|
281
|
+
error: err.message,
|
|
282
|
+
});
|
|
236
283
|
if (config.mode.resumable) this._saveState(state);
|
|
237
284
|
}
|
|
238
285
|
|
|
239
286
|
// ── Delay between issues ──────────────────────────────────
|
|
240
287
|
if (i < pending.length - 1) {
|
|
241
|
-
// Never sleep during a dry run / preview — it's pointless
|
|
242
288
|
if (safeMode && !config.mode.dryRun) {
|
|
243
289
|
const delay = this._pickDelay();
|
|
244
290
|
await liveCountdown(Math.round(delay / 1000), pending.length, i + 1, delay, drawBar);
|
|
@@ -261,6 +307,7 @@ export class IssueCannon {
|
|
|
261
307
|
dryRun: config.mode.dryRun,
|
|
262
308
|
safeMode: config.mode.safeMode,
|
|
263
309
|
created: results.created,
|
|
310
|
+
updated: results.updated,
|
|
264
311
|
failed: results.failed,
|
|
265
312
|
};
|
|
266
313
|
fs.writeFileSync(logPath, JSON.stringify(logData, null, 2));
|
|
@@ -271,7 +318,7 @@ export class IssueCannon {
|
|
|
271
318
|
if (!results.failed.length && config.mode.resumable) {
|
|
272
319
|
try {
|
|
273
320
|
fs.unlinkSync(config.stateFile);
|
|
274
|
-
} catch {}
|
|
321
|
+
} catch { }
|
|
275
322
|
}
|
|
276
323
|
|
|
277
324
|
return results;
|
|
@@ -289,7 +336,7 @@ export class IssueCannon {
|
|
|
289
336
|
try {
|
|
290
337
|
if (fs.existsSync(this.config.stateFile))
|
|
291
338
|
return JSON.parse(fs.readFileSync(this.config.stateFile, 'utf-8'));
|
|
292
|
-
} catch {}
|
|
339
|
+
} catch { }
|
|
293
340
|
return { completed: [], failed: [] };
|
|
294
341
|
}
|
|
295
342
|
|
|
@@ -348,10 +395,21 @@ export class IssueCannon {
|
|
|
348
395
|
console.log('');
|
|
349
396
|
}
|
|
350
397
|
|
|
398
|
+
if (results.updated.length) {
|
|
399
|
+
console.log(`${c.yellow}${c.bold} ↑ Updated: ${results.updated.length}${c.reset}\n`);
|
|
400
|
+
boxTable(
|
|
401
|
+
results.updated.map((r, i) => [`#${r.number ?? i + 1}`, r.repo, r.title, r.url || '']),
|
|
402
|
+
['#', 'Repo', 'Title', 'URL'],
|
|
403
|
+
[c.yellow, c.blue, c.reset, c.dim]
|
|
404
|
+
);
|
|
405
|
+
console.log('');
|
|
406
|
+
}
|
|
407
|
+
|
|
351
408
|
if (results.failed.length) {
|
|
352
409
|
console.log(`${c.red}${c.bold} ✖ Failed / Skipped: ${results.failed.length}${c.reset}\n`);
|
|
353
410
|
const shortReason = (err = '') => {
|
|
354
411
|
if (err.startsWith('DUPLICATE:')) return '⟳ already exists (skipped)';
|
|
412
|
+
if (err.startsWith('UPDATE_NOT_FOUND:')) return '✕ issue not found for update';
|
|
355
413
|
if (err.includes('not found')) return '✕ repo not found';
|
|
356
414
|
if (err.includes('no permission')) return '✕ no permission';
|
|
357
415
|
if (err.includes('required scope')) return '✕ token missing scope';
|
|
@@ -362,19 +420,20 @@ export class IssueCannon {
|
|
|
362
420
|
return err.slice(0, 40);
|
|
363
421
|
};
|
|
364
422
|
boxTable(
|
|
365
|
-
results.failed.map((r) => [r.repo, r.title, shortReason(r.error)]),
|
|
366
|
-
['Repo', 'Title', 'Reason'],
|
|
367
|
-
[c.blue, c.red, c.yellow]
|
|
423
|
+
results.failed.map((r) => [r.action ?? 'create', r.repo, r.title, shortReason(r.error)]),
|
|
424
|
+
['Action', 'Repo', 'Title', 'Reason'],
|
|
425
|
+
[c.cyan, c.blue, c.red, c.yellow]
|
|
368
426
|
);
|
|
369
427
|
console.log('');
|
|
370
428
|
}
|
|
371
429
|
|
|
372
|
-
const total = results.created.length + results.failed.length;
|
|
430
|
+
const total = results.created.length + results.updated.length + results.failed.length;
|
|
373
431
|
console.log(
|
|
374
432
|
` ${c.dim}Total: ${total} · ` +
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
433
|
+
`Created: ${c.green}${results.created.length}${c.reset}${c.dim} · ` +
|
|
434
|
+
`Updated: ${c.yellow}${results.updated.length}${c.reset}${c.dim} · ` +
|
|
435
|
+
`Failed: ${c.red}${results.failed.length}${c.reset}${c.dim} · ` +
|
|
436
|
+
`Time: ${c.yellow}${elapsed}${c.reset}\n`
|
|
378
437
|
);
|
|
379
438
|
}
|
|
380
439
|
}
|
|
@@ -398,4 +457,4 @@ function fmtDelay(ms) {
|
|
|
398
457
|
const s = Math.round(ms / 1_000);
|
|
399
458
|
const m = Math.floor(s / 60);
|
|
400
459
|
return m > 0 ? `${m}m ${s % 60}s` : `${s}s`;
|
|
401
|
-
}
|
|
460
|
+
}
|
package/src/github.js
CHANGED
|
@@ -37,19 +37,10 @@ export async function getOrCreateMilestone(repo, milestoneName, token) {
|
|
|
37
37
|
|
|
38
38
|
const _labelCache = {};
|
|
39
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
40
|
export async function ensureLabel(repo, name, color, token) {
|
|
49
41
|
const key = `${repo}::${name}`;
|
|
50
42
|
if (_labelCache[key]) return;
|
|
51
43
|
|
|
52
|
-
// Check existing labels
|
|
53
44
|
const listRes = await fetch(`${GITHUB_API}/repos/${repo}/labels?per_page=100`, {
|
|
54
45
|
headers: authHeaders(token),
|
|
55
46
|
});
|
|
@@ -61,7 +52,6 @@ export async function ensureLabel(repo, name, color, token) {
|
|
|
61
52
|
}
|
|
62
53
|
}
|
|
63
54
|
|
|
64
|
-
// Create label
|
|
65
55
|
const createRes = await fetch(`${GITHUB_API}/repos/${repo}/labels`, {
|
|
66
56
|
method: 'POST',
|
|
67
57
|
headers: { ...authHeaders(token), 'Content-Type': 'application/json' },
|
|
@@ -71,8 +61,9 @@ export async function ensureLabel(repo, name, color, token) {
|
|
|
71
61
|
}
|
|
72
62
|
|
|
73
63
|
const _existingTitles = {};
|
|
64
|
+
const _issueNumberCache = {};
|
|
74
65
|
|
|
75
|
-
async function
|
|
66
|
+
async function fetchExistingIssues(repo, token) {
|
|
76
67
|
if (_existingTitles[repo]) return _existingTitles[repo];
|
|
77
68
|
const titles = new Set();
|
|
78
69
|
let page = 1;
|
|
@@ -84,7 +75,13 @@ async function fetchExistingTitles(repo, token) {
|
|
|
84
75
|
if (!res.ok) break;
|
|
85
76
|
const items = await res.json();
|
|
86
77
|
if (!items.length) break;
|
|
87
|
-
items.forEach((i) =>
|
|
78
|
+
items.forEach((i) => {
|
|
79
|
+
const key = i.title.trim().toLowerCase();
|
|
80
|
+
titles.add(key);
|
|
81
|
+
// Cache issue number by title for update lookup
|
|
82
|
+
if (!_issueNumberCache[repo]) _issueNumberCache[repo] = {};
|
|
83
|
+
_issueNumberCache[repo][key] = i.number;
|
|
84
|
+
});
|
|
88
85
|
if (items.length < 100) break;
|
|
89
86
|
page++;
|
|
90
87
|
}
|
|
@@ -92,20 +89,27 @@ async function fetchExistingTitles(repo, token) {
|
|
|
92
89
|
return titles;
|
|
93
90
|
}
|
|
94
91
|
|
|
92
|
+
/**
|
|
93
|
+
* Find an existing issue number by title.
|
|
94
|
+
* Returns null if not found.
|
|
95
|
+
*/
|
|
96
|
+
export async function findIssueByTitle(repo, title, token) {
|
|
97
|
+
await fetchExistingIssues(repo, token);
|
|
98
|
+
const key = title.trim().toLowerCase();
|
|
99
|
+
const number = _issueNumberCache[repo]?.[key];
|
|
100
|
+
return number ?? null;
|
|
101
|
+
}
|
|
102
|
+
|
|
95
103
|
/**
|
|
96
104
|
* Create a single GitHub issue.
|
|
97
105
|
* 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
106
|
*/
|
|
103
107
|
export async function createIssue(issue, token, dryRun = false) {
|
|
104
108
|
const repo = issue.repo?.trim();
|
|
105
109
|
if (!repo) throw new Error(`Issue missing 'repo': "${issue.title}"`);
|
|
106
110
|
|
|
107
111
|
if (!dryRun) {
|
|
108
|
-
const existing = await
|
|
112
|
+
const existing = await fetchExistingIssues(repo, token);
|
|
109
113
|
if (existing.has(issue.title.trim().toLowerCase())) {
|
|
110
114
|
throw new Error(`DUPLICATE: issue already exists in ${repo}`);
|
|
111
115
|
}
|
|
@@ -149,6 +153,64 @@ export async function createIssue(issue, token, dryRun = false) {
|
|
|
149
153
|
return created;
|
|
150
154
|
}
|
|
151
155
|
|
|
156
|
+
/**
|
|
157
|
+
* Update an existing GitHub issue by title match.
|
|
158
|
+
* Finds the issue number from the repo, then PATCHes it.
|
|
159
|
+
*
|
|
160
|
+
* @param {object} issue — { repo, title, body, labels, milestone }
|
|
161
|
+
* @param {string} token — GitHub PAT
|
|
162
|
+
* @param {boolean} dryRun — if true, returns a fake response without hitting the API
|
|
163
|
+
*/
|
|
164
|
+
export async function updateIssue(issue, token, dryRun = false) {
|
|
165
|
+
const repo = issue.repo?.trim();
|
|
166
|
+
if (!repo) throw new Error(`Issue missing 'repo': "${issue.title}"`);
|
|
167
|
+
|
|
168
|
+
if (dryRun) {
|
|
169
|
+
return {
|
|
170
|
+
html_url: `https://github.com/${repo}/issues/0`,
|
|
171
|
+
number: 0,
|
|
172
|
+
_dryRun: true,
|
|
173
|
+
_updated: true,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Find existing issue number by title
|
|
178
|
+
const issueNumber = await findIssueByTitle(repo, issue.title, token);
|
|
179
|
+
if (!issueNumber) {
|
|
180
|
+
throw new Error(`UPDATE_NOT_FOUND: no issue with title "${issue.title}" found in ${repo}`);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const labels = normLabels(issue.labels);
|
|
184
|
+
const milestoneNumber = await getOrCreateMilestone(repo, issue.milestone, token);
|
|
185
|
+
|
|
186
|
+
const payload = {
|
|
187
|
+
title: issue.title?.trim(),
|
|
188
|
+
body: issue.body?.trim() ?? '',
|
|
189
|
+
labels,
|
|
190
|
+
...(milestoneNumber ? { milestone: milestoneNumber } : {}),
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
const res = await fetch(`${GITHUB_API}/repos/${repo}/issues/${issueNumber}`, {
|
|
194
|
+
method: 'PATCH',
|
|
195
|
+
headers: { ...authHeaders(token), 'Content-Type': 'application/json' },
|
|
196
|
+
body: JSON.stringify(payload),
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
if (!res.ok) {
|
|
200
|
+
const text = await res.text();
|
|
201
|
+
const hint =
|
|
202
|
+
res.status === 403
|
|
203
|
+
? ' — check token has "repo" or "public_repo" scope'
|
|
204
|
+
: res.status === 401
|
|
205
|
+
? ' — token invalid or expired; run: cannon auth login'
|
|
206
|
+
: '';
|
|
207
|
+
throw new Error(`GitHub ${res.status} on ${repo}#${issueNumber}${hint}: ${text}`);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const updated = await res.json();
|
|
211
|
+
return { ...updated, _updated: true };
|
|
212
|
+
}
|
|
213
|
+
|
|
152
214
|
function authHeaders(token) {
|
|
153
215
|
return {
|
|
154
216
|
Authorization: `Bearer ${token}`,
|
|
@@ -164,4 +226,4 @@ function normLabels(raw) {
|
|
|
164
226
|
.split(',')
|
|
165
227
|
.map((l) => l.trim())
|
|
166
228
|
.filter(Boolean);
|
|
167
|
-
}
|
|
229
|
+
}
|
package/src/loaders/index.js
CHANGED
|
@@ -17,6 +17,8 @@ const LOADERS = {
|
|
|
17
17
|
array: async (opts) => opts.data ?? [],
|
|
18
18
|
};
|
|
19
19
|
|
|
20
|
+
const VALID_ACTIONS = ['create', 'update'];
|
|
21
|
+
|
|
20
22
|
/**
|
|
21
23
|
* @param {{ source: string, [key: string]: any }} opts
|
|
22
24
|
* @returns {Promise<object[]>}
|
|
@@ -34,11 +36,17 @@ export async function loadIssues(opts = {}) {
|
|
|
34
36
|
|
|
35
37
|
const issues = await loader(rest);
|
|
36
38
|
|
|
37
|
-
// Normalise: ensure required fields exist
|
|
39
|
+
// Normalise: ensure required fields exist and preserve action field
|
|
38
40
|
return issues.map((row, i) => {
|
|
39
41
|
if (!row.title) throw new Error(`Issue at index ${i} is missing "title"`);
|
|
40
42
|
if (!row.repo) throw new Error(`Issue "${row.title}" is missing "repo"`);
|
|
43
|
+
|
|
44
|
+
// Resolve action — default to 'create' if not specified or blank
|
|
45
|
+
const rawAction = (row.action ?? '').trim().toLowerCase();
|
|
46
|
+
const action = VALID_ACTIONS.includes(rawAction) ? rawAction : 'create';
|
|
47
|
+
|
|
41
48
|
return {
|
|
49
|
+
action,
|
|
42
50
|
repo: row.repo.trim(),
|
|
43
51
|
title: row.title.trim(),
|
|
44
52
|
body: row.body?.trim() ?? '',
|
|
@@ -48,4 +56,4 @@ export async function loadIssues(opts = {}) {
|
|
|
48
56
|
track: row.track?.trim() ?? '',
|
|
49
57
|
};
|
|
50
58
|
});
|
|
51
|
-
}
|
|
59
|
+
}
|