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/common/repository/models/release_plan.go b/pkg/microservice/aslan/core/common/repository/models/release_plan.go index 7cdc25ed7f..e3e37e4b58 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,43 @@ 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"` + 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 { return "release_plan_log" } + +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"` + 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"` + 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..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 @@ -48,7 +48,14 @@ 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}}, + }, + } + + _, err := c.Indexes().CreateMany(ctx, mod, mongotool.CreateIndexOptions(ctx)) + return err } func (c *ReleasePlanLogColl) Create(args *models.ReleasePlanLog) error { @@ -76,7 +83,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 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..f8e6edb4b6 --- /dev/null +++ b/pkg/microservice/aslan/core/common/repository/mongodb/release_plan_version.go @@ -0,0 +1,117 @@ +/* + * 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 { + return c.CreateWithCtx(context.Background(), args) +} + +func (c *ReleasePlanVersionColl) CreateWithCtx(ctx context.Context, args *models.ReleasePlanVersion) error { + if args == nil { + return errors.New("nil ReleasePlanVersion") + } + + _, err := c.InsertOne(ctx, args) + return err +} + +func (c *ReleasePlanVersionColl) Delete(planID string, version int64) error { + return c.DeleteWithCtx(context.Background(), planID, version) +} + +func (c *ReleasePlanVersionColl) DeleteWithCtx(ctx context.Context, planID string, version int64) error { + _, err := c.DeleteOne(ctx, bson.M{ + "plan_id": planID, + "version": version, + }) + 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 +} + +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/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..10f8edd91c 100644 --- a/pkg/microservice/aslan/core/release_plan/handler/router.go +++ b/pkg/microservice/aslan/core/release_plan/handler/router.go @@ -28,7 +28,10 @@ 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.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 new file mode 100644 index 0000000000..a02f30e5d3 --- /dev/null +++ b/pkg/microservice/aslan/core/release_plan/service/collaboration.go @@ -0,0 +1,850 @@ +/* + * 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" + "net/http" + "net/url" + "sort" + "strings" + "sync" + "time" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "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 = 90 * time.Second + releasePlanCollabWSWriteWait = 10 * time.Second + releasePlanCollabWSPongWait = 60 * time.Second + releasePlanCollabWSPingPeriod = releasePlanCollabWSPongWait * 9 / 10 + releasePlanCollabWSReadLimit = 16 * 1024 + 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, + CheckOrigin: checkReleasePlanCollaborationOrigin, +} + +var ( + storeReleasePlanEditingSession = func(session *ReleasePlanEditingSession, payload string) error { + redisCache := cache.NewRedisCache(configbase.RedisCommonCacheTokenDB()) + if err := redisCache.Write(releasePlanCollabSessionKey(session.SessionID), payload, releasePlanCollabSessionTTL); err != nil { + return err + } + return redisCache.AddElementsToSet(releasePlanCollabPlanSetKey(session.PlanID), []string{session.SessionID}, releasePlanCollabSessionTTL) + } + publishReleasePlanCollaboration = broadcastReleasePlanCollaboration +) + +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"` + 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"` +} + +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 + id string + conn *websocket.Conn + send chan []byte + + sessionMu sync.Mutex + sessionIDs map[string]struct{} +} + +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(watchReleasePlanCollaborationBroadcasts) + }) +} + +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 { + return releasePlanCollabSessionKeyPrefix + sessionID +} + +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 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 "" + } + 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) error { + if planID == "" { + return nil + } + 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 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 + } + 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 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 { + 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) + } + displaySession := *session + displaySession.ConnectionID = "" + group.Editors = append(group.Editors, &displaySession) + } + + 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 + } + if len(sessionIDs) == 0 { + return []*ReleasePlanEditingSession{}, nil + } + + keys := make([]string, 0, len(sessionIDs)) + for _, sessionID := range sessionIDs { + 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 + } + return resp[i].SectionKey < resp[j].SectionKey + }) + + 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 + } + session.SectionKey, session.SectionType, session.SectionName = normalizeReleasePlanCollaborationSection(session.SectionKey, session.SectionType, session.SectionName) + resp = append(resp, session) + } + return resp +} + +func persistReleasePlanEditingSession(session *ReleasePlanEditingSession) error { + return saveReleasePlanEditingSession(session, true) +} + +func refreshReleasePlanEditingSession(session *ReleasePlanEditingSession) error { + return saveReleasePlanEditingSession(session, false) +} + +func saveReleasePlanEditingSession(session *ReleasePlanEditingSession, broadcast bool) 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 + } + + if err := storeReleasePlanEditingSession(session, string(payload)); err != nil { + return err + } + if !broadcast { + return nil + } + return publishReleasePlanCollaboration(session.PlanID) +} + +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 + } + return broadcastReleasePlanCollaboration(planID) +} + +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") + } + session.SectionKey, session.SectionType, session.SectionName = normalizeReleasePlanCollaborationSection(session.SectionKey, session.SectionType, session.SectionName) + 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) +} + +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) + } + var closeWSOnce sync.Once + closeWS := func() { + _ = ws.Close() + } + defer closeWSOnce.Do(closeWS) + setupReleasePlanCollaborationWSDeadline(ws) + + ensureReleasePlanCollaborationLoop() + + client := &collaborationClient{ + 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{}) + 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": + sectionKey, sectionType, sectionName := normalizeReleasePlanCollaborationSection(msg.SectionKey, msg.SectionType, msg.SectionName) + if !authorizeReleasePlanEditing(ctx, 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) + 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, + ConnectionID: client.id, + UserID: ctx.UserID, + UserName: ctx.UserName, + Account: ctx.Account, + IdentityType: ctx.IdentityType, + SectionKey: sectionKey, + SectionType: sectionType, + SectionName: sectionName, + BaseVersion: msg.BaseVersion, + EditingStartedAt: time.Now().Unix(), + } + if existingSession != nil { + session.EditingStartedAt = existingSession.EditingStartedAt + if session.BaseVersion == 0 { + session.BaseVersion = existingSession.BaseVersion + } + if existingSession.SectionKey != "" && existingSession.SectionKey != sectionKey { + session.EditingStartedAt = time.Now().Unix() + 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 + } + rememberCollaborationClientSession(client, msg.SessionID) + snapshot, err := GetReleasePlanCollaborationSnapshot(planID) + if err == nil { + queueCollaborationClientMessage(client, &releasePlanCollabWSOutbound{Type: "snapshot", Snapshot: snapshot}) + } + case "heartbeat": + session, err := getReleasePlanEditingSession(planID, msg.SessionID) + if err != nil { + queueCollaborationClientMessage(client, &releasePlanCollabWSOutbound{Type: "error", Error: err.Error()}) + continue + } + if !authorizeReleasePlanEditing(ctx, session.SectionType) { + queueCollaborationClientMessage(client, &releasePlanCollabWSOutbound{Type: "error", Error: "permission denied"}) + continue + } + if !canManageReleasePlanEditingSession(session, ctx.UserID, ctx.Resources != nil && ctx.Resources.IsSystemAdmin) { + queueCollaborationClientMessage(client, &releasePlanCollabWSOutbound{Type: "error", Error: "permission denied"}) + continue + } + if err := refreshReleasePlanEditingSession(session); err != nil { + queueCollaborationClientMessage(client, &releasePlanCollabWSOutbound{Type: "error", Error: err.Error()}) + continue + } + 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()}) + continue + } + forgetCollaborationClientSession(client, msg.SessionID) + } + } + }) + + util.Go(func() { + ticker := time.NewTicker(releasePlanCollabWSPingPeriod) + defer ticker.Stop() + defer closeWSOnce.Do(closeWS) + for { + select { + case payload := <-client.send: + 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: + 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..7473607339 --- /dev/null +++ b/pkg/microservice/aslan/core/release_plan/service/diff.go @@ -0,0 +1,1555 @@ +/* + * 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" + "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/models" + "github.com/koderover/zadig/v2/pkg/microservice/aslan/core/common/repository/mongodb" +) + +const ( + releasePlanHashPruneMinMapKeys = 4 + releasePlanHashPruneMinArrayItems = 4 + releasePlanDiffMaxDepth = 50 + releasePlanDiffChangeTypeOrder = "order_changed" + releasePlanDiffDisplayApprovalSpec = "approval_spec" + releasePlanDiffDisplayWorkflowSpec = "workflow_spec" + releasePlanDiffDisplayMetadataSpec = "metadata_spec" +) + +type ReleasePlanVersionDiffResponse struct { + PlanID string `json:"plan_id"` + Version int64 `json:"version"` + PreviousVersion int64 `json:"previous_version"` + Groups []*ReleasePlanVersionDiffGroup `json:"groups"` +} + +type ReleasePlanVersionDiffGroup struct { + 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 { + Key string `json:"key,omitempty"` + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` +} + +type ReleasePlanVersionDiffChange struct { + ChangeType string `json:"change_type,omitempty"` + Path string `json:"path,omitempty"` + 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 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 + Before interface{} + After interface{} + BeforeOrder []*ReleasePlanVersionDiffOrderItem + AfterOrder []*ReleasePlanVersionDiffOrderItem +} + +type releasePlanDiffContext struct { + GroupType string +} + +type releasePlanMetadataDiffField struct { + Key string + Label string + ValueType string +} + +type releasePlanArrayDiffStrategy int + +const ( + releasePlanArrayDiffStrategyIndex releasePlanArrayDiffStrategy = iota + releasePlanArrayDiffStrategyKeyedUnordered + releasePlanArrayDiffStrategyKeyedOrdered +) + +type releasePlanArrayKeyBuilder func(item interface{}) (string, bool) + +type releasePlanArrayDiffRule struct { + GroupType string + Path string + Strategy releasePlanArrayDiffStrategy + BuildKey releasePlanArrayKeyBuilder +} + +func newReleasePlanExactArrayRule(groupType, path string, strategy releasePlanArrayDiffStrategy, buildKey releasePlanArrayKeyBuilder) releasePlanArrayDiffRule { + return releasePlanArrayDiffRule{ + GroupType: groupType, + Path: path, + Strategy: strategy, + BuildKey: buildKey, + } +} + +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": "备注", + "order": "顺序", + "approve_users": "审批人", + "approval_nodes": "审批节点", + "native_approval": "原生审批", + "lark_approval": "飞书审批", + "dingtalk_approval": "钉钉审批", + "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 { + 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 !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) + } + + toData, err := toReleasePlanGenericValue(current.Snapshot) + if err != nil { + return nil, errors.Wrap(err, "convert current snapshot") + } + + return &ReleasePlanVersionDiffResponse{ + PlanID: planID, + Version: version, + PreviousVersion: current.PreviousVersion, + Groups: buildReleasePlanVersionDiffGroups(current, fromData, toData), + }, nil +} + +func buildReleasePlanVersionDiffGroups(current *models.ReleasePlanVersion, fromData, toData interface{}) []*ReleasePlanVersionDiffGroup { + if current.SectionKey == releasePlanVersionSectionPlan && current.Verb == VerbCreate { + return buildReleasePlanCreateVersionDiffGroups(current, fromData, toData) + } + + groupKey, groupName, groupType := releasePlanVersionDiffGroup(current.SectionKey, current.SectionName) + groups := buildReleasePlanSectionVersionDiffGroups(groupKey, groupName, groupType, current.SectionKey, current.Verb, fromData, toData) + sort.Slice(groups, func(i, j int) bool { + return groups[i].GroupKey < groups[j].GroupKey + }) + return groups +} + +func buildReleasePlanCreateVersionDiffGroups(current *models.ReleasePlanVersion, fromData, toData interface{}) []*ReleasePlanVersionDiffGroup { + fromPlan := releasePlanVersionDiffPlanSnapshot(fromData) + toPlan := releasePlanVersionDiffPlanSnapshot(toData) + + groupMap := map[string]*ReleasePlanVersionDiffGroup{} + groupOrder := make([]string, 0) + appendReleasePlanVersionDiffGroup(groupMap, &groupOrder, releasePlanVersionSectionPlan, releasePlanVersionSectionName(releasePlanVersionSectionPlan, current.SectionName), releasePlanVersionSectionGroupType(releasePlanVersionSectionPlan), releasePlanVersionSectionPlan, current.Verb, fromPlan, toPlan) + if hasReleasePlanSnapshotChanges(fromPlan["approval"], toPlan["approval"]) { + appendReleasePlanVersionDiffGroup(groupMap, &groupOrder, releasePlanVersionSectionApproval, releasePlanVersionSectionName(releasePlanVersionSectionApproval, ""), releasePlanVersionSectionGroupType(releasePlanVersionSectionApproval), releasePlanVersionSectionApproval, current.Verb, fromPlan["approval"], toPlan["approval"]) + } + appendReleasePlanCreateJobVersionDiffGroups(groupMap, &groupOrder, current.Verb, fromPlan["jobs"], toPlan["jobs"]) + + return releasePlanVersionDiffGroupsFromMap(groupMap, groupOrder) +} + +func buildReleasePlanSectionVersionDiffGroups(groupKey, groupName, groupType, sectionKey, verb string, fromData, toData interface{}) []*ReleasePlanVersionDiffGroup { + groupMap := map[string]*ReleasePlanVersionDiffGroup{} + groupOrder := make([]string, 0) + appendReleasePlanVersionDiffGroup(groupMap, &groupOrder, groupKey, groupName, groupType, sectionKey, verb, fromData, toData) + return releasePlanVersionDiffGroupsFromMap(groupMap, groupOrder) +} + +func appendReleasePlanVersionDiffGroup(groupMap map[string]*ReleasePlanVersionDiffGroup, groupOrder *[]string, groupKey, groupName, groupType, sectionKey, verb string, fromData, toData interface{}) { + displayMode, beforeSpec, afterSpec := releasePlanVersionDiffDisplaySpec(sectionKey, groupType, verb, fromData, toData) + + rawEntries := make([]*releasePlanRawDiffEntry, 0) + 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) + } + + if shouldAddReleasePlanVersionDiffDisplaySpec(displayMode, 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 + } + group := ensureReleasePlanVersionDiffGroup(groupMap, groupOrder, groupKey, groupName, groupType) + + change := &ReleasePlanVersionDiffChange{ + 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 + } else { + change.Before = normalizeReleasePlanDiffValue(entry.Before) + change.After = normalizeReleasePlanDiffValue(entry.After) + } + group.Changes = append(group.Changes, change) + } +} + +func appendReleasePlanCreateJobVersionDiffGroups(groupMap map[string]*ReleasePlanVersionDiffGroup, groupOrder *[]string, verb string, fromData, toData interface{}) { + fromJobs, fromOrder := releasePlanVersionDiffJobsByID(fromData) + toJobs, toOrder := releasePlanVersionDiffJobsByID(toData) + for _, jobID := range mergeReleasePlanVersionDiffJobOrder(toOrder, fromOrder) { + sectionKey := releasePlanVersionSectionJobPrefix + jobID + groupName := releasePlanVersionDiffJobName(toJobs[jobID], fromJobs[jobID]) + appendReleasePlanVersionDiffGroup(groupMap, groupOrder, sectionKey, releasePlanVersionSectionName(sectionKey, groupName), releasePlanVersionSectionGroupType(sectionKey), sectionKey, verb, releasePlanVersionDiffCreateJobSnapshot(fromJobs[jobID]), releasePlanVersionDiffCreateJobSnapshot(toJobs[jobID])) + } +} + +func releasePlanVersionDiffCreateJobSnapshot(job map[string]interface{}) interface{} { + if job == nil { + return nil + } + if jobType, ok := getStringField(job, "type"); ok && jobType == string(config.JobText) { + return releasePlanVersionDiffTextJobSnapshot(job) + } + return job +} + +func releasePlanVersionDiffTextJobSnapshot(job map[string]interface{}) map[string]interface{} { + resp := make(map[string]interface{}, len(job)) + for key, value := range job { + if key == "spec" { + resp[key] = releasePlanVersionDiffTextJobSpecSnapshot(value) + continue + } + resp[key] = value + } + return resp +} + +func releasePlanVersionDiffTextJobSpecSnapshot(spec interface{}) interface{} { + specMap, ok := getMapField(spec) + if !ok { + return spec + } + + resp := make(map[string]interface{}, len(specMap)) + for key, value := range specMap { + if key == "content" { + resp[key] = normalizeReleasePlanRichTextComparableValue(value) + continue + } + resp[key] = value + } + return resp +} + +func releasePlanVersionDiffPlanSnapshot(value interface{}) map[string]interface{} { + snapshot, ok := getMapField(value) + if !ok { + return map[string]interface{}{} + } + return snapshot +} + +func releasePlanVersionDiffJobsByID(value interface{}) (map[string]map[string]interface{}, []string) { + jobs := map[string]map[string]interface{}{} + order := make([]string, 0) + items, ok := value.([]interface{}) + if !ok { + return jobs, order + } + for _, item := range items { + job, ok := getMapField(item) + if !ok { + continue + } + jobID, ok := getStringField(job, "id") + if !ok { + continue + } + if _, exists := jobs[jobID]; exists { + continue + } + jobs[jobID] = job + order = append(order, jobID) + } + return jobs, order +} + +func mergeReleasePlanVersionDiffJobOrder(primary, secondary []string) []string { + merged := make([]string, 0, len(primary)+len(secondary)) + seen := map[string]struct{}{} + for _, jobID := range primary { + if _, exists := seen[jobID]; exists { + continue + } + seen[jobID] = struct{}{} + merged = append(merged, jobID) + } + for _, jobID := range secondary { + if _, exists := seen[jobID]; exists { + continue + } + seen[jobID] = struct{}{} + merged = append(merged, jobID) + } + return merged +} + +func releasePlanVersionDiffJobName(values ...map[string]interface{}) string { + for _, value := range values { + name, ok := getStringField(value, "name") + if ok { + return name + } + } + return "" +} + +func releasePlanVersionDiffGroupsFromMap(groupMap map[string]*ReleasePlanVersionDiffGroup, groupOrder []string) []*ReleasePlanVersionDiffGroup { + 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 groups +} + +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 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, releasePlanVersionDiffWorkflowSpec(fromData), releasePlanVersionDiffWorkflowSpec(toData) + default: + if sectionKey == releasePlanVersionSectionPlan && verb == VerbCreate { + beforeSpec, afterSpec := releasePlanVersionDiffMetadataSpec(fromData, toData) + return releasePlanDiffDisplayMetadataSpec, beforeSpec, afterSpec + } + return "", nil, nil + } +} + +func shouldBuildReleasePlanPathDiff(displayMode string) bool { + return displayMode == "" || displayMode == releasePlanDiffDisplayApprovalSpec +} + +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 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) + + 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 "", "
", " 0
+ }
+ return true
+}
+
+func hasReleasePlanWorkflowSnapshotFields(value map[string]interface{}, keys ...string) bool {
+ for _, key := range keys {
+ if _, exists := value[key]; !exists {
+ return false
+ }
+ }
+ return true
+}
+
+func hasReleasePlanWorkflowSnapshotStringFields(value map[string]interface{}, keys ...string) bool {
+ for _, key := range keys {
+ fieldValue, ok := getStringField(value, key)
+ if !ok || fieldValue == "" {
+ return false
+ }
+ }
+ return true
+}
+
+func applyReleasePlanWorkflowLatestLookupCompat(spec interface{}, workflowSpec *models.WorkflowReleaseJobSpec) {
+ if workflowSpec == nil {
+ return
+ }
+
+ specMap, ok := getMapField(spec)
+ if !ok {
+ return
+ }
+
+ workflowName := firstReleasePlanWorkflowLookupString(specMap, releasePlanWorkflowLegacyNameKeys...)
+ projectName := firstReleasePlanWorkflowLookupString(specMap, releasePlanWorkflowLegacyProjectKeys...)
+ 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 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()
+ }()
+ log.Warnf("enrich release plan workflow panic: %v", recovered)
+}
+
+func buildReleasePlanWorkflowInputSnapshot(workflow interface{}) (interface{}, error) {
+ if workflow == nil {
+ return nil, nil
+ }
+
+ genericValue, err := toReleasePlanGenericValue(workflow)
+ if err != nil {
+ return nil, err
+ }
+ workflowMap, ok := getMapField(genericValue)
+ if !ok {
+ return nil, nil
+ }
+
+ resp := make(map[string]interface{})
+ for _, key := range releasePlanWorkflowSnapshotTopLevelFields {
+ if value, exists := workflowMap[key]; exists {
+ resp[key] = value
+ }
+ }
+ if params, exists := workflowMap["params"]; exists {
+ resp["params"] = filterReleasePlanWorkflowInputValueAtPath("params", params)
+ }
+ if customField, exists := workflowMap["custom_field"]; exists {
+ if filtered := filterReleasePlanWorkflowInputValueAtPath("custom_field", customField); filtered != nil {
+ resp["custom_field"] = filtered
+ }
+ }
+ if stages, exists := workflowMap["stages"]; exists {
+ resp["stages"] = buildReleasePlanWorkflowStagesInputSnapshot("stages", stages)
+ }
+ if jobs, exists := workflowMap["jobs"]; exists {
+ resp["jobs"] = buildReleasePlanWorkflowJobsInputSnapshot("jobs", jobs)
+ }
+ return sanitizeReleasePlanGenericValue("", resp), nil
+}
+
+func buildReleasePlanWorkflowStagesInputSnapshot(path string, value interface{}) interface{} {
+ stages, ok := value.([]interface{})
+ if !ok {
+ return nil
+ }
+
+ resp := make([]interface{}, 0, len(stages))
+ for _, stage := range stages {
+ stageMap, ok := getMapField(stage)
+ if !ok {
+ continue
+ }
+ stageResp := make(map[string]interface{})
+ for _, key := range releasePlanWorkflowSnapshotStageDisplayFields {
+ if value, exists := stageMap[key]; exists {
+ stageResp[key] = filterReleasePlanWorkflowInputValueAtPath(joinReleasePlanWorkflowInputPath(path, key), value)
+ }
+ }
+ if jobs, exists := stageMap["jobs"]; exists {
+ stageResp["jobs"] = buildReleasePlanWorkflowJobsInputSnapshot(joinReleasePlanWorkflowInputPath(path, "jobs"), jobs)
+ }
+ if len(stageResp) > 0 {
+ resp = append(resp, stageResp)
+ }
+ }
+ return resp
+}
+
+func buildReleasePlanWorkflowJobsInputSnapshot(path string, 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 releasePlanWorkflowSnapshotJobDisplayFields {
+ if item, exists := jobMap[key]; exists {
+ jobResp[key] = filterReleasePlanWorkflowInputValueAtPath(joinReleasePlanWorkflowInputPath(path, key), item)
+ }
+ }
+ if serviceModules, exists := jobMap["service_modules"]; exists {
+ jobResp["service_modules"] = filterReleasePlanWorkflowInputValueAtPath(joinReleasePlanWorkflowInputPath(path, "service_modules"), serviceModules)
+ }
+ if spec, exists := jobMap["spec"]; exists {
+ jobResp["spec"] = filterReleasePlanWorkflowInputValueAtPath(joinReleasePlanWorkflowInputPath(path, "spec"), spec)
+ }
+ if len(jobResp) > 0 {
+ resp = append(resp, jobResp)
+ }
+ }
+ return resp
+}
+
+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 := filterReleasePlanPluginTemplateInputValueAtPath(joinReleasePlanWorkflowInputPath(path, key), item)
+ if filteredPlugin != nil {
+ resp[key] = filteredPlugin
+ }
+ continue
+ }
+ if shouldDropReleasePlanWorkflowInputField(key) {
+ continue
+ }
+ 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, filterReleasePlanWorkflowInputValueAtPath(path, item))
+ }
+ stabilizeReleasePlanWorkflowInputArray(path, resp)
+ return resp
+ default:
+ return value
+ }
+}
+
+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
+ }
+
+ inputs, exists := plugin["inputs"]
+ if !exists {
+ return nil
+ }
+
+ return map[string]interface{}{
+ "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{}
+ primaryKey string
+ tieBreak string
+ }
+
+ sortableItems := make([]sortableItem, 0, len(items))
+ for _, item := range items {
+ primaryKey, ok := buildKey(item)
+ if !ok {
+ return
+ }
+ sortableItems = append(sortableItems, sortableItem{item: item, primaryKey: primaryKey})
+ }
+
+ sort.SliceStable(sortableItems, func(i, j int) bool {
+ return sortableItems[i].primaryKey < sortableItems[j].primaryKey
+ })
+ for start := 0; start < len(sortableItems); {
+ end := start + 1
+ for end < len(sortableItems) && sortableItems[end].primaryKey == sortableItems[start].primaryKey {
+ end++
+ }
+ if end-start > 1 {
+ for i := start; i < end; i++ {
+ hash, err := hashReleasePlanSubtree(sortableItems[i].item)
+ if err != nil {
+ // Keep the caller's original order intact if we cannot build a stable tie-break key.
+ // We return before copying sortableItems back into items, so no partial normalized state leaks out.
+ return
+ }
+ sortableItems[i].tieBreak = hash
+ }
+ sort.SliceStable(sortableItems[start:end], func(i, j int) bool {
+ return sortableItems[start+i].tieBreak < sortableItems[start+j].tieBreak
+ })
+ }
+ start = end
+ }
+ 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 {
+ if key == "" {
+ return false
+ }
+
+ dropKeys := map[string]struct{}{
+ "last_status": {},
+ "updated": {},
+ "executed_by": {},
+ "executed_time": {},
+ "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": {},
+ "properties": {},
+ "outputs": {},
+ }
+ if _, exists := dropKeys[key]; exists {
+ return true
+ }
+
+ return false
+}
+
+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/update.go b/pkg/microservice/aslan/core/release_plan/service/update.go
index 8939e7355d..b699733cd5 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"
@@ -49,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 = "更新"
@@ -70,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{
@@ -83,6 +106,7 @@ var VerbI18nMap = map[string]string{
VerbUpdate: "Update",
VerbDelete: "Delete",
VerbExecute: "Execute",
+ VerbRetry: "Retry",
VerbSkip: "Skip",
}
@@ -96,8 +120,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
@@ -149,10 +172,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 {
@@ -186,10 +208,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 {
@@ -221,15 +242,10 @@ func NewTimeRangeUpdater(args *UpdateReleasePlanArgs) (*TimeRangeUpdater, error)
return &updater, nil
}
-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))
+func (u *TimeRangeUpdater) Update(plan *models.ReleasePlan) error {
plan.StartTime = u.StartTime
plan.EndTime = u.EndTime
- return
+ return nil
}
func (u *TimeRangeUpdater) Lint() error {
@@ -261,11 +277,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 {
@@ -310,8 +325,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,
@@ -321,7 +335,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 {
@@ -362,22 +376,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")
+ return fmt.Errorf("job type cannot be changed")
}
- before, after = job, 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.
@@ -416,15 +429,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
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 {
@@ -458,13 +471,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
@@ -474,13 +481,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 {
@@ -514,13 +520,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 {
@@ -557,10 +562,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 {
@@ -654,12 +658,9 @@ func NewScheduleExecuteTimeUpdater(args *UpdateReleasePlanArgs) (*ScheduleExecut
return &updater, nil
}
-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)
+func (u *ScheduleExecuteTimeUpdater) Update(plan *models.ReleasePlan) error {
plan.ScheduleExecuteTime = u.ScheduleExecuteTime
- return
+ return nil
}
func (u *ScheduleExecuteTimeUpdater) Lint() error {
@@ -690,10 +691,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 {
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..13908b3e89
--- /dev/null
+++ b/pkg/microservice/aslan/core/release_plan/service/version.go
@@ -0,0 +1,211 @@
+/*
+ * 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"
+ "go.mongodb.org/mongo-driver/mongo"
+
+ "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"
+ mongotool "github.com/koderover/zadig/v2/pkg/tool/mongo"
+)
+
+var (
+ updateReleasePlanDocument = func(ctx context.Context, planID string, plan *models.ReleasePlan) error {
+ return mongodb.NewReleasePlanColl().UpdateByID(ctx, planID, plan)
+ }
+ createReleasePlanVersionDocument = func(ctx context.Context, version *models.ReleasePlanVersion) error {
+ return mongodb.NewReleasePlanVersionColl().CreateWithCtx(ctx, version)
+ }
+ deleteReleasePlanVersionDocument = func(ctx context.Context, planID string, version int64) error {
+ return mongodb.NewReleasePlanVersionColl().DeleteWithCtx(ctx, planID, version)
+ }
+)
+
+func createReleasePlanVersion(planID string, version int64, snapshot interface{}, operator, account, sectionKey, sectionName, verb string) error {
+ return createReleasePlanVersionWithBaseSnapshotCtx(context.Background(), planID, version, 0, nil, snapshot, operator, account, sectionKey, sectionName, verb)
+}
+
+func createReleasePlanVersionWithBaseSnapshot(planID string, version, previousVersion int64, baseSnapshot, snapshot interface{}, operator, account, sectionKey, sectionName, verb string) error {
+ return createReleasePlanVersionWithBaseSnapshotCtx(context.Background(), planID, version, previousVersion, baseSnapshot, snapshot, operator, account, sectionKey, sectionName, verb)
+}
+
+func createReleasePlanVersionWithBaseSnapshotCtx(ctx context.Context, planID string, version, previousVersion int64, baseSnapshot, snapshot interface{}, operator, account, sectionKey, sectionName, verb string) error {
+ return createReleasePlanVersionDocument(ctx, newReleasePlanVersionDocument(planID, version, previousVersion, baseSnapshot, snapshot, operator, account, sectionKey, sectionName, verb))
+}
+
+func newReleasePlanVersionDocument(planID string, version, previousVersion int64, baseSnapshot, snapshot interface{}, operator, account, sectionKey, sectionName, verb string) *models.ReleasePlanVersion {
+ return &models.ReleasePlanVersion{
+ PlanID: planID,
+ Version: version,
+ PreviousVersion: previousVersion,
+ Operator: operator,
+ Account: account,
+ SectionKey: sectionKey,
+ SectionName: sectionName,
+ SectionType: releasePlanVersionSectionGroupType(sectionKey),
+ Verb: verb,
+ BaseSnapshot: sanitizeReleasePlanValue(baseSnapshot),
+ Snapshot: sanitizeReleasePlanValue(snapshot),
+ CreatedAt: time.Now().Unix(),
+ }
+}
+
+func persistReleasePlanWithVersion(ctx context.Context, planID string, plan *models.ReleasePlan, versionDoc *models.ReleasePlanVersion) error {
+ if plan == nil {
+ return errors.New("nil release plan")
+ }
+ if versionDoc == nil {
+ return errors.New("nil release plan version")
+ }
+
+ if config.EnableTransaction() {
+ session, deferSession, err := mongotool.SessionWithTransaction(ctx)
+ if err != nil {
+ return errors.Wrap(err, "start release plan transaction")
+ }
+
+ var retErr error
+ defer func() {
+ deferSession(retErr)
+ }()
+
+ sessionCtx := mongotool.SessionContext(ctx, session)
+ if err := updateReleasePlanDocument(sessionCtx, planID, plan); err != nil {
+ retErr = errors.Wrap(err, "update plan")
+ return retErr
+ }
+ if err := createReleasePlanVersionDocument(sessionCtx, versionDoc); err != nil {
+ retErr = errors.Wrap(err, "create release plan version")
+ return retErr
+ }
+ if err := mongotool.CommitTransaction(session); err != nil {
+ retErr = errors.Wrap(err, "commit release plan transaction")
+ return retErr
+ }
+ return nil
+ }
+
+ if err := createReleasePlanVersionDocument(ctx, versionDoc); err != nil {
+ return errors.Wrap(err, "create release plan version")
+ }
+ if err := updateReleasePlanDocument(ctx, planID, plan); err != nil {
+ cleanupErr := deleteReleasePlanVersionDocument(ctx, planID, versionDoc.Version)
+ if cleanupErr != nil {
+ return errors.Wrapf(err, "update plan; cleanup release plan version error: %v", cleanupErr)
+ }
+ return errors.Wrap(err, "update plan")
+ }
+ return nil
+}
+
+func shouldBuildReleasePlanVersionBaseSnapshot(planID, sectionKey string, version int64, verb UpdateReleasePlanVerb) (bool, int64, error) {
+ switch verb {
+ case VerbDeleteReleaseJob, VerbDeleteApproval, VerbReorderReleaseJob:
+ return true, version - 1, nil
+ default:
+ 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
+ }
+}
+
+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
+}
+
+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 isIncompleteReleasePlanWorkflowDisplaySnapshot(previousSnapshot, currentSnapshot), nil
+}
+
+func isIncompleteReleasePlanWorkflowDisplaySnapshot(previousSnapshot, currentSnapshot interface{}) bool {
+ previousSpec, ok := getMapField(releasePlanVersionDiffJobSpec(previousSnapshot))
+ if !ok {
+ return false
+ }
+ currentSpec, ok := getMapField(releasePlanVersionDiffJobSpec(currentSnapshot))
+ if !ok {
+ 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
+ }
+ }
+ }
+ return false
+}
diff --git a/pkg/microservice/aslan/core/release_plan/service/watcher.go b/pkg/microservice/aslan/core/release_plan/service/watcher.go
index d18c6f1087..c076f26a18 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 {
@@ -229,16 +230,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,
+ TargetName: releasePlanTargetTypeDisplayName(TargetTypeReleasePlanStatus),
TargetType: TargetTypeReleasePlanStatus,
Detail: DetailApprovalPass,
- After: config.ReleasePlanStatusExecuting,
+ Before: beforeStatus,
CreatedAt: time.Now().Unix(),
}
@@ -260,11 +263,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: releasePlanTargetTypeDisplayName(TargetTypeReleasePlanStatus),
+ TargetType: TargetTypeReleasePlanStatus,
+ Detail: DetailApprovalReject,
+ Before: beforeStatus,
+ After: config.ReleasePlanStatusApprovalDenied,
+ CreatedAt: time.Now().Unix(),
}
plan.Status = config.ReleasePlanStatusApprovalDenied
plan.ApprovalTime = time.Now().Unix()
@@ -285,7 +296,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)
}
}()
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()
}