ework-web 0.10.43 → 0.10.45

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ework-web",
3
- "version": "0.10.43",
3
+ "version": "0.10.45",
4
4
  "type": "module",
5
5
  "description": "ework-web — standalone multi-project issue tracker. Local SQLite-backed, no external API dependency. Bun + TypeScript + SSR HTML.",
6
6
  "license": "MIT",
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,6 +87,7 @@ import {
86
87
  import {
87
88
  emitIssueEvent,
88
89
  emitCommentEvent,
90
+ emitStatusChanged,
89
91
  emitPingEvent,
90
92
  listWebhooks,
91
93
  createWebhook,
@@ -374,6 +376,7 @@ const REPO_MODEL_RE = /^\/([^/]+)\/([^/]+)\/settings\/model$/;
374
376
  const REPO_LABELS_RE = /^\/([^/]+)\/([^/]+)\/settings\/labels$/;
375
377
  const REPO_LABEL_ADD_RE = /^\/([^/]+)\/([^/]+)\/settings\/labels\/add$/;
376
378
  const REPO_LABEL_ACTION_RE = /^\/([^/]+)\/([^/]+)\/settings\/labels\/(\d+)\/(update|archive|unarchive|delete)$/;
379
+ const REPO_ISSUE_HALT_RE = /^\/([^/]+)\/([^/]+)\/issues\/(\d+)\/(halt|resume)$/;
377
380
  const API_ISSUE_LABELS_RE = /^\/api\/([^/]+)\/([^/]+)\/issues\/(\d+)\/labels$/;
378
381
  const WH_ACTION_RE = /^\/__wh\/(\d+)\/(delete|toggle|test)$/;
379
382
  const SESSIONS_RE = /^\/sessions$/;
@@ -564,6 +567,26 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
564
567
  // /api/* routes since those would 404 on /api/v1/* paths. The shim reuses
565
568
  // the existing ctx.user (cookie or PAT auth already resolved above).
566
569
  if (url.pathname.startsWith("/api/v1/")) {
570
+ const evStatus = url.pathname.match(/^\/api\/v1\/repos\/([^/]+)\/([^/]+)\/issues\/(\d+)\/status$/);
571
+ if (evStatus && req.method === "POST") {
572
+ const [, owner, repo, numStr] = evStatus;
573
+ if (!(owner && repo && numStr)) return json({ error: "bad path" }, 400);
574
+ try {
575
+ const project = await getProject(owner, repo);
576
+ if (!project) return json({ error: "project not found" }, 404);
577
+ const issue = await getIssueWithMeta(project.id, Number(numStr));
578
+ if (!issue) return json({ error: "issue not found" }, 404);
579
+ const body = (await req.json().catch(() => ({}))) as { status?: string; detail?: string };
580
+ const newStatus = typeof body.status === "string" ? body.status : "";
581
+ const oldStatus = issue.ai_status ?? "";
582
+ if (newStatus === oldStatus) return json({ ok: true, status: newStatus, unchanged: true });
583
+ await updateIssueAiStatus(issue.id, newStatus);
584
+ void emitStatusChanged(project.id, issue.id, oldStatus, newStatus, ctx.user?.login ?? "daemon", url.origin, body.detail);
585
+ return json({ ok: true, status: newStatus });
586
+ } catch (e) {
587
+ return json({ error: errMsg(e) }, e instanceof StoreError ? e.status : 500);
588
+ }
589
+ }
567
590
  const result = await handleGiteaApi(req, url, { user: ctx.user });
568
591
  if (result) {
569
592
  if (result.body === null) {
@@ -1023,8 +1046,34 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
1023
1046
 
1024
1047
  const daemonIdRe = /^\/api\/daemons\/(\d+)$/;
1025
1048
  const daemonRestartRe = /^\/api\/daemons\/(\d+)\/restart$/;
1049
+ const daemonPauseRe = /^\/api\/daemons\/(\d+)\/pause$/;
1050
+ const daemonResumeRe = /^\/api\/daemons\/(\d+)\/resume$/;
1026
1051
  const daemonRemoveRe = /^\/api\/daemons\/(\d+)\/remove$/;
1027
1052
 
1053
+ if (req.method === "POST" && (daemonPauseRe.test(url.pathname) || daemonResumeRe.test(url.pathname))) {
1054
+ if (!ctx.user || ctx.user.is_admin !== 1) return json({ error: "admin required" }, 403);
1055
+ const isPause = daemonPauseRe.test(url.pathname);
1056
+ const match = url.pathname.match(isPause ? daemonPauseRe : daemonResumeRe);
1057
+ const id = match ? Number(match[1]) : NaN;
1058
+ if (!Number.isFinite(id)) return json({ error: "invalid id" }, 400);
1059
+ const rows = await getDB().all<{ endpoint: string }>(
1060
+ `SELECT internal_endpoint AS endpoint FROM {{d_daemons}} WHERE id = ?`, [id]
1061
+ );
1062
+ if (rows.length === 0) return json({ ok: false, error: "daemon not found" }, 404);
1063
+ const ep = rows[0]!.endpoint;
1064
+ const proto = ep.startsWith("http") ? "" : "http://";
1065
+ try {
1066
+ const upstream = await fetch(`${proto}${ep}/api/admin/${isPause ? "pause" : "resume"}`, { method: "POST" });
1067
+ if (!upstream.ok) {
1068
+ const body = await upstream.text().catch(() => "");
1069
+ return json({ ok: false, error: `daemon returned ${upstream.status}: ${body.slice(0, 200)}` }, 502);
1070
+ }
1071
+ } catch (e) {
1072
+ return json({ ok: false, error: `cannot reach daemon: ${errMsg(e)}` }, 502);
1073
+ }
1074
+ return json({ ok: true, paused: isPause });
1075
+ }
1076
+
1028
1077
  if (req.method === "POST" && daemonRestartRe.test(url.pathname)) {
1029
1078
  if (!ctx.user || ctx.user.is_admin !== 1) return json({ error: "admin required" }, 403);
1030
1079
  const match = url.pathname.match(daemonRestartRe);
@@ -1613,6 +1662,26 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
1613
1662
  }
1614
1663
  }
1615
1664
 
1665
+ const hp = url.pathname.match(REPO_ISSUE_HALT_RE);
1666
+ if (hp) {
1667
+ const [, owner, repo, numStr, action] = hp;
1668
+ if (!(owner && repo && numStr && action)) return json({ error: "bad path" }, 400);
1669
+ const number = Number(numStr);
1670
+ try {
1671
+ const project = await getProject(owner, repo);
1672
+ if (!project) return json({ error: "project not found" }, 404);
1673
+ const issue = await getIssueWithMeta(project.id, number);
1674
+ if (!issue) return json({ error: "issue not found" }, 404);
1675
+ const newStatus = action === "halt" ? "halted" : "";
1676
+ const oldStatus = issue.ai_status ?? "";
1677
+ await updateIssueAiStatus(issue.id, newStatus);
1678
+ void emitStatusChanged(project.id, issue.id, oldStatus, newStatus, ctx.user!.login, url.origin);
1679
+ return json({ ok: true, status: newStatus });
1680
+ } catch (e) {
1681
+ return json({ error: errMsg(e) }, e instanceof StoreError ? e.status : 500);
1682
+ }
1683
+ }
1684
+
1616
1685
  const ci = url.pathname.match(REPO_LIST_RE);
1617
1686
  if (ci) {
1618
1687
  const [, owner, repo] = ci;
@@ -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 === "processing"
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
  }
@@ -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
@@ -55,7 +55,11 @@
55
55
  var stRaw = String(d.status || 'unknown').toLowerCase();
56
56
  var actions = '';
57
57
  if (stRaw === 'active') {
58
- actions = '<button type="button" class="stop" data-id="' + d.id + '">停止</button>';
58
+ actions = '<button type="button" class="pause" data-id="' + d.id + '">暂停</button>' +
59
+ '<button type="button" class="stop" data-id="' + d.id + '">停止</button>';
60
+ } else if (stRaw === 'drained' || stRaw === 'paused') {
61
+ actions = '<button type="button" class="resume" data-id="' + d.id + '">恢复</button>' +
62
+ '<button type="button" class="remove" data-id="' + d.id + '">删除</button>';
59
63
  } else {
60
64
  actions = '<button type="button" class="restart" data-id="' + d.id + '">重启</button>' +
61
65
  '<button type="button" class="remove" data-id="' + d.id + '">删除</button>';
@@ -81,6 +85,8 @@
81
85
  });
82
86
  };
83
87
  bindBtn('button.stop', stopDaemon);
88
+ bindBtn('button.pause', pauseDaemon);
89
+ bindBtn('button.resume', resumeDaemon);
84
90
  bindBtn('button.restart', restartDaemon);
85
91
  bindBtn('button.remove', removeDaemon);
86
92
  }
@@ -138,6 +144,29 @@
138
144
  }
