Merge pull request 'feat(delivery): add project delivery status' (#7) from feat/project-capability-control-plane into master
All checks were successful
Push & PR Check / check (push) Successful in 58s

Reviewed-on: #7
This commit is contained in:
lanrtop 2026-09-21 16:52:12 +08:00
commit 2294dbf5b5
12 changed files with 844 additions and 5 deletions

View File

@ -6,7 +6,7 @@ description: >
成为每个项目的贴身导师。炼境本身亦被纳入其中,
形成可自我进化的开发智能闭环。
iteration: 1
updated: 2026-09-20
updated: 2026-09-21
agent_button_carrier: tauri
@ -451,6 +451,14 @@ modules:
status: planned
progress: 0
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:
# 前端依赖
- from: workspace-mgmt
@ -733,3 +741,12 @@ edges:
- from: codex-model-switch
to: ui-restructure
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

View 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 重复劳动/上下文丢失次数。不能降低任一真实成本的门禁或自动化应降级或移除。

View File

@ -3,7 +3,7 @@
> 把此文件粘贴给任何 AI Agent,让它了解本项目的 git 工作方式。由炼境接入包分发。
> `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。
## 交付状态(炼境托管项目)
在准备开 PR、合并 PR 或排查部署问题时,先调用 MCP 工具:
```json
{ "project_id": "<id>" }
```
工具名为 `get_delivery_status`,返回本地工作区与最近 commit SHA、当前分支的 Gitea PR、CI、review 和已知的部署/健康证据。
- `unknown`:炼境尚未获得该项证据,**不是成功**;例如 CI 成功不能代替 production 健康。
- `unavailable`:对应外部来源暂时不可达;保留本地验证与 Gitea 网页作为降级路径。
- 结果是交付上下文,不替代项目自己的测试、业务验收或发布决定。
## 常用命令
```bash

View File

@ -26,6 +26,8 @@ src/lib/commands.ts 前后端契约(唯一前端入口)
src-tauri/src/commands/ Rust 后端实现(一功能一文件)
src-tauri/src/db.rs 数据库 schema + migration
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/ 接入包分发模板
.blueprint/ 项目蓝图
docs/ai-context/ AI 上下文文档(本文件、契约映射)

View File

@ -8,6 +8,13 @@ contracts:
- "src-tauri/src/mcp_server.rs"
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 接口(前后端契约)
paths:
- "src-tauri/src/commands/**/*.rs"

View File

@ -3,7 +3,7 @@
> 把此文件粘贴给任何 AI Agent,让它了解本项目的 git 工作方式。由炼境接入包分发。
> `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。
## 交付状态(炼境托管项目)
在准备开 PR、合并 PR 或排查部署问题时,先调用 MCP 工具:
```json
{ "project_id": "<id>" }
```
工具名为 `get_delivery_status`,返回本地工作区与最近 commit SHA、当前分支的 Gitea PR、CI、review 和已知的部署/健康证据。
- `unknown`:炼境尚未获得该项证据,**不是成功**;例如 CI 成功不能代替 production 健康。
- `unavailable`:对应外部来源暂时不可达;保留本地验证与 Gitea 网页作为降级路径。
- 结果是交付上下文,不替代项目自己的测试、业务验收或发布决定。
## 常用命令
```bash

View 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");
}
}

View File

@ -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 已随旧仓库失效)
fn upsert_repo_link(
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!([{"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());
}
}

View File

@ -9,6 +9,7 @@ pub mod claude_config;
pub mod cloud_db_databases;
pub mod cloud_products;
pub mod commit_metrics;
pub mod delivery_status;
pub mod devtools;
pub mod docker_apps;
pub mod git_ops;

View File

@ -443,7 +443,11 @@ pub fn verify_onboarding(
// 1/2 模板管理块版本到位(期望版本从内嵌模板自省,不硬编码)
for (rel, tmpl, label) in [
("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 (pass, detail) = check_managed_version(read(rel).as_deref(), expected);

View File

@ -183,6 +183,10 @@ mod tests {
"工具清单不应因去组化而缺失,实际 {}",
list.len()
);
assert!(
list.iter().any(|tool| tool["name"] == "get_delivery_status"),
"MCP 握手后的工具清单应暴露 get_delivery_status"
);
}
}

View File

@ -185,6 +185,17 @@ pub fn tools_list_result() -> Value {
"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",
"description": "合并项目的指定 PR。建议在 get_ci_status 确认通过后再调用。等价于 Gitea UI「合并 PR」按钮。",
@ -251,7 +262,7 @@ pub fn tools_list_result() -> Value {
},
{
"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": {
"type": "object",
"properties": {
@ -436,6 +447,16 @@ pub async fn tools_call(params: Option<&Value>) -> Value {
}
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" => {
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);
@ -1431,6 +1452,7 @@ mod tests {
);
assert!(names.contains(&"inject_mcp"), "缺少 inject_mcp");
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(&"predict_task_risk"), "缺少 predict_task_risk");
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 或文件库共用)
fn setup_notes_schema(conn: &rusqlite::Connection) {
conn.execute_batch(