feat(delivery): add project delivery status #7
@ -6,7 +6,7 @@ description: >
|
|||||||
成为每个项目的贴身导师。炼境本身亦被纳入其中,
|
成为每个项目的贴身导师。炼境本身亦被纳入其中,
|
||||||
形成可自我进化的开发智能闭环。
|
形成可自我进化的开发智能闭环。
|
||||||
iteration: 1
|
iteration: 1
|
||||||
updated: 2026-09-20
|
updated: 2026-09-21
|
||||||
agent_button_carrier: tauri
|
agent_button_carrier: tauri
|
||||||
|
|
||||||
|
|
||||||
@ -451,6 +451,14 @@ modules:
|
|||||||
status: planned
|
status: planned
|
||||||
progress: 0
|
progress: 0
|
||||||
position: [750, 800]
|
position: [750, 800]
|
||||||
|
|
||||||
|
- id: project-capability-control-plane
|
||||||
|
name: 项目能力与交付控制面
|
||||||
|
area: infra
|
||||||
|
status: in_progress
|
||||||
|
progress: 14
|
||||||
|
note: "P1 已完成:只读交付信号聚合(Git / PR / CI / review / 部署 unknown),不改变 Enterprise 既有 production 自动部署;后续受控执行按需推进。"
|
||||||
|
position: [2000, 800]
|
||||||
edges:
|
edges:
|
||||||
# 前端依赖
|
# 前端依赖
|
||||||
- from: workspace-mgmt
|
- from: workspace-mgmt
|
||||||
@ -733,3 +741,12 @@ edges:
|
|||||||
- from: codex-model-switch
|
- from: codex-model-switch
|
||||||
to: ui-restructure
|
to: ui-restructure
|
||||||
type: related
|
type: related
|
||||||
|
- from: onboarding-pack
|
||||||
|
to: project-capability-control-plane
|
||||||
|
type: dependency
|
||||||
|
- from: gitea-integration
|
||||||
|
to: project-capability-control-plane
|
||||||
|
type: dependency
|
||||||
|
- from: project-capability-control-plane
|
||||||
|
to: runtime-diagnostics
|
||||||
|
type: dependency
|
||||||
|
|||||||
151
.blueprint/modules/project-capability-control-plane.md
Normal file
151
.blueprint/modules/project-capability-control-plane.md
Normal file
@ -0,0 +1,151 @@
|
|||||||
|
---
|
||||||
|
id: project-capability-control-plane
|
||||||
|
name: 项目能力与交付控制面
|
||||||
|
status: planned
|
||||||
|
progress: 0
|
||||||
|
---
|
||||||
|
|
||||||
|
## 概述
|
||||||
|
|
||||||
|
炼境是被管理项目的工程管家,而非项目业务大脑或通用 CI 替代品。项目拥有业务规则、测试、迁移和部署要求;炼境保存云端环境与凭证引用,执行受控操作,采集运行证据。Enterprise System 是首个真实样板。
|
||||||
|
|
||||||
|
## 设计决策:项目能力协议与轻量交付控制面 v1
|
||||||
|
|
||||||
|
### 背景
|
||||||
|
|
||||||
|
Enterprise System 已有可用的云端交付基础:Gitea `main` CI 通过 SSH 调用服务器端 `deploy/deploy-api.sh`;脚本用 Git SHA 标记镜像,在停止旧容器前做环境/网络/镜像检查,切换后做容器内 HTTP 健康闸门,失败自动回滚,且保留近期镜像与 Gitea registry 回滚池。其关键缺口不是再造部署脚本,而是让 Agent 可安全发现、请求、验证和追溯这条能力。
|
||||||
|
|
||||||
|
### 方案选择
|
||||||
|
|
||||||
|
| 方案 | 说明 | 结论 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 炼境直接内置每个项目的部署命令 | 快,但把项目细节、环境差异和业务风险硬编码进平台 | 否决 |
|
||||||
|
| 项目声明能力,炼境以 adapter 执行 | 项目保持自主,凭证与审计集中,能复用到异构项目 | **选定** |
|
||||||
|
| 强制所有 commit 经 PR/CI 后再部署 | 治理完整但阻塞 Agent 高频真实环境验证 | 否决;仅 production/高风险路径强制 |
|
||||||
|
|
||||||
|
### 系统边界
|
||||||
|
|
||||||
|
```
|
||||||
|
项目(Enterprise System)
|
||||||
|
声明 verify/build/migrate/deploy/health/logs/rollback 的输入与成功断言
|
||||||
|
保留 Dockerfile、migration、部署脚本和业务终验
|
||||||
|
|
||||||
|
炼境控制面
|
||||||
|
保存环境、凭证引用、允许项目/目标、部署记录和运行证据
|
||||||
|
通过 SSH / Docker Compose 等 adapter 执行受控动作
|
||||||
|
|
||||||
|
Gitea adapter
|
||||||
|
提供 SHA、PR、少量 CI 与 release 依据;不是炼境核心依赖
|
||||||
|
|
||||||
|
Codex / Claude adapter
|
||||||
|
读取项目规则与能力声明,调用炼境 MCP;不持有生产凭证
|
||||||
|
```
|
||||||
|
|
||||||
|
### 交付节奏
|
||||||
|
|
||||||
|
1. Agent 完成本地验证后小步 commit;多个相关 commit 汇成一个可说明的 PR/交付包。
|
||||||
|
2. 普通内部功能在 PR 合并后沿既有 Gitea CI 自动部署 production;炼境先采集版本、CI、健康与回滚证据,不替换项目部署脚本。
|
||||||
|
3. 涉及公共引擎、跨业务线、数据写路径或 migration 的改动进入 PR;Gitea CI 只保留高价值检查,并要求在 PR 中说明备份或回滚方式。
|
||||||
|
4. 炼境只呈现信号和执行受控操作,不判断业务是否“应该上线”;没有可验证的部署回传时必须明确标为 `unknown`。
|
||||||
|
|
||||||
|
### 协议草案
|
||||||
|
|
||||||
|
项目 canonical context 采用工具无关位置 `.lian-jing/context.json`;现有 `.claude/liangjing.json` 与 Codex 配置是派生兼容层。项目能力声明建议位于 `.lian-jing/capabilities.yaml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
version: 1
|
||||||
|
capabilities:
|
||||||
|
verify:
|
||||||
|
command: pnpm verify
|
||||||
|
deploy:
|
||||||
|
targets:
|
||||||
|
production:
|
||||||
|
adapter: ssh-script
|
||||||
|
environment_ref: enterprise-production
|
||||||
|
revision: git_sha
|
||||||
|
command: bash deploy/deploy-api.sh
|
||||||
|
requires_confirmation: true
|
||||||
|
health:
|
||||||
|
checks:
|
||||||
|
- kind: http
|
||||||
|
url_ref: enterprise-api
|
||||||
|
expect_status: 401
|
||||||
|
```
|
||||||
|
|
||||||
|
声明不得包含 IP、SSH 私钥、数据库 URL 或 Secret。`environment_ref` 由炼境映射到加密保存的环境事实;adapter 命令采用 allowlist/模板参数,禁止 Agent 任意拼接远程 shell。
|
||||||
|
|
||||||
|
### 证据与失败模型
|
||||||
|
|
||||||
|
一次 deployment record 至少包含:`project_id`、`environment`、`requested_revision`、`resolved_sha`、`requester`、`started_at`、`finished_at`、`migration_set`、`adapter_result`、`health_evidence`、`previous_healthy_release`、`rollback_result`。任何外部源不可用都显式标为 unavailable/error,不能伪装成“无 PR”或“部署成功”。
|
||||||
|
|
||||||
|
### 验证路径
|
||||||
|
|
||||||
|
- 炼境:`cargo test` 直接调用 Git/Gitea 状态归并纯函数,断言脏工作区、无绑定、网络不可达和缺失部署证据均可诚实降级。
|
||||||
|
- Enterprise System:继续使用现有 production 部署脚本及其健康闸门/回滚;本阶段不把 production 当作自动测试路径。
|
||||||
|
- MCP:`get_delivery_status(project_id)` 返回本地 Git、Gitea、CI、review 与可取得的部署/health 来源状态,无错误字段时每项均可追溯。
|
||||||
|
|
||||||
|
### 现有链路的兼容策略
|
||||||
|
|
||||||
|
Enterprise System 当前 `push main → Gitea CI → deploy-api.sh` 是 production 链路,且服务器资源有限。本阶段保持该链路不动,也不引入常驻 staging。`get_delivery_status` 不将 CI 成功误报为线上健康;后续若线上验证成本或事故频率证明有必要,再以独立资源或按需环境增加 staging adapter。
|
||||||
|
|
||||||
|
### 风险点
|
||||||
|
|
||||||
|
- migration 非向后兼容:v1 不自动执行任意 migration;先读取项目声明、预检与锁定策略,production 必须人工确认。
|
||||||
|
- 无 staging 的生产验证风险:普通改动依赖本地验证、PR 和现有自动回滚;高风险 migration/关键数据改动要求备份和显式交付说明。
|
||||||
|
- Git SHA 与服务器工作树漂移:adapter 输出必须解析/回传 resolved SHA,且 health 证据绑定同一记录。
|
||||||
|
- 现有部署健康闸门仅证明 HTTP 监听:协议同时支持项目声明业务 health probe,不把存活探测误报为业务就绪。
|
||||||
|
- Gitea/网络故障:交付状态明确降级;项目本地验证与项目自有手工部署能力不受炼境阻断。
|
||||||
|
|
||||||
|
## 任务卡
|
||||||
|
|
||||||
|
### 📋 A. Canonical 项目上下文与 capability schema
|
||||||
|
- status: todo
|
||||||
|
- complexity: M
|
||||||
|
- files: src-tauri/src/mcp_inject.rs, src-tauri/src/commands/onboarding.rs, src-tauri/src/mcp_tools.rs, src-tauri/resources/onboarding/, docs/templates/agents-meta-rules.md
|
||||||
|
- acceptance: "[cargo test] 旧 `.claude/liangjing.json` 项目重跑接入 → 写入 `.lian-jing/context.json`,project_id 一致;Claude/Codex adapter 均从 canonical context 派生且保留用户手工同名配置;无 canonical context 时给出可诊断兼容状态。"
|
||||||
|
|
||||||
|
### 📋 B. 炼境环境、授权与 deployment record 数据模型(后续受控执行)
|
||||||
|
- status: todo
|
||||||
|
- complexity: M
|
||||||
|
- depends: A. Canonical 项目上下文与 capability schema
|
||||||
|
- files: src-tauri/src/db.rs, src-tauri/src/models.rs, src-tauri/src/commands/
|
||||||
|
- acceptance: "[cargo test] in-memory DB 创建 environment / project authorization / deployment record 后,未授权项目、production 未确认、同环境并发请求均被拒绝;record 可记录 requested_sha、resolved_sha、health_evidence 与 previous_healthy_release。"
|
||||||
|
|
||||||
|
### 📋 C. SSH-script adapter 与生产环境锁(后续受控执行)
|
||||||
|
- status: todo
|
||||||
|
- complexity: M
|
||||||
|
- depends: B. 炼境环境、授权与 deployment record 数据模型
|
||||||
|
- files: src-tauri/src/commands/, src-tauri/src/lib.rs, src/lib/commands.ts
|
||||||
|
- acceptance: "[Tauri command] deploy_production(project_id, revision, confirmation) 对 mock SSH adapter → 仅接受声明中的 adapter/command,写入 running→succeeded|failed record;同一 environment_ref 第二次调用返回锁定状态,绝不执行第二条远程命令。"
|
||||||
|
|
||||||
|
### ✅ P1. 交付状态与 Gitea 轻量治理投影
|
||||||
|
- status: done
|
||||||
|
- complexity: M
|
||||||
|
- files: src-tauri/src/commands/gitea.rs, src-tauri/src/mcp_tools.rs, src-tauri/src/mcp_server.rs, docs/ai-context/
|
||||||
|
- acceptance: "[MCP tool] get_delivery_status(project_id) → 返回当前 SHA/工作树、PR/CI/review 来源状态与 deployment/health evidence 的已知或 unknown 状态;Gitea 不可达时标记 unavailable 而非把 PR 误报为空;cargo test 通过纯函数/命令级降级测试。"
|
||||||
|
- done_note: "2026-09-21:MCP schema/dispatch、Git/Gitea 聚合、接入说明和降级测试完成;production 部署回传尚未接入,按协议显式返回 unknown。"
|
||||||
|
|
||||||
|
### 📋 E. Enterprise System 可选 staging 隔离与部署 profile
|
||||||
|
- status: todo
|
||||||
|
- complexity: M
|
||||||
|
- depends: A. Canonical 项目上下文与 capability schema, C. SSH-script staging adapter 与环境锁
|
||||||
|
- files: E:/code-project/enterprise-system/deploy/deploy-api.sh, E:/code-project/enterprise-system/deploy/README.md, E:/code-project/enterprise-system/.gitea/workflows/, E:/code-project/enterprise-system/.env.api.example
|
||||||
|
- acceptance: "agent_verify: [future isolated staging] 部署已 push SHA → 使用与 production 不同的 environment_ref、服务器目录/容器名/域名/运行时配置,resolved SHA 和 health evidence 可读取;不触发既有 `push main` production job;human_verify: staging 关键业务接口可用。仅当资源与实际线上验证成本证明必要时启动。"
|
||||||
|
|
||||||
|
### 📋 F. Enterprise System capability 声明与可选 staging 适配验证
|
||||||
|
- status: todo
|
||||||
|
- complexity: M
|
||||||
|
- depends: E. Enterprise System 可选 staging 隔离与部署 profile
|
||||||
|
- files: E:/code-project/enterprise-system/.lian-jing/, E:/code-project/enterprise-system/deploy/README.md, E:/code-project/enterprise-system/.blueprint/
|
||||||
|
- acceptance: "agent_verify: [炼境 staging adapter] 请求 Enterprise System 已 push SHA → 只匹配声明的 staging profile,服务器输出 resolved SHA,容器 health gate 与项目声明的 HTTP probe 均通过,炼境 record 绑定同一 SHA;human_verify: staging 关键业务接口可用。"
|
||||||
|
|
||||||
|
### 📋 G. production 发布确认、回滚与操作报告(后续受控执行)
|
||||||
|
- status: todo
|
||||||
|
- complexity: M
|
||||||
|
- depends: C. SSH-script adapter 与生产环境锁(后续受控执行), P1. 交付状态与 Gitea 轻量治理投影, F. Enterprise System capability 声明与可选 staging 适配验证
|
||||||
|
- files: src-tauri/src/commands/, src-tauri/src/mcp_tools.rs, src-tauri/src/commands/gitea.rs, E:/code-project/enterprise-system/deploy/
|
||||||
|
- acceptance: "[cargo test] deploy_production 缺显式 confirmation 或 required CI/PR evidence → 无远程执行;[adapter integration] health gate 失败 → record 标 failed 并保存 rollback result / previous release,生产成功记录含确认者与 resolved SHA。"
|
||||||
|
|
||||||
|
## 迭代衡量
|
||||||
|
|
||||||
|
首个版本上线后连续观察四周:commit 至 production 部署结果可追溯的时间、production 前实际拦截问题数、失败定位与回滚耗时、PR 等待时间、Agent 重复劳动/上下文丢失次数。不能降低任一真实成本的门禁或自动化应降级或移除。
|
||||||
@ -3,7 +3,7 @@
|
|||||||
> 把此文件粘贴给任何 AI Agent,让它了解本项目的 git 工作方式。由炼境接入包分发。
|
> 把此文件粘贴给任何 AI Agent,让它了解本项目的 git 工作方式。由炼境接入包分发。
|
||||||
> `ONBOARDING_MANAGED` 标记块由炼境版本化管理(重跑接入包可原位升级),块外可自由补充项目特有约定。
|
> `ONBOARDING_MANAGED` 标记块由炼境版本化管理(重跑接入包可原位升级),块外可自由补充项目特有约定。
|
||||||
|
|
||||||
<!-- ONBOARDING_MANAGED_START version="1.5.1" -->
|
<!-- ONBOARDING_MANAGED_START version="1.5.2" -->
|
||||||
> ⚠️ 本块由炼境版本化托管,升级时**整块原位覆盖**——项目定制请写到块外,块内手改必丢。
|
> ⚠️ 本块由炼境版本化托管,升级时**整块原位覆盖**——项目定制请写到块外,块内手改必丢。
|
||||||
|
|
||||||
## 分支策略(复杂度感知)
|
## 分支策略(复杂度感知)
|
||||||
@ -93,6 +93,20 @@ PR 别攒太大(建议每 3~5 个 commit 或几百行内就开一次),否
|
|||||||
|
|
||||||
若炼境未运行(MCP 不可用),降级方案:去 Gitea 网页手动创建 PR。
|
若炼境未运行(MCP 不可用),降级方案:去 Gitea 网页手动创建 PR。
|
||||||
|
|
||||||
|
## 交付状态(炼境托管项目)
|
||||||
|
|
||||||
|
在准备开 PR、合并 PR 或排查部署问题时,先调用 MCP 工具:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "project_id": "<id>" }
|
||||||
|
```
|
||||||
|
|
||||||
|
工具名为 `get_delivery_status`,返回本地工作区与最近 commit SHA、当前分支的 Gitea PR、CI、review 和已知的部署/健康证据。
|
||||||
|
|
||||||
|
- `unknown`:炼境尚未获得该项证据,**不是成功**;例如 CI 成功不能代替 production 健康。
|
||||||
|
- `unavailable`:对应外部来源暂时不可达;保留本地验证与 Gitea 网页作为降级路径。
|
||||||
|
- 结果是交付上下文,不替代项目自己的测试、业务验收或发布决定。
|
||||||
|
|
||||||
## 常用命令
|
## 常用命令
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@ -26,6 +26,8 @@ src/lib/commands.ts 前后端契约(唯一前端入口)
|
|||||||
src-tauri/src/commands/ Rust 后端实现(一功能一文件)
|
src-tauri/src/commands/ Rust 后端实现(一功能一文件)
|
||||||
src-tauri/src/db.rs 数据库 schema + migration
|
src-tauri/src/db.rs 数据库 schema + migration
|
||||||
src-tauri/src/mcp_server.rs MCP Server(agent 调用炼境的入口)
|
src-tauri/src/mcp_server.rs MCP Server(agent 调用炼境的入口)
|
||||||
|
src-tauri/src/mcp_tools.rs MCP tool schema 与 dispatch
|
||||||
|
src-tauri/src/commands/delivery_status.rs 项目交付信号聚合(Git / Gitea / 部署证据)
|
||||||
src-tauri/resources/onboarding/ 接入包分发模板
|
src-tauri/resources/onboarding/ 接入包分发模板
|
||||||
.blueprint/ 项目蓝图
|
.blueprint/ 项目蓝图
|
||||||
docs/ai-context/ AI 上下文文档(本文件、契约映射)
|
docs/ai-context/ AI 上下文文档(本文件、契约映射)
|
||||||
|
|||||||
@ -8,6 +8,13 @@ contracts:
|
|||||||
- "src-tauri/src/mcp_server.rs"
|
- "src-tauri/src/mcp_server.rs"
|
||||||
doc: "路由变更需同步 API 文档"
|
doc: "路由变更需同步 API 文档"
|
||||||
|
|
||||||
|
- label: MCP 工具接口
|
||||||
|
paths:
|
||||||
|
- "src-tauri/src/mcp_tools.rs"
|
||||||
|
- "src-tauri/src/commands/delivery_status.rs"
|
||||||
|
- "src-tauri/src/commands/gitea.rs"
|
||||||
|
doc: "工具 schema、dispatch 与调用说明必须同步;外部来源不可用时返回显式降级状态,不得伪造成功"
|
||||||
|
|
||||||
- label: Tauri command 接口(前后端契约)
|
- label: Tauri command 接口(前后端契约)
|
||||||
paths:
|
paths:
|
||||||
- "src-tauri/src/commands/**/*.rs"
|
- "src-tauri/src/commands/**/*.rs"
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
> 把此文件粘贴给任何 AI Agent,让它了解本项目的 git 工作方式。由炼境接入包分发。
|
> 把此文件粘贴给任何 AI Agent,让它了解本项目的 git 工作方式。由炼境接入包分发。
|
||||||
> `ONBOARDING_MANAGED` 标记块由炼境版本化管理(重跑接入包可原位升级),块外可自由补充项目特有约定。
|
> `ONBOARDING_MANAGED` 标记块由炼境版本化管理(重跑接入包可原位升级),块外可自由补充项目特有约定。
|
||||||
|
|
||||||
<!-- ONBOARDING_MANAGED_START version="1.5.1" -->
|
<!-- ONBOARDING_MANAGED_START version="1.5.2" -->
|
||||||
> ⚠️ 本块由炼境版本化托管,升级时**整块原位覆盖**——项目定制请写到块外,块内手改必丢。
|
> ⚠️ 本块由炼境版本化托管,升级时**整块原位覆盖**——项目定制请写到块外,块内手改必丢。
|
||||||
|
|
||||||
## 分支策略(复杂度感知)
|
## 分支策略(复杂度感知)
|
||||||
@ -93,6 +93,20 @@ PR 别攒太大(建议每 3~5 个 commit 或几百行内就开一次),否
|
|||||||
|
|
||||||
若炼境未运行(MCP 不可用),降级方案:去 Gitea 网页手动创建 PR。
|
若炼境未运行(MCP 不可用),降级方案:去 Gitea 网页手动创建 PR。
|
||||||
|
|
||||||
|
## 交付状态(炼境托管项目)
|
||||||
|
|
||||||
|
在准备开 PR、合并 PR 或排查部署问题时,先调用 MCP 工具:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "project_id": "<id>" }
|
||||||
|
```
|
||||||
|
|
||||||
|
工具名为 `get_delivery_status`,返回本地工作区与最近 commit SHA、当前分支的 Gitea PR、CI、review 和已知的部署/健康证据。
|
||||||
|
|
||||||
|
- `unknown`:炼境尚未获得该项证据,**不是成功**;例如 CI 成功不能代替 production 健康。
|
||||||
|
- `unavailable`:对应外部来源暂时不可达;保留本地验证与 Gitea 网页作为降级路径。
|
||||||
|
- 结果是交付上下文,不替代项目自己的测试、业务验收或发布决定。
|
||||||
|
|
||||||
## 常用命令
|
## 常用命令
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
229
src-tauri/src/commands/delivery_status.rs
Normal file
229
src-tauri/src/commands/delivery_status.rs
Normal file
@ -0,0 +1,229 @@
|
|||||||
|
//! 项目交付状态聚合。
|
||||||
|
//!
|
||||||
|
//! 本模块只采集和呈现信号,不替项目判断功能是否应交付。Git 本地状态、Gitea
|
||||||
|
//! 信号与部署证据各自独立降级:任一来源不可用时必须明确标出,而不能猜测成功。
|
||||||
|
|
||||||
|
use std::{path::Path, process::Command};
|
||||||
|
|
||||||
|
use serde::Serialize;
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Clone, PartialEq)]
|
||||||
|
pub struct DeliverySignal {
|
||||||
|
/// 领域状态,例如 `passed`、`open`、`unknown`、`unavailable`。
|
||||||
|
pub state: String,
|
||||||
|
/// 产生该信号的系统,例如 `gitea`、`local_git`、`deployment_report`。
|
||||||
|
pub source: String,
|
||||||
|
/// 面向 Agent 的可读解释;不得把未知状态描述成成功。
|
||||||
|
pub detail: String,
|
||||||
|
/// 结构化原始摘要,便于不同 Agent 作自己的交付判断。
|
||||||
|
pub data: serde_json::Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DeliverySignal {
|
||||||
|
pub fn new(
|
||||||
|
state: impl Into<String>,
|
||||||
|
source: impl Into<String>,
|
||||||
|
detail: impl Into<String>,
|
||||||
|
data: serde_json::Value,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
state: state.into(),
|
||||||
|
source: source.into(),
|
||||||
|
detail: detail.into(),
|
||||||
|
data,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Clone, PartialEq)]
|
||||||
|
pub struct RecentCommit {
|
||||||
|
pub sha: String,
|
||||||
|
pub short_sha: String,
|
||||||
|
pub committed_at: String,
|
||||||
|
pub subject: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Clone, PartialEq)]
|
||||||
|
pub struct GitDeliveryStatus {
|
||||||
|
/// `clean` / `dirty` / `unavailable`。
|
||||||
|
pub state: String,
|
||||||
|
pub source: String,
|
||||||
|
pub detail: String,
|
||||||
|
pub branch: Option<String>,
|
||||||
|
pub changed_files: usize,
|
||||||
|
pub recent_commit: Option<RecentCommit>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Clone, PartialEq)]
|
||||||
|
pub struct DeliveryStatus {
|
||||||
|
pub project_id: String,
|
||||||
|
pub git: GitDeliveryStatus,
|
||||||
|
pub pull_request: DeliverySignal,
|
||||||
|
pub ci: DeliverySignal,
|
||||||
|
pub review: DeliverySignal,
|
||||||
|
pub deployment: DeliverySignal,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 聚合已注册项目的交付信号。连接由 MCP/Tauri 边界传入,方便 in-memory DB 测试。
|
||||||
|
pub fn get_delivery_status(
|
||||||
|
conn: &rusqlite::Connection,
|
||||||
|
project_id: &str,
|
||||||
|
project_root: &Path,
|
||||||
|
) -> DeliveryStatus {
|
||||||
|
let git = inspect_git_status(project_root);
|
||||||
|
let sha = git.recent_commit.as_ref().map(|commit| commit.sha.as_str());
|
||||||
|
let gitea =
|
||||||
|
crate::commands::gitea::get_delivery_signals(conn, project_id, git.branch.as_deref(), sha);
|
||||||
|
|
||||||
|
DeliveryStatus {
|
||||||
|
project_id: project_id.to_string(),
|
||||||
|
git,
|
||||||
|
pull_request: gitea.pull_request,
|
||||||
|
ci: gitea.ci,
|
||||||
|
review: gitea.review,
|
||||||
|
// P1 尚未要求项目向炼境回传部署结果。保留独立信号位,防止把 CI 成功误报为线上健康。
|
||||||
|
deployment: DeliverySignal::new(
|
||||||
|
"unknown",
|
||||||
|
"deployment_report",
|
||||||
|
"项目尚未向炼境回传部署完成或健康证据;CI 成功不等于 production 健康",
|
||||||
|
serde_json::json!({}),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 读取本地 Git,不依赖网络。失败也返回结构化状态,使 MCP 调用可继续展示其他来源。
|
||||||
|
pub fn inspect_git_status(project_root: &Path) -> GitDeliveryStatus {
|
||||||
|
let porcelain = match run_git(project_root, ["status", "--porcelain=v1", "--branch"]) {
|
||||||
|
Ok(output) => output,
|
||||||
|
Err(detail) => {
|
||||||
|
return GitDeliveryStatus {
|
||||||
|
state: "unavailable".into(),
|
||||||
|
source: "local_git".into(),
|
||||||
|
detail,
|
||||||
|
branch: None,
|
||||||
|
changed_files: 0,
|
||||||
|
recent_commit: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let (branch, changed_files) = parse_porcelain_status(&porcelain);
|
||||||
|
let recent_commit = run_git(
|
||||||
|
project_root,
|
||||||
|
["log", "-1", "--format=%H%x1f%h%x1f%cI%x1f%s"],
|
||||||
|
)
|
||||||
|
.ok()
|
||||||
|
.and_then(|output| parse_recent_commit(&output));
|
||||||
|
let state = if changed_files == 0 { "clean" } else { "dirty" };
|
||||||
|
|
||||||
|
GitDeliveryStatus {
|
||||||
|
state: state.into(),
|
||||||
|
source: "local_git".into(),
|
||||||
|
detail: if changed_files == 0 {
|
||||||
|
"工作区无未提交改动".into()
|
||||||
|
} else {
|
||||||
|
format!("工作区有 {changed_files} 个未提交路径")
|
||||||
|
},
|
||||||
|
branch,
|
||||||
|
changed_files,
|
||||||
|
recent_commit,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_git<const N: usize>(project_root: &Path, args: [&str; N]) -> Result<String, String> {
|
||||||
|
let output = Command::new("git")
|
||||||
|
.arg("-C")
|
||||||
|
.arg(project_root)
|
||||||
|
.args(args)
|
||||||
|
.output()
|
||||||
|
.map_err(|e| format!("无法执行本地 git:{e}"))?;
|
||||||
|
if !output.status.success() {
|
||||||
|
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||||
|
return Err(if stderr.is_empty() {
|
||||||
|
"本地 git 命令失败".into()
|
||||||
|
} else {
|
||||||
|
format!("本地 git 命令失败:{stderr}")
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(String::from_utf8_lossy(&output.stdout).to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse_porcelain_status(output: &str) -> (Option<String>, usize) {
|
||||||
|
let mut lines = output.lines();
|
||||||
|
let branch = lines.next().and_then(|line| {
|
||||||
|
line.strip_prefix("## ").and_then(|value| {
|
||||||
|
let name = value
|
||||||
|
.strip_prefix("No commits yet on ")
|
||||||
|
.unwrap_or(value)
|
||||||
|
.split("...")
|
||||||
|
.next()
|
||||||
|
.unwrap_or(value)
|
||||||
|
.trim()
|
||||||
|
.trim_end_matches(" [gone]");
|
||||||
|
(!name.is_empty() && !name.starts_with("HEAD (")).then(|| name.to_string())
|
||||||
|
})
|
||||||
|
});
|
||||||
|
(branch, lines.filter(|line| !line.trim().is_empty()).count())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse_recent_commit(output: &str) -> Option<RecentCommit> {
|
||||||
|
let mut fields = output.trim().split('\u{1f}');
|
||||||
|
let sha = fields.next()?.trim();
|
||||||
|
let short_sha = fields.next()?.trim();
|
||||||
|
let committed_at = fields.next()?.trim();
|
||||||
|
let subject = fields.next()?.trim();
|
||||||
|
(!sha.is_empty()).then(|| RecentCommit {
|
||||||
|
sha: sha.to_string(),
|
||||||
|
short_sha: short_sha.to_string(),
|
||||||
|
committed_at: committed_at.to_string(),
|
||||||
|
subject: subject.to_string(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn porcelain_parser_reports_branch_and_dirty_paths() {
|
||||||
|
let (branch, changed_files) = parse_porcelain_status(
|
||||||
|
"## feat/delivery...origin/feat/delivery [ahead 1]\n M src/a.rs\n?? docs/new.md\n",
|
||||||
|
);
|
||||||
|
assert_eq!(branch.as_deref(), Some("feat/delivery"));
|
||||||
|
assert_eq!(changed_files, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn porcelain_parser_handles_unborn_and_detached_heads() {
|
||||||
|
let (branch, _) = parse_porcelain_status("## No commits yet on feat/new\n?? file.rs\n");
|
||||||
|
assert_eq!(branch.as_deref(), Some("feat/new"));
|
||||||
|
let (branch, _) = parse_porcelain_status("## HEAD (detached at abc123)\n");
|
||||||
|
assert!(branch.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn recent_commit_parser_keeps_delivery_anchor() {
|
||||||
|
let commit = parse_recent_commit(
|
||||||
|
"1234567890abcdef\u{1f}12345678\u{1f}2026-09-21T10:00:00+09:00\u{1f}feat(mcp): status\n",
|
||||||
|
)
|
||||||
|
.expect("应解析 commit");
|
||||||
|
assert_eq!(commit.short_sha, "12345678");
|
||||||
|
assert_eq!(commit.subject, "feat(mcp): status");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unavailable_local_git_is_explicit() {
|
||||||
|
let status = inspect_git_status(Path::new("E:/definitely-not-a-project"));
|
||||||
|
assert_eq!(status.state, "unavailable");
|
||||||
|
assert!(status.recent_commit.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_gitea_binding_does_not_fabricate_delivery_success() {
|
||||||
|
let conn = crate::db::conn_with_schema();
|
||||||
|
let status =
|
||||||
|
get_delivery_status(&conn, "unbound", Path::new("E:/definitely-not-a-project"));
|
||||||
|
assert_eq!(status.pull_request.state, "not_configured");
|
||||||
|
assert_eq!(status.ci.state, "not_configured");
|
||||||
|
assert_eq!(status.deployment.state, "unknown");
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -230,6 +230,328 @@ fn api_err(prefix: &str, e: ureq::Error) -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `get_delivery_status` 使用的 Gitea 投影。它只反映远端信号,不给出发布决策。
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct GiteaDeliverySignals {
|
||||||
|
pub pull_request: crate::commands::delivery_status::DeliverySignal,
|
||||||
|
pub ci: crate::commands::delivery_status::DeliverySignal,
|
||||||
|
pub review: crate::commands::delivery_status::DeliverySignal,
|
||||||
|
}
|
||||||
|
|
||||||
|
type DeliverySignal = crate::commands::delivery_status::DeliverySignal;
|
||||||
|
|
||||||
|
fn unavailable_delivery_signals(detail: impl Into<String>) -> GiteaDeliverySignals {
|
||||||
|
let detail = detail.into();
|
||||||
|
GiteaDeliverySignals {
|
||||||
|
pull_request: DeliverySignal::new(
|
||||||
|
"unavailable",
|
||||||
|
"gitea",
|
||||||
|
detail.clone(),
|
||||||
|
serde_json::json!({}),
|
||||||
|
),
|
||||||
|
ci: DeliverySignal::new(
|
||||||
|
"unavailable",
|
||||||
|
"gitea",
|
||||||
|
detail.clone(),
|
||||||
|
serde_json::json!({}),
|
||||||
|
),
|
||||||
|
review: DeliverySignal::new("unavailable", "gitea", detail, serde_json::json!({})),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn gitea_get_json(
|
||||||
|
base_url: &str,
|
||||||
|
token: &str,
|
||||||
|
path: &str,
|
||||||
|
label: &str,
|
||||||
|
) -> Result<serde_json::Value, String> {
|
||||||
|
ureq::get(&format!("{base_url}{path}"))
|
||||||
|
.set("Authorization", &format!("token {token}"))
|
||||||
|
.call()
|
||||||
|
.map_err(|e| api_err(label, e))?
|
||||||
|
.into_json()
|
||||||
|
.map_err(|e| format!("{label}: 解析响应失败: {e}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 从 Gitea 拉取当前分支的 PR、当前 SHA 的 CI 以及该 PR 的 review 摘要。
|
||||||
|
///
|
||||||
|
/// 远端不可用必须作为数据返回,以便上层继续展示本地 Git 与部署未知状态。
|
||||||
|
pub fn get_delivery_signals(
|
||||||
|
conn: &rusqlite::Connection,
|
||||||
|
project_id: &str,
|
||||||
|
branch: Option<&str>,
|
||||||
|
commit_sha: Option<&str>,
|
||||||
|
) -> GiteaDeliverySignals {
|
||||||
|
let binding: rusqlite::Result<(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)?)),
|
||||||
|
);
|
||||||
|
let (instance_id, owner, repo) = match binding {
|
||||||
|
Ok(binding) => binding,
|
||||||
|
Err(rusqlite::Error::QueryReturnedNoRows) => {
|
||||||
|
let detail = "项目未绑定 Gitea 仓库";
|
||||||
|
return GiteaDeliverySignals {
|
||||||
|
pull_request: DeliverySignal::new(
|
||||||
|
"not_configured",
|
||||||
|
"gitea",
|
||||||
|
detail,
|
||||||
|
serde_json::json!({}),
|
||||||
|
),
|
||||||
|
ci: DeliverySignal::new("not_configured", "gitea", detail, serde_json::json!({})),
|
||||||
|
review: DeliverySignal::new(
|
||||||
|
"not_configured",
|
||||||
|
"gitea",
|
||||||
|
detail,
|
||||||
|
serde_json::json!({}),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
Err(error) => return unavailable_delivery_signals(format!("读取 Gitea 绑定失败:{error}")),
|
||||||
|
};
|
||||||
|
let (base_url, token) = match instance_auth(conn, &instance_id) {
|
||||||
|
Ok(auth) => auth,
|
||||||
|
Err(error) => return unavailable_delivery_signals(error),
|
||||||
|
};
|
||||||
|
|
||||||
|
let pull_path =
|
||||||
|
format!("/api/v1/repos/{owner}/{repo}/pulls?state=open&limit=50&type=pullrequest");
|
||||||
|
let pull_requests = match gitea_get_json(&base_url, &token, &pull_path, "读取 Gitea PR 失败")
|
||||||
|
{
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(error) => return unavailable_delivery_signals(error),
|
||||||
|
};
|
||||||
|
if !pull_requests.is_array() {
|
||||||
|
return unavailable_delivery_signals("Gitea PR 响应不是数组");
|
||||||
|
}
|
||||||
|
|
||||||
|
let matched_pr = branch.and_then(|name| select_open_pull_request(&pull_requests, name));
|
||||||
|
let pull_request = match (branch, matched_pr) {
|
||||||
|
(None, _) => DeliverySignal::new(
|
||||||
|
"unknown",
|
||||||
|
"gitea",
|
||||||
|
"本地 Git 未提供当前分支,无法匹配开放 PR",
|
||||||
|
serde_json::json!({}),
|
||||||
|
),
|
||||||
|
(Some(_), Some(pr)) => DeliverySignal::new(
|
||||||
|
"open",
|
||||||
|
"gitea",
|
||||||
|
"当前分支存在开放 PR",
|
||||||
|
pull_request_summary(pr),
|
||||||
|
),
|
||||||
|
(Some(_), None) => DeliverySignal::new(
|
||||||
|
"none",
|
||||||
|
"gitea",
|
||||||
|
"当前分支没有开放 PR",
|
||||||
|
serde_json::json!({}),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
let ci = match commit_sha {
|
||||||
|
Some(sha) => {
|
||||||
|
let path = format!("/api/v1/repos/{owner}/{repo}/statuses/{sha}?limit=50");
|
||||||
|
match gitea_get_json(&base_url, &token, &path, "读取 Gitea CI 状态失败") {
|
||||||
|
Ok(statuses) => summarize_ci_statuses(&statuses),
|
||||||
|
Err(error) => {
|
||||||
|
DeliverySignal::new("unavailable", "gitea", error, serde_json::json!({}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => DeliverySignal::new(
|
||||||
|
"unknown",
|
||||||
|
"gitea",
|
||||||
|
"本地 Git 未提供最近 commit SHA,无法查询 CI",
|
||||||
|
serde_json::json!({}),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
let review = match matched_pr.and_then(|pr| pr["number"].as_i64()) {
|
||||||
|
Some(number) => {
|
||||||
|
let path = format!("/api/v1/repos/{owner}/{repo}/pulls/{number}/reviews");
|
||||||
|
match gitea_get_json(&base_url, &token, &path, "读取 Gitea review 状态失败") {
|
||||||
|
Ok(reviews) => summarize_reviews(&reviews),
|
||||||
|
Err(error) => {
|
||||||
|
DeliverySignal::new("unavailable", "gitea", error, serde_json::json!({}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None if branch.is_some() => DeliverySignal::new(
|
||||||
|
"not_applicable",
|
||||||
|
"gitea",
|
||||||
|
"当前分支没有开放 PR,因此无 review 状态",
|
||||||
|
serde_json::json!({}),
|
||||||
|
),
|
||||||
|
None => DeliverySignal::new(
|
||||||
|
"unknown",
|
||||||
|
"gitea",
|
||||||
|
"无法确定当前分支,未查询 review",
|
||||||
|
serde_json::json!({}),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
GiteaDeliverySignals {
|
||||||
|
pull_request,
|
||||||
|
ci,
|
||||||
|
review,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn select_open_pull_request<'a>(
|
||||||
|
pull_requests: &'a serde_json::Value,
|
||||||
|
branch: &str,
|
||||||
|
) -> Option<&'a serde_json::Value> {
|
||||||
|
pull_requests.as_array()?.iter().find(|pr| {
|
||||||
|
pr["head"]["ref"].as_str() == Some(branch)
|
||||||
|
|| pr["head"]["label"]
|
||||||
|
.as_str()
|
||||||
|
.map(|label| label.ends_with(&format!(":{branch}")) || label == branch)
|
||||||
|
.unwrap_or(false)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pull_request_summary(pr: &serde_json::Value) -> serde_json::Value {
|
||||||
|
serde_json::json!({
|
||||||
|
"number": pr["number"],
|
||||||
|
"title": pr["title"],
|
||||||
|
"url": pr["html_url"],
|
||||||
|
"head": pr["head"]["ref"],
|
||||||
|
"base": pr["base"]["ref"],
|
||||||
|
"updated_at": pr["updated_at"],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 把 Gitea commit status 归并成稳定枚举;保留每个 context,供 Agent 自行判断门禁。
|
||||||
|
pub fn summarize_ci_statuses(statuses: &serde_json::Value) -> DeliverySignal {
|
||||||
|
let Some(items) = statuses.as_array() else {
|
||||||
|
return DeliverySignal::new(
|
||||||
|
"unknown",
|
||||||
|
"gitea",
|
||||||
|
"Gitea CI 响应不是数组",
|
||||||
|
serde_json::json!({}),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
if items.is_empty() {
|
||||||
|
return DeliverySignal::new(
|
||||||
|
"unknown",
|
||||||
|
"gitea",
|
||||||
|
"当前 commit 暂无 CI 记录",
|
||||||
|
serde_json::json!({ "contexts": [] }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut latest: std::collections::BTreeMap<String, &serde_json::Value> =
|
||||||
|
std::collections::BTreeMap::new();
|
||||||
|
for item in items {
|
||||||
|
let context = item["context"].as_str().unwrap_or("unknown").to_string();
|
||||||
|
// Gitea statuses API 按新到旧返回,同名 context 只保留第一条。
|
||||||
|
latest.entry(context).or_insert(item);
|
||||||
|
}
|
||||||
|
let contexts: Vec<serde_json::Value> = latest
|
||||||
|
.iter()
|
||||||
|
.map(|(context, item)| {
|
||||||
|
let state = item["status"]
|
||||||
|
.as_str()
|
||||||
|
.or_else(|| item["state"].as_str())
|
||||||
|
.unwrap_or("unknown");
|
||||||
|
serde_json::json!({
|
||||||
|
"context": context,
|
||||||
|
"state": state,
|
||||||
|
"description": item["description"],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let states: Vec<String> = contexts
|
||||||
|
.iter()
|
||||||
|
.filter_map(|item| item["state"].as_str())
|
||||||
|
.map(|state| state.to_ascii_lowercase())
|
||||||
|
.collect();
|
||||||
|
let state = if states
|
||||||
|
.iter()
|
||||||
|
.any(|state| state == "failure" || state == "error")
|
||||||
|
{
|
||||||
|
"failed"
|
||||||
|
} else if states.iter().all(|state| state == "success") {
|
||||||
|
"passed"
|
||||||
|
} else {
|
||||||
|
"pending"
|
||||||
|
};
|
||||||
|
let detail = match state {
|
||||||
|
"passed" => "所有最新 CI context 均成功",
|
||||||
|
"failed" => "至少一个最新 CI context 失败",
|
||||||
|
_ => "存在尚未成功的 CI context",
|
||||||
|
};
|
||||||
|
DeliverySignal::new(
|
||||||
|
state,
|
||||||
|
"gitea",
|
||||||
|
detail,
|
||||||
|
serde_json::json!({ "contexts": contexts }),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 对每位 reviewer 仅保留提交时间最新的状态,避免旧的 approve 掩盖后续 request changes。
|
||||||
|
pub fn summarize_reviews(reviews: &serde_json::Value) -> DeliverySignal {
|
||||||
|
let Some(items) = reviews.as_array() else {
|
||||||
|
return DeliverySignal::new(
|
||||||
|
"unknown",
|
||||||
|
"gitea",
|
||||||
|
"Gitea review 响应不是数组",
|
||||||
|
serde_json::json!({}),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
let mut latest: std::collections::BTreeMap<String, &serde_json::Value> =
|
||||||
|
std::collections::BTreeMap::new();
|
||||||
|
for (index, review) in items.iter().enumerate() {
|
||||||
|
let reviewer = review["user"]["login"]
|
||||||
|
.as_str()
|
||||||
|
.map(str::to_string)
|
||||||
|
.unwrap_or_else(|| format!("unknown-{index}"));
|
||||||
|
let should_replace = latest
|
||||||
|
.get(&reviewer)
|
||||||
|
.map(|existing| review_timestamp(review) >= review_timestamp(existing))
|
||||||
|
.unwrap_or(true);
|
||||||
|
if should_replace {
|
||||||
|
latest.insert(reviewer, review);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let reviewers: Vec<serde_json::Value> = latest
|
||||||
|
.iter()
|
||||||
|
.map(|(reviewer, review)| {
|
||||||
|
serde_json::json!({
|
||||||
|
"reviewer": reviewer,
|
||||||
|
"state": review["state"],
|
||||||
|
"submitted_at": review["submitted_at"],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let states: Vec<String> = reviewers
|
||||||
|
.iter()
|
||||||
|
.filter_map(|review| review["state"].as_str())
|
||||||
|
.map(|state| state.to_ascii_uppercase())
|
||||||
|
.collect();
|
||||||
|
let state = if states.iter().any(|state| state == "REQUEST_CHANGES") {
|
||||||
|
"changes_requested"
|
||||||
|
} else if states.iter().any(|state| state == "APPROVED") {
|
||||||
|
"approved"
|
||||||
|
} else {
|
||||||
|
"pending"
|
||||||
|
};
|
||||||
|
let detail = match state {
|
||||||
|
"changes_requested" => "存在 reviewer 请求修改",
|
||||||
|
"approved" => "至少一位 reviewer 已批准,未发现后续请求修改",
|
||||||
|
_ => "尚无有效 approve/reject review",
|
||||||
|
};
|
||||||
|
DeliverySignal::new(
|
||||||
|
state,
|
||||||
|
"gitea",
|
||||||
|
detail,
|
||||||
|
serde_json::json!({ "reviewers": reviewers }),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn review_timestamp(review: &serde_json::Value) -> &str {
|
||||||
|
review["submitted_at"].as_str().unwrap_or("")
|
||||||
|
}
|
||||||
|
|
||||||
/// 写入或更新项目 ↔ 仓库绑定(换绑时重置 webhook 字段,旧 hook 已随旧仓库失效)
|
/// 写入或更新项目 ↔ 仓库绑定(换绑时重置 webhook 字段,旧 hook 已随旧仓库失效)
|
||||||
fn upsert_repo_link(
|
fn upsert_repo_link(
|
||||||
conn: &rusqlite::Connection,
|
conn: &rusqlite::Connection,
|
||||||
@ -1249,4 +1571,42 @@ mod tests {
|
|||||||
assert!(parse_gitea_commits(&serde_json::json!({"not": "array"})).is_empty());
|
assert!(parse_gitea_commits(&serde_json::json!({"not": "array"})).is_empty());
|
||||||
assert!(parse_gitea_commits(&serde_json::json!([{"no_sha": true}])).is_empty());
|
assert!(parse_gitea_commits(&serde_json::json!([{"no_sha": true}])).is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn delivery_ci_summary_uses_latest_context_and_preserves_failure() {
|
||||||
|
let statuses = serde_json::json!([
|
||||||
|
{"context": "test", "status": "success", "description": "green"},
|
||||||
|
{"context": "test", "status": "failure", "description": "old failure"},
|
||||||
|
{"context": "lint", "state": "failure", "description": "bad"}
|
||||||
|
]);
|
||||||
|
let summary = summarize_ci_statuses(&statuses);
|
||||||
|
assert_eq!(summary.state, "failed");
|
||||||
|
assert_eq!(summary.data["contexts"].as_array().unwrap().len(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn delivery_review_summary_prefers_latest_reviewer_decision() {
|
||||||
|
let reviews = serde_json::json!([
|
||||||
|
{"user": {"login": "alice"}, "state": "REQUEST_CHANGES", "submitted_at": "2026-09-21T10:00:00Z"},
|
||||||
|
{"user": {"login": "alice"}, "state": "APPROVED", "submitted_at": "2026-09-21T09:00:00Z"},
|
||||||
|
{"user": {"login": "bob"}, "state": "APPROVED", "submitted_at": "2026-09-21T10:01:00Z"}
|
||||||
|
]);
|
||||||
|
let summary = summarize_reviews(&reviews);
|
||||||
|
assert_eq!(summary.state, "changes_requested");
|
||||||
|
assert_eq!(summary.data["reviewers"].as_array().unwrap().len(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn delivery_pr_selection_matches_current_branch_only() {
|
||||||
|
let pulls = serde_json::json!([
|
||||||
|
{"number": 2, "head": {"ref": "feat/other"}},
|
||||||
|
{"number": 3, "head": {"ref": "feat/delivery"}}
|
||||||
|
]);
|
||||||
|
assert_eq!(
|
||||||
|
select_open_pull_request(&pulls, "feat/delivery").expect("应匹配当前分支")["number"]
|
||||||
|
.as_i64(),
|
||||||
|
Some(3)
|
||||||
|
);
|
||||||
|
assert!(select_open_pull_request(&pulls, "main").is_none());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,6 +9,7 @@ pub mod claude_config;
|
|||||||
pub mod cloud_db_databases;
|
pub mod cloud_db_databases;
|
||||||
pub mod cloud_products;
|
pub mod cloud_products;
|
||||||
pub mod commit_metrics;
|
pub mod commit_metrics;
|
||||||
|
pub mod delivery_status;
|
||||||
pub mod devtools;
|
pub mod devtools;
|
||||||
pub mod docker_apps;
|
pub mod docker_apps;
|
||||||
pub mod git_ops;
|
pub mod git_ops;
|
||||||
|
|||||||
@ -420,7 +420,11 @@ pub fn verify_onboarding(
|
|||||||
// 1/2 模板管理块版本到位(期望版本从内嵌模板自省,不硬编码)
|
// 1/2 模板管理块版本到位(期望版本从内嵌模板自省,不硬编码)
|
||||||
for (rel, tmpl, label) in [
|
for (rel, tmpl, label) in [
|
||||||
("AGENTS.md", TMPL_AGENTS, "AGENTS.md 管理块版本"),
|
("AGENTS.md", TMPL_AGENTS, "AGENTS.md 管理块版本"),
|
||||||
("docs/ai-context/git-workflow.md", TMPL_GIT_WORKFLOW, "git-workflow 管理块版本"),
|
(
|
||||||
|
"docs/ai-context/git-workflow.md",
|
||||||
|
TMPL_GIT_WORKFLOW,
|
||||||
|
"git-workflow 管理块版本(含交付状态调用说明)",
|
||||||
|
),
|
||||||
] {
|
] {
|
||||||
let expected = extract_managed_block(tmpl).and_then(block_version).unwrap_or("?");
|
let expected = extract_managed_block(tmpl).and_then(block_version).unwrap_or("?");
|
||||||
let (pass, detail) = check_managed_version(read(rel).as_deref(), expected);
|
let (pass, detail) = check_managed_version(read(rel).as_deref(), expected);
|
||||||
|
|||||||
@ -183,6 +183,10 @@ mod tests {
|
|||||||
"工具清单不应因去组化而缺失,实际 {}",
|
"工具清单不应因去组化而缺失,实际 {}",
|
||||||
list.len()
|
list.len()
|
||||||
);
|
);
|
||||||
|
assert!(
|
||||||
|
list.iter().any(|tool| tool["name"] == "get_delivery_status"),
|
||||||
|
"MCP 握手后的工具清单应暴露 get_delivery_status"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -185,6 +185,17 @@ pub fn tools_list_result() -> Value {
|
|||||||
"required": ["project_id", "commit_sha"]
|
"required": ["project_id", "commit_sha"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "get_delivery_status",
|
||||||
|
"description": "读取项目当前交付信号:本地 Git 工作区与最近 SHA、当前分支的 Gitea PR、该 SHA 的 CI、PR review,以及已知 deployment/health 证据。该工具只呈现来源信号,不替项目判断是否应该发布;来源不可用或未接入时会明确返回 unknown/unavailable。",
|
||||||
|
"inputSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"project_id": { "type": "string", "description": "项目 workspace ID(来自 list_all_projects 的返回结果)" }
|
||||||
|
},
|
||||||
|
"required": ["project_id"]
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "merge_pull_request",
|
"name": "merge_pull_request",
|
||||||
"description": "合并项目的指定 PR。建议在 get_ci_status 确认通过后再调用。等价于 Gitea UI「合并 PR」按钮。",
|
"description": "合并项目的指定 PR。建议在 get_ci_status 确认通过后再调用。等价于 Gitea UI「合并 PR」按钮。",
|
||||||
@ -251,7 +262,7 @@ pub fn tools_list_result() -> Value {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "verify_onboarding",
|
"name": "verify_onboarding",
|
||||||
"description": "接入生效验收:检查接入包在项目里是否真正生效(模板管理块版本到位 / lefthook hook 真挂在 .git/hooks / lefthook 检查段对齐模板 / origin 指向已登记 Gitea / CI workflow 存在 / 近 30 天飞轮 commit 数据流入)。分发成功 ≠ 功能生效,apply_onboarding_pack 之后应调用本工具做消费者角色验收。",
|
"description": "接入生效验收:检查接入包在项目里是否真正生效(含 get_delivery_status 调用说明的模板管理块版本、lefthook hook 真挂在 .git/hooks、检查段对齐模板、origin 指向已登记 Gitea、CI workflow 存在、近 30 天飞轮 commit 数据流入)。分发成功 ≠ 功能生效,apply_onboarding_pack 之后应调用本工具做消费者角色验收。",
|
||||||
"inputSchema": {
|
"inputSchema": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@ -436,6 +447,16 @@ pub async fn tools_call(params: Option<&Value>) -> Value {
|
|||||||
}
|
}
|
||||||
crate::commands::gitea::gitea_api_get_ci_status(pid, sha)
|
crate::commands::gitea::gitea_api_get_ci_status(pid, sha)
|
||||||
}
|
}
|
||||||
|
"get_delivery_status" => {
|
||||||
|
let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or("");
|
||||||
|
if pid.is_empty() {
|
||||||
|
return Err("project_id 不能为空".to_string());
|
||||||
|
}
|
||||||
|
let root = resolve_project_root(pid)?;
|
||||||
|
let conn = db::pool().get().map_err(|e| e.to_string())?;
|
||||||
|
let status = crate::commands::delivery_status::get_delivery_status(&conn, pid, &root);
|
||||||
|
serde_json::to_string_pretty(&status).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
"merge_pull_request" => {
|
"merge_pull_request" => {
|
||||||
let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or("");
|
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);
|
let pr_num = args.get("pr_number").and_then(|v| v.as_i64()).unwrap_or(0);
|
||||||
@ -1431,6 +1452,7 @@ mod tests {
|
|||||||
);
|
);
|
||||||
assert!(names.contains(&"inject_mcp"), "缺少 inject_mcp");
|
assert!(names.contains(&"inject_mcp"), "缺少 inject_mcp");
|
||||||
assert!(names.contains(&"create_pull_request"), "缺少 create_pull_request");
|
assert!(names.contains(&"create_pull_request"), "缺少 create_pull_request");
|
||||||
|
assert!(names.contains(&"get_delivery_status"), "缺少 get_delivery_status");
|
||||||
assert!(names.contains(&"poll_now"), "缺少 poll_now");
|
assert!(names.contains(&"poll_now"), "缺少 poll_now");
|
||||||
assert!(names.contains(&"predict_task_risk"), "缺少 predict_task_risk");
|
assert!(names.contains(&"predict_task_risk"), "缺少 predict_task_risk");
|
||||||
assert!(names.contains(&"ingest_git_history"), "缺少 ingest_git_history");
|
assert!(names.contains(&"ingest_git_history"), "缺少 ingest_git_history");
|
||||||
@ -1444,6 +1466,20 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn delivery_status_tool_requires_project_id() {
|
||||||
|
let result = tools_call(Some(&json!({
|
||||||
|
"name": "get_delivery_status",
|
||||||
|
"arguments": {}
|
||||||
|
})))
|
||||||
|
.await;
|
||||||
|
assert_eq!(result["isError"], true);
|
||||||
|
assert!(result["content"][0]["text"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.contains("project_id 不能为空"));
|
||||||
|
}
|
||||||
|
|
||||||
/// notes 档案测试用最小 schema(in-memory 或文件库共用)
|
/// notes 档案测试用最小 schema(in-memory 或文件库共用)
|
||||||
fn setup_notes_schema(conn: &rusqlite::Connection) {
|
fn setup_notes_schema(conn: &rusqlite::Connection) {
|
||||||
conn.execute_batch(
|
conn.execute_batch(
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user