ework-web 0.10.65 → 0.10.67

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.65",
3
+ "version": "0.10.67",
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/index.ts CHANGED
@@ -2189,7 +2189,9 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
2189
2189
  const project = await getProject(owner, repo);
2190
2190
  if (!project) return html(errorPage("404", "项目不存在"), 404);
2191
2191
  if (!(await canReadProject(project.id, ctx.user))) return html(errorPage("404", "项目不存在"), 404);
2192
- const { html: body } = await buildIssueThread(cfg, owner, repo, number, ctx.user?.login);
2192
+ const dispatchCfg = await getConfigAll();
2193
+ const projectDispatchOff = dispatchCfg[`dispatchOff:${owner}/${repo}`] === "1";
2194
+ const { html: body } = await buildIssueThread(cfg, owner, repo, number, ctx.user?.login, projectDispatchOff);
2193
2195
  return html(body);
2194
2196
  } catch (e) {
2195
2197
  const status = e instanceof StoreError ? e.status : 500;
@@ -18,6 +18,7 @@ export interface LayoutProps {
18
18
  labels?: { id: number; name: string; color: string }[];
19
19
  canEditLabels?: boolean;
20
20
  aiStatus?: string;
21
+ projectDispatchOff?: boolean;
21
22
  viewerLogin?: string;
22
23
  viewerIsAdmin?: boolean;
23
24
  customActions?: IssueAction[];
@@ -192,9 +193,11 @@ export function renderLayout(props: LayoutProps, inner: string, initialItems: st
192
193
  : `<button type="button" class="action-btn" data-action-href="${escapeAttr(repoIssuesHref)}/${props.issueNumber}/halt" data-action-confirm="确认停止 AI 处理?" title="停止 AI 处理">⏹ 停止</button>`
193
194
  : "";
194
195
  const dispatchBtnHtml = showActions
195
- ? props.aiStatus === "dispatch_off"
196
- ? `<button type="button" class="action-btn dispatch-btn" data-action-href="${escapeAttr(repoIssuesHref)}/${props.issueNumber}/dispatch-on" title="允许自动接单">🔔 接单</button>`
197
- : `<button type="button" class="action-btn dispatch-btn" data-action-href="${escapeAttr(repoIssuesHref)}/${props.issueNumber}/dispatch-off" data-action-confirm="设为不自动接单?" title="设为不自动接单">🔕 不接单</button>`
196
+ ? props.projectDispatchOff
197
+ ? `<span class="action-btn dispatch-btn" style="opacity:.5;cursor:not-allowed" title="项目已关闭自动接单">🔕 项目不接单</span>`
198
+ : props.aiStatus === "dispatch_off"
199
+ ? `<button type="button" class="action-btn dispatch-btn" data-action-href="${escapeAttr(repoIssuesHref)}/${props.issueNumber}/dispatch-on" title="允许自动接单">🔔 接单</button>`
200
+ : `<button type="button" class="action-btn dispatch-btn" data-action-href="${escapeAttr(repoIssuesHref)}/${props.issueNumber}/dispatch-off" data-action-confirm="设为不自动接单?" title="设为不自动接单">🔕 不接单</button>`
198
201
  : "";
199
202
  return `<!doctype html>
200
203
  <html lang="zh">
@@ -91,7 +91,8 @@ export async function buildIssueThread(
91
91
  owner: string,
92
92
  repo: string,
93
93
  number: number,
94
- viewerLogin?: string
94
+ viewerLogin?: string,
95
+ projectDispatchOff?: boolean
95
96
  ): Promise<{ html: string }> {
96
97
  const project = await getProject(owner, repo);
97
98
  if (!project) throw new StoreError(404, `项目 ${owner}/${repo} 不存在`);
@@ -150,6 +151,7 @@ export async function buildIssueThread(
150
151
  labels: labels.map((l) => ({ id: l.id, name: l.name, color: l.color })),
151
152
  canEditLabels: cfg.writesEnabled !== false,
152
153
  aiStatus: issue.ai_status ?? "",
154
+ projectDispatchOff: projectDispatchOff ?? false,
153
155
  viewerLogin,
154
156
  viewerIsAdmin,
155
157
  customActions,
package/src/webhooks.ts CHANGED
@@ -28,7 +28,6 @@ import {
28
28
  resolveModel,
29
29
  listLabelsForIssue,
30
30
  getUserByLogin,
31
- canWriteProject,
32
31
  type IssueRow,
33
32
  type ProjectRow,
34
33
  type CommentRow,
@@ -301,6 +300,7 @@ interface PayloadComment {
301
300
  created_at: string;
302
301
  updated_at: string;
303
302
  user: PayloadUser;
303
+ author_kind?: string;
304
304
  }
305
305
 
306
306
  interface IssueEventPayload {
@@ -312,6 +312,7 @@ interface IssueEventPayload {
312
312
  commit_id?: string;
313
313
  commit_url?: string;
314
314
  url?: string;
315
+ dispatch_off?: boolean;
315
316
  }
316
317
 
317
318
  interface CommentEventPayload {
@@ -636,23 +637,23 @@ export async function emitIssueEvent(
636
637
  const project = await getProjectById(projectId);
637
638
  if (!project) return;
638
639
  const issue = await getIssueById(issueId);
639
- if (!issue) return;
640
+ if (!issue) { log.warn(`emitIssueEvent: issue ${issueId} not found — silent skip prevented`); return; }
641
+
642
+ // Web is a dumb message bus for comments — always fan out.
643
+ // BUT for issue_opened, dispatch state is a subscription-level switch:
644
+ // if dispatch is off (global/project/issue), don't emit at all.
645
+ // This is the "webhook switch" — downstream never sees the event.
646
+ // We still include dispatch_off in payload for defense-in-depth.
647
+ const cfg = await getConfigAll();
648
+ const scopeKey = `${project.owner}/${project.name}`;
649
+ const globalOff = cfg["dispatchEnabled"] === "false";
650
+ const projectOff = cfg[`dispatchOff:${scopeKey}`] === "1";
651
+ const issueOff = issue.ai_status === "dispatch_off" || issue.ai_status === "halted";
652
+ const dispatchOff = globalOff || projectOff || issueOff;
640
653
 
641
- if (action === "opened") {
642
- const cfg = await getConfigAll();
643
- const scopeKey = `${project.owner}/${project.name}`;
644
- if (cfg["dispatchEnabled"] === "false") {
645
- log.info(`webhook: global dispatch disabled — skipping opened for ${scopeKey}#${issue.number}`);
646
- return;
647
- }
648
- if (cfg[`dispatchOff:${scopeKey}`] === "1") {
649
- log.info(`webhook: project dispatch disabled — skipping opened for ${scopeKey}#${issue.number}`);
650
- return;
651
- }
652
- if ((issue as IssueRow).ai_status === "dispatch_off") {
653
- log.info(`webhook: issue dispatch disabled — skipping opened for ${scopeKey}#${issue.number}`);
654
- return;
655
- }
654
+ if (action === "opened" && dispatchOff) {
655
+ log.info(`webhook: issue_opened suppressed (dispatch off: global=${globalOff} project=${projectOff} issue=${issueOff}) for ${scopeKey}#${issue.number}`);
656
+ return;
656
657
  }
657
658
 
658
659
  const commentCount = await countCommentsSafe(issueId);
@@ -664,6 +665,7 @@ export async function emitIssueEvent(
664
665
  const labels = await toPayloadLabels(issueId);
665
666
  const payload = buildIssuePayload(issue, project, commentCount, action, origin, model, labels);
666
667
  (payload as IssueEventPayload).event_id = randomUUID();
668
+ (payload as IssueEventPayload).dispatch_off = dispatchOff;
667
669
  const rawBody = JSON.stringify(payload);
668
670
  await fanOut(projectId, "issues", rawBody);
669
671
  } catch (e) {
@@ -689,48 +691,16 @@ export async function emitCommentEvent(
689
691
  if (!issue) return;
690
692
  const comment = await getCommentByIdSafe(commentId);
691
693
  if (!comment) return;
692
- const cfg = await getConfigAll();
693
- const scopeKey = `${project.owner}/${project.name}`;
694
- // Issue-level dispatch_off is a per-issue explicit close — block comments entirely (same as halted).
695
- if ((issue as IssueRow).ai_status === "dispatch_off") {
696
- log.info(`webhook: issue dispatch disabled — skipping comment_created (author=${comment.author}) for ${scopeKey}#${issue.number}`);
697
- return;
698
- }
694
+ // Web is a dumb message bus — always fan out. Wake policy is enforced
695
+ // downstream (daemon handleEvent checks author_kind, ai_status, env config).
699
696
  const authorUser = await getUserByLogin(comment.author);
700
- const dispatchOff = cfg["dispatchEnabled"] === "false" || cfg[`dispatchOff:${scopeKey}`] === "1";
701
- if (dispatchOff && authorUser && authorUser.kind !== "human") {
702
- log.info(`webhook: dispatch disabled — non-human comment blocked (author=${comment.author} kind=${authorUser.kind}) for ${scopeKey}#${issue.number}`);
703
- return;
704
- }
705
- if (dispatchOff) {
706
- log.info(`webhook: dispatch disabled but human comment waking AI (author=${comment.author}) for ${scopeKey}#${issue.number}`);
707
- }
708
- const appCfg = await loadConfig();
709
- const cfgList = (k: string, d: string) =>
710
- (cfg[k] ?? d).split(",").map((s) => s.trim()).filter(Boolean);
711
- const noWake = cfgList("noWakeLogins", appCfg.daemonBotLogin);
712
- const okLogins = cfgList("wakeLogins", "");
713
- const okKinds = cfgList("wakeKinds", "human");
714
- const author = comment.author;
715
- const denied =
716
- noWake.includes(author) ? "deny-list" :
717
- okLogins.includes(author) ? null :
718
- (authorUser && !okKinds.includes(authorUser.kind)) ? `kind=${authorUser.kind}` :
719
- null;
720
- if (denied) {
721
- log.info(`webhook: comment wake denied (author=${author} reason=${denied}) for ${scopeKey}#${issue.number}`);
722
- return;
723
- }
724
- if (authorUser && !(await canWriteProject(projectId, { login: comment.author, is_admin: authorUser.is_admin }))) {
725
- log.info(`webhook: comment wake denied (author=${comment.author} not project member) for ${scopeKey}#${issue.number}`);
726
- return;
727
- }
728
697
  const commentCount = await countCommentsSafe(issueId);
729
698
  const globalDefault = (await loadConfig()).defaultModel;
730
699
  const model = resolveModel(project.model, globalDefault);
731
700
  const labels = await toPayloadLabels(issueId);
732
701
  const payload = buildCommentPayload(issue, comment, project, commentCount, origin, model, labels);
733
702
  (payload as CommentEventPayload).event_id = randomUUID();
703
+ (payload as CommentEventPayload).comment.author_kind = authorUser?.kind ?? "human";
734
704
  const rawBody = JSON.stringify(payload);
735
705
  await fanOut(projectId, "issue_comment", rawBody);
736
706
  } catch (e) {