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.
- package/README.md +46 -1
- package/bin/ework-aio +378 -3
- package/bin/install.sh +48 -1
- package/package.json +2 -1
- package/scripts/backfill-timestamps.ts +225 -0
- package/scripts/migrate-from-gitea.ts +640 -0
- package/scripts/regression.sh +249 -0
|
@@ -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();
|