fix(gitea-integration): 删除项目登记时一并解除 Gitea 绑定,轮询不再写入已删除或已解绑的项目(G8) #35

Merged
lanrtop merged 3 commits from fix/gitea-unbind-on-unregister into master 2026-10-03 09:59:08 +08:00
5 changed files with 632 additions and 150 deletions

View File

@ -187,6 +187,35 @@ Gitea push → POST /webhook/gitea/:project_id
- files: src/components/dashboard/GiteaPanel.tsx, src/components/dashboard/BlueprintModal.tsx - files: src/components/dashboard/GiteaPanel.tsx, src/components/dashboard/BlueprintModal.tsx
- acceptance: 治理面板新增"Gitea"区块;可添加/测试/删除实例;显示已绑定仓库列表(仓库名/webhook 状态);操作:新建仓库、迁移、绑定已有、设置 webhook;显示可复制的 webhook 回调地址 - acceptance: 治理面板新增"Gitea"区块;可添加/测试/删除实例;显示已绑定仓库列表(仓库名/webhook 状态);操作:新建仓库、迁移、绑定已有、设置 webhook;显示可复制的 webhook 回调地址
### 🔵 G8. 删除项目登记时一并解除 Gitea 绑定(轮询不再写入已删除项目)
- status: in_progress
- complexity: M
- depends: G2, G6
- files: src-tauri/src/commands/projects.rs, src-tauri/src/commands/gitea.rs, src-tauri/src/mcp_tools.rs, docs/ai-context/project-context.yaml
- acceptance:
- agent_verify: [cargo test] 删除登记本体(UI 删除按钮与 MCP unregister_project 共用)在同一事务里删除该项目的 gitea_repos 绑定,且只删本项目的绑定 → 回归测试通过;事务内后续删除失败时绑定随之回滚 → 测试通过
- 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_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 里显式开启。"
## 决策记录 ## 决策记录
- **公网 Gitea + 内网炼境为标准拓扑(2026-07-02 裁决)**:观测面以 G6 出站轮询为主(每 10 分钟),webhook 仅作同网/有穿透场景的补充。理由:① 炼境 27190 端口(MCP 路由无鉴权)保持零公网暴露;② 飞轮是统计系统非监控系统,10 分钟延迟在天/周分析粒度下为零影响;③ /ship 在 enterprise-system 已实证全出站 API 可支撑完整 PR 流水线;④ 主动拉取更贴合风筝线"持线人"定位。若未来需秒级实时:云服务器 nginx 仅反代 /webhook/gitea/ 路径 + 内网穿透(备案,不建议) - **公网 Gitea + 内网炼境为标准拓扑(2026-07-02 裁决)**:观测面以 G6 出站轮询为主(每 10 分钟),webhook 仅作同网/有穿透场景的补充。理由:① 炼境 27190 端口(MCP 路由无鉴权)保持零公网暴露;② 飞轮是统计系统非监控系统,10 分钟延迟在天/周分析粒度下为零影响;③ /ship 在 enterprise-system 已实证全出站 API 可支撑完整 PR 流水线;④ 主动拉取更贴合风筝线"持线人"定位。若未来需秒级实时:云服务器 nginx 仅反代 /webhook/gitea/ 路径 + 内网穿透(备案,不建议)

View File

@ -17,7 +17,7 @@ contracts:
- "src-tauri/src/commands/gitea.rs" - "src-tauri/src/commands/gitea.rs"
- "src-tauri/src/commands/dev_variables.rs" - "src-tauri/src/commands/dev_variables.rs"
- "src-tauri/src/envoy.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_registration):同一事务内解除 Gitea 仓库绑定,提交后尽力删除 Gitea 侧 webhook 与本机签名凭证;Gitea 轮询跳过所属项目已不存在的遗留绑定,且每个仓库写入前复核绑定仍在、项目仍登记,不向已删除或已解绑的项目 ID 写 commit 与 PR 事件;每个遗留绑定每次运行只记一次日志,日志写明清理路径(对该 ID 调 unregister_project)。"
- label: 被管理项目身份上下文 - label: 被管理项目身份上下文
paths: paths:

View File

