ework-aio 0.1.6 → 0.1.9

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.
@@ -0,0 +1,640 @@
1
+ #!/usr/bin/env bun
2
+ // ework-aio migrate: Gitea → ework-web migration via the shared Gitea REST
3
+ // protocol (source = Gitea; target = ework-web's Gitea-compat shim).
4
+ //
5
+ // Invariants that aren't obvious from the code alone:
6
+ // 1. Target POSTs MUST authenticate as the `awork` bot user. ework-mirror
7
+ // treats `awork` as a self-emitter and skips echo-back to source Gitea.
8
+ // Any other login → duplicates in source. We verify the token resolves
9
+ // to `awork` on startup and refuse to run otherwise.
10
+ // 2. Idempotent. A SQLite ledger keyed on (source_url, source_id) records
11
+ // every migrated item. Re-runs only catch up. The user anticipates two
12
+ // passes: bulk first while :1195 still active, catch-up after cutover.
13
+ // 3. Out of scope (v1): attachments, edits, deletes, labels, milestones,
14
+ // assignments, reactions.
15
+
16
+ import { Database } from "bun:sqlite";
17
+ import { parseArgs } from "node:util";
18
+ import { mkdirSync, existsSync, writeFileSync, readFileSync } from "node:fs";
19
+ import { homedir } from "node:os";
20
+ import { join } from "node:path";
21
+ import { setTimeout as sleep } from "node:timers/promises";
22
+
23
+
24
+ const { values } = parseArgs({
25
+ options: {
26
+ "source-url": { type: "string" },
27
+ "source-token": { type: "string" },
28
+ "target-url": { type: "string" },
29
+ "target-token": { type: "string" },
30
+ repo: { type: "string", multiple: true, default: [] },
31
+ "mark-complete": { type: "boolean", default: false },
32
+ "dry-run": { type: "boolean", default: false },
33
+ ledger: { type: "string" },
34
+ "data-dir": { type: "string" },
35
+ "sleep-ms": { type: "string" },
36
+ "ework-db": { type: "string" },
37
+ help: { type: "boolean", default: false },
38
+ },
39
+ allowPositionals: true,
40
+ tokens: true,
41
+ });
42
+
43
+ if (values.help) {
44
+ process.stdout.write(`Usage: ework-aio migrate --source-url URL --source-token TOKEN [options]
45
+
46
+ Required:
47
+ --source-url URL Source Gitea base URL (e.g. http://192.168.10.96:3300)
48
+ --source-token TOKEN Source Gitea access token (read access to all repos
49
+ you want to migrate)
50
+
51
+ Target (auto-filled from ~/.local/share/ework-aio/ework-web/.env if missing):
52
+ --target-url URL ework-web URL (default http://[::1]:<WORK_PORT>)
53
+ --target-token TOKEN ework-web PAT for the \`awork\` bot user. MUST be the
54
+ \`awork\` login so ework-mirror sees a self-emitter
55
+ and skips echo-back to source Gitea.
56
+
57
+ Selection:
58
+ --repo owner/name Only migrate these repos (repeatable). Default: all
59
+ repos visible to --source-token.
60
+
61
+ Misc:
62
+ --mark-complete Write .migration-complete flag after a successful
63
+ run. Signals that :1196 is ready to serve users.
64
+ --dry-run Don't POST anything; just print what would happen
65
+ and what's already in the ledger.
66
+ --ledger PATH Override ledger DB path.
67
+ --data-dir PATH Override ework-aio data dir.
68
+ --sleep-ms N Politeness delay between target POSTs (default 50).
69
+ --ework-db PATH Deprecated. Accepted for backward compat, ignored.
70
+ Timestamps now flow through the ework-web API
71
+ (requires ework-web ≥ 0.1.2). For older targets,
72
+ run scripts/backfill-timestamps.ts after migration.
73
+ -h, --help Show this help.
74
+ `);
75
+ process.exit(0);
76
+ }
77
+
78
+
79
+ const DATA_DIR = values["data-dir"] ?? join(homedir(), ".local/share/ework-aio");
80
+ const LEDGER_PATH = values["ledger"] ?? join(DATA_DIR, "migration-ledger.db");
81
+ const COMPLETE_FLAG = join(DATA_DIR, ".migration-complete");
82
+ const SLEEP_MS = Number(values["sleep-ms"] ?? 50);
83
+
84
+ // Optional direct-DB access to ework.db is no longer required for timestamp
85
+ // preservation: ework-web ≥ 0.1.2 accepts created_at/updated_at/state/closed_at
86
+ // in POST/PATCH bodies. The --ework-db flag remains accepted for backward
87
+ // compat but is a no-op now. For pre-0.1.2 targets, use
88
+ // scripts/backfill-timestamps.ts after migration.
89
+ const EWORK_DB_PATH = values["ework-db"] ?? join(DATA_DIR, "ework-web/ework.db");
90
+ if (values["ework-db"]) {
91
+ console.error(`note: --ework-db is deprecated (timestamps now flow via API).`);
92
+ console.error(` Path was ${EWORK_DB_PATH}; ignoring.`);
93
+ }
94
+ const sourceUrl = (values["source-url"] ?? "").replace(/\/+$/, "");
95
+ const sourceToken = values["source-token"] ?? "";
96
+
97
+ let targetUrl = (values["target-url"] ?? "").replace(/\/+$/, "");
98
+ let targetToken = values["target-token"] ?? "";
99
+
100
+ if (!sourceUrl || !sourceToken) {
101
+ console.error("error: --source-url and --source-token are required");
102
+ process.exit(2);
103
+ }
104
+
105
+ // Auto-fill target from ework-web .env. We read the file directly rather
106
+ // than spawning a shell because this script may run in contexts where
107
+ // systemd isn't fully initialised yet.
108
+ if (!targetUrl || !targetToken) {
109
+ const envPath = join(DATA_DIR, "ework-web/.env");
110
+ if (existsSync(envPath)) {
111
+ const kv: Record<string, string> = {};
112
+ for (const line of readFileSync(envPath, "utf8").split("\n")) {
113
+ const m = line.match(/^\s*([A-Z_][A-Z0-9_]*)\s*=\s*(.*)\s*$/);
114
+ if (m) kv[m[1]] = m[2].replace(/^["']|["']$/g, "");
115
+ }
116
+ if (!targetUrl && kv.WORK_PORT) {
117
+ targetUrl = `http://[::1]:${kv.WORK_PORT}`;
118
+ }
119
+ if (!targetToken && kv.WORK_TOKEN) {
120
+ // WORK_TOKEN is the admin bootstrap token (resolves to operator login,
121
+ // usually `dog`). This works but bypasses loop-suppression — only use
122
+ // it for --dry-run or if ework-mirror isn't installed yet.
123
+ targetToken = kv.WORK_TOKEN;
124
+ }
125
+ }
126
+ }
127
+
128
+ if (!targetUrl || !targetToken) {
129
+ console.error(
130
+ "error: --target-url and --target-token required (and couldn't auto-fill from ework-web/.env)"
131
+ );
132
+ process.exit(2);
133
+ }
134
+
135
+
136
+ mkdirSync(DATA_DIR, { recursive: true });
137
+ const ledger = new Database(LEDGER_PATH);
138
+ ledger.exec("PRAGMA journal_mode = WAL");
139
+ ledger.exec("PRAGMA synchronous = NORMAL");
140
+ ledger.exec(`
141
+ CREATE TABLE IF NOT EXISTS issue_map (
142
+ source_url TEXT NOT NULL,
143
+ source_repo TEXT NOT NULL,
144
+ source_issue_number INTEGER NOT NULL,
145
+ target_repo TEXT NOT NULL,
146
+ target_issue_number INTEGER NOT NULL,
147
+ migrated_at TEXT NOT NULL,
148
+ PRIMARY KEY (source_url, source_repo, source_issue_number)
149
+ );
150
+ CREATE TABLE IF NOT EXISTS comment_map (
151
+ source_url TEXT NOT NULL,
152
+ source_comment_id INTEGER NOT NULL,
153
+ source_repo TEXT NOT NULL,
154
+ target_repo TEXT NOT NULL,
155
+ target_issue_number INTEGER NOT NULL,
156
+ target_comment_id INTEGER NOT NULL,
157
+ migrated_at TEXT NOT NULL,
158
+ PRIMARY KEY (source_url, source_comment_id)
159
+ );
160
+ CREATE TABLE IF NOT EXISTS repo_map (
161
+ source_url TEXT NOT NULL,
162
+ source_repo TEXT NOT NULL,
163
+ target_repo TEXT NOT NULL,
164
+ migrated_at TEXT NOT NULL,
165
+ PRIMARY KEY (source_url, source_repo)
166
+ );
167
+ `);
168
+
169
+ const stmt = {
170
+ insIssue: ledger.prepare(
171
+ `INSERT OR IGNORE INTO issue_map VALUES (?, ?, ?, ?, ?, ?)`
172
+ ),
173
+ seenIssue: ledger.prepare(
174
+ `SELECT target_issue_number FROM issue_map WHERE source_url=? AND source_repo=? AND source_issue_number=?`
175
+ ),
176
+ insComment: ledger.prepare(
177
+ `INSERT OR IGNORE INTO comment_map VALUES (?, ?, ?, ?, ?, ?, ?)`
178
+ ),
179
+ seenComment: ledger.prepare(
180
+ `SELECT 1 FROM comment_map WHERE source_url=? AND source_comment_id=?`
181
+ ),
182
+ insRepo: ledger.prepare(
183
+ `INSERT OR IGNORE INTO repo_map VALUES (?, ?, ?, ?)`
184
+ ),
185
+ seenRepo: ledger.prepare(
186
+ `SELECT 1 FROM repo_map WHERE source_url=? AND source_repo=?`
187
+ ),
188
+ countIssues: ledger.prepare(`SELECT COUNT(*) AS n FROM issue_map`),
189
+ countComments: ledger.prepare(`SELECT COUNT(*) AS n FROM comment_map`),
190
+ countRepos: ledger.prepare(`SELECT COUNT(*) AS n FROM repo_map`),
191
+ };
192
+
193
+
194
+ class ApiError extends Error {
195
+ constructor(public status: number, message: string, public body?: string) {
196
+ super(message);
197
+ }
198
+ }
199
+
200
+ async function withRetry<T>(label: string, fn: () => Promise<T>): Promise<T> {
201
+ let lastErr: unknown;
202
+ for (let attempt = 0; attempt < 4; attempt++) {
203
+ try {
204
+ return await fn();
205
+ } catch (e) {
206
+ lastErr = e;
207
+ // Don't retry on 4xx (caller will handle or surface); only 5xx / network.
208
+ if (e instanceof ApiError && e.status >= 400 && e.status < 500) throw e;
209
+ if (attempt < 3) {
210
+ const backoff = 500 * 2 ** attempt;
211
+ console.error(` ${label}: transient error (${errSummary(e)}); retry in ${backoff}ms`);
212
+ await sleep(backoff);
213
+ }
214
+ }
215
+ }
216
+ throw lastErr;
217
+ }
218
+
219
+ function errSummary(e: unknown): string {
220
+ if (e instanceof Error) return e.message;
221
+ return String(e);
222
+ }
223
+
224
+ async function srcGet<T = any>(path: string): Promise<T | null> {
225
+ return withRetry(`GET ${sourceUrl}${path}`, async () => {
226
+ const r = await fetch(`${sourceUrl}${path}`, {
227
+ headers: { Authorization: `token ${sourceToken}` },
228
+ });
229
+ if (r.status === 404) return null;
230
+ if (r.status === 409) return null; // empty/archived repos sometimes 409
231
+ if (!r.ok) {
232
+ throw new ApiError(r.status, `source ${path}: HTTP ${r.status}`, await r.text());
233
+ }
234
+ return (await r.json()) as T;
235
+ });
236
+ }
237
+
238
+ async function tgtGet(path: string): Promise<any | null> {
239
+ return withRetry(`GET ${targetUrl}${path}`, async () => {
240
+ const r = await fetch(`${targetUrl}${path}`, {
241
+ headers: { Authorization: `Bearer ${targetToken}` },
242
+ });
243
+ if (r.status === 404) return null;
244
+ if (!r.ok) {
245
+ throw new ApiError(r.status, `target ${path}: HTTP ${r.status}`, await r.text());
246
+ }
247
+ return await r.json();
248
+ });
249
+ }
250
+
251
+ async function tgtPostJson(path: string, body: unknown): Promise<any> {
252
+ if (values["dry-run"]) {
253
+ console.error(` [dry-run] POST ${targetUrl}${path} ${JSON.stringify(body).slice(0, 100)}…`);
254
+ return { number: -1, id: -1 };
255
+ }
256
+ return withRetry(`POST ${targetUrl}${path}`, async () => {
257
+ const r = await fetch(`${targetUrl}${path}`, {
258
+ method: "POST",
259
+ headers: {
260
+ Authorization: `Bearer ${targetToken}`,
261
+ "Content-Type": "application/json",
262
+ },
263
+ body: JSON.stringify(body),
264
+ });
265
+ if (!r.ok) {
266
+ throw new ApiError(r.status, `target POST ${path}: HTTP ${r.status}`, await r.text());
267
+ }
268
+ return await r.json();
269
+ });
270
+ }
271
+
272
+ async function tgtPatchJson(path: string, body: unknown): Promise<void> {
273
+ if (values["dry-run"]) {
274
+ console.error(` [dry-run] PATCH ${targetUrl}${path} ${JSON.stringify(body)}`);
275
+ return;
276
+ }
277
+ await withRetry(`PATCH ${targetUrl}${path}`, async () => {
278
+ const r = await fetch(`${targetUrl}${path}`, {
279
+ method: "PATCH",
280
+ headers: {
281
+ Authorization: `Bearer ${targetToken}`,
282
+ "Content-Type": "application/json",
283
+ },
284
+ body: JSON.stringify(body),
285
+ });
286
+ if (!r.ok) {
287
+ throw new ApiError(r.status, `target PATCH ${path}: HTTP ${r.status}`, await r.text());
288
+ }
289
+ });
290
+ }
291
+
292
+ async function tgtPostForm(path: string, form: Record<string, string>): Promise<void> {
293
+ // ework-web's /projects flow is form-encoded + 303-redirect on success.
294
+ if (values["dry-run"]) {
295
+ console.error(` [dry-run] POST ${targetUrl}${path} (form) ${JSON.stringify(form)}`);
296
+ return;
297
+ }
298
+ await withRetry(`POST ${targetUrl}${path}`, async () => {
299
+ const r = await fetch(`${targetUrl}${path}`, {
300
+ method: "POST",
301
+ headers: {
302
+ Authorization: `Bearer ${targetToken}`,
303
+ "Content-Type": "application/x-www-form-urlencoded",
304
+ },
305
+ body: new URLSearchParams(form).toString(),
306
+ redirect: "manual", // don't chase the 303 — it just leads back to UI
307
+ });
308
+ // 303 / 302 = success (handler redirects to issue list). Other 3xx also OK.
309
+ if (r.status >= 300 && r.status < 400) return;
310
+ if (!r.ok) {
311
+ throw new ApiError(r.status, `target POST ${path}: HTTP ${r.status}`, await r.text());
312
+ }
313
+ });
314
+ }
315
+
316
+
317
+ interface SourceRepo {
318
+ full_name: string;
319
+ owner: string;
320
+ name: string;
321
+ }
322
+
323
+ async function listSourceRepos(): Promise<SourceRepo[]> {
324
+ const out: SourceRepo[] = [];
325
+ let page = 1;
326
+ while (true) {
327
+ const data = await srcGet<{ data: Array<{ full_name: string; owner: { login: string }; name: string }> }>(
328
+ `/api/v1/repos/search?limit=50&page=${page}`
329
+ );
330
+ if (!data || !Array.isArray(data.data) || data.data.length === 0) break;
331
+ for (const r of data.data) {
332
+ out.push({ full_name: r.full_name, owner: r.owner.login, name: r.name });
333
+ }
334
+ if (data.data.length < 50) break;
335
+ page++;
336
+ }
337
+ return out;
338
+ }
339
+
340
+ interface SourceIssue {
341
+ number: number;
342
+ title: string;
343
+ body: string | null;
344
+ state: "open" | "closed";
345
+ created_at: string;
346
+ updated_at?: string;
347
+ closed_at?: string | null;
348
+ user: { login: string };
349
+ }
350
+
351
+ async function listSourceIssues(owner: string, name: string): Promise<SourceIssue[]> {
352
+ const out: SourceIssue[] = [];
353
+ let page = 1;
354
+ while (true) {
355
+ // Source Gitea (Forgejo) doesn't honor sort/direction params — we sort
356
+ // client-side below. Issue-number parity with source only holds on an
357
+ // empty target, which is the common first-run case.
358
+ const data = await srcGet<SourceIssue[]>(
359
+ `/api/v1/repos/${owner}/${name}/issues?state=all&limit=50&page=${page}`
360
+ );
361
+ if (!data || !Array.isArray(data) || data.length === 0) break;
362
+ out.push(...data);
363
+ if (data.length < 50) break;
364
+ page++;
365
+ }
366
+ out.sort((a, b) => a.number - b.number);
367
+ return out;
368
+ }
369
+
370
+ interface SourceComment {
371
+ id: number;
372
+ body: string | null;
373
+ created_at: string;
374
+ updated_at?: string;
375
+ user: { login: string };
376
+ }
377
+
378
+ async function listSourceComments(
379
+ owner: string,
380
+ name: string,
381
+ issueNumber: number
382
+ ): Promise<SourceComment[]> {
383
+ // Forgejo's /comments endpoint ignores `page` — every page returns the
384
+ // full set. Detect that via a seen-IDs set: once we re-see an ID, stop.
385
+ const out: SourceComment[] = [];
386
+ const seen = new Set<number>();
387
+ let page = 1;
388
+ while (true) {
389
+ const data = await srcGet<SourceComment[]>(
390
+ `/api/v1/repos/${owner}/${name}/issues/${issueNumber}/comments?limit=50&page=${page}`
391
+ );
392
+ if (!data || !Array.isArray(data) || data.length === 0) break;
393
+ let dupes = 0;
394
+ for (const c of data) {
395
+ if (seen.has(c.id)) {
396
+ dupes++;
397
+ continue;
398
+ }
399
+ seen.add(c.id);
400
+ out.push(c);
401
+ }
402
+ // If every item on this page was already seen, Forgejo is repeating → stop.
403
+ if (dupes === data.length) break;
404
+ if (data.length < 50) break;
405
+ page++;
406
+ }
407
+ out.sort((a, b) => a.id - b.id);
408
+ return out;
409
+ }
410
+
411
+
412
+ async function main() {
413
+ const started = Date.now();
414
+
415
+ // Verify target token resolves to `awork` (loop-suppression requirement).
416
+ const me = await tgtGet(`/api/v1/user`);
417
+ if (!me || typeof me.login !== "string") {
418
+ console.error("error: couldn't read /api/v1/user on target — token invalid?");
419
+ process.exit(2);
420
+ }
421
+ const wantsLogin = "awork";
422
+ if (me.login !== wantsLogin) {
423
+ console.error(`error: target token resolves to user "${me.login}", expected "${wantsLogin}".`);
424
+ console.error(` Loop suppression requires the awork bot PAT.`);
425
+ console.error(` Mint one in ework-web: login as admin → Admin → Users → create`);
426
+ console.error(` \`awork\` (kind=bot), then as awork → /me/tokens → create PAT.`);
427
+ if (!values["dry-run"]) process.exit(2);
428
+ else console.error(` (continuing because --dry-run)`);
429
+ } else {
430
+ console.error(`ok: target auth resolves to "${me.login}" (loop-suppression-safe)`);
431
+ }
432
+
433
+ const repoFilter = values.repo;
434
+ const allRepos = await listSourceRepos();
435
+ const wantRepos = repoFilter.length
436
+ ? allRepos.filter((r) => repoFilter.includes(r.full_name))
437
+ : allRepos;
438
+
439
+ if (wantRepos.length === 0) {
440
+ console.error(`no repos to process (filter: ${repoFilter.join(",") || "<all>"})`);
441
+ console.error(`source listed ${allRepos.length} repos total`);
442
+ process.exit(0);
443
+ }
444
+
445
+ console.error(
446
+ `migrating ${wantRepos.length} repo(s) from ${sourceUrl} → ${targetUrl}` +
447
+ (values["dry-run"] ? " (DRY RUN)" : "")
448
+ );
449
+
450
+ let newIssues = 0;
451
+ let newComments = 0;
452
+ let skippedIssues = 0;
453
+ let skippedComments = 0;
454
+ let failedIssues = 0;
455
+ let failedComments = 0;
456
+
457
+ for (const repo of wantRepos) {
458
+ const fullName = `${repo.owner}/${repo.name}`;
459
+ console.error(`\n[${fullName}]`);
460
+
461
+ if (!stmt.seenRepo.get(sourceUrl, fullName)) {
462
+ const existing = await tgtGet(`/api/v1/repos/${repo.owner}/${repo.name}`);
463
+ if (!existing) {
464
+ console.error(` target project missing; creating as ${me.login}…`);
465
+ try {
466
+ await tgtPostForm(`/projects`, {
467
+ owner: repo.owner,
468
+ name: repo.name,
469
+ description: `(migrated from ${sourceUrl}/${fullName})`,
470
+ });
471
+ } catch (e) {
472
+ // Most likely race with parallel migration or pre-existing. Recheck.
473
+ const recheck = await tgtGet(`/api/v1/repos/${repo.owner}/${repo.name}`);
474
+ if (!recheck) {
475
+ console.error(` failed to create target project: ${errSummary(e)}`);
476
+ continue;
477
+ }
478
+ }
479
+ }
480
+ if (!values["dry-run"]) {
481
+ stmt.insRepo.run(sourceUrl, fullName, fullName, new Date().toISOString());
482
+ }
483
+ } else {
484
+ console.error(` repo already in ledger (skip ensure)`);
485
+ }
486
+
487
+ const issues = await listSourceIssues(repo.owner, repo.name);
488
+ console.error(` ${issues.length} source issue(s)`);
489
+
490
+ let i = 0;
491
+ for (const iss of issues) {
492
+ i++;
493
+ const mapped = stmt.seenIssue.get(sourceUrl, fullName, iss.number) as
494
+ | { target_issue_number: number }
495
+ | undefined;
496
+
497
+ let targetNum: number;
498
+ let issueIsNew = false;
499
+ if (mapped) {
500
+ targetNum = mapped.target_issue_number;
501
+ skippedIssues++;
502
+ } else {
503
+ issueIsNew = true;
504
+ console.error(` [${i}/${issues.length}] issue #${iss.number} new → POST…`);
505
+ const prefixedBody = prefixBody(iss.body, iss.user.login, iss.created_at, sourceUrl, fullName);
506
+ try {
507
+ // ework-web ≥ 0.1.2 accepts created_at/updated_at/state/closed_at in
508
+ // POST body, so timestamps + closed state survive migration in a
509
+ // single round-trip (no --ework-db inline UPDATE fallback needed).
510
+ const postBody: Record<string, unknown> = {
511
+ title: iss.title,
512
+ body: prefixedBody,
513
+ created_at: iss.created_at,
514
+ updated_at: iss.updated_at ?? iss.created_at,
515
+ };
516
+ if (iss.state === "closed") {
517
+ postBody.state = "closed";
518
+ postBody.closed_at = iss.closed_at ?? iss.updated_at ?? iss.created_at;
519
+ }
520
+ const created = await tgtPostJson(
521
+ `/api/v1/repos/${repo.owner}/${repo.name}/issues`,
522
+ postBody
523
+ );
524
+ targetNum = created.number;
525
+ if (!values["dry-run"]) {
526
+ stmt.insIssue.run(
527
+ sourceUrl,
528
+ fullName,
529
+ iss.number,
530
+ fullName,
531
+ targetNum,
532
+ new Date().toISOString()
533
+ );
534
+ }
535
+ newIssues++;
536
+ await sleep(SLEEP_MS);
537
+ } catch (e) {
538
+ failedIssues++;
539
+ console.error(
540
+ ` ! issue #${iss.number} "${iss.title.slice(0, 60)}": ${errSummary(e)}`
541
+ );
542
+ continue;
543
+ }
544
+ }
545
+
546
+ // Comment enumeration. For catch-up runs we still need to enumerate to
547
+ // find new comments added since the last run; the per-comment ledger
548
+ // check makes this idempotent.
549
+ const comments = await listSourceComments(repo.owner, repo.name, iss.number);
550
+ if (comments.length === 0) continue;
551
+ if (issueIsNew) {
552
+ console.error(` ${comments.length} source comment(s)`);
553
+ }
554
+ for (const c of comments) {
555
+ const seen = stmt.seenComment.get(sourceUrl, c.id);
556
+ if (seen) {
557
+ skippedComments++;
558
+ continue;
559
+ }
560
+ try {
561
+ const prefixed = prefixComment(c.body, c.user.login, c.created_at);
562
+ const created = await tgtPostJson(
563
+ `/api/v1/repos/${repo.owner}/${repo.name}/issues/${targetNum}/comments`,
564
+ {
565
+ body: prefixed,
566
+ created_at: c.created_at,
567
+ updated_at: c.updated_at ?? c.created_at,
568
+ }
569
+ );
570
+ if (!values["dry-run"]) {
571
+ stmt.insComment.run(
572
+ sourceUrl,
573
+ c.id,
574
+ fullName,
575
+ fullName,
576
+ targetNum,
577
+ created.id,
578
+ new Date().toISOString()
579
+ );
580
+ }
581
+ newComments++;
582
+ await sleep(SLEEP_MS);
583
+ } catch (e) {
584
+ failedComments++;
585
+ console.error(` ! comment ${c.id}: ${errSummary(e)}`);
586
+ }
587
+ }
588
+ }
589
+ }
590
+
591
+ const elapsed = ((Date.now() - started) / 1000).toFixed(1);
592
+ console.error(`\n--- done in ${elapsed}s ---`);
593
+ console.error(`issues: ${newIssues} new, ${skippedIssues} skipped, ${failedIssues} failed`);
594
+ console.error(`comments: ${newComments} new, ${skippedComments} skipped, ${failedComments} failed`);
595
+ console.error(
596
+ `ledger total: ${(stmt.countRepos.get() as { n: number }).n} repos, ` +
597
+ `${(stmt.countIssues.get() as { n: number }).n} issues, ` +
598
+ `${(stmt.countComments.get() as { n: number }).n} comments`
599
+ );
600
+
601
+ if (failedIssues > 0 || failedComments > 0) {
602
+ console.error("\nnote: some items failed — re-run to retry (idempotent ledger)");
603
+ process.exitCode = 1;
604
+ }
605
+
606
+ if (values["mark-complete"]) {
607
+ if (!values["dry-run"]) {
608
+ writeFileSync(COMPLETE_FLAG, `${new Date().toISOString()}\n`);
609
+ console.error(`\nwrote ${COMPLETE_FLAG}`);
610
+ } else {
611
+ console.error(`\n[dry-run] would write ${COMPLETE_FLAG}`);
612
+ }
613
+ } else {
614
+ console.error(`\n(note: --mark-complete not given; :1196 status will show "not migrated")`);
615
+ }
616
+
617
+ ledger.close();
618
+ }
619
+
620
+ function prefixBody(
621
+ body: string | null,
622
+ author: string,
623
+ createdAt: string,
624
+ sourceUrl: string,
625
+ fullName: string
626
+ ): string {
627
+ const prefix = `> _Migrated from ${sourceUrl}/${fullName} — original author @${author} at ${createdAt}_\n\n`;
628
+ return prefix + (body ?? "");
629
+ }
630
+
631
+ function prefixComment(body: string | null, author: string, createdAt: string): string {
632
+ const prefix = `> _Originally posted by @${author} at ${createdAt}_\n\n`;
633
+ return prefix + (body ?? "");
634
+ }
635
+
636
+ main().catch((e) => {
637
+ console.error(`fatal: ${errSummary(e)}`);
638
+ if (e instanceof Error && e.stack) console.error(e.stack);
639
+ process.exit(1);
640
+ });