diff --git a/.gitea/workflows/pr-check.yml b/.gitea/workflows/pr-check.yml new file mode 100644 index 0000000..751328f --- /dev/null +++ b/.gitea/workflows/pr-check.yml @@ -0,0 +1,28 @@ +# PR 代码质量检查(Gitea Actions) +# 在 PR 合并前执行 build / lint,确保代码可编译且风格合规 +name: PR Check + +on: + pull_request: + branches: [main] + +jobs: + check: + runs-on: windows + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + with: + version: 9 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'pnpm' + + - run: pnpm install --frozen-lockfile + - run: pnpm build + - run: pnpm lint --if-present + - run: pnpm test --if-present --passWithNoTests diff --git a/src-tauri/src/commands/gitea.rs b/src-tauri/src/commands/gitea.rs index 63a86c4..d3b2c87 100644 --- a/src-tauri/src/commands/gitea.rs +++ b/src-tauri/src/commands/gitea.rs @@ -657,9 +657,8 @@ pub fn gitea_list_linked_repos() -> Result, String> { Ok(rows) } -/// 通过炼境存储的凭证为项目创建 PR,返回 JSON 字符串(含 pr_url / pr_number) -/// -/// 设计意图:MCP 工具调用此函数,避免 Claude 在 bash 里提取 token 再 curl(会被权限系统拒绝)。 +/// 通过炼境存储的凭证为项目创建 PR,返回 JSON 字符串(含 pr_url / pr_number / already_exists)。 +/// 409 冲突(PR 已存在)时自动查找并返回现有 PR,already_exists=true。 pub fn gitea_api_create_pull_request( project_id: &str, head: &str, @@ -677,28 +676,46 @@ pub fn gitea_api_create_pull_request( let (base_url, token) = instance_auth(&conn, &instance_id)?; - let url = format!("{base_url}/api/v1/repos/{owner}/{repo_name}/pulls"); - let resp = ureq::post(&url) + let create_url = format!("{base_url}/api/v1/repos/{owner}/{repo_name}/pulls"); + let (json, already_exists) = match ureq::post(&create_url) .set("Authorization", &format!("token {token}")) .set("Content-Type", "application/json") - .send_json(serde_json::json!({ - "head": head, - "base": base, - "title": title, - "body": body, - })) - .map_err(|e| api_err("创建 PR 失败", e))?; + .send_json(serde_json::json!({ "head": head, "base": base, "title": title, "body": body })) + { + Ok(resp) => (resp.into_json::().map_err(|e| format!("解析响应失败: {e}"))?, false), + Err(ureq::Error::Status(409, _)) => { + // PR 已存在,列出开放 PR 找到匹配的分支 + let list_url = format!("{base_url}/api/v1/repos/{owner}/{repo_name}/pulls?state=open&limit=30"); + let prs: serde_json::Value = ureq::get(&list_url) + .set("Authorization", &format!("token {token}")) + .call() + .map_err(|e| api_err("获取已有 PR 列表失败", e))? + .into_json() + .map_err(|e| format!("解析 PR 列表失败: {e}"))?; + let arr = prs.as_array().ok_or("PR 列表格式错误")?; + let existing = arr.iter() + .find(|pr| { + pr["head"]["ref"].as_str() == Some(head) + || pr["head"]["label"].as_str().map(|l| l.ends_with(head)).unwrap_or(false) + }) + .cloned() + .ok_or_else(|| format!("PR 已存在但找不到(head={head})"))?; + (existing, true) + } + Err(e) => return Err(api_err("创建 PR 失败", e)), + }; - let json: serde_json::Value = resp.into_json().map_err(|e| format!("解析响应失败: {e}"))?; let pr_number = json["number"].as_u64().unwrap_or(0); - let pr_url = json["html_url"].as_str().unwrap_or("").to_string(); + let pr_url = json["html_url"].as_str().unwrap_or("").to_string(); + let pr_title = json["title"].as_str().unwrap_or(title).to_string(); serde_json::to_string(&serde_json::json!({ - "pr_number": pr_number, - "pr_url": pr_url, - "head": head, - "base": base, - "title": title, + "pr_number": pr_number, + "pr_url": pr_url, + "head": head, + "base": base, + "title": pr_title, + "already_exists": already_exists, })).map_err(|e| e.to_string()) } diff --git a/src-tauri/src/mcp_server.rs b/src-tauri/src/mcp_server.rs index 2858a7e..18d2335 100644 --- a/src-tauri/src/mcp_server.rs +++ b/src-tauri/src/mcp_server.rs @@ -670,6 +670,29 @@ fn tools_list_result() -> Value { }, "required": ["project_id", "workflow_type"] } + }, + { + "name": "list_all_projects", + "description": "列出炼境中所有已登记项目(不限产品组),包含 project_id、名称、路径、所属产品组、MCP 注入状态。用于发现尚未注入的项目,再调 inject_mcp 完成接入。", + "inputSchema": { + "type": "object", + "properties": {}, + "required": [] + } + }, + { + "name": "apply_onboarding_pack", + "description": "向项目分发标准接入包:写入 AGENTS.md / lefthook / git-workflow 模板(已存在则原位升级 ONBOARDING_MANAGED 块,绝不覆盖块外用户内容),并挂载 buff 自动监听 git 提交。等价于炼境 UI「接入包」区域的「应用接入包」按钮。模板版本升级后对存量项目重新调用即可推送新版本。", + "inputSchema": { + "type": "object", + "properties": { + "project_id": { + "type": "string", + "description": "项目 workspace ID(来自 list_group_projects 的返回结果)" + } + }, + "required": ["project_id"] + } } ] }) @@ -868,6 +891,34 @@ async fn tools_call(group_id: &str, params: Option<&Value>) -> Value { let wt = args.get("workflow_type").and_then(|v| v.as_str()).unwrap_or(""); crate::commands::cicd::distribute_workflow_for_project(pid, wt) } + "list_all_projects" => list_all_projects(), + "apply_onboarding_pack" => { + let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or(""); + resolve_project_root(&gid, pid).and_then(|root| { + let path = root.to_string_lossy().to_string(); + crate::commands::onboarding::apply_onboarding_pack(path, pid.to_string()) + .map(|report| { + let template_lines: Vec = report.templates.iter() + .map(|t| { + let icon = match t.action.as_str() { + "written" => "✅ written", + "updated" => "🔄 updated", + "appended" => "➕ appended", + "skipped" => "⏭ skipped", + _ => "❌ failed", + }; + format!(" {icon}: {}", t.name) + }) + .collect(); + format!( + "接入包分发完成(project_id={})\n\n模板:\n{}\n\nbuff:{}", + pid, + template_lines.join("\n"), + report.buff.status + ) + }) + }) + } _ => Err(format!("未知工具: {}", tool_name)), }) .await; @@ -1219,6 +1270,61 @@ fn get_project_snapshots(group_id: &str, project_id: &str, days: u32) -> Result< Ok(md) } +fn list_all_projects() -> Result { + let conn = db::pool().get().map_err(|e| e.to_string())?; + + let mut stmt = conn.prepare( + "SELECT pw.id, pp.name, pw.platform, pw.win_path, pw.wsl_path, + pgm.group_id + FROM project_workspaces pw + JOIN project_profiles pp ON pp.id = pw.profile_id + LEFT JOIN project_group_map pgm ON pgm.project_id = pw.id + ORDER BY pp.name" + ).map_err(|e| e.to_string())?; + + let rows: Vec<(String, String, Option, Option, Option, Option)> = + stmt.query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?, r.get(5)?))) + .map_err(|e| e.to_string())? + .filter_map(|r| r.ok()) + .collect(); + + if rows.is_empty() { + return Ok("炼境暂无登记项目。".to_string()); + } + + let mut out = format!("## 炼境全量项目(共 {} 个)\n\n", rows.len()); + for (id, name, platform, win_path, wsl_path, group_id) in &rows { + // 判断是否已注入:检查 liangjing.json 是否存在 + let project_path = if platform.as_deref() == Some("wsl") { + wsl_path.as_ref().map(|p| { + let conn2 = db::pool().get().ok(); + let distro = conn2.and_then(|c| c.query_row( + "SELECT value FROM settings WHERE key = 'wsl_distro'", [], |r| r.get::<_,String>(0) + ).ok()).unwrap_or_else(|| "Ubuntu".to_string()); + let inner = p.trim_start_matches('/').replace('/', "\\"); + format!("\\\\wsl.localhost\\{}\\{}", distro, inner) + }) + } else { + win_path.clone() + }; + + let injected = project_path.as_ref().map(|p| { + std::path::Path::new(p).join(".claude").join("liangjing.json").exists() + }).unwrap_or(false); + + let inject_icon = if injected { "✅ 已注入" } else { "⚠️ 未注入" }; + let group_str = group_id.as_deref().unwrap_or("无产品组"); + let path_str = project_path.as_deref().unwrap_or("未配置路径"); + + out.push_str(&format!( + "- **{}** `{}`\n {} | 组: `{}` | {}\n\n", + name, id, inject_icon, group_str, path_str + )); + } + out.push_str("---\n对未注入项目调用 `inject_mcp(project_id)` 完成接入。"); + Ok(out) +} + // ── Gitea Webhook 单元测试 ──────────────────────────────────────────────────── #[cfg(test)]