@ -1650,49 +1650,82 @@ fn persist_webhook_secret(
Ok(()) Ok(())
} }
/// 解绑项目与 Gitea 仓库(顺带删除 Gitea 侧 webhook,失败不阻断) /// 已从数据库解除的项目 ↔ 仓库绑定;提交后交给 [`cleanup_removed_repo_link`] 做外部清理
#[tauri::command] pub(crate) struct RemovedRepoLink {
pub fn gitea_unlink_repo(project_id: String) -> Result<(), String> { link: GiteaRepo,
let conn = crate::db::pool().get().map_err(|e| e.to_string())?; webhook_secret_ref: Option<String>,
let link = match conn.query_row( }
/// 解绑本体的数据库部分(UI 解绑与删除项目登记共用):删除项目的绑定行并返回它,未绑定返回 None。
/// 不碰网络与受保护存储,可放进调用方事务;外部清理在提交后调用 [`cleanup_removed_repo_link`]。
pub(crate) fn remove_repo_link_records(
conn: &rusqlite::Connection,
project_id: &str,
) -> Result<Option<RemovedRepoLink>, String> {
let removed = conn
.query_row(
"SELECT id, instance_id, project_id, gitea_owner, gitea_repo_name, "SELECT id, instance_id, project_id, gitea_owner, gitea_repo_name,
clone_url, webhook_id, linked_at clone_url, webhook_id, linked_at, webhook_secret_ref
FROM gitea_repos WHERE project_id = ?1", FROM gitea_repos WHERE project_id = ?1",
params![project_id], params![project_id],
row_to_repo, |row| {
) { Ok(RemovedRepoLink {
Ok(l) => l, link: row_to_repo(row)?,
Err(rusqlite::Error::QueryReturnedNoRows) => return Ok(()), webhook_secret_ref: row.get(8)?,
Err(e) => return Err(e.to_string()), })
}; },
let old_reference: Option<String> = 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())?; .optional()
.map_err(|e| e.to_string())?;
if let Some(hook_id) = link.webhook_id { if removed.is_some() {
if let Ok((base_url, token)) = instance_auth(&conn, &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}"))
.call();
}
}
conn.execute( conn.execute(
"DELETE FROM gitea_repos WHERE project_id = ?1", "DELETE FROM gitea_repos WHERE project_id = ?1",
params![project_id], params![project_id],
) )
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
if let Some(reference) = old_reference { }
let _ = crate::credential_store::CredentialStore::delete( 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_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);
}
}
/// 解绑项目与 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())?;
// 读与删放进同一事务(与删除登记路径同写法):并发的 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, &crate::credential_store::WindowsCredentialStore,
&reference, &removed,
); );
} }
Ok(()) Ok(())
@ -1719,23 +1752,129 @@ pub fn parse_gitea_commits(json: &serde_json::Value) -> Vec<(String, String, Opt
.unwrap_or_default() .unwrap_or_default()
} }
/// 轮询所有绑定仓库的最新 commit,幂等写入 commit_metrics。 /// 一条 Gitea 绑定的轮询目标:(project_id, owner, repo, instance_id)
/// 返回 (轮询仓库数, 新写入 commit 数)。纯出站 HTTPS,适配公网 Gitea + 内网炼境拓扑。 type PollTarget = (String, String, String, String);
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)> = { /// 兜底:删除登记已在同一事务解除绑定,但存量孤儿绑定不能再往已删除的项目 ID 写 commit 与 PR 事件。
fn load_poll_targets(
conn: &rusqlite::Connection,
) -> Result<(Vec<PollTarget>, Vec<PollTarget>), String> {
let mut stmt = conn let mut stmt = conn
.prepare( .prepare(
"SELECT gr.project_id, gr.gitea_owner, gr.gitea_repo_name, gi.id "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", FROM gitea_repos gr JOIN gitea_instances gi ON gi.id = gr.instance_id",
) )
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
let rows = stmt let rows = stmt
.query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?))) .query_map([], |r| {
.map_err(|e| e.to_string())?; Ok((
rows.collect::<rusqlite::Result<Vec<_>>>() (r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?),
r.get::<_, bool>(4)?,
))
})
.map_err(|e| e.to_string())? .map_err(|e| e.to_string())?
}; .collect::<rusqlite::Result<Vec<(PollTarget, bool)>>>()
.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::Mutex<std::collections::HashSet<String>>> =
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} 已不存在,跳过轮询(删除登记时遗留的孤儿绑定)。清理:对该 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> {
let conn = crate::db::pool().get().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 ingested = 0usize;
let mut polled = 0usize; let mut polled = 0usize;
@ -1767,31 +1906,7 @@ pub fn poll_all_repos() -> Result<(usize, usize), String> {
Err(_) => continue, Err(_) => continue,
}; };
polled += 1; polled += 1;
// U3:按 project_id 加载规则 ID 集合(每仓库一次),剔除 trailer 里的未知 ID ingested += ingest_polled_commits(&conn, project_id, owner, repo, &json);
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;
}
}
} }
if ingested > 0 { if ingested > 0 {
@ -1851,10 +1966,28 @@ fn poll_pr_events(
Ok(j) => j, Ok(j) => j,
Err(_) => continue, Err(_) => continue,
}; };
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() { let prs = match prs.as_array() {
Some(a) => a, Some(a) => a,
None => continue, None => return 0,
}; };
if !poll_target_still_bound(conn, project_id, owner, repo) {
return 0;
}
let mut count = 0;
for pr in prs { for pr in prs {
let pr_number = match pr["number"].as_i64() { let pr_number = match pr["number"].as_i64() {
Some(n) => n, Some(n) => n,
@ -1893,7 +2026,6 @@ fn poll_pr_events(
} }
} }
} }
}
count count
} }
@ -2994,6 +3126,136 @@ mod tests {
assert!(result.is_err(), "project_id 唯一索引未生效"); 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::<Vec<_>>()
};
assert_eq!(ids(&live), ["live"]);
assert_eq!(
ids(&orphaned),
["deleted"],
"已删除项目的遗留绑定不得进入轮询"
);
}
#[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<String> {
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::<rusqlite::Result<Vec<String>>>()
.unwrap()
};
assert_eq!(rows("commit_metrics"), ["live"]);
assert_eq!(rows("pr_events"), ["live"]);
}
// ── G6:轮询解析 ────────────────────────────────────────────────────── // ── G6:轮询解析 ──────────────────────────────────────────────────────
#[test] #[test]

