diff --git a/.claude/settings.json b/.claude/settings.json index a6e2b67..843074e 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -12,6 +12,12 @@ } ] }, + "mcpServers": { + "lian-jing:group-ffa9e42d": { + "type": "sse", + "url": "http://127.0.0.1:27190/mcp/group/ffa9e42d" + } + }, "permissions": { "defaultMode": "bypassPermissions" } diff --git a/AGENTS.md b/AGENTS.md index 6b2cecf..de6b158 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -110,7 +110,7 @@ docs/templates/ 炼境接入包模板草案(设计层) - [ ] `src/lib/commands.ts` 的封装覆盖了所有 `src-tauri/src/commands/` 下的 command 吗? - [ ] 接入包模板(`resources/onboarding/`)和 `docs/templates/` 内容一致吗? - + ## 项目蓝图 本项目使用 `.blueprint/` 目录维护项目全景架构图,驱动需求讨论和任务管理。 @@ -147,3 +147,52 @@ docs/templates/ 炼境接入包模板草案(设计层) 完整规则(迭代管理、阻塞处理、复盘笔记等)详见 `.blueprint/CONVENTIONS.md`。 + + +## 核心开发约束 + +### 包管理 +- **pnpm 唯一**:禁止 npm / yarn / npx / bun +- lock 文件只有 `pnpm-lock.yaml`,不生成其他 + +### 代码规范 +- TypeScript 优先,避免 `any` +- 函数/变量 camelCase;组件 PascalCase +- 异步统一 `async/await`,不用裸 Promise 链 +- 错误处理明确 `try/catch`,不吞异常 +- 优先编辑现有文件,避免创建不必要的新文件 + +## Git 工作流 + +见 `docs/ai-context/git-workflow.md`(分支策略 + Conventional Commits + Rules-Applied trailer + PR 规范)。 +**commit 必须符合 Conventional Commits**(lefthook 强制),炼境据此采集飞轮数据。 + +## agent 按钮与验证路径 + +功能的本体是代码层的能力;UI 按钮是它给人的投影,**agent 按钮**是给 agent 的投影 +(可发现 + 可调用 + 有边界的能力声明)。开发任何功能时遵守: + +1. **边界单点实现**:业务校验只写在能力本体层(API/command),UI 只呈现不复制逻辑—— + 否则人与 agent 的验证路径会分裂 +2. **载体就近选择**(本项目的 agent 按钮是什么,按项目类型对号入座): + | 项目类型 | agent 按钮载体 | + |---------|---------------| + | Web 服务 | HTTP API 本身(前端按钮与 curl 调同一 endpoint,天然双投影) | + | CLI/脚本 | 命令与 npm scripts | + | 桌面应用 | 测试直接调用 command 函数 | + | UI 呈现层 | Playwright 驱动真界面(最后手段,仅关键路径) | +3. **完成的定义**:功能实现后,由 agent 沿投影路径真实走通一次(起服务 → 调接口 → + 断言行为),再交人做 UI 终验——不许绕过功能手改产物冒充验证 +4. **侦察 → 驻军**:agent 走通的关键路径,顺手固化为一条测试(vitest / cargo test); + 一次性验收不固化(少而稳 > 多而脆) + +## 多 AI CLI 协作 + +本项目可由多个 AI CLI 协同。交接时把结果总结成 3 条传给下一个: + +``` +1. 已完成:<改了哪些文件 + 核心决策> +2. 待验证:<需要确认的地方> +3. 约束继承:<影响后续的关键限制> +``` + diff --git a/CLAUDE.md b/CLAUDE.md index de6ff6f..8f8ed54 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -47,7 +47,7 @@ docs/templates/ 炼境接入包模板草案(agents-meta-rules - 代码重构/简化:/simplify → /code-review - 发现深层问题:/Dev-deep-think - + ## 项目蓝图 蓝图工作流规则统一维护在 @AGENTS.md 的 BLUEPRINT_MANAGED 区(跨工具单一来源,勿在此复制)。 diff --git a/src-tauri/resources/onboarding/git-workflow.md.tmpl b/src-tauri/resources/onboarding/git-workflow.md.tmpl index 0289e3b..56b421c 100644 --- a/src-tauri/resources/onboarding/git-workflow.md.tmpl +++ b/src-tauri/resources/onboarding/git-workflow.md.tmpl @@ -3,7 +3,7 @@ > 把此文件粘贴给任何 AI Agent,让它了解本项目的 git 工作方式。由炼境接入包分发。 > `ONBOARDING_MANAGED` 标记块由炼境版本化管理(重跑接入包可原位升级),块外可自由补充项目特有约定。 - + ## 分支策略 @@ -72,6 +72,22 @@ git commit -m "feat(模块): 一件事" PR 别攒太大(建议每 3~5 个 commit 或几百行内就开一次),否则 CI code review 易超时失效。 「所有检测通过」≠ 代码被审查过——大 PR 即使 CI 全绿,review 可能已静默失效。 +## PR 创建(炼境托管项目) + +本项目由炼境管理,MCP 已注入且 `.claude/liangjing.json` 含 `project_id`。 + +**推送分支后,通过炼境 MCP 创建 PR,禁止用 curl + git credential fill 提取 token:** + +``` +1. Read .claude/liangjing.json → 取出 project_id +2. 调用 MCP 工具 create_pull_request: + { "project_id": "", "head": "dev", "base": "main", + "title": "", "body": "<变更摘要>" } +3. 炼境内部用存储的 Gitea 凭证调 API,返回 pr_url +``` + +若炼境未运行(MCP 不可用),降级方案:去 Gitea 网页手动创建 PR。 + ## 常用命令 ```bash diff --git a/src-tauri/src/commands/cicd.rs b/src-tauri/src/commands/cicd.rs index fc47a22..a804fa0 100644 --- a/src-tauri/src/commands/cicd.rs +++ b/src-tauri/src/commands/cicd.rs @@ -82,6 +82,8 @@ pub fn generate_workflow(project_id: String, project_path: String) -> Result build_tauri_oss_workflow(&config), + "pr_ci" => build_pr_ci_workflow(&config), + "deepseek_review" => build_deepseek_review_workflow(&config), _ => build_web_ssh_workflow(&config), }; @@ -295,6 +297,144 @@ jobs: (content, "release.yml".to_string(), secrets) } +fn build_pr_ci_workflow(cfg: &CicdConfig) -> (String, String, Vec) { + let branch = &cfg.trigger_branch; + let node = cfg.node_version.as_deref().unwrap_or("20"); + let build_cmd = cfg.build_command.as_deref().unwrap_or("pnpm build"); + + let content = format!( + r#"# PR 代码质量检查(Gitea Actions) +# 在 PR 合并前执行 build / lint,确保代码可编译且风格合规 +name: PR Check + +on: + pull_request: + branches: [{branch}] + +jobs: + check: + runs-on: ubuntu-latest + 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: '{node}' + cache: 'pnpm' + + - run: pnpm install --frozen-lockfile + - run: {build_cmd} + - run: pnpm lint --if-present + - run: pnpm test --if-present --passWithNoTests +"# + ); + + (content, "pr-check.yml".to_string(), vec![]) +} + +fn build_deepseek_review_workflow(_cfg: &CicdConfig) -> (String, String, Vec) { + let content = r#"# AI 代码审查(DeepSeek + Gitea Actions) +# PR 开启时自动调用 DeepSeek 生成审查报告,以评论形式发布到 Gitea PR +name: AI Code Review + +on: + pull_request: + types: [opened, synchronize] + +jobs: + ai-review: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: DeepSeek Code Review + env: + DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY }} + GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} + GITEA_URL: ${{ secrets.GITEA_URL }} + PR_NUMBER: ${{ github.event.pull_request.number }} + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + REPO: ${{ github.repository }} + run: | + python3 << 'PYEOF' + import os, sys, json, urllib.request, subprocess + + api_key = os.environ["DEEPSEEK_API_KEY"] + token = os.environ["GITEA_TOKEN"] + base_url = os.environ["GITEA_URL"].rstrip("/") + pr_num = os.environ["PR_NUMBER"] + base_sha = os.environ["BASE_SHA"] + head_sha = os.environ["HEAD_SHA"] + repo = os.environ["REPO"] + + diff = subprocess.check_output( + ["git", "diff", f"{base_sha}...{head_sha}", "--diff-filter=AM", "-p"], + text=True + )[:8000] + + if not diff.strip(): + print("diff 为空,跳过审查") + sys.exit(0) + + prompt = f"""请审查以下 PR diff,简洁指出潜在 bug、安全风险或可维护性问题。 +用中文回复,每条意见以 - 开头,无问题则回复"LGTM 无明显问题"。 + +diff: +{diff}""" + + req_data = json.dumps({ + "model": "deepseek-chat", + "messages": [{"role": "user", "content": prompt}], + "max_tokens": 800 + }).encode() + req = urllib.request.Request( + "https://api.deepseek.com/v1/chat/completions", + data=req_data, + headers={ + "Content-Type": "application/json", + "Authorization": f"Bearer {api_key}" + }, + method="POST" + ) + with urllib.request.urlopen(req, timeout=60) as res: + body = json.load(res) + review = body["choices"][0]["message"]["content"] + + owner, repo_name = repo.split("/", 1) + comment = f"🤖 **AI 代码审查(DeepSeek)**\n\n{review}\n\n---\n*由炼境自动触发*" + req_data = json.dumps({"body": comment}).encode() + req = urllib.request.Request( + f"{base_url}/api/v1/repos/{owner}/{repo_name}/issues/{pr_num}/comments", + data=req_data, + headers={ + "Content-Type": "application/json", + "Authorization": f"token {token}" + }, + method="POST" + ) + with urllib.request.urlopen(req) as _: + pass + print("AI 审查评论已发布") + PYEOF +"# + .to_string(); + + let secrets = vec![ + "DEEPSEEK_API_KEY".to_string(), + "GITEA_TOKEN".to_string(), + "GITEA_URL".to_string(), + ]; + (content, "ai-review.yml".to_string(), secrets) +} + #[cfg(test)] mod tests { use super::*; @@ -337,4 +477,27 @@ mod tests { assert!(secrets.contains(&"TAURI_SIGNING_PRIVATE_KEY".to_string())); assert!(!secrets.iter().any(|s| s.contains("GITHUB")), "secrets 残留 GitHub 项"); } + + #[test] + fn pr_ci_template_is_correct() { + let (content, filename, secrets) = build_pr_ci_workflow(&cfg("pr_ci")); + assert!(content.contains("pull_request"), "触发器应为 pull_request"); + assert!(content.contains("pnpm build"), "应含构建步骤"); + assert!(!content.contains("SERVER_HOST"), "不应含 SSH 部署配置"); + assert!(!content.contains("ssh-action"), "不应含 SSH Action"); + assert_eq!(filename, "pr-check.yml"); + assert!(secrets.is_empty(), "PR CI 不需要 secrets"); + } + + #[test] + fn deepseek_review_template_is_correct() { + let (content, filename, secrets) = build_deepseek_review_workflow(&cfg("deepseek_review")); + assert!(content.contains("DEEPSEEK_API_KEY"), "缺少 DeepSeek API key 引用"); + assert!(content.contains("pull_request"), "触发器应为 pull_request"); + assert!(content.contains("deepseek-chat"), "缺少模型名称"); + assert_eq!(filename, "ai-review.yml"); + assert!(secrets.contains(&"DEEPSEEK_API_KEY".to_string())); + assert!(secrets.contains(&"GITEA_TOKEN".to_string())); + assert!(secrets.contains(&"GITEA_URL".to_string())); + } } diff --git a/src-tauri/src/commands/gitea.rs b/src-tauri/src/commands/gitea.rs index f675e0f..79a7b17 100644 --- a/src-tauri/src/commands/gitea.rs +++ b/src-tauri/src/commands/gitea.rs @@ -402,7 +402,7 @@ pub fn gitea_setup_webhook(project_id: String, callback_base: String) -> Result< "type": "gitea", "active": true, "branch_filter": "*", - "events": ["push"], + "events": ["push", "pull_request"], "config": { "url": callback_url, "content_type": "json", @@ -581,6 +581,51 @@ pub fn gitea_list_linked_repos() -> Result, String> { Ok(rows) } +/// 通过炼境存储的凭证为项目创建 PR,返回 JSON 字符串(含 pr_url / pr_number) +/// +/// 设计意图:MCP 工具调用此函数,避免 Claude 在 bash 里提取 token 再 curl(会被权限系统拒绝)。 +pub fn gitea_api_create_pull_request( + project_id: &str, + head: &str, + base: &str, + title: &str, + body: &str, +) -> Result { + let conn = crate::db::pool().get().map_err(|e| e.to_string())?; + + let (instance_id, owner, repo_name): (String, String, String) = conn.query_row( + "SELECT instance_id, gitea_owner, gitea_repo_name FROM gitea_repos WHERE project_id = ?1", + params![project_id], + |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)), + ).map_err(|_| format!("项目 {} 未绑定 Gitea 仓库", project_id))?; + + 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) + .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))?; + + 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(); + + serde_json::to_string(&serde_json::json!({ + "pr_number": pr_number, + "pr_url": pr_url, + "head": head, + "base": base, + "title": title, + })).map_err(|e| e.to_string()) +} + /// 查询单个项目的 Gitea 绑定 #[tauri::command] pub fn gitea_get_repo_link(project_id: String) -> Result, String> { diff --git a/src-tauri/src/db.rs b/src-tauri/src/db.rs index b5860c8..f60142c 100644 --- a/src-tauri/src/db.rs +++ b/src-tauri/src/db.rs @@ -538,6 +538,22 @@ fn migrate(conn: &rusqlite::Connection) { // 增量迁移(飞轮 R1):Rules-Applied trailer 规则命中列(已存在则忽略) let _ = conn.execute("ALTER TABLE commit_metrics ADD COLUMN rules_applied TEXT", []); + // 飞轮 L1:PR 生命周期事件(opened / closed / merged) + conn.execute_batch(" + CREATE TABLE IF NOT EXISTS pr_events ( + id TEXT PRIMARY KEY, + project_id TEXT NOT NULL, + pr_number INTEGER NOT NULL, + title TEXT, + action TEXT NOT NULL, + head_branch TEXT, + base_branch TEXT, + author TEXT, + event_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE INDEX IF NOT EXISTS idx_pr_events_project ON pr_events(project_id); + ").expect("create pr_events table"); + // 仪表盘全局快启应用(无项目 FK 约束) conn.execute_batch(" CREATE TABLE IF NOT EXISTS global_tools ( diff --git a/src-tauri/src/mcp_inject.rs b/src-tauri/src/mcp_inject.rs index de2745a..0e0ad56 100644 --- a/src-tauri/src/mcp_inject.rs +++ b/src-tauri/src/mcp_inject.rs @@ -43,6 +43,23 @@ fn settings_path(project_id: &str) -> Result { Ok(project_root(project_id)?.join(".claude").join("settings.json")) } +fn liangjing_path(project_id: &str) -> Result { + Ok(project_root(project_id)?.join(".claude").join("liangjing.json")) +} + +/// 写炼境上下文文件,供子项目 Claude 会话直接读取 project_id(不通过遍历 list_group_projects 匹配) +fn write_liangjing_context(project_id: &str, port: u16) -> Result<(), String> { + let path = liangjing_path(project_id)?; + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent) + .map_err(|e| format!("创建 .claude 目录失败: {}", e))?; + } + let ctx = json!({ "project_id": project_id, "mcp_port": port }); + let content = serde_json::to_string_pretty(&ctx).map_err(|e| e.to_string())?; + std::fs::write(&path, content) + .map_err(|e| format!("写入 liangjing.json 失败: {}", e)) +} + // ── JSON 读写 ───────────────────────────────────────────────────────────────── fn read_json(path: &PathBuf) -> Result { @@ -80,11 +97,15 @@ pub fn inject_project_mcp(project_id: &str, group_id: &str) -> Result<(), String let path = settings_path(project_id)?; let mut settings = read_json(&path)?; - let servers = settings + let obj = settings .as_object_mut() - .ok_or("settings.json 顶层必须是 JSON 对象")? - .entry("mcpServers") - .or_insert(json!({})); + .ok_or("settings.json 顶层必须是 JSON 对象")?; + + // 炼境管理的项目需要无人值守运行(/ship 等技能不应被 permission 弹窗打断) + obj.entry("permissions") + .or_insert(json!({"defaultMode": "bypassPermissions"})); + + let servers = obj.entry("mcpServers").or_insert(json!({})); let key = format!("{}{}", MCP_KEY_PREFIX, group_id); servers[&key] = json!({ @@ -95,7 +116,11 @@ pub fn inject_project_mcp(project_id: &str, group_id: &str) -> Result<(), String write_json(&path, &settings).inspect(|_| { let ctx = serde_json::json!({"project_id": project_id, "group_id": group_id}).to_string(); log_event("mcp_inject", "info", "MCP 配置注入成功", Some(&ctx)); - }) + })?; + + // 写炼境上下文,让子项目 Claude 会话无需遍历即可知道自己的 project_id + let _ = write_liangjing_context(project_id, port); + Ok(()) } /// 从项目的 .claude/settings.json 移除产品组 MCP 配置 diff --git a/src-tauri/src/mcp_server.rs b/src-tauri/src/mcp_server.rs index 08f591a..046d19d 100644 --- a/src-tauri/src/mcp_server.rs +++ b/src-tauri/src/mcp_server.rs @@ -158,53 +158,90 @@ async fn handle_gitea_webhook( return (StatusCode::UNAUTHORIZED, Json(json!({"error": "签名验证失败"}))).into_response(); } - // 3. 只处理 push 事件,其他事件确认收到但忽略 + // 3. 按事件类型分发 let event = headers .get("x-gitea-event") .and_then(|v| v.to_str().ok()) .unwrap_or(""); - if event != "push" { - return Json(json!({"ignored": event})).into_response(); + + match event { + "push" => handle_push(&project_id, &body).await, + "pull_request" => handle_pull_request(&project_id, &body).await, + other => Json(json!({"ignored": other})).into_response(), } +} - // 4. 解析 commits 并写入 commit_metrics(sha 主键幂等,与本地 watcher 双路径不重复计数) - let commits = match parse_push_payload(&body) { +async fn handle_push(project_id: &str, body: &Bytes) -> Response { + let commits = match parse_push_payload(body) { Ok(c) => c, - Err(e) => { - return (StatusCode::BAD_REQUEST, Json(json!({"error": e}))).into_response(); - } + Err(e) => return (StatusCode::BAD_REQUEST, Json(json!({"error": e}))).into_response(), }; - let conn = match db::pool().get() { Ok(c) => c, - Err(e) => { - return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": e.to_string()}))).into_response() - } + Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": e.to_string()}))).into_response(), }; let mut received = 0; for c in &commits { if crate::commands::commit_metrics::record_commit( - &conn, - &project_id, - &c.sha, - &c.message, - c.timestamp.as_deref(), - ) - .is_ok() - { - received += 1; - } + &conn, project_id, &c.sha, &c.message, c.timestamp.as_deref(), + ).is_ok() { received += 1; } } - log_event( - "gitea_webhook", - "info", + "gitea_webhook", "info", &format!("项目 {project_id} push 事件已接收,写入 {received}/{} 条 commit", commits.len()), None, ); Json(json!({"received": received})).into_response() } +async fn handle_pull_request(project_id: &str, body: &Bytes) -> Response { + let payload: serde_json::Value = match serde_json::from_slice(body) { + Ok(v) => v, + Err(e) => return (StatusCode::BAD_REQUEST, Json(json!({"error": e.to_string()}))).into_response(), + }; + + let raw_action = payload["action"].as_str().unwrap_or(""); + let pr = &payload["pull_request"]; + let pr_number = pr["number"].as_i64().unwrap_or(0) as i64; + + // 只追踪有价值的生命周期节点 + let action = match raw_action { + "opened" | "reopened" => "opened", + "closed" => { + if pr["merged"].as_bool().unwrap_or(false) { "merged" } else { "closed" } + } + _ => return Json(json!({"ignored": raw_action})).into_response(), + }; + + let title = pr["title"].as_str().unwrap_or("").to_string(); + let head_branch = pr["head"]["label"].as_str().unwrap_or("").to_string(); + let base_branch = pr["base"]["label"].as_str().unwrap_or("").to_string(); + let author = pr["user"]["login"].as_str().unwrap_or("").to_string(); + let id = uuid::Uuid::new_v4().to_string(); + + let conn = match db::pool().get() { + Ok(c) => c, + Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": e.to_string()}))).into_response(), + }; + let result = 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)", + rusqlite::params![id, project_id, pr_number, title, action, head_branch, base_branch, author], + ); + + match result { + Ok(_) => { + log_event( + "gitea_webhook", "info", + &format!("项目 {project_id} PR #{pr_number} {action}"), + None, + ); + Json(json!({"recorded": action, "pr_number": pr_number})).into_response() + } + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": e.to_string()}))).into_response(), + } +} + // ── JSON-RPC 类型 ───────────────────────────────────────────────────────────── #[derive(Deserialize)] @@ -455,6 +492,36 @@ fn tools_list_result() -> Value { "required": ["project_id"] } }, + { + "name": "create_pull_request", + "description": "通过炼境存储的 Gitea 凭证为项目创建合并请求(PR)。炼境内部调用 Gitea API,无需 Claude 自行提取 token。/ship 交付流程在推送分支后应调用此工具完成 PR 创建。", + "inputSchema": { + "type": "object", + "properties": { + "project_id": { + "type": "string", + "description": "项目 workspace ID(来自 list_group_projects 的返回结果)" + }, + "head": { + "type": "string", + "description": "源分支名,如 dev" + }, + "base": { + "type": "string", + "description": "目标分支名,如 main" + }, + "title": { + "type": "string", + "description": "PR 标题" + }, + "body": { + "type": "string", + "description": "PR 描述(Markdown),可为空字符串" + } + }, + "required": ["project_id", "head", "base", "title"] + } + }, { "name": "get_project_snapshots", "description": "获取项目的历史健康快照(每日一条),包含 git 提交间隔、蓝图完成率、阻塞任务数、错误情况。用于分析趋势、识别高价值改进变量。", @@ -548,6 +615,14 @@ async fn tools_call(group_id: &str, params: Option<&Value>) -> Value { let days = args.get("days").and_then(|v| v.as_u64()).unwrap_or(30) as u32; get_project_snapshots(&gid, pid, days) } + "create_pull_request" => { + let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or(""); + let head = args.get("head").and_then(|v| v.as_str()).unwrap_or(""); + let base = args.get("base").and_then(|v| v.as_str()).unwrap_or("main"); + let title = args.get("title").and_then(|v| v.as_str()).unwrap_or(""); + 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) + } _ => Err(format!("未知工具: {}", tool_name)), }) .await; diff --git a/src/components/dashboard/CicdModal.tsx b/src/components/dashboard/CicdModal.tsx index 9b205f5..fc68fca 100644 --- a/src/components/dashboard/CicdModal.tsx +++ b/src/components/dashboard/CicdModal.tsx @@ -111,6 +111,7 @@ export function CicdModal({ } const isWebSsh = form.config_type === "web_ssh"; + const isTauriOss = form.config_type === "tauri_oss"; const hasSavedConfig = !!saved?.id; const hasPath = !!(winPath || wslPath); @@ -132,11 +133,13 @@ export function CicdModal({
{/* 类型选择 */}
- -
+ +
{[ - { value: "web_ssh", label: "Web 项目(SSH 部署)", desc: "WSL / Linux 服务器" }, - { value: "tauri_oss", label: "桌面客户端(OSS 分发)", desc: "Tauri / Windows 安装包" }, + { value: "pr_ci", label: "PR 质量检查", desc: "build / lint / test" }, + { value: "web_ssh", label: "Web 部署(SSH)", desc: "WSL / Linux 服务器" }, + { value: "tauri_oss", label: "客户端发布(OSS)", desc: "Tauri / Windows 安装包" }, + { value: "deepseek_review", label: "AI 代码审查", desc: "DeepSeek PR 评论" }, ].map((opt) => (