@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 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. Create your issues
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
- | `repo` | | `owner/repo` |
209
- | `title` | ✅ | Issue title |
210
- | `body` | | Description |
211
- | `labels` | — | Comma-separated: `bug,auth` |
212
- | `milestone` | — | Auto-created if it doesn't exist |
213
- | `priority` | — | `HIGH` · `MED` · `LOW` (informational) |
214
- | `track` | — | e.g. `auth`, `ui`, `docs` (informational) |
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,Add dark mode,"User request",enhancement,v1.1
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alien-protocol/cannon",
3
- "version": "2.2.2",
3
+ "version": "2.3.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
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, // programmatic overrides still win
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 created, skipping`);
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 created!');
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, estMin;
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 create: ${c.bold}${pending.length}${c.reset} · Delay: ${estLabel}\n`);
179
- this._log('step', '🚀 Creating issues…\n');
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
- `${c.yellow}creating…${c.reset} ${c.dim}${issue.title.slice(0, 35)}${c.reset}`
216
+ `${actionLabel} ${c.dim}${issue.title.slice(0, 35)}${c.reset}`
208
217
  );
209
218
 
210
219
  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
- );
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
- `${c.dim}${err.message.startsWith('DUPLICATE') ? 'already exists (skipped)' : err.message.slice(0, 40)}${c.reset}\n`
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({ repo: issue.repo, title: issue.title, error: err.message });
235
- state.failed.push({ repo: issue.repo, title: issue.title, error: err.message });
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
- `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`
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 fetchExistingTitles(repo, token) {
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) => titles.add(i.title.trim().toLowerCase()));
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 fetchExistingTitles(repo, token);
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
+ }
@@ -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
+ }