From e0d794b047ce50cfa263e2dccd910eb8a8196141 Mon Sep 17 00:00:00 2001 From: huanghongbo-hhb Date: Tue, 19 May 2026 18:11:12 +0800 Subject: [PATCH 01/33] feat: add release plan collaboration and versioned diffs Signed-off-by: huanghongbo-hhb --- ...e-plan-collaboration-and-versioned-diff.md | 411 ++++++++++++ .../common/repository/models/release_plan.go | 45 +- .../common/repository/mongodb/release_plan.go | 33 + .../repository/mongodb/release_plan_log.go | 53 +- .../mongodb/release_plan_version.go | 89 +++ .../core/release_plan/handler/release_plan.go | 81 +++ .../aslan/core/release_plan/handler/router.go | 4 + .../release_plan/service/collaboration.go | 517 +++++++++++++++ .../aslan/core/release_plan/service/diff.go | 616 ++++++++++++++++++ .../core/release_plan/service/masking.go | 199 ++++++ .../core/release_plan/service/openapi.go | 58 +- .../core/release_plan/service/release_plan.go | 261 ++++++-- .../release_plan/service/section_snapshot.go | 303 +++++++++ .../aslan/core/release_plan/service/update.go | 24 +- .../core/release_plan/service/version.go | 141 ++++ .../core/release_plan/service/watcher.go | 2 +- 16 files changed, 2751 insertions(+), 86 deletions(-) create mode 100644 community/rfc/2026-05-14-release-plan-collaboration-and-versioned-diff.md create mode 100644 pkg/microservice/aslan/core/common/repository/mongodb/release_plan_version.go create mode 100644 pkg/microservice/aslan/core/release_plan/service/collaboration.go create mode 100644 pkg/microservice/aslan/core/release_plan/service/diff.go create mode 100644 pkg/microservice/aslan/core/release_plan/service/masking.go create mode 100644 pkg/microservice/aslan/core/release_plan/service/section_snapshot.go create mode 100644 pkg/microservice/aslan/core/release_plan/service/version.go diff --git a/community/rfc/2026-05-14-release-plan-collaboration-and-versioned-diff.md b/community/rfc/2026-05-14-release-plan-collaboration-and-versioned-diff.md new file mode 100644 index 0000000000..98b4dba4a2 --- /dev/null +++ b/community/rfc/2026-05-14-release-plan-collaboration-and-versioned-diff.md @@ -0,0 +1,411 @@ +# 发布计划多人协作与版本化变更展示方案 + +- 作者:KodeRover +- 关联 Issue:TBD +- 日期:2026-05-14 +- 评审人:TBD +- 评审状态:pending + +## 目标 + +这次方案同时解决发布计划的两个需求: + +- 多人协作编辑:用户编辑发布计划时,可以看到有哪些人正在编辑哪些内容。 +- 操作记录细化:用户查看发布计划操作记录时,可以看到这次保存具体改了哪些工作流任务参数。 +- 版本记录:每次配置保存后记录一个版本,版本里只保存这次编辑区块的输入参数快照,后续查看“这次改了什么”时,用这次编辑的前后快照做对比。 + +## 一句话方案 + +不做强锁,也不阻止多人同时编辑。用户编辑时,前端实时展示“谁正在编辑什么”;用户保存后,后端记录一个“编辑区块输入参数版本”;用户点开操作记录详情时,后端比较这次编辑区块的前后快照,把变化整理成按发布内容和工作流任务分组的可读详情。 + +## 用户能看到什么 + +### 正在编辑提示 + +用户进入发布计划详情页后,不会默认显示“正在编辑”。只有当某个用户真正点击某块内容的编辑入口后,其他用户才会看到提示。 + +第一版建议展示这些编辑区块: + +- 基础信息:名称、负责人、发布窗口、定时执行、需求关联。 +- 审批配置。 +- 某一个发布内容。 + +示例: + +```text +huanghongbo 正在编辑基础信息 +patrick 正在编辑发布内容 log-test +2 人正在编辑审批配置 +``` + +### 操作记录详情 + +操作记录列表仍然先展示一句摘要: + +```text +huanghongbo 更新发布内容 log-test +``` + +用户点开详情后,再展示这次保存具体改了什么: + +```text +发布内容:log-test + +构建任务:build +- 代码分支:main -> release/202605 +- 镜像标签:v1.2.3 -> v1.2.4 + +Apollo 任务:update-config +- 命名空间:application -> application-prod +- DB_HOST:10.0.0.1 -> 10.0.0.2 + +DMS 任务:data-change +- SQL 内容:已变更 +``` + +大文本内容,比如脚本、SQL、大段 YAML 或 JSON,第一版默认只展示“已变更”,不在普通操作记录里展开全文。 + +敏感字段沿用工作流本身已有的敏感变量配置,例如 keyvault 的 `is_sensitive` 和工作流变量里的 `is_credential`,只展示“已变更”,不返回原始值。 + +## 前端需要支持什么 + +### 进入编辑态 + +- 用户打开发布计划详情页时,只建立 WebSocket 连接,不立即进入编辑态。 +- 用户点击某个编辑入口后,前端再告诉后端“我正在编辑这一块”。 +- 编辑区块建议和后端保持一致:`metadata`、`approval`、`job:`。 +- 页面上哪些状态可以编辑、哪些入口显示,由前端根据发布计划状态和权限判断;后端收到请求时会再做一次校验。 + +### 维护编辑会话 + +前端需要在一次区块编辑期间维护同一个 `session_id`: + +- 用户进入某个编辑区块时生成或获取一个 `session_id`。 +- 该编辑区块里的所有保存请求都带同一个 `session_id`。 +- 每 10 到 15 秒发送一次心跳,告诉后端“我还在编辑”。 +- 用户取消编辑、关闭弹窗、保存完成或切换编辑区块时,通知后端离开当前编辑态。 + +### 保存配置 + +现有 `verb + spec` 保存方式继续保留。前端在调用保存接口时,需要额外带上 `session_id`: + +```json +{ + "verb": "update_release_job", + "spec": {}, + "session_id": "uuid" +} +``` + +这样后端可以知道这几次 `verb` 保存属于同一次区块编辑。 + +### 提交版本 + +如果采用“按编辑区块合并版本”的方式,前端需要在一次区块编辑完成时调用版本提交接口: + +```json +{ + "session_id": "uuid", + "section_key": "job:job-id" +} +``` + +建议触发时机: + +- 用户点击编辑弹窗里的“保存”或“确定”。 +- 用户关闭编辑弹窗时,如果已经有变更,也需要触发提交。 +- 用户切换到另一个编辑区块前,如果当前区块已有变更,也需要先提交当前区块。 + +这样可以避免一个区块里多次 `verb` 保存生成多个版本。 + +### 展示版本差异 + +操作记录列表仍然先展示摘要。用户点开某条操作记录详情时: + +- 如果该记录包含 `from_version` 和 `to_version`,前端调用版本差异接口。 +- 前端按返回的 `groups` 渲染变更详情。 +- 大文本字段如果标记为 `large_text`,默认展示“已变更”。 +- 敏感字段如果在原始配置里标记为敏感变量,只展示“已变更”,不展示原始值。 +- 如果历史记录没有版本信息,前端继续按旧展示方式处理。 + +## 后端需要支持什么 + +### 实时协作编辑态 + +后端为每个浏览器编辑会话维护一条编辑记录。建议第一版的编辑粒度按内容块划分: + +- `metadata`:基础信息,比如名称、负责人、发布窗口、定时执行、需求关联。 +- `approval`:审批配置。 +- `job:`:某一个发布内容。 + +编辑记录建议包含: + +```go +type ReleasePlanEditingSession struct { + PlanID string `json:"plan_id"` + SessionID string `json:"session_id"` + UserID string `json:"user_id"` + UserName string `json:"user_name"` + Account string `json:"account"` + IdentityType string `json:"identity_type,omitempty"` + Avatar string `json:"avatar,omitempty"` + SectionKey string `json:"section_key"` + SectionType string `json:"section_type"` + SectionName string `json:"section_name"` + BaseVersion int64 `json:"base_version"` + EditingStartedAt int64 `json:"editing_started_at"` + LastHeartbeatAt int64 `json:"last_heartbeat_at"` +} +``` + +说明: + +- `BaseVersion` 表示用户开始编辑时看到的发布计划版本。第一版只用于前端提示,不用于阻断保存。 +- `SectionType` 表示正在编辑哪类内容,前端可以直接判断这是基础信息、审批还是发布内容。 +- `IdentityType` 和 `Avatar` 是可选增强字段,便于前端直接展示“谁正在编辑”。 +- `EditingStartedAt` 表示真正进入编辑态的时间,不等同于页面建立连接的时间。 + +编辑态使用 Redis 保存,并设置自动过期时间。这样即使浏览器异常关闭、断网、服务端连接断开,编辑态也会自动消失,不会一直显示“某人在编辑”。 + +如果 Aslan 是多副本部署,需要用 Redis 做一次“跨 Pod 通知”。某个 Pod 收到用户编辑态变化后,先写 Redis,再发一条 Redis 消息;其他 Pod 收到这条消息后,再推送给自己本地持有的 WebSocket 连接。这样连接在不同 Pod 上的用户也能互相看到编辑状态。 + +`hook` 外部系统配置本身不纳入第一版多人协作提示范围。外部 `hook` 触发后,发布计划可能进入“外部检测”阶段,这个阶段是否还能编辑,由前端决定是否展示编辑入口;后端只负责兜底校验。 + +### 保存与版本化 + +当前后端已经有统一的配置更新接口: + +- `PUT /api/release_plan/v1/:id` +- 请求体使用 `verb + spec` 表示“这次改的是哪一块内容” + +这一套机制建议继续保留,不需要为了版本化把它推翻重做。原因有三个: + +- 现有权限判断就是按 `verb` 分开的。 +- 现有操作记录也是按 `verb` 生成摘要。 +- 第一版版本化只需要挂在“配置保存成功”这个时机上,不要求接口形态改变。 + +保存规则保持“最后保存生效”: + +- 不加硬锁。 +- 不因为其他人正在编辑就拒绝保存。 +- 多人保存同一块内容时,以最后一次成功保存的结果为准。 + +### 版本和保存次数 + +这里需要和前端约定清楚: + +- 版本是按“成功保存一次配置”生成的,不是按“产生一条操作记录”生成的。 +- 配置类保存包括:名称、负责人、时间窗口、审批、发布内容增删改排等。 +- 流程类动作不生成配置版本,比如:状态流转、审批通过/拒绝、执行、重试、跳过、外部检测回调。 + +如果当前前端交互是“用户改一块、点一次保存、发一个 `verb` 请求”,那就自然是一条配置保存对应一个版本。 + +如果采用“按编辑区块合并版本”,则同一个 `session_id` 下的多次 `verb` 保存,最终合并成一个版本。 + +如果后面前端希望改成“整页统一保存”: + +- 可以把多个改动合并后一次提交。 +- 后端一次性应用这些改动。 +- 最终只生成一个版本。 + +这属于前端交互方式变化,不影响版本模型本身。第一版可以先兼容现有单 `verb` 保存模式,后续如果真的需要整页保存,再单独补一个批量保存接口。 + +### 版本生成时机 + +- 用户点击保存配置时,生成新的配置版本。 +- 审批通过、审批拒绝、进入执行、执行完成、外部检测等自动流转,默认只记录状态事件,不生成新的配置版本。 +- 如果未来某个系统流程会直接改动发布计划配置本身,再单独评估是否补充“系统生成版本”。 + +### 发布计划版本模型 + +新增发布计划版本集合。这里不保存完整发布计划,而是只保存“这次编辑区块的输入参数快照”。建议模型: + +```go +type ReleasePlanVersion struct { + ID primitive.ObjectID `bson:"_id,omitempty" json:"id"` + PlanID string `bson:"plan_id" json:"plan_id"` + BaseVersion int64 `bson:"base_version,omitempty" json:"base_version,omitempty"` + Version int64 `bson:"version" json:"version"` + Operator string `bson:"operator" json:"operator"` + Account string `bson:"account" json:"account"` + SectionKey string `bson:"section_key" json:"section_key"` + SectionName string `bson:"section_name" json:"section_name"` + Verb string `bson:"verb" json:"verb"` + BaseSnapshot interface{} `bson:"base_snapshot,omitempty" json:"base_snapshot,omitempty"` + Snapshot interface{} `bson:"snapshot" json:"snapshot"` + CreatedAt int64 `bson:"created_at" json:"created_at"` +} +``` + +说明: + +- `SectionKey` 表示这个版本对应哪个编辑区块,例如 `metadata`、`approval`、`job:`。 +- `BaseVersion` 表示这次编辑会话开始时看到的版本号。 +- `BaseSnapshot` 表示该区块开始编辑时的输入参数快照。 +- `Snapshot` 表示该区块保存完成后的输入参数快照。 + +快照里只保留输入参数,不保留运行态和执行态字段。例如: + +- 基础信息版本只保留名称、负责人、时间窗口、定时执行、需求关联、Jira 关联等输入项。 +- 审批版本只保留审批配置输入,不保留审批实例运行状态。 +- 发布内容版本只保留该发布内容的输入参数,不保留 `status`、`task_id`、`executed_by`、`executed_time` 这类运行字段。 + +这样可以避免因为单条发布计划过大导致版本体积和 diff 计算成本失控。 + +发布计划主表中也建议增加当前版本号: + +```go +Version int64 `bson:"version" json:"version"` +``` + +历史发布计划默认版本可以是 `0`。升级后第一次保存生成 `version = 1`。 + +### 操作日志关联版本 + +现有发布计划操作日志需要关联版本。建议给 `ReleasePlanLog` 增加: + +```go +FromVersion int64 `bson:"from_version,omitempty" json:"from_version,omitempty"` +ToVersion int64 `bson:"to_version,omitempty" json:"to_version,omitempty"` +``` + +操作记录列表仍然保持简洁,例如: + +```text +2026-05-14 10:00 huanghongbo 更新发布内容 log-test v12 -> v13 +``` + +用户点击详情时,前端使用 `plan_id`、`from_version`、`to_version` 请求版本差异。 + +这里的 `from_version` 不一定总是“上一个版本”。它表示这次编辑会话开始时看到的版本。 + +例如: + +- A 基于 `v1` 开始编辑某个发布内容。 +- B 先保存,生成 `v2`。 +- A 继续编辑后再保存,生成 `v3`。 + +那 A 这条操作记录应该关联: + +- `from_version = 1` +- `to_version = 3` + +这样点开详情时,看到的是这次编辑会话对应的区块变更区间,而不是简单的 `v2 -> v3`。 + +这里的版本和状态事件职责分开: + +- 版本主要回答“这次保存后的配置是什么”。 +- 操作日志和状态日志继续回答“发布计划后来经历了什么流转”。 +- 即使自动流转不生成版本,用户仍然可以从最近一次配置版本里查看当时保存下来的区块输入参数。 + +## 变更计算 + +变更计算负责比较一次编辑区块版本的前后快照。设计上先保证所有输入参数变化都能被找出来,再把结果整理成用户能看懂的字段名和分组。 + +第一版建议在用户查看详情时再计算变更内容,而不是在保存时就提前算好: + +- 保存成功时,只负责保存新版本、写操作日志、广播版本变更。 +- 用户点击某条操作记录详情时,后端再根据 `from_version` 和 `to_version` 读取该次编辑区块的前后快照并计算变更内容。 +- 第一版先不做变更结果缓存;如果后续确认详情打开比较慢,再单独评估缓存或后台提前计算。 + +处理流程: + +1. 读取 `to_version` 对应版本里的 `BaseSnapshot` 和 `Snapshot`。 +2. 从外到内逐层比较字段。 +3. 数组优先按“能代表这个元素身份的字段”匹配。 +4. 生成基础差异项。 +5. 对差异项做脱敏、分组和字段翻译。 + +### 对比前的数据整理 + +由于版本里保存的是区块输入参数快照,真正对比之前只需要再过滤少量不适合展示的字段,例如敏感信息和大文本字段,不需要再从整份运行对象里剔除执行状态。 + +### 数组匹配 + +数组不能总是按下标比较。能识别元素身份的数组,应该按身份字段对齐: + +```text +发布内容:id +工作流阶段:name +工作流任务:name + type +工作流参数:name +代码仓库:source + repo_namespace + repo_name + remote_name +构建服务:service_name + service_module +部署服务:service_name + service_module +key/value 数组:key +``` + +如果某类数组没有明确的身份字段,再退回按下标比较。 + +### 字段规则管理 + +字段中文名、忽略哪些字段、哪些字段需要脱敏,这些规则可以放在一张统一的规则表里管理。实现上可以用普通的路径映射表;如果规则特别多,再考虑用 `trie` 这种“按字段路径快速查规则”的结构。 + +这里的 `trie` 只适合做“某个字段路径该怎么处理”的查找,不适合拿来做两个版本内容是否有差异的核心计算。真正找出变化的步骤,还是靠前面的逐层比较。 + +## API 变更 + +### 协作态 + +```http +GET /api/aslan/release_plan/v1/:id/collaboration/ws +GET /api/aslan/release_plan/v1/:id/collaboration/editors +``` + +`editors` 用于页面初始化和 WebSocket 断线重连后的状态恢复。 + +### 版本 + +```http +GET /api/aslan/release_plan/v1/:id/versions +GET /api/aslan/release_plan/v1/:id/versions/:version +GET /api/aslan/release_plan/v1/:id/versions/:from/diff?to=:to +POST /api/aslan/release_plan/v1/:id/versions/commit +``` + +`commit` 接口用于告诉后端“这次区块编辑结束了,可以把这组变更合成一个版本”。 + +### 操作日志 + +现有日志接口保持不变,但返回值增加版本信息: + +```http +GET /api/aslan/release_plan/v1/:id/logs +``` + +每条日志可以包含 `from_version`、`to_version`。 + +## 向后兼容 + +- 现有发布计划 API 保存语义不变。 +- 历史发布计划默认版本号为 `0`。 +- 没有版本信息的历史操作日志仍按旧格式展示。 +- 升级后第一次保存生成第一个版本。 +- 版本差异展示是新增能力,不影响现有保存流程。 + +## 性能考虑 + +- 版本只保存编辑区块的输入参数快照,不保存整份发布计划运行对象。 +- 数组先按身份字段对齐后再比较,避免两两查找导致耗时变长。 +- 大文本字段在普通响应里只标记“已变更”,不做全文对比。 +- 如果一个内容块本身完全没变,就直接跳过,不继续往下比。 +- 用户点开操作详情时再计算变更内容,避免把比较耗时放到保存流程里。 +- 后续如果遇到大对象性能问题,可以先比较每个工作流任务输入参数是否变化;没变化的任务就不继续往下比。 +- 第一版先不做结果缓存,等确认真的有明显性能压力,再考虑补。 + +## 安全与隐私 + +- 敏感字段在返回前必须脱敏,脱敏依据沿用工作流自身已经配置好的敏感变量标记,不额外定义审批人、手机号之类的特殊字段规则。 +- 脱敏规则在生成基础差异项后、返回给前端前执行。 +- 不通过版本差异接口暴露敏感变量原始值。 +- WebSocket 接口需要复用发布计划查看/编辑权限校验。 + +## 实施拆分 + +虽然产品目标是一口气交付完整体验,工程实现仍建议按模块拆: + +1. 增加版本模型,并在保存成功后记录编辑区块的输入参数快照。 +2. 增加 WebSocket 协作态,使用 Redis 自动过期和跨 Pod 通知。 +3. 增加变更计算能力,支持数组按身份字段匹配和敏感字段脱敏。 +4. 增加翻译后的变更详情返回结构,并和操作日志关联。 +5. 首版尽量一次性覆盖已知字段中文标签,未知字段用处理后的字段路径兜底展示,但不改变核心返回格式。 diff --git a/pkg/microservice/aslan/core/common/repository/models/release_plan.go b/pkg/microservice/aslan/core/common/repository/models/release_plan.go index 7cdc25ed7f..f873e385ad 100644 --- a/pkg/microservice/aslan/core/common/repository/models/release_plan.go +++ b/pkg/microservice/aslan/core/common/repository/models/release_plan.go @@ -25,6 +25,7 @@ import ( type ReleasePlan struct { ID primitive.ObjectID `bson:"_id,omitempty" yaml:"-" json:"id"` Index int64 `bson:"index" yaml:"index" json:"index"` + Version int64 `bson:"version" yaml:"version" json:"version"` Name string `bson:"name" yaml:"name" json:"name"` Manager string `bson:"manager" yaml:"manager" json:"manager"` // ManagerID is the user id of the manager @@ -120,19 +121,41 @@ type WorkflowReleaseJobSpec struct { } type ReleasePlanLog struct { - ID primitive.ObjectID `bson:"_id,omitempty" json:"id"` - PlanID string `bson:"plan_id" json:"plan_id"` - Username string `bson:"username" json:"username"` - Account string `bson:"account" json:"account"` - Verb string `bson:"verb" json:"verb"` - TargetName string `bson:"target_name" json:"target_name"` - TargetType string `bson:"target_type" json:"target_type"` - Before interface{} `bson:"before" json:"before"` - After interface{} `bson:"after" json:"after"` - Detail string `bson:"detail" json:"detail"` - CreatedAt int64 `bson:"created_at" json:"created_at"` + ID primitive.ObjectID `bson:"_id,omitempty" json:"id"` + PlanID string `bson:"plan_id" json:"plan_id"` + SessionID string `bson:"session_id,omitempty" json:"session_id,omitempty"` + Username string `bson:"username" json:"username"` + Account string `bson:"account" json:"account"` + Verb string `bson:"verb" json:"verb"` + TargetName string `bson:"target_name" json:"target_name"` + TargetType string `bson:"target_type" json:"target_type"` + Before interface{} `bson:"before" json:"before"` + After interface{} `bson:"after" json:"after"` + Detail string `bson:"detail" json:"detail"` + FromVersion int64 `bson:"from_version,omitempty" json:"from_version,omitempty"` + ToVersion int64 `bson:"to_version,omitempty" json:"to_version,omitempty"` + CreatedAt int64 `bson:"created_at" json:"created_at"` } func (ReleasePlanLog) TableName() string { return "release_plan_log" } + +type ReleasePlanVersion struct { + ID primitive.ObjectID `bson:"_id,omitempty" json:"id"` + PlanID string `bson:"plan_id" json:"plan_id"` + BaseVersion int64 `bson:"base_version,omitempty" json:"base_version,omitempty"` + Version int64 `bson:"version" json:"version"` + Operator string `bson:"operator" json:"operator"` + Account string `bson:"account" json:"account"` + SectionKey string `bson:"section_key,omitempty" json:"section_key,omitempty"` + SectionName string `bson:"section_name,omitempty" json:"section_name,omitempty"` + Verb string `bson:"verb,omitempty" json:"verb,omitempty"` + BaseSnapshot interface{} `bson:"base_snapshot,omitempty" json:"base_snapshot,omitempty"` + Snapshot interface{} `bson:"snapshot" json:"snapshot"` + CreatedAt int64 `bson:"created_at" json:"created_at"` +} + +func (ReleasePlanVersion) TableName() string { + return "release_plan_version" +} diff --git a/pkg/microservice/aslan/core/common/repository/mongodb/release_plan.go b/pkg/microservice/aslan/core/common/repository/mongodb/release_plan.go index 6c4bb0d328..cf9c37cc7f 100644 --- a/pkg/microservice/aslan/core/common/repository/mongodb/release_plan.go +++ b/pkg/microservice/aslan/core/common/repository/mongodb/release_plan.go @@ -79,6 +79,10 @@ func (c *ReleasePlanColl) EnsureIndex(ctx context.Context) error { Keys: bson.M{"update_time": 1}, Options: options.Index().SetUnique(false), }, + { + Keys: bson.M{"version": 1}, + Options: options.Index().SetUnique(false), + }, } _, err := c.Indexes().CreateMany(ctx, mod, mongotool.CreateIndexOptions(ctx)) @@ -121,6 +125,35 @@ func (c *ReleasePlanColl) UpdateByID(ctx context.Context, idString string, args return err } +func (c *ReleasePlanColl) UpdateVersionByID(ctx context.Context, idString string, version int64) error { + id, err := primitive.ObjectIDFromHex(idString) + if err != nil { + return fmt.Errorf("invalid id") + } + + query := bson.M{"_id": id} + change := bson.M{"$set": bson.M{"version": version}} + _, err = c.UpdateOne(ctx, query, change) + return err +} + +func (c *ReleasePlanColl) IncrementVersionByID(ctx context.Context, idString string) (int64, error) { + id, err := primitive.ObjectIDFromHex(idString) + if err != nil { + return 0, fmt.Errorf("invalid id") + } + + query := bson.M{"_id": id} + change := bson.M{"$inc": bson.M{"version": 1}} + opts := options.FindOneAndUpdate().SetReturnDocument(options.After) + + result := new(models.ReleasePlan) + if err := c.FindOneAndUpdate(ctx, query, change, opts).Decode(result); err != nil { + return 0, err + } + return result.Version, nil +} + func (c *ReleasePlanColl) DeleteByID(ctx context.Context, idString string) error { id, err := primitive.ObjectIDFromHex(idString) if err != nil { diff --git a/pkg/microservice/aslan/core/common/repository/mongodb/release_plan_log.go b/pkg/microservice/aslan/core/common/repository/mongodb/release_plan_log.go index c0e9f8d7e9..760c9c3739 100644 --- a/pkg/microservice/aslan/core/common/repository/mongodb/release_plan_log.go +++ b/pkg/microservice/aslan/core/common/repository/mongodb/release_plan_log.go @@ -48,7 +48,17 @@ func (c *ReleasePlanLogColl) GetCollectionName() string { } func (c *ReleasePlanLogColl) EnsureIndex(ctx context.Context) error { - return nil + mod := []mongo.IndexModel{ + { + Keys: bson.D{{Key: "plan_id", Value: 1}, {Key: "created_at", Value: -1}}, + }, + { + Keys: bson.D{{Key: "session_id", Value: 1}}, + }, + } + + _, err := c.Indexes().CreateMany(ctx, mod, mongotool.CreateIndexOptions(ctx)) + return err } func (c *ReleasePlanLogColl) Create(args *models.ReleasePlanLog) error { @@ -76,7 +86,7 @@ func (c *ReleasePlanLogColl) ListByOptions(opt *ListReleasePlanLogOption) ([]*mo ctx := context.Background() opts := options.Find() if opt.IsSort { - opts.SetSort(bson.D{{"create_time", -1}}) + opts.SetSort(bson.D{{"created_at", -1}}) } if opt.PlanID != "" { query["plan_id"] = opt.PlanID @@ -94,3 +104,42 @@ func (c *ReleasePlanLogColl) ListByOptions(opt *ListReleasePlanLogOption) ([]*mo return resp, nil } + +func (c *ReleasePlanLogColl) FillVersionsBySessionID(planID, sessionID string, fromVersion, toVersion int64) error { + if sessionID == "" { + return errors.New("empty session id") + } + + query := bson.M{ + "plan_id": planID, + "session_id": sessionID, + "$or": []bson.M{ + {"to_version": bson.M{"$exists": false}}, + {"to_version": 0}, + }, + } + change := bson.M{"$set": bson.M{ + "from_version": fromVersion, + "to_version": toVersion, + }} + + _, err := c.UpdateMany(context.Background(), query, change) + return err +} + +func (c *ReleasePlanLogColl) CountPendingBySessionID(planID, sessionID string) (int64, error) { + if sessionID == "" { + return 0, errors.New("empty session id") + } + + query := bson.M{ + "plan_id": planID, + "session_id": sessionID, + "$or": []bson.M{ + {"to_version": bson.M{"$exists": false}}, + {"to_version": 0}, + }, + } + + return c.CountDocuments(context.Background(), query) +} diff --git a/pkg/microservice/aslan/core/common/repository/mongodb/release_plan_version.go b/pkg/microservice/aslan/core/common/repository/mongodb/release_plan_version.go new file mode 100644 index 0000000000..5d90bf4e20 --- /dev/null +++ b/pkg/microservice/aslan/core/common/repository/mongodb/release_plan_version.go @@ -0,0 +1,89 @@ +/* + * Copyright 2026 The KodeRover Authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package mongodb + +import ( + "context" + + "github.com/pkg/errors" + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" + + "github.com/koderover/zadig/v2/pkg/microservice/aslan/config" + "github.com/koderover/zadig/v2/pkg/microservice/aslan/core/common/repository/models" + mongotool "github.com/koderover/zadig/v2/pkg/tool/mongo" +) + +type ReleasePlanVersionColl struct { + *mongo.Collection + + coll string +} + +func NewReleasePlanVersionColl() *ReleasePlanVersionColl { + name := models.ReleasePlanVersion{}.TableName() + return &ReleasePlanVersionColl{ + Collection: mongotool.Database(config.MongoDatabase()).Collection(name), + coll: name, + } +} + +func (c *ReleasePlanVersionColl) GetCollectionName() string { + return c.coll +} + +func (c *ReleasePlanVersionColl) EnsureIndex(ctx context.Context) error { + mod := []mongo.IndexModel{ + { + Keys: bson.D{{Key: "plan_id", Value: 1}, {Key: "version", Value: 1}}, + Options: options.Index().SetUnique(true), + }, + { + Keys: bson.D{{Key: "plan_id", Value: 1}, {Key: "created_at", Value: -1}}, + }, + } + + _, err := c.Indexes().CreateMany(ctx, mod, mongotool.CreateIndexOptions(ctx)) + return err +} + +func (c *ReleasePlanVersionColl) Create(args *models.ReleasePlanVersion) error { + if args == nil { + return errors.New("nil ReleasePlanVersion") + } + + _, err := c.InsertOne(context.Background(), args) + return err +} + +func (c *ReleasePlanVersionColl) Get(planID string, version int64) (*models.ReleasePlanVersion, error) { + resp := new(models.ReleasePlanVersion) + err := c.FindOne(context.Background(), bson.M{ + "plan_id": planID, + "version": version, + }).Decode(resp) + return resp, err +} + +func (c *ReleasePlanVersionColl) GetLatest(planID string) (*models.ReleasePlanVersion, error) { + resp := new(models.ReleasePlanVersion) + err := c.FindOne(context.Background(), bson.M{ + "plan_id": planID, + }, options.FindOne().SetSort(bson.D{{Key: "version", Value: -1}})).Decode(resp) + return resp, err +} diff --git a/pkg/microservice/aslan/core/release_plan/handler/release_plan.go b/pkg/microservice/aslan/core/release_plan/handler/release_plan.go index e8ea19963d..5471eea658 100644 --- a/pkg/microservice/aslan/core/release_plan/handler/release_plan.go +++ b/pkg/microservice/aslan/core/release_plan/handler/release_plan.go @@ -19,6 +19,7 @@ package handler import ( "fmt" "strings" + "strconv" "github.com/gin-gonic/gin" @@ -78,6 +79,56 @@ func GetReleasePlanLogs(c *gin.Context) { ctx.Resp, ctx.RespErr = service.GetReleasePlanLogs(c.Param("id")) } +func GetReleasePlanCollaborationEditors(c *gin.Context) { + ctx, err := internalhandler.NewContextWithAuthorization(c) + defer func() { internalhandler.JSONResponse(c, ctx) }() + + if err != nil { + ctx.Logger.Errorf("failed to generate authorization info for user: %s, error: %s", ctx.UserID, err) + ctx.RespErr = fmt.Errorf("authorization Info Generation failed: err %s", err) + ctx.UnAuthorized = true + return + } + + if !ctx.Resources.IsSystemAdmin && !ctx.Resources.SystemActions.ReleasePlan.View { + ctx.UnAuthorized = true + return + } + + err = commonutil.CheckZadigEnterpriseLicense() + if err != nil { + ctx.RespErr = err + return + } + + ctx.Resp, ctx.RespErr = service.GetReleasePlanCollaborationEditors(c.Param("id")) +} + +func ReleasePlanCollaborationWS(c *gin.Context) { + ctx, err := internalhandler.NewContextWithAuthorization(c) + defer func() { internalhandler.JSONResponse(c, ctx) }() + + if err != nil { + ctx.Logger.Errorf("failed to generate authorization info for user: %s, error: %s", ctx.UserID, err) + ctx.RespErr = fmt.Errorf("authorization Info Generation failed: err %s", err) + ctx.UnAuthorized = true + return + } + + if !ctx.Resources.IsSystemAdmin && !ctx.Resources.SystemActions.ReleasePlan.View { + ctx.UnAuthorized = true + return + } + + err = commonutil.CheckZadigEnterpriseLicense() + if err != nil { + ctx.RespErr = err + return + } + + ctx.RespErr = service.OpenReleasePlanCollaborationWS(c, ctx, c.Param("id")) +} + func CreateReleasePlan(c *gin.Context) { ctx, err := internalhandler.NewContextWithAuthorization(c) defer func() { internalhandler.JSONResponse(c, ctx) }() @@ -189,6 +240,36 @@ func UpdateReleasePlan(c *gin.Context) { ctx.RespErr = service.UpdateReleasePlan(ctx, c.Param("id"), req) } +func GetReleasePlanVersionDiff(c *gin.Context) { + ctx, err := internalhandler.NewContextWithAuthorization(c) + defer func() { internalhandler.JSONResponse(c, ctx) }() + + if err != nil { + ctx.RespErr = fmt.Errorf("authorization Info Generation failed: err %s", err) + ctx.UnAuthorized = true + return + } + + if !ctx.Resources.IsSystemAdmin && !ctx.Resources.SystemActions.ReleasePlan.View { + ctx.UnAuthorized = true + return + } + + err = commonutil.CheckZadigEnterpriseLicense() + if err != nil { + ctx.RespErr = err + return + } + + version, err := strconv.ParseInt(c.Param("version"), 10, 64) + if err != nil { + ctx.RespErr = e.ErrInvalidParam.AddDesc(err.Error()) + return + } + + ctx.Resp, ctx.RespErr = service.GetReleasePlanVersionDiff(c.Param("id"), version) +} + func GetReleasePlanJobDetail(c *gin.Context) { ctx, err := internalhandler.NewContextWithAuthorization(c) defer func() { internalhandler.JSONResponse(c, ctx) }() diff --git a/pkg/microservice/aslan/core/release_plan/handler/router.go b/pkg/microservice/aslan/core/release_plan/handler/router.go index f75f4aefc4..e06142d253 100644 --- a/pkg/microservice/aslan/core/release_plan/handler/router.go +++ b/pkg/microservice/aslan/core/release_plan/handler/router.go @@ -28,7 +28,11 @@ func (*Router) Inject(router *gin.RouterGroup) { v1.POST("/:id/copy", CopyReleasePlan) v1.GET("/:id", GetReleasePlan) v1.GET("/:id/logs", GetReleasePlanLogs) + v1.GET("/:id/collaboration/editors", GetReleasePlanCollaborationEditors) + v1.GET("/:id/collaboration/ws", ReleasePlanCollaborationWS) v1.PUT("/:id", UpdateReleasePlan) + v1.POST("/:id/versions/commit", CommitReleasePlanVersion) + v1.GET("/:id/versions/:fromVersion/:toVersion/diff", GetReleasePlanVersionDiff) v1.GET("/:id/job/:jobID", GetReleasePlanJobDetail) v1.DELETE("/:id", DeleteReleasePlan) diff --git a/pkg/microservice/aslan/core/release_plan/service/collaboration.go b/pkg/microservice/aslan/core/release_plan/service/collaboration.go new file mode 100644 index 0000000000..2f6948e604 --- /dev/null +++ b/pkg/microservice/aslan/core/release_plan/service/collaboration.go @@ -0,0 +1,517 @@ +/* + * Copyright 2026 The KodeRover Authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package service + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "sort" + "strings" + "sync" + "time" + + "github.com/gin-gonic/gin" + "github.com/gorilla/websocket" + "github.com/pkg/errors" + + configbase "github.com/koderover/zadig/v2/pkg/config" + "github.com/koderover/zadig/v2/pkg/microservice/aslan/config" + "github.com/koderover/zadig/v2/pkg/microservice/aslan/core/common/repository/models" + "github.com/koderover/zadig/v2/pkg/microservice/aslan/core/common/repository/mongodb" + "github.com/koderover/zadig/v2/pkg/shared/handler" + "github.com/koderover/zadig/v2/pkg/tool/cache" + e "github.com/koderover/zadig/v2/pkg/tool/errors" + "github.com/koderover/zadig/v2/pkg/tool/log" + "github.com/koderover/zadig/v2/pkg/util" +) + +const ( + releasePlanCollabSessionKeyPrefix = "release-plan:collab:session:" + releasePlanCollabPlanSetPrefix = "release-plan:collab:plan:" + releasePlanCollabBroadcastChannel = "release-plan-collaboration" + releasePlanCollabSessionTTL = 45 * time.Second + releasePlanCollabBroadcastTTL = 5 * time.Minute +) + +var upgrader = websocket.Upgrader{ + ReadBufferSize: 1024, + WriteBufferSize: 1024, + CheckOrigin: func(r *http.Request) bool { + return true + }, +} + +type ReleasePlanEditingSession struct { + PlanID string `json:"plan_id"` + SessionID string `json:"session_id"` + UserID string `json:"user_id"` + UserName string `json:"user_name"` + Account string `json:"account"` + IdentityType string `json:"identity_type,omitempty"` + Avatar string `json:"avatar,omitempty"` + SectionKey string `json:"section_key"` + SectionType string `json:"section_type"` + SectionName string `json:"section_name"` + BaseVersion int64 `json:"base_version"` + BaseSnapshot string `json:"base_snapshot,omitempty"` + EditingStartedAt int64 `json:"editing_started_at"` + LastHeartbeatAt int64 `json:"last_heartbeat_at"` +} + +type ReleasePlanCollaborationGroup struct { + SectionKey string `json:"section_key"` + SectionType string `json:"section_type"` + SectionName string `json:"section_name"` + Editors []*ReleasePlanEditingSession `json:"editors"` +} + +type ReleasePlanCollaborationSnapshot struct { + PlanID string `json:"plan_id"` + PlanVersion int64 `json:"plan_version"` + Groups []*ReleasePlanCollaborationGroup `json:"groups"` +} + +type releasePlanCollabWSMessage struct { + Type string `json:"type"` + SessionID string `json:"session_id,omitempty"` + SectionKey string `json:"section_key,omitempty"` + SectionType string `json:"section_type,omitempty"` + SectionName string `json:"section_name,omitempty"` + BaseVersion int64 `json:"base_version,omitempty"` +} + +type releasePlanCollabWSOutbound struct { + Type string `json:"type"` + Snapshot *ReleasePlanCollaborationSnapshot `json:"snapshot,omitempty"` + Error string `json:"error,omitempty"` +} + +type collaborationClient struct { + planID string + conn *websocket.Conn + send chan []byte +} + +var collaborationHub = struct { + sync.RWMutex + clients map[string]map[*collaborationClient]struct{} +}{ + clients: map[string]map[*collaborationClient]struct{}{}, +} + +var collaborationLoopOnce sync.Once + +func ensureReleasePlanCollaborationLoop() { + collaborationLoopOnce.Do(func() { + util.Go(func() { + ch, closeFn := cache.NewRedisCache(configbase.RedisCommonCacheTokenDB()).Subscribe(releasePlanCollabBroadcastChannel) + defer closeFn() + + for msg := range ch { + planID := strings.TrimSpace(msg.Payload) + if planID == "" { + continue + } + broadcastReleasePlanCollaborationSnapshot(planID) + } + }) + }) +} + +func releasePlanCollabSessionKey(sessionID string) string { + return releasePlanCollabSessionKeyPrefix + sessionID +} + +func releasePlanCollabPlanSetKey(planID string) string { + return fmt.Sprintf("%s%s:sessions", releasePlanCollabPlanSetPrefix, planID) +} + +func broadcastReleasePlanCollaboration(planID string) { + if planID == "" { + return + } + _ = cache.NewRedisCache(configbase.RedisCommonCacheTokenDB()).Publish(releasePlanCollabBroadcastChannel, planID) +} + +func registerCollaborationClient(planID string, client *collaborationClient) { + collaborationHub.Lock() + defer collaborationHub.Unlock() + + if _, exists := collaborationHub.clients[planID]; !exists { + collaborationHub.clients[planID] = make(map[*collaborationClient]struct{}) + } + collaborationHub.clients[planID][client] = struct{}{} +} + +func unregisterCollaborationClient(planID string, client *collaborationClient) { + collaborationHub.Lock() + defer collaborationHub.Unlock() + + if _, exists := collaborationHub.clients[planID]; !exists { + return + } + delete(collaborationHub.clients[planID], client) + if len(collaborationHub.clients[planID]) == 0 { + delete(collaborationHub.clients, planID) + } +} + +func sendSnapshotToLocalClients(planID string, snapshot *ReleasePlanCollaborationSnapshot) { + if snapshot == nil { + return + } + payload, err := json.Marshal(&releasePlanCollabWSOutbound{ + Type: "snapshot", + Snapshot: snapshot, + }) + if err != nil { + return + } + + collaborationHub.RLock() + clients := make([]*collaborationClient, 0, len(collaborationHub.clients[planID])) + for client := range collaborationHub.clients[planID] { + clients = append(clients, client) + } + collaborationHub.RUnlock() + + for _, client := range clients { + select { + case client.send <- payload: + default: + _ = client.conn.Close() + } + } +} + +func queueCollaborationClientMessage(client *collaborationClient, outbound *releasePlanCollabWSOutbound) { + if client == nil || outbound == nil { + return + } + payload, err := json.Marshal(outbound) + if err != nil { + return + } + select { + case client.send <- payload: + default: + } +} + +func broadcastReleasePlanCollaborationSnapshot(planID string) { + snapshot, err := GetReleasePlanCollaborationSnapshot(planID) + if err != nil { + log.Errorf("get release plan collaboration snapshot error: %v", err) + return + } + sendSnapshotToLocalClients(planID, snapshot) +} + +func GetReleasePlanCollaborationEditors(planID string) (*ReleasePlanCollaborationSnapshot, error) { + return GetReleasePlanCollaborationSnapshot(planID) +} + +func GetReleasePlanCollaborationSnapshot(planID string) (*ReleasePlanCollaborationSnapshot, error) { + ctx, cancel := context.WithTimeout(context.Background(), defaultTimeout) + defer cancel() + plan, err := mongodb.NewReleasePlanColl().GetByID(ctx, planID) + if err != nil { + return nil, errors.Wrap(err, "get plan") + } + + editors, err := listActiveReleasePlanEditingSessions(planID) + if err != nil { + return nil, err + } + + groupMap := map[string]*ReleasePlanCollaborationGroup{} + groupOrder := make([]string, 0) + for _, session := range editors { + key := session.SectionKey + group, exists := groupMap[key] + if !exists { + group = &ReleasePlanCollaborationGroup{ + SectionKey: session.SectionKey, + SectionType: session.SectionType, + SectionName: session.SectionName, + Editors: make([]*ReleasePlanEditingSession, 0), + } + groupMap[key] = group + groupOrder = append(groupOrder, key) + } + group.Editors = append(group.Editors, session) + } + + sort.Strings(groupOrder) + resp := make([]*ReleasePlanCollaborationGroup, 0, len(groupOrder)) + for _, key := range groupOrder { + resp = append(resp, groupMap[key]) + } + + return &ReleasePlanCollaborationSnapshot{ + PlanID: planID, + PlanVersion: plan.Version, + Groups: resp, + }, nil +} + +func listActiveReleasePlanEditingSessions(planID string) ([]*ReleasePlanEditingSession, error) { + redisCache := cache.NewRedisCache(configbase.RedisCommonCacheTokenDB()) + sessionIDs, err := redisCache.ListSetMembers(releasePlanCollabPlanSetKey(planID)) + if err != nil { + return nil, err + } + + resp := make([]*ReleasePlanEditingSession, 0, len(sessionIDs)) + for _, sessionID := range sessionIDs { + value, err := redisCache.GetString(releasePlanCollabSessionKey(sessionID)) + if err != nil { + continue + } + session := new(ReleasePlanEditingSession) + if err := json.Unmarshal([]byte(value), session); err != nil { + continue + } + if session.PlanID != planID { + continue + } + session.BaseSnapshot = "" + resp = append(resp, session) + } + + sort.Slice(resp, func(i, j int) bool { + if resp[i].SectionKey == resp[j].SectionKey { + return resp[i].EditingStartedAt < resp[j].EditingStartedAt + } + return resp[i].SectionKey < resp[j].SectionKey + }) + + return resp, nil +} + +func persistReleasePlanEditingSession(session *ReleasePlanEditingSession) error { + if session == nil { + return errors.New("nil editing session") + } + if session.PlanID == "" || session.SessionID == "" { + return errors.New("missing session id or plan id") + } + if session.EditingStartedAt == 0 { + session.EditingStartedAt = time.Now().Unix() + } + session.LastHeartbeatAt = time.Now().Unix() + + payload, err := json.Marshal(session) + if err != nil { + return err + } + + redisCache := cache.NewRedisCache(configbase.RedisCommonCacheTokenDB()) + if err := redisCache.Write(releasePlanCollabSessionKey(session.SessionID), string(payload), releasePlanCollabSessionTTL); err != nil { + return err + } + if err := redisCache.AddElementsToSet(releasePlanCollabPlanSetKey(session.PlanID), []string{session.SessionID}, releasePlanCollabBroadcastTTL); err != nil { + return err + } + broadcastReleasePlanCollaboration(session.PlanID) + return nil +} + +func removeReleasePlanEditingSession(planID, sessionID string) error { + redisCache := cache.NewRedisCache(configbase.RedisCommonCacheTokenDB()) + if err := redisCache.Delete(releasePlanCollabSessionKey(sessionID)); err != nil { + return err + } + if err := redisCache.RemoveElementsFromSet(releasePlanCollabPlanSetKey(planID), []string{sessionID}); err != nil { + return err + } + broadcastReleasePlanCollaboration(planID) + return nil +} + +func authorizeReleasePlanEditing(ctx *handler.Context, sectionType string) bool { + if ctx.Resources.IsSystemAdmin { + return true + } + switch sectionType { + case "metadata": + return ctx.Resources.SystemActions.ReleasePlan.EditMetadata + case "approval": + return ctx.Resources.SystemActions.ReleasePlan.EditApproval + case "job": + return ctx.Resources.SystemActions.ReleasePlan.EditSubtasks + default: + return false + } +} + +func validateReleasePlanEditingPlan(plan *models.ReleasePlan) error { + if plan == nil { + return errors.New("nil plan") + } + if plan.Status != config.ReleasePlanStatusPlanning { + return errors.Errorf("plan status is %s, can not edit", plan.Status) + } + return nil +} + +func getReleasePlanEditingSession(planID, sessionID string) (*ReleasePlanEditingSession, error) { + if sessionID == "" { + return nil, errors.New("empty session id") + } + value, err := cache.NewRedisCache(configbase.RedisCommonCacheTokenDB()).GetString(releasePlanCollabSessionKey(sessionID)) + if err != nil { + return nil, err + } + session := new(ReleasePlanEditingSession) + if err := json.Unmarshal([]byte(value), session); err != nil { + return nil, err + } + if session.PlanID != planID { + return nil, errors.New("session does not belong to current plan") + } + return session, nil +} + +func OpenReleasePlanCollaborationWS(gCtx *gin.Context, ctx *handler.Context, planID string) error { + return openReleasePlanCollaborationWS(gCtx, ctx, planID) +} + +func releasePlanSnapshotString(plan *models.ReleasePlan, sectionKey string) string { + if plan == nil { + return "" + } + sectionSnapshot, err := buildReleasePlanVersionSnapshot(plan, sectionKey) + if err != nil { + return "" + } + return encodeReleasePlanVersionSnapshot(sectionSnapshot) +} + +func openReleasePlanCollaborationWS(gCtx *gin.Context, ctx *handler.Context, planID string) error { + ws, err := upgrader.Upgrade(gCtx.Writer, gCtx.Request, nil) + if err != nil { + return e.ErrInvalidParam.AddErr(err) + } + defer ws.Close() + + ensureReleasePlanCollaborationLoop() + + client := &collaborationClient{ + planID: planID, + conn: ws, + send: make(chan []byte, 16), + } + registerCollaborationClient(planID, client) + defer unregisterCollaborationClient(planID, client) + + done := make(chan struct{}) + util.Go(func() { + defer close(done) + for { + _, payload, err := ws.ReadMessage() + if err != nil { + return + } + + msg := new(releasePlanCollabWSMessage) + if err := json.Unmarshal(payload, msg); err != nil { + continue + } + + switch msg.Type { + case "join", "focus_section", "heartbeat": + if !authorizeReleasePlanEditing(ctx, msg.SectionType) { + queueCollaborationClientMessage(client, &releasePlanCollabWSOutbound{Type: "error", Error: "permission denied"}) + continue + } + plan, err := mongodb.NewReleasePlanColl().GetByID(context.Background(), planID) + if err != nil { + queueCollaborationClientMessage(client, &releasePlanCollabWSOutbound{Type: "error", Error: err.Error()}) + continue + } + if err := validateReleasePlanEditingPlan(plan); err != nil { + queueCollaborationClientMessage(client, &releasePlanCollabWSOutbound{Type: "error", Error: err.Error()}) + continue + } + existingSession, _ := getReleasePlanEditingSession(planID, msg.SessionID) + session := &ReleasePlanEditingSession{ + PlanID: planID, + SessionID: msg.SessionID, + UserID: ctx.UserID, + UserName: ctx.UserName, + Account: ctx.Account, + IdentityType: ctx.IdentityType, + SectionKey: msg.SectionKey, + SectionType: msg.SectionType, + SectionName: msg.SectionName, + BaseVersion: msg.BaseVersion, + BaseSnapshot: releasePlanSnapshotString(plan, msg.SectionKey), + EditingStartedAt: time.Now().Unix(), + } + if existingSession != nil { + session.EditingStartedAt = existingSession.EditingStartedAt + if existingSession.BaseSnapshot != "" { + session.BaseSnapshot = existingSession.BaseSnapshot + } + if session.BaseVersion == 0 { + session.BaseVersion = existingSession.BaseVersion + } + if existingSession.SectionKey != "" && existingSession.SectionKey != msg.SectionKey { + session.EditingStartedAt = time.Now().Unix() + session.BaseSnapshot = releasePlanSnapshotString(plan, msg.SectionKey) + } + } + if err := persistReleasePlanEditingSession(session); err != nil { + queueCollaborationClientMessage(client, &releasePlanCollabWSOutbound{Type: "error", Error: err.Error()}) + continue + } + snapshot, err := GetReleasePlanCollaborationSnapshot(planID) + if err == nil { + queueCollaborationClientMessage(client, &releasePlanCollabWSOutbound{Type: "snapshot", Snapshot: snapshot}) + } + case "leave": + if err := removeReleasePlanEditingSession(planID, msg.SessionID); err != nil { + queueCollaborationClientMessage(client, &releasePlanCollabWSOutbound{Type: "error", Error: err.Error()}) + } + } + } + }) + + util.Go(func() { + for { + select { + case payload := <-client.send: + if err := ws.WriteMessage(websocket.TextMessage, payload); err != nil { + return + } + case <-done: + return + } + } + }) + + snapshot, err := GetReleasePlanCollaborationSnapshot(planID) + if err == nil { + queueCollaborationClientMessage(client, &releasePlanCollabWSOutbound{Type: "snapshot", Snapshot: snapshot}) + } + + <-done + return nil +} diff --git a/pkg/microservice/aslan/core/release_plan/service/diff.go b/pkg/microservice/aslan/core/release_plan/service/diff.go new file mode 100644 index 0000000000..d083e525e6 --- /dev/null +++ b/pkg/microservice/aslan/core/release_plan/service/diff.go @@ -0,0 +1,616 @@ +/* + * Copyright 2026 The KodeRover Authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package service + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "reflect" + "sort" + "strings" + + "github.com/pkg/errors" + + "github.com/koderover/zadig/v2/pkg/microservice/aslan/core/common/repository/mongodb" +) + +const ( + releasePlanHashPruneMinMapKeys = 4 + releasePlanHashPruneMinArrayItems = 4 +) + +type ReleasePlanVersionDiffResponse struct { + PlanID string `json:"plan_id"` + FromVersion int64 `json:"from_version"` + ToVersion int64 `json:"to_version"` + Groups []*ReleasePlanVersionDiffGroup `json:"groups"` +} + +type ReleasePlanVersionDiffGroup struct { + GroupKey string `json:"group_key"` + GroupName string `json:"group_name"` + GroupType string `json:"group_type"` + Changes []*ReleasePlanVersionDiffChange `json:"changes"` +} + +type ReleasePlanVersionDiffChange struct { + TaskName string `json:"task_name,omitempty"` + TaskType string `json:"task_type,omitempty"` + Path string `json:"path"` + Label string `json:"label"` + Before interface{} `json:"before,omitempty"` + After interface{} `json:"after,omitempty"` + LargeText bool `json:"large_text,omitempty"` + Masked bool `json:"masked,omitempty"` +} + +type releasePlanRawDiffEntry struct { + Path string + Before interface{} + After interface{} +} + +var releasePlanFieldLabels = map[string]string{ + "name": "名称", + "manager": "负责人", + "manager_id": "负责人 ID", + "start_time": "开始时间", + "end_time": "结束时间", + "schedule_execute_time": "定时执行时间", + "description": "需求关联", + "approval": "审批配置", + "type": "类型", + "enabled": "是否启用", + "content": "内容", + "remark": "备注", + "branch": "代码分支", + "tag": "Tag", + "pr": "PR", + "repo_name": "仓库名称", + "repo_namespace": "仓库命名空间", + "remote_name": "远端名称", + "job_name": "任务名称", + "build_name": "构建名称", + "service_name": "服务名称", + "service_module": "服务组件", + "image": "镜像", + "image_name": "镜像名称", + "namespace": "命名空间", + "env": "环境", + "cluster_id": "集群", + "cluster_source": "集群来源", + "target": "目标", + "targets": "目标列表", + "key_vals": "变量", + "key": "变量名", + "value": "变量值", + "params": "参数", + "stages": "阶段", + "jobs": "任务", + "script": "脚本内容", + "sql": "SQL 内容", + "manual_exec_users": "人工执行用户", + "approve_users": "审批人", + "approval_nodes": "审批节点", + "services": "服务", + "service_and_builds": "构建对象", + "default_service_and_builds": "默认构建对象", + "repos": "代码仓库", + "workflow": "工作流", + "native_approval": "原生审批", + "lark_approval": "飞书审批", + "dingtalk_approval": "钉钉审批", + "workwx_approval": "企业微信审批", +} + +func GetReleasePlanVersionDiff(planID string, fromVersion, toVersion int64) (*ReleasePlanVersionDiffResponse, error) { + to, err := mongodb.NewReleasePlanVersionColl().Get(planID, toVersion) + if err != nil { + return nil, errors.Wrap(err, "get to version") + } + + var fromData map[string]interface{} + var toData map[string]interface{} + groupKey, groupName, groupType := releasePlanVersionDiffGroup(to.SectionKey, to.SectionName) + + if to.BaseVersion == fromVersion { + fromData, err = toGenericMap(to.BaseSnapshot) + if err != nil { + return nil, errors.Wrap(err, "convert base snapshot") + } + toData, err = toGenericMap(to.Snapshot) + if err != nil { + return nil, errors.Wrap(err, "convert current snapshot") + } + } else { + if fromVersion == 0 { + return nil, errors.Errorf("release plan baseline diff v0 -> v%d is not available after subsequent version commits", toVersion) + } + from, err := mongodb.NewReleasePlanVersionColl().Get(planID, fromVersion) + if err != nil { + return nil, errors.Wrap(err, "get from version") + } + fromData, err = toGenericMap(from.Snapshot) + if err != nil { + return nil, errors.Wrap(err, "convert from snapshot") + } + toData, err = toGenericMap(to.Snapshot) + if err != nil { + return nil, errors.Wrap(err, "convert to snapshot") + } + } + + rawEntries := make([]*releasePlanRawDiffEntry, 0) + diffReleasePlanValues("", fromData, toData, &rawEntries) + + groupMap := map[string]*ReleasePlanVersionDiffGroup{} + groupOrder := make([]string, 0) + for _, entry := range rawEntries { + if shouldIgnoreReleasePlanDiffPath(entry.Path) { + continue + } + taskName, taskType := classifyReleasePlanDiffTask(entry.Path) + group, exists := groupMap[groupKey] + if !exists { + group = &ReleasePlanVersionDiffGroup{ + GroupKey: groupKey, + GroupName: groupName, + GroupType: groupType, + Changes: make([]*ReleasePlanVersionDiffChange, 0), + } + groupMap[groupKey] = group + groupOrder = append(groupOrder, groupKey) + } + + change := &ReleasePlanVersionDiffChange{ + TaskName: taskName, + TaskType: taskType, + Path: entry.Path, + Label: buildReleasePlanDiffLabel(entry.Path), + } + if isMaskedReleasePlanDiffValue(entry.Before) || isMaskedReleasePlanDiffValue(entry.After) { + change.Masked = true + } else if isLargeTextReleasePlanDiffPath(entry.Path, entry.Before, entry.After) { + change.LargeText = true + } else { + change.Before = normalizeReleasePlanDiffValue(entry.Before) + change.After = normalizeReleasePlanDiffValue(entry.After) + } + group.Changes = append(group.Changes, change) + } + + sort.Strings(groupOrder) + groups := make([]*ReleasePlanVersionDiffGroup, 0, len(groupOrder)) + for _, key := range groupOrder { + group := groupMap[key] + sort.Slice(group.Changes, func(i, j int) bool { + return group.Changes[i].Path < group.Changes[j].Path + }) + groups = append(groups, group) + } + + return &ReleasePlanVersionDiffResponse{ + PlanID: planID, + FromVersion: fromVersion, + ToVersion: toVersion, + Groups: groups, + }, nil +} + +func toGenericMap(value interface{}) (map[string]interface{}, error) { + if value == nil { + return map[string]interface{}{}, nil + } + payload, err := json.Marshal(value) + if err != nil { + return nil, err + } + resp := map[string]interface{}{} + if err := json.Unmarshal(payload, &resp); err != nil { + return nil, err + } + return resp, nil +} + +func diffReleasePlanValues(path string, left, right interface{}, entries *[]*releasePlanRawDiffEntry) { + if shouldIgnoreReleasePlanDiffPath(path) { + return + } + + if equal, hashed := equalReleasePlanSubtreeByHash(left, right); hashed { + if equal { + return + } + } else if reflect.DeepEqual(left, right) { + return + } + + leftMap, leftIsMap := left.(map[string]interface{}) + rightMap, rightIsMap := right.(map[string]interface{}) + if leftIsMap || rightIsMap { + keys := make([]string, 0) + keySet := map[string]struct{}{} + for key := range leftMap { + keySet[key] = struct{}{} + } + for key := range rightMap { + keySet[key] = struct{}{} + } + for key := range keySet { + keys = append(keys, key) + } + sort.Strings(keys) + for _, key := range keys { + nextPath := joinReleasePlanDiffPath(path, key) + diffReleasePlanValues(nextPath, leftMap[key], rightMap[key], entries) + } + return + } + + leftList, leftIsList := left.([]interface{}) + rightList, rightIsList := right.([]interface{}) + if leftIsList || rightIsList { + diffReleasePlanArray(path, leftList, rightList, entries) + return + } + + *entries = append(*entries, &releasePlanRawDiffEntry{ + Path: path, + Before: left, + After: right, + }) +} + +func equalReleasePlanSubtreeByHash(left, right interface{}) (equal bool, hashed bool) { + if !shouldUseReleasePlanSubtreeHash(left, right) { + return false, false + } + + leftHash, err := hashReleasePlanSubtree(left) + if err != nil { + return false, false + } + rightHash, err := hashReleasePlanSubtree(right) + if err != nil { + return false, false + } + return leftHash == rightHash, true +} + +func shouldUseReleasePlanSubtreeHash(left, right interface{}) bool { + switch leftValue := left.(type) { + case map[string]interface{}: + rightValue, ok := right.(map[string]interface{}) + if !ok { + return false + } + return len(leftValue) >= releasePlanHashPruneMinMapKeys || len(rightValue) >= releasePlanHashPruneMinMapKeys + case []interface{}: + rightValue, ok := right.([]interface{}) + if !ok { + return false + } + return len(leftValue) >= releasePlanHashPruneMinArrayItems || len(rightValue) >= releasePlanHashPruneMinArrayItems + default: + return false + } +} + +func hashReleasePlanSubtree(value interface{}) (string, error) { + payload, err := json.Marshal(value) + if err != nil { + return "", err + } + sum := sha256.Sum256(payload) + return hex.EncodeToString(sum[:]), nil +} + +func diffReleasePlanArray(path string, left, right []interface{}, entries *[]*releasePlanRawDiffEntry) { + leftMap, leftOrdered, leftMapped := buildReleasePlanArrayMap(left) + rightMap, rightOrdered, rightMapped := buildReleasePlanArrayMap(right) + if leftMapped && rightMapped { + keySet := map[string]struct{}{} + keys := make([]string, 0) + for _, key := range leftOrdered { + if _, exists := keySet[key]; !exists { + keySet[key] = struct{}{} + keys = append(keys, key) + } + } + for _, key := range rightOrdered { + if _, exists := keySet[key]; !exists { + keySet[key] = struct{}{} + keys = append(keys, key) + } + } + for _, key := range keys { + nextPath := fmt.Sprintf("%s[%s]", path, key) + diffReleasePlanValues(nextPath, leftMap[key], rightMap[key], entries) + } + return + } + + maxLen := len(left) + if len(right) > maxLen { + maxLen = len(right) + } + for i := 0; i < maxLen; i++ { + nextPath := fmt.Sprintf("%s[%d]", path, i) + var leftVal, rightVal interface{} + if i < len(left) { + leftVal = left[i] + } + if i < len(right) { + rightVal = right[i] + } + diffReleasePlanValues(nextPath, leftVal, rightVal, entries) + } +} + +func buildReleasePlanArrayMap(values []interface{}) (map[string]interface{}, []string, bool) { + result := make(map[string]interface{}, len(values)) + orderedKeys := make([]string, 0, len(values)) + for idx, item := range values { + key, ok := getReleasePlanArrayItemKey(item) + if !ok { + return nil, nil, false + } + if _, exists := result[key]; exists { + key = fmt.Sprintf("%s#%d", key, idx) + } + result[key] = item + orderedKeys = append(orderedKeys, key) + } + return result, orderedKeys, true +} + +func getReleasePlanArrayItemKey(item interface{}) (string, bool) { + switch value := item.(type) { + case map[string]interface{}: + if name, ok := getStringField(value, "name"); ok { + if jobType, ok := getStringField(value, "type"); ok { + if id, ok := getStringField(value, "id"); ok { + return fmt.Sprintf("%s|%s|%s", name, jobType, id), true + } + return fmt.Sprintf("%s|%s", name, jobType), true + } + return name, true + } + if key, ok := getStringField(value, "key"); ok { + return key, true + } + if service, ok := getStringField(value, "service_name"); ok { + if module, ok := getStringField(value, "service_module"); ok { + return fmt.Sprintf("%s/%s", service, module), true + } + } + if repo, ok := getStringField(value, "repo_name"); ok { + namespace, _ := getStringField(value, "repo_namespace") + remote, _ := getStringField(value, "remote_name") + return fmt.Sprintf("%s/%s/%s", namespace, repo, remote), true + } + if target, ok := getStringField(value, "target"); ok { + return target, true + } + if userID, ok := getStringField(value, "user_id"); ok { + return userID, true + } + if id, ok := getStringField(value, "id"); ok { + return id, true + } + return "", false + default: + return "", false + } +} + +func getStringField(input map[string]interface{}, key string) (string, bool) { + value, exists := input[key] + if !exists { + return "", false + } + str, ok := value.(string) + return str, ok && str != "" +} + +func joinReleasePlanDiffPath(path, key string) string { + if path == "" { + return key + } + return path + "." + key +} + +func shouldIgnoreReleasePlanDiffPath(path string) bool { + if path == "" { + return false + } + prefixes := []string{ + "id", + "index", + "version", + "created_by", + "create_time", + "updated_by", + "update_time", + "status", + "planning_time", + "finish_planning_time", + "approval_time", + "executing_time", + "success_time", + "instance_code", + "hook_settings", + "wait_for_finish_planning_external_check_time", + "wait_for_approve_external_check_time", + "wait_for_execute_external_check_time", + "wait_for_all_done_external_check_time", + "external_check_failed_reason", + "callback_description", + } + for _, prefix := range prefixes { + if path == prefix || strings.HasPrefix(path, prefix+".") { + return true + } + } + + suffixes := []string{ + ".status", + ".last_status", + ".updated", + ".executed_by", + ".executed_time", + ".task_id", + ".hook_payload", + ".hash", + ".notification_id", + ".operation_time", + ".reject_or_approve", + ".approval_instance", + ".manual_exector_id", + ".manual_exector_name", + ".notification_sent", + } + for _, suffix := range suffixes { + if strings.HasSuffix(path, suffix) { + return true + } + } + return false +} + +func classifyReleasePlanDiffTask(path string) (taskName, taskType string) { + jobSegments := releasePlanBracketSegments(path, "jobs") + if len(jobSegments) >= 2 { + taskName, taskType = splitReleasePlanBracketKey(jobSegments[len(jobSegments)-1]) + } + return +} + +func firstReleasePlanBracketSegment(path, prefix string) string { + for _, segment := range strings.Split(path, ".") { + if strings.HasPrefix(segment, prefix+"[") { + return segment + } + } + return prefix +} + +func releasePlanBracketSegments(path, prefix string) []string { + resp := make([]string, 0) + for _, segment := range strings.Split(path, ".") { + if strings.HasPrefix(segment, prefix+"[") { + resp = append(resp, segment) + } + } + return resp +} + +func splitReleasePlanBracketKey(segment string) (string, string) { + primary := bracketPrimaryName(segment) + parts := strings.Split(primary, "|") + if len(parts) == 1 { + return primary, "" + } + return parts[0], strings.Join(parts[1:], "|") +} + +func bracketPrimaryName(segment string) string { + start := strings.Index(segment, "[") + end := strings.LastIndex(segment, "]") + if start == -1 || end == -1 || end <= start+1 { + return segment + } + return segment[start+1 : end] +} + +func buildReleasePlanDiffLabel(path string) string { + segments := strings.Split(path, ".") + labels := make([]string, 0, len(segments)) + for _, segment := range segments { + if segment == "spec" || segment == "workflow" { + continue + } + label := segment + switch { + case strings.HasPrefix(segment, "jobs["): + name, _ := splitReleasePlanBracketKey(segment) + label = fmt.Sprintf("任务 %s", name) + case strings.HasPrefix(segment, "stages["): + label = fmt.Sprintf("阶段 %s", bracketPrimaryName(segment)) + case strings.HasPrefix(segment, "params["): + label = fmt.Sprintf("参数 %s", bracketPrimaryName(segment)) + case strings.HasPrefix(segment, "key_vals["): + label = fmt.Sprintf("变量 %s", bracketPrimaryName(segment)) + case strings.HasPrefix(segment, "services["): + label = fmt.Sprintf("服务 %s", bracketPrimaryName(segment)) + case strings.Contains(segment, "["): + fieldName := segment[:strings.Index(segment, "[")] + label = fmt.Sprintf("%s %s", translateReleasePlanFieldLabel(fieldName), bracketPrimaryName(segment)) + default: + label = translateReleasePlanFieldLabel(segment) + } + labels = append(labels, label) + } + if len(labels) == 0 { + return path + } + return strings.Join(labels, " / ") +} + +func translateReleasePlanFieldLabel(name string) string { + if label, exists := releasePlanFieldLabels[name]; exists { + return label + } + return strings.ReplaceAll(name, "_", " ") +} + +func isMaskedReleasePlanDiffValue(value interface{}) bool { + return isReleasePlanMaskedStorageValue(value) +} + +func isLargeTextReleasePlanDiffPath(path string, before, after interface{}) bool { + lowerPath := strings.ToLower(path) + keywords := []string{"script", "sql", "content", "yaml", "json"} + for _, keyword := range keywords { + if strings.Contains(lowerPath, keyword) { + return true + } + } + + if value, ok := before.(string); ok && len(value) > 256 { + return true + } + if value, ok := after.(string); ok && len(value) > 256 { + return true + } + return false +} + +func normalizeReleasePlanDiffValue(value interface{}) interface{} { + switch value.(type) { + case nil, string, bool, float64: + return value + default: + payload, err := json.Marshal(value) + if err != nil { + return fmt.Sprintf("%v", value) + } + return string(payload) + } +} diff --git a/pkg/microservice/aslan/core/release_plan/service/masking.go b/pkg/microservice/aslan/core/release_plan/service/masking.go new file mode 100644 index 0000000000..b6bcc4ffe0 --- /dev/null +++ b/pkg/microservice/aslan/core/release_plan/service/masking.go @@ -0,0 +1,199 @@ +package service + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "strings" + + "github.com/pkg/errors" + + "github.com/koderover/zadig/v2/pkg/microservice/aslan/core/common/repository/models" + "github.com/koderover/zadig/v2/pkg/microservice/aslan/core/common/repository/mongodb" +) + +const ( + releasePlanMaskedValueDisplay = "已脱敏" + releasePlanMaskedValuePrefix = "__masked__:" +) + +func createReleasePlanLog(logItem *models.ReleasePlanLog) error { + if logItem == nil { + return errors.New("nil release plan log") + } + + cloned := *logItem + cloned.Before = sanitizeReleasePlanValue(logItem.Before) + cloned.After = sanitizeReleasePlanValue(logItem.After) + return mongodb.NewReleasePlanLogColl().Create(&cloned) +} + +func sanitizeReleasePlanValue(value interface{}) interface{} { + if value == nil { + return nil + } + + genericValue, err := toReleasePlanGenericValue(value) + if err != nil { + return value + } + + return sanitizeReleasePlanGenericValue("", genericValue) +} + +func sanitizeReleasePlanValueForDisplay(value interface{}) interface{} { + if value == nil { + return nil + } + + genericValue, err := toReleasePlanGenericValue(value) + if err != nil { + if isReleasePlanMaskedStorageValue(value) { + return releasePlanMaskedValueDisplay + } + return value + } + + if hasReleasePlanRawSensitiveValue(genericValue) { + genericValue = sanitizeReleasePlanGenericValue("", genericValue) + } + return sanitizeReleasePlanDisplayGenericValue(genericValue) +} + +func sanitizeReleasePlanGenericValue(path string, value interface{}) interface{} { + switch typedValue := value.(type) { + case map[string]interface{}: + resp := make(map[string]interface{}, len(typedValue)) + for key, item := range typedValue { + resp[key] = sanitizeReleasePlanGenericValue(joinReleasePlanMaskPath(path, key), item) + } + if isReleasePlanSensitiveValueNode(resp) { + maskReleasePlanSensitiveValueNode(resp) + } + return resp + case []interface{}: + resp := make([]interface{}, 0, len(typedValue)) + for idx, item := range typedValue { + resp = append(resp, sanitizeReleasePlanGenericValue(fmt.Sprintf("%s[%d]", path, idx), item)) + } + return resp + default: + return value + } +} + +func sanitizeReleasePlanDisplayGenericValue(value interface{}) interface{} { + switch typedValue := value.(type) { + case map[string]interface{}: + resp := make(map[string]interface{}, len(typedValue)) + for key, item := range typedValue { + resp[key] = sanitizeReleasePlanDisplayGenericValue(item) + } + return resp + case []interface{}: + resp := make([]interface{}, 0, len(typedValue)) + for _, item := range typedValue { + resp = append(resp, sanitizeReleasePlanDisplayGenericValue(item)) + } + return resp + case string: + if isReleasePlanMaskedStorageValue(typedValue) { + return releasePlanMaskedValueDisplay + } + return typedValue + default: + return value + } +} + +func toReleasePlanGenericValue(value interface{}) (interface{}, error) { + payload, err := json.Marshal(value) + if err != nil { + return nil, err + } + var resp interface{} + if err := json.Unmarshal(payload, &resp); err != nil { + return nil, err + } + return resp, nil +} + +func maskReleasePlanValue(value interface{}) string { + if isReleasePlanMaskedStorageValue(value) { + if str, ok := value.(string); ok { + return str + } + } + + payload, err := json.Marshal(value) + if err != nil { + payload = []byte(fmt.Sprintf("%v", value)) + } + hash := sha256.Sum256(payload) + return releasePlanMaskedValuePrefix + hex.EncodeToString(hash[:8]) +} + +func isReleasePlanMaskedStorageValue(value interface{}) bool { + str, ok := value.(string) + return ok && strings.HasPrefix(str, releasePlanMaskedValuePrefix) +} + +func isReleasePlanSensitiveValueNode(value map[string]interface{}) bool { + if value == nil { + return false + } + return isReleasePlanSensitiveFlagTrue(value, "is_credential") || isReleasePlanSensitiveFlagTrue(value, "is_sensitive") +} + +func hasReleasePlanRawSensitiveValue(value interface{}) bool { + switch typedValue := value.(type) { + case map[string]interface{}: + if isReleasePlanSensitiveValueNode(typedValue) { + for _, key := range []string{"value", "choice_value"} { + if item, exists := typedValue[key]; exists && !isReleasePlanMaskedStorageValue(item) { + return true + } + } + } + for _, item := range typedValue { + if hasReleasePlanRawSensitiveValue(item) { + return true + } + } + case []interface{}: + for _, item := range typedValue { + if hasReleasePlanRawSensitiveValue(item) { + return true + } + } + } + return false +} + +func isReleasePlanSensitiveFlagTrue(input map[string]interface{}, key string) bool { + value, exists := input[key] + if !exists { + return false + } + flag, ok := value.(bool) + return ok && flag +} + +func maskReleasePlanSensitiveValueNode(value map[string]interface{}) { + if value == nil { + return + } + for _, key := range []string{"value", "choice_value"} { + if item, exists := value[key]; exists { + value[key] = maskReleasePlanValue(item) + } + } +} + +func joinReleasePlanMaskPath(path, key string) string { + if path == "" { + return key + } + return path + "." + key +} diff --git a/pkg/microservice/aslan/core/release_plan/service/openapi.go b/pkg/microservice/aslan/core/release_plan/service/openapi.go index 2c6739d293..1e87f01c9f 100644 --- a/pkg/microservice/aslan/core/release_plan/service/openapi.go +++ b/pkg/microservice/aslan/core/release_plan/service/openapi.go @@ -158,6 +158,7 @@ func OpenAPICreateReleasePlan(c *handler.Context, rawArgs *OpenAPICreateReleaseP args.UpdatedBy = c.UserName args.CreateTime = time.Now().Unix() args.UpdateTime = time.Now().Unix() + args.Version = 1 args.Status = config.ReleasePlanStatusPlanning planID, err := mongodb.NewReleasePlanColl().Create(args) @@ -166,13 +167,21 @@ func OpenAPICreateReleasePlan(c *handler.Context, rawArgs *OpenAPICreateReleaseP } go func() { - if err := mongodb.NewReleasePlanLogColl().Create(&models.ReleasePlanLog{ + sectionSnapshot, err := buildReleasePlanInputSnapshot(args) + if err == nil { + err = createReleasePlanVersion(planID, 0, 1, nil, sectionSnapshot, c.UserName, c.Account, releasePlanVersionSectionPlan, releasePlanVersionSectionName(releasePlanVersionSectionPlan, args.Name), VerbCreate) + } + if err != nil { + log.Errorf("create release plan version error: %v", err) + } + if err := createReleasePlanLog(&models.ReleasePlanLog{ PlanID: planID, Username: c.UserName, Account: c.Account, Verb: VerbCreate, TargetName: args.Name, TargetType: TargetTypeReleasePlan, + ToVersion: 1, CreatedAt: time.Now().Unix(), }); err != nil { log.Errorf("create release plan log error: %v", err) @@ -220,6 +229,10 @@ func OpenAPICreateReleasePlanWithJobs(c *handler.Context, id string, rawArgs *Op if err != nil { return errors.Wrap(err, "get release plan error") } + originalPlan, err := cloneReleasePlan(plan) + if err != nil { + return errors.Wrap(err, "clone release plan") + } if rawArgs.Name == "" || rawArgs.Manager == "" { return errors.New("Required parameters are missing") @@ -361,25 +374,42 @@ func OpenAPICreateReleasePlanWithJobs(c *handler.Context, id string, rawArgs *Op } plan.Jobs = newJobs + fromVersion, err := ensureReleasePlanBaselineVersion(c, id, plan) + if err != nil { + return errors.Wrap(err, "ensure release plan baseline version") + } + plan.Version = fromVersion + 1 err = mongodb.NewReleasePlanColl().UpdateByID(c, id, plan) if err != nil { return errors.Wrap(err, "update release plan error") } - go func() { - if err := mongodb.NewReleasePlanLogColl().Create(&models.ReleasePlanLog{ - PlanID: plan.ID.Hex(), - Username: c.UserName, - Account: c.Account, - Verb: VerbUpdate, - TargetName: plan.Name, - TargetType: TargetTypeReleasePlan, - CreatedAt: time.Now().Unix(), - }); err != nil { - log.Errorf("create release plan log error: %v", err) - } - }() + baseSnapshot, err := buildReleasePlanInputSnapshot(originalPlan) + if err != nil { + return errors.Wrap(err, "build release plan base snapshot") + } + currentSnapshot, err := buildReleasePlanInputSnapshot(plan) + if err != nil { + return errors.Wrap(err, "build release plan current snapshot") + } + if err := createReleasePlanVersion(plan.ID.Hex(), fromVersion, plan.Version, baseSnapshot, currentSnapshot, c.UserName, c.Account, releasePlanVersionSectionPlan, releasePlanVersionSectionName(releasePlanVersionSectionPlan, plan.Name), VerbUpdate); err != nil { + log.Errorf("create release plan version error: %v", err) + } + if err := createReleasePlanLog(&models.ReleasePlanLog{ + PlanID: plan.ID.Hex(), + Username: c.UserName, + Account: c.Account, + Verb: VerbUpdate, + TargetName: plan.Name, + TargetType: TargetTypeReleasePlan, + FromVersion: fromVersion, + ToVersion: plan.Version, + CreatedAt: time.Now().Unix(), + }); err != nil { + log.Errorf("create release plan log error: %v", err) + } + broadcastReleasePlanCollaboration(plan.ID.Hex()) return nil } diff --git a/pkg/microservice/aslan/core/release_plan/service/release_plan.go b/pkg/microservice/aslan/core/release_plan/service/release_plan.go index c8659b82b1..075d2a3b2a 100644 --- a/pkg/microservice/aslan/core/release_plan/service/release_plan.go +++ b/pkg/microservice/aslan/core/release_plan/service/release_plan.go @@ -110,6 +110,7 @@ func CreateReleasePlan(c *handler.Context, args *models.ReleasePlan) error { args.UpdatedBy = c.UserName args.CreateTime = time.Now().Unix() args.UpdateTime = time.Now().Unix() + args.Version = 1 args.Status = config.ReleasePlanStatusPlanning args.InstanceCode, err = generateInstanceCode(args) @@ -131,13 +132,21 @@ func CreateReleasePlan(c *handler.Context, args *models.ReleasePlan) error { } go func() { - if err := mongodb.NewReleasePlanLogColl().Create(&models.ReleasePlanLog{ + sectionSnapshot, err := buildReleasePlanInputSnapshot(args) + if err == nil { + err = createReleasePlanVersion(planID, 0, 1, nil, sectionSnapshot, c.UserName, c.Account, releasePlanVersionSectionPlan, releasePlanVersionSectionName(releasePlanVersionSectionPlan, args.Name), VerbCreate) + } + if err != nil { + log.Errorf("create release plan version error: %v", err) + } + if err := createReleasePlanLog(&models.ReleasePlanLog{ PlanID: planID, Username: c.UserName, Account: c.Account, Verb: VerbCreate, TargetName: args.Name, TargetType: TargetTypeReleasePlan, + ToVersion: 1, CreatedAt: time.Now().Unix(), }); err != nil { log.Errorf("create release plan log error: %v", err) @@ -331,8 +340,19 @@ func GetReleasePlanLogs(id string) (*GetReleasePlanLogsResponse, error) { return nil, errors.Wrap(err, "get release plan logs") } + sanitizedLogs := make([]*models.ReleasePlanLog, 0, len(logs)) + for _, item := range logs { + if item == nil { + continue + } + cloned := *item + cloned.Before = sanitizeReleasePlanValueForDisplay(item.Before) + cloned.After = sanitizeReleasePlanValueForDisplay(item.After) + sanitizedLogs = append(sanitizedLogs, &cloned) + } + return &GetReleasePlanLogsResponse{ - List: logs, + List: sanitizedLogs, I18N: &ReleasePlanLogI18N{ VerbI18Map: VerbI18nMap, TargetTypeI18Map: TargetTypeI18nMap, @@ -386,8 +406,9 @@ const ( ) type UpdateReleasePlanArgs struct { - Verb UpdateReleasePlanVerb `json:"verb"` - Spec interface{} `json:"spec"` + Verb UpdateReleasePlanVerb `json:"verb"` + Spec interface{} `json:"spec"` + SessionID string `json:"session_id,omitempty"` } func UpdateReleasePlan(c *handler.Context, planID string, args *UpdateReleasePlanArgs) error { @@ -401,6 +422,10 @@ func UpdateReleasePlan(c *handler.Context, planID string, args *UpdateReleasePla if err != nil { return errors.Wrap(err, "get plan") } + originalPlan, err := cloneReleasePlan(plan) + if err != nil { + return errors.Wrap(err, "clone plan") + } if plan.Status != config.ReleasePlanStatusPlanning { return errors.Errorf("plan status is %s, can not update", plan.Status) @@ -419,6 +444,28 @@ func UpdateReleasePlan(c *handler.Context, planID string, args *UpdateReleasePla return errors.Wrap(err, "update") } + sectionKey, sectionName, err := releasePlanVersionSectionKeyByVerb(originalPlan, plan, args) + if err != nil { + return errors.Wrap(err, "resolve release plan section") + } + baseSnapshot, err := buildReleasePlanVersionSnapshot(originalPlan, sectionKey) + if err != nil { + return errors.Wrap(err, "build release plan base snapshot") + } + currentSnapshot, err := buildReleasePlanVersionSnapshot(plan, sectionKey) + if err != nil { + return errors.Wrap(err, "build release plan current snapshot") + } + + var fromVersion int64 + if args.SessionID == "" { + fromVersion, err = ensureReleasePlanBaselineVersion(ctx, planID, plan) + if err != nil { + return errors.Wrap(err, "ensure release plan baseline version") + } + plan.Version = fromVersion + 1 + } + plan.UpdatedBy = c.UserName plan.UpdateTime = time.Now().Unix() @@ -442,21 +489,29 @@ func UpdateReleasePlan(c *handler.Context, planID string, args *UpdateReleasePla return errors.Wrap(err, "update plan") } - go func() { - if err := mongodb.NewReleasePlanLogColl().Create(&models.ReleasePlanLog{ - PlanID: planID, - Username: c.UserName, - Account: c.Account, - Verb: updater.Verb(), - Before: before, - After: after, - TargetName: updater.TargetName(), - TargetType: updater.TargetType(), - CreatedAt: time.Now().Unix(), - }); err != nil { - log.Errorf("create release plan log error: %v", err) + logItem := &models.ReleasePlanLog{ + PlanID: planID, + SessionID: args.SessionID, + Username: c.UserName, + Account: c.Account, + Verb: updater.Verb(), + Before: before, + After: after, + TargetName: updater.TargetName(), + TargetType: updater.TargetType(), + CreatedAt: time.Now().Unix(), + } + if args.SessionID == "" { + logItem.FromVersion = fromVersion + logItem.ToVersion = plan.Version + if err := createReleasePlanVersion(planID, fromVersion, plan.Version, baseSnapshot, currentSnapshot, c.UserName, c.Account, sectionKey, releasePlanVersionSectionName(sectionKey, sectionName), string(args.Verb)); err != nil { + log.Errorf("create release plan version error: %v", err) } - }() + } + if err := createReleasePlanLog(logItem); err != nil { + log.Errorf("create release plan log error: %v", err) + } + broadcastReleasePlanCollaboration(planID) return nil } @@ -495,6 +550,47 @@ func GetReleasePlanJobDetail(planID, jobID string) (*commonmodels.ReleaseJob, er return nil, fmt.Errorf("failed to find release plan job with id: %s. Job does not exist", jobID) } +func findReleasePlanJob(plan *models.ReleasePlan, jobID string) (*models.ReleaseJob, error) { + if plan == nil { + return nil, errors.New("nil release plan") + } + for _, job := range plan.Jobs { + if job.ID == jobID { + return job, nil + } + } + return nil, fmt.Errorf("failed to find release plan job with id: %s. Job does not exist", jobID) +} + +func buildReleasePlanJobLogSnapshot(job *models.ReleaseJob) map[string]interface{} { + if job == nil { + return nil + } + + snapshot := map[string]interface{}{ + "type": job.Type, + "status": job.Status, + "executed_by": job.ExecutedBy, + "executed_time": job.ExecutedTime, + } + + switch job.Type { + case config.JobText: + spec := new(models.TextReleaseJobSpec) + if err := models.IToi(job.Spec, spec); err == nil { + snapshot["remark"] = spec.Remark + } + case config.JobWorkflow: + spec := new(models.WorkflowReleaseJobSpec) + if err := models.IToi(job.Spec, spec); err == nil { + snapshot["workflow_status"] = spec.Status + snapshot["task_id"] = spec.TaskID + } + } + + return snapshot +} + type ExecuteReleaseJobArgs struct { ID string `json:"id"` Name string `json:"name"` @@ -531,6 +627,12 @@ func ExecuteReleaseJob(c *handler.Context, planID string, args *ExecuteReleaseJo } } + jobBefore, err := findReleasePlanJob(plan, args.ID) + if err != nil { + return errors.Wrap(err, "find release job before execute") + } + beforeSnapshot := buildReleasePlanJobLogSnapshot(jobBefore) + executor, err := NewReleaseJobExecutor(&ExecuteReleaseJobContext{ AuthResources: c.Resources, UserID: c.UserID, @@ -543,6 +645,11 @@ func ExecuteReleaseJob(c *handler.Context, planID string, args *ExecuteReleaseJo if err = executor.Execute(plan); err != nil { return errors.Wrap(err, "execute") } + jobAfter, err := findReleasePlanJob(plan, args.ID) + if err != nil { + return errors.Wrap(err, "find release job after execute") + } + afterSnapshot := buildReleasePlanJobLogSnapshot(jobAfter) plan.UpdatedBy = c.UserName plan.UpdateTime = time.Now().Unix() @@ -578,11 +685,13 @@ func ExecuteReleaseJob(c *handler.Context, planID string, args *ExecuteReleaseJo } go func() { - if err := mongodb.NewReleasePlanLogColl().Create(&models.ReleasePlanLog{ + if err := createReleasePlanLog(&models.ReleasePlanLog{ PlanID: planID, Username: c.UserName, Account: c.Account, Verb: VerbExecute, + Before: beforeSnapshot, + After: afterSnapshot, TargetName: args.Name, TargetType: TargetTypeReleaseJob, CreatedAt: time.Now().Unix(), @@ -630,6 +739,12 @@ func RetryReleaseJob(c *handler.Context, planID string, args *RetryReleaseJobArg } } + jobBefore, err := findReleasePlanJob(plan, args.ID) + if err != nil { + return errors.Wrap(err, "find release job before retry") + } + beforeSnapshot := buildReleasePlanJobLogSnapshot(jobBefore) + retryer, err := NewReleaseJobRetryer(&RetryReleaseJobContext{ AuthResources: c.Resources, UserID: c.UserID, @@ -637,11 +752,16 @@ func RetryReleaseJob(c *handler.Context, planID string, args *RetryReleaseJobArg UserName: c.UserName, }, args) if err != nil { - return errors.Wrap(err, "new release job executor") + return errors.Wrap(err, "new release job retryer") } if err = retryer.Retry(plan); err != nil { - return errors.Wrap(err, "execute") + return errors.Wrap(err, "retry") } + jobAfter, err := findReleasePlanJob(plan, args.ID) + if err != nil { + return errors.Wrap(err, "find release job after retry") + } + afterSnapshot := buildReleasePlanJobLogSnapshot(jobAfter) plan.UpdatedBy = c.UserName plan.UpdateTime = time.Now().Unix() @@ -679,11 +799,13 @@ func RetryReleaseJob(c *handler.Context, planID string, args *RetryReleaseJobArg } go func() { - if err := mongodb.NewReleasePlanLogColl().Create(&models.ReleasePlanLog{ + if err := createReleasePlanLog(&models.ReleasePlanLog{ PlanID: planID, Username: c.UserName, Account: c.Account, Verb: VerbRetry, + Before: beforeSnapshot, + After: afterSnapshot, TargetName: args.Name, TargetType: TargetTypeReleaseJob, CreatedAt: time.Now().Unix(), @@ -776,19 +898,11 @@ func ScheduleExecuteReleasePlan(c *handler.Context, planID, jobID string) error Type: string(job.Type), } - go func() { - if err := mongodb.NewReleasePlanLogColl().Create(&models.ReleasePlanLog{ - PlanID: planID, - Username: UserNameSystem, - Account: "", - Verb: VerbExecute, - TargetName: args.Name, - TargetType: TargetTypeReleaseJob, - CreatedAt: time.Now().Unix(), - }); err != nil { - log.Errorf("create release plan log error: %v", err) - } - }() + jobBefore, err := findReleasePlanJob(plan, job.ID) + if err != nil { + return err + } + beforeSnapshot := buildReleasePlanJobLogSnapshot(jobBefore) executor, err := NewReleaseJobExecutor(&ExecuteReleaseJobContext{ AuthResources: c.Resources, @@ -807,6 +921,12 @@ func ScheduleExecuteReleasePlan(c *handler.Context, planID, jobID string) error return err } + jobAfter, err := findReleasePlanJob(plan, job.ID) + if err != nil { + return err + } + afterSnapshot := buildReleasePlanJobLogSnapshot(jobAfter) + plan.UpdatedBy = UserNameSystem plan.UpdateTime = time.Now().Unix() @@ -831,6 +951,22 @@ func ScheduleExecuteReleasePlan(c *handler.Context, planID, jobID string) error log.Error(err) return err } + + go func(jobName string, before, after map[string]interface{}) { + if err := createReleasePlanLog(&models.ReleasePlanLog{ + PlanID: planID, + Username: UserNameSystem, + Account: "", + Verb: VerbExecute, + Before: before, + After: after, + TargetName: jobName, + TargetType: TargetTypeReleaseJob, + CreatedAt: time.Now().Unix(), + }); err != nil { + log.Errorf("create release plan log error: %v", err) + } + }(job.Name, beforeSnapshot, afterSnapshot) } } @@ -873,6 +1009,12 @@ func SkipReleaseJob(c *handler.Context, planID string, args *SkipReleaseJobArgs, } } + jobBefore, err := findReleasePlanJob(plan, args.ID) + if err != nil { + return errors.Wrap(err, "find release job before skip") + } + beforeSnapshot := buildReleasePlanJobLogSnapshot(jobBefore) + skipper, err := NewReleaseJobSkipper(&SkipReleaseJobContext{ AuthResources: c.Resources, UserID: c.UserID, @@ -885,6 +1027,11 @@ func SkipReleaseJob(c *handler.Context, planID string, args *SkipReleaseJobArgs, if err = skipper.Skip(plan); err != nil { return errors.Wrap(err, "skip") } + jobAfter, err := findReleasePlanJob(plan, args.ID) + if err != nil { + return errors.Wrap(err, "find release job after skip") + } + afterSnapshot := buildReleasePlanJobLogSnapshot(jobAfter) plan.UpdatedBy = c.UserName plan.UpdateTime = time.Now().Unix() @@ -918,11 +1065,13 @@ func SkipReleaseJob(c *handler.Context, planID string, args *SkipReleaseJobArgs, } go func() { - if err := mongodb.NewReleasePlanLogColl().Create(&models.ReleasePlanLog{ + if err := createReleasePlanLog(&models.ReleasePlanLog{ PlanID: planID, Username: c.UserName, Account: c.Account, Verb: VerbSkip, + Before: beforeSnapshot, + After: afterSnapshot, TargetName: args.Name, TargetType: TargetTypeReleaseJob, CreatedAt: time.Now().Unix(), @@ -954,7 +1103,9 @@ func UpdateReleasePlanStatus(c *handler.Context, planID, targetStatus string, is return errors.Errorf("only manager can update plan status") } - if !lo.Contains(config.ReleasePlanStatusMap[plan.Status], config.ReleasePlanStatus(targetStatus)) { + newStatus := config.ReleasePlanStatus(targetStatus) + oldStatus := plan.Status + if !lo.Contains(config.ReleasePlanStatusMap[plan.Status], newStatus) { return errors.Errorf("can't convert plan status %s to %s", plan.Status, targetStatus) } @@ -963,8 +1114,6 @@ func UpdateReleasePlanStatus(c *handler.Context, planID, targetStatus string, is return errors.Wrap(err, "get user") } - detail := "" - sendWebhook := false hookSetting, err := mongodb.NewSystemSettingColl().GetReleasePlanHookSetting() if err != nil { @@ -989,14 +1138,14 @@ func UpdateReleasePlanStatus(c *handler.Context, planID, targetStatus string, is config.ReleasePlanStatusWaitForExecuteExternalCheckFailed, config.ReleasePlanStatusWaitForAllDoneExternalCheck, config.ReleasePlanStatusWaitForAllDoneExternalCheckFailed: - if config.ReleasePlanStatus(targetStatus) != config.ReleasePlanStatusPlanning && config.ReleasePlanStatus(targetStatus) != config.ReleasePlanStatusCancel { + if newStatus != config.ReleasePlanStatusPlanning && newStatus != config.ReleasePlanStatusCancel { return fmt.Errorf("can't update status, current status: %s", plan.Status) } } - plan.Status = config.ReleasePlanStatus(targetStatus) + plan.Status = newStatus // target status check and update - switch config.ReleasePlanStatus(targetStatus) { + switch newStatus { case config.ReleasePlanStatusPlanning: for _, job := range plan.Jobs { job.LastStatus = job.Status @@ -1116,6 +1265,8 @@ func UpdateReleasePlanStatus(c *handler.Context, planID, targetStatus string, is if err := upsertReleasePlanCron(plan.ID.Hex(), plan.Name, plan.Index, plan.Status, plan.ScheduleExecuteTime); err != nil { return errors.Wrap(err, "upsert release plan cron") } + updatedStatus := plan.Status + detail := fmt.Sprintf("状态从 %s 变更为 %s", oldStatus, updatedStatus) if sendWebhook { if err := sendReleasePlanHook(plan, hookSetting); err != nil { @@ -1124,7 +1275,7 @@ func UpdateReleasePlanStatus(c *handler.Context, planID, targetStatus string, is } go func() { - if err := mongodb.NewReleasePlanLogColl().Create(&models.ReleasePlanLog{ + if err := createReleasePlanLog(&models.ReleasePlanLog{ PlanID: planID, Username: c.UserName, Account: c.Account, @@ -1132,8 +1283,8 @@ func UpdateReleasePlanStatus(c *handler.Context, planID, targetStatus string, is TargetName: TargetTypeReleasePlanStatus, TargetType: TargetTypeReleasePlanStatus, Detail: detail, - Before: plan.Status, - After: targetStatus, + Before: oldStatus, + After: updatedStatus, CreatedAt: time.Now().Unix(), }); err != nil { log.Errorf("create release plan log error: %v", err) @@ -1204,16 +1355,18 @@ func ApproveReleasePlan(c *handler.Context, planID string, req *ApproveRequest) plan.Approval.Status = config.StatusPassed } var planLog *models.ReleasePlanLog + beforeStatus := config.ReleasePlanStatusWaitForApprove switch plan.Approval.Status { case config.StatusPassed: planLog = &models.ReleasePlanLog{ PlanID: planID, Username: UserNameSystem, + Account: "", Verb: VerbUpdate, TargetName: TargetTypeReleasePlanStatus, TargetType: TargetTypeReleasePlanStatus, Detail: DetailApprovalPass, - After: config.ReleasePlanStatusExecuting, + Before: beforeStatus, CreatedAt: time.Now().Unix(), } plan.Status = config.ReleasePlanStatusExecuting @@ -1235,11 +1388,19 @@ func ApproveReleasePlan(c *handler.Context, planID string, req *ApproveRequest) sendWebhook = true setReleaseJobsForExecuting(plan) + planLog.After = plan.Status case config.StatusReject: planLog = &models.ReleasePlanLog{ - PlanID: planID, - Detail: DetailApprovalReject, - CreatedAt: time.Now().Unix(), + PlanID: planID, + Username: UserNameSystem, + Account: "", + Verb: VerbUpdate, + TargetName: TargetTypeReleasePlanStatus, + TargetType: TargetTypeReleasePlanStatus, + Detail: DetailApprovalReject, + Before: beforeStatus, + After: config.ReleasePlanStatusApprovalDenied, + CreatedAt: time.Now().Unix(), } plan.Status = config.ReleasePlanStatusApprovalDenied @@ -1261,7 +1422,7 @@ func ApproveReleasePlan(c *handler.Context, planID string, req *ApproveRequest) return } - if err := mongodb.NewReleasePlanLogColl().Create(planLog); err != nil { + if err := createReleasePlanLog(planLog); err != nil { log.Errorf("create release plan log error: %v", err) } }() diff --git a/pkg/microservice/aslan/core/release_plan/service/section_snapshot.go b/pkg/microservice/aslan/core/release_plan/service/section_snapshot.go new file mode 100644 index 0000000000..e8ff0fc07a --- /dev/null +++ b/pkg/microservice/aslan/core/release_plan/service/section_snapshot.go @@ -0,0 +1,303 @@ +package service + +import ( + "context" + "encoding/json" + "strings" + + "github.com/pkg/errors" + + "github.com/koderover/zadig/v2/pkg/microservice/aslan/config" + "github.com/koderover/zadig/v2/pkg/microservice/aslan/core/common/repository/models" + "github.com/koderover/zadig/v2/pkg/microservice/aslan/core/common/repository/mongodb" +) + +const ( + releasePlanVersionSectionPlan = "plan" + releasePlanVersionSectionMetadata = "metadata" + releasePlanVersionSectionApproval = "approval" + releasePlanVersionSectionJobsOrder = "jobs_order" + releasePlanVersionSectionJobPrefix = "job:" +) + +func releasePlanVersionSectionName(sectionKey, fallbackName string) string { + switch { + case sectionKey == releasePlanVersionSectionPlan: + return "发布计划" + case sectionKey == releasePlanVersionSectionMetadata: + return "基础信息" + case sectionKey == releasePlanVersionSectionApproval: + return "审批配置" + case sectionKey == releasePlanVersionSectionJobsOrder: + return "发布内容顺序" + case strings.HasPrefix(sectionKey, releasePlanVersionSectionJobPrefix): + if fallbackName != "" { + return fallbackName + } + return "发布内容" + default: + return fallbackName + } +} + +func releasePlanVersionSectionGroupType(sectionKey string) string { + switch { + case sectionKey == releasePlanVersionSectionMetadata: + return "metadata" + case sectionKey == releasePlanVersionSectionApproval: + return "approval" + case sectionKey == releasePlanVersionSectionJobsOrder: + return "jobs_order" + case strings.HasPrefix(sectionKey, releasePlanVersionSectionJobPrefix): + return "job" + default: + return "plan" + } +} + +func cloneReleasePlan(plan *models.ReleasePlan) (*models.ReleasePlan, error) { + if plan == nil { + return nil, errors.New("nil release plan") + } + + payload, err := json.Marshal(plan) + if err != nil { + return nil, err + } + + resp := new(models.ReleasePlan) + if err := json.Unmarshal(payload, resp); err != nil { + return nil, err + } + return resp, nil +} + +func releasePlanVersionSectionKeyByVerb(planBefore, planAfter *models.ReleasePlan, args *UpdateReleasePlanArgs) (string, string, error) { + if args == nil { + return releasePlanVersionSectionPlan, "发布计划", nil + } + + switch args.Verb { + case VerbUpdateName, VerbUpdateDesc, VerbUpdateTimeRange, VerbUpdateScheduleExecuteTime, VerbUpdateManager, VerbUpdateJiraSprint: + return releasePlanVersionSectionMetadata, "基础信息", nil + case VerbUpdateApproval, VerbDeleteApproval: + return releasePlanVersionSectionApproval, "审批配置", nil + case VerbReorderReleaseJob: + return releasePlanVersionSectionJobsOrder, "发布内容顺序", nil + case VerbUpdateReleaseJob, VerbDeleteReleaseJob: + jobID, _ := extractReleasePlanJobID(args.Spec) + if jobID == "" { + return "", "", errors.New("missing release job id") + } + jobName := releasePlanVersionSectionJobName(planAfter, jobID) + if jobName == "" { + jobName = releasePlanVersionSectionJobName(planBefore, jobID) + } + return releasePlanVersionSectionJobPrefix + jobID, jobName, nil + case VerbCreateReleaseJob: + createdJob := findCreatedReleasePlanJob(planBefore, planAfter) + if createdJob == nil { + return "", "", errors.New("failed to locate created release job") + } + return releasePlanVersionSectionJobPrefix + createdJob.ID, createdJob.Name, nil + default: + return releasePlanVersionSectionPlan, "发布计划", nil + } +} + +func extractReleasePlanJobID(spec interface{}) (string, error) { + if spec == nil { + return "", nil + } + payload, err := json.Marshal(spec) + if err != nil { + return "", err + } + resp := struct { + ID string `json:"id"` + }{} + if err := json.Unmarshal(payload, &resp); err != nil { + return "", err + } + return resp.ID, nil +} + +func releasePlanVersionSectionJobName(plan *models.ReleasePlan, jobID string) string { + if plan == nil { + return "" + } + for _, job := range plan.Jobs { + if job.ID == jobID { + return job.Name + } + } + return "" +} + +func findCreatedReleasePlanJob(planBefore, planAfter *models.ReleasePlan) *models.ReleaseJob { + if planAfter == nil { + return nil + } + beforeJobIDs := make(map[string]struct{}, len(planBefore.Jobs)) + if planBefore != nil { + for _, job := range planBefore.Jobs { + beforeJobIDs[job.ID] = struct{}{} + } + } + for _, job := range planAfter.Jobs { + if _, exists := beforeJobIDs[job.ID]; !exists { + return job + } + } + return nil +} + +func buildReleasePlanVersionSnapshot(plan *models.ReleasePlan, sectionKey string) (interface{}, error) { + if plan == nil { + return nil, nil + } + + switch { + case sectionKey == releasePlanVersionSectionPlan: + return buildReleasePlanInputSnapshot(plan) + case sectionKey == releasePlanVersionSectionMetadata: + return buildReleasePlanMetadataSnapshot(plan), nil + case sectionKey == releasePlanVersionSectionApproval: + return sanitizeReleasePlanValue(plan.Approval), nil + case sectionKey == releasePlanVersionSectionJobsOrder: + return buildReleasePlanJobsOrderSnapshot(plan), nil + case strings.HasPrefix(sectionKey, releasePlanVersionSectionJobPrefix): + jobID := strings.TrimPrefix(sectionKey, releasePlanVersionSectionJobPrefix) + job, err := findReleasePlanJob(plan, jobID) + if err != nil { + return nil, nil + } + return buildReleasePlanJobInputSnapshot(job) + default: + return nil, errors.Errorf("unsupported release plan version section key: %s", sectionKey) + } +} + +func buildReleasePlanInputSnapshot(plan *models.ReleasePlan) (interface{}, error) { + resp := map[string]interface{}{ + "metadata": buildReleasePlanMetadataSnapshot(plan), + "approval": sanitizeReleasePlanValue(plan.Approval), + "jobs": make([]interface{}, 0, len(plan.Jobs)), + } + for _, job := range plan.Jobs { + snapshot, err := buildReleasePlanJobInputSnapshot(job) + if err != nil { + return nil, err + } + resp["jobs"] = append(resp["jobs"].([]interface{}), snapshot) + } + return resp, nil +} + +func buildReleasePlanMetadataSnapshot(plan *models.ReleasePlan) map[string]interface{} { + if plan == nil { + return nil + } + return map[string]interface{}{ + "name": plan.Name, + "manager": plan.Manager, + "manager_id": plan.ManagerID, + "start_time": plan.StartTime, + "end_time": plan.EndTime, + "schedule_execute_time": plan.ScheduleExecuteTime, + "description": plan.Description, + "jira_sprint_association": sanitizeReleasePlanValue(plan.JiraSprintAssociation), + } +} + +func buildReleasePlanJobsOrderSnapshot(plan *models.ReleasePlan) []interface{} { + resp := make([]interface{}, 0) + if plan == nil { + return resp + } + for _, job := range plan.Jobs { + resp = append(resp, map[string]interface{}{ + "id": job.ID, + "name": job.Name, + }) + } + return resp +} + +func buildReleasePlanJobInputSnapshot(job *models.ReleaseJob) (interface{}, error) { + if job == nil { + return nil, nil + } + + spec, err := buildReleasePlanJobInputSpec(job.Type, job.Spec) + if err != nil { + return nil, err + } + + return map[string]interface{}{ + "id": job.ID, + "name": job.Name, + "manager": job.Manager, + "manager_id": job.ManagerID, + "type": job.Type, + "spec": spec, + }, nil +} + +func buildReleasePlanJobInputSpec(jobType config.ReleasePlanJobType, spec interface{}) (interface{}, error) { + switch jobType { + case config.JobText: + inputSpec := new(models.TextReleaseJobSpec) + if err := models.IToi(spec, inputSpec); err != nil { + return nil, err + } + return sanitizeReleasePlanValue(inputSpec), nil + case config.JobWorkflow: + inputSpec := new(models.WorkflowReleaseJobSpec) + if err := models.IToi(spec, inputSpec); err != nil { + return nil, err + } + return sanitizeReleasePlanValue(map[string]interface{}{ + "workflow": inputSpec.Workflow, + }), nil + default: + return sanitizeReleasePlanValue(spec), nil + } +} + +func encodeReleasePlanVersionSnapshot(snapshot interface{}) string { + if snapshot == nil { + return "" + } + payload, err := json.Marshal(snapshot) + if err != nil { + return "" + } + return string(payload) +} + +func decodeReleasePlanVersionSnapshot(snapshot string) (interface{}, error) { + if snapshot == "" { + return nil, nil + } + var resp interface{} + if err := json.Unmarshal([]byte(snapshot), &resp); err != nil { + return nil, err + } + return resp, nil +} + +func releasePlanVersionDiffGroup(sectionKey, sectionName string) (string, string, string) { + return sectionKey, releasePlanVersionSectionName(sectionKey, sectionName), releasePlanVersionSectionGroupType(sectionKey) +} + +func ensureReleasePlanBaselineVersion(ctx context.Context, planID string, currentPlan *models.ReleasePlan) (int64, error) { + if currentPlan.Version != 0 { + return currentPlan.Version, nil + } + currentPlan.Version = 0 + if err := mongodb.NewReleasePlanColl().UpdateVersionByID(ctx, planID, 0); err != nil { + return 0, errors.Wrap(err, "initialize release plan baseline version") + } + return 0, nil +} diff --git a/pkg/microservice/aslan/core/release_plan/service/update.go b/pkg/microservice/aslan/core/release_plan/service/update.go index 8939e7355d..1fa16fe161 100644 --- a/pkg/microservice/aslan/core/release_plan/service/update.go +++ b/pkg/microservice/aslan/core/release_plan/service/update.go @@ -83,6 +83,7 @@ var VerbI18nMap = map[string]string{ VerbUpdate: "Update", VerbDelete: "Delete", VerbExecute: "Execute", + VerbRetry: "Retry", VerbSkip: "Skip", } @@ -221,12 +222,18 @@ func NewTimeRangeUpdater(args *UpdateReleasePlanArgs) (*TimeRangeUpdater, error) return &updater, nil } +func formatReleasePlanDateTime(timestamp int64) string { + if timestamp == 0 { + return "未设置" + } + return time.Unix(timestamp, 0).Format("2006-01-02 15:04:05") +} + func (u *TimeRangeUpdater) Update(plan *models.ReleasePlan) (before interface{}, after interface{}, err error) { - format := "2006-01-02 15:04:05" - before = fmt.Sprintf("%s-%s", time.Unix(plan.StartTime, 0).Format(format), - time.Unix(plan.EndTime, 0).Format(format)) - after = fmt.Sprintf("%s-%s", time.Unix(u.StartTime, 0).Format(format), - time.Unix(u.EndTime, 0).Format(format)) + before = fmt.Sprintf("%s-%s", formatReleasePlanDateTime(plan.StartTime), + formatReleasePlanDateTime(plan.EndTime)) + after = fmt.Sprintf("%s-%s", formatReleasePlanDateTime(u.StartTime), + formatReleasePlanDateTime(u.EndTime)) plan.StartTime = u.StartTime plan.EndTime = u.EndTime return @@ -420,6 +427,8 @@ func (u *DeleteReleaseJobUpdater) Update(plan *models.ReleasePlan) (before inter for i, job := range plan.Jobs { if job.ID == u.ID { u.name = job.Name + before = job + after = nil plan.Jobs = append(plan.Jobs[:i], plan.Jobs[i+1:]...) return } @@ -655,9 +664,8 @@ func NewScheduleExecuteTimeUpdater(args *UpdateReleasePlanArgs) (*ScheduleExecut } func (u *ScheduleExecuteTimeUpdater) Update(plan *models.ReleasePlan) (before interface{}, after interface{}, err error) { - format := "2006-01-02 15:04:05" - before = time.Unix(plan.ScheduleExecuteTime, 0).Format(format) - after = time.Unix(u.ScheduleExecuteTime, 0).Format(format) + before = formatReleasePlanDateTime(plan.ScheduleExecuteTime) + after = formatReleasePlanDateTime(u.ScheduleExecuteTime) plan.ScheduleExecuteTime = u.ScheduleExecuteTime return } diff --git a/pkg/microservice/aslan/core/release_plan/service/version.go b/pkg/microservice/aslan/core/release_plan/service/version.go new file mode 100644 index 0000000000..ec26b39064 --- /dev/null +++ b/pkg/microservice/aslan/core/release_plan/service/version.go @@ -0,0 +1,141 @@ +/* + * Copyright 2026 The KodeRover Authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package service + +import ( + "context" + "time" + + "github.com/pkg/errors" + + "github.com/koderover/zadig/v2/pkg/microservice/aslan/core/common/repository/models" + "github.com/koderover/zadig/v2/pkg/microservice/aslan/core/common/repository/mongodb" +) + +type CommitReleasePlanVersionArgs struct { + SessionID string `json:"session_id"` + SectionKey string `json:"section_key"` +} + +func createReleasePlanVersion(planID string, baseVersion, version int64, baseSnapshot, snapshot interface{}, operator, account, sectionKey, sectionName, verb string) error { + return mongodb.NewReleasePlanVersionColl().Create(&models.ReleasePlanVersion{ + PlanID: planID, + BaseVersion: baseVersion, + Version: version, + Operator: operator, + Account: account, + SectionKey: sectionKey, + SectionName: sectionName, + Verb: verb, + BaseSnapshot: sanitizeReleasePlanValue(baseSnapshot), + Snapshot: sanitizeReleasePlanValue(snapshot), + CreatedAt: time.Now().Unix(), + }) +} + +func CommitReleasePlanVersion(ctx context.Context, planID string, args *CommitReleasePlanVersionArgs, userID, operator, account string) (*models.ReleasePlanVersion, error) { + if args == nil { + return nil, errors.New("nil commit args") + } + if args.SessionID == "" { + return nil, errors.New("empty session id") + } + approveLock := getLock(planID) + approveLock.Lock() + defer approveLock.Unlock() + + plan, err := mongodb.NewReleasePlanColl().GetByID(ctx, planID) + if err != nil { + return nil, errors.Wrap(err, "get plan") + } + if err := validateReleasePlanEditingPlan(plan); err != nil { + return nil, err + } + + session, err := getReleasePlanEditingSession(planID, args.SessionID) + if err != nil { + return nil, errors.Wrap(err, "get editing session") + } + if session.UserID != "" && userID != "" && session.UserID != userID { + return nil, errors.New("editing session does not belong to current user") + } + + pending, err := mongodb.NewReleasePlanLogColl().CountPendingBySessionID(planID, args.SessionID) + if err != nil { + return nil, errors.Wrap(err, "count pending session logs") + } + if pending == 0 { + return &models.ReleasePlanVersion{ + PlanID: planID, + Version: plan.Version, + Operator: operator, + Account: account, + SectionKey: args.SectionKey, + CreatedAt: time.Now().Unix(), + }, nil + } + + baseSnapshot, err := decodeReleasePlanVersionSnapshot(session.BaseSnapshot) + if err != nil { + return nil, errors.Wrap(err, "decode session snapshot") + } + + fromVersion := session.BaseVersion + if plan.Version == 0 { + fromVersion, err = ensureReleasePlanBaselineVersion(ctx, planID, plan) + if err != nil { + return nil, errors.Wrap(err, "ensure baseline version") + } + } + if fromVersion == 0 { + fromVersion = plan.Version + } + + currentVersion, err := mongodb.NewReleasePlanColl().IncrementVersionByID(ctx, planID) + if err != nil { + return nil, errors.Wrap(err, "increment plan version") + } + plan.Version = currentVersion + + currentSnapshot, err := buildReleasePlanVersionSnapshot(plan, args.SectionKey) + if err != nil { + return nil, errors.Wrap(err, "build release plan section snapshot") + } + + if err := createReleasePlanVersion(planID, fromVersion, currentVersion, baseSnapshot, currentSnapshot, operator, account, args.SectionKey, releasePlanVersionSectionName(args.SectionKey, session.SectionName), "commit"); err != nil { + return nil, errors.Wrap(err, "create committed version") + } + if err := mongodb.NewReleasePlanLogColl().FillVersionsBySessionID(planID, args.SessionID, fromVersion, currentVersion); err != nil { + return nil, errors.Wrap(err, "fill log versions") + } + + session.BaseVersion = currentVersion + session.BaseSnapshot = encodeReleasePlanVersionSnapshot(currentSnapshot) + if err := persistReleasePlanEditingSession(session); err != nil { + return nil, errors.Wrap(err, "refresh editing session") + } + + broadcastReleasePlanCollaboration(planID) + return &models.ReleasePlanVersion{ + PlanID: planID, + Version: currentVersion, + Operator: operator, + Account: account, + SectionKey: args.SectionKey, + CreatedAt: time.Now().Unix(), + }, nil +} diff --git a/pkg/microservice/aslan/core/release_plan/service/watcher.go b/pkg/microservice/aslan/core/release_plan/service/watcher.go index d18c6f1087..f532369431 100644 --- a/pkg/microservice/aslan/core/release_plan/service/watcher.go +++ b/pkg/microservice/aslan/core/release_plan/service/watcher.go @@ -285,7 +285,7 @@ func updatePlanApproval(plan *models.ReleasePlan) error { return } - if err := mongodb.NewReleasePlanLogColl().Create(planLog); err != nil { + if err := createReleasePlanLog(planLog); err != nil { log.Errorf("create release plan log error: %v", err) } }() From 2a847f602118f80c8a9a7cb3044d5f9958051e48 Mon Sep 17 00:00:00 2001 From: huanghongbo-hhb Date: Tue, 19 May 2026 18:24:42 +0800 Subject: [PATCH 02/33] chore: remove release plan RFC from PR Signed-off-by: huanghongbo-hhb --- ...e-plan-collaboration-and-versioned-diff.md | 411 ------------------ 1 file changed, 411 deletions(-) delete mode 100644 community/rfc/2026-05-14-release-plan-collaboration-and-versioned-diff.md diff --git a/community/rfc/2026-05-14-release-plan-collaboration-and-versioned-diff.md b/community/rfc/2026-05-14-release-plan-collaboration-and-versioned-diff.md deleted file mode 100644 index 98b4dba4a2..0000000000 --- a/community/rfc/2026-05-14-release-plan-collaboration-and-versioned-diff.md +++ /dev/null @@ -1,411 +0,0 @@ -# 发布计划多人协作与版本化变更展示方案 - -- 作者:KodeRover -- 关联 Issue:TBD -- 日期:2026-05-14 -- 评审人:TBD -- 评审状态:pending - -## 目标 - -这次方案同时解决发布计划的两个需求: - -- 多人协作编辑:用户编辑发布计划时,可以看到有哪些人正在编辑哪些内容。 -- 操作记录细化:用户查看发布计划操作记录时,可以看到这次保存具体改了哪些工作流任务参数。 -- 版本记录:每次配置保存后记录一个版本,版本里只保存这次编辑区块的输入参数快照,后续查看“这次改了什么”时,用这次编辑的前后快照做对比。 - -## 一句话方案 - -不做强锁,也不阻止多人同时编辑。用户编辑时,前端实时展示“谁正在编辑什么”;用户保存后,后端记录一个“编辑区块输入参数版本”;用户点开操作记录详情时,后端比较这次编辑区块的前后快照,把变化整理成按发布内容和工作流任务分组的可读详情。 - -## 用户能看到什么 - -### 正在编辑提示 - -用户进入发布计划详情页后,不会默认显示“正在编辑”。只有当某个用户真正点击某块内容的编辑入口后,其他用户才会看到提示。 - -第一版建议展示这些编辑区块: - -- 基础信息:名称、负责人、发布窗口、定时执行、需求关联。 -- 审批配置。 -- 某一个发布内容。 - -示例: - -```text -huanghongbo 正在编辑基础信息 -patrick 正在编辑发布内容 log-test -2 人正在编辑审批配置 -``` - -### 操作记录详情 - -操作记录列表仍然先展示一句摘要: - -```text -huanghongbo 更新发布内容 log-test -``` - -用户点开详情后,再展示这次保存具体改了什么: - -```text -发布内容:log-test - -构建任务:build -- 代码分支:main -> release/202605 -- 镜像标签:v1.2.3 -> v1.2.4 - -Apollo 任务:update-config -- 命名空间:application -> application-prod -- DB_HOST:10.0.0.1 -> 10.0.0.2 - -DMS 任务:data-change -- SQL 内容:已变更 -``` - -大文本内容,比如脚本、SQL、大段 YAML 或 JSON,第一版默认只展示“已变更”,不在普通操作记录里展开全文。 - -敏感字段沿用工作流本身已有的敏感变量配置,例如 keyvault 的 `is_sensitive` 和工作流变量里的 `is_credential`,只展示“已变更”,不返回原始值。 - -## 前端需要支持什么 - -### 进入编辑态 - -- 用户打开发布计划详情页时,只建立 WebSocket 连接,不立即进入编辑态。 -- 用户点击某个编辑入口后,前端再告诉后端“我正在编辑这一块”。 -- 编辑区块建议和后端保持一致:`metadata`、`approval`、`job:`。 -- 页面上哪些状态可以编辑、哪些入口显示,由前端根据发布计划状态和权限判断;后端收到请求时会再做一次校验。 - -### 维护编辑会话 - -前端需要在一次区块编辑期间维护同一个 `session_id`: - -- 用户进入某个编辑区块时生成或获取一个 `session_id`。 -- 该编辑区块里的所有保存请求都带同一个 `session_id`。 -- 每 10 到 15 秒发送一次心跳,告诉后端“我还在编辑”。 -- 用户取消编辑、关闭弹窗、保存完成或切换编辑区块时,通知后端离开当前编辑态。 - -### 保存配置 - -现有 `verb + spec` 保存方式继续保留。前端在调用保存接口时,需要额外带上 `session_id`: - -```json -{ - "verb": "update_release_job", - "spec": {}, - "session_id": "uuid" -} -``` - -这样后端可以知道这几次 `verb` 保存属于同一次区块编辑。 - -### 提交版本 - -如果采用“按编辑区块合并版本”的方式,前端需要在一次区块编辑完成时调用版本提交接口: - -```json -{ - "session_id": "uuid", - "section_key": "job:job-id" -} -``` - -建议触发时机: - -- 用户点击编辑弹窗里的“保存”或“确定”。 -- 用户关闭编辑弹窗时,如果已经有变更,也需要触发提交。 -- 用户切换到另一个编辑区块前,如果当前区块已有变更,也需要先提交当前区块。 - -这样可以避免一个区块里多次 `verb` 保存生成多个版本。 - -### 展示版本差异 - -操作记录列表仍然先展示摘要。用户点开某条操作记录详情时: - -- 如果该记录包含 `from_version` 和 `to_version`,前端调用版本差异接口。 -- 前端按返回的 `groups` 渲染变更详情。 -- 大文本字段如果标记为 `large_text`,默认展示“已变更”。 -- 敏感字段如果在原始配置里标记为敏感变量,只展示“已变更”,不展示原始值。 -- 如果历史记录没有版本信息,前端继续按旧展示方式处理。 - -## 后端需要支持什么 - -### 实时协作编辑态 - -后端为每个浏览器编辑会话维护一条编辑记录。建议第一版的编辑粒度按内容块划分: - -- `metadata`:基础信息,比如名称、负责人、发布窗口、定时执行、需求关联。 -- `approval`:审批配置。 -- `job:`:某一个发布内容。 - -编辑记录建议包含: - -```go -type ReleasePlanEditingSession struct { - PlanID string `json:"plan_id"` - SessionID string `json:"session_id"` - UserID string `json:"user_id"` - UserName string `json:"user_name"` - Account string `json:"account"` - IdentityType string `json:"identity_type,omitempty"` - Avatar string `json:"avatar,omitempty"` - SectionKey string `json:"section_key"` - SectionType string `json:"section_type"` - SectionName string `json:"section_name"` - BaseVersion int64 `json:"base_version"` - EditingStartedAt int64 `json:"editing_started_at"` - LastHeartbeatAt int64 `json:"last_heartbeat_at"` -} -``` - -说明: - -- `BaseVersion` 表示用户开始编辑时看到的发布计划版本。第一版只用于前端提示,不用于阻断保存。 -- `SectionType` 表示正在编辑哪类内容,前端可以直接判断这是基础信息、审批还是发布内容。 -- `IdentityType` 和 `Avatar` 是可选增强字段,便于前端直接展示“谁正在编辑”。 -- `EditingStartedAt` 表示真正进入编辑态的时间,不等同于页面建立连接的时间。 - -编辑态使用 Redis 保存,并设置自动过期时间。这样即使浏览器异常关闭、断网、服务端连接断开,编辑态也会自动消失,不会一直显示“某人在编辑”。 - -如果 Aslan 是多副本部署,需要用 Redis 做一次“跨 Pod 通知”。某个 Pod 收到用户编辑态变化后,先写 Redis,再发一条 Redis 消息;其他 Pod 收到这条消息后,再推送给自己本地持有的 WebSocket 连接。这样连接在不同 Pod 上的用户也能互相看到编辑状态。 - -`hook` 外部系统配置本身不纳入第一版多人协作提示范围。外部 `hook` 触发后,发布计划可能进入“外部检测”阶段,这个阶段是否还能编辑,由前端决定是否展示编辑入口;后端只负责兜底校验。 - -### 保存与版本化 - -当前后端已经有统一的配置更新接口: - -- `PUT /api/release_plan/v1/:id` -- 请求体使用 `verb + spec` 表示“这次改的是哪一块内容” - -这一套机制建议继续保留,不需要为了版本化把它推翻重做。原因有三个: - -- 现有权限判断就是按 `verb` 分开的。 -- 现有操作记录也是按 `verb` 生成摘要。 -- 第一版版本化只需要挂在“配置保存成功”这个时机上,不要求接口形态改变。 - -保存规则保持“最后保存生效”: - -- 不加硬锁。 -- 不因为其他人正在编辑就拒绝保存。 -- 多人保存同一块内容时,以最后一次成功保存的结果为准。 - -### 版本和保存次数 - -这里需要和前端约定清楚: - -- 版本是按“成功保存一次配置”生成的,不是按“产生一条操作记录”生成的。 -- 配置类保存包括:名称、负责人、时间窗口、审批、发布内容增删改排等。 -- 流程类动作不生成配置版本,比如:状态流转、审批通过/拒绝、执行、重试、跳过、外部检测回调。 - -如果当前前端交互是“用户改一块、点一次保存、发一个 `verb` 请求”,那就自然是一条配置保存对应一个版本。 - -如果采用“按编辑区块合并版本”,则同一个 `session_id` 下的多次 `verb` 保存,最终合并成一个版本。 - -如果后面前端希望改成“整页统一保存”: - -- 可以把多个改动合并后一次提交。 -- 后端一次性应用这些改动。 -- 最终只生成一个版本。 - -这属于前端交互方式变化,不影响版本模型本身。第一版可以先兼容现有单 `verb` 保存模式,后续如果真的需要整页保存,再单独补一个批量保存接口。 - -### 版本生成时机 - -- 用户点击保存配置时,生成新的配置版本。 -- 审批通过、审批拒绝、进入执行、执行完成、外部检测等自动流转,默认只记录状态事件,不生成新的配置版本。 -- 如果未来某个系统流程会直接改动发布计划配置本身,再单独评估是否补充“系统生成版本”。 - -### 发布计划版本模型 - -新增发布计划版本集合。这里不保存完整发布计划,而是只保存“这次编辑区块的输入参数快照”。建议模型: - -```go -type ReleasePlanVersion struct { - ID primitive.ObjectID `bson:"_id,omitempty" json:"id"` - PlanID string `bson:"plan_id" json:"plan_id"` - BaseVersion int64 `bson:"base_version,omitempty" json:"base_version,omitempty"` - Version int64 `bson:"version" json:"version"` - Operator string `bson:"operator" json:"operator"` - Account string `bson:"account" json:"account"` - SectionKey string `bson:"section_key" json:"section_key"` - SectionName string `bson:"section_name" json:"section_name"` - Verb string `bson:"verb" json:"verb"` - BaseSnapshot interface{} `bson:"base_snapshot,omitempty" json:"base_snapshot,omitempty"` - Snapshot interface{} `bson:"snapshot" json:"snapshot"` - CreatedAt int64 `bson:"created_at" json:"created_at"` -} -``` - -说明: - -- `SectionKey` 表示这个版本对应哪个编辑区块,例如 `metadata`、`approval`、`job:`。 -- `BaseVersion` 表示这次编辑会话开始时看到的版本号。 -- `BaseSnapshot` 表示该区块开始编辑时的输入参数快照。 -- `Snapshot` 表示该区块保存完成后的输入参数快照。 - -快照里只保留输入参数,不保留运行态和执行态字段。例如: - -- 基础信息版本只保留名称、负责人、时间窗口、定时执行、需求关联、Jira 关联等输入项。 -- 审批版本只保留审批配置输入,不保留审批实例运行状态。 -- 发布内容版本只保留该发布内容的输入参数,不保留 `status`、`task_id`、`executed_by`、`executed_time` 这类运行字段。 - -这样可以避免因为单条发布计划过大导致版本体积和 diff 计算成本失控。 - -发布计划主表中也建议增加当前版本号: - -```go -Version int64 `bson:"version" json:"version"` -``` - -历史发布计划默认版本可以是 `0`。升级后第一次保存生成 `version = 1`。 - -### 操作日志关联版本 - -现有发布计划操作日志需要关联版本。建议给 `ReleasePlanLog` 增加: - -```go -FromVersion int64 `bson:"from_version,omitempty" json:"from_version,omitempty"` -ToVersion int64 `bson:"to_version,omitempty" json:"to_version,omitempty"` -``` - -操作记录列表仍然保持简洁,例如: - -```text -2026-05-14 10:00 huanghongbo 更新发布内容 log-test v12 -> v13 -``` - -用户点击详情时,前端使用 `plan_id`、`from_version`、`to_version` 请求版本差异。 - -这里的 `from_version` 不一定总是“上一个版本”。它表示这次编辑会话开始时看到的版本。 - -例如: - -- A 基于 `v1` 开始编辑某个发布内容。 -- B 先保存,生成 `v2`。 -- A 继续编辑后再保存,生成 `v3`。 - -那 A 这条操作记录应该关联: - -- `from_version = 1` -- `to_version = 3` - -这样点开详情时,看到的是这次编辑会话对应的区块变更区间,而不是简单的 `v2 -> v3`。 - -这里的版本和状态事件职责分开: - -- 版本主要回答“这次保存后的配置是什么”。 -- 操作日志和状态日志继续回答“发布计划后来经历了什么流转”。 -- 即使自动流转不生成版本,用户仍然可以从最近一次配置版本里查看当时保存下来的区块输入参数。 - -## 变更计算 - -变更计算负责比较一次编辑区块版本的前后快照。设计上先保证所有输入参数变化都能被找出来,再把结果整理成用户能看懂的字段名和分组。 - -第一版建议在用户查看详情时再计算变更内容,而不是在保存时就提前算好: - -- 保存成功时,只负责保存新版本、写操作日志、广播版本变更。 -- 用户点击某条操作记录详情时,后端再根据 `from_version` 和 `to_version` 读取该次编辑区块的前后快照并计算变更内容。 -- 第一版先不做变更结果缓存;如果后续确认详情打开比较慢,再单独评估缓存或后台提前计算。 - -处理流程: - -1. 读取 `to_version` 对应版本里的 `BaseSnapshot` 和 `Snapshot`。 -2. 从外到内逐层比较字段。 -3. 数组优先按“能代表这个元素身份的字段”匹配。 -4. 生成基础差异项。 -5. 对差异项做脱敏、分组和字段翻译。 - -### 对比前的数据整理 - -由于版本里保存的是区块输入参数快照,真正对比之前只需要再过滤少量不适合展示的字段,例如敏感信息和大文本字段,不需要再从整份运行对象里剔除执行状态。 - -### 数组匹配 - -数组不能总是按下标比较。能识别元素身份的数组,应该按身份字段对齐: - -```text -发布内容:id -工作流阶段:name -工作流任务:name + type -工作流参数:name -代码仓库:source + repo_namespace + repo_name + remote_name -构建服务:service_name + service_module -部署服务:service_name + service_module -key/value 数组:key -``` - -如果某类数组没有明确的身份字段,再退回按下标比较。 - -### 字段规则管理 - -字段中文名、忽略哪些字段、哪些字段需要脱敏,这些规则可以放在一张统一的规则表里管理。实现上可以用普通的路径映射表;如果规则特别多,再考虑用 `trie` 这种“按字段路径快速查规则”的结构。 - -这里的 `trie` 只适合做“某个字段路径该怎么处理”的查找,不适合拿来做两个版本内容是否有差异的核心计算。真正找出变化的步骤,还是靠前面的逐层比较。 - -## API 变更 - -### 协作态 - -```http -GET /api/aslan/release_plan/v1/:id/collaboration/ws -GET /api/aslan/release_plan/v1/:id/collaboration/editors -``` - -`editors` 用于页面初始化和 WebSocket 断线重连后的状态恢复。 - -### 版本 - -```http -GET /api/aslan/release_plan/v1/:id/versions -GET /api/aslan/release_plan/v1/:id/versions/:version -GET /api/aslan/release_plan/v1/:id/versions/:from/diff?to=:to -POST /api/aslan/release_plan/v1/:id/versions/commit -``` - -`commit` 接口用于告诉后端“这次区块编辑结束了,可以把这组变更合成一个版本”。 - -### 操作日志 - -现有日志接口保持不变,但返回值增加版本信息: - -```http -GET /api/aslan/release_plan/v1/:id/logs -``` - -每条日志可以包含 `from_version`、`to_version`。 - -## 向后兼容 - -- 现有发布计划 API 保存语义不变。 -- 历史发布计划默认版本号为 `0`。 -- 没有版本信息的历史操作日志仍按旧格式展示。 -- 升级后第一次保存生成第一个版本。 -- 版本差异展示是新增能力,不影响现有保存流程。 - -## 性能考虑 - -- 版本只保存编辑区块的输入参数快照,不保存整份发布计划运行对象。 -- 数组先按身份字段对齐后再比较,避免两两查找导致耗时变长。 -- 大文本字段在普通响应里只标记“已变更”,不做全文对比。 -- 如果一个内容块本身完全没变,就直接跳过,不继续往下比。 -- 用户点开操作详情时再计算变更内容,避免把比较耗时放到保存流程里。 -- 后续如果遇到大对象性能问题,可以先比较每个工作流任务输入参数是否变化;没变化的任务就不继续往下比。 -- 第一版先不做结果缓存,等确认真的有明显性能压力,再考虑补。 - -## 安全与隐私 - -- 敏感字段在返回前必须脱敏,脱敏依据沿用工作流自身已经配置好的敏感变量标记,不额外定义审批人、手机号之类的特殊字段规则。 -- 脱敏规则在生成基础差异项后、返回给前端前执行。 -- 不通过版本差异接口暴露敏感变量原始值。 -- WebSocket 接口需要复用发布计划查看/编辑权限校验。 - -## 实施拆分 - -虽然产品目标是一口气交付完整体验,工程实现仍建议按模块拆: - -1. 增加版本模型,并在保存成功后记录编辑区块的输入参数快照。 -2. 增加 WebSocket 协作态,使用 Redis 自动过期和跨 Pod 通知。 -3. 增加变更计算能力,支持数组按身份字段匹配和敏感字段脱敏。 -4. 增加翻译后的变更详情返回结构,并和操作日志关联。 -5. 首版尽量一次性覆盖已知字段中文标签,未知字段用处理后的字段路径兜底展示,但不改变核心返回格式。 From c5a16fb258ed9feb9b04addc23b39c3c184cae06 Mon Sep 17 00:00:00 2001 From: huanghongbo-hhb Date: Wed, 20 May 2026 13:16:10 +0800 Subject: [PATCH 03/33] refactor: simplify release plan version semantics Signed-off-by: huanghongbo-hhb --- .../common/repository/models/release_plan.go | 27 ++--- .../repository/mongodb/release_plan_log.go | 42 ------- .../aslan/core/release_plan/handler/router.go | 3 +- .../release_plan/service/collaboration.go | 22 +--- .../aslan/core/release_plan/service/diff.go | 76 +++++-------- .../core/release_plan/service/openapi.go | 33 +++--- .../core/release_plan/service/release_plan.go | 28 ++--- .../release_plan/service/section_snapshot.go | 35 ------ .../core/release_plan/service/version.go | 104 +----------------- 9 files changed, 69 insertions(+), 301 deletions(-) diff --git a/pkg/microservice/aslan/core/common/repository/models/release_plan.go b/pkg/microservice/aslan/core/common/repository/models/release_plan.go index f873e385ad..9e8003856c 100644 --- a/pkg/microservice/aslan/core/common/repository/models/release_plan.go +++ b/pkg/microservice/aslan/core/common/repository/models/release_plan.go @@ -121,20 +121,18 @@ type WorkflowReleaseJobSpec struct { } type ReleasePlanLog struct { - ID primitive.ObjectID `bson:"_id,omitempty" json:"id"` - PlanID string `bson:"plan_id" json:"plan_id"` - SessionID string `bson:"session_id,omitempty" json:"session_id,omitempty"` - Username string `bson:"username" json:"username"` - Account string `bson:"account" json:"account"` - Verb string `bson:"verb" json:"verb"` - TargetName string `bson:"target_name" json:"target_name"` - TargetType string `bson:"target_type" json:"target_type"` - Before interface{} `bson:"before" json:"before"` - After interface{} `bson:"after" json:"after"` - Detail string `bson:"detail" json:"detail"` - FromVersion int64 `bson:"from_version,omitempty" json:"from_version,omitempty"` - ToVersion int64 `bson:"to_version,omitempty" json:"to_version,omitempty"` - CreatedAt int64 `bson:"created_at" json:"created_at"` + ID primitive.ObjectID `bson:"_id,omitempty" json:"id"` + PlanID string `bson:"plan_id" json:"plan_id"` + Username string `bson:"username" json:"username"` + Account string `bson:"account" json:"account"` + Verb string `bson:"verb" json:"verb"` + TargetName string `bson:"target_name" json:"target_name"` + TargetType string `bson:"target_type" json:"target_type"` + Before interface{} `bson:"before" json:"before"` + After interface{} `bson:"after" json:"after"` + Detail string `bson:"detail" json:"detail"` + Version int64 `bson:"version,omitempty" json:"version,omitempty"` + CreatedAt int64 `bson:"created_at" json:"created_at"` } func (ReleasePlanLog) TableName() string { @@ -144,7 +142,6 @@ func (ReleasePlanLog) TableName() string { type ReleasePlanVersion struct { ID primitive.ObjectID `bson:"_id,omitempty" json:"id"` PlanID string `bson:"plan_id" json:"plan_id"` - BaseVersion int64 `bson:"base_version,omitempty" json:"base_version,omitempty"` Version int64 `bson:"version" json:"version"` Operator string `bson:"operator" json:"operator"` Account string `bson:"account" json:"account"` diff --git a/pkg/microservice/aslan/core/common/repository/mongodb/release_plan_log.go b/pkg/microservice/aslan/core/common/repository/mongodb/release_plan_log.go index 760c9c3739..0686fa2bed 100644 --- a/pkg/microservice/aslan/core/common/repository/mongodb/release_plan_log.go +++ b/pkg/microservice/aslan/core/common/repository/mongodb/release_plan_log.go @@ -52,9 +52,6 @@ func (c *ReleasePlanLogColl) EnsureIndex(ctx context.Context) error { { Keys: bson.D{{Key: "plan_id", Value: 1}, {Key: "created_at", Value: -1}}, }, - { - Keys: bson.D{{Key: "session_id", Value: 1}}, - }, } _, err := c.Indexes().CreateMany(ctx, mod, mongotool.CreateIndexOptions(ctx)) @@ -104,42 +101,3 @@ func (c *ReleasePlanLogColl) ListByOptions(opt *ListReleasePlanLogOption) ([]*mo return resp, nil } - -func (c *ReleasePlanLogColl) FillVersionsBySessionID(planID, sessionID string, fromVersion, toVersion int64) error { - if sessionID == "" { - return errors.New("empty session id") - } - - query := bson.M{ - "plan_id": planID, - "session_id": sessionID, - "$or": []bson.M{ - {"to_version": bson.M{"$exists": false}}, - {"to_version": 0}, - }, - } - change := bson.M{"$set": bson.M{ - "from_version": fromVersion, - "to_version": toVersion, - }} - - _, err := c.UpdateMany(context.Background(), query, change) - return err -} - -func (c *ReleasePlanLogColl) CountPendingBySessionID(planID, sessionID string) (int64, error) { - if sessionID == "" { - return 0, errors.New("empty session id") - } - - query := bson.M{ - "plan_id": planID, - "session_id": sessionID, - "$or": []bson.M{ - {"to_version": bson.M{"$exists": false}}, - {"to_version": 0}, - }, - } - - return c.CountDocuments(context.Background(), query) -} diff --git a/pkg/microservice/aslan/core/release_plan/handler/router.go b/pkg/microservice/aslan/core/release_plan/handler/router.go index e06142d253..10f8edd91c 100644 --- a/pkg/microservice/aslan/core/release_plan/handler/router.go +++ b/pkg/microservice/aslan/core/release_plan/handler/router.go @@ -31,8 +31,7 @@ func (*Router) Inject(router *gin.RouterGroup) { v1.GET("/:id/collaboration/editors", GetReleasePlanCollaborationEditors) v1.GET("/:id/collaboration/ws", ReleasePlanCollaborationWS) v1.PUT("/:id", UpdateReleasePlan) - v1.POST("/:id/versions/commit", CommitReleasePlanVersion) - v1.GET("/:id/versions/:fromVersion/:toVersion/diff", GetReleasePlanVersionDiff) + v1.GET("/:id/versions/:version/diff", GetReleasePlanVersionDiff) v1.GET("/:id/job/:jobID", GetReleasePlanJobDetail) v1.DELETE("/:id", DeleteReleasePlan) diff --git a/pkg/microservice/aslan/core/release_plan/service/collaboration.go b/pkg/microservice/aslan/core/release_plan/service/collaboration.go index 2f6948e604..acea0abd73 100644 --- a/pkg/microservice/aslan/core/release_plan/service/collaboration.go +++ b/pkg/microservice/aslan/core/release_plan/service/collaboration.go @@ -69,7 +69,6 @@ type ReleasePlanEditingSession struct { SectionType string `json:"section_type"` SectionName string `json:"section_name"` BaseVersion int64 `json:"base_version"` - BaseSnapshot string `json:"base_snapshot,omitempty"` EditingStartedAt int64 `json:"editing_started_at"` LastHeartbeatAt int64 `json:"last_heartbeat_at"` } @@ -291,7 +290,6 @@ func listActiveReleasePlanEditingSessions(planID string) ([]*ReleasePlanEditingS if session.PlanID != planID { continue } - session.BaseSnapshot = "" resp = append(resp, session) } @@ -393,17 +391,6 @@ func OpenReleasePlanCollaborationWS(gCtx *gin.Context, ctx *handler.Context, pla return openReleasePlanCollaborationWS(gCtx, ctx, planID) } -func releasePlanSnapshotString(plan *models.ReleasePlan, sectionKey string) string { - if plan == nil { - return "" - } - sectionSnapshot, err := buildReleasePlanVersionSnapshot(plan, sectionKey) - if err != nil { - return "" - } - return encodeReleasePlanVersionSnapshot(sectionSnapshot) -} - func openReleasePlanCollaborationWS(gCtx *gin.Context, ctx *handler.Context, planID string) error { ws, err := upgrader.Upgrade(gCtx.Writer, gCtx.Request, nil) if err != nil { @@ -462,22 +449,21 @@ func openReleasePlanCollaborationWS(gCtx *gin.Context, ctx *handler.Context, pla SectionType: msg.SectionType, SectionName: msg.SectionName, BaseVersion: msg.BaseVersion, - BaseSnapshot: releasePlanSnapshotString(plan, msg.SectionKey), EditingStartedAt: time.Now().Unix(), } if existingSession != nil { session.EditingStartedAt = existingSession.EditingStartedAt - if existingSession.BaseSnapshot != "" { - session.BaseSnapshot = existingSession.BaseSnapshot - } if session.BaseVersion == 0 { session.BaseVersion = existingSession.BaseVersion } if existingSession.SectionKey != "" && existingSession.SectionKey != msg.SectionKey { session.EditingStartedAt = time.Now().Unix() - session.BaseSnapshot = releasePlanSnapshotString(plan, msg.SectionKey) + session.BaseVersion = 0 } } + if session.BaseVersion == 0 { + session.BaseVersion = plan.Version + } if err := persistReleasePlanEditingSession(session); err != nil { queueCollaborationClientMessage(client, &releasePlanCollabWSOutbound{Type: "error", Error: err.Error()}) continue diff --git a/pkg/microservice/aslan/core/release_plan/service/diff.go b/pkg/microservice/aslan/core/release_plan/service/diff.go index d083e525e6..16752c3c55 100644 --- a/pkg/microservice/aslan/core/release_plan/service/diff.go +++ b/pkg/microservice/aslan/core/release_plan/service/diff.go @@ -36,10 +36,10 @@ const ( ) type ReleasePlanVersionDiffResponse struct { - PlanID string `json:"plan_id"` - FromVersion int64 `json:"from_version"` - ToVersion int64 `json:"to_version"` - Groups []*ReleasePlanVersionDiffGroup `json:"groups"` + PlanID string `json:"plan_id"` + Version int64 `json:"version"` + PreviousVersion int64 `json:"previous_version"` + Groups []*ReleasePlanVersionDiffGroup `json:"groups"` } type ReleasePlanVersionDiffGroup struct { @@ -119,42 +119,22 @@ var releasePlanFieldLabels = map[string]string{ "workwx_approval": "企业微信审批", } -func GetReleasePlanVersionDiff(planID string, fromVersion, toVersion int64) (*ReleasePlanVersionDiffResponse, error) { - to, err := mongodb.NewReleasePlanVersionColl().Get(planID, toVersion) +func GetReleasePlanVersionDiff(planID string, version int64) (*ReleasePlanVersionDiffResponse, error) { + current, err := mongodb.NewReleasePlanVersionColl().Get(planID, version) if err != nil { - return nil, errors.Wrap(err, "get to version") + return nil, errors.Wrap(err, "get version") } - var fromData map[string]interface{} - var toData map[string]interface{} - groupKey, groupName, groupType := releasePlanVersionDiffGroup(to.SectionKey, to.SectionName) - - if to.BaseVersion == fromVersion { - fromData, err = toGenericMap(to.BaseSnapshot) - if err != nil { - return nil, errors.Wrap(err, "convert base snapshot") - } - toData, err = toGenericMap(to.Snapshot) - if err != nil { - return nil, errors.Wrap(err, "convert current snapshot") - } - } else { - if fromVersion == 0 { - return nil, errors.Errorf("release plan baseline diff v0 -> v%d is not available after subsequent version commits", toVersion) - } - from, err := mongodb.NewReleasePlanVersionColl().Get(planID, fromVersion) - if err != nil { - return nil, errors.Wrap(err, "get from version") - } - fromData, err = toGenericMap(from.Snapshot) - if err != nil { - return nil, errors.Wrap(err, "convert from snapshot") - } - toData, err = toGenericMap(to.Snapshot) - if err != nil { - return nil, errors.Wrap(err, "convert to snapshot") - } + fromData, err := toGenericMap(current.BaseSnapshot) + if err != nil { + return nil, errors.Wrap(err, "convert base snapshot") } + toData, err := toGenericMap(current.Snapshot) + if err != nil { + return nil, errors.Wrap(err, "convert current snapshot") + } + + groupKey, groupName, groupType := releasePlanVersionDiffGroup(current.SectionKey, current.SectionName) rawEntries := make([]*releasePlanRawDiffEntry, 0) diffReleasePlanValues("", fromData, toData, &rawEntries) @@ -206,13 +186,20 @@ func GetReleasePlanVersionDiff(planID string, fromVersion, toVersion int64) (*Re } return &ReleasePlanVersionDiffResponse{ - PlanID: planID, - FromVersion: fromVersion, - ToVersion: toVersion, - Groups: groups, + PlanID: planID, + Version: version, + PreviousVersion: previousReleasePlanVersion(version), + Groups: groups, }, nil } +func previousReleasePlanVersion(version int64) int64 { + if version <= 1 { + return 0 + } + return version - 1 +} + func toGenericMap(value interface{}) (map[string]interface{}, error) { if value == nil { return map[string]interface{}{}, nil @@ -502,15 +489,6 @@ func classifyReleasePlanDiffTask(path string) (taskName, taskType string) { return } -func firstReleasePlanBracketSegment(path, prefix string) string { - for _, segment := range strings.Split(path, ".") { - if strings.HasPrefix(segment, prefix+"[") { - return segment - } - } - return prefix -} - func releasePlanBracketSegments(path, prefix string) []string { resp := make([]string, 0) for _, segment := range strings.Split(path, ".") { diff --git a/pkg/microservice/aslan/core/release_plan/service/openapi.go b/pkg/microservice/aslan/core/release_plan/service/openapi.go index 1e87f01c9f..73e90ef046 100644 --- a/pkg/microservice/aslan/core/release_plan/service/openapi.go +++ b/pkg/microservice/aslan/core/release_plan/service/openapi.go @@ -169,7 +169,7 @@ func OpenAPICreateReleasePlan(c *handler.Context, rawArgs *OpenAPICreateReleaseP go func() { sectionSnapshot, err := buildReleasePlanInputSnapshot(args) if err == nil { - err = createReleasePlanVersion(planID, 0, 1, nil, sectionSnapshot, c.UserName, c.Account, releasePlanVersionSectionPlan, releasePlanVersionSectionName(releasePlanVersionSectionPlan, args.Name), VerbCreate) + err = createReleasePlanVersion(planID, 1, nil, sectionSnapshot, c.UserName, c.Account, releasePlanVersionSectionPlan, releasePlanVersionSectionName(releasePlanVersionSectionPlan, args.Name), VerbCreate) } if err != nil { log.Errorf("create release plan version error: %v", err) @@ -181,7 +181,7 @@ func OpenAPICreateReleasePlan(c *handler.Context, rawArgs *OpenAPICreateReleaseP Verb: VerbCreate, TargetName: args.Name, TargetType: TargetTypeReleasePlan, - ToVersion: 1, + Version: 1, CreatedAt: time.Now().Unix(), }); err != nil { log.Errorf("create release plan log error: %v", err) @@ -225,6 +225,10 @@ type OpenAPIWorkflowReleaseJobSpec struct { } func OpenAPICreateReleasePlanWithJobs(c *handler.Context, id string, rawArgs *OpenAPIUpdateReleasePlanWithJobsArgs) error { + approveLock := getLock(id) + approveLock.Lock() + defer approveLock.Unlock() + plan, err := mongodb.NewReleasePlanColl().GetByID(context.Background(), id) if err != nil { return errors.Wrap(err, "get release plan error") @@ -374,11 +378,7 @@ func OpenAPICreateReleasePlanWithJobs(c *handler.Context, id string, rawArgs *Op } plan.Jobs = newJobs - fromVersion, err := ensureReleasePlanBaselineVersion(c, id, plan) - if err != nil { - return errors.Wrap(err, "ensure release plan baseline version") - } - plan.Version = fromVersion + 1 + plan.Version = originalPlan.Version + 1 err = mongodb.NewReleasePlanColl().UpdateByID(c, id, plan) if err != nil { @@ -393,19 +393,18 @@ func OpenAPICreateReleasePlanWithJobs(c *handler.Context, id string, rawArgs *Op if err != nil { return errors.Wrap(err, "build release plan current snapshot") } - if err := createReleasePlanVersion(plan.ID.Hex(), fromVersion, plan.Version, baseSnapshot, currentSnapshot, c.UserName, c.Account, releasePlanVersionSectionPlan, releasePlanVersionSectionName(releasePlanVersionSectionPlan, plan.Name), VerbUpdate); err != nil { + if err := createReleasePlanVersion(plan.ID.Hex(), plan.Version, baseSnapshot, currentSnapshot, c.UserName, c.Account, releasePlanVersionSectionPlan, releasePlanVersionSectionName(releasePlanVersionSectionPlan, plan.Name), VerbUpdate); err != nil { log.Errorf("create release plan version error: %v", err) } if err := createReleasePlanLog(&models.ReleasePlanLog{ - PlanID: plan.ID.Hex(), - Username: c.UserName, - Account: c.Account, - Verb: VerbUpdate, - TargetName: plan.Name, - TargetType: TargetTypeReleasePlan, - FromVersion: fromVersion, - ToVersion: plan.Version, - CreatedAt: time.Now().Unix(), + PlanID: plan.ID.Hex(), + Username: c.UserName, + Account: c.Account, + Verb: VerbUpdate, + TargetName: plan.Name, + TargetType: TargetTypeReleasePlan, + Version: plan.Version, + CreatedAt: time.Now().Unix(), }); err != nil { log.Errorf("create release plan log error: %v", err) } diff --git a/pkg/microservice/aslan/core/release_plan/service/release_plan.go b/pkg/microservice/aslan/core/release_plan/service/release_plan.go index 075d2a3b2a..79c6041473 100644 --- a/pkg/microservice/aslan/core/release_plan/service/release_plan.go +++ b/pkg/microservice/aslan/core/release_plan/service/release_plan.go @@ -134,7 +134,7 @@ func CreateReleasePlan(c *handler.Context, args *models.ReleasePlan) error { go func() { sectionSnapshot, err := buildReleasePlanInputSnapshot(args) if err == nil { - err = createReleasePlanVersion(planID, 0, 1, nil, sectionSnapshot, c.UserName, c.Account, releasePlanVersionSectionPlan, releasePlanVersionSectionName(releasePlanVersionSectionPlan, args.Name), VerbCreate) + err = createReleasePlanVersion(planID, 1, nil, sectionSnapshot, c.UserName, c.Account, releasePlanVersionSectionPlan, releasePlanVersionSectionName(releasePlanVersionSectionPlan, args.Name), VerbCreate) } if err != nil { log.Errorf("create release plan version error: %v", err) @@ -146,7 +146,7 @@ func CreateReleasePlan(c *handler.Context, args *models.ReleasePlan) error { Verb: VerbCreate, TargetName: args.Name, TargetType: TargetTypeReleasePlan, - ToVersion: 1, + Version: 1, CreatedAt: time.Now().Unix(), }); err != nil { log.Errorf("create release plan log error: %v", err) @@ -406,9 +406,8 @@ const ( ) type UpdateReleasePlanArgs struct { - Verb UpdateReleasePlanVerb `json:"verb"` - Spec interface{} `json:"spec"` - SessionID string `json:"session_id,omitempty"` + Verb UpdateReleasePlanVerb `json:"verb"` + Spec interface{} `json:"spec"` } func UpdateReleasePlan(c *handler.Context, planID string, args *UpdateReleasePlanArgs) error { @@ -457,14 +456,7 @@ func UpdateReleasePlan(c *handler.Context, planID string, args *UpdateReleasePla return errors.Wrap(err, "build release plan current snapshot") } - var fromVersion int64 - if args.SessionID == "" { - fromVersion, err = ensureReleasePlanBaselineVersion(ctx, planID, plan) - if err != nil { - return errors.Wrap(err, "ensure release plan baseline version") - } - plan.Version = fromVersion + 1 - } + plan.Version = originalPlan.Version + 1 plan.UpdatedBy = c.UserName plan.UpdateTime = time.Now().Unix() @@ -491,7 +483,6 @@ func UpdateReleasePlan(c *handler.Context, planID string, args *UpdateReleasePla logItem := &models.ReleasePlanLog{ PlanID: planID, - SessionID: args.SessionID, Username: c.UserName, Account: c.Account, Verb: updater.Verb(), @@ -499,14 +490,11 @@ func UpdateReleasePlan(c *handler.Context, planID string, args *UpdateReleasePla After: after, TargetName: updater.TargetName(), TargetType: updater.TargetType(), + Version: plan.Version, CreatedAt: time.Now().Unix(), } - if args.SessionID == "" { - logItem.FromVersion = fromVersion - logItem.ToVersion = plan.Version - if err := createReleasePlanVersion(planID, fromVersion, plan.Version, baseSnapshot, currentSnapshot, c.UserName, c.Account, sectionKey, releasePlanVersionSectionName(sectionKey, sectionName), string(args.Verb)); err != nil { - log.Errorf("create release plan version error: %v", err) - } + if err := createReleasePlanVersion(planID, plan.Version, baseSnapshot, currentSnapshot, c.UserName, c.Account, sectionKey, releasePlanVersionSectionName(sectionKey, sectionName), string(args.Verb)); err != nil { + log.Errorf("create release plan version error: %v", err) } if err := createReleasePlanLog(logItem); err != nil { log.Errorf("create release plan log error: %v", err) diff --git a/pkg/microservice/aslan/core/release_plan/service/section_snapshot.go b/pkg/microservice/aslan/core/release_plan/service/section_snapshot.go index e8ff0fc07a..fa5581097f 100644 --- a/pkg/microservice/aslan/core/release_plan/service/section_snapshot.go +++ b/pkg/microservice/aslan/core/release_plan/service/section_snapshot.go @@ -1,7 +1,6 @@ package service import ( - "context" "encoding/json" "strings" @@ -9,7 +8,6 @@ import ( "github.com/koderover/zadig/v2/pkg/microservice/aslan/config" "github.com/koderover/zadig/v2/pkg/microservice/aslan/core/common/repository/models" - "github.com/koderover/zadig/v2/pkg/microservice/aslan/core/common/repository/mongodb" ) const ( @@ -265,39 +263,6 @@ func buildReleasePlanJobInputSpec(jobType config.ReleasePlanJobType, spec interf } } -func encodeReleasePlanVersionSnapshot(snapshot interface{}) string { - if snapshot == nil { - return "" - } - payload, err := json.Marshal(snapshot) - if err != nil { - return "" - } - return string(payload) -} - -func decodeReleasePlanVersionSnapshot(snapshot string) (interface{}, error) { - if snapshot == "" { - return nil, nil - } - var resp interface{} - if err := json.Unmarshal([]byte(snapshot), &resp); err != nil { - return nil, err - } - return resp, nil -} - func releasePlanVersionDiffGroup(sectionKey, sectionName string) (string, string, string) { return sectionKey, releasePlanVersionSectionName(sectionKey, sectionName), releasePlanVersionSectionGroupType(sectionKey) } - -func ensureReleasePlanBaselineVersion(ctx context.Context, planID string, currentPlan *models.ReleasePlan) (int64, error) { - if currentPlan.Version != 0 { - return currentPlan.Version, nil - } - currentPlan.Version = 0 - if err := mongodb.NewReleasePlanColl().UpdateVersionByID(ctx, planID, 0); err != nil { - return 0, errors.Wrap(err, "initialize release plan baseline version") - } - return 0, nil -} diff --git a/pkg/microservice/aslan/core/release_plan/service/version.go b/pkg/microservice/aslan/core/release_plan/service/version.go index ec26b39064..0373dde909 100644 --- a/pkg/microservice/aslan/core/release_plan/service/version.go +++ b/pkg/microservice/aslan/core/release_plan/service/version.go @@ -17,24 +17,15 @@ package service import ( - "context" "time" - "github.com/pkg/errors" - "github.com/koderover/zadig/v2/pkg/microservice/aslan/core/common/repository/models" "github.com/koderover/zadig/v2/pkg/microservice/aslan/core/common/repository/mongodb" ) -type CommitReleasePlanVersionArgs struct { - SessionID string `json:"session_id"` - SectionKey string `json:"section_key"` -} - -func createReleasePlanVersion(planID string, baseVersion, version int64, baseSnapshot, snapshot interface{}, operator, account, sectionKey, sectionName, verb string) error { +func createReleasePlanVersion(planID string, version int64, baseSnapshot, snapshot interface{}, operator, account, sectionKey, sectionName, verb string) error { return mongodb.NewReleasePlanVersionColl().Create(&models.ReleasePlanVersion{ PlanID: planID, - BaseVersion: baseVersion, Version: version, Operator: operator, Account: account, @@ -46,96 +37,3 @@ func createReleasePlanVersion(planID string, baseVersion, version int64, baseSna CreatedAt: time.Now().Unix(), }) } - -func CommitReleasePlanVersion(ctx context.Context, planID string, args *CommitReleasePlanVersionArgs, userID, operator, account string) (*models.ReleasePlanVersion, error) { - if args == nil { - return nil, errors.New("nil commit args") - } - if args.SessionID == "" { - return nil, errors.New("empty session id") - } - approveLock := getLock(planID) - approveLock.Lock() - defer approveLock.Unlock() - - plan, err := mongodb.NewReleasePlanColl().GetByID(ctx, planID) - if err != nil { - return nil, errors.Wrap(err, "get plan") - } - if err := validateReleasePlanEditingPlan(plan); err != nil { - return nil, err - } - - session, err := getReleasePlanEditingSession(planID, args.SessionID) - if err != nil { - return nil, errors.Wrap(err, "get editing session") - } - if session.UserID != "" && userID != "" && session.UserID != userID { - return nil, errors.New("editing session does not belong to current user") - } - - pending, err := mongodb.NewReleasePlanLogColl().CountPendingBySessionID(planID, args.SessionID) - if err != nil { - return nil, errors.Wrap(err, "count pending session logs") - } - if pending == 0 { - return &models.ReleasePlanVersion{ - PlanID: planID, - Version: plan.Version, - Operator: operator, - Account: account, - SectionKey: args.SectionKey, - CreatedAt: time.Now().Unix(), - }, nil - } - - baseSnapshot, err := decodeReleasePlanVersionSnapshot(session.BaseSnapshot) - if err != nil { - return nil, errors.Wrap(err, "decode session snapshot") - } - - fromVersion := session.BaseVersion - if plan.Version == 0 { - fromVersion, err = ensureReleasePlanBaselineVersion(ctx, planID, plan) - if err != nil { - return nil, errors.Wrap(err, "ensure baseline version") - } - } - if fromVersion == 0 { - fromVersion = plan.Version - } - - currentVersion, err := mongodb.NewReleasePlanColl().IncrementVersionByID(ctx, planID) - if err != nil { - return nil, errors.Wrap(err, "increment plan version") - } - plan.Version = currentVersion - - currentSnapshot, err := buildReleasePlanVersionSnapshot(plan, args.SectionKey) - if err != nil { - return nil, errors.Wrap(err, "build release plan section snapshot") - } - - if err := createReleasePlanVersion(planID, fromVersion, currentVersion, baseSnapshot, currentSnapshot, operator, account, args.SectionKey, releasePlanVersionSectionName(args.SectionKey, session.SectionName), "commit"); err != nil { - return nil, errors.Wrap(err, "create committed version") - } - if err := mongodb.NewReleasePlanLogColl().FillVersionsBySessionID(planID, args.SessionID, fromVersion, currentVersion); err != nil { - return nil, errors.Wrap(err, "fill log versions") - } - - session.BaseVersion = currentVersion - session.BaseSnapshot = encodeReleasePlanVersionSnapshot(currentSnapshot) - if err := persistReleasePlanEditingSession(session); err != nil { - return nil, errors.Wrap(err, "refresh editing session") - } - - broadcastReleasePlanCollaboration(planID) - return &models.ReleasePlanVersion{ - PlanID: planID, - Version: currentVersion, - Operator: operator, - Account: account, - SectionKey: args.SectionKey, - CreatedAt: time.Now().Unix(), - }, nil -} From b75af25eb9f1b36372de39617b9749ca01056c6b Mon Sep 17 00:00:00 2001 From: huanghongbo-hhb Date: Wed, 20 May 2026 18:18:32 +0800 Subject: [PATCH 04/33] chore: extend release plan collaboration ttl Signed-off-by: huanghongbo-hhb --- .../aslan/core/release_plan/service/collaboration.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/microservice/aslan/core/release_plan/service/collaboration.go b/pkg/microservice/aslan/core/release_plan/service/collaboration.go index acea0abd73..77f801e66b 100644 --- a/pkg/microservice/aslan/core/release_plan/service/collaboration.go +++ b/pkg/microservice/aslan/core/release_plan/service/collaboration.go @@ -45,7 +45,7 @@ const ( releasePlanCollabSessionKeyPrefix = "release-plan:collab:session:" releasePlanCollabPlanSetPrefix = "release-plan:collab:plan:" releasePlanCollabBroadcastChannel = "release-plan-collaboration" - releasePlanCollabSessionTTL = 45 * time.Second + releasePlanCollabSessionTTL = 90 * time.Second releasePlanCollabBroadcastTTL = 5 * time.Minute ) From 663a3cb595ef8bb8c3240487df06298337df6882 Mon Sep 17 00:00:00 2001 From: huanghongbo-hhb Date: Thu, 21 May 2026 10:01:31 +0800 Subject: [PATCH 05/33] fix: align release plan approval logs in watcher Signed-off-by: huanghongbo-hhb --- .../aslan/core/release_plan/service/watcher.go | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/pkg/microservice/aslan/core/release_plan/service/watcher.go b/pkg/microservice/aslan/core/release_plan/service/watcher.go index f532369431..8eb733fabf 100644 --- a/pkg/microservice/aslan/core/release_plan/service/watcher.go +++ b/pkg/microservice/aslan/core/release_plan/service/watcher.go @@ -229,16 +229,18 @@ func updatePlanApproval(plan *models.ReleasePlan) error { return errors.Errorf("update plan %s approval error: %v", plan.Name, err) } var planLog *models.ReleasePlanLog + beforeStatus := config.ReleasePlanStatusWaitForApprove switch plan.Approval.Status { case config.StatusPassed: planLog = &models.ReleasePlanLog{ PlanID: plan.ID.Hex(), Username: UserNameSystem, + Account: "", Verb: VerbUpdate, TargetName: TargetTypeReleasePlanStatus, TargetType: TargetTypeReleasePlanStatus, Detail: DetailApprovalPass, - After: config.ReleasePlanStatusExecuting, + Before: beforeStatus, CreatedAt: time.Now().Unix(), } @@ -260,11 +262,19 @@ func updatePlanApproval(plan *models.ReleasePlan) error { sendWebhook = true setReleaseJobsForExecuting(plan) + planLog.After = plan.Status case config.StatusReject: planLog = &models.ReleasePlanLog{ - PlanID: plan.ID.Hex(), - Detail: DetailApprovalReject, - CreatedAt: time.Now().Unix(), + PlanID: plan.ID.Hex(), + Username: UserNameSystem, + Account: "", + Verb: VerbUpdate, + TargetName: TargetTypeReleasePlanStatus, + TargetType: TargetTypeReleasePlanStatus, + Detail: DetailApprovalReject, + Before: beforeStatus, + After: config.ReleasePlanStatusApprovalDenied, + CreatedAt: time.Now().Unix(), } plan.Status = config.ReleasePlanStatusApprovalDenied plan.ApprovalTime = time.Now().Unix() From 4d0791cc9e16ee55ba8957e29774e9e71129d919 Mon Sep 17 00:00:00 2001 From: huanghongbo-hhb Date: Thu, 21 May 2026 11:21:56 +0800 Subject: [PATCH 06/33] fix: harden release plan collaboration flows Signed-off-by: huanghongbo-hhb --- pkg/cli/initconfig/cmd/init.go | 1 + .../release_plan/service/collaboration.go | 91 ++++++++++++- .../service/collaboration_test.go | 80 +++++++++++ .../aslan/core/release_plan/service/diff.go | 10 +- .../core/release_plan/service/diff_test.go | 127 ++++++++++++++++++ .../core/release_plan/service/masking_test.go | 49 +++++++ .../core/release_plan/service/release_plan.go | 2 + .../aslan/core/release_plan/service/update.go | 6 +- .../core/release_plan/service/watcher.go | 1 + 9 files changed, 358 insertions(+), 9 deletions(-) create mode 100644 pkg/microservice/aslan/core/release_plan/service/collaboration_test.go create mode 100644 pkg/microservice/aslan/core/release_plan/service/diff_test.go create mode 100644 pkg/microservice/aslan/core/release_plan/service/masking_test.go diff --git a/pkg/cli/initconfig/cmd/init.go b/pkg/cli/initconfig/cmd/init.go index 8ab70ad404..ad8296e7ca 100644 --- a/pkg/cli/initconfig/cmd/init.go +++ b/pkg/cli/initconfig/cmd/init.go @@ -187,6 +187,7 @@ func createOrUpdateMongodbIndex(ctx context.Context) { commonrepo.NewLLMIntegrationColl(), commonrepo.NewReleasePlanColl(), commonrepo.NewReleasePlanLogColl(), + commonrepo.NewReleasePlanVersionColl(), commonrepo.NewEnvServiceVersionColl(), commonrepo.NewLabelColl(), commonrepo.NewSprintTemplateColl(), diff --git a/pkg/microservice/aslan/core/release_plan/service/collaboration.go b/pkg/microservice/aslan/core/release_plan/service/collaboration.go index 77f801e66b..2eb84b0ef2 100644 --- a/pkg/microservice/aslan/core/release_plan/service/collaboration.go +++ b/pkg/microservice/aslan/core/release_plan/service/collaboration.go @@ -20,7 +20,9 @@ import ( "context" "encoding/json" "fmt" + "net" "net/http" + "net/url" "sort" "strings" "sync" @@ -52,9 +54,7 @@ const ( var upgrader = websocket.Upgrader{ ReadBufferSize: 1024, WriteBufferSize: 1024, - CheckOrigin: func(r *http.Request) bool { - return true - }, + CheckOrigin: checkReleasePlanCollaborationOrigin, } type ReleasePlanEditingSession struct { @@ -141,6 +141,68 @@ func releasePlanCollabPlanSetKey(planID string) string { return fmt.Sprintf("%s%s:sessions", releasePlanCollabPlanSetPrefix, planID) } +func checkReleasePlanCollaborationOrigin(r *http.Request) bool { + if r == nil { + return false + } + + origin := strings.TrimSpace(r.Header.Get("Origin")) + if origin == "" { + return true + } + + originURL, err := url.Parse(origin) + if err != nil { + return false + } + + expectedHost := releasePlanRequestHost(r) + if expectedHost == "" { + return false + } + + originHost, originPort := splitReleasePlanHostPort(originURL.Host) + requestHost, requestPort := splitReleasePlanHostPort(expectedHost) + if originHost == "" || requestHost == "" { + return false + } + if !strings.EqualFold(originHost, requestHost) { + return false + } + if originPort != "" && requestPort != "" && originPort != requestPort { + return false + } + + return true +} + +func releasePlanRequestHost(r *http.Request) string { + if r == nil { + return "" + } + if forwardedHost := strings.TrimSpace(r.Header.Get("X-Forwarded-Host")); forwardedHost != "" { + if idx := strings.Index(forwardedHost, ","); idx >= 0 { + forwardedHost = forwardedHost[:idx] + } + return strings.TrimSpace(forwardedHost) + } + return strings.TrimSpace(r.Host) +} + +func splitReleasePlanHostPort(rawHost string) (string, string) { + rawHost = strings.TrimSpace(rawHost) + if rawHost == "" { + return "", "" + } + + if host, port, err := net.SplitHostPort(rawHost); err == nil { + return strings.ToLower(host), port + } + + parsed := &url.URL{Host: rawHost} + return strings.ToLower(parsed.Hostname()), parsed.Port() +} + func broadcastReleasePlanCollaboration(planID string) { if planID == "" { return @@ -387,6 +449,16 @@ func getReleasePlanEditingSession(planID, sessionID string) (*ReleasePlanEditing return session, nil } +func canManageReleasePlanEditingSession(session *ReleasePlanEditingSession, userID string, isSystemAdmin bool) bool { + if isSystemAdmin { + return true + } + if session == nil || userID == "" { + return false + } + return session.UserID == userID +} + func OpenReleasePlanCollaborationWS(gCtx *gin.Context, ctx *handler.Context, planID string) error { return openReleasePlanCollaborationWS(gCtx, ctx, planID) } @@ -438,6 +510,10 @@ func openReleasePlanCollaborationWS(gCtx *gin.Context, ctx *handler.Context, pla continue } existingSession, _ := getReleasePlanEditingSession(planID, msg.SessionID) + if existingSession != nil && !canManageReleasePlanEditingSession(existingSession, ctx.UserID, ctx.Resources != nil && ctx.Resources.IsSystemAdmin) { + queueCollaborationClientMessage(client, &releasePlanCollabWSOutbound{Type: "error", Error: "permission denied"}) + continue + } session := &ReleasePlanEditingSession{ PlanID: planID, SessionID: msg.SessionID, @@ -473,6 +549,15 @@ func openReleasePlanCollaborationWS(gCtx *gin.Context, ctx *handler.Context, pla queueCollaborationClientMessage(client, &releasePlanCollabWSOutbound{Type: "snapshot", Snapshot: snapshot}) } case "leave": + session, err := getReleasePlanEditingSession(planID, msg.SessionID) + if err != nil { + queueCollaborationClientMessage(client, &releasePlanCollabWSOutbound{Type: "error", Error: err.Error()}) + continue + } + if !canManageReleasePlanEditingSession(session, ctx.UserID, ctx.Resources != nil && ctx.Resources.IsSystemAdmin) { + queueCollaborationClientMessage(client, &releasePlanCollabWSOutbound{Type: "error", Error: "permission denied"}) + continue + } if err := removeReleasePlanEditingSession(planID, msg.SessionID); err != nil { queueCollaborationClientMessage(client, &releasePlanCollabWSOutbound{Type: "error", Error: err.Error()}) } diff --git a/pkg/microservice/aslan/core/release_plan/service/collaboration_test.go b/pkg/microservice/aslan/core/release_plan/service/collaboration_test.go new file mode 100644 index 0000000000..b6628c6efc --- /dev/null +++ b/pkg/microservice/aslan/core/release_plan/service/collaboration_test.go @@ -0,0 +1,80 @@ +package service + +import ( + "net/http" + "testing" +) + +func TestCanManageReleasePlanEditingSession(t *testing.T) { + session := &ReleasePlanEditingSession{ + SessionID: "session-1", + UserID: "owner", + } + + if !canManageReleasePlanEditingSession(session, "owner", false) { + t.Fatalf("expected session owner to manage editing session") + } + if canManageReleasePlanEditingSession(session, "viewer", false) { + t.Fatalf("expected non-owner to be denied") + } + if !canManageReleasePlanEditingSession(session, "viewer", true) { + t.Fatalf("expected system admin to manage editing session") + } + if canManageReleasePlanEditingSession(nil, "owner", false) { + t.Fatalf("expected nil session to be denied") + } +} + +func TestCheckReleasePlanCollaborationOrigin(t *testing.T) { + t.Run("allow empty origin", func(t *testing.T) { + req, err := http.NewRequest(http.MethodGet, "http://zadig.example.com", nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + req.Host = "zadig.example.com" + + if !checkReleasePlanCollaborationOrigin(req) { + t.Fatalf("expected empty origin to be allowed") + } + }) + + t.Run("allow same host", func(t *testing.T) { + req, err := http.NewRequest(http.MethodGet, "http://internal", nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + req.Host = "zadig.example.com" + req.Header.Set("Origin", "https://zadig.example.com") + + if !checkReleasePlanCollaborationOrigin(req) { + t.Fatalf("expected same origin host to be allowed") + } + }) + + t.Run("allow forwarded host", func(t *testing.T) { + req, err := http.NewRequest(http.MethodGet, "http://internal", nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + req.Host = "aslan:25000" + req.Header.Set("X-Forwarded-Host", "zadig.example.com") + req.Header.Set("Origin", "https://zadig.example.com") + + if !checkReleasePlanCollaborationOrigin(req) { + t.Fatalf("expected forwarded host to be honored") + } + }) + + t.Run("reject cross origin host", func(t *testing.T) { + req, err := http.NewRequest(http.MethodGet, "http://zadig.example.com", nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + req.Host = "zadig.example.com" + req.Header.Set("Origin", "https://evil.example.com") + + if checkReleasePlanCollaborationOrigin(req) { + t.Fatalf("expected cross origin host to be rejected") + } + }) +} diff --git a/pkg/microservice/aslan/core/release_plan/service/diff.go b/pkg/microservice/aslan/core/release_plan/service/diff.go index 16752c3c55..a2ed26c49c 100644 --- a/pkg/microservice/aslan/core/release_plan/service/diff.go +++ b/pkg/microservice/aslan/core/release_plan/service/diff.go @@ -125,11 +125,11 @@ func GetReleasePlanVersionDiff(planID string, version int64) (*ReleasePlanVersio return nil, errors.Wrap(err, "get version") } - fromData, err := toGenericMap(current.BaseSnapshot) + fromData, err := toGenericValue(current.BaseSnapshot) if err != nil { return nil, errors.Wrap(err, "convert base snapshot") } - toData, err := toGenericMap(current.Snapshot) + toData, err := toGenericValue(current.Snapshot) if err != nil { return nil, errors.Wrap(err, "convert current snapshot") } @@ -200,15 +200,15 @@ func previousReleasePlanVersion(version int64) int64 { return version - 1 } -func toGenericMap(value interface{}) (map[string]interface{}, error) { +func toGenericValue(value interface{}) (interface{}, error) { if value == nil { - return map[string]interface{}{}, nil + return nil, nil } payload, err := json.Marshal(value) if err != nil { return nil, err } - resp := map[string]interface{}{} + var resp interface{} if err := json.Unmarshal(payload, &resp); err != nil { return nil, err } diff --git a/pkg/microservice/aslan/core/release_plan/service/diff_test.go b/pkg/microservice/aslan/core/release_plan/service/diff_test.go new file mode 100644 index 0000000000..15053c8041 --- /dev/null +++ b/pkg/microservice/aslan/core/release_plan/service/diff_test.go @@ -0,0 +1,127 @@ +package service + +import "testing" + +func TestGetReleasePlanArrayItemKey(t *testing.T) { + t.Run("job key", func(t *testing.T) { + key, ok := getReleasePlanArrayItemKey(map[string]interface{}{ + "name": "build", + "type": "zadig-build", + "id": "job-id", + }) + if !ok { + t.Fatalf("expected key") + } + if key != "build|zadig-build|job-id" { + t.Fatalf("unexpected key: %s", key) + } + }) + + t.Run("service key", func(t *testing.T) { + key, ok := getReleasePlanArrayItemKey(map[string]interface{}{ + "service_name": "gateway", + "service_module": "gateway", + }) + if !ok { + t.Fatalf("expected key") + } + if key != "gateway/gateway" { + t.Fatalf("unexpected key: %s", key) + } + }) +} + +func TestBuildReleasePlanDiffLabel(t *testing.T) { + label := buildReleasePlanDiffLabel("jobs[release-job|workflow|job-id].spec.workflow.stages[build].jobs[deploy|zadig-deploy].spec.namespace") + expected := "任务 release-job / 阶段 build / 任务 deploy / 命名空间" + if label != expected { + t.Fatalf("unexpected label: %s", label) + } +} + +func TestReleasePlanDiffPathRules(t *testing.T) { + if !shouldIgnoreReleasePlanDiffPath("update_time") { + t.Fatalf("expected update_time to be ignored") + } + if !isLargeTextReleasePlanDiffPath("jobs[deploy].spec.script", "echo 1", "echo 2") { + t.Fatalf("expected script to be marked as large text") + } +} + +func TestReleasePlanSubtreeHashPrune(t *testing.T) { + left := map[string]interface{}{ + "a": 1.0, + "b": "x", + "c": []interface{}{map[string]interface{}{"id": "1"}, map[string]interface{}{"id": "2"}}, + "d": map[string]interface{}{"name": "demo"}, + } + right := map[string]interface{}{ + "a": 1.0, + "b": "x", + "c": []interface{}{map[string]interface{}{"id": "1"}, map[string]interface{}{"id": "2"}}, + "d": map[string]interface{}{"name": "demo"}, + } + + equal, hashed := equalReleasePlanSubtreeByHash(left, right) + if !hashed { + t.Fatalf("expected hash pruning to be enabled for large maps") + } + if !equal { + t.Fatalf("expected identical subtrees to be equal") + } +} + +func TestReleasePlanSubtreeHashPruneSkipSmallNodes(t *testing.T) { + left := map[string]interface{}{"a": 1.0, "b": 2.0} + right := map[string]interface{}{"a": 1.0, "b": 2.0} + + equal, hashed := equalReleasePlanSubtreeByHash(left, right) + if hashed { + t.Fatalf("expected hash pruning to skip small maps") + } + if equal { + t.Fatalf("hash shortcut should not report equality for skipped small maps") + } +} + +func TestToGenericValueSupportsRootArrays(t *testing.T) { + value := []map[string]interface{}{ + {"id": "job-1", "name": "job-a"}, + {"id": "job-2", "name": "job-b"}, + } + + generic, err := toGenericValue(value) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + items, ok := generic.([]interface{}) + if !ok { + t.Fatalf("expected array root, got %T", generic) + } + if len(items) != 2 { + t.Fatalf("unexpected item count: %d", len(items)) + } +} + +func TestSanitizeReleasePlanValueForDisplay(t *testing.T) { + value := map[string]interface{}{ + "vars": []interface{}{ + map[string]interface{}{ + "key": "DB_PASSWORD", + "value": "secret-token", + "is_credential": true, + }, + }, + } + + sanitized := sanitizeReleasePlanValueForDisplay(value).(map[string]interface{}) + vars := sanitized["vars"].([]interface{}) + item := vars[0].(map[string]interface{}) + if item["value"] != releasePlanMaskedValueDisplay { + t.Fatalf("expected credential value to be hidden") + } + if item["key"] != "DB_PASSWORD" { + t.Fatalf("expected non-sensitive fields to stay visible") + } +} diff --git a/pkg/microservice/aslan/core/release_plan/service/masking_test.go b/pkg/microservice/aslan/core/release_plan/service/masking_test.go new file mode 100644 index 0000000000..260bd6199b --- /dev/null +++ b/pkg/microservice/aslan/core/release_plan/service/masking_test.go @@ -0,0 +1,49 @@ +package service + +import "testing" + +func TestSanitizeReleasePlanValueForDisplayMasksRawSensitiveFields(t *testing.T) { + value := map[string]interface{}{ + "key_vals": []interface{}{ + map[string]interface{}{ + "key": "DB_PASSWORD", + "value": "secret-token", + "is_credential": true, + }, + }, + } + + sanitized := sanitizeReleasePlanValueForDisplay(value).(map[string]interface{}) + keyVals := sanitized["key_vals"].([]interface{}) + item := keyVals[0].(map[string]interface{}) + if item["value"] != releasePlanMaskedValueDisplay { + t.Fatalf("expected credential value to be hidden") + } +} + +func TestIsReleasePlanSensitiveValueNode(t *testing.T) { + if isReleasePlanSensitiveValueNode(map[string]interface{}{"user_id": "alice"}) { + t.Fatalf("plain user field should not be treated as sensitive") + } + if !isReleasePlanSensitiveValueNode(map[string]interface{}{"is_credential": true, "value": "secret"}) { + t.Fatalf("credential flag should be treated as sensitive") + } + if !isReleasePlanSensitiveValueNode(map[string]interface{}{"is_sensitive": true, "value": "secret"}) { + t.Fatalf("keyvault sensitive flag should be treated as sensitive") + } +} + +func TestHasReleasePlanRawSensitiveValue(t *testing.T) { + if !hasReleasePlanRawSensitiveValue(map[string]interface{}{ + "is_credential": true, + "value": "secret", + }) { + t.Fatalf("expected raw credential value to require sanitize") + } + if hasReleasePlanRawSensitiveValue(map[string]interface{}{ + "is_credential": true, + "value": maskReleasePlanValue("secret"), + }) { + t.Fatalf("expected masked credential value to skip re-sanitize") + } +} diff --git a/pkg/microservice/aslan/core/release_plan/service/release_plan.go b/pkg/microservice/aslan/core/release_plan/service/release_plan.go index 79c6041473..43b386aaaf 100644 --- a/pkg/microservice/aslan/core/release_plan/service/release_plan.go +++ b/pkg/microservice/aslan/core/release_plan/service/release_plan.go @@ -1040,6 +1040,8 @@ func SkipReleaseJob(c *handler.Context, planID string, args *SkipReleaseJobArgs, } else { plan.SuccessTime = time.Now().Unix() } + + sendWebhook = true } if err = mongodb.NewReleasePlanColl().UpdateByID(ctx, planID, plan); err != nil { diff --git a/pkg/microservice/aslan/core/release_plan/service/update.go b/pkg/microservice/aslan/core/release_plan/service/update.go index 1fa16fe161..fbb9776673 100644 --- a/pkg/microservice/aslan/core/release_plan/service/update.go +++ b/pkg/microservice/aslan/core/release_plan/service/update.go @@ -375,7 +375,11 @@ func (u *UpdateReleaseJobUpdater) Update(plan *models.ReleasePlan) (before inter if job.Type != u.Type { return nil, nil, fmt.Errorf("job type cannot be changed") } - before, after = job, u + beforeJob := new(models.ReleaseJob) + if err := models.IToi(job, beforeJob); err != nil { + return nil, nil, errors.Wrap(err, "clone release job before update") + } + before, after = beforeJob, u job.Name = u.Name job.Manager = u.Manager job.ManagerID = u.ManagerID diff --git a/pkg/microservice/aslan/core/release_plan/service/watcher.go b/pkg/microservice/aslan/core/release_plan/service/watcher.go index 8eb733fabf..9e297a3bec 100644 --- a/pkg/microservice/aslan/core/release_plan/service/watcher.go +++ b/pkg/microservice/aslan/core/release_plan/service/watcher.go @@ -165,6 +165,7 @@ func WatchApproval() { }) if err != nil { log.Errorf("list approval workflow error: %v", err) + releasePlanApprovalLock.Unlock() continue } for _, plan := range list { From 19bec59a61cff88c5c81cf6f4e69636ad2b60583 Mon Sep 17 00:00:00 2001 From: huanghongbo-hhb Date: Thu, 21 May 2026 16:12:35 +0800 Subject: [PATCH 07/33] feat: support ordered release plan diffs Signed-off-by: huanghongbo-hhb --- .../aslan/core/release_plan/service/diff.go | 205 +++++++++++++++--- .../core/release_plan/service/diff_test.go | 97 +++++++++ 2 files changed, 277 insertions(+), 25 deletions(-) diff --git a/pkg/microservice/aslan/core/release_plan/service/diff.go b/pkg/microservice/aslan/core/release_plan/service/diff.go index a2ed26c49c..8a8478055a 100644 --- a/pkg/microservice/aslan/core/release_plan/service/diff.go +++ b/pkg/microservice/aslan/core/release_plan/service/diff.go @@ -33,6 +33,7 @@ import ( const ( releasePlanHashPruneMinMapKeys = 4 releasePlanHashPruneMinArrayItems = 4 + releasePlanDiffChangeTypeOrder = "order_changed" ) type ReleasePlanVersionDiffResponse struct { @@ -49,23 +50,47 @@ type ReleasePlanVersionDiffGroup struct { Changes []*ReleasePlanVersionDiffChange `json:"changes"` } +type ReleasePlanVersionDiffOrderItem struct { + Key string `json:"key,omitempty"` + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` +} + type ReleasePlanVersionDiffChange struct { - TaskName string `json:"task_name,omitempty"` - TaskType string `json:"task_type,omitempty"` - Path string `json:"path"` - Label string `json:"label"` - Before interface{} `json:"before,omitempty"` - After interface{} `json:"after,omitempty"` - LargeText bool `json:"large_text,omitempty"` - Masked bool `json:"masked,omitempty"` + TaskName string `json:"task_name,omitempty"` + TaskType string `json:"task_type,omitempty"` + ChangeType string `json:"change_type,omitempty"` + Path string `json:"path"` + Label string `json:"label"` + Before interface{} `json:"before,omitempty"` + After interface{} `json:"after,omitempty"` + BeforeOrder []*ReleasePlanVersionDiffOrderItem `json:"before_order,omitempty"` + AfterOrder []*ReleasePlanVersionDiffOrderItem `json:"after_order,omitempty"` + LargeText bool `json:"large_text,omitempty"` + Masked bool `json:"masked,omitempty"` } type releasePlanRawDiffEntry struct { - Path string - Before interface{} - After interface{} + Path string + ChangeType string + Before interface{} + After interface{} + BeforeOrder []*ReleasePlanVersionDiffOrderItem + AfterOrder []*ReleasePlanVersionDiffOrderItem +} + +type releasePlanDiffContext struct { + GroupType string } +type releasePlanArrayDiffStrategy int + +const ( + releasePlanArrayDiffStrategyIndex releasePlanArrayDiffStrategy = iota + releasePlanArrayDiffStrategyKeyedUnordered + releasePlanArrayDiffStrategyKeyedOrdered +) + var releasePlanFieldLabels = map[string]string{ "name": "名称", "manager": "负责人", @@ -100,6 +125,7 @@ var releasePlanFieldLabels = map[string]string{ "key_vals": "变量", "key": "变量名", "value": "变量值", + "order": "顺序", "params": "参数", "stages": "阶段", "jobs": "任务", @@ -137,7 +163,7 @@ func GetReleasePlanVersionDiff(planID string, version int64) (*ReleasePlanVersio groupKey, groupName, groupType := releasePlanVersionDiffGroup(current.SectionKey, current.SectionName) rawEntries := make([]*releasePlanRawDiffEntry, 0) - diffReleasePlanValues("", fromData, toData, &rawEntries) + diffReleasePlanValues(releasePlanDiffContext{GroupType: groupType}, "", fromData, toData, &rawEntries) groupMap := map[string]*ReleasePlanVersionDiffGroup{} groupOrder := make([]string, 0) @@ -159,12 +185,16 @@ func GetReleasePlanVersionDiff(planID string, version int64) (*ReleasePlanVersio } change := &ReleasePlanVersionDiffChange{ - TaskName: taskName, - TaskType: taskType, - Path: entry.Path, - Label: buildReleasePlanDiffLabel(entry.Path), - } - if isMaskedReleasePlanDiffValue(entry.Before) || isMaskedReleasePlanDiffValue(entry.After) { + TaskName: taskName, + TaskType: taskType, + ChangeType: entry.ChangeType, + Path: entry.Path, + Label: buildReleasePlanDiffLabel(entry.Path), + } + if entry.ChangeType == releasePlanDiffChangeTypeOrder { + change.BeforeOrder = entry.BeforeOrder + change.AfterOrder = entry.AfterOrder + } else if isMaskedReleasePlanDiffValue(entry.Before) || isMaskedReleasePlanDiffValue(entry.After) { change.Masked = true } else if isLargeTextReleasePlanDiffPath(entry.Path, entry.Before, entry.After) { change.LargeText = true @@ -215,7 +245,7 @@ func toGenericValue(value interface{}) (interface{}, error) { return resp, nil } -func diffReleasePlanValues(path string, left, right interface{}, entries *[]*releasePlanRawDiffEntry) { +func diffReleasePlanValues(ctx releasePlanDiffContext, path string, left, right interface{}, entries *[]*releasePlanRawDiffEntry) { if shouldIgnoreReleasePlanDiffPath(path) { return } @@ -245,7 +275,7 @@ func diffReleasePlanValues(path string, left, right interface{}, entries *[]*rel sort.Strings(keys) for _, key := range keys { nextPath := joinReleasePlanDiffPath(path, key) - diffReleasePlanValues(nextPath, leftMap[key], rightMap[key], entries) + diffReleasePlanValues(ctx, nextPath, leftMap[key], rightMap[key], entries) } return } @@ -253,7 +283,7 @@ func diffReleasePlanValues(path string, left, right interface{}, entries *[]*rel leftList, leftIsList := left.([]interface{}) rightList, rightIsList := right.([]interface{}) if leftIsList || rightIsList { - diffReleasePlanArray(path, leftList, rightList, entries) + diffReleasePlanArray(ctx, path, leftList, rightList, entries) return } @@ -308,10 +338,16 @@ func hashReleasePlanSubtree(value interface{}) (string, error) { return hex.EncodeToString(sum[:]), nil } -func diffReleasePlanArray(path string, left, right []interface{}, entries *[]*releasePlanRawDiffEntry) { +func diffReleasePlanArray(ctx releasePlanDiffContext, path string, left, right []interface{}, entries *[]*releasePlanRawDiffEntry) { leftMap, leftOrdered, leftMapped := buildReleasePlanArrayMap(left) rightMap, rightOrdered, rightMapped := buildReleasePlanArrayMap(right) - if leftMapped && rightMapped { + strategy := resolveReleasePlanArrayDiffStrategy(ctx, path, leftMapped, rightMapped) + if strategy == releasePlanArrayDiffStrategyKeyedOrdered { + if entry := buildReleasePlanArrayOrderChange(path, left, right, leftMap, leftOrdered, rightMap, rightOrdered); entry != nil { + *entries = append(*entries, entry) + } + } + if strategy == releasePlanArrayDiffStrategyKeyedOrdered || strategy == releasePlanArrayDiffStrategyKeyedUnordered { keySet := map[string]struct{}{} keys := make([]string, 0) for _, key := range leftOrdered { @@ -328,7 +364,7 @@ func diffReleasePlanArray(path string, left, right []interface{}, entries *[]*re } for _, key := range keys { nextPath := fmt.Sprintf("%s[%s]", path, key) - diffReleasePlanValues(nextPath, leftMap[key], rightMap[key], entries) + diffReleasePlanValues(ctx, nextPath, leftMap[key], rightMap[key], entries) } return } @@ -346,8 +382,124 @@ func diffReleasePlanArray(path string, left, right []interface{}, entries *[]*re if i < len(right) { rightVal = right[i] } - diffReleasePlanValues(nextPath, leftVal, rightVal, entries) + diffReleasePlanValues(ctx, nextPath, leftVal, rightVal, entries) + } +} + +func resolveReleasePlanArrayDiffStrategy(ctx releasePlanDiffContext, path string, leftMapped, rightMapped bool) releasePlanArrayDiffStrategy { + if !leftMapped || !rightMapped { + return releasePlanArrayDiffStrategyIndex + } + if shouldTrackReleasePlanArrayOrder(ctx, path) { + return releasePlanArrayDiffStrategyKeyedOrdered + } + return releasePlanArrayDiffStrategyKeyedUnordered +} + +func shouldTrackReleasePlanArrayOrder(ctx releasePlanDiffContext, path string) bool { + return ctx.GroupType == releasePlanVersionSectionJobsOrder && path == "" +} + +func buildReleasePlanArrayOrderChange( + path string, + left, right []interface{}, + leftMap map[string]interface{}, + leftOrdered []string, + rightMap map[string]interface{}, + rightOrdered []string, +) *releasePlanRawDiffEntry { + if !hasReleasePlanArrayRelativeOrderChange(leftMap, leftOrdered, rightMap, rightOrdered) { + return nil + } + + return &releasePlanRawDiffEntry{ + Path: joinReleasePlanDiffPath(path, "order"), + ChangeType: releasePlanDiffChangeTypeOrder, + BeforeOrder: buildReleasePlanArrayOrderItems(left, leftOrdered), + AfterOrder: buildReleasePlanArrayOrderItems(right, rightOrdered), + } +} + +func hasReleasePlanArrayRelativeOrderChange( + leftMap map[string]interface{}, + leftOrdered []string, + rightMap map[string]interface{}, + rightOrdered []string, +) bool { + leftShared := filterReleasePlanArrayOrderedKeys(leftOrdered, rightMap) + rightShared := filterReleasePlanArrayOrderedKeys(rightOrdered, leftMap) + return !reflect.DeepEqual(leftShared, rightShared) +} + +func filterReleasePlanArrayOrderedKeys(orderedKeys []string, otherMap map[string]interface{}) []string { + resp := make([]string, 0, len(orderedKeys)) + for _, key := range orderedKeys { + if _, exists := otherMap[key]; exists { + resp = append(resp, key) + } } + return resp +} + +func buildReleasePlanArrayOrderItems(values []interface{}, orderedKeys []string) []*ReleasePlanVersionDiffOrderItem { + resp := make([]*ReleasePlanVersionDiffOrderItem, 0, len(values)) + for idx, item := range values { + key := "" + if idx < len(orderedKeys) { + key = orderedKeys[idx] + } + resp = append(resp, buildReleasePlanArrayOrderItem(item, key)) + } + return resp +} + +func buildReleasePlanArrayOrderItem(item interface{}, key string) *ReleasePlanVersionDiffOrderItem { + resp := &ReleasePlanVersionDiffOrderItem{Key: key} + + switch value := item.(type) { + case map[string]interface{}: + if id, ok := getStringField(value, "id"); ok { + resp.ID = id + } + if name, ok := getStringField(value, "name"); ok { + resp.Name = name + return resp + } + if itemKey, ok := getStringField(value, "key"); ok { + resp.Name = itemKey + return resp + } + if service, ok := getStringField(value, "service_name"); ok { + if module, ok := getStringField(value, "service_module"); ok { + resp.Name = fmt.Sprintf("%s/%s", service, module) + } else { + resp.Name = service + } + return resp + } + if repo, ok := getStringField(value, "repo_name"); ok { + namespace, _ := getStringField(value, "repo_namespace") + remote, _ := getStringField(value, "remote_name") + resp.Name = strings.Trim(strings.Trim(fmt.Sprintf("%s/%s/%s", namespace, repo, remote), "/"), "/") + return resp + } + if target, ok := getStringField(value, "target"); ok { + resp.Name = target + return resp + } + if userID, ok := getStringField(value, "user_id"); ok { + resp.Name = userID + return resp + } + } + + if resp.Name == "" && key != "" { + resp.Name = key + } + if resp.Name == "" { + resp.Name = fmt.Sprintf("%v", item) + } + return resp } func buildReleasePlanArrayMap(values []interface{}) (map[string]interface{}, []string, bool) { @@ -377,6 +529,9 @@ func getReleasePlanArrayItemKey(item interface{}) (string, bool) { } return fmt.Sprintf("%s|%s", name, jobType), true } + if id, ok := getStringField(value, "id"); ok { + return fmt.Sprintf("%s|%s", name, id), true + } return name, true } if key, ok := getStringField(value, "key"); ok { diff --git a/pkg/microservice/aslan/core/release_plan/service/diff_test.go b/pkg/microservice/aslan/core/release_plan/service/diff_test.go index 15053c8041..21fc05a522 100644 --- a/pkg/microservice/aslan/core/release_plan/service/diff_test.go +++ b/pkg/microservice/aslan/core/release_plan/service/diff_test.go @@ -29,6 +29,19 @@ func TestGetReleasePlanArrayItemKey(t *testing.T) { t.Fatalf("unexpected key: %s", key) } }) + + t.Run("name and id key", func(t *testing.T) { + key, ok := getReleasePlanArrayItemKey(map[string]interface{}{ + "id": "job-id", + "name": "build", + }) + if !ok { + t.Fatalf("expected key") + } + if key != "build|job-id" { + t.Fatalf("unexpected key: %s", key) + } + }) } func TestBuildReleasePlanDiffLabel(t *testing.T) { @@ -39,6 +52,13 @@ func TestBuildReleasePlanDiffLabel(t *testing.T) { } } +func TestBuildReleasePlanDiffLabelForOrderChange(t *testing.T) { + label := buildReleasePlanDiffLabel("order") + if label != "顺序" { + t.Fatalf("unexpected order label: %s", label) + } +} + func TestReleasePlanDiffPathRules(t *testing.T) { if !shouldIgnoreReleasePlanDiffPath("update_time") { t.Fatalf("expected update_time to be ignored") @@ -125,3 +145,80 @@ func TestSanitizeReleasePlanValueForDisplay(t *testing.T) { t.Fatalf("expected non-sensitive fields to stay visible") } } + +func TestDiffReleasePlanValuesDetectsOrderedArrayChanges(t *testing.T) { + left := []interface{}{ + map[string]interface{}{"id": "job-1", "name": "build"}, + map[string]interface{}{"id": "job-2", "name": "deploy"}, + } + right := []interface{}{ + map[string]interface{}{"id": "job-2", "name": "deploy"}, + map[string]interface{}{"id": "job-1", "name": "build"}, + } + + entries := make([]*releasePlanRawDiffEntry, 0) + diffReleasePlanValues(releasePlanDiffContext{GroupType: releasePlanVersionSectionJobsOrder}, "", left, right, &entries) + + if len(entries) != 1 { + t.Fatalf("expected exactly one order change, got %d", len(entries)) + } + entry := entries[0] + if entry.ChangeType != releasePlanDiffChangeTypeOrder { + t.Fatalf("unexpected change type: %s", entry.ChangeType) + } + if entry.Path != "order" { + t.Fatalf("unexpected path: %s", entry.Path) + } + if len(entry.BeforeOrder) != 2 || len(entry.AfterOrder) != 2 { + t.Fatalf("unexpected order item counts: before=%d after=%d", len(entry.BeforeOrder), len(entry.AfterOrder)) + } + if entry.BeforeOrder[0].ID != "job-1" || entry.BeforeOrder[0].Name != "build" { + t.Fatalf("unexpected first before order item: %#v", entry.BeforeOrder[0]) + } + if entry.AfterOrder[0].ID != "job-2" || entry.AfterOrder[0].Name != "deploy" { + t.Fatalf("unexpected first after order item: %#v", entry.AfterOrder[0]) + } +} + +func TestDiffReleasePlanValuesDetectsOrderedArrayChangesWithDuplicateNames(t *testing.T) { + left := []interface{}{ + map[string]interface{}{"id": "job-1", "name": "build"}, + map[string]interface{}{"id": "job-2", "name": "build"}, + } + right := []interface{}{ + map[string]interface{}{"id": "job-2", "name": "build"}, + map[string]interface{}{"id": "job-1", "name": "build"}, + } + + entries := make([]*releasePlanRawDiffEntry, 0) + diffReleasePlanValues(releasePlanDiffContext{GroupType: releasePlanVersionSectionJobsOrder}, "", left, right, &entries) + + if len(entries) != 1 { + t.Fatalf("expected exactly one order change, got %d", len(entries)) + } + entry := entries[0] + if entry.ChangeType != releasePlanDiffChangeTypeOrder { + t.Fatalf("unexpected change type: %s", entry.ChangeType) + } + if entry.BeforeOrder[0].ID != "job-1" || entry.AfterOrder[0].ID != "job-2" { + t.Fatalf("unexpected duplicate-name order diff: before=%#v after=%#v", entry.BeforeOrder[0], entry.AfterOrder[0]) + } +} + +func TestDiffReleasePlanValuesKeepsDefaultKeyedArrayBehavior(t *testing.T) { + left := []interface{}{ + map[string]interface{}{"id": "stage-1", "name": "build"}, + map[string]interface{}{"id": "stage-2", "name": "deploy"}, + } + right := []interface{}{ + map[string]interface{}{"id": "stage-2", "name": "deploy"}, + map[string]interface{}{"id": "stage-1", "name": "build"}, + } + + entries := make([]*releasePlanRawDiffEntry, 0) + diffReleasePlanValues(releasePlanDiffContext{GroupType: "job"}, "spec.workflow.stages", left, right, &entries) + + if len(entries) != 0 { + t.Fatalf("expected keyed unordered arrays to ignore pure reorder by default, got %d entries", len(entries)) + } +} From bd85d620704f61241c2f19b9fd0627269de35909 Mon Sep 17 00:00:00 2001 From: huanghongbo-hhb Date: Thu, 21 May 2026 16:15:20 +0800 Subject: [PATCH 08/33] chore: remove diff tests from pr Signed-off-by: huanghongbo-hhb --- .../core/release_plan/service/diff_test.go | 97 ------------------- 1 file changed, 97 deletions(-) diff --git a/pkg/microservice/aslan/core/release_plan/service/diff_test.go b/pkg/microservice/aslan/core/release_plan/service/diff_test.go index 21fc05a522..15053c8041 100644 --- a/pkg/microservice/aslan/core/release_plan/service/diff_test.go +++ b/pkg/microservice/aslan/core/release_plan/service/diff_test.go @@ -29,19 +29,6 @@ func TestGetReleasePlanArrayItemKey(t *testing.T) { t.Fatalf("unexpected key: %s", key) } }) - - t.Run("name and id key", func(t *testing.T) { - key, ok := getReleasePlanArrayItemKey(map[string]interface{}{ - "id": "job-id", - "name": "build", - }) - if !ok { - t.Fatalf("expected key") - } - if key != "build|job-id" { - t.Fatalf("unexpected key: %s", key) - } - }) } func TestBuildReleasePlanDiffLabel(t *testing.T) { @@ -52,13 +39,6 @@ func TestBuildReleasePlanDiffLabel(t *testing.T) { } } -func TestBuildReleasePlanDiffLabelForOrderChange(t *testing.T) { - label := buildReleasePlanDiffLabel("order") - if label != "顺序" { - t.Fatalf("unexpected order label: %s", label) - } -} - func TestReleasePlanDiffPathRules(t *testing.T) { if !shouldIgnoreReleasePlanDiffPath("update_time") { t.Fatalf("expected update_time to be ignored") @@ -145,80 +125,3 @@ func TestSanitizeReleasePlanValueForDisplay(t *testing.T) { t.Fatalf("expected non-sensitive fields to stay visible") } } - -func TestDiffReleasePlanValuesDetectsOrderedArrayChanges(t *testing.T) { - left := []interface{}{ - map[string]interface{}{"id": "job-1", "name": "build"}, - map[string]interface{}{"id": "job-2", "name": "deploy"}, - } - right := []interface{}{ - map[string]interface{}{"id": "job-2", "name": "deploy"}, - map[string]interface{}{"id": "job-1", "name": "build"}, - } - - entries := make([]*releasePlanRawDiffEntry, 0) - diffReleasePlanValues(releasePlanDiffContext{GroupType: releasePlanVersionSectionJobsOrder}, "", left, right, &entries) - - if len(entries) != 1 { - t.Fatalf("expected exactly one order change, got %d", len(entries)) - } - entry := entries[0] - if entry.ChangeType != releasePlanDiffChangeTypeOrder { - t.Fatalf("unexpected change type: %s", entry.ChangeType) - } - if entry.Path != "order" { - t.Fatalf("unexpected path: %s", entry.Path) - } - if len(entry.BeforeOrder) != 2 || len(entry.AfterOrder) != 2 { - t.Fatalf("unexpected order item counts: before=%d after=%d", len(entry.BeforeOrder), len(entry.AfterOrder)) - } - if entry.BeforeOrder[0].ID != "job-1" || entry.BeforeOrder[0].Name != "build" { - t.Fatalf("unexpected first before order item: %#v", entry.BeforeOrder[0]) - } - if entry.AfterOrder[0].ID != "job-2" || entry.AfterOrder[0].Name != "deploy" { - t.Fatalf("unexpected first after order item: %#v", entry.AfterOrder[0]) - } -} - -func TestDiffReleasePlanValuesDetectsOrderedArrayChangesWithDuplicateNames(t *testing.T) { - left := []interface{}{ - map[string]interface{}{"id": "job-1", "name": "build"}, - map[string]interface{}{"id": "job-2", "name": "build"}, - } - right := []interface{}{ - map[string]interface{}{"id": "job-2", "name": "build"}, - map[string]interface{}{"id": "job-1", "name": "build"}, - } - - entries := make([]*releasePlanRawDiffEntry, 0) - diffReleasePlanValues(releasePlanDiffContext{GroupType: releasePlanVersionSectionJobsOrder}, "", left, right, &entries) - - if len(entries) != 1 { - t.Fatalf("expected exactly one order change, got %d", len(entries)) - } - entry := entries[0] - if entry.ChangeType != releasePlanDiffChangeTypeOrder { - t.Fatalf("unexpected change type: %s", entry.ChangeType) - } - if entry.BeforeOrder[0].ID != "job-1" || entry.AfterOrder[0].ID != "job-2" { - t.Fatalf("unexpected duplicate-name order diff: before=%#v after=%#v", entry.BeforeOrder[0], entry.AfterOrder[0]) - } -} - -func TestDiffReleasePlanValuesKeepsDefaultKeyedArrayBehavior(t *testing.T) { - left := []interface{}{ - map[string]interface{}{"id": "stage-1", "name": "build"}, - map[string]interface{}{"id": "stage-2", "name": "deploy"}, - } - right := []interface{}{ - map[string]interface{}{"id": "stage-2", "name": "deploy"}, - map[string]interface{}{"id": "stage-1", "name": "build"}, - } - - entries := make([]*releasePlanRawDiffEntry, 0) - diffReleasePlanValues(releasePlanDiffContext{GroupType: "job"}, "spec.workflow.stages", left, right, &entries) - - if len(entries) != 0 { - t.Fatalf("expected keyed unordered arrays to ignore pure reorder by default, got %d entries", len(entries)) - } -} From 7054f2e7eb72044bb0800c10713fa9e9f540f1cc Mon Sep 17 00:00:00 2001 From: huanghongbo-hhb Date: Fri, 22 May 2026 11:16:51 +0800 Subject: [PATCH 09/33] refactor: simplify release plan diff labels Signed-off-by: huanghongbo-hhb --- pkg/microservice/aslan/core/release_plan/service/diff.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/microservice/aslan/core/release_plan/service/diff.go b/pkg/microservice/aslan/core/release_plan/service/diff.go index 8a8478055a..441ac03fef 100644 --- a/pkg/microservice/aslan/core/release_plan/service/diff.go +++ b/pkg/microservice/aslan/core/release_plan/service/diff.go @@ -480,7 +480,7 @@ func buildReleasePlanArrayOrderItem(item interface{}, key string) *ReleasePlanVe if repo, ok := getStringField(value, "repo_name"); ok { namespace, _ := getStringField(value, "repo_namespace") remote, _ := getStringField(value, "remote_name") - resp.Name = strings.Trim(strings.Trim(fmt.Sprintf("%s/%s/%s", namespace, repo, remote), "/"), "/") + resp.Name = strings.Trim(fmt.Sprintf("%s/%s/%s", namespace, repo, remote), "/") return resp } if target, ok := getStringField(value, "target"); ok { From 4b06d33b1b85c09af8577bce71f8b64581f6edb7 Mon Sep 17 00:00:00 2001 From: huanghongbo-hhb Date: Fri, 22 May 2026 14:19:32 +0800 Subject: [PATCH 10/33] fix release plan collaboration cleanup and diff order Signed-off-by: huanghongbo-hhb --- .../release_plan/service/collaboration.go | 92 ++- .../aslan/core/release_plan/service/diff.go | 768 ++++++++++++++++-- 2 files changed, 805 insertions(+), 55 deletions(-) diff --git a/pkg/microservice/aslan/core/release_plan/service/collaboration.go b/pkg/microservice/aslan/core/release_plan/service/collaboration.go index 2eb84b0ef2..d87914e25d 100644 --- a/pkg/microservice/aslan/core/release_plan/service/collaboration.go +++ b/pkg/microservice/aslan/core/release_plan/service/collaboration.go @@ -29,6 +29,7 @@ import ( "time" "github.com/gin-gonic/gin" + "github.com/google/uuid" "github.com/gorilla/websocket" "github.com/pkg/errors" @@ -60,6 +61,7 @@ var upgrader = websocket.Upgrader{ type ReleasePlanEditingSession struct { PlanID string `json:"plan_id"` SessionID string `json:"session_id"` + ConnectionID string `json:"connection_id,omitempty"` UserID string `json:"user_id"` UserName string `json:"user_name"` Account string `json:"account"` @@ -103,8 +105,12 @@ type releasePlanCollabWSOutbound struct { type collaborationClient struct { planID string + id string conn *websocket.Conn send chan []byte + + sessionMu sync.Mutex + sessionIDs map[string]struct{} } var collaborationHub = struct { @@ -233,6 +239,75 @@ func unregisterCollaborationClient(planID string, client *collaborationClient) { } } +func rememberCollaborationClientSession(client *collaborationClient, sessionID string) { + if client == nil || sessionID == "" { + return + } + + client.sessionMu.Lock() + defer client.sessionMu.Unlock() + + if client.sessionIDs == nil { + client.sessionIDs = make(map[string]struct{}) + } + client.sessionIDs[sessionID] = struct{}{} +} + +func forgetCollaborationClientSession(client *collaborationClient, sessionID string) { + if client == nil || sessionID == "" { + return + } + + client.sessionMu.Lock() + defer client.sessionMu.Unlock() + + delete(client.sessionIDs, sessionID) +} + +func listCollaborationClientSessionIDs(client *collaborationClient) []string { + if client == nil { + return nil + } + + client.sessionMu.Lock() + defer client.sessionMu.Unlock() + + resp := make([]string, 0, len(client.sessionIDs)) + for sessionID := range client.sessionIDs { + resp = append(resp, sessionID) + } + sort.Strings(resp) + return resp +} + +func shouldCleanupReleasePlanEditingSession(session *ReleasePlanEditingSession, connectionID string) bool { + if session == nil || connectionID == "" { + return false + } + return session.ConnectionID == connectionID +} + +func cleanupReleasePlanEditingSessionsForClient(client *collaborationClient) { + if client == nil || client.planID == "" { + return + } + + for _, sessionID := range listCollaborationClientSessionIDs(client) { + session, err := getReleasePlanEditingSession(client.planID, sessionID) + if err != nil { + continue + } + if !shouldCleanupReleasePlanEditingSession(session, client.id) { + continue + } + if err := removeReleasePlanEditingSession(client.planID, sessionID); err != nil { + log.Errorf("remove release plan editing session on disconnect error: %v", err) + continue + } + forgetCollaborationClientSession(client, sessionID) + } +} + func sendSnapshotToLocalClients(planID string, snapshot *ReleasePlanCollaborationSnapshot) { if snapshot == nil { return @@ -316,7 +391,9 @@ func GetReleasePlanCollaborationSnapshot(planID string) (*ReleasePlanCollaborati groupMap[key] = group groupOrder = append(groupOrder, key) } - group.Editors = append(group.Editors, session) + displaySession := *session + displaySession.ConnectionID = "" + group.Editors = append(group.Editors, &displaySession) } sort.Strings(groupOrder) @@ -473,11 +550,14 @@ func openReleasePlanCollaborationWS(gCtx *gin.Context, ctx *handler.Context, pla ensureReleasePlanCollaborationLoop() client := &collaborationClient{ - planID: planID, - conn: ws, - send: make(chan []byte, 16), + planID: planID, + id: uuid.NewString(), + conn: ws, + send: make(chan []byte, 16), + sessionIDs: map[string]struct{}{}, } registerCollaborationClient(planID, client) + defer cleanupReleasePlanEditingSessionsForClient(client) defer unregisterCollaborationClient(planID, client) done := make(chan struct{}) @@ -517,6 +597,7 @@ func openReleasePlanCollaborationWS(gCtx *gin.Context, ctx *handler.Context, pla session := &ReleasePlanEditingSession{ PlanID: planID, SessionID: msg.SessionID, + ConnectionID: client.id, UserID: ctx.UserID, UserName: ctx.UserName, Account: ctx.Account, @@ -544,6 +625,7 @@ func openReleasePlanCollaborationWS(gCtx *gin.Context, ctx *handler.Context, pla queueCollaborationClientMessage(client, &releasePlanCollabWSOutbound{Type: "error", Error: err.Error()}) continue } + rememberCollaborationClientSession(client, msg.SessionID) snapshot, err := GetReleasePlanCollaborationSnapshot(planID) if err == nil { queueCollaborationClientMessage(client, &releasePlanCollabWSOutbound{Type: "snapshot", Snapshot: snapshot}) @@ -560,7 +642,9 @@ func openReleasePlanCollaborationWS(gCtx *gin.Context, ctx *handler.Context, pla } if err := removeReleasePlanEditingSession(planID, msg.SessionID); err != nil { queueCollaborationClientMessage(client, &releasePlanCollabWSOutbound{Type: "error", Error: err.Error()}) + continue } + forgetCollaborationClientSession(client, msg.SessionID) } } }) diff --git a/pkg/microservice/aslan/core/release_plan/service/diff.go b/pkg/microservice/aslan/core/release_plan/service/diff.go index 441ac03fef..d0e8229a18 100644 --- a/pkg/microservice/aslan/core/release_plan/service/diff.go +++ b/pkg/microservice/aslan/core/release_plan/service/diff.go @@ -23,10 +23,12 @@ import ( "fmt" "reflect" "sort" + "strconv" "strings" "github.com/pkg/errors" + "github.com/koderover/zadig/v2/pkg/microservice/aslan/config" "github.com/koderover/zadig/v2/pkg/microservice/aslan/core/common/repository/mongodb" ) @@ -91,6 +93,148 @@ const ( releasePlanArrayDiffStrategyKeyedOrdered ) +type releasePlanArrayDiffRuleMatchType int + +const ( + releasePlanArrayDiffRuleMatchTypeExact releasePlanArrayDiffRuleMatchType = iota + releasePlanArrayDiffRuleMatchTypeSafeSuffix +) + +type releasePlanArrayKeyBuilder func(item interface{}) (string, bool) + +type releasePlanArrayDiffRule struct { + GroupType string + Path string + ParentJobTypes map[string]struct{} + MatchType releasePlanArrayDiffRuleMatchType + Strategy releasePlanArrayDiffStrategy + BuildKey releasePlanArrayKeyBuilder +} + +func newReleasePlanExactArrayRule(groupType, path string, strategy releasePlanArrayDiffStrategy, buildKey releasePlanArrayKeyBuilder) releasePlanArrayDiffRule { + return releasePlanArrayDiffRule{ + GroupType: groupType, + Path: path, + MatchType: releasePlanArrayDiffRuleMatchTypeExact, + Strategy: strategy, + BuildKey: buildKey, + } +} + +func newReleasePlanTypedExactArrayRule(groupType, path string, parentJobTypes []config.JobType, strategy releasePlanArrayDiffStrategy, buildKey releasePlanArrayKeyBuilder) releasePlanArrayDiffRule { + rule := newReleasePlanExactArrayRule(groupType, path, strategy, buildKey) + rule.ParentJobTypes = make(map[string]struct{}, len(parentJobTypes)) + for _, jobType := range parentJobTypes { + rule.ParentJobTypes[string(jobType)] = struct{}{} + } + return rule +} + +func newReleasePlanSafeSuffixArrayRule(groupType, path string, strategy releasePlanArrayDiffStrategy, buildKey releasePlanArrayKeyBuilder) releasePlanArrayDiffRule { + return releasePlanArrayDiffRule{ + GroupType: groupType, + Path: path, + MatchType: releasePlanArrayDiffRuleMatchTypeSafeSuffix, + Strategy: strategy, + BuildKey: buildKey, + } +} + +var releasePlanArrayExactRules = []releasePlanArrayDiffRule{ + newReleasePlanExactArrayRule("plan", "jobs", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByNameTypeID), + newReleasePlanExactArrayRule(releasePlanVersionSectionJobsOrder, "", releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByNameID), + newReleasePlanExactArrayRule("job", "spec.workflow.params", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByNameType), + newReleasePlanExactArrayRule("job", "spec.workflow.stages", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByName), + newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByNameType), + newReleasePlanExactArrayRule("job", "spec.workflow.share_storages", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByName), + newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.default_services", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByServiceModule), + newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.service_config.default_services", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByServiceModule), + newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.service_and_builds", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByServiceModule), + newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.default_service_and_builds", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByServiceModule), + newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.service_and_builds_options", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByServiceModule), + newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.service_and_vm_deploys", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByServiceModule), + newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.default_service_and_vm_deploys", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByServiceModule), + newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.service_and_vm_deploys_options", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByServiceModule), + newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.service_and_tests", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByServiceModule), + newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.service_test_options", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByServiceModule), + newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.service_and_scannings", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByServiceModule), + newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.service_scanning_options", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByServiceModule), + newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.target_services", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByServiceModule), + newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.service_and_image", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByServiceModule), + newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.gray_services", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByServiceName), + newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.source_service", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByServiceModule), + newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.service_trigger_workflow", releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByWorkflowTrigger), + newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.fixed_workflow_list", releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByFixedWorkflowTrigger), + newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.service_trigger_workflow.params", releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByNameType), + newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.fixed_workflow_list.params", releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByNameType), + newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.test_modules", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByName), + newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.test_module_options", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByName), + newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.scannings", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByName), + newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.scanning_options", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByName), + newReleasePlanTypedExactArrayRule("job", "spec.workflow.stages.jobs.spec.services", []config.JobType{config.JobZadigDeploy, config.JobK8sBlueGreenDeploy}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByServiceName), + newReleasePlanTypedExactArrayRule("job", "spec.workflow.stages.jobs.spec.services", []config.JobType{config.JobFreestyle}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByServiceModule), + newReleasePlanTypedExactArrayRule("job", "spec.workflow.stages.jobs.spec.service_options", []config.JobType{config.JobK8sBlueGreenDeploy}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByServiceName), + newReleasePlanTypedExactArrayRule("job", "spec.workflow.stages.jobs.spec.service_config.services", []config.JobType{config.JobSAEDeploy}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByServiceModule), + newReleasePlanTypedExactArrayRule("job", "spec.workflow.stages.jobs.spec.services.modules", []config.JobType{config.JobZadigDeploy}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByServiceModuleNameOnly), + newReleasePlanTypedExactArrayRule("job", "spec.workflow.stages.jobs.spec.service_variable_config", []config.JobType{config.JobZadigDeploy}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByServiceName), + newReleasePlanTypedExactArrayRule("job", "spec.workflow.stages.jobs.spec.service_variable_config.modules", []config.JobType{config.JobZadigDeploy}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByServiceModuleNameOnly), + newReleasePlanTypedExactArrayRule("job", "spec.workflow.stages.jobs.spec.service_variable_config.variable_configs", []config.JobType{config.JobZadigDeploy}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByVariableConfig), + newReleasePlanTypedExactArrayRule("job", "spec.workflow.stages.jobs.spec.services.service_and_image", []config.JobType{config.JobK8sBlueGreenDeploy}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByServiceModuleNameOnly), + newReleasePlanTypedExactArrayRule("job", "spec.workflow.stages.jobs.spec.service_options.service_and_image", []config.JobType{config.JobK8sBlueGreenDeploy}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByServiceModuleNameOnly), + newReleasePlanTypedExactArrayRule("job", "spec.workflow.stages.jobs.spec.gray_services.service_and_image", []config.JobType{config.JobMseGrayRelease}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByServiceModuleNameOnly), + newReleasePlanTypedExactArrayRule("job", "spec.workflow.stages.jobs.spec.targets", []config.JobType{config.JobCustomDeploy}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByTarget), + newReleasePlanTypedExactArrayRule("job", "spec.workflow.stages.jobs.spec.target_options", []config.JobType{config.JobCustomDeploy}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByTarget), + newReleasePlanTypedExactArrayRule("job", "spec.workflow.stages.jobs.spec.targets", []config.JobType{config.JobZadigDistributeImage}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByServiceModule), + newReleasePlanTypedExactArrayRule("job", "spec.workflow.stages.jobs.spec.target_options", []config.JobType{config.JobZadigDistributeImage}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByServiceModule), + newReleasePlanTypedExactArrayRule("job", "spec.workflow.stages.jobs.spec.targets", []config.JobType{config.JobK8sBlueGreenDeploy, config.JobK8sCanaryDeploy}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByK8sTarget), + newReleasePlanTypedExactArrayRule("job", "spec.workflow.stages.jobs.spec.target_options", []config.JobType{config.JobK8sCanaryDeploy}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByK8sTarget), + newReleasePlanTypedExactArrayRule("job", "spec.workflow.stages.jobs.spec.targets", []config.JobType{config.JobK8sGrayRelease}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByGrayReleaseTarget), + newReleasePlanTypedExactArrayRule("job", "spec.workflow.stages.jobs.spec.target_options", []config.JobType{config.JobK8sGrayRelease}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByGrayReleaseTarget), + newReleasePlanTypedExactArrayRule("job", "spec.workflow.stages.jobs.spec.targets", []config.JobType{config.JobK8sGrayRollback}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByGrayRollbackTarget), + newReleasePlanTypedExactArrayRule("job", "spec.workflow.stages.jobs.spec.target_options", []config.JobType{config.JobK8sGrayRollback}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByGrayRollbackTarget), + newReleasePlanTypedExactArrayRule("job", "spec.workflow.stages.jobs.spec.targets", []config.JobType{config.JobIstioRelease, config.JobIstioRollback}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByIstioTarget), + newReleasePlanTypedExactArrayRule("job", "spec.workflow.stages.jobs.spec.target_options", []config.JobType{config.JobIstioRelease, config.JobIstioRollback}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByIstioTarget), + newReleasePlanTypedExactArrayRule("job", "spec.workflow.stages.jobs.spec.jobs", []config.JobType{config.JobJenkins}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByJobName), + newReleasePlanTypedExactArrayRule("job", "spec.workflow.stages.jobs.spec.job_options", []config.JobType{config.JobJenkins}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByJobName), + newReleasePlanTypedExactArrayRule("job", "spec.workflow.stages.jobs.spec.jobs.parameters", []config.JobType{config.JobJenkins}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByName), + newReleasePlanTypedExactArrayRule("job", "spec.workflow.stages.jobs.spec.job_options.parameters", []config.JobType{config.JobJenkins}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByName), + newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.alerts", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByNameID), + newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.alert_options", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByNameID), + newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.monitors", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByNameID), + newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.mail_users", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByUserID), + newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.mail_notification_config.target_users", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByUserID), + newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.lark_group_notification_config.at_users", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByThirdPartyUserID), + newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.lark_person_notification_config.target_users", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByThirdPartyUserID), + newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.native_approval.approve_users", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByUserID), + newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.dingtalk_approval.approval_nodes.approve_users", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByThirdPartyUserID), + newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.lark_approval.approve_users", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByThirdPartyUserID), + newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.lark_approval.approval_nodes.approve_users", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByThirdPartyUserID), + newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.lark_approval.approval_nodes.cc_users", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByThirdPartyUserID), + newReleasePlanExactArrayRule("approval", "native_approval.approve_users", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByUserID), + newReleasePlanExactArrayRule("approval", "dingtalk_approval.approval_nodes.approve_users", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByThirdPartyUserID), + newReleasePlanExactArrayRule("approval", "lark_approval.approve_users", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByThirdPartyUserID), + newReleasePlanExactArrayRule("approval", "lark_approval.approval_nodes.approve_users", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByThirdPartyUserID), + newReleasePlanExactArrayRule("approval", "lark_approval.approval_nodes.cc_users", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByThirdPartyUserID), + newReleasePlanExactArrayRule("approval", "lark_approval.approval_nodes.approve_groups", releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByApprovalGroup), + newReleasePlanExactArrayRule("approval", "lark_approval.approval_nodes.cc_groups", releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByApprovalGroup), + newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.lark_approval.approval_nodes.approve_groups", releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByApprovalGroup), + newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.lark_approval.approval_nodes.cc_groups", releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByApprovalGroup), + newReleasePlanExactArrayRule("metadata", "jira_sprint_association.sprints", releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByJiraSprint), +} + +var releasePlanArraySafeSuffixRules = []releasePlanArrayDiffRule{ + newReleasePlanSafeSuffixArrayRule("job", "repos", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByRepo), + newReleasePlanSafeSuffixArrayRule("job", "code_info", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByRepo), + newReleasePlanSafeSuffixArrayRule("job", "key_vals", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByKey), + newReleasePlanSafeSuffixArrayRule("job", "envs", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByKey), + newReleasePlanSafeSuffixArrayRule("job", "custom_envs", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByKey), + newReleasePlanSafeSuffixArrayRule("job", "custom_annotations", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByKey), + newReleasePlanSafeSuffixArrayRule("job", "custom_labels", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByKey), + newReleasePlanSafeSuffixArrayRule("job", "variable_kvs", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByKey), + newReleasePlanSafeSuffixArrayRule("job", "kv", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByKey), + newReleasePlanSafeSuffixArrayRule("job", "original_config", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByKey), +} + var releasePlanFieldLabels = map[string]string{ "name": "名称", "manager": "负责人", @@ -339,9 +483,20 @@ func hashReleasePlanSubtree(value interface{}) (string, error) { } func diffReleasePlanArray(ctx releasePlanDiffContext, path string, left, right []interface{}, entries *[]*releasePlanRawDiffEntry) { - leftMap, leftOrdered, leftMapped := buildReleasePlanArrayMap(left) - rightMap, rightOrdered, rightMapped := buildReleasePlanArrayMap(right) - strategy := resolveReleasePlanArrayDiffStrategy(ctx, path, leftMapped, rightMapped) + rule := matchReleasePlanArrayDiffRule(ctx, path) + if rule == nil || rule.Strategy == releasePlanArrayDiffStrategyIndex { + diffReleasePlanArrayByIndex(ctx, path, left, right, entries) + return + } + + leftMap, leftOrdered, leftMapped := buildReleasePlanArrayMap(left, rule.BuildKey) + rightMap, rightOrdered, rightMapped := buildReleasePlanArrayMap(right, rule.BuildKey) + if !leftMapped || !rightMapped { + diffReleasePlanArrayByIndex(ctx, path, left, right, entries) + return + } + + strategy := rule.Strategy if strategy == releasePlanArrayDiffStrategyKeyedOrdered { if entry := buildReleasePlanArrayOrderChange(path, left, right, leftMap, leftOrdered, rightMap, rightOrdered); entry != nil { *entries = append(*entries, entry) @@ -369,6 +524,10 @@ func diffReleasePlanArray(ctx releasePlanDiffContext, path string, left, right [ return } + diffReleasePlanArrayByIndex(ctx, path, left, right, entries) +} + +func diffReleasePlanArrayByIndex(ctx releasePlanDiffContext, path string, left, right []interface{}, entries *[]*releasePlanRawDiffEntry) { maxLen := len(left) if len(right) > maxLen { maxLen = len(right) @@ -386,18 +545,151 @@ func diffReleasePlanArray(ctx releasePlanDiffContext, path string, left, right [ } } -func resolveReleasePlanArrayDiffStrategy(ctx releasePlanDiffContext, path string, leftMapped, rightMapped bool) releasePlanArrayDiffStrategy { - if !leftMapped || !rightMapped { - return releasePlanArrayDiffStrategyIndex +type releasePlanArrayRuleLookupContext struct { + GroupType string + Path string + ParentJobType string +} + +func matchReleasePlanArrayDiffRule(ctx releasePlanDiffContext, path string) *releasePlanArrayDiffRule { + lookupContexts := buildReleasePlanArrayRuleLookupContexts(ctx, path) + for _, lookup := range lookupContexts { + for idx := range releasePlanArrayExactRules { + rule := &releasePlanArrayExactRules[idx] + if rule.GroupType != lookup.GroupType { + continue + } + if !matchesReleasePlanParentJobType(rule, lookup.ParentJobType) { + continue + } + if rule.Path == lookup.Path { + return rule + } + } } - if shouldTrackReleasePlanArrayOrder(ctx, path) { - return releasePlanArrayDiffStrategyKeyedOrdered + for _, lookup := range lookupContexts { + for idx := range releasePlanArraySafeSuffixRules { + rule := &releasePlanArraySafeSuffixRules[idx] + if rule.GroupType != lookup.GroupType { + continue + } + if lookup.Path == rule.Path || strings.HasSuffix(lookup.Path, "."+rule.Path) { + return rule + } + } } - return releasePlanArrayDiffStrategyKeyedUnordered + return nil } -func shouldTrackReleasePlanArrayOrder(ctx releasePlanDiffContext, path string) bool { - return ctx.GroupType == releasePlanVersionSectionJobsOrder && path == "" +func matchesReleasePlanParentJobType(rule *releasePlanArrayDiffRule, parentJobType string) bool { + if len(rule.ParentJobTypes) == 0 { + return true + } + _, ok := rule.ParentJobTypes[parentJobType] + return ok +} + +func buildReleasePlanArrayRuleLookupContexts(ctx releasePlanDiffContext, path string) []releasePlanArrayRuleLookupContext { + normalizedPath := normalizeReleasePlanDiffPath(path) + parentJobType := extractReleasePlanParentJobType(path) + resp := []releasePlanArrayRuleLookupContext{{ + GroupType: ctx.GroupType, + Path: normalizedPath, + ParentJobType: parentJobType, + }} + + if ctx.GroupType != "plan" { + return resp + } + + // Nested arrays under the plan snapshot still belong to job/approval/metadata structures. + if strings.HasPrefix(normalizedPath, "jobs.") { + resp = append(resp, releasePlanArrayRuleLookupContext{ + GroupType: "job", + Path: strings.TrimPrefix(normalizedPath, "jobs."), + ParentJobType: parentJobType, + }) + } + if strings.HasPrefix(normalizedPath, "approval.") { + resp = append(resp, releasePlanArrayRuleLookupContext{ + GroupType: "approval", + Path: strings.TrimPrefix(normalizedPath, "approval."), + ParentJobType: parentJobType, + }) + } + if strings.HasPrefix(normalizedPath, "metadata.") { + resp = append(resp, releasePlanArrayRuleLookupContext{ + GroupType: "metadata", + Path: strings.TrimPrefix(normalizedPath, "metadata."), + ParentJobType: parentJobType, + }) + } + return resp +} + +func extractReleasePlanParentJobType(path string) string { + parentJobType := "" + searchPath := path + for { + idx := strings.Index(searchPath, "jobs[") + if idx < 0 { + return parentJobType + } + searchPath = searchPath[idx+len("jobs["):] + endIdx := strings.IndexByte(searchPath, ']') + if endIdx < 0 { + return parentJobType + } + key := searchPath[:endIdx] + if jobType, ok := extractReleasePlanJobTypeFromArrayKey(key); ok { + parentJobType = jobType + } + searchPath = searchPath[endIdx+1:] + } +} + +func extractReleasePlanJobTypeFromArrayKey(key string) (string, bool) { + parts := strings.Split(key, "|") + if len(parts) < 2 { + return "", false + } + return trimReleasePlanArrayDuplicateSuffix(parts[1]), true +} + +func trimReleasePlanArrayDuplicateSuffix(value string) string { + idx := strings.LastIndex(value, "#") + if idx < 0 || idx == len(value)-1 { + return value + } + for _, ch := range value[idx+1:] { + if ch < '0' || ch > '9' { + return value + } + } + return value[:idx] +} + +func normalizeReleasePlanDiffPath(path string) string { + if path == "" { + return "" + } + + builder := strings.Builder{} + builder.Grow(len(path)) + inBracket := false + for _, ch := range path { + switch ch { + case '[': + inBracket = true + case ']': + inBracket = false + default: + if !inBracket { + builder.WriteRune(ch) + } + } + } + return builder.String() } func buildReleasePlanArrayOrderChange( @@ -469,6 +761,26 @@ func buildReleasePlanArrayOrderItem(item interface{}, key string) *ReleasePlanVe resp.Name = itemKey return resp } + if workflowName, ok := getStringField(value, "workflow_name"); ok { + projectName, _ := getStringField(value, "project_name") + serviceName, _ := getStringField(value, "service_name") + serviceModule, _ := getStringField(value, "service_module") + parts := make([]string, 0, 4) + if projectName != "" { + parts = append(parts, projectName) + } + if workflowName != "" { + parts = append(parts, workflowName) + } + if serviceName != "" { + parts = append(parts, serviceName) + } + if serviceModule != "" { + parts = append(parts, serviceModule) + } + resp.Name = strings.Join(parts, "/") + return resp + } if service, ok := getStringField(value, "service_name"); ok { if module, ok := getStringField(value, "service_module"); ok { resp.Name = fmt.Sprintf("%s/%s", service, module) @@ -477,6 +789,10 @@ func buildReleasePlanArrayOrderItem(item interface{}, key string) *ReleasePlanVe } return resp } + if module, ok := getStringField(value, "service_module"); ok { + resp.Name = module + return resp + } if repo, ok := getStringField(value, "repo_name"); ok { namespace, _ := getStringField(value, "repo_namespace") remote, _ := getStringField(value, "remote_name") @@ -491,6 +807,23 @@ func buildReleasePlanArrayOrderItem(item interface{}, key string) *ReleasePlanVe resp.Name = userID return resp } + if groupName, ok := getStringField(value, "group_name"); ok { + resp.Name = groupName + return resp + } + if sprintName, ok := getStringField(value, "sprint_name"); ok { + projectKey, _ := getStringField(value, "project_key") + if projectKey != "" { + resp.Name = fmt.Sprintf("%s/%s", projectKey, sprintName) + } else { + resp.Name = sprintName + } + return resp + } + if variableKey, ok := getStringField(value, "variable_key"); ok { + resp.Name = variableKey + return resp + } } if resp.Name == "" && key != "" { @@ -502,11 +835,15 @@ func buildReleasePlanArrayOrderItem(item interface{}, key string) *ReleasePlanVe return resp } -func buildReleasePlanArrayMap(values []interface{}) (map[string]interface{}, []string, bool) { +func buildReleasePlanArrayMap(values []interface{}, buildKey releasePlanArrayKeyBuilder) (map[string]interface{}, []string, bool) { + if buildKey == nil { + return nil, nil, false + } + result := make(map[string]interface{}, len(values)) orderedKeys := make([]string, 0, len(values)) for idx, item := range values { - key, ok := getReleasePlanArrayItemKey(item) + key, ok := buildKey(item) if !ok { return nil, nil, false } @@ -519,47 +856,326 @@ func buildReleasePlanArrayMap(values []interface{}) (map[string]interface{}, []s return result, orderedKeys, true } -func getReleasePlanArrayItemKey(item interface{}) (string, bool) { - switch value := item.(type) { - case map[string]interface{}: - if name, ok := getStringField(value, "name"); ok { - if jobType, ok := getStringField(value, "type"); ok { - if id, ok := getStringField(value, "id"); ok { - return fmt.Sprintf("%s|%s|%s", name, jobType, id), true - } - return fmt.Sprintf("%s|%s", name, jobType), true - } - if id, ok := getStringField(value, "id"); ok { - return fmt.Sprintf("%s|%s", name, id), true - } - return name, true - } - if key, ok := getStringField(value, "key"); ok { - return key, true - } - if service, ok := getStringField(value, "service_name"); ok { - if module, ok := getStringField(value, "service_module"); ok { - return fmt.Sprintf("%s/%s", service, module), true - } - } - if repo, ok := getStringField(value, "repo_name"); ok { - namespace, _ := getStringField(value, "repo_namespace") - remote, _ := getStringField(value, "remote_name") - return fmt.Sprintf("%s/%s/%s", namespace, repo, remote), true - } - if target, ok := getStringField(value, "target"); ok { - return target, true - } - if userID, ok := getStringField(value, "user_id"); ok { - return userID, true - } - if id, ok := getStringField(value, "id"); ok { - return id, true - } +func buildReleasePlanArrayKeyByNameTypeID(item interface{}) (string, bool) { + value, ok := getMapField(item) + if !ok { return "", false - default: + } + name, ok := getStringField(value, "name") + if !ok { return "", false } + jobType, ok := getStringField(value, "type") + if !ok { + return "", false + } + id, ok := getStringField(value, "id") + if !ok { + return "", false + } + return fmt.Sprintf("%s|%s|%s", name, jobType, id), true +} + +func buildReleasePlanArrayKeyByNameType(item interface{}) (string, bool) { + value, ok := getMapField(item) + if !ok { + return "", false + } + name, ok := getStringField(value, "name") + if !ok { + return "", false + } + itemType, ok := getStringField(value, "type") + if !ok { + return "", false + } + return fmt.Sprintf("%s|%s", name, itemType), true +} + +func buildReleasePlanArrayKeyByNameID(item interface{}) (string, bool) { + value, ok := getMapField(item) + if !ok { + return "", false + } + name, ok := getStringField(value, "name") + if !ok { + return "", false + } + id, ok := getStringField(value, "id") + if !ok { + return "", false + } + return fmt.Sprintf("%s|%s", name, id), true +} + +func buildReleasePlanArrayKeyByName(item interface{}) (string, bool) { + value, ok := getMapField(item) + if !ok { + return "", false + } + return getStringField(value, "name") +} + +func buildReleasePlanArrayKeyByKey(item interface{}) (string, bool) { + value, ok := getMapField(item) + if !ok { + return "", false + } + return getStringField(value, "key") +} + +func buildReleasePlanArrayKeyByTarget(item interface{}) (string, bool) { + value, ok := getMapField(item) + if !ok { + return "", false + } + return getStringField(value, "target") +} + +func buildReleasePlanArrayKeyByServiceModule(item interface{}) (string, bool) { + value, ok := getMapField(item) + if !ok { + return "", false + } + service, ok := getStringField(value, "service_name") + if !ok { + return "", false + } + module, ok := getStringField(value, "service_module") + if !ok { + return "", false + } + return fmt.Sprintf("%s/%s", service, module), true +} + +func buildReleasePlanArrayKeyByServiceModuleNameOnly(item interface{}) (string, bool) { + value, ok := getMapField(item) + if !ok { + return "", false + } + if module, ok := getStringField(value, "service_module"); ok { + return module, true + } + return getStringField(value, "name") +} + +func buildReleasePlanArrayKeyByServiceName(item interface{}) (string, bool) { + value, ok := getMapField(item) + if !ok { + return "", false + } + return getStringField(value, "service_name") +} + +func buildReleasePlanArrayKeyByVariableConfig(item interface{}) (string, bool) { + value, ok := getMapField(item) + if !ok { + return "", false + } + variableKey, ok := getStringField(value, "variable_key") + if !ok { + return "", false + } + source, _ := getStringField(value, "source") + return fmt.Sprintf("%s|%s", variableKey, source), true +} + +func buildReleasePlanArrayKeyByWorkflowTrigger(item interface{}) (string, bool) { + value, ok := getMapField(item) + if !ok { + return "", false + } + serviceName, ok := getStringField(value, "service_name") + if !ok { + return "", false + } + workflowName, ok := getStringField(value, "workflow_name") + if !ok { + return "", false + } + projectName, ok := getStringField(value, "project_name") + if !ok { + return "", false + } + serviceModule, _ := getStringField(value, "service_module") + return fmt.Sprintf("%s|%s|%s|%s", serviceName, serviceModule, workflowName, projectName), true +} + +func buildReleasePlanArrayKeyByFixedWorkflowTrigger(item interface{}) (string, bool) { + value, ok := getMapField(item) + if !ok { + return "", false + } + workflowName, ok := getStringField(value, "workflow_name") + if !ok { + return "", false + } + projectName, ok := getStringField(value, "project_name") + if !ok { + return "", false + } + return fmt.Sprintf("%s|%s", workflowName, projectName), true +} + +func buildReleasePlanArrayKeyByJobName(item interface{}) (string, bool) { + value, ok := getMapField(item) + if !ok { + return "", false + } + return getStringField(value, "job_name") +} + +func buildReleasePlanArrayKeyByRepo(item interface{}) (string, bool) { + value, ok := getMapField(item) + if !ok { + return "", false + } + repo, ok := getStringField(value, "repo_name") + if !ok { + return "", false + } + namespace, _ := getStringField(value, "repo_namespace") + remote, _ := getStringField(value, "remote_name") + return fmt.Sprintf("%s/%s/%s", namespace, repo, remote), true +} + +func buildReleasePlanArrayKeyByUserID(item interface{}) (string, bool) { + value, ok := getMapField(item) + if !ok { + return "", false + } + return getStringField(value, "user_id") +} + +func buildReleasePlanArrayKeyByThirdPartyUserID(item interface{}) (string, bool) { + value, ok := getMapField(item) + if !ok { + return "", false + } + if id, ok := getStringField(value, "id"); ok { + return id, true + } + name, hasName := getStringField(value, "name") + userID, hasUserID := getStringField(value, "user_id") + if hasName && hasUserID { + return fmt.Sprintf("%s|%s", name, userID), true + } + return "", false +} + +func buildReleasePlanArrayKeyByApprovalGroup(item interface{}) (string, bool) { + value, ok := getMapField(item) + if !ok { + return "", false + } + if groupID, ok := getStringField(value, "group_id"); ok { + return groupID, true + } + return getStringField(value, "group_name") +} + +func buildReleasePlanArrayKeyByJiraSprint(item interface{}) (string, bool) { + value, ok := getMapField(item) + if !ok { + return "", false + } + projectKey, _ := getStringField(value, "project_key") + if projectKey == "" { + projectKey, _ = getStringField(value, "project_name") + } + if projectKey == "" { + return "", false + } + boardID, ok := getNumberFieldString(value, "board_id") + if !ok { + return "", false + } + sprintID, ok := getNumberFieldString(value, "sprint_id") + if !ok { + return "", false + } + return fmt.Sprintf("%s|%s|%s", projectKey, boardID, sprintID), true +} + +func buildReleasePlanArrayKeyByK8sTarget(item interface{}) (string, bool) { + value, ok := getMapField(item) + if !ok { + return "", false + } + serviceName, ok := getStringField(value, "k8s_service_name") + if !ok { + return "", false + } + workloadName, ok := getStringField(value, "workload_name") + if !ok { + return "", false + } + containerName, ok := getStringField(value, "container_name") + if !ok { + return "", false + } + return fmt.Sprintf("%s|%s|%s", serviceName, workloadName, containerName), true +} + +func buildReleasePlanArrayKeyByGrayReleaseTarget(item interface{}) (string, bool) { + value, ok := getMapField(item) + if !ok { + return "", false + } + workloadType, ok := getStringField(value, "workload_type") + if !ok { + return "", false + } + workloadName, ok := getStringField(value, "workload_name") + if !ok { + return "", false + } + containerName, ok := getStringField(value, "container_name") + if !ok { + return "", false + } + return fmt.Sprintf("%s|%s|%s", workloadType, workloadName, containerName), true +} + +func buildReleasePlanArrayKeyByGrayRollbackTarget(item interface{}) (string, bool) { + value, ok := getMapField(item) + if !ok { + return "", false + } + workloadType, ok := getStringField(value, "workload_type") + if !ok { + return "", false + } + workloadName, ok := getStringField(value, "workload_name") + if !ok { + return "", false + } + return fmt.Sprintf("%s|%s", workloadType, workloadName), true +} + +func buildReleasePlanArrayKeyByIstioTarget(item interface{}) (string, bool) { + value, ok := getMapField(item) + if !ok { + return "", false + } + virtualServiceName, ok := getStringField(value, "virtual_service_name") + if !ok { + return "", false + } + workloadName, ok := getStringField(value, "workload_name") + if !ok { + return "", false + } + containerName, ok := getStringField(value, "container_name") + if !ok { + return "", false + } + return fmt.Sprintf("%s|%s|%s", virtualServiceName, workloadName, containerName), true +} + +func getMapField(item interface{}) (map[string]interface{}, bool) { + value, ok := item.(map[string]interface{}) + return value, ok } func getStringField(input map[string]interface{}, key string) (string, bool) { @@ -571,6 +1187,56 @@ func getStringField(input map[string]interface{}, key string) (string, bool) { return str, ok && str != "" } +func getNumberFieldString(input map[string]interface{}, key string) (string, bool) { + value, exists := input[key] + if !exists { + return "", false + } + switch typed := value.(type) { + case string: + return typed, typed != "" + case float64: + intValue := int64(typed) + if float64(intValue) != typed { + return "", false + } + return strconv.FormatInt(intValue, 10), true + case float32: + intValue := int64(typed) + if float32(intValue) != typed { + return "", false + } + return strconv.FormatInt(intValue, 10), true + case int: + return strconv.Itoa(typed), true + case int8: + return strconv.FormatInt(int64(typed), 10), true + case int16: + return strconv.FormatInt(int64(typed), 10), true + case int32: + return strconv.FormatInt(int64(typed), 10), true + case int64: + return strconv.FormatInt(typed, 10), true + case uint: + return strconv.FormatUint(uint64(typed), 10), true + case uint8: + return strconv.FormatUint(uint64(typed), 10), true + case uint16: + return strconv.FormatUint(uint64(typed), 10), true + case uint32: + return strconv.FormatUint(uint64(typed), 10), true + case uint64: + return strconv.FormatUint(typed, 10), true + case json.Number: + if intValue, err := typed.Int64(); err == nil { + return strconv.FormatInt(intValue, 10), true + } + return "", false + default: + return "", false + } +} + func joinReleasePlanDiffPath(path, key string) string { if path == "" { return key From 8954bab55842da786a62285f3571ec095a6814ff Mon Sep 17 00:00:00 2001 From: huanghongbo-hhb Date: Fri, 22 May 2026 15:03:40 +0800 Subject: [PATCH 11/33] fix: improve release plan target order labels Signed-off-by: huanghongbo-hhb --- .../aslan/core/release_plan/service/diff.go | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/pkg/microservice/aslan/core/release_plan/service/diff.go b/pkg/microservice/aslan/core/release_plan/service/diff.go index d0e8229a18..b000bc3a04 100644 --- a/pkg/microservice/aslan/core/release_plan/service/diff.go +++ b/pkg/microservice/aslan/core/release_plan/service/diff.go @@ -803,6 +803,10 @@ func buildReleasePlanArrayOrderItem(item interface{}, key string) *ReleasePlanVe resp.Name = target return resp } + if targetName := buildReleasePlanTargetOrderName(value); targetName != "" { + resp.Name = targetName + return resp + } if userID, ok := getStringField(value, "user_id"); ok { resp.Name = userID return resp @@ -835,6 +839,35 @@ func buildReleasePlanArrayOrderItem(item interface{}, key string) *ReleasePlanVe return resp } +func buildReleasePlanTargetOrderName(value map[string]interface{}) string { + if serviceName, ok := getStringField(value, "k8s_service_name"); ok { + workloadName, _ := getStringField(value, "workload_name") + containerName, _ := getStringField(value, "container_name") + return joinReleasePlanOrderNameParts(serviceName, workloadName, containerName) + } + if virtualServiceName, ok := getStringField(value, "virtual_service_name"); ok { + workloadName, _ := getStringField(value, "workload_name") + containerName, _ := getStringField(value, "container_name") + return joinReleasePlanOrderNameParts(virtualServiceName, workloadName, containerName) + } + if workloadType, ok := getStringField(value, "workload_type"); ok { + workloadName, _ := getStringField(value, "workload_name") + containerName, _ := getStringField(value, "container_name") + return joinReleasePlanOrderNameParts(workloadType, workloadName, containerName) + } + return "" +} + +func joinReleasePlanOrderNameParts(parts ...string) string { + filtered := make([]string, 0, len(parts)) + for _, part := range parts { + if part != "" { + filtered = append(filtered, part) + } + } + return strings.Join(filtered, " / ") +} + func buildReleasePlanArrayMap(values []interface{}, buildKey releasePlanArrayKeyBuilder) (map[string]interface{}, []string, bool) { if buildKey == nil { return nil, nil, false From 45473e7116b14ab382332e15460b985e43f43863 Mon Sep 17 00:00:00 2001 From: huanghongbo-hhb Date: Thu, 28 May 2026 11:07:57 +0800 Subject: [PATCH 12/33] fix: refine release plan version diff snapshots Signed-off-by: huanghongbo-hhb --- .../common/repository/models/release_plan.go | 50 ++-- .../mongodb/release_plan_version.go | 12 + .../aslan/core/release_plan/service/diff.go | 116 +++++++++- .../core/release_plan/service/openapi.go | 46 ++-- .../core/release_plan/service/release_plan.go | 52 ++--- .../release_plan/service/section_snapshot.go | 214 +++++++++++++++++- .../core/release_plan/service/version.go | 46 +++- 7 files changed, 438 insertions(+), 98 deletions(-) diff --git a/pkg/microservice/aslan/core/common/repository/models/release_plan.go b/pkg/microservice/aslan/core/common/repository/models/release_plan.go index 9e8003856c..f901fcc489 100644 --- a/pkg/microservice/aslan/core/common/repository/models/release_plan.go +++ b/pkg/microservice/aslan/core/common/repository/models/release_plan.go @@ -121,18 +121,21 @@ type WorkflowReleaseJobSpec struct { } type ReleasePlanLog struct { - ID primitive.ObjectID `bson:"_id,omitempty" json:"id"` - PlanID string `bson:"plan_id" json:"plan_id"` - Username string `bson:"username" json:"username"` - Account string `bson:"account" json:"account"` - Verb string `bson:"verb" json:"verb"` - TargetName string `bson:"target_name" json:"target_name"` - TargetType string `bson:"target_type" json:"target_type"` - Before interface{} `bson:"before" json:"before"` - After interface{} `bson:"after" json:"after"` - Detail string `bson:"detail" json:"detail"` - Version int64 `bson:"version,omitempty" json:"version,omitempty"` - CreatedAt int64 `bson:"created_at" json:"created_at"` + ID primitive.ObjectID `bson:"_id,omitempty" json:"id"` + PlanID string `bson:"plan_id" json:"plan_id"` + Username string `bson:"username" json:"username"` + Account string `bson:"account" json:"account"` + Verb string `bson:"verb" json:"verb"` + TargetName string `bson:"target_name" json:"target_name"` + TargetType string `bson:"target_type" json:"target_type"` + Before interface{} `bson:"before,omitempty" json:"before,omitempty"` + After interface{} `bson:"after,omitempty" json:"after,omitempty"` + Detail string `bson:"detail" json:"detail"` + Version int64 `bson:"version,omitempty" json:"version,omitempty"` + SectionKey string `bson:"section_key,omitempty" json:"section_key,omitempty"` + SectionName string `bson:"section_name,omitempty" json:"section_name,omitempty"` + SectionType string `bson:"section_type,omitempty" json:"section_type,omitempty"` + CreatedAt int64 `bson:"created_at" json:"created_at"` } func (ReleasePlanLog) TableName() string { @@ -140,17 +143,18 @@ func (ReleasePlanLog) TableName() string { } type ReleasePlanVersion struct { - ID primitive.ObjectID `bson:"_id,omitempty" json:"id"` - PlanID string `bson:"plan_id" json:"plan_id"` - Version int64 `bson:"version" json:"version"` - Operator string `bson:"operator" json:"operator"` - Account string `bson:"account" json:"account"` - SectionKey string `bson:"section_key,omitempty" json:"section_key,omitempty"` - SectionName string `bson:"section_name,omitempty" json:"section_name,omitempty"` - Verb string `bson:"verb,omitempty" json:"verb,omitempty"` - BaseSnapshot interface{} `bson:"base_snapshot,omitempty" json:"base_snapshot,omitempty"` - Snapshot interface{} `bson:"snapshot" json:"snapshot"` - CreatedAt int64 `bson:"created_at" json:"created_at"` + ID primitive.ObjectID `bson:"_id,omitempty" json:"id"` + PlanID string `bson:"plan_id" json:"plan_id"` + Version int64 `bson:"version" json:"version"` + PreviousVersion int64 `bson:"previous_version,omitempty" json:"previous_version,omitempty"` + Operator string `bson:"operator" json:"operator"` + Account string `bson:"account" json:"account"` + SectionKey string `bson:"section_key,omitempty" json:"section_key,omitempty"` + SectionName string `bson:"section_name,omitempty" json:"section_name,omitempty"` + SectionType string `bson:"section_type,omitempty" json:"section_type,omitempty"` + Verb string `bson:"verb,omitempty" json:"verb,omitempty"` + Snapshot interface{} `bson:"snapshot" json:"snapshot"` + CreatedAt int64 `bson:"created_at" json:"created_at"` } func (ReleasePlanVersion) TableName() string { diff --git a/pkg/microservice/aslan/core/common/repository/mongodb/release_plan_version.go b/pkg/microservice/aslan/core/common/repository/mongodb/release_plan_version.go index 5d90bf4e20..861d7ffb5d 100644 --- a/pkg/microservice/aslan/core/common/repository/mongodb/release_plan_version.go +++ b/pkg/microservice/aslan/core/common/repository/mongodb/release_plan_version.go @@ -87,3 +87,15 @@ func (c *ReleasePlanVersionColl) GetLatest(planID string) (*models.ReleasePlanVe }, options.FindOne().SetSort(bson.D{{Key: "version", Value: -1}})).Decode(resp) return resp, err } + +func (c *ReleasePlanVersionColl) GetLatestBySectionsBefore(planID string, sectionKeys []string, beforeVersion int64) (*models.ReleasePlanVersion, error) { + resp := new(models.ReleasePlanVersion) + err := c.FindOne(context.Background(), bson.M{ + "plan_id": planID, + "version": bson.M{"$lt": beforeVersion}, + "section_key": bson.M{ + "$in": sectionKeys, + }, + }, options.FindOne().SetSort(bson.D{{Key: "version", Value: -1}})).Decode(resp) + return resp, err +} diff --git a/pkg/microservice/aslan/core/release_plan/service/diff.go b/pkg/microservice/aslan/core/release_plan/service/diff.go index b000bc3a04..0de9065c2e 100644 --- a/pkg/microservice/aslan/core/release_plan/service/diff.go +++ b/pkg/microservice/aslan/core/release_plan/service/diff.go @@ -29,6 +29,7 @@ import ( "github.com/pkg/errors" "github.com/koderover/zadig/v2/pkg/microservice/aslan/config" + "github.com/koderover/zadig/v2/pkg/microservice/aslan/core/common/repository/models" "github.com/koderover/zadig/v2/pkg/microservice/aslan/core/common/repository/mongodb" ) @@ -144,6 +145,7 @@ var releasePlanArrayExactRules = []releasePlanArrayDiffRule{ newReleasePlanExactArrayRule("plan", "jobs", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByNameTypeID), newReleasePlanExactArrayRule(releasePlanVersionSectionJobsOrder, "", releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByNameID), newReleasePlanExactArrayRule("job", "spec.workflow.params", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByNameType), + newReleasePlanExactArrayRule("job", "spec.workflow.jobs", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByNameType), newReleasePlanExactArrayRule("job", "spec.workflow.stages", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByName), newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByNameType), newReleasePlanExactArrayRule("job", "spec.workflow.share_storages", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByName), @@ -172,13 +174,21 @@ var releasePlanArrayExactRules = []releasePlanArrayDiffRule{ newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.scannings", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByName), newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.scanning_options", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByName), newReleasePlanTypedExactArrayRule("job", "spec.workflow.stages.jobs.spec.services", []config.JobType{config.JobZadigDeploy, config.JobK8sBlueGreenDeploy}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByServiceName), + newReleasePlanTypedExactArrayRule("job", "spec.workflow.jobs.spec.services", []config.JobType{config.JobZadigDeploy, config.JobK8sBlueGreenDeploy}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByServiceName), newReleasePlanTypedExactArrayRule("job", "spec.workflow.stages.jobs.spec.services", []config.JobType{config.JobFreestyle}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByServiceModule), + newReleasePlanTypedExactArrayRule("job", "spec.workflow.jobs.spec.services", []config.JobType{config.JobFreestyle}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByServiceModule), newReleasePlanTypedExactArrayRule("job", "spec.workflow.stages.jobs.spec.service_options", []config.JobType{config.JobK8sBlueGreenDeploy}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByServiceName), + newReleasePlanTypedExactArrayRule("job", "spec.workflow.jobs.spec.service_options", []config.JobType{config.JobK8sBlueGreenDeploy}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByServiceName), newReleasePlanTypedExactArrayRule("job", "spec.workflow.stages.jobs.spec.service_config.services", []config.JobType{config.JobSAEDeploy}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByServiceModule), + newReleasePlanTypedExactArrayRule("job", "spec.workflow.jobs.spec.service_config.services", []config.JobType{config.JobSAEDeploy}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByServiceModule), newReleasePlanTypedExactArrayRule("job", "spec.workflow.stages.jobs.spec.services.modules", []config.JobType{config.JobZadigDeploy}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByServiceModuleNameOnly), + newReleasePlanTypedExactArrayRule("job", "spec.workflow.jobs.spec.services.modules", []config.JobType{config.JobZadigDeploy}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByServiceModuleNameOnly), newReleasePlanTypedExactArrayRule("job", "spec.workflow.stages.jobs.spec.service_variable_config", []config.JobType{config.JobZadigDeploy}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByServiceName), + newReleasePlanTypedExactArrayRule("job", "spec.workflow.jobs.spec.service_variable_config", []config.JobType{config.JobZadigDeploy}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByServiceName), newReleasePlanTypedExactArrayRule("job", "spec.workflow.stages.jobs.spec.service_variable_config.modules", []config.JobType{config.JobZadigDeploy}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByServiceModuleNameOnly), + newReleasePlanTypedExactArrayRule("job", "spec.workflow.jobs.spec.service_variable_config.modules", []config.JobType{config.JobZadigDeploy}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByServiceModuleNameOnly), newReleasePlanTypedExactArrayRule("job", "spec.workflow.stages.jobs.spec.service_variable_config.variable_configs", []config.JobType{config.JobZadigDeploy}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByVariableConfig), + newReleasePlanTypedExactArrayRule("job", "spec.workflow.jobs.spec.service_variable_config.variable_configs", []config.JobType{config.JobZadigDeploy}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByVariableConfig), newReleasePlanTypedExactArrayRule("job", "spec.workflow.stages.jobs.spec.services.service_and_image", []config.JobType{config.JobK8sBlueGreenDeploy}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByServiceModuleNameOnly), newReleasePlanTypedExactArrayRule("job", "spec.workflow.stages.jobs.spec.service_options.service_and_image", []config.JobType{config.JobK8sBlueGreenDeploy}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByServiceModuleNameOnly), newReleasePlanTypedExactArrayRule("job", "spec.workflow.stages.jobs.spec.gray_services.service_and_image", []config.JobType{config.JobMseGrayRelease}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByServiceModuleNameOnly), @@ -223,6 +233,7 @@ var releasePlanArrayExactRules = []releasePlanArrayDiffRule{ } var releasePlanArraySafeSuffixRules = []releasePlanArrayDiffRule{ + newReleasePlanSafeSuffixArrayRule("job", "params", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByNameType), newReleasePlanSafeSuffixArrayRule("job", "repos", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByRepo), newReleasePlanSafeSuffixArrayRule("job", "code_info", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByRepo), newReleasePlanSafeSuffixArrayRule("job", "key_vals", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByKey), @@ -233,6 +244,14 @@ var releasePlanArraySafeSuffixRules = []releasePlanArrayDiffRule{ newReleasePlanSafeSuffixArrayRule("job", "variable_kvs", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByKey), newReleasePlanSafeSuffixArrayRule("job", "kv", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByKey), newReleasePlanSafeSuffixArrayRule("job", "original_config", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByKey), + newReleasePlanSafeSuffixArrayRule("job", "service_and_builds", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByServiceModule), + newReleasePlanSafeSuffixArrayRule("job", "service_and_vm_deploys", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByServiceModule), + newReleasePlanSafeSuffixArrayRule("job", "service_and_tests", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByServiceModule), + newReleasePlanSafeSuffixArrayRule("job", "service_and_scannings", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByServiceModule), + newReleasePlanSafeSuffixArrayRule("job", "target_services", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByServiceModule), + newReleasePlanSafeSuffixArrayRule("job", "service_and_image", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByServiceModule), + newReleasePlanSafeSuffixArrayRule("job", "test_modules", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByName), + newReleasePlanSafeSuffixArrayRule("job", "scannings", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByName), } var releasePlanFieldLabels = map[string]string{ @@ -295,10 +314,15 @@ func GetReleasePlanVersionDiff(planID string, version int64) (*ReleasePlanVersio return nil, errors.Wrap(err, "get version") } - fromData, err := toGenericValue(current.BaseSnapshot) - if err != nil { - return nil, errors.Wrap(err, "convert base snapshot") + var previous *models.ReleasePlanVersion + if current.PreviousVersion > 0 { + previous, err = mongodb.NewReleasePlanVersionColl().Get(planID, current.PreviousVersion) + if err != nil { + return nil, errors.Wrap(err, "get previous version") + } } + + fromData := comparableReleasePlanVersionSnapshot(previous, current.SectionKey) toData, err := toGenericValue(current.Snapshot) if err != nil { return nil, errors.Wrap(err, "convert current snapshot") @@ -362,16 +386,65 @@ func GetReleasePlanVersionDiff(planID string, version int64) (*ReleasePlanVersio return &ReleasePlanVersionDiffResponse{ PlanID: planID, Version: version, - PreviousVersion: previousReleasePlanVersion(version), + PreviousVersion: current.PreviousVersion, Groups: groups, }, nil } -func previousReleasePlanVersion(version int64) int64 { - if version <= 1 { - return 0 +func comparableReleasePlanVersionSnapshot(version *models.ReleasePlanVersion, sectionKey string) interface{} { + if version == nil { + return nil + } + + switch { + case version.SectionKey == sectionKey, sectionKey == releasePlanVersionSectionPlan: + value, err := toGenericValue(version.Snapshot) + if err != nil { + return version.Snapshot + } + return value + case version.SectionKey != releasePlanVersionSectionPlan: + return nil + default: + return extractReleasePlanSectionSnapshot(version.Snapshot, sectionKey) + } +} + +func extractReleasePlanSectionSnapshot(snapshot interface{}, sectionKey string) interface{} { + genericValue, err := toGenericValue(snapshot) + if err != nil { + return nil + } + + planSnapshot, ok := genericValue.(map[string]interface{}) + if !ok { + return nil + } + + switch { + case sectionKey == releasePlanVersionSectionMetadata: + return planSnapshot["metadata"] + case sectionKey == releasePlanVersionSectionApproval: + return planSnapshot["approval"] + case sectionKey == releasePlanVersionSectionJobsOrder: + return planSnapshot["jobs_order"] + case strings.HasPrefix(sectionKey, releasePlanVersionSectionJobPrefix): + jobID := strings.TrimPrefix(sectionKey, releasePlanVersionSectionJobPrefix) + jobs, ok := planSnapshot["jobs"].([]interface{}) + if !ok { + return nil + } + for _, item := range jobs { + job, ok := item.(map[string]interface{}) + if !ok { + continue + } + if id, _ := job["id"].(string); id == jobID { + return job + } + } } - return version - 1 + return nil } func toGenericValue(value interface{}) (interface{}, error) { @@ -518,6 +591,9 @@ func diffReleasePlanArray(ctx releasePlanDiffContext, path string, left, right [ } } for _, key := range keys { + if shouldSkipReleasePlanWorkflowTaskPresenceChange(path, leftMap[key], rightMap[key]) { + continue + } nextPath := fmt.Sprintf("%s[%s]", path, key) diffReleasePlanValues(ctx, nextPath, leftMap[key], rightMap[key], entries) } @@ -527,6 +603,14 @@ func diffReleasePlanArray(ctx releasePlanDiffContext, path string, left, right [ diffReleasePlanArrayByIndex(ctx, path, left, right, entries) } +func shouldSkipReleasePlanWorkflowTaskPresenceChange(path string, left, right interface{}) bool { + if left != nil && right != nil { + return false + } + normalizedPath := normalizeReleasePlanDiffPath(path) + return normalizedPath == "spec.workflow.jobs" || strings.HasSuffix(normalizedPath, ".spec.workflow.jobs") +} + func diffReleasePlanArrayByIndex(ctx releasePlanDiffContext, path string, left, right []interface{}, entries *[]*releasePlanRawDiffEntry) { maxLen := len(left) if len(right) > maxLen { @@ -1281,6 +1365,10 @@ func shouldIgnoreReleasePlanDiffPath(path string) bool { if path == "" { return false } + if isReleasePlanWorkflowJobStructureDiffPath(path) { + return true + } + prefixes := []string{ "id", "index", @@ -1335,9 +1423,19 @@ func shouldIgnoreReleasePlanDiffPath(path string) bool { return false } +func isReleasePlanWorkflowJobStructureDiffPath(path string) bool { + if !strings.HasPrefix(path, "spec.workflow.jobs[") { + return false + } + if strings.Contains(path, "].spec.") { + return false + } + return true +} + func classifyReleasePlanDiffTask(path string) (taskName, taskType string) { jobSegments := releasePlanBracketSegments(path, "jobs") - if len(jobSegments) >= 2 { + if len(jobSegments) >= 1 { taskName, taskType = splitReleasePlanBracketKey(jobSegments[len(jobSegments)-1]) } return diff --git a/pkg/microservice/aslan/core/release_plan/service/openapi.go b/pkg/microservice/aslan/core/release_plan/service/openapi.go index 73e90ef046..0b0d2e3d0a 100644 --- a/pkg/microservice/aslan/core/release_plan/service/openapi.go +++ b/pkg/microservice/aslan/core/release_plan/service/openapi.go @@ -169,20 +169,23 @@ func OpenAPICreateReleasePlan(c *handler.Context, rawArgs *OpenAPICreateReleaseP go func() { sectionSnapshot, err := buildReleasePlanInputSnapshot(args) if err == nil { - err = createReleasePlanVersion(planID, 1, nil, sectionSnapshot, c.UserName, c.Account, releasePlanVersionSectionPlan, releasePlanVersionSectionName(releasePlanVersionSectionPlan, args.Name), VerbCreate) + err = createReleasePlanVersion(planID, 1, sectionSnapshot, c.UserName, c.Account, releasePlanVersionSectionPlan, releasePlanVersionSectionName(releasePlanVersionSectionPlan, args.Name), VerbCreate) } if err != nil { log.Errorf("create release plan version error: %v", err) } if err := createReleasePlanLog(&models.ReleasePlanLog{ - PlanID: planID, - Username: c.UserName, - Account: c.Account, - Verb: VerbCreate, - TargetName: args.Name, - TargetType: TargetTypeReleasePlan, - Version: 1, - CreatedAt: time.Now().Unix(), + PlanID: planID, + Username: c.UserName, + Account: c.Account, + Verb: VerbCreate, + TargetName: args.Name, + TargetType: TargetTypeReleasePlan, + Version: 1, + SectionKey: releasePlanVersionSectionPlan, + SectionName: releasePlanVersionSectionName(releasePlanVersionSectionPlan, args.Name), + SectionType: releasePlanVersionSectionGroupType(releasePlanVersionSectionPlan), + CreatedAt: time.Now().Unix(), }); err != nil { log.Errorf("create release plan log error: %v", err) } @@ -385,26 +388,25 @@ func OpenAPICreateReleasePlanWithJobs(c *handler.Context, id string, rawArgs *Op return errors.Wrap(err, "update release plan error") } - baseSnapshot, err := buildReleasePlanInputSnapshot(originalPlan) - if err != nil { - return errors.Wrap(err, "build release plan base snapshot") - } currentSnapshot, err := buildReleasePlanInputSnapshot(plan) if err != nil { return errors.Wrap(err, "build release plan current snapshot") } - if err := createReleasePlanVersion(plan.ID.Hex(), plan.Version, baseSnapshot, currentSnapshot, c.UserName, c.Account, releasePlanVersionSectionPlan, releasePlanVersionSectionName(releasePlanVersionSectionPlan, plan.Name), VerbUpdate); err != nil { + if err := createReleasePlanVersion(plan.ID.Hex(), plan.Version, currentSnapshot, c.UserName, c.Account, releasePlanVersionSectionPlan, releasePlanVersionSectionName(releasePlanVersionSectionPlan, plan.Name), VerbUpdate); err != nil { log.Errorf("create release plan version error: %v", err) } if err := createReleasePlanLog(&models.ReleasePlanLog{ - PlanID: plan.ID.Hex(), - Username: c.UserName, - Account: c.Account, - Verb: VerbUpdate, - TargetName: plan.Name, - TargetType: TargetTypeReleasePlan, - Version: plan.Version, - CreatedAt: time.Now().Unix(), + PlanID: plan.ID.Hex(), + Username: c.UserName, + Account: c.Account, + Verb: VerbUpdate, + TargetName: plan.Name, + TargetType: TargetTypeReleasePlan, + Version: plan.Version, + SectionKey: releasePlanVersionSectionPlan, + SectionName: releasePlanVersionSectionName(releasePlanVersionSectionPlan, plan.Name), + SectionType: releasePlanVersionSectionGroupType(releasePlanVersionSectionPlan), + CreatedAt: time.Now().Unix(), }); err != nil { log.Errorf("create release plan log error: %v", err) } diff --git a/pkg/microservice/aslan/core/release_plan/service/release_plan.go b/pkg/microservice/aslan/core/release_plan/service/release_plan.go index 43b386aaaf..9f2428302a 100644 --- a/pkg/microservice/aslan/core/release_plan/service/release_plan.go +++ b/pkg/microservice/aslan/core/release_plan/service/release_plan.go @@ -134,20 +134,23 @@ func CreateReleasePlan(c *handler.Context, args *models.ReleasePlan) error { go func() { sectionSnapshot, err := buildReleasePlanInputSnapshot(args) if err == nil { - err = createReleasePlanVersion(planID, 1, nil, sectionSnapshot, c.UserName, c.Account, releasePlanVersionSectionPlan, releasePlanVersionSectionName(releasePlanVersionSectionPlan, args.Name), VerbCreate) + err = createReleasePlanVersion(planID, 1, sectionSnapshot, c.UserName, c.Account, releasePlanVersionSectionPlan, releasePlanVersionSectionName(releasePlanVersionSectionPlan, args.Name), VerbCreate) } if err != nil { log.Errorf("create release plan version error: %v", err) } if err := createReleasePlanLog(&models.ReleasePlanLog{ - PlanID: planID, - Username: c.UserName, - Account: c.Account, - Verb: VerbCreate, - TargetName: args.Name, - TargetType: TargetTypeReleasePlan, - Version: 1, - CreatedAt: time.Now().Unix(), + PlanID: planID, + Username: c.UserName, + Account: c.Account, + Verb: VerbCreate, + TargetName: args.Name, + TargetType: TargetTypeReleasePlan, + Version: 1, + SectionKey: releasePlanVersionSectionPlan, + SectionName: releasePlanVersionSectionName(releasePlanVersionSectionPlan, args.Name), + SectionType: releasePlanVersionSectionGroupType(releasePlanVersionSectionPlan), + CreatedAt: time.Now().Unix(), }); err != nil { log.Errorf("create release plan log error: %v", err) } @@ -438,7 +441,7 @@ func UpdateReleasePlan(c *handler.Context, planID string, args *UpdateReleasePla if err = updater.Lint(); err != nil { return errors.Wrap(err, "lint") } - before, after, err := updater.Update(plan) + _, _, err = updater.Update(plan) if err != nil { return errors.Wrap(err, "update") } @@ -447,10 +450,6 @@ func UpdateReleasePlan(c *handler.Context, planID string, args *UpdateReleasePla if err != nil { return errors.Wrap(err, "resolve release plan section") } - baseSnapshot, err := buildReleasePlanVersionSnapshot(originalPlan, sectionKey) - if err != nil { - return errors.Wrap(err, "build release plan base snapshot") - } currentSnapshot, err := buildReleasePlanVersionSnapshot(plan, sectionKey) if err != nil { return errors.Wrap(err, "build release plan current snapshot") @@ -482,18 +481,19 @@ func UpdateReleasePlan(c *handler.Context, planID string, args *UpdateReleasePla } logItem := &models.ReleasePlanLog{ - PlanID: planID, - Username: c.UserName, - Account: c.Account, - Verb: updater.Verb(), - Before: before, - After: after, - TargetName: updater.TargetName(), - TargetType: updater.TargetType(), - Version: plan.Version, - CreatedAt: time.Now().Unix(), - } - if err := createReleasePlanVersion(planID, plan.Version, baseSnapshot, currentSnapshot, c.UserName, c.Account, sectionKey, releasePlanVersionSectionName(sectionKey, sectionName), string(args.Verb)); err != nil { + PlanID: planID, + Username: c.UserName, + Account: c.Account, + Verb: updater.Verb(), + TargetName: updater.TargetName(), + TargetType: updater.TargetType(), + Version: plan.Version, + SectionKey: sectionKey, + SectionName: releasePlanVersionSectionName(sectionKey, sectionName), + SectionType: releasePlanVersionSectionGroupType(sectionKey), + CreatedAt: time.Now().Unix(), + } + if err := createReleasePlanVersion(planID, plan.Version, currentSnapshot, c.UserName, c.Account, sectionKey, releasePlanVersionSectionName(sectionKey, sectionName), string(args.Verb)); err != nil { log.Errorf("create release plan version error: %v", err) } if err := createReleasePlanLog(logItem); err != nil { diff --git a/pkg/microservice/aslan/core/release_plan/service/section_snapshot.go b/pkg/microservice/aslan/core/release_plan/service/section_snapshot.go index fa5581097f..3017a2ed37 100644 --- a/pkg/microservice/aslan/core/release_plan/service/section_snapshot.go +++ b/pkg/microservice/aslan/core/release_plan/service/section_snapshot.go @@ -161,7 +161,7 @@ func buildReleasePlanVersionSnapshot(plan *models.ReleasePlan, sectionKey string case sectionKey == releasePlanVersionSectionMetadata: return buildReleasePlanMetadataSnapshot(plan), nil case sectionKey == releasePlanVersionSectionApproval: - return sanitizeReleasePlanValue(plan.Approval), nil + return buildReleasePlanApprovalSnapshot(plan.Approval) case sectionKey == releasePlanVersionSectionJobsOrder: return buildReleasePlanJobsOrderSnapshot(plan), nil case strings.HasPrefix(sectionKey, releasePlanVersionSectionJobPrefix): @@ -177,10 +177,16 @@ func buildReleasePlanVersionSnapshot(plan *models.ReleasePlan, sectionKey string } func buildReleasePlanInputSnapshot(plan *models.ReleasePlan) (interface{}, error) { + approvalSnapshot, err := buildReleasePlanApprovalSnapshot(plan.Approval) + if err != nil { + return nil, err + } + resp := map[string]interface{}{ - "metadata": buildReleasePlanMetadataSnapshot(plan), - "approval": sanitizeReleasePlanValue(plan.Approval), - "jobs": make([]interface{}, 0, len(plan.Jobs)), + "metadata": buildReleasePlanMetadataSnapshot(plan), + "approval": approvalSnapshot, + "jobs": make([]interface{}, 0, len(plan.Jobs)), + "jobs_order": buildReleasePlanJobsOrderSnapshot(plan), } for _, job := range plan.Jobs { snapshot, err := buildReleasePlanJobInputSnapshot(job) @@ -208,6 +214,57 @@ func buildReleasePlanMetadataSnapshot(plan *models.ReleasePlan) map[string]inter } } +func buildReleasePlanApprovalSnapshot(approval *models.Approval) (interface{}, error) { + if approval == nil { + return nil, nil + } + + genericValue, err := toReleasePlanGenericValue(approval) + if err != nil { + return nil, err + } + return sanitizeReleasePlanValue(filterReleasePlanApprovalInputValue(genericValue)), nil +} + +func filterReleasePlanApprovalInputValue(value interface{}) interface{} { + switch typedValue := value.(type) { + case map[string]interface{}: + resp := make(map[string]interface{}, len(typedValue)) + for key, item := range typedValue { + if shouldDropReleasePlanApprovalInputField(key) { + continue + } + resp[key] = filterReleasePlanApprovalInputValue(item) + } + return resp + case []interface{}: + resp := make([]interface{}, 0, len(typedValue)) + for _, item := range typedValue { + resp = append(resp, filterReleasePlanApprovalInputValue(item)) + } + return resp + default: + return value + } +} + +func shouldDropReleasePlanApprovalInputField(key string) bool { + dropKeys := map[string]struct{}{ + "status": {}, + "instance_code": {}, + "instance_id": {}, + "approval_instance": {}, + "task_list": {}, + "timeline": {}, + "reject_or_approve": {}, + "operation_time": {}, + "comment": {}, + "approval_node_details": {}, + } + _, exists := dropKeys[key] + return exists +} + func buildReleasePlanJobsOrderSnapshot(plan *models.ReleasePlan) []interface{} { resp := make([]interface{}, 0) if plan == nil { @@ -255,14 +312,157 @@ func buildReleasePlanJobInputSpec(jobType config.ReleasePlanJobType, spec interf if err := models.IToi(spec, inputSpec); err != nil { return nil, err } - return sanitizeReleasePlanValue(map[string]interface{}{ - "workflow": inputSpec.Workflow, - }), nil + workflowSnapshot, err := buildReleasePlanWorkflowInputSnapshot(inputSpec.Workflow) + if err != nil { + return nil, err + } + return map[string]interface{}{ + "workflow": workflowSnapshot, + }, nil default: return sanitizeReleasePlanValue(spec), nil } } +func buildReleasePlanWorkflowInputSnapshot(workflow *models.WorkflowV4) (interface{}, error) { + if workflow == nil { + return nil, nil + } + + resp := map[string]interface{}{ + "name": workflow.Name, + "display_name": workflow.DisplayName, + "project": workflow.Project, + "params": sanitizeReleasePlanValue(workflow.Params), + "jobs": make([]interface{}, 0), + } + + jobs := make([]interface{}, 0) + for _, stage := range workflow.Stages { + if stage == nil { + continue + } + for _, job := range stage.Jobs { + if job == nil { + continue + } + spec, err := buildReleasePlanWorkflowJobInputSpec(job.Spec) + if err != nil { + return nil, err + } + jobs = append(jobs, map[string]interface{}{ + "name": job.Name, + "type": job.JobType, + "spec": spec, + }) + } + } + resp["jobs"] = jobs + + return sanitizeReleasePlanValue(resp), nil +} + +func buildReleasePlanWorkflowJobInputSpec(spec interface{}) (interface{}, error) { + genericValue, err := toReleasePlanGenericValue(spec) + if err != nil { + return nil, err + } + return filterReleasePlanWorkflowInputValue(genericValue), nil +} + +func filterReleasePlanWorkflowInputValue(value interface{}) interface{} { + switch typedValue := value.(type) { + case map[string]interface{}: + resp := make(map[string]interface{}, len(typedValue)) + for key, item := range typedValue { + if key == "plugin" { + filteredPlugin := filterReleasePlanPluginTemplateInputValue(item) + if filteredPlugin != nil { + resp[key] = filteredPlugin + } + continue + } + if shouldDropReleasePlanWorkflowInputField(key) { + continue + } + resp[key] = filterReleasePlanWorkflowInputValue(item) + } + return resp + case []interface{}: + resp := make([]interface{}, 0, len(typedValue)) + for _, item := range typedValue { + resp = append(resp, filterReleasePlanWorkflowInputValue(item)) + } + return resp + default: + return value + } +} + +func filterReleasePlanPluginTemplateInputValue(value interface{}) interface{} { + plugin, ok := value.(map[string]interface{}) + if !ok { + return nil + } + + inputs, exists := plugin["inputs"] + if !exists { + return nil + } + + return map[string]interface{}{ + "inputs": filterReleasePlanWorkflowInputValue(inputs), + } +} + +func shouldDropReleasePlanWorkflowInputField(key string) bool { + if key == "" { + return false + } + + dropKeys := map[string]struct{}{ + "last_status": {}, + "updated": {}, + "executed_by": {}, + "executed_time": {}, + "task_id": {}, + "hook_payload": {}, + "hash": {}, + "notification_id": {}, + "created_by": {}, + "create_time": {}, + "updated_by": {}, + "update_time": {}, + "approval_instance": {}, + "operation_time": {}, + "reject_or_approve": {}, + "manual_exector_id": {}, + "manual_exector_name": {}, + "notification_sent": {}, + "advanced_setting": {}, + "runtime": {}, + "steps": {}, + "run_policy": {}, + "error_policy": {}, + "execute_policy": {}, + "skipped": {}, + } + if _, exists := dropKeys[key]; exists { + return true + } + + optionSuffixes := []string{ + "_options", + "_option", + } + for _, suffix := range optionSuffixes { + if strings.HasSuffix(key, suffix) { + return true + } + } + return strings.HasPrefix(key, "default_") +} + func releasePlanVersionDiffGroup(sectionKey, sectionName string) (string, string, string) { return sectionKey, releasePlanVersionSectionName(sectionKey, sectionName), releasePlanVersionSectionGroupType(sectionKey) } diff --git a/pkg/microservice/aslan/core/release_plan/service/version.go b/pkg/microservice/aslan/core/release_plan/service/version.go index 0373dde909..35705453fe 100644 --- a/pkg/microservice/aslan/core/release_plan/service/version.go +++ b/pkg/microservice/aslan/core/release_plan/service/version.go @@ -19,21 +19,45 @@ package service import ( "time" + "go.mongodb.org/mongo-driver/mongo" + "github.com/koderover/zadig/v2/pkg/microservice/aslan/core/common/repository/models" "github.com/koderover/zadig/v2/pkg/microservice/aslan/core/common/repository/mongodb" ) -func createReleasePlanVersion(planID string, version int64, baseSnapshot, snapshot interface{}, operator, account, sectionKey, sectionName, verb string) error { +func createReleasePlanVersion(planID string, version int64, snapshot interface{}, operator, account, sectionKey, sectionName, verb string) error { + previousVersion, err := previousComparableReleasePlanVersion(planID, sectionKey, version) + if err != nil { + return err + } + return mongodb.NewReleasePlanVersionColl().Create(&models.ReleasePlanVersion{ - PlanID: planID, - Version: version, - Operator: operator, - Account: account, - SectionKey: sectionKey, - SectionName: sectionName, - Verb: verb, - BaseSnapshot: sanitizeReleasePlanValue(baseSnapshot), - Snapshot: sanitizeReleasePlanValue(snapshot), - CreatedAt: time.Now().Unix(), + PlanID: planID, + Version: version, + PreviousVersion: previousVersion, + Operator: operator, + Account: account, + SectionKey: sectionKey, + SectionName: sectionName, + SectionType: releasePlanVersionSectionGroupType(sectionKey), + Verb: verb, + Snapshot: sanitizeReleasePlanValue(snapshot), + CreatedAt: time.Now().Unix(), }) } + +func previousComparableReleasePlanVersion(planID, sectionKey string, beforeVersion int64) (int64, error) { + sectionKeys := []string{sectionKey} + if sectionKey != releasePlanVersionSectionPlan { + sectionKeys = append(sectionKeys, releasePlanVersionSectionPlan) + } + + previous, err := mongodb.NewReleasePlanVersionColl().GetLatestBySectionsBefore(planID, sectionKeys, beforeVersion) + if err != nil { + if err == mongo.ErrNoDocuments { + return 0, nil + } + return 0, err + } + return previous.Version, nil +} From 07246b86974b0a15138506f53d56e767ce6eae6c Mon Sep 17 00:00:00 2001 From: huanghongbo-hhb Date: Thu, 28 May 2026 17:45:25 +0800 Subject: [PATCH 13/33] fix: polish release plan collaboration follow-ups Signed-off-by: huanghongbo-hhb --- .../release_plan/service/collaboration.go | 15 +- .../aslan/core/release_plan/service/diff.go | 221 +++++++++--------- .../core/release_plan/service/diff_test.go | 8 +- .../core/release_plan/service/openapi.go | 4 +- .../core/release_plan/service/release_plan.go | 7 +- .../aslan/core/release_plan/service/update.go | 96 +++----- 6 files changed, 160 insertions(+), 191 deletions(-) diff --git a/pkg/microservice/aslan/core/release_plan/service/collaboration.go b/pkg/microservice/aslan/core/release_plan/service/collaboration.go index d87914e25d..f431807acc 100644 --- a/pkg/microservice/aslan/core/release_plan/service/collaboration.go +++ b/pkg/microservice/aslan/core/release_plan/service/collaboration.go @@ -49,7 +49,6 @@ const ( releasePlanCollabPlanSetPrefix = "release-plan:collab:plan:" releasePlanCollabBroadcastChannel = "release-plan-collaboration" releasePlanCollabSessionTTL = 90 * time.Second - releasePlanCollabBroadcastTTL = 5 * time.Minute ) var upgrader = websocket.Upgrader{ @@ -209,11 +208,11 @@ func splitReleasePlanHostPort(rawHost string) (string, string) { return strings.ToLower(parsed.Hostname()), parsed.Port() } -func broadcastReleasePlanCollaboration(planID string) { +func broadcastReleasePlanCollaboration(planID string) error { if planID == "" { - return + return nil } - _ = cache.NewRedisCache(configbase.RedisCommonCacheTokenDB()).Publish(releasePlanCollabBroadcastChannel, planID) + return cache.NewRedisCache(configbase.RedisCommonCacheTokenDB()).Publish(releasePlanCollabBroadcastChannel, planID) } func registerCollaborationClient(planID string, client *collaborationClient) { @@ -463,11 +462,10 @@ func persistReleasePlanEditingSession(session *ReleasePlanEditingSession) error if err := redisCache.Write(releasePlanCollabSessionKey(session.SessionID), string(payload), releasePlanCollabSessionTTL); err != nil { return err } - if err := redisCache.AddElementsToSet(releasePlanCollabPlanSetKey(session.PlanID), []string{session.SessionID}, releasePlanCollabBroadcastTTL); err != nil { + if err := redisCache.AddElementsToSet(releasePlanCollabPlanSetKey(session.PlanID), []string{session.SessionID}, releasePlanCollabSessionTTL); err != nil { return err } - broadcastReleasePlanCollaboration(session.PlanID) - return nil + return broadcastReleasePlanCollaboration(session.PlanID) } func removeReleasePlanEditingSession(planID, sessionID string) error { @@ -478,8 +476,7 @@ func removeReleasePlanEditingSession(planID, sessionID string) error { if err := redisCache.RemoveElementsFromSet(releasePlanCollabPlanSetKey(planID), []string{sessionID}); err != nil { return err } - broadcastReleasePlanCollaboration(planID) - return nil + return broadcastReleasePlanCollaboration(planID) } func authorizeReleasePlanEditing(ctx *handler.Context, sectionType string) bool { diff --git a/pkg/microservice/aslan/core/release_plan/service/diff.go b/pkg/microservice/aslan/core/release_plan/service/diff.go index 0de9065c2e..0a40a897cb 100644 --- a/pkg/microservice/aslan/core/release_plan/service/diff.go +++ b/pkg/microservice/aslan/core/release_plan/service/diff.go @@ -112,6 +112,11 @@ type releasePlanArrayDiffRule struct { BuildKey releasePlanArrayKeyBuilder } +var releasePlanWorkflowJobArrayRulePrefixes = []string{ + "spec.workflow.stages.jobs", + "spec.workflow.jobs", +} + func newReleasePlanExactArrayRule(groupType, path string, strategy releasePlanArrayDiffStrategy, buildKey releasePlanArrayKeyBuilder) releasePlanArrayDiffRule { return releasePlanArrayDiffRule{ GroupType: groupType, @@ -131,6 +136,27 @@ func newReleasePlanTypedExactArrayRule(groupType, path string, parentJobTypes [] return rule } +func buildReleasePlanWorkflowJobArrayRulePath(prefix, pathSuffix string) string { + if pathSuffix == "" { + return prefix + } + return prefix + "." + pathSuffix +} + +func appendReleasePlanWorkflowJobExactArrayRules(rules []releasePlanArrayDiffRule, pathSuffix string, strategy releasePlanArrayDiffStrategy, buildKey releasePlanArrayKeyBuilder) []releasePlanArrayDiffRule { + for _, prefix := range releasePlanWorkflowJobArrayRulePrefixes { + rules = append(rules, newReleasePlanExactArrayRule("job", buildReleasePlanWorkflowJobArrayRulePath(prefix, pathSuffix), strategy, buildKey)) + } + return rules +} + +func appendReleasePlanWorkflowJobTypedExactArrayRules(rules []releasePlanArrayDiffRule, pathSuffix string, parentJobTypes []config.JobType, strategy releasePlanArrayDiffStrategy, buildKey releasePlanArrayKeyBuilder) []releasePlanArrayDiffRule { + for _, prefix := range releasePlanWorkflowJobArrayRulePrefixes { + rules = append(rules, newReleasePlanTypedExactArrayRule("job", buildReleasePlanWorkflowJobArrayRulePath(prefix, pathSuffix), parentJobTypes, strategy, buildKey)) + } + return rules +} + func newReleasePlanSafeSuffixArrayRule(groupType, path string, strategy releasePlanArrayDiffStrategy, buildKey releasePlanArrayKeyBuilder) releasePlanArrayDiffRule { return releasePlanArrayDiffRule{ GroupType: groupType, @@ -141,96 +167,90 @@ func newReleasePlanSafeSuffixArrayRule(groupType, path string, strategy releaseP } } -var releasePlanArrayExactRules = []releasePlanArrayDiffRule{ - newReleasePlanExactArrayRule("plan", "jobs", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByNameTypeID), - newReleasePlanExactArrayRule(releasePlanVersionSectionJobsOrder, "", releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByNameID), - newReleasePlanExactArrayRule("job", "spec.workflow.params", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByNameType), - newReleasePlanExactArrayRule("job", "spec.workflow.jobs", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByNameType), - newReleasePlanExactArrayRule("job", "spec.workflow.stages", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByName), - newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByNameType), - newReleasePlanExactArrayRule("job", "spec.workflow.share_storages", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByName), - newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.default_services", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByServiceModule), - newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.service_config.default_services", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByServiceModule), - newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.service_and_builds", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByServiceModule), - newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.default_service_and_builds", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByServiceModule), - newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.service_and_builds_options", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByServiceModule), - newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.service_and_vm_deploys", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByServiceModule), - newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.default_service_and_vm_deploys", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByServiceModule), - newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.service_and_vm_deploys_options", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByServiceModule), - newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.service_and_tests", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByServiceModule), - newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.service_test_options", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByServiceModule), - newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.service_and_scannings", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByServiceModule), - newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.service_scanning_options", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByServiceModule), - newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.target_services", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByServiceModule), - newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.service_and_image", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByServiceModule), - newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.gray_services", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByServiceName), - newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.source_service", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByServiceModule), - newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.service_trigger_workflow", releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByWorkflowTrigger), - newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.fixed_workflow_list", releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByFixedWorkflowTrigger), - newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.service_trigger_workflow.params", releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByNameType), - newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.fixed_workflow_list.params", releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByNameType), - newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.test_modules", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByName), - newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.test_module_options", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByName), - newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.scannings", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByName), - newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.scanning_options", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByName), - newReleasePlanTypedExactArrayRule("job", "spec.workflow.stages.jobs.spec.services", []config.JobType{config.JobZadigDeploy, config.JobK8sBlueGreenDeploy}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByServiceName), - newReleasePlanTypedExactArrayRule("job", "spec.workflow.jobs.spec.services", []config.JobType{config.JobZadigDeploy, config.JobK8sBlueGreenDeploy}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByServiceName), - newReleasePlanTypedExactArrayRule("job", "spec.workflow.stages.jobs.spec.services", []config.JobType{config.JobFreestyle}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByServiceModule), - newReleasePlanTypedExactArrayRule("job", "spec.workflow.jobs.spec.services", []config.JobType{config.JobFreestyle}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByServiceModule), - newReleasePlanTypedExactArrayRule("job", "spec.workflow.stages.jobs.spec.service_options", []config.JobType{config.JobK8sBlueGreenDeploy}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByServiceName), - newReleasePlanTypedExactArrayRule("job", "spec.workflow.jobs.spec.service_options", []config.JobType{config.JobK8sBlueGreenDeploy}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByServiceName), - newReleasePlanTypedExactArrayRule("job", "spec.workflow.stages.jobs.spec.service_config.services", []config.JobType{config.JobSAEDeploy}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByServiceModule), - newReleasePlanTypedExactArrayRule("job", "spec.workflow.jobs.spec.service_config.services", []config.JobType{config.JobSAEDeploy}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByServiceModule), - newReleasePlanTypedExactArrayRule("job", "spec.workflow.stages.jobs.spec.services.modules", []config.JobType{config.JobZadigDeploy}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByServiceModuleNameOnly), - newReleasePlanTypedExactArrayRule("job", "spec.workflow.jobs.spec.services.modules", []config.JobType{config.JobZadigDeploy}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByServiceModuleNameOnly), - newReleasePlanTypedExactArrayRule("job", "spec.workflow.stages.jobs.spec.service_variable_config", []config.JobType{config.JobZadigDeploy}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByServiceName), - newReleasePlanTypedExactArrayRule("job", "spec.workflow.jobs.spec.service_variable_config", []config.JobType{config.JobZadigDeploy}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByServiceName), - newReleasePlanTypedExactArrayRule("job", "spec.workflow.stages.jobs.spec.service_variable_config.modules", []config.JobType{config.JobZadigDeploy}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByServiceModuleNameOnly), - newReleasePlanTypedExactArrayRule("job", "spec.workflow.jobs.spec.service_variable_config.modules", []config.JobType{config.JobZadigDeploy}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByServiceModuleNameOnly), - newReleasePlanTypedExactArrayRule("job", "spec.workflow.stages.jobs.spec.service_variable_config.variable_configs", []config.JobType{config.JobZadigDeploy}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByVariableConfig), - newReleasePlanTypedExactArrayRule("job", "spec.workflow.jobs.spec.service_variable_config.variable_configs", []config.JobType{config.JobZadigDeploy}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByVariableConfig), - newReleasePlanTypedExactArrayRule("job", "spec.workflow.stages.jobs.spec.services.service_and_image", []config.JobType{config.JobK8sBlueGreenDeploy}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByServiceModuleNameOnly), - newReleasePlanTypedExactArrayRule("job", "spec.workflow.stages.jobs.spec.service_options.service_and_image", []config.JobType{config.JobK8sBlueGreenDeploy}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByServiceModuleNameOnly), - newReleasePlanTypedExactArrayRule("job", "spec.workflow.stages.jobs.spec.gray_services.service_and_image", []config.JobType{config.JobMseGrayRelease}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByServiceModuleNameOnly), - newReleasePlanTypedExactArrayRule("job", "spec.workflow.stages.jobs.spec.targets", []config.JobType{config.JobCustomDeploy}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByTarget), - newReleasePlanTypedExactArrayRule("job", "spec.workflow.stages.jobs.spec.target_options", []config.JobType{config.JobCustomDeploy}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByTarget), - newReleasePlanTypedExactArrayRule("job", "spec.workflow.stages.jobs.spec.targets", []config.JobType{config.JobZadigDistributeImage}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByServiceModule), - newReleasePlanTypedExactArrayRule("job", "spec.workflow.stages.jobs.spec.target_options", []config.JobType{config.JobZadigDistributeImage}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByServiceModule), - newReleasePlanTypedExactArrayRule("job", "spec.workflow.stages.jobs.spec.targets", []config.JobType{config.JobK8sBlueGreenDeploy, config.JobK8sCanaryDeploy}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByK8sTarget), - newReleasePlanTypedExactArrayRule("job", "spec.workflow.stages.jobs.spec.target_options", []config.JobType{config.JobK8sCanaryDeploy}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByK8sTarget), - newReleasePlanTypedExactArrayRule("job", "spec.workflow.stages.jobs.spec.targets", []config.JobType{config.JobK8sGrayRelease}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByGrayReleaseTarget), - newReleasePlanTypedExactArrayRule("job", "spec.workflow.stages.jobs.spec.target_options", []config.JobType{config.JobK8sGrayRelease}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByGrayReleaseTarget), - newReleasePlanTypedExactArrayRule("job", "spec.workflow.stages.jobs.spec.targets", []config.JobType{config.JobK8sGrayRollback}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByGrayRollbackTarget), - newReleasePlanTypedExactArrayRule("job", "spec.workflow.stages.jobs.spec.target_options", []config.JobType{config.JobK8sGrayRollback}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByGrayRollbackTarget), - newReleasePlanTypedExactArrayRule("job", "spec.workflow.stages.jobs.spec.targets", []config.JobType{config.JobIstioRelease, config.JobIstioRollback}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByIstioTarget), - newReleasePlanTypedExactArrayRule("job", "spec.workflow.stages.jobs.spec.target_options", []config.JobType{config.JobIstioRelease, config.JobIstioRollback}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByIstioTarget), - newReleasePlanTypedExactArrayRule("job", "spec.workflow.stages.jobs.spec.jobs", []config.JobType{config.JobJenkins}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByJobName), - newReleasePlanTypedExactArrayRule("job", "spec.workflow.stages.jobs.spec.job_options", []config.JobType{config.JobJenkins}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByJobName), - newReleasePlanTypedExactArrayRule("job", "spec.workflow.stages.jobs.spec.jobs.parameters", []config.JobType{config.JobJenkins}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByName), - newReleasePlanTypedExactArrayRule("job", "spec.workflow.stages.jobs.spec.job_options.parameters", []config.JobType{config.JobJenkins}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByName), - newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.alerts", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByNameID), - newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.alert_options", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByNameID), - newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.monitors", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByNameID), - newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.mail_users", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByUserID), - newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.mail_notification_config.target_users", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByUserID), - newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.lark_group_notification_config.at_users", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByThirdPartyUserID), - newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.lark_person_notification_config.target_users", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByThirdPartyUserID), - newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.native_approval.approve_users", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByUserID), - newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.dingtalk_approval.approval_nodes.approve_users", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByThirdPartyUserID), - newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.lark_approval.approve_users", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByThirdPartyUserID), - newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.lark_approval.approval_nodes.approve_users", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByThirdPartyUserID), - newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.lark_approval.approval_nodes.cc_users", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByThirdPartyUserID), - newReleasePlanExactArrayRule("approval", "native_approval.approve_users", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByUserID), - newReleasePlanExactArrayRule("approval", "dingtalk_approval.approval_nodes.approve_users", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByThirdPartyUserID), - newReleasePlanExactArrayRule("approval", "lark_approval.approve_users", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByThirdPartyUserID), - newReleasePlanExactArrayRule("approval", "lark_approval.approval_nodes.approve_users", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByThirdPartyUserID), - newReleasePlanExactArrayRule("approval", "lark_approval.approval_nodes.cc_users", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByThirdPartyUserID), - newReleasePlanExactArrayRule("approval", "lark_approval.approval_nodes.approve_groups", releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByApprovalGroup), - newReleasePlanExactArrayRule("approval", "lark_approval.approval_nodes.cc_groups", releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByApprovalGroup), - newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.lark_approval.approval_nodes.approve_groups", releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByApprovalGroup), - newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.lark_approval.approval_nodes.cc_groups", releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByApprovalGroup), - newReleasePlanExactArrayRule("metadata", "jira_sprint_association.sprints", releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByJiraSprint), -} +var releasePlanArrayExactRules = func() []releasePlanArrayDiffRule { + rules := []releasePlanArrayDiffRule{ + newReleasePlanExactArrayRule("plan", "jobs", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByNameTypeID), + newReleasePlanExactArrayRule(releasePlanVersionSectionJobsOrder, "", releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByNameID), + newReleasePlanExactArrayRule("job", "spec.workflow.params", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByNameType), + newReleasePlanExactArrayRule("job", "spec.workflow.stages", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByName), + newReleasePlanExactArrayRule("job", "spec.workflow.share_storages", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByName), + newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.default_services", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByServiceModule), + newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.service_config.default_services", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByServiceModule), + newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.service_and_builds", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByServiceModule), + newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.default_service_and_builds", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByServiceModule), + newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.service_and_builds_options", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByServiceModule), + newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.service_and_vm_deploys", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByServiceModule), + newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.default_service_and_vm_deploys", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByServiceModule), + newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.service_and_vm_deploys_options", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByServiceModule), + newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.service_and_tests", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByServiceModule), + newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.service_test_options", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByServiceModule), + newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.service_and_scannings", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByServiceModule), + newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.service_scanning_options", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByServiceModule), + newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.target_services", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByServiceModule), + newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.service_and_image", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByServiceModule), + newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.gray_services", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByServiceName), + newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.source_service", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByServiceModule), + newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.service_trigger_workflow", releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByWorkflowTrigger), + newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.fixed_workflow_list", releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByFixedWorkflowTrigger), + newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.service_trigger_workflow.params", releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByNameType), + newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.fixed_workflow_list.params", releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByNameType), + newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.test_modules", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByName), + newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.test_module_options", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByName), + newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.scannings", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByName), + newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.scanning_options", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByName), + newReleasePlanTypedExactArrayRule("job", "spec.workflow.stages.jobs.spec.services.service_and_image", []config.JobType{config.JobK8sBlueGreenDeploy}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByServiceModuleNameOnly), + newReleasePlanTypedExactArrayRule("job", "spec.workflow.stages.jobs.spec.service_options.service_and_image", []config.JobType{config.JobK8sBlueGreenDeploy}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByServiceModuleNameOnly), + newReleasePlanTypedExactArrayRule("job", "spec.workflow.stages.jobs.spec.gray_services.service_and_image", []config.JobType{config.JobMseGrayRelease}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByServiceModuleNameOnly), + newReleasePlanTypedExactArrayRule("job", "spec.workflow.stages.jobs.spec.targets", []config.JobType{config.JobCustomDeploy}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByTarget), + newReleasePlanTypedExactArrayRule("job", "spec.workflow.stages.jobs.spec.target_options", []config.JobType{config.JobCustomDeploy}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByTarget), + newReleasePlanTypedExactArrayRule("job", "spec.workflow.stages.jobs.spec.targets", []config.JobType{config.JobZadigDistributeImage}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByServiceModule), + newReleasePlanTypedExactArrayRule("job", "spec.workflow.stages.jobs.spec.target_options", []config.JobType{config.JobZadigDistributeImage}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByServiceModule), + newReleasePlanTypedExactArrayRule("job", "spec.workflow.stages.jobs.spec.targets", []config.JobType{config.JobK8sBlueGreenDeploy, config.JobK8sCanaryDeploy}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByK8sTarget), + newReleasePlanTypedExactArrayRule("job", "spec.workflow.stages.jobs.spec.target_options", []config.JobType{config.JobK8sCanaryDeploy}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByK8sTarget), + newReleasePlanTypedExactArrayRule("job", "spec.workflow.stages.jobs.spec.targets", []config.JobType{config.JobK8sGrayRelease}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByGrayReleaseTarget), + newReleasePlanTypedExactArrayRule("job", "spec.workflow.stages.jobs.spec.target_options", []config.JobType{config.JobK8sGrayRelease}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByGrayReleaseTarget), + newReleasePlanTypedExactArrayRule("job", "spec.workflow.stages.jobs.spec.targets", []config.JobType{config.JobK8sGrayRollback}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByGrayRollbackTarget), + newReleasePlanTypedExactArrayRule("job", "spec.workflow.stages.jobs.spec.target_options", []config.JobType{config.JobK8sGrayRollback}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByGrayRollbackTarget), + newReleasePlanTypedExactArrayRule("job", "spec.workflow.stages.jobs.spec.targets", []config.JobType{config.JobIstioRelease, config.JobIstioRollback}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByIstioTarget), + newReleasePlanTypedExactArrayRule("job", "spec.workflow.stages.jobs.spec.target_options", []config.JobType{config.JobIstioRelease, config.JobIstioRollback}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByIstioTarget), + newReleasePlanTypedExactArrayRule("job", "spec.workflow.stages.jobs.spec.jobs", []config.JobType{config.JobJenkins}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByJobName), + newReleasePlanTypedExactArrayRule("job", "spec.workflow.stages.jobs.spec.job_options", []config.JobType{config.JobJenkins}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByJobName), + newReleasePlanTypedExactArrayRule("job", "spec.workflow.stages.jobs.spec.jobs.parameters", []config.JobType{config.JobJenkins}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByName), + newReleasePlanTypedExactArrayRule("job", "spec.workflow.stages.jobs.spec.job_options.parameters", []config.JobType{config.JobJenkins}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByName), + newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.alerts", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByNameID), + newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.alert_options", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByNameID), + newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.monitors", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByNameID), + newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.mail_users", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByUserID), + newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.mail_notification_config.target_users", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByUserID), + newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.lark_group_notification_config.at_users", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByThirdPartyUserID), + newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.lark_person_notification_config.target_users", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByThirdPartyUserID), + newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.native_approval.approve_users", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByUserID), + newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.dingtalk_approval.approval_nodes.approve_users", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByThirdPartyUserID), + newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.lark_approval.approve_users", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByThirdPartyUserID), + newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.lark_approval.approval_nodes.approve_users", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByThirdPartyUserID), + newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.lark_approval.approval_nodes.cc_users", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByThirdPartyUserID), + newReleasePlanExactArrayRule("approval", "native_approval.approve_users", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByUserID), + newReleasePlanExactArrayRule("approval", "dingtalk_approval.approval_nodes.approve_users", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByThirdPartyUserID), + newReleasePlanExactArrayRule("approval", "lark_approval.approve_users", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByThirdPartyUserID), + newReleasePlanExactArrayRule("approval", "lark_approval.approval_nodes.approve_users", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByThirdPartyUserID), + newReleasePlanExactArrayRule("approval", "lark_approval.approval_nodes.cc_users", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByThirdPartyUserID), + newReleasePlanExactArrayRule("approval", "lark_approval.approval_nodes.approve_groups", releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByApprovalGroup), + newReleasePlanExactArrayRule("approval", "lark_approval.approval_nodes.cc_groups", releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByApprovalGroup), + newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.lark_approval.approval_nodes.approve_groups", releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByApprovalGroup), + newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.lark_approval.approval_nodes.cc_groups", releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByApprovalGroup), + newReleasePlanExactArrayRule("metadata", "jira_sprint_association.sprints", releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByJiraSprint), + } + rules = appendReleasePlanWorkflowJobExactArrayRules(rules, "", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByNameType) + rules = appendReleasePlanWorkflowJobTypedExactArrayRules(rules, "spec.services", []config.JobType{config.JobZadigDeploy, config.JobK8sBlueGreenDeploy}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByServiceName) + rules = appendReleasePlanWorkflowJobTypedExactArrayRules(rules, "spec.services", []config.JobType{config.JobFreestyle}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByServiceModule) + rules = appendReleasePlanWorkflowJobTypedExactArrayRules(rules, "spec.service_options", []config.JobType{config.JobK8sBlueGreenDeploy}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByServiceName) + rules = appendReleasePlanWorkflowJobTypedExactArrayRules(rules, "spec.service_config.services", []config.JobType{config.JobSAEDeploy}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByServiceModule) + rules = appendReleasePlanWorkflowJobTypedExactArrayRules(rules, "spec.services.modules", []config.JobType{config.JobZadigDeploy}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByServiceModuleNameOnly) + rules = appendReleasePlanWorkflowJobTypedExactArrayRules(rules, "spec.service_variable_config", []config.JobType{config.JobZadigDeploy}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByServiceName) + rules = appendReleasePlanWorkflowJobTypedExactArrayRules(rules, "spec.service_variable_config.modules", []config.JobType{config.JobZadigDeploy}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByServiceModuleNameOnly) + rules = appendReleasePlanWorkflowJobTypedExactArrayRules(rules, "spec.service_variable_config.variable_configs", []config.JobType{config.JobZadigDeploy}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByVariableConfig) + return rules +}() var releasePlanArraySafeSuffixRules = []releasePlanArrayDiffRule{ newReleasePlanSafeSuffixArrayRule("job", "params", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByNameType), @@ -323,7 +343,7 @@ func GetReleasePlanVersionDiff(planID string, version int64) (*ReleasePlanVersio } fromData := comparableReleasePlanVersionSnapshot(previous, current.SectionKey) - toData, err := toGenericValue(current.Snapshot) + toData, err := toReleasePlanGenericValue(current.Snapshot) if err != nil { return nil, errors.Wrap(err, "convert current snapshot") } @@ -398,7 +418,7 @@ func comparableReleasePlanVersionSnapshot(version *models.ReleasePlanVersion, se switch { case version.SectionKey == sectionKey, sectionKey == releasePlanVersionSectionPlan: - value, err := toGenericValue(version.Snapshot) + value, err := toReleasePlanGenericValue(version.Snapshot) if err != nil { return version.Snapshot } @@ -411,7 +431,7 @@ func comparableReleasePlanVersionSnapshot(version *models.ReleasePlanVersion, se } func extractReleasePlanSectionSnapshot(snapshot interface{}, sectionKey string) interface{} { - genericValue, err := toGenericValue(snapshot) + genericValue, err := toReleasePlanGenericValue(snapshot) if err != nil { return nil } @@ -447,21 +467,6 @@ func extractReleasePlanSectionSnapshot(snapshot interface{}, sectionKey string) return nil } -func toGenericValue(value interface{}) (interface{}, error) { - if value == nil { - return nil, nil - } - payload, err := json.Marshal(value) - if err != nil { - return nil, err - } - var resp interface{} - if err := json.Unmarshal(payload, &resp); err != nil { - return nil, err - } - return resp, nil -} - func diffReleasePlanValues(ctx releasePlanDiffContext, path string, left, right interface{}, entries *[]*releasePlanRawDiffEntry) { if shouldIgnoreReleasePlanDiffPath(path) { return diff --git a/pkg/microservice/aslan/core/release_plan/service/diff_test.go b/pkg/microservice/aslan/core/release_plan/service/diff_test.go index 15053c8041..272e9facd5 100644 --- a/pkg/microservice/aslan/core/release_plan/service/diff_test.go +++ b/pkg/microservice/aslan/core/release_plan/service/diff_test.go @@ -4,7 +4,7 @@ import "testing" func TestGetReleasePlanArrayItemKey(t *testing.T) { t.Run("job key", func(t *testing.T) { - key, ok := getReleasePlanArrayItemKey(map[string]interface{}{ + key, ok := buildReleasePlanArrayKeyByNameTypeID(map[string]interface{}{ "name": "build", "type": "zadig-build", "id": "job-id", @@ -18,7 +18,7 @@ func TestGetReleasePlanArrayItemKey(t *testing.T) { }) t.Run("service key", func(t *testing.T) { - key, ok := getReleasePlanArrayItemKey(map[string]interface{}{ + key, ok := buildReleasePlanArrayKeyByServiceModule(map[string]interface{}{ "service_name": "gateway", "service_module": "gateway", }) @@ -84,13 +84,13 @@ func TestReleasePlanSubtreeHashPruneSkipSmallNodes(t *testing.T) { } } -func TestToGenericValueSupportsRootArrays(t *testing.T) { +func TestToReleasePlanGenericValueSupportsRootArrays(t *testing.T) { value := []map[string]interface{}{ {"id": "job-1", "name": "job-a"}, {"id": "job-2", "name": "job-b"}, } - generic, err := toGenericValue(value) + generic, err := toReleasePlanGenericValue(value) if err != nil { t.Fatalf("unexpected error: %v", err) } diff --git a/pkg/microservice/aslan/core/release_plan/service/openapi.go b/pkg/microservice/aslan/core/release_plan/service/openapi.go index 0b0d2e3d0a..82fce69718 100644 --- a/pkg/microservice/aslan/core/release_plan/service/openapi.go +++ b/pkg/microservice/aslan/core/release_plan/service/openapi.go @@ -410,7 +410,9 @@ func OpenAPICreateReleasePlanWithJobs(c *handler.Context, id string, rawArgs *Op }); err != nil { log.Errorf("create release plan log error: %v", err) } - broadcastReleasePlanCollaboration(plan.ID.Hex()) + if err := broadcastReleasePlanCollaboration(plan.ID.Hex()); err != nil { + log.Errorf("broadcast release plan collaboration error: %v", err) + } return nil } diff --git a/pkg/microservice/aslan/core/release_plan/service/release_plan.go b/pkg/microservice/aslan/core/release_plan/service/release_plan.go index 9f2428302a..d4a6ebf14e 100644 --- a/pkg/microservice/aslan/core/release_plan/service/release_plan.go +++ b/pkg/microservice/aslan/core/release_plan/service/release_plan.go @@ -441,8 +441,7 @@ func UpdateReleasePlan(c *handler.Context, planID string, args *UpdateReleasePla if err = updater.Lint(); err != nil { return errors.Wrap(err, "lint") } - _, _, err = updater.Update(plan) - if err != nil { + if err = updater.Update(plan); err != nil { return errors.Wrap(err, "update") } @@ -499,7 +498,9 @@ func UpdateReleasePlan(c *handler.Context, planID string, args *UpdateReleasePla if err := createReleasePlanLog(logItem); err != nil { log.Errorf("create release plan log error: %v", err) } - broadcastReleasePlanCollaboration(planID) + if err := broadcastReleasePlanCollaboration(planID); err != nil { + log.Errorf("broadcast release plan collaboration error: %v", err) + } return nil } diff --git a/pkg/microservice/aslan/core/release_plan/service/update.go b/pkg/microservice/aslan/core/release_plan/service/update.go index fbb9776673..ae0381e71c 100644 --- a/pkg/microservice/aslan/core/release_plan/service/update.go +++ b/pkg/microservice/aslan/core/release_plan/service/update.go @@ -19,7 +19,6 @@ package service import ( "context" "fmt" - "time" "github.com/google/uuid" "github.com/pkg/errors" @@ -97,8 +96,7 @@ var UserNameI18nMap = map[string]string{ } type PlanUpdater interface { - // Update returns the old data and the updated data - Update(plan *models.ReleasePlan) (before interface{}, after interface{}, err error) + Update(plan *models.ReleasePlan) error Verb() string TargetName() string TargetType() string @@ -150,10 +148,9 @@ func NewNameUpdater(args *UpdateReleasePlanArgs) (*NameUpdater, error) { return &updater, nil } -func (u *NameUpdater) Update(plan *models.ReleasePlan) (before interface{}, after interface{}, err error) { - before, after = plan.Name, u.Name +func (u *NameUpdater) Update(plan *models.ReleasePlan) error { plan.Name = u.Name - return + return nil } func (u *NameUpdater) Lint() error { @@ -187,10 +184,9 @@ func NewDescUpdater(args *UpdateReleasePlanArgs) (*DescUpdater, error) { return &updater, nil } -func (u *DescUpdater) Update(plan *models.ReleasePlan) (before interface{}, after interface{}, err error) { - before, after = plan.Description, u.Description +func (u *DescUpdater) Update(plan *models.ReleasePlan) error { plan.Description = u.Description - return + return nil } func (u *DescUpdater) Lint() error { @@ -222,21 +218,10 @@ func NewTimeRangeUpdater(args *UpdateReleasePlanArgs) (*TimeRangeUpdater, error) return &updater, nil } -func formatReleasePlanDateTime(timestamp int64) string { - if timestamp == 0 { - return "未设置" - } - return time.Unix(timestamp, 0).Format("2006-01-02 15:04:05") -} - -func (u *TimeRangeUpdater) Update(plan *models.ReleasePlan) (before interface{}, after interface{}, err error) { - before = fmt.Sprintf("%s-%s", formatReleasePlanDateTime(plan.StartTime), - formatReleasePlanDateTime(plan.EndTime)) - after = fmt.Sprintf("%s-%s", formatReleasePlanDateTime(u.StartTime), - formatReleasePlanDateTime(u.EndTime)) +func (u *TimeRangeUpdater) Update(plan *models.ReleasePlan) error { plan.StartTime = u.StartTime plan.EndTime = u.EndTime - return + return nil } func (u *TimeRangeUpdater) Lint() error { @@ -268,11 +253,10 @@ func NewManagerUpdater(args *UpdateReleasePlanArgs) (*ManagerUpdater, error) { return &updater, nil } -func (u *ManagerUpdater) Update(plan *models.ReleasePlan) (before interface{}, after interface{}, err error) { - before, after = plan.Manager, u.Manager +func (u *ManagerUpdater) Update(plan *models.ReleasePlan) error { plan.ManagerID = u.ManagerID plan.Manager = u.Manager - return + return nil } func (u *ManagerUpdater) Lint() error { @@ -317,8 +301,7 @@ func NewCreateReleaseJobUpdater(args *UpdateReleasePlanArgs) (*CreateReleaseJobU return &updater, nil } -func (u *CreateReleaseJobUpdater) Update(plan *models.ReleasePlan) (before interface{}, after interface{}, err error) { - before, after = nil, u +func (u *CreateReleaseJobUpdater) Update(plan *models.ReleasePlan) error { job := &models.ReleaseJob{ ID: uuid.New().String(), Name: u.Name, @@ -328,7 +311,7 @@ func (u *CreateReleaseJobUpdater) Update(plan *models.ReleasePlan) (before inter Spec: u.Spec, } plan.Jobs = append(plan.Jobs, job) - return + return nil } func (u *CreateReleaseJobUpdater) Lint() error { @@ -369,26 +352,21 @@ func NewUpdateReleaseJobUpdater(args *UpdateReleasePlanArgs) (*UpdateReleaseJobU return &updater, nil } -func (u *UpdateReleaseJobUpdater) Update(plan *models.ReleasePlan) (before interface{}, after interface{}, err error) { +func (u *UpdateReleaseJobUpdater) Update(plan *models.ReleasePlan) error { for _, job := range plan.Jobs { if job.ID == u.ID { if job.Type != u.Type { - return nil, nil, fmt.Errorf("job type cannot be changed") - } - beforeJob := new(models.ReleaseJob) - if err := models.IToi(job, beforeJob); err != nil { - return nil, nil, errors.Wrap(err, "clone release job before update") + return fmt.Errorf("job type cannot be changed") } - before, after = beforeJob, u job.Name = u.Name job.Manager = u.Manager job.ManagerID = u.ManagerID job.Spec = u.Spec job.Updated = true - return + return nil } } - return nil, nil, fmt.Errorf("job %s-%s not found", u.Name, u.ID) + return fmt.Errorf("job %s-%s not found", u.Name, u.ID) } // note that the real linting process is when we finish planning, not saving the draft. @@ -427,17 +405,15 @@ func NewDeleteReleaseJobUpdater(args *UpdateReleasePlanArgs) (*DeleteReleaseJobU return &updater, nil } -func (u *DeleteReleaseJobUpdater) Update(plan *models.ReleasePlan) (before interface{}, after interface{}, err error) { +func (u *DeleteReleaseJobUpdater) Update(plan *models.ReleasePlan) error { for i, job := range plan.Jobs { if job.ID == u.ID { u.name = job.Name - before = job - after = nil plan.Jobs = append(plan.Jobs[:i], plan.Jobs[i+1:]...) - return + return nil } } - return nil, nil, fmt.Errorf("job %s not found", u.ID) + return fmt.Errorf("job %s not found", u.ID) } func (u *DeleteReleaseJobUpdater) Lint() error { @@ -471,13 +447,7 @@ func NewReorderReleaseJobUpdater(args *UpdateReleasePlanArgs) (*ReorderReleaseJo return &updater, nil } -func (u *ReorderReleaseJobUpdater) Update(plan *models.ReleasePlan) (before interface{}, after interface{}, err error) { - beforeIDs := make([]string, 0, len(plan.Jobs)) - for _, job := range plan.Jobs { - beforeIDs = append(beforeIDs, job.ID) - } - before = beforeIDs - +func (u *ReorderReleaseJobUpdater) Update(plan *models.ReleasePlan) error { jobMap := make(map[string]*models.ReleaseJob, len(plan.Jobs)) for _, job := range plan.Jobs { jobMap[job.ID] = job @@ -487,13 +457,12 @@ func (u *ReorderReleaseJobUpdater) Update(plan *models.ReleasePlan) (before inte for _, id := range u.JobIDs { job, ok := jobMap[id] if !ok { - return nil, nil, fmt.Errorf("job %s not found", id) + return fmt.Errorf("job %s not found", id) } newJobs = append(newJobs, job) } plan.Jobs = newJobs - after = u.JobIDs - return + return nil } func (u *ReorderReleaseJobUpdater) Lint() error { @@ -527,13 +496,12 @@ func NewUpdateApprovalUpdater(args *UpdateReleasePlanArgs) (*UpdateApprovalUpdat return &updater, nil } -func (u *UpdateApprovalUpdater) Update(plan *models.ReleasePlan) (before interface{}, after interface{}, err error) { +func (u *UpdateApprovalUpdater) Update(plan *models.ReleasePlan) error { if err := clearApprovalData(u.Approval); err != nil { - return nil, nil, errors.Wrap(err, "clear approval data") + return errors.Wrap(err, "clear approval data") } - before, after = plan.Approval, u.Approval plan.Approval = u.Approval - return + return nil } func (u *UpdateApprovalUpdater) Lint() error { @@ -570,10 +538,9 @@ func NewDeleteApprovalUpdater(args *UpdateReleasePlanArgs) (*DeleteApprovalUpdat return &DeleteApprovalUpdater{}, nil } -func (u *DeleteApprovalUpdater) Update(plan *models.ReleasePlan) (before interface{}, after interface{}, err error) { - before, after = plan.Approval, nil +func (u *DeleteApprovalUpdater) Update(plan *models.ReleasePlan) error { plan.Approval = nil - return + return nil } func (u *DeleteApprovalUpdater) Lint() error { @@ -667,11 +634,9 @@ func NewScheduleExecuteTimeUpdater(args *UpdateReleasePlanArgs) (*ScheduleExecut return &updater, nil } -func (u *ScheduleExecuteTimeUpdater) Update(plan *models.ReleasePlan) (before interface{}, after interface{}, err error) { - before = formatReleasePlanDateTime(plan.ScheduleExecuteTime) - after = formatReleasePlanDateTime(u.ScheduleExecuteTime) +func (u *ScheduleExecuteTimeUpdater) Update(plan *models.ReleasePlan) error { plan.ScheduleExecuteTime = u.ScheduleExecuteTime - return + return nil } func (u *ScheduleExecuteTimeUpdater) Lint() error { @@ -702,10 +667,9 @@ func NewJiraSprintUpdater(args *UpdateReleasePlanArgs) (*JiraSprintUpdater, erro return &updater, nil } -func (u *JiraSprintUpdater) Update(plan *models.ReleasePlan) (before interface{}, after interface{}, err error) { - before, after = plan.JiraSprintAssociation, u.JiraSprintAssociation +func (u *JiraSprintUpdater) Update(plan *models.ReleasePlan) error { plan.JiraSprintAssociation = u.JiraSprintAssociation - return + return nil } func (u *JiraSprintUpdater) Lint() error { From 79465a505509a9ba4c0fdceac4e6fe503f06314e Mon Sep 17 00:00:00 2001 From: huanghongbo-hhb Date: Tue, 9 Jun 2026 16:12:23 +0800 Subject: [PATCH 14/33] fix: harden release plan collaboration lifecycle Signed-off-by: huanghongbo-hhb --- .../common/repository/models/release_plan.go | 1 + .../release_plan/service/collaboration.go | 68 +++++++++++++++---- .../aslan/core/release_plan/service/diff.go | 21 +++++- .../core/release_plan/service/release_plan.go | 9 ++- .../core/release_plan/service/version.go | 26 ++++++- 5 files changed, 106 insertions(+), 19 deletions(-) diff --git a/pkg/microservice/aslan/core/common/repository/models/release_plan.go b/pkg/microservice/aslan/core/common/repository/models/release_plan.go index f901fcc489..e3e37e4b58 100644 --- a/pkg/microservice/aslan/core/common/repository/models/release_plan.go +++ b/pkg/microservice/aslan/core/common/repository/models/release_plan.go @@ -153,6 +153,7 @@ type ReleasePlanVersion struct { SectionName string `bson:"section_name,omitempty" json:"section_name,omitempty"` SectionType string `bson:"section_type,omitempty" json:"section_type,omitempty"` Verb string `bson:"verb,omitempty" json:"verb,omitempty"` + BaseSnapshot interface{} `bson:"base_snapshot,omitempty" json:"base_snapshot,omitempty"` Snapshot interface{} `bson:"snapshot" json:"snapshot"` CreatedAt int64 `bson:"created_at" json:"created_at"` } diff --git a/pkg/microservice/aslan/core/release_plan/service/collaboration.go b/pkg/microservice/aslan/core/release_plan/service/collaboration.go index f431807acc..5d73021b27 100644 --- a/pkg/microservice/aslan/core/release_plan/service/collaboration.go +++ b/pkg/microservice/aslan/core/release_plan/service/collaboration.go @@ -49,6 +49,11 @@ const ( releasePlanCollabPlanSetPrefix = "release-plan:collab:plan:" releasePlanCollabBroadcastChannel = "release-plan-collaboration" releasePlanCollabSessionTTL = 90 * time.Second + releasePlanCollabWSWriteWait = 10 * time.Second + releasePlanCollabWSPongWait = 60 * time.Second + releasePlanCollabWSPingPeriod = releasePlanCollabWSPongWait * 9 / 10 + releasePlanCollabWSReadLimit = 16 * 1024 + releasePlanCollabRedisRetryWait = 3 * time.Second ) var upgrader = websocket.Upgrader{ @@ -123,19 +128,29 @@ var collaborationLoopOnce sync.Once func ensureReleasePlanCollaborationLoop() { collaborationLoopOnce.Do(func() { - util.Go(func() { - ch, closeFn := cache.NewRedisCache(configbase.RedisCommonCacheTokenDB()).Subscribe(releasePlanCollabBroadcastChannel) - defer closeFn() + util.Go(watchReleasePlanCollaborationBroadcasts) + }) +} - for msg := range ch { - planID := strings.TrimSpace(msg.Payload) - if planID == "" { - continue - } - broadcastReleasePlanCollaborationSnapshot(planID) +func watchReleasePlanCollaborationBroadcasts() { + for { + ch, closeFn := cache.NewRedisCache(configbase.RedisCommonCacheTokenDB()).Subscribe(releasePlanCollabBroadcastChannel) + for msg := range ch { + if msg == nil { + continue } - }) - }) + planID := strings.TrimSpace(msg.Payload) + if planID == "" { + continue + } + broadcastReleasePlanCollaborationSnapshot(planID) + } + if err := closeFn(); err != nil { + log.Warnf("close release plan collaboration redis subscription error: %v", err) + } + log.Warnf("release plan collaboration redis subscription closed, retrying in %s", releasePlanCollabRedisRetryWait) + time.Sleep(releasePlanCollabRedisRetryWait) + } } func releasePlanCollabSessionKey(sessionID string) string { @@ -349,6 +364,21 @@ func queueCollaborationClientMessage(client *collaborationClient, outbound *rele } } +func setupReleasePlanCollaborationWSDeadline(ws *websocket.Conn) { + ws.SetReadLimit(releasePlanCollabWSReadLimit) + _ = ws.SetReadDeadline(time.Now().Add(releasePlanCollabWSPongWait)) + ws.SetPongHandler(func(string) error { + return ws.SetReadDeadline(time.Now().Add(releasePlanCollabWSPongWait)) + }) +} + +func writeReleasePlanCollaborationWSMessage(ws *websocket.Conn, messageType int, payload []byte) error { + if err := ws.SetWriteDeadline(time.Now().Add(releasePlanCollabWSWriteWait)); err != nil { + return err + } + return ws.WriteMessage(messageType, payload) +} + func broadcastReleasePlanCollaborationSnapshot(planID string) { snapshot, err := GetReleasePlanCollaborationSnapshot(planID) if err != nil { @@ -542,7 +572,12 @@ func openReleasePlanCollaborationWS(gCtx *gin.Context, ctx *handler.Context, pla if err != nil { return e.ErrInvalidParam.AddErr(err) } - defer ws.Close() + var closeWSOnce sync.Once + closeWS := func() { + _ = ws.Close() + } + defer closeWSOnce.Do(closeWS) + setupReleasePlanCollaborationWSDeadline(ws) ensureReleasePlanCollaborationLoop() @@ -647,10 +682,17 @@ func openReleasePlanCollaborationWS(gCtx *gin.Context, ctx *handler.Context, pla }) util.Go(func() { + ticker := time.NewTicker(releasePlanCollabWSPingPeriod) + defer ticker.Stop() + defer closeWSOnce.Do(closeWS) for { select { case payload := <-client.send: - if err := ws.WriteMessage(websocket.TextMessage, payload); err != nil { + if err := writeReleasePlanCollaborationWSMessage(ws, websocket.TextMessage, payload); err != nil { + return + } + case <-ticker.C: + if err := writeReleasePlanCollaborationWSMessage(ws, websocket.PingMessage, nil); err != nil { return } case <-done: diff --git a/pkg/microservice/aslan/core/release_plan/service/diff.go b/pkg/microservice/aslan/core/release_plan/service/diff.go index 0a40a897cb..5c2f0b9b65 100644 --- a/pkg/microservice/aslan/core/release_plan/service/diff.go +++ b/pkg/microservice/aslan/core/release_plan/service/diff.go @@ -334,15 +334,20 @@ func GetReleasePlanVersionDiff(planID string, version int64) (*ReleasePlanVersio return nil, errors.Wrap(err, "get version") } + fromData, hasBaseSnapshot, err := releasePlanVersionBaseSnapshotAsGenericValue(current) + if err != nil { + return nil, errors.Wrap(err, "convert base snapshot") + } + var previous *models.ReleasePlanVersion - if current.PreviousVersion > 0 { + if !hasBaseSnapshot && current.PreviousVersion > 0 { previous, err = mongodb.NewReleasePlanVersionColl().Get(planID, current.PreviousVersion) if err != nil { return nil, errors.Wrap(err, "get previous version") } + fromData = comparableReleasePlanVersionSnapshot(previous, current.SectionKey) } - fromData := comparableReleasePlanVersionSnapshot(previous, current.SectionKey) toData, err := toReleasePlanGenericValue(current.Snapshot) if err != nil { return nil, errors.Wrap(err, "convert current snapshot") @@ -411,6 +416,18 @@ func GetReleasePlanVersionDiff(planID string, version int64) (*ReleasePlanVersio }, nil } +func releasePlanVersionBaseSnapshotAsGenericValue(version *models.ReleasePlanVersion) (interface{}, bool, error) { + if version == nil || version.BaseSnapshot == nil { + return nil, false, nil + } + + value, err := toReleasePlanGenericValue(version.BaseSnapshot) + if err != nil { + return nil, true, err + } + return value, true, nil +} + func comparableReleasePlanVersionSnapshot(version *models.ReleasePlanVersion, sectionKey string) interface{} { if version == nil { return nil diff --git a/pkg/microservice/aslan/core/release_plan/service/release_plan.go b/pkg/microservice/aslan/core/release_plan/service/release_plan.go index d4a6ebf14e..fcf6468a67 100644 --- a/pkg/microservice/aslan/core/release_plan/service/release_plan.go +++ b/pkg/microservice/aslan/core/release_plan/service/release_plan.go @@ -453,6 +453,13 @@ func UpdateReleasePlan(c *handler.Context, planID string, args *UpdateReleasePla if err != nil { return errors.Wrap(err, "build release plan current snapshot") } + var baseSnapshot interface{} + if shouldBuildReleasePlanVersionBaseSnapshot(args.Verb) { + baseSnapshot, err = buildReleasePlanVersionSnapshot(originalPlan, sectionKey) + if err != nil { + return errors.Wrap(err, "build release plan base snapshot") + } + } plan.Version = originalPlan.Version + 1 @@ -492,7 +499,7 @@ func UpdateReleasePlan(c *handler.Context, planID string, args *UpdateReleasePla SectionType: releasePlanVersionSectionGroupType(sectionKey), CreatedAt: time.Now().Unix(), } - if err := createReleasePlanVersion(planID, plan.Version, currentSnapshot, c.UserName, c.Account, sectionKey, releasePlanVersionSectionName(sectionKey, sectionName), string(args.Verb)); err != nil { + if err := createReleasePlanVersionWithBaseSnapshot(planID, plan.Version, baseSnapshot, currentSnapshot, c.UserName, c.Account, sectionKey, releasePlanVersionSectionName(sectionKey, sectionName), string(args.Verb)); err != nil { log.Errorf("create release plan version error: %v", err) } if err := createReleasePlanLog(logItem); err != nil { diff --git a/pkg/microservice/aslan/core/release_plan/service/version.go b/pkg/microservice/aslan/core/release_plan/service/version.go index 35705453fe..ea036d5b2a 100644 --- a/pkg/microservice/aslan/core/release_plan/service/version.go +++ b/pkg/microservice/aslan/core/release_plan/service/version.go @@ -26,9 +26,19 @@ import ( ) func createReleasePlanVersion(planID string, version int64, snapshot interface{}, operator, account, sectionKey, sectionName, verb string) error { - previousVersion, err := previousComparableReleasePlanVersion(planID, sectionKey, version) - if err != nil { - return err + return createReleasePlanVersionWithBaseSnapshot(planID, version, nil, snapshot, operator, account, sectionKey, sectionName, verb) +} + +func createReleasePlanVersionWithBaseSnapshot(planID string, version int64, baseSnapshot, snapshot interface{}, operator, account, sectionKey, sectionName, verb string) error { + var previousVersion int64 + if baseSnapshot != nil { + previousVersion = version - 1 + } else { + var err error + previousVersion, err = previousComparableReleasePlanVersion(planID, sectionKey, version) + if err != nil { + return err + } } return mongodb.NewReleasePlanVersionColl().Create(&models.ReleasePlanVersion{ @@ -41,11 +51,21 @@ func createReleasePlanVersion(planID string, version int64, snapshot interface{} SectionName: sectionName, SectionType: releasePlanVersionSectionGroupType(sectionKey), Verb: verb, + BaseSnapshot: sanitizeReleasePlanValue(baseSnapshot), Snapshot: sanitizeReleasePlanValue(snapshot), CreatedAt: time.Now().Unix(), }) } +func shouldBuildReleasePlanVersionBaseSnapshot(verb UpdateReleasePlanVerb) bool { + switch verb { + case VerbDeleteReleaseJob, VerbDeleteApproval, VerbReorderReleaseJob: + return true + default: + return false + } +} + func previousComparableReleasePlanVersion(planID, sectionKey string, beforeVersion int64) (int64, error) { sectionKeys := []string{sectionKey} if sectionKey != releasePlanVersionSectionPlan { From be82dcee091f7f3ec04385a32725edac665ee463 Mon Sep 17 00:00:00 2001 From: huanghongbo-hhb Date: Tue, 9 Jun 2026 16:18:21 +0800 Subject: [PATCH 15/33] chore: remove release plan test files from pr Signed-off-by: huanghongbo-hhb --- .../service/collaboration_test.go | 80 ----------- .../core/release_plan/service/diff_test.go | 127 ------------------ .../core/release_plan/service/masking_test.go | 49 ------- 3 files changed, 256 deletions(-) delete mode 100644 pkg/microservice/aslan/core/release_plan/service/collaboration_test.go delete mode 100644 pkg/microservice/aslan/core/release_plan/service/diff_test.go delete mode 100644 pkg/microservice/aslan/core/release_plan/service/masking_test.go diff --git a/pkg/microservice/aslan/core/release_plan/service/collaboration_test.go b/pkg/microservice/aslan/core/release_plan/service/collaboration_test.go deleted file mode 100644 index b6628c6efc..0000000000 --- a/pkg/microservice/aslan/core/release_plan/service/collaboration_test.go +++ /dev/null @@ -1,80 +0,0 @@ -package service - -import ( - "net/http" - "testing" -) - -func TestCanManageReleasePlanEditingSession(t *testing.T) { - session := &ReleasePlanEditingSession{ - SessionID: "session-1", - UserID: "owner", - } - - if !canManageReleasePlanEditingSession(session, "owner", false) { - t.Fatalf("expected session owner to manage editing session") - } - if canManageReleasePlanEditingSession(session, "viewer", false) { - t.Fatalf("expected non-owner to be denied") - } - if !canManageReleasePlanEditingSession(session, "viewer", true) { - t.Fatalf("expected system admin to manage editing session") - } - if canManageReleasePlanEditingSession(nil, "owner", false) { - t.Fatalf("expected nil session to be denied") - } -} - -func TestCheckReleasePlanCollaborationOrigin(t *testing.T) { - t.Run("allow empty origin", func(t *testing.T) { - req, err := http.NewRequest(http.MethodGet, "http://zadig.example.com", nil) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - req.Host = "zadig.example.com" - - if !checkReleasePlanCollaborationOrigin(req) { - t.Fatalf("expected empty origin to be allowed") - } - }) - - t.Run("allow same host", func(t *testing.T) { - req, err := http.NewRequest(http.MethodGet, "http://internal", nil) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - req.Host = "zadig.example.com" - req.Header.Set("Origin", "https://zadig.example.com") - - if !checkReleasePlanCollaborationOrigin(req) { - t.Fatalf("expected same origin host to be allowed") - } - }) - - t.Run("allow forwarded host", func(t *testing.T) { - req, err := http.NewRequest(http.MethodGet, "http://internal", nil) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - req.Host = "aslan:25000" - req.Header.Set("X-Forwarded-Host", "zadig.example.com") - req.Header.Set("Origin", "https://zadig.example.com") - - if !checkReleasePlanCollaborationOrigin(req) { - t.Fatalf("expected forwarded host to be honored") - } - }) - - t.Run("reject cross origin host", func(t *testing.T) { - req, err := http.NewRequest(http.MethodGet, "http://zadig.example.com", nil) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - req.Host = "zadig.example.com" - req.Header.Set("Origin", "https://evil.example.com") - - if checkReleasePlanCollaborationOrigin(req) { - t.Fatalf("expected cross origin host to be rejected") - } - }) -} diff --git a/pkg/microservice/aslan/core/release_plan/service/diff_test.go b/pkg/microservice/aslan/core/release_plan/service/diff_test.go deleted file mode 100644 index 272e9facd5..0000000000 --- a/pkg/microservice/aslan/core/release_plan/service/diff_test.go +++ /dev/null @@ -1,127 +0,0 @@ -package service - -import "testing" - -func TestGetReleasePlanArrayItemKey(t *testing.T) { - t.Run("job key", func(t *testing.T) { - key, ok := buildReleasePlanArrayKeyByNameTypeID(map[string]interface{}{ - "name": "build", - "type": "zadig-build", - "id": "job-id", - }) - if !ok { - t.Fatalf("expected key") - } - if key != "build|zadig-build|job-id" { - t.Fatalf("unexpected key: %s", key) - } - }) - - t.Run("service key", func(t *testing.T) { - key, ok := buildReleasePlanArrayKeyByServiceModule(map[string]interface{}{ - "service_name": "gateway", - "service_module": "gateway", - }) - if !ok { - t.Fatalf("expected key") - } - if key != "gateway/gateway" { - t.Fatalf("unexpected key: %s", key) - } - }) -} - -func TestBuildReleasePlanDiffLabel(t *testing.T) { - label := buildReleasePlanDiffLabel("jobs[release-job|workflow|job-id].spec.workflow.stages[build].jobs[deploy|zadig-deploy].spec.namespace") - expected := "任务 release-job / 阶段 build / 任务 deploy / 命名空间" - if label != expected { - t.Fatalf("unexpected label: %s", label) - } -} - -func TestReleasePlanDiffPathRules(t *testing.T) { - if !shouldIgnoreReleasePlanDiffPath("update_time") { - t.Fatalf("expected update_time to be ignored") - } - if !isLargeTextReleasePlanDiffPath("jobs[deploy].spec.script", "echo 1", "echo 2") { - t.Fatalf("expected script to be marked as large text") - } -} - -func TestReleasePlanSubtreeHashPrune(t *testing.T) { - left := map[string]interface{}{ - "a": 1.0, - "b": "x", - "c": []interface{}{map[string]interface{}{"id": "1"}, map[string]interface{}{"id": "2"}}, - "d": map[string]interface{}{"name": "demo"}, - } - right := map[string]interface{}{ - "a": 1.0, - "b": "x", - "c": []interface{}{map[string]interface{}{"id": "1"}, map[string]interface{}{"id": "2"}}, - "d": map[string]interface{}{"name": "demo"}, - } - - equal, hashed := equalReleasePlanSubtreeByHash(left, right) - if !hashed { - t.Fatalf("expected hash pruning to be enabled for large maps") - } - if !equal { - t.Fatalf("expected identical subtrees to be equal") - } -} - -func TestReleasePlanSubtreeHashPruneSkipSmallNodes(t *testing.T) { - left := map[string]interface{}{"a": 1.0, "b": 2.0} - right := map[string]interface{}{"a": 1.0, "b": 2.0} - - equal, hashed := equalReleasePlanSubtreeByHash(left, right) - if hashed { - t.Fatalf("expected hash pruning to skip small maps") - } - if equal { - t.Fatalf("hash shortcut should not report equality for skipped small maps") - } -} - -func TestToReleasePlanGenericValueSupportsRootArrays(t *testing.T) { - value := []map[string]interface{}{ - {"id": "job-1", "name": "job-a"}, - {"id": "job-2", "name": "job-b"}, - } - - generic, err := toReleasePlanGenericValue(value) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - items, ok := generic.([]interface{}) - if !ok { - t.Fatalf("expected array root, got %T", generic) - } - if len(items) != 2 { - t.Fatalf("unexpected item count: %d", len(items)) - } -} - -func TestSanitizeReleasePlanValueForDisplay(t *testing.T) { - value := map[string]interface{}{ - "vars": []interface{}{ - map[string]interface{}{ - "key": "DB_PASSWORD", - "value": "secret-token", - "is_credential": true, - }, - }, - } - - sanitized := sanitizeReleasePlanValueForDisplay(value).(map[string]interface{}) - vars := sanitized["vars"].([]interface{}) - item := vars[0].(map[string]interface{}) - if item["value"] != releasePlanMaskedValueDisplay { - t.Fatalf("expected credential value to be hidden") - } - if item["key"] != "DB_PASSWORD" { - t.Fatalf("expected non-sensitive fields to stay visible") - } -} diff --git a/pkg/microservice/aslan/core/release_plan/service/masking_test.go b/pkg/microservice/aslan/core/release_plan/service/masking_test.go deleted file mode 100644 index 260bd6199b..0000000000 --- a/pkg/microservice/aslan/core/release_plan/service/masking_test.go +++ /dev/null @@ -1,49 +0,0 @@ -package service - -import "testing" - -func TestSanitizeReleasePlanValueForDisplayMasksRawSensitiveFields(t *testing.T) { - value := map[string]interface{}{ - "key_vals": []interface{}{ - map[string]interface{}{ - "key": "DB_PASSWORD", - "value": "secret-token", - "is_credential": true, - }, - }, - } - - sanitized := sanitizeReleasePlanValueForDisplay(value).(map[string]interface{}) - keyVals := sanitized["key_vals"].([]interface{}) - item := keyVals[0].(map[string]interface{}) - if item["value"] != releasePlanMaskedValueDisplay { - t.Fatalf("expected credential value to be hidden") - } -} - -func TestIsReleasePlanSensitiveValueNode(t *testing.T) { - if isReleasePlanSensitiveValueNode(map[string]interface{}{"user_id": "alice"}) { - t.Fatalf("plain user field should not be treated as sensitive") - } - if !isReleasePlanSensitiveValueNode(map[string]interface{}{"is_credential": true, "value": "secret"}) { - t.Fatalf("credential flag should be treated as sensitive") - } - if !isReleasePlanSensitiveValueNode(map[string]interface{}{"is_sensitive": true, "value": "secret"}) { - t.Fatalf("keyvault sensitive flag should be treated as sensitive") - } -} - -func TestHasReleasePlanRawSensitiveValue(t *testing.T) { - if !hasReleasePlanRawSensitiveValue(map[string]interface{}{ - "is_credential": true, - "value": "secret", - }) { - t.Fatalf("expected raw credential value to require sanitize") - } - if hasReleasePlanRawSensitiveValue(map[string]interface{}{ - "is_credential": true, - "value": maskReleasePlanValue("secret"), - }) { - t.Fatalf("expected masked credential value to skip re-sanitize") - } -} From c5080878af2ced7bf85ac6443380dce13be6542d Mon Sep 17 00:00:00 2001 From: huanghongbo-hhb Date: Tue, 9 Jun 2026 17:41:29 +0800 Subject: [PATCH 16/33] fix: preserve release plan initial diff baseline Signed-off-by: huanghongbo-hhb --- .../core/release_plan/service/openapi.go | 22 +++++++++++---- .../core/release_plan/service/release_plan.go | 11 ++++++-- .../core/release_plan/service/version.go | 28 ++++++++----------- 3 files changed, 37 insertions(+), 24 deletions(-) diff --git a/pkg/microservice/aslan/core/release_plan/service/openapi.go b/pkg/microservice/aslan/core/release_plan/service/openapi.go index 82fce69718..b65631d1e3 100644 --- a/pkg/microservice/aslan/core/release_plan/service/openapi.go +++ b/pkg/microservice/aslan/core/release_plan/service/openapi.go @@ -383,16 +383,28 @@ func OpenAPICreateReleasePlanWithJobs(c *handler.Context, id string, rawArgs *Op plan.Jobs = newJobs plan.Version = originalPlan.Version + 1 - err = mongodb.NewReleasePlanColl().UpdateByID(c, id, plan) + currentSnapshot, err := buildReleasePlanInputSnapshot(plan) if err != nil { - return errors.Wrap(err, "update release plan error") + return errors.Wrap(err, "build release plan current snapshot") + } + var baseSnapshot interface{} + needBaseSnapshot, previousVersion, err := shouldBuildReleasePlanVersionBaseSnapshot(plan.ID.Hex(), releasePlanVersionSectionPlan, plan.Version, VerbUpdate) + if err != nil { + return errors.Wrap(err, "check release plan base snapshot") + } + if needBaseSnapshot { + baseSnapshot, err = buildReleasePlanInputSnapshot(originalPlan) + if err != nil { + return errors.Wrap(err, "build release plan base snapshot") + } } - currentSnapshot, err := buildReleasePlanInputSnapshot(plan) + err = mongodb.NewReleasePlanColl().UpdateByID(c, id, plan) if err != nil { - return errors.Wrap(err, "build release plan current snapshot") + return errors.Wrap(err, "update release plan error") } - if err := createReleasePlanVersion(plan.ID.Hex(), plan.Version, currentSnapshot, c.UserName, c.Account, releasePlanVersionSectionPlan, releasePlanVersionSectionName(releasePlanVersionSectionPlan, plan.Name), VerbUpdate); err != nil { + + if err := createReleasePlanVersionWithBaseSnapshot(plan.ID.Hex(), plan.Version, previousVersion, baseSnapshot, currentSnapshot, c.UserName, c.Account, releasePlanVersionSectionPlan, releasePlanVersionSectionName(releasePlanVersionSectionPlan, plan.Name), VerbUpdate); err != nil { log.Errorf("create release plan version error: %v", err) } if err := createReleasePlanLog(&models.ReleasePlanLog{ diff --git a/pkg/microservice/aslan/core/release_plan/service/release_plan.go b/pkg/microservice/aslan/core/release_plan/service/release_plan.go index fcf6468a67..881d1ba190 100644 --- a/pkg/microservice/aslan/core/release_plan/service/release_plan.go +++ b/pkg/microservice/aslan/core/release_plan/service/release_plan.go @@ -454,14 +454,19 @@ func UpdateReleasePlan(c *handler.Context, planID string, args *UpdateReleasePla return errors.Wrap(err, "build release plan current snapshot") } var baseSnapshot interface{} - if shouldBuildReleasePlanVersionBaseSnapshot(args.Verb) { + nextVersion := originalPlan.Version + 1 + needBaseSnapshot, previousVersion, err := shouldBuildReleasePlanVersionBaseSnapshot(planID, sectionKey, nextVersion, args.Verb) + if err != nil { + return errors.Wrap(err, "check release plan base snapshot") + } + if needBaseSnapshot { baseSnapshot, err = buildReleasePlanVersionSnapshot(originalPlan, sectionKey) if err != nil { return errors.Wrap(err, "build release plan base snapshot") } } - plan.Version = originalPlan.Version + 1 + plan.Version = nextVersion plan.UpdatedBy = c.UserName plan.UpdateTime = time.Now().Unix() @@ -499,7 +504,7 @@ func UpdateReleasePlan(c *handler.Context, planID string, args *UpdateReleasePla SectionType: releasePlanVersionSectionGroupType(sectionKey), CreatedAt: time.Now().Unix(), } - if err := createReleasePlanVersionWithBaseSnapshot(planID, plan.Version, baseSnapshot, currentSnapshot, c.UserName, c.Account, sectionKey, releasePlanVersionSectionName(sectionKey, sectionName), string(args.Verb)); err != nil { + if err := createReleasePlanVersionWithBaseSnapshot(planID, plan.Version, previousVersion, baseSnapshot, currentSnapshot, c.UserName, c.Account, sectionKey, releasePlanVersionSectionName(sectionKey, sectionName), string(args.Verb)); err != nil { log.Errorf("create release plan version error: %v", err) } if err := createReleasePlanLog(logItem); err != nil { diff --git a/pkg/microservice/aslan/core/release_plan/service/version.go b/pkg/microservice/aslan/core/release_plan/service/version.go index ea036d5b2a..154d73b673 100644 --- a/pkg/microservice/aslan/core/release_plan/service/version.go +++ b/pkg/microservice/aslan/core/release_plan/service/version.go @@ -26,21 +26,10 @@ import ( ) func createReleasePlanVersion(planID string, version int64, snapshot interface{}, operator, account, sectionKey, sectionName, verb string) error { - return createReleasePlanVersionWithBaseSnapshot(planID, version, nil, snapshot, operator, account, sectionKey, sectionName, verb) + return createReleasePlanVersionWithBaseSnapshot(planID, version, 0, nil, snapshot, operator, account, sectionKey, sectionName, verb) } -func createReleasePlanVersionWithBaseSnapshot(planID string, version int64, baseSnapshot, snapshot interface{}, operator, account, sectionKey, sectionName, verb string) error { - var previousVersion int64 - if baseSnapshot != nil { - previousVersion = version - 1 - } else { - var err error - previousVersion, err = previousComparableReleasePlanVersion(planID, sectionKey, version) - if err != nil { - return err - } - } - +func createReleasePlanVersionWithBaseSnapshot(planID string, version, previousVersion int64, baseSnapshot, snapshot interface{}, operator, account, sectionKey, sectionName, verb string) error { return mongodb.NewReleasePlanVersionColl().Create(&models.ReleasePlanVersion{ PlanID: planID, Version: version, @@ -57,12 +46,19 @@ func createReleasePlanVersionWithBaseSnapshot(planID string, version int64, base }) } -func shouldBuildReleasePlanVersionBaseSnapshot(verb UpdateReleasePlanVerb) bool { +func shouldBuildReleasePlanVersionBaseSnapshot(planID, sectionKey string, version int64, verb UpdateReleasePlanVerb) (bool, int64, error) { switch verb { case VerbDeleteReleaseJob, VerbDeleteApproval, VerbReorderReleaseJob: - return true + return true, version - 1, nil default: - return false + previousVersion, err := previousComparableReleasePlanVersion(planID, sectionKey, version) + if err != nil { + return false, 0, err + } + if previousVersion == 0 { + return true, version - 1, nil + } + return false, previousVersion, nil } } From ae69bbf7651f6cb1f8de4f33cd283d098396dc35 Mon Sep 17 00:00:00 2001 From: huanghongbo-hhb Date: Wed, 10 Jun 2026 14:30:21 +0800 Subject: [PATCH 17/33] fix: align release plan workflow diff snapshot Signed-off-by: huanghongbo-hhb --- .../aslan/core/release_plan/service/diff.go | 694 +++--------------- .../core/release_plan/service/release_plan.go | 6 + .../release_plan/service/section_snapshot.go | 150 ++-- .../core/release_plan/service/version.go | 29 + 4 files changed, 256 insertions(+), 623 deletions(-) diff --git a/pkg/microservice/aslan/core/release_plan/service/diff.go b/pkg/microservice/aslan/core/release_plan/service/diff.go index 5c2f0b9b65..def6658f58 100644 --- a/pkg/microservice/aslan/core/release_plan/service/diff.go +++ b/pkg/microservice/aslan/core/release_plan/service/diff.go @@ -34,9 +34,10 @@ import ( ) const ( - releasePlanHashPruneMinMapKeys = 4 - releasePlanHashPruneMinArrayItems = 4 - releasePlanDiffChangeTypeOrder = "order_changed" + releasePlanHashPruneMinMapKeys = 4 + releasePlanHashPruneMinArrayItems = 4 + releasePlanDiffChangeTypeOrder = "order_changed" + releasePlanDiffDisplayWorkflowSpec = "workflow_spec" ) type ReleasePlanVersionDiffResponse struct { @@ -47,10 +48,13 @@ type ReleasePlanVersionDiffResponse struct { } type ReleasePlanVersionDiffGroup struct { - GroupKey string `json:"group_key"` - GroupName string `json:"group_name"` - GroupType string `json:"group_type"` - Changes []*ReleasePlanVersionDiffChange `json:"changes"` + GroupKey string `json:"group_key"` + GroupName string `json:"group_name"` + GroupType string `json:"group_type"` + DisplayMode string `json:"display_mode,omitempty"` + BeforeSpec interface{} `json:"before_spec,omitempty"` + AfterSpec interface{} `json:"after_spec,omitempty"` + Changes []*ReleasePlanVersionDiffChange `json:"changes"` } type ReleasePlanVersionDiffOrderItem struct { @@ -60,10 +64,8 @@ type ReleasePlanVersionDiffOrderItem struct { } type ReleasePlanVersionDiffChange struct { - TaskName string `json:"task_name,omitempty"` - TaskType string `json:"task_type,omitempty"` ChangeType string `json:"change_type,omitempty"` - Path string `json:"path"` + Path string `json:"path,omitempty"` Label string `json:"label"` Before interface{} `json:"before,omitempty"` After interface{} `json:"after,omitempty"` @@ -94,238 +96,59 @@ const ( releasePlanArrayDiffStrategyKeyedOrdered ) -type releasePlanArrayDiffRuleMatchType int - -const ( - releasePlanArrayDiffRuleMatchTypeExact releasePlanArrayDiffRuleMatchType = iota - releasePlanArrayDiffRuleMatchTypeSafeSuffix -) - type releasePlanArrayKeyBuilder func(item interface{}) (string, bool) type releasePlanArrayDiffRule struct { - GroupType string - Path string - ParentJobTypes map[string]struct{} - MatchType releasePlanArrayDiffRuleMatchType - Strategy releasePlanArrayDiffStrategy - BuildKey releasePlanArrayKeyBuilder -} - -var releasePlanWorkflowJobArrayRulePrefixes = []string{ - "spec.workflow.stages.jobs", - "spec.workflow.jobs", + GroupType string + Path string + Strategy releasePlanArrayDiffStrategy + BuildKey releasePlanArrayKeyBuilder } func newReleasePlanExactArrayRule(groupType, path string, strategy releasePlanArrayDiffStrategy, buildKey releasePlanArrayKeyBuilder) releasePlanArrayDiffRule { return releasePlanArrayDiffRule{ GroupType: groupType, Path: path, - MatchType: releasePlanArrayDiffRuleMatchTypeExact, Strategy: strategy, BuildKey: buildKey, } } -func newReleasePlanTypedExactArrayRule(groupType, path string, parentJobTypes []config.JobType, strategy releasePlanArrayDiffStrategy, buildKey releasePlanArrayKeyBuilder) releasePlanArrayDiffRule { - rule := newReleasePlanExactArrayRule(groupType, path, strategy, buildKey) - rule.ParentJobTypes = make(map[string]struct{}, len(parentJobTypes)) - for _, jobType := range parentJobTypes { - rule.ParentJobTypes[string(jobType)] = struct{}{} - } - return rule -} - -func buildReleasePlanWorkflowJobArrayRulePath(prefix, pathSuffix string) string { - if pathSuffix == "" { - return prefix - } - return prefix + "." + pathSuffix -} - -func appendReleasePlanWorkflowJobExactArrayRules(rules []releasePlanArrayDiffRule, pathSuffix string, strategy releasePlanArrayDiffStrategy, buildKey releasePlanArrayKeyBuilder) []releasePlanArrayDiffRule { - for _, prefix := range releasePlanWorkflowJobArrayRulePrefixes { - rules = append(rules, newReleasePlanExactArrayRule("job", buildReleasePlanWorkflowJobArrayRulePath(prefix, pathSuffix), strategy, buildKey)) - } - return rules -} - -func appendReleasePlanWorkflowJobTypedExactArrayRules(rules []releasePlanArrayDiffRule, pathSuffix string, parentJobTypes []config.JobType, strategy releasePlanArrayDiffStrategy, buildKey releasePlanArrayKeyBuilder) []releasePlanArrayDiffRule { - for _, prefix := range releasePlanWorkflowJobArrayRulePrefixes { - rules = append(rules, newReleasePlanTypedExactArrayRule("job", buildReleasePlanWorkflowJobArrayRulePath(prefix, pathSuffix), parentJobTypes, strategy, buildKey)) - } - return rules -} - -func newReleasePlanSafeSuffixArrayRule(groupType, path string, strategy releasePlanArrayDiffStrategy, buildKey releasePlanArrayKeyBuilder) releasePlanArrayDiffRule { - return releasePlanArrayDiffRule{ - GroupType: groupType, - Path: path, - MatchType: releasePlanArrayDiffRuleMatchTypeSafeSuffix, - Strategy: strategy, - BuildKey: buildKey, - } -} - -var releasePlanArrayExactRules = func() []releasePlanArrayDiffRule { - rules := []releasePlanArrayDiffRule{ - newReleasePlanExactArrayRule("plan", "jobs", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByNameTypeID), - newReleasePlanExactArrayRule(releasePlanVersionSectionJobsOrder, "", releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByNameID), - newReleasePlanExactArrayRule("job", "spec.workflow.params", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByNameType), - newReleasePlanExactArrayRule("job", "spec.workflow.stages", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByName), - newReleasePlanExactArrayRule("job", "spec.workflow.share_storages", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByName), - newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.default_services", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByServiceModule), - newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.service_config.default_services", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByServiceModule), - newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.service_and_builds", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByServiceModule), - newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.default_service_and_builds", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByServiceModule), - newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.service_and_builds_options", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByServiceModule), - newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.service_and_vm_deploys", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByServiceModule), - newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.default_service_and_vm_deploys", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByServiceModule), - newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.service_and_vm_deploys_options", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByServiceModule), - newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.service_and_tests", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByServiceModule), - newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.service_test_options", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByServiceModule), - newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.service_and_scannings", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByServiceModule), - newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.service_scanning_options", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByServiceModule), - newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.target_services", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByServiceModule), - newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.service_and_image", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByServiceModule), - newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.gray_services", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByServiceName), - newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.source_service", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByServiceModule), - newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.service_trigger_workflow", releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByWorkflowTrigger), - newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.fixed_workflow_list", releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByFixedWorkflowTrigger), - newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.service_trigger_workflow.params", releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByNameType), - newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.fixed_workflow_list.params", releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByNameType), - newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.test_modules", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByName), - newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.test_module_options", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByName), - newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.scannings", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByName), - newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.scanning_options", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByName), - newReleasePlanTypedExactArrayRule("job", "spec.workflow.stages.jobs.spec.services.service_and_image", []config.JobType{config.JobK8sBlueGreenDeploy}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByServiceModuleNameOnly), - newReleasePlanTypedExactArrayRule("job", "spec.workflow.stages.jobs.spec.service_options.service_and_image", []config.JobType{config.JobK8sBlueGreenDeploy}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByServiceModuleNameOnly), - newReleasePlanTypedExactArrayRule("job", "spec.workflow.stages.jobs.spec.gray_services.service_and_image", []config.JobType{config.JobMseGrayRelease}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByServiceModuleNameOnly), - newReleasePlanTypedExactArrayRule("job", "spec.workflow.stages.jobs.spec.targets", []config.JobType{config.JobCustomDeploy}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByTarget), - newReleasePlanTypedExactArrayRule("job", "spec.workflow.stages.jobs.spec.target_options", []config.JobType{config.JobCustomDeploy}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByTarget), - newReleasePlanTypedExactArrayRule("job", "spec.workflow.stages.jobs.spec.targets", []config.JobType{config.JobZadigDistributeImage}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByServiceModule), - newReleasePlanTypedExactArrayRule("job", "spec.workflow.stages.jobs.spec.target_options", []config.JobType{config.JobZadigDistributeImage}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByServiceModule), - newReleasePlanTypedExactArrayRule("job", "spec.workflow.stages.jobs.spec.targets", []config.JobType{config.JobK8sBlueGreenDeploy, config.JobK8sCanaryDeploy}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByK8sTarget), - newReleasePlanTypedExactArrayRule("job", "spec.workflow.stages.jobs.spec.target_options", []config.JobType{config.JobK8sCanaryDeploy}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByK8sTarget), - newReleasePlanTypedExactArrayRule("job", "spec.workflow.stages.jobs.spec.targets", []config.JobType{config.JobK8sGrayRelease}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByGrayReleaseTarget), - newReleasePlanTypedExactArrayRule("job", "spec.workflow.stages.jobs.spec.target_options", []config.JobType{config.JobK8sGrayRelease}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByGrayReleaseTarget), - newReleasePlanTypedExactArrayRule("job", "spec.workflow.stages.jobs.spec.targets", []config.JobType{config.JobK8sGrayRollback}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByGrayRollbackTarget), - newReleasePlanTypedExactArrayRule("job", "spec.workflow.stages.jobs.spec.target_options", []config.JobType{config.JobK8sGrayRollback}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByGrayRollbackTarget), - newReleasePlanTypedExactArrayRule("job", "spec.workflow.stages.jobs.spec.targets", []config.JobType{config.JobIstioRelease, config.JobIstioRollback}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByIstioTarget), - newReleasePlanTypedExactArrayRule("job", "spec.workflow.stages.jobs.spec.target_options", []config.JobType{config.JobIstioRelease, config.JobIstioRollback}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByIstioTarget), - newReleasePlanTypedExactArrayRule("job", "spec.workflow.stages.jobs.spec.jobs", []config.JobType{config.JobJenkins}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByJobName), - newReleasePlanTypedExactArrayRule("job", "spec.workflow.stages.jobs.spec.job_options", []config.JobType{config.JobJenkins}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByJobName), - newReleasePlanTypedExactArrayRule("job", "spec.workflow.stages.jobs.spec.jobs.parameters", []config.JobType{config.JobJenkins}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByName), - newReleasePlanTypedExactArrayRule("job", "spec.workflow.stages.jobs.spec.job_options.parameters", []config.JobType{config.JobJenkins}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByName), - newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.alerts", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByNameID), - newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.alert_options", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByNameID), - newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.monitors", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByNameID), - newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.mail_users", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByUserID), - newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.mail_notification_config.target_users", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByUserID), - newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.lark_group_notification_config.at_users", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByThirdPartyUserID), - newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.lark_person_notification_config.target_users", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByThirdPartyUserID), - newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.native_approval.approve_users", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByUserID), - newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.dingtalk_approval.approval_nodes.approve_users", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByThirdPartyUserID), - newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.lark_approval.approve_users", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByThirdPartyUserID), - newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.lark_approval.approval_nodes.approve_users", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByThirdPartyUserID), - newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.lark_approval.approval_nodes.cc_users", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByThirdPartyUserID), - newReleasePlanExactArrayRule("approval", "native_approval.approve_users", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByUserID), - newReleasePlanExactArrayRule("approval", "dingtalk_approval.approval_nodes.approve_users", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByThirdPartyUserID), - newReleasePlanExactArrayRule("approval", "lark_approval.approve_users", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByThirdPartyUserID), - newReleasePlanExactArrayRule("approval", "lark_approval.approval_nodes.approve_users", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByThirdPartyUserID), - newReleasePlanExactArrayRule("approval", "lark_approval.approval_nodes.cc_users", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByThirdPartyUserID), - newReleasePlanExactArrayRule("approval", "lark_approval.approval_nodes.approve_groups", releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByApprovalGroup), - newReleasePlanExactArrayRule("approval", "lark_approval.approval_nodes.cc_groups", releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByApprovalGroup), - newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.lark_approval.approval_nodes.approve_groups", releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByApprovalGroup), - newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.lark_approval.approval_nodes.cc_groups", releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByApprovalGroup), - newReleasePlanExactArrayRule("metadata", "jira_sprint_association.sprints", releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByJiraSprint), - } - rules = appendReleasePlanWorkflowJobExactArrayRules(rules, "", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByNameType) - rules = appendReleasePlanWorkflowJobTypedExactArrayRules(rules, "spec.services", []config.JobType{config.JobZadigDeploy, config.JobK8sBlueGreenDeploy}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByServiceName) - rules = appendReleasePlanWorkflowJobTypedExactArrayRules(rules, "spec.services", []config.JobType{config.JobFreestyle}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByServiceModule) - rules = appendReleasePlanWorkflowJobTypedExactArrayRules(rules, "spec.service_options", []config.JobType{config.JobK8sBlueGreenDeploy}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByServiceName) - rules = appendReleasePlanWorkflowJobTypedExactArrayRules(rules, "spec.service_config.services", []config.JobType{config.JobSAEDeploy}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByServiceModule) - rules = appendReleasePlanWorkflowJobTypedExactArrayRules(rules, "spec.services.modules", []config.JobType{config.JobZadigDeploy}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByServiceModuleNameOnly) - rules = appendReleasePlanWorkflowJobTypedExactArrayRules(rules, "spec.service_variable_config", []config.JobType{config.JobZadigDeploy}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByServiceName) - rules = appendReleasePlanWorkflowJobTypedExactArrayRules(rules, "spec.service_variable_config.modules", []config.JobType{config.JobZadigDeploy}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByServiceModuleNameOnly) - rules = appendReleasePlanWorkflowJobTypedExactArrayRules(rules, "spec.service_variable_config.variable_configs", []config.JobType{config.JobZadigDeploy}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByVariableConfig) - return rules -}() - -var releasePlanArraySafeSuffixRules = []releasePlanArrayDiffRule{ - newReleasePlanSafeSuffixArrayRule("job", "params", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByNameType), - newReleasePlanSafeSuffixArrayRule("job", "repos", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByRepo), - newReleasePlanSafeSuffixArrayRule("job", "code_info", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByRepo), - newReleasePlanSafeSuffixArrayRule("job", "key_vals", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByKey), - newReleasePlanSafeSuffixArrayRule("job", "envs", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByKey), - newReleasePlanSafeSuffixArrayRule("job", "custom_envs", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByKey), - newReleasePlanSafeSuffixArrayRule("job", "custom_annotations", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByKey), - newReleasePlanSafeSuffixArrayRule("job", "custom_labels", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByKey), - newReleasePlanSafeSuffixArrayRule("job", "variable_kvs", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByKey), - newReleasePlanSafeSuffixArrayRule("job", "kv", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByKey), - newReleasePlanSafeSuffixArrayRule("job", "original_config", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByKey), - newReleasePlanSafeSuffixArrayRule("job", "service_and_builds", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByServiceModule), - newReleasePlanSafeSuffixArrayRule("job", "service_and_vm_deploys", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByServiceModule), - newReleasePlanSafeSuffixArrayRule("job", "service_and_tests", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByServiceModule), - newReleasePlanSafeSuffixArrayRule("job", "service_and_scannings", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByServiceModule), - newReleasePlanSafeSuffixArrayRule("job", "target_services", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByServiceModule), - newReleasePlanSafeSuffixArrayRule("job", "service_and_image", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByServiceModule), - newReleasePlanSafeSuffixArrayRule("job", "test_modules", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByName), - newReleasePlanSafeSuffixArrayRule("job", "scannings", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByName), +var releasePlanArrayExactRules = []releasePlanArrayDiffRule{ + newReleasePlanExactArrayRule("plan", "jobs", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByNameTypeID), + newReleasePlanExactArrayRule(releasePlanVersionSectionJobsOrder, "", releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByNameID), + newReleasePlanExactArrayRule("approval", "native_approval.approve_users", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByUserID), + newReleasePlanExactArrayRule("approval", "dingtalk_approval.approval_nodes.approve_users", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByThirdPartyUserID), + newReleasePlanExactArrayRule("approval", "lark_approval.approve_users", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByThirdPartyUserID), + newReleasePlanExactArrayRule("approval", "lark_approval.approval_nodes.approve_users", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByThirdPartyUserID), + newReleasePlanExactArrayRule("approval", "lark_approval.approval_nodes.cc_users", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByThirdPartyUserID), + newReleasePlanExactArrayRule("approval", "lark_approval.approval_nodes.approve_groups", releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByApprovalGroup), + newReleasePlanExactArrayRule("approval", "lark_approval.approval_nodes.cc_groups", releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByApprovalGroup), + newReleasePlanExactArrayRule("metadata", "jira_sprint_association.sprints", releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByJiraSprint), } +// Keep only labels that are still used by path-based diff rendering. +// Workflow release jobs are now rendered from before_spec/after_spec directly. var releasePlanFieldLabels = map[string]string{ - "name": "名称", - "manager": "负责人", - "manager_id": "负责人 ID", - "start_time": "开始时间", - "end_time": "结束时间", - "schedule_execute_time": "定时执行时间", - "description": "需求关联", - "approval": "审批配置", - "type": "类型", - "enabled": "是否启用", - "content": "内容", - "remark": "备注", - "branch": "代码分支", - "tag": "Tag", - "pr": "PR", - "repo_name": "仓库名称", - "repo_namespace": "仓库命名空间", - "remote_name": "远端名称", - "job_name": "任务名称", - "build_name": "构建名称", - "service_name": "服务名称", - "service_module": "服务组件", - "image": "镜像", - "image_name": "镜像名称", - "namespace": "命名空间", - "env": "环境", - "cluster_id": "集群", - "cluster_source": "集群来源", - "target": "目标", - "targets": "目标列表", - "key_vals": "变量", - "key": "变量名", - "value": "变量值", - "order": "顺序", - "params": "参数", - "stages": "阶段", - "jobs": "任务", - "script": "脚本内容", - "sql": "SQL 内容", - "manual_exec_users": "人工执行用户", - "approve_users": "审批人", - "approval_nodes": "审批节点", - "services": "服务", - "service_and_builds": "构建对象", - "default_service_and_builds": "默认构建对象", - "repos": "代码仓库", - "workflow": "工作流", - "native_approval": "原生审批", - "lark_approval": "飞书审批", - "dingtalk_approval": "钉钉审批", - "workwx_approval": "企业微信审批", + "name": "名称", + "manager": "负责人", + "manager_id": "负责人 ID", + "start_time": "开始时间", + "end_time": "结束时间", + "schedule_execute_time": "定时执行时间", + "description": "需求关联", + "approval": "审批配置", + "type": "类型", + "enabled": "是否启用", + "content": "内容", + "remark": "备注", + "order": "顺序", + "approve_users": "审批人", + "approval_nodes": "审批节点", + "native_approval": "原生审批", + "lark_approval": "飞书审批", + "dingtalk_approval": "钉钉审批", + "workwx_approval": "企业微信审批", } func GetReleasePlanVersionDiff(planID string, version int64) (*ReleasePlanVersionDiffResponse, error) { @@ -354,32 +177,30 @@ func GetReleasePlanVersionDiff(planID string, version int64) (*ReleasePlanVersio } groupKey, groupName, groupType := releasePlanVersionDiffGroup(current.SectionKey, current.SectionName) + displayMode, beforeSpec, afterSpec := releasePlanVersionDiffDisplaySpec(groupType, fromData, toData) rawEntries := make([]*releasePlanRawDiffEntry, 0) - diffReleasePlanValues(releasePlanDiffContext{GroupType: groupType}, "", fromData, toData, &rawEntries) + if displayMode == "" { + // Workflow release jobs are rendered from full preset specs on the frontend. + // Keep path-level diff for simple sections only. + diffReleasePlanValues(releasePlanDiffContext{GroupType: groupType}, "", fromData, toData, &rawEntries) + } groupMap := map[string]*ReleasePlanVersionDiffGroup{} groupOrder := make([]string, 0) + if displayMode != "" && !reflect.DeepEqual(beforeSpec, afterSpec) { + group := ensureReleasePlanVersionDiffGroup(groupMap, &groupOrder, groupKey, groupName, groupType) + group.DisplayMode = displayMode + group.BeforeSpec = sanitizeReleasePlanValueForDisplay(beforeSpec) + group.AfterSpec = sanitizeReleasePlanValueForDisplay(afterSpec) + } for _, entry := range rawEntries { if shouldIgnoreReleasePlanDiffPath(entry.Path) { continue } - taskName, taskType := classifyReleasePlanDiffTask(entry.Path) - group, exists := groupMap[groupKey] - if !exists { - group = &ReleasePlanVersionDiffGroup{ - GroupKey: groupKey, - GroupName: groupName, - GroupType: groupType, - Changes: make([]*ReleasePlanVersionDiffChange, 0), - } - groupMap[groupKey] = group - groupOrder = append(groupOrder, groupKey) - } + group := ensureReleasePlanVersionDiffGroup(groupMap, &groupOrder, groupKey, groupName, groupType) change := &ReleasePlanVersionDiffChange{ - TaskName: taskName, - TaskType: taskType, ChangeType: entry.ChangeType, Path: entry.Path, Label: buildReleasePlanDiffLabel(entry.Path), @@ -416,6 +237,56 @@ func GetReleasePlanVersionDiff(planID string, version int64) (*ReleasePlanVersio }, nil } +func ensureReleasePlanVersionDiffGroup(groupMap map[string]*ReleasePlanVersionDiffGroup, groupOrder *[]string, groupKey, groupName, groupType string) *ReleasePlanVersionDiffGroup { + if group, exists := groupMap[groupKey]; exists { + return group + } + + group := &ReleasePlanVersionDiffGroup{ + GroupKey: groupKey, + GroupName: groupName, + GroupType: groupType, + Changes: make([]*ReleasePlanVersionDiffChange, 0), + } + groupMap[groupKey] = group + *groupOrder = append(*groupOrder, groupKey) + return group +} + +func releasePlanVersionDiffDisplaySpec(groupType string, fromData, toData interface{}) (string, interface{}, interface{}) { + if groupType != "job" { + return "", nil, nil + } + if !isReleasePlanWorkflowJobSnapshot(fromData) && !isReleasePlanWorkflowJobSnapshot(toData) { + return "", nil, nil + } + return releasePlanDiffDisplayWorkflowSpec, releasePlanVersionDiffJobSpec(fromData), releasePlanVersionDiffJobSpec(toData) +} + +func isReleasePlanWorkflowJobSnapshot(value interface{}) bool { + job, ok := getMapField(value) + if !ok { + return false + } + if jobType, ok := getStringField(job, "type"); ok && jobType == string(config.JobWorkflow) { + return true + } + spec, ok := getMapField(job["spec"]) + if !ok { + return false + } + _, exists := spec["workflow"] + return exists +} + +func releasePlanVersionDiffJobSpec(value interface{}) interface{} { + job, ok := getMapField(value) + if !ok { + return nil + } + return job["spec"] +} + func releasePlanVersionBaseSnapshotAsGenericValue(version *models.ReleasePlanVersion) (interface{}, bool, error) { if version == nil || version.BaseSnapshot == nil { return nil, false, nil @@ -652,9 +523,8 @@ func diffReleasePlanArrayByIndex(ctx releasePlanDiffContext, path string, left, } type releasePlanArrayRuleLookupContext struct { - GroupType string - Path string - ParentJobType string + GroupType string + Path string } func matchReleasePlanArrayDiffRule(ctx releasePlanDiffContext, path string) *releasePlanArrayDiffRule { @@ -665,116 +535,41 @@ func matchReleasePlanArrayDiffRule(ctx releasePlanDiffContext, path string) *rel if rule.GroupType != lookup.GroupType { continue } - if !matchesReleasePlanParentJobType(rule, lookup.ParentJobType) { - continue - } if rule.Path == lookup.Path { return rule } } } - for _, lookup := range lookupContexts { - for idx := range releasePlanArraySafeSuffixRules { - rule := &releasePlanArraySafeSuffixRules[idx] - if rule.GroupType != lookup.GroupType { - continue - } - if lookup.Path == rule.Path || strings.HasSuffix(lookup.Path, "."+rule.Path) { - return rule - } - } - } return nil } -func matchesReleasePlanParentJobType(rule *releasePlanArrayDiffRule, parentJobType string) bool { - if len(rule.ParentJobTypes) == 0 { - return true - } - _, ok := rule.ParentJobTypes[parentJobType] - return ok -} - func buildReleasePlanArrayRuleLookupContexts(ctx releasePlanDiffContext, path string) []releasePlanArrayRuleLookupContext { normalizedPath := normalizeReleasePlanDiffPath(path) - parentJobType := extractReleasePlanParentJobType(path) resp := []releasePlanArrayRuleLookupContext{{ - GroupType: ctx.GroupType, - Path: normalizedPath, - ParentJobType: parentJobType, + GroupType: ctx.GroupType, + Path: normalizedPath, }} if ctx.GroupType != "plan" { return resp } - // Nested arrays under the plan snapshot still belong to job/approval/metadata structures. - if strings.HasPrefix(normalizedPath, "jobs.") { - resp = append(resp, releasePlanArrayRuleLookupContext{ - GroupType: "job", - Path: strings.TrimPrefix(normalizedPath, "jobs."), - ParentJobType: parentJobType, - }) - } + // Nested arrays under the plan snapshot still belong to approval/metadata structures. if strings.HasPrefix(normalizedPath, "approval.") { resp = append(resp, releasePlanArrayRuleLookupContext{ - GroupType: "approval", - Path: strings.TrimPrefix(normalizedPath, "approval."), - ParentJobType: parentJobType, + GroupType: "approval", + Path: strings.TrimPrefix(normalizedPath, "approval."), }) } if strings.HasPrefix(normalizedPath, "metadata.") { resp = append(resp, releasePlanArrayRuleLookupContext{ - GroupType: "metadata", - Path: strings.TrimPrefix(normalizedPath, "metadata."), - ParentJobType: parentJobType, + GroupType: "metadata", + Path: strings.TrimPrefix(normalizedPath, "metadata."), }) } return resp } -func extractReleasePlanParentJobType(path string) string { - parentJobType := "" - searchPath := path - for { - idx := strings.Index(searchPath, "jobs[") - if idx < 0 { - return parentJobType - } - searchPath = searchPath[idx+len("jobs["):] - endIdx := strings.IndexByte(searchPath, ']') - if endIdx < 0 { - return parentJobType - } - key := searchPath[:endIdx] - if jobType, ok := extractReleasePlanJobTypeFromArrayKey(key); ok { - parentJobType = jobType - } - searchPath = searchPath[endIdx+1:] - } -} - -func extractReleasePlanJobTypeFromArrayKey(key string) (string, bool) { - parts := strings.Split(key, "|") - if len(parts) < 2 { - return "", false - } - return trimReleasePlanArrayDuplicateSuffix(parts[1]), true -} - -func trimReleasePlanArrayDuplicateSuffix(value string) string { - idx := strings.LastIndex(value, "#") - if idx < 0 || idx == len(value)-1 { - return value - } - for _, ch := range value[idx+1:] { - if ch < '0' || ch > '9' { - return value - } - } - return value[:idx] -} - func normalizeReleasePlanDiffPath(path string) string { if path == "" { return "" @@ -1015,22 +810,6 @@ func buildReleasePlanArrayKeyByNameTypeID(item interface{}) (string, bool) { return fmt.Sprintf("%s|%s|%s", name, jobType, id), true } -func buildReleasePlanArrayKeyByNameType(item interface{}) (string, bool) { - value, ok := getMapField(item) - if !ok { - return "", false - } - name, ok := getStringField(value, "name") - if !ok { - return "", false - } - itemType, ok := getStringField(value, "type") - if !ok { - return "", false - } - return fmt.Sprintf("%s|%s", name, itemType), true -} - func buildReleasePlanArrayKeyByNameID(item interface{}) (string, bool) { value, ok := getMapField(item) if !ok { @@ -1047,137 +826,6 @@ func buildReleasePlanArrayKeyByNameID(item interface{}) (string, bool) { return fmt.Sprintf("%s|%s", name, id), true } -func buildReleasePlanArrayKeyByName(item interface{}) (string, bool) { - value, ok := getMapField(item) - if !ok { - return "", false - } - return getStringField(value, "name") -} - -func buildReleasePlanArrayKeyByKey(item interface{}) (string, bool) { - value, ok := getMapField(item) - if !ok { - return "", false - } - return getStringField(value, "key") -} - -func buildReleasePlanArrayKeyByTarget(item interface{}) (string, bool) { - value, ok := getMapField(item) - if !ok { - return "", false - } - return getStringField(value, "target") -} - -func buildReleasePlanArrayKeyByServiceModule(item interface{}) (string, bool) { - value, ok := getMapField(item) - if !ok { - return "", false - } - service, ok := getStringField(value, "service_name") - if !ok { - return "", false - } - module, ok := getStringField(value, "service_module") - if !ok { - return "", false - } - return fmt.Sprintf("%s/%s", service, module), true -} - -func buildReleasePlanArrayKeyByServiceModuleNameOnly(item interface{}) (string, bool) { - value, ok := getMapField(item) - if !ok { - return "", false - } - if module, ok := getStringField(value, "service_module"); ok { - return module, true - } - return getStringField(value, "name") -} - -func buildReleasePlanArrayKeyByServiceName(item interface{}) (string, bool) { - value, ok := getMapField(item) - if !ok { - return "", false - } - return getStringField(value, "service_name") -} - -func buildReleasePlanArrayKeyByVariableConfig(item interface{}) (string, bool) { - value, ok := getMapField(item) - if !ok { - return "", false - } - variableKey, ok := getStringField(value, "variable_key") - if !ok { - return "", false - } - source, _ := getStringField(value, "source") - return fmt.Sprintf("%s|%s", variableKey, source), true -} - -func buildReleasePlanArrayKeyByWorkflowTrigger(item interface{}) (string, bool) { - value, ok := getMapField(item) - if !ok { - return "", false - } - serviceName, ok := getStringField(value, "service_name") - if !ok { - return "", false - } - workflowName, ok := getStringField(value, "workflow_name") - if !ok { - return "", false - } - projectName, ok := getStringField(value, "project_name") - if !ok { - return "", false - } - serviceModule, _ := getStringField(value, "service_module") - return fmt.Sprintf("%s|%s|%s|%s", serviceName, serviceModule, workflowName, projectName), true -} - -func buildReleasePlanArrayKeyByFixedWorkflowTrigger(item interface{}) (string, bool) { - value, ok := getMapField(item) - if !ok { - return "", false - } - workflowName, ok := getStringField(value, "workflow_name") - if !ok { - return "", false - } - projectName, ok := getStringField(value, "project_name") - if !ok { - return "", false - } - return fmt.Sprintf("%s|%s", workflowName, projectName), true -} - -func buildReleasePlanArrayKeyByJobName(item interface{}) (string, bool) { - value, ok := getMapField(item) - if !ok { - return "", false - } - return getStringField(value, "job_name") -} - -func buildReleasePlanArrayKeyByRepo(item interface{}) (string, bool) { - value, ok := getMapField(item) - if !ok { - return "", false - } - repo, ok := getStringField(value, "repo_name") - if !ok { - return "", false - } - namespace, _ := getStringField(value, "repo_namespace") - remote, _ := getStringField(value, "remote_name") - return fmt.Sprintf("%s/%s/%s", namespace, repo, remote), true -} - func buildReleasePlanArrayKeyByUserID(item interface{}) (string, bool) { value, ok := getMapField(item) if !ok { @@ -1236,82 +884,6 @@ func buildReleasePlanArrayKeyByJiraSprint(item interface{}) (string, bool) { return fmt.Sprintf("%s|%s|%s", projectKey, boardID, sprintID), true } -func buildReleasePlanArrayKeyByK8sTarget(item interface{}) (string, bool) { - value, ok := getMapField(item) - if !ok { - return "", false - } - serviceName, ok := getStringField(value, "k8s_service_name") - if !ok { - return "", false - } - workloadName, ok := getStringField(value, "workload_name") - if !ok { - return "", false - } - containerName, ok := getStringField(value, "container_name") - if !ok { - return "", false - } - return fmt.Sprintf("%s|%s|%s", serviceName, workloadName, containerName), true -} - -func buildReleasePlanArrayKeyByGrayReleaseTarget(item interface{}) (string, bool) { - value, ok := getMapField(item) - if !ok { - return "", false - } - workloadType, ok := getStringField(value, "workload_type") - if !ok { - return "", false - } - workloadName, ok := getStringField(value, "workload_name") - if !ok { - return "", false - } - containerName, ok := getStringField(value, "container_name") - if !ok { - return "", false - } - return fmt.Sprintf("%s|%s|%s", workloadType, workloadName, containerName), true -} - -func buildReleasePlanArrayKeyByGrayRollbackTarget(item interface{}) (string, bool) { - value, ok := getMapField(item) - if !ok { - return "", false - } - workloadType, ok := getStringField(value, "workload_type") - if !ok { - return "", false - } - workloadName, ok := getStringField(value, "workload_name") - if !ok { - return "", false - } - return fmt.Sprintf("%s|%s", workloadType, workloadName), true -} - -func buildReleasePlanArrayKeyByIstioTarget(item interface{}) (string, bool) { - value, ok := getMapField(item) - if !ok { - return "", false - } - virtualServiceName, ok := getStringField(value, "virtual_service_name") - if !ok { - return "", false - } - workloadName, ok := getStringField(value, "workload_name") - if !ok { - return "", false - } - containerName, ok := getStringField(value, "container_name") - if !ok { - return "", false - } - return fmt.Sprintf("%s|%s|%s", virtualServiceName, workloadName, containerName), true -} - func getMapField(item interface{}) (map[string]interface{}, bool) { value, ok := item.(map[string]interface{}) return value, ok @@ -1455,24 +1027,6 @@ func isReleasePlanWorkflowJobStructureDiffPath(path string) bool { return true } -func classifyReleasePlanDiffTask(path string) (taskName, taskType string) { - jobSegments := releasePlanBracketSegments(path, "jobs") - if len(jobSegments) >= 1 { - taskName, taskType = splitReleasePlanBracketKey(jobSegments[len(jobSegments)-1]) - } - return -} - -func releasePlanBracketSegments(path, prefix string) []string { - resp := make([]string, 0) - for _, segment := range strings.Split(path, ".") { - if strings.HasPrefix(segment, prefix+"[") { - resp = append(resp, segment) - } - } - return resp -} - func splitReleasePlanBracketKey(segment string) (string, string) { primary := bracketPrimaryName(segment) parts := strings.Split(primary, "|") @@ -1503,14 +1057,6 @@ func buildReleasePlanDiffLabel(path string) string { case strings.HasPrefix(segment, "jobs["): name, _ := splitReleasePlanBracketKey(segment) label = fmt.Sprintf("任务 %s", name) - case strings.HasPrefix(segment, "stages["): - label = fmt.Sprintf("阶段 %s", bracketPrimaryName(segment)) - case strings.HasPrefix(segment, "params["): - label = fmt.Sprintf("参数 %s", bracketPrimaryName(segment)) - case strings.HasPrefix(segment, "key_vals["): - label = fmt.Sprintf("变量 %s", bracketPrimaryName(segment)) - case strings.HasPrefix(segment, "services["): - label = fmt.Sprintf("服务 %s", bracketPrimaryName(segment)) case strings.Contains(segment, "["): fieldName := segment[:strings.Index(segment, "[")] label = fmt.Sprintf("%s %s", translateReleasePlanFieldLabel(fieldName), bracketPrimaryName(segment)) diff --git a/pkg/microservice/aslan/core/release_plan/service/release_plan.go b/pkg/microservice/aslan/core/release_plan/service/release_plan.go index 881d1ba190..95ef4c14df 100644 --- a/pkg/microservice/aslan/core/release_plan/service/release_plan.go +++ b/pkg/microservice/aslan/core/release_plan/service/release_plan.go @@ -459,6 +459,12 @@ func UpdateReleasePlan(c *handler.Context, planID string, args *UpdateReleasePla if err != nil { return errors.Wrap(err, "check release plan base snapshot") } + if !needBaseSnapshot { + needBaseSnapshot, err = shouldBuildReleasePlanWorkflowDisplayBaseSnapshot(planID, sectionKey, previousVersion, currentSnapshot) + if err != nil { + return errors.Wrap(err, "check release plan workflow base snapshot") + } + } if needBaseSnapshot { baseSnapshot, err = buildReleasePlanVersionSnapshot(originalPlan, sectionKey) if err != nil { diff --git a/pkg/microservice/aslan/core/release_plan/service/section_snapshot.go b/pkg/microservice/aslan/core/release_plan/service/section_snapshot.go index 3017a2ed37..e6e534564d 100644 --- a/pkg/microservice/aslan/core/release_plan/service/section_snapshot.go +++ b/pkg/microservice/aslan/core/release_plan/service/section_snapshot.go @@ -308,11 +308,15 @@ func buildReleasePlanJobInputSpec(jobType config.ReleasePlanJobType, spec interf } return sanitizeReleasePlanValue(inputSpec), nil case config.JobWorkflow: - inputSpec := new(models.WorkflowReleaseJobSpec) - if err := models.IToi(spec, inputSpec); err != nil { + genericValue, err := toReleasePlanGenericValue(spec) + if err != nil { return nil, err } - workflowSnapshot, err := buildReleasePlanWorkflowInputSnapshot(inputSpec.Workflow) + specMap, ok := getMapField(genericValue) + if !ok { + return nil, nil + } + workflowSnapshot, err := buildReleasePlanWorkflowInputSnapshot(specMap["workflow"]) if err != nil { return nil, err } @@ -324,50 +328,110 @@ func buildReleasePlanJobInputSpec(jobType config.ReleasePlanJobType, spec interf } } -func buildReleasePlanWorkflowInputSnapshot(workflow *models.WorkflowV4) (interface{}, error) { +func buildReleasePlanWorkflowInputSnapshot(workflow interface{}) (interface{}, error) { if workflow == nil { return nil, nil } - resp := map[string]interface{}{ - "name": workflow.Name, - "display_name": workflow.DisplayName, - "project": workflow.Project, - "params": sanitizeReleasePlanValue(workflow.Params), - "jobs": make([]interface{}, 0), + genericValue, err := toReleasePlanGenericValue(workflow) + if err != nil { + return nil, err + } + workflowMap, ok := getMapField(genericValue) + if !ok { + return nil, nil } - jobs := make([]interface{}, 0) - for _, stage := range workflow.Stages { - if stage == nil { - continue + resp := make(map[string]interface{}) + for _, key := range []string{ + "id", + "name", + "display_name", + "disabled", + "category", + "project", + "remark", + "remark_required", + "ignore_cache", + "share_storages", + "concurrency_limit", + } { + if value, exists := workflowMap[key]; exists { + resp[key] = value } - for _, job := range stage.Jobs { - if job == nil { - continue - } - spec, err := buildReleasePlanWorkflowJobInputSpec(job.Spec) - if err != nil { - return nil, err - } - jobs = append(jobs, map[string]interface{}{ - "name": job.Name, - "type": job.JobType, - "spec": spec, - }) + } + if params, exists := workflowMap["params"]; exists { + resp["params"] = filterReleasePlanWorkflowInputValue(params) + } + if customField, exists := workflowMap["custom_field"]; exists { + if filtered := filterReleasePlanWorkflowInputValue(customField); filtered != nil { + resp["custom_field"] = filtered } } - resp["jobs"] = jobs - + if stages, exists := workflowMap["stages"]; exists { + resp["stages"] = buildReleasePlanWorkflowStagesInputSnapshot(stages) + } + if jobs, exists := workflowMap["jobs"]; exists { + resp["jobs"] = buildReleasePlanWorkflowJobsInputSnapshot(jobs) + } return sanitizeReleasePlanValue(resp), nil } -func buildReleasePlanWorkflowJobInputSpec(spec interface{}) (interface{}, error) { - genericValue, err := toReleasePlanGenericValue(spec) - if err != nil { - return nil, err +func buildReleasePlanWorkflowStagesInputSnapshot(value interface{}) interface{} { + stages, ok := value.([]interface{}) + if !ok { + return nil } - return filterReleasePlanWorkflowInputValue(genericValue), nil + + resp := make([]interface{}, 0, len(stages)) + for _, stage := range stages { + stageMap, ok := getMapField(stage) + if !ok { + continue + } + stageResp := make(map[string]interface{}) + if name, exists := stageMap["name"]; exists { + stageResp["name"] = name + } + if jobs, exists := stageMap["jobs"]; exists { + stageResp["jobs"] = buildReleasePlanWorkflowJobsInputSnapshot(jobs) + } + if len(stageResp) > 0 { + resp = append(resp, stageResp) + } + } + return resp +} + +func buildReleasePlanWorkflowJobsInputSnapshot(value interface{}) interface{} { + jobs, ok := value.([]interface{}) + if !ok { + return nil + } + + resp := make([]interface{}, 0, len(jobs)) + for _, job := range jobs { + jobMap, ok := getMapField(job) + if !ok { + continue + } + jobResp := make(map[string]interface{}) + for _, key := range []string{"name", "type"} { + if item, exists := jobMap[key]; exists { + jobResp[key] = item + } + } + if serviceModules, exists := jobMap["service_modules"]; exists { + jobResp["service_modules"] = filterReleasePlanWorkflowInputValue(serviceModules) + } + if spec, exists := jobMap["spec"]; exists { + jobResp["spec"] = filterReleasePlanWorkflowInputValue(spec) + } + if len(jobResp) > 0 { + resp = append(resp, jobResp) + } + } + return resp } func filterReleasePlanWorkflowInputValue(value interface{}) interface{} { @@ -425,7 +489,6 @@ func shouldDropReleasePlanWorkflowInputField(key string) bool { "updated": {}, "executed_by": {}, "executed_time": {}, - "task_id": {}, "hook_payload": {}, "hash": {}, "notification_id": {}, @@ -442,25 +505,14 @@ func shouldDropReleasePlanWorkflowInputField(key string) bool { "advanced_setting": {}, "runtime": {}, "steps": {}, - "run_policy": {}, - "error_policy": {}, - "execute_policy": {}, - "skipped": {}, + "properties": {}, + "outputs": {}, } if _, exists := dropKeys[key]; exists { return true } - optionSuffixes := []string{ - "_options", - "_option", - } - for _, suffix := range optionSuffixes { - if strings.HasSuffix(key, suffix) { - return true - } - } - return strings.HasPrefix(key, "default_") + return false } func releasePlanVersionDiffGroup(sectionKey, sectionName string) (string, string, string) { diff --git a/pkg/microservice/aslan/core/release_plan/service/version.go b/pkg/microservice/aslan/core/release_plan/service/version.go index 154d73b673..02d62889af 100644 --- a/pkg/microservice/aslan/core/release_plan/service/version.go +++ b/pkg/microservice/aslan/core/release_plan/service/version.go @@ -77,3 +77,32 @@ func previousComparableReleasePlanVersion(planID, sectionKey string, beforeVersi } return previous.Version, nil } + +func shouldBuildReleasePlanWorkflowDisplayBaseSnapshot(planID, sectionKey string, previousVersion int64, currentSnapshot interface{}) (bool, error) { + if previousVersion == 0 || releasePlanVersionSectionGroupType(sectionKey) != "job" || !isReleasePlanWorkflowJobSnapshot(currentSnapshot) { + return false, nil + } + + previous, err := mongodb.NewReleasePlanVersionColl().Get(planID, previousVersion) + if err != nil { + return false, err + } + previousSnapshot := comparableReleasePlanVersionSnapshot(previous, sectionKey) + return isLegacyReleasePlanWorkflowJobSnapshot(previousSnapshot), nil +} + +func isLegacyReleasePlanWorkflowJobSnapshot(snapshot interface{}) bool { + spec, ok := getMapField(releasePlanVersionDiffJobSpec(snapshot)) + if !ok { + return false + } + workflow, ok := getMapField(spec["workflow"]) + if !ok { + return false + } + if _, hasStages := workflow["stages"]; hasStages { + return false + } + _, hasLegacyJobs := workflow["jobs"] + return hasLegacyJobs +} From 83310b6987b7d1f6132c6a4709fa5cd976c1b4e8 Mon Sep 17 00:00:00 2001 From: huanghongbo-hhb Date: Wed, 10 Jun 2026 15:36:12 +0800 Subject: [PATCH 18/33] fix: enrich release plan workflow snapshot Signed-off-by: huanghongbo-hhb --- .../release_plan/service/section_snapshot.go | 41 ++++++++++++++++--- 1 file changed, 36 insertions(+), 5 deletions(-) diff --git a/pkg/microservice/aslan/core/release_plan/service/section_snapshot.go b/pkg/microservice/aslan/core/release_plan/service/section_snapshot.go index e6e534564d..adf6f4d5e7 100644 --- a/pkg/microservice/aslan/core/release_plan/service/section_snapshot.go +++ b/pkg/microservice/aslan/core/release_plan/service/section_snapshot.go @@ -8,6 +8,7 @@ import ( "github.com/koderover/zadig/v2/pkg/microservice/aslan/config" "github.com/koderover/zadig/v2/pkg/microservice/aslan/core/common/repository/models" + "github.com/koderover/zadig/v2/pkg/microservice/aslan/core/workflow/service/workflow/controller" ) const ( @@ -316,7 +317,7 @@ func buildReleasePlanJobInputSpec(jobType config.ReleasePlanJobType, spec interf if !ok { return nil, nil } - workflowSnapshot, err := buildReleasePlanWorkflowInputSnapshot(specMap["workflow"]) + workflowSnapshot, err := buildReleasePlanWorkflowVersionSnapshot(spec, specMap["workflow"]) if err != nil { return nil, err } @@ -328,6 +329,34 @@ func buildReleasePlanJobInputSpec(jobType config.ReleasePlanJobType, spec interf } } +func buildReleasePlanWorkflowVersionSnapshot(spec, rawWorkflow interface{}) (interface{}, error) { + if workflow, ok := enrichReleasePlanWorkflowWithLatest(spec); ok { + return buildReleasePlanWorkflowInputSnapshot(workflow) + } + + return buildReleasePlanWorkflowInputSnapshot(rawWorkflow) +} + +func enrichReleasePlanWorkflowWithLatest(spec interface{}) (_ interface{}, ok bool) { + defer func() { + if recover() != nil { + ok = false + } + }() + + workflowSpec := new(models.WorkflowReleaseJobSpec) + if err := models.IToi(spec, workflowSpec); err != nil || workflowSpec.Workflow == nil { + return nil, false + } + + workflowController := controller.CreateWorkflowController(workflowSpec.Workflow) + if err := workflowController.UpdateWithLatestWorkflow(nil); err != nil || workflowController.WorkflowV4 == nil { + return nil, false + } + + return workflowController.WorkflowV4, true +} + func buildReleasePlanWorkflowInputSnapshot(workflow interface{}) (interface{}, error) { if workflow == nil { return nil, nil @@ -390,8 +419,10 @@ func buildReleasePlanWorkflowStagesInputSnapshot(value interface{}) interface{} continue } stageResp := make(map[string]interface{}) - if name, exists := stageMap["name"]; exists { - stageResp["name"] = name + for _, key := range []string{"name", "parallel", "approval", "manual_exec"} { + if value, exists := stageMap[key]; exists { + stageResp[key] = filterReleasePlanWorkflowInputValue(value) + } } if jobs, exists := stageMap["jobs"]; exists { stageResp["jobs"] = buildReleasePlanWorkflowJobsInputSnapshot(jobs) @@ -416,9 +447,9 @@ func buildReleasePlanWorkflowJobsInputSnapshot(value interface{}) interface{} { continue } jobResp := make(map[string]interface{}) - for _, key := range []string{"name", "type"} { + for _, key := range []string{"name", "type", "run_policy", "error_policy", "execute_policy"} { if item, exists := jobMap[key]; exists { - jobResp[key] = item + jobResp[key] = filterReleasePlanWorkflowInputValue(item) } } if serviceModules, exists := jobMap["service_modules"]; exists { From d767beacefae89f15a7ae77fc9f2a62033e364b1 Mon Sep 17 00:00:00 2001 From: huanghongbo-hhb Date: Wed, 10 Jun 2026 16:17:06 +0800 Subject: [PATCH 19/33] fix: log workflow snapshot recover events Signed-off-by: huanghongbo-hhb --- .../core/release_plan/service/section_snapshot.go | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/pkg/microservice/aslan/core/release_plan/service/section_snapshot.go b/pkg/microservice/aslan/core/release_plan/service/section_snapshot.go index adf6f4d5e7..4fb05324fb 100644 --- a/pkg/microservice/aslan/core/release_plan/service/section_snapshot.go +++ b/pkg/microservice/aslan/core/release_plan/service/section_snapshot.go @@ -9,6 +9,7 @@ import ( "github.com/koderover/zadig/v2/pkg/microservice/aslan/config" "github.com/koderover/zadig/v2/pkg/microservice/aslan/core/common/repository/models" "github.com/koderover/zadig/v2/pkg/microservice/aslan/core/workflow/service/workflow/controller" + "github.com/koderover/zadig/v2/pkg/tool/log" ) const ( @@ -339,7 +340,8 @@ func buildReleasePlanWorkflowVersionSnapshot(spec, rawWorkflow interface{}) (int func enrichReleasePlanWorkflowWithLatest(spec interface{}) (_ interface{}, ok bool) { defer func() { - if recover() != nil { + if r := recover(); r != nil { + warnReleasePlanWorkflowRecover(r) ok = false } }() @@ -357,6 +359,13 @@ func enrichReleasePlanWorkflowWithLatest(spec interface{}) (_ interface{}, ok bo return workflowController.WorkflowV4, true } +func warnReleasePlanWorkflowRecover(recovered interface{}) { + defer func() { + _ = recover() + }() + log.Warnf("enrich release plan workflow panic: %v", recovered) +} + func buildReleasePlanWorkflowInputSnapshot(workflow interface{}) (interface{}, error) { if workflow == nil { return nil, nil From 4198b6a6b5e622ead659a652822068ba28ccb931 Mon Sep 17 00:00:00 2001 From: huanghongbo-hhb Date: Wed, 10 Jun 2026 17:20:50 +0800 Subject: [PATCH 20/33] fix: keep workflow skipped flag in release plan snapshot Signed-off-by: huanghongbo-hhb --- .../aslan/core/release_plan/service/section_snapshot.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/microservice/aslan/core/release_plan/service/section_snapshot.go b/pkg/microservice/aslan/core/release_plan/service/section_snapshot.go index 4fb05324fb..099779ca3f 100644 --- a/pkg/microservice/aslan/core/release_plan/service/section_snapshot.go +++ b/pkg/microservice/aslan/core/release_plan/service/section_snapshot.go @@ -456,7 +456,7 @@ func buildReleasePlanWorkflowJobsInputSnapshot(value interface{}) interface{} { continue } jobResp := make(map[string]interface{}) - for _, key := range []string{"name", "type", "run_policy", "error_policy", "execute_policy"} { + for _, key := range []string{"name", "type", "skipped", "run_policy", "error_policy", "execute_policy"} { if item, exists := jobMap[key]; exists { jobResp[key] = filterReleasePlanWorkflowInputValue(item) } From 874447f60bfcf3d76ea1e9c4591c9b5e4d99e593 Mon Sep 17 00:00:00 2001 From: huanghongbo-hhb Date: Wed, 10 Jun 2026 17:53:22 +0800 Subject: [PATCH 21/33] fix: backfill incomplete workflow diff base snapshot Signed-off-by: huanghongbo-hhb --- .../core/release_plan/service/version.go | 47 +++++++++++++++---- 1 file changed, 39 insertions(+), 8 deletions(-) diff --git a/pkg/microservice/aslan/core/release_plan/service/version.go b/pkg/microservice/aslan/core/release_plan/service/version.go index 02d62889af..232c647ba8 100644 --- a/pkg/microservice/aslan/core/release_plan/service/version.go +++ b/pkg/microservice/aslan/core/release_plan/service/version.go @@ -88,21 +88,52 @@ func shouldBuildReleasePlanWorkflowDisplayBaseSnapshot(planID, sectionKey string return false, err } previousSnapshot := comparableReleasePlanVersionSnapshot(previous, sectionKey) - return isLegacyReleasePlanWorkflowJobSnapshot(previousSnapshot), nil + return isIncompleteReleasePlanWorkflowDisplaySnapshot(previousSnapshot, currentSnapshot), nil } -func isLegacyReleasePlanWorkflowJobSnapshot(snapshot interface{}) bool { - spec, ok := getMapField(releasePlanVersionDiffJobSpec(snapshot)) +func isIncompleteReleasePlanWorkflowDisplaySnapshot(previousSnapshot, currentSnapshot interface{}) bool { + previousSpec, ok := getMapField(releasePlanVersionDiffJobSpec(previousSnapshot)) if !ok { return false } - workflow, ok := getMapField(spec["workflow"]) + currentSpec, ok := getMapField(releasePlanVersionDiffJobSpec(currentSnapshot)) if !ok { return false } - if _, hasStages := workflow["stages"]; hasStages { - return false + + return hasMissingReleasePlanWorkflowDisplayFields(currentSpec, previousSpec) +} + +func hasMissingReleasePlanWorkflowDisplayFields(reference, candidate interface{}) bool { + switch typedReference := reference.(type) { + case map[string]interface{}: + typedCandidate, ok := candidate.(map[string]interface{}) + if !ok { + return true + } + for key, referenceValue := range typedReference { + candidateValue, exists := typedCandidate[key] + if !exists { + return true + } + if hasMissingReleasePlanWorkflowDisplayFields(referenceValue, candidateValue) { + return true + } + } + case []interface{}: + typedCandidate, ok := candidate.([]interface{}) + if !ok { + return true + } + limit := len(typedReference) + if len(typedCandidate) < limit { + limit = len(typedCandidate) + } + for idx := 0; idx < limit; idx++ { + if hasMissingReleasePlanWorkflowDisplayFields(typedReference[idx], typedCandidate[idx]) { + return true + } + } } - _, hasLegacyJobs := workflow["jobs"] - return hasLegacyJobs + return false } From 3e3d3a0d76f4959c9ef4062e8486df8658159327 Mon Sep 17 00:00:00 2001 From: huanghongbo-hhb Date: Wed, 10 Jun 2026 18:39:11 +0800 Subject: [PATCH 22/33] feat: expose approval snapshot diff display mode Signed-off-by: huanghongbo-hhb --- .../aslan/core/release_plan/service/diff.go | 24 ++++++++++++++----- 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/pkg/microservice/aslan/core/release_plan/service/diff.go b/pkg/microservice/aslan/core/release_plan/service/diff.go index def6658f58..b5b43d3afd 100644 --- a/pkg/microservice/aslan/core/release_plan/service/diff.go +++ b/pkg/microservice/aslan/core/release_plan/service/diff.go @@ -37,6 +37,7 @@ const ( releasePlanHashPruneMinMapKeys = 4 releasePlanHashPruneMinArrayItems = 4 releasePlanDiffChangeTypeOrder = "order_changed" + releasePlanDiffDisplayApprovalSpec = "approval_spec" releasePlanDiffDisplayWorkflowSpec = "workflow_spec" ) @@ -180,7 +181,7 @@ func GetReleasePlanVersionDiff(planID string, version int64) (*ReleasePlanVersio displayMode, beforeSpec, afterSpec := releasePlanVersionDiffDisplaySpec(groupType, fromData, toData) rawEntries := make([]*releasePlanRawDiffEntry, 0) - if displayMode == "" { + if shouldBuildReleasePlanPathDiff(displayMode) { // Workflow release jobs are rendered from full preset specs on the frontend. // Keep path-level diff for simple sections only. diffReleasePlanValues(releasePlanDiffContext{GroupType: groupType}, "", fromData, toData, &rawEntries) @@ -254,13 +255,24 @@ func ensureReleasePlanVersionDiffGroup(groupMap map[string]*ReleasePlanVersionDi } func releasePlanVersionDiffDisplaySpec(groupType string, fromData, toData interface{}) (string, interface{}, interface{}) { - if groupType != "job" { - return "", nil, nil - } - if !isReleasePlanWorkflowJobSnapshot(fromData) && !isReleasePlanWorkflowJobSnapshot(toData) { + switch groupType { + case "approval": + if fromData == nil && toData == nil { + return "", nil, nil + } + return releasePlanDiffDisplayApprovalSpec, fromData, toData + case "job": + if !isReleasePlanWorkflowJobSnapshot(fromData) && !isReleasePlanWorkflowJobSnapshot(toData) { + return "", nil, nil + } + return releasePlanDiffDisplayWorkflowSpec, releasePlanVersionDiffJobSpec(fromData), releasePlanVersionDiffJobSpec(toData) + default: return "", nil, nil } - return releasePlanDiffDisplayWorkflowSpec, releasePlanVersionDiffJobSpec(fromData), releasePlanVersionDiffJobSpec(toData) +} + +func shouldBuildReleasePlanPathDiff(displayMode string) bool { + return displayMode == "" || displayMode == releasePlanDiffDisplayApprovalSpec } func isReleasePlanWorkflowJobSnapshot(value interface{}) bool { From f03ea48618fbbfbb5e75a13672dc889ae720e4b7 Mon Sep 17 00:00:00 2001 From: huanghongbo-hhb Date: Wed, 10 Jun 2026 19:15:53 +0800 Subject: [PATCH 23/33] fix: preserve rich text release plan diff values Signed-off-by: huanghongbo-hhb --- .../aslan/core/release_plan/service/diff.go | 20 +++++++- .../core/release_plan/service/release_plan.go | 7 +-- .../aslan/core/release_plan/service/update.go | 48 ++++++++++++++----- .../core/release_plan/service/watcher.go | 4 +- 4 files changed, 61 insertions(+), 18 deletions(-) diff --git a/pkg/microservice/aslan/core/release_plan/service/diff.go b/pkg/microservice/aslan/core/release_plan/service/diff.go index b5b43d3afd..7cbf35deea 100644 --- a/pkg/microservice/aslan/core/release_plan/service/diff.go +++ b/pkg/microservice/aslan/core/release_plan/service/diff.go @@ -1095,8 +1095,12 @@ func isMaskedReleasePlanDiffValue(value interface{}) bool { } func isLargeTextReleasePlanDiffPath(path string, before, after interface{}) bool { + if shouldPreserveFullReleasePlanDiffValue(path) { + return false + } + lowerPath := strings.ToLower(path) - keywords := []string{"script", "sql", "content", "yaml", "json"} + keywords := []string{"script", "sql", "yaml", "json"} for _, keyword := range keywords { if strings.Contains(lowerPath, keyword) { return true @@ -1112,6 +1116,20 @@ func isLargeTextReleasePlanDiffPath(path string, before, after interface{}) bool return false } +func shouldPreserveFullReleasePlanDiffValue(path string) bool { + lowerPath := strings.ToLower(path) + switch { + case lowerPath == "description": + return true + case strings.HasSuffix(lowerPath, ".description"): + return true + case lowerPath == "spec.content": + return true + default: + return false + } +} + func normalizeReleasePlanDiffValue(value interface{}) interface{} { switch value.(type) { case nil, string, bool, float64: diff --git a/pkg/microservice/aslan/core/release_plan/service/release_plan.go b/pkg/microservice/aslan/core/release_plan/service/release_plan.go index 95ef4c14df..ee470717a6 100644 --- a/pkg/microservice/aslan/core/release_plan/service/release_plan.go +++ b/pkg/microservice/aslan/core/release_plan/service/release_plan.go @@ -349,6 +349,7 @@ func GetReleasePlanLogs(id string) (*GetReleasePlanLogsResponse, error) { continue } cloned := *item + cloned.TargetType = normalizeReleasePlanTargetType(item.TargetType) cloned.Before = sanitizeReleasePlanValueForDisplay(item.Before) cloned.After = sanitizeReleasePlanValueForDisplay(item.After) sanitizedLogs = append(sanitizedLogs, &cloned) @@ -1289,7 +1290,7 @@ func UpdateReleasePlanStatus(c *handler.Context, planID, targetStatus string, is Username: c.UserName, Account: c.Account, Verb: VerbUpdate, - TargetName: TargetTypeReleasePlanStatus, + TargetName: releasePlanTargetTypeDisplayName(TargetTypeReleasePlanStatus), TargetType: TargetTypeReleasePlanStatus, Detail: detail, Before: oldStatus, @@ -1372,7 +1373,7 @@ func ApproveReleasePlan(c *handler.Context, planID string, req *ApproveRequest) Username: UserNameSystem, Account: "", Verb: VerbUpdate, - TargetName: TargetTypeReleasePlanStatus, + TargetName: releasePlanTargetTypeDisplayName(TargetTypeReleasePlanStatus), TargetType: TargetTypeReleasePlanStatus, Detail: DetailApprovalPass, Before: beforeStatus, @@ -1404,7 +1405,7 @@ func ApproveReleasePlan(c *handler.Context, planID string, req *ApproveRequest) Username: UserNameSystem, Account: "", Verb: VerbUpdate, - TargetName: TargetTypeReleasePlanStatus, + TargetName: releasePlanTargetTypeDisplayName(TargetTypeReleasePlanStatus), TargetType: TargetTypeReleasePlanStatus, Detail: DetailApprovalReject, Before: beforeStatus, diff --git a/pkg/microservice/aslan/core/release_plan/service/update.go b/pkg/microservice/aslan/core/release_plan/service/update.go index ae0381e71c..b699733cd5 100644 --- a/pkg/microservice/aslan/core/release_plan/service/update.go +++ b/pkg/microservice/aslan/core/release_plan/service/update.go @@ -48,12 +48,12 @@ const ( VerbUpdateApproval = "update_approval" VerbDeleteApproval = "delete_approval" - TargetTypeReleasePlan = "发布计划" - TargetTypeReleasePlanStatus = "发布计划状态" - TargetTypeMetadata = "元数据" - TargetTypeReleaseJob = "发布内容" - TargetTypeApproval = "审批" - TargetTypeDescription = "需求关联" + TargetTypeReleasePlan = "release_plan" + TargetTypeReleasePlanStatus = "release_plan_status" + TargetTypeMetadata = "metadata" + TargetTypeReleaseJob = "release_job" + TargetTypeApproval = "approval" + TargetTypeDescription = "description" VerbCreate = "新建" VerbUpdate = "更新" @@ -69,12 +69,36 @@ const ( ) var TargetTypeI18nMap = map[string]string{ - TargetTypeReleasePlan: "Release Plan", - TargetTypeReleasePlanStatus: "Release Plan Status", - TargetTypeMetadata: "Metadata", - TargetTypeReleaseJob: "Release Job", - TargetTypeApproval: "Approval", - TargetTypeDescription: "Description", + TargetTypeReleasePlan: "发布计划", + TargetTypeReleasePlanStatus: "发布计划状态", + TargetTypeMetadata: "元数据", + TargetTypeReleaseJob: "发布内容", + TargetTypeApproval: "审批", + TargetTypeDescription: "需求关联", +} + +var legacyReleasePlanTargetTypeMap = map[string]string{ + "发布计划": TargetTypeReleasePlan, + "发布计划状态": TargetTypeReleasePlanStatus, + "元数据": TargetTypeMetadata, + "发布内容": TargetTypeReleaseJob, + "审批": TargetTypeApproval, + "需求关联": TargetTypeDescription, +} + +func normalizeReleasePlanTargetType(targetType string) string { + if normalized, ok := legacyReleasePlanTargetTypeMap[targetType]; ok { + return normalized + } + return targetType +} + +func releasePlanTargetTypeDisplayName(targetType string) string { + targetType = normalizeReleasePlanTargetType(targetType) + if displayName, ok := TargetTypeI18nMap[targetType]; ok { + return displayName + } + return targetType } var VerbI18nMap = map[string]string{ diff --git a/pkg/microservice/aslan/core/release_plan/service/watcher.go b/pkg/microservice/aslan/core/release_plan/service/watcher.go index 9e297a3bec..c076f26a18 100644 --- a/pkg/microservice/aslan/core/release_plan/service/watcher.go +++ b/pkg/microservice/aslan/core/release_plan/service/watcher.go @@ -238,7 +238,7 @@ func updatePlanApproval(plan *models.ReleasePlan) error { Username: UserNameSystem, Account: "", Verb: VerbUpdate, - TargetName: TargetTypeReleasePlanStatus, + TargetName: releasePlanTargetTypeDisplayName(TargetTypeReleasePlanStatus), TargetType: TargetTypeReleasePlanStatus, Detail: DetailApprovalPass, Before: beforeStatus, @@ -270,7 +270,7 @@ func updatePlanApproval(plan *models.ReleasePlan) error { Username: UserNameSystem, Account: "", Verb: VerbUpdate, - TargetName: TargetTypeReleasePlanStatus, + TargetName: releasePlanTargetTypeDisplayName(TargetTypeReleasePlanStatus), TargetType: TargetTypeReleasePlanStatus, Detail: DetailApprovalReject, Before: beforeStatus, From 9f6036ef0d24b8b9b05b9a418e9bd5068318081e Mon Sep 17 00:00:00 2001 From: huanghongbo-hhb Date: Thu, 11 Jun 2026 15:06:03 +0800 Subject: [PATCH 24/33] fix: skip empty release plan logs Signed-off-by: huanghongbo-hhb --- .../core/release_plan/service/openapi.go | 36 +++++++++++-------- .../core/release_plan/service/release_plan.go | 24 +++++++++++-- 2 files changed, 42 insertions(+), 18 deletions(-) diff --git a/pkg/microservice/aslan/core/release_plan/service/openapi.go b/pkg/microservice/aslan/core/release_plan/service/openapi.go index b65631d1e3..b373f4ccea 100644 --- a/pkg/microservice/aslan/core/release_plan/service/openapi.go +++ b/pkg/microservice/aslan/core/release_plan/service/openapi.go @@ -398,6 +398,11 @@ func OpenAPICreateReleasePlanWithJobs(c *handler.Context, id string, rawArgs *Op return errors.Wrap(err, "build release plan base snapshot") } } + logBaseSnapshot, err := resolveReleasePlanLogBaseSnapshot(baseSnapshot, originalPlan, releasePlanVersionSectionPlan) + if err != nil { + return errors.Wrap(err, "build release plan log base snapshot") + } + shouldCreateLog := hasReleasePlanSnapshotChanges(logBaseSnapshot, currentSnapshot) err = mongodb.NewReleasePlanColl().UpdateByID(c, id, plan) if err != nil { @@ -406,21 +411,22 @@ func OpenAPICreateReleasePlanWithJobs(c *handler.Context, id string, rawArgs *Op if err := createReleasePlanVersionWithBaseSnapshot(plan.ID.Hex(), plan.Version, previousVersion, baseSnapshot, currentSnapshot, c.UserName, c.Account, releasePlanVersionSectionPlan, releasePlanVersionSectionName(releasePlanVersionSectionPlan, plan.Name), VerbUpdate); err != nil { log.Errorf("create release plan version error: %v", err) - } - if err := createReleasePlanLog(&models.ReleasePlanLog{ - PlanID: plan.ID.Hex(), - Username: c.UserName, - Account: c.Account, - Verb: VerbUpdate, - TargetName: plan.Name, - TargetType: TargetTypeReleasePlan, - Version: plan.Version, - SectionKey: releasePlanVersionSectionPlan, - SectionName: releasePlanVersionSectionName(releasePlanVersionSectionPlan, plan.Name), - SectionType: releasePlanVersionSectionGroupType(releasePlanVersionSectionPlan), - CreatedAt: time.Now().Unix(), - }); err != nil { - log.Errorf("create release plan log error: %v", err) + } else if shouldCreateLog { + if err := createReleasePlanLog(&models.ReleasePlanLog{ + PlanID: plan.ID.Hex(), + Username: c.UserName, + Account: c.Account, + Verb: VerbUpdate, + TargetName: plan.Name, + TargetType: TargetTypeReleasePlan, + Version: plan.Version, + SectionKey: releasePlanVersionSectionPlan, + SectionName: releasePlanVersionSectionName(releasePlanVersionSectionPlan, plan.Name), + SectionType: releasePlanVersionSectionGroupType(releasePlanVersionSectionPlan), + CreatedAt: time.Now().Unix(), + }); err != nil { + log.Errorf("create release plan log error: %v", err) + } } if err := broadcastReleasePlanCollaboration(plan.ID.Hex()); err != nil { log.Errorf("broadcast release plan collaboration error: %v", err) diff --git a/pkg/microservice/aslan/core/release_plan/service/release_plan.go b/pkg/microservice/aslan/core/release_plan/service/release_plan.go index ee470717a6..c7580088ab 100644 --- a/pkg/microservice/aslan/core/release_plan/service/release_plan.go +++ b/pkg/microservice/aslan/core/release_plan/service/release_plan.go @@ -22,6 +22,7 @@ import ( "encoding/hex" "encoding/json" "fmt" + "reflect" "strconv" "strings" "time" @@ -366,6 +367,17 @@ func GetReleasePlanLogs(id string) (*GetReleasePlanLogsResponse, error) { }, nil } +func resolveReleasePlanLogBaseSnapshot(baseSnapshot interface{}, originalPlan *models.ReleasePlan, sectionKey string) (interface{}, error) { + if baseSnapshot != nil { + return baseSnapshot, nil + } + return buildReleasePlanVersionSnapshot(originalPlan, sectionKey) +} + +func hasReleasePlanSnapshotChanges(beforeSnapshot, afterSnapshot interface{}) bool { + return !reflect.DeepEqual(beforeSnapshot, afterSnapshot) +} + func DeleteReleasePlan(c *gin.Context, username, id string) error { info, err := mongodb.NewReleasePlanColl().GetByID(context.Background(), id) if err != nil { @@ -472,6 +484,11 @@ func UpdateReleasePlan(c *handler.Context, planID string, args *UpdateReleasePla return errors.Wrap(err, "build release plan base snapshot") } } + logBaseSnapshot, err := resolveReleasePlanLogBaseSnapshot(baseSnapshot, originalPlan, sectionKey) + if err != nil { + return errors.Wrap(err, "build release plan log base snapshot") + } + shouldCreateLog := hasReleasePlanSnapshotChanges(logBaseSnapshot, currentSnapshot) plan.Version = nextVersion @@ -513,9 +530,10 @@ func UpdateReleasePlan(c *handler.Context, planID string, args *UpdateReleasePla } if err := createReleasePlanVersionWithBaseSnapshot(planID, plan.Version, previousVersion, baseSnapshot, currentSnapshot, c.UserName, c.Account, sectionKey, releasePlanVersionSectionName(sectionKey, sectionName), string(args.Verb)); err != nil { log.Errorf("create release plan version error: %v", err) - } - if err := createReleasePlanLog(logItem); err != nil { - log.Errorf("create release plan log error: %v", err) + } else if shouldCreateLog { + if err := createReleasePlanLog(logItem); err != nil { + log.Errorf("create release plan log error: %v", err) + } } if err := broadcastReleasePlanCollaboration(planID); err != nil { log.Errorf("broadcast release plan collaboration error: %v", err) From c4975b6a1c7d81c2edd33529e418f7f989f878b8 Mon Sep 17 00:00:00 2001 From: huanghongbo-hhb Date: Thu, 11 Jun 2026 16:47:11 +0800 Subject: [PATCH 25/33] fix: cap diff depth and batch collab reads Signed-off-by: huanghongbo-hhb --- .../release_plan/service/collaboration.go | 44 +++++++++++++------ .../aslan/core/release_plan/service/diff.go | 32 ++++++++++---- pkg/tool/cache/redis_cache.go | 7 +++ 3 files changed, 61 insertions(+), 22 deletions(-) diff --git a/pkg/microservice/aslan/core/release_plan/service/collaboration.go b/pkg/microservice/aslan/core/release_plan/service/collaboration.go index 5d73021b27..dbeff05145 100644 --- a/pkg/microservice/aslan/core/release_plan/service/collaboration.go +++ b/pkg/microservice/aslan/core/release_plan/service/collaboration.go @@ -444,23 +444,22 @@ func listActiveReleasePlanEditingSessions(planID string) ([]*ReleasePlanEditingS if err != nil { return nil, err } + if len(sessionIDs) == 0 { + return []*ReleasePlanEditingSession{}, nil + } - resp := make([]*ReleasePlanEditingSession, 0, len(sessionIDs)) + keys := make([]string, 0, len(sessionIDs)) for _, sessionID := range sessionIDs { - value, err := redisCache.GetString(releasePlanCollabSessionKey(sessionID)) - if err != nil { - continue - } - session := new(ReleasePlanEditingSession) - if err := json.Unmarshal([]byte(value), session); err != nil { - continue - } - if session.PlanID != planID { - continue - } - resp = append(resp, session) + keys = append(keys, releasePlanCollabSessionKey(sessionID)) + } + + values, err := redisCache.MGet(keys) + if err != nil { + return nil, err } + resp := decodeReleasePlanEditingSessions(planID, values) + sort.Slice(resp, func(i, j int) bool { if resp[i].SectionKey == resp[j].SectionKey { return resp[i].EditingStartedAt < resp[j].EditingStartedAt @@ -471,6 +470,25 @@ func listActiveReleasePlanEditingSessions(planID string) ([]*ReleasePlanEditingS return resp, nil } +func decodeReleasePlanEditingSessions(planID string, values []interface{}) []*ReleasePlanEditingSession { + resp := make([]*ReleasePlanEditingSession, 0, len(values)) + for _, value := range values { + raw, ok := value.(string) + if !ok || raw == "" { + continue + } + session := new(ReleasePlanEditingSession) + if err := json.Unmarshal([]byte(raw), session); err != nil { + continue + } + if session.PlanID != planID { + continue + } + resp = append(resp, session) + } + return resp +} + func persistReleasePlanEditingSession(session *ReleasePlanEditingSession) error { if session == nil { return errors.New("nil editing session") diff --git a/pkg/microservice/aslan/core/release_plan/service/diff.go b/pkg/microservice/aslan/core/release_plan/service/diff.go index 7cbf35deea..7f88230e8f 100644 --- a/pkg/microservice/aslan/core/release_plan/service/diff.go +++ b/pkg/microservice/aslan/core/release_plan/service/diff.go @@ -36,6 +36,7 @@ import ( const ( releasePlanHashPruneMinMapKeys = 4 releasePlanHashPruneMinArrayItems = 4 + releasePlanDiffMaxDepth = 50 releasePlanDiffChangeTypeOrder = "order_changed" releasePlanDiffDisplayApprovalSpec = "approval_spec" releasePlanDiffDisplayWorkflowSpec = "workflow_spec" @@ -368,6 +369,10 @@ func extractReleasePlanSectionSnapshot(snapshot interface{}, sectionKey string) } func diffReleasePlanValues(ctx releasePlanDiffContext, path string, left, right interface{}, entries *[]*releasePlanRawDiffEntry) { + diffReleasePlanValuesWithDepth(ctx, path, 0, left, right, entries) +} + +func diffReleasePlanValuesWithDepth(ctx releasePlanDiffContext, path string, depth int, left, right interface{}, entries *[]*releasePlanRawDiffEntry) { if shouldIgnoreReleasePlanDiffPath(path) { return } @@ -380,6 +385,15 @@ func diffReleasePlanValues(ctx releasePlanDiffContext, path string, left, right return } + if depth >= releasePlanDiffMaxDepth { + *entries = append(*entries, &releasePlanRawDiffEntry{ + Path: path, + Before: left, + After: right, + }) + return + } + leftMap, leftIsMap := left.(map[string]interface{}) rightMap, rightIsMap := right.(map[string]interface{}) if leftIsMap || rightIsMap { @@ -397,7 +411,7 @@ func diffReleasePlanValues(ctx releasePlanDiffContext, path string, left, right sort.Strings(keys) for _, key := range keys { nextPath := joinReleasePlanDiffPath(path, key) - diffReleasePlanValues(ctx, nextPath, leftMap[key], rightMap[key], entries) + diffReleasePlanValuesWithDepth(ctx, nextPath, depth+1, leftMap[key], rightMap[key], entries) } return } @@ -405,7 +419,7 @@ func diffReleasePlanValues(ctx releasePlanDiffContext, path string, left, right leftList, leftIsList := left.([]interface{}) rightList, rightIsList := right.([]interface{}) if leftIsList || rightIsList { - diffReleasePlanArray(ctx, path, leftList, rightList, entries) + diffReleasePlanArray(ctx, path, depth, leftList, rightList, entries) return } @@ -460,17 +474,17 @@ func hashReleasePlanSubtree(value interface{}) (string, error) { return hex.EncodeToString(sum[:]), nil } -func diffReleasePlanArray(ctx releasePlanDiffContext, path string, left, right []interface{}, entries *[]*releasePlanRawDiffEntry) { +func diffReleasePlanArray(ctx releasePlanDiffContext, path string, depth int, left, right []interface{}, entries *[]*releasePlanRawDiffEntry) { rule := matchReleasePlanArrayDiffRule(ctx, path) if rule == nil || rule.Strategy == releasePlanArrayDiffStrategyIndex { - diffReleasePlanArrayByIndex(ctx, path, left, right, entries) + diffReleasePlanArrayByIndex(ctx, path, depth, left, right, entries) return } leftMap, leftOrdered, leftMapped := buildReleasePlanArrayMap(left, rule.BuildKey) rightMap, rightOrdered, rightMapped := buildReleasePlanArrayMap(right, rule.BuildKey) if !leftMapped || !rightMapped { - diffReleasePlanArrayByIndex(ctx, path, left, right, entries) + diffReleasePlanArrayByIndex(ctx, path, depth, left, right, entries) return } @@ -500,12 +514,12 @@ func diffReleasePlanArray(ctx releasePlanDiffContext, path string, left, right [ continue } nextPath := fmt.Sprintf("%s[%s]", path, key) - diffReleasePlanValues(ctx, nextPath, leftMap[key], rightMap[key], entries) + diffReleasePlanValuesWithDepth(ctx, nextPath, depth+1, leftMap[key], rightMap[key], entries) } return } - diffReleasePlanArrayByIndex(ctx, path, left, right, entries) + diffReleasePlanArrayByIndex(ctx, path, depth, left, right, entries) } func shouldSkipReleasePlanWorkflowTaskPresenceChange(path string, left, right interface{}) bool { @@ -516,7 +530,7 @@ func shouldSkipReleasePlanWorkflowTaskPresenceChange(path string, left, right in return normalizedPath == "spec.workflow.jobs" || strings.HasSuffix(normalizedPath, ".spec.workflow.jobs") } -func diffReleasePlanArrayByIndex(ctx releasePlanDiffContext, path string, left, right []interface{}, entries *[]*releasePlanRawDiffEntry) { +func diffReleasePlanArrayByIndex(ctx releasePlanDiffContext, path string, depth int, left, right []interface{}, entries *[]*releasePlanRawDiffEntry) { maxLen := len(left) if len(right) > maxLen { maxLen = len(right) @@ -530,7 +544,7 @@ func diffReleasePlanArrayByIndex(ctx releasePlanDiffContext, path string, left, if i < len(right) { rightVal = right[i] } - diffReleasePlanValues(ctx, nextPath, leftVal, rightVal, entries) + diffReleasePlanValuesWithDepth(ctx, nextPath, depth+1, leftVal, rightVal, entries) } } diff --git a/pkg/tool/cache/redis_cache.go b/pkg/tool/cache/redis_cache.go index f37dd5488b..7b3621bfa3 100644 --- a/pkg/tool/cache/redis_cache.go +++ b/pkg/tool/cache/redis_cache.go @@ -91,6 +91,13 @@ func (c *RedisCache) GetString(key string) (string, error) { return c.redisClient.Get(context.TODO(), key).Result() } +func (c *RedisCache) MGet(keys []string) ([]interface{}, error) { + if len(keys) == 0 { + return []interface{}{}, nil + } + return c.redisClient.MGet(context.TODO(), keys...).Result() +} + func (c *RedisCache) HGetString(key, field string) (string, error) { return c.redisClient.HGet(context.TODO(), key, field).Result() } From d6d1468cfef5b60adfc4b77dba99db21f0b9fa92 Mon Sep 17 00:00:00 2001 From: huanghongbo-hhb Date: Thu, 11 Jun 2026 18:15:20 +0800 Subject: [PATCH 26/33] refactor: align release plan metadata section keys Signed-off-by: huanghongbo-hhb --- .../release_plan/service/collaboration.go | 95 ++++++++++++++++++- .../aslan/core/release_plan/service/diff.go | 39 +++++++- .../release_plan/service/section_snapshot.go | 69 ++++++++++++-- 3 files changed, 190 insertions(+), 13 deletions(-) diff --git a/pkg/microservice/aslan/core/release_plan/service/collaboration.go b/pkg/microservice/aslan/core/release_plan/service/collaboration.go index dbeff05145..aa78c2ec50 100644 --- a/pkg/microservice/aslan/core/release_plan/service/collaboration.go +++ b/pkg/microservice/aslan/core/release_plan/service/collaboration.go @@ -56,6 +56,27 @@ const ( releasePlanCollabRedisRetryWait = 3 * time.Second ) +const ( + releasePlanCollabSectionMetadata = "metadata" + releasePlanCollabSectionMetadataName = "metadata:name" + releasePlanCollabSectionMetadataManager = "metadata:manager" + releasePlanCollabSectionMetadataTimeRange = "metadata:time_range" + releasePlanCollabSectionMetadataScheduleExecute = "metadata:schedule_execute_time" + releasePlanCollabSectionMetadataDescription = "metadata:description" + releasePlanCollabSectionMetadataJiraSprint = "metadata:jira_sprint_association" + releasePlanCollabSectionApproval = "approval" +) + +var releasePlanCollabMetadataSectionNames = map[string]string{ + releasePlanCollabSectionMetadata: "基础信息", + releasePlanCollabSectionMetadataName: "名称", + releasePlanCollabSectionMetadataManager: "发布负责人", + releasePlanCollabSectionMetadataTimeRange: "发布窗口日期", + releasePlanCollabSectionMetadataScheduleExecute: "定时执行", + releasePlanCollabSectionMetadataDescription: "需求关联", + releasePlanCollabSectionMetadataJiraSprint: "关联冲刺", +} + var upgrader = websocket.Upgrader{ ReadBufferSize: 1024, WriteBufferSize: 1024, @@ -196,6 +217,67 @@ func checkReleasePlanCollaborationOrigin(r *http.Request) bool { return true } +func normalizeReleasePlanCollaborationSection(sectionKey, sectionType, sectionName string) (string, string, string) { + sectionKey = strings.TrimSpace(sectionKey) + sectionType = strings.TrimSpace(sectionType) + sectionName = strings.TrimSpace(sectionName) + + switch { + case sectionType == "metadata" || sectionKey == releasePlanCollabSectionMetadata || strings.HasPrefix(sectionKey, releasePlanCollabSectionMetadata+":"): + normalizedKey, normalizedName := normalizeReleasePlanMetadataCollaborationSection(sectionKey, sectionName) + return normalizedKey, "metadata", normalizedName + case sectionType == "approval" || sectionKey == releasePlanCollabSectionApproval: + if sectionKey == "" { + sectionKey = releasePlanCollabSectionApproval + } + if sectionName == "" { + sectionName = "审批配置" + } + return sectionKey, "approval", sectionName + case sectionType == "job": + if sectionName == "" { + sectionName = "发布内容" + } + return sectionKey, "job", sectionName + default: + return sectionKey, sectionType, sectionName + } +} + +func normalizeReleasePlanMetadataCollaborationSection(sectionKey, sectionName string) (string, string) { + if sectionKey != releasePlanCollabSectionMetadata { + if normalizedName, exists := releasePlanCollabMetadataSectionNames[sectionKey]; exists { + return sectionKey, normalizedName + } + } + + switch strings.TrimSpace(sectionName) { + case "", "基础信息": + return releasePlanCollabSectionMetadata, releasePlanCollabMetadataSectionNames[releasePlanCollabSectionMetadata] + case "名称", "发布计划名称": + return releasePlanCollabSectionMetadataName, releasePlanCollabMetadataSectionNames[releasePlanCollabSectionMetadataName] + case "负责人", "发布负责人": + return releasePlanCollabSectionMetadataManager, releasePlanCollabMetadataSectionNames[releasePlanCollabSectionMetadataManager] + case "发布窗口日期", "发布窗口", "发布时间窗口": + return releasePlanCollabSectionMetadataTimeRange, releasePlanCollabMetadataSectionNames[releasePlanCollabSectionMetadataTimeRange] + case "定时执行": + return releasePlanCollabSectionMetadataScheduleExecute, releasePlanCollabMetadataSectionNames[releasePlanCollabSectionMetadataScheduleExecute] + case "需求关联": + return releasePlanCollabSectionMetadataDescription, releasePlanCollabMetadataSectionNames[releasePlanCollabSectionMetadataDescription] + case "关联冲刺", "Jira Sprint", "Jira Sprint 关联": + return releasePlanCollabSectionMetadataJiraSprint, releasePlanCollabMetadataSectionNames[releasePlanCollabSectionMetadataJiraSprint] + } + + if normalizedName, exists := releasePlanCollabMetadataSectionNames[sectionKey]; exists { + return sectionKey, normalizedName + } + + if sectionKey == "" { + sectionKey = releasePlanCollabSectionMetadata + } + return sectionKey, sectionName +} + func releasePlanRequestHost(r *http.Request) string { if r == nil { return "" @@ -484,6 +566,7 @@ func decodeReleasePlanEditingSessions(planID string, values []interface{}) []*Re if session.PlanID != planID { continue } + session.SectionKey, session.SectionType, session.SectionName = normalizeReleasePlanCollaborationSection(session.SectionKey, session.SectionType, session.SectionName) resp = append(resp, session) } return resp @@ -568,6 +651,7 @@ func getReleasePlanEditingSession(planID, sessionID string) (*ReleasePlanEditing if session.PlanID != planID { return nil, errors.New("session does not belong to current plan") } + session.SectionKey, session.SectionType, session.SectionName = normalizeReleasePlanCollaborationSection(session.SectionKey, session.SectionType, session.SectionName) return session, nil } @@ -626,7 +710,8 @@ func openReleasePlanCollaborationWS(gCtx *gin.Context, ctx *handler.Context, pla switch msg.Type { case "join", "focus_section", "heartbeat": - if !authorizeReleasePlanEditing(ctx, msg.SectionType) { + sectionKey, sectionType, sectionName := normalizeReleasePlanCollaborationSection(msg.SectionKey, msg.SectionType, msg.SectionName) + if !authorizeReleasePlanEditing(ctx, sectionType) { queueCollaborationClientMessage(client, &releasePlanCollabWSOutbound{Type: "error", Error: "permission denied"}) continue } @@ -652,9 +737,9 @@ func openReleasePlanCollaborationWS(gCtx *gin.Context, ctx *handler.Context, pla UserName: ctx.UserName, Account: ctx.Account, IdentityType: ctx.IdentityType, - SectionKey: msg.SectionKey, - SectionType: msg.SectionType, - SectionName: msg.SectionName, + SectionKey: sectionKey, + SectionType: sectionType, + SectionName: sectionName, BaseVersion: msg.BaseVersion, EditingStartedAt: time.Now().Unix(), } @@ -663,7 +748,7 @@ func openReleasePlanCollaborationWS(gCtx *gin.Context, ctx *handler.Context, pla if session.BaseVersion == 0 { session.BaseVersion = existingSession.BaseVersion } - if existingSession.SectionKey != "" && existingSession.SectionKey != msg.SectionKey { + if existingSession.SectionKey != "" && existingSession.SectionKey != sectionKey { session.EditingStartedAt = time.Now().Unix() session.BaseVersion = 0 } diff --git a/pkg/microservice/aslan/core/release_plan/service/diff.go b/pkg/microservice/aslan/core/release_plan/service/diff.go index 7f88230e8f..75e69dbcd7 100644 --- a/pkg/microservice/aslan/core/release_plan/service/diff.go +++ b/pkg/microservice/aslan/core/release_plan/service/diff.go @@ -343,8 +343,43 @@ func extractReleasePlanSectionSnapshot(snapshot interface{}, sectionKey string) } switch { - case sectionKey == releasePlanVersionSectionMetadata: - return planSnapshot["metadata"] + case isReleasePlanVersionMetadataSection(sectionKey): + metadata, ok := planSnapshot["metadata"].(map[string]interface{}) + if !ok { + return nil + } + switch sectionKey { + case releasePlanVersionSectionMetadata: + return metadata + case releasePlanCollabSectionMetadataName: + return map[string]interface{}{ + "name": metadata["name"], + } + case releasePlanCollabSectionMetadataManager: + return map[string]interface{}{ + "manager": metadata["manager"], + "manager_id": metadata["manager_id"], + } + case releasePlanCollabSectionMetadataTimeRange: + return map[string]interface{}{ + "start_time": metadata["start_time"], + "end_time": metadata["end_time"], + } + case releasePlanCollabSectionMetadataScheduleExecute: + return map[string]interface{}{ + "schedule_execute_time": metadata["schedule_execute_time"], + } + case releasePlanCollabSectionMetadataDescription: + return map[string]interface{}{ + "description": metadata["description"], + } + case releasePlanCollabSectionMetadataJiraSprint: + return map[string]interface{}{ + "jira_sprint_association": metadata["jira_sprint_association"], + } + default: + return metadata + } case sectionKey == releasePlanVersionSectionApproval: return planSnapshot["approval"] case sectionKey == releasePlanVersionSectionJobsOrder: diff --git a/pkg/microservice/aslan/core/release_plan/service/section_snapshot.go b/pkg/microservice/aslan/core/release_plan/service/section_snapshot.go index 099779ca3f..d561d89346 100644 --- a/pkg/microservice/aslan/core/release_plan/service/section_snapshot.go +++ b/pkg/microservice/aslan/core/release_plan/service/section_snapshot.go @@ -20,11 +20,18 @@ const ( releasePlanVersionSectionJobPrefix = "job:" ) +func isReleasePlanVersionMetadataSection(sectionKey string) bool { + return sectionKey == releasePlanVersionSectionMetadata || strings.HasPrefix(sectionKey, releasePlanVersionSectionMetadata+":") +} + func releasePlanVersionSectionName(sectionKey, fallbackName string) string { switch { case sectionKey == releasePlanVersionSectionPlan: return "发布计划" - case sectionKey == releasePlanVersionSectionMetadata: + case isReleasePlanVersionMetadataSection(sectionKey): + if name, exists := releasePlanCollabMetadataSectionNames[sectionKey]; exists { + return name + } return "基础信息" case sectionKey == releasePlanVersionSectionApproval: return "审批配置" @@ -42,7 +49,7 @@ func releasePlanVersionSectionName(sectionKey, fallbackName string) string { func releasePlanVersionSectionGroupType(sectionKey string) string { switch { - case sectionKey == releasePlanVersionSectionMetadata: + case isReleasePlanVersionMetadataSection(sectionKey): return "metadata" case sectionKey == releasePlanVersionSectionApproval: return "approval" @@ -78,8 +85,18 @@ func releasePlanVersionSectionKeyByVerb(planBefore, planAfter *models.ReleasePla } switch args.Verb { - case VerbUpdateName, VerbUpdateDesc, VerbUpdateTimeRange, VerbUpdateScheduleExecuteTime, VerbUpdateManager, VerbUpdateJiraSprint: - return releasePlanVersionSectionMetadata, "基础信息", nil + case VerbUpdateName: + return releasePlanCollabSectionMetadataName, releasePlanVersionSectionName(releasePlanCollabSectionMetadataName, ""), nil + case VerbUpdateDesc: + return releasePlanCollabSectionMetadataDescription, releasePlanVersionSectionName(releasePlanCollabSectionMetadataDescription, ""), nil + case VerbUpdateTimeRange: + return releasePlanCollabSectionMetadataTimeRange, releasePlanVersionSectionName(releasePlanCollabSectionMetadataTimeRange, ""), nil + case VerbUpdateScheduleExecuteTime: + return releasePlanCollabSectionMetadataScheduleExecute, releasePlanVersionSectionName(releasePlanCollabSectionMetadataScheduleExecute, ""), nil + case VerbUpdateManager: + return releasePlanCollabSectionMetadataManager, releasePlanVersionSectionName(releasePlanCollabSectionMetadataManager, ""), nil + case VerbUpdateJiraSprint: + return releasePlanCollabSectionMetadataJiraSprint, releasePlanVersionSectionName(releasePlanCollabSectionMetadataJiraSprint, ""), nil case VerbUpdateApproval, VerbDeleteApproval: return releasePlanVersionSectionApproval, "审批配置", nil case VerbReorderReleaseJob: @@ -160,8 +177,8 @@ func buildReleasePlanVersionSnapshot(plan *models.ReleasePlan, sectionKey string switch { case sectionKey == releasePlanVersionSectionPlan: return buildReleasePlanInputSnapshot(plan) - case sectionKey == releasePlanVersionSectionMetadata: - return buildReleasePlanMetadataSnapshot(plan), nil + case isReleasePlanVersionMetadataSection(sectionKey): + return buildReleasePlanMetadataSectionSnapshot(plan, sectionKey), nil case sectionKey == releasePlanVersionSectionApproval: return buildReleasePlanApprovalSnapshot(plan.Approval) case sectionKey == releasePlanVersionSectionJobsOrder: @@ -216,6 +233,46 @@ func buildReleasePlanMetadataSnapshot(plan *models.ReleasePlan) map[string]inter } } +func buildReleasePlanMetadataSectionSnapshot(plan *models.ReleasePlan, sectionKey string) map[string]interface{} { + metadata := buildReleasePlanMetadataSnapshot(plan) + if metadata == nil { + return nil + } + + switch sectionKey { + case releasePlanVersionSectionMetadata: + return metadata + case releasePlanCollabSectionMetadataName: + return map[string]interface{}{ + "name": metadata["name"], + } + case releasePlanCollabSectionMetadataManager: + return map[string]interface{}{ + "manager": metadata["manager"], + "manager_id": metadata["manager_id"], + } + case releasePlanCollabSectionMetadataTimeRange: + return map[string]interface{}{ + "start_time": metadata["start_time"], + "end_time": metadata["end_time"], + } + case releasePlanCollabSectionMetadataScheduleExecute: + return map[string]interface{}{ + "schedule_execute_time": metadata["schedule_execute_time"], + } + case releasePlanCollabSectionMetadataDescription: + return map[string]interface{}{ + "description": metadata["description"], + } + case releasePlanCollabSectionMetadataJiraSprint: + return map[string]interface{}{ + "jira_sprint_association": metadata["jira_sprint_association"], + } + default: + return metadata + } +} + func buildReleasePlanApprovalSnapshot(approval *models.Approval) (interface{}, error) { if approval == nil { return nil, nil From 94a3045c077e9cbd703a04023fedd979ac428907 Mon Sep 17 00:00:00 2001 From: huanghongbo-hhb Date: Fri, 12 Jun 2026 11:27:51 +0800 Subject: [PATCH 27/33] fix: skip empty release plan snapshot changes Signed-off-by: huanghongbo-hhb --- .../core/release_plan/service/release_plan.go | 166 +++++++++++++++++- 1 file changed, 164 insertions(+), 2 deletions(-) diff --git a/pkg/microservice/aslan/core/release_plan/service/release_plan.go b/pkg/microservice/aslan/core/release_plan/service/release_plan.go index c7580088ab..ee7eff98a3 100644 --- a/pkg/microservice/aslan/core/release_plan/service/release_plan.go +++ b/pkg/microservice/aslan/core/release_plan/service/release_plan.go @@ -22,7 +22,6 @@ import ( "encoding/hex" "encoding/json" "fmt" - "reflect" "strconv" "strings" "time" @@ -375,7 +374,170 @@ func resolveReleasePlanLogBaseSnapshot(baseSnapshot interface{}, originalPlan *m } func hasReleasePlanSnapshotChanges(beforeSnapshot, afterSnapshot interface{}) bool { - return !reflect.DeepEqual(beforeSnapshot, afterSnapshot) + return !releasePlanSnapshotValuesEqual(beforeSnapshot, afterSnapshot) +} + +func releasePlanSnapshotValuesEqual(left, right interface{}) bool { + switch leftValue := left.(type) { + case map[string]interface{}: + rightValue, ok := right.(map[string]interface{}) + if !ok { + return isEmptyReleasePlanSnapshotValue(left) && isEmptyReleasePlanSnapshotValue(right) + } + return releasePlanSnapshotMapsEqual(leftValue, rightValue) + case []interface{}: + rightValue, ok := right.([]interface{}) + if !ok { + return isEmptyReleasePlanSnapshotValue(left) && isEmptyReleasePlanSnapshotValue(right) + } + return releasePlanSnapshotListsEqual(leftValue, rightValue) + default: + switch right.(type) { + case map[string]interface{}, []interface{}: + return isEmptyReleasePlanSnapshotValue(left) && isEmptyReleasePlanSnapshotValue(right) + } + } + + if isEmptyReleasePlanSnapshotScalarValue(left) && isEmptyReleasePlanSnapshotScalarValue(right) { + return true + } + leftNumber, leftIsNumber := releasePlanSnapshotNumber(left) + rightNumber, rightIsNumber := releasePlanSnapshotNumber(right) + if leftIsNumber || rightIsNumber { + return leftIsNumber && rightIsNumber && leftNumber == rightNumber + } + + return left == right +} + +func releasePlanSnapshotMapsEqual(left, right map[string]interface{}) bool { + for key, leftValue := range left { + rightValue, exists := right[key] + if !exists { + if !isEmptyReleasePlanSnapshotValue(leftValue) { + return false + } + continue + } + if !releasePlanSnapshotValuesEqual(leftValue, rightValue) { + return false + } + } + + for key, rightValue := range right { + if _, exists := left[key]; exists { + continue + } + if !isEmptyReleasePlanSnapshotValue(rightValue) { + return false + } + } + + return true +} + +func releasePlanSnapshotListsEqual(left, right []interface{}) bool { + leftIdx, rightIdx := 0, 0 + for { + for leftIdx < len(left) && isEmptyReleasePlanSnapshotValue(left[leftIdx]) { + leftIdx++ + } + for rightIdx < len(right) && isEmptyReleasePlanSnapshotValue(right[rightIdx]) { + rightIdx++ + } + if leftIdx == len(left) || rightIdx == len(right) { + break + } + if !releasePlanSnapshotValuesEqual(left[leftIdx], right[rightIdx]) { + return false + } + leftIdx++ + rightIdx++ + } + + for leftIdx < len(left) { + if !isEmptyReleasePlanSnapshotValue(left[leftIdx]) { + return false + } + leftIdx++ + } + for rightIdx < len(right) { + if !isEmptyReleasePlanSnapshotValue(right[rightIdx]) { + return false + } + rightIdx++ + } + + return true +} + +func isEmptyReleasePlanSnapshotValue(value interface{}) bool { + switch typedValue := value.(type) { + case map[string]interface{}: + for _, item := range typedValue { + if !isEmptyReleasePlanSnapshotValue(item) { + return false + } + } + return true + case []interface{}: + for _, item := range typedValue { + if !isEmptyReleasePlanSnapshotValue(item) { + return false + } + } + return true + default: + return isEmptyReleasePlanSnapshotScalarValue(value) + } +} + +func isEmptyReleasePlanSnapshotScalarValue(value interface{}) bool { + switch typedValue := value.(type) { + case nil: + return true + case string: + return typedValue == "" + case bool: + return !typedValue + default: + number, ok := releasePlanSnapshotNumber(value) + return ok && number == 0 + } +} + +func releasePlanSnapshotNumber(value interface{}) (float64, bool) { + switch typedValue := value.(type) { + case int: + return float64(typedValue), true + case int8: + return float64(typedValue), true + case int16: + return float64(typedValue), true + case int32: + return float64(typedValue), true + case int64: + return float64(typedValue), true + case uint: + return float64(typedValue), true + case uint8: + return float64(typedValue), true + case uint16: + return float64(typedValue), true + case uint32: + return float64(typedValue), true + case uint64: + return float64(typedValue), true + case float32: + return float64(typedValue), true + case float64: + return typedValue, true + case json.Number: + number, err := typedValue.Float64() + return number, err == nil + default: + return 0, false + } } func DeleteReleasePlan(c *gin.Context, username, id string) error { From 18e11d028b6469759d5243b91c8bdc2f0cf76e35 Mon Sep 17 00:00:00 2001 From: huanghongbo-hhb Date: Fri, 12 Jun 2026 11:58:50 +0800 Subject: [PATCH 28/33] fix: ignore derived approval users in release plan logs Signed-off-by: huanghongbo-hhb --- .../aslan/core/release_plan/service/section_snapshot.go | 1 + 1 file changed, 1 insertion(+) diff --git a/pkg/microservice/aslan/core/release_plan/service/section_snapshot.go b/pkg/microservice/aslan/core/release_plan/service/section_snapshot.go index d561d89346..f07474e1d2 100644 --- a/pkg/microservice/aslan/core/release_plan/service/section_snapshot.go +++ b/pkg/microservice/aslan/core/release_plan/service/section_snapshot.go @@ -319,6 +319,7 @@ func shouldDropReleasePlanApprovalInputField(key string) bool { "operation_time": {}, "comment": {}, "approval_node_details": {}, + "flat_approve_users": {}, } _, exists := dropKeys[key] return exists From b9523d87264832a5f70e71252e5a1a993921fbe2 Mon Sep 17 00:00:00 2001 From: huanghongbo-hhb Date: Fri, 12 Jun 2026 13:14:21 +0800 Subject: [PATCH 29/33] fix: align restart workload fetch results Signed-off-by: huanghongbo-hhb --- .../service/workflowcontroller/jobcontroller/job_restart.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/microservice/aslan/core/common/service/workflowcontroller/jobcontroller/job_restart.go b/pkg/microservice/aslan/core/common/service/workflowcontroller/jobcontroller/job_restart.go index 4c48c850a2..ac70b38173 100644 --- a/pkg/microservice/aslan/core/common/service/workflowcontroller/jobcontroller/job_restart.go +++ b/pkg/microservice/aslan/core/common/service/workflowcontroller/jobcontroller/job_restart.go @@ -242,7 +242,7 @@ func (c *RestartJobCtl) restartHelmService(ctx context.Context, env *commonmodel } func restartWorkloadResources(ctx context.Context, clusterID string, resources []*kube.WorkloadResource, env *commonmodels.Product, kubeClient crClient.Client, clientSet *kubernetes.Clientset) (replaceResources []commonmodels.Resource, relatedPodLabels []map[string]string, err error) { - deployments, statefulSets, _, _, _, err := kube.FetchSelectedWorkloads(env.Namespace, resources, kubeClient, clientSet) + deployments, _, statefulSets, _, _, _, err := kube.FetchSelectedWorkloads(env.Namespace, resources, kubeClient, clientSet) if err != nil { return nil, nil, err } From b793e7075efa41af026bab891295b0549daf57c4 Mon Sep 17 00:00:00 2001 From: huanghongbo-hhb Date: Fri, 12 Jun 2026 15:30:25 +0800 Subject: [PATCH 30/33] fix: refine release plan metadata diff display Signed-off-by: huanghongbo-hhb --- .../aslan/core/release_plan/service/diff.go | 197 +++++++++++++++++- 1 file changed, 193 insertions(+), 4 deletions(-) diff --git a/pkg/microservice/aslan/core/release_plan/service/diff.go b/pkg/microservice/aslan/core/release_plan/service/diff.go index 75e69dbcd7..65c1792de6 100644 --- a/pkg/microservice/aslan/core/release_plan/service/diff.go +++ b/pkg/microservice/aslan/core/release_plan/service/diff.go @@ -40,6 +40,7 @@ const ( releasePlanDiffChangeTypeOrder = "order_changed" releasePlanDiffDisplayApprovalSpec = "approval_spec" releasePlanDiffDisplayWorkflowSpec = "workflow_spec" + releasePlanDiffDisplayMetadataSpec = "metadata_spec" ) type ReleasePlanVersionDiffResponse struct { @@ -77,6 +78,13 @@ type ReleasePlanVersionDiffChange struct { Masked bool `json:"masked,omitempty"` } +type ReleasePlanVersionMetadataDiffItem struct { + Key string `json:"key"` + Label string `json:"label"` + Value interface{} `json:"value"` + ValueType string `json:"value_type"` +} + type releasePlanRawDiffEntry struct { Path string ChangeType string @@ -90,6 +98,12 @@ type releasePlanDiffContext struct { GroupType string } +type releasePlanMetadataDiffField struct { + Key string + Label string + ValueType string +} + type releasePlanArrayDiffStrategy int const ( @@ -153,6 +167,16 @@ var releasePlanFieldLabels = map[string]string{ "workwx_approval": "企业微信审批", } +var releasePlanMetadataDiffFields = []releasePlanMetadataDiffField{ + {Key: "name", Label: "名称", ValueType: "text"}, + {Key: "manager", Label: "负责人", ValueType: "text"}, + {Key: "start_time", Label: "开始时间", ValueType: "time"}, + {Key: "end_time", Label: "结束时间", ValueType: "time"}, + {Key: "schedule_execute_time", Label: "定时执行时间", ValueType: "time"}, + {Key: "description", Label: "需求关联", ValueType: "rich_text"}, + {Key: "jira_sprint_association", Label: "关联冲刺", ValueType: "jira_sprint_association"}, +} + func GetReleasePlanVersionDiff(planID string, version int64) (*ReleasePlanVersionDiffResponse, error) { current, err := mongodb.NewReleasePlanVersionColl().Get(planID, version) if err != nil { @@ -179,7 +203,7 @@ func GetReleasePlanVersionDiff(planID string, version int64) (*ReleasePlanVersio } groupKey, groupName, groupType := releasePlanVersionDiffGroup(current.SectionKey, current.SectionName) - displayMode, beforeSpec, afterSpec := releasePlanVersionDiffDisplaySpec(groupType, fromData, toData) + displayMode, beforeSpec, afterSpec := releasePlanVersionDiffDisplaySpec(current.SectionKey, groupType, current.Verb, fromData, toData) rawEntries := make([]*releasePlanRawDiffEntry, 0) if shouldBuildReleasePlanPathDiff(displayMode) { @@ -190,7 +214,7 @@ func GetReleasePlanVersionDiff(planID string, version int64) (*ReleasePlanVersio groupMap := map[string]*ReleasePlanVersionDiffGroup{} groupOrder := make([]string, 0) - if displayMode != "" && !reflect.DeepEqual(beforeSpec, afterSpec) { + if shouldAddReleasePlanVersionDiffDisplaySpec(displayMode, beforeSpec, afterSpec) { group := ensureReleasePlanVersionDiffGroup(groupMap, &groupOrder, groupKey, groupName, groupType) group.DisplayMode = displayMode group.BeforeSpec = sanitizeReleasePlanValueForDisplay(beforeSpec) @@ -255,19 +279,38 @@ func ensureReleasePlanVersionDiffGroup(groupMap map[string]*ReleasePlanVersionDi return group } -func releasePlanVersionDiffDisplaySpec(groupType string, fromData, toData interface{}) (string, interface{}, interface{}) { +func shouldAddReleasePlanVersionDiffDisplaySpec(displayMode string, beforeSpec, afterSpec interface{}) bool { + if displayMode == "" { + return false + } + if displayMode == releasePlanDiffDisplayMetadataSpec { + beforeItems, _ := beforeSpec.([]*ReleasePlanVersionMetadataDiffItem) + afterItems, _ := afterSpec.([]*ReleasePlanVersionMetadataDiffItem) + return len(beforeItems) > 0 || len(afterItems) > 0 + } + return !reflect.DeepEqual(beforeSpec, afterSpec) +} + +func releasePlanVersionDiffDisplaySpec(sectionKey, groupType, verb string, fromData, toData interface{}) (string, interface{}, interface{}) { switch groupType { case "approval": if fromData == nil && toData == nil { return "", nil, nil } return releasePlanDiffDisplayApprovalSpec, fromData, toData + case "metadata": + beforeSpec, afterSpec := releasePlanVersionDiffMetadataSpec(fromData, toData) + return releasePlanDiffDisplayMetadataSpec, beforeSpec, afterSpec case "job": if !isReleasePlanWorkflowJobSnapshot(fromData) && !isReleasePlanWorkflowJobSnapshot(toData) { return "", nil, nil } return releasePlanDiffDisplayWorkflowSpec, releasePlanVersionDiffJobSpec(fromData), releasePlanVersionDiffJobSpec(toData) default: + if sectionKey == releasePlanVersionSectionPlan && verb == VerbCreate { + beforeSpec, afterSpec := releasePlanVersionDiffMetadataSpec(fromData, toData) + return releasePlanDiffDisplayMetadataSpec, beforeSpec, afterSpec + } return "", nil, nil } } @@ -300,6 +343,152 @@ func releasePlanVersionDiffJobSpec(value interface{}) interface{} { return job["spec"] } +func releasePlanVersionDiffMetadataSpec(fromData, toData interface{}) ([]*ReleasePlanVersionMetadataDiffItem, []*ReleasePlanVersionMetadataDiffItem) { + fromMetadata := releasePlanVersionDiffMetadataSnapshot(fromData) + toMetadata := releasePlanVersionDiffMetadataSnapshot(toData) + + beforeSpec := make([]*ReleasePlanVersionMetadataDiffItem, 0, len(releasePlanMetadataDiffFields)) + afterSpec := make([]*ReleasePlanVersionMetadataDiffItem, 0, len(releasePlanMetadataDiffFields)) + for _, field := range releasePlanMetadataDiffFields { + beforeValue := normalizeReleasePlanMetadataDiffValue(field.Key, fromMetadata[field.Key]) + afterValue := normalizeReleasePlanMetadataDiffValue(field.Key, toMetadata[field.Key]) + if reflect.DeepEqual(beforeValue, afterValue) { + continue + } + beforeSpec = append(beforeSpec, newReleasePlanVersionMetadataDiffItem(field, beforeValue)) + afterSpec = append(afterSpec, newReleasePlanVersionMetadataDiffItem(field, afterValue)) + } + return beforeSpec, afterSpec +} + +func releasePlanVersionDiffMetadataSnapshot(value interface{}) map[string]interface{} { + snapshot, ok := getMapField(value) + if !ok { + return map[string]interface{}{} + } + if metadata, ok := getMapField(snapshot["metadata"]); ok { + return metadata + } + return snapshot +} + +func newReleasePlanVersionMetadataDiffItem(field releasePlanMetadataDiffField, value interface{}) *ReleasePlanVersionMetadataDiffItem { + return &ReleasePlanVersionMetadataDiffItem{ + Key: field.Key, + Label: field.Label, + Value: value, + ValueType: field.ValueType, + } +} + +func normalizeReleasePlanMetadataDiffValue(key string, value interface{}) interface{} { + if value == nil { + return nil + } + + switch key { + case "description": + return normalizeReleasePlanMetadataRichTextValue(value) + case "start_time", "end_time", "schedule_execute_time": + return normalizeReleasePlanMetadataTimeValue(value) + case "jira_sprint_association": + return normalizeReleasePlanMetadataJiraSprintAssociationValue(value) + } + + if str, ok := value.(string); ok && strings.TrimSpace(str) == "" { + return nil + } + return value +} + +func normalizeReleasePlanMetadataRichTextValue(value interface{}) interface{} { + str, ok := value.(string) + if !ok { + return value + } + if isEmptyReleasePlanRichText(str) { + return nil + } + return str +} + +func isEmptyReleasePlanRichText(value string) bool { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return true + } + + // Compact is only used for empty-rich-text detection; returned content stays unchanged. + compact := strings.ToLower(strings.Join(strings.Fields(trimmed), "")) + compact = strings.ReplaceAll(compact, " ", "") + compact = strings.ReplaceAll(compact, "\u00a0", "") + switch compact { + case "", "

", "


", "


", "
", "
": + return true + default: + return false + } +} + +func normalizeReleasePlanMetadataJiraSprintAssociationValue(value interface{}) interface{} { + association, ok := getMapField(value) + if !ok { + return value + } + if isEmptyReleasePlanJiraSprintAssociation(association) { + return nil + } + return value +} + +func isEmptyReleasePlanJiraSprintAssociation(value map[string]interface{}) bool { + if value == nil { + return true + } + if jiraID, ok := value["jira_id"].(string); ok && strings.TrimSpace(jiraID) != "" { + return false + } + if sprints, ok := value["sprints"].([]interface{}); ok && len(sprints) > 0 { + return false + } + return true +} + +func normalizeReleasePlanMetadataTimeValue(value interface{}) interface{} { + switch typed := value.(type) { + case float64: + if typed == 0 { + return nil + } + intValue := int64(typed) + if float64(intValue) == typed { + return intValue + } + return typed + case int: + if typed == 0 { + return nil + } + return int64(typed) + case int64: + if typed == 0 { + return nil + } + return typed + case json.Number: + intValue, err := typed.Int64() + if err == nil { + if intValue == 0 { + return nil + } + return intValue + } + return value + default: + return value + } +} + func releasePlanVersionBaseSnapshotAsGenericValue(version *models.ReleasePlanVersion) (interface{}, bool, error) { if version == nil || version.BaseSnapshot == nil { return nil, false, nil @@ -1110,7 +1299,7 @@ func buildReleasePlanDiffLabel(path string) string { segments := strings.Split(path, ".") labels := make([]string, 0, len(segments)) for _, segment := range segments { - if segment == "spec" || segment == "workflow" { + if segment == "metadata" || segment == "spec" || segment == "workflow" { continue } label := segment From 8b62bf1877d0be80d9067d56d9ee512cddce3a23 Mon Sep 17 00:00:00 2001 From: huanghongbo-hhb Date: Fri, 12 Jun 2026 16:50:05 +0800 Subject: [PATCH 31/33] fix: stabilize release plan workflow diff snapshots Signed-off-by: huanghongbo-hhb --- .../aslan/core/release_plan/service/diff.go | 27 ++- .../release_plan/service/section_snapshot.go | 194 ++++++++++++++++-- 2 files changed, 205 insertions(+), 16 deletions(-) diff --git a/pkg/microservice/aslan/core/release_plan/service/diff.go b/pkg/microservice/aslan/core/release_plan/service/diff.go index 65c1792de6..2ce3357a18 100644 --- a/pkg/microservice/aslan/core/release_plan/service/diff.go +++ b/pkg/microservice/aslan/core/release_plan/service/diff.go @@ -305,7 +305,7 @@ func releasePlanVersionDiffDisplaySpec(sectionKey, groupType, verb string, fromD if !isReleasePlanWorkflowJobSnapshot(fromData) && !isReleasePlanWorkflowJobSnapshot(toData) { return "", nil, nil } - return releasePlanDiffDisplayWorkflowSpec, releasePlanVersionDiffJobSpec(fromData), releasePlanVersionDiffJobSpec(toData) + return releasePlanDiffDisplayWorkflowSpec, releasePlanVersionDiffWorkflowSpec(fromData), releasePlanVersionDiffWorkflowSpec(toData) default: if sectionKey == releasePlanVersionSectionPlan && verb == VerbCreate { beforeSpec, afterSpec := releasePlanVersionDiffMetadataSpec(fromData, toData) @@ -343,6 +343,31 @@ func releasePlanVersionDiffJobSpec(value interface{}) interface{} { return job["spec"] } +func releasePlanVersionDiffWorkflowSpec(value interface{}) interface{} { + job, ok := getMapField(value) + if !ok { + return nil + } + + resp := make(map[string]interface{}, 3) + for _, key := range []string{"name", "manager"} { + if item, exists := job[key]; exists { + resp[key] = item + } + } + if spec := releasePlanVersionDiffJobSpec(value); spec != nil { + if workflowSpec, ok := getMapField(spec); ok { + for key, item := range workflowSpec { + resp[key] = item + } + } + } + if len(resp) == 0 { + return nil + } + return resp +} + func releasePlanVersionDiffMetadataSpec(fromData, toData interface{}) ([]*ReleasePlanVersionMetadataDiffItem, []*ReleasePlanVersionMetadataDiffItem) { fromMetadata := releasePlanVersionDiffMetadataSnapshot(fromData) toMetadata := releasePlanVersionDiffMetadataSnapshot(toData) diff --git a/pkg/microservice/aslan/core/release_plan/service/section_snapshot.go b/pkg/microservice/aslan/core/release_plan/service/section_snapshot.go index f07474e1d2..1ffc451a03 100644 --- a/pkg/microservice/aslan/core/release_plan/service/section_snapshot.go +++ b/pkg/microservice/aslan/core/release_plan/service/section_snapshot.go @@ -2,6 +2,7 @@ package service import ( "encoding/json" + "sort" "strings" "github.com/pkg/errors" @@ -457,23 +458,23 @@ func buildReleasePlanWorkflowInputSnapshot(workflow interface{}) (interface{}, e } } if params, exists := workflowMap["params"]; exists { - resp["params"] = filterReleasePlanWorkflowInputValue(params) + resp["params"] = filterReleasePlanWorkflowInputValueAtPath("params", params) } if customField, exists := workflowMap["custom_field"]; exists { - if filtered := filterReleasePlanWorkflowInputValue(customField); filtered != nil { + if filtered := filterReleasePlanWorkflowInputValueAtPath("custom_field", customField); filtered != nil { resp["custom_field"] = filtered } } if stages, exists := workflowMap["stages"]; exists { - resp["stages"] = buildReleasePlanWorkflowStagesInputSnapshot(stages) + resp["stages"] = buildReleasePlanWorkflowStagesInputSnapshot("stages", stages) } if jobs, exists := workflowMap["jobs"]; exists { - resp["jobs"] = buildReleasePlanWorkflowJobsInputSnapshot(jobs) + resp["jobs"] = buildReleasePlanWorkflowJobsInputSnapshot("jobs", jobs) } return sanitizeReleasePlanValue(resp), nil } -func buildReleasePlanWorkflowStagesInputSnapshot(value interface{}) interface{} { +func buildReleasePlanWorkflowStagesInputSnapshot(path string, value interface{}) interface{} { stages, ok := value.([]interface{}) if !ok { return nil @@ -488,11 +489,11 @@ func buildReleasePlanWorkflowStagesInputSnapshot(value interface{}) interface{} stageResp := make(map[string]interface{}) for _, key := range []string{"name", "parallel", "approval", "manual_exec"} { if value, exists := stageMap[key]; exists { - stageResp[key] = filterReleasePlanWorkflowInputValue(value) + stageResp[key] = filterReleasePlanWorkflowInputValueAtPath(joinReleasePlanWorkflowInputPath(path, key), value) } } if jobs, exists := stageMap["jobs"]; exists { - stageResp["jobs"] = buildReleasePlanWorkflowJobsInputSnapshot(jobs) + stageResp["jobs"] = buildReleasePlanWorkflowJobsInputSnapshot(joinReleasePlanWorkflowInputPath(path, "jobs"), jobs) } if len(stageResp) > 0 { resp = append(resp, stageResp) @@ -501,7 +502,7 @@ func buildReleasePlanWorkflowStagesInputSnapshot(value interface{}) interface{} return resp } -func buildReleasePlanWorkflowJobsInputSnapshot(value interface{}) interface{} { +func buildReleasePlanWorkflowJobsInputSnapshot(path string, value interface{}) interface{} { jobs, ok := value.([]interface{}) if !ok { return nil @@ -516,14 +517,14 @@ func buildReleasePlanWorkflowJobsInputSnapshot(value interface{}) interface{} { jobResp := make(map[string]interface{}) for _, key := range []string{"name", "type", "skipped", "run_policy", "error_policy", "execute_policy"} { if item, exists := jobMap[key]; exists { - jobResp[key] = filterReleasePlanWorkflowInputValue(item) + jobResp[key] = filterReleasePlanWorkflowInputValueAtPath(joinReleasePlanWorkflowInputPath(path, key), item) } } if serviceModules, exists := jobMap["service_modules"]; exists { - jobResp["service_modules"] = filterReleasePlanWorkflowInputValue(serviceModules) + jobResp["service_modules"] = filterReleasePlanWorkflowInputValueAtPath(joinReleasePlanWorkflowInputPath(path, "service_modules"), serviceModules) } if spec, exists := jobMap["spec"]; exists { - jobResp["spec"] = filterReleasePlanWorkflowInputValue(spec) + jobResp["spec"] = filterReleasePlanWorkflowInputValueAtPath(joinReleasePlanWorkflowInputPath(path, "spec"), spec) } if len(jobResp) > 0 { resp = append(resp, jobResp) @@ -533,12 +534,16 @@ func buildReleasePlanWorkflowJobsInputSnapshot(value interface{}) interface{} { } func filterReleasePlanWorkflowInputValue(value interface{}) interface{} { + return filterReleasePlanWorkflowInputValueAtPath("", value) +} + +func filterReleasePlanWorkflowInputValueAtPath(path string, value interface{}) interface{} { switch typedValue := value.(type) { case map[string]interface{}: resp := make(map[string]interface{}, len(typedValue)) for key, item := range typedValue { if key == "plugin" { - filteredPlugin := filterReleasePlanPluginTemplateInputValue(item) + filteredPlugin := filterReleasePlanPluginTemplateInputValueAtPath(joinReleasePlanWorkflowInputPath(path, key), item) if filteredPlugin != nil { resp[key] = filteredPlugin } @@ -547,14 +552,18 @@ func filterReleasePlanWorkflowInputValue(value interface{}) interface{} { if shouldDropReleasePlanWorkflowInputField(key) { continue } - resp[key] = filterReleasePlanWorkflowInputValue(item) + if key == "variable_yaml" && hasReleasePlanWorkflowStructuredVariables(typedValue) { + continue + } + resp[key] = filterReleasePlanWorkflowInputValueAtPath(joinReleasePlanWorkflowInputPath(path, key), item) } return resp case []interface{}: resp := make([]interface{}, 0, len(typedValue)) for _, item := range typedValue { - resp = append(resp, filterReleasePlanWorkflowInputValue(item)) + resp = append(resp, filterReleasePlanWorkflowInputValueAtPath(path, item)) } + stabilizeReleasePlanWorkflowInputArray(path, resp) return resp default: return value @@ -562,6 +571,10 @@ func filterReleasePlanWorkflowInputValue(value interface{}) interface{} { } func filterReleasePlanPluginTemplateInputValue(value interface{}) interface{} { + return filterReleasePlanPluginTemplateInputValueAtPath("plugin", value) +} + +func filterReleasePlanPluginTemplateInputValueAtPath(path string, value interface{}) interface{} { plugin, ok := value.(map[string]interface{}) if !ok { return nil @@ -573,8 +586,159 @@ func filterReleasePlanPluginTemplateInputValue(value interface{}) interface{} { } return map[string]interface{}{ - "inputs": filterReleasePlanWorkflowInputValue(inputs), + "inputs": filterReleasePlanWorkflowInputValueAtPath(joinReleasePlanWorkflowInputPath(path, "inputs"), inputs), + } +} + +func hasReleasePlanWorkflowStructuredVariables(value map[string]interface{}) bool { + if value == nil { + return false + } + variableKVs, ok := value["variable_kvs"].([]interface{}) + return ok && len(variableKVs) > 0 +} + +func stabilizeReleasePlanWorkflowInputArray(path string, items []interface{}) { + if len(items) < 2 { + return + } + + // Only normalize collection-like arrays here. Execution-order arrays such as + // workflow stages/jobs are intentionally left untouched for display fidelity. + switch { + case path == "env_options" || strings.HasSuffix(path, ".env_options"): + sortReleasePlanWorkflowInputArray(items, releasePlanWorkflowInputArrayKeyByEnv) + case path == "services" || strings.HasSuffix(path, ".services"): + sortReleasePlanWorkflowInputArray(items, releasePlanWorkflowInputArrayKeyByService) + case path == "service_modules" || strings.HasSuffix(path, ".service_modules"): + sortReleasePlanWorkflowInputArray(items, releasePlanWorkflowInputArrayKeyByServiceModule) + case path == "modules" || strings.HasSuffix(path, ".modules"): + sortReleasePlanWorkflowInputArray(items, releasePlanWorkflowInputArrayKeyByModule) + case path == "variable_kvs" || strings.HasSuffix(path, ".variable_kvs"): + sortReleasePlanWorkflowInputArray(items, releasePlanWorkflowInputArrayKeyByVariable) + case path == "target_services" || strings.HasSuffix(path, ".target_services"): + sortReleasePlanWorkflowInputStringArray(items) + case path == "service_and_builds" || strings.HasSuffix(path, ".service_and_builds"), + path == "default_service_and_builds" || strings.HasSuffix(path, ".default_service_and_builds"), + path == "service_and_builds_options" || strings.HasSuffix(path, ".service_and_builds_options"), + path == "service_and_images" || strings.HasSuffix(path, ".service_and_images"): + sortReleasePlanWorkflowInputArray(items, releasePlanWorkflowInputArrayKeyByServiceBuild) + case path == "service_and_scannings" || strings.HasSuffix(path, ".service_and_scannings"), + path == "service_scanning_options" || strings.HasSuffix(path, ".service_scanning_options"), + path == "scannings" || strings.HasSuffix(path, ".scannings"), + path == "scanning_options" || strings.HasSuffix(path, ".scanning_options"): + sortReleasePlanWorkflowInputArray(items, releasePlanWorkflowInputArrayKeyByScanning) + case path == "nacos_filtered_data" || strings.HasSuffix(path, ".nacos_filtered_data"): + sortReleasePlanWorkflowInputArray(items, releasePlanWorkflowInputArrayKeyByNacosData) + } +} + +func sortReleasePlanWorkflowInputArray(items []interface{}, buildKey func(interface{}) (string, bool)) { + type sortableItem struct { + item interface{} + key string + } + + sortableItems := make([]sortableItem, 0, len(items)) + for _, item := range items { + primaryKey, ok := buildKey(item) + if !ok { + return + } + sortKey := primaryKey + if hash, err := hashReleasePlanSubtree(item); err == nil { + sortKey = primaryKey + "|" + hash + } + sortableItems = append(sortableItems, sortableItem{item: item, key: sortKey}) + } + + sort.SliceStable(sortableItems, func(i, j int) bool { + return sortableItems[i].key < sortableItems[j].key + }) + for i := range sortableItems { + items[i] = sortableItems[i].item + } +} + +func sortReleasePlanWorkflowInputStringArray(items []interface{}) { + for _, item := range items { + if _, ok := item.(string); !ok { + return + } + } + sort.SliceStable(items, func(i, j int) bool { + return items[i].(string) < items[j].(string) + }) +} + +func releasePlanWorkflowInputArrayKeyByEnv(item interface{}) (string, bool) { + return releasePlanWorkflowInputArrayKeyByFields(item, "env", "env_name", "env_alias") +} + +func releasePlanWorkflowInputArrayKeyByService(item interface{}) (string, bool) { + return releasePlanWorkflowInputArrayKeyByFields(item, "service_name", "service_module", "image_name") +} + +func releasePlanWorkflowInputArrayKeyByServiceModule(item interface{}) (string, bool) { + return releasePlanWorkflowInputArrayKeyByFields(item, "service_name", "service_module") +} + +func releasePlanWorkflowInputArrayKeyByModule(item interface{}) (string, bool) { + return releasePlanWorkflowInputArrayKeyByFields(item, "service_module", "image_name", "image") +} + +func releasePlanWorkflowInputArrayKeyByVariable(item interface{}) (string, bool) { + return releasePlanWorkflowInputArrayKeyByFields(item, "key") +} + +func releasePlanWorkflowInputArrayKeyByServiceBuild(item interface{}) (string, bool) { + return releasePlanWorkflowInputArrayKeyByFields(item, "service_name", "service_module", "image_name", "build_name", "name") +} + +func releasePlanWorkflowInputArrayKeyByScanning(item interface{}) (string, bool) { + return releasePlanWorkflowInputArrayKeyByFields(item, "service_name", "service_module", "name", "project_name") +} + +func releasePlanWorkflowInputArrayKeyByNacosData(item interface{}) (string, bool) { + return releasePlanWorkflowInputArrayKeyByFields(item, "namespace_id", "group", "data_id") +} + +func releasePlanWorkflowInputArrayKeyByFields(item interface{}, keys ...string) (string, bool) { + value, ok := getMapField(item) + if !ok { + return "", false + } + + parts := make([]string, 0, len(keys)) + for _, key := range keys { + part, exists := getStringField(value, key) + if exists { + parts = append(parts, part) + continue + } + if number, exists := getNumberFieldString(value, key); exists { + parts = append(parts, number) + continue + } + parts = append(parts, "") + } + + // Keep empty placeholders so keys from heterogeneous-but-compatible items + // still compare in a consistent field order. + if strings.TrimSpace(strings.Join(parts, "")) == "" { + return "", false + } + return strings.Join(parts, "|"), true +} + +func joinReleasePlanWorkflowInputPath(base, key string) string { + if key == "" { + return base + } + if base == "" { + return key } + return base + "." + key } func shouldDropReleasePlanWorkflowInputField(key string) bool { From bc3d2ab6d5d40821f4155a6e6698e234d26d033d Mon Sep 17 00:00:00 2001 From: huanghongbo-hhb Date: Fri, 12 Jun 2026 17:25:53 +0800 Subject: [PATCH 32/33] fix: compat legacy release plan workflow lookup fields Signed-off-by: huanghongbo-hhb --- .../release_plan/service/section_snapshot.go | 41 ++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) diff --git a/pkg/microservice/aslan/core/release_plan/service/section_snapshot.go b/pkg/microservice/aslan/core/release_plan/service/section_snapshot.go index 1ffc451a03..5ad65bc659 100644 --- a/pkg/microservice/aslan/core/release_plan/service/section_snapshot.go +++ b/pkg/microservice/aslan/core/release_plan/service/section_snapshot.go @@ -406,7 +406,11 @@ func enrichReleasePlanWorkflowWithLatest(spec interface{}) (_ interface{}, ok bo }() workflowSpec := new(models.WorkflowReleaseJobSpec) - if err := models.IToi(spec, workflowSpec); err != nil || workflowSpec.Workflow == nil { + if err := models.IToi(spec, workflowSpec); err != nil { + return nil, false + } + applyReleasePlanWorkflowLatestLookupCompat(spec, workflowSpec) + if workflowSpec.Workflow == nil || workflowSpec.Workflow.Name == "" { return nil, false } @@ -418,6 +422,41 @@ func enrichReleasePlanWorkflowWithLatest(spec interface{}) (_ interface{}, ok bo return workflowController.WorkflowV4, true } +func applyReleasePlanWorkflowLatestLookupCompat(spec interface{}, workflowSpec *models.WorkflowReleaseJobSpec) { + if workflowSpec == nil { + return + } + + specMap, ok := getMapField(spec) + if !ok { + return + } + + workflowName := firstReleasePlanWorkflowLookupString(specMap, "workflowName", "workflow_name") + projectName := firstReleasePlanWorkflowLookupString(specMap, "projectName", "project_name") + if workflowSpec.Workflow == nil { + if workflowName == "" && projectName == "" { + return + } + workflowSpec.Workflow = &models.WorkflowV4{} + } + if workflowSpec.Workflow.Name == "" { + workflowSpec.Workflow.Name = workflowName + } + if workflowSpec.Workflow.Project == "" { + workflowSpec.Workflow.Project = projectName + } +} + +func firstReleasePlanWorkflowLookupString(input map[string]interface{}, keys ...string) string { + for _, key := range keys { + if value, ok := getStringField(input, key); ok { + return value + } + } + return "" +} + func warnReleasePlanWorkflowRecover(recovered interface{}) { defer func() { _ = recover() From 4f80d047f21d23f79d16fff80b6a58d015761ec4 Mon Sep 17 00:00:00 2001 From: huanghongbo-hhb Date: Fri, 12 Jun 2026 18:07:06 +0800 Subject: [PATCH 33/33] fix: normalize legacy release plan workflow specs Signed-off-by: huanghongbo-hhb --- .../core/release_plan/service/execute.go | 8 +++- .../core/release_plan/service/release_plan.go | 4 ++ .../release_plan/service/section_snapshot.go | 38 +++++++++++++++++++ 3 files changed, 48 insertions(+), 2 deletions(-) diff --git a/pkg/microservice/aslan/core/release_plan/service/execute.go b/pkg/microservice/aslan/core/release_plan/service/execute.go index b0ffe843bc..5be2b987b4 100644 --- a/pkg/microservice/aslan/core/release_plan/service/execute.go +++ b/pkg/microservice/aslan/core/release_plan/service/execute.go @@ -135,9 +135,13 @@ func (e *WorkflowReleaseJobExecutor) Execute(plan *models.ReleasePlan) error { if spec.Workflow == nil { return errors.Errorf("workflow is nil") } - - err := jobManagerAuth(plan.Name, plan.ManagerID, job, e.Ctx.UserName, e.Ctx.UserID, e.Ctx.AuthResources) + normalizedWorkflow, err := normalizeReleasePlanWorkflowForController(spec.Workflow) if err != nil { + return errors.Wrap(err, "normalize workflow") + } + spec.Workflow = normalizedWorkflow + + if err := jobManagerAuth(plan.Name, plan.ManagerID, job, e.Ctx.UserName, e.Ctx.UserID, e.Ctx.AuthResources); err != nil { return err } diff --git a/pkg/microservice/aslan/core/release_plan/service/release_plan.go b/pkg/microservice/aslan/core/release_plan/service/release_plan.go index ee7eff98a3..d3cea7bfa0 100644 --- a/pkg/microservice/aslan/core/release_plan/service/release_plan.go +++ b/pkg/microservice/aslan/core/release_plan/service/release_plan.go @@ -720,6 +720,10 @@ func GetReleasePlanJobDetail(planID, jobID string) (*commonmodels.ReleaseJob, er if spec.Workflow == nil { return nil, fmt.Errorf("workflow is nil") } + spec.Workflow, err = normalizeReleasePlanWorkflowForController(spec.Workflow) + if err != nil { + return nil, fmt.Errorf("invalid workflow for job: %s. normalize error: %s", releasePlanJob.Name, err) + } workflowController := controller.CreateWorkflowController(spec.Workflow) if err := workflowController.UpdateWithLatestWorkflow(nil); err != nil { diff --git a/pkg/microservice/aslan/core/release_plan/service/section_snapshot.go b/pkg/microservice/aslan/core/release_plan/service/section_snapshot.go index 5ad65bc659..a81738dfa8 100644 --- a/pkg/microservice/aslan/core/release_plan/service/section_snapshot.go +++ b/pkg/microservice/aslan/core/release_plan/service/section_snapshot.go @@ -2,10 +2,15 @@ package service import ( "encoding/json" + "reflect" "sort" "strings" "github.com/pkg/errors" + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/bson/bsoncodec" + "go.mongodb.org/mongo-driver/bson/bsonoptions" + "go.mongodb.org/mongo-driver/bson/bsontype" "github.com/koderover/zadig/v2/pkg/microservice/aslan/config" "github.com/koderover/zadig/v2/pkg/microservice/aslan/core/common/repository/models" @@ -21,6 +26,12 @@ const ( releasePlanVersionSectionJobPrefix = "job:" ) +var releasePlanWorkflowControllerBSONRegistry = func() *bsoncodec.Registry { + nilSliceCodec := bsoncodec.NewSliceCodec(bsonoptions.SliceCodec().SetEncodeNilAsEmpty(true)) + tM := reflect.TypeOf(bson.M{}) + return bson.NewRegistryBuilder().RegisterTypeMapEntry(bsontype.EmbeddedDocument, tM).RegisterDefaultEncoder(reflect.Slice, nilSliceCodec).Build() +}() + func isReleasePlanVersionMetadataSection(sectionKey string) bool { return sectionKey == releasePlanVersionSectionMetadata || strings.HasPrefix(sectionKey, releasePlanVersionSectionMetadata+":") } @@ -413,6 +424,11 @@ func enrichReleasePlanWorkflowWithLatest(spec interface{}) (_ interface{}, ok bo if workflowSpec.Workflow == nil || workflowSpec.Workflow.Name == "" { return nil, false } + normalizedWorkflow, err := normalizeReleasePlanWorkflowForController(workflowSpec.Workflow) + if err != nil { + return nil, false + } + workflowSpec.Workflow = normalizedWorkflow workflowController := controller.CreateWorkflowController(workflowSpec.Workflow) if err := workflowController.UpdateWithLatestWorkflow(nil); err != nil || workflowController.WorkflowV4 == nil { @@ -457,6 +473,28 @@ func firstReleasePlanWorkflowLookupString(input map[string]interface{}, keys ... return "" } +func normalizeReleasePlanWorkflowForController(workflow *models.WorkflowV4) (*models.WorkflowV4, error) { + if workflow == nil { + return nil, nil + } + + raw, err := bson.Marshal(workflow) + if err != nil { + return nil, err + } + + generic := bson.M{} + if err := bson.UnmarshalWithRegistry(releasePlanWorkflowControllerBSONRegistry, raw, &generic); err != nil { + return nil, err + } + + resp := new(models.WorkflowV4) + if err := models.IToi(generic, resp); err != nil { + return nil, err + } + return resp, nil +} + func warnReleasePlanWorkflowRecover(recovered interface{}) { defer func() { _ = recover()