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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ework-web",
3
- "version": "0.10.44",
3
+ "version": "0.10.46",
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,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_,
@@ -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
  }
@@ -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;
@@ -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.