ework-aio 0.1.7 → 0.1.10

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,225 @@
1
+ #!/usr/bin/env bun
2
+ // Backfill created_at / updated_at / closed_at on already-migrated issues
3
+ // and comments. The ework-web REST API doesn't accept created_at in POST
4
+ // bodies, so the migrate tool wrote everything with server-now timestamps.
5
+ // We have local filesystem access to ework.db, so we can UPDATE directly.
6
+ //
7
+ // Idempotent: re-running just re-applies the same timestamps.
8
+
9
+ import { Database } from "bun:sqlite";
10
+ import { parseArgs } from "node:util";
11
+ import { existsSync } from "node:fs";
12
+ import { homedir } from "node:os";
13
+ import { join } from "node:path";
14
+ import { setTimeout as sleep } from "node:timers/promises";
15
+
16
+ const { values } = parseArgs({
17
+ options: {
18
+ "source-url": { type: "string" },
19
+ "source-token": { type: "string" },
20
+ "data-dir": { type: "string" },
21
+ "ework-db": { type: "string" },
22
+ ledger: { type: "string" },
23
+ help: { type: "boolean", default: false },
24
+ },
25
+ allowPositionals: false,
26
+ });
27
+
28
+ if (values.help) {
29
+ process.stdout.write(`Usage: ework-aio backfill-timestamps --source-url URL --source-token TOKEN [options]
30
+
31
+ Reads ~/.local/share/ework-aio/migration-ledger.db, fetches original
32
+ created_at / updated_at / closed_at from source Gitea for every migrated
33
+ issue + comment, and UPDATEs ework.db in place. Fixes the wrong "all
34
+ timestamps are migration time" problem.
35
+
36
+ Required:
37
+ --source-url URL Source Gitea base URL
38
+ --source-token TOKEN Source Gitea read token
39
+
40
+ Optional:
41
+ --data-dir PATH ework-aio data dir (default ~/.local/share/ework-aio)
42
+ --ework-db PATH Override path to ework.db
43
+ --ledger PATH Override path to migration-ledger.db
44
+ `);
45
+ process.exit(0);
46
+ }
47
+
48
+ const DATA_DIR = values["data-dir"] ?? join(homedir(), ".local/share/ework-aio");
49
+ const LEDGER_PATH = values.ledger ?? join(DATA_DIR, "migration-ledger.db");
50
+ const EWORK_DB_PATH = values["ework-db"] ?? join(DATA_DIR, "ework-web/ework.db");
51
+ const sourceUrl = (values["source-url"] ?? "").replace(/\/+$/, "");
52
+ const sourceToken = values["source-token"] ?? "";
53
+
54
+ if (!sourceUrl || !sourceToken) {
55
+ console.error("error: --source-url and --source-token required");
56
+ process.exit(2);
57
+ }
58
+ if (!existsSync(LEDGER_PATH)) {
59
+ console.error(`error: ledger not found at ${LEDGER_PATH}`);
60
+ process.exit(2);
61
+ }
62
+ if (!existsSync(EWORK_DB_PATH)) {
63
+ console.error(`error: ework.db not found at ${EWORK_DB_PATH}`);
64
+ process.exit(2);
65
+ }
66
+
67
+ const ledger = new Database(LEDGER_PATH, { readonly: true });
68
+ const ework = new Database(EWORK_DB_PATH);
69
+ ework.exec("PRAGMA journal_mode = WAL");
70
+
71
+ const issueMapRows = ledger
72
+ .query(
73
+ `SELECT source_repo, source_issue_number, target_repo, target_issue_number
74
+ FROM issue_map ORDER BY source_repo, source_issue_number`
75
+ )
76
+ .all() as Array<{
77
+ source_repo: string;
78
+ source_issue_number: number;
79
+ target_repo: string;
80
+ target_issue_number: number;
81
+ }>;
82
+
83
+ const commentMapRows = ledger
84
+ .query(
85
+ `SELECT source_repo, source_comment_id, target_repo, target_comment_id
86
+ FROM comment_map ORDER BY source_repo, source_comment_id`
87
+ )
88
+ .all() as Array<{
89
+ source_repo: string;
90
+ source_comment_id: number;
91
+ target_repo: string;
92
+ target_comment_id: number;
93
+ }>;
94
+
95
+ console.error(
96
+ `backfilling ${issueMapRows.length} issues + ${commentMapRows.length} comments`
97
+ );
98
+
99
+ async function srcGet<T = any>(path: string): Promise<T | null> {
100
+ for (let attempt = 0; attempt < 4; attempt++) {
101
+ try {
102
+ const r = await fetch(`${sourceUrl}${path}`, {
103
+ headers: { Authorization: `token ${sourceToken}` },
104
+ });
105
+ if (r.status === 404) return null;
106
+ if (r.status === 409) return null;
107
+ if (!r.ok) throw new Error(`${path}: HTTP ${r.status}`);
108
+ return (await r.json()) as T;
109
+ } catch (e) {
110
+ if (attempt === 3) throw e;
111
+ await sleep(500 * 2 ** attempt);
112
+ }
113
+ }
114
+ return null;
115
+ }
116
+
117
+ interface SourceIssue {
118
+ number: number;
119
+ created_at: string;
120
+ updated_at: string;
121
+ closed_at: string | null;
122
+ state: "open" | "closed";
123
+ }
124
+
125
+ interface SourceComment {
126
+ id: number;
127
+ created_at: string;
128
+ updated_at: string;
129
+ }
130
+
131
+ const updIssue = ework.prepare(
132
+ `UPDATE issues SET created_at = ?, updated_at = ? WHERE id = ?`
133
+ );
134
+ const getTargetIssueId = ework.prepare(
135
+ `SELECT i.id FROM issues i
136
+ JOIN projects p ON i.project_id = p.id
137
+ WHERE p.owner || '/' || p.name = ? AND i.number = ?`
138
+ );
139
+ const updComment = ework.prepare(
140
+ `UPDATE comments SET created_at = ?, updated_at = ? WHERE id = ?`
141
+ );
142
+
143
+ let issuesDone = 0;
144
+ let issuesSkipped = 0;
145
+ let issuesFailed = 0;
146
+ const started = Date.now();
147
+
148
+ for (const row of issueMapRows) {
149
+ const [owner, name] = row.source_repo.split("/");
150
+ if (!owner || !name) {
151
+ issuesFailed++;
152
+ continue;
153
+ }
154
+ try {
155
+ const src = await srcGet<SourceIssue>(
156
+ `/api/v1/repos/${owner}/${name}/issues/${row.source_issue_number}`
157
+ );
158
+ if (!src) {
159
+ issuesSkipped++;
160
+ continue;
161
+ }
162
+ const target = getTargetIssueId.get(row.target_repo, row.target_issue_number) as
163
+ | { id: number }
164
+ | undefined;
165
+ if (!target) {
166
+ issuesSkipped++;
167
+ continue;
168
+ }
169
+ // ework-web's issues table has no closed_at column — closed time is lost.
170
+ updIssue.run(src.created_at, src.updated_at, target.id);
171
+ issuesDone++;
172
+ if (issuesDone % 50 === 0) {
173
+ console.error(` issues: ${issuesDone}/${issueMapRows.length}`);
174
+ }
175
+ } catch (e) {
176
+ issuesFailed++;
177
+ console.error(` ! issue ${row.source_repo}#${row.source_issue_number}: ${e instanceof Error ? e.message : e}`);
178
+ }
179
+ }
180
+
181
+ // Comments — Forgejo doesn't expose a "comments by ID" batch endpoint, so we
182
+ // hit per-comment GET. With ~14k comments this is ~5 minutes wallclock; fine
183
+ // for a one-shot backfill.
184
+ let commentsDone = 0;
185
+ let commentsSkipped = 0;
186
+ let commentsFailed = 0;
187
+
188
+ for (const c of commentMapRows) {
189
+ const [owner, name] = c.source_repo.split("/");
190
+ if (!owner || !name) {
191
+ commentsFailed++;
192
+ continue;
193
+ }
194
+ try {
195
+ const src = await srcGet<SourceComment>(
196
+ `/api/v1/repos/${owner}/${name}/issues/comments/${c.source_comment_id}`
197
+ );
198
+ if (!src) {
199
+ commentsSkipped++;
200
+ continue;
201
+ }
202
+ updComment.run(src.created_at, src.updated_at, c.target_comment_id);
203
+ commentsDone++;
204
+ if (commentsDone % 200 === 0) {
205
+ console.error(` comments: ${commentsDone}/${commentMapRows.length}`);
206
+ }
207
+ } catch (e) {
208
+ commentsFailed++;
209
+ if (commentsFailed < 10) {
210
+ console.error(` ! comment ${c.source_repo}:${c.source_comment_id}: ${e instanceof Error ? e.message : e}`);
211
+ }
212
+ }
213
+ }
214
+
215
+ const elapsed = ((Date.now() - started) / 1000).toFixed(1);
216
+ console.error(`\n--- done in ${elapsed}s ---`);
217
+ console.error(
218
+ `issues: ${issuesDone} updated, ${issuesSkipped} skipped, ${issuesFailed} failed`
219
+ );
220
+ console.error(
221
+ `comments: ${commentsDone} updated, ${commentsSkipped} skipped, ${commentsFailed} failed`
222
+ );
223
+
224
+ ework.close();
225
+ ledger.close();