ework-web 0.10.64 → 0.10.66

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.64",
3
+ "version": "0.10.66",
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/giteaApi.ts CHANGED
@@ -126,12 +126,13 @@ export async function handleGiteaApi(
126
126
  if (!(await canSudo(caller))) return giteaError(403, "sudo requires permission");
127
127
  if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$/.test(sudoLogin)) return giteaError(400, "invalid sudo login");
128
128
  const sudoKindRaw = req.headers.get("Sudo-Kind")?.trim();
129
+ const sudoDisplayName = req.headers.get("Sudo-Display-Name")?.trim() || null;
129
130
  const existing = await getUserByLogin(sudoLogin);
130
131
  if (existing) {
131
132
  user = existing;
132
133
  } else {
133
134
  const kind = sudoKindRaw === "bot" || sudoKindRaw === "system" ? sudoKindRaw : "human";
134
- user = await ensureUser(sudoLogin, kind);
135
+ user = await ensureUser(sudoLogin, kind, sudoDisplayName);
135
136
  }
136
137
  if (!user.is_active) return giteaError(403, "sudo target inactive");
137
138
  }
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;
@@ -19,6 +19,7 @@ export interface CommentView {
19
19
  avatar: string;
20
20
  created_at: string;
21
21
  body_html: string;
22
+ display_name?: string | null;
22
23
  reactions?: { e: string; n: number }[];
23
24
  }
24
25
 
