Compare commits
11 Commits
bcdcad7f27
...
d8bd4a23e0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d8bd4a23e0 | ||
|
|
731fa11456 | ||
|
|
def53d739c | ||
|
|
dccca234b5 | ||
|
|
14fe02ec7d | ||
|
|
d4cb236ace | ||
|
|
6d10c71d15 | ||
|
|
97b116857f | ||
|
|
35a8212472 | ||
|
|
69183fb06c | ||
|
|
8432aa4f45 |
@ -369,18 +369,18 @@ modules:
|
|||||||
name: 接入包
|
name: 接入包
|
||||||
area: backend
|
area: backend
|
||||||
status: in_progress
|
status: in_progress
|
||||||
progress: 95
|
progress: 98
|
||||||
note: "体系分发主线(2026-07-03):T1 模板版本化 ✅ + B1 CONVENTIONS 1.10.0(R12/R13) ✅ + B2 AGENTS 模板 agent 按钮章节 ✅;剩 B3 提示词升级、B4 健康诊断遵守度"
|
note: "体系分发主线(2026-07-03):T1 模板版本化 ✅ + B1 CONVENTIONS 1.10.0(R12/R13) ✅ + B2 AGENTS 模板 agent 按钮章节 ✅ + B3 提示词升级 ✅(architect 声明载体/splitter acceptance 可执行化/模板升 1.2.0);剩 B4 健康诊断遵守度(后置)"
|
||||||
position: [3500, 0]
|
position: [3500, 0]
|
||||||
|
|
||||||
- id: gitea-integration
|
- id: gitea-integration
|
||||||
name: Gitea 集成
|
name: Gitea 集成
|
||||||
area: backend
|
area: backend
|
||||||
status: in_progress
|
status: done
|
||||||
progress: 85
|
progress: 100
|
||||||
note: "G1-G6+R0 完成(2026-07-02):全链路含发版实测通过(v0.1.16 已上 OSS);余:PR/CI 事件联动、DeepSeek review、存量迁移(concept)"
|
note: "/ship MCP 化完成(2026-07-03):create_pull_request 409 处理 + ship.md 全量重写(无 token 提取/无硬编码 URL,project_id 动态读取),gitea-integration 闭环"
|
||||||
position: [3500, 150]
|
position: [3500, 150]
|
||||||
updated: 2026-07-02
|
updated: 2026-07-03
|
||||||
|
|
||||||
- id: agent-infrastructure
|
- id: agent-infrastructure
|
||||||
name: Agent 友好基础设施生成
|
name: Agent 友好基础设施生成
|
||||||
|
|||||||
@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -27,6 +27,19 @@ allowed-tools: Read, Glob, Grep, Bash(git log *), Bash(git diff *)
|
|||||||
- 是否需要新增后端接口 / command
|
- 是否需要新增后端接口 / command
|
||||||
- 前后端数据流如何变化
|
- 前后端数据流如何变化
|
||||||
|
|
||||||
|
### 3.5 声明验证路径载体
|
||||||
|
|
||||||
|
读取项目 AGENTS.md 的「agent 按钮与验证路径」章节(若存在),按载体就近原则确认本功能的验证载体:
|
||||||
|
|
||||||
|
| 项目类型 | 优先验证载体 |
|
||||||
|
|---------|------------|
|
||||||
|
| 桌面应用(Tauri) | 直接调用 command 函数(vitest / cargo test) |
|
||||||
|
| Web 服务 | HTTP API 端点(curl / supertest) |
|
||||||
|
| CLI / 脚本 | 命令行 / npm scripts |
|
||||||
|
| UI 呈现层 | Playwright 驱动真界面(最后手段,仅关键路径) |
|
||||||
|
|
||||||
|
写一条示例验证步骤:`[载体类型] 调用/请求什么(参数) → 断言什么`。
|
||||||
|
|
||||||
### 4. 输出设计文档
|
### 4. 输出设计文档
|
||||||
|
|
||||||
```
|
```
|
||||||
@ -45,6 +58,10 @@ allowed-tools: Read, Glob, Grep, Bash(git log *), Bash(git diff *)
|
|||||||
### 系统边界变更
|
### 系统边界变更
|
||||||
<新增接口 / 数据表 / 字段定义>
|
<新增接口 / 数据表 / 字段定义>
|
||||||
|
|
||||||
|
### 验证路径
|
||||||
|
- 载体:<选定的验证载体(Tauri command / HTTP API / CLI / Playwright)>
|
||||||
|
- 示例:[载体] <具体调用>(<参数示例>) → <断言预期结果>
|
||||||
|
|
||||||
### 风险点
|
### 风险点
|
||||||
- <风险>:<规避方式>
|
- <风险>:<规避方式>
|
||||||
|
|
||||||
|
|||||||
@ -31,7 +31,12 @@ allowed-tools: Read, Edit, Write, Glob
|
|||||||
|
|
||||||
**可派发标准(每张卡必须满足)**
|
**可派发标准(每张卡必须满足)**
|
||||||
- `files`:明确列出涉及文件的相对路径
|
- `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 → 201,body.id 非空`
|
||||||
|
- 示例:`[cargo test] cargo test parse_conventional → 6 个 case 全通过`
|
||||||
- `complexity`:S 或 M
|
- `complexity`:S 或 M
|
||||||
|
|
||||||
### 3a. 写入蓝图(有 .blueprint/ 时)
|
### 3a. 写入蓝图(有 .blueprint/ 时)
|
||||||
@ -44,7 +49,7 @@ allowed-tools: Read, Edit, Write, Glob
|
|||||||
- complexity: S|M
|
- complexity: S|M
|
||||||
- files: src/path/to/file.tsx, src-tauri/src/commands/xxx.rs
|
- files: src/path/to/file.tsx, src-tauri/src/commands/xxx.rs
|
||||||
- depends: <依赖模块id>(可选)
|
- depends: <依赖模块id>(可选)
|
||||||
- acceptance: <一句话验收标准>
|
- acceptance: "[载体] 调用什么(参数) → 断言什么"
|
||||||
```
|
```
|
||||||
|
|
||||||
然后更新 `manifest.yaml`:status → `planned`,`updated` 改为今天日期。
|
然后更新 `manifest.yaml`:status → `planned`,`updated` 改为今天日期。
|
||||||
@ -56,11 +61,11 @@ allowed-tools: Read, Edit, Write, Glob
|
|||||||
|
|
||||||
### 📋 <卡名>(S)
|
### 📋 <卡名>(S)
|
||||||
- files: ...
|
- files: ...
|
||||||
- acceptance: ...
|
- acceptance: "[载体] 调用什么(参数) → 断言什么"
|
||||||
|
|
||||||
### 📋 <卡名>(M)
|
### 📋 <卡名>(M)
|
||||||
- files: ...
|
- files: ...
|
||||||
- acceptance: ...
|
- acceptance: "[载体] 调用什么(参数) → 断言什么"
|
||||||
- depends: 上一张卡
|
- depends: 上一张卡
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
28
.gitea/workflows/pr-check.yml
Normal file
28
.gitea/workflows/pr-check.yml
Normal 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
4
.gitignore
vendored
@ -25,5 +25,9 @@ src-tauri/target/
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
||||||
|
# 炼境实例数据(项目 ID 和端口,每台机器不同,不入库)
|
||||||
|
.claude/liangjing.json
|
||||||
|
src-tauri/dev_manager.db
|
||||||
|
|
||||||
# 防止签名密钥被误提交(Windows ~ 路径展开问题)
|
# 防止签名密钥被误提交(Windows ~ 路径展开问题)
|
||||||
~/
|
~/
|
||||||
|
|||||||
56
AGENTS.md
56
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,57 @@ docs/templates/ 炼境接入包模板草案(设计层)
|
|||||||
|
|
||||||
完整规则(迭代管理、阻塞处理、复盘笔记等)详见 `.blueprint/CONVENTIONS.md`。
|
完整规则(迭代管理、阻塞处理、复盘笔记等)详见 `.blueprint/CONVENTIONS.md`。
|
||||||
<!-- BLUEPRINT_MANAGED_END -->
|
<!-- 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/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);
|
||||||
|
一次性验收不固化(少而稳 > 多而脆)
|
||||||
|
5. **acceptance 可执行化**:`/splitter` 生成任务卡时,`acceptance` 字段必须是可执行验证路径,
|
||||||
|
格式:`[载体] 调用/运行什么(参数) → 断言什么`。
|
||||||
|
- 正例:`[Tauri command] apply_onboarding_pack(project_id) → written≥1,文件在磁盘存在`
|
||||||
|
- 正例:`[HTTP API] POST /api/users → 201,body.id 非空`
|
||||||
|
- 反例:`功能正常可用`(模糊,agent 无法自验)
|
||||||
|
|
||||||
|
## 多 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 区(跨工具单一来源,勿在此复制)。
|
||||||
|
|||||||
82
docs/ai-context/git-workflow.md
Normal file
82
docs/ai-context/git-workflow.md
Normal 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`
|
||||||
|
- 功能完整后开 PR(dev → 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 -->
|
||||||
@ -16,7 +16,7 @@
|
|||||||
|
|
||||||
<!-- TODO: 列出 README / ARCHITECTURE.md / 其他关键文档及用途。 -->
|
<!-- TODO: 列出 README / ARCHITECTURE.md / 其他关键文档及用途。 -->
|
||||||
|
|
||||||
<!-- ONBOARDING_MANAGED_START version="1.1.0" -->
|
<!-- ONBOARDING_MANAGED_START version="1.2.0" -->
|
||||||
## 核心开发约束
|
## 核心开发约束
|
||||||
|
|
||||||
### 包管理
|
### 包管理
|
||||||
@ -53,6 +53,11 @@
|
|||||||
断言行为),再交人做 UI 终验——不许绕过功能手改产物冒充验证
|
断言行为),再交人做 UI 终验——不许绕过功能手改产物冒充验证
|
||||||
4. **侦察 → 驻军**:agent 走通的关键路径,顺手固化为一条测试(vitest / cargo test);
|
4. **侦察 → 驻军**:agent 走通的关键路径,顺手固化为一条测试(vitest / cargo test);
|
||||||
一次性验收不固化(少而稳 > 多而脆)
|
一次性验收不固化(少而稳 > 多而脆)
|
||||||
|
5. **acceptance 可执行化**:`/splitter` 生成任务卡时,`acceptance` 字段必须是可执行验证路径,
|
||||||
|
格式:`[载体] 调用/运行什么(参数) → 断言什么`。
|
||||||
|
- 正例:`[Tauri command] apply_onboarding_pack(project_id) → written≥1,文件在磁盘存在`
|
||||||
|
- 正例:`[HTTP API] POST /api/users → 201,body.id 非空`
|
||||||
|
- 反例:`功能正常可用`(模糊,agent 无法自验)
|
||||||
|
|
||||||
## 多 AI CLI 协作
|
## 多 AI CLI 协作
|
||||||
|
|
||||||
|
|||||||
@ -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),
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -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("项目未配置本地路径")?
|
||||||
|
};
|
||||||
|
|
||||||
|
// 最小 config,pr_ci 用 trigger_branch;deepseek_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 模板生成 ──────────────────────────────────────────────────────────
|
// ── workflow 模板生成 ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
fn build_web_ssh_workflow(cfg: &CicdConfig) -> (String, String, Vec<String>) {
|
fn build_web_ssh_workflow(cfg: &CicdConfig) -> (String, String, Vec<String>) {
|
||||||
@ -295,6 +359,145 @@ 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: 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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@ -337,4 +540,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(&"LJ_TOKEN".to_string()));
|
||||||
|
assert!(secrets.contains(&"LJ_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",
|
||||||
@ -535,9 +535,85 @@ pub fn poll_all_repos() -> Result<(usize, usize), String> {
|
|||||||
crate::db::log_event("gitea_poll", "info",
|
crate::db::log_event("gitea_poll", "info",
|
||||||
&format!("轮询 {polled} 个仓库,新采集 {ingested} 条 commit"), None);
|
&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))
|
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「立即拉取」按钮)
|
/// 手动触发一次轮询(UI「立即拉取」按钮)
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn gitea_poll_now() -> Result<serde_json::Value, String> {
|
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)
|
Ok(rows)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 通过炼境存储的凭证为项目创建 PR,返回 JSON 字符串(含 pr_url / pr_number / already_exists)。
|
||||||
|
/// 409 冲突(PR 已存在)时自动查找并返回现有 PR,already_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 绑定
|
/// 查询单个项目的 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> {
|
||||||
@ -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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
@ -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)]
|
||||||
@ -286,7 +323,7 @@ async fn handle_sse_connect(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// POST /mcp/group/:group_id/messages?session_id=xxx
|
/// 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(
|
async fn handle_sse_message(
|
||||||
State(sessions): State<Sessions>,
|
State(sessions): State<Sessions>,
|
||||||
Path(group_id): Path<String>,
|
Path(group_id): Path<String>,
|
||||||
@ -296,27 +333,30 @@ async fn handle_sse_message(
|
|||||||
if req.id.is_none() {
|
if req.id.is_none() {
|
||||||
return StatusCode::ACCEPTED.into_response();
|
return StatusCode::ACCEPTED.into_response();
|
||||||
}
|
}
|
||||||
let id = req.id.clone();
|
|
||||||
|
|
||||||
let response_json = match process_rpc(&group_id, &req).await {
|
// 立即返回 202,在后台 task 里处理(避免长时间工具调用阻塞 HTTP 连接)
|
||||||
Ok(result) => json!({ "jsonrpc": "2.0", "id": id, "result": result }),
|
let sessions_clone = sessions.clone();
|
||||||
Err(msg) => json!({
|
let session_id = query.session_id.clone();
|
||||||
"jsonrpc": "2.0", "id": id,
|
tokio::spawn(async move {
|
||||||
"error": { "code": -32601, "message": msg }
|
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!({
|
||||||
let mut sessions_guard = sessions.lock().await;
|
"jsonrpc": "2.0", "id": id,
|
||||||
if let Some(tx) = sessions_guard.get(&query.session_id) {
|
"error": { "code": -32601, "message": msg }
|
||||||
if tx
|
}),
|
||||||
.send(("message".to_string(), response_json.to_string()))
|
};
|
||||||
.await
|
let mut guard = sessions_clone.lock().await;
|
||||||
.is_err()
|
if let Some(tx) = guard.get(&session_id) {
|
||||||
{
|
if tx
|
||||||
// 连接已关闭,惰性清理
|
.send(("message".to_string(), response_json.to_string()))
|
||||||
sessions_guard.remove(&query.session_id);
|
.await
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
guard.remove(&session_id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
|
|
||||||
StatusCode::ACCEPTED.into_response()
|
StatusCode::ACCEPTED.into_response()
|
||||||
}
|
}
|
||||||
@ -455,6 +495,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 提交间隔、蓝图完成率、阻塞任务数、错误情况。用于分析趋势、识别高价值改进变量。",
|
||||||
@ -472,6 +542,157 @@ fn tools_list_result() -> Value {
|
|||||||
},
|
},
|
||||||
"required": ["project_id"]
|
"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_ci(PR 质量检查)和 deepseek_review(AI 代码审查)两种类型。",
|
||||||
|
"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;
|
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)
|
||||||
|
}
|
||||||
|
"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)),
|
_ => Err(format!("未知工具: {}", tool_name)),
|
||||||
})
|
})
|
||||||
.await;
|
.await;
|
||||||
@ -899,6 +1270,61 @@ fn get_project_snapshots(group_id: &str, project_id: &str, days: u32) -> Result<
|
|||||||
Ok(md)
|
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 单元测试 ────────────────────────────────────────────────────
|
// ── Gitea Webhook 单元测试 ────────────────────────────────────────────────────
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
@ -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">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">
|
<div className="flex gap-2 pt-1">
|
||||||
<button
|
<button
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user