ework-web 0.1.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/LICENSE +21 -0
- package/README.md +170 -0
- package/bin/ework-web.js +2 -0
- package/package.json +61 -0
- package/src/attachments.ts +54 -0
- package/src/auth.ts +218 -0
- package/src/build.ts +17 -0
- package/src/config.ts +178 -0
- package/src/db.ts +106 -0
- package/src/fileview.ts +617 -0
- package/src/giteaApi.ts +333 -0
- package/src/index.ts +1310 -0
- package/src/logger.ts +44 -0
- package/src/opencode.ts +340 -0
- package/src/ratelimit.ts +35 -0
- package/src/reactions.ts +31 -0
- package/src/render/components.ts +66 -0
- package/src/render/layout.ts +240 -0
- package/src/render/markdown.ts +106 -0
- package/src/schema.sql +178 -0
- package/src/static/app.js +571 -0
- package/src/static/favicon.svg +6 -0
- package/src/static/file.js +103 -0
- package/src/static/session.js +380 -0
- package/src/static/tts.js +101 -0
- package/src/store.ts +1020 -0
- package/src/translate.ts +166 -0
- package/src/views/adminTokens.ts +122 -0
- package/src/views/home.ts +109 -0
- package/src/views/issueList.ts +89 -0
- package/src/views/issueNew.ts +35 -0
- package/src/views/issueThread.ts +179 -0
- package/src/views/issues.ts +66 -0
- package/src/views/projectMembers.ts +146 -0
- package/src/views/projectUpstreams.ts +145 -0
- package/src/views/sessionLog.ts +709 -0
- package/src/views/settings.ts +67 -0
- package/src/views/tokens.ts +146 -0
- package/src/views/ttsBackends.ts +98 -0
- package/src/views/users.ts +163 -0
- package/src/views/webhooks.ts +166 -0
- package/src/webhooks.ts +634 -0
package/src/webhooks.ts
ADDED
|
@@ -0,0 +1,634 @@
|
|
|
1
|
+
// Gitea-compatible webhook emitter.
|
|
2
|
+
//
|
|
3
|
+
// Emits `issues` (opened/closed/reopened) and `issue_comment` (created) events
|
|
4
|
+
// to project-scoped webhook URLs. Payload shape + headers mirror Gitea so that
|
|
5
|
+
// downstream consumers written against Gitea work
|
|
6
|
+
// unchanged. See:
|
|
7
|
+
// - Gitea docs: https://docs.gitea.com/development/webhooks
|
|
8
|
+
//
|
|
9
|
+
// Design notes:
|
|
10
|
+
// - HMAC-SHA256 signature is computed over the exact bytes we POST (rawBody),
|
|
11
|
+
// hex-encoded lowercase, matching Gitea's `X-Gitea-Signature`.
|
|
12
|
+
// - GitHub-compat alias `X-GitHub-Signature-256: sha256=<hex>` is included too.
|
|
13
|
+
// - Delivery is fire-and-forget: callers `void emitX(...)` and the HTTP response
|
|
14
|
+
// is not blocked. Failures are retried with exponential backoff, then recorded.
|
|
15
|
+
// - One DB row per delivery ATTEMPT (retries append), so the delivery log shows
|
|
16
|
+
// the full retry trail rather than an opaque final status.
|
|
17
|
+
|
|
18
|
+
import { createHmac, randomUUID } from "node:crypto";
|
|
19
|
+
import { rawDB } from "./db";
|
|
20
|
+
import { log } from "./logger";
|
|
21
|
+
import {
|
|
22
|
+
StoreError,
|
|
23
|
+
getIssueById,
|
|
24
|
+
getProjectById,
|
|
25
|
+
getDefaultUpstreamUrl,
|
|
26
|
+
ensureUser,
|
|
27
|
+
type IssueRow,
|
|
28
|
+
type ProjectRow,
|
|
29
|
+
type CommentRow,
|
|
30
|
+
} from "./store";
|
|
31
|
+
|
|
32
|
+
export type WebhookEventName = "issues" | "issue_comment";
|
|
33
|
+
export type IssueAction = "opened" | "closed" | "reopened";
|
|
34
|
+
|
|
35
|
+
export interface WebhookRow {
|
|
36
|
+
id: number;
|
|
37
|
+
project_id: number;
|
|
38
|
+
url: string;
|
|
39
|
+
secret: string;
|
|
40
|
+
content_type: string;
|
|
41
|
+
events: string; // JSON array, e.g. '["issues","issue_comment"]'
|
|
42
|
+
active: number; // 0 | 1
|
|
43
|
+
created_at: string;
|
|
44
|
+
updated_at: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface WebhookDeliveryRow {
|
|
48
|
+
id: number;
|
|
49
|
+
webhook_id: number;
|
|
50
|
+
event: WebhookEventName | string;
|
|
51
|
+
delivery_uuid: string;
|
|
52
|
+
payload: string;
|
|
53
|
+
response_status: number | null;
|
|
54
|
+
response_body: string | null;
|
|
55
|
+
duration_ms: number | null;
|
|
56
|
+
error: string | null;
|
|
57
|
+
created_at: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const DEFAULT_EVENTS: WebhookEventName[] = ["issues", "issue_comment"];
|
|
61
|
+
const MAX_RESPONSE_LOG_BYTES = 8192;
|
|
62
|
+
const HTTP_TIMEOUT_MS = 10_000;
|
|
63
|
+
const RETRY_DELAYS_MS = [0, 2_000, 8_000];
|
|
64
|
+
|
|
65
|
+
function now(): string {
|
|
66
|
+
return new Date().toISOString();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function parseEvents(events: unknown): WebhookEventName[] {
|
|
70
|
+
if (typeof events !== "string") return DEFAULT_EVENTS;
|
|
71
|
+
try {
|
|
72
|
+
const arr = JSON.parse(events) as unknown[];
|
|
73
|
+
const valid = arr.filter(
|
|
74
|
+
(v): v is WebhookEventName => v === "issues" || v === "issue_comment"
|
|
75
|
+
);
|
|
76
|
+
return valid.length > 0 ? valid : DEFAULT_EVENTS;
|
|
77
|
+
} catch {
|
|
78
|
+
return DEFAULT_EVENTS;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ─── CRUD ────────────────────────────────────────────────────
|
|
83
|
+
|
|
84
|
+
export function listWebhooks(projectId: number): WebhookRow[] {
|
|
85
|
+
return rawDB()
|
|
86
|
+
.query("SELECT * FROM webhooks WHERE project_id = ? ORDER BY id")
|
|
87
|
+
.all(projectId) as WebhookRow[];
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function getWebhook(id: number): WebhookRow | null {
|
|
91
|
+
const row = rawDB().query("SELECT * FROM webhooks WHERE id = ?").get(id) as
|
|
92
|
+
| WebhookRow
|
|
93
|
+
| undefined;
|
|
94
|
+
return row ?? null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export interface CreateWebhookInput {
|
|
98
|
+
project_id: number;
|
|
99
|
+
url: string;
|
|
100
|
+
secret?: string;
|
|
101
|
+
events?: WebhookEventName[];
|
|
102
|
+
active?: boolean;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function createWebhook(input: CreateWebhookInput): WebhookRow {
|
|
106
|
+
const url = input.url.trim();
|
|
107
|
+
if (!url) throw new StoreError(400, "URL 不能为空");
|
|
108
|
+
if (!/^https?:\/\//i.test(url)) throw new StoreError(400, "URL 必须是 http(s)://");
|
|
109
|
+
if (url.length > 2048) throw new StoreError(400, "URL 过长");
|
|
110
|
+
const secret = (input.secret ?? "").slice(0, 256);
|
|
111
|
+
const events = input.events && input.events.length > 0 ? input.events : DEFAULT_EVENTS;
|
|
112
|
+
const ts = now();
|
|
113
|
+
const db = rawDB();
|
|
114
|
+
const info = db
|
|
115
|
+
.query(
|
|
116
|
+
`INSERT INTO webhooks (project_id, url, secret, content_type, events, active, created_at, updated_at)
|
|
117
|
+
VALUES (?, ?, ?, 'application/json', ?, ?, ?, ?)`
|
|
118
|
+
)
|
|
119
|
+
.run(
|
|
120
|
+
input.project_id,
|
|
121
|
+
url,
|
|
122
|
+
secret,
|
|
123
|
+
JSON.stringify(events),
|
|
124
|
+
input.active === false ? 0 : 1,
|
|
125
|
+
ts,
|
|
126
|
+
ts
|
|
127
|
+
);
|
|
128
|
+
return getWebhook(Number(info.lastInsertRowid))!;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function deleteWebhook(id: number): void {
|
|
132
|
+
rawDB().query("DELETE FROM webhooks WHERE id = ?").run(id);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function setWebhookActive(id: number, active: boolean): void {
|
|
136
|
+
rawDB()
|
|
137
|
+
.query("UPDATE webhooks SET active = ?, updated_at = ? WHERE id = ?")
|
|
138
|
+
.run(active ? 1 : 0, now(), id);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function listDeliveries(webhookId: number, limit = 50): WebhookDeliveryRow[] {
|
|
142
|
+
return rawDB()
|
|
143
|
+
.query(
|
|
144
|
+
"SELECT * FROM webhook_deliveries WHERE webhook_id = ? ORDER BY id DESC LIMIT ?"
|
|
145
|
+
)
|
|
146
|
+
.all(webhookId, limit) as WebhookDeliveryRow[];
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ─── Payload builders (Gitea-compatible shape) ───────────────
|
|
150
|
+
//
|
|
151
|
+
// We populate the fields downstream consumers actually read (see Gitea's
|
|
152
|
+
// GiteaTracker.parseWebhookEvent): action, issue.{number,title,body,state,
|
|
153
|
+
// user.login,html_url}, comment.{id,body,user.login}, repository.{name,
|
|
154
|
+
// owner.login,full_name}, sender.login. We also include the rest of the
|
|
155
|
+
// standard Gitea fields (created_at, comments count, id, etc.) populated from
|
|
156
|
+
// ework's data so payload-completeness checkers / other Gitea clients work too.
|
|
157
|
+
|
|
158
|
+
interface PayloadUser {
|
|
159
|
+
id: number;
|
|
160
|
+
login: string;
|
|
161
|
+
login_name: string;
|
|
162
|
+
full_name: string;
|
|
163
|
+
email: string;
|
|
164
|
+
avatar_url: string;
|
|
165
|
+
html_url: string;
|
|
166
|
+
is_admin: boolean;
|
|
167
|
+
language: string;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
interface PayloadLabel {
|
|
171
|
+
id: number;
|
|
172
|
+
name: string;
|
|
173
|
+
color: string;
|
|
174
|
+
description: string;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
interface PayloadRepository {
|
|
178
|
+
id: number;
|
|
179
|
+
owner: PayloadUser;
|
|
180
|
+
name: string;
|
|
181
|
+
full_name: string;
|
|
182
|
+
description: string;
|
|
183
|
+
private: boolean;
|
|
184
|
+
fork: boolean;
|
|
185
|
+
parent: null;
|
|
186
|
+
empty: boolean;
|
|
187
|
+
mirror: boolean;
|
|
188
|
+
size: number;
|
|
189
|
+
html_url: string;
|
|
190
|
+
ssh_url: string;
|
|
191
|
+
clone_url: string;
|
|
192
|
+
website: string;
|
|
193
|
+
stars_count: number;
|
|
194
|
+
forks_count: number;
|
|
195
|
+
watchers_count: number;
|
|
196
|
+
open_issues_count: number;
|
|
197
|
+
default_branch: string;
|
|
198
|
+
created_at: string;
|
|
199
|
+
updated_at: string;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
interface PayloadIssue {
|
|
203
|
+
id: number;
|
|
204
|
+
url: string;
|
|
205
|
+
html_url: string;
|
|
206
|
+
number: number;
|
|
207
|
+
title: string;
|
|
208
|
+
body: string;
|
|
209
|
+
labels: PayloadLabel[];
|
|
210
|
+
milestone: null;
|
|
211
|
+
assignee: null;
|
|
212
|
+
assignees: null;
|
|
213
|
+
state: "open" | "closed";
|
|
214
|
+
is_locked: boolean;
|
|
215
|
+
comments: number;
|
|
216
|
+
created_at: string;
|
|
217
|
+
updated_at: string;
|
|
218
|
+
closed_at: string | null;
|
|
219
|
+
due_date: null;
|
|
220
|
+
pull_request: null;
|
|
221
|
+
repository: PayloadRepository;
|
|
222
|
+
user: PayloadUser;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
interface PayloadComment {
|
|
226
|
+
id: number;
|
|
227
|
+
html_url: string;
|
|
228
|
+
pull_request_url: string;
|
|
229
|
+
issue_url: string;
|
|
230
|
+
body: string;
|
|
231
|
+
created_at: string;
|
|
232
|
+
updated_at: string;
|
|
233
|
+
user: PayloadUser;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
interface IssueEventPayload {
|
|
237
|
+
action: IssueAction;
|
|
238
|
+
issue: PayloadIssue;
|
|
239
|
+
repository: PayloadRepository;
|
|
240
|
+
sender: PayloadUser;
|
|
241
|
+
commit_id?: string;
|
|
242
|
+
commit_url?: string;
|
|
243
|
+
url?: string;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
interface CommentEventPayload {
|
|
247
|
+
action: "created";
|
|
248
|
+
issue: PayloadIssue;
|
|
249
|
+
comment: PayloadComment;
|
|
250
|
+
repository: PayloadRepository;
|
|
251
|
+
sender: PayloadUser;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function buildUser(login: string, origin: string): PayloadUser {
|
|
255
|
+
return {
|
|
256
|
+
id: 1,
|
|
257
|
+
login,
|
|
258
|
+
login_name: "",
|
|
259
|
+
full_name: login,
|
|
260
|
+
email: `${login}@${new URL(origin).host ?? "localhost"}.noreply.ework`,
|
|
261
|
+
avatar_url: `${origin}/static/favicon.svg`,
|
|
262
|
+
html_url: origin,
|
|
263
|
+
is_admin: false,
|
|
264
|
+
language: "",
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function buildRepository(project: ProjectRow, origin: string): PayloadRepository {
|
|
269
|
+
const fullName = `${project.owner}/${project.name}`;
|
|
270
|
+
const htmlUrl = `${origin}/${encodeURIComponent(project.owner)}/${encodeURIComponent(project.name)}`;
|
|
271
|
+
// clone_url must be a real Git remote (ework-web is NOT a Git server). Use the
|
|
272
|
+
// project's default upstream URL when bound; fall back to the synthetic htmlUrl
|
|
273
|
+
// + .git for purely tracker-only projects (recipients that don't attempt git
|
|
274
|
+
// clone will still see a syntactically valid URL).
|
|
275
|
+
const upstream = getDefaultUpstreamUrl(project);
|
|
276
|
+
return {
|
|
277
|
+
id: project.id,
|
|
278
|
+
owner: buildUser(project.owner, origin),
|
|
279
|
+
name: project.name,
|
|
280
|
+
full_name: fullName,
|
|
281
|
+
description: project.description ?? "",
|
|
282
|
+
private: false,
|
|
283
|
+
fork: false,
|
|
284
|
+
parent: null,
|
|
285
|
+
empty: false,
|
|
286
|
+
mirror: false,
|
|
287
|
+
size: 0,
|
|
288
|
+
html_url: htmlUrl,
|
|
289
|
+
ssh_url: "",
|
|
290
|
+
clone_url: upstream ?? `${htmlUrl}.git`,
|
|
291
|
+
website: "",
|
|
292
|
+
stars_count: 0,
|
|
293
|
+
forks_count: 0,
|
|
294
|
+
watchers_count: 1,
|
|
295
|
+
open_issues_count: 0,
|
|
296
|
+
default_branch: "main",
|
|
297
|
+
created_at: project.created_at,
|
|
298
|
+
updated_at: project.updated_at,
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function buildIssue(
|
|
303
|
+
issue: IssueRow,
|
|
304
|
+
project: ProjectRow,
|
|
305
|
+
commentCount: number,
|
|
306
|
+
origin: string
|
|
307
|
+
): PayloadIssue {
|
|
308
|
+
const repoUrl = `${origin}/${encodeURIComponent(project.owner)}/${encodeURIComponent(project.name)}`;
|
|
309
|
+
const issueUrl = `${repoUrl}/issues/${issue.number}`;
|
|
310
|
+
return {
|
|
311
|
+
id: issue.id,
|
|
312
|
+
url: `${origin}/api/v1/repos/${encodeURIComponent(project.owner)}/${encodeURIComponent(project.name)}/issues/${issue.number}`,
|
|
313
|
+
html_url: issueUrl,
|
|
314
|
+
number: issue.number,
|
|
315
|
+
title: issue.title,
|
|
316
|
+
body: issue.body ?? "",
|
|
317
|
+
labels: [],
|
|
318
|
+
milestone: null,
|
|
319
|
+
assignee: null,
|
|
320
|
+
assignees: null,
|
|
321
|
+
state: issue.state,
|
|
322
|
+
is_locked: false,
|
|
323
|
+
comments: commentCount,
|
|
324
|
+
created_at: issue.created_at,
|
|
325
|
+
updated_at: issue.updated_at,
|
|
326
|
+
closed_at: issue.state === "closed" ? issue.updated_at : null,
|
|
327
|
+
due_date: null,
|
|
328
|
+
pull_request: null,
|
|
329
|
+
repository: buildRepository(project, origin),
|
|
330
|
+
user: buildUser(issue.author, origin),
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function buildComment(
|
|
335
|
+
issue: IssueRow,
|
|
336
|
+
comment: CommentRow,
|
|
337
|
+
project: ProjectRow,
|
|
338
|
+
origin: string
|
|
339
|
+
): PayloadComment {
|
|
340
|
+
const repoUrl = `${origin}/${encodeURIComponent(project.owner)}/${encodeURIComponent(project.name)}`;
|
|
341
|
+
const issueUrl = `${repoUrl}/issues/${issue.number}`;
|
|
342
|
+
return {
|
|
343
|
+
id: comment.id,
|
|
344
|
+
html_url: `${issueUrl}#issuecomment-${comment.id}`,
|
|
345
|
+
pull_request_url: "",
|
|
346
|
+
issue_url: issueUrl,
|
|
347
|
+
body: comment.body,
|
|
348
|
+
created_at: comment.created_at,
|
|
349
|
+
updated_at: comment.updated_at,
|
|
350
|
+
user: buildUser(comment.author, origin),
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function buildCommentPayload(
|
|
355
|
+
issue: IssueRow,
|
|
356
|
+
comment: CommentRow,
|
|
357
|
+
project: ProjectRow,
|
|
358
|
+
commentCount: number,
|
|
359
|
+
origin: string
|
|
360
|
+
): CommentEventPayload {
|
|
361
|
+
return {
|
|
362
|
+
action: "created",
|
|
363
|
+
issue: buildIssue(issue, project, commentCount, origin),
|
|
364
|
+
comment: buildComment(issue, comment, project, origin),
|
|
365
|
+
repository: buildRepository(project, origin),
|
|
366
|
+
sender: buildUser(comment.author, origin),
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function buildIssuePayload(
|
|
371
|
+
issue: IssueRow,
|
|
372
|
+
project: ProjectRow,
|
|
373
|
+
commentCount: number,
|
|
374
|
+
action: IssueAction,
|
|
375
|
+
origin: string
|
|
376
|
+
): IssueEventPayload {
|
|
377
|
+
return {
|
|
378
|
+
action,
|
|
379
|
+
issue: buildIssue(issue, project, commentCount, origin),
|
|
380
|
+
repository: buildRepository(project, origin),
|
|
381
|
+
sender: buildUser(issue.author, origin),
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// ─── Delivery ────────────────────────────────────────────────
|
|
386
|
+
|
|
387
|
+
function signBody(secret: string, rawBody: string): string {
|
|
388
|
+
return createHmac("sha256", secret).update(rawBody).digest("hex");
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function buildHeaders(
|
|
392
|
+
event: WebhookEventName,
|
|
393
|
+
deliveryUuid: string,
|
|
394
|
+
rawBody: string,
|
|
395
|
+
secret: string
|
|
396
|
+
): Record<string, string> {
|
|
397
|
+
const sig = signBody(secret, rawBody);
|
|
398
|
+
return {
|
|
399
|
+
"Content-Type": "application/json",
|
|
400
|
+
"User-Agent": "eworkServer/0.1",
|
|
401
|
+
"X-Gitea-Delivery": deliveryUuid,
|
|
402
|
+
"X-Gitea-Event": event,
|
|
403
|
+
"X-Gitea-Signature": sig,
|
|
404
|
+
"X-Gitea-Event-Type": event,
|
|
405
|
+
"X-Gogs-Event": event,
|
|
406
|
+
"X-Gogs-Delivery": deliveryUuid,
|
|
407
|
+
"X-Gogs-Signature": sig,
|
|
408
|
+
"X-GitHub-Delivery": deliveryUuid,
|
|
409
|
+
"X-GitHub-Event": event,
|
|
410
|
+
"X-GitHub-Signature-256": `sha256=${sig}`,
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
interface DeliveryAttemptResult {
|
|
415
|
+
status: number | null;
|
|
416
|
+
body: string | null;
|
|
417
|
+
durationMs: number;
|
|
418
|
+
error: string | null;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
async function postWithTimeout(
|
|
422
|
+
url: string,
|
|
423
|
+
body: string,
|
|
424
|
+
headers: Record<string, string>
|
|
425
|
+
): Promise<DeliveryAttemptResult> {
|
|
426
|
+
const start = Date.now();
|
|
427
|
+
const ctrl = new AbortController();
|
|
428
|
+
const timer = setTimeout(() => ctrl.abort(), HTTP_TIMEOUT_MS);
|
|
429
|
+
try {
|
|
430
|
+
const res = await fetch(url, {
|
|
431
|
+
method: "POST",
|
|
432
|
+
headers,
|
|
433
|
+
body,
|
|
434
|
+
signal: ctrl.signal,
|
|
435
|
+
redirect: "manual",
|
|
436
|
+
});
|
|
437
|
+
const text = await res.text().catch(() => "");
|
|
438
|
+
return {
|
|
439
|
+
status: res.status,
|
|
440
|
+
body: text.slice(0, MAX_RESPONSE_LOG_BYTES) || null,
|
|
441
|
+
durationMs: Date.now() - start,
|
|
442
|
+
error: null,
|
|
443
|
+
};
|
|
444
|
+
} catch (e) {
|
|
445
|
+
return {
|
|
446
|
+
status: null,
|
|
447
|
+
body: null,
|
|
448
|
+
durationMs: Date.now() - start,
|
|
449
|
+
error: e instanceof Error ? e.message : String(e),
|
|
450
|
+
};
|
|
451
|
+
} finally {
|
|
452
|
+
clearTimeout(timer);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function recordDelivery(
|
|
457
|
+
webhookId: number,
|
|
458
|
+
event: WebhookEventName,
|
|
459
|
+
deliveryUuid: string,
|
|
460
|
+
rawBody: string,
|
|
461
|
+
result: DeliveryAttemptResult
|
|
462
|
+
): void {
|
|
463
|
+
try {
|
|
464
|
+
rawDB()
|
|
465
|
+
.query(
|
|
466
|
+
`INSERT INTO webhook_deliveries
|
|
467
|
+
(webhook_id, event, delivery_uuid, payload, response_status, response_body, duration_ms, error, created_at)
|
|
468
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
469
|
+
)
|
|
470
|
+
.run(
|
|
471
|
+
webhookId,
|
|
472
|
+
event,
|
|
473
|
+
deliveryUuid,
|
|
474
|
+
rawBody,
|
|
475
|
+
result.status,
|
|
476
|
+
result.body,
|
|
477
|
+
result.durationMs,
|
|
478
|
+
result.error,
|
|
479
|
+
now()
|
|
480
|
+
);
|
|
481
|
+
} catch (e) {
|
|
482
|
+
log.error("webhook: failed to record delivery", { err: e as Error });
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
async function deliver(
|
|
487
|
+
webhook: WebhookRow,
|
|
488
|
+
event: WebhookEventName,
|
|
489
|
+
rawBody: string
|
|
490
|
+
): Promise<void> {
|
|
491
|
+
for (const attempt of RETRY_DELAYS_MS) {
|
|
492
|
+
if (attempt > 0) await Bun.sleep(attempt);
|
|
493
|
+
const deliveryUuid = randomUUID();
|
|
494
|
+
const headers = buildHeaders(event, deliveryUuid, rawBody, webhook.secret);
|
|
495
|
+
const result = await postWithTimeout(webhook.url, rawBody, headers);
|
|
496
|
+
const success = result.status !== null && result.status >= 200 && result.status < 300;
|
|
497
|
+
recordDelivery(webhook.id, event, deliveryUuid, rawBody, result);
|
|
498
|
+
if (success) return;
|
|
499
|
+
// 410 Gone: receiver explicitly says "stop sending" — honor it, no retry.
|
|
500
|
+
if (result.status === 410) return;
|
|
501
|
+
}
|
|
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
|
+
}
|
|
509
|
+
|
|
510
|
+
function fanOut(
|
|
511
|
+
projectId: number,
|
|
512
|
+
event: WebhookEventName,
|
|
513
|
+
rawBody: string
|
|
514
|
+
): void {
|
|
515
|
+
const webhooks = rawDB()
|
|
516
|
+
.query("SELECT * FROM webhooks WHERE project_id = ? AND active = 1")
|
|
517
|
+
.all(projectId) as WebhookRow[];
|
|
518
|
+
for (const wh of webhooks) {
|
|
519
|
+
if (!parseEvents(wh.events).includes(event)) continue;
|
|
520
|
+
void deliver(wh, event, rawBody);
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
// ─── Public emit API ─────────────────────────────────────────
|
|
525
|
+
|
|
526
|
+
export function emitIssueEvent(
|
|
527
|
+
projectId: number,
|
|
528
|
+
issueId: number,
|
|
529
|
+
action: IssueAction,
|
|
530
|
+
origin: string
|
|
531
|
+
): void {
|
|
532
|
+
try {
|
|
533
|
+
const project = getProjectById(projectId);
|
|
534
|
+
if (!project) return;
|
|
535
|
+
const issue = getIssueById(issueId);
|
|
536
|
+
if (!issue) return;
|
|
537
|
+
const commentCount = countCommentsSafe(issueId);
|
|
538
|
+
const payload = buildIssuePayload(issue, project, commentCount, action, origin);
|
|
539
|
+
const rawBody = JSON.stringify(payload);
|
|
540
|
+
fanOut(projectId, "issues", rawBody);
|
|
541
|
+
} catch (e) {
|
|
542
|
+
log.error("webhook: emitIssueEvent failed", {
|
|
543
|
+
err: e as Error,
|
|
544
|
+
projectId,
|
|
545
|
+
issueId,
|
|
546
|
+
action,
|
|
547
|
+
});
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
export function emitCommentEvent(
|
|
552
|
+
projectId: number,
|
|
553
|
+
issueId: number,
|
|
554
|
+
commentId: number,
|
|
555
|
+
origin: string
|
|
556
|
+
): void {
|
|
557
|
+
try {
|
|
558
|
+
const project = getProjectById(projectId);
|
|
559
|
+
if (!project) return;
|
|
560
|
+
const issue = getIssueById(issueId);
|
|
561
|
+
if (!issue) return;
|
|
562
|
+
const comment = getCommentByIdSafe(commentId);
|
|
563
|
+
if (!comment) return;
|
|
564
|
+
const commentCount = countCommentsSafe(issueId);
|
|
565
|
+
const payload = buildCommentPayload(issue, comment, project, commentCount, origin);
|
|
566
|
+
const rawBody = JSON.stringify(payload);
|
|
567
|
+
fanOut(projectId, "issue_comment", rawBody);
|
|
568
|
+
} catch (e) {
|
|
569
|
+
log.error("webhook: emitCommentEvent failed", {
|
|
570
|
+
err: e as Error,
|
|
571
|
+
projectId,
|
|
572
|
+
issueId,
|
|
573
|
+
commentId,
|
|
574
|
+
});
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
export function emitPingEvent(projectId: number, origin: string): void {
|
|
579
|
+
// Synthetic ping: send a small `ping` event (no issue context). Useful for
|
|
580
|
+
// verifying webhook configuration without triggering a real write.
|
|
581
|
+
try {
|
|
582
|
+
const project = getProjectById(projectId);
|
|
583
|
+
if (!project) return;
|
|
584
|
+
const payload = {
|
|
585
|
+
zen: "smoke",
|
|
586
|
+
hook_id: 0,
|
|
587
|
+
hook: { type: "Gitea", id: 0, active: true, events: parseEvents(undefined) },
|
|
588
|
+
repository: buildRepository(project, origin),
|
|
589
|
+
sender: buildUser(project.owner, origin),
|
|
590
|
+
};
|
|
591
|
+
const rawBody = JSON.stringify(payload);
|
|
592
|
+
fanOut(projectId, "issues", rawBody);
|
|
593
|
+
} catch (e) {
|
|
594
|
+
log.error("webhook: emitPingEvent failed", { err: e as Error, projectId });
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
// ─── Helpers (kept here to avoid widening store.ts surface) ──
|
|
599
|
+
|
|
600
|
+
function countCommentsSafe(issueId: number): number {
|
|
601
|
+
try {
|
|
602
|
+
const row = rawDB()
|
|
603
|
+
.query("SELECT COUNT(*) AS n FROM comments WHERE issue_id = ?")
|
|
604
|
+
.get(issueId) as { n: number } | undefined;
|
|
605
|
+
return row?.n ?? 0;
|
|
606
|
+
} catch {
|
|
607
|
+
return 0;
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
function getCommentByIdSafe(id: number): CommentRow | null {
|
|
612
|
+
try {
|
|
613
|
+
const row = rawDB().query("SELECT * FROM comments WHERE id = ?").get(id) as
|
|
614
|
+
| CommentRow
|
|
615
|
+
| undefined;
|
|
616
|
+
return row ?? null;
|
|
617
|
+
} catch {
|
|
618
|
+
return null;
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
// Re-exported so callers don't need to import ensureUser separately for tests.
|
|
623
|
+
export const _internal = { ensureUser, buildHeaders, signBody, parseEvents };
|
|
624
|
+
|
|
625
|
+
// Payload builders shared with giteaApi.ts (REST shim). The shim must return
|
|
626
|
+
// the exact same shape as the webhook payloads so consumers (daemon, plugins)
|
|
627
|
+
// see one consistent Gitea-compatible contract regardless of whether they
|
|
628
|
+
// pull via webhook push or REST poll.
|
|
629
|
+
export {
|
|
630
|
+
buildUser,
|
|
631
|
+
buildRepository,
|
|
632
|
+
buildIssue as buildIssuePayload,
|
|
633
|
+
buildComment as buildCommentPayload,
|
|
634
|
+
};
|