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": {
|
"permissions": {
|
||||||
"defaultMode": "bypassPermissions"
|
"defaultMode": "bypassPermissions"
|
||||||
}
|
}
|
||||||
|
|||||||
51
AGENTS.md
51
AGENTS.md
@ -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/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
|
- 代码重构/简化:/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 区(跨工具单一来源,勿在此复制)。
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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()));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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> {
|
||||||
|
|||||||
@ -538,6 +538,22 @@ fn migrate(conn: &rusqlite::Connection) {
|
|||||||
// 增量迁移(飞轮 R1):Rules-Applied trailer 规则命中列(已存在则忽略)
|
// 增量迁移(飞轮 R1):Rules-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", []);
|
||||||
|
|
||||||
|
// 飞轮 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 约束)
|
// 仪表盘全局快启应用(无项目 FK 约束)
|
||||||
conn.execute_batch("
|
conn.execute_batch("
|
||||||
CREATE TABLE IF NOT EXISTS global_tools (
|
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"))
|
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 配置
|
||||||
|
|||||||
@ -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_metrics(sha 主键幂等,与本地 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;
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user