Merge pull request 'feat(delivery/gitea): 归一化 Gitea 部署状态信号' (#13) from feat/project-capability-control-plane-p3a into master
All checks were successful
Push & PR Check / check (push) Successful in 1m1s

This commit is contained in:
lanrtop 2026-09-22 19:38:47 +08:00
commit ff51882c46
3 changed files with 517 additions and 46 deletions

View File

@ -457,7 +457,7 @@ modules:
area: infra
status: in_progress
progress: 14
note: "P1 已完成:只读交付信号聚合(Git / PR / CI / review / 部署 unknown),不改变 Enterprise 既有 production 自动部署;后续受控执行按需推进。"
note: "P1 与 P3-A 已完成:Gitea 1.22.6 真实 deployment context/时间/URL/SHA 已归一化,并修复 skipped-as-success 误判;P3-B~E 待执行。首轮只交付 MCP 闭环,不建 staging、不开放 SSH/部署/回滚写操作,UI 待 2~4 周收益观测后决定。"
position: [2000, 800]
- id: agent-client-capability-packs

View File

@ -1,7 +1,7 @@
---
id: project-capability-control-plane
name: 项目能力与交付控制面
status: planned
status: in_progress
progress: 0
---
@ -96,6 +96,160 @@ Enterprise System 当前 `push main → Gitea CI → deploy-api.sh` 是 producti
- 现有部署健康闸门仅证明 HTTP 监听:协议同时支持项目声明业务 health probe,不把存活探测误报为业务就绪。
- Gitea/网络故障:交付状态明确降级;项目本地验证与项目自有手工部署能力不受炼境阻断。
## 设计决策:production 交付观测 v1(2026-09-22,已确认)
### 背景与本轮收敛
P1 已能读取本地 Git、PR、CI 与 review,但 `deployment` 仍固定为 `unknown`。Enterprise System 的真实链路已经是 `push main → Gitea Actions → SSH → deploy-api.sh → 容器内健康闸门/失败回滚`;服务器资源有限,不适合为了治理额外常驻一套 staging,也没有必要由炼境重做部署脚本。
本轮目标只解决一个问题:让人和 Agent 都能回答“生产当前运行哪个 SHA、最近一次部署是否成功、技术健康是否可达”,同时保持现有自动部署链路完全不变。第一版明确不提供部署、回滚、migration 或任意远程命令执行能力。
### 方案选择
| 方案 | 优点 | 缺点 | 结论 |
| --- | --- | --- | --- |
| 只读取 Gitea Actions/commit status | 实现最小,不接触服务器 | 只能证明 job 结果,不能证明此刻运行的版本;回滚后尤其可能误报 | 不单独采用 |
| 炼境通过只读 SSH 查询 Docker | 能直接读取镜像与容器状态 | 需要生产凭证、耦合服务器命令与 Docker,扩大攻击面并把平台过早变成运维执行器 | 后置,不进入 v1 |
| Gitea 部署信号 + 服务版本探针双证据 | 既有部署过程证据,也有实时运行 SHA/健康证据;无需 staging 与 SSH | 需 Enterprise System 增加极小的公开只读端点,并把 SHA 烘焙进镜像 | **选定** |
### 数据流与信任边界
```text
Enterprise System main push
└─ Gitea Actions deploy-api job
├─ 现有 deploy-api.sh:构建、切换、健康闸门、失败回滚(保持所有权)
└─ Gitea commit status:目标 SHA 的部署过程证据
Enterprise production API
└─ GET /health/version
└─ 当前容器镜像内烘焙的 revision + technical health
炼境 get_delivery_status(project_id)
├─ 读取 Gitea 部署 context
├─ 读取项目声明的只读 health/version probe
└─ 纯函数归并:目标 SHA、运行 SHA、部署状态、技术健康、漂移
├─ MCP 投影:Codex / Claude
└─ Tauri 投影:项目交付状态面板
```
Gitea 是“部署过程事实源”,运行服务是“当前线上事实源”。只有最新部署 context 成功、版本探针健康且 `running_revision == target_revision` 时,聚合状态才可为 `deployed`。任一证据缺失必须保留为 `unknown` / `unavailable`;CI 成功不得替代部署成功,HTTP 存活也不得替代业务验收。
### 项目声明与外部契约
项目继续拥有能力声明。建议在 `.lian-jing/capabilities.yaml` 增加只读 delivery profile;该文件只允许声明公开 HTTP(S) 探针和 Gitea context 名称,不允许认证头、Token、SSH 命令、IP 凭证或数据库连接串:
```yaml
version: 1
delivery:
targets:
production:
provider: gitea-actions
components:
api:
deploy_context: deploy-api
probe:
kind: http-json
url: https://api3.yljt.info/health/version
revision_field: revision
healthy_field: ok
```
Enterprise System 新增未鉴权只读端点:
```json
GET /health/version
{
"ok": true,
"component": "api",
"revision": "<git-sha>",
"started_at": "<iso-8601>"
}
```
`revision` 必须在 Docker build 时写入镜像(例如 build arg → image ENV),不能在启动脚本里总是注入“本次请求 SHA”;否则部署失败后回滚旧镜像时会把旧代码伪报成新 SHA。端点不得返回环境变量、主机信息、数据库状态或其他敏感内容。
### `get_delivery_status` 兼容演进
保留现有顶层 `DeliveryStatus` 结构,不新增破坏性字段;扩展 `deployment.data`:
```json
{
"environment": "production",
"target_revision": "<sha>",
"running_revision": "<sha>",
"deployed_at": "<gitea-status-time>",
"components": {
"api": {
"deploy": { "state": "success", "source": "gitea" },
"health": { "state": "healthy", "source": "http_probe" },
"revision_match": true
}
},
"evidence": []
}
```
稳定状态语义:
- `not_configured`:项目没有 production delivery profile。
- `pending`:部署 context 尚未到达最终状态。
- `deployed`:部署成功、探针健康、运行 SHA 与目标 SHA 一致。
- `drifted`:探针可达,但运行 SHA 与目标 SHA 不一致(含回滚后的旧版本)。
- `failed`:Gitea 明确报告部署失败;若探针仍健康,必须同时展示实际运行 SHA,不能把线上误报为不可用。
- `unhealthy`:运行 SHA 可读但技术健康失败。
- `unknown`:缺少足以判断的证据。
- `unavailable`:已配置来源当前不可达。
v1 不新增 deployment history 表。最近部署时间和过程状态来自 Gitea status 的时间字段,当前运行事实来自实时探针;连续历史、受控部署和审计记录等真实需求出现后,再评估 SQLite record。
### 系统边界变更
- **Enterprise System HTTP 契约**:新增 `/health/version`;Docker 镜像携带不可变 revision,回滚镜像自然恢复对应旧 revision。
- **项目能力声明**:新增只读 delivery profile schema;onboarding 只提供示例/校验,绝不覆盖项目已写声明。
- **炼境聚合层**:`delivery_status.rs` 读取 profile、Gitea deployment context 与 HTTP 探针,归并逻辑保持纯函数可测。
- **Rust 本体与 MCP 投影**:Tauri command 与 MCP 复用同一个 Rust 业务函数;首轮只交付 MCP 可读闭环,UI 等 2~4 周观测证明收益后再决定是否补充。
- **无数据库与执行面变更**:v1 不新增凭证、环境授权、deployment record、SSH adapter、staging 或发布按钮。
### 受影响文件
- 修改:`src-tauri/src/commands/delivery_status.rs` — deployment 双证据聚合与稳定状态。
- 修改:`src-tauri/src/commands/gitea.rs` — 保留 deployment context 的时间、target URL 与原始状态;实现前先用真实 Gitea 1.22 payload 校准 context 名称。
- 修改:`src-tauri/src/mcp_tools.rs`、`src-tauri/src/mcp_server.rs` — MCP schema/dispatch 与协议测试。
- 修改:`src-tauri/src/commands/onboarding.rs`、`src-tauri/resources/onboarding/` — capability profile 示例与只读自检,不覆盖项目定制。
- 后置:`src/lib/commands.ts`、项目治理面板 — 不进入首轮 MVP;只有观测证明能减少人工确认或故障定位时间时再补。
- 消费者修改:`E:/code-project/enterprise-system/apps/api/src/index.ts`、`apps/api/Dockerfile`、`deploy/deploy-api.sh`、`.lian-jing/capabilities.yaml` — 版本探针、镜像 revision 和 production profile。
### 验证路径
- 载体:Tauri command / MCP(炼境)+ HTTP API(Enterprise System)。
- 示例:`[HTTP API] GET https://api3.yljt.info/health/version → 200,ok=true,revision 为当前镜像 SHA,且无敏感字段`。
- 示例:`[cargo test] aggregate_deployment_signal(target_sha, gitea_success, probe_same_sha) → state=deployed;probe_old_sha → state=drifted;Gitea 不可达 → unavailable;证据缺失 → unknown`。
- 示例:`[MCP] get_delivery_status(enterprise_project_id) → deployment.data 同时列出 Gitea 与 runtime 两类 evidence;两者一致才返回 deployed`。
- 首轮不设置 UI `human_verify`;真实 production 验收只读取现有部署结果,不新增人工发布步骤。
### 风险点与约束
- Gitea 1.22 status context 命名可能与 job 名不同:实现前必须读取一次真实 `/statuses/{sha}` payload 固化 fixture,不凭 workflow 文件猜测。
- HTTP 探针可能被 DNS、代理或短暂网络故障影响:设置短超时与响应体大小上限;网络失败返回 unavailable,不覆盖最后一次 Gitea 部署事实。
- 探针 URL 来自已注册项目声明,但仍只接受 `http/https`,禁止 `file:` 等协议、认证头和动态 Agent 入参,避免把工具变成任意请求代理。
- `ok=true` 只代表项目定义的技术健康,不代表业务流程验收通过;UI 与 MCP 文案必须保留这条边界。
- 多组件部署可能部分成功:数据结构支持 components,但首个消费者只落地 API;Web 的版本探针待真实需要后扩展。
### 确认后的 MVP 拆解(2026-09-22)
用户确认采用“先证明 Agent 能准确判断某个 SHA 是否真正在线,再决定是否补 UI”的薄 MVP,共 5 张 S/M 卡:
1. Gitea 1.22 真实 deployment signal 实证与归一化。
2. Enterprise System 不可变 revision + `/health/version`。
3. production delivery profile、安全探针读取与 onboarding 自检。
4. deployment 双证据聚合与 MCP 契约。
5. 经用户批准的既有 production 流程端到端验收,并建立 2~4 周观测基线。
## 本轮范围调整
- B/C/G 保留为远期 concept:只有四周观测证明“只读状态”仍不能降低失败定位或回滚成本时,才评估环境授权、SSH adapter 与受控 production 操作。
- E/F 取消:用户已明确服务器资源有限,不建设常驻 staging;未来若资源或事故数据改变,再新建独立迭代,不作为 P3 依赖。
## 任务卡
### ✅ A. Canonical 项目上下文与 capability schema
@ -105,19 +259,19 @@ Enterprise System 当前 `push main → Gitea CI → deploy-api.sh` 是 producti
- acceptance: "[cargo test] 旧 `.claude/liangjing.json` 项目重跑接入 → 写入 `.lian-jing/context.json`,project_id 一致;Claude/Codex adapter 均从 canonical context 派生且保留用户手工同名配置;无 canonical context 时给出可诊断兼容状态。"
- done_note: "2026-09-22:由 agent 复核确认与 P2-A 同一能力,canonical context、legacy 迁移、未知字段保留、手工同名 MCP 拒绝覆盖及诊断信号已在 PR #8/#9 完成;cargo test 139/139 与 Enterprise onboarding 12/12 已通过。"
### 📋 B. 炼境环境、授权与 deployment record 数据模型(后续受控执行)
- status: todo
- complexity: M
### 💭 B. 炼境环境、授权与 deployment record 数据模型(远期)
- status: concept
- 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。"
- note: "P3 只读观测不需要新表;待真实执行/历史审计需求出现后再升格。"
### 📋 C. SSH-script adapter 与生产环境锁(后续受控执行)
- status: todo
- complexity: M
### 💭 C. SSH-script adapter 与生产环境锁(远期)
- status: concept
- 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 第二次调用返回锁定状态,绝不执行第二条远程命令。"
- note: "v1 不持有 SSH 执行权;只读双证据运行四周后再评估。"
### ✅ P1. 交付状态与 Gitea 轻量治理投影
- status: done
@ -126,26 +280,61 @@ Enterprise System 当前 `push main → Gitea CI → deploy-api.sh` 是 producti
- 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
### E. Enterprise System 可选 staging 隔离与部署 profile
- status: abandoned
- 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 关键业务接口可用。仅当资源与实际线上验证成本证明必要时启动。"
- abandoned_reason: "2026-09-22 用户确认服务器资源有限,当前追求快速开发与快速 production 部署;不建设常驻 staging。"
### 📋 F. Enterprise System capability 声明与可选 staging 适配验证
- status: todo
- complexity: M
### F. Enterprise System capability 声明与可选 staging 适配验证
- status: abandoned
- 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 关键业务接口可用。"
- abandoned_reason: "随 staging 方案一并取消;production 只读 profile 将在设计确认后由 splitter 另拆。"
### 📋 G. production 发布确认、回滚与操作报告(后续受控执行)
- status: todo
- complexity: M
- depends: C. SSH-script adapter 与生产环境锁(后续受控执行), P1. 交付状态与 Gitea 轻量治理投影, F. Enterprise System capability 声明与可选 staging 适配验证
### 💭 G. production 发布确认、回滚与操作报告(远期)
- status: concept
- depends: C. SSH-script adapter 与生产环境锁(后续受控执行), P1. 交付状态与 Gitea 轻量治理投影
- 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。"
- note: "从 P3 的观测指标决定是否需要升格;不再依赖已取消的 staging 卡。"
### ✅ P3-A. Gitea 1.22 真实 deployment signal 实证与归一化
- status: done
- complexity: M
- files: src-tauri/src/commands/gitea.rs, E:/code-project/enterprise-system/.gitea/workflows/ci.yml
- depends: P1. 交付状态与 Gitea 轻量治理投影
- acceptance: "[cargo test] 使用脱敏后的真实 Gitea 1.22 `/statuses/{sha}` payload 运行 deployment signal 归一化测试 → success/pending/failure、context、updated_at、target_url 与目标 SHA 均被保留,无关 CI context 不得误判为部署;若真实 payload 不含独立 deploy signal,则由既有 workflow 发布稳定 context 后用同一测试通过。"
- done_note: "2026-09-22:通过炼境 MCP 实证 Gitea 1.22.6 为 run #525/#529 发布独立 context,API 为 `CI — DeepSeek Agent / deploy-api (push)`;保留 target SHA、target_url、created_at/updated_at,并发现/修复 skipped job 被编码为 status=success + description=Has been skipped 的误判风险。既有 workflow 无需修改;cargo test --lib 全量通过,实机 get_delivery_status 已将 run #529 deploy-api/deploy-web 正确归一化为 skipped。"
### 📋 P3-B. Enterprise System 不可变 revision 与版本健康端点
- status: todo
- complexity: M
- files: E:/code-project/enterprise-system/apps/api/src/index.ts, E:/code-project/enterprise-system/apps/api/Dockerfile, E:/code-project/enterprise-system/deploy/deploy-api.sh
- acceptance: "[HTTP API] 以 BUILD_REVISION=<sha-a> 构建并启动 API 镜像后调用 `GET /health/version` → 200,body 仅含 ok/component/revision/started_at 且 revision=<sha-a>;切回以 <sha-b> 构建的旧镜像 → revision=<sha-b>,证明回滚不会伪报新 SHA。"
### 📋 P3-C. production delivery profile、安全探针与 onboarding 自检
- status: todo
- complexity: M
- files: src-tauri/src/commands/delivery_status.rs, src-tauri/src/commands/onboarding.rs, src-tauri/resources/onboarding/
- depends: A. Canonical 项目上下文与 capability schema
- acceptance: "[cargo test] 读取声明了 production/http-json probe 的 capability profile → 得到固定 environment/component/context/url 字段;`file:`、URL 内凭证、动态认证头、超时响应和超限响应体分别被拒绝或降级为 unavailable;未配置项目返回 not_configured,onboarding 自检不覆盖已有声明。"
### 📋 P3-D. deployment 双证据聚合与 MCP 契约
- status: todo
- complexity: M
- files: src-tauri/src/commands/delivery_status.rs, src-tauri/src/mcp_tools.rs, src-tauri/src/mcp_server.rs
- depends: P3-A. Gitea 1.22 真实 deployment signal 实证与归一化, P3-C. production delivery profile、安全探针与 onboarding 自检
- acceptance: "[MCP] `get_delivery_status(project_id)` 对固定 fixture 调用 → Gitea success + probe healthy + SHA 相同返回 deployed;SHA 不同返回 drifted;deploy failure 同时保留实际运行 SHA;来源不可达返回 unavailable;证据不足返回 unknown,且现有 Git/PR/CI/review 顶层字段保持兼容。"
### 📋 P3-E. Enterprise production 端到端验收与观测基线
- status: todo
- complexity: M
- files: E:/code-project/enterprise-system/.lian-jing/capabilities.yaml, E:/code-project/enterprise-system/deploy/README.md, docs/ai-context/agent-button-system.md
- depends: P3-B. Enterprise System 不可变 revision 与版本健康端点, P3-D. deployment 双证据聚合与 MCP 契约
- acceptance: "agent_verify: [HTTP API + MCP] 经用户批准合并并由 Enterprise 既有 main workflow 部署后,`GET https://api3.yljt.info/health/version` 的 revision 与目标 commit SHA 一致,`get_delivery_status(enterprise_project_id)` 返回 deployed 且同时列出 Gitea/runtime evidence;不触发炼境 SSH、部署、回滚或数据库写入。随后记录 2~4 周人工确认次数、失败定位时间与漂移发现数,未证明收益前不新增 UI。"
## 迭代衡量

View File

@ -349,9 +349,10 @@ pub fn get_delivery_signals(
let ci = match commit_sha {
Some(sha) => {
let path = format!("/api/v1/repos/{owner}/{repo}/statuses/{sha}?limit=50");
let path =
format!("/api/v1/repos/{owner}/{repo}/statuses/{sha}?sort=recentupdate&limit=50");
match gitea_get_json(&base_url, &token, &path, "读取 Gitea CI 状态失败") {
Ok(statuses) => summarize_ci_statuses(&statuses),
Ok(statuses) => summarize_ci_statuses(&statuses, Some(sha)),
Err(error) => {
DeliverySignal::new("unavailable", "gitea", error, serde_json::json!({}))
}
@ -420,8 +421,75 @@ fn pull_request_summary(pr: &serde_json::Value) -> serde_json::Value {
})
}
/// 把 Gitea commit status 归并成稳定枚举;保留每个 context,供 Agent 自行判断门禁。
pub fn summarize_ci_statuses(statuses: &serde_json::Value) -> DeliverySignal {
fn status_timestamp(status: &serde_json::Value) -> Option<chrono::DateTime<chrono::FixedOffset>> {
status["updated_at"]
.as_str()
.or_else(|| status["created_at"].as_str())
.and_then(|value| chrono::DateTime::parse_from_rfc3339(value).ok())
}
fn normalized_status_state(status: &serde_json::Value) -> String {
let raw_state = status["status"]
.as_str()
.or_else(|| status["state"].as_str())
.unwrap_or("unknown")
.to_ascii_lowercase();
let description = status["description"]
.as_str()
.unwrap_or("")
.trim()
.to_ascii_lowercase();
// Gitea 1.22 Actions 会把条件未命中的 job 记录为 status=success,
// 仅在 description 中标明 "Has been skipped"。部署信号必须还原成 skipped,
// 否则会把“未部署”误报为“部署成功”。
if raw_state == "skipped" || matches!(description.as_str(), "has been skipped" | "skipped") {
"skipped".to_string()
} else {
raw_state
}
}
fn latest_statuses_by_context<'a>(
items: &'a [serde_json::Value],
) -> std::collections::BTreeMap<String, &'a serde_json::Value> {
let mut latest: std::collections::BTreeMap<String, &'a serde_json::Value> =
std::collections::BTreeMap::new();
for item in items {
let context = item["context"].as_str().unwrap_or("unknown").to_string();
let should_replace = latest
.get(&context)
.map(
|existing| match (status_timestamp(item), status_timestamp(existing)) {
(Some(candidate), Some(current)) => candidate > current,
(Some(_), None) => true,
_ => false,
},
)
.unwrap_or(true);
if should_replace {
latest.insert(context, item);
}
}
latest
}
fn status_context_summary(context: &str, item: &serde_json::Value) -> serde_json::Value {
let state = normalized_status_state(item);
serde_json::json!({
"context": context,
"state": state,
"description": item["description"],
"target_url": item["target_url"],
"created_at": item["created_at"],
"updated_at": item["updated_at"],
})
}
/// 把 Gitea commit status 归并成稳定枚举;保留每个 context 的可追溯字段,供 Agent 自行判断门禁。
pub fn summarize_ci_statuses(
statuses: &serde_json::Value,
target_sha: Option<&str>,
) -> DeliverySignal {
let Some(items) = statuses.as_array() else {
return DeliverySignal::new(
"unknown",
@ -435,30 +503,14 @@ pub fn summarize_ci_statuses(statuses: &serde_json::Value) -> DeliverySignal {
"unknown",
"gitea",
"当前 commit 暂无 CI 记录",
serde_json::json!({ "contexts": [] }),
serde_json::json!({ "target_sha": target_sha, "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 latest = latest_statuses_by_context(items);
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"],
})
})
.map(|(context, item)| status_context_summary(context, item))
.collect();
let states: Vec<String> = contexts
.iter()
@ -470,7 +522,12 @@ pub fn summarize_ci_statuses(statuses: &serde_json::Value) -> DeliverySignal {
.any(|state| state == "failure" || state == "error")
{
"failed"
} else if states.iter().all(|state| state == "success") {
} else if states.iter().all(|state| state == "skipped") {
"not_applicable"
} else if states
.iter()
.all(|state| state == "success" || state == "skipped")
{
"passed"
} else {
"pending"
@ -478,13 +535,75 @@ pub fn summarize_ci_statuses(statuses: &serde_json::Value) -> DeliverySignal {
let detail = match state {
"passed" => "所有最新 CI context 均成功",
"failed" => "至少一个最新 CI context 失败",
"not_applicable" => "所有最新 CI context 均未执行",
_ => "存在尚未成功的 CI context",
};
DeliverySignal::new(
state,
"gitea",
detail,
serde_json::json!({ "contexts": contexts }),
serde_json::json!({ "target_sha": target_sha, "contexts": contexts }),
)
}
/// 从同一批 commit statuses 中按项目声明的精确 context 提取部署过程信号。
///
/// 本函数不猜测 job 名,也不把普通 CI 成功当作部署成功;context 的所有权属于项目 profile。
/// P3-D 接入双证据聚合前先由 P3-A 固化并验证纯函数契约。
#[allow(dead_code)]
pub fn summarize_status_context(
statuses: &serde_json::Value,
target_sha: &str,
expected_context: &str,
) -> DeliverySignal {
let Some(items) = statuses.as_array() else {
return DeliverySignal::new(
"unknown",
"gitea",
"Gitea status 响应不是数组",
serde_json::json!({ "target_sha": target_sha, "expected_context": expected_context }),
);
};
let latest = latest_statuses_by_context(items);
let available_contexts: Vec<&str> = latest.keys().map(String::as_str).collect();
let Some(status) = latest.get(expected_context) else {
return DeliverySignal::new(
"unknown",
"gitea",
"目标 deployment context 尚无状态",
serde_json::json!({
"target_sha": target_sha,
"expected_context": expected_context,
"available_contexts": available_contexts,
}),
);
};
let raw_state = normalized_status_state(status);
let state = match raw_state.as_str() {
"success" => "success",
"failure" | "error" => "failure",
"pending" => "pending",
"warning" => "warning",
"skipped" => "skipped",
_ => "unknown",
};
let detail = match state {
"success" => "目标 deployment context 已成功",
"failure" => "目标 deployment context 已失败",
"pending" => "目标 deployment context 尚未结束",
"warning" => "目标 deployment context 带警告结束",
"skipped" => "目标 deployment context 未执行",
_ => "目标 deployment context 返回未知状态",
};
DeliverySignal::new(
state,
"gitea",
detail,
serde_json::json!({
"target_sha": target_sha,
"status": status_context_summary(expected_context, status),
"available_contexts": available_contexts,
}),
)
}
@ -1575,13 +1694,176 @@ mod tests {
#[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": "test",
"status": "failure",
"description": "old failure",
"target_url": "https://gitea.test/actions/runs/524/jobs/0",
"created_at": "2026-09-22T01:00:00Z",
"updated_at": "2026-09-22T01:01:00Z"
},
{
"context": "test",
"status": "success",
"description": "green",
"target_url": "https://gitea.test/actions/runs/525/jobs/0",
"created_at": "2026-09-22T02:00:00Z",
"updated_at": "2026-09-22T02:01:00Z"
},
{"context": "lint", "state": "failure", "description": "bad"}
]);
let summary = summarize_ci_statuses(&statuses);
let summary = summarize_ci_statuses(&statuses, Some("530888eb"));
assert_eq!(summary.state, "failed");
assert_eq!(summary.data["target_sha"], "530888eb");
assert_eq!(summary.data["contexts"].as_array().unwrap().len(), 2);
let test = summary.data["contexts"]
.as_array()
.unwrap()
.iter()
.find(|item| item["context"] == "test")
.unwrap();
assert_eq!(test["state"], "success");
assert_eq!(
test["target_url"],
"https://gitea.test/actions/runs/525/jobs/0"
);
assert_eq!(test["updated_at"], "2026-09-22T02:01:00Z");
}
#[test]
fn delivery_context_summary_uses_exact_context_and_preserves_evidence() {
// 字段形状来自 Gitea 1.22 CommitStatus schema;context/job 名与 target URL
// 来自 enterprise-system 的真实 run #525 页面,敏感 creator 信息不进入 fixture。
let statuses = serde_json::json!([
{
"context": "CI — DeepSeek Agent / agent (push)",
"status": "success",
"description": "success",
"target_url": "https://git.yljt.info/lanrtop/enterprise-system/actions/runs/525/jobs/0",
"created_at": "2026-09-22T03:35:00Z",
"updated_at": "2026-09-22T03:36:36Z"
},
{
"context": "CI — DeepSeek Agent / deploy-api (push)",
"status": "success",
"description": "success",
"target_url": "https://git.yljt.info/lanrtop/enterprise-system/actions/runs/525/jobs/2",
"created_at": "2026-09-22T03:35:00Z",
"updated_at": "2026-09-22T03:38:55Z"
}
]);
let signal = summarize_status_context(
&statuses,
"530888eb90983f21be4efa8191aae5711e03e2dc",
"CI — DeepSeek Agent / deploy-api (push)",
);
assert_eq!(signal.state, "success");
assert_eq!(
signal.data["target_sha"],
"530888eb90983f21be4efa8191aae5711e03e2dc"
);
assert_eq!(
signal.data["status"]["context"],
"CI — DeepSeek Agent / deploy-api (push)"
);
assert_eq!(
signal.data["status"]["target_url"],
"https://git.yljt.info/lanrtop/enterprise-system/actions/runs/525/jobs/2"
);
assert_eq!(signal.data["status"]["updated_at"], "2026-09-22T03:38:55Z");
}
#[test]
fn delivery_context_summary_normalizes_terminal_and_missing_states() {
for (raw_state, expected) in [
("pending", "pending"),
("failure", "failure"),
("error", "failure"),
("warning", "warning"),
("skipped", "skipped"),
] {
let statuses = serde_json::json!([
{"context": "deploy-api", "state": raw_state, "description": raw_state},
{"context": "agent", "status": "success"}
]);
let signal = summarize_status_context(&statuses, "abc", "deploy-api");
assert_eq!(signal.state, expected, "raw state={raw_state}");
}
let statuses = serde_json::json!([
{"context": "agent", "status": "success"}
]);
let missing = summarize_status_context(&statuses, "abc", "deploy-api");
assert_eq!(missing.state, "unknown");
assert_eq!(
missing.data["available_contexts"],
serde_json::json!(["agent"])
);
}
#[test]
fn gitea_1_22_skipped_job_is_not_deployment_success() {
// 脱敏自 enterprise-system 真实 run #529:Gitea 1.22.6 把 skipped job
// 编码为 status=success,仅通过 description 表达未执行,target_url 也是相对路径。
let statuses = serde_json::json!([
{
"context": "CI — DeepSeek Agent / deploy-api (push)",
"created_at": "2026-09-22T15:12:03+08:00",
"description": "Has been skipped",
"status": "success",
"target_url": "/lanrtop/enterprise-system/actions/runs/529/jobs/2",
"updated_at": "2026-09-22T15:12:03+08:00"
}
]);
let signal = summarize_status_context(
&statuses,
"a2e85f50b093cbd6af58a09eaa9a927849723724",
"CI — DeepSeek Agent / deploy-api (push)",
);
assert_eq!(signal.state, "skipped");
assert_eq!(signal.data["status"]["state"], "skipped");
assert_eq!(
signal.data["status"]["target_url"],
"/lanrtop/enterprise-system/actions/runs/529/jobs/2"
);
let ci = summarize_ci_statuses(&statuses, Some("a2e85f50b093cbd6af58a09eaa9a927849723724"));
assert_eq!(ci.state, "not_applicable");
assert_eq!(ci.data["contexts"][0]["state"], "skipped");
}
#[test]
fn delivery_ci_summary_compares_rfc3339_timestamps_across_offsets() {
let statuses = serde_json::json!([
{
"context": "test",
"status": "failure",
"description": "older despite later local calendar date",
"updated_at": "2026-09-22T02:00:00+09:00"
},
{
"context": "test",
"status": "success",
"description": "newer in UTC",
"updated_at": "2026-09-21T18:00:00Z"
}
]);
let summary = summarize_ci_statuses(&statuses, Some("abc"));
assert_eq!(summary.state, "passed");
assert_eq!(summary.data["contexts"][0]["description"], "newer in UTC");
}
#[test]
fn delivery_ci_summary_does_not_infer_skipped_from_unrelated_description() {
let statuses = serde_json::json!([{
"context": "test",
"status": "success",
"description": "success; no checks skipped",
"updated_at": "2026-09-22T10:00:00Z"
}]);
let summary = summarize_ci_statuses(&statuses, Some("abc"));
assert_eq!(summary.state, "passed");
assert_eq!(summary.data["contexts"][0]["state"], "success");
}
#[test]