139
145
  }
140
146
 
147
+ async function pauseDaemon(id) {
148
+ if (!confirm('确认暂停 daemon #' + id + '?\n暂停后不再接收新任务,已进行中的会话不受影响。')) return;
149
+ setResult('正在暂停 daemon #' + id + '…', 'loading');
150
+ try {
151
+ var res = await fetch('/api/daemons/' + id + '/pause', { method: 'POST' });
152
+ if (res.status === 401) { setResult('✗ 登录已过期,请刷新页面重新登录', 'err'); return; }
153
+ var data = await res.json();
154
+ if (data.ok) { setResult('✓ daemon #' + id + ' 已暂停', 'ok'); await loadDaemons(); }
155
+ else setResult('✗ ' + (data.error || '未知错误'), 'err');
156
+ } catch (e) { setResult('✗ ' + (e.message || String(e)), 'err'); }
157
+ }
158
+
159
+ async function resumeDaemon(id) {
160
+ setResult('正在恢复 daemon #' + id + '…', 'loading');
161
+ try {
162
+ var res = await fetch('/api/daemons/' + id + '/resume', { method: 'POST' });
163
+ if (res.status === 401) { setResult('✗ 登录已过期,请刷新页面重新登录', 'err'); return; }
164
+ var data = await res.json();
165
+ if (data.ok) { setResult('✓ daemon #' + id + ' 已恢复', 'ok'); await loadDaemons(); }
166
+ else setResult('✗ ' + (data.error || '未知错误'), 'err');
167
+ } catch (e) { setResult('✗ ' + (e.message || String(e)), 'err'); }
168
+ }
169
+
141
170
  async function stopDaemon(id) {
142
171
  if (!confirm('确认停止 daemon #' + id + '?\n只会标记为 drained(不再接新任务),需手动 kill 进程。')) return;
143
172
  setResult('正在标记 daemon #' + id + ' 为 drained…', 'loading');
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;
@@ -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 {
@@ -287,6 +287,7 @@ interface PayloadComment {
287
287
  }
288
288
 
289
289
  interface IssueEventPayload {
290
+ event_id?: string;
290
291
  action: IssueAction;
291
292
  issue: PayloadIssue;
292
293
  repository: PayloadRepository;
@@ -297,6 +298,7 @@ interface IssueEventPayload {
297
298
  }
298
299
 
299
300
  interface CommentEventPayload {
301
+ event_id?: string;
300
302
  action: "created";
301
303
  issue: PayloadIssue;
302
304
  comment: PayloadComment;
@@ -304,6 +306,16 @@ interface CommentEventPayload {
304
306
  sender: PayloadUser;
305
307
  }
306
308
 
309
+ interface StatusChangedPayload {
310
+ event_id: string;
311
+ action: "status_changed";
312
+ issue: PayloadIssue;
313
+ repository: PayloadRepository;
314
+ sender: PayloadUser;
315
+ status: { from: string; to: string };
316
+ detail?: string;
317
+ }
318
+
307
319
  function buildUser(login: string, origin: string): PayloadUser {
308
320
  return {
309
321
  id: 1,
@@ -615,6 +627,7 @@ export async function emitIssueEvent(
615
627
  const model = resolveModel(project.model, globalDefault);
616
628
  const labels = await toPayloadLabels(issueId);
617
629
  const payload = buildIssuePayload(issue, project, commentCount, action, origin, model, labels);
630
+ (payload as IssueEventPayload).event_id = randomUUID();
618
631
  const rawBody = JSON.stringify(payload);
619
632
  await fanOut(projectId, "issues", rawBody);
620
633
  } catch (e) {
@@ -645,6 +658,7 @@ export async function emitCommentEvent(
645
658
  const model = resolveModel(project.model, globalDefault);
646
659
  const labels = await toPayloadLabels(issueId);
647
660
  const payload = buildCommentPayload(issue, comment, project, commentCount, origin, model, labels);
661
+ (payload as CommentEventPayload).event_id = randomUUID();
648
662
  const rawBody = JSON.stringify(payload);
649
663
  await fanOut(projectId, "issue_comment", rawBody);
650
664
  } catch (e) {
@@ -657,6 +671,46 @@ export async function emitCommentEvent(
657
671
  }
658
672
  }
659
673
 
674
+ export async function emitStatusChanged(
675
+ projectId: number,
676
+ issueId: number,
677
+ from: string,
678
+ to: string,
679
+ actor: string,
680
+ origin: string,
681
+ detail?: string
682
+ ): Promise<void> {
683
+ try {
684
+ const project = await getProjectById(projectId);
685
+ if (!project) return;
686
+ const issue = await getIssueById(issueId);
687
+ if (!issue) return;
688
+ const commentCount = await countCommentsSafe(issueId);
689
+ const repo = buildRepository(project, origin);
690
+ const sender = buildUser(actor, origin);
691
+ const issuePayload = buildIssue(issue, project, commentCount, origin);
692
+ const payload: StatusChangedPayload = {
693
+ event_id: randomUUID(),
694
+ action: "status_changed",
695
+ issue: issuePayload,
696
+ repository: repo,
697
+ sender,
698
+ status: { from, to },
699
+ detail,
700
+ };
701
+ const rawBody = JSON.stringify(payload);
702
+ await fanOut(projectId, "status_changed", rawBody);
703
+ } catch (e) {
704
+ log.error("webhook: emitStatusChanged failed", {
705
+ err: e as Error,
706
+ projectId,
707
+ issueId,
708
+ from,
709
+ to,
710
+ });
711
+ }
712
+ }
713
+
660
714
  export async function emitPingEvent(projectId: number, origin: string): Promise<void> {
661
715
  // Synthetic ping: send a small `ping` event (no issue context). Useful for
662
716
  // verifying webhook configuration without triggering a real write.