ework-web 0.2.1 → 0.4.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/package.json +4 -2
- package/src/auth.ts +8 -8
- package/src/config.ts +2 -2
- package/src/db-admin.ts +566 -0
- package/src/db.ts +286 -49
- package/src/giteaApi.ts +53 -53
- package/src/index.ts +261 -102
- package/src/reactions.ts +2 -2
- package/src/schema-mysql.sql +178 -0
- package/src/schema.sql +40 -40
- package/src/store.ts +339 -379
- package/src/views/home.ts +10 -9
- package/src/views/issueList.ts +4 -4
- package/src/views/issueThread.ts +21 -21
- package/src/views/issues.ts +3 -3
- package/src/views/projectMembers.ts +8 -9
- package/src/views/projectUpstreams.ts +3 -3
- package/src/views/settings.ts +82 -0
- package/src/webhooks.ts +63 -73
package/src/db.ts
CHANGED
|
@@ -1,19 +1,78 @@
|
|
|
1
|
-
//
|
|
2
|
-
// (
|
|
3
|
-
//
|
|
1
|
+
// Storage bootstrap. Owns the AsyncDatabase singleton. Two drivers behind one
|
|
2
|
+
// AsyncDatabase surface: SQLite (bun:sqlite, default) and MySQL (mysql2/promise).
|
|
3
|
+
// Driver picked by WORK_DB_DRIVER (sqlite|mysql). Schema + migrations run in
|
|
4
|
+
// connect(). Callers MUST `await initDB()` once at boot before issuing queries.
|
|
5
|
+
//
|
|
6
|
+
// Table prefix: every table reference in SQL is written as a {{table}} token;
|
|
7
|
+
// applyPrefix() rewrites {{name}} -> <WORK_DB_PREFIX>+name before execution.
|
|
8
|
+
// Default prefix "" leaves SQL identical (backward-compatible with existing
|
|
9
|
+
// ework.db files). WORK_DB_PREFIX is ENV-ONLY (never stored in the config
|
|
10
|
+
// table) — the prefix is needed to locate the config table itself, so it
|
|
11
|
+
// cannot live inside it (chicken-and-egg).
|
|
4
12
|
|
|
5
|
-
import { Database } from "bun:sqlite";
|
|
13
|
+
import { Database, type SQLQueryBindings } from "bun:sqlite";
|
|
14
|
+
import { createPool, type Pool, type PoolConnection, type ResultSetHeader } from "mysql2/promise";
|
|
6
15
|
import { mkdirSync, readFileSync } from "fs";
|
|
7
16
|
import { dirname, join } from "path";
|
|
8
17
|
|
|
18
|
+
// ---- public async interface (driver-agnostic) ----
|
|
19
|
+
export interface DbRunResult {
|
|
20
|
+
/** Rowid of the last inserted row (SQLite lastInsertRowid / MySQL insertId). */
|
|
21
|
+
insertId: number;
|
|
22
|
+
/** Number of rows affected by the statement. */
|
|
23
|
+
changes: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface AsyncDatabase {
|
|
27
|
+
/** SELECT → all matching rows. Empty array when none. */
|
|
28
|
+
all<T = unknown>(sql: string, params?: unknown[]): Promise<T[]>;
|
|
29
|
+
/** SELECT → first matching row or null. */
|
|
30
|
+
get<T = unknown>(sql: string, params?: unknown[]): Promise<T | null>;
|
|
31
|
+
/** INSERT/UPDATE/DELETE → insertId + affected-row count. */
|
|
32
|
+
run(sql: string, params?: unknown[]): Promise<DbRunResult>;
|
|
33
|
+
/** Execute DDL / raw statement (no params, no rows back). */
|
|
34
|
+
exec(sql: string): Promise<void>;
|
|
35
|
+
/** Run fn inside a transaction: commit on resolve, rollback on throw. */
|
|
36
|
+
transaction<T>(fn: () => Promise<T>): Promise<T>;
|
|
37
|
+
/** Release the connection/pool. Idempotent. */
|
|
38
|
+
close(): Promise<void>;
|
|
39
|
+
/** Driver dialect — lets callers branch on SQLite vs MySQL specifics. */
|
|
40
|
+
readonly dialect: "sqlite" | "mysql";
|
|
41
|
+
}
|
|
42
|
+
|
|
9
43
|
const DB_PATH =
|
|
10
44
|
process.env.WORK_DB_PATH ||
|
|
11
45
|
join(process.env.XDG_DATA_HOME || `${process.env.HOME}/.local/share`, "ework", "ework.db");
|
|
12
46
|
|
|
13
|
-
|
|
47
|
+
// ---- table prefix (env-only; read once at module load) ----
|
|
48
|
+
// Validated as a safe SQL identifier prefix. Empty = no prefix (default,
|
|
49
|
+
// backward-compatible). A non-empty prefix lets multiple ework instances (or
|
|
50
|
+
// ework + another app) share one database without colliding on table names.
|
|
51
|
+
const DB_PREFIX = (() => {
|
|
52
|
+
const raw = (process.env.WORK_DB_PREFIX ?? "").trim();
|
|
53
|
+
if (raw && !/^[A-Za-z_][A-Za-z0-9_]{0,31}$/.test(raw)) {
|
|
54
|
+
throw new Error(
|
|
55
|
+
`Invalid WORK_DB_PREFIX "${raw}": must match ^[A-Za-z_][A-Za-z0-9_]{0,31}$`
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
return raw;
|
|
59
|
+
})();
|
|
60
|
+
|
|
61
|
+
/** Rewrite {{table}} tokens -> <prefix>table. No-op when sql contains no tokens. */
|
|
62
|
+
export function applyPrefix(sql: string): string {
|
|
63
|
+
if (!sql.includes("{{")) return sql;
|
|
64
|
+
return sql.replace(/\{\{(\w+)\}\}/g, (_m, name: string) => DB_PREFIX + name);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ---- driver selection (env-only; read once at module load) ----
|
|
68
|
+
const DB_DRIVER = (process.env.WORK_DB_DRIVER ?? "sqlite").trim().toLowerCase();
|
|
69
|
+
const DB_SKIP_CREATE = process.env.WORK_DB_SKIP_CREATE === "1" || process.env.WORK_DB_SKIP_CREATE === "true";
|
|
70
|
+
if (DB_DRIVER !== "sqlite" && DB_DRIVER !== "mysql") {
|
|
71
|
+
throw new Error(`Unsupported WORK_DB_DRIVER "${DB_DRIVER}": must be "sqlite" or "mysql"`);
|
|
72
|
+
}
|
|
14
73
|
|
|
15
74
|
function userTableColumns(db: Database): Set<string> {
|
|
16
|
-
const rows = db.query("PRAGMA table_info(users)").all() as { name: string }[];
|
|
75
|
+
const rows = db.query(applyPrefix("PRAGMA table_info({{users}})")).all() as { name: string }[];
|
|
17
76
|
return new Set(rows.map((r) => r.name));
|
|
18
77
|
}
|
|
19
78
|
|
|
@@ -23,21 +82,21 @@ function migrateUsersTable(db: Database): void {
|
|
|
23
82
|
if (userTableColumns(db).size === 0) return; // users doesn't exist yet; schema.sql will create it
|
|
24
83
|
const have = userTableColumns(db);
|
|
25
84
|
const additions: { col: string; ddl: string }[] = [
|
|
26
|
-
{ col: "password_hash", ddl: "ALTER TABLE users ADD COLUMN password_hash TEXT" },
|
|
27
|
-
{ col: "email", ddl: "ALTER TABLE users ADD COLUMN email TEXT" },
|
|
28
|
-
{ col: "is_admin", ddl: "ALTER TABLE users ADD COLUMN is_admin INTEGER NOT NULL DEFAULT 0" },
|
|
29
|
-
{ col: "is_active", ddl: "ALTER TABLE users ADD COLUMN is_active INTEGER NOT NULL DEFAULT 1" },
|
|
30
|
-
{ col: "updated_at", ddl: "ALTER TABLE users ADD COLUMN updated_at TEXT NOT NULL DEFAULT ''" },
|
|
85
|
+
{ col: "password_hash", ddl: "ALTER TABLE {{users}} ADD COLUMN password_hash TEXT" },
|
|
86
|
+
{ col: "email", ddl: "ALTER TABLE {{users}} ADD COLUMN email TEXT" },
|
|
87
|
+
{ col: "is_admin", ddl: "ALTER TABLE {{users}} ADD COLUMN is_admin INTEGER NOT NULL DEFAULT 0" },
|
|
88
|
+
{ col: "is_active", ddl: "ALTER TABLE {{users}} ADD COLUMN is_active INTEGER NOT NULL DEFAULT 1" },
|
|
89
|
+
{ col: "updated_at", ddl: "ALTER TABLE {{users}} ADD COLUMN updated_at TEXT NOT NULL DEFAULT ''" },
|
|
31
90
|
];
|
|
32
91
|
for (const m of additions) {
|
|
33
92
|
if (have.has(m.col)) continue;
|
|
34
|
-
db.exec(m.ddl);
|
|
93
|
+
db.exec(applyPrefix(m.ddl));
|
|
35
94
|
}
|
|
36
|
-
db.exec("CREATE INDEX IF NOT EXISTS users_is_admin ON users (is_admin) WHERE is_admin = 1");
|
|
95
|
+
db.exec(applyPrefix("CREATE INDEX IF NOT EXISTS users_is_admin ON {{users}} (is_admin) WHERE is_admin = 1"));
|
|
37
96
|
}
|
|
38
97
|
|
|
39
98
|
function tableColumns(db: Database, name: string): Set<string> {
|
|
40
|
-
const rows = db.query(`PRAGMA table_info(${name})`).all() as { name: string }[];
|
|
99
|
+
const rows = db.query(applyPrefix(`PRAGMA table_info({{${name}}})`)).all() as { name: string }[];
|
|
41
100
|
return new Set(rows.map((r) => r.name));
|
|
42
101
|
}
|
|
43
102
|
|
|
@@ -45,7 +104,7 @@ function migratePatTable(db: Database): void {
|
|
|
45
104
|
const have = tableColumns(db, "personal_access_tokens");
|
|
46
105
|
if (have.size === 0) return;
|
|
47
106
|
if (!have.has("ip_allowlist")) {
|
|
48
|
-
db.exec("ALTER TABLE personal_access_tokens ADD COLUMN ip_allowlist TEXT NOT NULL DEFAULT '[]'");
|
|
107
|
+
db.exec(applyPrefix("ALTER TABLE {{personal_access_tokens}} ADD COLUMN ip_allowlist TEXT NOT NULL DEFAULT '[]'"));
|
|
49
108
|
}
|
|
50
109
|
}
|
|
51
110
|
|
|
@@ -53,10 +112,10 @@ function migrateProjectsTable(db: Database): void {
|
|
|
53
112
|
const have = tableColumns(db, "projects");
|
|
54
113
|
if (have.size === 0) return;
|
|
55
114
|
if (!have.has("upstream_urls")) {
|
|
56
|
-
db.exec("ALTER TABLE projects ADD COLUMN upstream_urls TEXT NOT NULL DEFAULT '[]'");
|
|
115
|
+
db.exec(applyPrefix("ALTER TABLE {{projects}} ADD COLUMN upstream_urls TEXT NOT NULL DEFAULT '[]'"));
|
|
57
116
|
}
|
|
58
117
|
if (!have.has("model")) {
|
|
59
|
-
db.exec("ALTER TABLE projects ADD COLUMN model TEXT NOT NULL DEFAULT ''");
|
|
118
|
+
db.exec(applyPrefix("ALTER TABLE {{projects}} ADD COLUMN model TEXT NOT NULL DEFAULT ''"));
|
|
60
119
|
}
|
|
61
120
|
}
|
|
62
121
|
|
|
@@ -64,37 +123,216 @@ function migrateIssuesTable(db: Database): void {
|
|
|
64
123
|
const have = tableColumns(db, "issues");
|
|
65
124
|
if (have.size === 0) return;
|
|
66
125
|
if (!have.has("closed_at")) {
|
|
67
|
-
db.exec("ALTER TABLE issues ADD COLUMN closed_at TEXT");
|
|
126
|
+
db.exec(applyPrefix("ALTER TABLE {{issues}} ADD COLUMN closed_at TEXT"));
|
|
68
127
|
}
|
|
69
128
|
}
|
|
70
129
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
130
|
+
// ---- SqliteDriver: wraps bun:sqlite behind AsyncDatabase ----
|
|
131
|
+
class SqliteDriver implements AsyncDatabase {
|
|
132
|
+
readonly dialect = "sqlite" as const;
|
|
133
|
+
private readonly db: Database;
|
|
134
|
+
private inTx = false;
|
|
135
|
+
private constructor(db: Database) { this.db = db; }
|
|
136
|
+
|
|
137
|
+
static async create(): Promise<SqliteDriver> {
|
|
138
|
+
mkdirSync(dirname(DB_PATH), { recursive: true });
|
|
139
|
+
const db = new Database(DB_PATH, { create: true, readwrite: true });
|
|
140
|
+
db.exec("PRAGMA journal_mode = WAL");
|
|
141
|
+
db.exec("PRAGMA foreign_keys = ON");
|
|
142
|
+
db.exec(
|
|
143
|
+
applyPrefix(
|
|
144
|
+
"CREATE TABLE IF NOT EXISTS {{config}} (key TEXT PRIMARY KEY, value TEXT NOT NULL, updated_at TEXT NOT NULL)"
|
|
145
|
+
)
|
|
146
|
+
);
|
|
147
|
+
// Migration must run BEFORE schema.sql (same ordering as the original file).
|
|
148
|
+
migrateUsersTable(db);
|
|
149
|
+
migratePatTable(db);
|
|
150
|
+
migrateProjectsTable(db);
|
|
151
|
+
migrateIssuesTable(db);
|
|
152
|
+
db.exec(applyPrefix(readFileSync(join(import.meta.dir, "schema.sql"), "utf8")));
|
|
153
|
+
return new SqliteDriver(db);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async all<T = unknown>(sql: string, params: unknown[] = []): Promise<T[]> {
|
|
157
|
+
return this.db.query(applyPrefix(sql)).all(...(params as SQLQueryBindings[])) as T[];
|
|
158
|
+
}
|
|
159
|
+
async get<T = unknown>(sql: string, params: unknown[] = []): Promise<T | null> {
|
|
160
|
+
return (this.db.query(applyPrefix(sql)).get(...(params as SQLQueryBindings[])) as T | null) ?? null;
|
|
161
|
+
}
|
|
162
|
+
async run(sql: string, params: unknown[] = []): Promise<DbRunResult> {
|
|
163
|
+
const info = this.db.query(applyPrefix(sql)).run(...(params as SQLQueryBindings[])) as unknown as {
|
|
164
|
+
lastInsertRowid: number | bigint;
|
|
165
|
+
changes: number;
|
|
166
|
+
};
|
|
167
|
+
return { insertId: Number(info.lastInsertRowid), changes: info.changes };
|
|
168
|
+
}
|
|
169
|
+
async exec(sql: string): Promise<void> {
|
|
170
|
+
this.db.exec(applyPrefix(sql));
|
|
171
|
+
}
|
|
172
|
+
async transaction<T>(fn: () => Promise<T>): Promise<T> {
|
|
173
|
+
if (this.inTx) {
|
|
174
|
+
// SQLite can't nest BEGIN without SAVEPOINT; current codebase has no
|
|
175
|
+
// nesting, so this safety net just runs the body inline.
|
|
176
|
+
return fn();
|
|
177
|
+
}
|
|
178
|
+
this.db.exec("BEGIN");
|
|
179
|
+
this.inTx = true;
|
|
180
|
+
try {
|
|
181
|
+
const r = await fn();
|
|
182
|
+
this.db.exec("COMMIT");
|
|
183
|
+
return r;
|
|
184
|
+
} catch (e) {
|
|
185
|
+
try { this.db.exec("ROLLBACK"); } catch { /* already rolled back */ }
|
|
186
|
+
throw e;
|
|
187
|
+
} finally {
|
|
188
|
+
this.inTx = false;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
async close(): Promise<void> {
|
|
192
|
+
try { this.db.close(); } catch { /* already closed */ }
|
|
193
|
+
}
|
|
89
194
|
}
|
|
90
195
|
|
|
91
|
-
|
|
92
|
-
|
|
196
|
+
// ---- MysqlDriver: wraps mysql2/promise behind AsyncDatabase ----
|
|
197
|
+
// MySQL transactions must run on a single connection, so transaction() checks
|
|
198
|
+
// out a connection, pins it as txConn, and routes all/get/run/exec through it
|
|
199
|
+
// until commit/rollback. Outside a transaction, queries hit the pool. SQLite-
|
|
200
|
+
// specific SQL (INSERT OR IGNORE, ON CONFLICT ... excluded.x) is translated to
|
|
201
|
+
// MySQL equivalents by translateForMysql() so store.ts stays single-dialect.
|
|
202
|
+
interface MysqlOptions {
|
|
203
|
+
host: string;
|
|
204
|
+
port: number;
|
|
205
|
+
user: string;
|
|
206
|
+
password: string;
|
|
207
|
+
database: string;
|
|
208
|
+
skipCreate: boolean;
|
|
93
209
|
}
|
|
94
210
|
|
|
95
|
-
|
|
211
|
+
function translateForMysql(sql: string): string {
|
|
212
|
+
return sql
|
|
213
|
+
.replace(/INSERT OR IGNORE INTO/g, "INSERT IGNORE INTO")
|
|
214
|
+
.replace(/ON CONFLICT\((\w+)\) DO UPDATE SET/g, "ON DUPLICATE KEY UPDATE")
|
|
215
|
+
.replace(/excluded\.(\w+)/g, "VALUES($1)");
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
class MysqlDriver implements AsyncDatabase {
|
|
219
|
+
readonly dialect = "mysql" as const;
|
|
220
|
+
private readonly pool: Pool;
|
|
221
|
+
private txConn: PoolConnection | null = null;
|
|
222
|
+
private constructor(pool: Pool) { this.pool = pool; }
|
|
223
|
+
|
|
224
|
+
private get conn(): Pool | PoolConnection {
|
|
225
|
+
return this.txConn ?? this.pool;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
static async create(opts: MysqlOptions): Promise<MysqlDriver> {
|
|
229
|
+
const pool = createPool({
|
|
230
|
+
host: opts.host,
|
|
231
|
+
port: opts.port,
|
|
232
|
+
user: opts.user,
|
|
233
|
+
password: opts.password,
|
|
234
|
+
database: opts.database,
|
|
235
|
+
waitForConnections: true,
|
|
236
|
+
connectionLimit: 10,
|
|
237
|
+
charset: "utf8mb4",
|
|
238
|
+
});
|
|
239
|
+
const probe = await pool.getConnection();
|
|
240
|
+
try {
|
|
241
|
+
await probe.ping();
|
|
242
|
+
} finally {
|
|
243
|
+
probe.release();
|
|
244
|
+
}
|
|
245
|
+
if (!opts.skipCreate) {
|
|
246
|
+
const raw = applyPrefix(readFileSync(join(import.meta.dir, "schema-mysql.sql"), "utf8"));
|
|
247
|
+
// MySQL has no CREATE INDEX IF NOT EXISTS, so split into statements and
|
|
248
|
+
// tolerate ER_DUP_KEYNAME (1061) so re-runs stay idempotent. Comment lines
|
|
249
|
+
// are stripped first — they may contain ';' which would corrupt the split.
|
|
250
|
+
const schema = raw.split("\n").filter((l) => !l.trimStart().startsWith("--")).join("\n");
|
|
251
|
+
for (const stmt of schema.split(";").map((s) => s.trim()).filter((s) => s.length > 0)) {
|
|
252
|
+
try {
|
|
253
|
+
await pool.query(stmt);
|
|
254
|
+
} catch (e) {
|
|
255
|
+
if (e && typeof e === "object" && "errno" in e && (e as { errno: number }).errno === 1061) continue;
|
|
256
|
+
throw e;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
return new MysqlDriver(pool);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
private prepare(sql: string): string {
|
|
264
|
+
return translateForMysql(applyPrefix(sql));
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
async all<T = unknown>(sql: string, params: unknown[] = []): Promise<T[]> {
|
|
268
|
+
const [rows] = await this.conn.query(this.prepare(sql), params);
|
|
269
|
+
return rows as T[];
|
|
270
|
+
}
|
|
271
|
+
async get<T = unknown>(sql: string, params: unknown[] = []): Promise<T | null> {
|
|
272
|
+
const [rows] = await this.conn.query(this.prepare(sql), params);
|
|
273
|
+
const arr = rows as T[];
|
|
274
|
+
return arr[0] ?? null;
|
|
275
|
+
}
|
|
276
|
+
async run(sql: string, params: unknown[] = []): Promise<DbRunResult> {
|
|
277
|
+
const [result] = await this.conn.query(this.prepare(sql), params);
|
|
278
|
+
const r = result as ResultSetHeader;
|
|
279
|
+
return { insertId: Number(r.insertId), changes: r.affectedRows };
|
|
280
|
+
}
|
|
281
|
+
async exec(sql: string): Promise<void> {
|
|
282
|
+
await this.conn.query(this.prepare(sql));
|
|
283
|
+
}
|
|
284
|
+
async transaction<T>(fn: () => Promise<T>): Promise<T> {
|
|
285
|
+
if (this.txConn) return fn();
|
|
286
|
+
const conn = await this.pool.getConnection();
|
|
287
|
+
this.txConn = conn;
|
|
288
|
+
await conn.beginTransaction();
|
|
289
|
+
try {
|
|
290
|
+
const r = await fn();
|
|
291
|
+
await conn.commit();
|
|
292
|
+
return r;
|
|
293
|
+
} catch (e) {
|
|
294
|
+
try { await conn.rollback(); } catch { /* already rolled back */ }
|
|
295
|
+
throw e;
|
|
296
|
+
} finally {
|
|
297
|
+
this.txConn = null;
|
|
298
|
+
conn.release();
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
async close(): Promise<void> {
|
|
302
|
+
try { await this.pool.end(); } catch { /* already closed */ }
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
let _driver: AsyncDatabase | null = null;
|
|
307
|
+
|
|
308
|
+
/** Initialize + connect the database. MUST be awaited once at boot. */
|
|
309
|
+
export async function initDB(): Promise<AsyncDatabase> {
|
|
310
|
+
if (_driver) return _driver;
|
|
311
|
+
if (DB_DRIVER === "mysql") {
|
|
312
|
+
_driver = await MysqlDriver.create({
|
|
313
|
+
host: process.env.WORK_DB_HOST ?? "127.0.0.1",
|
|
314
|
+
port: Number(process.env.WORK_DB_PORT ?? 3306),
|
|
315
|
+
user: process.env.WORK_DB_USER ?? "ework",
|
|
316
|
+
password: process.env.WORK_DB_PASSWORD ?? "",
|
|
317
|
+
database: process.env.WORK_DB_NAME ?? "ework",
|
|
318
|
+
skipCreate: DB_SKIP_CREATE,
|
|
319
|
+
});
|
|
320
|
+
} else {
|
|
321
|
+
_driver = await SqliteDriver.create();
|
|
322
|
+
}
|
|
323
|
+
return _driver;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** Returns the initialized AsyncDatabase. Throws if initDB() wasn't awaited. */
|
|
327
|
+
export function getDB(): AsyncDatabase {
|
|
328
|
+
if (!_driver) throw new Error("getDB() called before initDB(); await initDB() at boot first");
|
|
329
|
+
return _driver;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
export async function getConfigAll(): Promise<Record<string, string>> {
|
|
96
333
|
try {
|
|
97
|
-
const
|
|
334
|
+
const driver = getDB();
|
|
335
|
+
const rows = await driver.all<{ key: string; value: string }>("SELECT key, value FROM {{config}}");
|
|
98
336
|
const out: Record<string, string> = {};
|
|
99
337
|
for (const r of rows) out[r.key] = r.value;
|
|
100
338
|
return out;
|
|
@@ -103,16 +341,15 @@ export function getConfigAll(): Record<string, string> {
|
|
|
103
341
|
}
|
|
104
342
|
}
|
|
105
343
|
|
|
106
|
-
export function setConfig(key: string, value: string): void {
|
|
344
|
+
export async function setConfig(key: string, value: string): Promise<void> {
|
|
107
345
|
const now = new Date().toISOString();
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
"
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
.run(key, value, now);
|
|
346
|
+
await getDB().run(
|
|
347
|
+
"INSERT INTO {{config}} (key, value, updated_at) VALUES (?, ?, ?) " +
|
|
348
|
+
"ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at",
|
|
349
|
+
[key, value, now]
|
|
350
|
+
);
|
|
114
351
|
}
|
|
115
352
|
|
|
116
|
-
export function deleteConfig(key: string): void {
|
|
117
|
-
|
|
353
|
+
export async function deleteConfig(key: string): Promise<void> {
|
|
354
|
+
await getDB().run("DELETE FROM {{config}} WHERE key = ?", [key]);
|
|
118
355
|
}
|
package/src/giteaApi.ts
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
// bg audit) plus a few cheap stubs (/version, /user, /repos/:o/:r) that
|
|
13
13
|
// Gitea clients commonly hit during connection bootstrap.
|
|
14
14
|
|
|
15
|
-
import {
|
|
15
|
+
import { getDB } from "./db";
|
|
16
16
|
import {
|
|
17
17
|
StoreError,
|
|
18
18
|
getProject,
|
|
@@ -122,18 +122,17 @@ export async function handleGiteaApi(
|
|
|
122
122
|
const limitRaw = Number(url.searchParams.get("limit") ?? 50);
|
|
123
123
|
const limit = Number.isFinite(limitRaw) && limitRaw > 0 ? Math.min(limitRaw, 200) : 50;
|
|
124
124
|
try {
|
|
125
|
-
const rows = listAllIssues({ q, state, limit });
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
};
|
|
125
|
+
const rows = await listAllIssues({ q, state, limit });
|
|
126
|
+
const body = [];
|
|
127
|
+
for (const row of rows) {
|
|
128
|
+
const project = await getProject(row.project_owner, row.project_name);
|
|
129
|
+
if (!project) continue;
|
|
130
|
+
body.push({
|
|
131
|
+
...buildIssuePayload(row, project, row.comment_count ?? 0, origin),
|
|
132
|
+
repository: buildRepository(project, origin),
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
return { status: 200, body };
|
|
137
136
|
} catch (e) {
|
|
138
137
|
return giteaError(e instanceof StoreError ? e.status : 500, e instanceof Error ? e.message : "error");
|
|
139
138
|
}
|
|
@@ -143,7 +142,7 @@ export async function handleGiteaApi(
|
|
|
143
142
|
if (m && req.method === "GET") {
|
|
144
143
|
const [, owner, repo] = m;
|
|
145
144
|
if (!(owner && repo)) return giteaError(404, "not found");
|
|
146
|
-
const project = getProject(owner, repo);
|
|
145
|
+
const project = await getProject(owner, repo);
|
|
147
146
|
if (!project) return giteaError(404, "repository not found");
|
|
148
147
|
return { status: 200, body: buildRepository(project, origin) };
|
|
149
148
|
}
|
|
@@ -153,22 +152,22 @@ export async function handleGiteaApi(
|
|
|
153
152
|
const [, owner, repo] = m;
|
|
154
153
|
if (!(owner && repo)) return giteaError(404, "not found");
|
|
155
154
|
try {
|
|
156
|
-
const project = getProject(owner, repo);
|
|
155
|
+
const project = await getProject(owner, repo);
|
|
157
156
|
if (!project) return giteaError(404, "repository not found");
|
|
158
|
-
if (!canWriteProject(project.id, user)) return giteaError(403, "requires writer role");
|
|
157
|
+
if (!(await canWriteProject(project.id, user))) return giteaError(403, "requires writer role");
|
|
159
158
|
const body = await readJson(req);
|
|
160
159
|
const title = asString(body.title);
|
|
161
160
|
if (!title) return giteaError(400, "title required");
|
|
162
161
|
const text = asString(body.body) ?? "";
|
|
163
|
-
const created = createIssue(project.id, title, text, user.login, {
|
|
162
|
+
const created = await createIssue(project.id, title, text, user.login, {
|
|
164
163
|
createdAt: asString(body.created_at),
|
|
165
164
|
updatedAt: asString(body.updated_at),
|
|
166
165
|
state: asIssueState(body.state) ?? "open",
|
|
167
166
|
closedAt: asString(body.closed_at) || undefined,
|
|
168
167
|
});
|
|
169
|
-
emitIssueEvent(project.id, created.id, "opened", origin);
|
|
168
|
+
void emitIssueEvent(project.id, created.id, "opened", origin);
|
|
170
169
|
if (created.state === "closed") {
|
|
171
|
-
emitIssueEvent(project.id, created.id, "closed", origin);
|
|
170
|
+
void emitIssueEvent(project.id, created.id, "closed", origin);
|
|
172
171
|
}
|
|
173
172
|
return { status: 201, body: buildIssuePayload(created, project, 0, origin) };
|
|
174
173
|
} catch (e) {
|
|
@@ -182,33 +181,33 @@ export async function handleGiteaApi(
|
|
|
182
181
|
if (!(owner && repo && numStr)) return giteaError(404, "not found");
|
|
183
182
|
const number = Number(numStr);
|
|
184
183
|
try {
|
|
185
|
-
const project = getProject(owner, repo);
|
|
184
|
+
const project = await getProject(owner, repo);
|
|
186
185
|
if (!project) return giteaError(404, "repository not found");
|
|
187
|
-
const issue = getIssue(project.id, number);
|
|
186
|
+
const issue = await getIssue(project.id, number);
|
|
188
187
|
if (!issue) return giteaError(404, "issue not found");
|
|
189
188
|
|
|
190
189
|
if (req.method === "GET") {
|
|
191
|
-
return { status: 200, body: buildIssuePayload(issue, project, countComments(issue.id), origin) };
|
|
190
|
+
return { status: 200, body: buildIssuePayload(issue, project, await countComments(issue.id), origin) };
|
|
192
191
|
}
|
|
193
192
|
if (req.method === "PATCH") {
|
|
194
|
-
if (!canWriteProject(project.id, user)) return giteaError(403, "requires writer role");
|
|
193
|
+
if (!(await canWriteProject(project.id, user))) return giteaError(403, "requires writer role");
|
|
195
194
|
const body = await readJson(req);
|
|
196
195
|
const before = issue.state;
|
|
197
|
-
editIssue(issue.id, {
|
|
196
|
+
await editIssue(issue.id, {
|
|
198
197
|
title: asString(body.title),
|
|
199
198
|
body: asString(body.body),
|
|
200
199
|
state: asIssueState(body.state),
|
|
201
200
|
closedAt: asString(body.closed_at) || undefined,
|
|
202
201
|
updatedAt: asString(body.updated_at),
|
|
203
202
|
});
|
|
204
|
-
const after = getIssueById(issue.id);
|
|
203
|
+
const after = await getIssueById(issue.id);
|
|
205
204
|
if (!after) return giteaError(404, "issue vanished mid-edit");
|
|
206
205
|
if (before === "open" && after.state === "closed") {
|
|
207
|
-
emitIssueEvent(project.id, after.id, "closed", origin);
|
|
206
|
+
void emitIssueEvent(project.id, after.id, "closed", origin);
|
|
208
207
|
} else if (before === "closed" && after.state === "open") {
|
|
209
|
-
emitIssueEvent(project.id, after.id, "reopened", origin);
|
|
208
|
+
void emitIssueEvent(project.id, after.id, "reopened", origin);
|
|
210
209
|
}
|
|
211
|
-
return { status: 200, body: buildIssuePayload(after, project, countComments(after.id), origin) };
|
|
210
|
+
return { status: 200, body: buildIssuePayload(after, project, await countComments(after.id), origin) };
|
|
212
211
|
}
|
|
213
212
|
return giteaError(405, `method ${req.method} not allowed`);
|
|
214
213
|
} catch (e) {
|
|
@@ -222,28 +221,28 @@ export async function handleGiteaApi(
|
|
|
222
221
|
if (!(owner && repo && numStr)) return giteaError(404, "not found");
|
|
223
222
|
const number = Number(numStr);
|
|
224
223
|
try {
|
|
225
|
-
const project = getProject(owner, repo);
|
|
224
|
+
const project = await getProject(owner, repo);
|
|
226
225
|
if (!project) return giteaError(404, "repository not found");
|
|
227
|
-
const issue = getIssue(project.id, number);
|
|
226
|
+
const issue = await getIssue(project.id, number);
|
|
228
227
|
if (!issue) return giteaError(404, "issue not found");
|
|
229
228
|
|
|
230
229
|
if (req.method === "GET") {
|
|
231
|
-
const comments = listCommentsForIssue(issue.id);
|
|
230
|
+
const comments = await listCommentsForIssue(issue.id);
|
|
232
231
|
return {
|
|
233
232
|
status: 200,
|
|
234
233
|
body: comments.map((c) => buildCommentPayload(issue, c, project, origin)),
|
|
235
234
|
};
|
|
236
235
|
}
|
|
237
236
|
if (req.method === "POST") {
|
|
238
|
-
if (!canWriteProject(project.id, user)) return giteaError(403, "requires writer role");
|
|
237
|
+
if (!(await canWriteProject(project.id, user))) return giteaError(403, "requires writer role");
|
|
239
238
|
const body = await readJson(req);
|
|
240
239
|
const text = asString(body.body);
|
|
241
240
|
if (text === undefined) return giteaError(400, "body required");
|
|
242
|
-
const created = postComment(issue.id, text, user.login, {
|
|
241
|
+
const created = await postComment(issue.id, text, user.login, {
|
|
243
242
|
createdAt: asString(body.created_at),
|
|
244
243
|
updatedAt: asString(body.updated_at),
|
|
245
244
|
});
|
|
246
|
-
emitCommentEvent(project.id, issue.id, created.id, origin);
|
|
245
|
+
void emitCommentEvent(project.id, issue.id, created.id, origin);
|
|
247
246
|
return { status: 201, body: buildCommentPayload(issue, created, project, origin) };
|
|
248
247
|
}
|
|
249
248
|
return giteaError(405, `method ${req.method} not allowed`);
|
|
@@ -268,29 +267,29 @@ export async function handleGiteaApi(
|
|
|
268
267
|
if (!(owner && repo && cidStr)) return giteaError(404, "not found");
|
|
269
268
|
const cid = Number(cidStr);
|
|
270
269
|
try {
|
|
271
|
-
const project = getProject(owner, repo);
|
|
270
|
+
const project = await getProject(owner, repo);
|
|
272
271
|
if (!project) return giteaError(404, "repository not found");
|
|
273
272
|
|
|
274
273
|
if (req.method === "GET") {
|
|
275
|
-
const comment = getComment(cid);
|
|
274
|
+
const comment = await getComment(cid);
|
|
276
275
|
if (!comment) return giteaError(404, "comment not found");
|
|
277
|
-
const issue = getIssueById(comment.issue_id);
|
|
276
|
+
const issue = await getIssueById(comment.issue_id);
|
|
278
277
|
if (!issue) return giteaError(404, "issue not found");
|
|
279
278
|
return { status: 200, body: buildCommentPayload(issue, comment, project, origin) };
|
|
280
279
|
}
|
|
281
280
|
if (req.method === "PATCH") {
|
|
282
|
-
if (!canWriteProject(project.id, user)) return giteaError(403, "requires writer role");
|
|
281
|
+
if (!(await canWriteProject(project.id, user))) return giteaError(403, "requires writer role");
|
|
283
282
|
const body = await readJson(req);
|
|
284
283
|
const text = asString(body.body);
|
|
285
284
|
if (text === undefined) return giteaError(400, "body required");
|
|
286
|
-
const updated = editComment(cid, text);
|
|
287
|
-
const issue = getIssueById(updated.issue_id);
|
|
285
|
+
const updated = await editComment(cid, text);
|
|
286
|
+
const issue = await getIssueById(updated.issue_id);
|
|
288
287
|
if (!issue) return giteaError(404, "issue not found");
|
|
289
288
|
return { status: 200, body: buildCommentPayload(issue, updated, project, origin) };
|
|
290
289
|
}
|
|
291
290
|
if (req.method === "DELETE") {
|
|
292
|
-
if (!canWriteProject(project.id, user)) return giteaError(403, "requires writer role");
|
|
293
|
-
deleteComment(cid);
|
|
291
|
+
if (!(await canWriteProject(project.id, user))) return giteaError(403, "requires writer role");
|
|
292
|
+
await deleteComment(cid);
|
|
294
293
|
return { status: 204, body: null };
|
|
295
294
|
}
|
|
296
295
|
return giteaError(405, `method ${req.method} not allowed`);
|
|
@@ -305,20 +304,20 @@ export async function handleGiteaApi(
|
|
|
305
304
|
if (!(owner && repo && cidStr)) return giteaError(404, "not found");
|
|
306
305
|
const cid = Number(cidStr);
|
|
307
306
|
try {
|
|
308
|
-
const project = getProject(owner, repo);
|
|
307
|
+
const project = await getProject(owner, repo);
|
|
309
308
|
if (!project) return giteaError(404, "repository not found");
|
|
310
309
|
|
|
311
310
|
if (req.method === "GET") {
|
|
312
|
-
return { status: 200, body: reactionsList(cid, origin) };
|
|
311
|
+
return { status: 200, body: await reactionsList(cid, origin) };
|
|
313
312
|
}
|
|
314
313
|
if (req.method === "POST" || req.method === "DELETE") {
|
|
315
|
-
if (!canWriteProject(project.id, user)) return giteaError(403, "requires writer role");
|
|
314
|
+
if (!(await canWriteProject(project.id, user))) return giteaError(403, "requires writer role");
|
|
316
315
|
const body = await readJson(req);
|
|
317
316
|
const content = asContent(body.content);
|
|
318
317
|
if (content === undefined) return giteaError(400, "content required");
|
|
319
|
-
if (req.method === "POST") addReaction(cid, user.login, content);
|
|
320
|
-
else removeReaction(cid, user.login, content);
|
|
321
|
-
return { status: 200, body: reactionsList(cid, origin) };
|
|
318
|
+
if (req.method === "POST") await addReaction(cid, user.login, content);
|
|
319
|
+
else await removeReaction(cid, user.login, content);
|
|
320
|
+
return { status: 200, body: await reactionsList(cid, origin) };
|
|
322
321
|
}
|
|
323
322
|
return giteaError(405, `method ${req.method} not allowed`);
|
|
324
323
|
} catch (e) {
|
|
@@ -332,12 +331,13 @@ export async function handleGiteaApi(
|
|
|
332
331
|
// Gitea returns a per-user list, not the aggregated counts ework's store
|
|
333
332
|
// keeps. The "reaction" key is Gitea's wire name; we also emit "content"
|
|
334
333
|
// for self-consistency with ework's schema.
|
|
335
|
-
function reactionsList(commentId: number, origin: string): { user: ReturnType<typeof buildUser>; reaction: string; content: string }[] {
|
|
336
|
-
const aggs = listReactionsFor([commentId]);
|
|
334
|
+
async function reactionsList(commentId: number, origin: string): Promise<{ user: ReturnType<typeof buildUser>; reaction: string; content: string }[]> {
|
|
335
|
+
const aggs = await listReactionsFor([commentId]);
|
|
337
336
|
if (aggs.length === 0) return [];
|
|
338
|
-
const rows =
|
|
339
|
-
|
|
340
|
-
|
|
337
|
+
const rows = await getDB().all<{ user_login: string; content: string }>(
|
|
338
|
+
"SELECT user_login, content FROM {{reactions}} WHERE comment_id = ? ORDER BY rowid",
|
|
339
|
+
[commentId]
|
|
340
|
+
);
|
|
341
341
|
return rows.map((r) => ({
|
|
342
342
|
user: buildUser(r.user_login, origin),
|
|
343
343
|
reaction: r.content,
|