feat(cicd+mcp): PR 事件追踪 + create_pull_request MCP 工具 + CI/AI 审查模板 + bypassPermissions 注入

炼境子项目工作流全链路打通:
- mcp_server: 新增 create_pull_request MCP 工具(凭证代理,子项目无需持有 token)
- mcp_server: webhook 路由拆分 push/pull_request,PR 事件写入 pr_events 表
- mcp_inject: 注入 bypassPermissions + 写入 liangjing.json(project_id + mcp_port)
- db: 新增 pr_events 表 + idx_pr_events_project 索引
- gitea.rs: gitea_api_create_pull_request() pub 函数;webhook 订阅补 pull_request 事件
- onboarding/git-workflow: v1.2.0,新增 PR 创建通过炼境 MCP 的说明与禁止条款
- cicd: 新增 pr_ci(PR 质量检查)+ deepseek_review(AI 代码审查)模板 + 4 项测试
- CicdModal: 类型选择器扩展为 4 项,deepseek_review 加 Secrets 提示面板
This commit is contained in:
lanrtop 2026-07-03 16:57:36 +09:00
parent bcdcad7f27
commit 8432aa4f45
10 changed files with 447 additions and 39 deletions

View File

@ -12,6 +12,12 @@
} }
] ]
}, },
"mcpServers": {
"lian-jing:group-ffa9e42d": {
"type": "sse",
"url": "http://127.0.0.1:27190/mcp/group/ffa9e42d"
}
},
"permissions": { "permissions": {
"defaultMode": "bypassPermissions" "defaultMode": "bypassPermissions"
} }

View File

@ -110,7 +110,7 @@ docs/templates/ 炼境接入包模板草案(设计层)
- [ ] `src/lib/commands.ts` 的封装覆盖了所有 `src-tauri/src/commands/` 下的 command 吗? - [ ] `src/lib/commands.ts` 的封装覆盖了所有 `src-tauri/src/commands/` 下的 command 吗?
- [ ] 接入包模板(`resources/onboarding/`)和 `docs/templates/` 内容一致吗? - [ ] 接入包模板(`resources/onboarding/`)和 `docs/templates/` 内容一致吗?
<!-- BLUEPRINT_MANAGED_START version="1.9.0" --> <!-- BLUEPRINT_MANAGED_START version="1.10.0" -->
## 项目蓝图 ## 项目蓝图
本项目使用 `.blueprint/` 目录维护项目全景架构图,驱动需求讨论和任务管理。 本项目使用 `.blueprint/` 目录维护项目全景架构图,驱动需求讨论和任务管理。
@ -147,3 +147,52 @@ docs/templates/ 炼境接入包模板草案(设计层)
完整规则(迭代管理、阻塞处理、复盘笔记等)详见 `.blueprint/CONVENTIONS.md` 完整规则(迭代管理、阻塞处理、复盘笔记等)详见 `.blueprint/CONVENTIONS.md`
<!-- BLUEPRINT_MANAGED_END --> <!-- BLUEPRINT_MANAGED_END -->
<!-- ONBOARDING_MANAGED_START version="1.1.0" -->
## 核心开发约束
### 包管理
- **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/commandUI 只呈现不复制逻辑——
否则人与 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. 约束继承:<影响后续的关键限制>
```
<!-- ONBOARDING_MANAGED_END -->

View File

@ -47,7 +47,7 @@ docs/templates/ 炼境接入包模板草案agents-meta-rules
- 代码重构/简化:/simplify → /code-review - 代码重构/简化:/simplify → /code-review
- 发现深层问题:/Dev-deep-think - 发现深层问题:/Dev-deep-think
<!-- BLUEPRINT_MANAGED_START version="1.9.0" --> <!-- BLUEPRINT_MANAGED_START version="1.10.0" -->
## 项目蓝图 ## 项目蓝图
蓝图工作流规则统一维护在 @AGENTS.md 的 BLUEPRINT_MANAGED 区(跨工具单一来源,勿在此复制)。 蓝图工作流规则统一维护在 @AGENTS.md 的 BLUEPRINT_MANAGED 区(跨工具单一来源,勿在此复制)。

View File

@ -3,7 +3,7 @@
> 把此文件粘贴给任何 AI Agent让它了解本项目的 git 工作方式。由炼境接入包分发。 > 把此文件粘贴给任何 AI Agent让它了解本项目的 git 工作方式。由炼境接入包分发。
> `ONBOARDING_MANAGED` 标记块由炼境版本化管理(重跑接入包可原位升级),块外可自由补充项目特有约定。 > `ONBOARDING_MANAGED` 标记块由炼境版本化管理(重跑接入包可原位升级),块外可自由补充项目特有约定。
<!-- ONBOARDING_MANAGED_START version="1.1.0" --> <!-- ONBOARDING_MANAGED_START version="1.2.0" -->
## 分支策略 ## 分支策略
@ -72,6 +72,22 @@ git commit -m "feat(模块): 一件事"
PR 别攒太大(建议每 3~5 个 commit 或几百行内就开一次),否则 CI code review 易超时失效。 PR 别攒太大(建议每 3~5 个 commit 或几百行内就开一次),否则 CI code review 易超时失效。
「所有检测通过」≠ 代码被审查过——大 PR 即使 CI 全绿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": "<id>", "head": "dev", "base": "main",
"title": "<Conventional Commits 格式>", "body": "<变更摘要>" }
3. 炼境内部用存储的 Gitea 凭证调 API返回 pr_url
```
若炼境未运行MCP 不可用),降级方案:去 Gitea 网页手动创建 PR。
## 常用命令 ## 常用命令
```bash ```bash

