Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 19 additions & 4 deletions src/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,20 @@ function migrateIssuesTable(db: Database): void {
}
}

function migrateLabelsTable(db: Database): void {
const have = tableColumns(db, "labels");
if (have.size === 0) return;
if (!have.has("description")) {
db.exec(applyPrefix("ALTER TABLE {{labels}} ADD COLUMN description TEXT NOT NULL DEFAULT ''"));
}
if (!have.has("exclusive")) {
db.exec(applyPrefix("ALTER TABLE {{labels}} ADD COLUMN exclusive INTEGER NOT NULL DEFAULT 0"));
}
if (!have.has("is_archived")) {
db.exec(applyPrefix("ALTER TABLE {{labels}} ADD COLUMN is_archived INTEGER NOT NULL DEFAULT 0"));
}
}

// ---- SqliteDriver: wraps bun:sqlite behind AsyncDatabase ----
class SqliteDriver implements AsyncDatabase {
readonly dialect = "sqlite" as const;
Expand All @@ -145,10 +159,11 @@ class SqliteDriver implements AsyncDatabase {
)
);
// Migration must run BEFORE schema.sql (same ordering as the original file).
migrateUsersTable(db);
migratePatTable(db);
migrateProjectsTable(db);
migrateIssuesTable(db);
migrateUsersTable(db);
migratePatTable(db);
migrateProjectsTable(db);
migrateIssuesTable(db);
migrateLabelsTable(db);
db.exec(applyPrefix(readFileSync(join(import.meta.dir, "schema.sql"), "utf8")));
return new SqliteDriver(db);
}
Expand Down
115 changes: 115 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,13 @@ import {
setProjectModel,
listCachedModels,
replaceCachedModels,
listLabels,
listLabelsForIssue,
createLabel,
updateLabel,
archiveLabel,
deleteLabel,
setIssueLabels,
type ProjectRole,
type UserRow,
} from "./store";
Expand Down Expand Up @@ -91,6 +98,7 @@ import { buildWebhookDeliveriesPage } from "./views/webhookDeliveries";
import { browseRemoteFile, proxyFileSince, RemoteFileError } from "./remote-file";
import { buildProjectMembersPage } from "./views/projectMembers";
import { buildProjectUpstreamsPage, trySetUpstreamUrls } from "./views/projectUpstreams";
import { buildProjectLabelsPage } from "./views/projectLabels";
import { buildProjectModelPage } from "./views/projectModel";
import { handleGiteaApi } from "./giteaApi";
import { deployRemoteDaemon, deployBatch, type DeployTarget } from "./daemon-deploy";
Expand Down Expand Up @@ -331,6 +339,10 @@ const REPO_MEMBER_ACTION_RE = /^\/([^/]+)\/([^/]+)\/settings\/members\/([^/]+)\/
const REPO_MEMBER_ADD_RE = /^\/([^/]+)\/([^/]+)\/settings\/members\/add$/;
const REPO_UPSTREAMS_RE = /^\/([^/]+)\/([^/]+)\/settings\/upstreams$/;
const REPO_MODEL_RE = /^\/([^/]+)\/([^/]+)\/settings\/model$/;
const REPO_LABELS_RE = /^\/([^/]+)\/([^/]+)\/settings\/labels$/;
const REPO_LABEL_ADD_RE = /^\/([^/]+)\/([^/]+)\/settings\/labels\/add$/;
const REPO_LABEL_ACTION_RE = /^\/([^/]+)\/([^/]+)\/settings\/labels\/(\d+)\/(update|archive|unarchive|delete)$/;
const API_ISSUE_LABELS_RE = /^\/api\/([^/]+)\/([^/]+)\/issues\/(\d+)\/labels$/;
const WH_ACTION_RE = /^\/__wh\/(\d+)\/(delete|toggle|test)$/;
const SESSIONS_RE = /^\/sessions$/;
const SESSION_VIEW_RE = /^\/sessions\/([A-Za-z0-9_-]+)$/;
Expand Down Expand Up @@ -1663,6 +1675,61 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
}
}

const labelAdd = url.pathname.match(REPO_LABEL_ADD_RE);
if (labelAdd) {
const [, owner, repo] = labelAdd;
if (!(owner && repo)) return html(errorPage("bad path", ""), 400);
const project = await getProject(owner, repo);
if (!project) return html(errorPage("项目不存在", ""), 404);
const back = `${url.origin}/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/settings/labels`;
if (!(await canAdminProject(project.id, ctx.user))) {
return Response.redirect(`${back}?err=${encodeURIComponent("无权限")}`, 303);
}
const form = await req.formData().catch(() => new FormData());
try {
await createLabel(project.id, {
name: String(form.get("name") ?? ""),
color: String(form.get("color") ?? "#888888"),
description: String(form.get("description") ?? ""),
exclusive: form.get("exclusive") === "1",
});
return Response.redirect(`${back}?ok=1&ok_msg=${encodeURIComponent("标签已创建")}`, 303);
} catch (e) {
return Response.redirect(`${back}?err=${encodeURIComponent(errMsg(e))}`, 303);
}
}

