diff --git a/src-tauri/src/commands/gitea.rs b/src-tauri/src/commands/gitea.rs index 79a7b17..f691417 100644 --- a/src-tauri/src/commands/gitea.rs +++ b/src-tauri/src/commands/gitea.rs @@ -535,9 +535,85 @@ pub fn poll_all_repos() -> Result<(usize, usize), String> { crate::db::log_event("gitea_poll", "info", &format!("轮询 {polled} 个仓库,新采集 {ingested} 条 commit"), None); } + + // PR 状态轮询:弥补公网 Gitea + 内网炼境时 webhook 不可达的场景 + let mut pr_ingested = 0usize; + for (project_id, owner, repo, base_url, token) in &repos { + pr_ingested += poll_pr_events(&conn, project_id, owner, repo, base_url, token); + } + if pr_ingested > 0 { + crate::db::log_event("gitea_poll", "info", + &format!("PR 轮询新采集 {pr_ingested} 条事件"), None); + } + Ok((polled, ingested)) } +/// 轮询单个仓库的 PR 状态,将未记录的事件写入 pr_events,返回新增条数 +fn poll_pr_events( + conn: &rusqlite::Connection, + project_id: &str, + owner: &str, + repo: &str, + base_url: &str, + token: &str, +) -> usize { + let mut count = 0; + for state in ["open", "closed"] { + let url = format!( + "{base_url}/api/v1/repos/{owner}/{repo}/pulls?state={state}&limit=50&type=pullrequest" + ); + let resp = match ureq::get(&url) + .set("Authorization", &format!("token {token}")) + .call() + { + Ok(r) => r, + Err(_) => continue, + }; + let prs: serde_json::Value = match resp.into_json() { + Ok(j) => j, + Err(_) => continue, + }; + let prs = match prs.as_array() { + Some(a) => a, + 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 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); + + 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; + } + } + } + } + count +} + /// 手动触发一次轮询(UI「立即拉取」按钮) #[tauri::command] pub fn gitea_poll_now() -> Result { diff --git a/src-tauri/src/mcp_server.rs b/src-tauri/src/mcp_server.rs index bf19c34..441919d 100644 --- a/src-tauri/src/mcp_server.rs +++ b/src-tauri/src/mcp_server.rs @@ -540,6 +540,15 @@ fn tools_list_result() -> Value { "required": ["project_id"] } }, + { + "name": "poll_now", + "description": "立即触发一次 Gitea 轮询:拉取所有绑定仓库的最新 commit 和 PR 状态写入飞轮。等价于炼境 UI「立即拉取」按钮。适用于公网 Gitea + 内网炼境(webhook 不可达)场景。", + "inputSchema": { + "type": "object", + "properties": {}, + "required": [] + } + }, { "name": "setup_webhook", "description": "为项目重新注册 Gitea webhook(订阅 push + pull_request 事件)。等价于炼境 UI「重设」按钮。导入新项目后、或需要更新事件订阅时调用。", @@ -651,6 +660,10 @@ async fn tools_call(group_id: &str, params: Option<&Value>) -> Value { let body = args.get("body").and_then(|v| v.as_str()).unwrap_or(""); crate::commands::gitea::gitea_api_create_pull_request(pid, head, base, title, body) } + "poll_now" => { + crate::commands::gitea::poll_all_repos() + .map(|(polled, ingested)| format!("轮询完成:{polled} 个仓库,新 commit {ingested} 条,PR 事件已同步")) + } "setup_webhook" => { let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or(""); let port = read_port();