View File

@ -82,6 +82,8 @@ pub fn generate_workflow(project_id: String, project_path: String) -> Result<Wor
let (content, filename, secrets) = match config.config_type.as_str() { let (content, filename, secrets) = match config.config_type.as_str() {
"tauri_oss" => build_tauri_oss_workflow(&config), "tauri_oss" => build_tauri_oss_workflow(&config),
"pr_ci" => build_pr_ci_workflow(&config),
"deepseek_review" => build_deepseek_review_workflow(&config),
_ => build_web_ssh_workflow(&config), _ => build_web_ssh_workflow(&config),
}; };
@ -295,6 +297,144 @@ jobs:
(content, "release.yml".to_string(), secrets) (content, "release.yml".to_string(), secrets)
} }
fn build_pr_ci_workflow(cfg: &CicdConfig) -> (String, String, Vec<String>) {
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<String>) {
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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@ -337,4 +477,27 @@ mod tests {
assert!(secrets.contains(&"TAURI_SIGNING_PRIVATE_KEY".to_string())); assert!(secrets.contains(&"TAURI_SIGNING_PRIVATE_KEY".to_string()));
assert!(!secrets.iter().any(|s| s.contains("GITHUB")), "secrets 残留 GitHub 项"); 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()));
}
} }

View File