const labelAction = url.pathname.match(REPO_LABEL_ACTION_RE);
if (labelAction) {
const [, owner, repo, lidStr, action] = labelAction;
if (!(owner && repo && lidStr && action)) return html(errorPage("bad path", ""), 400);
const project = await getProject(owner, repo);
if (!project) return html(errorPage("项目不存在", ""), 404);
const back = `${url.origin}/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/settings/labels`;
if (!(await canAdminProject(project.id, ctx.user))) {
return Response.redirect(`${back}?err=${encodeURIComponent("无权限")}`, 303);
}
const lid = Number(lidStr);
const form = await req.formData().catch(() => new FormData());
try {
if (action === "update") {
await updateLabel(project.id, lid, {
name: String(form.get("name") ?? ""),
color: String(form.get("color") ?? "#888888"),
description: String(form.get("description") ?? ""),
exclusive: form.get("exclusive") === "1",
});
} else if (action === "archive" || action === "unarchive") {
await archiveLabel(project.id, lid, action === "archive");
} else if (action === "delete") {
await deleteLabel(project.id, lid);
}
return Response.redirect(`${back}?ok=1`, 303);
} catch (e) {
return Response.redirect(`${back}?err=${encodeURIComponent(errMsg(e))}`, 303);
}
}

return json({ error: "not found" }, 404);
}

Expand Down Expand Up @@ -1696,6 +1763,38 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
}
}

const issueLabelsApi = url.pathname.match(API_ISSUE_LABELS_RE);
if (issueLabelsApi) {
const [, owner, repo, numStr] = issueLabelsApi;
if (!(owner && repo && numStr)) return html(errorPage("404", "bad path"), 404);
const project = await getProject(owner, repo);
if (!project) return json({ error: "project not found" }, 404);
const issue = await getIssueWithMeta(project.id, Number(numStr));
if (!issue) return json({ error: "issue not found" }, 404);
if (req.method === "GET") {
const [current, available] = await Promise.all([
listLabelsForIssue(issue.id),
listLabels(project.id),
]);
return json({ current, available });
}
if (req.method === "POST") {
if (!ctx.user || !(await canWriteProject(project.id, ctx.user))) {
return json({ error: "forbidden: needs writer role on project" }, 403);
}
const body = await req.json().catch(() => ({}));
const labelIds = Array.isArray(body.labelIds) ? body.labelIds.map((n: unknown) => Number(n)).filter((n: number) => Number.isInteger(n) && n > 0) : [];
try {
await setIssueLabels(issue.id, labelIds);
const current = await listLabelsForIssue(issue.id);
return json({ current });
} catch (e) {
return json({ error: errMsg(e) }, e instanceof StoreError ? e.status : 500);
}
}
return json({ error: "method not allowed" }, 405);
}

const isNew = url.pathname.match(REPO_NEW_RE);
if (isNew) {
const [, owner, repo] = isNew;
Expand Down Expand Up @@ -1779,6 +1878,22 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
return html(buildProjectUpstreamsPage(ctx.user!, project, flash));
}

const labelsPage = url.pathname.match(REPO_LABELS_RE);
if (labelsPage) {
const [, owner, repo] = labelsPage;
if (!(owner && repo)) return html(errorPage("404", "bad path"), 404);
const project = await getProject(owner, repo);
if (!project) return html(errorPage("项目不存在", "项目未创建"), 404);
if (ctx.user!.is_admin === 1) await ensureProjectBootstrapAdmin(project.id, ctx.user!.login);
if (!(await canAdminProject(project.id, ctx.user))) {
return html(errorPage("无权限", "需要该项目 admin 角色才能管理标签"), 403);
}
const flashKind = url.searchParams.get("ok") === "1" ? "ok" : url.searchParams.get("err") ? "err" : null;
const flashMsg = flashKind === "ok" ? (url.searchParams.get("ok_msg") ?? "") : (url.searchParams.get("err") ?? "");
const flash = flashKind ? { kind: flashKind as "ok" | "err", msg: flashMsg } : null;
return html(await buildProjectLabelsPage(ctx.user!, project, flash));
}