View File

@ -1,7 +1,8 @@
use crate::commands::gitea::{self, RemovedRepoLink};
use crate::db::pool; use crate::db::pool;
use crate::mcp_inject; use crate::mcp_inject;
use crate::models::{ActivityLog, Project}; use crate::models::{ActivityLog, Project};
use rusqlite::params; use rusqlite::{params, OptionalExtension};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::Value; use serde_json::Value;
@ -327,19 +328,95 @@ pub fn update_project_tags(id: String, tags: String) -> Result<(), String> {
Ok(()) Ok(())
} }
/// 删除项目登记的数据库部分(连接由调用方注入;生产调用一律经 [`delete_project_registration`]):
/// 同一事务内清理关联表、解除 Gitea 仓库绑定、删除 workspace 与孤儿 profile。
/// 返回被解除的 Gitea 绑定,供事务提交后交给 `gitea::cleanup_removed_repo_link`
/// 删除 Gitea 侧 webhook 与本机签名凭证。
pub(crate) fn delete_project_records(
conn: &rusqlite::Connection,
id: &str,
) -> Result<Option<RemovedRepoLink>, String> {
let profile_id: Option<String> = 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)
}
/// 删除项目登记本体:数据库部分([`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 清理客户端配置:在线程池执行,不占界面线程 // 会经 claude CLI 清理客户端配置:在线程池执行,不占界面线程
#[tauri::command(async)] #[tauri::command(async)]
pub fn delete_project(id: String, delete_local: Option<bool>) -> Result<(), String> { pub fn delete_project(id: String, delete_local: Option<bool>) -> Result<(), String> {
let conn = pool().get().map_err(|e| e.to_string())?; let conn = pool().get().map_err(|e| e.to_string())?;
// 删除前先读取路径和 profile_id // 删除前先读取路径(仅删除本地文件夹时使用)
let (profile_id, win_path, wsl_path): (Option<String>, Option<String>, Option<String>) = conn let (win_path, wsl_path): (Option<String>, Option<String>) = conn
.query_row( .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], 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( crate::commands::dev_variables::purge_project(
&conn, &conn,
@ -352,44 +429,7 @@ pub fn delete_project(id: String, delete_local: Option<bool>) -> Result<(), Stri
eprintln!("[MCP remove] 删除项目时清除失败 project={}: {}", id, e); eprintln!("[MCP remove] 删除项目时清除失败 project={}: {}", id, e);
} }
// 清理关联数据(旧 FK 指向 projects 表,需手动清理) delete_project_registration(&conn, &crate::credential_store::WindowsCredentialStore, &id)?;
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 delete_local.unwrap_or(false) { if delete_local.unwrap_or(false) {
@ -496,3 +536,154 @@ pub fn batch_create_projects(items: Vec<BatchProjectInput>) -> Result<Vec<String
} }
Ok(skipped) Ok(skipped)
} }
#[cfg(test)]
mod tests {
use super::*;
fn seed_project(conn: &rusqlite::Connection, id: &str) {
let profile_id = format!("prof-{id}");
conn.execute(
"INSERT INTO project_profiles (id, name) VALUES (?1, ?2)",
params![profile_id, id],
)
.unwrap();
conn.execute(
"INSERT INTO project_workspaces (id, profile_id) VALUES (?1, ?2)",
params![id, profile_id],
)
.unwrap();
}
fn bind_gitea_repo(conn: &rusqlite::Connection, project_id: &str) {
conn.execute(
"INSERT INTO gitea_repos (id, instance_id, project_id, gitea_owner, gitea_repo_name)
VALUES (?1, 'inst', ?2, 'owner', ?2)",
params![format!("repo-{project_id}"), project_id],
)
.unwrap();
}
fn gitea_binding_count(conn: &rusqlite::Connection, project_id: &str) -> 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,
"解绑与删除登记同一事务:删除失败时绑定不得被单独解除"
);
}
/// 给绑定挂上 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);
}
}

View File

@ -344,7 +344,7 @@ pub fn tools_list_result() -> Value {
}, },
{ {
"name": "unregister_project", "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": { "inputSchema": {
"type": "object", "type": "object",
"properties": { "properties": {