@ -402,7 +402,7 @@ pub fn gitea_setup_webhook(project_id: String, callback_base: String) -> Result<
"type": "gitea", "type": "gitea",
"active": true, "active": true,
"branch_filter": "*", "branch_filter": "*",
"events": ["push"], "events": ["push", "pull_request"],
"config": { "config": {
"url": callback_url, "url": callback_url,
"content_type": "json", "content_type": "json",
@ -581,6 +581,51 @@ pub fn gitea_list_linked_repos() -> Result<Vec<serde_json::Value>, String> {
Ok(rows) 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<String, String> {
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 绑定 /// 查询单个项目的 Gitea 绑定
#[tauri::command] #[tauri::command]
pub fn gitea_get_repo_link(project_id: String) -> Result<Option<GiteaRepo>, String> { pub fn gitea_get_repo_link(project_id: String) -> Result<Option<GiteaRepo>, String> {

View File

@ -538,6 +538,22 @@ fn migrate(conn: &rusqlite::Connection) {
// 增量迁移(飞轮 R1Rules-Applied trailer 规则命中列(已存在则忽略) // 增量迁移(飞轮 R1Rules-Applied trailer 规则命中列(已存在则忽略)
let _ = conn.execute("ALTER TABLE commit_metrics ADD COLUMN rules_applied TEXT", []); let _ = conn.execute("ALTER TABLE commit_metrics ADD COLUMN rules_applied TEXT", []);
// 飞轮 L1PR 生命周期事件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 约束) // 仪表盘全局快启应用(无项目 FK 约束)
conn.execute_batch(" conn.execute_batch("
CREATE TABLE IF NOT EXISTS global_tools ( CREATE TABLE IF NOT EXISTS global_tools (

View File

@ -43,6 +43,23 @@ fn settings_path(project_id: &str) -> Result<PathBuf, String> {
Ok(project_root(project_id)?.join(".claude").join("settings.json")) Ok(project_root(project_id)?.join(".claude").join("settings.json"))
} }
fn liangjing_path(project_id: &str) -> Result<PathBuf, String> {
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 读写 ───────────────────────────────────────────────────────────────── // ── JSON 读写 ─────────────────────────────────────────────────────────────────
fn read_json(path: &PathBuf) -> Result<Value, String> { fn read_json(path: &PathBuf) -> Result<Value, String> {
@ -80,11 +97,15 @@ pub fn inject_project_mcp(project_id: &str, group_id: &str) -> Result<(), String
let path = settings_path(project_id)?; let path = settings_path(project_id)?;
let mut settings = read_json(&path)?; let mut settings = read_json(&path)?;
let servers = settings let obj = settings
.as_object_mut() .as_object_mut()
.ok_or("settings.json 顶层必须是 JSON 对象")? .ok_or("settings.json 顶层必须是 JSON 对象")?;
.entry("mcpServers")
.or_insert(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); let key = format!("{}{}", MCP_KEY_PREFIX, group_id);
servers[&key] = json!({ 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(|_| { write_json(&path, &settings).inspect(|_| {
let ctx = serde_json::json!({"project_id": project_id, "group_id": group_id}).to_string(); let ctx = serde_json::json!({"project_id": project_id, "group_id": group_id}).to_string();
log_event("mcp_inject", "info", "MCP 配置注入成功", Some(&ctx)); log_event("mcp_inject", "info", "MCP 配置注入成功", Some(&ctx));
}) })?;
// 写炼境上下文,让子项目 Claude 会话无需遍历即可知道自己的 project_id
let _ = write_liangjing_context(project_id, port);
Ok(())
} }
/// 从项目的 .claude/settings.json 移除产品组 MCP 配置 /// 从项目的 .claude/settings.json 移除产品组 MCP 配置

View File

@ -158,53 +158,90 @@ async fn handle_gitea_webhook(
return (StatusCode::UNAUTHORIZED, Json(json!({"error": "签名验证失败"}))).into_response(); return (StatusCode::UNAUTHORIZED, Json(json!({"error": "签名验证失败"}))).into_response();
} }
// 3. 只处理 push 事件,其他事件确认收到但忽略 // 3. 按事件类型分发
let event = headers let event = headers
.get("x-gitea-event") .get("x-gitea-event")
.and_then(|v| v.to_str().ok()) .and_then(|v| v.to_str().ok())
.unwrap_or(""); .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_metricssha 主键幂等,与本地 watcher 双路径不重复计数) async fn handle_push(project_id: &str, body: &Bytes) -> Response {
let commits = match parse_push_payload(&body) { let commits = match parse_push_payload(body) {
Ok(c) => c, Ok(c) => c,
Err(e) => { Err(e) => return (StatusCode::BAD_REQUEST, Json(json!({"error": e}))).into_response(),
return (StatusCode::BAD_REQUEST, Json(json!({"error": e}))).into_response();
}
}; };
let conn = match db::pool().get() { let conn = match db::pool().get() {
Ok(c) => c, Ok(c) => c,
Err(e) => { Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": e.to_string()}))).into_response(),
return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": e.to_string()}))).into_response()
}
}; };
let mut received = 0; let mut received = 0;
for c in &commits { for c in &commits {
if crate::commands::commit_metrics::record_commit( if crate::commands::commit_metrics::record_commit(
&conn, &conn, project_id, &c.sha, &c.message, c.timestamp.as_deref(),
&project_id, ).is_ok() { received += 1; }
&c.sha,
&c.message,
c.timestamp.as_deref(),
)
.is_ok()
{
received += 1;
}
} }
log_event( log_event(
"gitea_webhook", "gitea_webhook", "info",
"info",
&format!("项目 {project_id} push 事件已接收,写入 {received}/{} 条 commit", commits.len()), &format!("项目 {project_id} push 事件已接收,写入 {received}/{} 条 commit", commits.len()),
None, None,
); );
Json(json!({"received": received})).into_response() 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 类型 ───────────────────────────────────────────────────────────── // ── JSON-RPC 类型 ─────────────────────────────────────────────────────────────
#[derive(Deserialize)] #[derive(Deserialize)]
@ -455,6 +492,36 @@ fn tools_list_result() -> Value {
"required": ["project_id"] "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", "name": "get_project_snapshots",
"description": "获取项目的历史健康快照(每日一条),包含 git 提交间隔、蓝图完成率、阻塞任务数、错误情况。用于分析趋势、识别高价值改进变量。", "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; let days = args.get("days").and_then(|v| v.as_u64()).unwrap_or(30) as u32;
get_project_snapshots(&gid, pid, days) 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)), _ => Err(format!("未知工具: {}", tool_name)),
}) })
.await; .await;

View File

@ -111,6 +111,7 @@ export function CicdModal({
} }
const isWebSsh = form.config_type === "web_ssh"; const isWebSsh = form.config_type === "web_ssh";
const isTauriOss = form.config_type === "tauri_oss";
const hasSavedConfig = !!saved?.id; const hasSavedConfig = !!saved?.id;
const hasPath = !!(winPath || wslPath); const hasPath = !!(winPath || wslPath);
@ -132,11 +133,13 @@ export function CicdModal({
<div className="space-y-4"> <div className="space-y-4">
{/* 类型选择 */} {/* 类型选择 */}
<div> <div>
<label className="text-xs text-zinc-400 mb-1 block"></label> <label className="text-xs text-zinc-400 mb-1 block">Workflow </label>
<div className="flex gap-2"> <div className="flex flex-wrap gap-2">
{[ {[
{ value: "web_ssh", label: "Web 项目SSH 部署)", desc: "WSL / Linux 服务器" }, { value: "pr_ci", label: "PR 质量检查", desc: "build / lint / test" },
{ value: "tauri_oss", label: "桌面客户端OSS 分发)", desc: "Tauri / Windows 安装包" }, { 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) => ( ].map((opt) => (
<button <button
key={opt.value} key={opt.value}
@ -213,7 +216,7 @@ export function CicdModal({
)} )}
{/* tauri_oss 专属字段 */} {/* tauri_oss 专属字段 */}
{!isWebSsh && ( {isTauriOss && (
<Field <Field
label="阿里云 OSS Endpoint" label="阿里云 OSS Endpoint"
value={form.oss_endpoint ?? ""} value={form.oss_endpoint ?? ""}
@ -224,6 +227,16 @@ export function CicdModal({
/> />
)} )}
{/* deepseek_review 提示 */}
{form.config_type === "deepseek_review" && (
<div className="rounded-lg bg-zinc-800 border border-zinc-700 px-3 py-2.5 text-xs text-zinc-400 space-y-1">
<p className="text-zinc-300 font-medium"> Gitea Settings Secrets </p>
<p><code className="text-amber-400">DEEPSEEK_API_KEY</code> DeepSeek API </p>
<p><code className="text-amber-400">GITEA_TOKEN</code> issue comment 访</p>
<p><code className="text-amber-400">GITEA_URL</code> Gitea <code>http://192.168.x.x:3000</code></p>
</div>
)}
{/* 操作按钮 */} {/* 操作按钮 */}
<div className="flex gap-2 pt-1"> <div className="flex gap-2 pt-1">
<button <button