ework-web 0.10.54 → 0.10.56

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.54",
3
+ "version": "0.10.56",
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
+ issueActionsHook: 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
+ issueActionsHook: process.env.WORK_ISSUE_ACTIONS_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/index.ts CHANGED
@@ -476,6 +476,8 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
476
476
  if (url.pathname === "/static/session.js") return staticAsset("session.js", "text/javascript; charset=utf-8", req);
477
477
  if (url.pathname === "/static/file.js") return staticAsset("file.js", "text/javascript; charset=utf-8", req);
478
478
  if (url.pathname === "/static/tts.js") return staticAsset("tts.js", "text/javascript; charset=utf-8", req);
479
+ if (url.pathname === "/static/issue-actions.js") return staticAsset("issue-actions.js", "text/javascript; charset=utf-8", req);
480
+ if (url.pathname === "/static/row-nav.js") return staticAsset("row-nav.js", "text/javascript; charset=utf-8", req);
479
481
  if (url.pathname === "/static/highlight.css") {
480
482
  return new Response(hlCss, {
481
483
  headers: { "content-type": "text/css; charset=utf-8", "cache-control": "no-cache", ...SEC_HEADERS },
@@ -0,0 +1,73 @@
1
+ export interface IssueActionContext {
2
+ owner: string;
3
+ repo: string;
4
+ issueNumber: number;
5
+ state: string;
6
+ aiStatus: string;
7
+ viewerLogin: string;
8
+ viewerIsAdmin: boolean;
9
+ labels: { id: number; name: string; color: string }[];
10
+ }
11
+
12
+ export interface IssueAction {
13
+ id: string;
14
+ label: string;
15
+ title?: string;
16
+ method?: "POST" | "GET";
17
+ href: string;
18
+ confirm?: string;
19
+ reloadOnOk?: boolean;
20
+ className?: string;
21
+ }
22
+
23
+ export interface IssueActionsProvider {
24
+ issueActions(ctx: IssueActionContext): Promise<IssueAction[]> | IssueAction[];
25
+ statusBadges?: Record<string, { cls: string; label: string }>;
26
+ }
27
+
28
+ const hookCache = new Map<string, IssueActionsProvider | null>();
29
+
30
+ async function loadHook(hookPath: string): Promise<IssueActionsProvider | null> {
31
+ const cached = hookCache.get(hookPath);
32
+ if (cached !== undefined) return cached;
33
+
34
+ try {
35
+ const path = await import("node:path");
36
+ const { pathToFileURL } = await import("node:url");
37
+ const abs = path.isAbsolute(hookPath) ? hookPath : path.resolve(process.cwd(), hookPath);
38
+ const mod = await import(pathToFileURL(abs).href);
39
+ const provider: IssueActionsProvider = mod.default ?? mod;
40
+ if (!provider || typeof provider.issueActions !== "function") {
41
+ console.warn(`[issue-actions-hook] ${hookPath}: must export an object with issueActions()`);
42
+ hookCache.set(hookPath, null);
43
+ return null;
44
+ }
45
+ hookCache.set(hookPath, provider);
46
+ return provider;
47
+ } catch (e) {
48
+ console.warn(`[issue-actions-hook] failed to load ${hookPath}:`, e);
49
+ hookCache.set(hookPath, null);
50
+ return null;
51
+ }
52
+ }
53
+
54
+ export async function runIssueActionsHook(
55
+ hookPath: string,
56
+ ctx: IssueActionContext,
57
+ ): Promise<{ actions: IssueAction[]; statusBadges?: Record<string, { cls: string; label: string }> }> {
58
+ if (!hookPath) return { actions: [] };
59
+ const provider = await loadHook(hookPath);
60
+ if (!provider) return { actions: [] };
61
+ try {
62
+ const actions = await provider.issueActions(ctx);
63
+ if (!Array.isArray(actions)) return { actions: [], statusBadges: provider.statusBadges };
64
+ return { actions, statusBadges: provider.statusBadges };
65
+ } catch (e) {
66
+ console.warn(`[issue-actions-hook] ${hookPath}: issueActions() threw:`, e);
67
+ return { actions: [] };
68
+ }
69
+ }
70
+
71
+ export function clearIssueActionsHookCache(): void {
72
+ hookCache.clear();
73
+ }
@@ -1,4 +1,5 @@
1
1
  import { BUILD_ID } from "../build";
2
+ import type { IssueAction } from "../issue-actions-hook";
2
3
  export interface LayoutProps {
3
4
  title: string;
4
5
  issueTitle: string;
@@ -17,6 +18,10 @@ export interface LayoutProps {
17
18
  labels?: { id: number; name: string; color: string }[];
18
19
  canEditLabels?: boolean;
19
20
  aiStatus?: string;
21
+ viewerLogin?: string;
22
+ viewerIsAdmin?: boolean;
23
+ customActions?: IssueAction[];
24
+ extraStatusBadges?: Record<string, { cls: string; label: string }>;
20
25
  }
21
26
 
22
27
  export const THEME_CSS = `
@@ -44,6 +49,8 @@ header.topbar .num{opacity:.7}
44
49
  .meta-bar{display:flex;flex-direction:column;gap:.3rem;padding:.7rem max(1rem,calc((100% - 900px)/2));border-bottom:1px solid var(--border);background:var(--bg-muted)}
45
50
  .meta-bar h1{font-size:18px;margin:0;font-weight:600;overflow-wrap:anywhere;word-break:break-word}
46
51
  .meta-status{display:flex;gap:.5rem;align-items:center;flex-wrap:wrap;font-size:13px;color:var(--text-muted)}
52
+ .action-group{display:flex;gap:.3rem;align-items:center;padding-left:.5rem;margin-left:.2rem;border-left:1px solid var(--border)}
53
+ .label-group{display:flex;gap:.3rem;align-items:center;padding-left:.5rem;margin-left:.2rem;border-left:1px solid var(--border);flex-wrap:wrap}
47
54
  .count{color:var(--text-muted);font-size:12px;white-space:nowrap}
48
55
  #list{padding:.5rem .6rem 4rem;max-width:900px;margin:0 auto}
49
56
  .sentinel{height:1px}
@@ -136,10 +143,14 @@ header.topbar .num{opacity:.7}
136
143
  .ai-completed{background:#1a7f37;color:#fff}
137
144
  .ai-failed{background:#cf222e;color:#fff}
138
145
  @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}
141
- .dispatch-btn{font-size:12px;padding:.15rem .6rem;border-radius:6px;border:1px solid var(--border);background:var(--bg-elev);color:#6f7781;cursor:pointer;font-weight:600}
142
- .dispatch-btn:hover{background:#6f7781;color:#fff}
146
+ .action-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}
147
+ .action-btn:hover{background:#cf222e;color:#fff}
148
+ .action-btn.resume-btn{color:#1a7f37}
149
+ .action-btn.resume-btn:hover{background:#1a7f37;color:#fff}
150
+ .action-btn.dispatch-btn{color:#6f7781}
151
+ .action-btn.dispatch-btn:hover{background:#6f7781;color:#fff}
152
+ .action-btn.custom-btn{color:var(--accent)}
153
+ .action-btn.custom-btn:hover{background:var(--accent);color:#fff}
143
154
  `;
144
155
 
145
156
  export function renderLayout(props: LayoutProps, inner: string, initialItems: string): string {
@@ -160,25 +171,29 @@ export function renderLayout(props: LayoutProps, inner: string, initialItems: st
160
171
  : "";
161
172
  const aiBadgeHtml = (() => {
162
173
  const s = props.aiStatus ?? "";
163
- if (!s) return "";
174
+ // halted/dispatch_off are represented by their action buttons — skip badge
175
+ if (!s || s === "halted" || s === "dispatch_off") return "";
164
176
  const map: Record<string, { cls: string; label: string }> = {
165
177
  processing: { cls: "ai-processing", label: "⚙️ 处理中" },
166
- halted: { cls: "ai-halted", label: "⏸️ 已暂停" },
167
- dispatch_off: { cls: "ai-dispatch-off", label: "🔕 不接单" },
168
178
  completed: { cls: "ai-completed", label: "✓ 已完成" },
169
179
  failed: { cls: "ai-failed", label: "✗ 失败" },
180
+ ...props.extraStatusBadges,
170
181
  };
171
182
  const m = map[s];
172
183
  if (!m) return "";
173
184
  return `<span class="ai-badge ${m.cls}">${m.label}</span>`;
174
185
  })();
175
- const haltBtnHtml = props.writesEnabled !== false && props.aiStatus !== "halted"
176
- ? `<button type="button" id="haltBtn" class="halt-btn" title="停止 AI 处理">⏹ 停止</button>`
186
+ const isTerminal = props.aiStatus === "completed";
187
+ const showActions = props.writesEnabled !== false && !isTerminal;
188
+ const haltBtnHtml = showActions
189
+ ? (props.aiStatus === "halted" || props.aiStatus === "failed")
190
+ ? `<button type="button" class="action-btn resume-btn" data-action-href="${escapeAttr(repoIssuesHref)}/${props.issueNumber}/resume" data-action-confirm="确认恢复 AI 处理?" title="恢复 AI 处理">▶ 恢复</button>`
191
+ : `<button type="button" class="action-btn" data-action-href="${escapeAttr(repoIssuesHref)}/${props.issueNumber}/halt" data-action-confirm="确认停止 AI 处理?" title="停止 AI 处理">⏹ 停止</button>`
177
192
  : "";
178
- const dispatchBtnHtml = props.writesEnabled !== false
193
+ const dispatchBtnHtml = showActions
179
194
  ? props.aiStatus === "dispatch_off"
180
- ? `<button type="button" id="dispatchBtn" class="dispatch-btn" title="允许自动接单">🔔 接单</button>`
181
- : `<button type="button" id="dispatchBtn" class="dispatch-btn" title="设为不自动接单">🔕 不接单</button>`
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>`
182
197
  : "";
183
198
  return `<!doctype html>
184
199
  <html lang="zh">
@@ -201,8 +216,16 @@ export function renderLayout(props: LayoutProps, inner: string, initialItems: st
201
216
  <h1>${escapeHtml(props.issueTitle)}</h1>
202
217
  <div class="meta-status">
203
218
  <span class="state-badge ${stateClass}">${stateLabel}</span>
204
- ${aiBadgeHtml}${haltBtnHtml}${dispatchBtnHtml}
205
- ${labelsHtml}${labelPickerBtn}
219
+ ${aiBadgeHtml}
220
+ ${(haltBtnHtml || dispatchBtnHtml || (props.customActions ?? []).length) ? `<span class="action-group">${haltBtnHtml}${dispatchBtnHtml}${(props.customActions ?? []).map((a) => {
221
+ const attrs = [`data-action-href="${escapeAttr(a.href)}"`];
222
+ if (a.method && a.method !== "POST") attrs.push(`data-action-method="${escapeAttr(a.method)}"`);
223
+ if (a.confirm) attrs.push(`data-action-confirm="${escapeAttr(a.confirm)}"`);
224
+ if (a.reloadOnOk === false) attrs.push(`data-action-reload="false"`);
225
+ const cls = a.className ? `${escapeAttr(a.className)}` : "action-btn custom-btn";
226
+ return `<button type="button" class="${cls}" ${attrs.join(" ")}${a.title ? ` title="${escapeAttr(a.title)}"` : ""}>${escapeHtml(a.label)}</button>`;
227
+ }).join("")}</span>` : ""}
228
+ ${(labelsHtml || labelPickerBtn) ? `<span class="label-group">${labelsHtml}${labelPickerBtn}</span>` : ""}
206
229
  <span class="count" id="count">…</span>
207
230
  ${props.upstreamWebUrl ? `<a class="upstream-link" href="${escapeAttr(props.upstreamWebUrl)}" target="_blank" rel="noopener noreferrer" title="跳转到上游仓库">🔗 查看上游</a>` : ""}
208
231
  </div>
@@ -233,32 +256,7 @@ ${props.writesEnabled !== false
233
256
  <script src="/static/app.js?v=${BUILD_ID}" defer></script>
234
257
  ${props.canEditLabels ? `<dialog id="labelDlg"><h3>标签</h3><div class="lp-list" id="lpList"></div><div class="lp-empty hidden" id="lpEmpty">该项目还没有标签。先到设置页创建。</div></dialog>
235
258
  <script src="/static/label-picker.js?v=${BUILD_ID}" defer></script>` : ""}
236
- ${haltBtnHtml ? `<script>
237
- (function(){
238
- var btn=document.getElementById("haltBtn");
239
- if(!btn)return;
240
- btn.addEventListener("click",function(){
241
- if(!confirm("确认停止 AI 处理?"))return;
242
- btn.disabled=true;btn.textContent="⏳ 停止中…";
243
- fetch(window.location.pathname+"/halt",{method:"POST"}).then(function(r){return r.json()}).then(function(d){
244
- if(d.ok){location.reload()}else{alert(d.error||"操作失败");btn.disabled=false;btn.textContent="⏹ 停止"}
245
- }).catch(function(e){alert("网络错误: "+e);btn.disabled=false;btn.textContent="⏹ 停止"})
246
- });
247
- })();
248
- </script>` : ""}
249
- ${dispatchBtnHtml ? `<script>
250
- (function(){
251
- var btn=document.getElementById("dispatchBtn");
252
- if(!btn)return;
253
- btn.addEventListener("click",function(){
254
- var off=btn.textContent.indexOf("不接单")>=0;
255
- btn.disabled=true;btn.textContent="⏳ …";
256
- fetch(window.location.pathname+"/"+(off?"dispatch-off":"dispatch-on"),{method:"POST"}).then(function(r){return r.json()}).then(function(d){
257
- if(d.ok){location.reload()}else{alert(d.error||"操作失败");btn.disabled=false;btn.textContent=off?"🔕 不接单":"🔔 接单"}
258
- }).catch(function(e){alert("网络错误: "+e);btn.disabled=false;btn.textContent=off?"🔕 不接单":"🔔 接单"})
259
- });
260
- })();
261
- </script>` : ""}
259
+ <script src="/static/issue-actions.js?v=${BUILD_ID}" defer></script>
262
260
  </body>
263
261
  </html>`;
264
262
  }
@@ -0,0 +1,38 @@
1
+ // Universal delegated click handler for issue action buttons.
2
+ // Buttons use data-* attributes so no inline JS is needed (CSP-safe).
3
+ // Supported attributes:
4
+ // data-action-href="/owner/repo/issues/N/halt" — POST target
5
+ // data-action-method="POST" — default POST
6
+ // data-action-confirm="确认停止?" — if set, confirm() first
7
+ // data-action-reload="true" — default true, reload on success
8
+ (function () {
9
+ document.addEventListener("click", function (e) {
10
+ var btn = e.target.closest("[data-action-href]");
11
+ if (!btn) return;
12
+ e.preventDefault();
13
+ var href = btn.getAttribute("data-action-href");
14
+ var method = btn.getAttribute("data-action-method") || "POST";
15
+ var confirmMsg = btn.getAttribute("data-action-confirm");
16
+ var reload = btn.getAttribute("data-action-reload") !== "false";
17
+ if (confirmMsg && !confirm(confirmMsg)) return;
18
+ var orig = btn.textContent;
19
+ btn.disabled = true;
20
+ btn.textContent = "⏳ …";
21
+ fetch(href, { method: method })
22
+ .then(function (r) { return r.json(); })
23
+ .then(function (d) {
24
+ if (d.ok) {
25
+ if (reload) location.reload();
26
+ } else {
27
+ alert(d.error || "操作失败");
28
+ btn.disabled = false;
29
+ btn.textContent = orig;
30
+ }
31
+ })
32
+ .catch(function (err) {
33
+ alert("网络错误: " + err);
34
+ btn.disabled = false;
35
+ btn.textContent = orig;
36
+ });
37
+ });
38
+ })();
@@ -0,0 +1,13 @@
1
+ // Delegated click handler for label chips inside <a class="row"> elements.
2
+ // Labels are <span class="issue-label" data-href="..."> nested inside the row <a>.
3
+ // Without interception, clicking a label would navigate to the issue (the <a> default).
4
+ // This handler stops propagation and navigates to the label filter URL instead.
5
+ (function () {
6
+ document.addEventListener("click", function (e) {
7
+ var label = e.target.closest(".issue-label[data-href]");
8
+ if (!label) return;
9
+ e.preventDefault();
10
+ e.stopPropagation();
11
+ location.href = label.getAttribute("data-href");
12
+ });
13
+ })();
@@ -1,4 +1,5 @@
1
1
  import { THEME_CSS, escapeHtml, escapeAttr, containsCI, highlightAll, tabNavHTML, aiStatusBadge } from "../render/layout";
2
+ import { BUILD_ID } from "../build";
2
3
  import { getProject, listIssues, listLabelsForIssues, type IssueWithMeta, type LabelRow } from "../store";
3
4
  import { relTime } from "../render/components";
4
5
 
@@ -6,7 +7,7 @@ export const LIST_PAGE_SIZE = 50;
6
7
 
7
8
  function labelChip(label: LabelRow, owner: string, repo: string, state: string): string {
8
9
  const href = `/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues?state=${state}&label=${encodeURIComponent(label.name)}`;
9
- return `<span class="issue-label" style="--lc:${escapeAttr(label.color)}" title="${escapeAttr(label.description ?? "")}" onclick="event.preventDefault();event.stopPropagation();location.href='${href}'">${escapeHtml(label.name)}</span>`;
10
+ return `<span class="issue-label" data-href="${escapeAttr(href)}" style="--lc:${escapeAttr(label.color)}" title="${escapeAttr(label.description ?? "")}">${escapeHtml(label.name)}</span>`;
10
11
  }
11
12
 
12
13
  function issueRow(
@@ -109,6 +110,7 @@ ${tabNavHTML("issues", viewer ? { login: viewer.login, is_admin: viewer.is_admin
109
110
  </div>
110
111
  ${labelFilterHint}
111
112
  <div class="rows">${rows}</div>
113
+ <script src="/static/row-nav.js?v=${BUILD_ID}" defer></script>
112
114
  </main>
113
115
  </body></html>`;
114
116
  }
@@ -2,6 +2,7 @@ import type { Config } from "../config";
2
2
  import { classifyActor, renderCommentCard, type CommentView } from "../render/components";
3
3
  import { renderMarkdown } from "../render/markdown";
4
4
  import { renderLayout, escapeHtml } from "../render/layout";
5
+ import { runIssueActionsHook, type IssueActionContext, type IssueAction } from "../issue-actions-hook";
5
6
  import { hydrateReactions } from "../reactions";
6
7
  import {
7
8
  StoreError,
@@ -12,6 +13,7 @@ import {
12
13
  listCommentsSince,
13
14
  getDefaultUpstreamUrl,
14
15
  listLabelsForIssue,
16
+ getUserByLogin,
15
17
  type CommentRow,
16
18
  type IssueWithMeta,
17
19
  } from "../store";
@@ -112,6 +114,22 @@ export async function buildIssueThread(
112
114
  return webUrlFromClone(clone);
113
115
  })();
114
116
  const labels = await listLabelsForIssue(issue.id);
117
+ const viewerUser = viewerLogin ? await getUserByLogin(viewerLogin) : null;
118
+ const viewerIsAdmin = !!viewerUser?.is_admin;
119
+
120
+ let customActions: IssueAction[] = [];
121
+ let extraStatusBadges: Record<string, { cls: string; label: string }> | undefined;
122
+ if (cfg.issueActionsHook && viewerLogin) {
123
+ const ctx: IssueActionContext = {
124
+ owner, repo, issueNumber: number,
125
+ state: issue.state, aiStatus: issue.ai_status ?? "",
126
+ viewerLogin, viewerIsAdmin,
127
+ labels: labels.map((l) => ({ id: l.id, name: l.name, color: l.color })),
128
+ };
129
+ const result = await runIssueActionsHook(cfg.issueActionsHook, ctx);
130
+ customActions = result.actions;
131
+ extraStatusBadges = result.statusBadges;
132
+ }
115
133
 
116
134
  const html = renderLayout(
117
135
  {
@@ -131,6 +149,10 @@ export async function buildIssueThread(
131
149
  labels: labels.map((l) => ({ id: l.id, name: l.name, color: l.color })),
132
150
  canEditLabels: cfg.writesEnabled !== false,
133
151
  aiStatus: issue.ai_status ?? "",
152
+ viewerLogin,
153
+ viewerIsAdmin,
154
+ customActions,
155
+ extraStatusBadges,
134
156
  },
135
157
  safeJsonEmbed(payload),
136
158
  displayViews.map((v) => renderCommentCard(v, cfg)).join("")
@@ -1,4 +1,5 @@
1
1
  import { THEME_CSS, escapeHtml, escapeAttr, containsCI, highlightAll, tabNavHTML, aiStatusBadge } from "../render/layout";
2
+ import { BUILD_ID } from "../build";
2
3
  import { listAllIssues, listLabelsForIssues, type IssueWithMeta, type LabelRow } from "../store";
3
4
  import { relTime } from "../render/components";
4
5
 
@@ -6,7 +7,7 @@ export const FEED_PAGE_SIZE = 50;
6
7
 
7
8
  function labelChip(label: LabelRow, owner: string, repo: string, state: string): string {
8
9
  const href = `/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues?state=${state}&label=${encodeURIComponent(label.name)}`;
9
- return `<span class="issue-label" style="--lc:${escapeAttr(label.color)}" title="${escapeAttr(label.description ?? "")}" onclick="event.preventDefault();event.stopPropagation();location.href='${href}'">${escapeHtml(label.name)}</span>`;
10
+ return `<span class="issue-label" data-href="${escapeAttr(href)}" style="--lc:${escapeAttr(label.color)}" title="${escapeAttr(label.description ?? "")}">${escapeHtml(label.name)}</span>`;
10
11
  }
11
12
 
12
13
  function issueRow(it: IssueWithMeta, q: string, state: string, labels: LabelRow[]): string {
@@ -87,6 +88,7 @@ ${tabNavHTML("issues", viewer ? { login: viewer.login, is_admin: viewer.is_admin
87
88
  </div>
88
89
  ${labelFilterHint}
89
90
  <div class="rows">${rows}</div>
91
+ <script src="/static/row-nav.js?v=${BUILD_ID}" defer></script>
90
92
  </main>
91
93
  </body></html>`;
92
94
  }
package/src/webhooks.ts CHANGED
@@ -27,6 +27,8 @@ import {
27
27
  ensureUser,
28
28
  resolveModel,
29
29
  listLabelsForIssue,
30
+ getUserByLogin,
31
+ canWriteProject,
30
32
  type IssueRow,
31
33
  type ProjectRow,
32
34
  type CommentRow,
@@ -692,6 +694,15 @@ export async function emitCommentEvent(
692
694
  if (cfg["dispatchEnabled"] === "false" || cfg[`dispatchOff:${scopeKey}`] === "1" || (issue as IssueRow).ai_status === "dispatch_off") {
693
695
  log.info(`webhook: dispatch disabled but waking via comment_created (author=${comment.author}) for ${scopeKey}#${issue.number}`);
694
696
  }
697
+ const authorUser = await getUserByLogin(comment.author);
698
+ if (authorUser && authorUser.kind !== "human") {
699
+ log.info(`webhook: comment wake denied (author=${comment.author} kind=${authorUser.kind}) for ${scopeKey}#${issue.number}`);
700
+ return;
701
+ }
702
+ if (authorUser && !(await canWriteProject(projectId, { login: comment.author, is_admin: authorUser.is_admin }))) {
703
+ log.info(`webhook: comment wake denied (author=${comment.author} not project member) for ${scopeKey}#${issue.number}`);
704
+ return;
705
+ }
695
706
  const commentCount = await countCommentsSafe(issueId);
696
707
  const globalDefault = (await loadConfig()).defaultModel;
697
708
  const model = resolveModel(project.model, globalDefault);