ework-web 0.10.44 → 0.10.46
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 +2 -0
- package/src/db.ts +16 -0
- package/src/index.ts +48 -3
- package/src/render/layout.ts +40 -0
- package/src/schema-mysql.sql +1 -0
- package/src/schema.sql +2 -0
- package/src/store.ts +10 -0
- package/src/views/issueThread.ts +2 -0
- package/src/webhooks.ts +73 -3
package/package.json
CHANGED
package/src/config.ts
CHANGED
|
@@ -82,6 +82,7 @@ export const configSchema = z.object({
|
|
|
82
82
|
internalAuthHook: z.string().default(""),
|
|
83
83
|
userAuthHook: z.string().default(""),
|
|
84
84
|
loginPage: z.string().default(""),
|
|
85
|
+
statusHook: z.string().default(""),
|
|
85
86
|
// Default "provider/model" string passed to `opencode run --model <X>`.
|
|
86
87
|
// Empty = let opencode pick per its own opencode.json + env. ework-daemon
|
|
87
88
|
// pushes this (or the per-project override) on every spawn to defend
|
|
@@ -192,6 +193,7 @@ export async function loadConfig(): Promise<Config> {
|
|
|
192
193
|
internalAuthHook: process.env.WORK_INTERNAL_AUTH_HOOK ?? "",
|
|
193
194
|
userAuthHook: process.env.WORK_USER_AUTH_HOOK ?? "",
|
|
194
195
|
loginPage: process.env.WORK_LOGIN_PAGE ?? "",
|
|
196
|
+
statusHook: process.env.WORK_STATUS_HOOK ?? "",
|
|
195
197
|
defaultModel: db.defaultModel ?? process.env.WORK_DEFAULT_MODEL,
|
|
196
198
|
autowireActive: process.env.WORK_AUTOWIRE_ACTIVE !== "false",
|
|
197
199
|
webhookMaxConcurrent: Number(process.env.WORK_WEBHOOK_MAX_CONCURRENT ?? "6"),
|
package/src/db.ts
CHANGED
|
@@ -128,6 +128,9 @@ function migrateIssuesTable(db: Database): void {
|
|
|
128
128
|
if (!have.has("closed_at")) {
|
|
129
129
|
db.exec(applyPrefix("ALTER TABLE {{issues}} ADD COLUMN closed_at TEXT"));
|
|
130
130
|
}
|
|
131
|
+
if (!have.has("ai_status")) {
|
|
132
|
+
db.exec(applyPrefix("ALTER TABLE {{issues}} ADD COLUMN ai_status TEXT NOT NULL DEFAULT ''"));
|
|
133
|
+
}
|
|
131
134
|
}
|
|
132
135
|
|
|
133
136
|
function migrateLabelsTable(db: Database): void {
|
|
@@ -354,6 +357,18 @@ async function migrateMysqlProjectsVisibility(pool: Pool): Promise<void> {
|
|
|
354
357
|
}
|
|
355
358
|
}
|
|
356
359
|
|
|
360
|
+
async function migrateMysqlIssuesAiStatus(pool: Pool): Promise<void> {
|
|
361
|
+
const [cols] = await pool.query(
|
|
362
|
+
applyPrefix("SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = '{{issues}}' AND COLUMN_NAME = 'ai_status'")
|
|
363
|
+
);
|
|
364
|
+
if (Array.isArray(cols) && cols.length > 0) return;
|
|
365
|
+
try {
|
|
366
|
+
await pool.query(applyPrefix("ALTER TABLE {{issues}} ADD COLUMN ai_status VARCHAR(32) NOT NULL DEFAULT ''"));
|
|
367
|
+
} catch (e) {
|
|
368
|
+
console.warn("[db] MySQL issues ai_status column add failed:", (e as Error).message);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
357
372
|
class MysqlDriver implements AsyncDatabase {
|
|
358
373
|
readonly dialect = "mysql" as const;
|
|
359
374
|
private readonly pool: Pool;
|
|
@@ -398,6 +413,7 @@ class MysqlDriver implements AsyncDatabase {
|
|
|
398
413
|
}
|
|
399
414
|
await migrateMysqlSurrogateId(pool);
|
|
400
415
|
await migrateMysqlProjectsVisibility(pool);
|
|
416
|
+
await migrateMysqlIssuesAiStatus(pool);
|
|
401
417
|
}
|
|
402
418
|
return new MysqlDriver(pool);
|
|
403
419
|
}
|
package/src/index.ts
CHANGED
|
@@ -37,6 +37,7 @@ import {
|
|
|
37
37
|
createProject,
|
|
38
38
|
postComment,
|
|
39
39
|
setIssueState,
|
|
40
|
+
updateIssueAiStatus,
|
|
40
41
|
createAttachment,
|
|
41
42
|
getAttachment,
|
|
42
43
|
verifyUserPassword,
|
|
@@ -86,7 +87,9 @@ import {
|
|
|
86
87
|
import {
|
|
87
88
|
emitIssueEvent,
|
|
88
89
|
emitCommentEvent,
|
|
90
|
+
emitStatusChanged,
|
|
89
91
|
emitPingEvent,
|
|
92
|
+
migrateWebhookEvents,
|
|
90
93
|
listWebhooks,
|
|
91
94
|
createWebhook,
|
|
92
95
|
deleteWebhook,
|
|
@@ -154,7 +157,7 @@ async function autoWireDaemon(projectId: number, origin: string): Promise<void>
|
|
|
154
157
|
project_id: projectId,
|
|
155
158
|
url: target,
|
|
156
159
|
secret: cfg.daemonWebhookSecret,
|
|
157
|
-
events: ["issues", "issue_comment"],
|
|
160
|
+
events: ["issues", "issue_comment", "status_changed"],
|
|
158
161
|
});
|
|
159
162
|
void emitPingEvent(projectId, origin);
|
|
160
163
|
}
|
|
@@ -183,6 +186,7 @@ async function autoWireAllProjects(origin: string): Promise<void> {
|
|
|
183
186
|
}
|
|
184
187
|
|
|
185
188
|
void autoWireAllProjects(`http://${cfg.host}:${cfg.port}`);
|
|
189
|
+
void migrateWebhookEvents().catch((e) => log.warn("migrateWebhookEvents failed", { err: e as Error }));
|
|
186
190
|
|
|
187
191
|
const SEC_HEADERS: Record<string, string> = {
|
|
188
192
|
"content-security-policy": buildCsp(cfg),
|
|
@@ -374,6 +378,7 @@ const REPO_MODEL_RE = /^\/([^/]+)\/([^/]+)\/settings\/model$/;
|
|
|
374
378
|
const REPO_LABELS_RE = /^\/([^/]+)\/([^/]+)\/settings\/labels$/;
|
|
375
379
|
const REPO_LABEL_ADD_RE = /^\/([^/]+)\/([^/]+)\/settings\/labels\/add$/;
|
|
376
380
|
const REPO_LABEL_ACTION_RE = /^\/([^/]+)\/([^/]+)\/settings\/labels\/(\d+)\/(update|archive|unarchive|delete)$/;
|
|
381
|
+
const REPO_ISSUE_HALT_RE = /^\/([^/]+)\/([^/]+)\/issues\/(\d+)\/(halt|resume)$/;
|
|
377
382
|
const API_ISSUE_LABELS_RE = /^\/api\/([^/]+)\/([^/]+)\/issues\/(\d+)\/labels$/;
|
|
378
383
|
const WH_ACTION_RE = /^\/__wh\/(\d+)\/(delete|toggle|test)$/;
|
|
379
384
|
const SESSIONS_RE = /^\/sessions$/;
|
|
@@ -564,6 +569,26 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
|
|
|
564
569
|
// /api/* routes since those would 404 on /api/v1/* paths. The shim reuses
|
|
565
570
|
// the existing ctx.user (cookie or PAT auth already resolved above).
|
|
566
571
|
if (url.pathname.startsWith("/api/v1/")) {
|
|
572
|
+
const evStatus = url.pathname.match(/^\/api\/v1\/repos\/([^/]+)\/([^/]+)\/issues\/(\d+)\/status$/);
|
|
573
|
+
if (evStatus && req.method === "POST") {
|
|
574
|
+
const [, owner, repo, numStr] = evStatus;
|
|
575
|
+
if (!(owner && repo && numStr)) return json({ error: "bad path" }, 400);
|
|
576
|
+
try {
|
|
577
|
+
const project = await getProject(owner, repo);
|
|
578
|
+
if (!project) return json({ error: "project not found" }, 404);
|
|
579
|
+
const issue = await getIssueWithMeta(project.id, Number(numStr));
|
|
580
|
+
if (!issue) return json({ error: "issue not found" }, 404);
|
|
581
|
+
const body = (await req.json().catch(() => ({}))) as { status?: string; detail?: string };
|
|
582
|
+
const newStatus = typeof body.status === "string" ? body.status : "";
|
|
583
|
+
const oldStatus = issue.ai_status ?? "";
|
|
584
|
+
if (newStatus === oldStatus) return json({ ok: true, status: newStatus, unchanged: true });
|
|
585
|
+
await updateIssueAiStatus(issue.id, newStatus);
|
|
586
|
+
void emitStatusChanged(project.id, issue.id, oldStatus, newStatus, ctx.user?.login ?? "daemon", url.origin, body.detail);
|
|
587
|
+
return json({ ok: true, status: newStatus });
|
|
588
|
+
} catch (e) {
|
|
589
|
+
return json({ error: errMsg(e) }, e instanceof StoreError ? e.status : 500);
|
|
590
|
+
}
|
|
591
|
+
}
|
|
567
592
|
const result = await handleGiteaApi(req, url, { user: ctx.user });
|
|
568
593
|
if (result) {
|
|
569
594
|
if (result.body === null) {
|
|
@@ -1639,6 +1664,26 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
|
|
|
1639
1664
|
}
|
|
1640
1665
|
}
|
|
1641
1666
|
|
|
1667
|
+
const hp = url.pathname.match(REPO_ISSUE_HALT_RE);
|
|
1668
|
+
if (hp) {
|
|
1669
|
+
const [, owner, repo, numStr, action] = hp;
|
|
1670
|
+
if (!(owner && repo && numStr && action)) return json({ error: "bad path" }, 400);
|
|
1671
|
+
const number = Number(numStr);
|
|
1672
|
+
try {
|
|
1673
|
+
const project = await getProject(owner, repo);
|
|
1674
|
+
if (!project) return json({ error: "project not found" }, 404);
|
|
1675
|
+
const issue = await getIssueWithMeta(project.id, number);
|
|
1676
|
+
if (!issue) return json({ error: "issue not found" }, 404);
|
|
1677
|
+
const newStatus = action === "halt" ? "halted" : "";
|
|
1678
|
+
const oldStatus = issue.ai_status ?? "";
|
|
1679
|
+
await updateIssueAiStatus(issue.id, newStatus);
|
|
1680
|
+
void emitStatusChanged(project.id, issue.id, oldStatus, newStatus, ctx.user!.login, url.origin);
|
|
1681
|
+
return json({ ok: true, status: newStatus });
|
|
1682
|
+
} catch (e) {
|
|
1683
|
+
return json({ error: errMsg(e) }, e instanceof StoreError ? e.status : 500);
|
|
1684
|
+
}
|
|
1685
|
+
}
|
|
1686
|
+
|
|
1642
1687
|
const ci = url.pathname.match(REPO_LIST_RE);
|
|
1643
1688
|
if (ci) {
|
|
1644
1689
|
const [, owner, repo] = ci;
|
|
@@ -1686,8 +1731,8 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
|
|
|
1686
1731
|
const url_ = String(form.get("url") ?? "").trim();
|
|
1687
1732
|
const secret = String(form.get("secret") ?? "");
|
|
1688
1733
|
const events = form.getAll("events") as string[];
|
|
1689
|
-
const validEvents = (events.length > 0 ? events : ["issues", "issue_comment"])
|
|
1690
|
-
.filter((e): e is WebhookEventName => e === "issues" || e === "issue_comment");
|
|
1734
|
+
const validEvents = (events.length > 0 ? events : ["issues", "issue_comment", "status_changed"])
|
|
1735
|
+
.filter((e): e is WebhookEventName => e === "issues" || e === "issue_comment" || e === "status_changed");
|
|
1691
1736
|
const wh = await createWebhook({
|
|
1692
1737
|
project_id: project.id,
|
|
1693
1738
|
url: url_,
|
package/src/render/layout.ts
CHANGED
|
@@ -16,6 +16,8 @@ export interface LayoutProps {
|
|
|
16
16
|
ttsEnabled?: boolean;
|
|
17
17
|
labels?: { id: number; name: string; color: string }[];
|
|
18
18
|
canEditLabels?: boolean;
|
|
19
|
+
aiStatus?: string;
|
|
20
|
+
statusHook?: string;
|
|
19
21
|
}
|
|
20
22
|
|
|
21
23
|
export const THEME_CSS = `
|
|
@@ -128,6 +130,14 @@ header.topbar .num{opacity:.7}
|
|
|
128
130
|
.lp-name{flex:1;overflow-wrap:anywhere}
|
|
129
131
|
.lp-scope{font-size:10px;color:var(--text-muted)}
|
|
130
132
|
.lp-empty{color:var(--text-muted);font-size:12px;padding:.6rem 0;text-align:center}
|
|
133
|
+
.ai-badge{font-size:12px;padding:.1rem .5rem;border-radius:10px;font-weight:600}
|
|
134
|
+
.ai-processing{background:#0969da;color:#fff;animation:ai-pulse 2s ease-in-out infinite}
|
|
135
|
+
.ai-halted{background:#bf8700;color:#fff}
|
|
136
|
+
.ai-completed{background:#1a7f37;color:#fff}
|
|
137
|
+
.ai-failed{background:#cf222e;color:#fff}
|
|
138
|
+
@keyframes ai-pulse{0%,100%{opacity:1}50%{opacity:.6}}
|
|
139
|
+
.halt-btn{font-size:12px;padding:.15rem .6rem;border-radius:6px;border:1px solid var(--border);background:var(--bg-elev);color:#cf222e;cursor:pointer;font-weight:600}
|
|
140
|
+
.halt-btn:hover{background:#cf222e;color:#fff}
|
|
131
141
|
`;
|
|
132
142
|
|
|
133
143
|
export function renderLayout(props: LayoutProps, inner: string, initialItems: string): string {
|
|
@@ -146,6 +156,22 @@ export function renderLayout(props: LayoutProps, inner: string, initialItems: st
|
|
|
146
156
|
const labelPickerBtn = props.canEditLabels
|
|
147
157
|
? `<button type="button" class="label-edit-btn" id="labelEditBtn" title="管理标签">🏷️</button>`
|
|
148
158
|
: "";
|
|
159
|
+
const aiBadgeHtml = (() => {
|
|
160
|
+
const s = props.aiStatus ?? "";
|
|
161
|
+
if (!s) return "";
|
|
162
|
+
const map: Record<string, { cls: string; label: string }> = {
|
|
163
|
+
processing: { cls: "ai-processing", label: "⚙️ 处理中" },
|
|
164
|
+
halted: { cls: "ai-halted", label: "⏸️ 已暂停" },
|
|
165
|
+
completed: { cls: "ai-completed", label: "✓ 已完成" },
|
|
166
|
+
failed: { cls: "ai-failed", label: "✗ 失败" },
|
|
167
|
+
};
|
|
168
|
+
const m = map[s];
|
|
169
|
+
if (!m) return "";
|
|
170
|
+
return `<span class="ai-badge ${m.cls}">${m.label}</span>`;
|
|
171
|
+
})();
|
|
172
|
+
const haltBtnHtml = props.writesEnabled !== false && props.aiStatus !== "halted"
|
|
173
|
+
? `<button type="button" id="haltBtn" class="halt-btn" title="停止 AI 处理">⏹ 停止</button>`
|
|
174
|
+
: "";
|
|
149
175
|
return `<!doctype html>
|
|
150
176
|
<html lang="zh">
|
|
151
177
|
<head>
|
|
@@ -167,6 +193,7 @@ export function renderLayout(props: LayoutProps, inner: string, initialItems: st
|
|
|
167
193
|
<h1>${escapeHtml(props.issueTitle)}</h1>
|
|
168
194
|
<div class="meta-status">
|
|
169
195
|
<span class="state-badge ${stateClass}">${stateLabel}</span>
|
|
196
|
+
${aiBadgeHtml}${haltBtnHtml}
|
|
170
197
|
${labelsHtml}${labelPickerBtn}
|
|
171
198
|
<span class="count" id="count">…</span>
|
|
172
199
|
${props.upstreamWebUrl ? `<a class="upstream-link" href="${escapeAttr(props.upstreamWebUrl)}" target="_blank" rel="noopener noreferrer" title="跳转到上游仓库">🔗 查看上游</a>` : ""}
|
|
@@ -198,6 +225,19 @@ ${props.writesEnabled !== false
|
|
|
198
225
|
<script src="/static/app.js?v=${BUILD_ID}" defer></script>
|
|
199
226
|
${props.canEditLabels ? `<dialog id="labelDlg"><h3>标签</h3><div class="lp-list" id="lpList"></div><div class="lp-empty hidden" id="lpEmpty">该项目还没有标签。先到设置页创建。</div></dialog>
|
|
200
227
|
<script src="/static/label-picker.js?v=${BUILD_ID}" defer></script>` : ""}
|
|
228
|
+
${haltBtnHtml ? `<script>
|
|
229
|
+
(function(){
|
|
230
|
+
var btn=document.getElementById("haltBtn");
|
|
231
|
+
if(!btn)return;
|
|
232
|
+
btn.addEventListener("click",function(){
|
|
233
|
+
if(!confirm("确认停止 AI 处理?"))return;
|
|
234
|
+
btn.disabled=true;btn.textContent="⏳ 停止中…";
|
|
235
|
+
fetch(window.location.pathname+"/halt",{method:"POST"}).then(function(r){return r.json()}).then(function(d){
|
|
236
|
+
if(d.ok){location.reload()}else{alert(d.error||"操作失败");btn.disabled=false;btn.textContent="⏹ 停止"}
|
|
237
|
+
}).catch(function(e){alert("网络错误: "+e);btn.disabled=false;btn.textContent="⏹ 停止"})
|
|
238
|
+
});
|
|
239
|
+
})();
|
|
240
|
+
</script>` : ""}
|
|
201
241
|
</body>
|
|
202
242
|
</html>`;
|
|
203
243
|
}
|
package/src/schema-mysql.sql
CHANGED
|
@@ -53,6 +53,7 @@ CREATE TABLE IF NOT EXISTS {{issues}} (
|
|
|
53
53
|
created_at VARCHAR(40) NOT NULL,
|
|
54
54
|
updated_at VARCHAR(40) NOT NULL,
|
|
55
55
|
closed_at VARCHAR(40) DEFAULT NULL,
|
|
56
|
+
ai_status VARCHAR(32) NOT NULL DEFAULT '',
|
|
56
57
|
UNIQUE (project_id, number),
|
|
57
58
|
CONSTRAINT {{fk_issues_project}} FOREIGN KEY (project_id) REFERENCES {{projects}}(id) ON DELETE CASCADE,
|
|
58
59
|
CONSTRAINT {{fk_issues_author}} FOREIGN KEY (author) REFERENCES {{users}}(login)
|
package/src/schema.sql
CHANGED
|
@@ -63,6 +63,8 @@ CREATE TABLE IF NOT EXISTS {{issues}} (
|
|
|
63
63
|
-- NULL when open; stamped on close, cleared on reopen. Backfill-safe because
|
|
64
64
|
-- migrateIssuesTable ADDs the column idempotently for legacy DBs.
|
|
65
65
|
closed_at TEXT,
|
|
66
|
+
-- AI processing status: '' (none) | 'processing' | 'halted' | 'completed' | 'failed'
|
|
67
|
+
ai_status TEXT NOT NULL DEFAULT '',
|
|
66
68
|
UNIQUE (project_id, number)
|
|
67
69
|
);
|
|
68
70
|
CREATE INDEX IF NOT EXISTS issues_project_state_updated
|
package/src/store.ts
CHANGED
|
@@ -73,6 +73,7 @@ export interface IssueRow {
|
|
|
73
73
|
created_at: string;
|
|
74
74
|
updated_at: string;
|
|
75
75
|
closed_at: string | null;
|
|
76
|
+
ai_status: string;
|
|
76
77
|
}
|
|
77
78
|
|
|
78
79
|
export interface IssueWithMeta extends IssueRow {
|
|
@@ -498,6 +499,15 @@ export async function setIssueState(
|
|
|
498
499
|
});
|
|
499
500
|
}
|
|
500
501
|
|
|
502
|
+
export async function updateIssueAiStatus(issueId: number, status: string): Promise<void> {
|
|
503
|
+
await getDB().run("UPDATE {{issues}} SET ai_status = ? WHERE id = ?", [status, issueId]);
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
export async function getIssueAiStatus(issueId: number): Promise<string> {
|
|
507
|
+
const row = await getDB().get<{ ai_status: string }>("SELECT ai_status FROM {{issues}} WHERE id = ?", [issueId]);
|
|
508
|
+
return row?.ai_status ?? "";
|
|
509
|
+
}
|
|
510
|
+
|
|
501
511
|
export interface IssuePatch {
|
|
502
512
|
title?: string;
|
|
503
513
|
body?: string;
|
package/src/views/issueThread.ts
CHANGED
|
@@ -130,6 +130,8 @@ export async function buildIssueThread(
|
|
|
130
130
|
ttsEnabled: cfg.ttsBackends.some((b) => b.url && b.url.trim() !== ""),
|
|
131
131
|
labels: labels.map((l) => ({ id: l.id, name: l.name, color: l.color })),
|
|
132
132
|
canEditLabels: cfg.writesEnabled !== false,
|
|
133
|
+
aiStatus: issue.ai_status ?? "",
|
|
134
|
+
statusHook: cfg.statusHook ?? "",
|
|
133
135
|
},
|
|
134
136
|
safeJsonEmbed(payload),
|
|
135
137
|
displayViews.map((v) => renderCommentCard(v, cfg)).join("")
|
package/src/webhooks.ts
CHANGED
|
@@ -32,7 +32,7 @@ import {
|
|
|
32
32
|
type CommentRow,
|
|
33
33
|
} from "./store";
|
|
34
34
|
|
|
35
|
-
export type WebhookEventName = "issues" | "issue_comment";
|
|
35
|
+
export type WebhookEventName = "issues" | "issue_comment" | "status_changed";
|
|
36
36
|
export type IssueAction = "opened" | "closed" | "reopened";
|
|
37
37
|
|
|
38
38
|
export interface WebhookRow {
|
|
@@ -60,7 +60,7 @@ export interface WebhookDeliveryRow {
|
|
|
60
60
|
created_at: string;
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
-
const DEFAULT_EVENTS: WebhookEventName[] = ["issues", "issue_comment"];
|
|
63
|
+
const DEFAULT_EVENTS: WebhookEventName[] = ["issues", "issue_comment", "status_changed"];
|
|
64
64
|
const MAX_RESPONSE_LOG_BYTES = 8192;
|
|
65
65
|
const HTTP_TIMEOUT_MS = 10_000;
|
|
66
66
|
const RETRY_DELAYS_MS = [0, 2_000, 8_000];
|
|
@@ -112,7 +112,7 @@ function parseEvents(events: unknown): WebhookEventName[] {
|
|
|
112
112
|
try {
|
|
113
113
|
const arr = JSON.parse(events) as unknown[];
|
|
114
114
|
const valid = arr.filter(
|
|
115
|
-
(v): v is WebhookEventName => v === "issues" || v === "issue_comment"
|
|
115
|
+
(v): v is WebhookEventName => v === "issues" || v === "issue_comment" || v === "status_changed"
|
|
116
116
|
);
|
|
117
117
|
return valid.length > 0 ? valid : DEFAULT_EVENTS;
|
|
118
118
|
} catch {
|
|
@@ -122,6 +122,20 @@ function parseEvents(events: unknown): WebhookEventName[] {
|
|
|
122
122
|
|
|
123
123
|
// ─── CRUD ────────────────────────────────────────────────────
|
|
124
124
|
|
|
125
|
+
export async function migrateWebhookEvents(): Promise<void> {
|
|
126
|
+
const rows = await getDB().all<WebhookRow>("SELECT * FROM {{webhooks}}");
|
|
127
|
+
for (const wh of rows) {
|
|
128
|
+
const current = parseEvents(wh.events);
|
|
129
|
+
if (!current.includes("status_changed")) {
|
|
130
|
+
const updated = [...current, "status_changed"];
|
|
131
|
+
await getDB().run("UPDATE {{webhooks}} SET events = ? WHERE id = ?", [
|
|
132
|
+
JSON.stringify(updated),
|
|
133
|
+
wh.id,
|
|
134
|
+
]);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
125
139
|
export async function listWebhooks(projectId: number): Promise<WebhookRow[]> {
|
|
126
140
|
return await getDB().all<WebhookRow>("SELECT * FROM {{webhooks}} WHERE project_id = ? ORDER BY id", [projectId]);
|
|
127
141
|
}
|
|
@@ -273,6 +287,7 @@ interface PayloadIssue {
|
|
|
273
287
|
pull_request: null;
|
|
274
288
|
repository: PayloadRepository;
|
|
275
289
|
user: PayloadUser;
|
|
290
|
+
ai_status?: string;
|
|
276
291
|
}
|
|
277
292
|
|
|
278
293
|
interface PayloadComment {
|
|
@@ -287,6 +302,7 @@ interface PayloadComment {
|
|
|
287
302
|
}
|
|
288
303
|
|
|
289
304
|
interface IssueEventPayload {
|
|
305
|
+
event_id?: string;
|
|
290
306
|
action: IssueAction;
|
|
291
307
|
issue: PayloadIssue;
|
|
292
308
|
repository: PayloadRepository;
|
|
@@ -297,6 +313,7 @@ interface IssueEventPayload {
|
|
|
297
313
|
}
|
|
298
314
|
|
|
299
315
|
interface CommentEventPayload {
|
|
316
|
+
event_id?: string;
|
|
300
317
|
action: "created";
|
|
301
318
|
issue: PayloadIssue;
|
|
302
319
|
comment: PayloadComment;
|
|
@@ -304,6 +321,16 @@ interface CommentEventPayload {
|
|
|
304
321
|
sender: PayloadUser;
|
|
305
322
|
}
|
|
306
323
|
|
|
324
|
+
interface StatusChangedPayload {
|
|
325
|
+
event_id: string;
|
|
326
|
+
action: "status_changed";
|
|
327
|
+
issue: PayloadIssue;
|
|
328
|
+
repository: PayloadRepository;
|
|
329
|
+
sender: PayloadUser;
|
|
330
|
+
status: { from: string; to: string };
|
|
331
|
+
detail?: string;
|
|
332
|
+
}
|
|
333
|
+
|
|
307
334
|
function buildUser(login: string, origin: string): PayloadUser {
|
|
308
335
|
return {
|
|
309
336
|
id: 1,
|
|
@@ -392,6 +419,7 @@ function buildIssue(
|
|
|
392
419
|
pull_request: null,
|
|
393
420
|
repository: buildRepository(project, origin, model),
|
|
394
421
|
user: buildUser(issue.author, origin),
|
|
422
|
+
ai_status: issue.ai_status ?? "",
|
|
395
423
|
};
|
|
396
424
|
}
|
|
397
425
|
|
|
@@ -615,6 +643,7 @@ export async function emitIssueEvent(
|
|
|
615
643
|
const model = resolveModel(project.model, globalDefault);
|
|
616
644
|
const labels = await toPayloadLabels(issueId);
|
|
617
645
|
const payload = buildIssuePayload(issue, project, commentCount, action, origin, model, labels);
|
|
646
|
+
(payload as IssueEventPayload).event_id = randomUUID();
|
|
618
647
|
const rawBody = JSON.stringify(payload);
|
|
619
648
|
await fanOut(projectId, "issues", rawBody);
|
|
620
649
|
} catch (e) {
|
|
@@ -645,6 +674,7 @@ export async function emitCommentEvent(
|
|
|
645
674
|
const model = resolveModel(project.model, globalDefault);
|
|
646
675
|
const labels = await toPayloadLabels(issueId);
|
|
647
676
|
const payload = buildCommentPayload(issue, comment, project, commentCount, origin, model, labels);
|
|
677
|
+
(payload as CommentEventPayload).event_id = randomUUID();
|
|
648
678
|
const rawBody = JSON.stringify(payload);
|
|
649
679
|
await fanOut(projectId, "issue_comment", rawBody);
|
|
650
680
|
} catch (e) {
|
|
@@ -657,6 +687,46 @@ export async function emitCommentEvent(
|
|
|
657
687
|
}
|
|
658
688
|
}
|
|
659
689
|
|
|
690
|
+
export async function emitStatusChanged(
|
|
691
|
+
projectId: number,
|
|
692
|
+
issueId: number,
|
|
693
|
+
from: string,
|
|
694
|
+
to: string,
|
|
695
|
+
actor: string,
|
|
696
|
+
origin: string,
|
|
697
|
+
detail?: string
|
|
698
|
+
): Promise<void> {
|
|
699
|
+
try {
|
|
700
|
+
const project = await getProjectById(projectId);
|
|
701
|
+
if (!project) return;
|
|
702
|
+
const issue = await getIssueById(issueId);
|
|
703
|
+
if (!issue) return;
|
|
704
|
+
const commentCount = await countCommentsSafe(issueId);
|
|
705
|
+
const repo = buildRepository(project, origin);
|
|
706
|
+
const sender = buildUser(actor, origin);
|
|
707
|
+
const issuePayload = buildIssue(issue, project, commentCount, origin);
|
|
708
|
+
const payload: StatusChangedPayload = {
|
|
709
|
+
event_id: randomUUID(),
|
|
710
|
+
action: "status_changed",
|
|
711
|
+
issue: issuePayload,
|
|
712
|
+
repository: repo,
|
|
713
|
+
sender,
|
|
714
|
+
status: { from, to },
|
|
715
|
+
detail,
|
|
716
|
+
};
|
|
717
|
+
const rawBody = JSON.stringify(payload);
|
|
718
|
+
await fanOut(projectId, "status_changed", rawBody);
|
|
719
|
+
} catch (e) {
|
|
720
|
+
log.error("webhook: emitStatusChanged failed", {
|
|
721
|
+
err: e as Error,
|
|
722
|
+
projectId,
|
|
723
|
+
issueId,
|
|
724
|
+
from,
|
|
725
|
+
to,
|
|
726
|
+
});
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
|
|
660
730
|
export async function emitPingEvent(projectId: number, origin: string): Promise<void> {
|
|
661
731
|
// Synthetic ping: send a small `ping` event (no issue context). Useful for
|
|
662
732
|
// verifying webhook configuration without triggering a real write.
|