diff --git a/.blueprint/modules/gitea-integration.md b/.blueprint/modules/gitea-integration.md index 165ca7c..008a6aa 100644 --- a/.blueprint/modules/gitea-integration.md +++ b/.blueprint/modules/gitea-integration.md @@ -199,15 +199,22 @@ Gitea push → POST /webhook/gitea/:project_id - 来源:重复登记排查会话发现——删除登记不清 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 条,逐轮记会挤掉其他日志) + - 删除登记本体 `projects::delete_project_registration`(连接与凭证存储注入)= `delete_project_records`(关联表、Gitea 绑定、workspace、孤儿 profile 同一事务)+ 提交后 Gitea 外部清理;UI `delete_project`(薄包装)与 MCP `unregister_project` 都经它 + - 轮询兜底 `gitea::load_poll_targets`:所属项目已不存在的遗留绑定不进入 commit / PR 轮询,每个绑定每次运行只记一次 warn(runtime_logs 只留 500 条,逐轮记会挤掉其他日志),日志写明清理路径(对该 ID 调 unregister_project) + - 轮询写入前复核(审查后补):commit 与 PR 的写入抽成 `ingest_polled_commits` / `ingest_polled_prs`,各自先用 `poll_target_still_bound` 复核绑定仍在(owner/repo 一致)且项目仍登记,不在就不写——每轮开头的快照到写入之间可能隔着数分钟的 HTTP 请求 + - UI「解绑」`gitea_unlink_repo` 的读与删放进同一事务(审查后补,与删除登记同写法) - 契约同步:`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 绑定都属于已登记项目,兜底过滤不会误跳过在用绑定,目前也没有遗留孤儿绑定 + - 审查处置后新增 3 条测试:`commands::projects::tests::deleting_project_registration_removes_its_webhook_secret`(MemoryCredentialStore,webhook_id 为 NULL 不发网络;被删项目的签名凭证已删、另一项目的保留)、`unregistering_orphaned_binding_id_cleans_the_binding`(workspace 已删、绑定仍在 → 对该 ID 删除登记后绑定与凭证都清掉)、`commands::gitea::tests::polled_results_are_dropped_when_binding_is_gone_mid_round`(快照含三个项目,之后一个删除登记、一个 UI 解绑 → 只有仍绑定的项目写入 commit 与 PR 事件)。既有断言未改 + - 审查处置后变异验证:`delete_project_registration` 去掉外部清理 → 两条 projects 新测试失败;`ingest_polled_commits` 去掉复核 → 「deleted 写了 commit」失败;`ingest_polled_prs` 去掉复核 → 「deleted 写了 PR 事件」失败;复核只查项目、不查绑定 → 「unlinked 写了 commit」失败。均从暂存区还原后通过 + - 审查处置后全量 `cargo test`:310 passed,0 failed,5 ignored - 待统筹会话合并后实测:在运行中的炼境里删除一个已绑定的测试项目,确认 Gitea 绑定与 webhook 一并清理、下一轮轮询不再写入该 ID +- review: "2026-10-03 独立审查(只读审查 c1632a0,结论可合并)5 条发现处置:① 低:删除登记提交后的 Gitea 外部清理没有测试 → 采纳。抽出连接与存储注入的本体 `projects::delete_project_registration`,`delete_project` 只做薄包装,补 `deleting_project_registration_removes_its_webhook_secret`;② 低:轮询目标只在每轮开头取一次快照,删除提交后本轮剩余时间仍会写入 → 采纳。commit 与每页 PR 写入前都复核;比审查建议多核对绑定行与 owner/repo,所以 UI 解绑和换绑也同样生效;补 `polled_results_are_dropped_when_binding_is_gone_mid_round`,窗口缩到复核到写入之间的毫秒级;③ 提示:孤儿绑定日志只写了「需清理」→ 采纳。日志写明对该 ID 调 MCP unregister_project,并补 `unregistering_orphaned_binding_id_cleans_the_binding`,用测试校验这条清理路径的数据库与凭证部分;④ 提示:`gitea_unlink_repo` 的读与删不在同一事务(原有问题)→ 采纳(改动 3 行)。用 unchecked_transaction 包住 remove_repo_link_records 再提交,并发设置 webhook 时由 WAL 读快照过期报 busy、整体回滚,不再出现删新行、清旧凭证的错配;没有并发测试;⑤ 提示:生产连接池只有迁移连接开了外键 → 不成立。rusqlite 的 bundled 特性用 `-DSQLITE_DEFAULT_FOREIGN_KEYS=1` 编译 SQLite(libsqlite3-sys 0.30.1 build.rs 第 123 行),每条新连接默认 foreign_keys=1。临时探针测试从 r2d2 内存池取 3 条连接、另开 1 条裸连接,PRAGMA foreign_keys 均为 1,跑完已还原,未提交。测试与生产一致,ON DELETE CASCADE 在生产照常生效。本卡没有改任何既有断言。" +- known_limits: "轮询复核与写入之间还剩毫秒级的非原子窗口:复核之后、写入之前恰好提交的删除,仍可能让一页数据写进去。要彻底消除,得用 BEGIN IMMEDIATE 把复核和写入包在一起,代价是轮询与 UI 写入抢写锁,收益抵不上成本,所以没做。gitea_unlink_repo 的事务化只是按 SQLite WAL 语义推理的结果,没做并发实测(铁律禁止对真实 DB 做并发实验)。薄包装 delete_project 本身没有测试:取连接、purge_project、remove_project_mcp 和本体的先后顺序都没测,只测了本体 delete_project_registration。webhook_id 非空时删除 Gitea 侧 hook 的分支没对真实 Gitea 验证过,测试特意把它设成 NULL,避免发网络请求。外键默认开启依赖 rusqlite 的 bundled 特性;改用系统 SQLite 时要在连接管理器 with_init 里显式开启。" ## 决策记录 diff --git a/docs/ai-context/project-context.yaml b/docs/ai-context/project-context.yaml index 2748eec..aa4b317 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 或部署。unregister_project 与 UI 删除项目共用同一本体(projects::delete_project_records):同一事务内解除 Gitea 仓库绑定,提交后尽力删除 Gitea 侧 webhook 与本机签名凭证;Gitea 轮询跳过所属项目已不存在的遗留绑定,不向已删除的项目 ID 写 commit 与 PR 事件,每个遗留绑定每次运行只记一次日志。" + 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_registration):同一事务内解除 Gitea 仓库绑定,提交后尽力删除 Gitea 侧 webhook 与本机签名凭证;Gitea 轮询跳过所属项目已不存在的遗留绑定,且每个仓库写入前复核绑定仍在、项目仍登记,不向已删除或已解绑的项目 ID 写 commit 与 PR 事件;每个遗留绑定每次运行只记一次日志,日志写明清理路径(对该 ID 调 unregister_project)。" - label: 被管理项目身份上下文 paths: diff --git a/src-tauri/src/commands/gitea.rs b/src-tauri/src/commands/gitea.rs index 24249a2..cb39062 100644 --- a/src-tauri/src/commands/gitea.rs +++ b/src-tauri/src/commands/gitea.rs @@ -1715,7 +1715,13 @@ pub(crate) fn cleanup_removed_repo_link( #[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)? { + // 读与删放进同一事务(与删除登记路径同写法):并发的 webhook 设置若在读与删之间提交了 + // 对绑定行的修改,删除会因读快照过期而报 busy、整体回滚,而不是删掉新行却按旧快照清理 + // (新 hook 与新凭证泄漏、旧凭证被删) + let tx = conn.unchecked_transaction().map_err(|e| e.to_string())?; + let removed = remove_repo_link_records(&tx, &project_id)?; + tx.commit().map_err(|e| e.to_string())?; + if let Some(removed) = removed { cleanup_removed_repo_link( &conn, &crate::credential_store::WindowsCredentialStore, @@ -1793,12 +1799,74 @@ fn log_orphaned_binding_once(project_id: &str, owner: &str, repo: &str) { crate::db::log_event( "gitea_poll", "warn", - &format!("仓库 {owner}/{repo} 绑定的项目 {project_id} 已不存在,跳过轮询(删除登记时遗留的孤儿绑定,需清理)"), + &format!("仓库 {owner}/{repo} 绑定的项目 {project_id} 已不存在,跳过轮询(删除登记时遗留的孤儿绑定)。清理:对该 ID 调 MCP unregister_project,会删除这条绑定并清理 Gitea 侧 webhook 与本机签名凭证"), None, ); } } +/// 轮询写入前复核:这条绑定仍在、所属项目仍登记。轮询目标只在每轮开头取一次快照, +/// 逐仓库的 HTTP 请求没有总时限,一轮可能持续数分钟;期间被删除登记或解绑的项目 +/// 不得再按旧快照写入。查询失败按「已不在」处理:本轮少写,下一轮重新拉取、幂等补写。 +fn poll_target_still_bound( + conn: &rusqlite::Connection, + project_id: &str, + owner: &str, + repo: &str, +) -> bool { + conn.query_row( + "SELECT EXISTS ( + SELECT 1 FROM gitea_repos gr + JOIN project_workspaces pw ON pw.id = gr.project_id + WHERE gr.project_id = ?1 AND gr.gitea_owner = ?2 AND gr.gitea_repo_name = ?3 + )", + params![project_id, owner, repo], + |r| r.get(0), + ) + .unwrap_or(false) +} + +/// 把一个仓库拉到的 commit 幂等写入 commit_metrics,返回新写入条数。 +/// 写入前复核绑定(见 [`poll_target_still_bound`])。 +fn ingest_polled_commits( + conn: &rusqlite::Connection, + project_id: &str, + owner: &str, + repo: &str, + json: &serde_json::Value, +) -> usize { + if !poll_target_still_bound(conn, project_id, owner, repo) { + return 0; + } + // U3:按 project_id 加载规则 ID 集合(每仓库一次),剔除 trailer 里的未知 ID + let valid_rules = + crate::commands::commit_metrics::load_rule_ids_by_project_id(conn, project_id); + let mut ingested = 0; + for (sha, message, date) in parse_gitea_commits(json) { + let before: i64 = conn + .query_row( + "SELECT COUNT(*) FROM commit_metrics WHERE sha = ?1", + params![sha], + |r| r.get(0), + ) + .unwrap_or(0); + if before == 0 + && crate::commands::commit_metrics::record_commit_validated( + conn, + project_id, + &sha, + &message, + date.as_deref(), + valid_rules.as_ref(), + ) + .is_ok() + { + ingested += 1; + } + } + ingested +} + /// 轮询所有绑定仓库的最新 commit,幂等写入 commit_metrics。 /// 返回 (轮询仓库数, 新写入 commit 数)。纯出站 HTTPS,适配公网 Gitea + 内网炼境拓扑。 pub fn poll_all_repos() -> Result<(usize, usize), String> { @@ -1838,31 +1906,7 @@ pub fn poll_all_repos() -> Result<(usize, usize), String> { Err(_) => continue, }; polled += 1; - // U3:按 project_id 加载规则 ID 集合(每仓库一次),剔除 trailer 里的未知 ID - let valid_rules = - crate::commands::commit_metrics::load_rule_ids_by_project_id(&conn, project_id); - for (sha, message, date) in parse_gitea_commits(&json) { - let before: i64 = conn - .query_row( - "SELECT COUNT(*) FROM commit_metrics WHERE sha = ?1", - params![sha], - |r| r.get(0), - ) - .unwrap_or(0); - if before == 0 - && crate::commands::commit_metrics::record_commit_validated( - &conn, - project_id, - &sha, - &message, - date.as_deref(), - valid_rules.as_ref(), - ) - .is_ok() - { - ingested += 1; - } - } + ingested += ingest_polled_commits(&conn, project_id, owner, repo, &json); } if ingested > 0 { @@ -1922,46 +1966,63 @@ fn poll_pr_events( Ok(j) => j, Err(_) => continue, }; - let prs = match prs.as_array() { - Some(a) => a, + count += ingest_polled_prs(conn, project_id, owner, repo, &prs); + } + count +} + +/// 把一个仓库拉到的一页 PR 中未记录的事件写入 pr_events,返回新增条数。 +/// 写入前复核绑定(见 [`poll_target_still_bound`])。 +fn ingest_polled_prs( + conn: &rusqlite::Connection, + project_id: &str, + owner: &str, + repo: &str, + prs: &serde_json::Value, +) -> usize { + let prs = match prs.as_array() { + Some(a) => a, + None => return 0, + }; + if !poll_target_still_bound(conn, project_id, owner, repo) { + return 0; + } + let mut count = 0; + for pr in prs { + let pr_number = match pr["number"].as_i64() { + Some(n) => n, None => continue, }; - for pr in prs { - let pr_number = match pr["number"].as_i64() { - Some(n) => n, - None => continue, - }; - let title = pr["title"].as_str().unwrap_or("").to_string(); - let head_branch = pr["head"]["ref"].as_str().unwrap_or("").to_string(); - let base_branch = pr["base"]["ref"].as_str().unwrap_or("").to_string(); - let author = pr["user"]["login"].as_str().unwrap_or("").to_string(); - let merged = pr["merged"].as_bool().unwrap_or(false); - let pr_state = pr["state"].as_str().unwrap_or("open"); + let title = pr["title"].as_str().unwrap_or("").to_string(); + let head_branch = pr["head"]["ref"].as_str().unwrap_or("").to_string(); + let base_branch = pr["base"]["ref"].as_str().unwrap_or("").to_string(); + let author = pr["user"]["login"].as_str().unwrap_or("").to_string(); + let merged = pr["merged"].as_bool().unwrap_or(false); + let pr_state = pr["state"].as_str().unwrap_or("open"); - // 确定动作:已合并 > 关闭 > 开启 - let action = if merged { - "merged" - } else if pr_state == "closed" { - "closed" - } else { - "opened" - }; + // 确定动作:已合并 > 关闭 > 开启 + let action = if merged { + "merged" + } else if pr_state == "closed" { + "closed" + } else { + "opened" + }; - let existing: i64 = conn.query_row( - "SELECT COUNT(*) FROM pr_events WHERE project_id = ?1 AND pr_number = ?2 AND action = ?3", - params![project_id, pr_number, action], - |r| r.get(0), - ).unwrap_or(0); + let existing: i64 = conn.query_row( + "SELECT COUNT(*) FROM pr_events WHERE project_id = ?1 AND pr_number = ?2 AND action = ?3", + params![project_id, pr_number, action], + |r| r.get(0), + ).unwrap_or(0); - if existing == 0 { - let id = uuid::Uuid::new_v4().to_string(); - if conn.execute( - "INSERT INTO pr_events (id, project_id, pr_number, title, action, head_branch, base_branch, author) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", - params![id, project_id, pr_number, title, action, head_branch, base_branch, author], - ).is_ok() { - count += 1; - } + if existing == 0 { + let id = uuid::Uuid::new_v4().to_string(); + if conn.execute( + "INSERT INTO pr_events (id, project_id, pr_number, title, action, head_branch, base_branch, author) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", + params![id, project_id, pr_number, title, action, head_branch, base_branch, author], + ).is_ok() { + count += 1; } } } @@ -3131,6 +3192,70 @@ mod tests { ); } + #[test] + fn polled_results_are_dropped_when_binding_is_gone_mid_round() { + 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(); + for project_id in ["live", "deleted", "unlinked"] { + conn.execute( + "INSERT INTO project_profiles (id, name) VALUES (?1, ?1)", + params![format!("prof-{project_id}")], + ) + .unwrap(); + conn.execute( + "INSERT INTO project_workspaces (id, profile_id) VALUES (?1, ?2)", + params![project_id, format!("prof-{project_id}")], + ) + .unwrap(); + 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(); + } + // 本轮开头的快照包含三者;HTTP 请求进行期间,一个项目被删除登记、一个被 UI 解绑 + let (targets, _) = load_poll_targets(&conn).unwrap(); + assert_eq!(targets.len(), 3); + crate::commands::projects::delete_project_records(&conn, "deleted").unwrap(); + remove_repo_link_records(&conn, "unlinked").unwrap(); + + for (project_id, owner, repo, _) in &targets { + let commits = serde_json::json!([{ + "sha": format!("sha-{project_id}"), + "commit": { "message": "fix: 轮询写入", "committer": { "date": "2026-10-03T00:00:00Z" } } + }]); + let prs = serde_json::json!([{ + "number": 1, "title": "t", "state": "open", "merged": false, + "head": { "ref": "feat/x" }, "base": { "ref": "master" }, "user": { "login": "u" } + }]); + let expected = usize::from(project_id == "live"); + assert_eq!( + ingest_polled_commits(&conn, project_id, owner, repo, &commits), + expected, + "{project_id}: 绑定已不在时不得按旧快照写 commit" + ); + assert_eq!( + ingest_polled_prs(&conn, project_id, owner, repo, &prs), + expected, + "{project_id}: 绑定已不在时不得按旧快照写 PR 事件" + ); + } + let rows = |table: &str| -> Vec { + let mut stmt = conn + .prepare(&format!( + "SELECT project_id FROM {table} ORDER BY project_id" + )) + .unwrap(); + stmt.query_map([], |r| r.get(0)) + .unwrap() + .collect::>>() + .unwrap() + }; + assert_eq!(rows("commit_metrics"), ["live"]); + assert_eq!(rows("pr_events"), ["live"]); + } + // ── G6:轮询解析 ────────────────────────────────────────────────────── #[test] diff --git a/src-tauri/src/commands/projects.rs b/src-tauri/src/commands/projects.rs index 3ad2180..6d772a0 100644 --- a/src-tauri/src/commands/projects.rs +++ b/src-tauri/src/commands/projects.rs @@ -328,10 +328,10 @@ pub fn update_project_tags(id: String, tags: String) -> Result<(), String> { Ok(()) } -/// 删除项目登记的数据库本体(UI 删除按钮与 MCP unregister_project 共用,连接由调用方注入): +/// 删除项目登记的数据库部分(连接由调用方注入;生产调用一律经 [`delete_project_registration`]): /// 同一事务内清理关联表、解除 Gitea 仓库绑定、删除 workspace 与孤儿 profile。 -/// 返回被解除的 Gitea 绑定,调用方在本函数返回(事务已提交)后交给 -/// `gitea::cleanup_removed_repo_link` 删除 Gitea 侧 webhook 与本机签名凭证。 +/// 返回被解除的 Gitea 绑定,供事务提交后交给 `gitea::cleanup_removed_repo_link` +/// 删除 Gitea 侧 webhook 与本机签名凭证。 pub(crate) fn delete_project_records( conn: &rusqlite::Connection, id: &str, @@ -390,6 +390,20 @@ pub(crate) fn delete_project_records( Ok(removed_link) } +/// 删除项目登记本体:数据库部分([`delete_project_records`],单事务)提交后, +/// 再对被解除的 Gitea 绑定做外部清理(Gitea 侧 webhook 与本机签名凭证,尽力而为)。 +/// 连接与凭证存储由调用方注入;UI `delete_project` 与 MCP `unregister_project` 经它删除登记。 +pub(crate) fn delete_project_registration( + conn: &rusqlite::Connection, + store: &impl crate::credential_store::CredentialStore, + id: &str, +) -> Result<(), String> { + if let Some(removed_link) = delete_project_records(conn, id)? { + gitea::cleanup_removed_repo_link(conn, store, &removed_link); + } + Ok(()) +} + // 会经 claude CLI 清理客户端配置:在线程池执行,不占界面线程 #[tauri::command(async)] pub fn delete_project(id: String, delete_local: Option) -> Result<(), String> { @@ -415,13 +429,7 @@ pub fn delete_project(id: String, delete_local: Option) -> Result<(), Stri eprintln!("[MCP remove] 删除项目时清除失败 project={}: {}", id, e); } - if let Some(removed_link) = delete_project_records(&conn, &id)? { - gitea::cleanup_removed_repo_link( - &conn, - &crate::credential_store::WindowsCredentialStore, - &removed_link, - ); - } + delete_project_registration(&conn, &crate::credential_store::WindowsCredentialStore, &id)?; // 删除本地文件夹(仅在明确要求时执行) if delete_local.unwrap_or(false) { @@ -622,4 +630,60 @@ mod tests { "解绑与删除登记同一事务:删除失败时绑定不得被单独解除" ); } + + /// 给绑定挂上 webhook 签名凭证引用,并在存储里放入对应凭证;webhook_id 保持 NULL,清理不发网络请求 + fn attach_webhook_secret( + conn: &rusqlite::Connection, + store: &crate::credential_store::MemoryCredentialStore, + project_id: &str, + ) -> String { + use crate::credential_store::CredentialStore; + let reference = format!("gitea/webhook-secret/{project_id}/v1"); + store.put(&reference, b"synthetic-hook").unwrap(); + conn.execute( + "UPDATE gitea_repos SET webhook_secret_ref = ?1 WHERE project_id = ?2", + params![reference, project_id], + ) + .unwrap(); + reference + } + + #[test] + fn deleting_project_registration_removes_its_webhook_secret() { + use crate::credential_store::CredentialStore; + let conn = conn_with_two_bound_projects(); + let store = crate::credential_store::MemoryCredentialStore::new(); + let gone_ref = attach_webhook_secret(&conn, &store, "gone"); + let kept_ref = attach_webhook_secret(&conn, &store, "kept"); + + delete_project_registration(&conn, &store, "gone").unwrap(); + + assert_eq!(gitea_binding_count(&conn, "gone"), 0); + assert!( + store.get(&gone_ref).unwrap().is_none(), + "删除登记提交后应清理被删项目的 webhook 签名凭证,否则本机凭证残留" + ); + assert!( + store.get(&kept_ref).unwrap().is_some(), + "不得清理其他项目的 webhook 签名凭证" + ); + } + + #[test] + fn unregistering_orphaned_binding_id_cleans_the_binding() { + // 孤儿绑定:workspace 已不在、绑定行还在(轮询日志提示的清理路径) + use crate::credential_store::CredentialStore; + let conn = conn_with_two_bound_projects(); + let store = crate::credential_store::MemoryCredentialStore::new(); + let gone_ref = attach_webhook_secret(&conn, &store, "gone"); + conn.execute("DELETE FROM project_workspaces WHERE id = 'gone'", []) + .unwrap(); + assert_eq!(gitea_binding_count(&conn, "gone"), 1, "前置:孤儿绑定仍在"); + + delete_project_registration(&conn, &store, "gone").unwrap(); + + assert_eq!(gitea_binding_count(&conn, "gone"), 0); + assert!(store.get(&gone_ref).unwrap().is_none()); + assert_eq!(gitea_binding_count(&conn, "kept"), 1); + } }