// Gitea 集成,拆两半(ui-restructure N4): // - GiteaInstancesSection:实例管理(全局配置)→ 挂设置页 // - GiteaPanel:当前项目仓库绑定 + webhook(项目级)→ 留项目蓝图治理面板 // 控制面在 gitea.rs,观测面(webhook 接收)在 mcp_server.rs import { useState } from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { giteaSaveInstance, giteaListInstances, giteaDeleteInstance, giteaUpdateInstance, giteaTestConnection, giteaGetRepoLink, giteaListLinkedRepos, giteaCreateRepo, giteaMigrateRepo, giteaLinkRepo, giteaSetupWebhook, giteaUnlinkRepo, giteaPollNow, scanLocalGit, gitInitAndCommit, gitRemoteSet, gitPush, type GiteaInstance, } from "../../lib/commands"; import { useUIStore } from "../../store/ui"; type BindMode = "create" | "migrate" | "link"; const CALLBACK_BASE_KEY = "gitea_callback_base"; const inputCls = "w-full text-[11px] border border-gray-200 rounded px-2 py-1.5 outline-none focus:border-blue-400"; const btnCls = "px-2 py-1 rounded text-[11px] border border-gray-200 text-gray-500 hover:bg-gray-50 transition-colors"; // ── 实例管理(全局,挂设置页)──────────────────────────────────────────────── export function GiteaInstancesSection() { const qc = useQueryClient(); const showToast = useUIStore((s) => s.showToast); const { data: instances = [] } = useQuery({ queryKey: ["gitea-instances"], queryFn: giteaListInstances, }); const [showAdd, setShowAdd] = useState(false); const [instName, setInstName] = useState(""); const [instUrl, setInstUrl] = useState(""); const [instToken, setInstToken] = useState(""); const [testing, setTesting] = useState(null); const [editingTokenId, setEditingTokenId] = useState(null); const [newToken, setNewToken] = useState(""); const handleUpdateToken = async (id: string) => { if (!newToken.trim()) return; try { await giteaUpdateInstance(id, { access_token: newToken.trim() }); qc.invalidateQueries({ queryKey: ["gitea-instances"] }); setEditingTokenId(null); setNewToken(""); showToast("Token 已更新 ✓ 可点「测试连接」验证"); } catch (e) { showToast(`❌ ${e}`); } }; const handleAdd = async () => { if (!instName.trim() || !instUrl.trim() || !instToken.trim()) return; try { await giteaSaveInstance({ name: instName.trim(), url: instUrl.trim(), access_token: instToken.trim(), is_default: instances.length === 0, }); qc.invalidateQueries({ queryKey: ["gitea-instances"] }); setInstName(""); setInstUrl(""); setInstToken(""); setShowAdd(false); showToast("Gitea 实例已添加 ✓"); } catch (e) { showToast(`❌ ${e}`); } }; const handleTest = async (inst: GiteaInstance) => { setTesting(inst.id); try { const user = await giteaTestConnection(inst.id); showToast(`✓ 连接成功,账户: ${user.login}`); } catch (e) { showToast(`❌ ${e}`); } finally { setTesting(null); } }; const handleDelete = async (inst: GiteaInstance) => { if (!confirm(`删除实例「${inst.name}」?其下所有项目绑定会一并删除。`)) return; try { await giteaDeleteInstance(inst.id); qc.invalidateQueries({ queryKey: ["gitea-instances"] }); qc.invalidateQueries({ queryKey: ["gitea-linked-repos"] }); showToast("已删除"); } catch (e) { showToast(`❌ ${e}`); } }; return (

🪁 Gitea 实例

