From c1632a0549d7bf6c1216f4f094ba1b1ac86a943c Mon Sep 17 00:00:00 2001 From: lanrtop Date: Sat, 3 Oct 2026 10:18:49 +0900 Subject: [PATCH] =?UTF-8?q?fix(gitea-integration):=20=E5=88=A0=E9=99=A4?= =?UTF-8?q?=E9=A1=B9=E7=9B=AE=E7=99=BB=E8=AE=B0=E6=97=B6=E5=90=8C=E4=B8=80?= =?UTF-8?q?=E4=BA=8B=E5=8A=A1=E8=A7=A3=E9=99=A4=20Gitea=20=E7=BB=91?= =?UTF-8?q?=E5=AE=9A=EF=BC=8C=E8=BD=AE=E8=AF=A2=E8=B7=B3=E8=BF=87=E5=B7=B2?= =?UTF-8?q?=E5=88=A0=E9=99=A4=E9=A1=B9=E7=9B=AE=E7=9A=84=E9=81=97=E7=95=99?= =?UTF-8?q?=E7=BB=91=E5=AE=9A=EF=BC=88G8=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 删除登记(UI 删除项目、MCP unregister_project)原来不清 gitea_repos, 后台轮询每 10 分钟仍把 commit 与 PR 事件写到已删除的项目 ID 下。 - gitea:解绑本体拆为 remove_repo_link_records(只动数据库,可进调用方事务) 与 cleanup_removed_repo_link(提交后尽力删 Gitea 侧 webhook 与本机签名凭证), UI 解绑 gitea_unlink_repo 与删除登记共用 - projects:抽出 delete_project_records(连接注入),关联表、Gitea 绑定、 workspace、孤儿 profile 同一事务删除;delete_project 提交后再做 Gitea 外部清理 - 轮询兜底:load_poll_targets 把所属项目已不存在的遗留绑定排除在 commit / PR 轮询之外,每个绑定每次运行只记一次 warn - 契约:unregister_project 描述与 project-context.yaml 同步 回归测试 deleting_project_registration_unbinds_its_gitea_repo 修复前失败、修复后通过; 变异验证通过;全量 cargo test 307 passed / 0 failed / 5 ignored。 Co-Authored-By: Claude Opus 5.5 --- .blueprint/modules/gitea-integration.md | 11 ++ docs/ai-context/project-context.yaml | 2 +- src-tauri/src/commands/gitea.rs | 225 +++++++++++++++++++----- src-tauri/src/commands/projects.rs | 213 +++++++++++++++++----- src-tauri/src/mcp_tools.rs | 2 +- 5 files changed, 364 insertions(+), 89 deletions(-) diff --git a/.blueprint/modules/gitea-integration.md b/.blueprint/modules/gitea-integration.md index 105eda0..165ca7c 100644 --- a/.blueprint/modules/gitea-integration.md +++ b/.blueprint/modules/gitea-integration.md @@ -197,6 +197,17 @@ Gitea push → POST /webhook/gitea/:project_id - agent_verify: [cargo test] Gitea 轮询目标加载跳过「绑定所属项目已不存在」的遗留绑定并单独返回,供记日志 → 测试通过 - agent_verify: [cargo test] 去掉修复后回归测试失败(变异验证);全量 cargo test 无 FAILED - 来源:重复登记排查会话发现——删除登记不清 Gitea 绑定,后台轮询(每 10 分钟)继续把 commit 与 PR 事件写到已删除的项目 ID 下 +- 实现(2026-10-03,分支 `fix/gitea-unbind-on-unregister`): + - 解绑本体拆成两段、边界只写一处:`gitea::remove_repo_link_records`(只动数据库,可进调用方事务)+ `gitea::cleanup_removed_repo_link`(提交后尽力删除 Gitea 侧 webhook 与本机签名凭证,webhook 删除加 10 秒超时);UI「解绑」`gitea_unlink_repo` 与删除登记共用这两段 + - 删除登记本体 `projects::delete_project_records`(连接注入):关联表、Gitea 绑定、workspace、孤儿 profile 同一事务;UI `delete_project` 与 MCP `unregister_project` 都经它,提交后再做 Gitea 外部清理 + - 轮询兜底 `gitea::load_poll_targets`:所属项目已不存在的遗留绑定不进入 commit / PR 轮询,每个绑定每次运行只记一次 warn(runtime_logs 只留 500 条,逐轮记会挤掉其他日志) + - 契约同步:`unregister_project` 描述补「Gitea 仓库绑定」;`docs/ai-context/project-context.yaml` MCP 工具接口段补一句。Tauri command 签名未变,无前端改动,无 schema 变更 +- verification(2026-10-03): + - 回归测试 `commands::projects::tests::deleting_project_registration_unbinds_its_gitea_repo` 修复前失败(残留绑定 1 条),修复后通过;`gitea_unbinding_rolls_back_with_failed_project_deletion`(触发器让删 workspace 失败 → 绑定仍在)、`commands::gitea::tests::poll_targets_skip_bindings_whose_project_no_longer_exists`、`unlink_removes_binding_and_webhook_secret_reference` 通过 + - 变异验证:去掉删除登记里的解绑调用、去掉轮询的项目存在过滤 → 对应两条测试失败;还原后通过 + - 全量 `cargo test`:307 passed,0 failed,5 ignored + - 只读查询运行中炼境 DB:现有 7 条 Gitea 绑定都属于已登记项目,兜底过滤不会误跳过在用绑定,目前也没有遗留孤儿绑定 + - 待统筹会话合并后实测:在运行中的炼境里删除一个已绑定的测试项目,确认 Gitea 绑定与 webhook 一并清理、下一轮轮询不再写入该 ID ## 决策记录 diff --git a/docs/ai-context/project-context.yaml b/docs/ai-context/project-context.yaml index 90acd44..2748eec 100644 --- a/docs/ai-context/project-context.yaml +++ b/docs/ai-context/project-context.yaml @@ -17,7 +17,7 @@ contracts: - "src-tauri/src/commands/gitea.rs" - "src-tauri/src/commands/dev_variables.rs" - "src-tauri/src/envoy.rs" - doc: "工具 schema、dispatch 与调用说明必须同步;使者回传三件套:report_envoy_return 为项目级(三端在 scoped 地址可用;kind/confidence/suggestion/client 为枚举,scope 为 this_project|all_projects|project_type:<类型> 格式校验,content 压成单行且限长;无凭据字段,服务端对私钥头与常见令牌前缀做兜底拒绝,其余靠描述约束——内容会双写进子项目 .blueprint/notes-archive.jsonl 随项目进 Git;同项目同内容未处置时幂等返回原编号),resolve_envoy_return 与 list_envoy_returns 仅控制面(全局地址),子项目 agent 只回传不处置;list 默认只列未处置并报告已处置计数,include_resolved=true 附列回执,超出展示上限须明说不得静默截断,回传格式首行前缀【炼境使者回传】保持兼容(B4 沉默项目统计按前缀匹配);get_project_resource_status 为项目 scoped 只读状态,仅返回 Gitea PR/webhook 的状态与来源,不返回凭证、引用、URL;旧凭证未迁移时返回 pending_migration,不提前宣称 configured。MCP 不提供 reveal_api_key、get_secret 或任意 HTTP/SSH/SQL/部署代理。明文例外只限 read_project_dev_variable 的单项标量与 read_project_test_account 的单张非生产测试账户;均须匹配 scoped 项目及本机认证。register_project_test_account 只写非秘密需求元数据;列表不返回值,禁止批量导出。production delivery profile 由项目维护,炼境只读 Gitea 部署 context 与公开版本探针,双证据相符才能报告 deployed。get_delivery_status 的 PR/CI/review 默认按项目主工作区当前分支与本地 HEAD 查询;可选 pr_number(优先)或 branch 时改由 Gitea 解析 head 分支与 head SHA(指定分支无开放 PR 时取远端分支 head),CI 结论绑定该 SHA,PR 不存在返回 not_found;顶层 target 标明 mode(local_branch/pull_request/branch)、pr_number、branch、head_sha 与来源,git 字段始终反映主工作区;显式给出的非法 pr_number/branch 直接报错,不得静默回退主工作区。外部来源不可用时返回显式降级状态,不得伪造成功。verify_onboarding 必须同时返回静态接入 checks 与 configured/reachable/handshake/tools_ready/reload_required 运行态证据,配置存在不得替代运行可用。CI workflow 使用 plan_cicd_workflow 只读预览,用户确认相同 digest 后方可 apply_cicd_workflow 写入;该接口不接收凭证值且不触发 CI 或部署。" + doc: "工具 schema、dispatch 与调用说明必须同步;使者回传三件套:report_envoy_return 为项目级(三端在 scoped 地址可用;kind/confidence/suggestion/client 为枚举,scope 为 this_project|all_projects|project_type:<类型> 格式校验,content 压成单行且限长;无凭据字段,服务端对私钥头与常见令牌前缀做兜底拒绝,其余靠描述约束——内容会双写进子项目 .blueprint/notes-archive.jsonl 随项目进 Git;同项目同内容未处置时幂等返回原编号),resolve_envoy_return 与 list_envoy_returns 仅控制面(全局地址),子项目 agent 只回传不处置;list 默认只列未处置并报告已处置计数,include_resolved=true 附列回执,超出展示上限须明说不得静默截断,回传格式首行前缀【炼境使者回传】保持兼容(B4 沉默项目统计按前缀匹配);get_project_resource_status 为项目 scoped 只读状态,仅返回 Gitea PR/webhook 的状态与来源,不返回凭证、引用、URL;旧凭证未迁移时返回 pending_migration,不提前宣称 configured。MCP 不提供 reveal_api_key、get_secret 或任意 HTTP/SSH/SQL/部署代理。明文例外只限 read_project_dev_variable 的单项标量与 read_project_test_account 的单张非生产测试账户;均须匹配 scoped 项目及本机认证。register_project_test_account 只写非秘密需求元数据;列表不返回值,禁止批量导出。production delivery profile 由项目维护,炼境只读 Gitea 部署 context 与公开版本探针,双证据相符才能报告 deployed。get_delivery_status 的 PR/CI/review 默认按项目主工作区当前分支与本地 HEAD 查询;可选 pr_number(优先)或 branch 时改由 Gitea 解析 head 分支与 head SHA(指定分支无开放 PR 时取远端分支 head),CI 结论绑定该 SHA,PR 不存在返回 not_found;顶层 target 标明 mode(local_branch/pull_request/branch)、pr_number、branch、head_sha 与来源,git 字段始终反映主工作区;显式给出的非法 pr_number/branch 直接报错,不得静默回退主工作区。外部来源不可用时返回显式降级状态,不得伪造成功。verify_onboarding 必须同时返回静态接入 checks 与 configured/reachable/handshake/tools_ready/reload_required 运行态证据,配置存在不得替代运行可用。CI workflow 使用 plan_cicd_workflow 只读预览,用户确认相同 digest 后方可 apply_cicd_workflow 写入;该接口不接收凭证值且不触发 CI 或部署。unregister_project 与 UI 删除项目共用同一本体(projects::delete_project_records):同一事务内解除 Gitea 仓库绑定,提交后尽力删除 Gitea 侧 webhook 与本机签名凭证;Gitea 轮询跳过所属项目已不存在的遗留绑定,不向已删除的项目 ID 写 commit 与 PR 事件,每个遗留绑定每次运行只记一次日志。" - label: 被管理项目身份上下文 paths: diff --git a/src-tauri/src/commands/gitea.rs b/src-tauri/src/commands/gitea.rs index cc65860..24249a2 100644 --- a/src-tauri/src/commands/gitea.rs +++ b/src-tauri/src/commands/gitea.rs @@ -1650,49 +1650,76 @@ fn persist_webhook_secret( Ok(()) } -/// 解绑项目与 Gitea 仓库(顺带删除 Gitea 侧 webhook,失败不阻断) -#[tauri::command] -pub fn gitea_unlink_repo(project_id: String) -> Result<(), String> { - let conn = crate::db::pool().get().map_err(|e| e.to_string())?; - let link = match conn.query_row( - "SELECT id, instance_id, project_id, gitea_owner, gitea_repo_name, - clone_url, webhook_id, linked_at - FROM gitea_repos WHERE project_id = ?1", - params![project_id], - row_to_repo, - ) { - Ok(l) => l, - Err(rusqlite::Error::QueryReturnedNoRows) => return Ok(()), - Err(e) => return Err(e.to_string()), - }; - let old_reference: Option = conn - .query_row( - "SELECT webhook_secret_ref FROM gitea_repos WHERE project_id = ?1", - params![project_id], - |row| row.get(0), - ) - .map_err(|_| "读取 webhook 凭证引用失败".to_string())?; +/// 已从数据库解除的项目 ↔ 仓库绑定;提交后交给 [`cleanup_removed_repo_link`] 做外部清理 +pub(crate) struct RemovedRepoLink { + link: GiteaRepo, + webhook_secret_ref: Option, +} +/// 解绑本体的数据库部分(UI 解绑与删除项目登记共用):删除项目的绑定行并返回它,未绑定返回 None。 +/// 不碰网络与受保护存储,可放进调用方事务;外部清理在提交后调用 [`cleanup_removed_repo_link`]。 +pub(crate) fn remove_repo_link_records( + conn: &rusqlite::Connection, + project_id: &str, +) -> Result, String> { + let removed = conn + .query_row( + "SELECT id, instance_id, project_id, gitea_owner, gitea_repo_name, + clone_url, webhook_id, linked_at, webhook_secret_ref + FROM gitea_repos WHERE project_id = ?1", + params![project_id], + |row| { + Ok(RemovedRepoLink { + link: row_to_repo(row)?, + webhook_secret_ref: row.get(8)?, + }) + }, + ) + .optional() + .map_err(|e| e.to_string())?; + if removed.is_some() { + conn.execute( + "DELETE FROM gitea_repos WHERE project_id = ?1", + params![project_id], + ) + .map_err(|e| e.to_string())?; + } + Ok(removed) +} + +/// 解绑本体的外部清理(数据库已提交后调用,尽力而为、失败不阻断): +/// 删除 Gitea 侧 webhook,删除本机受保护存储里的 webhook 签名凭证。 +pub(crate) fn cleanup_removed_repo_link( + conn: &rusqlite::Connection, + store: &impl crate::credential_store::CredentialStore, + removed: &RemovedRepoLink, +) { + let link = &removed.link; if let Some(hook_id) = link.webhook_id { - if let Ok((base_url, token)) = instance_auth(&conn, &link.instance_id) { + if let Ok((base_url, token)) = instance_auth_with_store(conn, store, &link.instance_id) { let _ = ureq::delete(&format!( "{base_url}/api/v1/repos/{}/{}/hooks/{hook_id}", link.gitea_owner, link.gitea_repo_name )) .set("Authorization", &format!("token {token}")) + .timeout(std::time::Duration::from_secs(10)) .call(); } } + if let Some(reference) = &removed.webhook_secret_ref { + let _ = store.delete(reference); + } +} - conn.execute( - "DELETE FROM gitea_repos WHERE project_id = ?1", - params![project_id], - ) - .map_err(|e| e.to_string())?; - if let Some(reference) = old_reference { - let _ = crate::credential_store::CredentialStore::delete( +/// 解绑项目与 Gitea 仓库(顺带删除 Gitea 侧 webhook,失败不阻断) +#[tauri::command] +pub fn gitea_unlink_repo(project_id: String) -> Result<(), String> { + let conn = crate::db::pool().get().map_err(|e| e.to_string())?; + if let Some(removed) = remove_repo_link_records(&conn, &project_id)? { + cleanup_removed_repo_link( + &conn, &crate::credential_store::WindowsCredentialStore, - &reference, + &removed, ); } Ok(()) @@ -1719,23 +1746,67 @@ pub fn parse_gitea_commits(json: &serde_json::Value) -> Vec<(String, String, Opt .unwrap_or_default() } +/// 一条 Gitea 绑定的轮询目标:(project_id, owner, repo, instance_id) +type PollTarget = (String, String, String, String); + +/// 加载轮询目标,返回 (可轮询, 所属项目已不存在的遗留绑定)。 +/// 兜底:删除登记已在同一事务解除绑定,但存量孤儿绑定不能再往已删除的项目 ID 写 commit 与 PR 事件。 +fn load_poll_targets( + conn: &rusqlite::Connection, +) -> Result<(Vec, Vec), String> { + let mut stmt = conn + .prepare( + "SELECT gr.project_id, gr.gitea_owner, gr.gitea_repo_name, gi.id, + EXISTS (SELECT 1 FROM project_workspaces pw WHERE pw.id = gr.project_id) + FROM gitea_repos gr JOIN gitea_instances gi ON gi.id = gr.instance_id", + ) + .map_err(|e| e.to_string())?; + let rows = stmt + .query_map([], |r| { + Ok(( + (r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?), + r.get::<_, bool>(4)?, + )) + }) + .map_err(|e| e.to_string())? + .collect::>>() + .map_err(|e| e.to_string())?; + let (live, orphaned): (Vec<_>, Vec<_>) = rows + .into_iter() + .partition(|(_, project_exists)| *project_exists); + Ok(( + live.into_iter().map(|(target, _)| target).collect(), + orphaned.into_iter().map(|(target, _)| target).collect(), + )) +} + +/// 孤儿绑定每个进程只记一次日志:轮询每 10 分钟一轮,runtime_logs 只保留最新 500 条,逐轮重复会挤掉其他日志 +fn log_orphaned_binding_once(project_id: &str, owner: &str, repo: &str) { + static LOGGED: std::sync::OnceLock>> = + std::sync::OnceLock::new(); + let first_time = LOGGED + .get_or_init(Default::default) + .lock() + .map(|mut logged| logged.insert(project_id.to_string())) + .unwrap_or(true); + if first_time { + crate::db::log_event( + "gitea_poll", + "warn", + &format!("仓库 {owner}/{repo} 绑定的项目 {project_id} 已不存在,跳过轮询(删除登记时遗留的孤儿绑定,需清理)"), + None, + ); + } +} + /// 轮询所有绑定仓库的最新 commit,幂等写入 commit_metrics。 /// 返回 (轮询仓库数, 新写入 commit 数)。纯出站 HTTPS,适配公网 Gitea + 内网炼境拓扑。 pub fn poll_all_repos() -> Result<(usize, usize), String> { let conn = crate::db::pool().get().map_err(|e| e.to_string())?; - let repos: Vec<(String, String, String, String)> = { - let mut stmt = conn - .prepare( - "SELECT gr.project_id, gr.gitea_owner, gr.gitea_repo_name, gi.id - FROM gitea_repos gr JOIN gitea_instances gi ON gi.id = gr.instance_id", - ) - .map_err(|e| e.to_string())?; - let rows = stmt - .query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?))) - .map_err(|e| e.to_string())?; - rows.collect::>>() - .map_err(|e| e.to_string())? - }; + let (repos, orphaned) = load_poll_targets(&conn)?; + for (project_id, owner, repo, _) in &orphaned { + log_orphaned_binding_once(project_id, owner, repo); + } let mut ingested = 0usize; let mut polled = 0usize; @@ -2994,6 +3065,72 @@ mod tests { assert!(result.is_err(), "project_id 唯一索引未生效"); } + #[test] + fn unlink_removes_binding_and_webhook_secret_reference() { + let conn = crate::db::conn_with_schema(); + let store = crate::credential_store::MemoryCredentialStore::new(); + store + .put("gitea/webhook-secret/p/v1", b"synthetic-hook") + .unwrap(); + conn.execute("INSERT INTO gitea_instances (id, name, url, access_token) VALUES ('i', 'test', 'https://example.invalid', '')", []).unwrap(); + conn.execute("INSERT INTO gitea_repos (id, instance_id, project_id, gitea_owner, gitea_repo_name, webhook_secret_ref) VALUES ('r', 'i', 'p', 'owner', 'repo', 'gitea/webhook-secret/p/v1')", []).unwrap(); + + let removed = remove_repo_link_records(&conn, "p") + .unwrap() + .expect("已绑定的项目应返回被解除的绑定"); + cleanup_removed_repo_link(&conn, &store, &removed); + + let count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM gitea_repos WHERE project_id = 'p'", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(count, 0); + assert!(store.get("gitea/webhook-secret/p/v1").unwrap().is_none()); + assert!(remove_repo_link_records(&conn, "p").unwrap().is_none()); + } + + #[test] + fn poll_targets_skip_bindings_whose_project_no_longer_exists() { + let conn = crate::db::conn_with_schema(); + conn.execute("INSERT INTO gitea_instances (id, name, url, access_token) VALUES ('i', 'test', 'https://example.invalid', '')", []).unwrap(); + conn.execute( + "INSERT INTO project_profiles (id, name) VALUES ('prof-live', 'live')", + [], + ) + .unwrap(); + conn.execute( + "INSERT INTO project_workspaces (id, profile_id) VALUES ('live', 'prof-live')", + [], + ) + .unwrap(); + for project_id in ["live", "deleted"] { + conn.execute( + "INSERT INTO gitea_repos (id, instance_id, project_id, gitea_owner, gitea_repo_name) + VALUES (?1, 'i', ?1, 'owner', ?1)", + params![project_id], + ) + .unwrap(); + } + + let (live, orphaned) = load_poll_targets(&conn).unwrap(); + + let ids = |targets: &[PollTarget]| { + targets + .iter() + .map(|target| target.0.clone()) + .collect::>() + }; + assert_eq!(ids(&live), ["live"]); + assert_eq!( + ids(&orphaned), + ["deleted"], + "已删除项目的遗留绑定不得进入轮询" + ); + } + // ── G6:轮询解析 ────────────────────────────────────────────────────── #[test] diff --git a/src-tauri/src/commands/projects.rs b/src-tauri/src/commands/projects.rs index 32e6c3a..3ad2180 100644 --- a/src-tauri/src/commands/projects.rs +++ b/src-tauri/src/commands/projects.rs @@ -1,7 +1,8 @@ +use crate::commands::gitea::{self, RemovedRepoLink}; use crate::db::pool; use crate::mcp_inject; use crate::models::{ActivityLog, Project}; -use rusqlite::params; +use rusqlite::{params, OptionalExtension}; use serde::{Deserialize, Serialize}; use serde_json::Value; @@ -327,19 +328,81 @@ pub fn update_project_tags(id: String, tags: String) -> Result<(), String> { Ok(()) } +/// 删除项目登记的数据库本体(UI 删除按钮与 MCP unregister_project 共用,连接由调用方注入): +/// 同一事务内清理关联表、解除 Gitea 仓库绑定、删除 workspace 与孤儿 profile。 +/// 返回被解除的 Gitea 绑定,调用方在本函数返回(事务已提交)后交给 +/// `gitea::cleanup_removed_repo_link` 删除 Gitea 侧 webhook 与本机签名凭证。 +pub(crate) fn delete_project_records( + conn: &rusqlite::Connection, + id: &str, +) -> Result, String> { + let profile_id: Option = conn + .query_row( + "SELECT profile_id FROM project_workspaces WHERE id = ?1", + params![id], + |row| row.get(0), + ) + .optional() + .map_err(|e| e.to_string())?; + + let tx = conn.unchecked_transaction().map_err(|e| e.to_string())?; + // 清理关联数据(旧 FK 指向 projects 表,需手动清理) + for table in [ + "activity_log", + "project_tools", + "project_group_map", + "project_env_tools", + "deploy_commands", + ] { + tx.execute( + &format!("DELETE FROM {table} WHERE project_id = ?1"), + params![id], + ) + .map_err(|e| e.to_string())?; + } + tx.execute( + "DELETE FROM relations WHERE from_project = ?1 OR to_project = ?1", + params![id], + ) + .map_err(|e| e.to_string())?; + // 不解绑的话,后台 Gitea 轮询会继续把 commit 与 PR 事件写到已删除的项目 ID 下 + let removed_link = gitea::remove_repo_link_records(&tx, id)?; + + tx.execute("DELETE FROM project_workspaces WHERE id = ?1", params![id]) + .map_err(|e| e.to_string())?; + + // 若 profile 已无 workspace,同步删除 profile + if let Some(pid) = profile_id { + let remaining: i64 = tx + .query_row( + "SELECT COUNT(*) FROM project_workspaces WHERE profile_id = ?1", + params![pid], + |row| row.get(0), + ) + .map_err(|e| e.to_string())?; + if remaining == 0 { + tx.execute("DELETE FROM project_profiles WHERE id = ?1", params![pid]) + .map_err(|e| e.to_string())?; + } + } + + tx.commit().map_err(|e| e.to_string())?; + Ok(removed_link) +} + // 会经 claude CLI 清理客户端配置:在线程池执行,不占界面线程 #[tauri::command(async)] pub fn delete_project(id: String, delete_local: Option) -> Result<(), String> { let conn = pool().get().map_err(|e| e.to_string())?; - // 删除前先读取路径和 profile_id - let (profile_id, win_path, wsl_path): (Option, Option, Option) = conn + // 删除前先读取路径(仅删除本地文件夹时使用) + let (win_path, wsl_path): (Option, Option) = conn .query_row( - "SELECT profile_id, win_path, wsl_path FROM project_workspaces WHERE id = ?1", + "SELECT win_path, wsl_path FROM project_workspaces WHERE id = ?1", params![id], - |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), + |row| Ok((row.get(0)?, row.get(1)?)), ) - .unwrap_or((None, None, None)); + .unwrap_or((None, None)); crate::commands::dev_variables::purge_project( &conn, @@ -352,43 +415,12 @@ pub fn delete_project(id: String, delete_local: Option) -> Result<(), Stri eprintln!("[MCP remove] 删除项目时清除失败 project={}: {}", id, e); } - // 清理关联数据(旧 FK 指向 projects 表,需手动清理) - for table in &[ - "activity_log", - "project_tools", - "project_group_map", - "project_env_tools", - "deploy_commands", - ] { - conn.execute( - &format!("DELETE FROM {table} WHERE project_id = ?1"), - params![id], - ) - .map_err(|e| e.to_string())?; - } - conn.execute( - "DELETE FROM relations WHERE from_project = ?1 OR to_project = ?1", - params![id], - ) - .map_err(|e| e.to_string())?; - - // 删除 workspace - conn.execute("DELETE FROM project_workspaces WHERE id = ?1", params![id]) - .map_err(|e| e.to_string())?; - - // 若 profile 已无 workspace,同步删除 profile - if let Some(pid) = profile_id { - let remaining: i64 = conn - .query_row( - "SELECT COUNT(*) FROM project_workspaces WHERE profile_id = ?1", - params![pid], - |row| row.get(0), - ) - .unwrap_or(0); - if remaining == 0 { - conn.execute("DELETE FROM project_profiles WHERE id = ?1", params![pid]) - .map_err(|e| e.to_string())?; - } + if let Some(removed_link) = delete_project_records(&conn, &id)? { + gitea::cleanup_removed_repo_link( + &conn, + &crate::credential_store::WindowsCredentialStore, + &removed_link, + ); } // 删除本地文件夹(仅在明确要求时执行) @@ -496,3 +528,98 @@ pub fn batch_create_projects(items: Vec) -> Result i64 { + conn.query_row( + "SELECT COUNT(*) FROM gitea_repos WHERE project_id = ?1", + params![project_id], + |row| row.get(0), + ) + .unwrap() + } + + fn conn_with_two_bound_projects() -> rusqlite::Connection { + let conn = crate::db::conn_with_schema(); + conn.execute( + "INSERT INTO gitea_instances (id, name, url, access_token) + VALUES ('inst', 'test', 'https://example.invalid', '')", + [], + ) + .unwrap(); + for id in ["gone", "kept"] { + seed_project(&conn, id); + bind_gitea_repo(&conn, id); + } + conn + } + + #[test] + fn deleting_project_registration_unbinds_its_gitea_repo() { + let conn = conn_with_two_bound_projects(); + + delete_project_records(&conn, "gone").unwrap(); + + assert_eq!( + gitea_binding_count(&conn, "gone"), + 0, + "删除登记后不应残留 Gitea 绑定,否则轮询会继续往已删除的项目 ID 写 commit 与 PR 事件" + ); + assert_eq!( + gitea_binding_count(&conn, "kept"), + 1, + "只解除被删项目的绑定" + ); + let workspaces: i64 = conn + .query_row( + "SELECT COUNT(*) FROM project_workspaces WHERE id = 'gone'", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(workspaces, 0); + } + + #[test] + fn gitea_unbinding_rolls_back_with_failed_project_deletion() { + let conn = conn_with_two_bound_projects(); + conn.execute_batch( + "CREATE TRIGGER fail_workspace_delete BEFORE DELETE ON project_workspaces + BEGIN SELECT RAISE(ABORT, 'injected failure'); END;", + ) + .unwrap(); + + assert!(delete_project_records(&conn, "gone").is_err()); + assert_eq!( + gitea_binding_count(&conn, "gone"), + 1, + "解绑与删除登记同一事务:删除失败时绑定不得被单独解除" + ); + } +} diff --git a/src-tauri/src/mcp_tools.rs b/src-tauri/src/mcp_tools.rs index 304adc0..c9df25d 100644 --- a/src-tauri/src/mcp_tools.rs +++ b/src-tauri/src/mcp_tools.rs @@ -344,7 +344,7 @@ pub fn tools_list_result() -> Value { }, { "name": "unregister_project", - "description": "从炼境删除一条项目登记(等价 UI 删除项目按钮的 agent 投影):清理产品组 MCP 配置、关联表、workspace 与孤儿 profile。绝不删除本地文件(该能力不对 agent 开放)。适用:scan_unregistered_projects 的反向操作——清理目录已消失或已无 .git 的死登记。删除不可逆,调用前先向用户列出目标清单确认。", + "description": "从炼境删除一条项目登记(等价 UI 删除项目按钮的 agent 投影):清理产品组 MCP 配置、关联表、Gitea 仓库绑定(含 Gitea 侧 webhook 与本机签名凭证)、workspace 与孤儿 profile。绝不删除本地文件(该能力不对 agent 开放)。适用:scan_unregistered_projects 的反向操作——清理目录已消失或已无 .git 的死登记。删除不可逆,调用前先向用户列出目标清单确认。", "inputSchema": { "type": "object", "properties": {