Compare commits

...

11 Commits

Author SHA1 Message Date
lanrtop
d8bd4a23e0 chore(blueprint): 同步 gitea-integration/onboarding-pack 状态
- gitea-integration: done 100%(/ship MCP 化闭环)
- onboarding-pack: in_progress 98%(B3 完成,B4 后置)
2026-07-03 21:12:37 +09:00
lanrtop
731fa11456 feat(onboarding+skill): B3 acceptance 可执行化 + template v1.2.0 + skill 升级
- AGENTS.md.tmpl: v1.2.0,新增 rule#5 acceptance 可执行化([载体] 调用→断言)
- 炼境 AGENTS.md: ONBOARDING_MANAGED 同步至 v1.2.0
- /architect: 新增 Step 3.5 声明验证路径载体(Tauri/HTTP/CLI/Playwright 优先级表)
- /splitter: acceptance 格式升级为可执行验证路径,含三类示例
2026-07-03 21:12:22 +09:00
lanrtop
def53d739c fix(gitea)+feat(mcp): PR 409 容错 + apply_onboarding_pack/list_all_projects agent 按钮
- gitea_api_create_pull_request: 409 时自动查 open PR 列表,返回已存在的 PR
- MCP 新增 apply_onboarding_pack(接入包分发 UI 按钮的 agent 投影)
- MCP 新增 list_all_projects(绕过 group 限制,列出全库项目及注入状态)
2026-07-03 21:12:06 +09:00
lanrtop
dccca234b5 fix(mcp): get_ci_status 兼容 Gitea 1.22 的 status 字段(非 state) 2026-07-03 19:59:00 +09:00
lanrtop
14fe02ec7d feat(mcp): 补全 agent 按钮体系——5 个新工具关闭三项结构性缺口
P0 PR 生命周期闭环:
- get_ci_status(project_id, commit_sha):查 Gitea CI pass/fail/pending
- merge_pull_request(project_id, pr_number):CI 绿灯后合并 PR
至此 /ship 流程全自主:推送→开 PR→查 CI→合并→poll_now

P1 飞轮数据对子项目透明:
- get_pr_events(project_id, limit?):读 pr_events 表,PR 生命周期历史
- get_commit_metrics(project_id, days?):读 commit_metrics,提交类型/返工分布

P2 分发层 agent 可自修复:
- distribute_cicd(project_id, workflow_type):写入 CI 模板,无需预存配置
2026-07-03 19:55:12 +09:00
lanrtop
d4cb236ace fix(mcp): SSE 消息端点立即返回 202,后台异步处理工具调用
poll_now 等耗时工具(发出 HTTP 请求到 Gitea)阻塞了 handle_sse_message,
导致客户端 POST 超时。改为 tokio::spawn 在后台处理 + SSE 推回结果,
HTTP 握手立即完成,符合 MCP SSE 传输规范。

另:新增 docs/ai-context/git-workflow.md(分支策略 + Conventional Commits),
.gitignore 排除炼境实例数据(.claude/liangjing.json / dev_manager.db)。
2026-07-03 19:17:25 +09:00
lanrtop
6d10c71d15 feat(gitea+mcp): PR 状态轮询 + poll_now agent 按钮(公网 Gitea webhook 不可达降级方案) 2026-07-03 18:59:19 +09:00
lanrtop
97b116857f feat(mcp): 新增 setup_webhook + inject_mcp agent 按钮(UI 操作的 MCP 投影) 2026-07-03 18:52:30 +09:00
lanrtop
35a8212472 fix(cicd): 模板默认 runner 改为 windows,deepseek_review 加 shell: bash 2026-07-03 18:11:44 +09:00
lanrtop
69183fb06c fix(cicd): 将 GITEA_TOKEN/URL 重命名为 LJ_TOKEN/URL(GITEA_ 前缀被 Gitea 保留) 2026-07-03 17:54:12 +09:00
lanrtop
8432aa4f45 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 提示面板
2026-07-03 16:57:36 +09:00
17 changed files with 1222 additions and 70 deletions

View File

@ -369,18 +369,18 @@ modules:
name: 接入包
area: backend
status: in_progress
progress: 95
note: "体系分发主线2026-07-03T1 模板版本化 ✅ + B1 CONVENTIONS 1.10.0(R12/R13) ✅ + B2 AGENTS 模板 agent 按钮章节 ✅;剩 B3 提示词升级、B4 健康诊断遵守度"
progress: 98
note: "体系分发主线2026-07-03T1 模板版本化 ✅ + B1 CONVENTIONS 1.10.0(R12/R13) ✅ + B2 AGENTS 模板 agent 按钮章节 ✅ + B3 提示词升级 ✅architect 声明载体/splitter acceptance 可执行化/模板升 1.2.0);剩 B4 健康诊断遵守度(后置)"
position: [3500, 0]
- id: gitea-integration
name: Gitea 集成
area: backend
status: in_progress
progress: 85
note: "G1-G6+R0 完成2026-07-02全链路含发版实测通过v0.1.16 已上 OSSPR/CI 事件联动、DeepSeek review、存量迁移concept"
status: done
progress: 100
note: "/ship MCP 化完成2026-07-03create_pull_request 409 处理 + ship.md 全量重写(无 token 提取/无硬编码 URLproject_id 动态读取gitea-integration 闭环"
position: [3500, 150]
updated: 2026-07-02
updated: 2026-07-03
- id: agent-infrastructure
name: Agent 友好基础设施生成

View File

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

View File

