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:
parent
bcdcad7f27
commit
8432aa4f45
@ -12,6 +12,12 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"mcpServers": {
|
||||
"lian-jing:group-ffa9e42d": {
|
||||
"type": "sse",
|
||||
"url": "http://127.0.0.1:27190/mcp/group/ffa9e42d"
|
||||
}
|
||||
},
|
||||
"permissions": {
|
||||
"defaultMode": "bypassPermissions"
|
||||
}
|
||||
|
||||
51
AGENTS.md
51
AGENTS.md
@ -110,7 +110,7 @@ docs/templates/ 炼境接入包模板草案(设计层)
|
||||
- [ ] `src/lib/commands.ts` 的封装覆盖了所有 `src-tauri/src/commands/` 下的 command 吗?
|
||||
- [ ] 接入包模板(`resources/onboarding/`)和 `docs/templates/` 内容一致吗?
|
||||
|
||||
<!-- BLUEPRINT_MANAGED_START version="1.9.0" -->
|
||||
<!-- BLUEPRINT_MANAGED_START version="1.10.0" -->
|
||||
## 项目蓝图
|
||||
|
||||
本项目使用 `.blueprint/` 目录维护项目全景架构图,驱动需求讨论和任务管理。
|
||||
@ -147,3 +147,52 @@ docs/templates/ 炼境接入包模板草案(设计层)
|
||||
|
||||
完整规则(迭代管理、阻塞处理、复盘笔记等)详见 `.blueprint/CONVENTIONS.md`。
|
||||
<!-- 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/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. 约束继承:<影响后续的关键限制>
|
||||
```
|
||||
<!-- ONBOARDING_MANAGED_END -->
|
||||
|
||||
@ -47,7 +47,7 @@ docs/templates/ 炼境接入包模板草案(agents-meta-rules
|
||||
- 代码重构/简化:/simplify → /code-review
|
||||
- 发现深层问题:/Dev-deep-think
|
||||
|
||||
<!-- BLUEPRINT_MANAGED_START version="1.9.0" -->
|
||||
<!-- BLUEPRINT_MANAGED_START version="1.10.0" -->
|
||||
## 项目蓝图
|
||||
|
||||
蓝图工作流规则统一维护在 @AGENTS.md 的 BLUEPRINT_MANAGED 区(跨工具单一来源,勿在此复制)。
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
> 把此文件粘贴给任何 AI Agent,让它了解本项目的 git 工作方式。由炼境接入包分发。
|
||||
> `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 即使 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
|
||||
|
||||
@ -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() {
|
||||
"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),
|
||||
};
|
||||
|
||||
@ -295,6 +297,144 @@ jobs:
|
||||
(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)]
|
||||
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()));
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<Vec<serde_json::Value>, 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<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 绑定
|
||||
#[tauri::command]
|
||||
pub fn gitea_get_repo_link(project_id: String) -> Result<Option<GiteaRepo>, String> {
|
||||
|
||||
@ -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 (
|
||||
|
||||
@ -43,6 +43,23 @@ fn settings_path(project_id: &str) -> Result<PathBuf, String> {
|
||||
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 读写 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
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 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 配置
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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({
|
||||
<div className="space-y-4">
|
||||
{/* 类型选择 */}
|
||||
<div>
|
||||
<label className="text-xs text-zinc-400 mb-1 block">部署类型</label>
|
||||
<div className="flex gap-2">
|
||||
<label className="text-xs text-zinc-400 mb-1 block">Workflow 类型</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{[
|
||||
{ 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) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
@ -213,7 +216,7 @@ export function CicdModal({
|
||||
)}
|
||||
|
||||
{/* tauri_oss 专属字段 */}
|
||||
{!isWebSsh && (
|
||||
{isTauriOss && (
|
||||
<Field
|
||||
label="阿里云 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">
|
||||
<button
|
||||
|
||||
Loading…
Reference in New Issue
Block a user