feat: Gitea 自动代码审查服务
基于 OpenCodeReview 的 webhook 服务:监听 Gitea 的 push 与 Pull Request 事件, 调用 OCR 审查 diff,把结果发布回 Gitea,并按阻断阈值决定是否自动合并。 主要能力: - Push / PR 事件触发,支持分支 glob 过滤与 PR-only / push-only 范围 - PR 内联评论(按 diff 行号定位)、汇总评论、Issue 生命周期、提交状态 - 可配置阻断阈值(严重级别 / 类别 / 任意意见) - 无阻断问题时自动合并,审查覆盖不完整时拒绝合并 - 内置 Web 后台:仓库配置、任务日志、失败重跑、连通性自检 - SQLite 持久化,worker 重启回收卡死任务,失败自动重试 实现为独立服务而非 Gitea Action:本机 act_runner 指向的实例不可达, 且后台配置与任务历史需要独立进程承载。
This commit is contained in:
@@ -0,0 +1,426 @@
|
||||
"use strict";
|
||||
|
||||
const state = { repos: [], jobs: [], token: "" };
|
||||
|
||||
/* ---------------------------------- auth ---------------------------------- */
|
||||
|
||||
const TOKEN_KEY = "cr-admin-token";
|
||||
|
||||
function token() {
|
||||
return state.token || localStorage.getItem(TOKEN_KEY) || "";
|
||||
}
|
||||
|
||||
function showGate(message) {
|
||||
const gate = document.getElementById("token-gate");
|
||||
const err = document.getElementById("token-error");
|
||||
gate.classList.remove("hidden");
|
||||
if (message) {
|
||||
err.textContent = message;
|
||||
err.classList.remove("hidden");
|
||||
} else {
|
||||
err.classList.add("hidden");
|
||||
}
|
||||
document.getElementById("token-form").elements.token.focus();
|
||||
}
|
||||
|
||||
function hideGate() {
|
||||
document.getElementById("token-gate").classList.add("hidden");
|
||||
}
|
||||
|
||||
function setBanner(message, kind) {
|
||||
const el = document.getElementById("banner");
|
||||
if (!message) {
|
||||
el.classList.add("hidden");
|
||||
return;
|
||||
}
|
||||
el.textContent = message;
|
||||
el.className = `banner${kind === "ok" ? " ok" : ""}`;
|
||||
}
|
||||
|
||||
document.getElementById("token-form").addEventListener("submit", async (ev) => {
|
||||
ev.preventDefault();
|
||||
const value = ev.target.elements.token.value.trim();
|
||||
if (!value) return;
|
||||
state.token = value;
|
||||
try {
|
||||
await api("/repos");
|
||||
localStorage.setItem(TOKEN_KEY, value);
|
||||
hideGate();
|
||||
document.getElementById("sign-out").classList.remove("hidden");
|
||||
setBanner("");
|
||||
await refreshAll();
|
||||
} catch (err) {
|
||||
state.token = "";
|
||||
showGate(`Token 无效:${err.message}`);
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById("sign-out").addEventListener("click", () => {
|
||||
state.token = "";
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
document.getElementById("sign-out").classList.add("hidden");
|
||||
showGate("");
|
||||
});
|
||||
|
||||
/* ---------------------------------- api ----------------------------------- */
|
||||
|
||||
async function api(path, { method = "GET", body } = {}) {
|
||||
const headers = { Accept: "application/json" };
|
||||
const t = token();
|
||||
if (t) headers.Authorization = `Bearer ${t}`;
|
||||
if (body !== undefined) headers["Content-Type"] = "application/json";
|
||||
const res = await fetch(`/api${path}`, {
|
||||
method, headers, body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
const textBody = await res.text();
|
||||
let parsed = null;
|
||||
try { parsed = textBody ? JSON.parse(textBody) : null; } catch { parsed = textBody; }
|
||||
if (!res.ok) {
|
||||
const error = new Error(parsed?.error || `HTTP ${res.status}`);
|
||||
error.status = res.status;
|
||||
throw error;
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function show(selector, content) {
|
||||
const el = document.querySelector(selector);
|
||||
el.textContent = typeof content === "string" ? content : JSON.stringify(content, null, 2);
|
||||
el.classList.remove("hidden");
|
||||
}
|
||||
|
||||
function hide(selector) { document.querySelector(selector).classList.add("hidden"); }
|
||||
|
||||
function esc(value) {
|
||||
return String(value ?? "").replace(/[&<>"]/g, (c) => (
|
||||
{ "&": "&", "<": "<", ">": ">", '"': """ }[c]
|
||||
));
|
||||
}
|
||||
|
||||
function fmtTime(value) {
|
||||
if (!value) return "";
|
||||
return String(value).replace("T", " ").replace("Z", "");
|
||||
}
|
||||
|
||||
/* ---------------------------------- tabs ---------------------------------- */
|
||||
|
||||
for (const tab of document.querySelectorAll(".tab")) {
|
||||
tab.addEventListener("click", () => {
|
||||
for (const t of document.querySelectorAll(".tab")) t.classList.toggle("active", t === tab);
|
||||
for (const p of document.querySelectorAll(".panel")) {
|
||||
p.classList.toggle("active", p.id === `tab-${tab.dataset.tab}`);
|
||||
}
|
||||
if (tab.dataset.tab === "jobs") loadJobs().catch(handleError);
|
||||
if (tab.dataset.tab === "settings") loadSettings().catch(handleError);
|
||||
});
|
||||
}
|
||||
|
||||
/* --------------------------------- health --------------------------------- */
|
||||
|
||||
async function loadHealth() {
|
||||
try {
|
||||
const h = await api("/health");
|
||||
const q = h.queue || {};
|
||||
document.getElementById("status").textContent =
|
||||
`队列 运行${q.running || 0} / 等待${q.queued || 0} · 成功${q.succeeded || 0} · 失败${q.failed || 0}` +
|
||||
(h.llmModel ? ` · ${h.llmModel}` : "");
|
||||
} catch (err) {
|
||||
document.getElementById("status").textContent = `服务异常:${err.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
function handleError(err) {
|
||||
if (err.status === 401) {
|
||||
document.getElementById("sign-out").classList.add("hidden");
|
||||
showGate("Token 无效或已过期,请重新输入。");
|
||||
return;
|
||||
}
|
||||
setBanner(err.message);
|
||||
}
|
||||
|
||||
/* ---------------------------------- repos --------------------------------- */
|
||||
|
||||
const REPO_FIELDS = [
|
||||
"id", "owner", "name", "enabled", "base_branch", "branch_patterns", "review_scope",
|
||||
"gitea_token", "llm_model", "llm_token", "background_template", "excludes",
|
||||
"publish_mode", "create_issue", "issue_labels", "block_severity", "block_categories",
|
||||
"fail_on_findings", "auto_merge", "auto_merge_mode", "merge_method", "delete_branch",
|
||||
"max_comments", "concurrency",
|
||||
];
|
||||
|
||||
async function loadRepos() {
|
||||
state.repos = await api("/repos");
|
||||
const tbody = document.querySelector("#repos-table tbody");
|
||||
if (state.repos.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="7" class="empty">还没有配置仓库。点右上角「新增仓库」开始,然后在 Gitea 仓库里添加 Webhook。</td></tr>';
|
||||
return;
|
||||
}
|
||||
tbody.innerHTML = state.repos.map((r) => `
|
||||
<tr>
|
||||
<td><strong>${esc(r.owner)}/${esc(r.name)}</strong><br><span class="tag muted">${esc(r.base_branch)}</span></td>
|
||||
<td><code>${esc(r.branch_patterns)}</code></td>
|
||||
<td>${esc(scopeLabel(r.review_scope))}</td>
|
||||
<td>${r.enabled ? '<span class="tag ok">启用</span>' : '<span class="tag muted">停用</span>'}</td>
|
||||
<td>${r.auto_merge ? `<span class="tag warn">${esc(r.merge_method)}</span>` : '<span class="tag muted">否</span>'}</td>
|
||||
<td><code>${esc(r.block_severity || "—")}</code></td>
|
||||
<td>
|
||||
<button data-edit="${r.id}">编辑</button>
|
||||
<button data-run="${r.id}">立即审查</button>
|
||||
<button class="danger" data-del="${r.id}">删除</button>
|
||||
</td>
|
||||
</tr>`).join("");
|
||||
}
|
||||
|
||||
function scopeLabel(scope) {
|
||||
return { both: "Push + PR", pr: "仅 PR", push: "仅 Push" }[scope] || scope;
|
||||
}
|
||||
|
||||
document.querySelector("#repos-table").addEventListener("click", async (ev) => {
|
||||
const target = ev.target.closest("button");
|
||||
if (!target) return;
|
||||
try {
|
||||
if (target.dataset.edit) openRepo(Number(target.dataset.edit));
|
||||
if (target.dataset.del) {
|
||||
if (!confirm("删除该仓库配置?历史任务会一并删除。")) return;
|
||||
await api(`/repos/${target.dataset.del}`, { method: "DELETE" });
|
||||
await loadRepos();
|
||||
}
|
||||
if (target.dataset.run) openManual(Number(target.dataset.run));
|
||||
} catch (err) {
|
||||
handleError(err);
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById("add-repo").addEventListener("click", () => openRepo(null));
|
||||
document.getElementById("refresh-jobs").addEventListener("click", () => loadJobs().catch(handleError));
|
||||
|
||||
/* ------------------------------- repo dialog ------------------------------ */
|
||||
|
||||
const modal = document.getElementById("modal");
|
||||
const form = document.getElementById("repo-form");
|
||||
|
||||
function openRepo(id) {
|
||||
form.reset();
|
||||
hide("#repo-result");
|
||||
const repo = id ? state.repos.find((r) => r.id === id) : null;
|
||||
document.getElementById("modal-title").textContent = repo ? `${repo.owner}/${repo.name}` : "新增仓库";
|
||||
for (const field of REPO_FIELDS) {
|
||||
const input = form.elements[field];
|
||||
if (!input) continue;
|
||||
if (!repo) {
|
||||
// Defaults for a brand-new repository.
|
||||
if (field === "enabled" || field === "create_issue") input.checked = true;
|
||||
continue;
|
||||
}
|
||||
if (input.type === "checkbox") input.checked = Boolean(repo[field]);
|
||||
else if (input.type === "password") input.value = "";
|
||||
else input.value = repo[field] ?? "";
|
||||
}
|
||||
modal.classList.remove("hidden");
|
||||
}
|
||||
|
||||
document.getElementById("modal-close").addEventListener("click", () => modal.classList.add("hidden"));
|
||||
modal.addEventListener("click", (ev) => { if (ev.target === modal) modal.classList.add("hidden"); });
|
||||
|
||||
form.addEventListener("submit", async (ev) => {
|
||||
ev.preventDefault();
|
||||
const data = {};
|
||||
for (const field of REPO_FIELDS) {
|
||||
const input = form.elements[field];
|
||||
if (!input) continue;
|
||||
if (input.type === "checkbox") data[field] = input.checked ? 1 : 0;
|
||||
else if (input.type === "password") { if (input.value) data[field] = input.value; }
|
||||
else if (input.value !== "") data[field] = input.value;
|
||||
}
|
||||
try {
|
||||
if (data.id) await api(`/repos/${data.id}`, { method: "PATCH", body: data });
|
||||
else await api("/repos", { method: "POST", body: data });
|
||||
modal.classList.add("hidden");
|
||||
setBanner("");
|
||||
await loadRepos();
|
||||
} catch (err) {
|
||||
if (err.status === 401) return handleError(err);
|
||||
show("#repo-result", err.message);
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById("discover").addEventListener("click", async () => {
|
||||
const id = form.elements.id.value;
|
||||
if (!id) return show("#repo-result", "请先保存仓库,再测试连接。");
|
||||
show("#repo-result", "测试中…");
|
||||
try {
|
||||
const info = await api(`/repos/${id}/discover`, { method: "POST" });
|
||||
show("#repo-result",
|
||||
`连接成功\n默认分支:${info.default_branch}\nIssue 功能:${info.has_issues}\nPR 功能:${info.has_pull_requests}\n分支:${info.branches.join(", ")}`);
|
||||
} catch (err) {
|
||||
if (err.status === 401) return handleError(err);
|
||||
show("#repo-result", err.message);
|
||||
}
|
||||
});
|
||||
|
||||
/* ------------------------------ manual dialog ----------------------------- */
|
||||
|
||||
const manualModal = document.getElementById("manual-modal");
|
||||
const manualForm = document.getElementById("manual-form");
|
||||
|
||||
function openManual(repoId) {
|
||||
const repo = state.repos.find((r) => r.id === repoId);
|
||||
if (!repo) return;
|
||||
manualForm.reset();
|
||||
hide("#manual-result");
|
||||
manualForm.elements.repo_id.value = repo.id;
|
||||
manualForm.elements.ref.value = repo.base_branch || "main";
|
||||
manualForm.elements.base_ref.value = repo.base_branch || "main";
|
||||
document.getElementById("manual-repo").textContent = `${repo.owner}/${repo.name}`;
|
||||
manualModal.classList.remove("hidden");
|
||||
}
|
||||
|
||||
document.getElementById("manual-close").addEventListener("click", () => manualModal.classList.add("hidden"));
|
||||
manualModal.addEventListener("click", (ev) => { if (ev.target === manualModal) manualModal.classList.add("hidden"); });
|
||||
|
||||
manualForm.addEventListener("submit", async (ev) => {
|
||||
ev.preventDefault();
|
||||
const body = {
|
||||
repo_id: Number(manualForm.elements.repo_id.value),
|
||||
ref: manualForm.elements.ref.value.trim(),
|
||||
sha: manualForm.elements.sha.value.trim(),
|
||||
base_ref: manualForm.elements.base_ref.value.trim() || undefined,
|
||||
};
|
||||
const pr = manualForm.elements.pr_number.value.trim();
|
||||
if (pr) body.pr_number = Number(pr);
|
||||
try {
|
||||
const res = await api("/review", { method: "POST", body });
|
||||
manualModal.classList.add("hidden");
|
||||
setBanner(`已加入队列:任务 #${res.jobId}`, "ok");
|
||||
await loadJobs();
|
||||
} catch (err) {
|
||||
if (err.status === 401) return handleError(err);
|
||||
show("#manual-result", err.message);
|
||||
}
|
||||
});
|
||||
|
||||
/* ---------------------------------- jobs ---------------------------------- */
|
||||
|
||||
const STATUS_TAG = {
|
||||
queued: "muted", running: "warn", succeeded: "ok", failed: "err", skipped: "muted",
|
||||
};
|
||||
|
||||
async function loadJobs() {
|
||||
state.jobs = await api("/jobs?limit=100");
|
||||
const tbody = document.querySelector("#jobs-table tbody");
|
||||
if (state.jobs.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="10" class="empty">暂无任务。仓库收到 push 或 Pull Request 后会出现在这里。</td></tr>';
|
||||
return;
|
||||
}
|
||||
tbody.innerHTML = state.jobs.map((j) => `
|
||||
<tr>
|
||||
<td>${j.id}</td>
|
||||
<td>${esc(j.owner)}/${esc(j.name)}</td>
|
||||
<td><code>${esc(j.ref_name)}</code>${j.pr_number ? ` <span class="tag muted">PR #${j.pr_number}</span>` : ""}</td>
|
||||
<td><code>${esc(String(j.to_sha).slice(0, 10))}</code></td>
|
||||
<td><span class="tag ${STATUS_TAG[j.status] || "muted"}">${esc(j.status)}</span>${j.phase ? ` <span class="tag muted">${esc(j.phase)}</span>` : ""}</td>
|
||||
<td>${j.findings ?? 0}</td>
|
||||
<td>${j.blocking ? `<span class="tag err">${j.blocking}</span>` : "0"}</td>
|
||||
<td>${j.merged ? '<span class="tag ok">已合并</span>' : ""}</td>
|
||||
<td>${fmtTime(j.started_at || j.created_at)}</td>
|
||||
<td><button data-log="${j.id}">日志</button> <button data-retry="${j.id}">重跑</button></td>
|
||||
</tr>`).join("");
|
||||
}
|
||||
|
||||
document.querySelector("#jobs-table").addEventListener("click", async (ev) => {
|
||||
const target = ev.target.closest("button");
|
||||
if (!target) return;
|
||||
try {
|
||||
if (target.dataset.log) {
|
||||
const job = await api(`/jobs/${target.dataset.log}`);
|
||||
show("#job-log", job.log || "(无日志)");
|
||||
}
|
||||
if (target.dataset.retry) {
|
||||
await api(`/jobs/${target.dataset.retry}/retry`, { method: "POST" });
|
||||
await loadJobs();
|
||||
}
|
||||
} catch (err) {
|
||||
handleError(err);
|
||||
}
|
||||
});
|
||||
|
||||
/* -------------------------------- settings -------------------------------- */
|
||||
|
||||
const SETTINGS_FIELDS = [
|
||||
"giteaUrl", "giteaToken", "webhookSecret", "adminToken", "llmUrl", "llmToken",
|
||||
"llmModel", "llmProtocol", "llmAuthHeader", "llmExtraHeaders", "rulePath",
|
||||
];
|
||||
|
||||
async function loadSettings() {
|
||||
const settingsForm = document.getElementById("settings-form");
|
||||
const s = await api("/settings");
|
||||
for (const field of SETTINGS_FIELDS) {
|
||||
const input = settingsForm.elements[field];
|
||||
if (!input) continue;
|
||||
if (input.type === "password") input.value = "";
|
||||
else input.value = s[field] ?? "";
|
||||
}
|
||||
document.getElementById("hook-url").textContent = `${location.origin}/webhook/gitea`;
|
||||
}
|
||||
|
||||
document.getElementById("settings-form").addEventListener("submit", async (ev) => {
|
||||
ev.preventDefault();
|
||||
const settingsForm = ev.target;
|
||||
const data = {};
|
||||
for (const field of SETTINGS_FIELDS) {
|
||||
const input = settingsForm.elements[field];
|
||||
if (input && input.value) data[field] = input.value;
|
||||
}
|
||||
try {
|
||||
await api("/settings", { method: "PUT", body: data });
|
||||
if (data.adminToken) {
|
||||
state.token = data.adminToken;
|
||||
localStorage.setItem(TOKEN_KEY, data.adminToken);
|
||||
}
|
||||
for (const field of SETTINGS_FIELDS) {
|
||||
const input = settingsForm.elements[field];
|
||||
if (input && input.type === "password") input.value = "";
|
||||
}
|
||||
show("#settings-result", "已保存。");
|
||||
await loadHealth();
|
||||
} catch (err) {
|
||||
if (err.status === 401) return handleError(err);
|
||||
show("#settings-result", err.message);
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById("test-gitea").addEventListener("click", async () => {
|
||||
show("#settings-result", "测试中…");
|
||||
try { show("#settings-result", await api("/gitea/test", { method: "POST" })); }
|
||||
catch (err) { show("#settings-result", err.message); }
|
||||
});
|
||||
|
||||
document.getElementById("test-llm").addEventListener("click", async () => {
|
||||
show("#settings-result", "测试中,请稍候…");
|
||||
try { show("#settings-result", await api("/selftest", { method: "POST" })); }
|
||||
catch (err) { show("#settings-result", err.message); }
|
||||
});
|
||||
|
||||
/* --------------------------------- startup -------------------------------- */
|
||||
|
||||
async function refreshAll() {
|
||||
await loadRepos();
|
||||
await loadHealth();
|
||||
}
|
||||
|
||||
async function boot() {
|
||||
await loadHealth();
|
||||
if (!token()) {
|
||||
showGate("");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await refreshAll();
|
||||
document.getElementById("sign-out").classList.remove("hidden");
|
||||
} catch (err) {
|
||||
handleError(err);
|
||||
}
|
||||
}
|
||||
|
||||
boot();
|
||||
setInterval(loadHealth, 10000);
|
||||
@@ -0,0 +1,192 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Gitea 代码审查</title>
|
||||
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Ctext y='14' font-size='14'%3E%F0%9F%94%8D%3C/text%3E%3C/svg%3E" />
|
||||
<link rel="stylesheet" href="/style.css" />
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>Gitea 代码审查</h1>
|
||||
<div class="right"><div class="status" id="status">加载中…</div><button class="linkish hidden" id="sign-out">清除访问 Token</button></div>
|
||||
</header>
|
||||
|
||||
<nav>
|
||||
<button class="tab active" data-tab="repos">仓库</button>
|
||||
<button class="tab" data-tab="jobs">任务</button>
|
||||
<button class="tab" data-tab="settings">设置</button>
|
||||
</nav>
|
||||
|
||||
<main>
|
||||
<div id="banner" class="banner hidden"></div>
|
||||
|
||||
<section id="tab-repos" class="panel active">
|
||||
<div class="row">
|
||||
<h2>已配置仓库</h2>
|
||||
<button id="add-repo">新增仓库</button>
|
||||
</div>
|
||||
<table id="repos-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>仓库</th><th>分支过滤</th><th>范围</th><th>启用</th>
|
||||
<th>自动合并</th><th>阻断级别</th><th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<section id="tab-jobs" class="panel">
|
||||
<div class="row">
|
||||
<h2>审查任务</h2>
|
||||
<button id="refresh-jobs">刷新</button>
|
||||
</div>
|
||||
<table id="jobs-table">
|
||||
<thead>
|
||||
<tr><th>#</th><th>仓库</th><th>分支</th><th>提交</th><th>状态</th>
|
||||
<th>意见</th><th>阻断</th><th>合并</th><th>开始</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
<pre id="job-log" class="log hidden"></pre>
|
||||
</section>
|
||||
|
||||
<section id="tab-settings" class="panel">
|
||||
<h2>全局设置</h2>
|
||||
<form id="settings-form">
|
||||
<fieldset>
|
||||
<legend>Gitea</legend>
|
||||
<label>API 地址 <input name="giteaUrl" placeholder="http://127.0.0.1:80" /></label>
|
||||
<label>管理员 Token <input name="giteaToken" type="password" placeholder="留空表示不修改" /></label>
|
||||
<label>Webhook 密钥 <input name="webhookSecret" type="password" placeholder="留空表示不修改" /></label>
|
||||
<label>后台访问 Token <input name="adminToken" type="password" placeholder="留空表示不修改" /></label>
|
||||
<p class="hint">Webhook 地址:<code id="hook-url"></code>(在仓库 Settings → Webhooks 中添加,密钥填上面这一项)</p>
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<legend>LLM</legend>
|
||||
<label>接口地址 <input name="llmUrl" placeholder="https://api.openai.com/v1" /></label>
|
||||
<label>API Key <input name="llmToken" type="password" placeholder="留空表示不修改" /></label>
|
||||
<label>模型 <input name="llmModel" placeholder="gpt-5.5" /></label>
|
||||
<label>协议 <input name="llmProtocol" placeholder="openai / anthropic" /></label>
|
||||
<label>自定义认证头 <input name="llmAuthHeader" placeholder="留空使用默认" /></label>
|
||||
<label>附加请求头 <input name="llmExtraHeaders" placeholder="K=V,K=V" /></label>
|
||||
<label>审查规则文件 <input name="rulePath" placeholder="/etc/gitea-codereview/rule.json" /></label>
|
||||
</fieldset>
|
||||
<div class="actions">
|
||||
<button type="submit">保存</button>
|
||||
<button type="button" id="test-gitea">测试 Gitea</button>
|
||||
<button type="button" id="test-llm">测试 LLM</button>
|
||||
</div>
|
||||
<pre id="settings-result" class="log hidden"></pre>
|
||||
</form>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<div id="token-gate" class="modal hidden">
|
||||
<div class="modal-body narrow">
|
||||
<h2>需要访问 Token</h2>
|
||||
<p class="hint">该服务设置了 <code>CR_ADMIN_TOKEN</code>,请输入后才能管理仓库与任务。</p>
|
||||
<form id="token-form">
|
||||
<label>访问 Token
|
||||
<input name="token" type="password" autocomplete="current-password" required />
|
||||
</label>
|
||||
<div class="actions">
|
||||
<button type="submit">进入</button>
|
||||
</div>
|
||||
<pre id="token-error" class="log hidden"></pre>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="manual-modal" class="modal hidden">
|
||||
<div class="modal-body narrow">
|
||||
<h2>立即审查</h2>
|
||||
<form id="manual-form">
|
||||
<input type="hidden" name="repo_id" />
|
||||
<p class="hint" id="manual-repo"></p>
|
||||
<label>分支 <input name="ref" placeholder="main" required /></label>
|
||||
<label>提交 SHA <input name="sha" placeholder="40 位 commit hash" required /></label>
|
||||
<label>基准分支 <input name="base_ref" placeholder="main" /></label>
|
||||
<label>关联 Pull Request 编号(可选) <input name="pr_number" type="number" min="1" /></label>
|
||||
<div class="actions">
|
||||
<button type="submit">加入队列</button>
|
||||
<button type="button" id="manual-close">取消</button>
|
||||
</div>
|
||||
<pre id="manual-result" class="log hidden"></pre>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="modal" class="modal hidden">
|
||||
<div class="modal-body">
|
||||
<h2 id="modal-title">仓库配置</h2>
|
||||
<form id="repo-form">
|
||||
<input type="hidden" name="id" />
|
||||
<div class="grid">
|
||||
<label>所有者 <input name="owner" required /></label>
|
||||
<label>仓库名 <input name="name" required /></label>
|
||||
<label>目标分支 <input name="base_branch" placeholder="main" /></label>
|
||||
<label>监听分支模式 <input name="branch_patterns" placeholder="*, release/*" /></label>
|
||||
<label>审查范围
|
||||
<select name="review_scope">
|
||||
<option value="both">Push 与 PR 都审查</option>
|
||||
<option value="pr">仅 Pull Request</option>
|
||||
<option value="push">仅分支 Push</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>发布方式
|
||||
<select name="publish_mode">
|
||||
<option value="inline">PR 内联评论</option>
|
||||
<option value="issue-only">仅 Issue</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>阻断级别 <input name="block_severity" placeholder="critical,high" /></label>
|
||||
<label>阻断类别 <input name="block_categories" placeholder="security" /></label>
|
||||
<label>Issue 标签 <input name="issue_labels" placeholder="code-review" /></label>
|
||||
<label>最大意见数 <input name="max_comments" type="number" min="1" max="200" /></label>
|
||||
<label>并发数 <input name="concurrency" type="number" min="1" max="16" /></label>
|
||||
<label>排除路径 <input name="excludes" placeholder="**/dist/**,**/*.lock" /></label>
|
||||
<label>仓库专用 Gitea Token <input name="gitea_token" type="password" placeholder="留空使用全局" /></label>
|
||||
<label>仓库专用 LLM Key <input name="llm_token" type="password" placeholder="留空使用全局" /></label>
|
||||
<label>LLM 模型 <input name="llm_model" placeholder="留空使用全局" /></label>
|
||||
<label>审查背景 <input name="background_template" placeholder="补充业务上下文" /></label>
|
||||
</div>
|
||||
<div class="checks">
|
||||
<label><input type="checkbox" name="enabled" /> 启用</label>
|
||||
<label><input type="checkbox" name="create_issue" /> 发现问题时创建 Issue</label>
|
||||
<label><input type="checkbox" name="auto_merge" /> 无阻断问题时自动合并</label>
|
||||
<label><input type="checkbox" name="delete_branch" /> 合并后删除分支</label>
|
||||
<label><input type="checkbox" name="fail_on_findings" /> 有意见即判定失败</label>
|
||||
</div>
|
||||
<div class="grid">
|
||||
<label>自动合并方式
|
||||
<select name="auto_merge_mode">
|
||||
<option value="when_checks_succeed">等待检查通过后合并</option>
|
||||
<option value="immediate">立即合并</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>合并方式
|
||||
<select name="merge_method">
|
||||
<option value="squash">squash</option>
|
||||
<option value="merge">merge</option>
|
||||
<option value="rebase">rebase</option>
|
||||
<option value="rebase-merge">rebase-merge</option>
|
||||
<option value="fast-forward-only">fast-forward-only</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button type="submit">保存</button>
|
||||
<button type="button" id="discover">测试连接</button>
|
||||
<button type="button" id="modal-close">取消</button>
|
||||
</div>
|
||||
<pre id="repo-result" class="log hidden"></pre>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,89 @@
|
||||
:root {
|
||||
--bg: #0f1115;
|
||||
--panel: #171a21;
|
||||
--panel-2: #1e222b;
|
||||
--border: #2a2f3a;
|
||||
--text: #e6e9ef;
|
||||
--muted: #99a2b3;
|
||||
--accent: #4c8dff;
|
||||
--ok: #3fb950;
|
||||
--warn: #d29922;
|
||||
--err: #f85149;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font: 14px/1.5 -apple-system, "Segoe UI", "Microsoft YaHei", sans-serif;
|
||||
}
|
||||
header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 16px 24px; border-bottom: 1px solid var(--border); background: var(--panel);
|
||||
}
|
||||
h1 { font-size: 18px; margin: 0; }
|
||||
h2 { font-size: 15px; margin: 0 0 12px; }
|
||||
.status { color: var(--muted); font-size: 13px; }
|
||||
nav { display: flex; gap: 4px; padding: 12px 24px 0; }
|
||||
.tab {
|
||||
background: transparent; border: 1px solid transparent; color: var(--muted);
|
||||
padding: 8px 14px; border-radius: 8px 8px 0 0; cursor: pointer; font-size: 14px;
|
||||
}
|
||||
.tab.active { background: var(--panel); border-color: var(--border); border-bottom-color: var(--panel); color: var(--text); }
|
||||
main { padding: 0 24px 48px; }
|
||||
.panel { display: none; background: var(--panel); border: 1px solid var(--border); border-radius: 0 8px 8px 8px; padding: 20px; }
|
||||
.panel.active { display: block; }
|
||||
.row { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; }
|
||||
button {
|
||||
background: var(--panel-2); color: var(--text); border: 1px solid var(--border);
|
||||
padding: 7px 14px; border-radius: 6px; cursor: pointer; font-size: 13px;
|
||||
}
|
||||
button:hover { border-color: var(--accent); }
|
||||
button.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
|
||||
button.danger:hover { border-color: var(--err); color: var(--err); }
|
||||
table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||
th, td { text-align: left; padding: 9px 10px; border-bottom: 1px solid var(--border); vertical-align: top; }
|
||||
th { color: var(--muted); font-weight: 500; }
|
||||
tr:hover td { background: rgba(255,255,255,0.02); }
|
||||
code { background: var(--panel-2); padding: 1px 5px; border-radius: 4px; font-size: 12px; }
|
||||
.tag { display: inline-block; padding: 1px 7px; border-radius: 10px; font-size: 12px; border: 1px solid var(--border); }
|
||||
.tag.ok { color: var(--ok); border-color: var(--ok); }
|
||||
.tag.err { color: var(--err); border-color: var(--err); }
|
||||
.tag.warn { color: var(--warn); border-color: var(--warn); }
|
||||
.tag.muted { color: var(--muted); }
|
||||
fieldset { border: 1px solid var(--border); border-radius: 8px; margin: 0 0 18px; padding: 16px; }
|
||||
legend { color: var(--muted); padding: 0 6px; }
|
||||
label { display: block; margin-bottom: 10px; color: var(--muted); font-size: 13px; }
|
||||
input, select {
|
||||
display: block; width: 100%; margin-top: 4px; padding: 7px 9px;
|
||||
background: var(--bg); border: 1px solid var(--border); border-radius: 6px;
|
||||
color: var(--text); font-size: 13px;
|
||||
}
|
||||
.checks { display: flex; flex-wrap: wrap; gap: 16px; margin: 8px 0 14px; }
|
||||
.checks label { display: flex; align-items: center; gap: 6px; margin: 0; }
|
||||
.checks input { width: auto; margin: 0; }
|
||||
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); gap: 0 16px; }
|
||||
.actions { display: flex; gap: 8px; margin-top: 10px; }
|
||||
.hint { color: var(--muted); font-size: 12px; margin: 6px 0 0; }
|
||||
.log {
|
||||
background: #0a0c10; border: 1px solid var(--border); border-radius: 6px;
|
||||
padding: 12px; font-size: 12px; overflow: auto; max-height: 420px; white-space: pre-wrap;
|
||||
}
|
||||
.modal { position: fixed; inset: 0; background: rgba(0,0,0,0.6); display: flex; align-items: flex-start; justify-content: center; padding: 40px 16px; overflow: auto; z-index: 20; }
|
||||
.modal-body { background: var(--panel); border: 1px solid var(--border); border-radius: 10px; padding: 22px; width: min(900px, 100%); }
|
||||
.modal-body.narrow { width: min(460px, 100%); }
|
||||
|
||||
/* Keep this last: it has to beat the display rules above. */
|
||||
.hidden { display: none !important; }
|
||||
.banner {
|
||||
margin: 0 0 16px; padding: 10px 14px; border-radius: 8px; font-size: 13px;
|
||||
border: 1px solid var(--err); color: var(--err); background: rgba(248,81,73,0.08);
|
||||
}
|
||||
.banner.ok { border-color: var(--ok); color: var(--ok); background: rgba(63,185,80,0.08); }
|
||||
.empty { color: var(--muted); padding: 18px 4px; }
|
||||
header .right { display: flex; align-items: center; gap: 12px; }
|
||||
.linkish {
|
||||
background: none; border: none; color: var(--muted); cursor: pointer;
|
||||
font-size: 13px; text-decoration: underline; padding: 0;
|
||||
}
|
||||
.linkish:hover { color: var(--accent); }
|
||||
Reference in New Issue
Block a user