@ -27,6 +27,19 @@ allowed-tools: Read, Glob, Grep, Bash(git log *), Bash(git diff *)
- 是否需要新增后端接口 / command
- 前后端数据流如何变化
### 3.5 声明验证路径载体
读取项目 AGENTS.md 的「agent 按钮与验证路径」章节(若存在),按载体就近原则确认本功能的验证载体:
| 项目类型 | 优先验证载体 |
|---------|------------|
| 桌面应用Tauri | 直接调用 command 函数vitest / cargo test |
| Web 服务 | HTTP API 端点curl / supertest |
| CLI / 脚本 | 命令行 / npm scripts |
| UI 呈现层 | Playwright 驱动真界面(最后手段,仅关键路径) |
写一条示例验证步骤:`[载体类型] 调用/请求什么(参数) → 断言什么`。
### 4. 输出设计文档
```
@ -45,6 +58,10 @@ allowed-tools: Read, Glob, Grep, Bash(git log *), Bash(git diff *)
### 系统边界变更
<新增接口 / 数据表 / 字段定义>
### 验证路径
- 载体:<选定的验证载体Tauri command / HTTP API / CLI / Playwright>
- 示例:[载体] <具体调用>(<参数示例>) → <断言预期结果>
### 风险点
- <风险><规避方式>

View File

@ -31,7 +31,12 @@ allowed-tools: Read, Edit, Write, Glob
**可派发标准(每张卡必须满足)**
- `files`:明确列出涉及文件的相对路径
- `acceptance`:一句话验收标准,描述可观察的结果
- `acceptance`**可执行验证路径**,格式:`[载体] 调用/运行什么(参数) → 断言什么`
- 载体从 architect 的「验证路径」声明继承就近选择Tauri command / HTTP API / CLI / Playwright
- 断言要具体(检查字段值、返回码、文件存在、数量 > 0不写模糊的"功能正常"
- 示例:`[Tauri command] apply_onboarding_pack(project_id) → 返回 written≥1磁盘上文件存在`
- 示例:`[HTTP API] POST /api/projects → 201body.id 非空`
- 示例:`[cargo test] cargo test parse_conventional → 6 个 case 全通过`
- `complexity`S 或 M
### 3a. 写入蓝图(有 .blueprint/ 时)
@ -44,7 +49,7 @@ allowed-tools: Read, Edit, Write, Glob
- complexity: S|M
- files: src/path/to/file.tsx, src-tauri/src/commands/xxx.rs
- depends: <依赖模块id>(可选)
- acceptance: <一句话验收标准>
- acceptance: "[载体] 调用什么(参数) → 断言什么"
```
然后更新 `manifest.yaml`status → `planned``updated` 改为今天日期。
@ -56,11 +61,11 @@ allowed-tools: Read, Edit, Write, Glob
### 📋 <卡名>S
- files: ...
- acceptance: ...
- acceptance: "[载体] 调用什么(参数) → 断言什么"
### 📋 <卡名>M
- files: ...
- acceptance: ...
- acceptance: "[载体] 调用什么(参数) → 断言什么"
- depends: 上一张卡
```

View File

@ -0,0 +1,28 @@
# PR 代码质量检查Gitea Actions
# 在 PR 合并前执行 build / lint确保代码可编译且风格合规
name: PR Check
on:
pull_request:
branches: [main]
jobs:
check:
runs-on: windows
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 9
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
- run: pnpm build
- run: pnpm lint --if-present
- run: pnpm test --if-present --passWithNoTests

4
.gitignore vendored
View File

@ -25,5 +25,9 @@ src-tauri/target/
.DS_Store
Thumbs.db
# 炼境实例数据(项目 ID 和端口,每台机器不同,不入库)
.claude/liangjing.json
src-tauri/dev_manager.db
# 防止签名密钥被误提交Windows ~ 路径展开问题)
~/

View File

@ -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,57 @@ docs/templates/ 炼境接入包模板草案(设计层)
完整规则(迭代管理、阻塞处理、复盘笔记等)详见 `.blueprint/CONVENTIONS.md`
<!-- BLUEPRINT_MANAGED_END -->
<!-- ONBOARDING_MANAGED_START version="1.2.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
一次性验收不固化(少而稳 > 多而脆)
5. **acceptance 可执行化**`/splitter` 生成任务卡时,`acceptance` 字段必须是可执行验证路径,
格式:`[载体] 调用/运行什么(参数) → 断言什么`。
- 正例:`[Tauri command] apply_onboarding_pack(project_id) → written≥1文件在磁盘存在`
- 正例:`[HTTP API] POST /api/users → 201body.id 非空`
- 反例:`功能正常可用`模糊agent 无法自验)
## 多 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
- 发现深层问题:/Dev-deep-think
<!-- BLUEPRINT_MANAGED_START version="1.9.0" -->
<!-- BLUEPRINT_MANAGED_START version="1.10.0" -->
## 项目蓝图
蓝图工作流规则统一维护在 @AGENTS.md 的 BLUEPRINT_MANAGED 区(跨工具单一来源,勿在此复制)。

View File

@ -0,0 +1,82 @@
# Git 工作流 — AI Agent 操作指南
> 把此文件粘贴给任何 AI Agent让它了解本项目的 git 工作方式。由炼境接入包分发。
> `ONBOARDING_MANAGED` 标记块由炼境版本化管理(重跑接入包可原位升级),块外可自由补充项目特有约定。
<!-- ONBOARDING_MANAGED_START version="1.1.0" -->
## 分支策略
```
dev ← 日常开发在这里 commit/push
main ← 稳定分支,通过 PR 合入(合入时触发 CI code review
```
- 小改动可直接 push `dev`
- 功能完整后开 PRdev → main
- 不要直接 push `main`,除非紧急热修复
## Commit 规范lefthook 强制)
格式:`<type>(<scope>): <描述>`
| type | 用途 |
|------|------|
| `feat` | 新功能 |
| `fix` | Bug 修复 |
| `chore` | 配置、依赖、脚本 |
| `docs` | 文档 |
| `refactor` | 重构(不改功能) |
| `ci` | CI 配置 |
| `perf` | 性能优化 |
| `style` | 代码格式 |
| `test` | 测试 |
`scope` 用「模块/子模块」二级结构(如 `auth/login`、`api/user`、`db/order`),对应项目功能模块。
```bash
git commit -m "feat(auth/login): 新增手机号登录"
git commit -m "fix(api/user): 修正分页 off-by-one"
```
> 炼境会解析这些 commit 的 type/scope 作为飞轮数据:`fix` 计入返工率、`scope` 归到模块。
> **commit 写得越规范,飞轮数据越准。**
## Rules-Applied trailer规则命中回传
若本次任务中触发过 CONVENTIONS 自动推荐规则R01-R11`.blueprint/CONVENTIONS.md` 规则列表),
在 commit message 末尾追加一行 trailer与正文空行分隔
```
feat(auth/login): 新增手机号登录
Rules-Applied: R01,R05
```
- 只记录**真实触发**(主动检查或执行)的规则,未触发则整行省略
- 炼境自动解析此 trailer 做「规则命中 × 返工」交叉统计,是 CONVENTIONS 置信度进化的数据来源
- 凭空罗列会污染数据——宁缺毋滥
## 一个 commit = 一件独立的事
不要 `git add -A` 一次提交所有。按功能分批:
```bash
git add <相关文件>
git commit -m "feat(模块): 一件事"
```
## PR 大小控制
PR 别攒太大(建议每 3~5 个 commit 或几百行内就开一次),否则 CI code review 易超时失效。
「所有检测通过」≠ 代码被审查过——大 PR 即使 CI 全绿review 可能已静默失效。
## 常用命令
```bash
git status --short
git log --oneline -10
git reset --soft HEAD~1 # 撤销最后一次 commit保留改动
```
<!-- ONBOARDING_MANAGED_END -->

View File

@ -16,7 +16,7 @@
<!-- TODO: 列出 README / ARCHITECTURE.md / 其他关键文档及用途。 -->
<!-- ONBOARDING_MANAGED_START version="1.1.0" -->
<!-- ONBOARDING_MANAGED_START version="1.2.0" -->
## 核心开发约束
### 包管理
@ -53,6 +53,11 @@
断言行为),再交人做 UI 终验——不许绕过功能手改产物冒充验证
4. **侦察 → 驻军**agent 走通的关键路径顺手固化为一条测试vitest / cargo test
一次性验收不固化(少而稳 > 多而脆)
5. **acceptance 可执行化**`/splitter` 生成任务卡时,`acceptance` 字段必须是可执行验证路径,
格式:`[载体] 调用/运行什么(参数) → 断言什么`。
- 正例:`[Tauri command] apply_onboarding_pack(project_id) → written≥1文件在磁盘存在`
- 正例:`[HTTP API] POST /api/users → 201body.id 非空`
- 反例:`功能正常可用`模糊agent 无法自验)
## 多 AI CLI 协作

View File

@ -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

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() {
"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),
};
@ -97,6 +99,68 @@ pub fn generate_workflow(project_id: String, project_path: String) -> Result<Wor
})
}
/// MCP 用:按 workflow_type 直接分发 CI 模板,自动解析项目路径,无需预存 cicd_config。
/// workflow_type: "pr_ci" | "deepseek_review"
pub fn distribute_workflow_for_project(project_id: &str, workflow_type: &str) -> Result<String, String> {
let conn = db::pool().get().map_err(|e| e.to_string())?;
let (win_path, wsl_path, platform): (Option<String>, Option<String>, Option<String>) = conn
.query_row(
"SELECT win_path, wsl_path, platform FROM project_workspaces WHERE id = ?1",
params![project_id],
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
)
.map_err(|_| format!("项目 {} 不存在", project_id))?;
let project_path = if platform.as_deref() == Some("wsl") {
let wsl = wsl_path.ok_or("WSL 项目未配置 wsl_path")?;
let distro: String = conn
.query_row("SELECT value FROM settings WHERE key = 'wsl_distro'", [], |r| r.get(0))
.unwrap_or_else(|_| "Ubuntu".to_string());
let inner = wsl.trim_start_matches('/').replace('/', "\\");
format!("\\\\wsl.localhost\\{}\\{}", distro, inner)
} else {
win_path.ok_or("项目未配置本地路径")?
};
// 最小 configpr_ci 用 trigger_branchdeepseek_review 不使用 config 字段
let minimal_cfg = crate::models::CicdConfig {
id: String::new(),
project_id: project_id.to_string(),
config_type: workflow_type.to_string(),
trigger_branch: "main".to_string(),
build_command: None,
node_version: None,
server_host: None,
server_user: None,
deploy_path: None,
start_command: None,
oss_endpoint: None,
created_at: None,
};
let (content, filename, secrets) = match workflow_type {
"pr_ci" => build_pr_ci_workflow(&minimal_cfg),
"deepseek_review" => build_deepseek_review_workflow(&minimal_cfg),
other => return Err(format!("不支持的 workflow_type: {other}(可选: pr_ci / deepseek_review")),
};
let workflows_dir = Path::new(&project_path).join(".gitea").join("workflows");
std::fs::create_dir_all(&workflows_dir).map_err(|e| format!("创建目录失败: {e}"))?;
let file_path = workflows_dir.join(&filename);
std::fs::write(&file_path, &content).map_err(|e| format!("写入文件失败: {e}"))?;
let secrets_hint = if secrets.is_empty() {
String::new()
} else {
format!(",需要配置 secrets: {}", secrets.join(" / "))
};
Ok(format!(
"CI 模板已写入:.gitea/workflows/{filename}{secrets_hint}\n提交并推送后 CI 将自动触发。"
))
}
// ── workflow 模板生成 ──────────────────────────────────────────────────────────
fn build_web_ssh_workflow(cfg: &CicdConfig) -> (String, String, Vec<String>) {
@ -295,6 +359,145 @@ 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: windows
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 9
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '{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: windows
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: DeepSeek Code Review
env:
DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY }}
LJ_TOKEN: ${{ secrets.LJ_TOKEN }}
LJ_URL: ${{ secrets.LJ_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 }}
shell: bash
run: |
python3 << 'PYEOF'
import os, sys, json, urllib.request, subprocess
api_key = os.environ["DEEPSEEK_API_KEY"]
token = os.environ["LJ_TOKEN"]
base_url = os.environ["LJ_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(),
"LJ_TOKEN".to_string(),
"LJ_URL".to_string(),
];
(content, "ai-review.yml".to_string(), secrets)
}
#[cfg(test)]
mod tests {
use super::*;
@ -337,4 +540,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(&"LJ_TOKEN".to_string()));
assert!(secrets.contains(&"LJ_URL".to_string()));
}
}

View File

@ -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",
@ -535,9 +535,85 @@ pub fn poll_all_repos() -> Result<(usize, usize), String> {
crate::db::log_event("gitea_poll", "info",
&format!("轮询 {polled} 个仓库,新采集 {ingested} 条 commit"), None);
}
// PR 状态轮询:弥补公网 Gitea + 内网炼境时 webhook 不可达的场景
let mut pr_ingested = 0usize;
for (project_id, owner, repo, base_url, token) in &repos {
pr_ingested += poll_pr_events(&conn, project_id, owner, repo, base_url, token);
}
if pr_ingested > 0 {
crate::db::log_event("gitea_poll", "info",
&format!("PR 轮询新采集 {pr_ingested} 条事件"), None);
}
Ok((polled, ingested))
}
/// 轮询单个仓库的 PR 状态,将未记录的事件写入 pr_events返回新增条数
fn poll_pr_events(
conn: &rusqlite::Connection,
project_id: &str,
owner: &str,
repo: &str,
base_url: &str,
token: &str,
) -> usize {
let mut count = 0;
for state in ["open", "closed"] {
let url = format!(
"{base_url}/api/v1/repos/{owner}/{repo}/pulls?state={state}&limit=50&type=pullrequest"
);
let resp = match ureq::get(&url)
.set("Authorization", &format!("token {token}"))
.call()
{
Ok(r) => r,
Err(_) => continue,
};
let prs: serde_json::Value = match resp.into_json() {
Ok(j) => j,
Err(_) => continue,
};
let prs = match prs.as_array() {
Some(a) => a,
None => continue,
};
for pr in prs {
let pr_number = match pr["number"].as_i64() {
Some(n) => n,
None => continue,
};
let title = pr["title"].as_str().unwrap_or("").to_string();
let head_branch = pr["head"]["ref"].as_str().unwrap_or("").to_string();
let base_branch = pr["base"]["ref"].as_str().unwrap_or("").to_string();
let author = pr["user"]["login"].as_str().unwrap_or("").to_string();
let merged = pr["merged"].as_bool().unwrap_or(false);
let pr_state = pr["state"].as_str().unwrap_or("open");
// 确定动作:已合并 > 关闭 > 开启
let action = if merged { "merged" } else if pr_state == "closed" { "closed" } else { "opened" };
let existing: i64 = conn.query_row(
"SELECT COUNT(*) FROM pr_events WHERE project_id = ?1 AND pr_number = ?2 AND action = ?3",
params![project_id, pr_number, action],
|r| r.get(0),
).unwrap_or(0);
if existing == 0 {
let id = uuid::Uuid::new_v4().to_string();
if 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)",
params![id, project_id, pr_number, title, action, head_branch, base_branch, author],
).is_ok() {
count += 1;
}
}
}
}
count
}
/// 手动触发一次轮询UI「立即拉取」按钮
#[tauri::command]
pub fn gitea_poll_now() -> Result<serde_json::Value, String> {
@ -581,6 +657,68 @@ pub fn gitea_list_linked_repos() -> Result<Vec<serde_json::Value>, String> {
Ok(rows)
}
/// 通过炼境存储的凭证为项目创建 PR返回 JSON 字符串(含 pr_url / pr_number / already_exists
/// 409 冲突PR 已存在)时自动查找并返回现有 PRalready_exists=true。
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 create_url = format!("{base_url}/api/v1/repos/{owner}/{repo_name}/pulls");
let (json, already_exists) = match ureq::post(&create_url)
.set("Authorization", &format!("token {token}"))
.set("Content-Type", "application/json")
.send_json(serde_json::json!({ "head": head, "base": base, "title": title, "body": body }))
{
Ok(resp) => (resp.into_json::<serde_json::Value>().map_err(|e| format!("解析响应失败: {e}"))?, false),
Err(ureq::Error::Status(409, _)) => {
// PR 已存在,列出开放 PR 找到匹配的分支
let list_url = format!("{base_url}/api/v1/repos/{owner}/{repo_name}/pulls?state=open&limit=30");
let prs: serde_json::Value = ureq::get(&list_url)
.set("Authorization", &format!("token {token}"))
.call()
.map_err(|e| api_err("获取已有 PR 列表失败", e))?
.into_json()
.map_err(|e| format!("解析 PR 列表失败: {e}"))?;
let arr = prs.as_array().ok_or("PR 列表格式错误")?;
let existing = arr.iter()
.find(|pr| {
pr["head"]["ref"].as_str() == Some(head)
|| pr["head"]["label"].as_str().map(|l| l.ends_with(head)).unwrap_or(false)
})
.cloned()
.ok_or_else(|| format!("PR 已存在但找不到head={head}"))?;
(existing, true)
}
Err(e) => return Err(api_err("创建 PR 失败", e)),
};
let pr_number = json["number"].as_u64().unwrap_or(0);
let pr_url = json["html_url"].as_str().unwrap_or("").to_string();
let pr_title = json["title"].as_str().unwrap_or(title).to_string();
serde_json::to_string(&serde_json::json!({
"pr_number": pr_number,
"pr_url": pr_url,
"head": head,
"base": base,
"title": pr_title,
"already_exists": already_exists,
})).map_err(|e| e.to_string())
}
/// 查询单个项目的 Gitea 绑定
#[tauri::command]
pub fn gitea_get_repo_link(project_id: String) -> Result<Option<GiteaRepo>, String> {
@ -598,6 +736,97 @@ pub fn gitea_get_repo_link(project_id: String) -> Result<Option<GiteaRepo>, Stri
}
}
// ── MCP 工具层CI 状态查询 + PR 合并 ────────────────────────────────────────
/// 查询某 commit 的所有 CI 检查状态,返回 Markdown 格式摘要。
/// 调用 GET /api/v1/repos/{owner}/{repo}/statuses/{sha}
pub fn gitea_api_get_ci_status(project_id: &str, commit_sha: &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}/statuses/{commit_sha}?limit=50");
let resp = ureq::get(&url)
.set("Authorization", &format!("token {token}"))
.call()
.map_err(|e| api_err("查询 CI 状态失败", e))?;
let statuses: serde_json::Value = resp.into_json().map_err(|e| format!("解析响应失败: {e}"))?;
let arr = statuses.as_array().ok_or_else(|| "响应格式错误".to_string())?;
let short_sha = &commit_sha[..8.min(commit_sha.len())];
if arr.is_empty() {
return Ok(format!(
"commit `{}` 暂无 CI 记录CI 可能尚未触发,或 commit 不存在)",
short_sha
));
}
// 同名 context 只取最新一条
let mut latest: std::collections::HashMap<String, &serde_json::Value> =
std::collections::HashMap::new();
for s in arr {
let ctx = s["context"].as_str().unwrap_or("unknown").to_string();
latest.entry(ctx).or_insert(s);
}
let mut lines = vec![format!("## CI 状态commit `{}`\n", short_sha)];
let mut all_pass = true;
for (ctx, s) in &latest {
// Gitea 1.22 Actions 使用 "status" 字段;传统 commit status API 用 "state"
let state = s["status"].as_str()
.or_else(|| s["state"].as_str())
.unwrap_or("unknown");
let desc = s["description"].as_str().unwrap_or("");
let icon = match state {
"success" => "",
"failure" | "error" => { all_pass = false; "" },
_ => { all_pass = false; "" },
};
lines.push(format!("- {} `{}` — {} {}", icon, ctx, state, desc));
}
let conclusion = if all_pass {
"✅ 全部通过,可以合并"
} else {
"❌ 存在失败或等待中的检查,暂不建议合并"
};
lines.push(format!("\n**结论:{}**", conclusion));
Ok(lines.join("\n"))
}
/// 合并指定 PR。调用 POST /api/v1/repos/{owner}/{repo}/pulls/{index}/merge
pub fn gitea_api_merge_pull_request(project_id: &str, pr_number: i64) -> 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/{pr_number}/merge");
ureq::post(&url)
.set("Authorization", &format!("token {token}"))
.set("Content-Type", "application/json")
.send_json(serde_json::json!({
"Do": "merge",
"merge_title_field": format!("Merge PR #{}", pr_number),
"merge_message_field": "",
}))
.map_err(|e| api_err("合并 PR 失败", e))?;
Ok(format!("PR #{pr_number} 已成功合并({owner}/{repo_name}"))
}
#[cfg(test)]
mod tests {
use super::*;

View File

@ -538,6 +538,22 @@ fn migrate(conn: &rusqlite::Connection) {
// 增量迁移(飞轮 R1Rules-Applied trailer 规则命中列(已存在则忽略)
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 约束)
conn.execute_batch("
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"))
}
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 配置

View File

@ -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_metricssha 主键幂等,与本地 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)]
@ -286,7 +323,7 @@ async fn handle_sse_connect(
}
/// POST /mcp/group/:group_id/messages?session_id=xxx
/// 客户端通过此 URL 发送 JSON-RPC 请求,响应经 SSE channel 推回。
/// 客户端通过此 URL 发送 JSON-RPC 请求,立即返回 202响应经 SSE channel 异步推回。
async fn handle_sse_message(
State(sessions): State<Sessions>,
Path(group_id): Path<String>,
@ -296,27 +333,30 @@ async fn handle_sse_message(
if req.id.is_none() {
return StatusCode::ACCEPTED.into_response();
}
let id = req.id.clone();
let response_json = match process_rpc(&group_id, &req).await {
Ok(result) => json!({ "jsonrpc": "2.0", "id": id, "result": result }),
Err(msg) => json!({
"jsonrpc": "2.0", "id": id,
"error": { "code": -32601, "message": msg }
}),
};
let mut sessions_guard = sessions.lock().await;
if let Some(tx) = sessions_guard.get(&query.session_id) {
if tx
.send(("message".to_string(), response_json.to_string()))
.await
.is_err()
{
// 连接已关闭,惰性清理
sessions_guard.remove(&query.session_id);
// 立即返回 202在后台 task 里处理(避免长时间工具调用阻塞 HTTP 连接)
let sessions_clone = sessions.clone();
let session_id = query.session_id.clone();
tokio::spawn(async move {
let id = req.id.clone();
let response_json = match process_rpc(&group_id, &req).await {
Ok(result) => json!({ "jsonrpc": "2.0", "id": id, "result": result }),
Err(msg) => json!({
"jsonrpc": "2.0", "id": id,
"error": { "code": -32601, "message": msg }
}),
};
let mut guard = sessions_clone.lock().await;
if let Some(tx) = guard.get(&session_id) {
if tx
.send(("message".to_string(), response_json.to_string()))
.await
.is_err()
{
guard.remove(&session_id);
}
}
}
});
StatusCode::ACCEPTED.into_response()
}
@ -455,6 +495,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 提交间隔、蓝图完成率、阻塞任务数、错误情况。用于分析趋势、识别高价值改进变量。",
@ -472,6 +542,157 @@ fn tools_list_result() -> Value {
},
"required": ["project_id"]
}
},
{
"name": "poll_now",
"description": "立即触发一次 Gitea 轮询:拉取所有绑定仓库的最新 commit 和 PR 状态写入飞轮。等价于炼境 UI「立即拉取」按钮。适用于公网 Gitea + 内网炼境webhook 不可达)场景。",
"inputSchema": {
"type": "object",
"properties": {},
"required": []
}
},
{
"name": "setup_webhook",
"description": "为项目重新注册 Gitea webhook订阅 push + pull_request 事件)。等价于炼境 UI「重设」按钮。导入新项目后、或需要更新事件订阅时调用。",
"inputSchema": {
"type": "object",
"properties": {
"project_id": {
"type": "string",
"description": "项目 workspace ID来自 list_group_projects 的返回结果)"
}
},
"required": ["project_id"]
}
},
{
"name": "inject_mcp",
"description": "向项目的 .claude/settings.json 注入炼境 MCP 配置,同时写入 bypassPermissions 和 liangjing.json。等价于炼境 UI「注入 MCP」按钮。导入新项目后或炼境重启后调用。",
"inputSchema": {
"type": "object",
"properties": {
"project_id": {
"type": "string",
"description": "项目 workspace ID来自 list_group_projects 的返回结果)"
}
},
"required": ["project_id"]
}
},
{
"name": "get_ci_status",
"description": "查询指定 commit 的 CI 检查结果pass/fail/pending。/ship 流程推送分支并开 PR 后,用此工具确认 CI 是否通过再决定是否合并。",
"inputSchema": {
"type": "object",
"properties": {
"project_id": {
"type": "string",
"description": "项目 workspace ID来自 list_group_projects 的返回结果)"
},
"commit_sha": {
"type": "string",
"description": "要查询的 commit SHA完整 40 位或前 8 位均可)"
}
},
"required": ["project_id", "commit_sha"]
}
},
{
"name": "merge_pull_request",
"description": "合并项目的指定 PR。建议在 get_ci_status 确认通过后再调用。等价于 Gitea UI「合并 PR」按钮。",
"inputSchema": {
"type": "object",
"properties": {
"project_id": {
"type": "string",
"description": "项目 workspace ID来自 list_group_projects 的返回结果)"
},
"pr_number": {
"type": "integer",
"description": "PR 编号(来自 create_pull_request 的返回结果或 Gitea UI"
}
},
"required": ["project_id", "pr_number"]
}
},
{
"name": "get_pr_events",
"description": "查询项目的 PR 生命周期历史opened/merged/closed。数据来自炼境飞轮本地 git 命令无法获取。",
"inputSchema": {
"type": "object",
"properties": {
"project_id": {
"type": "string",
"description": "项目 workspace ID来自 list_group_projects 的返回结果)"
},
"limit": {
"type": "integer",
"description": "返回最近 N 条记录,默认 20"
}
},
"required": ["project_id"]
}
},
{
"name": "get_commit_metrics",
"description": "查询项目的提交飞轮数据提交类型分布feat/fix/chore...)、是否返工、近期频率。数据来自炼境 DB本地 git log 无法直接得到解析后的分类。",
"inputSchema": {
"type": "object",
"properties": {
"project_id": {
"type": "string",
"description": "项目 workspace ID来自 list_group_projects 的返回结果)"
},
"days": {
"type": "integer",
"description": "查询最近 N 天,默认 30"
}
},
"required": ["project_id"]
}
},
{
"name": "distribute_cicd",
"description": "向项目写入 CI 模板文件(.gitea/workflows/)。当项目缺少 CI 配置时调用,自动解析项目路径写入。支持 pr_ciPR 质量检查)和 deepseek_reviewAI 代码审查)两种类型。",
"inputSchema": {
"type": "object",
"properties": {
"project_id": {
"type": "string",
"description": "项目 workspace ID来自 list_group_projects 的返回结果)"
},
"workflow_type": {
"type": "string",
"enum": ["pr_ci", "deepseek_review"],
"description": "pr_ci=PR 合并前质量检查deepseek_review=AI 代码审查并评论到 PR"
}
},
"required": ["project_id", "workflow_type"]
}
},
{
"name": "list_all_projects",
"description": "列出炼境中所有已登记项目(不限产品组),包含 project_id、名称、路径、所属产品组、MCP 注入状态。用于发现尚未注入的项目,再调 inject_mcp 完成接入。",
"inputSchema": {
"type": "object",
"properties": {},
"required": []
}
},
{
"name": "apply_onboarding_pack",
"description": "向项目分发标准接入包:写入 AGENTS.md / lefthook / git-workflow 模板(已存在则原位升级 ONBOARDING_MANAGED 块,绝不覆盖块外用户内容),并挂载 buff 自动监听 git 提交。等价于炼境 UI「接入包」区域的「应用接入包」按钮。模板版本升级后对存量项目重新调用即可推送新版本。",
"inputSchema": {
"type": "object",
"properties": {
"project_id": {
"type": "string",
"description": "项目 workspace ID来自 list_group_projects 的返回结果)"
}
},
"required": ["project_id"]
}
}
]
})
@ -548,6 +769,156 @@ 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)
}
"poll_now" => {
crate::commands::gitea::poll_all_repos()
.map(|(polled, ingested)| format!("轮询完成:{polled} 个仓库,新 commit {ingested}PR 事件已同步"))
}
"setup_webhook" => {
let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or("");
let port = read_port();
let callback_base = format!("http://127.0.0.1:{}", port);
crate::commands::gitea::gitea_setup_webhook(pid.to_string(), callback_base)
.map(|repo| format!(
"webhook 注册成功:{}/{} hook_id={}",
repo.gitea_owner, repo.gitea_repo_name,
repo.webhook_id.map(|id| id.to_string()).unwrap_or_default()
))
}
"inject_mcp" => {
let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or("");
let conn = db::pool().get().map_err(|e| e.to_string())?;
let group_id: String = conn.query_row(
"SELECT group_id FROM project_group_map WHERE project_id = ?1 LIMIT 1",
rusqlite::params![pid],
|r| r.get(0),
).map_err(|_| format!("项目 {} 未加入任何产品组", pid))?;
drop(conn);
crate::mcp_inject::inject_project_mcp(pid, &group_id)
.map(|_| format!("MCP 注入成功project_id={} group_id={}", pid, group_id))
}
"get_ci_status" => {
let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or("");
let sha = args.get("commit_sha").and_then(|v| v.as_str()).unwrap_or("");
if sha.is_empty() { return Err("commit_sha 不能为空".to_string()); }
crate::commands::gitea::gitea_api_get_ci_status(pid, sha)
}
"merge_pull_request" => {
let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or("");
let pr_num = args.get("pr_number").and_then(|v| v.as_i64()).unwrap_or(0);
if pr_num <= 0 { return Err("pr_number 必须为正整数".to_string()); }
crate::commands::gitea::gitea_api_merge_pull_request(pid, pr_num)
}
"get_pr_events" => {
let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or("");
let limit = args.get("limit").and_then(|v| v.as_u64()).unwrap_or(20) as usize;
let conn = db::pool().get().map_err(|e| e.to_string())?;
let mut stmt = conn.prepare(
"SELECT pr_number, title, action, head_branch, base_branch, author, event_at
FROM pr_events WHERE project_id = ?1 ORDER BY event_at DESC LIMIT ?2"
).map_err(|e| e.to_string())?;
let rows: Vec<String> = stmt.query_map(
rusqlite::params![pid, limit as i64],
|r| Ok((r.get::<_,i64>(0)?, r.get::<_,String>(1)?, r.get::<_,String>(2)?,
r.get::<_,String>(3)?, r.get::<_,String>(4)?,
r.get::<_,String>(5)?, r.get::<_,String>(6)?)),
).map_err(|e| e.to_string())?
.filter_map(|r| r.ok())
.map(|(num, title, action, head, base, author, at)| {
let icon = match action.as_str() {
"merged" => "", "closed" => "🚫", _ => "🔵",
};
format!("{icon} PR #{num} [{action}] {title}\n {head}{base} by {author} @ {at}")
}).collect();
if rows.is_empty() {
Ok(format!("项目 {} 暂无 PR 事件记录", pid))
} else {
Ok(format!("PR 事件(最近 {} 条):\n\n{}", rows.len(), rows.join("\n\n")))
}
}
"get_commit_metrics" => {
let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or("");
let days = args.get("days").and_then(|v| v.as_u64()).unwrap_or(30);
let conn = db::pool().get().map_err(|e| e.to_string())?;
let offset = format!("-{} days", days);
// 总体统计
let total: i64 = conn.query_row(
"SELECT COUNT(*) FROM commit_metrics WHERE project_id = ?1 AND committed_at >= datetime('now', ?2)",
rusqlite::params![pid, offset],
|r| r.get(0),
).unwrap_or(0);
// 按 commit_type 分组
let mut type_stmt = conn.prepare(
"SELECT COALESCE(commit_type,'unknown') as t, COUNT(*) as n,
SUM(is_rework) as rework
FROM commit_metrics WHERE project_id = ?1
AND committed_at >= datetime('now', ?2)
GROUP BY t ORDER BY n DESC"
).map_err(|e| e.to_string())?;
let type_rows: Vec<String> = type_stmt.query_map(
rusqlite::params![pid, offset],
|r| Ok((r.get::<_,String>(0)?, r.get::<_,i64>(1)?, r.get::<_,i64>(2)?)),
).map_err(|e| e.to_string())?
.filter_map(|r| r.ok())
.map(|(t, n, rework)| format!(" - {t}: {n} 条(返工 {rework} 条)"))
.collect();
let rework_total: i64 = conn.query_row(
"SELECT SUM(is_rework) FROM commit_metrics WHERE project_id = ?1 AND committed_at >= datetime('now', ?2)",
rusqlite::params![pid, offset],
|r| r.get(0),
).unwrap_or(0);
let mut out = format!("## 提交飞轮数据(最近 {} 天)\n\n", days);
out.push_str(&format!("- **总提交数**{}\n", total));
out.push_str(&format!("- **返工提交**{} 条({:.0}%\n", rework_total,
if total > 0 { rework_total as f64 / total as f64 * 100.0 } else { 0.0 }));
out.push_str("\n**按类型分布**\n");
out.push_str(&type_rows.join("\n"));
Ok(out)
}
"distribute_cicd" => {
let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or("");
let wt = args.get("workflow_type").and_then(|v| v.as_str()).unwrap_or("");
crate::commands::cicd::distribute_workflow_for_project(pid, wt)
}
"list_all_projects" => list_all_projects(),
"apply_onboarding_pack" => {
let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or("");
resolve_project_root(&gid, pid).and_then(|root| {
let path = root.to_string_lossy().to_string();
crate::commands::onboarding::apply_onboarding_pack(path, pid.to_string())
.map(|report| {
let template_lines: Vec<String> = report.templates.iter()
.map(|t| {
let icon = match t.action.as_str() {
"written" => "✅ written",
"updated" => "🔄 updated",
"appended" => " appended",
"skipped" => "⏭ skipped",
_ => "❌ failed",
};
format!(" {icon}: {}", t.name)
})
.collect();
format!(
"接入包分发完成project_id={}\n\n模板:\n{}\n\nbuff{}",
pid,
template_lines.join("\n"),
report.buff.status
)
})
})
}
_ => Err(format!("未知工具: {}", tool_name)),
})
.await;
@ -899,6 +1270,61 @@ fn get_project_snapshots(group_id: &str, project_id: &str, days: u32) -> Result<
Ok(md)
}
fn list_all_projects() -> Result<String, String> {
let conn = db::pool().get().map_err(|e| e.to_string())?;
let mut stmt = conn.prepare(
"SELECT pw.id, pp.name, pw.platform, pw.win_path, pw.wsl_path,
pgm.group_id
FROM project_workspaces pw
JOIN project_profiles pp ON pp.id = pw.profile_id
LEFT JOIN project_group_map pgm ON pgm.project_id = pw.id
ORDER BY pp.name"
).map_err(|e| e.to_string())?;
let rows: Vec<(String, String, Option<String>, Option<String>, Option<String>, Option<String>)> =
stmt.query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?, r.get(5)?)))
.map_err(|e| e.to_string())?
.filter_map(|r| r.ok())
.collect();
if rows.is_empty() {
return Ok("炼境暂无登记项目。".to_string());
}
let mut out = format!("## 炼境全量项目(共 {} 个)\n\n", rows.len());
for (id, name, platform, win_path, wsl_path, group_id) in &rows {
// 判断是否已注入:检查 liangjing.json 是否存在
let project_path = if platform.as_deref() == Some("wsl") {
wsl_path.as_ref().map(|p| {
let conn2 = db::pool().get().ok();
let distro = conn2.and_then(|c| c.query_row(
"SELECT value FROM settings WHERE key = 'wsl_distro'", [], |r| r.get::<_,String>(0)
).ok()).unwrap_or_else(|| "Ubuntu".to_string());
let inner = p.trim_start_matches('/').replace('/', "\\");
format!("\\\\wsl.localhost\\{}\\{}", distro, inner)
})
} else {
win_path.clone()
};
let injected = project_path.as_ref().map(|p| {
std::path::Path::new(p).join(".claude").join("liangjing.json").exists()
}).unwrap_or(false);
let inject_icon = if injected { "✅ 已注入" } else { "⚠️ 未注入" };
let group_str = group_id.as_deref().unwrap_or("无产品组");
let path_str = project_path.as_deref().unwrap_or("未配置路径");
out.push_str(&format!(
"- **{}** `{}`\n {} | 组: `{}` | {}\n\n",
name, id, inject_icon, group_str, path_str
));
}
out.push_str("---\n对未注入项目调用 `inject_mcp(project_id)` 完成接入。");
Ok(out)
}
// ── Gitea Webhook 单元测试 ────────────────────────────────────────────────────
#[cfg(test)]

View File

@ -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">LJ_TOKEN</code> Gitea 访 issue </p>
<p><code className="text-amber-400">LJ_URL</code> Gitea <code>http://192.168.x.x:3000</code></p>
</div>
)}
{/* 操作按钮 */}
<div className="flex gap-2 pt-1">
<button