@@ -59,7 +60,8 @@ export function renderCommentCard(c: CommentView, cfg?: { translateUrl?: string;
59
60
  `<div class="item item-${esc(tag)}" id="comment-${c.id}" data-id="${c.id}">` +
60
61
  `<div class="card"><div class="card-h">` +
61
62
  `<span class="tag tag-${esc(tag)}">${label} ${esc(tag)}</span>` +
62
- `<span class="who">${esc(c.login)}</span>` +
63
+ `<span class="who">${esc(c.display_name || c.login)}</span>` +
64
+ (c.display_name ? `<span class="who-login">${esc(c.login)}</span>` : "") +
63
65
  `<span class="when" data-ts="${esc(c.created_at)}" title="${esc(c.created_at)}">${relTime(c.created_at)}</span>` +
64
66
  rx +
65
67
  `<span class="card-actions">` + actionBarHTML({ cid: String(c.id), copy: true, link: true, translate: true, tts: true, translateEnabled, ttsEnabled }) + `</span>` +
@@ -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[];
@@ -61,6 +62,7 @@ header.topbar .num{opacity:.7}
61
62
  .card{background:var(--bg-elev);border:1px solid var(--border);border-radius:8px;overflow:hidden;min-width:0}
62
63
  .card-h{display:flex;align-items:center;gap:.5rem;padding:.4rem .7rem;background:var(--bg-muted);font-size:13px;flex-wrap:wrap}
63
64
  .card-h .who{font-weight:600;color:var(--text)}
65
+ .card-h .who-login{font-weight:400;color:var(--text-muted);font-size:.85em;margin-left:.25rem}
64
66
  .tag{font-size:11px;font-weight:600;padding:.05rem .4rem;border-radius:4px;line-height:1.5}
65
67
  .tag-human{background:color-mix(in srgb,var(--human) 18%,transparent);color:var(--human)}
66
68
  .tag-bot{background:color-mix(in srgb,var(--bot) 18%,transparent);color:var(--bot)}
@@ -191,9 +193,11 @@ export function renderLayout(props: LayoutProps, inner: string, initialItems: st
191
193
  : `<button type="button" class="action-btn" data-action-href="${escapeAttr(repoIssuesHref)}/${props.issueNumber}/halt" data-action-confirm="确认停止 AI 处理?" title="停止 AI 处理">⏹ 停止</button>`
192
194
  : "";
193
195
  const dispatchBtnHtml = showActions
194
- ? props.aiStatus === "dispatch_off"
195
- ? `<button type="button" class="action-btn dispatch-btn" data-action-href="${escapeAttr(repoIssuesHref)}/${props.issueNumber}/dispatch-on" title="允许自动接单">🔔 接单</button>`
196
- : `<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>`
197
201
  : "";
198
202
  return `<!doctype html>
199
203
  <html lang="zh">
package/src/static/app.js CHANGED
@@ -116,7 +116,8 @@
116
116
  `<div class="item item-${esc(tag)}${isNew}" id="comment-${c.id}" data-id="${c.id}">` +
117
117
  `<div class="card"><div class="card-h">` +
118
118
  `<span class="tag tag-${esc(tag)}">${label} ${esc(tag)}</span>` +
119
- `<span class="who">${esc(c.login)}</span>` +
119
+ `<span class="who">${esc(c.display_name || c.login)}</span>` +
120
+ (c.display_name ? `<span class="who-login">${esc(c.login)}</span>` : "") +
120
121
  `<span class="when" data-ts="${esc(c.created_at)}" title="${esc(c.created_at)}">${relTime(c.created_at)}</span>` +
121
122
  rx +
122
123
  `<span class="card-actions">` +
package/src/store.ts CHANGED
@@ -90,6 +90,7 @@ export interface CommentRow {
90
90
  created_at: string;
91
91
  updated_at: string;
92
92
  author_kind?: UserKind;
93
+ author_display_name?: string | null;
93
94
  }
94
95
 
95
96
  export interface LabelRow {
@@ -134,19 +135,24 @@ function now(): string {
134
135
  return new Date().toISOString();
135
136
  }
136
137
 
137
- export async function ensureUser(login: string, kind: UserKind = "human"): Promise<UserRow> {
138
+ export async function ensureUser(login: string, kind: UserKind = "human", displayName?: string | null): Promise<UserRow> {
138
139
  const db = getDB();
139
140
  const existing = await db.get<UserRow>("SELECT * FROM {{users}} WHERE login = ?", [login]);
140
- if (existing) return existing;
141
+ if (existing) {
142
+ if (displayName && displayName.trim() && existing.display_name !== displayName) {
143
+ await db.run("UPDATE {{users}} SET display_name = ?, updated_at = ? WHERE login = ?", [displayName, now(), login]);
144
+ }
145
+ return existing;
146
+ }
141
147
  const ts = now();
142
148
  await db.run(
143
- "INSERT INTO {{users}} (login, kind, created_at, updated_at) VALUES (?, ?, ?, ?)",
144
- [login, kind, ts, ts]
149
+ "INSERT INTO {{users}} (login, kind, display_name, created_at, updated_at) VALUES (?, ?, ?, ?, ?)",
150
+ [login, kind, displayName ?? null, ts, ts]
145
151
  );
146
152
  return {
147
153
  login,
148
154
  kind,
149
- display_name: null,
155
+ display_name: displayName ?? null,
150
156
  password_hash: null,
151
157
  email: null,
152
158
  is_admin: 0,
@@ -570,7 +576,7 @@ export async function listCommentsPage(issueId: number, page: number, pageSize:
570
576
  const clamped = Math.min(Math.max(1, page), totalPages);
571
577
  const offset = (clamped - 1) * pageSize;
572
578
  const rows = await getDB().all<CommentRow>(
573
- `SELECT c.*, u.kind AS author_kind
579
+ `SELECT c.*, u.kind AS author_kind, u.display_name AS author_display_name
574
580
  FROM {{comments}} c
575
581
  LEFT JOIN {{users}} u ON u.login = c.author
576
582
  WHERE c.issue_id = ?
@@ -583,7 +589,7 @@ export async function listCommentsPage(issueId: number, page: number, pageSize:
583
589
 
584
590
  export async function listCommentsSince(issueId: number, sinceISO: string): Promise<CommentRow[]> {
585
591
  return await getDB().all<CommentRow>(
586
- `SELECT c.*, u.kind AS author_kind
592
+ `SELECT c.*, u.kind AS author_kind, u.display_name AS author_display_name
587
593
  FROM {{comments}} c
588
594
  LEFT JOIN {{users}} u ON u.login = c.author
589
595
  WHERE c.issue_id = ? AND c.created_at > ?
@@ -594,7 +600,7 @@ export async function listCommentsSince(issueId: number, sinceISO: string): Prom
594
600
 
595
601
  export async function listCommentsForIssue(issueId: number): Promise<CommentRow[]> {
596
602
  return await getDB().all<CommentRow>(
597
- `SELECT c.*, u.kind AS author_kind
603
+ `SELECT c.*, u.kind AS author_kind, u.display_name AS author_display_name
598
604
  FROM {{comments}} c
599
605
  LEFT JOIN {{users}} u ON u.login = c.author
600
606
  WHERE c.issue_id = ?
@@ -628,7 +634,7 @@ export async function postComment(
628
634
  const row = await getDB().get<{ project_id: number }>("SELECT project_id FROM {{issues}} WHERE id = ?", [issueId]);
629
635
  await getDB().run("UPDATE {{projects}} SET updated_at = ? WHERE id = ?", [updatedAt, row!.project_id]);
630
636
  return (await getDB().get<CommentRow>(
631
- `SELECT c.*, u.kind AS author_kind
637
+ `SELECT c.*, u.kind AS author_kind, u.display_name AS author_display_name
632
638
  FROM {{comments}} c LEFT JOIN {{users}} u ON u.login = c.author
633
639
  WHERE c.id = ?`,
634
640
  [info.insertId]
@@ -638,7 +644,7 @@ export async function postComment(
638
644
 
639
645
  export async function getComment(commentId: number): Promise<CommentRow | null> {
640
646
  const row = await getDB().get<CommentRow>(
641
- `SELECT c.*, u.kind AS author_kind
647
+ `SELECT c.*, u.kind AS author_kind, u.display_name AS author_display_name
642
648
  FROM {{comments}} c LEFT JOIN {{users}} u ON u.login = c.author
643
649
  WHERE c.id = ?`,
644
650
  [commentId]
@@ -44,6 +44,7 @@ function toView(c: CommentRow): CommentView {
44
44
  avatar: "",
45
45
  created_at: c.created_at,
46
46
  body_html: renderMarkdown(c.body),
47
+ display_name: c.author_display_name ?? null,
47
48
  };
48
49
  }
49
50
 
@@ -90,7 +91,8 @@ export async function buildIssueThread(
90
91
  owner: string,
91
92
  repo: string,
92
93
  number: number,
93
- viewerLogin?: string
94
+ viewerLogin?: string,
95
+ projectDispatchOff?: boolean
94
96
  ): Promise<{ html: string }> {
95
97
  const project = await getProject(owner, repo);
96
98
  if (!project) throw new StoreError(404, `项目 ${owner}/${repo} 不存在`);
@@ -149,6 +151,7 @@ export async function buildIssueThread(
149
151
  labels: labels.map((l) => ({ id: l.id, name: l.name, color: l.color })),
150
152
  canEditLabels: cfg.writesEnabled !== false,
151
153
  aiStatus: issue.ai_status ?? "",
154
+ projectDispatchOff: projectDispatchOff ?? false,
152
155
  viewerLogin,
153
156
  viewerIsAdmin,
154
157
  customActions,
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 { getDB, getConfigAll } from "./db";
19
+ import { getDB } from "./db";
20
20
  import { log } from "./logger";
21
21
  import { loadConfig } from "./config";
22
22
  import {
@@ -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 {
@@ -636,25 +636,11 @@ export async function emitIssueEvent(
636
636
  const project = await getProjectById(projectId);
637
637
  if (!project) return;
638
638
  const issue = await getIssueById(issueId);
639
- if (!issue) return;
640
-
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
- }
656
- }
639
+ if (!issue) { log.warn(`emitIssueEvent: issue ${issueId} not found — silent skip prevented`); return; }
657
640
 
641
+ // Web is a dumb message bus — always fan out. Wake policy is enforced
642
+ // downstream (daemon handleEvent). Web only suppresses for subscription-
643
+ // level concerns (none currently).
658
644
  const commentCount = await countCommentsSafe(issueId);
659
645
  // Resolve model: project override > global default. Empty string = no
660
646
  // override (daemon omits --model, lets opencode pick). globalDefault
@@ -689,48 +675,16 @@ export async function emitCommentEvent(
689
675
  if (!issue) return;
690
676
  const comment = await getCommentByIdSafe(commentId);
691
677
  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
- }
678
+ // Web is a dumb message bus — always fan out. Wake policy is enforced
679
+ // downstream (daemon handleEvent checks author_kind, ai_status, env config).
699
680
  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
681
  const commentCount = await countCommentsSafe(issueId);
729
682
  const globalDefault = (await loadConfig()).defaultModel;
730
683
  const model = resolveModel(project.model, globalDefault);
731
684
  const labels = await toPayloadLabels(issueId);
732
685
  const payload = buildCommentPayload(issue, comment, project, commentCount, origin, model, labels);
733
686
  (payload as CommentEventPayload).event_id = randomUUID();
687
+ (payload as CommentEventPayload).comment.author_kind = authorUser?.kind ?? "human";
734
688
  const rawBody = JSON.stringify(payload);
735
689
  await fanOut(projectId, "issue_comment", rawBody);
736
690
  } catch (e) {