const modelPage = url.pathname.match(REPO_MODEL_RE);
if (modelPage) {
const [, owner, repo] = modelPage;
Expand Down
25 changes: 25 additions & 0 deletions src/render/layout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ export interface LayoutProps {
upstreamWebUrl?: string | null;
translateEnabled?: boolean;
ttsEnabled?: boolean;
labels?: { id: number; name: string; color: string }[];
canEditLabels?: boolean;
}

export const THEME_CSS = `
Expand Down Expand Up @@ -107,6 +109,20 @@ header.topbar .num{opacity:.7}
.desc-toggle{margin-top:.4rem;background:none;border:none;color:var(--accent);font-size:13px;cursor:pointer;padding:.2rem 0}
.upstream-link{font-size:12px;color:var(--accent);opacity:.85}
.upstream-link:hover{opacity:1;text-decoration:underline}
.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)}
.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)}
.label-edit-btn:hover{border-color:var(--accent);color:var(--accent)}
#labelDlg{border:1px solid var(--border);border-radius:10px;background:var(--bg-elev);color:var(--text);padding:1.1rem;max-width:380px;width:90vw}
#labelDlg::backdrop{background:rgba(0,0,0,.5)}
#labelDlg h3{margin:0 0 .6rem;font-size:14px}
.lp-list{max-height:300px;overflow-y:auto;display:flex;flex-direction:column;gap:.15rem}
.lp-item{display:flex;align-items:center;gap:.4rem;padding:.3rem .4rem;border-radius:6px;cursor:pointer;font-size:13px}
.lp-item:hover{background:var(--bg-muted)}
.lp-item input{margin:0}
.lp-dot{width:10px;height:10px;border-radius:50%;border:1px solid var(--border);flex-shrink:0}
.lp-name{flex:1;overflow-wrap:anywhere}
.lp-scope{font-size:10px;color:var(--text-muted)}
.lp-empty{color:var(--text-muted);font-size:12px;padding:.6rem 0;text-align:center}
`;

export function renderLayout(props: LayoutProps, inner: string, initialItems: string): string {
Expand All @@ -119,6 +135,12 @@ export function renderLayout(props: LayoutProps, inner: string, initialItems: st
const [repoOwner, repoName] = props.repoPath.split("/");
const repoIssuesHref = `/${encodeURIComponent(repoOwner ?? "")}/${encodeURIComponent(repoName ?? "")}/issues`;
const op = props.operatorLogin ?? "operator";
const labelsHtml = (props.labels ?? []).length
? props.labels!.map((l) => `<span class="issue-label" style="border-color:${escapeAttr(l.color)};color:${escapeAttr(l.color)}">${escapeHtml(l.name)}</span>`).join("")
: "";
const labelPickerBtn = props.canEditLabels
? `<button type="button" class="label-edit-btn" id="labelEditBtn" title="管理标签">🏷️</button>`
: "";
return `<!doctype html>
<html lang="zh">
<head>
Expand All @@ -140,6 +162,7 @@ export function renderLayout(props: LayoutProps, inner: string, initialItems: st
<h1>${escapeHtml(props.issueTitle)}</h1>
<div class="meta-status">
<span class="state-badge ${stateClass}">${stateLabel}</span>
${labelsHtml}${labelPickerBtn}
<span class="count" id="count">…</span>
${props.upstreamWebUrl ? `<a class="upstream-link" href="${escapeAttr(props.upstreamWebUrl)}" target="_blank" rel="noopener noreferrer" title="跳转到上游仓库">🔗 查看上游</a>` : ""}
</div>
Expand Down Expand Up @@ -168,6 +191,8 @@ ${props.writesEnabled !== false
<script type="application/json" id="initial-data">${inner}</script>
<script src="/static/tts.js?v=${BUILD_ID}" defer></script>
<script src="/static/app.js?v=${BUILD_ID}" defer></script>
${props.canEditLabels ? `<dialog id="labelDlg"><h3>标签</h3><div class="lp-list" id="lpList"></div><div class="lp-empty hidden" id="lpEmpty">该项目还没有标签。先到设置页创建。</div></dialog>
<script src="/static/label-picker.js?v=${BUILD_ID}" defer></script>` : ""}
</body>
</html>`;
}
Expand Down
11 changes: 7 additions & 4 deletions src/schema-mysql.sql
Original file line number Diff line number Diff line change
Expand Up @@ -74,10 +74,13 @@ CREATE INDEX comments_issue_created ON {{comments}} (issue_id, created_at);
CREATE INDEX comments_author ON {{comments}} (author);

CREATE TABLE IF NOT EXISTS {{labels}} (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
project_id BIGINT NOT NULL,
name VARCHAR(255) NOT NULL,
color VARCHAR(16) NOT NULL DEFAULT '#888888',
id BIGINT AUTO_INCREMENT PRIMARY KEY,
project_id BIGINT NOT NULL,
name VARCHAR(255) NOT NULL,
color VARCHAR(16) NOT NULL DEFAULT '#888888',
description VARCHAR(255) NOT NULL DEFAULT '',
exclusive TINYINT NOT NULL DEFAULT 0,
is_archived TINYINT NOT NULL DEFAULT 0,
UNIQUE (project_id, name),
CONSTRAINT {{fk_labels_project}} FOREIGN KEY (project_id) REFERENCES {{projects}}(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
Expand Down
11 changes: 7 additions & 4 deletions src/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -78,10 +78,13 @@ CREATE INDEX IF NOT EXISTS comments_issue_created
ON {{comments}} (issue_id, created_at);

CREATE TABLE IF NOT EXISTS {{labels}} (
id INTEGER PRIMARY KEY AUTOINCREMENT,
project_id INTEGER NOT NULL REFERENCES {{projects}}(id) ON DELETE CASCADE,
name TEXT NOT NULL,
color TEXT NOT NULL DEFAULT '#888888',
id INTEGER PRIMARY KEY AUTOINCREMENT,
project_id INTEGER NOT NULL REFERENCES {{projects}}(id) ON DELETE CASCADE,
name TEXT NOT NULL,
color TEXT NOT NULL DEFAULT '#888888',
description TEXT NOT NULL DEFAULT '',
exclusive INTEGER NOT NULL DEFAULT 0,
is_archived INTEGER NOT NULL DEFAULT 0,
UNIQUE (project_id, name)
);

Expand Down
106 changes: 106 additions & 0 deletions src/static/label-picker.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
(function () {
var btn = document.getElementById("labelEditBtn");
if (!btn) return;
var dlg = document.getElementById("labelDlg");
var listEl = document.getElementById("lpList");
var emptyEl = document.getElementById("lpEmpty");
if (!dlg || !listEl) return;

var path = window.location.pathname.replace(/\/+$/, "");
var m = path.match(/^\/(.+)\/(.+)\/issues\/(\d+)$/);
if (!m) return;
var apiBase = "/api/" + encodeURIComponent(m[1]) + "/" + encodeURIComponent(m[2]) + "/issues/" + m[3] + "/labels";

function scopeOf(name) {
var i = name.indexOf("/");
return i > 0 ? name.slice(0, i) : "";
}

var state = { available: [], current: [] };

function render() {
if (!state.available.length) {
listEl.classList.add("hidden");
emptyEl.classList.remove("hidden");
return;
}
listEl.classList.remove("hidden");
emptyEl.classList.add("hidden");
var currentIds = new Set(state.current.map(function (l) { return l.id; }));
listEl.innerHTML = state.available.map(function (l) {
var checked = currentIds.has(l.id);
var sc = scopeOf(l.name);
var scopeHint = l.exclusive === 1 && sc ? '<span class="lp-scope">互斥 · ' + esc(sc) + "</span>" : "";
return '<label class="lp-item">' +
'<input type="checkbox" data-id="' + l.id + '"' + (l.exclusive === 1 ? ' data-exclusive="1"' : "") +
(sc ? ' data-scope="' + esc(sc) + '"' : "") +
(checked ? " checked" : "") + ">" +
'<span class="lp-dot" style="background:' + esc(l.color) + '"></span>' +
'<span class="lp-name">' + esc(l.name) + "</span>" + scopeHint +
"</label>";
}).join("");
}

function renderChips() {
var row = document.querySelector(".meta-status");
if (!row) return;
var existing = row.querySelectorAll(".issue-label");
existing.forEach(function (e) { e.remove(); });
var badge = row.querySelector(".state-badge");
var html = state.current.map(function (l) {
return '<span class="issue-label" style="border-color:' + esc(l.color) + ";color:" + esc(l.color) + '">' + esc(l.name) + "</span>";
}).join("");
badge.insertAdjacentHTML("afterend", html);
}

function esc(s) {
return String(s).replace(/[&<>"']/g, function (c) {
return { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c];
});
}

async function load() {
try {
var res = await fetch(apiBase);
if (!res.ok) return;
var data = await res.json();
state.available = data.available || [];
state.current = data.current || [];
render();
} catch (e) {}
}

async function save(labelIds) {
try {
var res = await fetch(apiBase, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ labelIds: labelIds }),
});
if (!res.ok) return;
var data = await res.json();
state.current = data.current || [];
renderChips();
} catch (e) {}
}

btn.addEventListener("click", async function () {
await load();
dlg.showModal();
});

listEl.addEventListener("change", function (e) {
var cb = e.target;
if (cb.tagName !== "INPUT" || cb.type !== "checkbox") return;
if (cb.checked && cb.dataset.exclusive === "1" && cb.dataset.scope) {
var scope = cb.dataset.scope;
listEl.querySelectorAll('input[data-exclusive="1"]').forEach(function (other) {
if (other !== cb && other.dataset.scope === scope) other.checked = false;
});
}
var ids = Array.from(listEl.querySelectorAll("input:checked")).map(function (c) {
return Number(c.dataset.id);
});
save(ids);
});
})();
Loading
Loading