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 +1 -1
- package/src/config.ts +2 -0
- package/src/index.ts +2 -0
- package/src/issue-actions-hook.ts +73 -0
- package/src/render/layout.ts +38 -40
- package/src/static/issue-actions.js +38 -0
- package/src/static/row-nav.js +13 -0
- package/src/views/issueList.ts +3 -1
- package/src/views/issueThread.ts +22 -0
- package/src/views/issues.ts +3 -1
- package/src/webhooks.ts +11 -0
package/package.json
CHANGED
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
|
+
}
|
package/src/render/layout.ts
CHANGED
|
@@ -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
|
-
.
|
|
140
|
-
.
|
|
141
|
-
.
|
|
142
|
-
.
|
|
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
|
-
|
|
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
|
|
176
|
-
|
|
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 =
|
|
193
|
+
const dispatchBtnHtml = showActions
|
|
179
194
|
? props.aiStatus === "dispatch_off"
|
|
180
|
-
? `<button type="button"
|
|
181
|
-
: `<button type="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}
|
|
205
|
-
${
|
|
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
|
-
|
|
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
|
+
})();
|
package/src/views/issueList.ts
CHANGED
|
@@ -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 ?? "")}"
|
|
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
|
}
|
package/src/views/issueThread.ts
CHANGED
|
@@ -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("")
|
package/src/views/issues.ts
CHANGED
|
@@ -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 ?? "")}"
|
|
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);
|