ework-web 0.2.0 → 0.3.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 +3 -3
- package/src/db.ts +286 -49
- package/src/giteaApi.ts +53 -53
- package/src/index.ts +112 -99
- 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/projectModel.ts +1 -1
- package/src/views/projectUpstreams.ts +3 -3
- package/src/webhooks.ts +63 -73
package/src/views/home.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { listProjectsWithCounts, createProject, canAdminProject, StoreError, typ
|
|
|
3
3
|
import { relTime } from "../render/components";
|
|
4
4
|
import { getDefaultUpstreamUrl, webUrlFromClone } from "./projectUpstreams";
|
|
5
5
|
|
|
6
|
-
function projectCard(p: ProjectWithCounts, viewer: UserRow | null): string {
|
|
6
|
+
async function projectCard(p: ProjectWithCounts, viewer: UserRow | null): Promise<string> {
|
|
7
7
|
const issuesHref = `/${encodeURIComponent(p.owner)}/${encodeURIComponent(p.name)}/issues`;
|
|
8
8
|
const settingsHref = `/${encodeURIComponent(p.owner)}/${encodeURIComponent(p.name)}/settings/upstreams`;
|
|
9
9
|
const desc = p.description ? `<div class="pcard-desc">${escapeHtml(p.description)}</div>` : "";
|
|
@@ -12,7 +12,7 @@ function projectCard(p: ProjectWithCounts, viewer: UserRow | null): string {
|
|
|
12
12
|
const upstreamIcon = upstreamWebUrl
|
|
13
13
|
? `<a class="pcard-icon" href="${escapeAttr(upstreamWebUrl)}" target="_blank" rel="noopener noreferrer" title="查看上游:${escapeAttr(upstreamWebUrl)}" aria-label="查看上游">🔗</a>`
|
|
14
14
|
: "";
|
|
15
|
-
const adminIcon = canAdminProject(p.id, viewer)
|
|
15
|
+
const adminIcon = await canAdminProject(p.id, viewer)
|
|
16
16
|
? `<a class="pcard-icon" href="${escapeAttr(settingsHref)}" title="项目设置(上游 / Webhooks / 成员)" aria-label="项目设置">⚙️</a>`
|
|
17
17
|
: "";
|
|
18
18
|
const iconsHtml = (upstreamIcon || adminIcon)
|
|
@@ -29,13 +29,14 @@ function projectCard(p: ProjectWithCounts, viewer: UserRow | null): string {
|
|
|
29
29
|
</div>`;
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
-
export function buildHome(
|
|
32
|
+
export async function buildHome(
|
|
33
33
|
viewer: UserRow | null,
|
|
34
34
|
flash: { kind: "ok" | "err"; msg: string } | null = null
|
|
35
|
-
): string {
|
|
36
|
-
const projects = listProjectsWithCounts();
|
|
35
|
+
): Promise<string> {
|
|
36
|
+
const projects = await listProjectsWithCounts();
|
|
37
|
+
const cards = await Promise.all(projects.map((p) => projectCard(p, viewer)));
|
|
37
38
|
const list = projects.length
|
|
38
|
-
?
|
|
39
|
+
? cards.join("")
|
|
39
40
|
: `<div class="empty">还没有项目。在下面创建一个:</div>`;
|
|
40
41
|
const flashHtml = flash
|
|
41
42
|
? `<div class="flash ${flash.kind === "ok" ? "ok" : "err"}">${escapeHtml(flash.msg)}</div>`
|
|
@@ -90,15 +91,15 @@ ${tabNavHTML("projects")}
|
|
|
90
91
|
</body></html>`;
|
|
91
92
|
}
|
|
92
93
|
|
|
93
|
-
export function handleCreateProject(
|
|
94
|
+
export async function handleCreateProject(
|
|
94
95
|
form: Record<string, string | undefined>
|
|
95
|
-
): { location: string; error?: string; projectId?: number } {
|
|
96
|
+
): Promise<{ location: string; error?: string; projectId?: number }> {
|
|
96
97
|
const owner = (form.owner ?? "").trim();
|
|
97
98
|
const name = (form.name ?? "").trim();
|
|
98
99
|
const description = (form.description ?? "").trim();
|
|
99
100
|
if (!owner || !name) return { location: "/projects", error: "owner 和 name 必填" };
|
|
100
101
|
try {
|
|
101
|
-
const p = createProject(owner, name, description);
|
|
102
|
+
const p = await createProject(owner, name, description);
|
|
102
103
|
return { location: `/${encodeURIComponent(p.owner)}/${encodeURIComponent(p.name)}/issues`, projectId: p.id };
|
|
103
104
|
} catch (e) {
|
|
104
105
|
return {
|
package/src/views/issueList.ts
CHANGED
|
@@ -13,18 +13,18 @@ function issueRow(it: IssueWithMeta, owner: string, repo: string, q: string): st
|
|
|
13
13
|
</a>`;
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
-
export function buildIssueList(
|
|
16
|
+
export async function buildIssueList(
|
|
17
17
|
owner: string,
|
|
18
18
|
repo: string,
|
|
19
19
|
state: "open" | "closed" | "all",
|
|
20
20
|
writesEnabled: boolean,
|
|
21
21
|
q: string
|
|
22
|
-
): string {
|
|
23
|
-
const project = getProject(owner, repo);
|
|
22
|
+
): Promise<string> {
|
|
23
|
+
const project = await getProject(owner, repo);
|
|
24
24
|
if (!project) {
|
|
25
25
|
return notFoundProject(owner, repo);
|
|
26
26
|
}
|
|
27
|
-
const issues = listIssues(project.id, { state, q, limit: LIST_PAGE_SIZE });
|
|
27
|
+
const issues = await listIssues(project.id, { state, q, limit: LIST_PAGE_SIZE });
|
|
28
28
|
const matchesFirst = q
|
|
29
29
|
? [...issues].sort((a, b) => Number(containsCI(b.title, q)) - Number(containsCI(a.title, q)))
|
|
30
30
|
: issues;
|
package/src/views/issueThread.ts
CHANGED
|
@@ -44,9 +44,9 @@ function toView(c: CommentRow): CommentView {
|
|
|
44
44
|
};
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
-
export function viewsFromComments(rows: CommentRow[]): CommentView[] {
|
|
47
|
+
export async function viewsFromComments(rows: CommentRow[]): Promise<CommentView[]> {
|
|
48
48
|
const views = rows.map((r) => toView(r));
|
|
49
|
-
hydrateReactions(views);
|
|
49
|
+
await hydrateReactions(views);
|
|
50
50
|
return views;
|
|
51
51
|
}
|
|
52
52
|
|
|
@@ -82,23 +82,23 @@ function orderForDisplay(views: CommentView[], sort: "desc" | "asc"): CommentVie
|
|
|
82
82
|
return sort === "asc" ? views : views.slice().reverse();
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
-
export function buildIssueThread(
|
|
85
|
+
export async function buildIssueThread(
|
|
86
86
|
cfg: Config,
|
|
87
87
|
owner: string,
|
|
88
88
|
repo: string,
|
|
89
89
|
number: number,
|
|
90
90
|
viewerLogin?: string
|
|
91
|
-
): { html: string } {
|
|
92
|
-
const project = getProject(owner, repo);
|
|
91
|
+
): Promise<{ html: string }> {
|
|
92
|
+
const project = await getProject(owner, repo);
|
|
93
93
|
if (!project) throw new StoreError(404, `项目 ${owner}/${repo} 不存在`);
|
|
94
|
-
const issue = getIssueWithMeta(project.id, number);
|
|
94
|
+
const issue = await getIssueWithMeta(project.id, number);
|
|
95
95
|
if (!issue) throw new StoreError(404, `#${number} 在 ${owner}/${repo} 不存在`);
|
|
96
96
|
|
|
97
|
-
const total = countComments(issue.id);
|
|
97
|
+
const total = await countComments(issue.id);
|
|
98
98
|
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
|
99
99
|
const currentPage = totalPages;
|
|
100
|
-
const { rows } = listCommentsPage(issue.id, currentPage, PAGE_SIZE);
|
|
101
|
-
const views = viewsFromComments(rows);
|
|
100
|
+
const { rows } = await listCommentsPage(issue.id, currentPage, PAGE_SIZE);
|
|
101
|
+
const views = await viewsFromComments(rows);
|
|
102
102
|
const hasOlder = currentPage > 1;
|
|
103
103
|
const displayViews = orderForDisplay(views, cfg.commentSort);
|
|
104
104
|
const payload = payloadFromComments(issue, displayViews, currentPage, hasOlder, cfg.commentSort);
|
|
@@ -140,33 +140,33 @@ export interface IssuePageData {
|
|
|
140
140
|
hasOlder: boolean;
|
|
141
141
|
}
|
|
142
142
|
|
|
143
|
-
export function fetchIssuePage(
|
|
143
|
+
export async function fetchIssuePage(
|
|
144
144
|
owner: string,
|
|
145
145
|
repo: string,
|
|
146
146
|
number: number,
|
|
147
147
|
page: number
|
|
148
|
-
): IssuePageData {
|
|
149
|
-
const project = getProject(owner, repo);
|
|
148
|
+
): Promise<IssuePageData> {
|
|
149
|
+
const project = await getProject(owner, repo);
|
|
150
150
|
if (!project) throw new StoreError(404, `项目 ${owner}/${repo} 不存在`);
|
|
151
|
-
const issue = getIssueWithMeta(project.id, number);
|
|
151
|
+
const issue = await getIssueWithMeta(project.id, number);
|
|
152
152
|
if (!issue) throw new StoreError(404, `#${number} 不存在`);
|
|
153
|
-
const { rows, page: clamped } = listCommentsPage(issue.id, page, PAGE_SIZE);
|
|
154
|
-
const views = viewsFromComments(rows);
|
|
153
|
+
const { rows, page: clamped } = await listCommentsPage(issue.id, page, PAGE_SIZE);
|
|
154
|
+
const views = await viewsFromComments(rows);
|
|
155
155
|
return { issue, views, currentPage: clamped, hasOlder: clamped > 1 };
|
|
156
156
|
}
|
|
157
157
|
|
|
158
|
-
export function fetchIssueSince(
|
|
158
|
+
export async function fetchIssueSince(
|
|
159
159
|
owner: string,
|
|
160
160
|
repo: string,
|
|
161
161
|
number: number,
|
|
162
162
|
sinceISO: string
|
|
163
|
-
): CommentView[] {
|
|
164
|
-
const project = getProject(owner, repo);
|
|
163
|
+
): Promise<CommentView[]> {
|
|
164
|
+
const project = await getProject(owner, repo);
|
|
165
165
|
if (!project) throw new StoreError(404, `项目 ${owner}/${repo} 不存在`);
|
|
166
|
-
const issue = getIssueWithMeta(project.id, number);
|
|
166
|
+
const issue = await getIssueWithMeta(project.id, number);
|
|
167
167
|
if (!issue) throw new StoreError(404, `#${number} 不存在`);
|
|
168
|
-
const rows = listCommentsSince(issue.id, sinceISO);
|
|
169
|
-
return viewsFromComments(rows);
|
|
168
|
+
const rows = await listCommentsSince(issue.id, sinceISO);
|
|
169
|
+
return await viewsFromComments(rows);
|
|
170
170
|
}
|
|
171
171
|
|
|
172
172
|
export function safeJsonEmbed(v: unknown): string {
|
package/src/views/issues.ts
CHANGED
|
@@ -14,11 +14,11 @@ function issueRow(it: IssueWithMeta, q: string): string {
|
|
|
14
14
|
</a>`;
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
-
export function buildIssuesFeed(
|
|
17
|
+
export async function buildIssuesFeed(
|
|
18
18
|
state: "open" | "closed" | "all",
|
|
19
19
|
q: string
|
|
20
|
-
): string {
|
|
21
|
-
const issues = listAllIssues({ state, q, limit: FEED_PAGE_SIZE });
|
|
20
|
+
): Promise<string> {
|
|
21
|
+
const issues = await listAllIssues({ state, q, limit: FEED_PAGE_SIZE });
|
|
22
22
|
const matchesFirst = q
|
|
23
23
|
? [...issues].sort((a, b) => Number(containsCI(b.title, q)) - Number(containsCI(a.title, q)))
|
|
24
24
|
: issues;
|
|
@@ -22,25 +22,24 @@ function roleBadge(role: ProjectRole): string {
|
|
|
22
22
|
return `<span class="badge role-${cls}">${escapeHtml(role)}</span>`;
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
-
export function buildProjectMembersPage(
|
|
25
|
+
export async function buildProjectMembersPage(
|
|
26
26
|
viewer: UserRow,
|
|
27
27
|
project: ProjectRow,
|
|
28
28
|
flash: Flash | null,
|
|
29
|
-
): string {
|
|
30
|
-
const members = listProjectMembersWithUsers(project.id);
|
|
31
|
-
const users = listUsers();
|
|
29
|
+
): Promise<string> {
|
|
30
|
+
const members = await listProjectMembersWithUsers(project.id);
|
|
31
|
+
const users = await listUsers();
|
|
32
32
|
const memberLogins = new Set(members.map((m) => m.user_login));
|
|
33
33
|
const candidates = users.filter((u) => u.is_active === 1 && !memberLogins.has(u.login));
|
|
34
34
|
|
|
35
35
|
const flashHtml = flash ? `<div class="flash ${flash.kind}">${escapeHtml(flash.msg)}</div>` : "";
|
|
36
36
|
|
|
37
|
+
const memberRows = await Promise.all(members.map((m) => memberRowHtml(m, viewer, project)));
|
|
37
38
|
const rowsHtml = members.length
|
|
38
39
|
? `<table>
|
|
39
40
|
<thead><tr><th>用户</th><th>角色</th><th>加入时间</th><th>操作</th></tr></thead>
|
|
40
41
|
<tbody>
|
|
41
|
-
${
|
|
42
|
-
.map((m) => memberRowHtml(m, viewer, project))
|
|
43
|
-
.join("")}
|
|
42
|
+
${memberRows.join("")}
|
|
44
43
|
</tbody>
|
|
45
44
|
</table>`
|
|
46
45
|
: `<div class="hint">该项目还没有成员记录(site-admin 已自动获得 admin 权限,可在下方添加)。</div>`;
|
|
@@ -117,9 +116,9 @@ ${addFormHtml}
|
|
|
117
116
|
</main></body></html>`;
|
|
118
117
|
}
|
|
119
118
|
|
|
120
|
-
function memberRowHtml(m: ProjectMemberWithUser, viewer: UserRow, project: ProjectRow): string {
|
|
119
|
+
async function memberRowHtml(m: ProjectMemberWithUser, viewer: UserRow, project: ProjectRow): Promise<string> {
|
|
121
120
|
const isSelf = m.user_login === viewer.login;
|
|
122
|
-
const isAdminCountOne = m.role === "admin" && countProjectAdmins(project.id) <= 1;
|
|
121
|
+
const isAdminCountOne = m.role === "admin" && (await countProjectAdmins(project.id)) <= 1;
|
|
123
122
|
const kindBadge = m.user_kind === "bot" ? `<span class="badge bot">bot</span>` : m.user_kind === "system" ? `<span class="badge">system</span>` : "";
|
|
124
123
|
const inactiveBadge = m.user_is_active === 1 ? "" : `<span class="badge inactive">禁用</span>`;
|
|
125
124
|
const selfNote = isSelf ? `<span class="meta">(你)</span>` : "";
|
|
@@ -18,7 +18,7 @@ export function buildProjectModelPage(
|
|
|
18
18
|
const effective = cur || globalDefault;
|
|
19
19
|
const effectiveLine = effective
|
|
20
20
|
? `<p class="hint">实际生效:<code>${escapeHtml(effective)}</code>${cur ? "" : " (继承全局默认)"}</p>`
|
|
21
|
-
: `<p class="hint">实际生效:<em>未配置</em> — daemon 不会加 <code>--model</code>,opencode
|
|
21
|
+
: `<p class="hint">实际生效:<em>未配置</em> — daemon 不会加 <code>--model</code>,opencode 按 opencode.json 选;注意环境变量(如 <code>OPENCODE_MODEL</code>)可能污染。建议去 <a href="/settings">全局设置</a> 点「刷新 opencode 模型列表」自动选一个默认。</p>`;
|
|
22
22
|
// Empty cache → free-text input (same degradation as global settings).
|
|
23
23
|
const field = models.length === 0
|
|
24
24
|
? `<input type="text" name="model" value="${escapeAttr(cur)}" placeholder="provider/model(去 /settings 刷新模型列表)">`
|
|
@@ -129,13 +129,13 @@ export function parseUpstreamUrlsForm(text: string): string[] {
|
|
|
129
129
|
.filter((line) => line.length > 0);
|
|
130
130
|
}
|
|
131
131
|
|
|
132
|
-
export function trySetUpstreamUrls(
|
|
132
|
+
export async function trySetUpstreamUrls(
|
|
133
133
|
projectId: number,
|
|
134
134
|
raw: string,
|
|
135
|
-
): { ok: true; urls: string[] } | { ok: false; msg: string } {
|
|
135
|
+
): Promise<{ ok: true; urls: string[] } | { ok: false; msg: string }> {
|
|
136
136
|
try {
|
|
137
137
|
const urls = parseUpstreamUrlsForm(raw);
|
|
138
|
-
const cleaned = setProjectUpstreamUrls(projectId, urls);
|
|
138
|
+
const cleaned = await setProjectUpstreamUrls(projectId, urls);
|
|
139
139
|
return { ok: true, urls: cleaned };
|
|
140
140
|
} catch (e) {
|
|
141
141
|
const msg = e instanceof StoreError ? e.message : e instanceof Error ? e.message : "保存失败";
|
package/src/webhooks.ts
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
// the full retry trail rather than an opaque final status.
|
|
17
17
|
|
|
18
18
|
import { createHmac, randomUUID } from "node:crypto";
|
|
19
|
-
import {
|
|
19
|
+
import { getDB } from "./db";
|
|
20
20
|
import { log } from "./logger";
|
|
21
21
|
import { loadConfig } from "./config";
|
|
22
22
|
import {
|
|
@@ -121,16 +121,12 @@ function parseEvents(events: unknown): WebhookEventName[] {
|
|
|
121
121
|
|
|
122
122
|
// ─── CRUD ────────────────────────────────────────────────────
|
|
123
123
|
|
|
124
|
-
export function listWebhooks(projectId: number): WebhookRow[] {
|
|
125
|
-
return
|
|
126
|
-
.query("SELECT * FROM webhooks WHERE project_id = ? ORDER BY id")
|
|
127
|
-
.all(projectId) as WebhookRow[];
|
|
124
|
+
export async function listWebhooks(projectId: number): Promise<WebhookRow[]> {
|
|
125
|
+
return await getDB().all<WebhookRow>("SELECT * FROM {{webhooks}} WHERE project_id = ? ORDER BY id", [projectId]);
|
|
128
126
|
}
|
|
129
127
|
|
|
130
|
-
export function getWebhook(id: number): WebhookRow | null {
|
|
131
|
-
const row =
|
|
132
|
-
| WebhookRow
|
|
133
|
-
| undefined;
|
|
128
|
+
export async function getWebhook(id: number): Promise<WebhookRow | null> {
|
|
129
|
+
const row = await getDB().get<WebhookRow>("SELECT * FROM {{webhooks}} WHERE id = ?", [id]);
|
|
134
130
|
return row ?? null;
|
|
135
131
|
}
|
|
136
132
|
|
|
@@ -142,7 +138,7 @@ export interface CreateWebhookInput {
|
|
|
142
138
|
active?: boolean;
|
|
143
139
|
}
|
|
144
140
|
|
|
145
|
-
export function createWebhook(input: CreateWebhookInput): WebhookRow {
|
|
141
|
+
export async function createWebhook(input: CreateWebhookInput): Promise<WebhookRow> {
|
|
146
142
|
const url = input.url.trim();
|
|
147
143
|
if (!url) throw new StoreError(400, "URL 不能为空");
|
|
148
144
|
if (!/^https?:\/\//i.test(url)) throw new StoreError(400, "URL 必须是 http(s)://");
|
|
@@ -150,13 +146,10 @@ export function createWebhook(input: CreateWebhookInput): WebhookRow {
|
|
|
150
146
|
const secret = (input.secret ?? "").slice(0, 256);
|
|
151
147
|
const events = input.events && input.events.length > 0 ? input.events : DEFAULT_EVENTS;
|
|
152
148
|
const ts = now();
|
|
153
|
-
const
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
VALUES (?, ?, ?, 'application/json', ?, ?, ?, ?)`
|
|
158
|
-
)
|
|
159
|
-
.run(
|
|
149
|
+
const info = await getDB().run(
|
|
150
|
+
`INSERT INTO {{webhooks}} (project_id, url, secret, content_type, events, active, created_at, updated_at)
|
|
151
|
+
VALUES (?, ?, ?, 'application/json', ?, ?, ?, ?)`,
|
|
152
|
+
[
|
|
160
153
|
input.project_id,
|
|
161
154
|
url,
|
|
162
155
|
secret,
|
|
@@ -164,26 +157,24 @@ export function createWebhook(input: CreateWebhookInput): WebhookRow {
|
|
|
164
157
|
input.active === false ? 0 : 1,
|
|
165
158
|
ts,
|
|
166
159
|
ts
|
|
167
|
-
|
|
168
|
-
|
|
160
|
+
]
|
|
161
|
+
);
|
|
162
|
+
return (await getWebhook(info.insertId))!;
|
|
169
163
|
}
|
|
170
164
|
|
|
171
|
-
export function deleteWebhook(id: number): void {
|
|
172
|
-
|
|
165
|
+
export async function deleteWebhook(id: number): Promise<void> {
|
|
166
|
+
await getDB().run("DELETE FROM {{webhooks}} WHERE id = ?", [id]);
|
|
173
167
|
}
|
|
174
168
|
|
|
175
|
-
export function setWebhookActive(id: number, active: boolean): void {
|
|
176
|
-
|
|
177
|
-
.query("UPDATE webhooks SET active = ?, updated_at = ? WHERE id = ?")
|
|
178
|
-
.run(active ? 1 : 0, now(), id);
|
|
169
|
+
export async function setWebhookActive(id: number, active: boolean): Promise<void> {
|
|
170
|
+
await getDB().run("UPDATE {{webhooks}} SET active = ?, updated_at = ? WHERE id = ?", [active ? 1 : 0, now(), id]);
|
|
179
171
|
}
|
|
180
172
|
|
|
181
|
-
export function listDeliveries(webhookId: number, limit = 50): WebhookDeliveryRow[] {
|
|
182
|
-
return
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
.all(webhookId, limit) as WebhookDeliveryRow[];
|
|
173
|
+
export async function listDeliveries(webhookId: number, limit = 50): Promise<WebhookDeliveryRow[]> {
|
|
174
|
+
return await getDB().all<WebhookDeliveryRow>(
|
|
175
|
+
"SELECT * FROM {{webhook_deliveries}} WHERE webhook_id = ? ORDER BY id DESC LIMIT ?",
|
|
176
|
+
[webhookId, limit]
|
|
177
|
+
);
|
|
187
178
|
}
|
|
188
179
|
|
|
189
180
|
// ─── Payload builders (Gitea-compatible shape) ───────────────
|
|
@@ -504,21 +495,19 @@ async function postWithTimeout(
|
|
|
504
495
|
}
|
|
505
496
|
}
|
|
506
497
|
|
|
507
|
-
function recordDelivery(
|
|
498
|
+
async function recordDelivery(
|
|
508
499
|
webhookId: number,
|
|
509
500
|
event: WebhookEventName,
|
|
510
501
|
deliveryUuid: string,
|
|
511
502
|
rawBody: string,
|
|
512
503
|
result: DeliveryAttemptResult
|
|
513
|
-
): void {
|
|
504
|
+
): Promise<void> {
|
|
514
505
|
try {
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
)
|
|
521
|
-
.run(
|
|
506
|
+
await getDB().run(
|
|
507
|
+
`INSERT INTO {{webhook_deliveries}}
|
|
508
|
+
(webhook_id, event, delivery_uuid, payload, response_status, response_body, duration_ms, error, created_at)
|
|
509
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
510
|
+
[
|
|
522
511
|
webhookId,
|
|
523
512
|
event,
|
|
524
513
|
deliveryUuid,
|
|
@@ -528,7 +517,8 @@ function recordDelivery(
|
|
|
528
517
|
result.durationMs,
|
|
529
518
|
result.error,
|
|
530
519
|
now()
|
|
531
|
-
|
|
520
|
+
]
|
|
521
|
+
);
|
|
532
522
|
} catch (e) {
|
|
533
523
|
log.error("webhook: failed to record delivery", { err: e as Error });
|
|
534
524
|
}
|
|
@@ -547,7 +537,7 @@ async function deliver(
|
|
|
547
537
|
const headers = buildHeaders(event, deliveryUuid, rawBody, webhook.secret);
|
|
548
538
|
const result = await postWithTimeout(webhook.url, rawBody, headers);
|
|
549
539
|
const success = result.status !== null && result.status >= 200 && result.status < 300;
|
|
550
|
-
recordDelivery(webhook.id, event, deliveryUuid, rawBody, result);
|
|
540
|
+
await recordDelivery(webhook.id, event, deliveryUuid, rawBody, result);
|
|
551
541
|
if (success) return;
|
|
552
542
|
// 410 Gone: receiver explicitly says "stop sending" — honor it, no retry.
|
|
553
543
|
if (result.status === 410) return;
|
|
@@ -563,14 +553,15 @@ async function deliver(
|
|
|
563
553
|
}
|
|
564
554
|
}
|
|
565
555
|
|
|
566
|
-
function fanOut(
|
|
556
|
+
async function fanOut(
|
|
567
557
|
projectId: number,
|
|
568
558
|
event: WebhookEventName,
|
|
569
559
|
rawBody: string
|
|
570
|
-
): void {
|
|
571
|
-
const webhooks =
|
|
572
|
-
|
|
573
|
-
|
|
560
|
+
): Promise<void> {
|
|
561
|
+
const webhooks = await getDB().all<WebhookRow>(
|
|
562
|
+
"SELECT * FROM {{webhooks}} WHERE project_id = ? AND active = 1",
|
|
563
|
+
[projectId]
|
|
564
|
+
);
|
|
574
565
|
for (const wh of webhooks) {
|
|
575
566
|
if (!parseEvents(wh.events).includes(event)) continue;
|
|
576
567
|
void deliver(wh, event, rawBody);
|
|
@@ -579,26 +570,26 @@ function fanOut(
|
|
|
579
570
|
|
|
580
571
|
// ─── Public emit API ─────────────────────────────────────────
|
|
581
572
|
|
|
582
|
-
export function emitIssueEvent(
|
|
573
|
+
export async function emitIssueEvent(
|
|
583
574
|
projectId: number,
|
|
584
575
|
issueId: number,
|
|
585
576
|
action: IssueAction,
|
|
586
577
|
origin: string
|
|
587
|
-
): void {
|
|
578
|
+
): Promise<void> {
|
|
588
579
|
try {
|
|
589
|
-
const project = getProjectById(projectId);
|
|
580
|
+
const project = await getProjectById(projectId);
|
|
590
581
|
if (!project) return;
|
|
591
|
-
const issue = getIssueById(issueId);
|
|
582
|
+
const issue = await getIssueById(issueId);
|
|
592
583
|
if (!issue) return;
|
|
593
|
-
const commentCount = countCommentsSafe(issueId);
|
|
584
|
+
const commentCount = await countCommentsSafe(issueId);
|
|
594
585
|
// Resolve model: project override > global default. Empty string = no
|
|
595
586
|
// override (daemon omits --model, lets opencode pick). globalDefault
|
|
596
587
|
// comes from the config table via loadConfig() — cheap DB read.
|
|
597
|
-
const globalDefault = loadConfig().defaultModel;
|
|
588
|
+
const globalDefault = (await loadConfig()).defaultModel;
|
|
598
589
|
const model = resolveModel(project.model, globalDefault);
|
|
599
590
|
const payload = buildIssuePayload(issue, project, commentCount, action, origin, model);
|
|
600
591
|
const rawBody = JSON.stringify(payload);
|
|
601
|
-
fanOut(projectId, "issues", rawBody);
|
|
592
|
+
await fanOut(projectId, "issues", rawBody);
|
|
602
593
|
} catch (e) {
|
|
603
594
|
log.error("webhook: emitIssueEvent failed", {
|
|
604
595
|
err: e as Error,
|
|
@@ -609,25 +600,25 @@ export function emitIssueEvent(
|
|
|
609
600
|
}
|
|
610
601
|
}
|
|
611
602
|
|
|
612
|
-
export function emitCommentEvent(
|
|
603
|
+
export async function emitCommentEvent(
|
|
613
604
|
projectId: number,
|
|
614
605
|
issueId: number,
|
|
615
606
|
commentId: number,
|
|
616
607
|
origin: string
|
|
617
|
-
): void {
|
|
608
|
+
): Promise<void> {
|
|
618
609
|
try {
|
|
619
|
-
const project = getProjectById(projectId);
|
|
610
|
+
const project = await getProjectById(projectId);
|
|
620
611
|
if (!project) return;
|
|
621
|
-
const issue = getIssueById(issueId);
|
|
612
|
+
const issue = await getIssueById(issueId);
|
|
622
613
|
if (!issue) return;
|
|
623
|
-
const comment = getCommentByIdSafe(commentId);
|
|
614
|
+
const comment = await getCommentByIdSafe(commentId);
|
|
624
615
|
if (!comment) return;
|
|
625
|
-
const commentCount = countCommentsSafe(issueId);
|
|
626
|
-
const globalDefault = loadConfig().defaultModel;
|
|
616
|
+
const commentCount = await countCommentsSafe(issueId);
|
|
617
|
+
const globalDefault = (await loadConfig()).defaultModel;
|
|
627
618
|
const model = resolveModel(project.model, globalDefault);
|
|
628
619
|
const payload = buildCommentPayload(issue, comment, project, commentCount, origin, model);
|
|
629
620
|
const rawBody = JSON.stringify(payload);
|
|
630
|
-
fanOut(projectId, "issue_comment", rawBody);
|
|
621
|
+
await fanOut(projectId, "issue_comment", rawBody);
|
|
631
622
|
} catch (e) {
|
|
632
623
|
log.error("webhook: emitCommentEvent failed", {
|
|
633
624
|
err: e as Error,
|
|
@@ -638,11 +629,11 @@ export function emitCommentEvent(
|
|
|
638
629
|
}
|
|
639
630
|
}
|
|
640
631
|
|
|
641
|
-
export function emitPingEvent(projectId: number, origin: string): void {
|
|
632
|
+
export async function emitPingEvent(projectId: number, origin: string): Promise<void> {
|
|
642
633
|
// Synthetic ping: send a small `ping` event (no issue context). Useful for
|
|
643
634
|
// verifying webhook configuration without triggering a real write.
|
|
644
635
|
try {
|
|
645
|
-
const project = getProjectById(projectId);
|
|
636
|
+
const project = await getProjectById(projectId);
|
|
646
637
|
if (!project) return;
|
|
647
638
|
const payload = {
|
|
648
639
|
zen: "smoke",
|
|
@@ -652,7 +643,7 @@ export function emitPingEvent(projectId: number, origin: string): void {
|
|
|
652
643
|
sender: buildUser(project.owner, origin),
|
|
653
644
|
};
|
|
654
645
|
const rawBody = JSON.stringify(payload);
|
|
655
|
-
fanOut(projectId, "issues", rawBody);
|
|
646
|
+
await fanOut(projectId, "issues", rawBody);
|
|
656
647
|
} catch (e) {
|
|
657
648
|
log.error("webhook: emitPingEvent failed", { err: e as Error, projectId });
|
|
658
649
|
}
|
|
@@ -660,22 +651,21 @@ export function emitPingEvent(projectId: number, origin: string): void {
|
|
|
660
651
|
|
|
661
652
|
// ─── Helpers (kept here to avoid widening store.ts surface) ──
|
|
662
653
|
|
|
663
|
-
function countCommentsSafe(issueId: number): number {
|
|
654
|
+
async function countCommentsSafe(issueId: number): Promise<number> {
|
|
664
655
|
try {
|
|
665
|
-
const row =
|
|
666
|
-
|
|
667
|
-
|
|
656
|
+
const row = await getDB().get<{ n: number }>(
|
|
657
|
+
"SELECT COUNT(*) AS n FROM {{comments}} WHERE issue_id = ?",
|
|
658
|
+
[issueId]
|
|
659
|
+
);
|
|
668
660
|
return row?.n ?? 0;
|
|
669
661
|
} catch {
|
|
670
662
|
return 0;
|
|
671
663
|
}
|
|
672
664
|
}
|
|
673
665
|
|
|
674
|
-
function getCommentByIdSafe(id: number): CommentRow | null {
|
|
666
|
+
async function getCommentByIdSafe(id: number): Promise<CommentRow | null> {
|
|
675
667
|
try {
|
|
676
|
-
const row =
|
|
677
|
-
| CommentRow
|
|
678
|
-
| undefined;
|
|
668
|
+
const row = await getDB().get<CommentRow>("SELECT * FROM {{comments}} WHERE id = ?", [id]);
|
|
679
669
|
return row ?? null;
|
|
680
670
|
} catch {
|
|
681
671
|
return null;
|