ework-web 0.10.17 → 0.10.18
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 +4 -2
- package/src/db.ts +19 -4
- package/src/index.ts +119 -2
- package/src/render/layout.ts +25 -0
- package/src/schema-mysql.sql +7 -4
- package/src/schema.sql +7 -4
- package/src/static/daemon-groups.js +62 -1
- package/src/static/label-picker.js +106 -0
- package/src/store.ts +103 -9
- package/src/views/issueThread.ts +4 -0
- package/src/views/projectLabels.ts +190 -0
- package/src/views/projectUpstreams.ts +2 -1
- package/src/views/settings.ts +5 -0
- package/src/webhooks.ts +16 -5
package/package.json
CHANGED
package/src/config.ts
CHANGED
|
@@ -78,6 +78,7 @@ export const configSchema = z.object({
|
|
|
78
78
|
daemonBotLogin: z.string().default(""),
|
|
79
79
|
daemonWebhookUrl: z.string().default(""),
|
|
80
80
|
daemonWebhookSecret: z.string().default(""),
|
|
81
|
+
routerAdminToken: z.string().default(""),
|
|
81
82
|
// Default "provider/model" string passed to `opencode run --model <X>`.
|
|
82
83
|
// Empty = let opencode pick per its own opencode.json + env. ework-daemon
|
|
83
84
|
// pushes this (or the per-project override) on every spawn to defend
|
|
@@ -175,8 +176,9 @@ export async function loadConfig(): Promise<Config> {
|
|
|
175
176
|
ttsDefaultBackend: db.ttsDefaultBackend ?? process.env.WORK_TTS_DEFAULT_BACKEND,
|
|
176
177
|
ttsSpeed: db.ttsSpeed ?? process.env.WORK_TTS_SPEED,
|
|
177
178
|
daemonBotLogin: process.env.WORK_DAEMON_BOT_LOGIN ?? "",
|
|
178
|
-
|
|
179
|
-
|
|
179
|
+
daemonWebhookUrl: process.env.WORK_DAEMON_WEBHOOK_URL ?? "",
|
|
180
|
+
daemonWebhookSecret: process.env.WORK_DAEMON_WEBHOOK_SECRET ?? "",
|
|
181
|
+
routerAdminToken: process.env.WORK_ROUTER_ADMIN_TOKEN ?? "",
|
|
180
182
|
defaultModel: db.defaultModel ?? process.env.WORK_DEFAULT_MODEL,
|
|
181
183
|
autowireActive: process.env.WORK_AUTOWIRE_ACTIVE !== "false",
|
|
182
184
|
webhookMaxConcurrent: Number(process.env.WORK_WEBHOOK_MAX_CONCURRENT ?? "6"),
|
package/src/db.ts
CHANGED
|
@@ -127,6 +127,20 @@ function migrateIssuesTable(db: Database): void {
|
|
|
127
127
|
}
|
|
128
128
|
}
|
|
129
129
|
|
|
130
|
+
function migrateLabelsTable(db: Database): void {
|
|
131
|
+
const have = tableColumns(db, "labels");
|
|
132
|
+
if (have.size === 0) return;
|
|
133
|
+
if (!have.has("description")) {
|
|
134
|
+
db.exec(applyPrefix("ALTER TABLE {{labels}} ADD COLUMN description TEXT NOT NULL DEFAULT ''"));
|
|
135
|
+
}
|
|
136
|
+
if (!have.has("exclusive")) {
|
|
137
|
+
db.exec(applyPrefix("ALTER TABLE {{labels}} ADD COLUMN exclusive INTEGER NOT NULL DEFAULT 0"));
|
|
138
|
+
}
|
|
139
|
+
if (!have.has("is_archived")) {
|
|
140
|
+
db.exec(applyPrefix("ALTER TABLE {{labels}} ADD COLUMN is_archived INTEGER NOT NULL DEFAULT 0"));
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
130
144
|
// ---- SqliteDriver: wraps bun:sqlite behind AsyncDatabase ----
|
|
131
145
|
class SqliteDriver implements AsyncDatabase {
|
|
132
146
|
readonly dialect = "sqlite" as const;
|
|
@@ -145,10 +159,11 @@ class SqliteDriver implements AsyncDatabase {
|
|
|
145
159
|
)
|
|
146
160
|
);
|
|
147
161
|
// Migration must run BEFORE schema.sql (same ordering as the original file).
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
162
|
+
migrateUsersTable(db);
|
|
163
|
+
migratePatTable(db);
|
|
164
|
+
migrateProjectsTable(db);
|
|
165
|
+
migrateIssuesTable(db);
|
|
166
|
+
migrateLabelsTable(db);
|
|
152
167
|
db.exec(applyPrefix(readFileSync(join(import.meta.dir, "schema.sql"), "utf8")));
|
|
153
168
|
return new SqliteDriver(db);
|
|
154
169
|
}
|
package/src/index.ts
CHANGED
|
@@ -61,6 +61,13 @@ import {
|
|
|
61
61
|
setProjectModel,
|
|
62
62
|
listCachedModels,
|
|
63
63
|
replaceCachedModels,
|
|
64
|
+
listLabels,
|
|
65
|
+
listLabelsForIssue,
|
|
66
|
+
createLabel,
|
|
67
|
+
updateLabel,
|
|
68
|
+
archiveLabel,
|
|
69
|
+
deleteLabel,
|
|
70
|
+
setIssueLabels,
|
|
64
71
|
type ProjectRole,
|
|
65
72
|
type UserRow,
|
|
66
73
|
} from "./store";
|
|
@@ -91,6 +98,7 @@ import { buildWebhookDeliveriesPage } from "./views/webhookDeliveries";
|
|
|
91
98
|
import { browseRemoteFile, proxyFileSince, RemoteFileError } from "./remote-file";
|
|
92
99
|
import { buildProjectMembersPage } from "./views/projectMembers";
|
|
93
100
|
import { buildProjectUpstreamsPage, trySetUpstreamUrls } from "./views/projectUpstreams";
|
|
101
|
+
import { buildProjectLabelsPage } from "./views/projectLabels";
|
|
94
102
|
import { buildProjectModelPage } from "./views/projectModel";
|
|
95
103
|
import { handleGiteaApi } from "./giteaApi";
|
|
96
104
|
import { deployRemoteDaemon, deployBatch, type DeployTarget } from "./daemon-deploy";
|
|
@@ -331,6 +339,10 @@ const REPO_MEMBER_ACTION_RE = /^\/([^/]+)\/([^/]+)\/settings\/members\/([^/]+)\/
|
|
|
331
339
|
const REPO_MEMBER_ADD_RE = /^\/([^/]+)\/([^/]+)\/settings\/members\/add$/;
|
|
332
340
|
const REPO_UPSTREAMS_RE = /^\/([^/]+)\/([^/]+)\/settings\/upstreams$/;
|
|
333
341
|
const REPO_MODEL_RE = /^\/([^/]+)\/([^/]+)\/settings\/model$/;
|
|
342
|
+
const REPO_LABELS_RE = /^\/([^/]+)\/([^/]+)\/settings\/labels$/;
|
|
343
|
+
const REPO_LABEL_ADD_RE = /^\/([^/]+)\/([^/]+)\/settings\/labels\/add$/;
|
|
344
|
+
const REPO_LABEL_ACTION_RE = /^\/([^/]+)\/([^/]+)\/settings\/labels\/(\d+)\/(update|archive|unarchive|delete)$/;
|
|
345
|
+
const API_ISSUE_LABELS_RE = /^\/api\/([^/]+)\/([^/]+)\/issues\/(\d+)\/labels$/;
|
|
334
346
|
const WH_ACTION_RE = /^\/__wh\/(\d+)\/(delete|toggle|test)$/;
|
|
335
347
|
const SESSIONS_RE = /^\/sessions$/;
|
|
336
348
|
const SESSION_VIEW_RE = /^\/sessions\/([A-Za-z0-9_-]+)$/;
|
|
@@ -1048,16 +1060,18 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
|
|
|
1048
1060
|
if (url.pathname === "/api/router/strategy") {
|
|
1049
1061
|
if (!ctx.user || ctx.user.is_admin !== 1) return json({ error: "admin required" }, 403);
|
|
1050
1062
|
const routerUrl = cfg.daemonWebhookUrl.replace(/\/$/, "");
|
|
1063
|
+
const routerHeaders: Record<string, string> = { "Content-Type": "application/json" };
|
|
1064
|
+
if (cfg.routerAdminToken) routerHeaders["Authorization"] = `Bearer ${cfg.routerAdminToken}`;
|
|
1051
1065
|
try {
|
|
1052
1066
|
if (req.method === "GET") {
|
|
1053
|
-
const res = await fetch(`${routerUrl}/api/strategy`, { signal: AbortSignal.timeout(5000) });
|
|
1067
|
+
const res = await fetch(`${routerUrl}/api/strategy`, { headers: routerHeaders, signal: AbortSignal.timeout(5000) });
|
|
1054
1068
|
return json(await res.json());
|
|
1055
1069
|
}
|
|
1056
1070
|
if (req.method === "POST") {
|
|
1057
1071
|
const body = await req.text();
|
|
1058
1072
|
const res = await fetch(`${routerUrl}/api/strategy`, {
|
|
1059
1073
|
method: "POST",
|
|
1060
|
-
headers:
|
|
1074
|
+
headers: routerHeaders,
|
|
1061
1075
|
body,
|
|
1062
1076
|
signal: AbortSignal.timeout(5000),
|
|
1063
1077
|
});
|
|
@@ -1663,6 +1677,61 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
|
|
|
1663
1677
|
}
|
|
1664
1678
|
}
|
|
1665
1679
|
|
|
1680
|
+
const labelAdd = url.pathname.match(REPO_LABEL_ADD_RE);
|
|
1681
|
+
if (labelAdd) {
|
|
1682
|
+
const [, owner, repo] = labelAdd;
|
|
1683
|
+
if (!(owner && repo)) return html(errorPage("bad path", ""), 400);
|
|
1684
|
+
const project = await getProject(owner, repo);
|
|
1685
|
+
if (!project) return html(errorPage("项目不存在", ""), 404);
|
|
1686
|
+
const back = `${url.origin}/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/settings/labels`;
|
|
1687
|
+
if (!(await canAdminProject(project.id, ctx.user))) {
|
|
1688
|
+
return Response.redirect(`${back}?err=${encodeURIComponent("无权限")}`, 303);
|
|
1689
|
+
}
|
|
1690
|
+
const form = await req.formData().catch(() => new FormData());
|
|
1691
|
+
try {
|
|
1692
|
+
await createLabel(project.id, {
|
|
1693
|
+
name: String(form.get("name") ?? ""),
|
|
1694
|
+
color: String(form.get("color") ?? "#888888"),
|
|
1695
|
+
description: String(form.get("description") ?? ""),
|
|
1696
|
+
exclusive: form.get("exclusive") === "1",
|
|
1697
|
+
});
|
|
1698
|
+
return Response.redirect(`${back}?ok=1&ok_msg=${encodeURIComponent("标签已创建")}`, 303);
|
|
1699
|
+
} catch (e) {
|
|
1700
|
+
return Response.redirect(`${back}?err=${encodeURIComponent(errMsg(e))}`, 303);
|
|
1701
|
+
}
|
|
1702
|
+
}
|
|
1703
|
+
|
|
1704
|
+
const labelAction = url.pathname.match(REPO_LABEL_ACTION_RE);
|
|
1705
|
+
if (labelAction) {
|
|
1706
|
+
const [, owner, repo, lidStr, action] = labelAction;
|
|
1707
|
+
if (!(owner && repo && lidStr && action)) return html(errorPage("bad path", ""), 400);
|
|
1708
|
+
const project = await getProject(owner, repo);
|
|
1709
|
+
if (!project) return html(errorPage("项目不存在", ""), 404);
|
|
1710
|
+
const back = `${url.origin}/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/settings/labels`;
|
|
1711
|
+
if (!(await canAdminProject(project.id, ctx.user))) {
|
|
1712
|
+
return Response.redirect(`${back}?err=${encodeURIComponent("无权限")}`, 303);
|
|
1713
|
+
}
|
|
1714
|
+
const lid = Number(lidStr);
|
|
1715
|
+
const form = await req.formData().catch(() => new FormData());
|
|
1716
|
+
try {
|
|
1717
|
+
if (action === "update") {
|
|
1718
|
+
await updateLabel(project.id, lid, {
|
|
1719
|
+
name: String(form.get("name") ?? ""),
|
|
1720
|
+
color: String(form.get("color") ?? "#888888"),
|
|
1721
|
+
description: String(form.get("description") ?? ""),
|
|
1722
|
+
exclusive: form.get("exclusive") === "1",
|
|
1723
|
+
});
|
|
1724
|
+
} else if (action === "archive" || action === "unarchive") {
|
|
1725
|
+
await archiveLabel(project.id, lid, action === "archive");
|
|
1726
|
+
} else if (action === "delete") {
|
|
1727
|
+
await deleteLabel(project.id, lid);
|
|
1728
|
+
}
|
|
1729
|
+
return Response.redirect(`${back}?ok=1`, 303);
|
|
1730
|
+
} catch (e) {
|
|
1731
|
+
return Response.redirect(`${back}?err=${encodeURIComponent(errMsg(e))}`, 303);
|
|
1732
|
+
}
|
|
1733
|
+
}
|
|
1734
|
+
|
|
1666
1735
|
return json({ error: "not found" }, 404);
|
|
1667
1736
|
}
|
|
1668
1737
|
|
|
@@ -1696,6 +1765,38 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
|
|
|
1696
1765
|
}
|
|
1697
1766
|
}
|
|
1698
1767
|
|
|
1768
|
+
const issueLabelsApi = url.pathname.match(API_ISSUE_LABELS_RE);
|
|
1769
|
+
if (issueLabelsApi) {
|
|
1770
|
+
const [, owner, repo, numStr] = issueLabelsApi;
|
|
1771
|
+
if (!(owner && repo && numStr)) return html(errorPage("404", "bad path"), 404);
|
|
1772
|
+
const project = await getProject(owner, repo);
|
|
1773
|
+
if (!project) return json({ error: "project not found" }, 404);
|
|
1774
|
+
const issue = await getIssueWithMeta(project.id, Number(numStr));
|
|
1775
|
+
if (!issue) return json({ error: "issue not found" }, 404);
|
|
1776
|
+
if (req.method === "GET") {
|
|
1777
|
+
const [current, available] = await Promise.all([
|
|
1778
|
+
listLabelsForIssue(issue.id),
|
|
1779
|
+
listLabels(project.id),
|
|
1780
|
+
]);
|
|
1781
|
+
return json({ current, available });
|
|
1782
|
+
}
|
|
1783
|
+
if (req.method === "POST") {
|
|
1784
|
+
if (!ctx.user || !(await canWriteProject(project.id, ctx.user))) {
|
|
1785
|
+
return json({ error: "forbidden: needs writer role on project" }, 403);
|
|
1786
|
+
}
|
|
1787
|
+
const body = await req.json().catch(() => ({}));
|
|
1788
|
+
const labelIds = Array.isArray(body.labelIds) ? body.labelIds.map((n: unknown) => Number(n)).filter((n: number) => Number.isInteger(n) && n > 0) : [];
|
|
1789
|
+
try {
|
|
1790
|
+
await setIssueLabels(issue.id, labelIds);
|
|
1791
|
+
const current = await listLabelsForIssue(issue.id);
|
|
1792
|
+
return json({ current });
|
|
1793
|
+
} catch (e) {
|
|
1794
|
+
return json({ error: errMsg(e) }, e instanceof StoreError ? e.status : 500);
|
|
1795
|
+
}
|
|
1796
|
+
}
|
|
1797
|
+
return json({ error: "method not allowed" }, 405);
|
|
1798
|
+
}
|
|
1799
|
+
|
|
1699
1800
|
const isNew = url.pathname.match(REPO_NEW_RE);
|
|
1700
1801
|
if (isNew) {
|
|
1701
1802
|
const [, owner, repo] = isNew;
|
|
@@ -1779,6 +1880,22 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
|
|
|
1779
1880
|
return html(buildProjectUpstreamsPage(ctx.user!, project, flash));
|
|
1780
1881
|
}
|
|
1781
1882
|
|
|
1883
|
+
const labelsPage = url.pathname.match(REPO_LABELS_RE);
|
|
1884
|
+
if (labelsPage) {
|
|
1885
|
+
const [, owner, repo] = labelsPage;
|
|
1886
|
+
if (!(owner && repo)) return html(errorPage("404", "bad path"), 404);
|
|
1887
|
+
const project = await getProject(owner, repo);
|
|
1888
|
+
if (!project) return html(errorPage("项目不存在", "项目未创建"), 404);
|
|
1889
|
+
if (ctx.user!.is_admin === 1) await ensureProjectBootstrapAdmin(project.id, ctx.user!.login);
|
|
1890
|
+
if (!(await canAdminProject(project.id, ctx.user))) {
|
|
1891
|
+
return html(errorPage("无权限", "需要该项目 admin 角色才能管理标签"), 403);
|
|
1892
|
+
}
|
|
1893
|
+
const flashKind = url.searchParams.get("ok") === "1" ? "ok" : url.searchParams.get("err") ? "err" : null;
|
|
1894
|
+
const flashMsg = flashKind === "ok" ? (url.searchParams.get("ok_msg") ?? "") : (url.searchParams.get("err") ?? "");
|
|
1895
|
+
const flash = flashKind ? { kind: flashKind as "ok" | "err", msg: flashMsg } : null;
|
|
1896
|
+
return html(await buildProjectLabelsPage(ctx.user!, project, flash));
|
|
1897
|
+
}
|
|
1898
|
+
|
|
1782
1899
|
const modelPage = url.pathname.match(REPO_MODEL_RE);
|
|
1783
1900
|
if (modelPage) {
|
|
1784
1901
|
const [, owner, repo] = modelPage;
|
package/src/render/layout.ts
CHANGED
|
@@ -14,6 +14,8 @@ export interface LayoutProps {
|
|
|
14
14
|
upstreamWebUrl?: string | null;
|
|
15
15
|
translateEnabled?: boolean;
|
|
16
16
|
ttsEnabled?: boolean;
|
|
17
|
+
labels?: { id: number; name: string; color: string }[];
|
|
18
|
+
canEditLabels?: boolean;
|
|
17
19
|
}
|
|
18
20
|
|
|
19
21
|
export const THEME_CSS = `
|
|
@@ -107,6 +109,20 @@ header.topbar .num{opacity:.7}
|
|
|
107
109
|
.desc-toggle{margin-top:.4rem;background:none;border:none;color:var(--accent);font-size:13px;cursor:pointer;padding:.2rem 0}
|
|
108
110
|
.upstream-link{font-size:12px;color:var(--accent);opacity:.85}
|
|
109
111
|
.upstream-link:hover{opacity:1;text-decoration:underline}
|
|
112
|
+
.issue-label{display:inline-flex;align-items:center;font-size:12px;font-weight:500;padding:.05rem .5rem;border:1px solid;border-radius:99px;line-height:1.6;background:color-mix(in srgb,currentColor 8%,transparent)}
|
|
113
|
+
.label-edit-btn{background:none;border:1px solid var(--border);border-radius:6px;padding:.05rem .35rem;cursor:pointer;font-size:13px;line-height:1.5;color:var(--text-muted)}
|
|
114
|
+
.label-edit-btn:hover{border-color:var(--accent);color:var(--accent)}
|
|
115
|
+
#labelDlg{border:1px solid var(--border);border-radius:10px;background:var(--bg-elev);color:var(--text);padding:1.1rem;max-width:380px;width:90vw}
|
|
116
|
+
#labelDlg::backdrop{background:rgba(0,0,0,.5)}
|
|
117
|
+
#labelDlg h3{margin:0 0 .6rem;font-size:14px}
|
|
118
|
+
.lp-list{max-height:300px;overflow-y:auto;display:flex;flex-direction:column;gap:.15rem}
|
|
119
|
+
.lp-item{display:flex;align-items:center;gap:.4rem;padding:.3rem .4rem;border-radius:6px;cursor:pointer;font-size:13px}
|
|
120
|
+
.lp-item:hover{background:var(--bg-muted)}
|
|
121
|
+
.lp-item input{margin:0}
|
|
122
|
+
.lp-dot{width:10px;height:10px;border-radius:50%;border:1px solid var(--border);flex-shrink:0}
|
|
123
|
+
.lp-name{flex:1;overflow-wrap:anywhere}
|
|
124
|
+
.lp-scope{font-size:10px;color:var(--text-muted)}
|
|
125
|
+
.lp-empty{color:var(--text-muted);font-size:12px;padding:.6rem 0;text-align:center}
|
|
110
126
|
`;
|
|
111
127
|
|
|
112
128
|
export function renderLayout(props: LayoutProps, inner: string, initialItems: string): string {
|
|
@@ -119,6 +135,12 @@ export function renderLayout(props: LayoutProps, inner: string, initialItems: st
|
|
|
119
135
|
const [repoOwner, repoName] = props.repoPath.split("/");
|
|
120
136
|
const repoIssuesHref = `/${encodeURIComponent(repoOwner ?? "")}/${encodeURIComponent(repoName ?? "")}/issues`;
|
|
121
137
|
const op = props.operatorLogin ?? "operator";
|
|
138
|
+
const labelsHtml = (props.labels ?? []).length
|
|
139
|
+
? props.labels!.map((l) => `<span class="issue-label" style="border-color:${escapeAttr(l.color)};color:${escapeAttr(l.color)}">${escapeHtml(l.name)}</span>`).join("")
|
|
140
|
+
: "";
|
|
141
|
+
const labelPickerBtn = props.canEditLabels
|
|
142
|
+
? `<button type="button" class="label-edit-btn" id="labelEditBtn" title="管理标签">🏷️</button>`
|
|
143
|
+
: "";
|
|
122
144
|
return `<!doctype html>
|
|
123
145
|
<html lang="zh">
|
|
124
146
|
<head>
|
|
@@ -140,6 +162,7 @@ export function renderLayout(props: LayoutProps, inner: string, initialItems: st
|
|
|
140
162
|
<h1>${escapeHtml(props.issueTitle)}</h1>
|
|
141
163
|
<div class="meta-status">
|
|
142
164
|
<span class="state-badge ${stateClass}">${stateLabel}</span>
|
|
165
|
+
${labelsHtml}${labelPickerBtn}
|
|
143
166
|
<span class="count" id="count">…</span>
|
|
144
167
|
${props.upstreamWebUrl ? `<a class="upstream-link" href="${escapeAttr(props.upstreamWebUrl)}" target="_blank" rel="noopener noreferrer" title="跳转到上游仓库">🔗 查看上游</a>` : ""}
|
|
145
168
|
</div>
|
|
@@ -168,6 +191,8 @@ ${props.writesEnabled !== false
|
|
|
168
191
|
<script type="application/json" id="initial-data">${inner}</script>
|
|
169
192
|
<script src="/static/tts.js?v=${BUILD_ID}" defer></script>
|
|
170
193
|
<script src="/static/app.js?v=${BUILD_ID}" defer></script>
|
|
194
|
+
${props.canEditLabels ? `<dialog id="labelDlg"><h3>标签</h3><div class="lp-list" id="lpList"></div><div class="lp-empty hidden" id="lpEmpty">该项目还没有标签。先到设置页创建。</div></dialog>
|
|
195
|
+
<script src="/static/label-picker.js?v=${BUILD_ID}" defer></script>` : ""}
|
|
171
196
|
</body>
|
|
172
197
|
</html>`;
|
|
173
198
|
}
|
package/src/schema-mysql.sql
CHANGED
|
@@ -74,10 +74,13 @@ CREATE INDEX comments_issue_created ON {{comments}} (issue_id, created_at);
|
|
|
74
74
|
CREATE INDEX comments_author ON {{comments}} (author);
|
|
75
75
|
|
|
76
76
|
CREATE TABLE IF NOT EXISTS {{labels}} (
|
|
77
|
-
id
|
|
78
|
-
project_id
|
|
79
|
-
name
|
|
80
|
-
color
|
|
77
|
+
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
|
78
|
+
project_id BIGINT NOT NULL,
|
|
79
|
+
name VARCHAR(255) NOT NULL,
|
|
80
|
+
color VARCHAR(16) NOT NULL DEFAULT '#888888',
|
|
81
|
+
description VARCHAR(255) NOT NULL DEFAULT '',
|
|
82
|
+
exclusive TINYINT NOT NULL DEFAULT 0,
|
|
83
|
+
is_archived TINYINT NOT NULL DEFAULT 0,
|
|
81
84
|
UNIQUE (project_id, name),
|
|
82
85
|
CONSTRAINT {{fk_labels_project}} FOREIGN KEY (project_id) REFERENCES {{projects}}(id) ON DELETE CASCADE
|
|
83
86
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
package/src/schema.sql
CHANGED
|
@@ -78,10 +78,13 @@ CREATE INDEX IF NOT EXISTS comments_issue_created
|
|
|
78
78
|
ON {{comments}} (issue_id, created_at);
|
|
79
79
|
|
|
80
80
|
CREATE TABLE IF NOT EXISTS {{labels}} (
|
|
81
|
-
id
|
|
82
|
-
project_id
|
|
83
|
-
name
|
|
84
|
-
color
|
|
81
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
82
|
+
project_id INTEGER NOT NULL REFERENCES {{projects}}(id) ON DELETE CASCADE,
|
|
83
|
+
name TEXT NOT NULL,
|
|
84
|
+
color TEXT NOT NULL DEFAULT '#888888',
|
|
85
|
+
description TEXT NOT NULL DEFAULT '',
|
|
86
|
+
exclusive INTEGER NOT NULL DEFAULT 0,
|
|
87
|
+
is_archived INTEGER NOT NULL DEFAULT 0,
|
|
85
88
|
UNIQUE (project_id, name)
|
|
86
89
|
);
|
|
87
90
|
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
const saveBtn = document.getElementById("strategy-save");
|
|
7
7
|
const addBindingBtn = document.getElementById("binding-add");
|
|
8
8
|
|
|
9
|
-
let currentStrategy = { strategy: "least-loaded", groupBindings: {}, daemonGroups: {} };
|
|
9
|
+
let currentStrategy = { strategy: "least-loaded", groupBindings: {}, daemonGroups: {}, groupConfigs: {} };
|
|
10
10
|
|
|
11
11
|
function showResult(msg, ok) {
|
|
12
12
|
result.textContent = msg;
|
|
@@ -14,6 +14,47 @@
|
|
|
14
14
|
setTimeout(() => { result.textContent = ""; result.className = "db-result"; }, 3000);
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
+
function esc(s) {
|
|
18
|
+
return String(s).replace(/[&<>"']/g, function (c) {
|
|
19
|
+
return { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c];
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function collectGroupNames() {
|
|
24
|
+
var names = new Set();
|
|
25
|
+
var dg = currentStrategy.daemonGroups || {};
|
|
26
|
+
Object.values(dg).forEach(function (arr) { (arr || []).forEach(function (g) { names.add(g); }); });
|
|
27
|
+
var gb = currentStrategy.groupBindings || {};
|
|
28
|
+
Object.values(gb).forEach(function (g) { names.add(g); });
|
|
29
|
+
Object.keys(currentStrategy.groupConfigs || {}).forEach(function (g) { names.add(g); });
|
|
30
|
+
return Array.from(names).sort();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function renderGroupConfigs() {
|
|
34
|
+
var container = document.getElementById("group-configs-list");
|
|
35
|
+
if (!container) return;
|
|
36
|
+
var names = collectGroupNames();
|
|
37
|
+
if (!names.length) {
|
|
38
|
+
container.innerHTML = '<p class="hint" style="margin:0">先添加分组(上方给 daemon 打组或绑定 repo→组)后这里会出现配置项。</p>';
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
container.innerHTML = names.map(function (g) {
|
|
42
|
+
var cfg = (currentStrategy.groupConfigs || {})[g] || {};
|
|
43
|
+
return '<details class="gc-card" style="border:1px solid var(--border);border-radius:8px;padding:.6rem .8rem;margin:.4rem 0">' +
|
|
44
|
+
'<summary style="cursor:pointer;font-weight:600;font-size:13px">' + esc(g) + '</summary>' +
|
|
45
|
+
'<div style="margin-top:.5rem">' +
|
|
46
|
+
'<label style="font-size:12px;color:var(--text-muted)">工作目录模板(变量: {owner} {repo} {issue} {session})</label>' +
|
|
47
|
+
'<input type="text" class="gc-workdir" data-group="' + esc(g) + '" value="' + esc(cfg.workdirTemplate || "") + '" placeholder="/data/work/{owner}/{repo}/{issue}" maxlength="512" style="width:100%;padding:.35rem .5rem;border:1px solid var(--border);border-radius:6px;background:var(--bg);color:var(--text);font-size:13px;margin-bottom:.5rem;font-family:ui-monospace,monospace">' +
|
|
48
|
+
'<label style="font-size:12px;color:var(--text-muted)">Init 脚本(投递时跑,cwd=workdir,env 有 $EWORK_OWNER/$EWORK_REPO/$EWORK_ISSUE/$EWORK_WORKDIR)</label>' +
|
|
49
|
+
'<textarea class="gc-init" data-group="' + esc(g) + '" placeholder="git clone https://gitea.example.com/$EWORK_OWNER/$EWORK_REPO.git ." rows="2" maxlength="4096" style="width:100%;padding:.35rem .5rem;border:1px solid var(--border);border-radius:6px;background:var(--bg);color:var(--text);font-size:12px;margin-bottom:.5rem;font-family:ui-monospace,monospace;resize:vertical">' + esc(cfg.initScript || "") + '</textarea>' +
|
|
50
|
+
'<label style="font-size:12px;color:var(--text-muted)">Destroy 脚本(关闭时跑,cwd=workdir)</label>' +
|
|
51
|
+
'<textarea class="gc-destroy" data-group="' + esc(g) + '" rows="2" maxlength="4096" style="width:100%;padding:.35rem .5rem;border:1px solid var(--border);border-radius:6px;background:var(--bg);color:var(--text);font-size:12px;margin-bottom:.5rem;font-family:ui-monospace,monospace;resize:vertical">' + esc(cfg.destroyScript || "") + '</textarea>' +
|
|
52
|
+
'<label style="font-size:12px;color:var(--text-muted)">Env-Init 脚本(机器级,预留)</label>' +
|
|
53
|
+
'<textarea class="gc-envinit" data-group="' + esc(g) + '" rows="1" disabled maxlength="4096" placeholder="预留,暂不执行" style="width:100%;padding:.35rem .5rem;border:1px solid var(--border);border-radius:6px;background:var(--bg-muted);color:var(--text-muted);font-size:12px;resize:vertical">' + esc(cfg.envInitScript || "") + '</textarea>' +
|
|
54
|
+
'</div></details>';
|
|
55
|
+
}).join("");
|
|
56
|
+
}
|
|
57
|
+
|
|
17
58
|
async function load() {
|
|
18
59
|
try {
|
|
19
60
|
const [daemonsRes, strategyRes] = await Promise.all([
|
|
@@ -35,6 +76,7 @@
|
|
|
35
76
|
}).join("") || '<tr><td colspan="4" class="daemon-empty">没有已注册的 daemon(节点启动后自动出现)</td></tr>';
|
|
36
77
|
|
|
37
78
|
renderBindings();
|
|
79
|
+
renderGroupConfigs();
|
|
38
80
|
} catch (e) {
|
|
39
81
|
tbody.innerHTML = '<tr><td colspan="4" class="daemon-empty">无法连接 router — 确认 router 已启动</td></tr>';
|
|
40
82
|
}
|
|
@@ -67,6 +109,7 @@
|
|
|
67
109
|
document.getElementById("binding-repo").value = "";
|
|
68
110
|
document.getElementById("binding-group").value = "";
|
|
69
111
|
renderBindings();
|
|
112
|
+
renderGroupConfigs();
|
|
70
113
|
};
|
|
71
114
|
|
|
72
115
|
saveBtn.onclick = async () => {
|
|
@@ -79,6 +122,24 @@
|
|
|
79
122
|
});
|
|
80
123
|
currentStrategy.daemonGroups = newGroups;
|
|
81
124
|
|
|
125
|
+
var newConfigs = {};
|
|
126
|
+
document.querySelectorAll("details.gc-card").forEach(function (card) {
|
|
127
|
+
var g = card.querySelector("summary").textContent.trim();
|
|
128
|
+
var workdir = (card.querySelector(".gc-workdir") || {}).value || "";
|
|
129
|
+
var init = (card.querySelector(".gc-init") || {}).value || "";
|
|
130
|
+
var destroy = (card.querySelector(".gc-destroy") || {}).value || "";
|
|
131
|
+
var envinit = (card.querySelector(".gc-envinit") || {}).value || "";
|
|
132
|
+
if (workdir.trim() || init.trim() || destroy.trim() || envinit.trim()) {
|
|
133
|
+
newConfigs[g] = {
|
|
134
|
+
workdirTemplate: workdir.trim() || undefined,
|
|
135
|
+
initScript: init.trim() || undefined,
|
|
136
|
+
destroyScript: destroy.trim() || undefined,
|
|
137
|
+
envInitScript: envinit.trim() || undefined,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
currentStrategy.groupConfigs = newConfigs;
|
|
142
|
+
|
|
82
143
|
try {
|
|
83
144
|
const res = await fetch("/api/router/strategy", {
|
|
84
145
|
method: "POST",
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
(function () {
|
|
2
|
+
var btn = document.getElementById("labelEditBtn");
|
|
3
|
+
if (!btn) return;
|
|
4
|
+
var dlg = document.getElementById("labelDlg");
|
|
5
|
+
var listEl = document.getElementById("lpList");
|
|
6
|
+
var emptyEl = document.getElementById("lpEmpty");
|
|
7
|
+
if (!dlg || !listEl) return;
|
|
8
|
+
|
|
9
|
+
var path = window.location.pathname.replace(/\/+$/, "");
|
|
10
|
+
var m = path.match(/^\/(.+)\/(.+)\/issues\/(\d+)$/);
|
|
11
|
+
if (!m) return;
|
|
12
|
+
var apiBase = "/api/" + encodeURIComponent(m[1]) + "/" + encodeURIComponent(m[2]) + "/issues/" + m[3] + "/labels";
|
|
13
|
+
|
|
14
|
+
function scopeOf(name) {
|
|
15
|
+
var i = name.indexOf("/");
|
|
16
|
+
return i > 0 ? name.slice(0, i) : "";
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
var state = { available: [], current: [] };
|
|
20
|
+
|
|
21
|
+
function render() {
|
|
22
|
+
if (!state.available.length) {
|
|
23
|
+
listEl.classList.add("hidden");
|
|
24
|
+
emptyEl.classList.remove("hidden");
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
listEl.classList.remove("hidden");
|
|
28
|
+
emptyEl.classList.add("hidden");
|
|
29
|
+
var currentIds = new Set(state.current.map(function (l) { return l.id; }));
|
|
30
|
+
listEl.innerHTML = state.available.map(function (l) {
|
|
31
|
+
var checked = currentIds.has(l.id);
|
|
32
|
+
var sc = scopeOf(l.name);
|
|
33
|
+
var scopeHint = l.exclusive === 1 && sc ? '<span class="lp-scope">互斥 · ' + esc(sc) + "</span>" : "";
|
|
34
|
+
return '<label class="lp-item">' +
|
|
35
|
+
'<input type="checkbox" data-id="' + l.id + '"' + (l.exclusive === 1 ? ' data-exclusive="1"' : "") +
|
|
36
|
+
(sc ? ' data-scope="' + esc(sc) + '"' : "") +
|
|
37
|
+
(checked ? " checked" : "") + ">" +
|
|
38
|
+
'<span class="lp-dot" style="background:' + esc(l.color) + '"></span>' +
|
|
39
|
+
'<span class="lp-name">' + esc(l.name) + "</span>" + scopeHint +
|
|
40
|
+
"</label>";
|
|
41
|
+
}).join("");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function renderChips() {
|
|
45
|
+
var row = document.querySelector(".meta-status");
|
|
46
|
+
if (!row) return;
|
|
47
|
+
var existing = row.querySelectorAll(".issue-label");
|
|
48
|
+
existing.forEach(function (e) { e.remove(); });
|
|
49
|
+
var badge = row.querySelector(".state-badge");
|
|
50
|
+
var html = state.current.map(function (l) {
|
|
51
|
+
return '<span class="issue-label" style="border-color:' + esc(l.color) + ";color:" + esc(l.color) + '">' + esc(l.name) + "</span>";
|
|
52
|
+
}).join("");
|
|
53
|
+
badge.insertAdjacentHTML("afterend", html);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function esc(s) {
|
|
57
|
+
return String(s).replace(/[&<>"']/g, function (c) {
|
|
58
|
+
return { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c];
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function load() {
|
|
63
|
+
try {
|
|
64
|
+
var res = await fetch(apiBase);
|
|
65
|
+
if (!res.ok) return;
|
|
66
|
+
var data = await res.json();
|
|
67
|
+
state.available = data.available || [];
|
|
68
|
+
state.current = data.current || [];
|
|
69
|
+
render();
|
|
70
|
+
} catch (e) {}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function save(labelIds) {
|
|
74
|
+
try {
|
|
75
|
+
var res = await fetch(apiBase, {
|
|
76
|
+
method: "POST",
|
|
77
|
+
headers: { "Content-Type": "application/json" },
|
|
78
|
+
body: JSON.stringify({ labelIds: labelIds }),
|
|
79
|
+
});
|
|
80
|
+
if (!res.ok) return;
|
|
81
|
+
var data = await res.json();
|
|
82
|
+
state.current = data.current || [];
|
|
83
|
+
renderChips();
|
|
84
|
+
} catch (e) {}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
btn.addEventListener("click", async function () {
|
|
88
|
+
await load();
|
|
89
|
+
dlg.showModal();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
listEl.addEventListener("change", function (e) {
|
|
93
|
+
var cb = e.target;
|
|
94
|
+
if (cb.tagName !== "INPUT" || cb.type !== "checkbox") return;
|
|
95
|
+
if (cb.checked && cb.dataset.exclusive === "1" && cb.dataset.scope) {
|
|
96
|
+
var scope = cb.dataset.scope;
|
|
97
|
+
listEl.querySelectorAll('input[data-exclusive="1"]').forEach(function (other) {
|
|
98
|
+
if (other !== cb && other.dataset.scope === scope) other.checked = false;
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
var ids = Array.from(listEl.querySelectorAll("input:checked")).map(function (c) {
|
|
102
|
+
return Number(c.dataset.id);
|
|
103
|
+
});
|
|
104
|
+
save(ids);
|
|
105
|
+
});
|
|
106
|
+
})();
|
package/src/store.ts
CHANGED
|
@@ -79,6 +79,17 @@ export interface LabelRow {
|
|
|
79
79
|
project_id: number;
|
|
80
80
|
name: string;
|
|
81
81
|
color: string;
|
|
82
|
+
description: string;
|
|
83
|
+
exclusive: number;
|
|
84
|
+
is_archived: number;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** For an exclusive label named "scope/name", the scope is "scope". A label
|
|
88
|
+
* with no "/" has no scope and is never exclusive. Issues may carry at most
|
|
89
|
+
* one label per scope (Gitea semantics). */
|
|
90
|
+
export function labelScope(name: string): string {
|
|
91
|
+
const i = name.indexOf("/");
|
|
92
|
+
return i > 0 ? name.slice(0, i) : "";
|
|
82
93
|
}
|
|
83
94
|
|
|
84
95
|
export interface AttachmentRow {
|
|
@@ -595,8 +606,12 @@ export async function removeReaction(commentId: number, userLogin: string, conte
|
|
|
595
606
|
);
|
|
596
607
|
}
|
|
597
608
|
|
|
598
|
-
|
|
599
|
-
|
|
609
|
+
const LABEL_COLOR_RE = /^#[0-9a-fA-F]{6}$/;
|
|
610
|
+
const LABEL_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9 _\-./]{0,62}$/;
|
|
611
|
+
|
|
612
|
+
export async function listLabels(projectId: number, includeArchived = false): Promise<LabelRow[]> {
|
|
613
|
+
const where = includeArchived ? "project_id = ?" : "project_id = ? AND is_archived = 0";
|
|
614
|
+
return await getDB().all<LabelRow>(`SELECT * FROM {{labels}} WHERE ${where} ORDER BY name`, [projectId]);
|
|
600
615
|
}
|
|
601
616
|
|
|
602
617
|
export async function listLabelsForIssue(issueId: number): Promise<LabelRow[]> {
|
|
@@ -608,19 +623,98 @@ export async function listLabelsForIssue(issueId: number): Promise<LabelRow[]> {
|
|
|
608
623
|
);
|
|
609
624
|
}
|
|
610
625
|
|
|
611
|
-
export async function
|
|
612
|
-
|
|
626
|
+
export async function getLabel(projectId: number, id: number): Promise<LabelRow | null> {
|
|
627
|
+
return (await getDB().get<LabelRow>("SELECT * FROM {{labels}} WHERE project_id = ? AND id = ?", [projectId, id])) ?? null;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
interface LabelInput {
|
|
631
|
+
name: string;
|
|
632
|
+
color: string;
|
|
633
|
+
description?: string;
|
|
634
|
+
exclusive?: boolean;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
function validateLabel(t: LabelInput): { name: string; color: string; description: string; exclusive: number } {
|
|
638
|
+
const name = t.name.trim();
|
|
613
639
|
if (!name) throw new StoreError(400, "标签名不能为空");
|
|
614
|
-
if (
|
|
615
|
-
|
|
640
|
+
if (!LABEL_NAME_RE.test(name)) throw new StoreError(400, "标签名仅允许字母数字、空格、_ - . /,且不能以 / 开头");
|
|
641
|
+
if (!LABEL_COLOR_RE.test(t.color)) throw new StoreError(400, "颜色须为 #RRGGBB");
|
|
642
|
+
return { name, color: t.color, description: (t.description ?? "").trim(), exclusive: t.exclusive ? 1 : 0 };
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
export async function createLabel(projectId: number, input: LabelInput): Promise<LabelRow> {
|
|
646
|
+
const v = validateLabel(input);
|
|
647
|
+
const info = await getDB().run(
|
|
648
|
+
"INSERT INTO {{labels}} (project_id, name, color, description, exclusive) VALUES (?, ?, ?, ?, ?)",
|
|
649
|
+
[projectId, v.name, v.color, v.description, v.exclusive]
|
|
650
|
+
);
|
|
616
651
|
return (await getDB().get<LabelRow>("SELECT * FROM {{labels}} WHERE id = ?", [info.insertId]))!;
|
|
617
652
|
}
|
|
618
653
|
|
|
654
|
+
export async function updateLabel(projectId: number, id: number, input: Partial<LabelInput>): Promise<LabelRow> {
|
|
655
|
+
const existing = await getLabel(projectId, id);
|
|
656
|
+
if (!existing) throw new StoreError(404, "标签不存在");
|
|
657
|
+
const merged: LabelInput = {
|
|
658
|
+
name: input.name ?? existing.name,
|
|
659
|
+
color: input.color ?? existing.color,
|
|
660
|
+
description: input.description ?? existing.description,
|
|
661
|
+
exclusive: input.exclusive ?? (existing.exclusive === 1),
|
|
662
|
+
};
|
|
663
|
+
const v = validateLabel(merged);
|
|
664
|
+
await getDB().run(
|
|
665
|
+
"UPDATE {{labels}} SET name = ?, color = ?, description = ?, exclusive = ? WHERE project_id = ? AND id = ?",
|
|
666
|
+
[v.name, v.color, v.description, v.exclusive, projectId, id]
|
|
667
|
+
);
|
|
668
|
+
return (await getDB().get<LabelRow>("SELECT * FROM {{labels}} WHERE id = ?", [id]))!;
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
export async function archiveLabel(projectId: number, id: number, archived: boolean): Promise<LabelRow> {
|
|
672
|
+
const existing = await getLabel(projectId, id);
|
|
673
|
+
if (!existing) throw new StoreError(404, "标签不存在");
|
|
674
|
+
await getDB().run("UPDATE {{labels}} SET is_archived = ? WHERE project_id = ? AND id = ?", [archived ? 1 : 0, projectId, id]);
|
|
675
|
+
return (await getDB().get<LabelRow>("SELECT * FROM {{labels}} WHERE id = ?", [id]))!;
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
export async function deleteLabel(projectId: number, id: number): Promise<void> {
|
|
679
|
+
await getDB().run("DELETE FROM {{labels}} WHERE project_id = ? AND id = ?", [projectId, id]);
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
/** Attach (`on=true`) or detach a label. When attaching an exclusive label
|
|
683
|
+
* (scope/name with exclusive=1), any other already-attached label sharing the
|
|
684
|
+
* same scope is removed first, so an issue holds at most one label per scope. */
|
|
619
685
|
export async function setIssueLabel(issueId: number, labelId: number, on: boolean): Promise<void> {
|
|
620
|
-
if (on) {
|
|
621
|
-
await getDB().run("INSERT OR IGNORE INTO {{issue_labels}} (issue_id, label_id) VALUES (?, ?)", [issueId, labelId]);
|
|
622
|
-
} else {
|
|
686
|
+
if (!on) {
|
|
623
687
|
await getDB().run("DELETE FROM {{issue_labels}} WHERE issue_id = ? AND label_id = ?", [issueId, labelId]);
|
|
688
|
+
return;
|
|
689
|
+
}
|
|
690
|
+
const label = await getDB().get<LabelRow>("SELECT * FROM {{labels}} WHERE id = ?", [labelId]);
|
|
691
|
+
if (!label) throw new StoreError(404, "标签不存在");
|
|
692
|
+
if (label.exclusive === 1) {
|
|
693
|
+
const scope = labelScope(label.name);
|
|
694
|
+
if (scope) {
|
|
695
|
+
const current = await listLabelsForIssue(issueId);
|
|
696
|
+
const siblings = current.filter((l) => l.exclusive === 1 && labelScope(l.name) === scope && l.id !== labelId);
|
|
697
|
+
for (const s of siblings) {
|
|
698
|
+
await getDB().run("DELETE FROM {{issue_labels}} WHERE issue_id = ? AND label_id = ?", [issueId, s.id]);
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
const dialect = getDB().dialect;
|
|
703
|
+
const insert = dialect === "sqlite"
|
|
704
|
+
? "INSERT OR IGNORE INTO {{issue_labels}} (issue_id, label_id) VALUES (?, ?)"
|
|
705
|
+
: "INSERT IGNORE INTO {{issue_labels}} (issue_id, label_id) VALUES (?, ?)";
|
|
706
|
+
await getDB().run(insert, [issueId, labelId]);
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
export async function setIssueLabels(issueId: number, labelIds: number[]): Promise<void> {
|
|
710
|
+
const dialect = getDB().dialect;
|
|
711
|
+
await getDB().run("DELETE FROM {{issue_labels}} WHERE issue_id = ?", [issueId]);
|
|
712
|
+
const ids = Array.from(new Set(labelIds));
|
|
713
|
+
const insert = dialect === "sqlite"
|
|
714
|
+
? "INSERT OR IGNORE INTO {{issue_labels}} (issue_id, label_id) VALUES (?, ?)"
|
|
715
|
+
: "INSERT IGNORE INTO {{issue_labels}} (issue_id, label_id) VALUES (?, ?)";
|
|
716
|
+
for (const id of ids) {
|
|
717
|
+
await getDB().run(insert, [issueId, id]);
|
|
624
718
|
}
|
|
625
719
|
}
|
|
626
720
|
|
package/src/views/issueThread.ts
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
listCommentsPage,
|
|
12
12
|
listCommentsSince,
|
|
13
13
|
getDefaultUpstreamUrl,
|
|
14
|
+
listLabelsForIssue,
|
|
14
15
|
type CommentRow,
|
|
15
16
|
type IssueWithMeta,
|
|
16
17
|
} from "../store";
|
|
@@ -110,6 +111,7 @@ export async function buildIssueThread(
|
|
|
110
111
|
if (!clone) return null;
|
|
111
112
|
return webUrlFromClone(clone);
|
|
112
113
|
})();
|
|
114
|
+
const labels = await listLabelsForIssue(issue.id);
|
|
113
115
|
|
|
114
116
|
const html = renderLayout(
|
|
115
117
|
{
|
|
@@ -126,6 +128,8 @@ export async function buildIssueThread(
|
|
|
126
128
|
upstreamWebUrl,
|
|
127
129
|
translateEnabled: !!cfg.translateUrl && cfg.translateUrl.trim() !== "",
|
|
128
130
|
ttsEnabled: cfg.ttsBackends.some((b) => b.url && b.url.trim() !== ""),
|
|
131
|
+
labels: labels.map((l) => ({ id: l.id, name: l.name, color: l.color })),
|
|
132
|
+
canEditLabels: cfg.writesEnabled !== false,
|
|
129
133
|
},
|
|
130
134
|
safeJsonEmbed(payload),
|
|
131
135
|
displayViews.map((v) => renderCommentCard(v, cfg)).join("")
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { THEME_CSS, escapeHtml, escapeAttr } from "../render/layout";
|
|
2
|
+
import { projectSettingsTabsHTML } from "./projectUpstreams";
|
|
3
|
+
import { listLabels, type ProjectRow, type UserRow, type LabelRow } from "../store";
|
|
4
|
+
|
|
5
|
+
interface Flash {
|
|
6
|
+
kind: "ok" | "err";
|
|
7
|
+
msg: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const PALETTE = [
|
|
11
|
+
"#888888", "#e11d48", "#db2777", "#9333ea", "#4f46e5",
|
|
12
|
+
"#2563eb", "#0891b2", "#0d9488", "#16a34a", "#ca8a04",
|
|
13
|
+
"#d97706", "#92400e",
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
function colorDot(c: string): string {
|
|
17
|
+
return `<span class="dot" style="background:${escapeAttr(c)}"></span>`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function labelRowHtml(l: LabelRow, owner: string, repo: string): string {
|
|
21
|
+
const base = `/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/settings/labels/${l.id}`;
|
|
22
|
+
const exclusiveBadge = l.exclusive === 1 ? `<span class="tag" title="互斥标签(scope/name)">互斥</span>` : "";
|
|
23
|
+
const archivedBadge = l.is_archived === 1 ? `<span class="tag arch">已归档</span>` : "";
|
|
24
|
+
return `<tr>
|
|
25
|
+
<td class="nm">${colorDot(l.color)}${escapeHtml(l.name)} ${exclusiveBadge}${archivedBadge}</td>
|
|
26
|
+
<td class="desc">${escapeHtml(l.description || "—")}</td>
|
|
27
|
+
<td class="act">
|
|
28
|
+
<form method="POST" action="${escapeAttr(base)}/update" class="inline" data-edit="${l.id}">
|
|
29
|
+
<input type="hidden" name="name" value="${escapeAttr(l.name)}">
|
|
30
|
+
<input type="hidden" name="color" value="${escapeAttr(l.color)}">
|
|
31
|
+
<input type="hidden" name="description" value="${escapeAttr(l.description)}">
|
|
32
|
+
<input type="hidden" name="exclusive" value="${l.exclusive}">
|
|
33
|
+
<button type="button" class="lnk" data-edit-btn="${l.id}">编辑</button>
|
|
34
|
+
</form>
|
|
35
|
+
<form method="POST" action="${escapeAttr(base)}/${l.is_archived === 1 ? "unarchive" : "archive"}" class="inline">
|
|
36
|
+
<button type="submit" class="lnk">${l.is_archived === 1 ? "恢复" : "归档"}</button>
|
|
37
|
+
</form>
|
|
38
|
+
<form method="POST" action="${escapeAttr(base)}/delete" class="inline" onsubmit="return confirm('删除标签「${escapeAttr(l.name)}」?会从所有 issue 上移除。')">
|
|
39
|
+
<button type="submit" class="lnk danger">删除</button>
|
|
40
|
+
</form>
|
|
41
|
+
</td>
|
|
42
|
+
</tr>`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function buildProjectLabelsPage(
|
|
46
|
+
_viewer: UserRow,
|
|
47
|
+
project: ProjectRow,
|
|
48
|
+
flash: Flash | null,
|
|
49
|
+
): Promise<string> {
|
|
50
|
+
const labels = await listLabels(project.id, true);
|
|
51
|
+
const rowsHtml = labels.length
|
|
52
|
+
? `<table>
|
|
53
|
+
<thead><tr><th>标签</th><th>描述</th><th>操作</th></tr></thead>
|
|
54
|
+
<tbody>${labels.map((l) => labelRowHtml(l, project.owner, project.name)).join("")}</tbody>
|
|
55
|
+
</table>`
|
|
56
|
+
: `<div class="hint">该项目还没有标签。在下方创建。</div>`;
|
|
57
|
+
|
|
58
|
+
const flashHtml = flash ? `<div class="flash ${flash.kind}">${escapeHtml(flash.msg)}</div>` : "";
|
|
59
|
+
const createAction = `/${encodeURIComponent(project.owner)}/${encodeURIComponent(project.name)}/settings/labels/add`;
|
|
60
|
+
const paletteHtml = PALETTE.map((c) => `<button type="button" class="swatch" data-color="${c}" style="background:${c}" aria-label="${c}"></button>`).join("");
|
|
61
|
+
const labelsJson = JSON.stringify(labels.map((l) => ({ id: l.id, name: l.name, color: l.color, description: l.description, exclusive: l.exclusive, is_archived: l.is_archived })));
|
|
62
|
+
|
|
63
|
+
return `<!doctype html>
|
|
64
|
+
<html lang="zh"><head><meta charset="utf-8">
|
|
65
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
66
|
+
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
|
67
|
+
<title>标签 · ${escapeHtml(project.owner)}/${escapeHtml(project.name)}</title>
|
|
68
|
+
<style>${THEME_CSS}
|
|
69
|
+
.flash{padding:.5rem .7rem;border-radius:6px;font-size:13px;margin-bottom:.8rem}
|
|
70
|
+
.flash.ok{background:color-mix(in srgb,var(--green) 18%,transparent);color:var(--green)}
|
|
71
|
+
.flash.err{background:color-mix(in srgb,#f85149 18%,transparent);color:#f85149}
|
|
72
|
+
.wrap{max-width:920px;margin:0 auto;padding:1rem}
|
|
73
|
+
h1{font-size:18px;margin:0 0 .4rem}
|
|
74
|
+
h1 a{color:var(--text)}
|
|
75
|
+
.subtabs{display:flex;gap:.4rem;padding:.4rem 0 0;border-bottom:1px solid var(--border);margin-bottom:.9rem}
|
|
76
|
+
.subtab{padding:.35rem .8rem;border-radius:6px 6px 0 0;font-size:13px;color:var(--text-muted)}
|
|
77
|
+
.subtab.active{background:var(--bg-muted);color:var(--text);font-weight:600}
|
|
78
|
+
.card{background:var(--bg-elev);border:1px solid var(--border);border-radius:10px;padding:.9rem 1rem;margin-bottom:.9rem}
|
|
79
|
+
.card h2{font-size:14px;margin:0 0 .7rem;font-weight:600}
|
|
80
|
+
table{width:100%;border-collapse:collapse;font-size:13px}
|
|
81
|
+
th,td{text-align:left;padding:.55rem .45rem;border-bottom:1px solid var(--border);vertical-align:middle}
|
|
82
|
+
th{color:var(--text-muted);font-weight:600;font-size:12px}
|
|
83
|
+
td.nm{font-weight:500}
|
|
84
|
+
td.desc{color:var(--text-muted);font-size:12px}
|
|
85
|
+
td.act{white-space:nowrap;text-align:right}
|
|
86
|
+
.dot{display:inline-block;width:12px;height:12px;border-radius:50%;vertical-align:middle;margin-right:.4rem;border:1px solid var(--border)}
|
|
87
|
+
.chip{display:inline-block;padding:.1rem .5rem;border:1px solid;border-radius:99px;font-size:12px;font-weight:500}
|
|
88
|
+
.tag{display:inline-block;margin-left:.3rem;padding:.05rem .35rem;border-radius:4px;font-size:10px;font-weight:600;background:color-mix(in srgb,var(--accent) 16%,transparent);color:var(--accent)}
|
|
89
|
+
.tag.arch{background:color-mix(in srgb,var(--text-muted) 16%,transparent);color:var(--text-muted)}
|
|
90
|
+
.hint{color:var(--text-muted);font-size:12px;line-height:1.5;margin:.4rem 0}
|
|
91
|
+
label{display:block;font-size:12px;color:var(--text-muted);margin:0 0 .25rem}
|
|
92
|
+
input[type=text],input[type=color]{width:100%;box-sizing:border-box;padding:.5rem .65rem;border:1px solid var(--border);border-radius:6px;background:var(--bg);color:var(--text);font:inherit;font-size:13px;margin-bottom:.7rem}
|
|
93
|
+
input[type=color]{height:38px;padding:3px;cursor:pointer}
|
|
94
|
+
.row{display:flex;gap:.7rem;align-items:flex-start}
|
|
95
|
+
.row>div{flex:1}
|
|
96
|
+
.swatches{display:flex;flex-wrap:wrap;gap:.3rem;margin:.2rem 0 .7rem}
|
|
97
|
+
.swatch{width:22px;height:22px;border-radius:50%;border:2px solid transparent;cursor:pointer;padding:0}
|
|
98
|
+
.swatch.sel{border-color:var(--text);box-shadow:0 0 0 2px var(--bg)}
|
|
99
|
+
.check{display:flex;align-items:center;gap:.4rem;font-size:13px;color:var(--text);margin-bottom:.7rem;cursor:pointer}
|
|
100
|
+
.check input{margin:0}
|
|
101
|
+
button.primary{padding:.5rem 1rem;border:0;border-radius:6px;background:var(--accent);color:#fff;font-size:13px;cursor:pointer}
|
|
102
|
+
.lnk{background:none;border:0;color:var(--accent);font-size:12px;cursor:pointer;padding:.15rem .25rem;text-decoration:underline}
|
|
103
|
+
.lnk.danger{color:#f85149}
|
|
104
|
+
form.inline{display:inline}
|
|
105
|
+
dialog{border:1px solid var(--border);border-radius:10px;background:var(--bg-elev);color:var(--text);padding:1.2rem;max-width:420px}
|
|
106
|
+
dialog::backdrop{background:rgba(0,0,0,.5)}
|
|
107
|
+
dialog h3{margin:0 0 .7rem;font-size:15px}
|
|
108
|
+
dialog .swatches{margin:.4rem 0}
|
|
109
|
+
dialog .row2{display:flex;gap:.6rem}
|
|
110
|
+
dialog .row2>div{flex:1}
|
|
111
|
+
</style></head><body>
|
|
112
|
+
<header class="topbar"><span style="font-weight:600">🏷️ ${escapeHtml(project.owner)}/${escapeHtml(project.name)} · 标签</span></header>
|
|
113
|
+
<main class="wrap">
|
|
114
|
+
<h1><a href="/${escapeAttr(project.owner)}/${escapeAttr(project.name)}/issues">${escapeHtml(project.owner)}/${escapeHtml(project.name)}</a> · 标签</h1>
|
|
115
|
+
${projectSettingsTabsHTML(project.owner, project.name, "labels")}
|
|
116
|
+
${flashHtml}
|
|
117
|
+
|
|
118
|
+
<div class="card">
|
|
119
|
+
<h2>已有标签(${labels.length})</h2>
|
|
120
|
+
${rowsHtml}
|
|
121
|
+
</div>
|
|
122
|
+
|
|
123
|
+
<form class="card" method="POST" action="${escapeAttr(createAction)}">
|
|
124
|
+
<h2>新建标签</h2>
|
|
125
|
+
<div class="row">
|
|
126
|
+
<div>
|
|
127
|
+
<label for="f-name">名称(支持 <code>scope/name</code> 互斥语法)</label>
|
|
128
|
+
<input id="f-name" name="name" type="text" required maxlength="63" placeholder="如 bug / priority/high">
|
|
129
|
+
</div>
|
|
130
|
+
<div style="flex:0 0 80px">
|
|
131
|
+
<label for="f-color">颜色</label>
|
|
132
|
+
<input id="f-color" name="color" type="color" value="#888888">
|
|
133
|
+
</div>
|
|
134
|
+
</div>
|
|
135
|
+
<div class="swatches" id="palette">${paletteHtml}</div>
|
|
136
|
+
<label for="f-desc">描述(可选)</label>
|
|
137
|
+
<input id="f-desc" name="description" type="text" maxlength="255" placeholder="简短说明">
|
|
138
|
+
<label class="check"><input type="checkbox" name="exclusive" value="1"> 互斥标签(同 scope 下只能选一个)</label>
|
|
139
|
+
<div class="hint">名称含 <code>/</code> 时,<code>/</code> 前的部分为 scope。同一 scope 下的互斥标签,一个 issue 只能贴一个。</div>
|
|
140
|
+
<button class="primary" type="submit">创建标签</button>
|
|
141
|
+
</form>
|
|
142
|
+
</main>
|
|
143
|
+
|
|
144
|
+
<dialog id="dlg">
|
|
145
|
+
<h3>编辑标签</h3>
|
|
146
|
+
<form method="POST" id="edit-form">
|
|
147
|
+
<div class="row2">
|
|
148
|
+
<div><label>名称</label><input name="name" type="text" required maxlength="63"></div>
|
|
149
|
+
<div style="flex:0 0 80px"><label>颜色</label><input name="color" type="color"></div>
|
|
150
|
+
</div>
|
|
151
|
+
<div class="swatches" id="dlg-palette"></div>
|
|
152
|
+
<label>描述</label><input name="description" type="text" maxlength="255">
|
|
153
|
+
<label class="check"><input type="checkbox" name="exclusive" value="1"> 互斥</label>
|
|
154
|
+
<div style="display:flex;gap:.5rem;justify-content:flex-end;margin-top:.8rem">
|
|
155
|
+
<button type="button" id="dlg-cancel">取消</button>
|
|
156
|
+
<button type="submit" class="primary">保存</button>
|
|
157
|
+
</div>
|
|
158
|
+
</form>
|
|
159
|
+
</dialog>
|
|
160
|
+
<script>
|
|
161
|
+
const LABELS = ${labelsJson};
|
|
162
|
+
const palette = ${JSON.stringify(PALETTE)};
|
|
163
|
+
function bindSwatches(container, colorInput){
|
|
164
|
+
container.innerHTML = palette.map(c => '<button type="button" class="swatch" data-color="'+c+'" style="background:'+c+'" aria-label="'+c+'"></button>').join('');
|
|
165
|
+
const sync = ()=>{ container.querySelectorAll('.swatch').forEach(b=>b.classList.toggle('sel', b.dataset.color.toLowerCase()===colorInput.value.toLowerCase())); };
|
|
166
|
+
sync();
|
|
167
|
+
container.addEventListener('click', e=>{ const b=e.target.closest('.swatch'); if(!b)return; colorInput.value=b.dataset.color; sync(); });
|
|
168
|
+
colorInput.addEventListener('input', sync);
|
|
169
|
+
}
|
|
170
|
+
bindSwatches(document.getElementById('palette'), document.getElementById('f-color'));
|
|
171
|
+
const dlg=document.getElementById('dlg'), ef=document.getElementById('edit-form'), dp=document.getElementById('dlg-palette');
|
|
172
|
+
let dlgColor;
|
|
173
|
+
document.addEventListener('click', e=>{
|
|
174
|
+
const btn=e.target.closest('[data-edit-btn]');
|
|
175
|
+
if(!btn)return;
|
|
176
|
+
const id=Number(btn.dataset.editBtn);
|
|
177
|
+
const l=LABELS.find(x=>x.id===id); if(!l)return;
|
|
178
|
+
ef.action=btn.form.action;
|
|
179
|
+
ef.querySelector('[name=name]').value=l.name;
|
|
180
|
+
ef.querySelector('[name=color]').value=l.color;
|
|
181
|
+
dlgColor=ef.querySelector('[name=color]');
|
|
182
|
+
ef.querySelector('[name=description]').value=l.description;
|
|
183
|
+
ef.querySelector('[name=exclusive]').checked=l.exclusive===1;
|
|
184
|
+
bindSwatches(dp, dlgColor);
|
|
185
|
+
dlg.showModal();
|
|
186
|
+
});
|
|
187
|
+
document.getElementById('dlg-cancel').addEventListener('click', ()=>dlg.close());
|
|
188
|
+
</script>
|
|
189
|
+
</body></html>`;
|
|
190
|
+
}
|
|
@@ -16,7 +16,7 @@ interface Flash {
|
|
|
16
16
|
export function projectSettingsTabsHTML(
|
|
17
17
|
owner: string,
|
|
18
18
|
name: string,
|
|
19
|
-
active: "webhooks" | "members" | "upstreams" | "model",
|
|
19
|
+
active: "webhooks" | "members" | "upstreams" | "model" | "labels",
|
|
20
20
|
): string {
|
|
21
21
|
const base = `/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/settings`;
|
|
22
22
|
const cls = (which: typeof active) => (active === which ? " active" : "");
|
|
@@ -24,6 +24,7 @@ export function projectSettingsTabsHTML(
|
|
|
24
24
|
<a class="subtab${cls("webhooks")}" href="${escapeAttr(base)}/webhooks">Webhooks</a>
|
|
25
25
|
<a class="subtab${cls("members")}" href="${escapeAttr(base)}/members">成员</a>
|
|
26
26
|
<a class="subtab${cls("upstreams")}" href="${escapeAttr(base)}/upstreams">上游</a>
|
|
27
|
+
<a class="subtab${cls("labels")}" href="${escapeAttr(base)}/labels">🏷️ 标签</a>
|
|
27
28
|
<a class="subtab${cls("model")}" href="${escapeAttr(base)}/model">🤖 模型</a>
|
|
28
29
|
</nav>`;
|
|
29
30
|
}
|
package/src/views/settings.ts
CHANGED
|
@@ -208,6 +208,11 @@ function buildGroupsSection(): string {
|
|
|
208
208
|
<button type="button" id="binding-add" class="secondary">添加绑定</button>
|
|
209
209
|
</div>
|
|
210
210
|
</div>
|
|
211
|
+
<div id="group-configs-editor" style="margin-top:.7rem">
|
|
212
|
+
<h2 style="margin-bottom:.5rem">分组工作目录与脚本</h2>
|
|
213
|
+
<p class="hint" style="margin:0 0 .5rem">为每个分组配置工作目录模板和生命周期脚本。模板变量:<code>{owner}</code> <code>{repo}</code> <code>{issue}</code> <code>{session}</code>。脚本在 daemon 上以工作目录为 cwd 执行。</p>
|
|
214
|
+
<div id="group-configs-list"></div>
|
|
215
|
+
</div>
|
|
211
216
|
<div class="db-controls" style="margin-top:.7rem">
|
|
212
217
|
<button type="button" id="strategy-save">保存策略</button>
|
|
213
218
|
</div>
|
package/src/webhooks.ts
CHANGED
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
getDefaultUpstreamUrl,
|
|
27
27
|
ensureUser,
|
|
28
28
|
resolveModel,
|
|
29
|
+
listLabelsForIssue,
|
|
29
30
|
type IssueRow,
|
|
30
31
|
type ProjectRow,
|
|
31
32
|
type CommentRow,
|
|
@@ -355,12 +356,18 @@ function buildRepository(project: ProjectRow, origin: string, model?: string): P
|
|
|
355
356
|
return repo;
|
|
356
357
|
}
|
|
357
358
|
|
|
359
|
+
async function toPayloadLabels(issueId: number): Promise<PayloadLabel[]> {
|
|
360
|
+
const rows = await listLabelsForIssue(issueId);
|
|
361
|
+
return rows.map((l) => ({ id: l.id, name: l.name, color: l.color, description: l.description }));
|
|
362
|
+
}
|
|
363
|
+
|
|
358
364
|
function buildIssue(
|
|
359
365
|
issue: IssueRow,
|
|
360
366
|
project: ProjectRow,
|
|
361
367
|
commentCount: number,
|
|
362
368
|
origin: string,
|
|
363
369
|
model?: string,
|
|
370
|
+
labels: PayloadLabel[] = [],
|
|
364
371
|
): PayloadIssue {
|
|
365
372
|
const repoUrl = `${origin}/${encodeURIComponent(project.owner)}/${encodeURIComponent(project.name)}`;
|
|
366
373
|
const issueUrl = `${repoUrl}/issues/${issue.number}`;
|
|
@@ -371,7 +378,7 @@ function buildIssue(
|
|
|
371
378
|
number: issue.number,
|
|
372
379
|
title: issue.title,
|
|
373
380
|
body: issue.body ?? "",
|
|
374
|
-
labels
|
|
381
|
+
labels,
|
|
375
382
|
milestone: null,
|
|
376
383
|
assignee: null,
|
|
377
384
|
assignees: null,
|
|
@@ -415,10 +422,11 @@ function buildCommentPayload(
|
|
|
415
422
|
commentCount: number,
|
|
416
423
|
origin: string,
|
|
417
424
|
model?: string,
|
|
425
|
+
labels: PayloadLabel[] = [],
|
|
418
426
|
): CommentEventPayload {
|
|
419
427
|
return {
|
|
420
428
|
action: "created",
|
|
421
|
-
issue: buildIssue(issue, project, commentCount, origin, model),
|
|
429
|
+
issue: buildIssue(issue, project, commentCount, origin, model, labels),
|
|
422
430
|
comment: buildComment(issue, comment, project, origin),
|
|
423
431
|
repository: buildRepository(project, origin, model),
|
|
424
432
|
sender: buildUser(comment.author, origin),
|
|
@@ -432,10 +440,11 @@ function buildIssuePayload(
|
|
|
432
440
|
action: IssueAction,
|
|
433
441
|
origin: string,
|
|
434
442
|
model?: string,
|
|
443
|
+
labels: PayloadLabel[] = [],
|
|
435
444
|
): IssueEventPayload {
|
|
436
445
|
return {
|
|
437
446
|
action,
|
|
438
|
-
issue: buildIssue(issue, project, commentCount, origin, model),
|
|
447
|
+
issue: buildIssue(issue, project, commentCount, origin, model, labels),
|
|
439
448
|
repository: buildRepository(project, origin, model),
|
|
440
449
|
sender: buildUser(issue.author, origin),
|
|
441
450
|
};
|
|
@@ -604,7 +613,8 @@ export async function emitIssueEvent(
|
|
|
604
613
|
// comes from the config table via loadConfig() — cheap DB read.
|
|
605
614
|
const globalDefault = (await loadConfig()).defaultModel;
|
|
606
615
|
const model = resolveModel(project.model, globalDefault);
|
|
607
|
-
const
|
|
616
|
+
const labels = await toPayloadLabels(issueId);
|
|
617
|
+
const payload = buildIssuePayload(issue, project, commentCount, action, origin, model, labels);
|
|
608
618
|
const rawBody = JSON.stringify(payload);
|
|
609
619
|
await fanOut(projectId, "issues", rawBody);
|
|
610
620
|
} catch (e) {
|
|
@@ -633,7 +643,8 @@ export async function emitCommentEvent(
|
|
|
633
643
|
const commentCount = await countCommentsSafe(issueId);
|
|
634
644
|
const globalDefault = (await loadConfig()).defaultModel;
|
|
635
645
|
const model = resolveModel(project.model, globalDefault);
|
|
636
|
-
const
|
|
646
|
+
const labels = await toPayloadLabels(issueId);
|
|
647
|
+
const payload = buildCommentPayload(issue, comment, project, commentCount, origin, model, labels);
|
|
637
648
|
const rawBody = JSON.stringify(payload);
|
|
638
649
|
await fanOut(projectId, "issue_comment", rawBody);
|
|
639
650
|
} catch (e) {
|