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..861d7ffb5d --- /dev/null +++ b/pkg/microservice/aslan/core/common/repository/mongodb/release_plan_version.go @@ -0,0 +1,101 @@ +/* + * Copyright 2026 The KodeRover Authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package mongodb + +import ( + "context" + + "github.com/pkg/errors" + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" + + "github.com/koderover/zadig/v2/pkg/microservice/aslan/config" + "github.com/koderover/zadig/v2/pkg/microservice/aslan/core/common/repository/models" + mongotool "github.com/koderover/zadig/v2/pkg/tool/mongo" +) + +type ReleasePlanVersionColl struct { + *mongo.Collection + + coll string +} + +func NewReleasePlanVersionColl() *ReleasePlanVersionColl { + name := models.ReleasePlanVersion{}.TableName() + return &ReleasePlanVersionColl{ + Collection: mongotool.Database(config.MongoDatabase()).Collection(name), + coll: name, + } +} + +func (c *ReleasePlanVersionColl) GetCollectionName() string { + return c.coll +} + +func (c *ReleasePlanVersionColl) EnsureIndex(ctx context.Context) error { + mod := []mongo.IndexModel{ + { + Keys: bson.D{{Key: "plan_id", Value: 1}, {Key: "version", Value: 1}}, + Options: options.Index().SetUnique(true), + }, + { + Keys: bson.D{{Key: "plan_id", Value: 1}, {Key: "created_at", Value: -1}}, + }, + } + + _, err := c.Indexes().CreateMany(ctx, mod, mongotool.CreateIndexOptions(ctx)) + return err +} + +func (c *ReleasePlanVersionColl) Create(args *models.ReleasePlanVersion) error { + if args == nil { + return errors.New("nil ReleasePlanVersion") + } + + _, err := c.InsertOne(context.Background(), args) + return err +} + +func (c *ReleasePlanVersionColl) Get(planID string, version int64) (*models.ReleasePlanVersion, error) { + resp := new(models.ReleasePlanVersion) + err := c.FindOne(context.Background(), bson.M{ + "plan_id": planID, + "version": version, + }).Decode(resp) + return resp, err +} + +func (c *ReleasePlanVersionColl) GetLatest(planID string) (*models.ReleasePlanVersion, error) { + resp := new(models.ReleasePlanVersion) + err := c.FindOne(context.Background(), bson.M{ + "plan_id": planID, + }, options.FindOne().SetSort(bson.D{{Key: "version", Value: -1}})).Decode(resp) + return resp, err +} + +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/common/service/workflowcontroller/jobcontroller/job_restart.go b/pkg/microservice/aslan/core/common/service/workflowcontroller/jobcontroller/job_restart.go index 4c48c850a2..ac70b38173 100644 --- a/pkg/microservice/aslan/core/common/service/workflowcontroller/jobcontroller/job_restart.go +++ b/pkg/microservice/aslan/core/common/service/workflowcontroller/jobcontroller/job_restart.go @@ -242,7 +242,7 @@ func (c *RestartJobCtl) restartHelmService(ctx context.Context, env *commonmodel } func restartWorkloadResources(ctx context.Context, clusterID string, resources []*kube.WorkloadResource, env *commonmodels.Product, kubeClient crClient.Client, clientSet *kubernetes.Clientset) (replaceResources []commonmodels.Resource, relatedPodLabels []map[string]string, err error) { - deployments, statefulSets, _, _, _, err := kube.FetchSelectedWorkloads(env.Namespace, resources, kubeClient, clientSet) + deployments, _, statefulSets, _, _, _, err := kube.FetchSelectedWorkloads(env.Namespace, resources, kubeClient, clientSet) if err != nil { return nil, nil, err } 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..aa78c2ec50 --- /dev/null +++ b/pkg/microservice/aslan/core/release_plan/service/collaboration.go @@ -0,0 +1,814 @@ +/* + * 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, +} + +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 { + if session == nil { + return errors.New("nil editing session") + } + if session.PlanID == "" || session.SessionID == "" { + return errors.New("missing session id or plan id") + } + if session.EditingStartedAt == 0 { + session.EditingStartedAt = time.Now().Unix() + } + session.LastHeartbeatAt = time.Now().Unix() + + payload, err := json.Marshal(session) + if err != nil { + return err + } + + redisCache := cache.NewRedisCache(configbase.RedisCommonCacheTokenDB()) + if err := redisCache.Write(releasePlanCollabSessionKey(session.SessionID), string(payload), releasePlanCollabSessionTTL); err != nil { + return err + } + if err := redisCache.AddElementsToSet(releasePlanCollabPlanSetKey(session.PlanID), []string{session.SessionID}, releasePlanCollabSessionTTL); err != nil { + return err + } + return broadcastReleasePlanCollaboration(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", "heartbeat": + 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 "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..2ce3357a18 --- /dev/null +++ b/pkg/microservice/aslan/core/release_plan/service/diff.go @@ -0,0 +1,1407 @@ +/* + * 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") + } + + groupKey, groupName, groupType := releasePlanVersionDiffGroup(current.SectionKey, current.SectionName) + displayMode, beforeSpec, afterSpec := releasePlanVersionDiffDisplaySpec(current.SectionKey, groupType, current.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) + } + + groupMap := map[string]*ReleasePlanVersionDiffGroup{} + groupOrder := make([]string, 0) + 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) + } + + sort.Strings(groupOrder) + groups := make([]*ReleasePlanVersionDiffGroup, 0, len(groupOrder)) + for _, key := range groupOrder { + group := groupMap[key] + sort.Slice(group.Changes, func(i, j int) bool { + return group.Changes[i].Path < group.Changes[j].Path + }) + groups = append(groups, group) + } + + return &ReleasePlanVersionDiffResponse{ + PlanID: planID, + Version: version, + PreviousVersion: current.PreviousVersion, + Groups: groups, + }, nil +} + +func ensureReleasePlanVersionDiffGroup(groupMap map[string]*ReleasePlanVersionDiffGroup, groupOrder *[]string, groupKey, groupName, groupType string) *ReleasePlanVersionDiffGroup { + if group, exists := groupMap[groupKey]; exists { + return group + } + + group := &ReleasePlanVersionDiffGroup{ + GroupKey: groupKey, + GroupName: groupName, + GroupType: groupType, + Changes: make([]*ReleasePlanVersionDiffChange, 0), + } + groupMap[groupKey] = group + *groupOrder = append(*groupOrder, groupKey) + return group +} + +func 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 "", "

", "


", "


", "
", "
": + return true + default: + return false + } +} + +func normalizeReleasePlanMetadataJiraSprintAssociationValue(value interface{}) interface{} { + association, ok := getMapField(value) + if !ok { + return value + } + if isEmptyReleasePlanJiraSprintAssociation(association) { + return nil + } + return value +} + +func isEmptyReleasePlanJiraSprintAssociation(value map[string]interface{}) bool { + if value == nil { + return true + } + if jiraID, ok := value["jira_id"].(string); ok && strings.TrimSpace(jiraID) != "" { + return false + } + if sprints, ok := value["sprints"].([]interface{}); ok && len(sprints) > 0 { + return false + } + return true +} + +func normalizeReleasePlanMetadataTimeValue(value interface{}) interface{} { + switch typed := value.(type) { + case float64: + if typed == 0 { + return nil + } + intValue := int64(typed) + if float64(intValue) == typed { + return intValue + } + return typed + case int: + if typed == 0 { + return nil + } + return int64(typed) + case int64: + if typed == 0 { + return nil + } + return typed + case json.Number: + intValue, err := typed.Int64() + if err == nil { + if intValue == 0 { + return nil + } + return intValue + } + return value + default: + return value + } +} + +func releasePlanVersionBaseSnapshotAsGenericValue(version *models.ReleasePlanVersion) (interface{}, bool, error) { + if version == nil || version.BaseSnapshot == nil { + return nil, false, nil + } + + value, err := toReleasePlanGenericValue(version.BaseSnapshot) + if err != nil { + return nil, true, err + } + return value, true, nil +} + +func comparableReleasePlanVersionSnapshot(version *models.ReleasePlanVersion, sectionKey string) interface{} { + if version == nil { + return nil + } + + switch { + case version.SectionKey == sectionKey, sectionKey == releasePlanVersionSectionPlan: + value, err := toReleasePlanGenericValue(version.Snapshot) + if err != nil { + return version.Snapshot + } + return value + case version.SectionKey != releasePlanVersionSectionPlan: + return nil + default: + return extractReleasePlanSectionSnapshot(version.Snapshot, sectionKey) + } +} + +func extractReleasePlanSectionSnapshot(snapshot interface{}, sectionKey string) interface{} { + genericValue, err := toReleasePlanGenericValue(snapshot) + if err != nil { + return nil + } + + planSnapshot, ok := genericValue.(map[string]interface{}) + if !ok { + return nil + } + + switch { + case isReleasePlanVersionMetadataSection(sectionKey): + metadata, ok := planSnapshot["metadata"].(map[string]interface{}) + if !ok { + return nil + } + switch sectionKey { + case releasePlanVersionSectionMetadata: + return metadata + case releasePlanCollabSectionMetadataName: + return map[string]interface{}{ + "name": metadata["name"], + } + case releasePlanCollabSectionMetadataManager: + return map[string]interface{}{ + "manager": metadata["manager"], + "manager_id": metadata["manager_id"], + } + case releasePlanCollabSectionMetadataTimeRange: + return map[string]interface{}{ + "start_time": metadata["start_time"], + "end_time": metadata["end_time"], + } + case releasePlanCollabSectionMetadataScheduleExecute: + return map[string]interface{}{ + "schedule_execute_time": metadata["schedule_execute_time"], + } + case releasePlanCollabSectionMetadataDescription: + return map[string]interface{}{ + "description": metadata["description"], + } + case releasePlanCollabSectionMetadataJiraSprint: + return map[string]interface{}{ + "jira_sprint_association": metadata["jira_sprint_association"], + } + default: + return metadata + } + case sectionKey == releasePlanVersionSectionApproval: + return planSnapshot["approval"] + case sectionKey == releasePlanVersionSectionJobsOrder: + return planSnapshot["jobs_order"] + case strings.HasPrefix(sectionKey, releasePlanVersionSectionJobPrefix): + jobID := strings.TrimPrefix(sectionKey, releasePlanVersionSectionJobPrefix) + jobs, ok := planSnapshot["jobs"].([]interface{}) + if !ok { + return nil + } + for _, item := range jobs { + job, ok := item.(map[string]interface{}) + if !ok { + continue + } + if id, _ := job["id"].(string); id == jobID { + return job + } + } + } + return nil +} + +func diffReleasePlanValues(ctx releasePlanDiffContext, path string, left, right interface{}, entries *[]*releasePlanRawDiffEntry) { + diffReleasePlanValuesWithDepth(ctx, path, 0, left, right, entries) +} + +func diffReleasePlanValuesWithDepth(ctx releasePlanDiffContext, path string, depth int, left, right interface{}, entries *[]*releasePlanRawDiffEntry) { + if shouldIgnoreReleasePlanDiffPath(path) { + return + } + + if equal, hashed := equalReleasePlanSubtreeByHash(left, right); hashed { + if equal { + return + } + } else if reflect.DeepEqual(left, right) { + return + } + + if depth >= releasePlanDiffMaxDepth { + *entries = append(*entries, &releasePlanRawDiffEntry{ + Path: path, + Before: left, + After: right, + }) + return + } + + leftMap, leftIsMap := left.(map[string]interface{}) + rightMap, rightIsMap := right.(map[string]interface{}) + if leftIsMap || rightIsMap { + keys := make([]string, 0) + keySet := map[string]struct{}{} + for key := range leftMap { + keySet[key] = struct{}{} + } + for key := range rightMap { + keySet[key] = struct{}{} + } + for key := range keySet { + keys = append(keys, key) + } + sort.Strings(keys) + for _, key := range keys { + nextPath := joinReleasePlanDiffPath(path, key) + diffReleasePlanValuesWithDepth(ctx, nextPath, depth+1, leftMap[key], rightMap[key], entries) + } + return + } + + leftList, leftIsList := left.([]interface{}) + rightList, rightIsList := right.([]interface{}) + if leftIsList || rightIsList { + diffReleasePlanArray(ctx, path, depth, leftList, rightList, entries) + return + } + + *entries = append(*entries, &releasePlanRawDiffEntry{ + Path: path, + Before: left, + After: right, + }) +} + +func equalReleasePlanSubtreeByHash(left, right interface{}) (equal bool, hashed bool) { + if !shouldUseReleasePlanSubtreeHash(left, right) { + return false, false + } + + leftHash, err := hashReleasePlanSubtree(left) + if err != nil { + return false, false + } + rightHash, err := hashReleasePlanSubtree(right) + if err != nil { + return false, false + } + return leftHash == rightHash, true +} + +func shouldUseReleasePlanSubtreeHash(left, right interface{}) bool { + switch leftValue := left.(type) { + case map[string]interface{}: + rightValue, ok := right.(map[string]interface{}) + if !ok { + return false + } + return len(leftValue) >= releasePlanHashPruneMinMapKeys || len(rightValue) >= releasePlanHashPruneMinMapKeys + case []interface{}: + rightValue, ok := right.([]interface{}) + if !ok { + return false + } + return len(leftValue) >= releasePlanHashPruneMinArrayItems || len(rightValue) >= releasePlanHashPruneMinArrayItems + default: + return false + } +} + +func hashReleasePlanSubtree(value interface{}) (string, error) { + payload, err := json.Marshal(value) + if err != nil { + return "", err + } + sum := sha256.Sum256(payload) + return hex.EncodeToString(sum[:]), nil +} + +func diffReleasePlanArray(ctx releasePlanDiffContext, path string, depth int, left, right []interface{}, entries *[]*releasePlanRawDiffEntry) { + rule := matchReleasePlanArrayDiffRule(ctx, path) + if rule == nil || rule.Strategy == releasePlanArrayDiffStrategyIndex { + diffReleasePlanArrayByIndex(ctx, path, depth, left, right, entries) + return + } + + leftMap, leftOrdered, leftMapped := buildReleasePlanArrayMap(left, rule.BuildKey) + rightMap, rightOrdered, rightMapped := buildReleasePlanArrayMap(right, rule.BuildKey) + if !leftMapped || !rightMapped { + diffReleasePlanArrayByIndex(ctx, path, depth, left, right, entries) + return + } + + strategy := rule.Strategy + if strategy == releasePlanArrayDiffStrategyKeyedOrdered { + if entry := buildReleasePlanArrayOrderChange(path, left, right, leftMap, leftOrdered, rightMap, rightOrdered); entry != nil { + *entries = append(*entries, entry) + } + } + if strategy == releasePlanArrayDiffStrategyKeyedOrdered || strategy == releasePlanArrayDiffStrategyKeyedUnordered { + keySet := map[string]struct{}{} + keys := make([]string, 0) + for _, key := range leftOrdered { + if _, exists := keySet[key]; !exists { + keySet[key] = struct{}{} + keys = append(keys, key) + } + } + for _, key := range rightOrdered { + if _, exists := keySet[key]; !exists { + keySet[key] = struct{}{} + keys = append(keys, key) + } + } + for _, key := range keys { + if shouldSkipReleasePlanWorkflowTaskPresenceChange(path, leftMap[key], rightMap[key]) { + continue + } + nextPath := fmt.Sprintf("%s[%s]", path, key) + diffReleasePlanValuesWithDepth(ctx, nextPath, depth+1, leftMap[key], rightMap[key], entries) + } + return + } + + diffReleasePlanArrayByIndex(ctx, path, depth, left, right, entries) +} + +func shouldSkipReleasePlanWorkflowTaskPresenceChange(path string, left, right interface{}) bool { + if left != nil && right != nil { + return false + } + normalizedPath := normalizeReleasePlanDiffPath(path) + return normalizedPath == "spec.workflow.jobs" || strings.HasSuffix(normalizedPath, ".spec.workflow.jobs") +} + +func diffReleasePlanArrayByIndex(ctx releasePlanDiffContext, path string, depth int, left, right []interface{}, entries *[]*releasePlanRawDiffEntry) { + maxLen := len(left) + if len(right) > maxLen { + maxLen = len(right) + } + for i := 0; i < maxLen; i++ { + nextPath := fmt.Sprintf("%s[%d]", path, i) + var leftVal, rightVal interface{} + if i < len(left) { + leftVal = left[i] + } + if i < len(right) { + rightVal = right[i] + } + diffReleasePlanValuesWithDepth(ctx, nextPath, depth+1, leftVal, rightVal, entries) + } +} + +type releasePlanArrayRuleLookupContext struct { + GroupType string + Path string +} + +func matchReleasePlanArrayDiffRule(ctx releasePlanDiffContext, path string) *releasePlanArrayDiffRule { + lookupContexts := buildReleasePlanArrayRuleLookupContexts(ctx, path) + for _, lookup := range lookupContexts { + for idx := range releasePlanArrayExactRules { + rule := &releasePlanArrayExactRules[idx] + if rule.GroupType != lookup.GroupType { + continue + } + if rule.Path == lookup.Path { + return rule + } + } + } + return nil +} + +func buildReleasePlanArrayRuleLookupContexts(ctx releasePlanDiffContext, path string) []releasePlanArrayRuleLookupContext { + normalizedPath := normalizeReleasePlanDiffPath(path) + resp := []releasePlanArrayRuleLookupContext{{ + GroupType: ctx.GroupType, + Path: normalizedPath, + }} + + if ctx.GroupType != "plan" { + return resp + } + + // Nested arrays under the plan snapshot still belong to approval/metadata structures. + if strings.HasPrefix(normalizedPath, "approval.") { + resp = append(resp, releasePlanArrayRuleLookupContext{ + GroupType: "approval", + Path: strings.TrimPrefix(normalizedPath, "approval."), + }) + } + if strings.HasPrefix(normalizedPath, "metadata.") { + resp = append(resp, releasePlanArrayRuleLookupContext{ + GroupType: "metadata", + Path: strings.TrimPrefix(normalizedPath, "metadata."), + }) + } + return resp +} + +func normalizeReleasePlanDiffPath(path string) string { + if path == "" { + return "" + } + + builder := strings.Builder{} + builder.Grow(len(path)) + inBracket := false + for _, ch := range path { + switch ch { + case '[': + inBracket = true + case ']': + inBracket = false + default: + if !inBracket { + builder.WriteRune(ch) + } + } + } + return builder.String() +} + +func buildReleasePlanArrayOrderChange( + path string, + left, right []interface{}, + leftMap map[string]interface{}, + leftOrdered []string, + rightMap map[string]interface{}, + rightOrdered []string, +) *releasePlanRawDiffEntry { + if !hasReleasePlanArrayRelativeOrderChange(leftMap, leftOrdered, rightMap, rightOrdered) { + return nil + } + + return &releasePlanRawDiffEntry{ + Path: joinReleasePlanDiffPath(path, "order"), + ChangeType: releasePlanDiffChangeTypeOrder, + BeforeOrder: buildReleasePlanArrayOrderItems(left, leftOrdered), + AfterOrder: buildReleasePlanArrayOrderItems(right, rightOrdered), + } +} + +func hasReleasePlanArrayRelativeOrderChange( + leftMap map[string]interface{}, + leftOrdered []string, + rightMap map[string]interface{}, + rightOrdered []string, +) bool { + leftShared := filterReleasePlanArrayOrderedKeys(leftOrdered, rightMap) + rightShared := filterReleasePlanArrayOrderedKeys(rightOrdered, leftMap) + return !reflect.DeepEqual(leftShared, rightShared) +} + +func filterReleasePlanArrayOrderedKeys(orderedKeys []string, otherMap map[string]interface{}) []string { + resp := make([]string, 0, len(orderedKeys)) + for _, key := range orderedKeys { + if _, exists := otherMap[key]; exists { + resp = append(resp, key) + } + } + return resp +} + +func buildReleasePlanArrayOrderItems(values []interface{}, orderedKeys []string) []*ReleasePlanVersionDiffOrderItem { + resp := make([]*ReleasePlanVersionDiffOrderItem, 0, len(values)) + for idx, item := range values { + key := "" + if idx < len(orderedKeys) { + key = orderedKeys[idx] + } + resp = append(resp, buildReleasePlanArrayOrderItem(item, key)) + } + return resp +} + +func buildReleasePlanArrayOrderItem(item interface{}, key string) *ReleasePlanVersionDiffOrderItem { + resp := &ReleasePlanVersionDiffOrderItem{Key: key} + + switch value := item.(type) { + case map[string]interface{}: + if id, ok := getStringField(value, "id"); ok { + resp.ID = id + } + if name, ok := getStringField(value, "name"); ok { + resp.Name = name + return resp + } + if itemKey, ok := getStringField(value, "key"); ok { + resp.Name = itemKey + return resp + } + if workflowName, ok := getStringField(value, "workflow_name"); ok { + projectName, _ := getStringField(value, "project_name") + serviceName, _ := getStringField(value, "service_name") + serviceModule, _ := getStringField(value, "service_module") + parts := make([]string, 0, 4) + if projectName != "" { + parts = append(parts, projectName) + } + if workflowName != "" { + parts = append(parts, workflowName) + } + if serviceName != "" { + parts = append(parts, serviceName) + } + if serviceModule != "" { + parts = append(parts, serviceModule) + } + resp.Name = strings.Join(parts, "/") + return resp + } + if service, ok := getStringField(value, "service_name"); ok { + if module, ok := getStringField(value, "service_module"); ok { + resp.Name = fmt.Sprintf("%s/%s", service, module) + } else { + resp.Name = service + } + return resp + } + if module, ok := getStringField(value, "service_module"); ok { + resp.Name = module + return resp + } + if repo, ok := getStringField(value, "repo_name"); ok { + namespace, _ := getStringField(value, "repo_namespace") + remote, _ := getStringField(value, "remote_name") + resp.Name = strings.Trim(fmt.Sprintf("%s/%s/%s", namespace, repo, remote), "/") + return resp + } + if target, ok := getStringField(value, "target"); ok { + resp.Name = target + return resp + } + if targetName := buildReleasePlanTargetOrderName(value); targetName != "" { + resp.Name = targetName + return resp + } + if userID, ok := getStringField(value, "user_id"); ok { + resp.Name = userID + return resp + } + if groupName, ok := getStringField(value, "group_name"); ok { + resp.Name = groupName + return resp + } + if sprintName, ok := getStringField(value, "sprint_name"); ok { + projectKey, _ := getStringField(value, "project_key") + if projectKey != "" { + resp.Name = fmt.Sprintf("%s/%s", projectKey, sprintName) + } else { + resp.Name = sprintName + } + return resp + } + if variableKey, ok := getStringField(value, "variable_key"); ok { + resp.Name = variableKey + return resp + } + } + + if resp.Name == "" && key != "" { + resp.Name = key + } + if resp.Name == "" { + resp.Name = fmt.Sprintf("%v", item) + } + return resp +} + +func buildReleasePlanTargetOrderName(value map[string]interface{}) string { + if serviceName, ok := getStringField(value, "k8s_service_name"); ok { + workloadName, _ := getStringField(value, "workload_name") + containerName, _ := getStringField(value, "container_name") + return joinReleasePlanOrderNameParts(serviceName, workloadName, containerName) + } + if virtualServiceName, ok := getStringField(value, "virtual_service_name"); ok { + workloadName, _ := getStringField(value, "workload_name") + containerName, _ := getStringField(value, "container_name") + return joinReleasePlanOrderNameParts(virtualServiceName, workloadName, containerName) + } + if workloadType, ok := getStringField(value, "workload_type"); ok { + workloadName, _ := getStringField(value, "workload_name") + containerName, _ := getStringField(value, "container_name") + return joinReleasePlanOrderNameParts(workloadType, workloadName, containerName) + } + return "" +} + +func joinReleasePlanOrderNameParts(parts ...string) string { + filtered := make([]string, 0, len(parts)) + for _, part := range parts { + if part != "" { + filtered = append(filtered, part) + } + } + return strings.Join(filtered, " / ") +} + +func buildReleasePlanArrayMap(values []interface{}, buildKey releasePlanArrayKeyBuilder) (map[string]interface{}, []string, bool) { + if buildKey == nil { + return nil, nil, false + } + + result := make(map[string]interface{}, len(values)) + orderedKeys := make([]string, 0, len(values)) + for idx, item := range values { + key, ok := buildKey(item) + if !ok { + return nil, nil, false + } + if _, exists := result[key]; exists { + key = fmt.Sprintf("%s#%d", key, idx) + } + result[key] = item + orderedKeys = append(orderedKeys, key) + } + return result, orderedKeys, true +} + +func buildReleasePlanArrayKeyByNameTypeID(item interface{}) (string, bool) { + value, ok := getMapField(item) + if !ok { + return "", false + } + name, ok := getStringField(value, "name") + if !ok { + return "", false + } + jobType, ok := getStringField(value, "type") + if !ok { + return "", false + } + id, ok := getStringField(value, "id") + if !ok { + return "", false + } + return fmt.Sprintf("%s|%s|%s", name, jobType, id), true +} + +func buildReleasePlanArrayKeyByNameID(item interface{}) (string, bool) { + value, ok := getMapField(item) + if !ok { + return "", false + } + name, ok := getStringField(value, "name") + if !ok { + return "", false + } + id, ok := getStringField(value, "id") + if !ok { + return "", false + } + return fmt.Sprintf("%s|%s", name, id), true +} + +func buildReleasePlanArrayKeyByUserID(item interface{}) (string, bool) { + value, ok := getMapField(item) + if !ok { + return "", false + } + return getStringField(value, "user_id") +} + +func buildReleasePlanArrayKeyByThirdPartyUserID(item interface{}) (string, bool) { + value, ok := getMapField(item) + if !ok { + return "", false + } + if id, ok := getStringField(value, "id"); ok { + return id, true + } + name, hasName := getStringField(value, "name") + userID, hasUserID := getStringField(value, "user_id") + if hasName && hasUserID { + return fmt.Sprintf("%s|%s", name, userID), true + } + return "", false +} + +func buildReleasePlanArrayKeyByApprovalGroup(item interface{}) (string, bool) { + value, ok := getMapField(item) + if !ok { + return "", false + } + if groupID, ok := getStringField(value, "group_id"); ok { + return groupID, true + } + return getStringField(value, "group_name") +} + +func buildReleasePlanArrayKeyByJiraSprint(item interface{}) (string, bool) { + value, ok := getMapField(item) + if !ok { + return "", false + } + projectKey, _ := getStringField(value, "project_key") + if projectKey == "" { + projectKey, _ = getStringField(value, "project_name") + } + if projectKey == "" { + return "", false + } + boardID, ok := getNumberFieldString(value, "board_id") + if !ok { + return "", false + } + sprintID, ok := getNumberFieldString(value, "sprint_id") + if !ok { + return "", false + } + return fmt.Sprintf("%s|%s|%s", projectKey, boardID, sprintID), true +} + +func getMapField(item interface{}) (map[string]interface{}, bool) { + value, ok := item.(map[string]interface{}) + return value, ok +} + +func getStringField(input map[string]interface{}, key string) (string, bool) { + value, exists := input[key] + if !exists { + return "", false + } + str, ok := value.(string) + return str, ok && str != "" +} + +func getNumberFieldString(input map[string]interface{}, key string) (string, bool) { + value, exists := input[key] + if !exists { + return "", false + } + switch typed := value.(type) { + case string: + return typed, typed != "" + case float64: + intValue := int64(typed) + if float64(intValue) != typed { + return "", false + } + return strconv.FormatInt(intValue, 10), true + case float32: + intValue := int64(typed) + if float32(intValue) != typed { + return "", false + } + return strconv.FormatInt(intValue, 10), true + case int: + return strconv.Itoa(typed), true + case int8: + return strconv.FormatInt(int64(typed), 10), true + case int16: + return strconv.FormatInt(int64(typed), 10), true + case int32: + return strconv.FormatInt(int64(typed), 10), true + case int64: + return strconv.FormatInt(typed, 10), true + case uint: + return strconv.FormatUint(uint64(typed), 10), true + case uint8: + return strconv.FormatUint(uint64(typed), 10), true + case uint16: + return strconv.FormatUint(uint64(typed), 10), true + case uint32: + return strconv.FormatUint(uint64(typed), 10), true + case uint64: + return strconv.FormatUint(typed, 10), true + case json.Number: + if intValue, err := typed.Int64(); err == nil { + return strconv.FormatInt(intValue, 10), true + } + return "", false + default: + return "", false + } +} + +func joinReleasePlanDiffPath(path, key string) string { + if path == "" { + return key + } + return path + "." + key +} + +func shouldIgnoreReleasePlanDiffPath(path string) bool { + if path == "" { + return false + } + if isReleasePlanWorkflowJobStructureDiffPath(path) { + return true + } + + prefixes := []string{ + "id", + "index", + "version", + "created_by", + "create_time", + "updated_by", + "update_time", + "status", + "planning_time", + "finish_planning_time", + "approval_time", + "executing_time", + "success_time", + "instance_code", + "hook_settings", + "wait_for_finish_planning_external_check_time", + "wait_for_approve_external_check_time", + "wait_for_execute_external_check_time", + "wait_for_all_done_external_check_time", + "external_check_failed_reason", + "callback_description", + } + for _, prefix := range prefixes { + if path == prefix || strings.HasPrefix(path, prefix+".") { + return true + } + } + + suffixes := []string{ + ".status", + ".last_status", + ".updated", + ".executed_by", + ".executed_time", + ".task_id", + ".hook_payload", + ".hash", + ".notification_id", + ".operation_time", + ".reject_or_approve", + ".approval_instance", + ".manual_exector_id", + ".manual_exector_name", + ".notification_sent", + } + for _, suffix := range suffixes { + if strings.HasSuffix(path, suffix) { + return true + } + } + return false +} + +func isReleasePlanWorkflowJobStructureDiffPath(path string) bool { + if !strings.HasPrefix(path, "spec.workflow.jobs[") { + return false + } + if strings.Contains(path, "].spec.") { + return false + } + return true +} + +func splitReleasePlanBracketKey(segment string) (string, string) { + primary := bracketPrimaryName(segment) + parts := strings.Split(primary, "|") + if len(parts) == 1 { + return primary, "" + } + return parts[0], strings.Join(parts[1:], "|") +} + +func bracketPrimaryName(segment string) string { + start := strings.Index(segment, "[") + end := strings.LastIndex(segment, "]") + if start == -1 || end == -1 || end <= start+1 { + return segment + } + return segment[start+1 : end] +} + +func buildReleasePlanDiffLabel(path string) string { + segments := strings.Split(path, ".") + labels := make([]string, 0, len(segments)) + for _, segment := range segments { + if segment == "metadata" || segment == "spec" || segment == "workflow" { + continue + } + label := segment + switch { + case strings.HasPrefix(segment, "jobs["): + name, _ := splitReleasePlanBracketKey(segment) + label = fmt.Sprintf("任务 %s", name) + case strings.Contains(segment, "["): + fieldName := segment[:strings.Index(segment, "[")] + label = fmt.Sprintf("%s %s", translateReleasePlanFieldLabel(fieldName), bracketPrimaryName(segment)) + default: + label = translateReleasePlanFieldLabel(segment) + } + labels = append(labels, label) + } + if len(labels) == 0 { + return path + } + return strings.Join(labels, " / ") +} + +func translateReleasePlanFieldLabel(name string) string { + if label, exists := releasePlanFieldLabels[name]; exists { + return label + } + return strings.ReplaceAll(name, "_", " ") +} + +func isMaskedReleasePlanDiffValue(value interface{}) bool { + return isReleasePlanMaskedStorageValue(value) +} + +func isLargeTextReleasePlanDiffPath(path string, before, after interface{}) bool { + if shouldPreserveFullReleasePlanDiffValue(path) { + return false + } + + lowerPath := strings.ToLower(path) + keywords := []string{"script", "sql", "yaml", "json"} + for _, keyword := range keywords { + if strings.Contains(lowerPath, keyword) { + return true + } + } + + if value, ok := before.(string); ok && len(value) > 256 { + return true + } + if value, ok := after.(string); ok && len(value) > 256 { + return true + } + return false +} + +func shouldPreserveFullReleasePlanDiffValue(path string) bool { + lowerPath := strings.ToLower(path) + switch { + case lowerPath == "description": + return true + case strings.HasSuffix(lowerPath, ".description"): + return true + case lowerPath == "spec.content": + return true + default: + return false + } +} + +func normalizeReleasePlanDiffValue(value interface{}) interface{} { + switch value.(type) { + case nil, string, bool, float64: + return value + default: + payload, err := json.Marshal(value) + if err != nil { + return fmt.Sprintf("%v", value) + } + return string(payload) + } +} diff --git a/pkg/microservice/aslan/core/release_plan/service/execute.go b/pkg/microservice/aslan/core/release_plan/service/execute.go index b0ffe843bc..5be2b987b4 100644 --- a/pkg/microservice/aslan/core/release_plan/service/execute.go +++ b/pkg/microservice/aslan/core/release_plan/service/execute.go @@ -135,9 +135,13 @@ func (e *WorkflowReleaseJobExecutor) Execute(plan *models.ReleasePlan) error { if spec.Workflow == nil { return errors.Errorf("workflow is nil") } - - err := jobManagerAuth(plan.Name, plan.ManagerID, job, e.Ctx.UserName, e.Ctx.UserID, e.Ctx.AuthResources) + normalizedWorkflow, err := normalizeReleasePlanWorkflowForController(spec.Workflow) if err != nil { + return errors.Wrap(err, "normalize workflow") + } + spec.Workflow = normalizedWorkflow + + if err := jobManagerAuth(plan.Name, plan.ManagerID, job, e.Ctx.UserName, e.Ctx.UserID, e.Ctx.AuthResources); err != nil { return err } diff --git a/pkg/microservice/aslan/core/release_plan/service/masking.go b/pkg/microservice/aslan/core/release_plan/service/masking.go new file mode 100644 index 0000000000..b6bcc4ffe0 --- /dev/null +++ b/pkg/microservice/aslan/core/release_plan/service/masking.go @@ -0,0 +1,199 @@ +package service + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "strings" + + "github.com/pkg/errors" + + "github.com/koderover/zadig/v2/pkg/microservice/aslan/core/common/repository/models" + "github.com/koderover/zadig/v2/pkg/microservice/aslan/core/common/repository/mongodb" +) + +const ( + releasePlanMaskedValueDisplay = "已脱敏" + releasePlanMaskedValuePrefix = "__masked__:" +) + +func createReleasePlanLog(logItem *models.ReleasePlanLog) error { + if logItem == nil { + return errors.New("nil release plan log") + } + + cloned := *logItem + cloned.Before = sanitizeReleasePlanValue(logItem.Before) + cloned.After = sanitizeReleasePlanValue(logItem.After) + return mongodb.NewReleasePlanLogColl().Create(&cloned) +} + +func sanitizeReleasePlanValue(value interface{}) interface{} { + if value == nil { + return nil + } + + genericValue, err := toReleasePlanGenericValue(value) + if err != nil { + return value + } + + return sanitizeReleasePlanGenericValue("", genericValue) +} + +func sanitizeReleasePlanValueForDisplay(value interface{}) interface{} { + if value == nil { + return nil + } + + genericValue, err := toReleasePlanGenericValue(value) + if err != nil { + if isReleasePlanMaskedStorageValue(value) { + return releasePlanMaskedValueDisplay + } + return value + } + + if hasReleasePlanRawSensitiveValue(genericValue) { + genericValue = sanitizeReleasePlanGenericValue("", genericValue) + } + return sanitizeReleasePlanDisplayGenericValue(genericValue) +} + +func sanitizeReleasePlanGenericValue(path string, value interface{}) interface{} { + switch typedValue := value.(type) { + case map[string]interface{}: + resp := make(map[string]interface{}, len(typedValue)) + for key, item := range typedValue { + resp[key] = sanitizeReleasePlanGenericValue(joinReleasePlanMaskPath(path, key), item) + } + if isReleasePlanSensitiveValueNode(resp) { + maskReleasePlanSensitiveValueNode(resp) + } + return resp + case []interface{}: + resp := make([]interface{}, 0, len(typedValue)) + for idx, item := range typedValue { + resp = append(resp, sanitizeReleasePlanGenericValue(fmt.Sprintf("%s[%d]", path, idx), item)) + } + return resp + default: + return value + } +} + +func sanitizeReleasePlanDisplayGenericValue(value interface{}) interface{} { + switch typedValue := value.(type) { + case map[string]interface{}: + resp := make(map[string]interface{}, len(typedValue)) + for key, item := range typedValue { + resp[key] = sanitizeReleasePlanDisplayGenericValue(item) + } + return resp + case []interface{}: + resp := make([]interface{}, 0, len(typedValue)) + for _, item := range typedValue { + resp = append(resp, sanitizeReleasePlanDisplayGenericValue(item)) + } + return resp + case string: + if isReleasePlanMaskedStorageValue(typedValue) { + return releasePlanMaskedValueDisplay + } + return typedValue + default: + return value + } +} + +func toReleasePlanGenericValue(value interface{}) (interface{}, error) { + payload, err := json.Marshal(value) + if err != nil { + return nil, err + } + var resp interface{} + if err := json.Unmarshal(payload, &resp); err != nil { + return nil, err + } + return resp, nil +} + +func maskReleasePlanValue(value interface{}) string { + if isReleasePlanMaskedStorageValue(value) { + if str, ok := value.(string); ok { + return str + } + } + + payload, err := json.Marshal(value) + if err != nil { + payload = []byte(fmt.Sprintf("%v", value)) + } + hash := sha256.Sum256(payload) + return releasePlanMaskedValuePrefix + hex.EncodeToString(hash[:8]) +} + +func isReleasePlanMaskedStorageValue(value interface{}) bool { + str, ok := value.(string) + return ok && strings.HasPrefix(str, releasePlanMaskedValuePrefix) +} + +func isReleasePlanSensitiveValueNode(value map[string]interface{}) bool { + if value == nil { + return false + } + return isReleasePlanSensitiveFlagTrue(value, "is_credential") || isReleasePlanSensitiveFlagTrue(value, "is_sensitive") +} + +func hasReleasePlanRawSensitiveValue(value interface{}) bool { + switch typedValue := value.(type) { + case map[string]interface{}: + if isReleasePlanSensitiveValueNode(typedValue) { + for _, key := range []string{"value", "choice_value"} { + if item, exists := typedValue[key]; exists && !isReleasePlanMaskedStorageValue(item) { + return true + } + } + } + for _, item := range typedValue { + if hasReleasePlanRawSensitiveValue(item) { + return true + } + } + case []interface{}: + for _, item := range typedValue { + if hasReleasePlanRawSensitiveValue(item) { + return true + } + } + } + return false +} + +func isReleasePlanSensitiveFlagTrue(input map[string]interface{}, key string) bool { + value, exists := input[key] + if !exists { + return false + } + flag, ok := value.(bool) + return ok && flag +} + +func maskReleasePlanSensitiveValueNode(value map[string]interface{}) { + if value == nil { + return + } + for _, key := range []string{"value", "choice_value"} { + if item, exists := value[key]; exists { + value[key] = maskReleasePlanValue(item) + } + } +} + +func joinReleasePlanMaskPath(path, key string) string { + if path == "" { + return key + } + return path + "." + key +} diff --git a/pkg/microservice/aslan/core/release_plan/service/openapi.go b/pkg/microservice/aslan/core/release_plan/service/openapi.go index 2c6739d293..b373f4ccea 100644 --- a/pkg/microservice/aslan/core/release_plan/service/openapi.go +++ b/pkg/microservice/aslan/core/release_plan/service/openapi.go @@ -158,6 +158,7 @@ func OpenAPICreateReleasePlan(c *handler.Context, rawArgs *OpenAPICreateReleaseP args.UpdatedBy = c.UserName args.CreateTime = time.Now().Unix() args.UpdateTime = time.Now().Unix() + args.Version = 1 args.Status = config.ReleasePlanStatusPlanning planID, err := mongodb.NewReleasePlanColl().Create(args) @@ -166,14 +167,25 @@ func OpenAPICreateReleasePlan(c *handler.Context, rawArgs *OpenAPICreateReleaseP } go func() { - if err := mongodb.NewReleasePlanLogColl().Create(&models.ReleasePlanLog{ - PlanID: planID, - Username: c.UserName, - Account: c.Account, - Verb: VerbCreate, - TargetName: args.Name, - TargetType: TargetTypeReleasePlan, - CreatedAt: time.Now().Unix(), + sectionSnapshot, err := buildReleasePlanInputSnapshot(args) + if err == nil { + err = createReleasePlanVersion(planID, 1, sectionSnapshot, c.UserName, c.Account, releasePlanVersionSectionPlan, releasePlanVersionSectionName(releasePlanVersionSectionPlan, args.Name), VerbCreate) + } + if err != nil { + log.Errorf("create release plan version error: %v", err) + } + if err := createReleasePlanLog(&models.ReleasePlanLog{ + PlanID: planID, + Username: c.UserName, + Account: c.Account, + Verb: VerbCreate, + TargetName: args.Name, + TargetType: TargetTypeReleasePlan, + Version: 1, + SectionKey: releasePlanVersionSectionPlan, + SectionName: releasePlanVersionSectionName(releasePlanVersionSectionPlan, args.Name), + SectionType: releasePlanVersionSectionGroupType(releasePlanVersionSectionPlan), + CreatedAt: time.Now().Unix(), }); err != nil { log.Errorf("create release plan log error: %v", err) } @@ -216,10 +228,18 @@ type OpenAPIWorkflowReleaseJobSpec struct { } func OpenAPICreateReleasePlanWithJobs(c *handler.Context, id string, rawArgs *OpenAPIUpdateReleasePlanWithJobsArgs) error { + approveLock := getLock(id) + approveLock.Lock() + defer approveLock.Unlock() + plan, err := mongodb.NewReleasePlanColl().GetByID(context.Background(), id) if err != nil { return errors.Wrap(err, "get release plan error") } + originalPlan, err := cloneReleasePlan(plan) + if err != nil { + return errors.Wrap(err, "clone release plan") + } if rawArgs.Name == "" || rawArgs.Manager == "" { return errors.New("Required parameters are missing") @@ -361,25 +381,56 @@ func OpenAPICreateReleasePlanWithJobs(c *handler.Context, id string, rawArgs *Op } plan.Jobs = newJobs + plan.Version = originalPlan.Version + 1 + + currentSnapshot, err := buildReleasePlanInputSnapshot(plan) + if err != nil { + return errors.Wrap(err, "build release plan current snapshot") + } + var baseSnapshot interface{} + needBaseSnapshot, previousVersion, err := shouldBuildReleasePlanVersionBaseSnapshot(plan.ID.Hex(), releasePlanVersionSectionPlan, plan.Version, VerbUpdate) + if err != nil { + return errors.Wrap(err, "check release plan base snapshot") + } + if needBaseSnapshot { + baseSnapshot, err = buildReleasePlanInputSnapshot(originalPlan) + if err != nil { + return errors.Wrap(err, "build release plan base snapshot") + } + } + logBaseSnapshot, err := resolveReleasePlanLogBaseSnapshot(baseSnapshot, originalPlan, releasePlanVersionSectionPlan) + if err != nil { + return errors.Wrap(err, "build release plan log base snapshot") + } + shouldCreateLog := hasReleasePlanSnapshotChanges(logBaseSnapshot, currentSnapshot) err = mongodb.NewReleasePlanColl().UpdateByID(c, id, plan) if err != nil { return errors.Wrap(err, "update release plan error") } - go func() { - if err := mongodb.NewReleasePlanLogColl().Create(&models.ReleasePlanLog{ - PlanID: plan.ID.Hex(), - Username: c.UserName, - Account: c.Account, - Verb: VerbUpdate, - TargetName: plan.Name, - TargetType: TargetTypeReleasePlan, - CreatedAt: time.Now().Unix(), + if err := createReleasePlanVersionWithBaseSnapshot(plan.ID.Hex(), plan.Version, previousVersion, baseSnapshot, currentSnapshot, c.UserName, c.Account, releasePlanVersionSectionPlan, releasePlanVersionSectionName(releasePlanVersionSectionPlan, plan.Name), VerbUpdate); err != nil { + log.Errorf("create release plan version error: %v", err) + } else if shouldCreateLog { + if err := createReleasePlanLog(&models.ReleasePlanLog{ + PlanID: plan.ID.Hex(), + Username: c.UserName, + Account: c.Account, + Verb: VerbUpdate, + TargetName: plan.Name, + TargetType: TargetTypeReleasePlan, + Version: plan.Version, + SectionKey: releasePlanVersionSectionPlan, + SectionName: releasePlanVersionSectionName(releasePlanVersionSectionPlan, plan.Name), + SectionType: releasePlanVersionSectionGroupType(releasePlanVersionSectionPlan), + CreatedAt: time.Now().Unix(), }); err != nil { log.Errorf("create release plan log error: %v", err) } - }() + } + if err := broadcastReleasePlanCollaboration(plan.ID.Hex()); err != nil { + log.Errorf("broadcast release plan collaboration error: %v", err) + } return nil } diff --git a/pkg/microservice/aslan/core/release_plan/service/release_plan.go b/pkg/microservice/aslan/core/release_plan/service/release_plan.go index c8659b82b1..d3cea7bfa0 100644 --- a/pkg/microservice/aslan/core/release_plan/service/release_plan.go +++ b/pkg/microservice/aslan/core/release_plan/service/release_plan.go @@ -110,6 +110,7 @@ func CreateReleasePlan(c *handler.Context, args *models.ReleasePlan) error { args.UpdatedBy = c.UserName args.CreateTime = time.Now().Unix() args.UpdateTime = time.Now().Unix() + args.Version = 1 args.Status = config.ReleasePlanStatusPlanning args.InstanceCode, err = generateInstanceCode(args) @@ -131,14 +132,25 @@ func CreateReleasePlan(c *handler.Context, args *models.ReleasePlan) error { } go func() { - if err := mongodb.NewReleasePlanLogColl().Create(&models.ReleasePlanLog{ - PlanID: planID, - Username: c.UserName, - Account: c.Account, - Verb: VerbCreate, - TargetName: args.Name, - TargetType: TargetTypeReleasePlan, - CreatedAt: time.Now().Unix(), + sectionSnapshot, err := buildReleasePlanInputSnapshot(args) + if err == nil { + err = createReleasePlanVersion(planID, 1, sectionSnapshot, c.UserName, c.Account, releasePlanVersionSectionPlan, releasePlanVersionSectionName(releasePlanVersionSectionPlan, args.Name), VerbCreate) + } + if err != nil { + log.Errorf("create release plan version error: %v", err) + } + if err := createReleasePlanLog(&models.ReleasePlanLog{ + PlanID: planID, + Username: c.UserName, + Account: c.Account, + Verb: VerbCreate, + TargetName: args.Name, + TargetType: TargetTypeReleasePlan, + Version: 1, + SectionKey: releasePlanVersionSectionPlan, + SectionName: releasePlanVersionSectionName(releasePlanVersionSectionPlan, args.Name), + SectionType: releasePlanVersionSectionGroupType(releasePlanVersionSectionPlan), + CreatedAt: time.Now().Unix(), }); err != nil { log.Errorf("create release plan log error: %v", err) } @@ -331,8 +343,20 @@ func GetReleasePlanLogs(id string) (*GetReleasePlanLogsResponse, error) { return nil, errors.Wrap(err, "get release plan logs") } + sanitizedLogs := make([]*models.ReleasePlanLog, 0, len(logs)) + for _, item := range logs { + if item == nil { + continue + } + cloned := *item + cloned.TargetType = normalizeReleasePlanTargetType(item.TargetType) + cloned.Before = sanitizeReleasePlanValueForDisplay(item.Before) + cloned.After = sanitizeReleasePlanValueForDisplay(item.After) + sanitizedLogs = append(sanitizedLogs, &cloned) + } + return &GetReleasePlanLogsResponse{ - List: logs, + List: sanitizedLogs, I18N: &ReleasePlanLogI18N{ VerbI18Map: VerbI18nMap, TargetTypeI18Map: TargetTypeI18nMap, @@ -342,6 +366,180 @@ func GetReleasePlanLogs(id string) (*GetReleasePlanLogsResponse, error) { }, nil } +func resolveReleasePlanLogBaseSnapshot(baseSnapshot interface{}, originalPlan *models.ReleasePlan, sectionKey string) (interface{}, error) { + if baseSnapshot != nil { + return baseSnapshot, nil + } + return buildReleasePlanVersionSnapshot(originalPlan, sectionKey) +} + +func hasReleasePlanSnapshotChanges(beforeSnapshot, afterSnapshot interface{}) bool { + return !releasePlanSnapshotValuesEqual(beforeSnapshot, afterSnapshot) +} + +func releasePlanSnapshotValuesEqual(left, right interface{}) bool { + switch leftValue := left.(type) { + case map[string]interface{}: + rightValue, ok := right.(map[string]interface{}) + if !ok { + return isEmptyReleasePlanSnapshotValue(left) && isEmptyReleasePlanSnapshotValue(right) + } + return releasePlanSnapshotMapsEqual(leftValue, rightValue) + case []interface{}: + rightValue, ok := right.([]interface{}) + if !ok { + return isEmptyReleasePlanSnapshotValue(left) && isEmptyReleasePlanSnapshotValue(right) + } + return releasePlanSnapshotListsEqual(leftValue, rightValue) + default: + switch right.(type) { + case map[string]interface{}, []interface{}: + return isEmptyReleasePlanSnapshotValue(left) && isEmptyReleasePlanSnapshotValue(right) + } + } + + if isEmptyReleasePlanSnapshotScalarValue(left) && isEmptyReleasePlanSnapshotScalarValue(right) { + return true + } + leftNumber, leftIsNumber := releasePlanSnapshotNumber(left) + rightNumber, rightIsNumber := releasePlanSnapshotNumber(right) + if leftIsNumber || rightIsNumber { + return leftIsNumber && rightIsNumber && leftNumber == rightNumber + } + + return left == right +} + +func releasePlanSnapshotMapsEqual(left, right map[string]interface{}) bool { + for key, leftValue := range left { + rightValue, exists := right[key] + if !exists { + if !isEmptyReleasePlanSnapshotValue(leftValue) { + return false + } + continue + } + if !releasePlanSnapshotValuesEqual(leftValue, rightValue) { + return false + } + } + + for key, rightValue := range right { + if _, exists := left[key]; exists { + continue + } + if !isEmptyReleasePlanSnapshotValue(rightValue) { + return false + } + } + + return true +} + +func releasePlanSnapshotListsEqual(left, right []interface{}) bool { + leftIdx, rightIdx := 0, 0 + for { + for leftIdx < len(left) && isEmptyReleasePlanSnapshotValue(left[leftIdx]) { + leftIdx++ + } + for rightIdx < len(right) && isEmptyReleasePlanSnapshotValue(right[rightIdx]) { + rightIdx++ + } + if leftIdx == len(left) || rightIdx == len(right) { + break + } + if !releasePlanSnapshotValuesEqual(left[leftIdx], right[rightIdx]) { + return false + } + leftIdx++ + rightIdx++ + } + + for leftIdx < len(left) { + if !isEmptyReleasePlanSnapshotValue(left[leftIdx]) { + return false + } + leftIdx++ + } + for rightIdx < len(right) { + if !isEmptyReleasePlanSnapshotValue(right[rightIdx]) { + return false + } + rightIdx++ + } + + return true +} + +func isEmptyReleasePlanSnapshotValue(value interface{}) bool { + switch typedValue := value.(type) { + case map[string]interface{}: + for _, item := range typedValue { + if !isEmptyReleasePlanSnapshotValue(item) { + return false + } + } + return true + case []interface{}: + for _, item := range typedValue { + if !isEmptyReleasePlanSnapshotValue(item) { + return false + } + } + return true + default: + return isEmptyReleasePlanSnapshotScalarValue(value) + } +} + +func isEmptyReleasePlanSnapshotScalarValue(value interface{}) bool { + switch typedValue := value.(type) { + case nil: + return true + case string: + return typedValue == "" + case bool: + return !typedValue + default: + number, ok := releasePlanSnapshotNumber(value) + return ok && number == 0 + } +} + +func releasePlanSnapshotNumber(value interface{}) (float64, bool) { + switch typedValue := value.(type) { + case int: + return float64(typedValue), true + case int8: + return float64(typedValue), true + case int16: + return float64(typedValue), true + case int32: + return float64(typedValue), true + case int64: + return float64(typedValue), true + case uint: + return float64(typedValue), true + case uint8: + return float64(typedValue), true + case uint16: + return float64(typedValue), true + case uint32: + return float64(typedValue), true + case uint64: + return float64(typedValue), true + case float32: + return float64(typedValue), true + case float64: + return typedValue, true + case json.Number: + number, err := typedValue.Float64() + return number, err == nil + default: + return 0, false + } +} + func DeleteReleasePlan(c *gin.Context, username, id string) error { info, err := mongodb.NewReleasePlanColl().GetByID(context.Background(), id) if err != nil { @@ -401,6 +599,10 @@ func UpdateReleasePlan(c *handler.Context, planID string, args *UpdateReleasePla if err != nil { return errors.Wrap(err, "get plan") } + originalPlan, err := cloneReleasePlan(plan) + if err != nil { + return errors.Wrap(err, "clone plan") + } if plan.Status != config.ReleasePlanStatusPlanning { return errors.Errorf("plan status is %s, can not update", plan.Status) @@ -414,11 +616,44 @@ func UpdateReleasePlan(c *handler.Context, planID string, args *UpdateReleasePla if err = updater.Lint(); err != nil { return errors.Wrap(err, "lint") } - before, after, err := updater.Update(plan) - if err != nil { + if err = updater.Update(plan); err != nil { return errors.Wrap(err, "update") } + sectionKey, sectionName, err := releasePlanVersionSectionKeyByVerb(originalPlan, plan, args) + if err != nil { + return errors.Wrap(err, "resolve release plan section") + } + currentSnapshot, err := buildReleasePlanVersionSnapshot(plan, sectionKey) + if err != nil { + return errors.Wrap(err, "build release plan current snapshot") + } + var baseSnapshot interface{} + nextVersion := originalPlan.Version + 1 + needBaseSnapshot, previousVersion, err := shouldBuildReleasePlanVersionBaseSnapshot(planID, sectionKey, nextVersion, args.Verb) + if err != nil { + return errors.Wrap(err, "check release plan base snapshot") + } + if !needBaseSnapshot { + needBaseSnapshot, err = shouldBuildReleasePlanWorkflowDisplayBaseSnapshot(planID, sectionKey, previousVersion, currentSnapshot) + if err != nil { + return errors.Wrap(err, "check release plan workflow base snapshot") + } + } + if needBaseSnapshot { + baseSnapshot, err = buildReleasePlanVersionSnapshot(originalPlan, sectionKey) + if err != nil { + return errors.Wrap(err, "build release plan base snapshot") + } + } + logBaseSnapshot, err := resolveReleasePlanLogBaseSnapshot(baseSnapshot, originalPlan, sectionKey) + if err != nil { + return errors.Wrap(err, "build release plan log base snapshot") + } + shouldCreateLog := hasReleasePlanSnapshotChanges(logBaseSnapshot, currentSnapshot) + + plan.Version = nextVersion + plan.UpdatedBy = c.UserName plan.UpdateTime = time.Now().Unix() @@ -442,21 +677,29 @@ func UpdateReleasePlan(c *handler.Context, planID string, args *UpdateReleasePla return errors.Wrap(err, "update plan") } - go func() { - if err := mongodb.NewReleasePlanLogColl().Create(&models.ReleasePlanLog{ - PlanID: planID, - Username: c.UserName, - Account: c.Account, - Verb: updater.Verb(), - Before: before, - After: after, - TargetName: updater.TargetName(), - TargetType: updater.TargetType(), - CreatedAt: time.Now().Unix(), - }); err != nil { + logItem := &models.ReleasePlanLog{ + PlanID: planID, + Username: c.UserName, + Account: c.Account, + Verb: updater.Verb(), + TargetName: updater.TargetName(), + TargetType: updater.TargetType(), + Version: plan.Version, + SectionKey: sectionKey, + SectionName: releasePlanVersionSectionName(sectionKey, sectionName), + SectionType: releasePlanVersionSectionGroupType(sectionKey), + CreatedAt: time.Now().Unix(), + } + if err := createReleasePlanVersionWithBaseSnapshot(planID, plan.Version, previousVersion, baseSnapshot, currentSnapshot, c.UserName, c.Account, sectionKey, releasePlanVersionSectionName(sectionKey, sectionName), string(args.Verb)); err != nil { + log.Errorf("create release plan version error: %v", err) + } else if shouldCreateLog { + if err := createReleasePlanLog(logItem); err != nil { log.Errorf("create release plan log error: %v", err) } - }() + } + if err := broadcastReleasePlanCollaboration(planID); err != nil { + log.Errorf("broadcast release plan collaboration error: %v", err) + } return nil } @@ -477,6 +720,10 @@ func GetReleasePlanJobDetail(planID, jobID string) (*commonmodels.ReleaseJob, er if spec.Workflow == nil { return nil, fmt.Errorf("workflow is nil") } + spec.Workflow, err = normalizeReleasePlanWorkflowForController(spec.Workflow) + if err != nil { + return nil, fmt.Errorf("invalid workflow for job: %s. normalize error: %s", releasePlanJob.Name, err) + } workflowController := controller.CreateWorkflowController(spec.Workflow) if err := workflowController.UpdateWithLatestWorkflow(nil); err != nil { @@ -495,6 +742,47 @@ func GetReleasePlanJobDetail(planID, jobID string) (*commonmodels.ReleaseJob, er return nil, fmt.Errorf("failed to find release plan job with id: %s. Job does not exist", jobID) } +func findReleasePlanJob(plan *models.ReleasePlan, jobID string) (*models.ReleaseJob, error) { + if plan == nil { + return nil, errors.New("nil release plan") + } + for _, job := range plan.Jobs { + if job.ID == jobID { + return job, nil + } + } + return nil, fmt.Errorf("failed to find release plan job with id: %s. Job does not exist", jobID) +} + +func buildReleasePlanJobLogSnapshot(job *models.ReleaseJob) map[string]interface{} { + if job == nil { + return nil + } + + snapshot := map[string]interface{}{ + "type": job.Type, + "status": job.Status, + "executed_by": job.ExecutedBy, + "executed_time": job.ExecutedTime, + } + + switch job.Type { + case config.JobText: + spec := new(models.TextReleaseJobSpec) + if err := models.IToi(job.Spec, spec); err == nil { + snapshot["remark"] = spec.Remark + } + case config.JobWorkflow: + spec := new(models.WorkflowReleaseJobSpec) + if err := models.IToi(job.Spec, spec); err == nil { + snapshot["workflow_status"] = spec.Status + snapshot["task_id"] = spec.TaskID + } + } + + return snapshot +} + type ExecuteReleaseJobArgs struct { ID string `json:"id"` Name string `json:"name"` @@ -531,6 +819,12 @@ func ExecuteReleaseJob(c *handler.Context, planID string, args *ExecuteReleaseJo } } + jobBefore, err := findReleasePlanJob(plan, args.ID) + if err != nil { + return errors.Wrap(err, "find release job before execute") + } + beforeSnapshot := buildReleasePlanJobLogSnapshot(jobBefore) + executor, err := NewReleaseJobExecutor(&ExecuteReleaseJobContext{ AuthResources: c.Resources, UserID: c.UserID, @@ -543,6 +837,11 @@ func ExecuteReleaseJob(c *handler.Context, planID string, args *ExecuteReleaseJo if err = executor.Execute(plan); err != nil { return errors.Wrap(err, "execute") } + jobAfter, err := findReleasePlanJob(plan, args.ID) + if err != nil { + return errors.Wrap(err, "find release job after execute") + } + afterSnapshot := buildReleasePlanJobLogSnapshot(jobAfter) plan.UpdatedBy = c.UserName plan.UpdateTime = time.Now().Unix() @@ -578,11 +877,13 @@ func ExecuteReleaseJob(c *handler.Context, planID string, args *ExecuteReleaseJo } go func() { - if err := mongodb.NewReleasePlanLogColl().Create(&models.ReleasePlanLog{ + if err := createReleasePlanLog(&models.ReleasePlanLog{ PlanID: planID, Username: c.UserName, Account: c.Account, Verb: VerbExecute, + Before: beforeSnapshot, + After: afterSnapshot, TargetName: args.Name, TargetType: TargetTypeReleaseJob, CreatedAt: time.Now().Unix(), @@ -630,6 +931,12 @@ func RetryReleaseJob(c *handler.Context, planID string, args *RetryReleaseJobArg } } + jobBefore, err := findReleasePlanJob(plan, args.ID) + if err != nil { + return errors.Wrap(err, "find release job before retry") + } + beforeSnapshot := buildReleasePlanJobLogSnapshot(jobBefore) + retryer, err := NewReleaseJobRetryer(&RetryReleaseJobContext{ AuthResources: c.Resources, UserID: c.UserID, @@ -637,11 +944,16 @@ func RetryReleaseJob(c *handler.Context, planID string, args *RetryReleaseJobArg UserName: c.UserName, }, args) if err != nil { - return errors.Wrap(err, "new release job executor") + return errors.Wrap(err, "new release job retryer") } if err = retryer.Retry(plan); err != nil { - return errors.Wrap(err, "execute") + return errors.Wrap(err, "retry") } + jobAfter, err := findReleasePlanJob(plan, args.ID) + if err != nil { + return errors.Wrap(err, "find release job after retry") + } + afterSnapshot := buildReleasePlanJobLogSnapshot(jobAfter) plan.UpdatedBy = c.UserName plan.UpdateTime = time.Now().Unix() @@ -679,11 +991,13 @@ func RetryReleaseJob(c *handler.Context, planID string, args *RetryReleaseJobArg } go func() { - if err := mongodb.NewReleasePlanLogColl().Create(&models.ReleasePlanLog{ + if err := createReleasePlanLog(&models.ReleasePlanLog{ PlanID: planID, Username: c.UserName, Account: c.Account, Verb: VerbRetry, + Before: beforeSnapshot, + After: afterSnapshot, TargetName: args.Name, TargetType: TargetTypeReleaseJob, CreatedAt: time.Now().Unix(), @@ -776,19 +1090,11 @@ func ScheduleExecuteReleasePlan(c *handler.Context, planID, jobID string) error Type: string(job.Type), } - go func() { - if err := mongodb.NewReleasePlanLogColl().Create(&models.ReleasePlanLog{ - PlanID: planID, - Username: UserNameSystem, - Account: "", - Verb: VerbExecute, - TargetName: args.Name, - TargetType: TargetTypeReleaseJob, - CreatedAt: time.Now().Unix(), - }); err != nil { - log.Errorf("create release plan log error: %v", err) - } - }() + jobBefore, err := findReleasePlanJob(plan, job.ID) + if err != nil { + return err + } + beforeSnapshot := buildReleasePlanJobLogSnapshot(jobBefore) executor, err := NewReleaseJobExecutor(&ExecuteReleaseJobContext{ AuthResources: c.Resources, @@ -807,6 +1113,12 @@ func ScheduleExecuteReleasePlan(c *handler.Context, planID, jobID string) error return err } + jobAfter, err := findReleasePlanJob(plan, job.ID) + if err != nil { + return err + } + afterSnapshot := buildReleasePlanJobLogSnapshot(jobAfter) + plan.UpdatedBy = UserNameSystem plan.UpdateTime = time.Now().Unix() @@ -831,6 +1143,22 @@ func ScheduleExecuteReleasePlan(c *handler.Context, planID, jobID string) error log.Error(err) return err } + + go func(jobName string, before, after map[string]interface{}) { + if err := createReleasePlanLog(&models.ReleasePlanLog{ + PlanID: planID, + Username: UserNameSystem, + Account: "", + Verb: VerbExecute, + Before: before, + After: after, + TargetName: jobName, + TargetType: TargetTypeReleaseJob, + CreatedAt: time.Now().Unix(), + }); err != nil { + log.Errorf("create release plan log error: %v", err) + } + }(job.Name, beforeSnapshot, afterSnapshot) } } @@ -873,6 +1201,12 @@ func SkipReleaseJob(c *handler.Context, planID string, args *SkipReleaseJobArgs, } } + jobBefore, err := findReleasePlanJob(plan, args.ID) + if err != nil { + return errors.Wrap(err, "find release job before skip") + } + beforeSnapshot := buildReleasePlanJobLogSnapshot(jobBefore) + skipper, err := NewReleaseJobSkipper(&SkipReleaseJobContext{ AuthResources: c.Resources, UserID: c.UserID, @@ -885,6 +1219,11 @@ func SkipReleaseJob(c *handler.Context, planID string, args *SkipReleaseJobArgs, if err = skipper.Skip(plan); err != nil { return errors.Wrap(err, "skip") } + jobAfter, err := findReleasePlanJob(plan, args.ID) + if err != nil { + return errors.Wrap(err, "find release job after skip") + } + afterSnapshot := buildReleasePlanJobLogSnapshot(jobAfter) plan.UpdatedBy = c.UserName plan.UpdateTime = time.Now().Unix() @@ -905,6 +1244,8 @@ func SkipReleaseJob(c *handler.Context, planID string, args *SkipReleaseJobArgs, } else { plan.SuccessTime = time.Now().Unix() } + + sendWebhook = true } if err = mongodb.NewReleasePlanColl().UpdateByID(ctx, planID, plan); err != nil { @@ -918,11 +1259,13 @@ func SkipReleaseJob(c *handler.Context, planID string, args *SkipReleaseJobArgs, } go func() { - if err := mongodb.NewReleasePlanLogColl().Create(&models.ReleasePlanLog{ + if err := createReleasePlanLog(&models.ReleasePlanLog{ PlanID: planID, Username: c.UserName, Account: c.Account, Verb: VerbSkip, + Before: beforeSnapshot, + After: afterSnapshot, TargetName: args.Name, TargetType: TargetTypeReleaseJob, CreatedAt: time.Now().Unix(), @@ -954,7 +1297,9 @@ func UpdateReleasePlanStatus(c *handler.Context, planID, targetStatus string, is return errors.Errorf("only manager can update plan status") } - if !lo.Contains(config.ReleasePlanStatusMap[plan.Status], config.ReleasePlanStatus(targetStatus)) { + newStatus := config.ReleasePlanStatus(targetStatus) + oldStatus := plan.Status + if !lo.Contains(config.ReleasePlanStatusMap[plan.Status], newStatus) { return errors.Errorf("can't convert plan status %s to %s", plan.Status, targetStatus) } @@ -963,8 +1308,6 @@ func UpdateReleasePlanStatus(c *handler.Context, planID, targetStatus string, is return errors.Wrap(err, "get user") } - detail := "" - sendWebhook := false hookSetting, err := mongodb.NewSystemSettingColl().GetReleasePlanHookSetting() if err != nil { @@ -989,14 +1332,14 @@ func UpdateReleasePlanStatus(c *handler.Context, planID, targetStatus string, is config.ReleasePlanStatusWaitForExecuteExternalCheckFailed, config.ReleasePlanStatusWaitForAllDoneExternalCheck, config.ReleasePlanStatusWaitForAllDoneExternalCheckFailed: - if config.ReleasePlanStatus(targetStatus) != config.ReleasePlanStatusPlanning && config.ReleasePlanStatus(targetStatus) != config.ReleasePlanStatusCancel { + if newStatus != config.ReleasePlanStatusPlanning && newStatus != config.ReleasePlanStatusCancel { return fmt.Errorf("can't update status, current status: %s", plan.Status) } } - plan.Status = config.ReleasePlanStatus(targetStatus) + plan.Status = newStatus // target status check and update - switch config.ReleasePlanStatus(targetStatus) { + switch newStatus { case config.ReleasePlanStatusPlanning: for _, job := range plan.Jobs { job.LastStatus = job.Status @@ -1116,6 +1459,8 @@ func UpdateReleasePlanStatus(c *handler.Context, planID, targetStatus string, is if err := upsertReleasePlanCron(plan.ID.Hex(), plan.Name, plan.Index, plan.Status, plan.ScheduleExecuteTime); err != nil { return errors.Wrap(err, "upsert release plan cron") } + updatedStatus := plan.Status + detail := fmt.Sprintf("状态从 %s 变更为 %s", oldStatus, updatedStatus) if sendWebhook { if err := sendReleasePlanHook(plan, hookSetting); err != nil { @@ -1124,16 +1469,16 @@ func UpdateReleasePlanStatus(c *handler.Context, planID, targetStatus string, is } go func() { - if err := mongodb.NewReleasePlanLogColl().Create(&models.ReleasePlanLog{ + if err := createReleasePlanLog(&models.ReleasePlanLog{ PlanID: planID, Username: c.UserName, Account: c.Account, Verb: VerbUpdate, - TargetName: TargetTypeReleasePlanStatus, + TargetName: releasePlanTargetTypeDisplayName(TargetTypeReleasePlanStatus), TargetType: TargetTypeReleasePlanStatus, Detail: detail, - Before: plan.Status, - After: targetStatus, + Before: oldStatus, + After: updatedStatus, CreatedAt: time.Now().Unix(), }); err != nil { log.Errorf("create release plan log error: %v", err) @@ -1204,16 +1549,18 @@ func ApproveReleasePlan(c *handler.Context, planID string, req *ApproveRequest) plan.Approval.Status = config.StatusPassed } var planLog *models.ReleasePlanLog + beforeStatus := config.ReleasePlanStatusWaitForApprove switch plan.Approval.Status { case config.StatusPassed: planLog = &models.ReleasePlanLog{ PlanID: planID, Username: UserNameSystem, + Account: "", Verb: VerbUpdate, - TargetName: TargetTypeReleasePlanStatus, + TargetName: releasePlanTargetTypeDisplayName(TargetTypeReleasePlanStatus), TargetType: TargetTypeReleasePlanStatus, Detail: DetailApprovalPass, - After: config.ReleasePlanStatusExecuting, + Before: beforeStatus, CreatedAt: time.Now().Unix(), } plan.Status = config.ReleasePlanStatusExecuting @@ -1235,11 +1582,19 @@ func ApproveReleasePlan(c *handler.Context, planID string, req *ApproveRequest) sendWebhook = true setReleaseJobsForExecuting(plan) + planLog.After = plan.Status case config.StatusReject: planLog = &models.ReleasePlanLog{ - PlanID: planID, - Detail: DetailApprovalReject, - CreatedAt: time.Now().Unix(), + PlanID: planID, + Username: UserNameSystem, + Account: "", + Verb: VerbUpdate, + TargetName: releasePlanTargetTypeDisplayName(TargetTypeReleasePlanStatus), + TargetType: TargetTypeReleasePlanStatus, + Detail: DetailApprovalReject, + Before: beforeStatus, + After: config.ReleasePlanStatusApprovalDenied, + CreatedAt: time.Now().Unix(), } plan.Status = config.ReleasePlanStatusApprovalDenied @@ -1261,7 +1616,7 @@ func ApproveReleasePlan(c *handler.Context, planID string, req *ApproveRequest) return } - if err := mongodb.NewReleasePlanLogColl().Create(planLog); err != nil { + if err := createReleasePlanLog(planLog); err != nil { log.Errorf("create release plan log error: %v", err) } }() diff --git a/pkg/microservice/aslan/core/release_plan/service/section_snapshot.go b/pkg/microservice/aslan/core/release_plan/service/section_snapshot.go new file mode 100644 index 0000000000..a81738dfa8 --- /dev/null +++ b/pkg/microservice/aslan/core/release_plan/service/section_snapshot.go @@ -0,0 +1,859 @@ +package service + +import ( + "encoding/json" + "reflect" + "sort" + "strings" + + "github.com/pkg/errors" + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/bson/bsoncodec" + "go.mongodb.org/mongo-driver/bson/bsonoptions" + "go.mongodb.org/mongo-driver/bson/bsontype" + + "github.com/koderover/zadig/v2/pkg/microservice/aslan/config" + "github.com/koderover/zadig/v2/pkg/microservice/aslan/core/common/repository/models" + "github.com/koderover/zadig/v2/pkg/microservice/aslan/core/workflow/service/workflow/controller" + "github.com/koderover/zadig/v2/pkg/tool/log" +) + +const ( + releasePlanVersionSectionPlan = "plan" + releasePlanVersionSectionMetadata = "metadata" + releasePlanVersionSectionApproval = "approval" + releasePlanVersionSectionJobsOrder = "jobs_order" + releasePlanVersionSectionJobPrefix = "job:" +) + +var releasePlanWorkflowControllerBSONRegistry = func() *bsoncodec.Registry { + nilSliceCodec := bsoncodec.NewSliceCodec(bsonoptions.SliceCodec().SetEncodeNilAsEmpty(true)) + tM := reflect.TypeOf(bson.M{}) + return bson.NewRegistryBuilder().RegisterTypeMapEntry(bsontype.EmbeddedDocument, tM).RegisterDefaultEncoder(reflect.Slice, nilSliceCodec).Build() +}() + +func isReleasePlanVersionMetadataSection(sectionKey string) bool { + return sectionKey == releasePlanVersionSectionMetadata || strings.HasPrefix(sectionKey, releasePlanVersionSectionMetadata+":") +} + +func releasePlanVersionSectionName(sectionKey, fallbackName string) string { + switch { + case sectionKey == releasePlanVersionSectionPlan: + return "发布计划" + case isReleasePlanVersionMetadataSection(sectionKey): + if name, exists := releasePlanCollabMetadataSectionNames[sectionKey]; exists { + return name + } + return "基础信息" + case sectionKey == releasePlanVersionSectionApproval: + return "审批配置" + case sectionKey == releasePlanVersionSectionJobsOrder: + return "发布内容顺序" + case strings.HasPrefix(sectionKey, releasePlanVersionSectionJobPrefix): + if fallbackName != "" { + return fallbackName + } + return "发布内容" + default: + return fallbackName + } +} + +func releasePlanVersionSectionGroupType(sectionKey string) string { + switch { + case isReleasePlanVersionMetadataSection(sectionKey): + return "metadata" + case sectionKey == releasePlanVersionSectionApproval: + return "approval" + case sectionKey == releasePlanVersionSectionJobsOrder: + return "jobs_order" + case strings.HasPrefix(sectionKey, releasePlanVersionSectionJobPrefix): + return "job" + default: + return "plan" + } +} + +func cloneReleasePlan(plan *models.ReleasePlan) (*models.ReleasePlan, error) { + if plan == nil { + return nil, errors.New("nil release plan") + } + + payload, err := json.Marshal(plan) + if err != nil { + return nil, err + } + + resp := new(models.ReleasePlan) + if err := json.Unmarshal(payload, resp); err != nil { + return nil, err + } + return resp, nil +} + +func releasePlanVersionSectionKeyByVerb(planBefore, planAfter *models.ReleasePlan, args *UpdateReleasePlanArgs) (string, string, error) { + if args == nil { + return releasePlanVersionSectionPlan, "发布计划", nil + } + + switch args.Verb { + case VerbUpdateName: + return releasePlanCollabSectionMetadataName, releasePlanVersionSectionName(releasePlanCollabSectionMetadataName, ""), nil + case VerbUpdateDesc: + return releasePlanCollabSectionMetadataDescription, releasePlanVersionSectionName(releasePlanCollabSectionMetadataDescription, ""), nil + case VerbUpdateTimeRange: + return releasePlanCollabSectionMetadataTimeRange, releasePlanVersionSectionName(releasePlanCollabSectionMetadataTimeRange, ""), nil + case VerbUpdateScheduleExecuteTime: + return releasePlanCollabSectionMetadataScheduleExecute, releasePlanVersionSectionName(releasePlanCollabSectionMetadataScheduleExecute, ""), nil + case VerbUpdateManager: + return releasePlanCollabSectionMetadataManager, releasePlanVersionSectionName(releasePlanCollabSectionMetadataManager, ""), nil + case VerbUpdateJiraSprint: + return releasePlanCollabSectionMetadataJiraSprint, releasePlanVersionSectionName(releasePlanCollabSectionMetadataJiraSprint, ""), nil + case VerbUpdateApproval, VerbDeleteApproval: + return releasePlanVersionSectionApproval, "审批配置", nil + case VerbReorderReleaseJob: + return releasePlanVersionSectionJobsOrder, "发布内容顺序", nil + case VerbUpdateReleaseJob, VerbDeleteReleaseJob: + jobID, _ := extractReleasePlanJobID(args.Spec) + if jobID == "" { + return "", "", errors.New("missing release job id") + } + jobName := releasePlanVersionSectionJobName(planAfter, jobID) + if jobName == "" { + jobName = releasePlanVersionSectionJobName(planBefore, jobID) + } + return releasePlanVersionSectionJobPrefix + jobID, jobName, nil + case VerbCreateReleaseJob: + createdJob := findCreatedReleasePlanJob(planBefore, planAfter) + if createdJob == nil { + return "", "", errors.New("failed to locate created release job") + } + return releasePlanVersionSectionJobPrefix + createdJob.ID, createdJob.Name, nil + default: + return releasePlanVersionSectionPlan, "发布计划", nil + } +} + +func extractReleasePlanJobID(spec interface{}) (string, error) { + if spec == nil { + return "", nil + } + payload, err := json.Marshal(spec) + if err != nil { + return "", err + } + resp := struct { + ID string `json:"id"` + }{} + if err := json.Unmarshal(payload, &resp); err != nil { + return "", err + } + return resp.ID, nil +} + +func releasePlanVersionSectionJobName(plan *models.ReleasePlan, jobID string) string { + if plan == nil { + return "" + } + for _, job := range plan.Jobs { + if job.ID == jobID { + return job.Name + } + } + return "" +} + +func findCreatedReleasePlanJob(planBefore, planAfter *models.ReleasePlan) *models.ReleaseJob { + if planAfter == nil { + return nil + } + beforeJobIDs := make(map[string]struct{}, len(planBefore.Jobs)) + if planBefore != nil { + for _, job := range planBefore.Jobs { + beforeJobIDs[job.ID] = struct{}{} + } + } + for _, job := range planAfter.Jobs { + if _, exists := beforeJobIDs[job.ID]; !exists { + return job + } + } + return nil +} + +func buildReleasePlanVersionSnapshot(plan *models.ReleasePlan, sectionKey string) (interface{}, error) { + if plan == nil { + return nil, nil + } + + switch { + case sectionKey == releasePlanVersionSectionPlan: + return buildReleasePlanInputSnapshot(plan) + case isReleasePlanVersionMetadataSection(sectionKey): + return buildReleasePlanMetadataSectionSnapshot(plan, sectionKey), nil + case sectionKey == releasePlanVersionSectionApproval: + return buildReleasePlanApprovalSnapshot(plan.Approval) + case sectionKey == releasePlanVersionSectionJobsOrder: + return buildReleasePlanJobsOrderSnapshot(plan), nil + case strings.HasPrefix(sectionKey, releasePlanVersionSectionJobPrefix): + jobID := strings.TrimPrefix(sectionKey, releasePlanVersionSectionJobPrefix) + job, err := findReleasePlanJob(plan, jobID) + if err != nil { + return nil, nil + } + return buildReleasePlanJobInputSnapshot(job) + default: + return nil, errors.Errorf("unsupported release plan version section key: %s", sectionKey) + } +} + +func buildReleasePlanInputSnapshot(plan *models.ReleasePlan) (interface{}, error) { + approvalSnapshot, err := buildReleasePlanApprovalSnapshot(plan.Approval) + if err != nil { + return nil, err + } + + resp := map[string]interface{}{ + "metadata": buildReleasePlanMetadataSnapshot(plan), + "approval": approvalSnapshot, + "jobs": make([]interface{}, 0, len(plan.Jobs)), + "jobs_order": buildReleasePlanJobsOrderSnapshot(plan), + } + for _, job := range plan.Jobs { + snapshot, err := buildReleasePlanJobInputSnapshot(job) + if err != nil { + return nil, err + } + resp["jobs"] = append(resp["jobs"].([]interface{}), snapshot) + } + return resp, nil +} + +func buildReleasePlanMetadataSnapshot(plan *models.ReleasePlan) map[string]interface{} { + if plan == nil { + return nil + } + return map[string]interface{}{ + "name": plan.Name, + "manager": plan.Manager, + "manager_id": plan.ManagerID, + "start_time": plan.StartTime, + "end_time": plan.EndTime, + "schedule_execute_time": plan.ScheduleExecuteTime, + "description": plan.Description, + "jira_sprint_association": sanitizeReleasePlanValue(plan.JiraSprintAssociation), + } +} + +func buildReleasePlanMetadataSectionSnapshot(plan *models.ReleasePlan, sectionKey string) map[string]interface{} { + metadata := buildReleasePlanMetadataSnapshot(plan) + if metadata == nil { + return nil + } + + switch sectionKey { + case releasePlanVersionSectionMetadata: + return metadata + case releasePlanCollabSectionMetadataName: + return map[string]interface{}{ + "name": metadata["name"], + } + case releasePlanCollabSectionMetadataManager: + return map[string]interface{}{ + "manager": metadata["manager"], + "manager_id": metadata["manager_id"], + } + case releasePlanCollabSectionMetadataTimeRange: + return map[string]interface{}{ + "start_time": metadata["start_time"], + "end_time": metadata["end_time"], + } + case releasePlanCollabSectionMetadataScheduleExecute: + return map[string]interface{}{ + "schedule_execute_time": metadata["schedule_execute_time"], + } + case releasePlanCollabSectionMetadataDescription: + return map[string]interface{}{ + "description": metadata["description"], + } + case releasePlanCollabSectionMetadataJiraSprint: + return map[string]interface{}{ + "jira_sprint_association": metadata["jira_sprint_association"], + } + default: + return metadata + } +} + +func buildReleasePlanApprovalSnapshot(approval *models.Approval) (interface{}, error) { + if approval == nil { + return nil, nil + } + + genericValue, err := toReleasePlanGenericValue(approval) + if err != nil { + return nil, err + } + return sanitizeReleasePlanValue(filterReleasePlanApprovalInputValue(genericValue)), nil +} + +func filterReleasePlanApprovalInputValue(value interface{}) interface{} { + switch typedValue := value.(type) { + case map[string]interface{}: + resp := make(map[string]interface{}, len(typedValue)) + for key, item := range typedValue { + if shouldDropReleasePlanApprovalInputField(key) { + continue + } + resp[key] = filterReleasePlanApprovalInputValue(item) + } + return resp + case []interface{}: + resp := make([]interface{}, 0, len(typedValue)) + for _, item := range typedValue { + resp = append(resp, filterReleasePlanApprovalInputValue(item)) + } + return resp + default: + return value + } +} + +func shouldDropReleasePlanApprovalInputField(key string) bool { + dropKeys := map[string]struct{}{ + "status": {}, + "instance_code": {}, + "instance_id": {}, + "approval_instance": {}, + "task_list": {}, + "timeline": {}, + "reject_or_approve": {}, + "operation_time": {}, + "comment": {}, + "approval_node_details": {}, + "flat_approve_users": {}, + } + _, exists := dropKeys[key] + return exists +} + +func buildReleasePlanJobsOrderSnapshot(plan *models.ReleasePlan) []interface{} { + resp := make([]interface{}, 0) + if plan == nil { + return resp + } + for _, job := range plan.Jobs { + resp = append(resp, map[string]interface{}{ + "id": job.ID, + "name": job.Name, + }) + } + return resp +} + +func buildReleasePlanJobInputSnapshot(job *models.ReleaseJob) (interface{}, error) { + if job == nil { + return nil, nil + } + + spec, err := buildReleasePlanJobInputSpec(job.Type, job.Spec) + if err != nil { + return nil, err + } + + return map[string]interface{}{ + "id": job.ID, + "name": job.Name, + "manager": job.Manager, + "manager_id": job.ManagerID, + "type": job.Type, + "spec": spec, + }, nil +} + +func buildReleasePlanJobInputSpec(jobType config.ReleasePlanJobType, spec interface{}) (interface{}, error) { + switch jobType { + case config.JobText: + inputSpec := new(models.TextReleaseJobSpec) + if err := models.IToi(spec, inputSpec); err != nil { + return nil, err + } + return sanitizeReleasePlanValue(inputSpec), nil + case config.JobWorkflow: + genericValue, err := toReleasePlanGenericValue(spec) + if err != nil { + return nil, err + } + specMap, ok := getMapField(genericValue) + if !ok { + return nil, nil + } + workflowSnapshot, err := buildReleasePlanWorkflowVersionSnapshot(spec, specMap["workflow"]) + if err != nil { + return nil, err + } + return map[string]interface{}{ + "workflow": workflowSnapshot, + }, nil + default: + return sanitizeReleasePlanValue(spec), nil + } +} + +func buildReleasePlanWorkflowVersionSnapshot(spec, rawWorkflow interface{}) (interface{}, error) { + if workflow, ok := enrichReleasePlanWorkflowWithLatest(spec); ok { + return buildReleasePlanWorkflowInputSnapshot(workflow) + } + + return buildReleasePlanWorkflowInputSnapshot(rawWorkflow) +} + +func enrichReleasePlanWorkflowWithLatest(spec interface{}) (_ interface{}, ok bool) { + defer func() { + if r := recover(); r != nil { + warnReleasePlanWorkflowRecover(r) + ok = false + } + }() + + workflowSpec := new(models.WorkflowReleaseJobSpec) + if err := models.IToi(spec, workflowSpec); err != nil { + return nil, false + } + applyReleasePlanWorkflowLatestLookupCompat(spec, workflowSpec) + if workflowSpec.Workflow == nil || workflowSpec.Workflow.Name == "" { + return nil, false + } + normalizedWorkflow, err := normalizeReleasePlanWorkflowForController(workflowSpec.Workflow) + if err != nil { + return nil, false + } + workflowSpec.Workflow = normalizedWorkflow + + workflowController := controller.CreateWorkflowController(workflowSpec.Workflow) + if err := workflowController.UpdateWithLatestWorkflow(nil); err != nil || workflowController.WorkflowV4 == nil { + return nil, false + } + + return workflowController.WorkflowV4, true +} + +func applyReleasePlanWorkflowLatestLookupCompat(spec interface{}, workflowSpec *models.WorkflowReleaseJobSpec) { + if workflowSpec == nil { + return + } + + specMap, ok := getMapField(spec) + if !ok { + return + } + + workflowName := firstReleasePlanWorkflowLookupString(specMap, "workflowName", "workflow_name") + projectName := firstReleasePlanWorkflowLookupString(specMap, "projectName", "project_name") + if workflowSpec.Workflow == nil { + if workflowName == "" && projectName == "" { + return + } + workflowSpec.Workflow = &models.WorkflowV4{} + } + if workflowSpec.Workflow.Name == "" { + workflowSpec.Workflow.Name = workflowName + } + if workflowSpec.Workflow.Project == "" { + workflowSpec.Workflow.Project = projectName + } +} + +func firstReleasePlanWorkflowLookupString(input map[string]interface{}, keys ...string) string { + for _, key := range keys { + if value, ok := getStringField(input, key); ok { + return value + } + } + return "" +} + +func 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 []string{ + "id", + "name", + "display_name", + "disabled", + "category", + "project", + "remark", + "remark_required", + "ignore_cache", + "share_storages", + "concurrency_limit", + } { + if value, exists := workflowMap[key]; exists { + resp[key] = value + } + } + 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 sanitizeReleasePlanValue(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 []string{"name", "parallel", "approval", "manual_exec"} { + 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 []string{"name", "type", "skipped", "run_policy", "error_policy", "execute_policy"} { + 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{} + key string + } + + sortableItems := make([]sortableItem, 0, len(items)) + for _, item := range items { + primaryKey, ok := buildKey(item) + if !ok { + return + } + sortKey := primaryKey + if hash, err := hashReleasePlanSubtree(item); err == nil { + sortKey = primaryKey + "|" + hash + } + sortableItems = append(sortableItems, sortableItem{item: item, key: sortKey}) + } + + sort.SliceStable(sortableItems, func(i, j int) bool { + return sortableItems[i].key < sortableItems[j].key + }) + for i := range sortableItems { + items[i] = sortableItems[i].item + } +} + +func sortReleasePlanWorkflowInputStringArray(items []interface{}) { + for _, item := range items { + if _, ok := item.(string); !ok { + return + } + } + sort.SliceStable(items, func(i, j int) bool { + return items[i].(string) < items[j].(string) + }) +} + +func releasePlanWorkflowInputArrayKeyByEnv(item interface{}) (string, bool) { + return releasePlanWorkflowInputArrayKeyByFields(item, "env", "env_name", "env_alias") +} + +func releasePlanWorkflowInputArrayKeyByService(item interface{}) (string, bool) { + return releasePlanWorkflowInputArrayKeyByFields(item, "service_name", "service_module", "image_name") +} + +func releasePlanWorkflowInputArrayKeyByServiceModule(item interface{}) (string, bool) { + return releasePlanWorkflowInputArrayKeyByFields(item, "service_name", "service_module") +} + +func releasePlanWorkflowInputArrayKeyByModule(item interface{}) (string, bool) { + return releasePlanWorkflowInputArrayKeyByFields(item, "service_module", "image_name", "image") +} + +func releasePlanWorkflowInputArrayKeyByVariable(item interface{}) (string, bool) { + return releasePlanWorkflowInputArrayKeyByFields(item, "key") +} + +func releasePlanWorkflowInputArrayKeyByServiceBuild(item interface{}) (string, bool) { + return releasePlanWorkflowInputArrayKeyByFields(item, "service_name", "service_module", "image_name", "build_name", "name") +} + +func releasePlanWorkflowInputArrayKeyByScanning(item interface{}) (string, bool) { + return releasePlanWorkflowInputArrayKeyByFields(item, "service_name", "service_module", "name", "project_name") +} + +func releasePlanWorkflowInputArrayKeyByNacosData(item interface{}) (string, bool) { + return releasePlanWorkflowInputArrayKeyByFields(item, "namespace_id", "group", "data_id") +} + +func releasePlanWorkflowInputArrayKeyByFields(item interface{}, keys ...string) (string, bool) { + value, ok := getMapField(item) + if !ok { + return "", false + } + + parts := make([]string, 0, len(keys)) + for _, key := range keys { + part, exists := getStringField(value, key) + if exists { + parts = append(parts, part) + continue + } + if number, exists := getNumberFieldString(value, key); exists { + parts = append(parts, number) + continue + } + parts = append(parts, "") + } + + // Keep empty placeholders so keys from heterogeneous-but-compatible items + // still compare in a consistent field order. + if strings.TrimSpace(strings.Join(parts, "")) == "" { + return "", false + } + return strings.Join(parts, "|"), true +} + +func joinReleasePlanWorkflowInputPath(base, key string) string { + if key == "" { + return base + } + if base == "" { + return key + } + return base + "." + key +} + +func shouldDropReleasePlanWorkflowInputField(key string) bool { + 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..232c647ba8 --- /dev/null +++ b/pkg/microservice/aslan/core/release_plan/service/version.go @@ -0,0 +1,139 @@ +/* + * 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 ( + "time" + + "go.mongodb.org/mongo-driver/mongo" + + "github.com/koderover/zadig/v2/pkg/microservice/aslan/core/common/repository/models" + "github.com/koderover/zadig/v2/pkg/microservice/aslan/core/common/repository/mongodb" +) + +func createReleasePlanVersion(planID string, version int64, snapshot interface{}, operator, account, sectionKey, sectionName, verb string) error { + return createReleasePlanVersionWithBaseSnapshot(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 mongodb.NewReleasePlanVersionColl().Create(&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 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() }