From 0141b8b09acf32bfb2e93d791e6a3523da1453e9 Mon Sep 17 00:00:00 2001 From: lanrtop Date: Tue, 22 Sep 2026 19:54:43 +0900 Subject: [PATCH] =?UTF-8?q?feat(delivery/gitea):=20=E5=BD=92=E4=B8=80?= =?UTF-8?q?=E5=8C=96=20Gitea=20=E9=83=A8=E7=BD=B2=E7=8A=B6=E6=80=81?= =?UTF-8?q?=E4=BF=A1=E5=8F=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 保留目标 SHA、context、时间与跳转证据,按精确 context 提取部署结果,并修正 Gitea 1.22 skipped job 被编码为 success 的误判。同步落账 production 只读双证据方案与 P3-A 完成状态。 Rules-Applied: R07,R10,R13 Feature-Confirmed: true --- .blueprint/manifest.yaml | 2 +- .../project-capability-control-plane.md | 223 +++++++++++- src-tauri/src/commands/gitea.rs | 338 ++++++++++++++++-- 3 files changed, 517 insertions(+), 46 deletions(-) diff --git a/.blueprint/manifest.yaml b/.blueprint/manifest.yaml index f7bd702..d96f5a1 100644 --- a/.blueprint/manifest.yaml +++ b/.blueprint/manifest.yaml @@ -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 diff --git a/.blueprint/modules/project-capability-control-plane.md b/.blueprint/modules/project-capability-control-plane.md index 4e05008..6af080f 100644 --- a/.blueprint/modules/project-capability-control-plane.md +++ b/.blueprint/modules/project-capability-control-plane.md @@ -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": "", + "started_at": "" +} +``` + +`revision` 必须在 Docker build 时写入镜像(例如 build arg → image ENV),不能在启动脚本里总是注入“本次请求 SHA”;否则部署失败后回滚旧镜像时会把旧代码伪报成新 SHA。端点不得返回环境变量、主机信息、数据库状态或其他敏感内容。 + +### `get_delivery_status` 兼容演进 + +保留现有顶层 `DeliveryStatus` 结构,不新增破坏性字段;扩展 `deployment.data`: + +```json +{ + "environment": "production", + "target_revision": "", + "running_revision": "", + "deployed_at": "", + "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= 构建并启动 API 镜像后调用 `GET /health/version` → 200,body 仅含 ok/component/revision/started_at 且 revision=;切回以 构建的旧镜像 → revision=,证明回滚不会伪报新 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。" ## 迭代衡量 diff --git a/src-tauri/src/commands/gitea.rs b/src-tauri/src/commands/gitea.rs index 3e45888..9042cf4 100644 --- a/src-tauri/src/commands/gitea.rs +++ b/src-tauri/src/commands/gitea.rs @@ -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> { + 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 { + let mut latest: std::collections::BTreeMap = + 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 = - 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 = 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 = 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]