ework-web 0.1.0 → 0.1.3
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 +1 -1
- package/src/config.ts +9 -1
- package/src/db.ts +9 -0
- package/src/giteaApi.ts +15 -2
- package/src/index.ts +5 -1
- package/src/render/components.ts +4 -2
- package/src/render/layout.ts +9 -3
- package/src/schema.sql +3 -0
- package/src/store.ts +68 -16
- package/src/views/issueThread.ts +3 -1
- package/src/webhooks.ts +60 -17
package/package.json
CHANGED
package/src/config.ts
CHANGED
|
@@ -78,6 +78,12 @@ export const configSchema = z.object({
|
|
|
78
78
|
daemonBotLogin: z.string().default(""),
|
|
79
79
|
daemonWebhookUrl: z.string().default(""),
|
|
80
80
|
daemonWebhookSecret: z.string().default(""),
|
|
81
|
+
autowireActive: z.coerce.boolean().default(true),
|
|
82
|
+
webhookMaxConcurrent: z.preprocess((v) => {
|
|
83
|
+
const n = typeof v === "number" ? v : Number(v);
|
|
84
|
+
if (!Number.isFinite(n) || n < 1 || n > 64) return 6;
|
|
85
|
+
return Math.floor(n);
|
|
86
|
+
}, z.number().int().min(1).max(64)),
|
|
81
87
|
});
|
|
82
88
|
|
|
83
89
|
export type Config = z.infer<typeof configSchema>;
|
|
@@ -160,11 +166,13 @@ export function loadConfig(): Config {
|
|
|
160
166
|
daemonBotLogin: process.env.WORK_DAEMON_BOT_LOGIN ?? "",
|
|
161
167
|
daemonWebhookUrl: process.env.WORK_DAEMON_WEBHOOK_URL ?? "",
|
|
162
168
|
daemonWebhookSecret: process.env.WORK_DAEMON_WEBHOOK_SECRET ?? "",
|
|
169
|
+
autowireActive: process.env.WORK_AUTOWIRE_ACTIVE !== "false",
|
|
170
|
+
webhookMaxConcurrent: Number(process.env.WORK_WEBHOOK_MAX_CONCURRENT ?? "6"),
|
|
163
171
|
});
|
|
164
172
|
}
|
|
165
173
|
|
|
166
174
|
export function resolveTtsBackend(cfg: Config, id?: string): TtsBackend | null {
|
|
167
|
-
const list = cfg.ttsBackends;
|
|
175
|
+
const list = cfg.ttsBackends.filter((b) => b.url.trim() !== "");
|
|
168
176
|
if (list.length === 0) return null;
|
|
169
177
|
return list.find((b) => b.id === id) ?? list.find((b) => b.id === cfg.ttsDefaultBackend) ?? list[0] ?? null;
|
|
170
178
|
}
|
package/src/db.ts
CHANGED
|
@@ -57,6 +57,14 @@ function migrateProjectsTable(db: Database): void {
|
|
|
57
57
|
}
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
+
function migrateIssuesTable(db: Database): void {
|
|
61
|
+
const have = tableColumns(db, "issues");
|
|
62
|
+
if (have.size === 0) return;
|
|
63
|
+
if (!have.has("closed_at")) {
|
|
64
|
+
db.exec("ALTER TABLE issues ADD COLUMN closed_at TEXT");
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
60
68
|
function db(): Database {
|
|
61
69
|
if (_db) return _db;
|
|
62
70
|
mkdirSync(dirname(DB_PATH), { recursive: true });
|
|
@@ -72,6 +80,7 @@ function db(): Database {
|
|
|
72
80
|
migrateUsersTable(_db);
|
|
73
81
|
migratePatTable(_db);
|
|
74
82
|
migrateProjectsTable(_db);
|
|
83
|
+
migrateIssuesTable(_db);
|
|
75
84
|
_db.exec(readFileSync(join(import.meta.dir, "schema.sql"), "utf8"));
|
|
76
85
|
return _db;
|
|
77
86
|
}
|
package/src/giteaApi.ts
CHANGED
|
@@ -160,8 +160,16 @@ export async function handleGiteaApi(
|
|
|
160
160
|
const title = asString(body.title);
|
|
161
161
|
if (!title) return giteaError(400, "title required");
|
|
162
162
|
const text = asString(body.body) ?? "";
|
|
163
|
-
const created = createIssue(project.id, title, text, user.login
|
|
163
|
+
const created = createIssue(project.id, title, text, user.login, {
|
|
164
|
+
createdAt: asString(body.created_at),
|
|
165
|
+
updatedAt: asString(body.updated_at),
|
|
166
|
+
state: asIssueState(body.state) ?? "open",
|
|
167
|
+
closedAt: asString(body.closed_at) || undefined,
|
|
168
|
+
});
|
|
164
169
|
emitIssueEvent(project.id, created.id, "opened", origin);
|
|
170
|
+
if (created.state === "closed") {
|
|
171
|
+
emitIssueEvent(project.id, created.id, "closed", origin);
|
|
172
|
+
}
|
|
165
173
|
return { status: 201, body: buildIssuePayload(created, project, 0, origin) };
|
|
166
174
|
} catch (e) {
|
|
167
175
|
return giteaError(e instanceof StoreError ? e.status : 500, e instanceof Error ? e.message : "error");
|
|
@@ -190,6 +198,8 @@ export async function handleGiteaApi(
|
|
|
190
198
|
title: asString(body.title),
|
|
191
199
|
body: asString(body.body),
|
|
192
200
|
state: asIssueState(body.state),
|
|
201
|
+
closedAt: asString(body.closed_at) || undefined,
|
|
202
|
+
updatedAt: asString(body.updated_at),
|
|
193
203
|
});
|
|
194
204
|
const after = getIssueById(issue.id);
|
|
195
205
|
if (!after) return giteaError(404, "issue vanished mid-edit");
|
|
@@ -229,7 +239,10 @@ export async function handleGiteaApi(
|
|
|
229
239
|
const body = await readJson(req);
|
|
230
240
|
const text = asString(body.body);
|
|
231
241
|
if (text === undefined) return giteaError(400, "body required");
|
|
232
|
-
const created = postComment(issue.id, text, user.login
|
|
242
|
+
const created = postComment(issue.id, text, user.login, {
|
|
243
|
+
createdAt: asString(body.created_at),
|
|
244
|
+
updatedAt: asString(body.updated_at),
|
|
245
|
+
});
|
|
233
246
|
emitCommentEvent(project.id, issue.id, created.id, origin);
|
|
234
247
|
return { status: 201, body: buildCommentPayload(issue, created, project, origin) };
|
|
235
248
|
}
|
package/src/index.ts
CHANGED
|
@@ -89,6 +89,10 @@ const cfg: Config = loadConfig();
|
|
|
89
89
|
const opencode = new OpencodeClient(cfg);
|
|
90
90
|
|
|
91
91
|
function autoWireDaemon(projectId: number, origin: string): void {
|
|
92
|
+
if (!cfg.autowireActive) {
|
|
93
|
+
log.info("autoWireDaemon: skipped (WORK_AUTOWIRE_ACTIVE=false)", { projectId });
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
92
96
|
const botLogin = cfg.daemonBotLogin.trim();
|
|
93
97
|
const hookUrl = cfg.daemonWebhookUrl.trim();
|
|
94
98
|
if (botLogin) {
|
|
@@ -534,7 +538,7 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
|
|
|
534
538
|
}
|
|
535
539
|
|
|
536
540
|
if (req.method === "GET" && url.pathname === "/api/tts/backends") {
|
|
537
|
-
return json(cfg.ttsBackends.map((b) => ({ id: b.id, label: b.label, voice: b.voice })));
|
|
541
|
+
return json(cfg.ttsBackends.filter((b) => b.url.trim() !== "").map((b) => ({ id: b.id, label: b.label, voice: b.voice })));
|
|
538
542
|
}
|
|
539
543
|
|
|
540
544
|
if (req.method === "POST" && url.pathname === "/api/tts") {
|
package/src/render/components.ts
CHANGED
|
@@ -44,7 +44,7 @@ export function relTime(iso: string): string {
|
|
|
44
44
|
}
|
|
45
45
|
|
|
46
46
|
// Must match app.js cardHTML so client hydration/polling produce identical markup.
|
|
47
|
-
export function renderCommentCard(c: CommentView): string {
|
|
47
|
+
export function renderCommentCard(c: CommentView, cfg?: { translateUrl?: string; ttsBackends?: { url: string }[] }): string {
|
|
48
48
|
const tag = c.tag || "human";
|
|
49
49
|
const label = TAG_LABEL[tag] || "👤";
|
|
50
50
|
const rx =
|
|
@@ -53,6 +53,8 @@ export function renderCommentCard(c: CommentView): string {
|
|
|
53
53
|
.map((r) => `<span class="rxc">${r.e}<span class="rxn">${r.n}</span></span>`)
|
|
54
54
|
.join("")}</span>`
|
|
55
55
|
: "";
|
|
56
|
+
const translateEnabled = cfg ? !!cfg.translateUrl && cfg.translateUrl.trim() !== "" : true;
|
|
57
|
+
const ttsEnabled = cfg ? !!(cfg.ttsBackends && cfg.ttsBackends.some((b) => b.url && b.url.trim() !== "")) : true;
|
|
56
58
|
return (
|
|
57
59
|
`<div class="item item-${esc(tag)}" id="comment-${c.id}" data-id="${c.id}">` +
|
|
58
60
|
`<div class="card"><div class="card-h">` +
|
|
@@ -60,7 +62,7 @@ export function renderCommentCard(c: CommentView): string {
|
|
|
60
62
|
`<span class="who">${esc(c.login)}</span>` +
|
|
61
63
|
`<span class="when" data-ts="${esc(c.created_at)}" title="${esc(c.created_at)}">${relTime(c.created_at)}</span>` +
|
|
62
64
|
rx +
|
|
63
|
-
`<span class="card-actions">` + actionBarHTML({ cid: String(c.id), copy: true, link: true, translate: true, tts: true }) + `</span>` +
|
|
65
|
+
`<span class="card-actions">` + actionBarHTML({ cid: String(c.id), copy: true, link: true, translate: true, tts: true, translateEnabled, ttsEnabled }) + `</span>` +
|
|
64
66
|
`</div><div class="card-b">${c.body_html}</div></div></div>`
|
|
65
67
|
);
|
|
66
68
|
}
|
package/src/render/layout.ts
CHANGED
|
@@ -12,6 +12,8 @@ export interface LayoutProps {
|
|
|
12
12
|
writesEnabled?: boolean;
|
|
13
13
|
operatorLogin?: string;
|
|
14
14
|
upstreamWebUrl?: string | null;
|
|
15
|
+
translateEnabled?: boolean;
|
|
16
|
+
ttsEnabled?: boolean;
|
|
15
17
|
}
|
|
16
18
|
|
|
17
19
|
export const THEME_CSS = `
|
|
@@ -143,7 +145,7 @@ export function renderLayout(props: LayoutProps, inner: string, initialItems: st
|
|
|
143
145
|
</div>
|
|
144
146
|
</div>
|
|
145
147
|
${props.descriptionHtml.trim() ? `<div class="desc-wrap">
|
|
146
|
-
<div class="desc-actions">${actionBarHTML({ copy: true, link: true, translate: true, tts: true })}</div>
|
|
148
|
+
<div class="desc-actions">${actionBarHTML({ copy: true, link: true, translate: true, tts: true, translateEnabled: props.translateEnabled !== false, ttsEnabled: props.ttsEnabled !== false })}</div>
|
|
147
149
|
<div class="desc${props.descriptionCollapsed ? " collapsed" : ""}" id="issueDesc">${props.descriptionHtml}</div>
|
|
148
150
|
${props.descriptionCollapsed ? `<button type="button" class="desc-toggle" id="descToggle">显示详情 ▾</button>` : ""}
|
|
149
151
|
</div>` : ""}
|
|
@@ -226,14 +228,18 @@ export interface ActionBarOpts {
|
|
|
226
228
|
link?: boolean;
|
|
227
229
|
translate?: boolean;
|
|
228
230
|
tts?: boolean;
|
|
231
|
+
translateEnabled?: boolean;
|
|
232
|
+
ttsEnabled?: boolean;
|
|
229
233
|
}
|
|
230
234
|
|
|
231
235
|
export function actionBarHTML(o: ActionBarOpts): string {
|
|
232
236
|
const d = o.cid != null ? ` data-cid="${escapeAttr(o.cid)}"` : "";
|
|
233
237
|
const cpy = o.copy ? `<button type="button" class="cbtn"${d} title="复制">📋</button>` : "";
|
|
234
238
|
const lnk = o.link ? `<button type="button" class="clink"${d} title="复制楼层链接">🔗</button>` : "";
|
|
235
|
-
const tr = o.translate
|
|
236
|
-
|
|
239
|
+
const tr = (o.translate && o.translateEnabled !== false)
|
|
240
|
+
? `<button type="button" class="tbtn"${d} title="翻译">翻译</button>`
|
|
241
|
+
: "";
|
|
242
|
+
const tts = (o.tts && o.ttsEnabled !== false)
|
|
237
243
|
? `<button type="button" class="ttsstop"${d} title="停止朗读">⏹</button><button type="button" class="ttsbtn"${d} title="朗读">🔊</button>`
|
|
238
244
|
: "";
|
|
239
245
|
return cpy + lnk + tr + tts;
|
package/src/schema.sql
CHANGED
|
@@ -42,6 +42,9 @@ CREATE TABLE IF NOT EXISTS issues (
|
|
|
42
42
|
author TEXT NOT NULL REFERENCES users(login),
|
|
43
43
|
created_at TEXT NOT NULL,
|
|
44
44
|
updated_at TEXT NOT NULL,
|
|
45
|
+
-- NULL when open; stamped on close, cleared on reopen. Backfill-safe because
|
|
46
|
+
-- migrateIssuesTable ADDs the column idempotently for legacy DBs.
|
|
47
|
+
closed_at TEXT,
|
|
45
48
|
UNIQUE (project_id, number)
|
|
46
49
|
);
|
|
47
50
|
CREATE INDEX IF NOT EXISTS issues_project_state_updated
|
package/src/store.ts
CHANGED
|
@@ -49,6 +49,7 @@ export interface IssueRow {
|
|
|
49
49
|
author: string;
|
|
50
50
|
created_at: string;
|
|
51
51
|
updated_at: string;
|
|
52
|
+
closed_at: string | null;
|
|
52
53
|
}
|
|
53
54
|
|
|
54
55
|
export interface IssueWithMeta extends IssueRow {
|
|
@@ -308,18 +309,36 @@ export function listAllIssues(opts: ListIssuesOpts = {}): IssueWithMeta[] {
|
|
|
308
309
|
return rawDB().query(sql).all(...args) as IssueWithMeta[];
|
|
309
310
|
}
|
|
310
311
|
|
|
312
|
+
export interface CreateIssueOpts {
|
|
313
|
+
createdAt?: string;
|
|
314
|
+
updatedAt?: string;
|
|
315
|
+
state?: "open" | "closed";
|
|
316
|
+
closedAt?: string | null;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function isoOr(value: string | undefined, fallback: string): string {
|
|
320
|
+
if (value === undefined || value === "") return fallback;
|
|
321
|
+
const parsed = Date.parse(value);
|
|
322
|
+
if (Number.isNaN(parsed)) throw new StoreError(400, "非法 ISO8601 时间戳");
|
|
323
|
+
return new Date(parsed).toISOString();
|
|
324
|
+
}
|
|
325
|
+
|
|
311
326
|
export function createIssue(
|
|
312
327
|
projectId: number,
|
|
313
328
|
title: string,
|
|
314
329
|
body: string,
|
|
315
|
-
author: string
|
|
330
|
+
author: string,
|
|
331
|
+
opts: CreateIssueOpts = {}
|
|
316
332
|
): IssueRow {
|
|
317
333
|
title = title.trim();
|
|
318
334
|
if (!title) throw new StoreError(400, "标题不能为空");
|
|
319
335
|
if (title.length > 255) throw new StoreError(400, "标题过长(≤255)");
|
|
320
336
|
if (body.length > 65536) throw new StoreError(413, "正文过长(≤65536)");
|
|
321
337
|
ensureUser(author);
|
|
322
|
-
const
|
|
338
|
+
const createdAt = isoOr(opts.createdAt, now());
|
|
339
|
+
const updatedAt = opts.updatedAt ? isoOr(opts.updatedAt, createdAt) : createdAt;
|
|
340
|
+
const state: "open" | "closed" = opts.state ?? "open";
|
|
341
|
+
const closedAt = state === "closed" ? isoOr(opts.closedAt ?? undefined, updatedAt) : null;
|
|
323
342
|
const db = rawDB();
|
|
324
343
|
return tx(db, () => {
|
|
325
344
|
const next = (
|
|
@@ -329,19 +348,29 @@ export function createIssue(
|
|
|
329
348
|
).n;
|
|
330
349
|
const info = db
|
|
331
350
|
.query(
|
|
332
|
-
"INSERT INTO issues (project_id, number, title, body, state, author, created_at, updated_at) VALUES (?, ?, ?, ?,
|
|
351
|
+
"INSERT INTO issues (project_id, number, title, body, state, author, created_at, updated_at, closed_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"
|
|
333
352
|
)
|
|
334
|
-
.run(projectId, next, title, body, author,
|
|
335
|
-
db.query("UPDATE projects SET updated_at = ? WHERE id = ?").run(
|
|
353
|
+
.run(projectId, next, title, body, state, author, createdAt, updatedAt, closedAt);
|
|
354
|
+
db.query("UPDATE projects SET updated_at = ? WHERE id = ?").run(updatedAt, projectId);
|
|
336
355
|
return getIssueById(Number(info.lastInsertRowid))!;
|
|
337
356
|
});
|
|
338
357
|
}
|
|
339
358
|
|
|
340
|
-
export function setIssueState(
|
|
341
|
-
|
|
359
|
+
export function setIssueState(
|
|
360
|
+
issueId: number,
|
|
361
|
+
state: "open" | "closed",
|
|
362
|
+
opts: { closedAt?: string; updatedAt?: string } = {}
|
|
363
|
+
): void {
|
|
364
|
+
const ts = opts.updatedAt ? isoOr(opts.updatedAt, now()) : now();
|
|
365
|
+
const closedAt = state === "closed" ? isoOr(opts.closedAt ?? undefined, ts) : null;
|
|
342
366
|
const db = rawDB();
|
|
343
367
|
tx(db, () => {
|
|
344
|
-
db.query("UPDATE issues SET state = ?, updated_at = ? WHERE id = ?").run(
|
|
368
|
+
db.query("UPDATE issues SET state = ?, updated_at = ?, closed_at = ? WHERE id = ?").run(
|
|
369
|
+
state,
|
|
370
|
+
ts,
|
|
371
|
+
closedAt,
|
|
372
|
+
issueId
|
|
373
|
+
);
|
|
345
374
|
const row = db.query("SELECT project_id FROM issues WHERE id = ?").get(issueId) as { project_id: number };
|
|
346
375
|
db.query("UPDATE projects SET updated_at = ? WHERE id = ?").run(ts, row.project_id);
|
|
347
376
|
});
|
|
@@ -351,11 +380,13 @@ export interface IssuePatch {
|
|
|
351
380
|
title?: string;
|
|
352
381
|
body?: string;
|
|
353
382
|
state?: "open" | "closed";
|
|
383
|
+
closedAt?: string | null;
|
|
384
|
+
updatedAt?: string;
|
|
354
385
|
}
|
|
355
386
|
|
|
356
387
|
export function editIssue(issueId: number, patch: IssuePatch): void {
|
|
357
388
|
const sets: string[] = [];
|
|
358
|
-
const args: (string | number)[] = [];
|
|
389
|
+
const args: (string | number | null)[] = [];
|
|
359
390
|
if (patch.title !== undefined) {
|
|
360
391
|
const t = patch.title.trim();
|
|
361
392
|
if (!t) throw new StoreError(400, "标题不能为空");
|
|
@@ -374,10 +405,20 @@ export function editIssue(issueId: number, patch: IssuePatch): void {
|
|
|
374
405
|
}
|
|
375
406
|
sets.push("state = ?");
|
|
376
407
|
args.push(patch.state);
|
|
408
|
+
const ts = patch.updatedAt ? isoOr(patch.updatedAt, now()) : now();
|
|
409
|
+
sets.push("updated_at = ?");
|
|
410
|
+
args.push(ts);
|
|
411
|
+
sets.push("closed_at = ?");
|
|
412
|
+
args.push(patch.state === "closed" ? isoOr(patch.closedAt ?? undefined, ts) : null);
|
|
413
|
+
} else if (patch.updatedAt !== undefined) {
|
|
414
|
+
sets.push("updated_at = ?");
|
|
415
|
+
args.push(isoOr(patch.updatedAt, now()));
|
|
377
416
|
}
|
|
378
417
|
if (sets.length === 0) return;
|
|
379
|
-
|
|
380
|
-
|
|
418
|
+
if (!patch.state && !patch.updatedAt) {
|
|
419
|
+
sets.push("updated_at = ?");
|
|
420
|
+
args.push(now());
|
|
421
|
+
}
|
|
381
422
|
args.push(issueId);
|
|
382
423
|
const db = rawDB();
|
|
383
424
|
tx(db, () => {
|
|
@@ -434,19 +475,30 @@ export function listCommentsForIssue(issueId: number): CommentRow[] {
|
|
|
434
475
|
.all(issueId) as CommentRow[];
|
|
435
476
|
}
|
|
436
477
|
|
|
437
|
-
export
|
|
478
|
+
export interface CreateCommentOpts {
|
|
479
|
+
createdAt?: string;
|
|
480
|
+
updatedAt?: string;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
export function postComment(
|
|
484
|
+
issueId: number,
|
|
485
|
+
body: string,
|
|
486
|
+
author: string,
|
|
487
|
+
opts: CreateCommentOpts = {}
|
|
488
|
+
): CommentRow {
|
|
438
489
|
if (!body || !body.trim()) throw new StoreError(400, "评论不能为空");
|
|
439
490
|
if (body.length > 65536) throw new StoreError(413, "评论过长(≤65536)");
|
|
440
491
|
ensureUser(author);
|
|
441
|
-
const
|
|
492
|
+
const createdAt = isoOr(opts.createdAt, now());
|
|
493
|
+
const updatedAt = opts.updatedAt ? isoOr(opts.updatedAt, createdAt) : createdAt;
|
|
442
494
|
const db = rawDB();
|
|
443
495
|
return tx(db, () => {
|
|
444
496
|
const info = db
|
|
445
497
|
.query("INSERT INTO comments (issue_id, author, body, created_at, updated_at) VALUES (?, ?, ?, ?, ?)")
|
|
446
|
-
.run(issueId, author, body,
|
|
447
|
-
db.query("UPDATE issues SET updated_at = ? WHERE id = ?").run(
|
|
498
|
+
.run(issueId, author, body, createdAt, updatedAt);
|
|
499
|
+
db.query("UPDATE issues SET updated_at = ? WHERE id = ?").run(updatedAt, issueId);
|
|
448
500
|
const row = db.query("SELECT project_id FROM issues WHERE id = ?").get(issueId) as { project_id: number };
|
|
449
|
-
db.query("UPDATE projects SET updated_at = ? WHERE id = ?").run(
|
|
501
|
+
db.query("UPDATE projects SET updated_at = ? WHERE id = ?").run(updatedAt, row.project_id);
|
|
450
502
|
return db.query(
|
|
451
503
|
`SELECT c.*, u.kind AS author_kind
|
|
452
504
|
FROM comments c LEFT JOIN users u ON u.login = c.author
|
package/src/views/issueThread.ts
CHANGED
|
@@ -124,9 +124,11 @@ export function buildIssueThread(
|
|
|
124
124
|
writesEnabled: cfg.writesEnabled,
|
|
125
125
|
operatorLogin: viewerLogin ?? cfg.operatorLogin,
|
|
126
126
|
upstreamWebUrl,
|
|
127
|
+
translateEnabled: !!cfg.translateUrl && cfg.translateUrl.trim() !== "",
|
|
128
|
+
ttsEnabled: cfg.ttsBackends.some((b) => b.url && b.url.trim() !== ""),
|
|
127
129
|
},
|
|
128
130
|
safeJsonEmbed(payload),
|
|
129
|
-
displayViews.map(renderCommentCard).join("")
|
|
131
|
+
displayViews.map((v) => renderCommentCard(v, cfg)).join("")
|
|
130
132
|
);
|
|
131
133
|
return { html };
|
|
132
134
|
}
|
package/src/webhooks.ts
CHANGED
|
@@ -62,6 +62,44 @@ const MAX_RESPONSE_LOG_BYTES = 8192;
|
|
|
62
62
|
const HTTP_TIMEOUT_MS = 10_000;
|
|
63
63
|
const RETRY_DELAYS_MS = [0, 2_000, 8_000];
|
|
64
64
|
|
|
65
|
+
// Global cap on concurrent in-flight webhook HTTP deliveries. Without this, a
|
|
66
|
+
// flood of events against dead targets (e.g. daemon down during migration)
|
|
67
|
+
// kicks off unbounded concurrent fetches — each waits HTTP_TIMEOUT_MS, so the
|
|
68
|
+
// Bun event loop saturates and ework-web stops serving user traffic. The cap
|
|
69
|
+
// queues surplus deliveries; releaseSlot() drains the queue FIFO.
|
|
70
|
+
const MAX_CONCURRENT_DELIVERIES = clampPositiveInt(
|
|
71
|
+
Number(process.env.WORK_WEBHOOK_MAX_CONCURRENT ?? "6"),
|
|
72
|
+
1,
|
|
73
|
+
64,
|
|
74
|
+
6
|
|
75
|
+
);
|
|
76
|
+
let inFlightDeliveries = 0;
|
|
77
|
+
const deliveryQueue: Array<() => void> = [];
|
|
78
|
+
|
|
79
|
+
function clampPositiveInt(v: number, lo: number, hi: number, fallback: number): number {
|
|
80
|
+
if (!Number.isFinite(v) || v < lo || v > hi) return fallback;
|
|
81
|
+
return Math.floor(v);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function acquireDeliverySlot(): Promise<void> {
|
|
85
|
+
if (inFlightDeliveries < MAX_CONCURRENT_DELIVERIES) {
|
|
86
|
+
inFlightDeliveries++;
|
|
87
|
+
return Promise.resolve();
|
|
88
|
+
}
|
|
89
|
+
return new Promise<void>((resolve) => {
|
|
90
|
+
deliveryQueue.push(() => {
|
|
91
|
+
inFlightDeliveries++;
|
|
92
|
+
resolve();
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function releaseDeliverySlot(): void {
|
|
98
|
+
inFlightDeliveries--;
|
|
99
|
+
const next = deliveryQueue.shift();
|
|
100
|
+
if (next) next();
|
|
101
|
+
}
|
|
102
|
+
|
|
65
103
|
function now(): string {
|
|
66
104
|
return new Date().toISOString();
|
|
67
105
|
}
|
|
@@ -323,7 +361,7 @@ function buildIssue(
|
|
|
323
361
|
comments: commentCount,
|
|
324
362
|
created_at: issue.created_at,
|
|
325
363
|
updated_at: issue.updated_at,
|
|
326
|
-
closed_at: issue.
|
|
364
|
+
closed_at: issue.closed_at,
|
|
327
365
|
due_date: null,
|
|
328
366
|
pull_request: null,
|
|
329
367
|
repository: buildRepository(project, origin),
|
|
@@ -488,23 +526,28 @@ async function deliver(
|
|
|
488
526
|
event: WebhookEventName,
|
|
489
527
|
rawBody: string
|
|
490
528
|
): Promise<void> {
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
const
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
529
|
+
await acquireDeliverySlot();
|
|
530
|
+
try {
|
|
531
|
+
for (const attempt of RETRY_DELAYS_MS) {
|
|
532
|
+
if (attempt > 0) await Bun.sleep(attempt);
|
|
533
|
+
const deliveryUuid = randomUUID();
|
|
534
|
+
const headers = buildHeaders(event, deliveryUuid, rawBody, webhook.secret);
|
|
535
|
+
const result = await postWithTimeout(webhook.url, rawBody, headers);
|
|
536
|
+
const success = result.status !== null && result.status >= 200 && result.status < 300;
|
|
537
|
+
recordDelivery(webhook.id, event, deliveryUuid, rawBody, result);
|
|
538
|
+
if (success) return;
|
|
539
|
+
// 410 Gone: receiver explicitly says "stop sending" — honor it, no retry.
|
|
540
|
+
if (result.status === 410) return;
|
|
541
|
+
}
|
|
542
|
+
log.warn("webhook: gave up after retries", {
|
|
543
|
+
attempts: RETRY_DELAYS_MS.length,
|
|
544
|
+
webhookId: webhook.id,
|
|
545
|
+
url: webhook.url,
|
|
546
|
+
event,
|
|
547
|
+
});
|
|
548
|
+
} finally {
|
|
549
|
+
releaseDeliverySlot();
|
|
501
550
|
}
|
|
502
|
-
log.warn("webhook: gave up after retries", {
|
|
503
|
-
attempts: RETRY_DELAYS_MS.length,
|
|
504
|
-
webhookId: webhook.id,
|
|
505
|
-
url: webhook.url,
|
|
506
|
-
event,
|
|
507
|
-
});
|
|
508
551
|
}
|
|
509
552
|
|
|
510
553
|
function fanOut(
|