{!showAdd && ( )}
{instances.length === 0 && !showAdd && (

尚未添加 Gitea 实例——添加后可在各项目蓝图的治理面板中绑定仓库

)} {instances.map((inst) => (
{inst.name} {inst.is_default && ( 默认 )} {inst.url}
{editingTokenId === inst.id && (
setNewToken(e.target.value)} autoFocus />
)}
))} {showAdd && (
setInstName(e.target.value)} /> setInstUrl(e.target.value)} /> setInstToken(e.target.value)} />
)}
); } // ── 项目级绑定 + webhook(留项目蓝图治理面板)──────────────────────────────── interface Props { projectId: string; projectName: string; projectPath?: string; } export function GiteaPanel({ projectId, projectName, projectPath }: Props) { const qc = useQueryClient(); const showToast = useUIStore((s) => s.showToast); const { data: instances = [] } = useQuery({ queryKey: ["gitea-instances"], queryFn: giteaListInstances, }); const { data: link } = useQuery({ queryKey: ["gitea-link", projectId], queryFn: () => giteaGetRepoLink(projectId), }); const { data: allLinks = [] } = useQuery({ queryKey: ["gitea-linked-repos"], queryFn: giteaListLinkedRepos, }); // G7 一键接线:绑定后检测本地 git 状态(无 git / 无 origin / 已接线) const { data: localGit, refetch: refetchLocalGit } = useQuery({ queryKey: ["gitea-local-git", projectId], queryFn: () => scanLocalGit(projectPath!), enabled: !!link && !!projectPath, staleTime: 30000, retry: false, }); const hasOrigin = localGit?.remotes.some((r) => r.name === "origin") ?? false; const needsWiring = !!link && !!projectPath && localGit != null && (!localGit.has_git || !hasOrigin); const [wiring, setWiring] = useState(false); const handleWireUp = async () => { if (!projectPath || !link?.clone_url) return; setWiring(true); try { if (!localGit?.has_git || !localGit?.has_commits) { await gitInitAndCommit(projectPath, "chore: 项目初始化(炼境接线)"); } await gitRemoteSet(projectPath, link.clone_url); const msg = await gitPush(projectPath); showToast(`✓ 接线完成:${msg}`); refetchLocalGit(); } catch (e) { showToast(`❌ 接线失败: ${e}`); } finally { setWiring(false); } }; const refresh = () => { qc.invalidateQueries({ queryKey: ["gitea-link", projectId] }); qc.invalidateQueries({ queryKey: ["gitea-linked-repos"] }); }; // ── 绑定操作 ── const [bindMode, setBindMode] = useState(null); const [bindInstanceId, setBindInstanceId] = useState(""); const [repoName, setRepoName] = useState(""); const [repoOwner, setRepoOwner] = useState(""); const [cloneAddr, setCloneAddr] = useState(""); const [migrateToken, setMigrateToken] = useState(""); const [binding, setBinding] = useState(false); const effectiveInstanceId = bindInstanceId || instances.find((i) => i.is_default)?.id || instances[0]?.id || ""; const handleBind = async () => { if (!effectiveInstanceId || !repoName.trim()) return; setBinding(true); try { if (bindMode === "create") { await giteaCreateRepo(effectiveInstanceId, projectId, repoName.trim(), projectName, true); } else if (bindMode === "migrate") { if (!cloneAddr.trim()) { showToast("❌ 请填写源仓库地址"); return; } await giteaMigrateRepo( effectiveInstanceId, projectId, cloneAddr.trim(), repoName.trim(), migrateToken.trim() || undefined, true ); } else if (bindMode === "link") { if (!repoOwner.trim()) { showToast("❌ 请填写仓库 owner"); return; } await giteaLinkRepo(effectiveInstanceId, projectId, repoOwner.trim(), repoName.trim()); } refresh(); setBindMode(null); setRepoName(""); setRepoOwner(""); setCloneAddr(""); setMigrateToken(""); showToast("仓库绑定成功 ✓"); } catch (e) { showToast(`❌ ${e}`); } finally { setBinding(false); } }; // ── Webhook ── const [callbackBase, setCallbackBase] = useState( () => localStorage.getItem(CALLBACK_BASE_KEY) ?? "http://127.0.0.1:27190" ); const [settingWebhook, setSettingWebhook] = useState(false); const callbackUrl = `${callbackBase.replace(/\/+$/, "")}/webhook/gitea/${projectId}`; const handleSetupWebhook = async () => { setSettingWebhook(true); try { localStorage.setItem(CALLBACK_BASE_KEY, callbackBase); await giteaSetupWebhook(projectId, callbackBase); refresh(); showToast("Webhook 已注册 ✓ push 事件将实时进入飞轮"); } catch (e) { showToast(`❌ ${e}`); } finally { setSettingWebhook(false); } }; const handleUnlink = async () => { if (!confirm("解绑当前项目与 Gitea 仓库?(Gitea 侧仓库不会删除)")) return; try { await giteaUnlinkRepo(projectId); refresh(); showToast("已解绑"); } catch (e) { showToast(`❌ ${e}`); } }; const [polling, setPolling] = useState(false); const handlePollNow = async () => { setPolling(true); try { const r = await giteaPollNow(); showToast(`✓ 轮询 ${r.polled} 个仓库,新采集 ${r.ingested} 条 commit`); } catch (e) { showToast(`❌ ${e}`); } finally { setPolling(false); } }; const copy = (text: string) => { navigator.clipboard.writeText(text).catch(() => {}); showToast("已复制 ✓"); }; return (

🪁 Gitea 绑定

{/* ── 当前项目绑定 ── */}
{link ? ( <>
已绑定 {link.gitea_owner}/{link.gitea_repo_name} {link.clone_url && ( )}
{/* G7 本地接线:仓库已建但本地未连通时的一次性引导 */} {needsWiring && (

{!localGit?.has_git ? "本地目录还不是 git 仓库" : "本地仓库尚未关联远端 origin"}

{!localGit?.has_git ? "将执行:git init + 首次提交 + 关联 origin + 推送" : "将执行:关联 origin + 推送当前分支"}

接线是一次性动作;日常 commit / push 在项目自己的 AI 会话中进行。

)} {/* webhook 状态与注册 */}
{link.webhook_id ? `Webhook 已注册(#${link.webhook_id})` : "Webhook 未注册"}
setCallbackBase(e.target.value)} placeholder="Gitea 服务器可达的宿主机地址,如 http://192.168.1.5:27190" />
{callbackUrl}

需 Gitea 服务器能访问上述地址(MCP 端口默认 27190)。push 事件将实时写入飞轮 commit 数据。

公网 Gitea + 内网炼境时 webhook 不可达——已由后台轮询兜底(每 10 分钟出站拉取)。

) : instances.length === 0 ? (

尚未配置 Gitea 实例——请先在「设置 → Gitea 实例」中添加

) : ( <>
本项目未绑定: {(["create", "migrate", "link"] as BindMode[]).map((m) => ( ))}
{bindMode && (
{instances.length > 1 && ( )} {bindMode === "migrate" && ( <> setCloneAddr(e.target.value)} /> setMigrateToken(e.target.value)} /> )} {bindMode === "link" && ( setRepoOwner(e.target.value)} /> )} setRepoName(e.target.value)} />
)} )}
{/* ── 全部绑定概览 ── */} {allLinks.length > 0 && (

全部绑定({allLinks.length})

{allLinks.map((r) => (
{r.gitea_owner}/{r.gitea_repo_name} {r.instance_name}
))}
)}
); }